From 566e412a3ac4dabdcda081d477bf0e33327aa132 Mon Sep 17 00:00:00 2001 From: kris Date: Mon, 30 Mar 2026 01:37:42 +0800 Subject: [PATCH] feat: finish mobile-native workbench flows --- web/storyforge-web-v4/assets/app.js | 140 ++++++++++++++++-- web/storyforge-web-v4/assets/styles.css | 31 +++- .../tests/workbench-pages.test.mjs | 51 +++++++ 3 files changed, 211 insertions(+), 11 deletions(-) diff --git a/web/storyforge-web-v4/assets/app.js b/web/storyforge-web-v4/assets/app.js index 7e64cf6..d2f178a 100644 --- a/web/storyforge-web-v4/assets/app.js +++ b/web/storyforge-web-v4/assets/app.js @@ -866,6 +866,7 @@ function ensureActionUi() { modal.className = "action-modal-backdrop hidden"; modal.innerHTML = `
+

快速操作

@@ -992,6 +993,7 @@ function ensureOneLinerUi() { panel.className = "oneliner-backdrop hidden"; panel.innerHTML = `
+

OneLiner

@@ -5150,13 +5152,35 @@ function renderProjectsScreen() { `${button("新建项目", "create-project", "primary")} ${button("导入作品", "open-import-video-link")} ${button("导入文本", "open-import-text")} ${button("上传视频", "open-upload-video")} ${button("交给主 Agent", "handoff-to-main-agent", "secondary", { attrs: intakeHandoffAttrs })}`, ` ${renderMainAgentLandingNotice("intake")} -
+

当前项目

${escapeHtml(selectedProject?.name || "还没有项目")} · ${escapeHtml(selectedProject?.description || "创建后即可承接对标、Agent 和生产任务。")}

${projects.map((project) => `${escapeHtml(project.name)}`).join("") || `暂无项目`}
+
+
+ 当前项目任务 + ${escapeHtml(selectedProject?.name || "未选项目")} +
+

${escapeHtml( + selectedProject + ? `先围绕 ${selectedProject.name} 决定是继续导入内容、切换项目,还是直接交给主 Agent 推进。` + : "先创建或切换到一个项目,再继续绑定账号和推进生产。" + )}

+
+ ${actionTag(selectedProject ? "导入作品" : "新建项目", selectedProject ? "open-import-video-link" : "create-project")} + ${actionTag("切换项目", "open-dashboard-project-switcher")} + ${actionTag("交给主 Agent", "handoff-to-main-agent", intakeHandoffAttrs)} +
+
+
+ 当前项目 ${escapeHtml(selectedProject?.name || "未选项目")} + 项目 ${escapeHtml(formatNumber(projects.length))} + 内容源 ${escapeHtml(formatNumber(appState.contentSources.length))} + ${escapeHtml(formatNumber(safeArray(appState.dashboard.recent_jobs).length))} 个任务 +
@@ -5598,7 +5622,7 @@ function renderTrackingScreen() { `${button("同步全部", "refresh-tracking")} ${button("标记已读", "mark-tracking-read")} ${button("交给主 Agent", "handoff-to-main-agent", "secondary", { attrs: trackingHandoffAttrs })} ${button("跳到找对标", "goto-discovery", "primary")}`, ` ${renderMainAgentLandingNotice("tracking")} -
+

日报逻辑

按上次打开后汇总。上次打开距今 ${escapeHtml(daysSince(platformCursor))} 天,本次优先展示有更新且值得借鉴的内容。

@@ -5606,15 +5630,34 @@ function renderTrackingScreen() { 上次已读 ${escapeHtml(cursorLabel)}
+
+
+ 当前跟踪任务 + ${escapeHtml(getPlatformShortLabel(currentPlatform))} +
+

${escapeHtml( + digestItems.length + ? `先看最近 ${Math.min(digestItems.length, 12)} 条更新日报,再决定同步重点账号还是回到找对标。` + : trackedAccounts.length + ? "先同步重点跟踪账号,等新作品出现后再回来看日报。" + : "先去找对标把值得持续观察的账号加入跟踪。" + )}

+
+ ${actionTag(digestItems.length ? "标记已读" : "同步全部", digestItems.length ? "mark-tracking-read" : "refresh-tracking")} + ${actionTag("跳到找对标", "goto-discovery")} + ${actionTag("交给主 Agent", "handoff-to-main-agent", trackingHandoffAttrs)} +
+
+
+ 跟踪 ${escapeHtml(formatNumber(trackedAccounts.length))} + 日报 ${escapeHtml(formatNumber(digestItems.length))} + ${escapeHtml(daysSince(platformCursor))} 天窗口 + ${escapeHtml(getPlatformShortLabel(currentPlatform))} +

跟踪列表

真实跟踪对象与绑定 Agent
${escapeHtml(formatNumber(trackedAccounts.length))} 个
-
- 跟踪 ${escapeHtml(formatNumber(trackedAccounts.length))} - 日报 ${escapeHtml(formatNumber(digestItems.length))} - ${escapeHtml(daysSince(platformCursor))} 天窗口 -
${trackedAccounts.map((item) => `
@@ -5687,7 +5730,7 @@ function renderAutomationScreen() { `${button("刷新", "refresh-data")} ${button("交给主 Agent", "handoff-to-main-agent", "secondary", { attrs: automationHandoffAttrs })} ${renderPipelineButton("aiVideo")} ${renderPipelineButton("realCut")} ${button("去生产", "goto-production", "primary")}`, ` ${renderMainAgentLandingNotice("automation")} -
+

自动流程

当前按真实任务量和依赖健康状态给出看板,自动流程受阻时会直接在这里拦住动作。

@@ -5705,6 +5748,29 @@ function renderAutomationScreen() {
${escapeHtml(overview.headline)}
+
+
+ 当前自动流程任务 + ${escapeHtml(tabs.find((tab) => tab.value === activeTab)?.label || "依赖健康")} +
+

${escapeHtml( + activeTab === "guards" + ? "先确认 AI 视频、实拍剪辑这类动作是否被拦截,再决定回到生产还是交给主 Agent。" + : "先看当前依赖健康,再决定哪些动作可以继续自动推进。" + )}

+
+ ${activeTab === "guards" + ? `${renderPipelineButton("aiVideo")} ${renderPipelineButton("realCut")} ${actionTag("交给主 Agent", "handoff-to-main-agent", automationHandoffAttrs)}` + : `${actionTag("刷新", "refresh-data")} ${actionTag("动作防呆", "select-page-tab", `data-page-tab-key="automationDetailTab" data-page-tab-value="guards"`)} ${actionTag("交给主 Agent", "handoff-to-main-agent", automationHandoffAttrs)}` + } +
+
+
+ 分析 ${escapeHtml(formatNumber(analysisJobs))} + AI 视频 ${escapeHtml(formatNumber(aiVideoJobs))} + ${escapeHtml(formatNumber(realCutJobs))} 实拍 + ${escapeHtml(overview.headline)} +
${renderDetailTabs("automationDetailTab", tabs)} ${activeTab === "health" ? renderIntegrationOverviewPanel({ showActions: false }) : `
@@ -5750,7 +5816,7 @@ function renderOwnedScreen() { "这里先用当前登录账号和最近产出组合成第一版总览。", `${button("刷新", "refresh-data")} ${button("去 Agent", "goto-playbook", "primary")}`, ` -
+
${escapeHtml(initials(me.display_name || me.username))}
@@ -5765,6 +5831,28 @@ function renderOwnedScreen() {
素材${escapeHtml(formatNumber(appState.documents.length))}
+
+
+ 当前账号任务 + ${escapeHtml(me.display_name || me.username || "当前账号")} +
+

${escapeHtml( + activeJobs + ? "先处理当前待推进任务,再决定是否继续补 Agent 或整理项目信息。" + : "当前任务压力不高,更适合补 Agent 策略、整理项目说明或继续跟踪。" + )}

+
+ ${actionTag(activeJobs ? "去生产中心" : "去 Agent", activeJobs ? "goto-production" : "goto-playbook")} + ${actionTag("去我的项目", "goto-intake")} + ${actionTag("看跟踪账号", "goto-tracking")} +
+
+
+ 当前项目 ${escapeHtml(selectedProject?.name || "未选项目")} + ${escapeHtml(getSelectedAssistant()?.name || "未选 Agent")} + 待推进 ${escapeHtml(formatNumber(activeJobs))} + 已完成 ${escapeHtml(formatNumber(completedJobs))} +
@@ -6431,6 +6519,40 @@ function renderReviewScreen() { `${button("写复盘", "open-create-review")} ${button("刷新", "refresh-data")} ${button("交给主 Agent", "handoff-to-main-agent", "secondary", { attrs: reviewHandoffAttrs })} ${button("去生产", "goto-production", "primary")}`, ` ${renderMainAgentLandingNotice("review")} +
+

复盘工作区

+

${escapeHtml(project?.name || "当前项目")} · 先把完成任务沉淀成可追溯复盘,再决定是否回到生产继续推进。

+
+
已保存${escapeHtml(formatNumber(reviews.length))}
+
最近完成${escapeHtml(formatNumber(completed.length))}
+
当前项目${escapeHtml(project?.name || "未选项目")}
+
下一步${escapeHtml(completed.length ? "写复盘" : "回生产")}
+
+
+
+
+ 当前复盘任务 + ${escapeHtml(project?.name || "当前项目")} +
+

${escapeHtml( + completed.length + ? "先把最近完成任务写成复盘,再决定是否继续沉淀发布结论。" + : reviews.length + ? "先回看已保存复盘,再决定是否回到生产继续推进。" + : "当前还没有可用复盘,先回到生产中心跑出一条完成链路。" + )}

+
+ ${actionTag(completed.length ? "写复盘" : "去生产", completed.length ? "open-review-from-job" : "goto-production", completed[0]?.id ? `data-job-id="${escapeHtml(completed[0].id)}"` : "")} + ${actionTag("刷新", "refresh-data")} + ${actionTag("交给主 Agent", "handoff-to-main-agent", reviewHandoffAttrs)} +
+
+
+ 已保存 ${escapeHtml(formatNumber(reviews.length))} + 最近完成 ${escapeHtml(formatNumber(completed.length))} + ${escapeHtml(project?.name || "未选项目")} + ${escapeHtml(completed.length ? "可继续写复盘" : "先回生产")} +
diff --git a/web/storyforge-web-v4/assets/styles.css b/web/storyforge-web-v4/assets/styles.css index 1184075..c3c6edb 100644 --- a/web/storyforge-web-v4/assets/styles.css +++ b/web/storyforge-web-v4/assets/styles.css @@ -533,6 +533,14 @@ select { padding: 22px; } +.sheet-handle { + width: 48px; + height: 5px; + border-radius: 999px; + background: rgba(133, 155, 189, 0.34); + margin: 0 auto 6px; +} + .auth-head { display: flex; align-items: start; @@ -2068,17 +2076,21 @@ tbody tr:hover { .action-modal { width: 100%; max-height: min(90vh, 100%); - border-radius: 20px; padding: 18px; } .oneliner-panel { width: 100%; height: min(88vh, 100%); - border-radius: 22px; padding: 18px 18px calc(18px + env(safe-area-inset-bottom)); } + .auth-modal, + .action-modal, + .oneliner-panel { + border-radius: 24px 24px 0 0; + } + .oneliner-head { flex-direction: column; align-items: flex-start; @@ -2100,6 +2112,12 @@ tbody tr:hover { .auth-actions { flex-direction: column-reverse; + position: sticky; + bottom: calc(env(safe-area-inset-bottom) * -1); + margin: 8px -18px -18px; + padding: 12px 18px calc(18px + env(safe-area-inset-bottom)); + background: linear-gradient(180deg, rgba(255, 255, 255, 0.78) 0%, rgba(255, 255, 255, 0.98) 28%); + backdrop-filter: blur(10px); } .auth-actions .btn { @@ -2107,6 +2125,15 @@ tbody tr:hover { justify-content: center; } + .oneliner-composer { + position: sticky; + bottom: calc(env(safe-area-inset-bottom) * -1); + margin: 0 -18px -18px; + padding: 12px 18px calc(18px + env(safe-area-inset-bottom)); + background: linear-gradient(180deg, rgba(255, 255, 255, 0.76) 0%, rgba(255, 255, 255, 0.985) 26%); + backdrop-filter: blur(10px); + } + .screen { margin-top: 14px; } diff --git a/web/storyforge-web-v4/tests/workbench-pages.test.mjs b/web/storyforge-web-v4/tests/workbench-pages.test.mjs index c2ced6e..62dc455 100644 --- a/web/storyforge-web-v4/tests/workbench-pages.test.mjs +++ b/web/storyforge-web-v4/tests/workbench-pages.test.mjs @@ -95,6 +95,14 @@ test("mobile shell removes duplicated desktop topbar and collapses the main agen assert.match(CSS, /@media \(max-width: 760px\)[\s\S]*\.oneliner-fab-text\s*\{[\s\S]*display:\s*none/); }); +test("mobile action sheets and oneliner runtime behave like bottom sheets", () => { + assert.match(APP, /class="sheet-handle"/); + assert.match(CSS, /\.sheet-handle\s*\{/); + assert.match(CSS, /@media \(max-width: 760px\)[\s\S]*\.auth-modal,\s*[\s\S]*\.action-modal,\s*[\s\S]*\.oneliner-panel\s*\{[\s\S]*border-radius:\s*24px 24px 0 0/); + assert.match(CSS, /@media \(max-width: 760px\)[\s\S]*\.auth-actions\s*\{[\s\S]*position:\s*sticky/); + assert.match(CSS, /@media \(max-width: 760px\)[\s\S]*\.oneliner-composer\s*\{[\s\S]*position:\s*sticky/); +}); + test("mobile touch targets raise tappable buttons, tabs, and action tags closer to native sizes", () => { assert.match(CSS, /@media \(max-width: 760px\)[\s\S]*\.btn,\s*[\s\S]*\.tab,\s*[\s\S]*\.tag\.clickable-tag\s*\{[\s\S]*min-height:\s*44px/); assert.match(CSS, /@media \(max-width: 760px\)[\s\S]*\.btn,\s*[\s\S]*\.tab,\s*[\s\S]*\.tag\.clickable-tag\s*\{[\s\S]*display:\s*inline-flex/); @@ -218,13 +226,23 @@ test("discovery and production screens expose mobile focus cards with next-step test("mobile heavy screens mark redundant desktop metric blocks for compact hiding", () => { const discovery = extractBetween(APP, "function renderDiscoveryScreen()", "function renderTrackingScreen()"); + const tracking = extractBetween(APP, "function renderTrackingScreen()", "function renderAutomationScreen()"); + const automation = extractBetween(APP, "function renderAutomationScreen()", "function renderOwnedScreen()"); + const owned = extractBetween(APP, "function renderOwnedScreen()", "function renderPlaybookScreen()"); + const projects = extractBetween(APP, "function renderProjectsScreen()", "function getActiveDetailTab("); const production = extractBetween(APP, "function renderProductionScreen()", "function renderReviewScreen()"); + const review = extractBetween(APP, "function renderReviewScreen()", "function renderStrategyScreen()"); const playbook = extractBetween(APP, "function renderPlaybookScreen()", "function renderProductionScreen()"); const strategy = extractBetween(APP, "function renderStrategyScreen()", "function renderCreditsScreen()"); const cssMobile = extractBetween(CSS, "@media (max-width: 760px) {", "@media (max-width: 560px) {"); assert.match(discovery, /discovery-selected-hero mobile-secondary-card/); + assert.match(tracking, /hero-card mobile-secondary-card/); + assert.match(automation, /hero-card mobile-secondary-card/); + assert.match(owned, /hero-card mobile-secondary-card/); + assert.match(projects, /hero-card mobile-secondary-card/); assert.match(production, /production-queue-grid/); + assert.match(review, /hero-card mobile-secondary-card/); assert.match(playbook, /hero-card mobile-secondary-card/); assert.match(strategy, /hero-card mobile-secondary-card/); assert.match(cssMobile, /\.discovery-selected-hero \.mini-grid/); @@ -233,6 +251,39 @@ test("mobile heavy screens mark redundant desktop metric blocks for compact hidi assert.match(cssMobile, /\.mobile-secondary-card/); }); +test("remaining mobile workbench screens expose focus cards and compact summaries", () => { + const projects = extractBetween(APP, "function renderProjectsScreen()", "function getActiveDetailTab("); + const tracking = extractBetween(APP, "function renderTrackingScreen()", "function renderAutomationScreen()"); + const automation = extractBetween(APP, "function renderAutomationScreen()", "function renderOwnedScreen()"); + const owned = extractBetween(APP, "function renderOwnedScreen()", "function renderPlaybookScreen()"); + const review = extractBetween(APP, "function renderReviewScreen()", "function renderStrategyScreen()"); + + assert.match(projects, /mobile-only mobile-flow-focus-card/); + assert.match(projects, /当前项目任务/); + assert.match(projects, /mobile-only compact-summary-row/); + assert.match(projects, /当前项目/); + + assert.match(tracking, /mobile-only mobile-flow-focus-card/); + assert.match(tracking, /当前跟踪任务/); + assert.match(tracking, /mobile-only compact-summary-row/); + assert.match(tracking, /日报/); + + assert.match(automation, /mobile-only mobile-flow-focus-card/); + assert.match(automation, /当前自动流程任务/); + assert.match(automation, /mobile-only compact-summary-row/); + assert.match(automation, /AI 视频/); + + assert.match(owned, /mobile-only mobile-flow-focus-card/); + assert.match(owned, /当前账号任务/); + assert.match(owned, /mobile-only compact-summary-row/); + assert.match(owned, /当前项目/); + + assert.match(review, /mobile-only mobile-flow-focus-card/); + assert.match(review, /当前复盘任务/); + assert.match(review, /mobile-only compact-summary-row/); + assert.match(review, /已保存/); +}); + test("projects screen uses an adaptive project grid instead of a fixed three-column squeeze", () => { const projects = extractBetween(APP, "function renderProjectsScreen()", "function getActiveDetailTab("); assert.match(projects, /project-status-grid/);