From 7bf93e610e1ab07c8e59d49b98beec5d7f0b8706 Mon Sep 17 00:00:00 2001 From: kris Date: Sat, 28 Mar 2026 06:32:47 +0800 Subject: [PATCH] refine workbench page usability --- ...03-28-workbench-pages-usability-cleanup.md | 85 ++ ...orkbench-pages-usability-cleanup-design.md | 134 +++ scripts/check_repo_baseline.sh | 6 +- web/storyforge-web-v4/assets/app.js | 1026 +++++++++++------ web/storyforge-web-v4/index.html | 3 +- .../tests/workbench-pages.test.mjs | 49 + 6 files changed, 978 insertions(+), 325 deletions(-) create mode 100644 docs/superpowers/plans/2026-03-28-workbench-pages-usability-cleanup.md create mode 100644 docs/superpowers/specs/2026-03-28-workbench-pages-usability-cleanup-design.md create mode 100644 web/storyforge-web-v4/tests/workbench-pages.test.mjs diff --git a/docs/superpowers/plans/2026-03-28-workbench-pages-usability-cleanup.md b/docs/superpowers/plans/2026-03-28-workbench-pages-usability-cleanup.md new file mode 100644 index 0000000..4625b79 --- /dev/null +++ b/docs/superpowers/plans/2026-03-28-workbench-pages-usability-cleanup.md @@ -0,0 +1,85 @@ +# Workbench Pages Usability Cleanup Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Reduce cognitive overload across all non-dashboard workbench pages while preserving the current StoryForge UI style and business logic. + +**Architecture:** Keep the existing single-page shell and page render functions, but add lightweight page-level tab state, restore the missing settings route, and move admin-grade sections out of user pages. Prefer small render helpers and targeted page restructuring over a full rewrite. + +**Tech Stack:** Vanilla JavaScript SPA, static HTML, CSS, Node built-in test runner, Playwright for live verification. + +--- + +### Task 1: Add regression coverage for page routing and scope boundaries + +**Files:** +- Create: `/Users/kris/code/StoryForge-gitea/web/storyforge-web-v4/tests/workbench-pages.test.mjs` + +- [ ] Write source-level regression tests for: + - settings nav route exists + - settings screen exists + - `renderAll()` renders settings + - automation page no longer renders quota / registry / admin ops + - Agent page no longer renders quota / registry + - discovery / production / admin pages expose page-tab interactions + +### Task 2: Restore settings as a real page + +**Files:** +- Modify: `/Users/kris/code/StoryForge-gitea/web/storyforge-web-v4/index.html` +- Modify: `/Users/kris/code/StoryForge-gitea/web/storyforge-web-v4/assets/app.js` + +- [ ] Add `data-screen-target="settings"` to the sidebar button. +- [ ] Add a real `data-screen="settings"` section. +- [ ] Implement `renderSettingsScreen()` and include it in `renderAll()`. + +### Task 3: Add shared page-tab state and event handling + +**Files:** +- Modify: `/Users/kris/code/StoryForge-gitea/web/storyforge-web-v4/assets/app.js` +- Modify: `/Users/kris/code/StoryForge-gitea/web/storyforge-web-v4/assets/styles.css` + +- [ ] Add page-level tab state to `appState`. +- [ ] Add a small helper to render page tabs consistently. +- [ ] Add click handling for `select-page-tab`. + +### Task 4: Simplify the heaviest user pages + +**Files:** +- Modify: `/Users/kris/code/StoryForge-gitea/web/storyforge-web-v4/assets/app.js` +- Modify: `/Users/kris/code/StoryForge-gitea/web/storyforge-web-v4/assets/styles.css` + +- [ ] Refactor `renderDiscoveryScreen()` to use tabs for overview / snapshots / similar. +- [ ] Refactor `renderProductionScreen()` to use tabs for queue / recovery / recorder / outputs. +- [ ] Refactor `renderAutomationScreen()` to use tabs and remove admin-grade sections. +- [ ] Refactor `renderPlaybookScreen()` to use tabs and remove quota / registry from the user page. + +### Task 5: Strengthen thin pages and compress medium pages + +**Files:** +- Modify: `/Users/kris/code/StoryForge-gitea/web/storyforge-web-v4/assets/app.js` +- Modify: `/Users/kris/code/StoryForge-gitea/web/storyforge-web-v4/assets/styles.css` + +- [ ] Expand `renderOwnedScreen()` into a usable account workbench. +- [ ] Expand `renderCreditsScreen()` into a readable quota page. +- [ ] Lightly tune `renderProjectsScreen()` and `renderReviewScreen()` to reduce raw data feel and repetition. + +### Task 6: Convert admin workbench into a tabbed control surface + +**Files:** +- Modify: `/Users/kris/code/StoryForge-gitea/web/storyforge-web-v4/assets/app.js` +- Modify: `/Users/kris/code/StoryForge-gitea/web/storyforge-web-v4/assets/styles.css` + +- [ ] Refactor `renderAdminWorkbenchScreen()` to use tabs for integrations / storage / agents / ops. +- [ ] Keep all system governance sections here instead of user pages. + +### Task 7: Verify locally and on the NAS page + +**Files:** +- None + +- [ ] Run `node --test /Users/kris/code/StoryForge-gitea/web/storyforge-web-v4/tests/dashboard-home.test.mjs /Users/kris/code/StoryForge-gitea/web/storyforge-web-v4/tests/workbench-pages.test.mjs` +- [ ] Run `node --check /Users/kris/code/StoryForge-gitea/web/storyforge-web-v4/assets/app.js` +- [ ] Run `bash /Users/kris/code/StoryForge-gitea/scripts/check_repo_baseline.sh` +- [ ] Redeploy the NAS frontend if needed. +- [ ] Re-check the key pages in a real browser and confirm no new console errors. diff --git a/docs/superpowers/specs/2026-03-28-workbench-pages-usability-cleanup-design.md b/docs/superpowers/specs/2026-03-28-workbench-pages-usability-cleanup-design.md new file mode 100644 index 0000000..c4935f4 --- /dev/null +++ b/docs/superpowers/specs/2026-03-28-workbench-pages-usability-cleanup-design.md @@ -0,0 +1,134 @@ +# 非首页工作台页面可用性整改设计 + +## 目标 + +在不改变当前 StoryForge Web V4 整体视觉风格的前提下,整改首页之外的工作台页面,让页面更符合人类使用逻辑: + +- 信息更容易扫读 +- 同页只处理一类任务 +- 普通用户更容易上手 +- 管理员页保留专业度,但不再是长页面堆叠 + +## 范围 + +本轮只处理首页之外的页面: + +- 我的项目 +- 找对标 +- 跟踪账号 +- 我的账号 +- Agent +- 生产中心 +- 发布与复盘 +- 自动流程 +- 额度 +- 管理员配置台 +- 设置 + +首页不在本轮范围内。 + +## 设计原则 + +1. 不换皮,只做信息减法和层级重排。 +2. 一个页面只允许一个主任务视角,深层信息通过页内 tab 切换。 +3. 系统治理内容从普通用户页面移走,优先归入管理员配置台。 +4. 太空的页面要补到能独立成立,太重的页面要压缩成单主区。 + +## 页面级方案 + +### 设置 + +- 修复现有切页异常,点击后必须真正进入设置页。 +- 新增轻量设置页,展示: + - 当前连接状态 + - 当前工作区 / 当前项目 + - 自动连接说明 + - 页面使用偏好与帮助入口 +- 超级管理员在这里看到“去管理员配置台”的入口提示,但不把管理员配置内容塞进设置页。 + +### 自动流程 + +- 只保留普通用户真正需要理解的两层: + - 依赖健康 + - 动作防呆 +- 租户额度、OneLiner 动作注册表、运维审计从本页移走。 +- 页面结构改成页内 tab,一次只看一个区块。 + +### Agent + +- 普通用户 Agent 页只保留: + - OneLiner 主 Agent 使用区 + - 当前 Agent + - 平台 Agent 协作状态 + - 模型/学习/最近生成 +- 租户额度和 OneLiner 动作注册表移出本页。 +- 页面改成页内 tab,降低一次性信息密度。 + +### 找对标 + +- 保留当前业务逻辑,不删能力。 +- 但把深层信息改成页内 tab,一次只展示一种视角: + - 账号概览 + - 快照 / 字段 / 报告 + - 相似对标 / 已绑关系 +- 账号列表和当前选中账号仍保留在主结构里。 + +### 生产中心 + +- 页面改成页内 tab: + - 生产队列 + - 失败恢复 + - 录制维护 + - 作品与产物 +- 当前页顶部保留总览和主动作,不再把四大块内容同时展开。 + +### 管理员配置台 + +- 保留系统级治理边界: + - 依赖健康 + - 存储状态 + - 平台 Agent / OneLiner 系统级管理 + - 运维与审计 +- 页面改成页内 tab,避免长页面堆叠。 + +### 我的账号 + +- 从“单张摘要卡”补成可成立的工作页面: + - 当前身份 + - 当前负责项目与 Agent + - 最近工作摘要 + - 常用快捷动作 + +### 额度 + +- 从“几个孤立数字”补成更接近产品化额度页: + - 当前额度摘要 + - 已用 / 剩余 / 预估 + - 套餐化解释 + - 风险提示 + +### 我的项目 / 跟踪账号 / 发布与复盘 + +- 保持现有方向,不大改视觉骨架。 +- 只做轻量压缩: + - 我的项目:弱化原始导入队列感,强调下一步动作 + - 跟踪账号:增强空状态可理解性 + - 发布与复盘:减少重复项,强调“待补复盘” + +## 信息边界 + +普通用户页面不再承接这些系统级内容: + +- OneLiner 动作注册表 +- 租户额度与审计全量治理面板 +- 运维与审计 Agent 全量事件墙 + +这些内容统一收口到管理员配置台。 + +## 验收标准 + +- 设置页可以真实切换并渲染独立内容。 +- 自动流程、Agent、找对标、生产中心、管理员配置台都改成“一次只展开一类信息”的结构。 +- 自动流程与 Agent 页面不再混入管理员治理内容。 +- 我的账号和额度页面不再显得像半成品空页。 +- 控制台无新增报错,现有 NAS 页面可正常浏览。 diff --git a/scripts/check_repo_baseline.sh b/scripts/check_repo_baseline.sh index 9fdaf9e..4442d17 100755 --- a/scripts/check_repo_baseline.sh +++ b/scripts/check_repo_baseline.sh @@ -39,7 +39,9 @@ for file in web/storyforge-web-v4/assets/app.js web/storyforge-web-v4/assets/sto done node --check scripts/douyin-browser-capture/control_panel.mjs -echo "[5/5] validate homepage dashboard tests" -node --test web/storyforge-web-v4/tests/dashboard-home.test.mjs +echo "[5/5] validate homepage and workbench tests" +node --test \ + web/storyforge-web-v4/tests/dashboard-home.test.mjs \ + web/storyforge-web-v4/tests/workbench-pages.test.mjs echo "baseline checks passed" diff --git a/web/storyforge-web-v4/assets/app.js b/web/storyforge-web-v4/assets/app.js index 6052fda..d549c4f 100644 --- a/web/storyforge-web-v4/assets/app.js +++ b/web/storyforge-web-v4/assets/app.js @@ -28,6 +28,12 @@ const appState = { currentPlatform: localStorage.getItem(STORAGE_KEY + ":currentPlatform") || "", selectedProjectId: "", dashboardOverviewTab: "project_progress", + discoveryDetailTab: "overview", + playbookDetailTab: "workspace", + productionDetailTab: "queue", + automationDetailTab: "health", + adminWorkbenchTab: "integrations", + settingsDetailTab: "workspace", selectedAssistantId: "", lastSeenAt: SESSION_STORE.getLastSeenAt(Date.now()), trackingCursorMap: {}, @@ -631,16 +637,25 @@ function setBusy(next, message = "") { renderAuthUi(); } -function setScreen(id) { - appState.screen = id; +function getScreenFromHash() { + const next = window.location.hash.replace("#", ""); + return screenMap[next] ? next : "dashboard"; +} + +function setScreen(id, options = {}) { + const { updateHash = true } = options; + const resolvedId = screenMap[id] ? id : "dashboard"; + appState.screen = resolvedId; navButtons.forEach((button) => { - const active = button.dataset.screenTarget === id; + const active = button.dataset.screenTarget === resolvedId; button.classList.toggle("is-active", active); }); screens.forEach((screen) => { - screen.classList.toggle("is-active", screen.dataset.screen === id); + screen.classList.toggle("is-active", screen.dataset.screen === resolvedId); }); - window.location.hash = id; + if (updateHash && window.location.hash !== `#${resolvedId}`) { + window.location.hash = resolvedId; + } } function ensureAuthUi() { @@ -3829,16 +3844,34 @@ function renderAdminWorkbenchScreen() { renderEmptyState("无权限", "请使用超级管理员账号访问管理员配置台。") ); } + const tabs = [ + { value: "integrations", label: "依赖健康" }, + { value: "storage", label: "存储状态" }, + { value: "agents", label: "Agent 治理" }, + { value: "ops", label: "运维审计" } + ]; + const activeTab = getActiveDetailTab("adminWorkbenchTab", tabs); return screenShell( "管理员配置台", "系统级依赖、存储、平台 Agent 与运维治理。", "", ` - ${renderIntegrationOverviewPanel({ showActions: false })} -
${renderStorageStatusPanel()}
-
${renderPlatformAgentPanel()}
-
${renderOneLinerActionRegistryPanel()}
- ${renderAdminOpsPanel()} +
+
+
+

系统治理工作区

+
按系统依赖、存储、Agent 治理和运维审计分区查看,不再整页堆叠。
+
+
+ ${renderDetailTabs("adminWorkbenchTab", tabs)} + ${activeTab === "integrations" + ? renderIntegrationOverviewPanel({ showActions: false }) + : activeTab === "storage" + ? renderStorageStatusPanel() + : activeTab === "agents" + ? `${renderPlatformAgentPanel()}
${renderOneLinerActionRegistryPanel()}
` + : renderAdminOpsPanel()} +
` ); } @@ -3934,6 +3967,146 @@ function renderProjectsScreen() { ); } +function getActiveDetailTab(stateKey, tabs) { + const fallback = tabs[0]?.value || ""; + const values = tabs.map((item) => item.value); + const active = values.includes(appState[stateKey]) ? appState[stateKey] : fallback; + appState[stateKey] = active; + return active; +} + +function renderDetailTabs(stateKey, tabs) { + const active = getActiveDetailTab(stateKey, tabs); + return ` +
+ ${tabs.map((tab) => ` + + `).join("")} +
+ `; +} + +function renderDiscoveryOverviewSection({ selected, selectedProject, importedSources, tracked, workbenchReason, topVideos, reports, latestVideos, currentPlatformLabel }) { + return ` +
+
+
+

接入当前项目

把当前对标导入到项目,并绑定 Agent 做持续同步
${escapeHtml(importedSources.length ? "已接入" : "未接入")}
+ ${selected ? ` +
+

${escapeHtml(selectedProject?.name || "未选项目")}

+

${escapeHtml(importedSources.length ? `当前项目已接入 ${formatNumber(importedSources.length)} 个内容源,可继续同步或换 Agent。` : "当前项目还没有接入这个对标账号,可直接导入主页并绑定 Agent。")}

+
+ ${escapeHtml(selectedProject?.name || "未选项目")} + ${escapeHtml(getSelectedAssistant()?.name || "未选 Agent")} + ${actionTag(importedSources.length ? "继续同步" : "导入当前对标", "open-import-selected-account", "", { disabledReason: workbenchReason || "" })} + ${tracked ? `已在跟踪` : actionTag("加入跟踪", "open-track-selected-account", "", { disabledReason: workbenchReason || "" })} +
+
+ ` : `

还没有选中账号

先从上方列表选一个对标账号,再决定是否导入到当前项目。

`} +
+
+
+

账号画像

+
    +
  • ${escapeHtml(selected?.signature || "暂无签名")}
  • +
  • ${escapeHtml("平台:" + currentPlatformLabel)}
  • +
  • ${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(latestVideos.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("") || `

还没有最近作品

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

`} +
+
+
+
+ `; +} + +function renderDiscoveryRelationsSection(linkedAccounts, similarCandidates) { + return ` +
+
+
+

已绑关系

当前账号已经保存的对标关系
${escapeHtml(formatNumber(linkedAccounts.length))} 个
+
+ ${linkedAccounts.map((link) => ` +
+

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

+

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

+
+ ${escapeHtml(link.relation_type || "benchmark")} + ${link.target_account_id ? `看详情` : ""} + ${link.target_profile_url ? `打开主页` : ""} +
+
+ `).join("") || `

暂无已保存对标

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

`} +
+
+
+
+
+

最近相似候选

由 Agent 辅助生成
${escapeHtml(formatNumber(similarCandidates.length))} 个
+
+ ${similarCandidates.map((candidate, index) => ` +
+

${escapeHtml(candidate.candidate_nickname || candidate.candidate_profile_url || "候选账号")}

+

${escapeHtml(brief(candidate.rationale_text || "暂无理由", 96))}

+
+ 启发分 ${escapeHtml(formatNumber(candidate.agent_score || candidate.heuristic_score || 0))} + ${candidate.candidate_account_id ? `看详情` : ""} + ${isCandidateLinked(candidate, linkedAccounts) || candidate.saved ? `已保存` : `存对标`} + ${candidate.candidate_profile_url ? `打开主页` : ""} +
+
+ `).join("") || `

还没有相似候选

先点“查相似”,这里会展示最近一轮结果。

`} +
+
+
+
+ `; +} + function renderDiscoveryScreen() { if (!appState.dashboard) { return screenShell("找对标", "完成工作区自动连接后才能加载真实对标账号。", `${button("自动连接", "open-auth", "primary")}`, renderEmptyState("对标库未加载", "自动连接成功后,这里会显示当前平台的账号列表和详情。")); @@ -3963,6 +4136,47 @@ function renderDiscoveryScreen() { const selectedProject = getSelectedProject(); const importedSources = getCurrentProjectSourcesForAccount(selected, selectedProject?.id || ""); const tracked = selected?.id ? isTrackedAccount(selected.id) : false; + const detailTabs = [ + { value: "overview", label: "账号概览" }, + { value: "snapshots", label: "快照 / 字段 / 报告" }, + { value: "relations", label: "相似对标 / 已绑关系" } + ]; + const activeTab = getActiveDetailTab("discoveryDetailTab", detailTabs); + const selectedSummaryHtml = ` +
+
+
${escapeHtml(initials(getAccountName(selected) || "SF"))}
+
+

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

+

${escapeHtml(getAccountProfileUrl(selected) || selected?.signature || "先从上方列表选一个账号,这里会展示当前对象。")}

+
+
+
+
作品数${escapeHtml(formatNumber(selected?.video_summary?.count))}
+
高分作品${escapeHtml(formatNumber(topVideos.length))}
+
报告数${escapeHtml(formatNumber(reports.length))}
+
已绑对标${escapeHtml(formatNumber(linkedAccounts.length))}
+
+
+ `; + let detailBodyHtml = ""; + if (activeTab === "overview") { + detailBodyHtml = renderDiscoveryOverviewSection({ + selected, + selectedProject, + importedSources, + tracked, + workbenchReason, + topVideos, + reports, + latestVideos, + currentPlatformLabel + }); + } else if (activeTab === "snapshots") { + detailBodyHtml = renderDouyinInsightPanel(); + } else { + detailBodyHtml = renderDiscoveryRelationsSection(linkedAccounts, similarCandidates); + } return screenShell( "找对标", isWorkbenchPlatform(currentPlatform) @@ -4049,127 +4263,17 @@ function renderDiscoveryScreen() { -
-
-
-

当前选中对标

直接来自当前平台工作台
${escapeHtml(getAccountName(selected) || "未选中")}
-
-
-
${escapeHtml(initials(getAccountName(selected) || "SF"))}
-
-

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

-

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

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

接入当前项目

把当前对标导入到项目,并绑定 Agent 做持续同步
${escapeHtml(importedSources.length ? "已接入" : "未接入")}
- ${selected ? ` -
-

${escapeHtml(selectedProject?.name || "未选项目")}

-

${escapeHtml(importedSources.length ? `当前项目已接入 ${formatNumber(importedSources.length)} 个内容源,可继续同步或换 Agent。` : "当前项目还没有接入这个对标账号,可直接导入主页并绑定 Agent。")}

-
- ${escapeHtml(selectedProject?.name || "未选项目")} - ${escapeHtml(getSelectedAssistant()?.name || "未选 Agent")} - ${actionTag(importedSources.length ? "继续同步" : "导入当前对标", "open-import-selected-account", "", { disabledReason: workbenchReason || "" })} - ${tracked ? `${escapeHtml("已在跟踪")}` : actionTag("加入跟踪", "open-track-selected-account", "", { disabledReason: workbenchReason || "" })} -
-
- ` : `

还没有选中账号

先从左侧列表选一个对标账号,再决定是否导入到当前项目。

`} -
-
-
-

账号画像

-
    -
  • ${escapeHtml(selected?.signature || "暂无签名")}
  • -
  • ${escapeHtml("平台:" + currentPlatformLabel)}
  • -
  • ${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(effectiveVideos.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("") || `

还没有最近作品

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

`} -
-
- ${renderDouyinInsightPanel()} -
-
-
-
-

相似对标 / 已绑关系

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

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

-

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

-
- ${escapeHtml(link.relation_type || "benchmark")} - ${link.target_account_id ? `看详情` : ""} - ${link.target_profile_url ? `打开主页` : ""} -
-
- `).join("") || `

暂无已保存对标

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

`} -
-
-
-

最近相似候选

由 Agent 辅助生成
${escapeHtml(formatNumber(similarCandidates.length))} 个
-
- ${similarCandidates.map((candidate, index) => ` -
-

${escapeHtml(candidate.candidate_nickname || candidate.candidate_profile_url || "候选账号")}

-

${escapeHtml(brief(candidate.rationale_text || "暂无理由", 96))}

-
- 启发分 ${escapeHtml(formatNumber(candidate.agent_score || candidate.heuristic_score || 0))} - ${candidate.candidate_account_id ? `看详情` : ""} - ${isCandidateLinked(candidate, linkedAccounts) || candidate.saved ? `已保存` : `存对标`} - ${candidate.candidate_profile_url ? `打开主页` : ""} -
-
- `).join("") || `

还没有相似候选

先点“查相似”,这里会展示最近一轮结果。

`} -
+
+
+
+

当前选中对标

+
先看核心信息,再按分类切换深层内容。
+ ${escapeHtml(getAccountName(selected) || "未选中")}
+ ${selectedSummaryHtml} + ${renderDetailTabs("discoveryDetailTab", detailTabs)} + ${detailBodyHtml}
` @@ -4267,6 +4371,11 @@ function renderAutomationScreen() { 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(); + const tabs = [ + { value: "health", label: "依赖健康" }, + { value: "guards", label: "动作防呆" } + ]; + const activeTab = getActiveDetailTab("automationDetailTab", tabs); return screenShell( "自动流程", "自动同步、日报生成和失败补跑先统一看这里。", @@ -4282,35 +4391,37 @@ function renderAutomationScreen() {
内容源${escapeHtml(formatNumber(appState.contentSources.length))}
-
- ${renderIntegrationOverviewPanel({ showActions: false })} -
-
+
-

动作防呆

-
依赖不可用时,相关动作会在这里和生产页一起被拦住。
+

自动链路工作区

+
普通用户只看健康状态和动作是否可执行,系统治理移到管理员配置台。
${escapeHtml(overview.headline)}
-
- AI 视频 ${escapeHtml(getPipelineGuard("aiVideo").enabled ? "可执行" : "已拦截")} - 实拍剪辑 ${escapeHtml(getPipelineGuard("realCut").enabled ? "可执行" : "已拦截")} - ASR ${escapeHtml(getIntegrationStatus(getIntegrationDetail("asr")).summary)} -
-
- ${renderPipelineButton("aiVideo", "primary")} - ${renderPipelineButton("realCut")} -
-
${escapeHtml(overview.subtitle)}
+ ${renderDetailTabs("automationDetailTab", tabs)} + ${activeTab === "health" ? renderIntegrationOverviewPanel({ showActions: false }) : ` +
+
+
+

动作防呆

+
依赖不可用时,相关动作会在这里和生产页一起被拦住。
+
+ ${escapeHtml(overview.headline)} +
+
+ AI 视频 ${escapeHtml(getPipelineGuard("aiVideo").enabled ? "可执行" : "已拦截")} + 实拍剪辑 ${escapeHtml(getPipelineGuard("realCut").enabled ? "可执行" : "已拦截")} + ASR ${escapeHtml(getIntegrationStatus(getIntegrationDetail("asr")).summary)} +
+
+ ${renderPipelineButton("aiVideo", "primary")} + ${renderPipelineButton("realCut")} +
+
${escapeHtml(overview.subtitle)}
+
+ `}
-
- ${renderTenantQuotaPanel()} -
-
- ${renderOneLinerActionRegistryPanel()} -
- ${renderAdminOpsPanel()} ` ); } @@ -4321,6 +4432,10 @@ function renderOwnedScreen() { } const me = appState.me || appState.session?.account || {}; const firstAssistant = safeArray(appState.dashboard.assistants)[0]; + const selectedProject = getSelectedProject(); + const jobs = safeArray(appState.dashboard.recent_jobs); + const completedJobs = jobs.filter((item) => item.status === "completed").length; + const activeJobs = jobs.filter((item) => item.status !== "completed").length; return screenShell( "我的账号", "这里先用当前登录账号和最近产出组合成第一版总览。", @@ -4341,6 +4456,51 @@ function renderOwnedScreen() {
素材${escapeHtml(formatNumber(appState.documents.length))}
+
+
+
+

当前负责范围

把你当前最常用的工作上下文放在一起。
+
+
+

当前项目 · ${escapeHtml(selectedProject?.name || "未选项目")}

+

${escapeHtml(selectedProject?.description || "当前还没有清晰项目说明,可以去“我的项目”补齐。")}

+
+ 项目 ${escapeHtml(formatNumber(appState.dashboard.projects?.length))} + ${escapeHtml(getSelectedAssistant()?.name || "未选 Agent")} + ${actionTag("去我的项目", "goto-intake")} +
+
+
+

当前主 Agent

+

${escapeHtml(firstAssistant?.generation_goal || firstAssistant?.description || "先在 Agent 页面补齐你的默认协作方式。")}

+
+ ${escapeHtml(firstAssistant?.name || "默认文案助手")} + ${actionTag("去 Agent", "goto-playbook")} +
+
+
+
+
+
+
+

最近工作摘要

用最少的信息告诉你最近跑了什么。
+
+
待推进任务${escapeHtml(formatNumber(activeJobs))}
+
已完成任务${escapeHtml(formatNumber(completedJobs))}
+
平台数${escapeHtml(formatNumber(getPlatformOptions().length))}
+
对标数${escapeHtml(formatNumber(appState.accounts.length))}
+
+
+

推荐下一步

+

${escapeHtml(activeJobs ? "先去生产中心处理待推进任务,再回到首页看下一条动作。" : "当前任务不重,适合补 Agent 策略或整理项目说明。")}

+
+ ${actionTag(activeJobs ? "去生产中心" : "去 Agent", activeJobs ? "goto-production" : "goto-playbook")} + ${actionTag("看跟踪账号", "goto-tracking")} +
+
+
+
+
` ); } @@ -4355,6 +4515,12 @@ function renderPlaybookScreen() { const currentAssistant = getSelectedAssistant(); const localCatalog = appState.localModelCatalog || {}; const gatewayModels = safeArray(localCatalog.models).map((item) => item.id).filter(Boolean); + const tabs = [ + { value: "workspace", label: "当前 Agent 工作台" }, + { value: "platform_agents", label: "平台 Agent" }, + { value: "models", label: "模型与学习" } + ]; + const activeTab = getActiveDetailTab("playbookDetailTab", tabs); return screenShell( "Agent", "这里接真实 Agent 列表,当前已经支持切换和编辑 Agent。", @@ -4370,129 +4536,144 @@ function renderPlaybookScreen() {
-

OneLiner 主 Agent

-
前端还没上的功能由它兜底承接,并调度平台 Agent。
-
-
- ${escapeHtml(appState.onelinerProfile?.display_name || "OneLiner")} - ${escapeHtml(appState.onelinerProfile?.default_platform ? platformLabel(appState.onelinerProfile.default_platform) : "未设默认平台")} - 打开对话 +

Agent 工作区

+
先处理你当前真的会用到的 Agent 信息,系统治理内容已移到管理员配置台。
-
-

${escapeHtml(appState.onelinerProfile?.long_term_goal || "还没有设置长期目标")}

-

${escapeHtml(appState.onelinerProfile?.notes || "你可以把用户长期目标、账号目标、默认平台都绑给 OneLiner,再让它去调度平台 Agent。")}

-
- 会话 ${escapeHtml(formatNumber(safeArray(appState.onelinerSessions).length))} - 平台 Agent ${escapeHtml(formatNumber(safeArray(appState.platformAgents).length))} - 编辑配置 -
-
-
-
- ${renderOneLinerActionRegistryPanel()} -
-
- ${renderPlatformAgentPanel()} -
-
- ${renderTenantQuotaPanel()} -
-
-
-
-

当前 Agent

-
后续文案生成、对标绑定和复盘默认都会优先使用这里选中的 Agent
-
-
- ${currentAssistant ? `已选` : `未选`} - ${currentAssistant ? `编辑` : ""} -
-
- ${currentAssistant ? ` -
-

${escapeHtml(currentAssistant.name)}

-

${escapeHtml(currentAssistant.generation_goal || currentAssistant.description || "先补齐这个 Agent 的目标和说明。")}

-
- ${escapeHtml(models.find((item) => item.id === currentAssistant.model_profile_id)?.name || "默认模型")} - ${escapeHtml(formatNumber(safeArray(currentAssistant.knowledge_base_ids).length))} 条知识库 - ${escapeHtml(brief(currentAssistant.description || "暂无说明", 22))} -
-
- ` : `

还没有可用 Agent

先创建一个 Agent,再把当前项目的内容都交给它学习。

`} -
-
-
-
-

本机模型网关

-
当前默认分析会优先走本机 cli-proxy-api
-
-
- ${escapeHtml(localCatalog.reachable ? "在线" : "离线")} - ${localCatalog.management_url ? `打开管理页` : ""} -
-
-
-

${escapeHtml(currentModel?.name || localCatalog.default_model || "GLM-5")}

-

${escapeHtml(currentModel ? `${currentModel.model_name || "-"} · ${currentModel.base_url || "-"}` : (localCatalog.public_base_url || localCatalog.base_url || "尚未读取到网关地址"))}

-
- ${gatewayModels.slice(0, 6).map((model) => `${escapeHtml(model)}`).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 || "默认模型")} - ${assistant.id === currentAssistant?.id ? "当前 Agent" : "设为当前"} - 编辑 + ${renderDetailTabs("playbookDetailTab", tabs)} + ${activeTab === "workspace" ? ` +
+
+
+
+
+

OneLiner 主 Agent

+
前端还没上的功能由它兜底承接,并调度平台 Agent。
+
+
+ ${escapeHtml(appState.onelinerProfile?.display_name || "OneLiner")} + ${escapeHtml(appState.onelinerProfile?.default_platform ? platformLabel(appState.onelinerProfile.default_platform) : "未设默认平台")} + 打开对话 +
+
+
+

${escapeHtml(appState.onelinerProfile?.long_term_goal || "还没有设置长期目标")}

+

${escapeHtml(appState.onelinerProfile?.notes || "你可以把用户长期目标、账号目标、默认平台都绑给 OneLiner,再让它去调度平台 Agent。")}

+
+ 会话 ${escapeHtml(formatNumber(safeArray(appState.onelinerSessions).length))} + 平台 Agent ${escapeHtml(formatNumber(safeArray(appState.platformAgents).length))} + 编辑配置 +
- `).join("") || `

还没有 Agent

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

`} -
-
-
-

最近学习素材

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

${escapeHtml(doc.title)}

-

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

+
+
+
+

当前 Agent

+
后续文案生成、对标绑定和复盘默认都会优先使用这里选中的 Agent。
+
+
+ ${currentAssistant ? `已选` : `未选`} + ${currentAssistant ? `编辑` : ""} +
+
+ ${currentAssistant ? ` +
+

${escapeHtml(currentAssistant.name)}

+

${escapeHtml(currentAssistant.generation_goal || currentAssistant.description || "先补齐这个 Agent 的目标和说明。")}

+
+ ${escapeHtml(models.find((item) => item.id === currentAssistant.model_profile_id)?.name || "默认模型")} + ${escapeHtml(formatNumber(safeArray(currentAssistant.knowledge_base_ids).length))} 条知识库 + ${escapeHtml(brief(currentAssistant.description || "暂无说明", 22))} +
+
+ ` : `

还没有可用 Agent

先创建一个 Agent,再把当前项目的内容都交给它学习。

`}
- `).join("") || `

还没有学习素材

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

`} -
-
-
-

最近生成

当前先承接文案生成结果
- ${appState.lastGeneratedCopy ? ` -
-

${escapeHtml(appState.lastGeneratedCopy.assistantName)}

-

${escapeHtml(appState.lastGeneratedCopy.content)}

-
- 需求:${escapeHtml(brief(appState.lastGeneratedCopy.prompt, 24))} - ${escapeHtml(formatNumber(appState.lastGeneratedCopy.usedDocuments.length))} 条参考 +
+

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 || "默认模型")} + ${assistant.id === currentAssistant?.id ? "当前 Agent" : "设为当前"} + 编辑 +
+
+ `).join("") || `

还没有 Agent

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

`} +
- ` : `

还没有生成结果

先点“生成文案”,这里会保留最近一次结果。

`} -
+
+
+

最近生成

当前先承接文案生成结果
+ ${appState.lastGeneratedCopy ? ` +
+

${escapeHtml(appState.lastGeneratedCopy.assistantName)}

+

${escapeHtml(appState.lastGeneratedCopy.content)}

+
+ 需求:${escapeHtml(brief(appState.lastGeneratedCopy.prompt, 24))} + ${escapeHtml(formatNumber(appState.lastGeneratedCopy.usedDocuments.length))} 条参考 +
+
+ ` : `

还没有生成结果

先点“生成文案”,这里会保留最近一次结果。

`} +
+
+
+ ` : activeTab === "platform_agents" ? ` +
${renderPlatformAgentPanel()}
+ ` : ` +
+
+
+
+
+

本机模型网关

+
当前默认分析会优先走本机 cli-proxy-api。
+
+
+ ${escapeHtml(localCatalog.reachable ? "在线" : "离线")} + ${localCatalog.management_url ? `打开管理页` : ""} +
+
+
+

${escapeHtml(currentModel?.name || localCatalog.default_model || "GLM-5")}

+

${escapeHtml(currentModel ? `${currentModel.model_name || "-"} · ${currentModel.base_url || "-"}` : (localCatalog.public_base_url || localCatalog.base_url || "尚未读取到网关地址"))}

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

模型列表

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

${escapeHtml(model.name)}

+

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

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

暂无模型

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

`} +
+
+
+
+
+

最近学习素材

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

${escapeHtml(doc.title)}

+

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

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

还没有学习素材

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

`} +
+
+
+
+ `}
` ); @@ -4508,6 +4689,13 @@ function renderProductionScreen() { const recoverableCount = failedJobs.filter((item) => item.recovery.recoverable).length; const recentDocs = appState.documents.slice(0, 3); const works = getProductionWorks(6); + const tabs = [ + { value: "queue", label: "生产队列" }, + { value: "recovery", label: "失败恢复" }, + { value: "recorder", label: "录制维护" }, + { value: "outputs", label: "作品与产物" } + ]; + const activeTab = getActiveDetailTab("productionDetailTab", tabs); return screenShell( "生产中心", "这里已经接上真实任务和知识库文档,后续再继续补任务创建动作。", @@ -4531,37 +4719,13 @@ function renderProductionScreen() {
-

失败任务恢复

-
把最近失败任务按恢复可行性分组,批量恢复入口在这里。
-
-
- ${escapeHtml(formatNumber(failedJobs.filter((item) => item.recovery.recoverable).length))} 可恢复 - ${escapeHtml(formatNumber(failedJobs.filter((item) => !item.recovery.recoverable).length))} 需人工 - 批量恢复 +

生产工作区

+
把队列、恢复、录制和产物拆开看,减少一次性信息量。
-
- ${failedJobs.map(({ job, recovery }) => ` -
-

${escapeHtml(job.title || job.id)}

-

${escapeHtml(brief(job.error || recovery.reason || "任务失败,请查看恢复说明。", 120))}

-
- ${escapeHtml(recovery.label)} - ${escapeHtml(job.line_type || job.source_type || "analysis")} - ${job.updated_at ? `${escapeHtml(formatDateTime(job.updated_at))}` : ""} - ${recovery.recoverable ? `${escapeHtml(recovery.actionLabel)}` : `${escapeHtml(recovery.reason)}`} - 看详情 -
-
- `).join("") || `

当前没有失败任务

最近任务都在正常推进,暂时不需要恢复。

`} -
-
-
- ${renderLiveRecorderManagementPanel()} -
-
-
-
+ ${renderDetailTabs("productionDetailTab", tabs)} + ${activeTab === "queue" ? ` +

当前任务

来自 recent_jobs
${(activeJobs.length ? activeJobs : jobs.slice(0, 4)).map((job) => ` @@ -4579,34 +4743,78 @@ function renderProductionScreen() { `).join("") || `

还没有任务

先去找对标导入内容。

`}
-
-
-
-

作品与成片

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

${escapeHtml(describeVideo(video))}

-

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

+ ` : activeTab === "recovery" ? ` +
+
+
+
+
+

失败任务恢复

+
把最近失败任务按恢复可行性分组,批量恢复入口在这里。
+
- ${escapeHtml(video.content_type || "video")} - 得分 ${escapeHtml(formatNumber(video.score?.performance_score || 0))} - ${getVideoLink(video) ? `打开原作品` : ""} + ${escapeHtml(formatNumber(failedJobs.filter((item) => item.recovery.recoverable).length))} 可恢复 + ${escapeHtml(formatNumber(failedJobs.filter((item) => !item.recovery.recoverable).length))} 需人工 + 批量恢复
- `).join("")} - ${recentDocs.map((doc) => ` -
-

${escapeHtml(doc.title)}

-

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

-
${escapeHtml(doc.source_type || "document")}学习素材
+
+ ${failedJobs.map(({ job, recovery }) => ` +
+

${escapeHtml(job.title || job.id)}

+

${escapeHtml(brief(job.error || recovery.reason || "任务失败,请查看恢复说明。", 120))}

+
+ ${escapeHtml(recovery.label)} + ${escapeHtml(job.line_type || job.source_type || "analysis")} + ${job.updated_at ? `${escapeHtml(formatDateTime(job.updated_at))}` : ""} + ${recovery.recoverable ? `${escapeHtml(recovery.actionLabel)}` : `${escapeHtml(recovery.reason)}`} + 看详情 +
+
+ `).join("") || `

当前没有失败任务

最近任务都在正常推进,暂时不需要恢复。

`}
- `).join("") || (works.length ? "" : `

还没有作品

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

`)} +
+
+
+ ${renderRecoveryHistoryPanel()}
- ${renderLastJobDetailCard()} - ${renderRecoveryHistoryPanel()} -
+ ` : activeTab === "recorder" ? ` +
+ ${renderLiveRecorderManagementPanel()} +
+ ` : ` +
+
+
+

作品与成片

先看真实作品,再补文档与成片
+
+ ${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 ? "" : `

还没有作品

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

`)} +
+
+
+
+ ${renderLastJobDetailCard()} +
+
+ `}
` ); @@ -4684,15 +4892,162 @@ function renderCreditsScreen() { return screenShell("额度", "先自动连接工作区。", `${button("自动连接", "open-auth", "primary")}`, renderEmptyState("额度未加载", "自动连接成功后,这里会展示真实额度和运营看板。")); } const jobs = safeArray(appState.dashboard.recent_jobs); + const quota = appState.tenantQuota; + const usage = appState.tenantUsage || quota?.usage || {}; + const categories = usage?.categories || {}; + const estimatedVideoUsage = (categories.ai_video?.quantity || 0) + (categories.real_cut?.quantity || 0); 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 视频 / 实拍剪辑可做套餐
+
文案消耗预估${escapeHtml(formatNumber(categories.copy?.quantity || jobs.filter((item) => item.line_type === "analysis").length))}
分析 / 生成链路按任务量估算
+
本周期预算${escapeHtml(formatNumber((quota?.monthly_budget_cents || usage?.total_cost_cents || 0) / 100))}
已用 ${escapeHtml(formatNumber((usage?.total_cost_cents || 0) / 100))} 元
+
视频消耗预估${escapeHtml(formatNumber(estimatedVideoUsage || jobs.filter((item) => item.line_type === "ai_video" || item.line_type === "real_cut").length))}
AI 视频 / 实拍剪辑可做套餐
+
+
+
+
+

当前额度策略

先让用户能看懂“还剩多少、风险在哪、下一步怎么做”。
+
+
+

预算与已用

+

${escapeHtml(quota ? `当前预算 ${formatNumber((quota.monthly_budget_cents || 0) / 100)} 元,已用 ${formatNumber((usage?.total_cost_cents || 0) / 100)} 元。` : "后端尚未完全接入真实预算,当前先按任务量做用户可理解的额度看板。")}

+
+
+

动作额度

+

${escapeHtml(quota ? `文案 ${formatNumber(quota.copy_quota || 0)} / AI 视频 ${formatNumber(quota.ai_video_quota || 0)} / 实拍剪辑 ${formatNumber(quota.real_cut_quota || 0)}。` : "当前优先展示文案、封面、视频三类额度池,后续再接真实套餐。")}

+
+
+

使用建议

+

${escapeHtml((quota?.enabled === false) ? "当前额度保护已关闭,适合内部联调,不适合正式对外。":"当前额度保护已开启,适合逐步转向对外产品表达。")}

+
+
+
+
+
+
+

用户能理解的表达

不要只给数字,要给解释和风险提示。
+
+
+

文案额度

+

适合高频迭代,建议和项目阶段绑定,而不是裸数字展示。

+
+
+

视频额度

+

成本更高,适合明确写出已用、剩余和推荐使用场景。

+
+
+

风险提示

+

${escapeHtml(quota?.storage_over_limit ? "当前存储已超限,后续应优先处理清理或扩容。" : "当前没有明显超限风险,但仍建议补齐真实计费链路。")}

+
+
+
+
+
+ ` + ); +} + +function renderSettingsScreen() { + const session = appState.session; + const project = getSelectedProject(); + const tabs = [ + { value: "workspace", label: "连接与工作区" }, + { value: "display", label: "界面与帮助" } + ]; + const activeTab = getActiveDetailTab("settingsDetailTab", tabs); + return screenShell( + "设置", + "这里不放系统治理内容,只处理当前用户需要理解的连接、界面和帮助信息。", + `${button("连接状态", "open-auth")} ${isSuperAdmin() ? button("管理员配置台", "goto-admin-workbench", "primary") : button("刷新", "refresh-data", "primary")}`, + ` +
+

设置与帮助

+

把连接状态、当前工作区和使用说明放在一起,避免和管理员控制面混在同一页。

+
+
+

当前设置

先看你现在接到了哪里,再决定是否要调整。
+ ${renderDetailTabs("settingsDetailTab", tabs)} + ${activeTab === "workspace" ? ` +
+
+
+

当前连接

当前前端与后端的真实连接状态。
+
+
+

${escapeHtml(session?.account?.display_name || session?.account?.username || "未连接")}

+

${escapeHtml(session?.backendUrl || DEFAULT_BACKEND_URL)}

+
+ ${escapeHtml(session ? "已自动连接" : "等待连接")} + ${project ? `${escapeHtml(project.name)}` : ""} +
+
+
+

自动连接说明

+

当前站点不会要求用户手输账号密码,而是直接向固定后端请求自动会话。

+
+
+
+
+
+
+

快捷入口

从设置页快速回到最常用的工作区。
+
+
+

项目与 Agent

+

如果你想切项目、看默认 Agent 或确认当前工作上下文,可以直接从这里回去。

+
+ ${actionTag("去我的项目", "goto-intake")} + ${actionTag("去 Agent", "goto-playbook")} +
+
+
+

生产与跟踪

+

如果你更关心最近任务和跟踪动态,可以直接回到这两个工作页。

+
+ ${actionTag("去生产中心", "goto-production")} + ${actionTag("去跟踪账号", "goto-tracking")} +
+
+
+
+
+
+ ` : ` +
+
+
+

界面原则

帮助用户理解当前产品的使用方式。
+
+

首页优先看动作

首页只负责“今天先做什么”,深层信息回到各工作页处理。

+

单页只做一类决策

重页面已经收成页内 tab,避免一次看到太多层信息。

+

系统治理不混进用户页

管理员相关配置统一收口到管理员配置台。

+
+
+
+
+
+

帮助入口

用户先看到帮助和定位,不直接掉进系统控制面。
+
+
+

连接状态

+

如果页面看起来没有数据,先确认当前工作区是否已自动连接。

+
${actionTag("打开连接状态", "open-auth")}
+
+ ${isSuperAdmin() ? ` +
+

管理员配置台

+

系统依赖、存储、平台 Agent 和运维审计都在管理员配置台里,不放在普通用户页。

+
${actionTag("去管理员配置台", "goto-admin-workbench")}
+
+ ` : ""} +
+
+
+
+ `}
` ); @@ -4766,6 +5121,9 @@ function renderAll() { screenMap.production.innerHTML = renderProductionScreen(); screenMap.review.innerHTML = renderReviewScreen(); screenMap.credits.innerHTML = renderCreditsScreen(); + if (screenMap.settings) { + screenMap.settings.innerHTML = renderSettingsScreen(); + } if (screenMap["admin-workbench"]) { screenMap["admin-workbench"].innerHTML = renderAdminWorkbenchScreen(); } @@ -7503,6 +7861,23 @@ document.addEventListener("click", async (event) => { setScreen("review"); return; } + if (name === "goto-settings") { + setScreen("settings"); + return; + } + if (name === "goto-admin-workbench") { + setScreen("admin-workbench"); + return; + } + if (name === "select-page-tab") { + const key = action.dataset.pageTabKey; + const value = action.dataset.pageTabValue; + if (key && value) { + appState[key] = value; + renderAll(); + } + return; + } if (name === "open-import-homepage") { openImportHomepageAction(); return; @@ -7870,6 +8245,13 @@ navButtons.forEach((button) => { }); }); +window.addEventListener("hashchange", () => { + const next = getScreenFromHash(); + if (next !== appState.screen) { + setScreen(next, { updateHash: false }); + } +}); + ensureAuthUi(); renderAll(); bootstrap(); diff --git a/web/storyforge-web-v4/index.html b/web/storyforge-web-v4/index.html index 5927b72..f3bb888 100644 --- a/web/storyforge-web-v4/index.html +++ b/web/storyforge-web-v4/index.html @@ -68,7 +68,7 @@ 管理员配置台 - @@ -1915,6 +1915,7 @@
+
diff --git a/web/storyforge-web-v4/tests/workbench-pages.test.mjs b/web/storyforge-web-v4/tests/workbench-pages.test.mjs new file mode 100644 index 0000000..0bc169f --- /dev/null +++ b/web/storyforge-web-v4/tests/workbench-pages.test.mjs @@ -0,0 +1,49 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; + +const ROOT = path.resolve(process.cwd(), "web/storyforge-web-v4"); +const HTML = fs.readFileSync(path.join(ROOT, "index.html"), "utf8"); +const APP = fs.readFileSync(path.join(ROOT, "assets/app.js"), "utf8"); + +function extractBetween(source, startToken, endToken) { + const start = source.indexOf(startToken); + assert.notEqual(start, -1, `Missing token: ${startToken}`); + const end = source.indexOf(endToken, start); + assert.notEqual(end, -1, `Missing token: ${endToken}`); + return source.slice(start, end); +} + +test("settings navigation and screen are real routes", () => { + assert.match(HTML, /data-screen-target="settings"/); + assert.match(HTML, /data-screen="settings"/); + assert.match(APP, /function renderSettingsScreen\(/); + assert.match(APP, /screenMap\.settings\.innerHTML = renderSettingsScreen\(\);/); + assert.match(APP, /window\.addEventListener\("hashchange"/); +}); + +test("automation screen stays user-facing and excludes admin-only panels", () => { + const source = extractBetween(APP, "function renderAutomationScreen()", "function renderOwnedScreen()"); + assert.doesNotMatch(source, /renderTenantQuotaPanel\(/); + assert.doesNotMatch(source, /renderOneLinerActionRegistryPanel\(/); + assert.doesNotMatch(source, /renderAdminOpsPanel\(/); + assert.match(source, /renderDetailTabs\("automationDetailTab"/); +}); + +test("agent screen excludes quota and registry panels and uses page tabs", () => { + const source = extractBetween(APP, "function renderPlaybookScreen()", "function renderProductionScreen()"); + assert.doesNotMatch(source, /renderTenantQuotaPanel\(/); + assert.doesNotMatch(source, /renderOneLinerActionRegistryPanel\(/); + assert.match(source, /renderDetailTabs\("playbookDetailTab"/); +}); + +test("discovery, production, and admin screens use page tabs for heavy content", () => { + const discovery = extractBetween(APP, "function renderDiscoveryScreen()", "function renderTrackingScreen()"); + const production = extractBetween(APP, "function renderProductionScreen()", "function renderReviewScreen()"); + const admin = extractBetween(APP, "function renderAdminWorkbenchScreen()", "function renderDashboardScreen()"); + + assert.match(discovery, /renderDetailTabs\("discoveryDetailTab"/); + assert.match(production, /renderDetailTabs\("productionDetailTab"/); + assert.match(admin, /renderDetailTabs\("adminWorkbenchTab"/); +});