feat: strengthen native mobile workbench shell

This commit is contained in:
kris
2026-03-30 01:09:25 +08:00
parent 25a050453e
commit 18351993df
4 changed files with 229 additions and 8 deletions

View File

@@ -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 `
<div class="screen-head">
<div>
<h2>${escapeHtml(title)}</h2>
<p>${escapeHtml(subtitle)}</p>
</div>
<div class="action-row">${actionsHtml || ""}</div>
<div class="action-row">
${actionLayout.primary ? `<div class="action-row-primary">${actionLayout.primary}</div>` : ""}
${actionLayout.secondary ? `<div class="action-row-secondary">${actionLayout.secondary}</div>` : ""}
</div>
</div>
${bodyHtml}
`;
}
function splitPrimaryAction(actionsHtml) {
const source = String(actionsHtml || "").trim();
if (!source) return { primary: "", secondary: "" };
const actions = source
.split(/(?=<button\b)/)
.map((item) => 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 = `
<span class="mobile-workspace-project-label">当前项目</span>
<strong>${escapeHtml(project?.name || (appState.session ? "已连接工作区" : "未连接工作区"))}</strong>
`;
}
if (mobileWorkspacePlatforms) {
mobileWorkspacePlatforms.innerHTML = getPlatformOptions().map((item) => `
<button
class="chip clickable-tag ${item.value === currentPlatform ? "active" : ""}"
type="button"
data-action="select-platform"
data-platform="${escapeHtml(item.value)}"
>
${escapeHtml(getPlatformShortLabel(item.value))}
</button>
`).join("");
}
if (platforms) {
const currentPlatform = getCurrentPlatformValue();
platforms.innerHTML = [
`<span class="chip">已接入平台</span>`,
...getPlatformOptions().map((item) => `

View File

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

View File

@@ -18,6 +18,17 @@
<span data-role="mobile-project-title">当前项目</span>
</div>
<button class="mobile-shell-status" type="button" data-action="open-auth">连接状态</button>
<div class="mobile-workspace-strip">
<button
class="mobile-workspace-project"
type="button"
data-role="mobile-workspace-project"
data-action="open-dashboard-project-switcher"
>
当前项目
</button>
<div class="mobile-workspace-platforms" data-role="mobile-workspace-platforms"></div>
</div>
</div>
<div class="mobile-sidebar-backdrop" data-action="close-mobile-sidebar" aria-hidden="true"></div>
<div class="app-shell">

View File

@@ -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, /<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"/);
assert.match(HTML, /class="mobile-shell-bar"/);
assert.match(HTML, /class="mobile-workspace-strip"/);
assert.match(HTML, /data-role="mobile-workspace-project"/);
assert.match(HTML, /data-role="mobile-workspace-platforms"/);
assert.match(HTML, /data-action="open-mobile-sidebar"/);
assert.match(HTML, /class="mobile-tabbar"/);
assert.match(HTML, /class="mobile-sidebar-backdrop"/);
assert.match(HTML, /class="mobile-workspace-summary"/);
assert.match(HTML, /data-screen-target="dashboard"[\s\S]*mobile-tabbar-item/);
assert.match(HTML, /data-screen-target="intake"[\s\S]*mobile-tabbar-item/);
assert.match(HTML, /data-screen-target="discovery"[\s\S]*mobile-tabbar-item/);
@@ -46,7 +48,7 @@ test("mobile shell styling uses safe-area padding, drawer navigation, and 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\)\)/);
assert.match(CSS, /@media \(max-width: 760px\)[\s\S]*\.mobile-workspace-summary\s*\{[\s\S]*display:\s*flex/);
assert.match(CSS, /@media \(max-width: 760px\)[\s\S]*\.mobile-workspace-strip\s*\{[\s\S]*display:\s*grid/);
assert.match(CSS, /@media \(max-width: 760px\)[\s\S]*\.workspace-switch span\s*\{[\s\S]*display:\s*none/);
assert.match(CSS, /@media \(max-width: 760px\)[\s\S]*\.auth-status\s*\{[\s\S]*display:\s*none/);
assert.match(CSS, /@media \(max-width: 760px\)[\s\S]*\.oneliner-fab-text\s*\{[\s\S]*display:\s*none/);
@@ -59,7 +61,8 @@ test("mobile shell javascript syncs drawer state and active labels with the curr
assert.match(APP, /function setMobileSidebarOpen\(next\)/);
assert.match(APP, /function getScreenLabel\(screenId = appState\.screen\)/);
assert.match(APP, /function syncMobileShell\(\)/);
assert.match(APP, /const mobileWorkspaceSummary = document\.querySelector\("\.mobile-workspace-summary"\)/);
assert.match(APP, /const mobileWorkspaceProject = document\.querySelector\('\[data-role="mobile-workspace-project"\]'\)/);
assert.match(APP, /const mobileWorkspacePlatforms = document\.querySelector\('\[data-role="mobile-workspace-platforms"\]'\)/);
assert.match(shell, /syncMobileShell\(\);/);
assert.match(APP, /setMobileSidebarOpen\(false\);[\s\S]*appState\.screen = resolvedId;/);
assert.match(clicks, /name === "open-mobile-sidebar"/);
@@ -67,6 +70,16 @@ test("mobile shell javascript syncs drawer state and active labels with the curr
assert.match(clicks, /action\.closest\("\.sidebar"\)/);
});
test("mobile workspace strip stays available for project and platform switching", () => {
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"\}"/);