feat: redesign homepage dashboard workbench
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 概览
|
||||
- 管理员配置台通过独立导航进入,不再挤占首页主体
|
||||
|
||||
## 当前已接入的真实能力
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
259
web/storyforge-web-v4/assets/storyforge-dashboard-home.js
Normal file
259
web/storyforge-web-v4/assets/storyforge-dashboard-home.js
Normal file
@@ -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) => `<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
|
||||
};
|
||||
})();
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
100
web/storyforge-web-v4/tests/dashboard-home.test.mjs
Normal file
100
web/storyforge-web-v4/tests/dashboard-home.test.mjs
Normal 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\">右栏保留"));
|
||||
});
|
||||
Reference in New Issue
Block a user