feat: adapt workbench shell for mobile-native navigation

This commit is contained in:
kris
2026-03-29 23:46:28 +08:00
parent 32c28fb7d6
commit 5ec86ae48a
4 changed files with 396 additions and 33 deletions

View File

@@ -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) {
<button
class="tab ${tab.value === active ? "active" : ""}"
type="button"
aria-pressed="${tab.value === active ? "true" : "false"}"
data-action="select-page-tab"
data-page-tab-key="${escapeHtml(stateKey)}"
data-page-tab-value="${escapeHtml(tab.value)}"
@@ -9697,6 +9748,18 @@ document.addEventListener("click", async (event) => {
const action = event.target.closest("[data-action]");
if (action) {
const name = action.dataset.action;
if (name === "open-mobile-sidebar") {
setMobileSidebarOpen(true);
return;
}
if (name === "close-mobile-sidebar") {
if (action.closest(".sidebar") || action.classList.contains("mobile-sidebar-backdrop")) {
setMobileSidebarOpen(false);
} else {
setMobileSidebarOpen(false);
}
return;
}
if (name === "open-auth") {
openAuthModal();
return;
@@ -10382,6 +10445,12 @@ window.addEventListener("hashchange", () => {
}
});
window.addEventListener("resize", () => {
if (window.innerWidth > 760) {
setMobileSidebarOpen(false);
}
});
ensureAuthUi();
renderAll();
bootstrap();

View File

@@ -33,6 +33,7 @@ html,
body {
margin: 0;
padding: 0;
min-height: 100%;
font-family: "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
background:
radial-gradient(circle at top left, rgba(129, 180, 255, 0.18), transparent 28%),
@@ -59,6 +60,128 @@ select {
width: 100%;
}
.mobile-shell-bar,
.mobile-tabbar,
.mobile-sidebar-backdrop,
.mobile-sidebar-close {
display: none;
}
.mobile-shell-bar {
position: sticky;
top: 0;
z-index: 45;
align-items: center;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 12px;
padding-top: max(12px, env(safe-area-inset-top));
padding: max(12px, env(safe-area-inset-top)) 14px 10px;
background: rgba(248, 251, 255, 0.92);
backdrop-filter: blur(18px);
border-bottom: 1px solid rgba(201, 220, 239, 0.82);
}
.mobile-shell-toggle,
.mobile-shell-status,
.mobile-sidebar-close {
border: 1px solid rgba(201, 220, 239, 0.82);
background: rgba(255, 255, 255, 0.88);
color: var(--text);
border-radius: 14px;
min-height: 42px;
padding: 0 14px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.mobile-shell-title-group {
min-width: 0;
display: grid;
gap: 2px;
}
.mobile-shell-title-group strong,
.mobile-shell-title-group span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mobile-shell-title-group strong {
font-size: 15px;
}
.mobile-shell-title-group span {
font-size: 12px;
color: var(--muted);
}
.mobile-shell-status {
font-size: 12px;
color: var(--blue-700);
}
.mobile-sidebar-backdrop {
position: fixed;
inset: 0;
z-index: 38;
background: rgba(24, 36, 51, 0.26);
opacity: 0;
pointer-events: none;
transition: opacity 0.22s ease;
}
.mobile-tabbar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 44;
padding: 10px 12px calc(10px + env(safe-area-inset-bottom));
background: rgba(248, 251, 255, 0.94);
backdrop-filter: blur(18px);
border-top: 1px solid rgba(201, 220, 239, 0.82);
box-shadow: 0 -10px 28px rgba(67, 93, 125, 0.12);
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 8px;
}
.mobile-tabbar-item {
border: none;
background: transparent;
color: var(--muted);
border-radius: 16px;
min-height: 54px;
display: grid;
place-items: center;
gap: 4px;
padding: 8px 6px;
text-align: center;
font-size: 11px;
}
.mobile-tabbar-item .icon {
width: 28px;
height: 28px;
border-radius: 10px;
background: var(--blue-50);
display: grid;
place-items: center;
font-size: 14px;
}
.mobile-tabbar-item.is-active {
color: var(--blue-700);
background: linear-gradient(180deg, #edf5ff 0%, #e6f0ff 100%);
box-shadow: inset 0 0 0 1px rgba(106, 164, 255, 0.22);
}
.mobile-tabbar-item.is-active .icon {
background: var(--blue-100);
}
.sidebar {
background: rgba(255, 255, 255, 0.82);
border-right: 1px solid rgba(201, 220, 239, 0.75);
@@ -1720,8 +1843,47 @@ tbody tr:hover {
display: none !important;
}
body.mobile-sidebar-open {
overflow: hidden;
}
.mobile-shell-bar,
.mobile-tabbar,
.mobile-sidebar-backdrop,
.mobile-sidebar-close {
display: grid;
}
.mobile-shell-toggle,
.mobile-shell-status,
.mobile-sidebar-close {
display: inline-flex;
}
.mobile-sidebar-open .mobile-sidebar-backdrop {
opacity: 1;
pointer-events: auto;
}
.sidebar {
padding: 14px 14px 12px;
position: fixed;
left: 0;
top: 0;
bottom: 0;
width: min(88vw, 320px);
height: 100vh;
z-index: 42;
transform: translateX(-104%);
transition: transform 0.22s ease;
border-right: 1px solid rgba(201, 220, 239, 0.82);
border-bottom: none;
padding: max(18px, env(safe-area-inset-top)) 14px calc(16px + env(safe-area-inset-bottom));
box-shadow: 24px 0 48px rgba(38, 63, 93, 0.16);
overflow: auto;
}
.mobile-sidebar-open .sidebar {
transform: translateX(0);
}
.brand {
@@ -1729,6 +1891,11 @@ tbody tr:hover {
padding: 4px 4px 12px;
}
.brand > div:nth-child(2) {
min-width: 0;
flex: 1 1 auto;
}
.brand-mark {
width: 38px;
height: 38px;
@@ -1754,12 +1921,15 @@ tbody tr:hover {
}
.content {
padding: 12px 12px 22px;
padding: 12px 12px calc(110px + env(safe-area-inset-bottom));
padding-bottom: calc(110px + env(safe-area-inset-bottom));
}
.topbar {
margin-top: 6px;
padding: 14px;
border-radius: 18px;
box-shadow: none;
}
.topbar-left .chip-row,
@@ -1778,6 +1948,11 @@ tbody tr:hover {
display: none;
}
.topbar-right .search,
.topbar-right .top-pill {
display: none;
}
.top-pill {
padding: 7px 10px;
}
@@ -1814,7 +1989,7 @@ tbody tr:hover {
width: 100%;
height: min(88vh, 100%);
border-radius: 22px;
padding: 18px;
padding: 18px 18px calc(18px + env(safe-area-inset-bottom));
}
.oneliner-head {
@@ -1854,20 +2029,51 @@ tbody tr:hover {
}
.screen-head h2 {
font-size: 24px;
display: none;
}
.screen-head p {
font-size: 12px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.action-row {
width: 100%;
flex-wrap: nowrap;
overflow-x: auto;
overscroll-behavior-x: contain;
scroll-snap-type: x proximity;
padding-bottom: 2px;
scrollbar-width: none;
}
.action-row .btn {
flex: 1 1 calc(50% - 10px);
min-width: 0;
flex: 0 0 auto;
min-width: max-content;
scroll-snap-align: start;
}
.action-row::-webkit-scrollbar,
.page-detail-tabs::-webkit-scrollbar {
display: none;
}
.page-detail-tabs {
flex-wrap: nowrap;
overflow-x: auto;
overscroll-behavior-x: contain;
scroll-snap-type: x proximity;
padding-bottom: 2px;
scrollbar-width: none;
}
.page-detail-tabs .tab {
flex: 0 0 auto;
white-space: nowrap;
scroll-snap-align: start;
}
.integration-summary {
@@ -1996,45 +2202,37 @@ tbody tr:hover {
row-gap: 6px;
}
.oneliner-fab {
right: 14px;
bottom: calc(96px + env(safe-area-inset-bottom));
padding: 11px 12px;
}
table {
min-width: 680px;
}
}
@media (max-width: 560px) {
.sidebar {
padding: 12px;
}
.brand {
padding: 2px 2px 10px;
}
.nav-group {
display: flex;
align-items: center;
display: grid;
gap: 8px;
overflow-x: auto;
padding-bottom: 4px;
overflow: visible;
padding-bottom: 0;
margin-top: 8px;
scrollbar-width: none;
}
.nav-group::-webkit-scrollbar {
display: none;
}
.nav-title {
padding: 0 4px 0 2px;
flex: 0 0 auto;
white-space: nowrap;
}
.nav-group .nav-item {
width: auto;
width: 100%;
margin-right: 0;
flex: 0 0 auto;
white-space: nowrap;
padding: 9px 11px;
}
@@ -2115,7 +2313,7 @@ tbody tr:hover {
.topbar-right .avatar {
order: 4;
align-self: flex-end;
align-self: flex-start;
}
.auth-inline .btn {
@@ -2135,7 +2333,7 @@ tbody tr:hover {
.oneliner-fab {
right: 14px;
bottom: 14px;
bottom: calc(94px + env(safe-area-inset-bottom));
padding: 11px 12px;
}
@@ -2188,7 +2386,7 @@ tbody tr:hover {
.oneliner-fab {
right: 12px;
bottom: 12px;
bottom: calc(92px + env(safe-area-inset-bottom));
gap: 8px;
}

View File

@@ -2,12 +2,24 @@
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#eef4fb" />
<title>StoryForge Web V4 Prototype</title>
<link rel="icon" href="./assets/favicon.svg" type="image/svg+xml" />
<link rel="stylesheet" href="./assets/styles.css" />
</head>
<body>
<div class="mobile-shell-bar">
<button class="mobile-shell-toggle" type="button" data-action="open-mobile-sidebar" aria-label="打开导航">
<span></span>
</button>
<div class="mobile-shell-title-group">
<strong data-role="mobile-screen-title">项目总台</strong>
<span data-role="mobile-project-title">当前项目</span>
</div>
<button class="mobile-shell-status" type="button" data-action="open-auth">连接状态</button>
</div>
<div class="mobile-sidebar-backdrop" data-action="close-mobile-sidebar" aria-hidden="true"></div>
<div class="app-shell">
<aside class="sidebar">
<div class="brand">
@@ -16,6 +28,9 @@
<h1>StoryForge</h1>
<p>多平台新媒体运营中台</p>
</div>
<button class="mobile-sidebar-close" type="button" data-action="close-mobile-sidebar" aria-label="关闭导航">
<span></span>
</button>
</div>
<div class="nav-group">
@@ -1923,6 +1938,28 @@
<section class="screen" data-screen="settings"></section>
</main>
</div>
<nav class="mobile-tabbar" aria-label="移动端主导航">
<button class="mobile-tabbar-item" type="button" data-screen-target="dashboard">
<span class="icon"></span>
<span>总台</span>
</button>
<button class="mobile-tabbar-item" type="button" data-screen-target="intake">
<span class="icon"></span>
<span>项目</span>
</button>
<button class="mobile-tabbar-item" type="button" data-screen-target="discovery">
<span class="icon"></span>
<span>对标</span>
</button>
<button class="mobile-tabbar-item" type="button" data-screen-target="production">
<span class="icon"></span>
<span>生产</span>
</button>
<button class="mobile-tabbar-item" type="button" data-screen-target="playbook">
<span class="icon"></span>
<span>Agent</span>
</button>
</nav>
<script src="./assets/storyforge-runtime-config.js"></script>
<script src="./assets/storyforge-session-store.js"></script>

View File

@@ -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, /<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"/);
assert.match(HTML, /class="mobile-shell-bar"/);
assert.match(HTML, /data-action="open-mobile-sidebar"/);
assert.match(HTML, /class="mobile-tabbar"/);
assert.match(HTML, /class="mobile-sidebar-backdrop"/);
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/);
assert.match(HTML, /data-screen-target="production"[\s\S]*mobile-tabbar-item/);
assert.match(HTML, /data-screen-target="playbook"[\s\S]*mobile-tabbar-item/);
});
test("mobile shell styling uses safe-area padding, drawer navigation, and fixed bottom navigation", () => {
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 调度失败"\)/);