feat: adapt workbench shell for mobile-native navigation
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 调度失败"\)/);
|
||||
|
||||
Reference in New Issue
Block a user