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 = '
";
+ 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();
}