diff --git a/web/storyforge-web-v4/README.md b/web/storyforge-web-v4/README.md index 0faa8c7..21702fe 100644 --- a/web/storyforge-web-v4/README.md +++ b/web/storyforge-web-v4/README.md @@ -10,8 +10,8 @@ ## 当前定位 -- 这不是最终生产版,而是从 `Web V4` 高保真原型提升出来的真实前端骨架 -- 目录已经从 `output/ui/` 原型区独立出来,后续应直接在这里继续接真实接口 +- 这不是最终生产版,但已经不是纯静态原型 +- 目录已经从 `output/ui/` 原型区独立出来,并接上了第一层真实业务接口 - 当前保留的核心页面结构: - 项目总台 - 我的项目 @@ -23,8 +23,39 @@ - 发布与复盘 - 额度 +## 当前已接入的真实能力 + +- 后端登录与会话保持 +- 工作区信息与 `/v2/me` +- 项目总台 `/v2/me/dashboard` +- 项目创建 `/v2/projects` +- 内容源列表 `/v2/content-sources` +- 抖音对标账号 `/v2/douyin/accounts` +- 单账号工作台 `/v2/douyin/accounts/{id}/workspace` +- 单账号作品列表 `/v2/douyin/accounts/{id}/videos` +- 最近知识库文档 `/v2/knowledge-bases/{id}/documents` + +## 本地预览 + +推荐直接在目录内起一个临时静态服务: + +```bash +cd /Users/kris/code/StoryForge-gitea/web/storyforge-web-v4 +python3 -m http.server 3918 +``` + +然后打开: + +- [http://127.0.0.1:3918/index.html](http://127.0.0.1:3918/index.html) + +首次进入需要手动连接后端,默认地址是: + +- `http://127.0.0.1:8081` + ## 后续建议 -- 先补前端服务层,再接业务接口 +- 继续补动作型接口,例如导入、绑定 Agent、触发分析与生产 +- 把全局搜索和页内搜索合并成统一搜索体验 +- 为 `生产中心 / 发布与复盘` 接入更完整的任务与成片对象 - 不要把这套页面重新塞回 `scripts/douyin-browser-capture/control_panel.mjs` - 抖音采集控制台仍作为独立工具存在,这里才是正式业务应用壳 diff --git a/web/storyforge-web-v4/assets/app.js b/web/storyforge-web-v4/assets/app.js index c2fefee..f7d85a5 100644 --- a/web/storyforge-web-v4/assets/app.js +++ b/web/storyforge-web-v4/assets/app.js @@ -1,24 +1,1300 @@ -const navButtons = document.querySelectorAll("[data-screen-target]"); -const screens = document.querySelectorAll("[data-screen]"); +const STORAGE_KEY = "storyforge-web-v4-session"; +const DEFAULT_BACKEND_URL = "http://127.0.0.1:8081"; -function activateScreen(id) { +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: "", + lastSeenAt: Number(localStorage.getItem(STORAGE_KEY + ":lastSeenAt") || Date.now()), + busy: false, + message: "" +}; + +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 (["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 markSeenNow() { + appState.lastSeenAt = Date.now(); + localStorage.setItem(STORAGE_KEY + ":lastSeenAt", String(appState.lastSeenAt)); +} + +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 = ` + + + + `; + topbarRight.insertBefore(inline, topbarRight.firstChild); + } + + if (!document.querySelector(".auth-modal")) { + const modal = document.createElement("div"); + modal.className = "auth-modal-backdrop hidden"; + modal.innerHTML = ` +
+
+
+

连接 StoryForge

+

先登录后端,再加载项目、对标、Agent 和生产数据。

+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ `; + 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") + }; +} + +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.selectedWorkspace = null; + appState.selectedVideos = { items: [], meta: {}, top_scored_video_ids: [], latest_video_ids: [], high_score_threshold: 60 }; + appState.documents = []; + 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; +} + +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] = await Promise.all([ + storyforgeFetch("/v2/me/dashboard"), + storyforgeFetch("/v2/content-sources").catch(() => []), + storyforgeFetch("/v2/douyin/accounts").catch(() => []) + ]); + appState.dashboard = dashboard; + appState.contentSources = safeArray(contentSources); + appState.accounts = safeArray(accounts); + appState.documents = await loadKnowledgeDocuments(dashboard.knowledge_bases); + appState.selectedProjectId = appState.selectedProjectId || dashboard.projects?.[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 }; + } + markSeenNow(); + } catch (error) { + appState.message = error.message; + if (String(error.message || "").includes("401") || String(error.message || "").includes("Not authenticated")) { + persistSession(null); + } + } finally { + setBusy(false, ""); + renderAll(); + } +} + +function getSelectedProject() { + const projects = safeArray(appState.dashboard?.projects); + return projects.find((item) => item.id === appState.selectedProjectId) || projects[0] || null; +} + +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 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 || ""; +} + +function screenShell(title, subtitle, actionsHtml, bodyHtml) { + return ` +
+
+

${escapeHtml(title)}

+

${escapeHtml(subtitle)}

+
+
${actionsHtml || ""}
+
+ ${bodyHtml} + `; +} + +function button(label, action, tone = "secondary") { + return ``; +} + +function renderEmptyState(title, description) { + return `
${escapeHtml(title)}

${escapeHtml(description)}

`; +} + +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 actions = []; + if (!projects.length) actions.push("先新建一个项目"); + if (!assistants.length) actions.push("先创建第一个 Agent"); + if (!accounts.length) actions.push("先导入一个抖音主页或作品"); + if (jobs.some((item) => item.status !== "completed")) actions.push("处理进行中的生产任务"); + if (!actions.length) actions.push("继续补高分对标并安排生产"); + const digestItems = accounts + .flatMap((account) => safeArray(account.video_summary?.videos).slice(0, 1).map((video) => ({ account, video }))) + .slice(0, 3); + return screenShell( + "项目总台", + "先看项目状态、待办动作和高价值对标。", + `${button("新建项目", "create-project")} ${button("刷新", "refresh-data")} ${button("找对标", "goto-discovery", "primary")}`, + ` +
+
活跃项目${escapeHtml(formatNumber(projects.length))}
项目总数${escapeHtml(formatNumber(projects.filter((item) => item.description).length))} 个有说明
+
导入内容${escapeHtml(formatNumber(appState.contentSources.length))}
主页 / 作品 / 本地素材${escapeHtml(formatNumber(appState.contentSources.filter((item) => item.source_kind === "creator_account").length))} 个主页
+
跟踪账号${escapeHtml(formatNumber(accounts.length))}
可生成日报${escapeHtml(formatNumber(digestItems.length))} 条新摘要
+
Agent${escapeHtml(formatNumber(assistants.length))}
已创建${escapeHtml(formatNumber(assistants.filter((item) => !(item.model_profile_id || "")).length))} 个待补模型
+
生产任务${escapeHtml(formatNumber(jobs.length))}
最近 20 条${escapeHtml(formatNumber(jobs.filter((item) => item.status === "completed").length))} 条已完成
+
+
+
+
+

当前主流程

+

项目 → Agent → 调研 → 导入并绑定 → 生产 → 复盘

+
+ 我的项目 + 找对标 + 跟踪账号 + Agent + 生产中心 +
+
+
+

今日重点动作

按当前数据自动生成
${escapeHtml(formatNumber(actions.length))} 项
+
+ ${actions.map((item, index) => ` +
+

${index + 1}. ${escapeHtml(item)}

+

${escapeHtml(index === 0 ? "先把最影响主流程的动作做掉。" : "做完上一步再继续推进。")}

+
+ `).join("")} +
+
+
+

高分对标

优先看当前已同步账号
+
+ ${accounts.slice(0, 3).map((account) => ` +
+
+
${escapeHtml(initials(account.nickname || account.douyin_id))}
+
+
${escapeHtml(account.nickname || "未命名账号")}
+
${escapeHtml(account.signature || account.profile_url || "已同步抖音账号")}
+
+
+
+ 作品 ${escapeHtml(formatNumber(account.video_summary?.count))} + 均播 ${escapeHtml(formatNumber(account.video_summary?.avg_play))} + ${escapeHtml(account.sync_status || "synced")} +
+
+ `).join("") || `
先到“找对标”导入一个账号。
`} +
+
+
+
+
+

当前项目

+

${escapeHtml(getSelectedProject()?.name || "还没有项目")}

+
+
知识库${escapeHtml(formatNumber(getSelectedProject() ? getProjectStats(getSelectedProject().id).knowledgeBases.length : 0))}
+
Agent${escapeHtml(formatNumber(getSelectedProject() ? getProjectStats(getSelectedProject().id).assistants.length : 0))}
+
任务${escapeHtml(formatNumber(getSelectedProject() ? getProjectStats(getSelectedProject().id).jobs.length : 0))}
+
来源${escapeHtml(formatNumber(getSelectedProject() ? getProjectStats(getSelectedProject().id).sources.length : 0))}
+
+
+
+

跟踪摘要

按最近同步的账号作品生成
${escapeHtml(daysSince(appState.lastSeenAt))} 天汇总
+
+ ${digestItems.map(({ account, video }) => ` +
+

${escapeHtml(account.nickname || "未命名账号")} · ${escapeHtml(video.title || video.description || "最新作品")}

+

最近发布时间 ${escapeHtml(formatDateTime(video.published_at))},适合继续交给 Agent 做借鉴点标注。

+
抖音可学习
+
+ `).join("") || `

还没有日报

先同步一个抖音账号,日报才会开始累积。

`} +
+
+
+

最新异常

直接看需要处理的阻塞
+
+ ${jobs.filter((item) => item.status === "failed").slice(0, 3).map((job) => ` +
+

${escapeHtml(job.title)}

+

${escapeHtml(job.error || "任务失败,请重新进入生产中心查看。")}

+
失败${escapeHtml(job.line_type || "analysis")}
+
+ `).join("") || `

当前无异常

最近任务运行正常,继续推进导入和生产即可。

`} +
+
+
+
+ ` + ); +} + +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("刷新", "refresh-data")}`, + ` +
+

当前项目

+

${escapeHtml(selectedProject?.name || "还没有项目")} · ${escapeHtml(selectedProject?.description || "创建后即可承接对标、Agent 和生产任务。")}

+
+ ${projects.map((project) => `${escapeHtml(project.name)}`).join("") || `暂无项目`} +
+
+
+
+
+

项目状态

真实项目列表
+
+ ${projects.map((project) => { + const stats = getProjectStats(project.id); + return ` +
+
${escapeHtml(project.name)}
+
${escapeHtml(project.description || "未填写说明")}
+
+ 知识库 ${escapeHtml(formatNumber(stats.knowledgeBases.length))} + Agent ${escapeHtml(formatNumber(stats.assistants.length))} + 任务 ${escapeHtml(formatNumber(stats.jobs.length))} +
+
+ `; + }).join("") || `
当前还没有项目。
`} +
+
+
+
+
+

最近导入队列

按内容源展示
+
+ ${safeArray(appState.contentSources).slice(0, 6).map((source) => ` +
+

${escapeHtml(source.title || source.handle || source.source_url || source.id)}

+

${escapeHtml(source.platform || source.source_kind || "内容源")} · ${escapeHtml(source.source_url || source.local_path || "暂无链接")}

+
+ ${source.project_id === appState.selectedProjectId ? "当前项目" : "其他项目"} + ${escapeHtml(source.source_kind || "-")} +
+
+ `).join("") || `

还没有导入内容

先去“找对标”导入主页、作品或本地视频。

`} +
+
+
+
+ ` + ); +} + +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); + return screenShell( + "找对标", + "这里已经接入真实抖音账号列表和单账号详情。", + `${button("刷新", "refresh-data")} ${button("聚焦当前账号", "scroll-selected")} ${button("切到生产", "goto-production", "primary")}`, + ` +
+
+
+ +
+
平台:抖音
+
账号数:${escapeHtml(formatNumber(accounts.length))}
+
报告:${escapeHtml(formatNumber(reports.length))}
+
作品:${escapeHtml(formatNumber(videos.length))}
+
+
+
+
+ 真实接口 + 工作台详情 + 高分作品 + 绑定关系 +
+
+
+
+ + + + + + + + + + + + + ${accounts.map((account) => ` + + + + + + + + + `).join("") || ``} + +
账号签名作品数平均播放平均点赞动作
+
+
${escapeHtml(initials(account.nickname || account.douyin_id))}
+
+
${escapeHtml(account.nickname || "未命名账号")}
+
${escapeHtml(account.douyin_id || account.profile_url || "-")}
+
+
+
${escapeHtml(brief(account.signature || "暂无签名", 36))}${escapeHtml(formatNumber(account.video_summary?.count))}${escapeHtml(formatNumber(account.video_summary?.avg_play))}${escapeHtml(formatNumber(account.video_summary?.avg_like))}
查看
当前没有抖音账号数据。
+
+
+
+
+

当前选中对标

直接来自工作台接口
${escapeHtml(selected?.nickname || "未选中")}
+
+
+
${escapeHtml(initials(selected?.nickname || "SF"))}
+
+

${escapeHtml(selected?.nickname || "还没有选中账号")}

+

${escapeHtml(selected?.profile_url || selected?.signature || "左侧点一个账号,这里会展示详情。")}

+
+
+
+
作品数${escapeHtml(formatNumber(selected?.video_summary?.count))}
+
高分作品${escapeHtml(formatNumber(topVideos.length))}
+
报告数${escapeHtml(formatNumber(reports.length))}
+
已绑对标${escapeHtml(formatNumber(linkedAccounts.length))}
+
+
+
+
+

账号画像

+
    +
  • ${escapeHtml(selected?.signature || "暂无签名")}
  • +
  • ${escapeHtml("标签:" + safeArray(selected?.tags).slice(0, 4).join(" / ") || "暂无标签")}
  • +
  • ${escapeHtml("同步状态:" + (selected?.sync_status || "-"))}
  • +
+
+
+

高分作品

+
    + ${topVideos.map((video) => `
  • ${escapeHtml(describeVideo(video))}
  • `).join("") || "
  • 暂无高分作品
  • "} +
+
+
+

最近报告

+
    + ${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 `
  • ${escapeHtml(brief(summary, 48))}
  • `; + }).join("") || "
  • 暂无分析报告
  • "} +
+
+
+
+

最新作品

优先看近期更新与窗口期题材
${escapeHtml(formatNumber(videos.length))} 条
+
+ ${latestVideos.map((video) => ` +
+

${escapeHtml(describeVideo(video))}

+

发布时间 ${escapeHtml(formatDateTime(video.published_at))} · 播放 ${escapeHtml(formatNumber(video.stats?.play))} · 点赞 ${escapeHtml(formatNumber(video.stats?.like))}

+
+ ${escapeHtml(video.content_type || "video")} + 得分 ${escapeHtml(formatNumber(video.score?.performance_score || 0))} + ${getVideoLink(video) ? `打开原作品` : ""} +
+
+ `).join("") || `

还没有最近作品

当前账号只同步了基础信息,还没拉到完整作品列表。

`} +
+
+
+
+
+
+

相似对标 / 已绑关系

来自账号工作台
+
+ ${linkedAccounts.map((link) => ` +
+

${escapeHtml(link.target_nickname || link.target_profile_url || "未命名对标")}

+

${escapeHtml(link.note || link.target_profile_url || "已保存对标关系")}

+
${escapeHtml(link.relation_type || "benchmark")}
+
+ `).join("") || `

暂无已保存对标

当前账号还没有保存过对标关系。

`} +
+
+
+
+
+ ` + ); +} + +function renderTrackingScreen() { + if (!appState.dashboard) { + return screenShell("跟踪账号", "登录后才能生成真实日报。", `${button("连接后端", "open-auth", "primary")}`, renderEmptyState("日报未加载", "当前还没有可用的对标账号数据。")); + } + const accounts = safeArray(appState.accounts); + const digestItems = accounts.flatMap((account) => + safeArray(account.video_summary?.videos).slice(0, 1).map((video) => ({ account, video })) + ).slice(0, 6); + return screenShell( + "跟踪账号", + "当前先按上次打开后生成一份轻量日报摘要。", + `${button("刷新日报", "refresh-data")} ${button("跳到找对标", "goto-discovery", "primary")}`, + ` +
+

日报逻辑

+

按上次打开后汇总。上次打开距今 ${escapeHtml(daysSince(appState.lastSeenAt))} 天,本次摘要优先展示有作品更新的账号。

+
+ 按上次打开汇总 + Agent 标借鉴点 + 高价值内容可进学习集 +
+
+
+
+
+

跟踪列表

当前基于已同步抖音账号
${escapeHtml(formatNumber(accounts.length))} 个
+
+ ${accounts.map((account) => ` +
+

${escapeHtml(account.nickname || "未命名账号")}

+

最近作品 ${escapeHtml(formatNumber(account.video_summary?.count))} 条 · 平均播放 ${escapeHtml(formatNumber(account.video_summary?.avg_play))}

+
已同步${escapeHtml(account.sync_status || "synced")}
+
+ `).join("") || `

暂无跟踪账号

先去找对标导入一个主页。

`} +
+
+
+
+
+

更新日报

优先看最近更新的作品摘要
${escapeHtml(formatNumber(digestItems.length))} 条
+
+ ${digestItems.map(({ account, video }) => ` +
+

${escapeHtml(account.nickname || "账号")} · ${escapeHtml(video.title || video.description || "最新作品")}

+

发布时间 ${escapeHtml(formatDateTime(video.published_at))},建议交给当前项目 Agent 继续判断借鉴点。

+
抖音可学习
+
+ `).join("") || `

暂无日报

先同步一个账号,日报才会开始累积。

`} +
+
+
+
+ ` + ); +} + +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; + return screenShell( + "自动流程", + "自动同步、日报生成和失败补跑先统一看这里。", + `${button("刷新", "refresh-data")} ${button("去生产", "goto-production", "primary")}`, + ` +
+

自动流程

+

当前按真实任务量给出一版轻量看板,后续再接更完整的定时与重试配置。

+
+
分析任务${escapeHtml(formatNumber(analysisJobs))}
+
AI 视频${escapeHtml(formatNumber(aiVideoJobs))}
+
实拍剪辑${escapeHtml(formatNumber(realCutJobs))}
+
内容源${escapeHtml(formatNumber(appState.contentSources.length))}
+
+
+ ` + ); +} + +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")}`, + ` +
+
+
${escapeHtml(initials(me.display_name || me.username))}
+
+

${escapeHtml(me.display_name || me.username || "当前账号")}

+

${escapeHtml(firstAssistant?.generation_goal || "先创建 Agent,再把平台目标和变现方式补齐。")}

+
+
+
+
项目${escapeHtml(formatNumber(appState.dashboard.projects?.length))}
+
Agent${escapeHtml(formatNumber(appState.dashboard.assistants?.length))}
+
任务${escapeHtml(formatNumber(appState.dashboard.recent_jobs?.length))}
+
素材${escapeHtml(formatNumber(appState.documents.length))}
+
+
+ ` + ); +} + +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("刷新", "refresh-data")} ${button("去生产", "goto-production", "primary")}`, + ` +
+

Agent 概览

+

先定项目、平台和主模型,再导入内容让 Agent 学习。

+
+ ${models.slice(0, 6).map((model) => `${escapeHtml(model.name)}`).join("") || `暂无模型`} +
+
+
+
+

模型列表

来自真实 model_profiles
+
+ ${models.map((model) => ` +
+

${escapeHtml(model.name)}

+

${escapeHtml(model.model_name || "-")} · ${escapeHtml(model.base_url || "-")}

+
+ `).join("") || `

暂无模型

先在“我的”里配置模型。

`} +
+
+
+

Agent 列表

当前接的是后端 assistants
+
+ ${assistants.map((assistant) => ` +
+

${escapeHtml(assistant.name)}

+

${escapeHtml(assistant.description || assistant.generation_goal || "暂无说明")}

+
+ 知识库 ${escapeHtml(formatNumber(safeArray(assistant.knowledge_base_ids).length))} + ${escapeHtml(models.find((item) => item.id === assistant.model_profile_id)?.name || "默认模型")} +
+
+ `).join("") || `

还没有 Agent

下一步可以直接把创建动作接进来。

`} +
+
+
+

最近学习素材

从知识库文档取最近内容
+
+ ${appState.documents.slice(0, 4).map((doc) => ` +
+

${escapeHtml(doc.title)}

+

${escapeHtml(brief(doc.style_summary || doc.transcript_text || doc.combined_text, 72))}

+
+ `).join("") || `

还没有学习素材

先去找对标导入一条主页或作品。

`} +
+
+
+ ` + ); +} + +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( + "生产中心", + "这里已经接上真实任务和知识库文档,后续再继续补任务创建动作。", + `${button("刷新", "refresh-data")} ${button("看对标", "goto-discovery")} ${button("去复盘", "goto-review", "primary")}`, + ` +
+

生产队列

最近任务的真实状态
+
+

分析任务

最近 ${escapeHtml(formatNumber(jobs.filter((item) => item.line_type === "analysis").length))} 条

+

实拍剪辑

最近 ${escapeHtml(formatNumber(jobs.filter((item) => item.line_type === "real_cut").length))} 条

+

AI 视频

最近 ${escapeHtml(formatNumber(jobs.filter((item) => item.line_type === "ai_video").length))} 条

+

内容源同步

最近 ${escapeHtml(formatNumber(jobs.filter((item) => item.line_type === "content_source_sync").length))} 条

+
+
+
+
+
+

当前任务

来自 recent_jobs
+
+ ${(activeJobs.length ? activeJobs : jobs.slice(0, 4)).map((job) => ` +
+

${escapeHtml(job.title)}

+

${escapeHtml(brief(job.style_summary || job.transcript_text || job.error || "暂无摘要", 80))}

+
+ ${escapeHtml(job.status)} + ${escapeHtml(job.line_type || "analysis")} +
+
+ `).join("") || `

还没有任务

先去找对标导入内容。

`} +
+
+
+
+
+

作品与成片

先看真实作品,再补文档与成片
+
+ ${works.map((video) => ` +
+

${escapeHtml(describeVideo(video))}

+

${escapeHtml(`发布时间 ${formatDateTime(video.published_at)} · 播放 ${formatNumber(video.stats?.play)} · 点赞 ${formatNumber(video.stats?.like)}`)}

+
+ ${escapeHtml(video.content_type || "video")} + 得分 ${escapeHtml(formatNumber(video.score?.performance_score || 0))} + ${getVideoLink(video) ? `打开原作品` : ""} +
+
+ `).join("")} + ${recentDocs.map((doc) => ` +
+

${escapeHtml(doc.title)}

+

${escapeHtml(brief(doc.style_summary || doc.combined_text || doc.transcript_text, 92))}

+
${escapeHtml(doc.source_type || "document")}学习素材
+
+ `).join("") || (works.length ? "" : `

还没有作品

先导入内容或跑一次分析任务。

`)} +
+
+
+
+ ` + ); +} + +function renderReviewScreen() { + if (!appState.dashboard) { + return screenShell("发布与复盘", "先连接后端。", `${button("连接后端", "open-auth", "primary")}`, renderEmptyState("复盘未加载", "登录后这里会先用最近任务生成一版复盘入口。")); + } + const completed = safeArray(appState.dashboard.recent_jobs).filter((item) => item.status === "completed").slice(0, 4); + return screenShell( + "发布与复盘", + "当前先用最近完成任务承接一版复盘视图。", + `${button("刷新", "refresh-data")} ${button("去生产", "goto-production", "primary")}`, + ` +
+

最近完成

后续再接真实发布记录
+
+ ${completed.map((job) => ` +
+

${escapeHtml(job.title)}

+

${escapeHtml(brief(job.style_summary || job.transcript_text || "已完成,待补复盘。", 84))}

+
已完成${escapeHtml(job.line_type || "analysis")}
+
+ `).join("") || `

还没有完成任务

先去生产中心跑一条链路。

`} +
+
+ ` + ); +} + +function renderCreditsScreen() { + if (!appState.dashboard) { + return screenShell("额度", "先连接后端。", `${button("连接后端", "open-auth", "primary")}`, renderEmptyState("额度未加载", "后续接真实计费前,先用任务量做运营看板。")); + } + const jobs = safeArray(appState.dashboard.recent_jobs); + return screenShell( + "额度", + "在接真实计费前,先按任务量给出运营看板。", + `${button("刷新", "refresh-data")}`, + ` +
+
文案消耗预估${escapeHtml(formatNumber(jobs.filter((item) => item.line_type === "analysis").length))}
分析 / 生成链路按任务数估算
+
封面消耗预估0
封面链待接暂未真实计费
+
视频消耗预估${escapeHtml(formatNumber(jobs.filter((item) => item.line_type === "ai_video" || item.line_type === "real_cut").length))}
AI 视频 / 实拍剪辑可做套餐
+
+ ` + ); +} + +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) => `${escapeHtml(label)}`).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, ""); + } +} + +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 === "submit-auth") { + 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, ""); + } + return; + } + if (name === "auth-refresh" || name === "refresh-data") { + 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 === "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(); + } +}); + navButtons.forEach((button) => { button.addEventListener("click", () => { const next = button.dataset.screenTarget; - activateScreen(next); - window.location.hash = next; + setScreen(next); }); }); -const initial = window.location.hash.replace("#", "") || "dashboard"; -activateScreen(initial); +ensureAuthUi(); +renderAll(); +bootstrap(); diff --git a/web/storyforge-web-v4/assets/styles.css b/web/storyforge-web-v4/assets/styles.css index 1ee93af..244d301 100644 --- a/web/storyforge-web-v4/assets/styles.css +++ b/web/storyforge-web-v4/assets/styles.css @@ -269,6 +269,114 @@ select { font-weight: 700; } +.auth-inline { + display: flex; + align-items: center; + gap: 10px; +} + +.auth-status { + max-width: 240px; + color: var(--muted); + font-size: 12px; + line-height: 1.4; +} + +.auth-modal-backdrop { + position: fixed; + inset: 0; + background: rgba(15, 28, 45, 0.28); + backdrop-filter: blur(6px); + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + z-index: 40; +} + +.auth-modal-backdrop.hidden { + display: none; +} + +.auth-modal { + width: min(560px, 100%); + border-radius: 24px; + border: 1px solid var(--line-strong); + background: rgba(255, 255, 255, 0.98); + box-shadow: var(--shadow); + padding: 22px; +} + +.auth-head { + display: flex; + align-items: start; + justify-content: space-between; + gap: 18px; + margin-bottom: 18px; +} + +.auth-head h3 { + margin: 0 0 6px; + font-size: 22px; +} + +.auth-head p { + margin: 0; + color: var(--muted); + font-size: 13px; + line-height: 1.5; +} + +.field-stack { + display: grid; + gap: 8px; + margin-bottom: 14px; +} + +.field-stack label { + font-size: 12px; + color: var(--muted); +} + +.field-stack input, +.field-stack textarea { + width: 100%; + border: 1px solid var(--line); + border-radius: 14px; + padding: 12px 14px; + background: white; + color: var(--text); + resize: vertical; +} + +.helper-text { + min-height: 18px; + color: var(--orange); + font-size: 12px; +} + +.auth-actions { + display: flex; + justify-content: flex-end; + gap: 10px; + margin-top: 10px; +} + +.empty-state { + padding: 18px; + border-radius: 18px; + border: 1px dashed var(--line-strong); + background: linear-gradient(180deg, #fbfdff 0%, #f4f8ff 100%); + color: var(--muted); + line-height: 1.6; +} + +.empty-state strong { + display: block; + color: var(--text); + margin-bottom: 6px; +} + .screen { display: none; margin-top: 18px;