diff --git a/docs/governance/architecture-inventory.json b/docs/governance/architecture-inventory.json
index cf550ca..a8cc486 100644
--- a/docs/governance/architecture-inventory.json
+++ b/docs/governance/architecture-inventory.json
@@ -254,8 +254,8 @@
"code_hotspots": [
{
"path": "static/app.js",
- "bytes": 447568,
- "lines": 9403
+ "bytes": 441609,
+ "lines": 9262
},
{
"path": "static/styles.css",
@@ -274,8 +274,8 @@
},
{
"path": "static/index.html",
- "bytes": 133691,
- "lines": 1873
+ "bytes": 133815,
+ "lines": 1875
},
{
"path": "database.py",
diff --git a/docs/governance/stage-15-frontend-shell.md b/docs/governance/stage-15-frontend-shell.md
new file mode 100644
index 0000000..1f77f2f
--- /dev/null
+++ b/docs/governance/stage-15-frontend-shell.md
@@ -0,0 +1,42 @@
+# Stage 15: Shared Frontend Shell and Page Registry
+
+## Runtime Page Registry
+
+`static/pages.config.js` is the build-free runtime representation of
+`config/pages.config.json`. It registers every primary workspace plus the internal strategy
+tracking workspace. Automated parity checks prevent titles, feature ownership, access labels,
+groups, default-page selection, and layout metadata from drifting between the two registries.
+
+Legacy route aliases are resolved by the registry instead of page business code. The registry
+describes navigation and presentation metadata only; backend authorization remains
+authoritative.
+
+## Shared Shell
+
+`static/shared/shell.js` now owns:
+
+- sidebar initialization, persistence, collapse state, and responsive control labels;
+- primary and mobile navigation binding and active-state synchronization;
+- workspace mounting, entry animation, URL state, and scroll reset;
+- header command-menu lifecycle;
+- market-summary expansion;
+- status-bar page titles and data dates;
+- the single-open-dialog lifecycle used by global application dialogs.
+
+`static/app.js` retains feature-specific enter and leave behavior, such as stopping Wentian
+animations or loading auction data. It asks the shell to mount a registered page and no longer
+mutates global page geometry directly.
+
+## Compatibility
+
+- Existing DOM IDs, CSS classes, query parameters, page animations, and mobile navigation are
+ unchanged.
+- `screenerTrackingView` continues to highlight the Intelligent Screener navigation item.
+- Existing function entry points remain as thin compatibility facades where feature code still
+ calls status, dialog, or command-menu services.
+
+## Residual Risk
+
+Feature renderers and feature event binding still share `static/app.js`. Their state ownership
+is now explicit and their shell dependencies are removed, so later page-module extraction can
+be performed one feature at a time instead of as a single rewrite.
diff --git a/static/app.js b/static/app.js
index 1629ef4..30cbc63 100644
--- a/static/app.js
+++ b/static/app.js
@@ -201,6 +201,19 @@ window.XiaobaiAPI.configure({
onUnauthorized: () => showAuthGate("登录状态已失效,请重新登录。"),
});
+const applicationShell = window.XiaobaiShell.create({
+ state,
+ pages: window.XiaobaiPages,
+ motionEnabled,
+ animateRows,
+ refreshIcons,
+ tradeDate: () => displayCompactDate(
+ state.dashboard?.meta?.trade_date || document.querySelector("#tradeDate")?.value || "",
+ ),
+ onNavigate: (viewId) => openView(viewId),
+ onNavigationSync: () => toggleAccountDropdown(false),
+});
+
const elements = {
tradeDate: document.querySelector("#tradeDate"),
loading: document.querySelector("#loadingOverlay"),
@@ -225,11 +238,7 @@ const elements = {
};
function openModalDialog(dialog) {
- if (!(dialog instanceof HTMLDialogElement)) return;
- document.querySelectorAll("dialog[open]").forEach((openDialog) => {
- if (openDialog !== dialog) openDialog.close();
- });
- if (!dialog.open) dialog.showModal();
+ applicationShell.openModalDialog(dialog);
}
const metricAnimationFrames = new WeakMap();
@@ -250,20 +259,6 @@ let heartDustAnimationFrame = 0;
let heartDustParticles = [];
let heartIncenseAnimation = null;
const heartCoinRotations = [0, 0, 0];
-const MARKET_VIEWS = new Set([
- "limitPool",
- "brokenView",
- "downView",
- "yesterdayView",
- "performanceView",
- "sentimentCycleView",
- "auctionView",
- "ladderView",
- "rotationView",
- "themeLibraryView",
- "popularityView",
- "dragonView",
-]);
let rowAnimationObserver = null;
let stockPreviewOpenTimer = null;
let stockPreviewCloseTimer = null;
@@ -446,7 +441,7 @@ function toggleTheme() {
async function initialize() {
syncThemeControl();
refreshIcons();
- initializeApplicationShell();
+ applicationShell.initialize();
elements.tradeDate.value = todayString();
const initialUrl = new URL(window.location.href);
if (initialUrl.searchParams.has("date")) {
@@ -488,9 +483,12 @@ async function startAuthenticatedApp() {
if (["trend", "fortune", "heart"].includes(requestedHeavenPanel)) {
state.heavenPanel = requestedHeavenPanel;
}
- const legacyViewAliases = { sectorView: "rotationView", breadthView: "limitPool" };
- const requestedView = legacyViewAliases[searchParams.get("view")] || searchParams.get("view");
- if (requestedView && document.getElementById(requestedView)?.classList.contains("workspace-view")) {
+ const requestedView = window.XiaobaiPages.resolve(searchParams.get("view"));
+ if (
+ requestedView
+ && window.XiaobaiPages.has(requestedView)
+ && document.getElementById(requestedView)?.classList.contains("workspace-view")
+ ) {
openView(requestedView, false);
if (requestedView !== searchParams.get("view")) {
const url = new URL(window.location.href);
@@ -626,17 +624,6 @@ function bindEvents() {
});
});
- document.querySelectorAll(".module-tab").forEach((button) => {
- button.addEventListener("click", () => openView(button.dataset.view));
- });
- document.querySelector("#mobileMarketViewSelect").addEventListener("change", (event) => {
- openView(event.target.value);
- });
- document.querySelector("#sidebarCollapseButton").addEventListener("click", toggleSidebar);
- document.querySelector("#headerMenuButton").addEventListener("click", (event) => {
- event.stopPropagation();
- toggleHeaderCommandMenu();
- });
document.querySelector("#globalSearchButton").addEventListener("click", openGlobalSearch);
document.querySelector("#themeToggle").addEventListener("click", toggleTheme);
document.querySelector("#alertButton").addEventListener("click", openAlerts);
@@ -669,33 +656,22 @@ function bindEvents() {
const result = event.target.closest("[data-search-result-index]");
if (result) openGlobalSearchResult(number(result.dataset.searchResultIndex));
});
- document.querySelector("#headerCommandGroup").addEventListener("click", (event) => {
- if (event.target.closest("button") && !event.target.closest(".account-menu-shell")) toggleHeaderCommandMenu(false);
- });
document.addEventListener("click", (event) => {
- if (!event.target.closest(".header-actions")) toggleHeaderCommandMenu(false);
if (!event.target.closest(".account-menu-shell")) toggleAccountDropdown(false);
});
window.addEventListener("keydown", handleGlobalSearchShortcut);
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
- toggleHeaderCommandMenu(false);
toggleAccountDropdown(false, true);
toggleMentorDirectory(false);
}
handleAccountMenuKeydown(event);
});
window.addEventListener("resize", () => {
- if (window.innerWidth > 720) toggleHeaderCommandMenu(false);
if (window.innerWidth > 720) toggleMentorDirectory(false);
if (!elements.stockPreview.hidden) closeStockPreview();
- updateSidebarControl();
- syncNavigationState(state.activeView);
if (state.activeView === "dragonView") layoutDragonCards();
});
- document.querySelectorAll("[data-open-view]").forEach((button) => {
- button.addEventListener("click", () => openView(button.dataset.openView));
- });
document.querySelectorAll("[data-open-account]").forEach((button) => {
button.addEventListener("click", () => openSettings("membership"));
});
@@ -754,17 +730,6 @@ function bindEvents() {
renderRotationHistory();
});
});
- document.querySelector("#overviewToggle").addEventListener("click", () => {
- const overview = document.querySelector(".overview-strip");
- const expanded = overview.dataset.overviewExpanded !== "true";
- overview.dataset.overviewExpanded = String(expanded);
- const toggle = document.querySelector("#overviewToggle");
- toggle.setAttribute("aria-expanded", String(expanded));
- toggle.title = expanded ? "收起市场详情" : "展开市场详情";
- toggle.querySelector("span").textContent = expanded ? "收起详情" : "展开详情";
- toggle.querySelector("i").setAttribute("data-lucide", expanded ? "chevron-up" : "chevron-down");
- refreshIcons();
- });
document.querySelector("#sentimentExportButton").addEventListener("click", exportSentimentHistory);
document.querySelectorAll("[data-sentiment-range]").forEach((button) => {
button.addEventListener("click", () => {
@@ -8456,35 +8421,15 @@ function applyMembershipAccess() {
}
function openView(viewId, updateHash = true) {
+ if (!applicationShell.page(viewId)) return;
closeStockPreview();
- state.activeView = viewId;
if (viewId !== "auctionView") clearAuctionTimer();
if (viewId !== "heavenView") {
stopQiFieldCanvas();
stopHeartDust();
cancelHeavenPerformance();
}
- document.querySelectorAll(".workspace-view").forEach((view) => {
- const active = view.id === viewId;
- view.classList.toggle("active-view", active);
- view.classList.remove("view-entering");
- if (active && motionEnabled()) {
- void view.offsetWidth;
- view.classList.add("view-entering");
- view.addEventListener("animationend", () => view.classList.remove("view-entering"), { once: true });
- const body = view.querySelector("tbody");
- if (body) animateRows(body);
- }
- });
- syncNavigationState(viewId);
- setPageStatus(viewId);
- if (updateHash) {
- const url = new URL(window.location.href);
- url.searchParams.set("view", viewId);
- url.hash = "";
- history.replaceState(null, "", url);
- }
- window.scrollTo({ top: 0, behavior: "auto" });
+ if (!applicationShell.mount(viewId, { updateUrl: updateHash })) return;
applyMembershipAccess();
if (viewId === "dragonView") loadDragonTiger();
if (viewId === "reviewWorkspaceView") loadReviewWorkspace();
@@ -9153,32 +9098,7 @@ function setLoading(loading, text = "正在加载复盘数据", context = "defau
}
function setStatus(text) {
- setText("statusText", text);
-}
-
-function setPageStatus(viewId) {
- const labels = {
- sentimentCycleView: "情绪周期",
- limitPool: "涨停池",
- brokenView: "炸板池",
- downView: "跌停板",
- yesterdayView: "昨日涨停",
- performanceView: "涨停表现",
- ladderView: "市场天梯",
- rotationView: "板块轮动",
- auctionView: "集合竞价",
- themeLibraryView: "题材库",
- popularityView: "人气热榜",
- dragonView: "龙虎榜",
- screenerView: "智能选股",
- screenerTrackingView: "策略持续跟踪",
- mentorView: "问师",
- heavenView: "问天",
- reviewWorkspaceView: "我的复盘",
- };
- const label = labels[viewId] || "小白复盘";
- const tradeDate = displayCompactDate(state.dashboard?.meta?.trade_date || elements.tradeDate?.value || "");
- setStatus(tradeDate && tradeDate !== "--" ? `${label} · 数据日期 ${tradeDate}` : `${label} · 等待数据`);
+ applicationShell.setStatus(text);
}
let toastTimer;
@@ -9203,47 +9123,8 @@ function refreshIcons() {
window.lucide.createIcons({ attrs: { "aria-hidden": "true" } });
}
-function initializeApplicationShell() {
- let collapsed = false;
- try {
- collapsed = window.localStorage.getItem("xiaobai-sidebar-collapsed") === "1";
- } catch (_error) {
- collapsed = false;
- }
- document.body.classList.toggle("sidebar-collapsed", collapsed);
- updateSidebarControl();
- syncNavigationState(state.activeView);
-}
-
-function toggleSidebar() {
- const collapsed = document.body.classList.toggle("sidebar-collapsed");
- try {
- window.localStorage.setItem("xiaobai-sidebar-collapsed", collapsed ? "1" : "0");
- } catch (_error) {
- // The visual state still works when storage is unavailable.
- }
- updateSidebarControl();
-}
-
-function updateSidebarControl() {
- const button = document.querySelector("#sidebarCollapseButton");
- if (!button) return;
- const automaticallyCollapsed = window.innerWidth <= 1023 && window.innerWidth > 720;
- const collapsed = document.body.classList.contains("sidebar-collapsed") || automaticallyCollapsed;
- button.setAttribute("aria-expanded", String(!collapsed));
- button.setAttribute("aria-label", collapsed ? "展开侧栏" : "收起侧栏");
- button.title = collapsed ? "展开侧栏" : "收起侧栏";
- const label = button.querySelector("span");
- if (label) label.textContent = collapsed ? "展开侧栏" : "收起侧栏";
-}
-
function toggleHeaderCommandMenu(force) {
- const menu = document.querySelector("#headerCommandGroup");
- const button = document.querySelector("#headerMenuButton");
- if (!menu || !button) return;
- const open = typeof force === "boolean" ? force : !menu.classList.contains("is-open");
- menu.classList.toggle("is-open", open);
- button.setAttribute("aria-expanded", String(open));
+ applicationShell.toggleHeaderCommandMenu(force);
}
function toggleAccountDropdown(force, returnFocus = false) {
@@ -9287,28 +9168,6 @@ function handleAccountMenuKeydown(event) {
}
}
-function syncNavigationState(viewId) {
- const marketView = MARKET_VIEWS.has(viewId);
- document.body.dataset.activeView = viewId;
- document.querySelectorAll(".module-tab").forEach((button) => {
- button.classList.toggle(
- "active",
- button.dataset.view === viewId
- || (viewId === "screenerTrackingView" && button.dataset.view === "screenerView"),
- );
- button.classList.toggle(
- "mobile-active",
- window.innerWidth <= 720 && marketView && button.dataset.view === "limitPool" && viewId !== "limitPool",
- );
- });
- const selector = document.querySelector("#mobileMarketSelector");
- const select = document.querySelector("#mobileMarketViewSelect");
- if (selector) selector.hidden = !marketView;
- if (select && marketView) select.value = viewId;
- toggleHeaderCommandMenu(false);
- toggleAccountDropdown(false);
-}
-
function updateSentimentGauge(rawScore) {
const gauge = document.querySelector("#sentimentGauge");
if (!gauge) return;
diff --git a/static/index.html b/static/index.html
index 516d6b8..ecfdf8c 100644
--- a/static/index.html
+++ b/static/index.html
@@ -1865,9 +1865,11 @@
+
+
-
+