diff --git a/app/frontend/m/css/shell.css b/app/frontend/m/css/shell.css index d0939be..3f2a407 100644 --- a/app/frontend/m/css/shell.css +++ b/app/frontend/m/css/shell.css @@ -74,68 +74,8 @@ body { overflow-x: hidden; } -.m-entry-list { - list-style: none; - margin: 0; - padding: 0; - display: flex; - flex-direction: column; - gap: 8px; -} - -.m-entry { - display: flex; - align-items: center; - width: 100%; - height: 64px; - padding: 0 12px; - border: 0; - border-radius: 8px; - background: var(--surface); - color: var(--text-primary); - text-align: left; - cursor: pointer; - box-shadow: var(--elevation-card); -} - -.m-entry:active { - background: var(--surface-hover); -} - -.m-entry-icon { - display: inline-flex; - align-items: center; - justify-content: center; - width: 24px; - height: 24px; - margin-right: 12px; - color: var(--action); - flex: 0 0 auto; -} - -.m-entry-body { - flex: 1; - min-width: 0; - display: flex; - flex-direction: column; -} - -.m-entry-body strong { - font-size: var(--font-size-card-title); - font-weight: 600; - line-height: 1.4; -} - -.m-entry-body small { - font-size: 12px; - color: var(--text-secondary); - line-height: 1.4; -} - -.m-entry-chevron { - display: inline-flex; - color: var(--text-tertiary); - flex: 0 0 auto; +#m-app[data-tabbar="true"] .m-view { + padding-bottom: calc(var(--mobile-tabbar-height) + var(--mobile-safe-bottom) + var(--mobile-page-padding)); } .m-placeholder { @@ -337,3 +277,138 @@ body { opacity: 0.5; cursor: default; } + +.m-tabbar { + position: fixed; + left: 0; + right: 0; + bottom: var(--m-keyboard-inset); + z-index: 60; + display: flex; + align-items: stretch; + height: calc(var(--mobile-tabbar-height) + var(--mobile-safe-bottom)); + padding-bottom: var(--mobile-safe-bottom); + background: var(--surface); + border-top: 1px solid var(--border); +} + +.m-tabbar-item { + flex: 1; + min-width: 0; + min-height: var(--mobile-tabbar-height); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 3px; + border: 0; + background: transparent; + color: var(--text-tertiary); + cursor: pointer; + -webkit-tap-highlight-color: transparent; +} + +.m-tabbar-item:active { + background: var(--surface-hover); +} + +.m-tabbar-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + flex: 0 0 auto; +} + +.m-tabbar-label { + font-size: var(--mobile-tabbar-label-size); + line-height: 1.2; + color: inherit; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; +} + +.m-tabbar-item.active { + color: var(--action); +} + +.m-theme-section { + margin-bottom: 12px; +} + +.m-theme-row { + display: flex; + align-items: center; + gap: 12px; + padding: 12px; + border-radius: 12px; + background: var(--surface); + box-shadow: var(--elevation-card); +} + +.m-theme-row-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 44px; + height: 44px; + border-radius: 12px; + background: var(--action-soft); + color: var(--action); + flex: 0 0 auto; +} + +.m-theme-row-body { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; +} + +.m-theme-row-body strong { + font-size: var(--font-size-card-title); + font-weight: 600; + line-height: 1.4; +} + +.m-theme-row-body small { + font-size: 12px; + color: var(--text-secondary); + line-height: 1.4; +} + +.m-theme-switch { + position: relative; + flex: 0 0 auto; + width: 48px; + height: 28px; + padding: 0; + border: 0; + border-radius: 999px; + background: var(--border-strong); + cursor: pointer; + -webkit-tap-highlight-color: transparent; +} + +.m-theme-switch[aria-checked="true"] { + background: var(--action); +} + +.m-theme-switch-thumb { + position: absolute; + top: 3px; + left: 3px; + width: 22px; + height: 22px; + border-radius: 50%; + background: var(--text-inverse); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); + transition: transform 0.15s ease; +} + +.m-theme-switch[aria-checked="true"] .m-theme-switch-thumb { + transform: translateX(20px); +} diff --git a/app/frontend/m/css/tokens.css b/app/frontend/m/css/tokens.css index c00bf9d..9a07e5e 100644 --- a/app/frontend/m/css/tokens.css +++ b/app/frontend/m/css/tokens.css @@ -40,6 +40,9 @@ --mobile-icon-cell: 78px; --mobile-sheet-radius: 12px; --mobile-min-width: 320px; + --mobile-tabbar-height: 56px; + --mobile-tabbar-label-size: 11px; + --m-keyboard-inset: 0px; --font-size-aux: 11.5px; --font-size-caption: 12.5px; diff --git a/app/frontend/m/index.html b/app/frontend/m/index.html index 3021f3d..206c1a3 100644 --- a/app/frontend/m/index.html +++ b/app/frontend/m/index.html @@ -29,6 +29,7 @@
+ diff --git a/app/frontend/m/js/boot.js b/app/frontend/m/js/boot.js index d21951d..f6c47d6 100644 --- a/app/frontend/m/js/boot.js +++ b/app/frontend/m/js/boot.js @@ -32,8 +32,29 @@ global.MobileTheme = { readTheme: readTheme, applyTheme: applyTheme, toggle: toggle }; + function syncKeyboardInset() { + const app = document.getElementById("m-app"); + const visual = global.visualViewport; + if (!visual) { + app.style.setProperty("--m-keyboard-inset", "0px"); + return; + } + const inset = Math.max(0, global.innerHeight - visual.height - visual.offsetTop); + app.style.setProperty("--m-keyboard-inset", inset + "px"); + } + + function bindViewport() { + const visual = global.visualViewport; + if (visual) { + visual.addEventListener("resize", syncKeyboardInset); + visual.addEventListener("scroll", syncKeyboardInset); + } + syncKeyboardInset(); + } + async function boot() { applyTheme(readTheme()); + bindViewport(); let session = { authenticated: false }; try { session = await global.MobileSession.me(); @@ -44,7 +65,7 @@ if (!session.authenticated) { if (!/^#\/?auth/.test(hash)) global.location.replace("#/auth"); } else if (/^#\/?auth/.test(hash)) { - global.location.replace("#/home"); + global.location.replace("#/hub/market"); } global.MobileRouter.init(); } diff --git a/app/frontend/m/js/router.js b/app/frontend/m/js/router.js index 2863b8f..224a6ad 100644 --- a/app/frontend/m/js/router.js +++ b/app/frontend/m/js/router.js @@ -10,7 +10,7 @@ "wand-2": '', "notebook-pen": '', "message-square": '', - "settings": '', + "settings": '', "inbox": '', "activity": '', "trending-up": '', @@ -50,13 +50,15 @@ }); } + const DEFAULT_HASH = "#/hub/market"; + const stack = []; let internalNav = 0; let authMode = "login"; function currentHash() { let hash = window.location.hash || ""; - if (!hash || hash === "#" || hash === "#/") return "#/home"; + if (!hash || hash === "#" || hash === "#/") return DEFAULT_HASH; if (hash.charAt(0) !== "#") hash = "#" + hash; return hash; } @@ -71,6 +73,17 @@ return routeOf(stack.length ? stack[stack.length - 1] : currentHash()); } + function entryKeyOfRoute(route) { + if (route.name === "hub") return route.params[0]; + if (route.name === "feature") return route.params[0]; + if (route.name === "assistant") return "assistant"; + return null; + } + + function entryRoute(key) { + return key === "assistant" ? "#/assistant/chat" : "#/hub/" + key; + } + function setHash(hash) { internalNav++; window.location.hash = hash; @@ -89,10 +102,15 @@ render(); } + function resetStack(hash) { + stack.length = 0; + stack.push(hash); + setHash(hash); + render(); + } + function parentRoute(route) { - if (route.name === "hub") return "#/home"; if (route.name === "feature") return "#/hub/" + route.params[0]; - if (route.name === "assistant") return "#/home"; return null; } @@ -104,7 +122,19 @@ return; } const parent = parentRoute(currentRoute()); - replace(parent || "#/home"); + if (parent) replace(parent); + } + + function switchEntry(key) { + const route = currentRoute(); + const current = entryKeyOfRoute(route); + if (current === key) { + if (route.name === "feature") { + resetStack(entryRoute(key)); + } + return; + } + resetStack(entryRoute(key)); } function updateHeader(options) { @@ -113,12 +143,6 @@ document.getElementById("m-actions").innerHTML = options.actions || ""; } - function themeToggleHtml() { - const dark = document.getElementById("m-app").dataset.theme === "dark"; - const label = dark ? "切换到日间模式" : "切换到夜间模式"; - return '"; - } - function placeholderHtml(title, subtitle) { return '
' + '' + icon("inbox", 26) + "" + @@ -138,18 +162,14 @@ return null; } - function renderHome() { - updateHeader({ title: "小白复盘", back: false, actions: themeToggleHtml() }); - const items = global.MobileNav.entries.map(function (entry) { - const route = entry.key === "assistant" ? "#/assistant/chat" : "#/hub/" + entry.key; - return '
  • ' + - '
  • "; - }).join(""); - document.getElementById("m-view").innerHTML = '
      ' + items + "
    "; + function themeToggleSection() { + const dark = document.getElementById("m-app").dataset.theme === "dark"; + return '
    ' + + '
    ' + + '' + icon(dark ? "moon" : "sun") + "" + + '外观主题' + (dark ? "当前:夜间模式" : "当前:日间模式") + "" + + '' + + "
    "; } function visibleHubItems(hub) { @@ -162,12 +182,14 @@ function renderHub(key) { const hub = global.MobileNav.hubs[key]; if (!hub) { - renderHome(); + replace(DEFAULT_HASH); return; } const items = visibleHubItems(hub); - updateHeader({ title: hub.title, back: true }); + updateHeader({ title: hub.title, back: false }); + const section = key === "system" ? themeToggleSection() : ""; document.getElementById("m-view").innerHTML = + section + '
      ' + items.map(function (item) { return '
    • ' + @@ -186,7 +208,7 @@ } function renderAssistant() { - updateHeader({ title: "复盘助手", back: true }); + updateHeader({ title: "复盘助手", back: false }); document.getElementById("m-view").innerHTML = placeholderHtml("复盘助手", "聊天工作台将在后续批次实现。"); } @@ -253,7 +275,7 @@ } else { await global.MobileSession.login(username, password); } - replace("#/home"); + replace(DEFAULT_HASH); } catch (error) { errorElement.textContent = error.message || "账号操作失败"; errorElement.hidden = false; @@ -262,13 +284,43 @@ } } + function buildTabbar() { + document.getElementById("m-tabbar").innerHTML = global.MobileNav.entries.map(function (entry) { + return '"; + }).join(""); + } + + function updateTabbar(activeKey) { + document.querySelectorAll("#m-tabbar .m-tabbar-item").forEach(function (button) { + const active = button.dataset.tab === activeKey; + button.classList.toggle("active", active); + if (active) button.setAttribute("aria-current", "page"); + else button.removeAttribute("aria-current"); + }); + } + function render() { const route = currentRoute(); + if (route.name === "home") { + replace(DEFAULT_HASH); + return; + } + const tabbar = document.getElementById("m-tabbar"); + if (route.name === "auth") { + tabbar.hidden = true; + document.getElementById("m-app").dataset.tabbar = "false"; + } else { + tabbar.hidden = false; + document.getElementById("m-app").dataset.tabbar = "true"; + updateTabbar(entryKeyOfRoute(route)); + } if (route.name === "hub") renderHub(route.params[0]); else if (route.name === "feature") renderFeature(route.params.join("/")); else if (route.name === "assistant") renderAssistant(); else if (route.name === "auth") renderAuth(); - else renderHome(); } function bindGlobalEvents() { @@ -278,14 +330,16 @@ navigate(entry.dataset.route); } }); + document.getElementById("m-tabbar").addEventListener("click", function (event) { + const item = event.target.closest("[data-tab]"); + if (item) switchEntry(item.dataset.tab); + }); document.getElementById("m-back").addEventListener("click", back); document.addEventListener("click", function (event) { const toggle = event.target.closest("[data-theme-toggle]"); if (!toggle) return; global.MobileTheme.toggle(); - if (currentRoute().name === "home") { - document.getElementById("m-actions").innerHTML = themeToggleHtml(); - } + render(); }); window.addEventListener("hashchange", function () { if (internalNav > 0) { @@ -304,8 +358,15 @@ function init() { bindGlobalEvents(); + buildTabbar(); stack.length = 0; - stack.push(currentHash()); + const raw = window.location.hash || ""; + if (!raw || raw === "#" || raw === "#/") { + stack.push(DEFAULT_HASH); + setHash(DEFAULT_HASH); + } else { + stack.push(currentHash()); + } render(); }