diff --git a/web/storyforge-web-v4/assets/app.js b/web/storyforge-web-v4/assets/app.js index e183b8c..e590a72 100644 --- a/web/storyforge-web-v4/assets/app.js +++ b/web/storyforge-web-v4/assets/app.js @@ -722,14 +722,35 @@ function getScreenFromHash() { return screenMap[next] ? next : "dashboard"; } +function getMobileTabGroup(screenId = appState.screen) { + const groups = { + dashboard: "dashboard", + credits: "dashboard", + settings: "dashboard", + intake: "intake", + owned: "intake", + discovery: "discovery", + tracking: "discovery", + production: "production", + review: "production", + automation: "production", + playbook: "playbook", + strategy: "playbook", + "admin-workbench": "playbook" + }; + return groups[screenId] || screenId || "dashboard"; +} + function setScreen(id, options = {}) { const { updateHash = true } = options; const resolvedId = screenMap[id] ? id : "dashboard"; + const mobileGroup = getMobileTabGroup(resolvedId); setMobileSidebarOpen(false); appState.screen = resolvedId; navButtons.forEach((button) => { const active = button.dataset.screenTarget === resolvedId; - button.classList.toggle("is-active", active); + const mobileGroupActive = button.classList.contains("mobile-tabbar-item") && button.dataset.screenTarget === mobileGroup; + button.classList.toggle("is-active", active || mobileGroupActive); }); screens.forEach((screen) => { screen.classList.toggle("is-active", screen.dataset.screen === resolvedId); @@ -4359,18 +4380,36 @@ async function saveCandidateAsBenchmark(candidateIndex, relationType = "benchmar } function screenShell(title, subtitle, actionsHtml, bodyHtml) { + const actionLayout = splitPrimaryAction(actionsHtml); return `

${escapeHtml(title)}

${escapeHtml(subtitle)}

-
${actionsHtml || ""}
+
+ ${actionLayout.primary ? `
${actionLayout.primary}
` : ""} + ${actionLayout.secondary ? `
${actionLayout.secondary}
` : ""} +
${bodyHtml} `; } +function splitPrimaryAction(actionsHtml) { + const source = String(actionsHtml || "").trim(); + if (!source) return { primary: "", secondary: "" }; + const actions = source + .split(/(?= item.trim()) + .filter(Boolean); + if (!actions.length) return { primary: source, secondary: "" }; + return { + primary: actions[0], + secondary: actions.slice(1).join("") + }; +} + function button(label, action, tone = "secondary", options = {}) { const classes = ["btn", `btn-${tone}`]; if (options.className) classes.push(options.className); @@ -6616,11 +6655,15 @@ function renderSettingsScreen() { function renderTopbar() { const workspaceStrong = document.querySelector(".workspace-switch strong"); const workspaceSpan = document.querySelector(".workspace-switch span"); + const mobileWorkspaceProject = document.querySelector('[data-role="mobile-workspace-project"]'); + const mobileWorkspacePlatforms = document.querySelector('[data-role="mobile-workspace-platforms"]'); const searchInput = document.querySelector(".search input"); const avatar = document.querySelector(".avatar"); const topPills = document.querySelectorAll(".top-pill"); const platforms = document.querySelector(".topbar-left .chip-row"); const project = getSelectedProject(); + const currentPlatform = getCurrentPlatformValue(); + const connectedLabel = appState.busy ? "同步中" : (appState.session ? "已连接" : "待连接"); if (workspaceStrong) { workspaceStrong.textContent = project?.name || (appState.session ? "已连接工作区" : "未连接工作区"); } @@ -6641,8 +6684,26 @@ function renderTopbar() { topPills[1].textContent = `对标 ${formatNumber(appState.accounts.length)}`; topPills[2].textContent = `任务 ${formatNumber(appState.dashboard?.recent_jobs?.length || 0)}`; } + if (mobileWorkspaceProject) { + mobileWorkspaceProject.dataset.action = "open-dashboard-project-switcher"; + mobileWorkspaceProject.innerHTML = ` + 当前项目 + ${escapeHtml(project?.name || (appState.session ? "已连接工作区" : "未连接工作区"))} + `; + } + if (mobileWorkspacePlatforms) { + mobileWorkspacePlatforms.innerHTML = getPlatformOptions().map((item) => ` + + `).join(""); + } if (platforms) { - const currentPlatform = getCurrentPlatformValue(); platforms.innerHTML = [ `已接入平台`, ...getPlatformOptions().map((item) => ` diff --git a/web/storyforge-web-v4/assets/styles.css b/web/storyforge-web-v4/assets/styles.css index 1805d6f..6580add 100644 --- a/web/storyforge-web-v4/assets/styles.css +++ b/web/storyforge-web-v4/assets/styles.css @@ -123,6 +123,57 @@ select { color: var(--blue-700); } +.mobile-workspace-strip { + display: none; +} + +.mobile-workspace-project, +.mobile-workspace-platforms { + min-width: 0; +} + +.mobile-workspace-project { + display: inline-flex; + align-items: flex-start; + justify-content: space-between; + gap: 6px; + border: 1px solid rgba(181, 205, 231, 0.92); + background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(240, 247, 255, 0.98)); + border-radius: 16px; + padding: 10px 12px; + box-shadow: 0 10px 24px rgba(52, 83, 122, 0.08); + color: var(--ink); + text-align: left; +} + +.mobile-workspace-project strong { + display: block; + font-size: 14px; + line-height: 1.35; +} + +.mobile-workspace-project-label { + display: block; + font-size: 11px; + color: var(--muted); +} + +.mobile-workspace-platforms { + display: flex; + gap: 6px; + overflow-x: auto; + scrollbar-width: none; +} + +.mobile-workspace-platforms::-webkit-scrollbar { + display: none; +} + +.mobile-workspace-platforms .chip { + flex: 0 0 auto; + white-space: nowrap; +} + .mobile-sidebar-backdrop { position: fixed; inset: 0; @@ -601,6 +652,12 @@ select { display: flex; gap: 10px; flex-wrap: wrap; + align-items: center; +} + +.action-row-primary, +.action-row-secondary { + display: contents; } .btn { @@ -959,6 +1016,9 @@ select { } .tag { + display: inline-flex; + align-items: center; + justify-content: center; padding: 5px 9px; border-radius: 999px; background: #f6f9fe; @@ -1579,6 +1639,9 @@ tbody tr:hover { } .tab { + display: inline-flex; + align-items: center; + justify-content: center; padding: 8px 12px; border-radius: 999px; border: 1px solid var(--line); @@ -1970,11 +2033,27 @@ tbody tr:hover { } .auth-status { + display: none; max-width: none; width: 100%; grid-column: 1 / -1; } + .mobile-workspace-strip { + display: grid; + grid-column: 1 / -1; + gap: 8px; + padding-bottom: 2px; + } + + .mobile-workspace-project { + width: 100%; + } + + .mobile-workspace-platforms { + padding-bottom: 2px; + } + .auth-modal-backdrop, .action-modal-backdrop, .oneliner-backdrop { @@ -2054,6 +2133,23 @@ tbody tr:hover { .action-row { width: 100%; + display: grid; + gap: 8px; + padding-bottom: 2px; + } + + .action-row-primary { + display: block; + } + + .action-row-primary .btn { + width: 100%; + justify-content: center; + } + + .action-row-secondary { + display: flex; + gap: 8px; flex-wrap: nowrap; overflow-x: auto; overscroll-behavior-x: contain; @@ -2062,17 +2158,30 @@ tbody tr:hover { scrollbar-width: none; } - .action-row .btn { + .action-row-secondary .btn { flex: 0 0 auto; min-width: max-content; scroll-snap-align: start; } - .action-row::-webkit-scrollbar, + .action-row-secondary::-webkit-scrollbar, .page-detail-tabs::-webkit-scrollbar { display: none; } + .btn, + .tab, + .tag.clickable-tag { + min-height: 44px; + display: inline-flex; + align-items: center; + justify-content: center; + } + + .tag.clickable-tag { + padding-inline: 14px; + } + .page-detail-tabs { flex-wrap: nowrap; overflow-x: auto; @@ -2175,6 +2284,10 @@ tbody tr:hover { flex: 1 1 calc(50% - 4px); } + .workspace-switch span { + display: none; + } + .mobile-account-list { padding: 12px; } diff --git a/web/storyforge-web-v4/index.html b/web/storyforge-web-v4/index.html index e29dfbf..de07518 100644 --- a/web/storyforge-web-v4/index.html +++ b/web/storyforge-web-v4/index.html @@ -18,6 +18,17 @@ 当前项目 +
+ +
+
diff --git a/web/storyforge-web-v4/tests/workbench-pages.test.mjs b/web/storyforge-web-v4/tests/workbench-pages.test.mjs index bfd70eb..ee2beec 100644 --- a/web/storyforge-web-v4/tests/workbench-pages.test.mjs +++ b/web/storyforge-web-v4/tests/workbench-pages.test.mjs @@ -27,10 +27,12 @@ test("settings navigation and screen are real routes", () => { test("mobile shell includes a native-like header, drawer toggle, and bottom tab bar", () => { assert.match(HTML, / { + const topbar = extractBetween(APP, "function renderTopbar()", "function syncRoleGatedNav()"); + + assert.match(topbar, /data-role="mobile-workspace-project"/); + assert.match(topbar, /data-role="mobile-workspace-platforms"/); + assert.match(topbar, /mobileWorkspaceProject\.dataset\.action = "open-dashboard-project-switcher"/); + assert.match(topbar, /data-action="select-platform"/); + assert.match(CSS, /@media \(max-width: 760px\)[\s\S]*\.mobile-workspace-strip\s*\{[\s\S]*display:\s*grid/); +}); + 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/); @@ -82,6 +95,29 @@ 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 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/); +}); + +test("mobile screen heads split the first action into a primary rail and keep the rest in a secondary strip", () => { + assert.match(APP, /function splitPrimaryAction\(actionsHtml\)/); + assert.match(APP, /class="action-row-primary"/); + assert.match(APP, /class="action-row-secondary"/); + assert.match(CSS, /@media \(max-width: 760px\)[\s\S]*\.action-row\s*\{[\s\S]*display:\s*grid/); + assert.match(CSS, /@media \(max-width: 760px\)[\s\S]*\.action-row-primary\s+\.btn\s*\{[\s\S]*width:\s*100%/); + assert.match(CSS, /@media \(max-width: 760px\)[\s\S]*\.action-row-secondary\s*\{[\s\S]*overflow-x:\s*auto/); +}); + +test("mobile bottom navigation stays highlighted for grouped related screens", () => { + assert.match(APP, /function getMobileTabGroup\(screenId = appState\.screen\)/); + assert.match(APP, /tracking:\s*"discovery"/); + assert.match(APP, /review:\s*"production"/); + assert.match(APP, /strategy:\s*"playbook"/); + assert.match(APP, /credits:\s*"dashboard"/); + assert.match(APP, /button\.classList\.toggle\("is-active", active \|\| mobileGroupActive\)/); +}); + 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"\}"/);