feat: redesign homepage dashboard workbench

This commit is contained in:
kris
2026-03-28 05:34:20 +08:00
parent 45f6dca984
commit 7bec3680fb
8 changed files with 949 additions and 125 deletions

View File

@@ -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"

View File

@@ -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)

View File

@@ -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 概览
- 管理员配置台通过独立导航进入,不再挤占首页主体
## 当前已接入的真实能力

View File

@@ -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 `
<div class="dashboard-overview-detail">
<div class="mini-grid">
<div class="mini-card">
<small>当前项目</small>
<strong>${escapeHtml(project?.name || "还没有项目")}</strong>
<span>${escapeHtml(project?.description || "先选定项目,首页动作才会真正收敛。")}</span>
</div>
<div class="mini-card">
<small>主流程进度</small>
<strong>${escapeHtml(`${progress.completed} / ${progress.total}`)}</strong>
<span>${escapeHtml(progress.nextStep)}</span>
</div>
<div class="mini-card">
<small>阶段风险</small>
<strong>${escapeHtml(progress.risk)}</strong>
<span>${escapeHtml(stats.jobs.length ? `当前项目任务 ${formatNumber(stats.jobs.length)}` : "还没有生产任务")}</span>
</div>
</div>
<div class="task-item compact dashboard-overview-note">
<h4>下一步建议</h4>
<p>${escapeHtml(project ? `当前项目建议先「${progress.nextStep}」,做完后再回到首页看下一条动作。` : "先创建项目或切换到已有项目,然后首页动作区会自动切到该项目。")}</p>
<div class="task-meta">
${actionTag(project ? "切换项目" : "去建项目", project ? "open-dashboard-project-switcher" : "goto-intake")}
${actionTag("去生产中心", "goto-production")}
</div>
</div>
</div>
`;
}
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 `
<div class="task-item compact dashboard-overview-empty">
<h4>还没有重点账号 / 对标</h4>
<p>${escapeHtml(project ? "先导入主页或把一个重点账号加入跟踪,首页才会持续给出更聪明的动作推荐。" : "先创建项目,再导入第一个重点账号。")}</p>
<div class="task-meta">
${actionTag("去找对标", "goto-discovery")}
${actionTag("去跟踪账号", "goto-tracking")}
</div>
</div>
`;
}
return `
<div class="three-col dashboard-overview-grid">
${items.map((item) => {
const account = item.account || item;
const accountId = account?.id || item.tracked_account_id || "";
return `
<div class="entity-card pad dashboard-overview-card">
<div class="entity-cell">
<div class="avatar-lg">${escapeHtml(initials(getAccountName(account)))}</div>
<div>
<div class="cell-title">${escapeHtml(getAccountName(account))}</div>
<div class="cell-desc">${escapeHtml(platformLabel(item.platform || getAccountPlatform(account)))} · ${escapeHtml(item.assistant_name || account.signature || "重点关注对象")}</div>
</div>
</div>
<div class="entity-meta">
<span class="tag blue">${escapeHtml(account.video_summary?.count ? `作品 ${formatNumber(account.video_summary.count)}` : "重点对象")}</span>
${account.sync_status ? `<span class="tag">${escapeHtml(account.sync_status)}</span>` : ""}
${accountId ? actionTag("查看详情", "select-account", `data-account-id="${escapeHtml(accountId)}"`) : ""}
</div>
</div>
`;
}).join("")}
</div>
`;
}
function renderDashboardProductionJobsBody(stats) {
const jobs = sortItemsByIsoDesc(stats.jobs, "updated_at").slice(0, 4);
if (!jobs.length) {
return `
<div class="task-item compact dashboard-overview-empty">
<h4>当前项目还没有生产任务</h4>
<p>先补完项目和对标,再从首页动作区或生产中心发起第一条任务。</p>
<div class="task-meta">
${actionTag("去生产中心", "goto-production")}
${actionTag("去找对标", "goto-discovery")}
</div>
</div>
`;
}
return `
<div class="list dashboard-overview-list">
${jobs.map((job) => `
<div class="task-item compact">
<h4>${escapeHtml(job.title || job.id)}</h4>
<p>${escapeHtml(brief(job.error || job.summary || `最近更新于 ${formatDateTime(job.updated_at || job.created_at)}`, 120))}</p>
<div class="task-meta">
<span class="tag ${statusTone(job.status)}">${escapeHtml(job.status || "-")}</span>
${job.line_type ? `<span class="tag">${escapeHtml(job.line_type)}</span>` : ""}
${actionTag("查看详情", "open-job-detail", `data-job-id="${escapeHtml(job.id)}"`)}
</div>
</div>
`).join("")}
</div>
`;
}
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: `
<div class="sheet-html">
<div class="task-item compact">
<h4>${escapeHtml(appState.dashboardActionReason.title)}</h4>
<p>${escapeHtml(appState.dashboardActionReason.reason)}</p>
<div class="task-meta">
<span class="tag blue">${escapeHtml(appState.dashboardActionReason.sourceLabel)}</span>
${appState.dashboardActionReason.badges.map((item) => `<span class="tag">${escapeHtml(item)}</span>`).join("")}
</div>
</div>
</div>
`
}
]
});
}
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 })}
<div style="margin-top:18px;">${renderStorageStatusPanel()}</div>
<div style="margin-top:18px;">${renderPlatformAgentPanel()}</div>
<div style="margin-top:18px;">${renderOneLinerActionRegistryPanel()}</div>
${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")}`,
`
<div class="layout-grid grid-5">
<div class="stat-card"><small>活跃项目</small><strong>${escapeHtml(formatNumber(projects.length))}</strong><div class="stat-foot"><span></span><span class="positive">${escapeHtml(formatNumber(projects.filter((item) => item.description).length))} </span></div></div>
<div class="stat-card"><small>导入内容</small><strong>${escapeHtml(formatNumber(appState.contentSources.length))}</strong><div class="stat-foot"><span> / / </span><span class="positive">${escapeHtml(formatNumber(appState.contentSources.filter((item) => item.source_kind === "creator_account").length))} </span></div></div>
<div class="stat-card"><small>跟踪账号</small><strong>${escapeHtml(formatNumber(trackedAccounts.length))}</strong><div class="stat-foot"><span></span><span class="positive">${escapeHtml(formatNumber(digestItems.length))} </span></div></div>
<div class="stat-card"><small>Agent</small><strong>${escapeHtml(formatNumber(assistants.length))}</strong><div class="stat-foot"><span></span><span class="warn">${escapeHtml(formatNumber(assistants.filter((item) => !(item.model_profile_id || "")).length))} </span></div></div>
<div class="stat-card"><small>生产任务</small><strong>${escapeHtml(formatNumber(jobs.length))}</strong><div class="stat-foot"><span> 20 </span><span class="positive">${escapeHtml(formatNumber(jobs.filter((item) => item.status === "completed").length))} </span></div></div>
</div>
<div style="margin-top:18px;">
${renderIntegrationOverviewPanel({ compact: true })}
</div>
<div class="layout-grid grid-main" style="margin-top:18px;">
<div class="side-stack">
<div class="hero-card">
<h3>当前主流程</h3>
<p>项目 Agent 调研 导入并绑定 生产 复盘</p>
<div class="chip-row" style="margin-top:14px;">
<span class="chip active">我的项目</span>
<span class="chip">找对标</span>
<span class="chip">跟踪账号</span>
<span class="chip">Agent</span>
<span class="chip">生产中心</span>
</div>
</div>
<div class="panel pad">
<div class="panel-head"><div><h3>今日重点动作</h3><div class="panel-subtitle"></div></div><span class="tag blue">${escapeHtml(formatNumber(actions.length))} </span></div>
<div class="list">
${actions.map((item, index) => `
<div class="task-item">
<h4>${index + 1}. ${escapeHtml(item)}</h4>
<p>${escapeHtml(index === 0 ? "先把最影响主流程的动作做掉。" : "做完上一步再继续推进。")}</p>
</div>
`).join("")}
</div>
</div>
${renderLastActionCard()}
<div class="panel pad">
<div class="panel-head"><div><h3>高分对标</h3><div class="panel-subtitle"></div></div></div>
<div class="three-col">
${accounts.slice(0, 3).map((account) => `
<div class="entity-card pad">
<div class="entity-cell">
<div class="avatar-lg">${escapeHtml(initials(getAccountName(account)))}</div>
<div>
<div class="cell-title">${escapeHtml(getAccountName(account))}</div>
<div class="cell-desc">${escapeHtml(account.signature || getAccountProfileUrl(account) || `已同步${platformLabel(getAccountPlatform(account))}账号`)}</div>
</div>
</div>
<div class="entity-meta">
<span class="tag blue">作品 ${escapeHtml(formatNumber(account.video_summary?.count))}</span>
<span class="tag green">均播 ${escapeHtml(formatNumber(account.video_summary?.avg_play))}</span>
<span class="tag">${escapeHtml(account.sync_status || "synced")}</span>
</div>
</div>
`).join("") || `<div class="empty-state">先到“找对标”导入一个账号。</div>`}
</div>
</div>
</div>
<div class="side-stack">
<div class="hero-card">
<h3>当前项目</h3>
<p>${escapeHtml(getSelectedProject()?.name || "还没有项目")}</p>
<div class="mini-grid">
<div class="mini-card"><small>知识库</small><strong>${escapeHtml(formatNumber(getSelectedProject() ? getProjectStats(getSelectedProject().id).knowledgeBases.length : 0))}</strong></div>
<div class="mini-card"><small>Agent</small><strong>${escapeHtml(formatNumber(getSelectedProject() ? getProjectStats(getSelectedProject().id).assistants.length : 0))}</strong></div>
<div class="mini-card"><small>任务</small><strong>${escapeHtml(formatNumber(getSelectedProject() ? getProjectStats(getSelectedProject().id).jobs.length : 0))}</strong></div>
<div class="mini-card"><small>来源</small><strong>${escapeHtml(formatNumber(getSelectedProject() ? getProjectStats(getSelectedProject().id).sources.length : 0))}</strong></div>
</div>
</div>
<div style="margin-top:18px;">
${renderPlatformAgentPanel()}
</div>
${renderStorageStatusPanel()}
<div class="panel pad">
<div class="panel-head"><div><h3>跟踪摘要</h3><div class="panel-subtitle"></div></div><span class="tag blue">${escapeHtml(daysSince(appState.lastSeenAt))} </span></div>
<div class="list">
${digestItems.map((item) => `
<div class="task-item">
<h4>${escapeHtml(item.account?.nickname || "未命名账号")} · ${escapeHtml(item.video?.title || item.video?.description || "最新作品")}</h4>
<p>${escapeHtml(item.summary || `最近发布时间 ${formatDateTime(item.video?.published_at)},适合继续交给 Agent 做借鉴点标注。`)}</p>
<div class="task-meta">
<span class="tag">${escapeHtml(getPlatformShortLabel(item.platform || item.account?.platform || getCurrentPlatformValue()))}</span>
<span class="tag green">${escapeHtml(item.is_high_value ? "高价值" : "可学习")}</span>
${item.assistant_name ? `<span class="tag">${escapeHtml(item.assistant_name)}</span>` : ""}
</div>
</div>
`).join("") || `<div class="task-item"><h4>还没有日报</h4><p>先把重点账号加入跟踪,日报才会开始累积。</p></div>`}
</div>
</div>
<div class="panel pad">
<div class="panel-head"><div><h3>最新异常</h3><div class="panel-subtitle"></div></div></div>
<div class="list">
${jobs.filter((item) => item.status === "failed").slice(0, 3).map((job) => `
<div class="task-item">
<h4>${escapeHtml(job.title)}</h4>
<p>${escapeHtml(job.error || "任务失败,请重新进入生产中心查看。")}</p>
<div class="task-meta"><span class="tag red">失败</span><span class="tag">${escapeHtml(job.line_type || "analysis")}</span></div>
</div>
`).join("") || `<div class="task-item"><h4>当前无异常</h4><p>最近任务运行正常,继续推进导入和生产即可。</p></div>`}
</div>
</div>
</div>
</div>
`
"先做最能推进当前项目的一步,再按需看概览。",
`${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;

View File

@@ -0,0 +1,259 @@
(function () {
function defaultEscapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
function safeArray(value) {
return Array.isArray(value) ? value : [];
}
function renderTags(items, escapeHtml) {
return safeArray(items)
.map((item) => `<span class="tag">${escapeHtml(item)}</span>`)
.join("");
}
function renderActionButton(config, escapeHtml) {
if (!config?.label || !config?.action) return "";
const tone = config.tone || "secondary";
const attrs = safeArray(config.attrs).join(" ");
return `
<button class="btn btn-${escapeHtml(tone)}" type="button" data-action="${escapeHtml(config.action)}" ${attrs}>
${escapeHtml(config.label)}
</button>
`;
}
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 `
<div class="dashboard-action-secondary">
<div class="dashboard-action-index">${index + 2}</div>
<div class="dashboard-action-copy">
<h5>${escapeHtml(item.title)}</h5>
<p>${escapeHtml(item.reason)}</p>
</div>
<div class="dashboard-action-buttons">
${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)}
</div>
</div>
`;
}
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 `
<div class="dashboard-home">
<div class="panel pad dashboard-context-panel">
<div class="panel-head">
<div>
<h3>项目总台</h3>
<div class="panel-subtitle">当前项目优先,先做决定再展开细节。</div>
</div>
</div>
<div class="dashboard-context-row">
<div class="dashboard-context-left">
<div class="dashboard-context-chip">
<strong>当前工作区</strong>
<span>${escapeHtml(model?.workspaceLabel || "当前工作区")}</span>
</div>
<button class="dashboard-context-chip dashboard-context-chip-button" type="button" data-action="open-dashboard-project-switcher">
<strong>当前项目</strong>
<span>${escapeHtml(model?.currentProjectName || "还没有项目")}</span>
</button>
</div>
<div class="dashboard-context-right">
${contextLinks.map((item) => `
<button class="dashboard-context-chip dashboard-context-chip-button" type="button" data-action="${escapeHtml(item.action || "")}">
<strong>${escapeHtml(item.label || "")}</strong>
<span>${escapeHtml(item.value || "0")}</span>
</button>
`).join("")}
</div>
</div>
</div>
<div class="panel pad dashboard-priority-panel">
<div class="panel-head">
<div>
<h3>今天先做什么</h3>
<div class="panel-subtitle">首页只保留 1 主 2 次动作,避免同时看太多说明。</div>
</div>
<span class="tag blue">${escapeHtml(model?.actionSourceLabel || "规则推荐")}</span>
</div>
<div class="dashboard-action-primary">
<div class="dashboard-action-copy">
<h4>${escapeHtml(primaryAction.title || "先建立当前项目的主流程")}</h4>
<p>${escapeHtml(primaryAction.reason || "先完成当前项目最影响推进的一步。")}</p>
<div class="task-meta">
${renderTags(primaryAction.badges, escapeHtml)}
</div>
</div>
<div class="dashboard-action-buttons">
${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)}
</div>
</div>
<div class="dashboard-action-secondary-list">
${secondaryActions.map((item, index) => renderSecondaryAction(item, index, escapeHtml)).join("")}
</div>
</div>
<div class="panel pad dashboard-overview-panel">
<div class="panel-head">
<div>
<h3>项目概览</h3>
<div class="panel-subtitle">同一块内容区按 tab 切换,不再把所有细节同时摊开。</div>
</div>
<span class="tag">${escapeHtml(model?.activeTabLabel || "项目进度")}</span>
</div>
<div class="dashboard-overview-tabs">
${summaryTabs.map((item) => `
<button
class="dashboard-overview-tab ${item.active ? "is-active" : ""}"
type="button"
data-action="select-dashboard-tab"
data-dashboard-tab="${escapeHtml(item.key)}"
>
<small>${escapeHtml(item.label)}</small>
<strong>${escapeHtml(item.value)}</strong>
<span>${escapeHtml(item.hint)}</span>
</button>
`).join("")}
</div>
<div class="dashboard-overview-body">${model?.overviewBodyHtml || ""}</div>
</div>
</div>
`;
}
window.StoryForgeDashboardHome = {
createDashboardHomeModel,
renderDashboardHome
};
})();

View File

@@ -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;
}

View File

@@ -64,6 +64,10 @@
<span class="icon">¤</span>
<span>额度</span>
</button>
<button class="nav-item hidden" data-screen-target="admin-workbench" data-role-gate="super_admin" hidden>
<span class="icon"></span>
<span>管理员配置台</span>
</button>
<button class="nav-item">
<span class="icon"></span>
<span>设置</span>
@@ -1909,6 +1913,8 @@
</div>
<div class="footer-note">StoryForge Web V4 prototype · credit system expressed as user-facing quota pools</div>
</section>
<section class="screen" data-screen="admin-workbench"></section>
</main>
</div>
@@ -1916,6 +1922,7 @@
<script src="./assets/storyforge-session-store.js"></script>
<script src="./assets/storyforge-api-client.js"></script>
<script src="./assets/storyforge-platform-runtime.js"></script>
<script src="./assets/storyforge-dashboard-home.js"></script>
<script src="./assets/app.js"></script>
</body>
</html>

View File

@@ -0,0 +1,100 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import vm from "node:vm";
const ROOT = path.resolve(process.cwd(), "web/storyforge-web-v4");
function loadHomepageModule() {
const source = fs.readFileSync(path.join(ROOT, "assets/storyforge-dashboard-home.js"), "utf8");
const context = {
window: {},
console,
escapeHtml: (value) => String(value ?? ""),
formatNumber: (value) => String(value ?? 0),
safeArray: (value) => Array.isArray(value) ? value : [],
button: (label, action, tone = "secondary") =>
`<button class="btn btn-${tone}" data-action="${action}">${label}</button>`
};
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: "<section>这里只展示当前 tab 的核心状态。</section>"
});
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: "<section>tab body</section>"
});
assert.ok(html.includes('data-action="select-dashboard-tab"'));
assert.ok(!html.includes("当前项目推进详情"));
assert.ok(!html.includes("重点账号 / 对标</h3><div class=\"panel-subtitle\">右栏保留"));
});