feat: strengthen native mobile workbench shell
This commit is contained in:
@@ -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) => `
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"\}"/);
|
||||
|
||||
Reference in New Issue
Block a user