diff --git a/scripts/check_repo_baseline.sh b/scripts/check_repo_baseline.sh index 37f231c..9fdaf9e 100755 --- a/scripts/check_repo_baseline.sh +++ b/scripts/check_repo_baseline.sh @@ -16,13 +16,13 @@ need_cmd node cd "$ROOT" -echo "[1/4] compile collector-service" +echo "[1/5] compile collector-service" python3 -m compileall collector-service/app >/dev/null -echo "[2/4] validate docker compose" +echo "[2/5] validate docker compose" docker compose config >/dev/null -echo "[3/4] validate n8n workflows" +echo "[3/5] validate n8n workflows" python3 - <<'PY' import json import pathlib @@ -33,10 +33,13 @@ for path in sorted(pathlib.Path("n8n/workflows").glob("*.json")): print(f"workflow ok: {path.name}") PY -echo "[4/4] validate web scripts" +echo "[4/5] validate web scripts" for file in web/storyforge-web-v4/assets/app.js web/storyforge-web-v4/assets/storyforge-*.js; do node --check "$file" 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 "baseline checks passed" diff --git a/tests/test_production_baseline.py b/tests/test_production_baseline.py index 7df5565..41a3924 100644 --- a/tests/test_production_baseline.py +++ b/tests/test_production_baseline.py @@ -249,6 +249,10 @@ class ProductionBaselineTests(unittest.TestCase): ]: self.assertIn(expected, content) + def test_baseline_script_covers_homepage_dashboard_node_test(self) -> None: + script = (ROOT / "scripts" / "check_repo_baseline.sh").read_text(encoding="utf-8") + self.assertIn("dashboard-home.test.mjs", script) + def test_healthz_exposes_lan_routing_summary(self) -> None: response = self.client.get("/healthz") self.assertEqual(response.status_code, 200, response.text) diff --git a/web/storyforge-web-v4/README.md b/web/storyforge-web-v4/README.md index bb47533..191bb20 100644 --- a/web/storyforge-web-v4/README.md +++ b/web/storyforge-web-v4/README.md @@ -6,7 +6,7 @@ - 页面:`index.html` - 样式:`assets/styles.css` -- 页面交互:`assets/storyforge-session-store.js`、`assets/storyforge-api-client.js`、`assets/storyforge-platform-runtime.js`、`assets/app.js` +- 页面交互:`assets/storyforge-session-store.js`、`assets/storyforge-api-client.js`、`assets/storyforge-platform-runtime.js`、`assets/storyforge-dashboard-home.js`、`assets/app.js` ## 当前定位 @@ -30,6 +30,10 @@ - 生产中心 - 发布与复盘 - 额度 +- 首页已切到“人类决策优先”结构: + - 先显示当前项目上下文与 `1 主 2 次` 今日动作 + - 再显示 `项目进度 / 重点账号·对标 / 生产任务` tab 概览 + - 管理员配置台通过独立导航进入,不再挤占首页主体 ## 当前已接入的真实能力 diff --git a/web/storyforge-web-v4/assets/app.js b/web/storyforge-web-v4/assets/app.js index 9b2b484..52e3a61 100644 --- a/web/storyforge-web-v4/assets/app.js +++ b/web/storyforge-web-v4/assets/app.js @@ -27,6 +27,7 @@ const appState = { discoveryQuery: "", currentPlatform: localStorage.getItem(STORAGE_KEY + ":currentPlatform") || "", selectedProjectId: "", + dashboardOverviewTab: "project_progress", selectedAssistantId: "", lastSeenAt: SESSION_STORE.getLastSeenAt(Date.now()), trackingCursorMap: {}, @@ -55,6 +56,7 @@ const appState = { autoConnectAttempted: false, autoConnectSuppressed: false, autoConnectError: "", + dashboardActionReason: null, busy: false, message: "", lastAction: null, @@ -2094,6 +2096,329 @@ function getTrackingAccounts() { ); } +function getTrackingAccountsForProject(projectId) { + if (!projectId) return getTrackingAccounts(); + const assistantIds = new Set(getProjectAssistants(projectId).map((item) => item.id)); + const scoped = getTrackingAccounts().filter((item) => item.assistant_id && assistantIds.has(item.assistant_id)); + return scoped.length ? scoped : getTrackingAccounts(); +} + +function dashboardTabLabel(value) { + return ({ + project_progress: "项目进度", + focus_accounts: "重点账号 / 对标", + production_jobs: "生产任务" + })[value] || "项目进度"; +} + +function getDashboardActionSourceLabel() { + return appState.onelinerProfile ? "主 Agent 优先推荐" : "规则推荐"; +} + +function getDashboardProjectProgressSummary(project, stats, trackedAccounts) { + const total = 5; + const completed = [ + Boolean(project), + stats.assistants.length > 0, + stats.sources.length > 0 || appState.accounts.length > 0, + trackedAccounts.length > 0, + stats.jobs.length > 0 + ].filter(Boolean).length; + const failedJobs = stats.jobs.filter((item) => item.status === "failed").length; + let nextStep = "先创建当前项目"; + if (project && !stats.assistants.length) { + nextStep = "补第一个 Agent"; + } else if (project && !stats.sources.length && !appState.accounts.length) { + nextStep = "导入主页或作品"; + } else if (project && !trackedAccounts.length) { + nextStep = "加一个重点跟踪"; + } else if (project && !stats.jobs.length) { + nextStep = "发起第一条生产任务"; + } else if (project) { + nextStep = "继续推进当前主流程"; + } + const risk = failedJobs + ? `${failedJobs} 条任务失败待处理` + : (!trackedAccounts.length && project) + ? "还没有重点账号跟踪" + : "当前主流程没有明显阻塞"; + return { total, completed, nextStep, risk }; +} + +function buildDashboardOverviewTabs(project, stats, trackedAccounts) { + const progress = getDashboardProjectProgressSummary(project, stats, trackedAccounts); + const pendingJobs = stats.jobs.filter((item) => item.status !== "completed").length; + return [ + { + key: "project_progress", + label: "项目进度", + value: `${progress.completed} / ${progress.total}`, + hint: progress.nextStep, + active: appState.dashboardOverviewTab === "project_progress" + }, + { + key: "focus_accounts", + label: "重点账号 / 对标", + value: formatNumber(trackedAccounts.length || appState.accounts.length), + hint: trackedAccounts.length ? "优先看变化最多的对象" : "等待接入重点对象", + active: appState.dashboardOverviewTab === "focus_accounts" + }, + { + key: "production_jobs", + label: "生产任务", + value: formatNumber(stats.jobs.length), + hint: pendingJobs ? `${formatNumber(pendingJobs)} 条待推进` : "可发起下一条任务", + active: appState.dashboardOverviewTab === "production_jobs" + } + ]; +} + +function renderDashboardProjectProgressBody(project, stats, trackedAccounts) { + const progress = getDashboardProjectProgressSummary(project, stats, trackedAccounts); + return ` +
+
+
+ 当前项目 + ${escapeHtml(project?.name || "还没有项目")} + ${escapeHtml(project?.description || "先选定项目,首页动作才会真正收敛。")} +
+
+ 主流程进度 + ${escapeHtml(`${progress.completed} / ${progress.total}`)} + ${escapeHtml(progress.nextStep)} +
+
+ 阶段风险 + ${escapeHtml(progress.risk)} + ${escapeHtml(stats.jobs.length ? `当前项目任务 ${formatNumber(stats.jobs.length)} 条` : "还没有生产任务")} +
+
+
+

下一步建议

+

${escapeHtml(project ? `当前项目建议先「${progress.nextStep}」,做完后再回到首页看下一条动作。` : "先创建项目或切换到已有项目,然后首页动作区会自动切到该项目。")}

+
+ ${actionTag(project ? "切换项目" : "去建项目", project ? "open-dashboard-project-switcher" : "goto-intake")} + ${actionTag("去生产中心", "goto-production")} +
+
+
+ `; +} + +function renderDashboardFocusAccountsBody(project, trackedAccounts) { + const fallbackAccounts = safeArray(appState.accounts).slice(0, 3).map((item) => ({ + platform: getAccountPlatform(item), + assistant_name: "", + account: item + })); + const items = (trackedAccounts.length ? trackedAccounts : fallbackAccounts).slice(0, 3); + if (!items.length) { + return ` +
+

还没有重点账号 / 对标

+

${escapeHtml(project ? "先导入主页或把一个重点账号加入跟踪,首页才会持续给出更聪明的动作推荐。" : "先创建项目,再导入第一个重点账号。")}

+
+ ${actionTag("去找对标", "goto-discovery")} + ${actionTag("去跟踪账号", "goto-tracking")} +
+
+ `; + } + return ` +
+ ${items.map((item) => { + const account = item.account || item; + const accountId = account?.id || item.tracked_account_id || ""; + return ` +
+
+
${escapeHtml(initials(getAccountName(account)))}
+
+
${escapeHtml(getAccountName(account))}
+
${escapeHtml(platformLabel(item.platform || getAccountPlatform(account)))} · ${escapeHtml(item.assistant_name || account.signature || "重点关注对象")}
+
+
+
+ ${escapeHtml(account.video_summary?.count ? `作品 ${formatNumber(account.video_summary.count)}` : "重点对象")} + ${account.sync_status ? `${escapeHtml(account.sync_status)}` : ""} + ${accountId ? actionTag("查看详情", "select-account", `data-account-id="${escapeHtml(accountId)}"`) : ""} +
+
+ `; + }).join("")} +
+ `; +} + +function renderDashboardProductionJobsBody(stats) { + const jobs = sortItemsByIsoDesc(stats.jobs, "updated_at").slice(0, 4); + if (!jobs.length) { + return ` +
+

当前项目还没有生产任务

+

先补完项目和对标,再从首页动作区或生产中心发起第一条任务。

+
+ ${actionTag("去生产中心", "goto-production")} + ${actionTag("去找对标", "goto-discovery")} +
+
+ `; + } + return ` +
+ ${jobs.map((job) => ` +
+

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

+

${escapeHtml(brief(job.error || job.summary || `最近更新于 ${formatDateTime(job.updated_at || job.created_at)}`, 120))}

+
+ ${escapeHtml(job.status || "-")} + ${job.line_type ? `${escapeHtml(job.line_type)}` : ""} + ${actionTag("查看详情", "open-job-detail", `data-job-id="${escapeHtml(job.id)}"`)} +
+
+ `).join("")} +
+ `; +} + +function renderDashboardOverviewBody(tab, context) { + if (tab === "focus_accounts") { + return renderDashboardFocusAccountsBody(context.project, context.trackedAccounts); + } + if (tab === "production_jobs") { + return renderDashboardProductionJobsBody(context.stats); + } + return renderDashboardProjectProgressBody(context.project, context.stats, context.trackedAccounts); +} + +function buildDashboardHomeModel() { + const project = getSelectedProject(); + const emptyStats = { knowledgeBases: [], assistants: [], jobs: [], sources: [] }; + const stats = project ? getProjectStats(project.id) : emptyStats; + const trackedAccounts = project ? getTrackingAccountsForProject(project.id) : getTrackingAccounts(); + const dashboardModule = window.StoryForgeDashboardHome; + const summaryTabs = buildDashboardOverviewTabs(project, stats, trackedAccounts); + const activeTabLabel = dashboardTabLabel(appState.dashboardOverviewTab); + const baseModel = dashboardModule?.createDashboardHomeModel + ? dashboardModule.createDashboardHomeModel({ + workspaceLabel: appState.me?.display_name || appState.me?.username || "当前工作区", + currentProjectName: project?.name || "还没有项目", + trackedAccountsCount: trackedAccounts.length || appState.accounts.length, + assistantCount: stats.assistants.length, + jobCount: stats.jobs.length, + hasProject: Boolean(project), + actionSourceLabel: getDashboardActionSourceLabel(), + dashboardOverviewTab: appState.dashboardOverviewTab, + summaryTabs, + activeTabLabel, + contextLinks: [ + { label: "账号", value: formatNumber(trackedAccounts.length || appState.accounts.length), action: "goto-owned" }, + { label: "任务", value: formatNumber(stats.jobs.length), action: "goto-production" }, + { label: "Agent", value: formatNumber(stats.assistants.length), action: "goto-playbook" } + ] + }) + : { + workspaceLabel: appState.me?.display_name || appState.me?.username || "当前工作区", + currentProjectName: project?.name || "还没有项目", + actionSourceLabel: getDashboardActionSourceLabel(), + contextLinks: [ + { label: "账号", value: formatNumber(trackedAccounts.length || appState.accounts.length), action: "goto-owned" }, + { label: "任务", value: formatNumber(stats.jobs.length), action: "goto-production" }, + { label: "Agent", value: formatNumber(stats.assistants.length), action: "goto-playbook" } + ], + primaryAction: { + title: project ? "继续推进当前项目主流程" : "先创建或切换到一个项目", + reason: project ? "从首页动作区进入当前最该做的事。" : "首页动作和概览都跟随当前项目。", + badges: ["默认动作"], + goAction: project ? "goto-production" : "goto-intake", + goLabel: project ? "去处理" : "去项目", + agentLabel: "交给主 Agent" + }, + secondaryActions: [], + summaryTabs, + activeTabLabel + }; + return { + ...baseModel, + summaryTabs, + activeTabLabel, + overviewBodyHtml: renderDashboardOverviewBody(appState.dashboardOverviewTab, { project, stats, trackedAccounts }) + }; +} + +function openDashboardProjectSwitcher() { + const options = getProjectOptions(); + if (!options.length) { + rememberAction("还没有项目", "先创建一个项目,再让首页跟着当前项目切换。", "orange"); + setScreen("intake"); + return; + } + openActionModal({ + title: "切换当前项目", + description: "首页上下文、今日动作和项目概览都会跟着当前项目一起切换。", + submitLabel: "切换项目", + fields: [ + { name: "projectId", label: "当前项目", type: "select", value: getSelectedProject()?.id || "", options } + ], + onSubmit: async (payload) => { + appState.selectedProjectId = payload.projectId || ""; + setBusy(true, "正在切换项目视图..."); + try { + if (backendSupports("/v2/storage/status")) { + await loadStorageStatus(appState.selectedProjectId || ""); + } else { + appState.storageStatus = null; + } + await loadAgentControlSurfaces(appState.selectedProjectId || ""); + if (appState.selectedOnelinerSessionId) { + await loadOneLinerMessages(appState.selectedOnelinerSessionId); + } + } finally { + setBusy(false, ""); + } + renderAll(); + } + }); +} + +function openDashboardActionReasonAction(index) { + const model = buildDashboardHomeModel(); + const actions = [model.primaryAction, ...safeArray(model.secondaryActions)]; + const action = actions[Number(index)] || actions[0]; + if (!action) return; + appState.dashboardActionReason = { + title: action.title, + reason: action.reason, + sourceLabel: model.actionSourceLabel, + badges: safeArray(action.badges), + goLabel: action.goLabel || "去处理" + }; + openActionModal({ + title: "动作原因", + description: "首页只放最短判断,这里再把原因展开一层。", + hideSubmit: true, + fields: [ + { + type: "html", + label: "动作详情", + html: ` +
+
+

${escapeHtml(appState.dashboardActionReason.title)}

+

${escapeHtml(appState.dashboardActionReason.reason)}

+
+ ${escapeHtml(appState.dashboardActionReason.sourceLabel)} + ${appState.dashboardActionReason.badges.map((item) => `${escapeHtml(item)}`).join("")} +
+
+
+ ` + } + ] + }); +} + function getTrackingDigestItems(limit = 6, options = {}) { const targetPlatform = normalizePlatformValue(options.platform || "", ""); const fallbackPlatform = targetPlatform || getCurrentPlatformValue(); @@ -3492,6 +3817,29 @@ function renderAdminFixRunsPanel() { `; } +function renderAdminWorkbenchScreen() { + if (!isSuperAdmin()) { + return screenShell( + "管理员配置台", + "仅超级管理员可见。", + "", + renderEmptyState("无权限", "请使用超级管理员账号访问管理员配置台。") + ); + } + return screenShell( + "管理员配置台", + "系统级依赖、存储、平台 Agent 与运维治理。", + "", + ` + ${renderIntegrationOverviewPanel({ showActions: false })} +
${renderStorageStatusPanel()}
+
${renderPlatformAgentPanel()}
+
${renderOneLinerActionRegistryPanel()}
+ ${renderAdminOpsPanel()} + ` + ); +} + function renderDashboardScreen() { if (!appState.session) { return screenShell( @@ -3509,128 +3857,15 @@ function renderDashboardScreen() { renderEmptyState("工作区暂未就绪", appState.message || "如果账号未审批通过,当前页会先停在待审批状态。") ); } - const dashboard = appState.dashboard; - const projects = safeArray(dashboard.projects); - const jobs = safeArray(dashboard.recent_jobs); - const assistants = safeArray(dashboard.assistants); - const accounts = safeArray(appState.accounts); - const trackedAccounts = getTrackingAccounts(); - const digestItems = getTrackingDigestItems(3); - const actions = []; - if (!projects.length) actions.push("先新建一个项目"); - if (!assistants.length) actions.push("先创建第一个 Agent"); - if (!accounts.length) actions.push("先导入一个平台主页或作品"); - if (!trackedAccounts.length && accounts.length) actions.push("挑 1 个重点账号加入跟踪"); - if (jobs.some((item) => item.status !== "completed")) actions.push("处理进行中的生产任务"); - if (!actions.length) actions.push("继续补高分对标并安排生产"); + const dashboardHomeRenderer = window.StoryForgeDashboardHome; + const homeModel = buildDashboardHomeModel(); return screenShell( "项目总台", - "先看项目状态、待办动作和高价值对标。", - `${button("新建项目", "create-project")} ${button("导入主页", "open-import-homepage")} ${button("OneLiner", "open-oneliner")} ${button("创建 Agent", "open-create-assistant", "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(trackedAccounts.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))} 条已完成
-
-
- ${renderIntegrationOverviewPanel({ compact: true })} -
-
-
-
-

当前主流程

-

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

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

今日重点动作

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

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

-

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

-
- `).join("")} -
-
- ${renderLastActionCard()} -
-

高分对标

优先看当前已同步账号
-
- ${accounts.slice(0, 3).map((account) => ` -
-
-
${escapeHtml(initials(getAccountName(account)))}
-
-
${escapeHtml(getAccountName(account))}
-
${escapeHtml(account.signature || getAccountProfileUrl(account) || `已同步${platformLabel(getAccountPlatform(account))}账号`)}
-
-
-
- 作品 ${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))}
-
-
-
- ${renderPlatformAgentPanel()} -
- ${renderStorageStatusPanel()} -
-

跟踪摘要

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

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

-

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

-
- ${escapeHtml(getPlatformShortLabel(item.platform || item.account?.platform || getCurrentPlatformValue()))} - ${escapeHtml(item.is_high_value ? "高价值" : "可学习")} - ${item.assistant_name ? `${escapeHtml(item.assistant_name)}` : ""} -
-
- `).join("") || `

还没有日报

先把重点账号加入跟踪,日报才会开始累积。

`} -
-
-
-

最新异常

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

${escapeHtml(job.title)}

-

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

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

当前无异常

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

`} -
-
-
-
- ` + "先做最能推进当前项目的一步,再按需看概览。", + `${button("新建项目", "create-project")} ${button("导入主页", "open-import-homepage")} ${button("创建 Agent", "open-create-assistant", "primary")}`, + dashboardHomeRenderer?.renderDashboardHome + ? dashboardHomeRenderer.renderDashboardHome(homeModel, { escapeHtml }) + : renderEmptyState("首页模块未加载", "请刷新页面后重试。") ); } @@ -4501,9 +4736,23 @@ function renderTopbar() { } } +function syncRoleGatedNav() { + const allowAdmin = isSuperAdmin(); + document.querySelectorAll("[data-role-gate]").forEach((element) => { + const gate = element.getAttribute("data-role-gate"); + const visible = gate === "super_admin" ? allowAdmin : true; + element.classList.toggle("hidden", !visible); + element.hidden = !visible; + }); + if (!allowAdmin && appState.screen === "admin-workbench") { + appState.screen = "dashboard"; + } +} + function renderAll() { renderTopbar(); renderAuthUi(); + syncRoleGatedNav(); screenMap.dashboard.innerHTML = renderDashboardScreen(); screenMap.intake.innerHTML = renderProjectsScreen(); screenMap.discovery.innerHTML = renderDiscoveryScreen(); @@ -4514,6 +4763,9 @@ function renderAll() { screenMap.production.innerHTML = renderProductionScreen(); screenMap.review.innerHTML = renderReviewScreen(); screenMap.credits.innerHTML = renderCreditsScreen(); + if (screenMap["admin-workbench"]) { + screenMap["admin-workbench"].innerHTML = renderAdminWorkbenchScreen(); + } renderOneLinerUi(); setScreen(screenMap[appState.screen] ? appState.screen : "dashboard"); } @@ -7232,6 +7484,14 @@ document.addEventListener("click", async (event) => { setScreen("playbook"); return; } + if (name === "goto-owned") { + setScreen("owned"); + return; + } + if (name === "goto-tracking") { + setScreen("tracking"); + return; + } if (name === "goto-production") { setScreen("production"); return; @@ -7272,6 +7532,19 @@ document.addEventListener("click", async (event) => { openCreateAssistantAction(); return; } + if (name === "open-dashboard-project-switcher") { + openDashboardProjectSwitcher(); + return; + } + if (name === "open-dashboard-action-reason") { + openDashboardActionReasonAction(action.dataset.dashboardActionIndex || "0"); + return; + } + if (name === "select-dashboard-tab") { + appState.dashboardOverviewTab = action.dataset.dashboardTab || "project_progress"; + renderAll(); + return; + } if (name === "open-oneliner-profile") { openOneLinerProfileAction(); return; diff --git a/web/storyforge-web-v4/assets/storyforge-dashboard-home.js b/web/storyforge-web-v4/assets/storyforge-dashboard-home.js new file mode 100644 index 0000000..0d7673c --- /dev/null +++ b/web/storyforge-web-v4/assets/storyforge-dashboard-home.js @@ -0,0 +1,259 @@ +(function () { + function defaultEscapeHtml(value) { + return String(value ?? "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); + } + + function safeArray(value) { + return Array.isArray(value) ? value : []; + } + + function renderTags(items, escapeHtml) { + return safeArray(items) + .map((item) => `${escapeHtml(item)}`) + .join(""); + } + + function renderActionButton(config, escapeHtml) { + if (!config?.label || !config?.action) return ""; + const tone = config.tone || "secondary"; + const attrs = safeArray(config.attrs).join(" "); + return ` + + `; + } + + function createDashboardHomeModel(raw) { + const trackedAccountsCount = Number(raw?.trackedAccountsCount || 0); + const assistantCount = Number(raw?.assistantCount || 0); + const jobCount = Number(raw?.jobCount || 0); + const hasProject = raw?.hasProject != null + ? Boolean(raw.hasProject) + : Boolean(String(raw?.currentProjectName || "").trim() && String(raw?.currentProjectName || "").trim() !== "还没有项目"); + + const actions = []; + if (!hasProject) { + actions.push({ + title: "先创建或切换到一个项目", + reason: "首页动作和概览都跟随当前项目,先定项目再推进后续工作。", + badges: ["最优先", "项目入口"], + goAction: "goto-intake", + goLabel: "去项目", + agentLabel: "交给主 Agent" + }); + } else if (trackedAccountsCount > 0) { + actions.push({ + title: "先补抖音重点对标的高分作品分析", + reason: "最近有新作品,但还没形成高分样本。", + badges: ["最优先", "预计 10 分钟判断", "关联:重点账号"], + goAction: "goto-discovery", + goLabel: "去找对标", + agentLabel: "交给主 Agent" + }); + } else if (assistantCount <= 0) { + actions.push({ + title: "先为当前项目创建第一个 Agent", + reason: "项目已经存在,但还没有可承接分析和生产动作的 Agent。", + badges: ["最优先", "Agent 缺失"], + goAction: "goto-playbook", + goLabel: "去创建", + agentLabel: "交给主 Agent" + }); + } else { + actions.push({ + title: "继续补高分对标并安排生产", + reason: "当前项目没有更高优先阻塞,继续推动主流程即可。", + badges: ["默认推进", "主流程"], + goAction: "goto-production", + goLabel: "去处理", + agentLabel: "交给主 Agent" + }); + } + + if (jobCount > 0) { + actions.push({ + title: "确认一个待执行的生产计划", + reason: "素材和结论都在,只差最后确认。", + goAction: "goto-production", + goLabel: "去处理" + }); + } + + actions.push({ + title: "更新重点账号的跟踪摘要", + reason: "有新动态,但不值得占据大块首页空间。", + goAction: "goto-tracking", + goLabel: "去处理" + }); + + while (actions.length < 3) { + actions.push({ + title: "继续补高分对标并安排生产", + reason: "当前项目没有更多高优先动作时,保持主流程推进。", + goAction: "goto-production", + goLabel: "去处理" + }); + } + + const summaryTabs = safeArray(raw?.summaryTabs).length + ? raw.summaryTabs + : [ + { key: "project_progress", label: "项目进度", value: "0 / 5", hint: "等待项目建立", active: raw?.dashboardOverviewTab !== "focus_accounts" && raw?.dashboardOverviewTab !== "production_jobs" }, + { key: "focus_accounts", label: "重点账号 / 对标", value: String(trackedAccountsCount), hint: "重点对象", active: raw?.dashboardOverviewTab === "focus_accounts" }, + { key: "production_jobs", label: "生产任务", value: String(jobCount), hint: "当前项目任务", active: raw?.dashboardOverviewTab === "production_jobs" } + ]; + + return { + workspaceLabel: raw?.workspaceLabel || "当前工作区", + currentProjectName: raw?.currentProjectName || "还没有项目", + actionSourceLabel: raw?.actionSourceLabel || "规则推荐", + contextLinks: safeArray(raw?.contextLinks), + primaryAction: actions[0], + secondaryActions: actions.slice(1, 3), + summaryTabs, + activeTabLabel: raw?.activeTabLabel || (summaryTabs.find((item) => item.active)?.label || "项目进度"), + overviewBodyHtml: raw?.overviewBodyHtml || "" + }; + } + + function renderSecondaryAction(item, index, escapeHtml) { + return ` +
+
${index + 2}
+
+
${escapeHtml(item.title)}
+

${escapeHtml(item.reason)}

+
+
+ ${renderActionButton({ + label: "原因", + action: "open-dashboard-action-reason", + tone: "secondary", + attrs: [`data-dashboard-action-index="${index + 1}"`] + }, escapeHtml)} + ${renderActionButton({ + label: item.goLabel || "去处理", + action: item.goAction || "goto-production", + tone: "secondary" + }, escapeHtml)} +
+
+ `; + } + + function renderDashboardHome(model, helpers = {}) { + const escapeHtml = helpers.escapeHtml || defaultEscapeHtml; + const summaryTabs = safeArray(model?.summaryTabs); + const contextLinks = safeArray(model?.contextLinks); + const primaryAction = model?.primaryAction || {}; + const secondaryActions = safeArray(model?.secondaryActions); + + return ` +
+
+
+
+

项目总台

+
当前项目优先,先做决定再展开细节。
+
+
+
+
+
+ 当前工作区 + ${escapeHtml(model?.workspaceLabel || "当前工作区")} +
+ +
+
+ ${contextLinks.map((item) => ` + + `).join("")} +
+
+
+ +
+
+
+

今天先做什么

+
首页只保留 1 主 2 次动作,避免同时看太多说明。
+
+ ${escapeHtml(model?.actionSourceLabel || "规则推荐")} +
+
+
+

${escapeHtml(primaryAction.title || "先建立当前项目的主流程")}

+

${escapeHtml(primaryAction.reason || "先完成当前项目最影响推进的一步。")}

+
+ ${renderTags(primaryAction.badges, escapeHtml)} +
+
+
+ ${renderActionButton({ + label: "查看原因", + action: "open-dashboard-action-reason", + tone: "secondary", + attrs: ['data-dashboard-action-index="0"'] + }, escapeHtml)} + ${renderActionButton({ + label: primaryAction.goLabel || "去处理", + action: primaryAction.goAction || "goto-production", + tone: "secondary" + }, escapeHtml)} + ${renderActionButton({ + label: primaryAction.agentLabel || "交给主 Agent", + action: "open-oneliner", + tone: "primary" + }, escapeHtml)} +
+
+
+ ${secondaryActions.map((item, index) => renderSecondaryAction(item, index, escapeHtml)).join("")} +
+
+ +
+
+
+

项目概览

+
同一块内容区按 tab 切换,不再把所有细节同时摊开。
+
+ ${escapeHtml(model?.activeTabLabel || "项目进度")} +
+
+ ${summaryTabs.map((item) => ` + + `).join("")} +
+
${model?.overviewBodyHtml || ""}
+
+
+ `; + } + + window.StoryForgeDashboardHome = { + createDashboardHomeModel, + renderDashboardHome + }; +})(); diff --git a/web/storyforge-web-v4/assets/styles.css b/web/storyforge-web-v4/assets/styles.css index 11448b2..b925b47 100644 --- a/web/storyforge-web-v4/assets/styles.css +++ b/web/storyforge-web-v4/assets/styles.css @@ -187,6 +187,10 @@ select { border-color: rgba(79, 143, 238, 0.22); } +.hidden { + display: none !important; +} + .content { padding: 18px 22px 26px; min-width: 0; @@ -576,6 +580,163 @@ select { line-height: 1.4; } +.dashboard-home { + display: grid; + gap: 18px; +} + +.dashboard-context-panel, +.dashboard-priority-panel, +.dashboard-overview-panel { + display: grid; + gap: 14px; +} + +.dashboard-context-row { + display: flex; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; +} + +.dashboard-context-left, +.dashboard-context-right { + display: flex; + flex-wrap: wrap; + gap: 12px; +} + +.dashboard-context-chip { + display: inline-flex; + align-items: center; + gap: 8px; + border-radius: 14px; + border: 1px solid var(--line); + background: var(--panel-soft); + padding: 10px 12px; + color: var(--muted); +} + +.dashboard-context-chip strong { + color: var(--text); + font-size: 12px; +} + +.dashboard-context-chip-button { + cursor: pointer; +} + +.dashboard-action-primary { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 18px; + align-items: center; + padding: 18px; + border-radius: 18px; + border: 1px solid rgba(79, 143, 238, 0.16); + background: linear-gradient(180deg, #f8fbff 0%, #eef5ff 100%); +} + +.dashboard-action-copy h4, +.dashboard-action-copy h5 { + margin: 0; +} + +.dashboard-action-copy p { + margin: 8px 0 0; + color: var(--muted); + line-height: 1.6; +} + +.dashboard-action-buttons { + display: flex; + gap: 10px; + flex-wrap: wrap; + justify-content: flex-end; +} + +.dashboard-action-secondary-list { + display: grid; + gap: 10px; +} + +.dashboard-action-secondary { + display: grid; + grid-template-columns: 34px minmax(0, 1fr) auto; + gap: 14px; + align-items: center; + border: 1px solid var(--line); + border-radius: 16px; + padding: 14px 16px; + background: var(--panel-soft); +} + +.dashboard-action-index { + width: 34px; + height: 34px; + display: grid; + place-items: center; + border-radius: 12px; + background: var(--blue-50); + color: var(--blue-700); + font-weight: 700; +} + +.dashboard-overview-tabs { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; +} + +.dashboard-overview-tab { + border: 1px solid var(--line); + border-radius: 16px; + background: var(--panel-soft); + padding: 14px; + text-align: left; + cursor: pointer; + display: grid; + gap: 6px; + color: var(--text); +} + +.dashboard-overview-tab small, +.dashboard-overview-tab span { + color: var(--muted); +} + +.dashboard-overview-tab strong { + font-size: 20px; + line-height: 1.1; +} + +.dashboard-overview-tab.is-active { + border-color: rgba(79, 143, 238, 0.28); + background: linear-gradient(180deg, #f7fbff 0%, #edf5ff 100%); + box-shadow: inset 0 0 0 1px rgba(79, 143, 238, 0.08); +} + +.dashboard-overview-body { + border-top: 1px solid var(--line); + padding-top: 14px; +} + +.dashboard-overview-detail, +.dashboard-overview-list, +.dashboard-overview-grid { + display: grid; + gap: 14px; +} + +.dashboard-overview-note, +.dashboard-overview-empty { + margin-top: 0; +} + +.dashboard-overview-card { + min-height: 100%; +} + .stat-card { padding: 18px; border-radius: 20px; @@ -1877,6 +2038,19 @@ tbody tr:hover { width: 100%; } + .dashboard-action-primary, + .dashboard-action-secondary { + grid-template-columns: 1fr; + } + + .dashboard-action-buttons { + justify-content: flex-start; + } + + .dashboard-overview-tabs { + grid-template-columns: 1fr; + } + .workspace-switch { padding: 10px 12px; } diff --git a/web/storyforge-web-v4/index.html b/web/storyforge-web-v4/index.html index 6f78058..5927b72 100644 --- a/web/storyforge-web-v4/index.html +++ b/web/storyforge-web-v4/index.html @@ -64,6 +64,10 @@ ¤ 额度 + ` + }; + vm.createContext(context); + vm.runInContext(source, context); + return context.window.StoryForgeDashboardHome; +} + +test("homepage v6 puts actions before overview and uses 1-primary-2-secondary structure", () => { + const mod = loadHomepageModule(); + const html = mod.renderDashboardHome({ + title: "项目总台", + workspaceLabel: "Kris", + currentProjectName: "品牌增长实验室", + contextLinks: [], + actionSourceLabel: "规则推荐", + summaryTabs: [ + { key: "project_progress", label: "项目进度", value: "3 / 5", hint: "2 项可继续推进", active: true }, + { key: "focus_accounts", label: "重点账号 / 对标", value: "2 个", hint: "1 个缺高分分析", active: false }, + { key: "production_jobs", label: "生产任务", value: "4 条", hint: "1 条待确认", active: false } + ], + activeTabLabel: "项目进度", + primaryAction: { + title: "先补抖音重点对标的高分作品分析", + reason: "最近有新作品,但还没形成高分样本。", + badges: ["最优先", "预计 10 分钟判断", "关联:重点账号"], + goAction: "goto-discovery", + goLabel: "去找对标", + agentLabel: "交给主 Agent" + }, + secondaryActions: [ + { title: "确认一个待执行的生产计划", reason: "素材和结论都在,只差最后确认。", goAction: "goto-production", goLabel: "去处理" }, + { title: "更新重点账号的跟踪摘要", reason: "有新动态,但不值得占据大块首页空间。", goAction: "goto-tracking", goLabel: "去处理" } + ], + overviewBodyHtml: "
这里只展示当前 tab 的核心状态。
" + }); + + assert.ok(html.includes("今天先做什么")); + assert.ok(html.includes("项目概览")); + assert.ok(html.indexOf("今天先做什么") < html.indexOf("项目概览")); + assert.match(html, /先补抖音重点对标的高分作品分析/); + assert.match(html, /确认一个待执行的生产计划/); + assert.match(html, /更新重点账号的跟踪摘要/); +}); + +test("homepage model builds one primary action, two secondary actions, and a rule fallback label", () => { + const mod = loadHomepageModule(); + assert.equal(typeof mod.createDashboardHomeModel, "function"); + + const model = mod.createDashboardHomeModel({ + workspaceLabel: "Kris", + currentProjectName: "品牌增长实验室", + trackedAccountsCount: 2, + assistantCount: 1, + jobCount: 4, + actionSourceLabel: "规则推荐", + dashboardOverviewTab: "project_progress" + }); + + assert.equal(model.actionSourceLabel, "规则推荐"); + assert.equal(model.secondaryActions.length, 2); + assert.match(model.primaryAction.title, /高分作品分析|继续补高分对标/); +}); + +test("homepage overview uses tab buttons and does not render legacy repeated sections", () => { + const mod = loadHomepageModule(); + const html = mod.renderDashboardHome({ + workspaceLabel: "Kris", + currentProjectName: "品牌增长实验室", + contextLinks: [], + actionSourceLabel: "主 Agent 优先推荐", + primaryAction: { title: "A", reason: "B", badges: [], goAction: "x", goLabel: "去处理", agentLabel: "交给主 Agent" }, + secondaryActions: [], + summaryTabs: [ + { key: "project_progress", label: "项目进度", value: "3 / 5", hint: "2 项可继续推进", active: true } + ], + activeTabLabel: "项目进度", + overviewBodyHtml: "
tab body
" + }); + + assert.ok(html.includes('data-action="select-dashboard-tab"')); + assert.ok(!html.includes("当前项目推进详情")); + assert.ok(!html.includes("重点账号 / 对标
右栏保留")); +});