3219 lines
147 KiB
JavaScript
3219 lines
147 KiB
JavaScript
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: "",
|
||
selectedProjectId: "",
|
||
selectedAssistantId: "",
|
||
lastSeenAt: Number(localStorage.getItem(STORAGE_KEY + ":lastSeenAt") || Date.now()),
|
||
trackingAccounts: [],
|
||
trackingDigest: null,
|
||
reviews: [],
|
||
integrationHealth: null,
|
||
busy: false,
|
||
message: "",
|
||
lastAction: null,
|
||
lastGeneratedCopy: null,
|
||
lastSimilaritySearch: null,
|
||
lastJobDetail: null
|
||
};
|
||
|
||
const INTEGRATION_ORDER = ["local_model", "cutvideo", "huobao", "n8n", "asr"];
|
||
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 escapeHtml(value) {
|
||
return String(value ?? "")
|
||
.replaceAll("&", "&")
|
||
.replaceAll("<", "<")
|
||
.replaceAll(">", ">")
|
||
.replaceAll('"', """)
|
||
.replaceAll("'", "'");
|
||
}
|
||
|
||
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 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.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.lastAction = null;
|
||
appState.lastGeneratedCopy = null;
|
||
appState.lastSimilaritySearch = null;
|
||
appState.lastJobDetail = null;
|
||
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 loadDouyinAccount(accountId) {
|
||
if (!accountId) return;
|
||
appState.selectedAccountId = accountId;
|
||
const [workspace, videos] = await Promise.all([
|
||
storyforgeFetch(`/v2/douyin/accounts/${encodeURIComponent(accountId)}/workspace`),
|
||
storyforgeFetch(`/v2/douyin/accounts/${encodeURIComponent(accountId)}/videos?limit=80`).catch(() => ({
|
||
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;
|
||
}
|
||
const [dashboard, contentSources, accounts, trackingAccountsPayload, reviews, integrationHealth] = await Promise.all([
|
||
storyforgeFetch("/v2/me/dashboard"),
|
||
storyforgeFetch("/v2/content-sources").catch(() => []),
|
||
storyforgeFetch("/v2/douyin/accounts").catch(() => []),
|
||
storyforgeFetch("/v2/douyin/tracking/accounts").catch(() => ({ items: [], cursor_last_seen_at: "" })),
|
||
storyforgeFetch("/v2/reviews").catch(() => []),
|
||
storyforgeFetch("/v2/integrations/health").catch(() => null)
|
||
]);
|
||
const trackingCursorLastSeenAt = trackingAccountsPayload?.cursor_last_seen_at || "";
|
||
if (trackingCursorLastSeenAt) {
|
||
setLastSeenAt(trackingCursorLastSeenAt);
|
||
}
|
||
const trackingSince = trackingCursorLastSeenAt || getTrackingSinceIso();
|
||
const trackingDigest = await storyforgeFetch(`/v2/douyin/tracking/digest?since=${encodeURIComponent(trackingSince)}&limit=24`).catch(() => ({
|
||
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.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) {
|
||
await loadDouyinAccount(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 nextSeenAt = new Date().toISOString();
|
||
await storyforgeFetch("/v2/douyin/tracking/cursor", {
|
||
method: "POST",
|
||
body: { last_seen_at: nextSeenAt }
|
||
});
|
||
setLastSeenAt(nextSeenAt);
|
||
}
|
||
|
||
async function refreshTrackingAccountsAction() {
|
||
setBusy(true, "正在同步跟踪账号...");
|
||
try {
|
||
const payload = await storyforgeFetch("/v2/douyin/tracking/refresh", {
|
||
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");
|
||
}
|
||
setBusy(true, "正在同步该跟踪账号...");
|
||
try {
|
||
const payload = await storyforgeFetch(`/v2/douyin/tracking/accounts/${encodeURIComponent(trackedAccountId)}/refresh`, {
|
||
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 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 profileUrl = String(account.profile_url || "").trim();
|
||
const douyinId = String(account.douyin_id || "").trim();
|
||
const nickname = String(account.nickname || "").trim();
|
||
return safeArray(appState.contentSources).filter((source) => {
|
||
const sourceUrl = String(source.source_url || "").trim();
|
||
const handle = String(source.handle || "").trim();
|
||
const title = String(source.title || "").trim();
|
||
return (
|
||
(profileUrl && sourceUrl === profileUrl) ||
|
||
(douyinId && handle === douyinId) ||
|
||
(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);
|
||
return items
|
||
.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);
|
||
return items
|
||
.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 || "")
|
||
};
|
||
}
|
||
|
||
function getIntegrationStatus(detail) {
|
||
if (!detail.available) {
|
||
return { tone: "blue", 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 (!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) => item.detail.available && !item.detail.reachable);
|
||
if (!blocked.length) {
|
||
return { enabled: true, reason: "", blocked: [] };
|
||
}
|
||
return {
|
||
enabled: false,
|
||
blocked,
|
||
reason: `${config.label}暂不可用:${blocked.map((item) => describeIntegrationFailure(item.key)).join(";")}`
|
||
};
|
||
}
|
||
|
||
function getIntegrationCards() {
|
||
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) {
|
||
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 = "探测失败,请检查服务进程和网络";
|
||
}
|
||
}
|
||
return {
|
||
key,
|
||
meta,
|
||
detail,
|
||
status,
|
||
note
|
||
};
|
||
});
|
||
}
|
||
|
||
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 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(`/v2/douyin/accounts/${encodeURIComponent(account.id)}/benchmark-links`, {
|
||
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>
|
||
<div class="integration-url">${escapeHtml(item.detail.url || item.detail.baseUrl || "未提供探测地址")}</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(account.nickname || account.douyin_id))}</div>
|
||
<div>
|
||
<div class="cell-title">${escapeHtml(account.nickname || "未命名账号")}</div>
|
||
<div class="cell-desc">${escapeHtml(account.signature || account.profile_url || "已同步抖音账号")}</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 [account.nickname, account.signature, account.profile_url, account.douyin_id, ...safeArray(account.tags), ...safeArray(account.keywords)]
|
||
.join(" ")
|
||
.toLowerCase()
|
||
.includes(query);
|
||
});
|
||
const selected = getSelectedAccount();
|
||
const reports = safeArray(appState.selectedWorkspace?.recent_reports);
|
||
const linkedAccounts = safeArray(appState.selectedWorkspace?.linked_accounts);
|
||
const videos = safeArray(appState.selectedVideos?.items);
|
||
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(
|
||
"找对标",
|
||
"这里已经接入真实抖音账号列表和单账号详情。",
|
||
`${button("导入主页", "open-import-homepage")} ${button("导入当前对标", "open-import-selected-account")} ${button(tracked ? "已在跟踪" : "加入跟踪", "open-track-selected-account")} ${button("账号分析", "analyze-selected-account")} ${button("高分分析", "analyze-top-videos")} ${button("查相似", "open-similar-search")} ${button("存对标", "open-benchmark-link", "primary")}`,
|
||
`
|
||
<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">平台:抖音</div>
|
||
<div class="filter">账号数:${escapeHtml(formatNumber(accounts.length))}</div>
|
||
<div class="filter">报告:${escapeHtml(formatNumber(reports.length))}</div>
|
||
<div class="filter">作品:${escapeHtml(formatNumber(videos.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(account.nickname || account.douyin_id))}</div>
|
||
<div>
|
||
<div class="cell-title">${escapeHtml(account.nickname || "未命名账号")}</div>
|
||
<div class="cell-desc">${escapeHtml(account.douyin_id || "未填抖音号")}</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(account.nickname || account.douyin_id))}</div>
|
||
<div>
|
||
<div class="cell-title">${escapeHtml(account.nickname || "未命名账号")}</div>
|
||
<div class="cell-desc">${escapeHtml(account.douyin_id || account.profile_url || "-")}</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(selected?.nickname || "未选中")}</span></div>
|
||
<div class="hero-card" style="padding:18px;">
|
||
<div class="entity-cell">
|
||
<div class="avatar-lg">${escapeHtml(initials(selected?.nickname || "SF"))}</div>
|
||
<div>
|
||
<h3>${escapeHtml(selected?.nickname || "还没有选中账号")}</h3>
|
||
<p>${escapeHtml(selected?.profile_url || 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("标签:" + 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(videos.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 trackedAccounts = safeArray(appState.trackingAccounts);
|
||
const digestItems = getTrackingDigestItems(12);
|
||
const cursorLabel = appState.lastSeenAt ? formatDateTime(appState.lastSeenAt) : "尚未记录";
|
||
return screenShell(
|
||
"跟踪账号",
|
||
"这里已经接上真实跟踪对象和按上次打开后的更新日报。",
|
||
`${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);
|
||
return screenShell(
|
||
"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="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">
|
||
<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>
|
||
</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("复盘未加载", "登录后这里会先用最近任务生成一版复盘入口。"));
|
||
}
|
||
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(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) {
|
||
const chips = ["全平台", "抖音", "小红书", "B站", "YouTube"];
|
||
platforms.innerHTML = 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 currentId = appState.me?.preferred_analysis_model_id
|
||
|| safeArray(appState.dashboard?.model_profiles).find((item) => item.is_default)?.id
|
||
|| models[0]?.value
|
||
|| "";
|
||
openActionModal({
|
||
title: "设置分析主模型",
|
||
description: "后续导入分析、市场调研和风格学习会优先使用这里设置的模型。",
|
||
submitLabel: "保存模型",
|
||
fields: [
|
||
{ 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站 / YouTube 主页。先建内容源,再触发同步与分析。",
|
||
submitLabel: "开始同步",
|
||
fields: [
|
||
{ name: "projectId", label: "归属项目", type: "select", value: project.id, options: getProjectOptions() },
|
||
{ name: "platform", label: "平台", type: "select", value: "douyin", options: [
|
||
{ value: "douyin", label: "抖音" },
|
||
{ value: "xiaohongshu", label: "小红书" },
|
||
{ value: "bilibili", label: "哔哩哔哩" },
|
||
{ value: "youtube", label: "YouTube" },
|
||
{ value: "kuaishou", label: "快手" },
|
||
{ value: "wechat_video", label: "微信视频号" }
|
||
] },
|
||
{ 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 source = await storyforgeFetch("/v2/content-sources", {
|
||
method: "POST",
|
||
body: {
|
||
project_id: projectId,
|
||
source_kind: "creator_account",
|
||
platform: values.platform || "douyin",
|
||
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: values.platform || "douyin",
|
||
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 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: "douyin", options: [
|
||
{ value: "douyin", label: "抖音" },
|
||
{ value: "xiaohongshu", label: "小红书" },
|
||
{ value: "bilibili", label: "哔哩哔哩" },
|
||
{ value: "youtube", label: "YouTube" },
|
||
{ value: "kuaishou", label: "快手" },
|
||
{ value: "wechat_video", label: "微信视频号" }
|
||
] },
|
||
{ name: "title", label: "内容源标题", value: currentSource?.title || `${account.nickname || account.douyin_id || "对标账号"} 对标主页` },
|
||
{ name: "handle", label: "账号标识", value: currentSource?.handle || account.douyin_id || "" },
|
||
{ name: "sourceUrl", label: "主页链接", type: "url", value: currentSource?.source_url || account.profile_url || "", 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 source = currentSource && currentSource.project_id === projectId
|
||
? currentSource
|
||
: await storyforgeFetch("/v2/content-sources", {
|
||
method: "POST",
|
||
body: {
|
||
project_id: projectId,
|
||
source_kind: "creator_account",
|
||
platform: values.platform || "douyin",
|
||
handle: values.handle || "",
|
||
source_url: values.sourceUrl.trim(),
|
||
title: values.title || values.handle || account.nickname || "对标主页",
|
||
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: values.platform || "douyin",
|
||
handle: values.handle || account.douyin_id || "",
|
||
source_url: values.sourceUrl.trim(),
|
||
title: values.title || account.nickname || values.handle || "对标主页",
|
||
max_items: Number(values.maxItems || 6),
|
||
skip_existing: Boolean(values.skipExisting),
|
||
auto_trigger_analysis: Boolean(values.autoAnalyze)
|
||
}
|
||
});
|
||
rememberAction("对标已接入项目", `已把「${account.nickname || account.douyin_id || "当前对标"}」接入项目,并创建同步任务 ${job.title || job.id}。`, "green", { source, job });
|
||
await bootstrap();
|
||
}
|
||
});
|
||
}
|
||
|
||
function openTrackSelectedAccountAction() {
|
||
const account = requireSelectedAccountRow();
|
||
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(account.nickname || account.douyin_id || "未命名账号")}</strong><p>${escapeHtml(account.profile_url || 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("/v2/douyin/tracking/accounts", {
|
||
method: "POST",
|
||
body: {
|
||
tracked_account_id: account.id,
|
||
assistant_id: values.assistantId || "",
|
||
note: values.note || ""
|
||
}
|
||
});
|
||
rememberAction(trackedItem ? "跟踪已更新" : "已加入跟踪", `账号「${account.nickname || account.douyin_id || "当前对标"}」现在会进入更新日报。`, "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 openAnalyzeSelectedAccountAction() {
|
||
const account = requireSelectedAccountRow();
|
||
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(`/v2/douyin/accounts/${encodeURIComponent(account.id)}/analysis`, {
|
||
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 loadDouyinAccount(account.id);
|
||
renderAll();
|
||
}
|
||
});
|
||
}
|
||
|
||
function openAnalyzeTopVideosAction() {
|
||
const account = requireSelectedAccountRow();
|
||
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(`/v2/douyin/accounts/${encodeURIComponent(account.id)}/videos/analyze-top`, {
|
||
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 loadDouyinAccount(account.id);
|
||
renderAll();
|
||
}
|
||
});
|
||
}
|
||
|
||
function openSimilaritySearchAction() {
|
||
const account = requireSelectedAccountRow();
|
||
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("/v2/douyin/similar-searches", {
|
||
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 detail = searchId
|
||
? await storyforgeFetch(`/v2/douyin/similar-searches/${encodeURIComponent(searchId)}`)
|
||
: created;
|
||
appState.lastSimilaritySearch = detail;
|
||
rememberAction("相似账号已生成", `已生成 ${formatNumber(safeArray(detail.candidates).length)} 个候选账号。`, "green", detail);
|
||
await loadDouyinAccount(account.id);
|
||
renderAll();
|
||
}
|
||
});
|
||
}
|
||
|
||
function openBenchmarkLinkAction(defaults = {}) {
|
||
const account = requireSelectedAccountRow();
|
||
const options = safeArray(appState.accounts)
|
||
.filter((item) => item.id !== account.id)
|
||
.map((item) => ({ value: item.id, label: item.nickname || item.douyin_id || 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(`/v2/douyin/accounts/${encodeURIComponent(account.id)}/benchmark-links`, {
|
||
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: "抖音", options: [
|
||
{ value: "抖音", label: "抖音" },
|
||
{ value: "小红书", label: "小红书" },
|
||
{ value: "哔哩哔哩", label: "哔哩哔哩" },
|
||
{ value: "YouTube", label: "YouTube" }
|
||
] },
|
||
{ 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: values.platform || "抖音",
|
||
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: existingReview?.platform || defaults.platform || "douyin", options: [
|
||
{ value: "douyin", label: "抖音" },
|
||
{ value: "xiaohongshu", label: "小红书" },
|
||
{ value: "bilibili", label: "哔哩哔哩" },
|
||
{ value: "youtube", label: "YouTube" },
|
||
{ value: "kuaishou", label: "快手" },
|
||
{ value: "wechat_video", label: "微信视频号" }
|
||
] },
|
||
{ 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: 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 === "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 {
|
||
await loadDouyinAccount(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();
|