From 5ec86ae48a6ecfea9308b4aaf368aa50cf01e716 Mon Sep 17 00:00:00 2001 From: kris Date: Sun, 29 Mar 2026 23:46:28 +0800 Subject: [PATCH] feat: adapt workbench shell for mobile-native navigation --- web/storyforge-web-v4/assets/app.js | 79 +++++- web/storyforge-web-v4/assets/styles.css | 252 ++++++++++++++++-- web/storyforge-web-v4/index.html | 39 ++- .../tests/workbench-pages.test.mjs | 59 ++++ 4 files changed, 396 insertions(+), 33 deletions(-) diff --git a/web/storyforge-web-v4/assets/app.js b/web/storyforge-web-v4/assets/app.js index bc0b237..ff666be 100644 --- a/web/storyforge-web-v4/assets/app.js +++ b/web/storyforge-web-v4/assets/app.js @@ -6,6 +6,25 @@ const RECOVERY_HISTORY_KEY = STORAGE_KEY + ":recovery-history"; const navButtons = document.querySelectorAll("[data-screen-target]"); const screens = Array.from(document.querySelectorAll("[data-screen]")); const screenMap = Object.fromEntries(screens.map((screen) => [screen.dataset.screen, screen])); +const mobileScreenTitle = document.querySelector('[data-role="mobile-screen-title"]'); +const mobileProjectTitle = document.querySelector('[data-role="mobile-project-title"]'); +const mobileShellStatus = document.querySelector(".mobile-shell-status"); + +const SCREEN_LABELS = { + dashboard: "项目总台", + intake: "我的项目", + discovery: "找对标", + tracking: "跟踪账号", + owned: "我的账号", + playbook: "Agent", + strategy: "我的策略", + production: "生产中心", + review: "发布与复盘", + automation: "自动流程", + credits: "额度", + settings: "设置", + "admin-workbench": "管理员配置台" +}; const appState = { screen: window.location.hash.replace("#", "") || "dashboard", @@ -674,6 +693,30 @@ function setBusy(next, message = "") { renderAuthUi(); } +function getScreenLabel(screenId = appState.screen) { + return SCREEN_LABELS[screenId] || "StoryForge"; +} + +function getMobileProjectLabel() { + const selectedProject = typeof getSelectedProject === "function" ? getSelectedProject() : null; + if (selectedProject?.name) return selectedProject.name; + if (appState.selectedWorkspace?.project?.name) return appState.selectedWorkspace.project.name; + if (appState.selectedWorkspace?.account?.display_name) return appState.selectedWorkspace.account.display_name; + return appState.me?.display_name || appState.me?.username || "当前工作区"; +} + +function setMobileSidebarOpen(next) { + document.body.classList.toggle("mobile-sidebar-open", Boolean(next)); +} + +function syncMobileShell() { + if (mobileScreenTitle) mobileScreenTitle.textContent = getScreenLabel(); + if (mobileProjectTitle) mobileProjectTitle.textContent = getMobileProjectLabel(); + if (mobileShellStatus) { + mobileShellStatus.textContent = appState.busy ? "同步中" : (appState.session ? "已连接" : "连接状态"); + } +} + function getScreenFromHash() { const next = window.location.hash.replace("#", ""); return screenMap[next] ? next : "dashboard"; @@ -682,6 +725,7 @@ function getScreenFromHash() { function setScreen(id, options = {}) { const { updateHash = true } = options; const resolvedId = screenMap[id] ? id : "dashboard"; + setMobileSidebarOpen(false); appState.screen = resolvedId; navButtons.forEach((button) => { const active = button.dataset.screenTarget === resolvedId; @@ -693,6 +737,7 @@ function setScreen(id, options = {}) { if (updateHash && window.location.hash !== `#${resolvedId}`) { window.location.hash = resolvedId; } + syncMobileShell(); } function ensureAuthUi() { @@ -762,6 +807,7 @@ function renderAuthUi() { if (message) { message.textContent = appState.busy ? appState.message : (appState.autoConnectError || ""); } + syncMobileShell(); } function openAuthModal() { @@ -2384,7 +2430,7 @@ async function bootstrap() { const supportsLiveRecorderStatus = backendSupports("/v2/live-recorder/status"); const supportsLiveRecorderFiles = backendSupports("/v2/live-recorder/files"); const supportsLiveRecorderHealth = backendSupports("/v2/live-recorder/health"); - const [contentSources, platformPayloads, reviews, integrationHealth, localModelCatalog, liveRecorderSourcesPayload, liveRecorderStatus, liveRecorderFilesPayload, liveRecorderHealth] = await Promise.all([ + const [contentSources, platformPayloads, reviews, integrationHealth, localModelCatalog, liveRecorderSourcesPayload] = await Promise.all([ storyforgeFetch("/v2/content-sources").catch(() => []), Promise.all(runtimePlatforms.map(async (platform) => { const accountListPath = getWorkbenchRoute(platform, "accounts"); @@ -2424,10 +2470,14 @@ async function bootstrap() { supportsReviews ? storyforgeFetch("/v2/reviews").catch(() => []) : Promise.resolve([]), supportsIntegrationHealth ? storyforgeFetch("/v2/integrations/health").catch(() => null) : Promise.resolve(null), supportsLocalModels ? storyforgeFetch("/v2/integrations/local-models").catch(() => null) : Promise.resolve(null), - supportsLiveRecorderSources ? storyforgeFetch("/v2/live-recorder/sources").catch(() => ({ items: [] })) : Promise.resolve({ items: [] }), - supportsLiveRecorderStatus ? storyforgeFetch("/v2/live-recorder/status").catch(() => null) : Promise.resolve(null), - supportsLiveRecorderFiles ? storyforgeFetch("/v2/live-recorder/files?limit=16").catch(() => ({ items: [] })) : Promise.resolve({ items: [] }), - supportsLiveRecorderHealth ? storyforgeFetch("/v2/live-recorder/health").catch(() => null) : Promise.resolve(null) + supportsLiveRecorderSources ? storyforgeFetch("/v2/live-recorder/sources").catch(() => ({ items: [] })) : Promise.resolve({ items: [] }) + ]); + const liveRecorderIntegration = integrationHealth?.live_recorder || null; + const canLoadLiveRecorderRuntime = Boolean(liveRecorderIntegration?.reachable); + const [liveRecorderStatus, liveRecorderFilesPayload, liveRecorderHealth] = await Promise.all([ + supportsLiveRecorderStatus && canLoadLiveRecorderRuntime ? storyforgeFetch("/v2/live-recorder/status").catch(() => null) : Promise.resolve(null), + supportsLiveRecorderFiles && canLoadLiveRecorderRuntime ? storyforgeFetch("/v2/live-recorder/files?limit=16").catch(() => ({ items: [] })) : Promise.resolve({ items: [] }), + supportsLiveRecorderHealth && canLoadLiveRecorderRuntime ? storyforgeFetch("/v2/live-recorder/health").catch(() => null) : Promise.resolve(null) ]); const mergedAccounts = safeArray(platformPayloads) .flatMap((entry) => safeArray(entry.accounts)) @@ -5115,6 +5165,7 @@ function renderDetailTabs(stateKey, tabs) { +
+ 项目总台 + 当前项目 +
+ + +
+ diff --git a/web/storyforge-web-v4/tests/workbench-pages.test.mjs b/web/storyforge-web-v4/tests/workbench-pages.test.mjs index 6e8bea4..6086e5f 100644 --- a/web/storyforge-web-v4/tests/workbench-pages.test.mjs +++ b/web/storyforge-web-v4/tests/workbench-pages.test.mjs @@ -24,6 +24,56 @@ test("settings navigation and screen are real routes", () => { assert.match(APP, /window\.addEventListener\("hashchange"/); }); +test("mobile shell includes a native-like header, drawer toggle, and bottom tab bar", () => { + assert.match(HTML, / { + assert.match(CSS, /padding-top:\s*max\(12px,\s*env\(safe-area-inset-top\)\)/); + assert.match(CSS, /\.mobile-shell-bar\s*\{[\s\S]*position:\s*sticky/); + assert.match(CSS, /\.mobile-tabbar\s*\{[\s\S]*position:\s*fixed/); + assert.match(CSS, /\.mobile-sidebar-backdrop\s*\{[\s\S]*position:\s*fixed/); + assert.match(CSS, /\.mobile-sidebar-open\s+\.sidebar\s*\{[\s\S]*transform:\s*translateX\(0\)/); + assert.match(CSS, /\.content\s*\{[\s\S]*padding-bottom:\s*calc\(110px \+ env\(safe-area-inset-bottom\)\)/); + assert.match(CSS, /\.oneliner-fab\s*\{[\s\S]*bottom:\s*calc\(96px \+ env\(safe-area-inset-bottom\)\)/); +}); + +test("mobile shell javascript syncs drawer state and active labels with the current screen", () => { + const shell = extractBetween(APP, "function renderAuthUi()", "function openAuthModal()"); + const clicks = extractBetween(APP, "document.addEventListener(\"click\", async (event) => {", "document.addEventListener(\"submit\", async (event) => {"); + + assert.match(APP, /function setMobileSidebarOpen\(next\)/); + assert.match(APP, /function getScreenLabel\(screenId = appState\.screen\)/); + assert.match(APP, /function syncMobileShell\(\)/); + assert.match(shell, /syncMobileShell\(\);/); + assert.match(APP, /setMobileSidebarOpen\(false\);[\s\S]*appState\.screen = resolvedId;/); + assert.match(clicks, /name === "open-mobile-sidebar"/); + assert.match(clicks, /name === "close-mobile-sidebar"/); + assert.match(clicks, /action\.closest\("\.sidebar"\)/); +}); + +test("mobile layout turns screen actions and page tabs into native-like horizontal rails", () => { + assert.match(CSS, /@media \(max-width: 760px\)[\s\S]*\.action-row\s*\{[\s\S]*flex-wrap:\s*nowrap/); + assert.match(CSS, /@media \(max-width: 760px\)[\s\S]*\.action-row\s*\{[\s\S]*overflow-x:\s*auto/); + assert.match(CSS, /@media \(max-width: 760px\)[\s\S]*\.page-detail-tabs\s*\{[\s\S]*overflow-x:\s*auto/); + assert.match(CSS, /@media \(max-width: 760px\)[\s\S]*\.page-detail-tabs\s*\{[\s\S]*scroll-snap-type:\s*x proximity/); + assert.match(CSS, /@media \(max-width: 760px\)[\s\S]*\.page-detail-tabs \.tab\s*\{[\s\S]*flex:\s*0 0 auto/); +}); + +test("detail tab buttons expose the active state for touch navigation", () => { + const detailTabs = extractBetween(APP, "function renderDetailTabs(stateKey, tabs) {", "function renderDiscoveryOverviewSection("); + assert.match(detailTabs, /aria-pressed="\$\{tab\.value === active \? "true" : "false"\}"/); +}); + test("strategy navigation and screen are real routes", () => { assert.match(HTML, /data-screen-target="strategy"/); assert.match(HTML, /data-screen="strategy"/); @@ -105,6 +155,15 @@ test("bootstrap does not trust a stored session from a different backend", () => assert.match(bootstrap, /await ensureAutoSession\(\{ force: backendMismatch \}\);/); }); +test("bootstrap only loads live recorder runtime endpoints when the integration is reachable", () => { + const bootstrap = extractBetween(APP, "async function bootstrap()", "async function markTrackingDigestRead()"); + assert.match(bootstrap, /const liveRecorderIntegration = integrationHealth\?\.live_recorder \|\| null/); + assert.match(bootstrap, /const canLoadLiveRecorderRuntime = Boolean\(liveRecorderIntegration\?\.reachable\)/); + assert.match(bootstrap, /supportsLiveRecorderStatus && canLoadLiveRecorderRuntime/); + assert.match(bootstrap, /supportsLiveRecorderFiles && canLoadLiveRecorderRuntime/); + assert.match(bootstrap, /supportsLiveRecorderHealth && canLoadLiveRecorderRuntime/); +}); + test("oneliner submit failures stay inside the app instead of using a browser alert", () => { assert.doesNotMatch(APP, /alert\("OneLiner 调度失败:/); assert.match(APP, /presentActionFailure\(error,\s*"OneLiner 调度失败"\)/);