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(appState.contentSources.length))}
+
跟踪账号${escapeHtml(formatNumber(accounts.length))}
+
Agent${escapeHtml(formatNumber(assistants.length))}
+
生产任务${escapeHtml(formatNumber(jobs.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")}`,
+ `
+
+
+
+
+
+
+ | 账号 |
+ 签名 |
+ 作品数 |
+ 平均播放 |
+ 平均点赞 |
+ 动作 |
+
+
+
+ ${accounts.map((account) => `
+
+
+
+ ${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))} |
+ 查看 |
+
+ `).join("") || `| 当前没有抖音账号数据。 |
`}
+
+
+
+
+
+
+
${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))}
+
+
+ `).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("") || `暂无模型`}
+
+
+
+
+
+
+ ${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))} 条
+
+
+
+
+
+
+
+ ${(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)}`)}
+
+
+ `).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))}
+
+ `
+ );
+}
+
+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;