Files
storyforge/web/storyforge-web-v4/assets/app.js

3665 lines
169 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const STORAGE_KEY = "storyforge-web-v4-session";
const DEFAULT_BACKEND_URL = "http://127.0.0.1:8081";
const navButtons = document.querySelectorAll("[data-screen-target]");
const screens = Array.from(document.querySelectorAll("[data-screen]"));
const screenMap = Object.fromEntries(screens.map((screen) => [screen.dataset.screen, screen]));
const appState = {
screen: window.location.hash.replace("#", "") || "dashboard",
session: loadStoredSession(),
me: null,
dashboard: null,
contentSources: [],
accounts: [],
selectedAccountId: "",
selectedWorkspace: null,
selectedVideos: { items: [], meta: {}, top_scored_video_ids: [], latest_video_ids: [], high_score_threshold: 60 },
documents: [],
discoveryQuery: "",
currentPlatform: localStorage.getItem(STORAGE_KEY + ":currentPlatform") || "",
selectedProjectId: "",
selectedAssistantId: "",
lastSeenAt: Number(localStorage.getItem(STORAGE_KEY + ":lastSeenAt") || Date.now()),
trackingAccounts: [],
trackingDigest: null,
reviews: [],
integrationHealth: null,
localModelCatalog: null,
backendCapabilities: null,
busy: false,
message: "",
lastAction: null,
lastGeneratedCopy: null,
lastSimilaritySearch: null,
lastJobDetail: null
};
const INTEGRATION_ORDER = ["local_model", "cutvideo", "huobao", "n8n", "asr"];
const ACTIVE_PLATFORMS = [
{ value: "douyin", label: "抖音" },
{ value: "xiaohongshu", label: "小红书" },
{ value: "bilibili", label: "哔哩哔哩" },
{ value: "kuaishou", label: "快手" },
{ value: "wechat_video", label: "微信视频号" }
];
const ACTIVE_PLATFORM_CHIPS = ["全平台", "抖音", "小红书", "B站", "快手", "视频号"];
const PLATFORM_REGISTRY = {
douyin: {
label: "抖音",
shortLabel: "抖音",
workbenchReady: true,
routes: {
accounts: "/v2/douyin/accounts",
workspace: (accountId) => `/v2/douyin/accounts/${encodeURIComponent(accountId)}/workspace`,
videos: (accountId) => `/v2/douyin/accounts/${encodeURIComponent(accountId)}/videos?limit=80`,
analyzeAccount: (accountId) => `/v2/douyin/accounts/${encodeURIComponent(accountId)}/analysis`,
analyzeTopVideos: (accountId) => `/v2/douyin/accounts/${encodeURIComponent(accountId)}/videos/analyze-top`,
similarSearches: "/v2/douyin/similar-searches",
similarSearchDetail: (searchId) => `/v2/douyin/similar-searches/${encodeURIComponent(searchId)}`,
benchmarkLinks: (accountId) => `/v2/douyin/accounts/${encodeURIComponent(accountId)}/benchmark-links`,
trackingAccounts: "/v2/douyin/tracking/accounts",
trackingDigest: "/v2/douyin/tracking/digest",
trackingRefresh: "/v2/douyin/tracking/refresh",
trackingCursor: "/v2/douyin/tracking/cursor",
trackingAccountRefresh: (trackedAccountId) => `/v2/douyin/tracking/accounts/${encodeURIComponent(trackedAccountId)}/refresh`
}
},
xiaohongshu: {
label: "小红书",
shortLabel: "小红书",
workbenchReady: false,
pendingText: "小红书工作台待接入"
},
bilibili: {
label: "哔哩哔哩",
shortLabel: "B站",
workbenchReady: false,
pendingText: "B站工作台待接入"
},
kuaishou: {
label: "快手",
shortLabel: "快手",
workbenchReady: false,
pendingText: "快手工作台待接入"
},
wechat_video: {
label: "微信视频号",
shortLabel: "视频号",
workbenchReady: false,
pendingText: "视频号工作台待接入"
}
};
const INTEGRATION_META = {
local_model: {
label: "本机模型",
hint: "OpenAI-compatible",
impacts: ["账号分析", "高分分析", "文案生成"]
},
cutvideo: {
label: "自动剪辑",
hint: "Windows cutvideo",
impacts: ["实拍剪辑"]
},
huobao: {
label: "AI 视频",
hint: "huobao-drama",
impacts: ["AI 视频"]
},
n8n: {
label: "编排",
hint: "n8n workflow",
impacts: ["AI 视频", "实拍剪辑", "自动链路"]
},
asr: {
label: "ASR",
hint: "素材转写",
impacts: ["分析转写"]
}
};
const PIPELINE_GUARDS = {
aiVideo: {
label: "AI 视频",
openAction: "open-ai-video",
jobAction: "job-to-ai-video",
dependencies: ["n8n", "huobao"]
},
realCut: {
label: "实拍剪辑",
openAction: "open-real-cut",
jobAction: "job-to-real-cut",
dependencies: ["n8n", "cutvideo"]
}
};
function safeArray(value) {
return Array.isArray(value) ? value : [];
}
function getPlatformOptions() {
return ACTIVE_PLATFORMS.map((item) => ({ value: item.value, label: item.label }));
}
function normalizePlatformValue(value, fallback = "douyin") {
const normalized = String(value || "").trim().toLowerCase();
if (!normalized) return fallback;
const byValue = ACTIVE_PLATFORMS.find((item) => item.value === normalized);
if (byValue) return byValue.value;
const byLabel = ACTIVE_PLATFORMS.find((item) => item.label === value);
return byLabel?.value || fallback;
}
function platformLabel(value) {
const matched = ACTIVE_PLATFORMS.find((item) => item.value === normalizePlatformValue(value, ""));
return matched?.label || String(value || "抖音");
}
function getPlatformMeta(value) {
return PLATFORM_REGISTRY[normalizePlatformValue(value, "")] || null;
}
function getPlatformShortLabel(value) {
return getPlatformMeta(value)?.shortLabel || platformLabel(value);
}
function isWorkbenchPlatform(value) {
return Boolean(getPlatformMeta(value)?.workbenchReady);
}
function getWorkbenchRoute(platform, key, ...args) {
const routes = getPlatformMeta(platform)?.routes;
if (!routes) return "";
const route = routes[key];
if (typeof route === "function") return route(...args);
return route || "";
}
function setCurrentPlatform(value) {
const normalized = normalizePlatformValue(value, "");
appState.currentPlatform = normalized;
if (normalized) {
localStorage.setItem(STORAGE_KEY + ":currentPlatform", normalized);
} else {
localStorage.removeItem(STORAGE_KEY + ":currentPlatform");
}
}
function getAccountPlatform(account) {
return normalizePlatformValue(
account?.platform
|| account?.source_platform
|| account?.metadata?.platform
|| "",
"douyin"
);
}
function getAccountHandle(account) {
return String(
account?.handle
|| account?.douyin_id
|| account?.xhs_id
|| account?.bilibili_uid
|| account?.kuaishou_id
|| account?.wechat_video_id
|| account?.uid
|| account?.username
|| ""
).trim();
}
function getAccountProfileUrl(account) {
return String(account?.profile_url || account?.source_url || account?.homepage_url || "").trim();
}
function getAccountName(account) {
return String(account?.nickname || getAccountHandle(account) || "未命名账号").trim();
}
function getAccountSubtitle(account) {
return getAccountHandle(account) || getAccountProfileUrl(account) || platformLabel(getAccountPlatform(account));
}
function getPendingWorkbenchReason(platform) {
const meta = getPlatformMeta(platform);
return meta?.pendingText || `${platformLabel(platform)}工作台待接入`;
}
function getPreferredPlatform() {
const selectedAccountPlatform = getAccountPlatform(getSelectedAccount());
if (selectedAccountPlatform && isWorkbenchPlatform(selectedAccountPlatform)) return selectedAccountPlatform;
const current = normalizePlatformValue(appState.currentPlatform, "");
if (current && isWorkbenchPlatform(current)) return current;
const sourcePlatform = normalizePlatformValue(
safeArray(appState.contentSources).find((item) => isWorkbenchPlatform(item.platform))?.platform || "",
""
);
if (sourcePlatform) return sourcePlatform;
return "douyin";
}
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function brief(value, max = 88) {
const text = String(value ?? "").trim();
if (!text) return "暂无";
return text.length > max ? text.slice(0, max).trimEnd() + "…" : text;
}
function formatNumber(value) {
const num = Number(value || 0);
if (!Number.isFinite(num)) return "-";
if (num >= 100000000) return (num / 100000000).toFixed(1).replace(/\.0$/, "") + "亿";
if (num >= 10000) return (num / 10000).toFixed(1).replace(/\.0$/, "") + "w";
if (num >= 1000) return num.toLocaleString("zh-CN");
return String(Math.round(num * 10) / 10);
}
function formatDateTime(value) {
if (!value) return "-";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return String(value);
return new Intl.DateTimeFormat("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit"
}).format(date);
}
function formatDate(value) {
if (!value) return "-";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return String(value);
return new Intl.DateTimeFormat("zh-CN", {
month: "2-digit",
day: "2-digit"
}).format(date);
}
function daysSince(value) {
if (!value) return "-";
const time = new Date(value).getTime();
if (!Number.isFinite(time)) return "-";
const diff = Date.now() - time;
return Math.max(0, Math.floor(diff / 86400000));
}
function initials(value) {
const raw = String(value || "").trim();
if (!raw) return "SF";
return raw.slice(0, 2).toUpperCase();
}
function statusTone(status) {
const normalized = String(status || "").toLowerCase();
if (["completed", "ready", "approved", "ok"].includes(normalized)) return "green";
if (["failed", "error", "rejected"].includes(normalized)) return "red";
if (["worth_scaling", "good_reference"].includes(normalized)) return "green";
if (["needs_rework"].includes(normalized)) return "red";
if (["hold"].includes(normalized)) return "orange";
if (["running", "processing", "pending", "queued"].includes(normalized)) return "orange";
return "blue";
}
function loadStoredSession() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? JSON.parse(raw) : null;
} catch {
return null;
}
}
function persistSession(session) {
appState.session = session;
if (session) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(session));
} else {
localStorage.removeItem(STORAGE_KEY);
}
}
function setLastSeenAt(value) {
const date = value instanceof Date ? value : new Date(value);
const time = Number.isFinite(date.getTime()) ? date.getTime() : Date.now();
appState.lastSeenAt = time;
localStorage.setItem(STORAGE_KEY + ":lastSeenAt", String(time));
}
function markSeenNow() {
setLastSeenAt(Date.now());
}
function setBusy(next, message = "") {
appState.busy = next;
appState.message = message;
renderAuthUi();
}
function setScreen(id) {
appState.screen = id;
navButtons.forEach((button) => {
const active = button.dataset.screenTarget === id;
button.classList.toggle("is-active", active);
});
screens.forEach((screen) => {
screen.classList.toggle("is-active", screen.dataset.screen === id);
});
window.location.hash = id;
}
function ensureAuthUi() {
const topbarRight = document.querySelector(".topbar-right");
if (!topbarRight) return;
if (!document.querySelector(".auth-inline")) {
const inline = document.createElement("div");
inline.className = "auth-inline";
inline.innerHTML = `
<button class="btn btn-secondary" type="button" data-action="open-auth">连接后端</button>
<button class="btn btn-secondary" type="button" data-action="logout-session">退出</button>
<span class="auth-status"></span>
`;
topbarRight.insertBefore(inline, topbarRight.firstChild);
}
if (!document.querySelector(".auth-modal")) {
const modal = document.createElement("div");
modal.className = "auth-modal-backdrop hidden";
modal.innerHTML = `
<div class="auth-modal">
<form class="auth-form" data-role="auth-form">
<div class="auth-head">
<div>
<h3>连接 StoryForge</h3>
<p>先登录后端再加载项目、对标、Agent 和生产数据。</p>
</div>
<button class="btn btn-secondary" type="button" data-action="close-auth">关闭</button>
</div>
<div class="field-stack">
<label>后端地址</label>
<input type="text" data-auth-field="backendUrl" placeholder="${DEFAULT_BACKEND_URL}" autocomplete="url" />
</div>
<div class="field-stack">
<label>用户名</label>
<input type="text" data-auth-field="username" placeholder="kris" autocomplete="username" />
</div>
<div class="field-stack">
<label>密码</label>
<input type="password" data-auth-field="password" placeholder="输入密码" autocomplete="current-password" />
</div>
<div class="field-stack">
<label>已有 Token</label>
<textarea data-auth-field="token" rows="3" placeholder="可选:直接粘贴 token跳过账号密码"></textarea>
</div>
<div class="helper-text" data-role="auth-message"></div>
<div class="auth-actions">
<button class="btn btn-secondary" type="button" data-action="auth-refresh">刷新数据</button>
<button class="btn btn-primary" type="submit" data-action="submit-auth">登录并加载</button>
</div>
</form>
</div>
`;
document.body.appendChild(modal);
}
}
function renderAuthUi() {
ensureAuthUi();
const session = appState.session;
const openButton = document.querySelector('[data-action="open-auth"]');
const logoutButton = document.querySelector('[data-action="logout-session"]');
const status = document.querySelector(".auth-status");
const message = document.querySelector('[data-role="auth-message"]');
if (openButton) openButton.textContent = session ? "切换连接" : "连接后端";
if (logoutButton) logoutButton.hidden = !session;
if (status) {
status.textContent = appState.busy
? appState.message || "正在加载..."
: session
? `${session.account?.display_name || session.account?.username || "已连接"} · ${session.backendUrl}`
: "未连接";
}
if (message) {
message.textContent = appState.busy ? appState.message : "";
}
}
function openAuthModal() {
ensureAuthUi();
const modal = document.querySelector(".auth-modal-backdrop");
if (!modal) return;
const session = appState.session;
modal.classList.remove("hidden");
setAuthField("backendUrl", session?.backendUrl || DEFAULT_BACKEND_URL);
setAuthField("username", session?.account?.username || "");
setAuthField("password", "");
setAuthField("token", session?.token || "");
}
function closeAuthModal() {
document.querySelector(".auth-modal-backdrop")?.classList.add("hidden");
}
function setAuthField(name, value) {
const input = document.querySelector(`[data-auth-field="${name}"]`);
if (input) input.value = value ?? "";
}
function readAuthForm() {
const pick = (name) => document.querySelector(`[data-auth-field="${name}"]`)?.value?.trim() || "";
return {
backendUrl: pick("backendUrl") || DEFAULT_BACKEND_URL,
username: pick("username"),
password: document.querySelector('[data-auth-field="password"]')?.value || "",
token: pick("token")
};
}
let currentActionConfig = null;
function ensureActionUi() {
if (document.querySelector(".action-modal-backdrop")) return;
const modal = document.createElement("div");
modal.className = "action-modal-backdrop hidden";
modal.innerHTML = `
<div class="action-modal">
<div class="auth-head">
<div>
<h3 data-role="action-title">快速操作</h3>
<p data-role="action-description">根据当前工作区执行动作。</p>
</div>
<button class="btn btn-secondary" type="button" data-action="close-sheet">关闭</button>
</div>
<div class="field-stack" data-role="action-fields"></div>
<div class="helper-text" data-role="action-message"></div>
<div class="auth-actions">
<button class="btn btn-secondary" type="button" data-action="close-sheet">取消</button>
<button class="btn btn-primary" type="button" data-action="submit-sheet">执行</button>
</div>
</div>
`;
document.body.appendChild(modal);
}
function renderActionFields(fields) {
return fields.map((field) => {
const common = `data-action-field="${escapeHtml(field.name)}"`;
if (field.type === "html") {
return `
<div class="field-stack">
<label>${escapeHtml(field.label || "")}</label>
<div class="sheet-html">${field.html || ""}</div>
</div>
`;
}
if (field.type === "textarea") {
return `
<div class="field-stack">
<label>${escapeHtml(field.label)}</label>
<textarea ${common} rows="${escapeHtml(field.rows || 4)}" placeholder="${escapeHtml(field.placeholder || "")}">${escapeHtml(field.value || "")}</textarea>
</div>
`;
}
if (field.type === "select") {
return `
<div class="field-stack">
<label>${escapeHtml(field.label)}</label>
<select ${common}>
${(field.options || []).map((option) => `
<option value="${escapeHtml(option.value)}" ${String(option.value) === String(field.value ?? "") ? "selected" : ""}>${escapeHtml(option.label)}</option>
`).join("")}
</select>
</div>
`;
}
if (field.type === "checkbox") {
return `
<label class="checkbox-row">
<input type="checkbox" ${common} ${field.value ? "checked" : ""} />
<span>${escapeHtml(field.label)}</span>
</label>
`;
}
if (field.type === "file") {
return `
<div class="field-stack">
<label>${escapeHtml(field.label)}</label>
<input type="file" ${common} accept="${escapeHtml(field.accept || "")}" />
</div>
`;
}
return `
<div class="field-stack">
<label>${escapeHtml(field.label)}</label>
<input
type="${escapeHtml(field.type || "text")}"
${common}
value="${escapeHtml(field.value || "")}"
placeholder="${escapeHtml(field.placeholder || "")}"
${field.min != null ? `min="${escapeHtml(field.min)}"` : ""}
${field.max != null ? `max="${escapeHtml(field.max)}"` : ""}
/>
</div>
`;
}).join("");
}
function openActionModal(config) {
ensureActionUi();
currentActionConfig = config;
const modal = document.querySelector(".action-modal-backdrop");
const title = document.querySelector('[data-role="action-title"]');
const description = document.querySelector('[data-role="action-description"]');
const fields = document.querySelector('[data-role="action-fields"]');
const message = document.querySelector('[data-role="action-message"]');
const submit = document.querySelector('[data-action="submit-sheet"]');
if (!modal || !title || !description || !fields || !message || !submit) return;
title.textContent = config.title || "快速操作";
description.textContent = config.description || "";
fields.innerHTML = renderActionFields(config.fields || []);
message.textContent = "";
submit.textContent = config.submitLabel || "执行";
submit.disabled = false;
submit.hidden = Boolean(config.hideSubmit);
modal.classList.remove("hidden");
}
function closeActionModal() {
currentActionConfig = null;
document.querySelector(".action-modal-backdrop")?.classList.add("hidden");
}
function readActionForm() {
const values = {};
document.querySelectorAll("[data-action-field]").forEach((element) => {
const name = element.getAttribute("data-action-field");
if (!name) return;
if (element instanceof HTMLInputElement && element.type === "checkbox") {
values[name] = element.checked;
return;
}
if (element instanceof HTMLInputElement && element.type === "file") {
values[name] = element.files?.[0] || null;
return;
}
values[name] = element.value;
});
return values;
}
async function submitActionModal() {
if (!currentActionConfig?.onSubmit) return;
const message = document.querySelector('[data-role="action-message"]');
const submit = document.querySelector('[data-action="submit-sheet"]');
const values = readActionForm();
if (submit) submit.disabled = true;
if (message) message.textContent = "正在执行...";
try {
const result = await currentActionConfig.onSubmit(values);
if (result?.keepOpen) {
if (message) message.textContent = result.message || "已完成";
} else {
closeActionModal();
}
} catch (error) {
if (message) message.textContent = error.message || "执行失败";
if (submit) submit.disabled = false;
return;
}
if (submit) submit.disabled = false;
}
async function storyforgeFetch(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}`;
let body = options.body;
if (body && !(body instanceof FormData) && !headers["Content-Type"] && !headers["content-type"]) {
headers["Content-Type"] = "application/json";
body = JSON.stringify(body);
}
const response = await fetch(`${backendUrl}${path}`, {
method: options.method || "GET",
headers,
body,
cache: "no-store"
});
const isJson = (response.headers.get("content-type") || "").includes("application/json");
const payload = isJson ? await response.json() : await response.text();
if (!response.ok) {
const detail = typeof payload === "object" && payload
? payload.detail || payload.message || JSON.stringify(payload)
: String(payload || response.statusText);
throw new Error(detail);
}
return payload;
}
async function loadBackendCapabilities(backendUrl) {
const normalizedUrl = (backendUrl || DEFAULT_BACKEND_URL).replace(/\/$/, "");
const response = await fetch(`${normalizedUrl}/openapi.json`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const payload = await response.json();
return new Set(Object.keys(payload.paths || {}));
}
function backendSupports(path) {
if (!(appState.backendCapabilities instanceof Set)) return true;
return appState.backendCapabilities.has(path);
}
async function loginWithForm() {
const auth = readAuthForm();
if (!auth.backendUrl) {
throw new Error("请先填写后端地址");
}
if (auth.token) {
const account = await storyforgeFetch("/v2/me", {
backendUrl: auth.backendUrl,
token: auth.token
});
persistSession({ backendUrl: auth.backendUrl, token: auth.token, account });
return;
}
if (!auth.username || !auth.password) {
throw new Error("请填写账号密码,或者直接填 Token");
}
const payload = await storyforgeFetch("/v2/auth/login", {
backendUrl: auth.backendUrl,
auth: false,
method: "POST",
body: {
username: auth.username,
password: auth.password
}
});
persistSession({
backendUrl: auth.backendUrl,
token: payload.token,
account: payload.account
});
}
async function logoutSession() {
try {
if (appState.session) {
await storyforgeFetch("/v2/auth/logout", { method: "POST" });
}
} catch {}
persistSession(null);
appState.me = null;
appState.dashboard = null;
appState.contentSources = [];
appState.accounts = [];
appState.selectedAccountId = "";
appState.currentPlatform = "";
appState.selectedAssistantId = "";
appState.selectedWorkspace = null;
appState.selectedVideos = { items: [], meta: {}, top_scored_video_ids: [], latest_video_ids: [], high_score_threshold: 60 };
appState.documents = [];
appState.trackingAccounts = [];
appState.trackingDigest = null;
appState.reviews = [];
appState.integrationHealth = null;
appState.backendCapabilities = null;
appState.lastAction = null;
appState.lastGeneratedCopy = null;
appState.lastSimilaritySearch = null;
appState.lastJobDetail = null;
localStorage.removeItem(STORAGE_KEY + ":currentPlatform");
renderAll();
}
async function loadKnowledgeDocuments(knowledgeBases) {
const targets = safeArray(knowledgeBases).slice(0, 3);
if (!targets.length) return [];
const groups = await Promise.all(
targets.map((kb) =>
storyforgeFetch(`/v2/knowledge-bases/${encodeURIComponent(kb.id)}/documents`).catch(() => [])
)
);
return groups.flat().slice(0, 12);
}
async function loadPlatformAccount(platform, accountId) {
if (!accountId) return;
const normalizedPlatform = normalizePlatformValue(platform, getPreferredPlatform());
appState.selectedAccountId = accountId;
setCurrentPlatform(normalizedPlatform);
const workspacePath = getWorkbenchRoute(normalizedPlatform, "workspace", accountId);
if (!workspacePath) {
appState.selectedWorkspace = null;
appState.selectedVideos = { items: [], meta: {}, top_scored_video_ids: [], latest_video_ids: [], high_score_threshold: 60 };
return;
}
const videosPath = getWorkbenchRoute(normalizedPlatform, "videos", accountId);
const supportsAccountVideos = videosPath && backendSupports(`/v2/${normalizedPlatform}/accounts/{account_id}/videos`);
const [workspace, videos] = await Promise.all([
storyforgeFetch(workspacePath),
supportsAccountVideos
? storyforgeFetch(videosPath).catch(() => ({
items: [],
meta: {},
top_scored_video_ids: [],
latest_video_ids: [],
high_score_threshold: 60
}))
: Promise.resolve({
items: [],
meta: {},
top_scored_video_ids: [],
latest_video_ids: [],
high_score_threshold: 60
})
]);
appState.selectedWorkspace = workspace;
appState.selectedVideos = videos;
}
function getTrackingSinceIso() {
const date = new Date(appState.lastSeenAt || Date.now());
if (Number.isNaN(date.getTime())) return new Date(Date.now() - 86400000).toISOString();
return date.toISOString();
}
async function bootstrap() {
renderAll();
if (!appState.session) {
renderAuthUi();
return;
}
setBusy(true, "正在同步工作区...");
try {
appState.me = await storyforgeFetch("/v2/me");
if (appState.me.approval_status !== "approved" && appState.me.role !== "super_admin") {
appState.dashboard = null;
appState.accounts = [];
appState.contentSources = [];
appState.documents = [];
renderAll();
return;
}
appState.backendCapabilities = await loadBackendCapabilities(appState.session.backendUrl).catch(() => null);
const preferredPlatform = getPreferredPlatform();
setCurrentPlatform(preferredPlatform);
const accountListPath = getWorkbenchRoute(preferredPlatform, "accounts");
const trackingAccountsPath = getWorkbenchRoute(preferredPlatform, "trackingAccounts");
const trackingDigestPath = getWorkbenchRoute(preferredPlatform, "trackingDigest");
const supportsTrackingDigest = trackingDigestPath && backendSupports(trackingDigestPath);
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([
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)
]);
const trackingCursorLastSeenAt = trackingAccountsPayload?.cursor_last_seen_at || "";
if (trackingCursorLastSeenAt) {
setLastSeenAt(trackingCursorLastSeenAt);
}
const trackingSince = trackingCursorLastSeenAt || getTrackingSinceIso();
const trackingDigest = trackingDigestPath
? await storyforgeFetch(`${trackingDigestPath}?since=${encodeURIComponent(trackingSince)}&limit=24`).catch(() => ({
items: [],
tracked_accounts: [],
cursor_last_seen_at: trackingCursorLastSeenAt
}))
: ({
items: [],
tracked_accounts: [],
cursor_last_seen_at: trackingCursorLastSeenAt
});
appState.dashboard = dashboard;
appState.contentSources = safeArray(contentSources);
appState.accounts = safeArray(accounts);
appState.trackingAccounts = safeArray(trackingAccountsPayload.items || trackingAccountsPayload);
appState.trackingDigest = trackingDigest;
appState.reviews = safeArray(reviews);
appState.integrationHealth = integrationHealth;
appState.localModelCatalog = localModelCatalog;
appState.documents = await loadKnowledgeDocuments(dashboard.knowledge_bases);
appState.selectedProjectId = appState.selectedProjectId || dashboard.projects?.[0]?.id || "";
const selectedAssistantExists = safeArray(dashboard.assistants).some((item) => item.id === appState.selectedAssistantId);
appState.selectedAssistantId = selectedAssistantExists ? appState.selectedAssistantId : (dashboard.assistants?.[0]?.id || "");
const selectedAccountExists = appState.accounts.some((item) => item.id === appState.selectedAccountId);
const nextAccountId = selectedAccountExists ? appState.selectedAccountId : appState.accounts[0]?.id || "";
if (nextAccountId) {
const nextAccount = appState.accounts.find((item) => item.id === nextAccountId) || null;
await loadPlatformAccount(getAccountPlatform(nextAccount), nextAccountId);
} else {
appState.selectedAccountId = "";
appState.selectedWorkspace = null;
appState.selectedVideos = { items: [], meta: {}, top_scored_video_ids: [], latest_video_ids: [], high_score_threshold: 60 };
}
} catch (error) {
appState.message = error.message;
if (String(error.message || "").includes("401") || String(error.message || "").includes("Not authenticated")) {
persistSession(null);
}
} finally {
setBusy(false, "");
renderAll();
}
}
async function markTrackingDigestRead() {
const platform = getPreferredPlatform();
const trackingCursorPath = getWorkbenchRoute(platform, "trackingCursor");
if (!trackingCursorPath || !backendSupports(trackingCursorPath)) {
rememberAction("当前后端暂不支持", "这套 live collector 还没有接入跟踪已读游标。", "orange");
renderAll();
return;
}
const nextSeenAt = new Date().toISOString();
await storyforgeFetch(trackingCursorPath, {
method: "POST",
body: { last_seen_at: nextSeenAt }
});
setLastSeenAt(nextSeenAt);
}
async function refreshTrackingAccountsAction() {
const platform = getPreferredPlatform();
const trackingRefreshPath = getWorkbenchRoute(platform, "trackingRefresh");
if (!trackingRefreshPath || !backendSupports(trackingRefreshPath)) {
rememberAction("当前后端暂不支持", "这套 live collector 还没有接入批量跟踪同步。", "orange");
renderAll();
return;
}
setBusy(true, "正在同步跟踪账号...");
try {
const payload = await storyforgeFetch(trackingRefreshPath, {
method: "POST"
});
rememberAction(
"跟踪已同步",
`已刷新 ${formatNumber(payload.refreshed || 0)} 个账号${payload.failed ? `,失败 ${formatNumber(payload.failed)}` : ""}`,
payload.failed ? "orange" : "green",
payload
);
await bootstrap();
} finally {
setBusy(false, "");
}
}
async function refreshTrackedAccountAction(trackedAccountId) {
if (!trackedAccountId) {
throw new Error("trackedAccountId is required");
}
const platform = getPreferredPlatform();
const trackingRefreshPath = getWorkbenchRoute(platform, "trackingAccountRefresh", trackedAccountId);
if (!trackingRefreshPath || !backendSupports(`/v2/${platform}/tracking/accounts/{tracked_account_id}/refresh`)) {
rememberAction("当前后端暂不支持", "这套 live collector 还没有接入单账号跟踪同步。", "orange");
renderAll();
return;
}
setBusy(true, "正在同步该跟踪账号...");
try {
const payload = await storyforgeFetch(trackingRefreshPath, {
method: "POST"
});
const success = payload.success !== false;
rememberAction(
success ? "单账号已同步" : "单账号刷新失败",
success
? `已刷新「${payload.account?.nickname || trackedAccountId}」的最新作品。`
: `暂时无法刷新「${payload.account?.nickname || trackedAccountId}」:${payload.message || "请稍后重试"}`,
success ? (safeArray(payload.sync_errors).length ? "orange" : "green") : "orange",
payload
);
await bootstrap();
} finally {
setBusy(false, "");
}
}
function getSelectedProject() {
const projects = safeArray(appState.dashboard?.projects);
return projects.find((item) => item.id === appState.selectedProjectId) || projects[0] || null;
}
function getProjectKnowledgeBases(projectId) {
return safeArray(appState.dashboard?.knowledge_bases).filter((item) => item.project_id === projectId);
}
function getProjectAssistants(projectId) {
return safeArray(appState.dashboard?.assistants).filter((item) => item.project_id === projectId);
}
function getSelectedAssistant() {
const assistants = safeArray(appState.dashboard?.assistants);
return assistants.find((item) => item.id === appState.selectedAssistantId) || assistants[0] || null;
}
function getProjectOptions() {
return safeArray(appState.dashboard?.projects).map((project) => ({ value: project.id, label: project.name }));
}
function getAssistantOptions(projectId) {
return getProjectAssistants(projectId).map((assistant) => ({ value: assistant.id, label: assistant.name }));
}
function getKnowledgeBaseOptions(projectId) {
return getProjectKnowledgeBases(projectId).map((kb) => ({ value: kb.id, label: kb.name }));
}
function getModelOptions() {
return safeArray(appState.dashboard?.model_profiles).map((model) => ({ value: model.id, label: model.name }));
}
function getCurrentModelProfile() {
const models = safeArray(appState.dashboard?.model_profiles);
const currentId = appState.me?.preferred_analysis_model_id
|| models.find((item) => item.is_default)?.id
|| "";
return models.find((item) => item.id === currentId) || models.find((item) => item.is_default) || models[0] || null;
}
function getCompletedJobOptions() {
return safeArray(appState.dashboard?.recent_jobs)
.filter((item) => item.status === "completed")
.map((job) => ({
value: job.id,
label: `${job.title} · ${job.line_type || "analysis"}`
}));
}
function getProjectStats(projectId) {
const dashboard = appState.dashboard || {};
const knowledgeBases = safeArray(dashboard.knowledge_bases).filter((item) => item.project_id === projectId);
const assistants = safeArray(dashboard.assistants).filter((item) => item.project_id === projectId);
const jobs = safeArray(dashboard.recent_jobs).filter((item) => item.project_id === projectId);
const sources = safeArray(appState.contentSources).filter((item) => item.project_id === projectId);
return { knowledgeBases, assistants, jobs, sources };
}
function getProjectReviews(projectId) {
return safeArray(appState.reviews).filter((item) => item.project_id === projectId);
}
function getReviewById(reviewId) {
return safeArray(appState.reviews).find((item) => item.id === reviewId) || null;
}
function getContentSourcesForAccount(account) {
if (!account) return [];
const platform = getAccountPlatform(account);
const profileUrl = getAccountProfileUrl(account);
const handle = getAccountHandle(account);
const nickname = getAccountName(account);
return safeArray(appState.contentSources).filter((source) => {
const sourceUrl = String(source.source_url || "").trim();
const sourceHandle = String(source.handle || "").trim();
const title = String(source.title || "").trim();
const sourcePlatform = normalizePlatformValue(source.platform || "", platform);
return (
sourcePlatform === platform && (
(profileUrl && sourceUrl === profileUrl) ||
(handle && sourceHandle === handle) ||
(nickname && title.includes(nickname))
)
);
});
}
function getCurrentProjectSourcesForAccount(account, projectId) {
return getContentSourcesForAccount(account).filter((source) => source.project_id === projectId);
}
function isTrackedAccount(accountId) {
return safeArray(appState.trackingAccounts).some((item) => item.tracked_account_id === accountId);
}
function getTrackingDigestItems(limit = 6) {
return safeArray(appState.trackingDigest?.items).slice(0, limit);
}
function getSelectedAccount() {
return appState.selectedWorkspace?.account
|| appState.accounts.find((item) => item.id === appState.selectedAccountId)
|| null;
}
function getHighScoreVideos(limit = 3) {
const items = safeArray(appState.selectedVideos?.items);
const fallback = safeArray(getSelectedAccount()?.video_summary?.videos);
const pool = items.length ? items : fallback;
return pool
.slice()
.sort((a, b) => Number(b.score?.performance_score || 0) - Number(a.score?.performance_score || 0))
.slice(0, limit);
}
function getLatestVideos(limit = 3) {
const items = safeArray(appState.selectedVideos?.items);
const fallback = safeArray(getSelectedAccount()?.video_summary?.videos);
const pool = items.length ? items : fallback;
return pool
.slice()
.sort((a, b) => new Date(b.published_at || 0).getTime() - new Date(a.published_at || 0).getTime())
.slice(0, limit);
}
function getProductionWorks(limit = 6) {
const preferred = safeArray(appState.selectedVideos?.items);
const fallback = safeArray(appState.accounts)
.flatMap((account) => safeArray(account.video_summary?.videos))
.filter(Boolean);
const pool = preferred.length ? preferred : fallback;
const scored = pool
.slice()
.sort((a, b) => Number(b.score?.performance_score || 0) - Number(a.score?.performance_score || 0))
.slice(0, Math.ceil(limit / 2));
const latest = pool
.slice()
.sort((a, b) => new Date(b.published_at || 0).getTime() - new Date(a.published_at || 0).getTime())
.slice(0, Math.ceil(limit / 2) + 1);
const deduped = [];
const seen = new Set();
[...scored, ...latest].forEach((item) => {
const key = item.aweme_id || item.share_url || item.title || item.description;
if (!key || seen.has(key)) return;
seen.add(key);
deduped.push(item);
});
return deduped.slice(0, limit);
}
function describeVideo(video) {
return video.title || video.description || video.aweme_id || "未命名作品";
}
function getVideoLink(video) {
return video.share_url || video.play_url || "";
}
async function loadJobDetail(jobId) {
const [job, events, childJobs] = await Promise.all([
storyforgeFetch(`/v2/explore/jobs/${encodeURIComponent(jobId)}`),
storyforgeFetch(`/v2/explore/jobs/${encodeURIComponent(jobId)}/events`).catch(() => []),
storyforgeFetch(`/v2/explore/jobs?parent_job_id=${encodeURIComponent(jobId)}`).catch(() => [])
]);
appState.lastJobDetail = { job, events: safeArray(events), childJobs: safeArray(childJobs) };
return appState.lastJobDetail;
}
function isJobCompleted(job) {
return String(job?.status || "").toLowerCase() === "completed";
}
function canDeriveAiVideo(job) {
if (!job || !isJobCompleted(job)) return false;
return String(job.line_type || "").toLowerCase() !== "ai_video";
}
function canDeriveRealCut(job) {
if (!job || !isJobCompleted(job)) return false;
const sourceType = String(job.source_type || "").toLowerCase();
return ["video_link", "upload_video"].includes(sourceType);
}
function hasIntegrationHealthData() {
return Boolean(appState.integrationHealth && typeof appState.integrationHealth === "object");
}
function getIntegrationDetail(key) {
const raw = hasIntegrationHealthData() ? appState.integrationHealth?.[key] : null;
return {
key,
available: Boolean(raw && typeof raw === "object"),
configured: Boolean(raw?.configured),
reachable: Boolean(raw?.reachable),
statusCode: Number(raw?.status_code || 0),
error: String(raw?.error || ""),
url: String(raw?.url || raw?.base_url || ""),
baseUrl: String(raw?.base_url || ""),
supportsUploads: raw?.supports_uploads !== undefined ? Boolean(raw?.supports_uploads) : true,
uploadStatusCode: Number(raw?.upload_status_code || 0),
uploadError: String(raw?.upload_error || ""),
uploadUrl: String(raw?.upload_url || "")
};
}
function getIntegrationStatus(detail) {
if (!detail.available) {
return { tone: "blue", summary: "未拉取" };
}
if (detail.key === "cutvideo" && detail.reachable && !detail.supportsUploads) {
return { tone: "orange", summary: "缺上传能力" };
}
if (detail.reachable) {
return { tone: "green", summary: "在线" };
}
if (detail.configured) {
return { tone: "red", summary: "不可达" };
}
return { tone: "orange", summary: "未配置" };
}
function describeIntegrationFailure(key) {
const detail = getIntegrationDetail(key);
const meta = INTEGRATION_META[key] || { label: key };
if (!detail.available) return `${meta.label}健康状态未拉取`;
if (key === "cutvideo" && detail.reachable && !detail.supportsUploads) {
return `${meta.label}缺少 /api/uploads`;
}
if (!detail.configured) return `${meta.label}未配置`;
if (detail.statusCode) return `${meta.label}返回 HTTP ${detail.statusCode}`;
if (detail.error) return `${meta.label}${brief(detail.error, 42)}`;
return `${meta.label}不可达`;
}
function getPipelineGuard(kind) {
const config = PIPELINE_GUARDS[kind];
if (!config) {
return { enabled: true, reason: "", blocked: [] };
}
const blocked = config.dependencies
.map((key) => ({ key, detail: getIntegrationDetail(key), meta: INTEGRATION_META[key] || { label: key } }))
.filter((item) => {
if (!item.detail.available) return false;
if (!item.detail.reachable) return true;
if (item.key === "cutvideo" && !item.detail.supportsUploads) return true;
return false;
});
if (!blocked.length) {
return { enabled: true, reason: "", blocked: [] };
}
return {
enabled: false,
blocked,
reason: `${config.label}暂不可用:${blocked.map((item) => describeIntegrationFailure(item.key)).join("")}`
};
}
function getIntegrationCards() {
const currentModel = getCurrentModelProfile();
const localCatalog = appState.localModelCatalog || {};
return INTEGRATION_ORDER.map((key) => {
const detail = getIntegrationDetail(key);
const status = getIntegrationStatus(detail);
const meta = INTEGRATION_META[key] || { label: key, hint: key, impacts: [] };
let note = "尚未获取健康检查数据";
if (detail.available) {
if (detail.reachable) {
if (key === "cutvideo" && !detail.supportsUploads) {
note = detail.uploadStatusCode
? `主服务在线,但 /api/uploads 返回 HTTP ${detail.uploadStatusCode}`
: (detail.uploadError ? brief(detail.uploadError, 72) : "主服务在线,但缺少上传接口");
} else {
note = detail.statusCode
? `健康探测返回 HTTP ${detail.statusCode}`
: "TCP 探测已通过";
}
} else if (!detail.configured) {
note = "后端还没有配置该依赖地址";
} else if (detail.statusCode) {
note = `探测返回 HTTP ${detail.statusCode}`;
} else if (detail.error) {
note = brief(detail.error, 72);
} else {
note = "探测失败,请检查服务进程和网络";
}
}
let extra = "";
let actions = "";
if (key === "local_model") {
const availableModels = safeArray(localCatalog.models).map((item) => item.id).filter(Boolean);
extra = currentModel
? `当前主模型:${currentModel.name} · ${currentModel.model_name || "-"}`
: `默认模型:${localCatalog.default_model || "GLM-5"}`;
if (availableModels.length) {
extra += ` · 可用:${availableModels.slice(0, 4).join(" / ")}${availableModels.length > 4 ? "…" : ""}`;
}
actions = [
localCatalog.management_url
? `<a class="tag blue" href="${escapeHtml(localCatalog.management_url)}" target="_blank" rel="noreferrer">打开管理页</a>`
: "",
`<span class="tag clickable-tag" data-action="open-preferred-model">设主模型</span>`
].filter(Boolean).join("");
}
return {
key,
meta,
detail,
status,
note,
extra,
actions
};
});
}
function getIntegrationOverview() {
const cards = getIntegrationCards();
const reachableCount = cards.filter((item) => item.detail.available && item.detail.reachable).length;
const availableCount = cards.filter((item) => item.detail.available).length;
const aiVideoGuard = getPipelineGuard("aiVideo");
const realCutGuard = getPipelineGuard("realCut");
const blockedActions = [
!aiVideoGuard.enabled ? aiVideoGuard.reason : "",
!realCutGuard.enabled ? realCutGuard.reason : ""
].filter(Boolean);
const tone = !availableCount
? "blue"
: blockedActions.length
? "red"
: cards.some((item) => item.detail.available && !item.detail.reachable)
? "orange"
: "green";
const headline = !availableCount
? "依赖健康尚未拉取"
: blockedActions.length
? `自动链路受阻:${blockedActions.length}`
: `${reachableCount}/${cards.length} 项依赖在线`;
const subtitle = !availableCount
? "刷新后会显示 cutvideo / huobao / n8n / ASR 的真实状态。"
: blockedActions.length
? blockedActions.join("")
: "AI 视频与实拍剪辑链路当前可直接发起。";
return { cards, tone, headline, subtitle };
}
function getJobSeedBrief(job) {
return [
job?.style_summary,
job?.transcript_text,
job?.result?.summary,
job?.artifacts?.brief,
job?.title
].find((value) => String(value || "").trim()) || "";
}
function collectHttpLinks(input, path = "result", bucket = []) {
if (!input) return bucket;
if (typeof input === "string") {
const value = input.trim();
if (/^https?:\/\//i.test(value)) {
bucket.push({ label: path, url: value });
}
return bucket;
}
if (Array.isArray(input)) {
input.forEach((item, index) => collectHttpLinks(item, `${path}[${index}]`, bucket));
return bucket;
}
if (typeof input === "object") {
Object.entries(input).forEach(([key, value]) => collectHttpLinks(value, `${path}.${key}`, bucket));
}
return bucket;
}
function getJobPreviewLinks(job) {
const deduped = [];
const seen = new Set();
collectHttpLinks(job?.result, "result", deduped);
collectHttpLinks(job?.artifacts, "artifacts", deduped);
return deduped.filter((item) => {
if (!item.url || seen.has(item.url)) return false;
seen.add(item.url);
return true;
}).slice(0, 8);
}
function isCandidateLinked(candidate, links) {
const accountId = String(candidate?.candidate_account_id || "");
const profileUrl = String(candidate?.candidate_profile_url || "");
return safeArray(links).some((link) => (
(accountId && String(link.target_account_id || "") === accountId) ||
(profileUrl && String(link.target_profile_url || "") === profileUrl)
));
}
function markSavedCandidate(candidate, links) {
const nextCandidates = safeArray(appState.lastSimilaritySearch?.candidates).map((item) => {
const sameAccount = String(item.candidate_account_id || "") && String(item.candidate_account_id || "") === String(candidate.candidate_account_id || "");
const sameUrl = String(item.candidate_profile_url || "") && String(item.candidate_profile_url || "") === String(candidate.candidate_profile_url || "");
if (!sameAccount && !sameUrl) return item;
return { ...item, saved: true };
});
if (appState.lastSimilaritySearch) {
appState.lastSimilaritySearch = {
...appState.lastSimilaritySearch,
candidates: nextCandidates
};
}
if (appState.selectedWorkspace) {
appState.selectedWorkspace = {
...appState.selectedWorkspace,
linked_accounts: safeArray(links)
};
}
}
async function saveCandidateAsBenchmark(candidateIndex, relationType = "benchmark") {
const account = requireSelectedAccountRow();
const platform = getAccountPlatform(account);
const benchmarkPath = getWorkbenchRoute(platform, "benchmarkLinks", account.id);
if (!benchmarkPath) throw new Error(getPendingWorkbenchReason(platform));
const candidate = safeArray(appState.lastSimilaritySearch?.candidates)[Number(candidateIndex)];
if (!candidate) throw new Error("当前候选不存在,请先重新查相似");
const payload = {
target_account_ids: candidate.candidate_account_id ? [candidate.candidate_account_id] : [],
target_profile_urls: candidate.candidate_account_id ? [] : [candidate.candidate_profile_url].filter(Boolean),
relation_type: relationType,
note: brief(candidate.rationale_text || "由相似搜索自动加入对标库", 120),
search_id: appState.lastSimilaritySearch?.id || ""
};
if (!payload.target_account_ids.length && !payload.target_profile_urls.length) {
throw new Error("当前候选没有可保存的账号或主页链接");
}
const result = await storyforgeFetch(benchmarkPath, {
method: "POST",
body: payload
});
markSavedCandidate(candidate, result.links);
rememberAction("候选已存对标", `已把「${candidate.candidate_nickname || candidate.candidate_profile_url || "候选账号"}」加入对标关系。`, "green", result);
renderAll();
}
function screenShell(title, subtitle, actionsHtml, bodyHtml) {
return `
<div class="screen-head">
<div>
<h2>${escapeHtml(title)}</h2>
<p>${escapeHtml(subtitle)}</p>
</div>
<div class="action-row">${actionsHtml || ""}</div>
</div>
${bodyHtml}
`;
}
function button(label, action, tone = "secondary", options = {}) {
const classes = ["btn", `btn-${tone}`];
if (options.className) classes.push(options.className);
if (options.disabledReason) classes.push("is-disabled");
const targetAction = options.disabledReason ? "show-disabled-reason" : action;
const title = options.disabledReason || options.title || "";
return `
<button
class="${classes.join(" ")}"
type="button"
data-action="${escapeHtml(targetAction)}"
${options.disabledReason ? `data-disabled-reason="${escapeHtml(options.disabledReason)}" aria-disabled="true"` : ""}
${title ? `title="${escapeHtml(title)}"` : ""}
>${escapeHtml(label)}</button>
`.replace(/\s+/g, " ").trim();
}
function actionTag(label, action, attrs = "", options = {}) {
const classes = ["tag"];
const targetAction = options.disabledReason ? "show-disabled-reason" : action;
if (options.disabledReason) {
classes.push("tag-disabled");
} else if (targetAction) {
classes.push("clickable-tag");
}
const title = options.disabledReason || options.title || "";
return `
<span
class="${classes.join(" ")}"
${targetAction ? `data-action="${escapeHtml(targetAction)}"` : ""}
${options.disabledReason ? `data-disabled-reason="${escapeHtml(options.disabledReason)}" aria-disabled="true"` : ""}
${title ? `title="${escapeHtml(title)}"` : ""}
${attrs}
>${escapeHtml(label)}</span>
`.replace(/\s+/g, " ").trim();
}
function renderPipelineButton(kind, tone = "secondary") {
const config = PIPELINE_GUARDS[kind];
if (!config) return "";
const guard = getPipelineGuard(kind);
return button(config.label, config.openAction, tone, {
disabledReason: guard.enabled ? "" : guard.reason
});
}
function renderPipelineJobTag(kind, job, label) {
const config = PIPELINE_GUARDS[kind];
if (!config || !job?.id) return "";
const guard = getPipelineGuard(kind);
return actionTag(label, config.jobAction, `data-job-id="${escapeHtml(job.id)}"`, {
disabledReason: guard.enabled ? "" : guard.reason
});
}
function renderIntegrationOverviewPanel(options = {}) {
const overview = getIntegrationOverview();
const cards = overview.cards;
const showActions = options.showActions !== false;
return `
<div class="panel pad integration-panel ${options.compact ? "integration-panel-compact" : ""}">
<div class="integration-summary ${overview.tone}">
<div>
<strong>${escapeHtml(overview.headline)}</strong>
<p>${escapeHtml(overview.subtitle)}</p>
</div>
${showActions ? `
<div class="integration-actions">
${renderPipelineButton("aiVideo", "primary")}
${renderPipelineButton("realCut")}
</div>
` : ""}
</div>
<div class="task-meta integration-highlights">
${cards.map((item) => `<span class="tag ${item.status.tone}">${escapeHtml(item.meta.label)} ${escapeHtml(item.status.summary)}</span>`).join("")}
</div>
<div class="layout-grid grid-4 integration-grid">
${cards.map((item) => `
<div class="integration-card ${item.status.tone}">
<div class="integration-card-head">
<div>
<h4>${escapeHtml(item.meta.label)}</h4>
<p>${escapeHtml(item.meta.hint)}</p>
</div>
<span class="tag ${item.status.tone}">${escapeHtml(item.status.summary)}</span>
</div>
<div class="task-meta">
${safeArray(item.meta.impacts).map((impact) => `<span class="tag">${escapeHtml(impact)}</span>`).join("")}
${item.detail.statusCode ? `<span class="tag">HTTP ${escapeHtml(item.detail.statusCode)}</span>` : ""}
</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>
${item.actions ? `<div class="task-meta integration-highlights" style="margin-top:12px;">${item.actions}</div>` : ""}
</div>
`).join("")}
</div>
</div>
`;
}
function renderEmptyState(title, description) {
return `<div class="panel pad"><div class="empty-state"><strong>${escapeHtml(title)}</strong><p>${escapeHtml(description)}</p></div></div>`;
}
function renderDashboardScreen() {
if (!appState.session) {
return screenShell(
"项目总台",
"先连接后端再加载项目、对标、Agent 和生产状态。",
`${button("连接后端", "open-auth", "primary")}`,
renderEmptyState("还没有连接 StoryForge", "登录后就能把项目总台替换成真实数据。")
);
}
if (!appState.dashboard) {
return screenShell(
"项目总台",
appState.me?.approval_status === "pending" ? "当前账号还在等待审批。" : "正在加载项目数据。",
`${button("刷新", "refresh-data")} ${button("切换连接", "open-auth")}`,
renderEmptyState("工作区暂未就绪", appState.message || "如果账号未审批通过,当前页会先停在待审批状态。")
);
}
const dashboard = appState.dashboard;
const projects = safeArray(dashboard.projects);
const jobs = safeArray(dashboard.recent_jobs);
const assistants = safeArray(dashboard.assistants);
const accounts = safeArray(appState.accounts);
const trackedAccounts = safeArray(appState.trackingAccounts);
const digestItems = getTrackingDigestItems(3);
const actions = [];
if (!projects.length) actions.push("先新建一个项目");
if (!assistants.length) actions.push("先创建第一个 Agent");
if (!accounts.length) actions.push("先导入一个抖音主页或作品");
if (!trackedAccounts.length && accounts.length) actions.push("挑 1 个重点账号加入跟踪");
if (jobs.some((item) => item.status !== "completed")) actions.push("处理进行中的生产任务");
if (!actions.length) actions.push("继续补高分对标并安排生产");
return screenShell(
"项目总台",
"先看项目状态、待办动作和高价值对标。",
`${button("新建项目", "create-project")} ${button("导入主页", "open-import-homepage")} ${button("创建 Agent", "open-create-assistant", "primary")}`,
`
<div class="layout-grid grid-5">
<div class="stat-card"><small>活跃项目</small><strong>${escapeHtml(formatNumber(projects.length))}</strong><div class="stat-foot"><span>项目总数</span><span class="positive">${escapeHtml(formatNumber(projects.filter((item) => item.description).length))} 个有说明</span></div></div>
<div class="stat-card"><small>导入内容</small><strong>${escapeHtml(formatNumber(appState.contentSources.length))}</strong><div class="stat-foot"><span>主页 / 作品 / 本地素材</span><span class="positive">${escapeHtml(formatNumber(appState.contentSources.filter((item) => item.source_kind === "creator_account").length))} 个主页</span></div></div>
<div class="stat-card"><small>跟踪账号</small><strong>${escapeHtml(formatNumber(trackedAccounts.length))}</strong><div class="stat-foot"><span>可生成日报</span><span class="positive">${escapeHtml(formatNumber(digestItems.length))} 条新摘要</span></div></div>
<div class="stat-card"><small>Agent</small><strong>${escapeHtml(formatNumber(assistants.length))}</strong><div class="stat-foot"><span>已创建</span><span class="warn">${escapeHtml(formatNumber(assistants.filter((item) => !(item.model_profile_id || "")).length))} 个待补模型</span></div></div>
<div class="stat-card"><small>生产任务</small><strong>${escapeHtml(formatNumber(jobs.length))}</strong><div class="stat-foot"><span>最近 20 条</span><span class="positive">${escapeHtml(formatNumber(jobs.filter((item) => item.status === "completed").length))} 条已完成</span></div></div>
</div>
<div style="margin-top:18px;">
${renderIntegrationOverviewPanel({ compact: true })}
</div>
<div class="layout-grid grid-main" style="margin-top:18px;">
<div class="side-stack">
<div class="hero-card">
<h3>当前主流程</h3>
<p>项目 → Agent → 调研 → 导入并绑定 → 生产 → 复盘</p>
<div class="chip-row" style="margin-top:14px;">
<span class="chip active">我的项目</span>
<span class="chip">找对标</span>
<span class="chip">跟踪账号</span>
<span class="chip">Agent</span>
<span class="chip">生产中心</span>
</div>
</div>
<div class="panel pad">
<div class="panel-head"><div><h3>今日重点动作</h3><div class="panel-subtitle">按当前数据自动生成</div></div><span class="tag blue">${escapeHtml(formatNumber(actions.length))} 项</span></div>
<div class="list">
${actions.map((item, index) => `
<div class="task-item">
<h4>${index + 1}. ${escapeHtml(item)}</h4>
<p>${escapeHtml(index === 0 ? "先把最影响主流程的动作做掉。" : "做完上一步再继续推进。")}</p>
</div>
`).join("")}
</div>
</div>
${renderLastActionCard()}
<div class="panel pad">
<div class="panel-head"><div><h3>高分对标</h3><div class="panel-subtitle">优先看当前已同步账号</div></div></div>
<div class="three-col">
${accounts.slice(0, 3).map((account) => `
<div class="entity-card pad">
<div class="entity-cell">
<div class="avatar-lg">${escapeHtml(initials(getAccountName(account)))}</div>
<div>
<div class="cell-title">${escapeHtml(getAccountName(account))}</div>
<div class="cell-desc">${escapeHtml(account.signature || getAccountProfileUrl(account) || `已同步${platformLabel(getAccountPlatform(account))}账号`)}</div>
</div>
</div>
<div class="entity-meta">
<span class="tag blue">作品 ${escapeHtml(formatNumber(account.video_summary?.count))}</span>
<span class="tag green">均播 ${escapeHtml(formatNumber(account.video_summary?.avg_play))}</span>
<span class="tag">${escapeHtml(account.sync_status || "synced")}</span>
</div>
</div>
`).join("") || `<div class="empty-state">先到“找对标”导入一个账号。</div>`}
</div>
</div>
</div>
<div class="side-stack">
<div class="hero-card">
<h3>当前项目</h3>
<p>${escapeHtml(getSelectedProject()?.name || "还没有项目")}</p>
<div class="mini-grid">
<div class="mini-card"><small>知识库</small><strong>${escapeHtml(formatNumber(getSelectedProject() ? getProjectStats(getSelectedProject().id).knowledgeBases.length : 0))}</strong></div>
<div class="mini-card"><small>Agent</small><strong>${escapeHtml(formatNumber(getSelectedProject() ? getProjectStats(getSelectedProject().id).assistants.length : 0))}</strong></div>
<div class="mini-card"><small>任务</small><strong>${escapeHtml(formatNumber(getSelectedProject() ? getProjectStats(getSelectedProject().id).jobs.length : 0))}</strong></div>
<div class="mini-card"><small>来源</small><strong>${escapeHtml(formatNumber(getSelectedProject() ? getProjectStats(getSelectedProject().id).sources.length : 0))}</strong></div>
</div>
</div>
<div class="panel pad">
<div class="panel-head"><div><h3>跟踪摘要</h3><div class="panel-subtitle">按最近同步的账号作品生成</div></div><span class="tag blue">${escapeHtml(daysSince(appState.lastSeenAt))} 天汇总</span></div>
<div class="list">
${digestItems.map((item) => `
<div class="task-item">
<h4>${escapeHtml(item.account?.nickname || "未命名账号")} · ${escapeHtml(item.video?.title || item.video?.description || "最新作品")}</h4>
<p>${escapeHtml(item.summary || `最近发布时间 ${formatDateTime(item.video?.published_at)},适合继续交给 Agent 做借鉴点标注。`)}</p>
<div class="task-meta">
<span class="tag">抖音</span>
<span class="tag green">${escapeHtml(item.is_high_value ? "高价值" : "可学习")}</span>
${item.assistant_name ? `<span class="tag">${escapeHtml(item.assistant_name)}</span>` : ""}
</div>
</div>
`).join("") || `<div class="task-item"><h4>还没有日报</h4><p>先把重点账号加入跟踪,日报才会开始累积。</p></div>`}
</div>
</div>
<div class="panel pad">
<div class="panel-head"><div><h3>最新异常</h3><div class="panel-subtitle">直接看需要处理的阻塞</div></div></div>
<div class="list">
${jobs.filter((item) => item.status === "failed").slice(0, 3).map((job) => `
<div class="task-item">
<h4>${escapeHtml(job.title)}</h4>
<p>${escapeHtml(job.error || "任务失败,请重新进入生产中心查看。")}</p>
<div class="task-meta"><span class="tag red">失败</span><span class="tag">${escapeHtml(job.line_type || "analysis")}</span></div>
</div>
`).join("") || `<div class="task-item"><h4>当前无异常</h4><p>最近任务运行正常,继续推进导入和生产即可。</p></div>`}
</div>
</div>
</div>
</div>
`
);
}
function renderProjectsScreen() {
if (!appState.dashboard) {
return screenShell("我的项目", "先连接工作区,再加载项目。", `${button("连接后端", "open-auth", "primary")}`, renderEmptyState("项目未加载", "登录成功后,这里会显示真实项目和导入队列。"));
}
const projects = safeArray(appState.dashboard.projects);
const selectedProject = getSelectedProject();
return screenShell(
"我的项目",
"先建项目,再决定是否绑定自己的账号。",
`${button("新建项目", "create-project", "primary")} ${button("导入作品", "open-import-video-link")} ${button("导入文本", "open-import-text")} ${button("上传视频", "open-upload-video")}`,
`
<div class="hero-card">
<h3>当前项目</h3>
<p>${escapeHtml(selectedProject?.name || "还没有项目")} · ${escapeHtml(selectedProject?.description || "创建后即可承接对标、Agent 和生产任务。")}</p>
<div class="chip-row" style="margin-top:14px;">
${projects.map((project) => `<span class="chip ${project.id === appState.selectedProjectId ? "active" : ""}" data-action="select-project" data-project-id="${escapeHtml(project.id)}">${escapeHtml(project.name)}</span>`).join("") || `<span class="chip active">暂无项目</span>`}
</div>
</div>
<div class="layout-grid grid-main" style="margin-top:18px;">
<div class="side-stack">
<div class="panel pad">
<div class="panel-head"><div><h3>项目状态</h3><div class="panel-subtitle">真实项目列表</div></div></div>
<div class="three-col">
${projects.map((project) => {
const stats = getProjectStats(project.id);
return `
<div class="entity-card pad">
<div class="cell-title">${escapeHtml(project.name)}</div>
<div class="cell-desc">${escapeHtml(project.description || "未填写说明")}</div>
<div class="entity-meta">
<span class="tag blue">知识库 ${escapeHtml(formatNumber(stats.knowledgeBases.length))}</span>
<span class="tag">Agent ${escapeHtml(formatNumber(stats.assistants.length))}</span>
<span class="tag green">任务 ${escapeHtml(formatNumber(stats.jobs.length))}</span>
</div>
</div>
`;
}).join("") || `<div class="empty-state">当前还没有项目</div>`}
</div>
</div>
</div>
<div class="side-stack">
<div class="panel pad">
<div class="panel-head"><div><h3>最近导入队列</h3><div class="panel-subtitle"></div></div></div>
<div class="list">
${safeArray(appState.contentSources).slice(0, 6).map((source) => `
<div class="review-card">
<h4>${escapeHtml(source.title || source.handle || source.source_url || source.id)}</h4>
<p>${escapeHtml(source.platform || source.source_kind || "内容源")} · ${escapeHtml(source.source_url || source.local_path || "暂无链接")}</p>
<div class="task-meta">
<span class="tag ${source.project_id === appState.selectedProjectId ? "green" : "blue"}">${source.project_id === appState.selectedProjectId ? "当前项目" : "其他项目"}</span>
<span class="tag">${escapeHtml(source.source_kind || "-")}</span>
</div>
</div>
`).join("") || `<div class="review-card"><h4>还没有导入内容</h4><p>先去“找对标”导入主页、作品或本地视频。</p></div>`}
</div>
</div>
</div>
</div>
`
);
}
function renderDiscoveryScreen() {
if (!appState.dashboard) {
return screenShell("找对标", "连接后端后才能加载真实对标账号。", `${button("连接后端", "open-auth", "primary")}`, renderEmptyState("对标库未加载", "登录后这里会显示当前平台的账号列表和详情。"));
}
const query = appState.discoveryQuery.toLowerCase();
const accounts = safeArray(appState.accounts).filter((account) => {
if (!query) return true;
return [getAccountName(account), account.signature, getAccountProfileUrl(account), getAccountHandle(account), ...safeArray(account.tags), ...safeArray(account.keywords)]
.join(" ")
.toLowerCase()
.includes(query);
});
const selected = getSelectedAccount();
const currentPlatform = getAccountPlatform(selected) || getPreferredPlatform();
const currentPlatformLabel = getPlatformShortLabel(currentPlatform);
const workbenchReason = !isWorkbenchPlatform(currentPlatform) ? getPendingWorkbenchReason(currentPlatform) : "";
const reports = safeArray(appState.selectedWorkspace?.recent_reports);
const linkedAccounts = safeArray(appState.selectedWorkspace?.linked_accounts);
const videos = safeArray(appState.selectedVideos?.items);
const fallbackVideos = safeArray(selected?.video_summary?.videos);
const effectiveVideos = videos.length ? videos : fallbackVideos;
const topVideos = getHighScoreVideos(3);
const latestVideos = getLatestVideos(2);
const similarCandidates = safeArray(appState.lastSimilaritySearch?.candidates).slice(0, 5);
const selectedProject = getSelectedProject();
const importedSources = getCurrentProjectSourcesForAccount(selected, selectedProject?.id || "");
const tracked = selected?.id ? isTrackedAccount(selected.id) : false;
return screenShell(
"找对标",
isWorkbenchPlatform(currentPlatform)
? `这里已经接入真实${currentPlatformLabel}账号列表和单账号详情`
: `${workbenchReason}当前仍可导入内容源绑定 Agent 和沉淀复盘`,
`${button("导入主页", "open-import-homepage")} ${button("导入当前对标", "open-import-selected-account")} ${button(tracked ? "已在跟踪" : "加入跟踪", "open-track-selected-account", "secondary", { disabledReason: workbenchReason || "" })} ${button("账号分析", "analyze-selected-account", "secondary", { disabledReason: workbenchReason || "" })} ${button("高分分析", "analyze-top-videos", "secondary", { disabledReason: workbenchReason || "" })} ${button("查相似", "open-similar-search", "secondary", { disabledReason: workbenchReason || "" })} ${button("存对标", "open-benchmark-link", "primary", { disabledReason: workbenchReason || "" })}`,
`
<div class="panel">
<div class="toolbar">
<div class="toolbar-stack">
<label class="search search-inline">
<span></span>
<input data-action="discovery-query" value="${escapeHtml(appState.discoveryQuery)}" placeholder="搜账号名、账号标识、主页链接、关键词" />
</label>
<div class="filters">
<div class="filter">平台${escapeHtml(currentPlatformLabel)}</div>
<div class="filter">账号数${escapeHtml(formatNumber(accounts.length))}</div>
<div class="filter">报告${escapeHtml(formatNumber(reports.length))}</div>
<div class="filter">作品${escapeHtml(formatNumber(effectiveVideos.length))}</div>
</div>
</div>
<div class="side-stack">
<div class="chip-row">
<span class="chip active">真实接口</span>
<span class="chip">工作台详情</span>
<span class="chip">高分作品</span>
<span class="chip">绑定关系</span>
</div>
</div>
</div>
<div class="mobile-only mobile-account-list">
${accounts.map((account) => {
const active = account.id === appState.selectedAccountId;
return `
<button class="account-select-card ${active ? "is-active" : ""}" type="button" data-action="select-account" data-account-id="${escapeHtml(account.id)}">
<div class="entity-cell">
<div class="avatar-lg">${escapeHtml(initials(getAccountName(account)))}</div>
<div>
<div class="cell-title">${escapeHtml(getAccountName(account))}</div>
<div class="cell-desc">${escapeHtml(getAccountSubtitle(account) || "未填账号标识")}</div>
</div>
</div>
<div class="kpi-inline">
<span>作品 ${escapeHtml(formatNumber(account.video_summary?.count))}</span>
<span>均播 ${escapeHtml(formatNumber(account.video_summary?.avg_play))}</span>
<span>均赞 ${escapeHtml(formatNumber(account.video_summary?.avg_like))}</span>
</div>
<div class="task-meta">
<span class="tag ${active ? "green" : "blue"}">${active ? "当前选中" : "点击查看"}</span>
<span class="tag">${escapeHtml(account.sync_status || "ready")}</span>
</div>
</button>
`;
}).join("") || `<div class="empty-state">当前平台没有账号数据。</div>`}
</div>
<div class="table-wrap">
<table class="account-table">
<thead>
<tr>
<th>账号</th>
<th>签名</th>
<th>作品数</th>
<th>平均播放</th>
<th>平均点赞</th>
<th>动作</th>
</tr>
</thead>
<tbody>
${accounts.map((account) => `
<tr>
<td>
<div class="entity-cell">
<div class="avatar-lg">${escapeHtml(initials(getAccountName(account)))}</div>
<div>
<div class="cell-title">${escapeHtml(getAccountName(account))}</div>
<div class="cell-desc">${escapeHtml(getAccountSubtitle(account) || "-")}</div>
</div>
</div>
</td>
<td>${escapeHtml(brief(account.signature || "暂无签名", 36))}</td>
<td><span class="metric">${escapeHtml(formatNumber(account.video_summary?.count))}</span></td>
<td>${escapeHtml(formatNumber(account.video_summary?.avg_play))}</td>
<td>${escapeHtml(formatNumber(account.video_summary?.avg_like))}</td>
<td><div class="row-meta"><span class="tag" data-action="select-account" data-account-id="${escapeHtml(account.id)}">查看</span></div></td>
</tr>
`).join("") || `<tr><td colspan="6">当前平台没有账号数据。</td></tr>`}
</tbody>
</table>
</div>
<div class="layout-grid grid-main" style="padding:18px; border-top:1px solid var(--line);">
<div class="side-stack">
<div class="panel pad" style="box-shadow:none;" id="selected-account-anchor">
<div class="panel-head"><div><h3>当前选中对标</h3><div class="panel-subtitle"></div></div><span class="tag blue">${escapeHtml(getAccountName(selected) || "")}</span></div>
<div class="hero-card" style="padding:18px;">
<div class="entity-cell">
<div class="avatar-lg">${escapeHtml(initials(getAccountName(selected) || "SF"))}</div>
<div>
<h3>${escapeHtml(getAccountName(selected) || "还没有选中账号")}</h3>
<p>${escapeHtml(getAccountProfileUrl(selected) || selected?.signature || "左侧点一个账号,这里会展示详情。")}</p>
</div>
</div>
<div class="mobile-only compact-summary-row" style="margin-top:14px;">
<span class="tag blue">作品 ${escapeHtml(formatNumber(selected?.video_summary?.count))}</span>
<span class="tag green">高分 ${escapeHtml(formatNumber(topVideos.length))}</span>
<span class="tag">报告 ${escapeHtml(formatNumber(reports.length))}</span>
<span class="tag">对标 ${escapeHtml(formatNumber(linkedAccounts.length))}</span>
</div>
<div class="mini-grid">
<div class="mini-card"><small>作品数</small><strong>${escapeHtml(formatNumber(selected?.video_summary?.count))}</strong></div>
<div class="mini-card"><small>高分作品</small><strong>${escapeHtml(formatNumber(topVideos.length))}</strong></div>
<div class="mini-card"><small>报告数</small><strong>${escapeHtml(formatNumber(reports.length))}</strong></div>
<div class="mini-card"><small>已绑对标</small><strong>${escapeHtml(formatNumber(linkedAccounts.length))}</strong></div>
</div>
</div>
<div class="panel pad" style="box-shadow:none; margin-top:16px;">
<div class="panel-head"><div><h3>接入当前项目</h3><div class="panel-subtitle"> Agent </div></div><span class="tag ${importedSources.length ? "green" : "blue"}">${escapeHtml(importedSources.length ? "" : "")}</span></div>
${selected ? `
<div class="task-item">
<h4>${escapeHtml(selectedProject?.name || "未选项目")}</h4>
<p>${escapeHtml(importedSources.length ? `当前项目已接入 ${formatNumber(importedSources.length)} 个内容源,可继续同步或换 Agent。` : "当前项目还没有接入这个对标账号,可直接导入主页并绑定 Agent。")}</p>
<div class="task-meta">
<span class="tag">${escapeHtml(selectedProject?.name || "未选项目")}</span>
<span class="tag">${escapeHtml(getSelectedAssistant()?.name || "未选 Agent")}</span>
<span class="tag clickable-tag" data-action="open-import-selected-account">${importedSources.length ? "继续同步" : "导入当前对标"}</span>
<span class="tag ${tracked ? "green" : "clickable-tag"}" ${tracked ? "" : `data-action="open-track-selected-account"`}>${escapeHtml(tracked ? "已在跟踪" : "加入跟踪")}</span>
</div>
</div>
` : `<div class="task-item"><h4>还没有选中账号</h4><p>先从左侧列表选一个对标账号,再决定是否导入到当前项目。</p></div>`}
</div>
<div class="three-col">
<div class="insight-card">
<h4>账号画像</h4>
<ul>
<li>${escapeHtml(selected?.signature || "暂无签名")}</li>
<li>${escapeHtml("平台:" + currentPlatformLabel)}</li>
<li>${escapeHtml("标签:" + safeArray(selected?.tags).slice(0, 4).join(" / ") || "暂无标签")}</li>
<li>${escapeHtml("同步状态:" + (selected?.sync_status || "-"))}</li>
</ul>
</div>
<div class="insight-card">
<h4>高分作品</h4>
<ul>
${topVideos.map((video) => `<li>${escapeHtml(describeVideo(video))}</li>`).join("") || "<li>暂无高分作品</li>"}
</ul>
</div>
<div class="insight-card">
<h4>最近报告</h4>
<ul>
${reports.slice(0, 3).map((report) => {
const suggestion = safeArray(report.suggestions)[0];
const summary = suggestion?.parsed_json?.executive_summary || suggestion?.suggestion_text || report.focus_text || "暂无结论";
return `<li>${escapeHtml(brief(summary, 48))}</li>`;
}).join("") || "<li>暂无分析报告</li>"}
</ul>
</div>
</div>
<div class="panel pad" style="box-shadow:none; margin-top:16px;">
<div class="panel-head"><div><h3>最新作品</h3><div class="panel-subtitle"></div></div><span class="tag">${escapeHtml(formatNumber(effectiveVideos.length))} </span></div>
<div class="list">
${latestVideos.map((video) => `
<div class="task-item">
<h4>${escapeHtml(describeVideo(video))}</h4>
<p>发布时间 ${escapeHtml(formatDateTime(video.published_at))} · 播放 ${escapeHtml(formatNumber(video.stats?.play))} · 点赞 ${escapeHtml(formatNumber(video.stats?.like))}</p>
<div class="task-meta">
<span class="tag blue">${escapeHtml(video.content_type || "video")}</span>
<span class="tag green">得分 ${escapeHtml(formatNumber(video.score?.performance_score || 0))}</span>
${getVideoLink(video) ? `<a class="tag" href="${escapeHtml(getVideoLink(video))}" target="_blank" rel="noreferrer">打开原作品</a>` : ""}
</div>
</div>
`).join("") || `<div class="task-item"><h4>还没有最近作品</h4><p>当前账号只同步了基础信息,还没拉到完整作品列表。</p></div>`}
</div>
</div>
</div>
</div>
<div class="side-stack">
<div class="panel pad" style="box-shadow:none;">
<div class="panel-head"><div><h3>相似对标 / 已绑关系</h3><div class="panel-subtitle"></div></div></div>
<div class="list">
${linkedAccounts.map((link) => `
<div class="task-item">
<h4>${escapeHtml(link.target_nickname || link.target_profile_url || "未命名对标")}</h4>
<p>${escapeHtml(link.note || link.target_profile_url || "已保存对标关系")}</p>
<div class="task-meta">
<span class="tag">${escapeHtml(link.relation_type || "benchmark")}</span>
${link.target_account_id ? `<span class="tag clickable-tag" data-action="select-account" data-account-id="${escapeHtml(link.target_account_id)}">看详情</span>` : ""}
${link.target_profile_url ? `<a class="tag" href="${escapeHtml(link.target_profile_url)}" target="_blank" rel="noreferrer">打开主页</a>` : ""}
</div>
</div>
`).join("") || `<div class="task-item"><h4>暂无已保存对标</h4><p>当前账号还没有保存过对标关系。</p></div>`}
</div>
</div>
<div class="panel pad" style="box-shadow:none;">
<div class="panel-head"><div><h3>最近相似候选</h3><div class="panel-subtitle"> Agent </div></div><span class="tag">${escapeHtml(formatNumber(similarCandidates.length))} </span></div>
<div class="list">
${similarCandidates.map((candidate, index) => `
<div class="task-item">
<h4>${escapeHtml(candidate.candidate_nickname || candidate.candidate_profile_url || "候选账号")}</h4>
<p>${escapeHtml(brief(candidate.rationale_text || "暂无理由", 96))}</p>
<div class="task-meta">
<span class="tag blue">启发分 ${escapeHtml(formatNumber(candidate.agent_score || candidate.heuristic_score || 0))}</span>
${candidate.candidate_account_id ? `<span class="tag clickable-tag" data-action="select-account" data-account-id="${escapeHtml(candidate.candidate_account_id)}">看详情</span>` : ""}
${isCandidateLinked(candidate, linkedAccounts) || candidate.saved ? `<span class="tag green">已保存</span>` : `<span class="tag clickable-tag" data-action="save-candidate-benchmark" data-candidate-index="${escapeHtml(index)}">存对标</span>`}
${candidate.candidate_profile_url ? `<a class="tag" href="${escapeHtml(candidate.candidate_profile_url)}" target="_blank" rel="noreferrer">打开主页</a>` : ""}
</div>
</div>
`).join("") || `<div class="task-item"><h4>还没有相似候选</h4><p>先点“查相似”,这里会展示最近一轮结果。</p></div>`}
</div>
</div>
</div>
</div>
</div>
`
);
}
function renderTrackingScreen() {
if (!appState.dashboard) {
return screenShell("跟踪账号", "登录后才能生成真实日报。", `${button("连接后端", "open-auth", "primary")}`, renderEmptyState("日报未加载", "当前还没有可用的对标账号数据。"));
}
const currentPlatform = getPreferredPlatform();
const trackingAccountsPath = getWorkbenchRoute(currentPlatform, "trackingAccounts");
if (!trackingAccountsPath || !backendSupports(trackingAccountsPath)) {
return screenShell(
"跟踪账号",
`${getPendingWorkbenchReason(currentPlatform)}`,
`${button("跳到找对标", "goto-discovery", "primary")}`,
renderEmptyState("跟踪能力暂未接入", `这套后端还没有接入 ${platformLabel(currentPlatform)} 跟踪接口 live collector 同步后这里会自动切成真实日报`)
);
}
const trackedAccounts = safeArray(appState.trackingAccounts);
const digestItems = getTrackingDigestItems(12);
const cursorLabel = appState.lastSeenAt ? formatDateTime(appState.lastSeenAt) : "尚未记录";
return screenShell(
"跟踪账号",
`这里已经接上真实${getPlatformShortLabel(currentPlatform)}跟踪对象和按上次打开后的更新日报`,
`${button("同步全部", "refresh-tracking")} ${button("标记已读", "mark-tracking-read")} ${button("跳到找对标", "goto-discovery", "primary")}`,
`
<div class="hero-card">
<h3>日报逻辑</h3>
<p>按上次打开后汇总上次打开距今 ${escapeHtml(daysSince(appState.lastSeenAt))} 本次优先展示有更新且值得借鉴的内容</p>
<div class="chip-row" style="margin-top:14px;">
<span class="chip active">按上次打开汇总</span>
<span class="chip">Agent 标借鉴点</span>
<span class="chip">高价值内容可进学习集</span>
<span class="chip">上次已读 ${escapeHtml(cursorLabel)}</span>
</div>
</div>
<div class="layout-grid grid-main" style="margin-top:18px;">
<div class="side-stack">
<div class="panel pad">
<div class="panel-head"><div><h3>跟踪列表</h3><div class="panel-subtitle"> Agent</div></div><span class="tag">${escapeHtml(formatNumber(trackedAccounts.length))} </span></div>
<div class="mobile-only compact-summary-row">
<span class="tag blue">跟踪 ${escapeHtml(formatNumber(trackedAccounts.length))}</span>
<span class="tag green">日报 ${escapeHtml(formatNumber(digestItems.length))}</span>
<span class="tag">${escapeHtml(daysSince(appState.lastSeenAt))} 天窗口</span>
</div>
<div class="list">
${trackedAccounts.map((item) => `
<div class="task-item compact">
<h4>${escapeHtml(item.account?.nickname || "未命名账号")}</h4>
<p>最近作品 ${escapeHtml(formatNumber(item.account?.video_summary?.count))} 条 · 平均播放 ${escapeHtml(formatNumber(item.account?.video_summary?.avg_play))}</p>
<div class="task-meta">
<span class="tag green">已跟踪</span>
<span class="tag">${escapeHtml(item.assistant_name || "未绑 Agent")}</span>
${actionTag("立即同步", "refresh-tracked-account", `data-tracked-account-id="${escapeHtml(item.tracked_account_id)}"`)}
${actionTag("看详情", "select-account", `data-account-id="${escapeHtml(item.tracked_account_id)}"`)}
</div>
</div>
`).join("") || `<div class="task-item"><h4>暂无跟踪账号</h4><p>先去找对标把重点账号加入跟踪。</p></div>`}
</div>
</div>
</div>
<div class="side-stack">
<div class="panel pad">
<div class="panel-head"><div><h3>更新日报</h3><div class="panel-subtitle"></div></div><span class="tag blue">${escapeHtml(formatNumber(digestItems.length))} </span></div>
<div class="list">
${digestItems.map((item) => `
<div class="review-card compact">
<h4>${escapeHtml(item.account?.nickname || "账号")} · ${escapeHtml(item.video?.title || item.video?.description || "最新作品")}</h4>
<p>${escapeHtml(item.summary || `发布时间 ${formatDateTime(item.video?.published_at)},建议继续判断借鉴点。`)}</p>
<div class="task-meta">
<span class="tag">抖音</span>
<span class="tag green">${escapeHtml(item.is_high_value ? "高价值" : "可学习")}</span>
${item.assistant_name ? `<span class="tag">${escapeHtml(item.assistant_name)}</span>` : ""}
${item.video?.share_url ? `<a class="tag" href="${escapeHtml(item.video.share_url)}" target="_blank" rel="noreferrer">打开作品</a>` : ""}
</div>
${safeArray(item.borrowing_points).length ? `
<div class="task-meta">
${safeArray(item.borrowing_points).slice(0, 3).map((point) => `<span class="tag blue">${escapeHtml(point)}</span>`).join("")}
</div>
` : ""}
</div>
`).join("") || `<div class="review-card"><h4>暂无日报</h4><p>先把账号加入跟踪,并等待新作品更新。</p></div>`}
</div>
</div>
</div>
</div>
`
);
}
function renderAutomationScreen() {
const jobs = safeArray(appState.dashboard?.recent_jobs);
const analysisJobs = jobs.filter((item) => item.line_type === "analysis").length;
const aiVideoJobs = jobs.filter((item) => item.line_type === "ai_video").length;
const realCutJobs = jobs.filter((item) => item.line_type === "real_cut").length;
const overview = getIntegrationOverview();
return screenShell(
"自动流程",
"自动同步、日报生成和失败补跑先统一看这里。",
`${button("刷新", "refresh-data")} ${renderPipelineButton("aiVideo")} ${renderPipelineButton("realCut")} ${button("去生产", "goto-production", "primary")}`,
`
<div class="hero-card">
<h3>自动流程</h3>
<p>当前按真实任务量和依赖健康状态给出看板自动流程受阻时会直接在这里拦住动作</p>
<div class="mini-grid">
<div class="mini-card"><small>分析任务</small><strong>${escapeHtml(formatNumber(analysisJobs))}</strong></div>
<div class="mini-card"><small>AI 视频</small><strong>${escapeHtml(formatNumber(aiVideoJobs))}</strong></div>
<div class="mini-card"><small>实拍剪辑</small><strong>${escapeHtml(formatNumber(realCutJobs))}</strong></div>
<div class="mini-card"><small>内容源</small><strong>${escapeHtml(formatNumber(appState.contentSources.length))}</strong></div>
</div>
</div>
<div style="margin-top:18px;">
${renderIntegrationOverviewPanel({ showActions: false })}
</div>
<div class="panel pad automation-guard-panel" style="margin-top:18px;">
<div class="panel-head">
<div>
<h3>动作防呆</h3>
<div class="panel-subtitle">依赖不可用时相关动作会在这里和生产页一起被拦住</div>
</div>
<span class="tag ${escapeHtml(overview.tone)}">${escapeHtml(overview.headline)}</span>
</div>
<div class="task-meta integration-highlights">
<span class="tag ${getPipelineGuard("aiVideo").enabled ? "green" : "red"}">AI 视频 ${escapeHtml(getPipelineGuard("aiVideo").enabled ? "可执行" : "已拦截")}</span>
<span class="tag ${getPipelineGuard("realCut").enabled ? "green" : "red"}">实拍剪辑 ${escapeHtml(getPipelineGuard("realCut").enabled ? "可执行" : "已拦截")}</span>
<span class="tag blue">ASR ${escapeHtml(getIntegrationStatus(getIntegrationDetail("asr")).summary)}</span>
</div>
<div class="integration-actions" style="margin-top:14px;">
${renderPipelineButton("aiVideo", "primary")}
${renderPipelineButton("realCut")}
</div>
<div class="integration-note" style="margin-top:12px;">${escapeHtml(overview.subtitle)}</div>
</div>
`
);
}
function renderOwnedScreen() {
if (!appState.dashboard) {
return screenShell("我的账号", "先连接后端。", `${button("连接后端", "open-auth", "primary")}`, renderEmptyState("我的账号未加载", "登录后这里会展示当前账号和建议动作。"));
}
const me = appState.me || appState.session?.account || {};
const firstAssistant = safeArray(appState.dashboard.assistants)[0];
return screenShell(
"我的账号",
"这里先用当前登录账号和最近产出组合成第一版总览。",
`${button("刷新", "refresh-data")} ${button("去 Agent", "goto-playbook", "primary")}`,
`
<div class="hero-card">
<div class="entity-cell">
<div class="avatar-lg">${escapeHtml(initials(me.display_name || me.username))}</div>
<div>
<h3>${escapeHtml(me.display_name || me.username || "当前账号")}</h3>
<p>${escapeHtml(firstAssistant?.generation_goal || "先创建 Agent再把平台目标和变现方式补齐。")}</p>
</div>
</div>
<div class="mini-grid">
<div class="mini-card"><small>项目</small><strong>${escapeHtml(formatNumber(appState.dashboard.projects?.length))}</strong></div>
<div class="mini-card"><small>Agent</small><strong>${escapeHtml(formatNumber(appState.dashboard.assistants?.length))}</strong></div>
<div class="mini-card"><small>任务</small><strong>${escapeHtml(formatNumber(appState.dashboard.recent_jobs?.length))}</strong></div>
<div class="mini-card"><small>素材</small><strong>${escapeHtml(formatNumber(appState.documents.length))}</strong></div>
</div>
</div>
`
);
}
function renderPlaybookScreen() {
if (!appState.dashboard) {
return screenShell("Agent", "先连接后端。", `${button("连接后端", "open-auth", "primary")}`, renderEmptyState("Agent 未加载", "登录后这里会展示真实 Agent 列表和模型。"));
}
const assistants = safeArray(appState.dashboard.assistants);
const models = safeArray(appState.dashboard.model_profiles);
const currentModel = getCurrentModelProfile();
const currentAssistant = getSelectedAssistant();
const localCatalog = appState.localModelCatalog || {};
const gatewayModels = safeArray(localCatalog.models).map((item) => item.id).filter(Boolean);
return screenShell(
"Agent",
"这里接真实 Agent 列表,当前已经支持切换和编辑 Agent。",
`${button("设主模型", "open-preferred-model")} ${button("新建 Agent", "open-create-assistant")} ${button("生成文案", "open-generate-copy")} ${button("去生产", "goto-production", "primary")}`,
`
<div class="hero-card">
<h3>Agent 概览</h3>
<p>先定项目平台和主模型再导入内容让 Agent 学习</p>
<div class="chip-row" style="margin-top:14px;">
${models.slice(0, 6).map((model) => `<span class="chip ${model.is_default ? "active" : ""}">${escapeHtml(model.name)}</span>`).join("") || `<span class="chip active">暂无模型</span>`}
</div>
</div>
<div class="panel pad" style="margin-top:18px;">
<div class="panel-head">
<div>
<h3>当前 Agent</h3>
<div class="panel-subtitle">后续文案生成对标绑定和复盘默认都会优先使用这里选中的 Agent</div>
</div>
<div class="task-meta">
${currentAssistant ? `<span class="tag blue">已选</span>` : `<span class="tag red">未选</span>`}
${currentAssistant ? `<span class="tag clickable-tag" data-action="open-edit-assistant" data-assistant-id="${escapeHtml(currentAssistant.id)}">编辑</span>` : ""}
</div>
</div>
${currentAssistant ? `
<div class="task-item compact">
<h4>${escapeHtml(currentAssistant.name)}</h4>
<p>${escapeHtml(currentAssistant.generation_goal || currentAssistant.description || "先补齐这个 Agent 的目标和说明。")}</p>
<div class="task-meta">
<span class="tag">${escapeHtml(models.find((item) => item.id === currentAssistant.model_profile_id)?.name || "默认模型")}</span>
<span class="tag blue">${escapeHtml(formatNumber(safeArray(currentAssistant.knowledge_base_ids).length))} 条知识库</span>
<span class="tag">${escapeHtml(brief(currentAssistant.description || "暂无说明", 22))}</span>
</div>
</div>
` : `<div class="task-item"><h4>还没有可用 Agent</h4><p>先创建一个 Agent再把当前项目的内容都交给它学习。</p></div>`}
</div>
<div class="panel pad" style="margin-top:18px;">
<div class="panel-head">
<div>
<h3>本机模型网关</h3>
<div class="panel-subtitle">当前默认分析会优先走本机 cli-proxy-api</div>
</div>
<div class="task-meta">
<span class="tag ${escapeHtml(localCatalog.reachable ? "green" : "red")}">${escapeHtml(localCatalog.reachable ? "在线" : "离线")}</span>
${localCatalog.management_url ? `<a class="tag blue" href="${escapeHtml(localCatalog.management_url)}" target="_blank" rel="noreferrer">打开管理页</a>` : ""}
</div>
</div>
<div class="task-item compact">
<h4>${escapeHtml(currentModel?.name || localCatalog.default_model || "GLM-5")}</h4>
<p>${escapeHtml(currentModel ? `${currentModel.model_name || "-"} · ${currentModel.base_url || "-"}` : (localCatalog.public_base_url || localCatalog.base_url || "尚未读取到网关地址"))}</p>
<div class="task-meta">
${gatewayModels.slice(0, 6).map((model) => `<span class="tag">${escapeHtml(model)}</span>`).join("") || `<span class="tag red">暂无可见模型</span>`}
</div>
</div>
</div>
<div class="layout-grid grid-split" style="margin-top:18px;">
<div class="panel pad">
<div class="panel-head"><div><h3>模型列表</h3><div class="panel-subtitle"> model_profiles</div></div></div>
<div class="playbook-list">
${models.map((model) => `
<div class="playbook-item ${model.is_default ? "active" : ""}">
<h4>${escapeHtml(model.name)}</h4>
<p>${escapeHtml(model.model_name || "-")} · ${escapeHtml(model.base_url || "-")}</p>
</div>
`).join("") || `<div class="playbook-item active"><h4>暂无模型</h4><p>先在“我的”里配置模型。</p></div>`}
</div>
</div>
<div class="panel pad">
<div class="panel-head"><div><h3>Agent 列表</h3><div class="panel-subtitle"> assistants</div></div></div>
<div class="list">
${assistants.map((assistant) => `
<div class="task-item ${assistant.id === currentAssistant?.id ? "active" : ""}">
<h4>${escapeHtml(assistant.name)}</h4>
<p>${escapeHtml(assistant.description || assistant.generation_goal || "暂无说明")}</p>
<div class="task-meta">
<span class="tag blue">知识库 ${escapeHtml(formatNumber(safeArray(assistant.knowledge_base_ids).length))}</span>
<span class="tag">${escapeHtml(models.find((item) => item.id === assistant.model_profile_id)?.name || "默认模型")}</span>
<span class="tag clickable-tag" data-action="select-assistant" data-assistant-id="${escapeHtml(assistant.id)}">${assistant.id === currentAssistant?.id ? "当前 Agent" : "设为当前"}</span>
<span class="tag clickable-tag" data-action="open-edit-assistant" data-assistant-id="${escapeHtml(assistant.id)}">编辑</span>
</div>
</div>
`).join("") || `<div class="task-item"><h4>还没有 Agent</h4><p>下一步可以直接把创建动作接进来。</p></div>`}
</div>
</div>
<div class="panel pad">
<div class="panel-head"><div><h3>最近学习素材</h3><div class="panel-subtitle"></div></div></div>
<div class="list">
${appState.documents.slice(0, 4).map((doc) => `
<div class="task-item">
<h4>${escapeHtml(doc.title)}</h4>
<p>${escapeHtml(brief(doc.style_summary || doc.transcript_text || doc.combined_text, 72))}</p>
</div>
`).join("") || `<div class="task-item"><h4>还没有学习素材</h4><p>先去找对标导入一条主页或作品。</p></div>`}
</div>
</div>
<div class="panel pad">
<div class="panel-head"><div><h3>最近生成</h3><div class="panel-subtitle"></div></div></div>
${appState.lastGeneratedCopy ? `
<div class="task-item">
<h4>${escapeHtml(appState.lastGeneratedCopy.assistantName)}</h4>
<p>${escapeHtml(appState.lastGeneratedCopy.content)}</p>
<div class="task-meta">
<span class="tag blue">需求:${escapeHtml(brief(appState.lastGeneratedCopy.prompt, 24))}</span>
<span class="tag">${escapeHtml(formatNumber(appState.lastGeneratedCopy.usedDocuments.length))} 条参考</span>
</div>
</div>
` : `<div class="task-item"><h4>还没有生成结果</h4><p>先点“生成文案”,这里会保留最近一次结果。</p></div>`}
</div>
</div>
`
);
}
function renderProductionScreen() {
if (!appState.dashboard) {
return screenShell("生产中心", "先连接后端。", `${button("连接后端", "open-auth", "primary")}`, renderEmptyState("生产中心未加载", "登录后这里会展示真实任务和作品。"));
}
const jobs = safeArray(appState.dashboard.recent_jobs);
const activeJobs = jobs.filter((item) => item.status !== "completed").slice(0, 4);
const recentDocs = appState.documents.slice(0, 3);
const works = getProductionWorks(6);
return screenShell(
"生产中心",
"这里已经接上真实任务和知识库文档,后续再继续补任务创建动作。",
`${renderPipelineButton("aiVideo")} ${renderPipelineButton("realCut")} ${button("去复盘", "goto-review", "primary")}`,
`
<div class="panel pad">
<div class="panel-head"><div><h3>生产队列</h3><div class="panel-subtitle"></div></div></div>
<div class="layout-grid grid-4" style="margin-top:16px;">
<div class="queue-card"><h4>分析任务</h4><p> ${escapeHtml(formatNumber(jobs.filter((item) => item.line_type === "analysis").length))} </p></div>
<div class="queue-card"><h4>实拍剪辑</h4><p> ${escapeHtml(formatNumber(jobs.filter((item) => item.line_type === "real_cut").length))} </p></div>
<div class="queue-card"><h4>AI 视频</h4><p> ${escapeHtml(formatNumber(jobs.filter((item) => item.line_type === "ai_video").length))} </p></div>
<div class="queue-card"><h4>内容源同步</h4><p> ${escapeHtml(formatNumber(jobs.filter((item) => item.line_type === "content_source_sync").length))} </p></div>
</div>
<div class="mobile-only compact-summary-row" style="margin-top:14px;">
<span class="tag blue">分析 ${escapeHtml(formatNumber(jobs.filter((item) => item.line_type === "analysis").length))}</span>
<span class="tag">实拍 ${escapeHtml(formatNumber(jobs.filter((item) => item.line_type === "real_cut").length))}</span>
<span class="tag green">AI 视频 ${escapeHtml(formatNumber(jobs.filter((item) => item.line_type === "ai_video").length))}</span>
</div>
</div>
<div class="layout-grid grid-main" style="margin-top:18px;">
<div class="side-stack">
<div class="panel pad">
<div class="panel-head"><div><h3>当前任务</h3><div class="panel-subtitle"> recent_jobs</div></div></div>
<div class="list">
${(activeJobs.length ? activeJobs : jobs.slice(0, 4)).map((job) => `
<div class="task-item compact">
<h4>${escapeHtml(job.title)}</h4>
<p>${escapeHtml(brief(job.style_summary || job.transcript_text || job.error || "暂无摘要", 80))}</p>
<div class="task-meta">
<span class="tag ${statusTone(job.status)}">${escapeHtml(job.status)}</span>
<span class="tag">${escapeHtml(job.line_type || "analysis")}</span>
${canDeriveAiVideo(job) ? renderPipelineJobTag("aiVideo", job, "做 AI 视频") : ""}
${canDeriveRealCut(job) ? renderPipelineJobTag("realCut", job, "做实拍剪辑") : ""}
${actionTag("看详情", "open-job-detail", `data-job-id="${escapeHtml(job.id)}"`)}
</div>
</div>
`).join("") || `<div class="task-item"><h4>还没有任务</h4><p>先去找对标导入内容。</p></div>`}
</div>
</div>
</div>
<div class="side-stack">
<div class="panel pad">
<div class="panel-head"><div><h3>作品与成片</h3><div class="panel-subtitle"></div></div></div>
<div class="list">
${works.map((video) => `
<div class="review-card compact">
<h4>${escapeHtml(describeVideo(video))}</h4>
<p>${escapeHtml(`发布时间 ${formatDateTime(video.published_at)} · 播放 ${formatNumber(video.stats?.play)} · 点赞 ${formatNumber(video.stats?.like)}`)}</p>
<div class="task-meta">
<span class="tag blue">${escapeHtml(video.content_type || "video")}</span>
<span class="tag green">得分 ${escapeHtml(formatNumber(video.score?.performance_score || 0))}</span>
${getVideoLink(video) ? `<a class="tag" href="${escapeHtml(getVideoLink(video))}" target="_blank" rel="noreferrer">打开原作品</a>` : ""}
</div>
</div>
`).join("")}
${recentDocs.map((doc) => `
<div class="review-card compact">
<h4>${escapeHtml(doc.title)}</h4>
<p>${escapeHtml(brief(doc.style_summary || doc.combined_text || doc.transcript_text, 92))}</p>
<div class="task-meta"><span class="tag">${escapeHtml(doc.source_type || "document")}</span><span class="tag blue">学习素材</span></div>
</div>
`).join("") || (works.length ? "" : `<div class="review-card"><h4>还没有作品</h4><p>先导入内容或跑一次分析任务。</p></div>`)}
</div>
</div>
${renderLastJobDetailCard()}
</div>
</div>
`
);
}
function renderReviewScreen() {
if (!appState.dashboard) {
return screenShell("发布与复盘", "先连接后端。", `${button("连接后端", "open-auth", "primary")}`, renderEmptyState("复盘未加载", "登录后这里会先用最近任务生成一版复盘入口。"));
}
if (!backendSupports("/v2/reviews")) {
return screenShell(
"发布与复盘",
"当前 live collector 还没有接入复盘读写接口。",
`${button("去生产", "goto-production", "primary")}`,
renderEmptyState("复盘能力暂未接入", "这套后端还缺 /v2/reviews当前可以继续跑生产任务等 live collector 同步后这里会自动切成真实复盘工作台。")
);
}
const project = getSelectedProject();
const completed = safeArray(appState.dashboard.recent_jobs).filter((item) => item.status === "completed").slice(0, 4);
const reviews = getProjectReviews(project?.id || "").slice(0, 8);
return screenShell(
"发布与复盘",
"先看已保存复盘,再把完成任务转成结构化复盘。",
`${button("写复盘", "open-create-review")} ${button("刷新", "refresh-data")} ${button("去生产", "goto-production", "primary")}`,
`
<div class="layout-grid grid-main">
<div class="side-stack">
<div class="panel pad">
<div class="panel-head"><div><h3>已保存复盘</h3><div class="panel-subtitle"></div></div><span class="tag blue">${escapeHtml(formatNumber(reviews.length))} </span></div>
<div class="list">
${reviews.map((review) => `
<div class="review-card compact">
<h4>${escapeHtml(review.title)}</h4>
<p>${escapeHtml(brief(review.highlights || review.next_actions || review.notes || "已保存复盘,待继续补充表现数据。", 92))}</p>
<div class="task-meta">
<span class="tag blue">${escapeHtml(platformLabel(review.platform || "douyin"))}</span>
<span class="tag ${statusTone(review.verdict || "blue")}">${escapeHtml(review.verdict || "已记录")}</span>
${review.publish_url ? `<a class="tag" href="${escapeHtml(review.publish_url)}" target="_blank" rel="noreferrer">打开链接</a>` : ""}
<span class="tag clickable-tag" data-action="open-review-edit" data-review-id="${escapeHtml(review.id)}">编辑</span>
</div>
</div>
`).join("") || `<div class="review-card"><h4>还没有复盘</h4><p>可以把最近完成任务直接写成一条复盘。</p></div>`}
</div>
</div>
</div>
<div class="side-stack">
<div class="panel pad">
<div class="panel-head"><div><h3>最近完成</h3><div class="panel-subtitle"></div></div></div>
<div class="list">
${completed.map((job) => `
<div class="review-card compact">
<h4>${escapeHtml(job.title)}</h4>
<p>${escapeHtml(brief(job.style_summary || job.transcript_text || "已完成,待补复盘。", 84))}</p>
<div class="task-meta">
<span class="tag green">已完成</span>
<span class="tag">${escapeHtml(job.line_type || "analysis")}</span>
${actionTag("写复盘", "open-review-from-job", `data-job-id="${escapeHtml(job.id)}"`)}
${canDeriveAiVideo(job) ? renderPipelineJobTag("aiVideo", job, "做 AI 视频") : ""}
${canDeriveRealCut(job) ? renderPipelineJobTag("realCut", job, "做实拍剪辑") : ""}
${actionTag("看详情", "open-job-detail", `data-job-id="${escapeHtml(job.id)}"`)}
</div>
</div>
`).join("") || `<div class="review-card"><h4>还没有完成任务</h4><p>先去生产中心跑一条链路。</p></div>`}
</div>
</div>
</div>
</div>
${renderLastJobDetailCard()}
`
);
}
function renderCreditsScreen() {
if (!appState.dashboard) {
return screenShell("额度", "先连接后端。", `${button("连接后端", "open-auth", "primary")}`, renderEmptyState("额度未加载", "后续接真实计费前,先用任务量做运营看板。"));
}
const jobs = safeArray(appState.dashboard.recent_jobs);
return screenShell(
"额度",
"在接真实计费前,先按任务量给出运营看板。",
`${button("刷新", "refresh-data")}`,
`
<div class="layout-grid grid-3">
<div class="stat-card"><small>文案消耗预估</small><strong>${escapeHtml(formatNumber(jobs.filter((item) => item.line_type === "analysis").length))}</strong><div class="stat-foot"><span> / </span><span class="positive"></span></div></div>
<div class="stat-card"><small>封面消耗预估</small><strong>0</strong><div class="stat-foot"><span></span><span class="warn"></span></div></div>
<div class="stat-card"><small>视频消耗预估</small><strong>${escapeHtml(formatNumber(jobs.filter((item) => item.line_type === "ai_video" || item.line_type === "real_cut").length))}</strong><div class="stat-foot"><span>AI / </span><span class="positive"></span></div></div>
</div>
`
);
}
function renderTopbar() {
const workspaceStrong = document.querySelector(".workspace-switch strong");
const workspaceSpan = document.querySelector(".workspace-switch span");
const searchInput = document.querySelector(".search input");
const avatar = document.querySelector(".avatar");
const topPills = document.querySelectorAll(".top-pill");
const platforms = document.querySelector(".topbar-left .chip-row");
const project = getSelectedProject();
if (workspaceStrong) {
workspaceStrong.textContent = project?.name || (appState.session ? "已连接工作区" : "未连接工作区");
}
if (workspaceSpan) {
workspaceSpan.textContent = appState.dashboard
? `${safeArray(appState.dashboard.projects).length} 个项目 · ${safeArray(appState.dashboard.assistants).length} Agent`
: "连接后加载项目和 Agent";
}
if (searchInput) {
searchInput.value = "";
searchInput.placeholder = "搜项目、账号、内容、Agent";
}
if (avatar) {
avatar.textContent = initials(appState.me?.display_name || appState.me?.username || appState.session?.account?.display_name || "SF");
}
if (topPills.length >= 3) {
topPills[0].textContent = `项目 ${formatNumber(appState.dashboard?.projects?.length || 0)}`;
topPills[1].textContent = `对标 ${formatNumber(appState.accounts.length)}`;
topPills[2].textContent = `任务 ${formatNumber(appState.dashboard?.recent_jobs?.length || 0)}`;
}
if (platforms) {
platforms.innerHTML = ACTIVE_PLATFORM_CHIPS.map((label, index) => `<span class="chip ${index === 0 ? "active" : ""}">${escapeHtml(label)}</span>`).join("");
}
}
function renderAll() {
renderTopbar();
renderAuthUi();
screenMap.dashboard.innerHTML = renderDashboardScreen();
screenMap.intake.innerHTML = renderProjectsScreen();
screenMap.discovery.innerHTML = renderDiscoveryScreen();
screenMap.tracking.innerHTML = renderTrackingScreen();
screenMap.automation.innerHTML = renderAutomationScreen();
screenMap.owned.innerHTML = renderOwnedScreen();
screenMap.playbook.innerHTML = renderPlaybookScreen();
screenMap.production.innerHTML = renderProductionScreen();
screenMap.review.innerHTML = renderReviewScreen();
screenMap.credits.innerHTML = renderCreditsScreen();
setScreen(screenMap[appState.screen] ? appState.screen : "dashboard");
}
async function createProject() {
if (!appState.session) {
openAuthModal();
return;
}
const name = window.prompt("输入项目名称");
if (!name) return;
const description = window.prompt("输入项目说明(可选)") || "";
setBusy(true, "正在创建项目...");
try {
await storyforgeFetch("/v2/projects", {
method: "POST",
body: { name, description }
});
await bootstrap();
} catch (error) {
alert("创建项目失败: " + error.message);
} finally {
setBusy(false, "");
}
}
function openPreferredModelAction() {
const models = getModelOptions();
const currentProfile = getCurrentModelProfile();
const currentId = currentProfile?.id || models[0]?.value || "";
const localCatalog = appState.localModelCatalog || {};
const gatewayModels = safeArray(localCatalog.models).map((item) => item.id).filter(Boolean);
openActionModal({
title: "设置分析主模型",
description: "后续导入分析、市场调研和风格学习会优先使用这里设置的模型。",
submitLabel: "保存模型",
fields: [
{
type: "html",
label: "本机模型网关",
html: `
<div class="task-item compact">
<h4>${escapeHtml(localCatalog.reachable ? "网关在线" : "网关离线")}</h4>
<p>${escapeHtml(currentProfile ? `当前主模型:${currentProfile.name} · ${currentProfile.model_name || "-"}` : `默认模型:${localCatalog.default_model || "GLM-5"}`)}</p>
<div class="task-meta">
${gatewayModels.slice(0, 6).map((model) => `<span class="tag">${escapeHtml(model)}</span>`).join("") || `<span class="tag red">暂未读取到模型目录</span>`}
${localCatalog.management_url ? `<a class="tag blue" href="${escapeHtml(localCatalog.management_url)}" target="_blank" rel="noreferrer">打开管理页</a>` : ""}
</div>
</div>
`
},
{ name: "modelProfileId", label: "主模型", type: "select", value: currentId, options: models }
],
onSubmit: async (values) => {
if (!values.modelProfileId) throw new Error("请先选择一个模型");
await storyforgeFetch("/v2/me/preferences/analysis-model", {
method: "POST",
body: { model_profile_id: values.modelProfileId }
});
rememberAction("主模型已更新", "新的分析主模型已经保存。", "green");
await bootstrap();
}
});
}
function rememberAction(title, summary, tone = "blue", payload = null) {
appState.lastAction = {
title,
summary,
tone,
payload,
createdAt: new Date().toISOString()
};
}
function extractGeneratedCopy(payload) {
const raw = payload?.content || payload?.text || payload?.copy || payload?.result?.content || "";
return brief(raw, 2400);
}
function renderLastActionCard() {
if (!appState.lastAction) return "";
return `
<div class="panel pad">
<div class="panel-head">
<div>
<h3>最近动作</h3>
<div class="panel-subtitle">${escapeHtml(formatDateTime(appState.lastAction.createdAt))}</div>
</div>
<span class="tag ${escapeHtml(appState.lastAction.tone || "blue")}">${escapeHtml(appState.lastAction.title)}</span>
</div>
<div class="task-item">
<h4>${escapeHtml(appState.lastAction.title)}</h4>
<p>${escapeHtml(appState.lastAction.summary)}</p>
</div>
</div>
`;
}
function renderLastJobDetailCard() {
const detail = appState.lastJobDetail;
if (!detail?.job) return "";
const previewLinks = getJobPreviewLinks(detail.job);
return `
<div class="panel pad">
<div class="panel-head">
<div>
<h3>最近任务详情</h3>
<div class="panel-subtitle">${escapeHtml(formatDateTime(detail.job.created_at))}</div>
</div>
<span class="tag ${statusTone(detail.job.status)}">${escapeHtml(detail.job.status || "-")}</span>
</div>
<div class="task-item">
<h4>${escapeHtml(detail.job.title || detail.job.id)}</h4>
<p>${escapeHtml(brief(detail.job.style_summary || detail.job.transcript_text || detail.job.error || "暂无摘要", 120))}</p>
<div class="task-meta">
<span class="tag">${escapeHtml(detail.job.line_type || "-")}</span>
${detail.job.status === "completed" ? actionTag("写复盘", "open-review-from-job", `data-job-id="${escapeHtml(detail.job.id)}"`) : ""}
${canDeriveAiVideo(detail.job) ? renderPipelineJobTag("aiVideo", detail.job, "做 AI 视频") : ""}
${canDeriveRealCut(detail.job) ? renderPipelineJobTag("realCut", detail.job, "做实拍剪辑") : ""}
${actionTag("看详情", "open-job-detail", `data-job-id="${escapeHtml(detail.job.id)}"`)}
</div>
</div>
${previewLinks.length ? `
<div class="list">
${previewLinks.slice(0, 3).map((item) => `
<div class="task-item compact">
<h4>${escapeHtml(item.label.replace(/^result\./, "").replace(/^artifacts\./, ""))}</h4>
<p>${escapeHtml(item.url)}</p>
</div>
`).join("")}
</div>
` : ""}
</div>
`;
}
function requireSelectedProject() {
const project = getSelectedProject();
if (!project) throw new Error("请先创建项目");
return project;
}
function requireSelectedAssistant() {
const assistant = getSelectedAssistant();
if (!assistant) throw new Error("请先创建 Agent");
return assistant;
}
function requireSelectedAccountRow() {
const account = getSelectedAccount();
if (!account) throw new Error("请先在“找对标”里选中一个账号");
return account;
}
function openImportHomepageAction() {
const project = requireSelectedProject();
const kb = getProjectKnowledgeBases(project.id)[0];
const assistants = getAssistantOptions(project.id);
openActionModal({
title: "导入主页并同步",
description: "适合抖音 / 小红书 / B站 / 快手 / 视频号主页。先建内容源,再触发同步与分析。",
submitLabel: "开始同步",
fields: [
{ name: "projectId", label: "归属项目", type: "select", value: project.id, options: getProjectOptions() },
{ name: "platform", label: "平台", type: "select", value: "douyin", options: getPlatformOptions() },
{ name: "title", label: "标题", placeholder: "例如:创业口播对标账号" },
{ name: "handle", label: "账号名 / handle", placeholder: "可选" },
{ name: "sourceUrl", label: "主页链接", type: "url", placeholder: "https://..." },
{ name: "assistantId", label: "绑定 Agent", type: "select", value: assistants[0]?.value || "", options: [{ value: "", label: "暂不绑定" }, ...assistants] },
{ name: "maxItems", label: "最多同步作品数", type: "number", value: 5, min: 1, max: 20 }
],
onSubmit: async (values) => {
if (!values.sourceUrl?.trim()) throw new Error("请填写主页链接");
const projectId = values.projectId || project.id;
const platform = normalizePlatformValue(values.platform, "douyin");
const source = await storyforgeFetch("/v2/content-sources", {
method: "POST",
body: {
project_id: projectId,
source_kind: "creator_account",
platform,
handle: values.handle || "",
source_url: values.sourceUrl.trim(),
title: values.title || values.handle || "主页对标",
metadata: {}
}
});
const job = await storyforgeFetch("/v2/pipelines/content-source-sync", {
method: "POST",
body: {
project_id: projectId,
knowledge_base_id: getProjectKnowledgeBases(projectId)[0]?.id || kb?.id || "",
assistant_id: values.assistantId || "",
content_source_id: source.id,
platform,
handle: values.handle || "",
source_url: values.sourceUrl.trim(),
title: values.title || values.handle || "主页对标",
max_items: Number(values.maxItems || 5),
skip_existing: true,
auto_trigger_analysis: true
}
});
rememberAction("主页同步已启动", `已把主页加入项目,并创建同步任务 ${job.title || job.id}`, "blue", job);
await bootstrap();
}
});
}
function openImportSelectedAccountAction() {
const account = requireSelectedAccountRow();
const platform = getAccountPlatform(account);
const project = requireSelectedProject();
const assistants = getAssistantOptions(project.id);
const currentSources = getCurrentProjectSourcesForAccount(account, project.id);
const currentSource = currentSources[0];
const kb = getProjectKnowledgeBases(project.id)[0];
openActionModal({
title: currentSource ? "继续同步当前对标" : "导入当前对标",
description: currentSource
? "当前项目里已经有这个对标账号,继续触发同步并可切换绑定 Agent。"
: "把当前选中的对标账号加入项目,并绑定 Agent 进入持续同步。",
submitLabel: currentSource ? "继续同步" : "导入并同步",
fields: [
{ name: "projectId", label: "归属项目", type: "select", value: project.id, options: getProjectOptions() },
{ name: "platform", label: "平台", type: "select", value: normalizePlatformValue(currentSource?.platform || platform), options: getPlatformOptions() },
{ name: "title", label: "内容源标题", value: currentSource?.title || `${getAccountName(account)} 对标主页` },
{ name: "handle", label: "账号标识", value: currentSource?.handle || getAccountHandle(account) || "" },
{ name: "sourceUrl", label: "主页链接", type: "url", value: currentSource?.source_url || getAccountProfileUrl(account) || "", placeholder: "https://..." },
{ name: "assistantId", label: "绑定 Agent", type: "select", value: getSelectedAssistant()?.id || assistants[0]?.value || "", options: [{ value: "", label: "暂不绑定" }, ...assistants] },
{ name: "maxItems", label: "最多同步作品数", type: "number", value: Number(currentSource?.metadata?.max_items || 6), min: 1, max: 20 },
{ name: "skipExisting", label: "跳过已存在作品", type: "checkbox", value: true },
{ name: "autoAnalyze", label: "同步后自动分析", type: "checkbox", value: true }
],
onSubmit: async (values) => {
if (!values.sourceUrl?.trim()) throw new Error("请先填写主页链接");
const projectId = values.projectId || project.id;
const platform = normalizePlatformValue(values.platform, "douyin");
const source = currentSource && currentSource.project_id === projectId
? currentSource
: await storyforgeFetch("/v2/content-sources", {
method: "POST",
body: {
project_id: projectId,
source_kind: "creator_account",
platform,
handle: values.handle || "",
source_url: values.sourceUrl.trim(),
title: values.title || values.handle || getAccountName(account) || "对标主页",
metadata: {
imported_from_account_id: account.id,
imported_from_workspace: "discovery"
}
}
});
const job = await storyforgeFetch("/v2/pipelines/content-source-sync", {
method: "POST",
body: {
project_id: projectId,
knowledge_base_id: getProjectKnowledgeBases(projectId)[0]?.id || kb?.id || "",
assistant_id: values.assistantId || "",
content_source_id: source.id,
platform,
handle: values.handle || getAccountHandle(account) || "",
source_url: values.sourceUrl.trim(),
title: values.title || getAccountName(account) || values.handle || "对标主页",
max_items: Number(values.maxItems || 6),
skip_existing: Boolean(values.skipExisting),
auto_trigger_analysis: Boolean(values.autoAnalyze)
}
});
rememberAction("对标已接入项目", `已把「${getAccountName(account) || "当前对标"}」接入项目,并创建同步任务 ${job.title || job.id}`, "green", { source, job });
await bootstrap();
}
});
}
function openTrackSelectedAccountAction() {
const account = requireSelectedAccountRow();
const platform = getAccountPlatform(account);
const trackingAccountsPath = getWorkbenchRoute(platform, "trackingAccounts");
if (!trackingAccountsPath) {
rememberAction("当前平台待接入", getPendingWorkbenchReason(platform), "orange");
renderAll();
return;
}
const project = requireSelectedProject();
const assistants = getAssistantOptions(project.id);
const trackedItem = safeArray(appState.trackingAccounts).find((item) => item.tracked_account_id === account.id);
openActionModal({
title: trackedItem ? "更新跟踪账号" : "加入跟踪",
description: trackedItem
? "这个账号已经在跟踪中,可以切换负责 Agent 或补充备注。"
: "把当前对标账号加入每日跟踪,后续自动生成更新日报。",
submitLabel: trackedItem ? "保存跟踪" : "开始跟踪",
fields: [
{ name: "accountName", label: "账号", type: "html", html: `<div class="sheet-html"><strong>${escapeHtml(getAccountName(account) || "未命名账号")}</strong><p>${escapeHtml(getAccountProfileUrl(account) || account.signature || "")}</p></div>` },
{ name: "assistantId", label: "负责 Agent", type: "select", value: trackedItem?.assistant_id || getSelectedAssistant()?.id || assistants[0]?.value || "", options: [{ value: "", label: "先不绑定" }, ...assistants] },
{ name: "note", label: "跟踪备注", value: trackedItem?.note || "", placeholder: "例如:重点观察开头结构、成交句式和更新频率" }
],
onSubmit: async (values) => {
await storyforgeFetch(trackingAccountsPath, {
method: "POST",
body: {
tracked_account_id: account.id,
assistant_id: values.assistantId || "",
note: values.note || ""
}
});
rememberAction(trackedItem ? "跟踪已更新" : "已加入跟踪", `账号「${getAccountName(account) || "当前对标"}」现在会进入更新日报。`, "green");
await bootstrap();
}
});
}
function openImportVideoLinkAction() {
const project = requireSelectedProject();
const assistants = getAssistantOptions(project.id);
openActionModal({
title: "导入作品链接",
description: "直接把单条视频链接送进分析链。",
submitLabel: "开始分析",
fields: [
{ name: "projectId", label: "归属项目", type: "select", value: project.id, options: getProjectOptions() },
{ name: "title", label: "标题", placeholder: "可选,不填则使用默认标题" },
{ name: "videoUrl", label: "作品链接", type: "url", placeholder: "https://..." },
{ name: "assistantId", label: "绑定 Agent", type: "select", value: assistants[0]?.value || "", options: [{ value: "", label: "暂不绑定" }, ...assistants] },
{ name: "language", label: "语言", type: "select", value: "auto", options: [{ value: "auto", label: "自动" }, { value: "zh-CN", label: "中文" }] }
],
onSubmit: async (values) => {
if (!values.videoUrl?.trim()) throw new Error("请填写作品链接");
const projectId = values.projectId || project.id;
const job = await storyforgeFetch("/v2/explore/video-link", {
method: "POST",
body: {
video_url: values.videoUrl.trim(),
title: values.title || "",
project_id: projectId,
knowledge_base_id: getProjectKnowledgeBases(projectId)[0]?.id || "",
assistant_id: values.assistantId || "",
language: values.language || "auto"
}
});
rememberAction("作品分析已启动", `已创建分析任务 ${job.title || job.id}`, "blue", job);
await bootstrap();
}
});
}
function openImportTextAction() {
const project = requireSelectedProject();
const assistants = getAssistantOptions(project.id);
openActionModal({
title: "导入文本素材",
description: "把口播稿、拆解稿或灵感文本直接送进知识与分析链。",
submitLabel: "开始分析",
fields: [
{ name: "projectId", label: "归属项目", type: "select", value: project.id, options: getProjectOptions() },
{ name: "title", label: "标题", placeholder: "例如:创业口播拆解" },
{ name: "content", label: "正文", type: "textarea", rows: 8, placeholder: "粘贴需要分析的文本" },
{ name: "assistantId", label: "绑定 Agent", type: "select", value: assistants[0]?.value || "", options: [{ value: "", label: "暂不绑定" }, ...assistants] }
],
onSubmit: async (values) => {
if (!values.title?.trim()) throw new Error("请填写标题");
if (!values.content?.trim()) throw new Error("请填写正文");
const projectId = values.projectId || project.id;
const job = await storyforgeFetch("/v2/explore/text", {
method: "POST",
body: {
title: values.title.trim(),
content: values.content.trim(),
project_id: projectId,
knowledge_base_id: getProjectKnowledgeBases(projectId)[0]?.id || "",
assistant_id: values.assistantId || ""
}
});
rememberAction("文本分析已启动", `已创建文本分析任务 ${job.title || job.id}`, "blue", job);
await bootstrap();
}
});
}
function openUploadVideoAction() {
const project = requireSelectedProject();
const assistants = getAssistantOptions(project.id);
openActionModal({
title: "上传本地视频",
description: "上传本地素材,直接进入分析链。",
submitLabel: "上传并分析",
fields: [
{ name: "projectId", label: "归属项目", type: "select", value: project.id, options: getProjectOptions() },
{ name: "title", label: "标题", placeholder: "可选,不填则用文件名" },
{ name: "assistantId", label: "绑定 Agent", type: "select", value: assistants[0]?.value || "", options: [{ value: "", label: "暂不绑定" }, ...assistants] },
{ name: "file", label: "本地视频", type: "file", accept: ".mp4,.mov,.m4v,.avi,.mkv,.webm" }
],
onSubmit: async (values) => {
if (!values.file) throw new Error("请先选择本地视频");
const projectId = values.projectId || project.id;
const form = new FormData();
form.append("file", values.file);
form.append("title", values.title || "");
form.append("project_id", projectId);
form.append("knowledge_base_id", getProjectKnowledgeBases(projectId)[0]?.id || "");
form.append("assistant_id", values.assistantId || "");
const job = await storyforgeFetch("/v2/explore/upload-video", {
method: "POST",
body: form
});
rememberAction("上传分析已启动", `已上传素材并创建任务 ${job.title || job.id}`, "blue", job);
await bootstrap();
}
});
}
function openCreateAssistantAction() {
const project = requireSelectedProject();
const kbOptions = getKnowledgeBaseOptions(project.id);
const modelOptions = getModelOptions();
openActionModal({
title: "创建 Agent",
description: "先定义用途、平台与目标,再让 Agent 学习内容。",
submitLabel: "创建 Agent",
fields: [
{ name: "projectId", label: "归属项目", type: "select", value: project.id, options: getProjectOptions() },
{ name: "name", label: "名称", placeholder: "例如:创业成交助手" },
{ name: "description", label: "说明", placeholder: "例如:服务创业 IP 与成交型短视频" },
{ name: "goal", label: "生成目标", placeholder: "例如:输出创业口播、对标拆解和成交文案" },
{ name: "systemPrompt", label: "系统提示词", type: "textarea", rows: 5, placeholder: "可选,不填则后续再补" },
{ name: "knowledgeBaseId", label: "默认知识库", type: "select", value: kbOptions[0]?.value || "", options: [{ value: "", label: "暂不绑定" }, ...kbOptions] },
{ name: "modelProfileId", label: "主模型", type: "select", value: modelOptions.find((item) => item.value === safeArray(appState.dashboard?.model_profiles).find((m) => m.is_default)?.id)?.value || modelOptions[0]?.value || "", options: modelOptions }
],
onSubmit: async (values) => {
if (!values.name?.trim()) throw new Error("请填写 Agent 名称");
const projectId = values.projectId || project.id;
const assistant = await storyforgeFetch("/v2/assistants", {
method: "POST",
body: {
project_id: projectId,
name: values.name.trim(),
description: values.description || "",
generation_goal: values.goal || "",
system_prompt: values.systemPrompt || "",
knowledge_base_ids: values.knowledgeBaseId ? [values.knowledgeBaseId] : [],
model_profile_id: values.modelProfileId || ""
}
});
appState.selectedAssistantId = assistant.id;
rememberAction("Agent 已创建", `已创建 Agent「${assistant.name}」。`, "green", assistant);
await bootstrap();
}
});
}
function openEditAssistantAction(assistantId = "") {
const assistant = safeArray(appState.dashboard?.assistants).find((item) => item.id === assistantId) || getSelectedAssistant();
if (!assistant) {
alert("请先选择一个 Agent");
return;
}
const modelOptions = getModelOptions();
openActionModal({
title: "编辑 Agent",
description: "更新当前 Agent 的名称、目标和主模型,不会影响已完成任务。",
submitLabel: "保存 Agent",
fields: [
{ name: "name", label: "名称", value: assistant.name || "", placeholder: "例如:创业成交助手" },
{ name: "description", label: "说明", value: assistant.description || "", placeholder: "例如:服务创业 IP 与成交型短视频" },
{ name: "goal", label: "生成目标", value: assistant.generation_goal || "", placeholder: "例如:输出创业口播、对标拆解和成交文案" },
{ name: "systemPrompt", label: "系统提示词", type: "textarea", rows: 5, value: assistant.system_prompt || "", placeholder: "可选,不填则后续再补" },
{ name: "modelProfileId", label: "主模型", type: "select", value: assistant.model_profile_id || modelOptions[0]?.value || "", options: modelOptions }
],
onSubmit: async (values) => {
if (!values.name?.trim()) throw new Error("请填写 Agent 名称");
const updated = await storyforgeFetch(`/v2/assistants/${encodeURIComponent(assistant.id)}`, {
method: "PATCH",
body: {
name: values.name.trim(),
description: values.description || "",
generation_goal: values.goal || "",
system_prompt: values.systemPrompt || "",
model_profile_id: values.modelProfileId || ""
}
});
appState.selectedAssistantId = updated.id;
rememberAction("Agent 已更新", `已更新 Agent「${updated.name}」。`, "green", updated);
await bootstrap();
}
});
}
function openAnalyzeSelectedAccountAction() {
const account = requireSelectedAccountRow();
const platform = getAccountPlatform(account);
const analyzePath = getWorkbenchRoute(platform, "analyzeAccount", account.id);
if (!analyzePath) {
rememberAction("当前平台待接入", getPendingWorkbenchReason(platform), "orange");
renderAll();
return;
}
openActionModal({
title: "分析当前对标账号",
description: "从商业化和内容运营角度重跑一次账号分析。",
submitLabel: "开始分析",
fields: [
{ name: "maxVideos", label: "纳入分析作品数", type: "number", value: 6, min: 3, max: 20 },
{ name: "extraFocus", label: "额外关注点", type: "textarea", rows: 4, placeholder: "例如:更关注商业化承接与私域转化" },
{ name: "autoAnalyzeTopVideos", label: "分析后自动补高分作品", type: "checkbox", value: true },
{ name: "topVideoCount", label: "高分作品分析数", type: "number", value: 4, min: 1, max: 10 }
],
onSubmit: async (values) => {
const result = await storyforgeFetch(analyzePath, {
method: "POST",
body: {
model_profile_ids: [],
linked_account_ids: [],
include_linked_accounts: true,
include_recent_similar_candidates: true,
max_videos: Number(values.maxVideos || 6),
extra_focus: values.extraFocus || "",
temperature: 0.35,
auto_analyze_top_videos: Boolean(values.autoAnalyzeTopVideos),
top_video_analysis_count: Number(values.topVideoCount || 4)
}
});
const summary = result?.suggestions?.[0]?.parsed_json?.executive_summary || result?.suggestions?.[0]?.suggestion_text || "已生成新的账号分析。";
rememberAction("对标账号分析完成", brief(summary, 120), "green", result);
await loadPlatformAccount(platform, account.id);
renderAll();
}
});
}
function openAnalyzeTopVideosAction() {
const account = requireSelectedAccountRow();
const platform = getAccountPlatform(account);
const analyzePath = getWorkbenchRoute(platform, "analyzeTopVideos", account.id);
if (!analyzePath || !backendSupports(`/v2/${platform}/accounts/{account_id}/videos/analyze-top`)) {
rememberAction("当前后端暂不支持", "这套 live collector 还没有接入高分作品批量分析。", "orange");
renderAll();
return;
}
openActionModal({
title: "分析高分作品",
description: "对当前对标账号的高分作品批量补分析。",
submitLabel: "开始分析",
fields: [
{ name: "topVideoCount", label: "分析作品数", type: "number", value: 5, min: 1, max: 12 },
{ name: "minScore", label: "最低分阈值", type: "number", value: 45, min: 0, max: 100 }
],
onSubmit: async (values) => {
const result = await storyforgeFetch(analyzePath, {
method: "POST",
body: {
model_profile_id: "",
top_video_count: Number(values.topVideoCount || 5),
min_score: Number(values.minScore || 45),
temperature: 0.25
}
});
rememberAction("高分作品分析完成", `已补分析 ${formatNumber(result.analyzed_count)} 条高分作品。`, "green", result);
await loadPlatformAccount(platform, account.id);
renderAll();
}
});
}
function openSimilaritySearchAction() {
const account = requireSelectedAccountRow();
const platform = getAccountPlatform(account);
const createPath = getWorkbenchRoute(platform, "similarSearches");
if (!createPath) {
rememberAction("当前平台待接入", getPendingWorkbenchReason(platform), "orange");
renderAll();
return;
}
openActionModal({
title: "查相似账号",
description: "让 Agent 基于当前账号画像找更多可借鉴对象。",
submitLabel: "开始查找",
fields: [
{ name: "maxCandidates", label: "最多候选数", type: "number", value: 8, min: 3, max: 20 },
{ name: "extraRequirements", label: "额外要求", type: "textarea", rows: 4, placeholder: "例如:优先找创业成交类、口播结构强的账号" }
],
onSubmit: async (values) => {
const created = await storyforgeFetch(createPath, {
method: "POST",
body: {
source_account_id: account.id,
candidate_urls: [],
seed_linked_accounts: true,
search_public_pages: true,
model_profile_id: "",
max_candidates: Number(values.maxCandidates || 8),
extra_requirements: values.extraRequirements || ""
}
});
const searchId = created.id || created.search_id;
const detailPath = searchId ? getWorkbenchRoute(platform, "similarSearchDetail", searchId) : "";
const detail = searchId
? await storyforgeFetch(detailPath)
: created;
appState.lastSimilaritySearch = detail;
rememberAction("相似账号已生成", `已生成 ${formatNumber(safeArray(detail.candidates).length)} 个候选账号。`, "green", detail);
await loadPlatformAccount(platform, account.id);
renderAll();
}
});
}
function openBenchmarkLinkAction(defaults = {}) {
const account = requireSelectedAccountRow();
const platform = getAccountPlatform(account);
const benchmarkPath = getWorkbenchRoute(platform, "benchmarkLinks", account.id);
if (!benchmarkPath) {
rememberAction("当前平台待接入", getPendingWorkbenchReason(platform), "orange");
renderAll();
return;
}
const options = safeArray(appState.accounts)
.filter((item) => item.id !== account.id)
.map((item) => ({ value: item.id, label: getAccountName(item) || item.id }));
const candidate = typeof defaults.candidateIndex === "number"
? safeArray(appState.lastSimilaritySearch?.candidates)[defaults.candidateIndex] || null
: null;
openActionModal({
title: "保存对标关系",
description: "把当前账号和另一个账号关联成对标关系,便于后续持续跟踪。",
submitLabel: "保存关系",
fields: [
{ name: "targetAccountId", label: "目标账号", type: "select", value: defaults.targetAccountId || candidate?.candidate_account_id || options[0]?.value || "", options: [{ value: "", label: "仅保存主页链接" }, ...options] },
{ name: "targetProfileUrl", label: "目标主页链接", type: "url", value: defaults.targetProfileUrl || candidate?.candidate_profile_url || "", placeholder: "没有本地账号时可直接保存主页链接" },
{ name: "relationType", label: "关系类型", type: "select", value: "benchmark", options: [
{ value: "benchmark", label: "对标" },
{ value: "learn", label: "学习" },
{ value: "watch", label: "跟踪" }
] },
{ name: "note", label: "备注", value: defaults.note || brief(candidate?.rationale_text || "", 120), placeholder: "例如:开场结构很强,适合持续跟踪" }
],
onSubmit: async (values) => {
if (!values.targetAccountId && !values.targetProfileUrl?.trim()) throw new Error("请先选择一个目标账号或填写主页链接");
const result = await storyforgeFetch(benchmarkPath, {
method: "POST",
body: {
target_account_ids: values.targetAccountId ? [values.targetAccountId] : [],
target_profile_urls: values.targetAccountId ? [] : [values.targetProfileUrl.trim()],
relation_type: values.relationType || "benchmark",
note: values.note || "",
search_id: appState.lastSimilaritySearch?.id || ""
}
});
if (candidate) {
markSavedCandidate(candidate, result.links);
} else if (appState.selectedWorkspace) {
appState.selectedWorkspace = {
...appState.selectedWorkspace,
linked_accounts: safeArray(result.links)
};
}
rememberAction("对标关系已保存", "当前账号的对标关系已更新。", "green");
renderAll();
}
});
}
function openJobDetailAction(jobId) {
if (!jobId) return;
setBusy(true, "正在加载任务详情...");
loadJobDetail(jobId)
.then(({ job, events, childJobs }) => {
const artifacts = JSON.stringify(job.artifacts || {}, null, 2);
const result = JSON.stringify(job.result || {}, null, 2);
const previewLinks = getJobPreviewLinks(job);
openActionModal({
title: job.title || "任务详情",
description: `状态:${job.status || "-"} · 类型:${job.line_type || job.source_type || "-"}`,
hideSubmit: true,
fields: [
{
type: "html",
label: "任务摘要",
html: `
<div class="detail-grid">
<div class="mini-card"><small>任务 ID</small><strong>${escapeHtml(job.id)}</strong></div>
<div class="mini-card"><small>状态</small><strong>${escapeHtml(job.status || "-")}</strong></div>
<div class="mini-card"><small>链路</small><strong>${escapeHtml(job.line_type || "-")}</strong></div>
<div class="mini-card"><small>创建时间</small><strong>${escapeHtml(formatDateTime(job.created_at))}</strong></div>
</div>
`
},
{
type: "html",
label: "事件时间线",
html: `
<div class="list">
${safeArray(events).slice(-6).map((event) => `
<div class="task-item compact">
<h4>${escapeHtml(event.event_type || "event")}</h4>
<p>${escapeHtml(brief(event.message || JSON.stringify(event.payload || {}), 120))}</p>
</div>
`).join("") || `<div class="task-item compact"><h4>暂无事件</h4><p>当前任务还没有可显示的事件。</p></div>`}
</div>
`
},
{
type: "html",
label: "结果预览",
html: `
<div class="list">
${previewLinks.map((item) => `
<div class="task-item compact">
<h4>${escapeHtml(item.label.replace(/^result\./, "").replace(/^artifacts\./, ""))}</h4>
<p>${escapeHtml(item.url)}</p>
<div class="task-meta">
<a class="tag" href="${escapeHtml(item.url)}" target="_blank" rel="noreferrer">打开结果</a>
</div>
</div>
`).join("") || `<div class="task-item compact"><h4>暂无外部结果链接</h4><p>当前先保留 artifacts 和 result 原始数据供查看。</p></div>`}
</div>
`
},
{
type: "html",
label: "下一步动作",
html: `
<div class="task-meta">
${canDeriveAiVideo(job) ? renderPipelineJobTag("aiVideo", job, "继续做 AI 视频") : ""}
${canDeriveRealCut(job) ? renderPipelineJobTag("realCut", job, "继续做实拍剪辑") : ""}
${actionTag("用摘要写文案", "job-to-generate-copy", `data-job-id="${escapeHtml(job.id)}"`)}
</div>
`
},
{
type: "html",
label: "子任务",
html: `
<div class="list">
${safeArray(childJobs).slice(0, 6).map((item) => `
<div class="task-item compact">
<h4>${escapeHtml(item.title || item.id)}</h4>
<p>${escapeHtml(brief(item.style_summary || item.transcript_text || item.error || "暂无摘要", 96))}</p>
<div class="task-meta">
<span class="tag ${statusTone(item.status)}">${escapeHtml(item.status || "-")}</span>
<span class="tag">${escapeHtml(item.line_type || "-")}</span>
<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>暂无子任务</h4><p>当前任务还没有派生出下一层任务。</p></div>`}
</div>
`
},
{ type: "textarea", name: "artifactsReadonly", label: "Artifacts", value: artifacts, rows: 8 },
{ type: "textarea", name: "resultReadonly", label: "Result", value: result, rows: 8 }
]
});
document.querySelector('[data-action-field="artifactsReadonly"]')?.setAttribute("readonly", "readonly");
document.querySelector('[data-action-field="resultReadonly"]')?.setAttribute("readonly", "readonly");
})
.catch((error) => {
alert("加载任务详情失败: " + error.message);
})
.finally(() => {
setBusy(false, "");
});
}
function openGenerateCopyAction(defaults = {}) {
const assistant = getSelectedAssistant() || requireSelectedAssistant();
const sourceJob = defaults.sourceJob || null;
openActionModal({
title: "生成文案",
description: "用当前 Agent 和知识库生成一版短视频文案。",
submitLabel: "开始生成",
fields: [
{ name: "brief", label: "创作需求", type: "textarea", rows: 5, value: defaults.brief || getJobSeedBrief(sourceJob), placeholder: "例如:给创业者写一条 60 字内的短视频开场文案" },
{ name: "platform", label: "平台", type: "select", value: normalizePlatformValue(defaults.platform || "douyin"), options: getPlatformOptions() },
{ name: "audience", label: "受众", value: "创业者" },
{ name: "extraRequirements", label: "额外要求", placeholder: "例如:强结论开头,结尾带 CTA" }
],
onSubmit: async (values) => {
if (!values.brief?.trim()) throw new Error("请填写创作需求");
const result = await storyforgeFetch(`/v2/assistants/${encodeURIComponent(assistant.id)}/generate`, {
method: "POST",
body: {
brief: values.brief.trim(),
platform: platformLabel(values.platform || "douyin"),
audience: values.audience || "创业者",
extra_requirements: values.extraRequirements || "",
knowledge_base_ids: safeArray(assistant.knowledge_base_ids)
}
});
appState.lastGeneratedCopy = {
assistantId: assistant.id,
assistantName: assistant.name,
prompt: values.brief.trim(),
content: extractGeneratedCopy(result),
usedDocuments: safeArray(result.used_documents).slice(0, 3)
};
rememberAction("文案生成完成", `已用 Agent「${assistant.name}」生成一版文案。`, "green", result);
renderAll();
}
});
}
function openCreateAiVideoAction(defaults = {}) {
const guard = getPipelineGuard("aiVideo");
if (!guard.enabled) {
alert(guard.reason);
return;
}
const project = requireSelectedProject();
const assistant = getSelectedAssistant();
const kb = getProjectKnowledgeBases(project.id)[0];
const sourceJob = defaults.sourceJob || null;
openActionModal({
title: "创建 AI 视频任务",
description: "输入 brief 后,直接触发 AI 视频链。",
submitLabel: "开始生产",
fields: [
{ name: "title", label: "任务标题", value: defaults.title || (sourceJob ? `${sourceJob.title} · AI 视频` : ""), placeholder: "例如:创业口播 AI 视频测试" },
{ name: "brief", label: "视频 brief", type: "textarea", rows: 5, value: defaults.brief || getJobSeedBrief(sourceJob), placeholder: "写明主题、风格、镜头和目标受众" },
{ name: "sourceJobId", label: "关联源任务", type: "select", value: defaults.sourceJobId || sourceJob?.id || "", options: [{ value: "", label: "不关联" }, ...getCompletedJobOptions()] },
{ name: "style", label: "风格", value: defaults.style || "realistic" },
{ name: "shots", label: "镜头数", type: "number", value: defaults.shots || 4, min: 1, max: 12 },
{ name: "duration", label: "单镜头秒数", type: "number", value: defaults.duration || 5, min: 3, max: 12 }
],
onSubmit: async (values) => {
if (!values.title?.trim()) throw new Error("请填写任务标题");
if (!values.brief?.trim()) throw new Error("请填写视频 brief");
const job = await storyforgeFetch("/v2/pipelines/ai-video", {
method: "POST",
body: {
project_id: project.id,
assistant_id: assistant?.id || "",
knowledge_base_id: kb?.id || "",
source_job_id: values.sourceJobId || "",
title: values.title.trim(),
brief: values.brief.trim(),
style: values.style || "realistic",
shots: Number(values.shots || 4),
duration: Number(values.duration || 5)
}
});
rememberAction("AI 视频任务已创建", `已创建任务 ${job.title || job.id}`, "blue", job);
await bootstrap();
}
});
}
function openCreateRealCutAction(defaults = {}) {
const guard = getPipelineGuard("realCut");
if (!guard.enabled) {
alert(guard.reason);
return;
}
const project = requireSelectedProject();
const sourceJob = defaults.sourceJob || null;
openActionModal({
title: "创建实拍剪辑任务",
description: "基于已完成的源任务,把素材发到 cutvideo。",
submitLabel: "开始剪辑",
fields: [
{ name: "title", label: "任务标题", value: defaults.title || (sourceJob ? `${sourceJob.title} · 实拍剪辑` : ""), placeholder: "例如:创业素材粗剪" },
{ name: "sourceJobId", label: "源任务", type: "select", value: defaults.sourceJobId || sourceJob?.id || getCompletedJobOptions()[0]?.value || "", options: getCompletedJobOptions() },
{ name: "targetDurationSec", label: "目标时长(秒)", type: "number", value: defaults.targetDurationSec || 60, min: 10, max: 300 },
{ name: "aspectRatio", label: "画幅", value: defaults.aspectRatio || "9:16" },
{ name: "objective", label: "目标", type: "textarea", rows: 4, value: defaults.objective || "", placeholder: "例如:保留高信息密度片段,输出适合短视频平台的粗剪结果" }
],
onSubmit: async (values) => {
if (!values.title?.trim()) throw new Error("请填写任务标题");
if (!values.sourceJobId) throw new Error("请先选择一个已完成的源任务");
const job = await storyforgeFetch("/v2/pipelines/real-cut", {
method: "POST",
body: {
project_id: project.id,
title: values.title.trim(),
source_job_id: values.sourceJobId,
target_duration_sec: Number(values.targetDurationSec || 60),
target_aspect_ratio: values.aspectRatio || "9:16",
objective: values.objective || "保留高信息密度片段,输出适合短视频平台的粗剪结果"
}
});
rememberAction("实拍剪辑任务已创建", `已创建任务 ${job.title || job.id}`, "blue", job);
await bootstrap();
}
});
}
function openReviewAction(defaults = {}) {
const project = requireSelectedProject();
const assistants = getAssistantOptions(project.id);
const sourceJob = defaults.sourceJob || null;
const existingReview = defaults.review || null;
const metrics = existingReview?.metrics || {};
openActionModal({
title: existingReview ? "编辑复盘" : "写复盘",
description: existingReview
? "补充表现数据、判断和下一步动作,持续迭代项目策略。"
: "把完成任务写成一条可追踪复盘,后续可按项目累计。",
submitLabel: existingReview ? "保存复盘" : "创建复盘",
fields: [
{ name: "title", label: "标题", value: existingReview?.title || defaults.title || sourceJob?.title || "", placeholder: "例如:创业口播 3 月 22 日复盘" },
{ name: "sourceJobId", label: "关联任务", type: "select", value: existingReview?.source_job_id || defaults.sourceJobId || sourceJob?.id || "", options: [{ value: "", label: "不关联任务" }, ...getCompletedJobOptions()] },
{ name: "assistantId", label: "负责 Agent", type: "select", value: existingReview?.assistant_id || getSelectedAssistant()?.id || assistants[0]?.value || "", options: [{ value: "", label: "先不绑定" }, ...assistants] },
{ name: "platform", label: "平台", type: "select", value: normalizePlatformValue(existingReview?.platform || defaults.platform || "douyin"), options: getPlatformOptions() },
{ name: "contentType", label: "内容类型", type: "select", value: existingReview?.content_type || "video", options: [
{ value: "video", label: "视频" },
{ value: "image_text", label: "图文" },
{ value: "live_clip", label: "直播切片" }
] },
{ name: "publishUrl", label: "发布链接", type: "url", value: existingReview?.publish_url || "", placeholder: "https://..." },
{ name: "publishedAt", label: "发布时间", value: existingReview?.published_at || "", placeholder: "2026-03-22T20:00:00+08:00" },
{ name: "playCount", label: "播放", type: "number", value: metrics.play_count || 0, min: 0 },
{ name: "likeCount", label: "点赞", type: "number", value: metrics.like_count || 0, min: 0 },
{ name: "commentCount", label: "评论", type: "number", value: metrics.comment_count || 0, min: 0 },
{ name: "shareCount", label: "分享", type: "number", value: metrics.share_count || 0, min: 0 },
{ name: "verdict", label: "结论", type: "select", value: existingReview?.verdict || "", options: [
{ value: "", label: "先不下结论" },
{ value: "worth_scaling", label: "值得放大" },
{ value: "needs_rework", label: "需要重做" },
{ value: "good_reference", label: "适合借鉴" },
{ value: "hold", label: "先观察" }
] },
{ name: "highlights", label: "亮点", type: "textarea", rows: 4, value: existingReview?.highlights || "", placeholder: "例如:开头 3 秒抓人、评论区问题很集中" },
{ name: "nextActions", label: "下一步", type: "textarea", rows: 4, value: existingReview?.next_actions || "", placeholder: "例如:保留结构,换一个细分人群再做一条" },
{ name: "notes", label: "备注", type: "textarea", rows: 4, value: existingReview?.notes || "", placeholder: "补充团队讨论、平台环境、发布时间段等信息" }
],
onSubmit: async (values) => {
if (!values.title?.trim()) throw new Error("请填写复盘标题");
const payload = {
project_id: project.id,
source_job_id: values.sourceJobId || "",
assistant_id: values.assistantId || "",
title: values.title.trim(),
platform: normalizePlatformValue(values.platform, "douyin"),
content_type: values.contentType || "video",
publish_url: values.publishUrl || "",
published_at: values.publishedAt || "",
metrics: {
play_count: Number(values.playCount || 0),
like_count: Number(values.likeCount || 0),
comment_count: Number(values.commentCount || 0),
share_count: Number(values.shareCount || 0)
},
verdict: values.verdict || "",
highlights: values.highlights || "",
next_actions: values.nextActions || "",
notes: values.notes || ""
};
const review = existingReview
? await storyforgeFetch(`/v2/reviews/${encodeURIComponent(existingReview.id)}`, {
method: "PATCH",
body: payload
})
: await storyforgeFetch("/v2/reviews", {
method: "POST",
body: payload
});
rememberAction(existingReview ? "复盘已更新" : "复盘已创建", `已保存「${review.title}」并回写到项目复盘。`, "green", review);
await bootstrap();
}
});
}
document.addEventListener("click", async (event) => {
const action = event.target.closest("[data-action]");
if (action) {
const name = action.dataset.action;
if (name === "open-auth") {
openAuthModal();
return;
}
if (name === "close-auth") {
closeAuthModal();
return;
}
if (name === "close-sheet") {
closeActionModal();
return;
}
if (name === "submit-sheet") {
await submitActionModal();
return;
}
if (name === "submit-auth") {
// Auth form submission is handled centrally so clicking the submit
// button and pressing Enter share the same code path.
return;
}
if (name === "show-disabled-reason") {
const reason = action.dataset.disabledReason || action.title || "当前动作暂不可用";
rememberAction("动作已拦截", reason, "orange");
renderAll();
alert(reason);
return;
}
if (name === "auth-refresh" || name === "refresh-data") {
await bootstrap();
return;
}
if (name === "refresh-tracking") {
await refreshTrackingAccountsAction();
return;
}
if (name === "mark-tracking-read") {
await markTrackingDigestRead();
rememberAction("日报已标记", "当前跟踪摘要已更新为已读,下次会从新的时间点继续汇总。", "green");
await bootstrap();
return;
}
if (name === "logout-session") {
await logoutSession();
return;
}
if (name === "goto-discovery") {
setScreen("discovery");
return;
}
if (name === "goto-playbook") {
setScreen("playbook");
return;
}
if (name === "goto-production") {
setScreen("production");
return;
}
if (name === "goto-review") {
setScreen("review");
return;
}
if (name === "open-import-homepage") {
openImportHomepageAction();
return;
}
if (name === "open-import-selected-account") {
openImportSelectedAccountAction();
return;
}
if (name === "open-track-selected-account") {
openTrackSelectedAccountAction();
return;
}
if (name === "refresh-tracked-account") {
await refreshTrackedAccountAction(action.dataset.trackedAccountId || "");
return;
}
if (name === "open-import-video-link") {
openImportVideoLinkAction();
return;
}
if (name === "open-import-text") {
openImportTextAction();
return;
}
if (name === "open-upload-video") {
openUploadVideoAction();
return;
}
if (name === "open-create-assistant") {
openCreateAssistantAction();
return;
}
if (name === "select-assistant") {
appState.selectedAssistantId = action.dataset.assistantId || "";
rememberAction("已切换当前 Agent", `当前默认 Agent 已更新为「${getSelectedAssistant()?.name || "未选择"}」。`, "green");
renderAll();
return;
}
if (name === "open-edit-assistant") {
openEditAssistantAction(action.dataset.assistantId || "");
return;
}
if (name === "analyze-selected-account") {
openAnalyzeSelectedAccountAction();
return;
}
if (name === "analyze-top-videos") {
openAnalyzeTopVideosAction();
return;
}
if (name === "open-generate-copy") {
openGenerateCopyAction();
return;
}
if (name === "open-ai-video") {
openCreateAiVideoAction();
return;
}
if (name === "open-real-cut") {
openCreateRealCutAction();
return;
}
if (name === "open-create-review") {
openReviewAction();
return;
}
if (name === "open-preferred-model") {
openPreferredModelAction();
return;
}
if (name === "open-review-from-job") {
const jobId = action.dataset.jobId || "";
const fromDashboard = safeArray(appState.dashboard?.recent_jobs).find((item) => item.id === jobId) || null;
const fromDetail = appState.lastJobDetail?.job?.id === jobId ? appState.lastJobDetail.job : null;
openReviewAction({
sourceJobId: jobId,
sourceJob: fromDetail || fromDashboard,
title: (fromDetail || fromDashboard)?.title || ""
});
return;
}
if (name === "open-review-edit") {
const review = getReviewById(action.dataset.reviewId || "");
if (!review) {
alert("复盘记录不存在,请先刷新页面");
return;
}
openReviewAction({ review });
return;
}
if (name === "open-similar-search") {
openSimilaritySearchAction();
return;
}
if (name === "open-benchmark-link") {
openBenchmarkLinkAction();
return;
}
if (name === "save-candidate-benchmark") {
setBusy(true, "正在保存对标关系...");
try {
await saveCandidateAsBenchmark(action.dataset.candidateIndex || "");
} catch (error) {
alert("保存候选失败: " + error.message);
} finally {
setBusy(false, "");
}
return;
}
if (name === "open-job-detail") {
openJobDetailAction(action.dataset.jobId || "");
return;
}
if (name === "job-to-ai-video") {
const jobId = action.dataset.jobId || "";
const detail = appState.lastJobDetail?.job?.id === jobId ? appState.lastJobDetail.job : null;
closeActionModal();
openCreateAiVideoAction({ sourceJobId: jobId, sourceJob: detail });
return;
}
if (name === "job-to-real-cut") {
const jobId = action.dataset.jobId || "";
const detail = appState.lastJobDetail?.job?.id === jobId ? appState.lastJobDetail.job : null;
closeActionModal();
openCreateRealCutAction({ sourceJobId: jobId, sourceJob: detail, objective: detail ? `基于任务「${detail.title}」保留高信息密度片段,输出适合短视频平台的粗剪结果。` : "" });
return;
}
if (name === "job-to-generate-copy") {
const jobId = action.dataset.jobId || "";
const detail = appState.lastJobDetail?.job?.id === jobId ? appState.lastJobDetail.job : null;
closeActionModal();
openGenerateCopyAction({ sourceJob: detail, brief: detail ? `基于任务「${detail.title}」的结果,生成一版可发布的短视频文案。参考摘要:${getJobSeedBrief(detail)}` : "" });
return;
}
if (name === "create-project") {
await createProject();
return;
}
if (name === "select-project") {
appState.selectedProjectId = action.dataset.projectId || "";
renderAll();
return;
}
if (name === "select-account") {
const accountId = action.dataset.accountId;
if (!accountId) return;
setBusy(true, "正在加载对标详情...");
try {
const account = safeArray(appState.accounts).find((item) => item.id === accountId) || null;
await loadPlatformAccount(getAccountPlatform(account), accountId);
renderAll();
} catch (error) {
alert("加载对标详情失败: " + error.message);
} finally {
setBusy(false, "");
}
return;
}
if (name === "scroll-selected") {
document.getElementById("selected-account-anchor")?.scrollIntoView({ behavior: "smooth", block: "start" });
return;
}
}
});
document.addEventListener("input", (event) => {
const target = event.target;
if (!(target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement)) return;
if (target.dataset.action === "discovery-query") {
appState.discoveryQuery = target.value.trim();
screenMap.discovery.innerHTML = renderDiscoveryScreen();
}
});
document.addEventListener("submit", async (event) => {
const form = event.target;
if (!(form instanceof HTMLFormElement) || form.dataset.role !== "auth-form") return;
event.preventDefault();
setBusy(true, "正在登录并加载...");
try {
await loginWithForm();
closeAuthModal();
await bootstrap();
} catch (error) {
const message = document.querySelector('[data-role="auth-message"]');
if (message) message.textContent = error.message;
} finally {
setBusy(false, "");
}
});
navButtons.forEach((button) => {
button.addEventListener("click", () => {
const next = button.dataset.screenTarget;
setScreen(next);
});
});
ensureAuthUi();
renderAll();
bootstrap();