refactor: establish shared frontend shell

This commit is contained in:
leefer
2026-07-29 21:38:38 +08:00
parent e177f21d1c
commit 8d617cfa17
8 changed files with 380 additions and 173 deletions
+25 -166
View File
@@ -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;
+3 -1
View File
@@ -1865,9 +1865,11 @@
<script src="/vendor/lucide.min.js" defer></script>
<script src="/ui-core.js" defer></script>
<script src="/pages.config.js?v=20260729-1" defer></script>
<script src="/shared/state.js?v=20260729-1" defer></script>
<script src="/shared/api.js?v=20260729-1" defer></script>
<script src="/shared/shell.js?v=20260729-1" defer></script>
<script src="/heaven-loading-v2.js?v=20260728-2" defer></script>
<script src="/app.js?v=20260729-4" defer></script>
<script src="/app.js?v=20260729-5" defer></script>
</body>
</html>
+69
View File
@@ -0,0 +1,69 @@
(function exposePageRegistry(global) {
"use strict";
const pages = [
["sentimentCycleView", "情绪周期", "sentiment", "market", "authenticated", true],
["limitPool", "涨停池", "pools", "market", "authenticated", false],
["brokenView", "炸板池", "pools", "market", "authenticated", false],
["downView", "跌停板", "pools", "market", "authenticated", false],
["yesterdayView", "昨日涨停", "pools", "market", "authenticated", false],
["performanceView", "涨停表现", "pools", "market", "authenticated", false],
["ladderView", "市场天梯", "ladder", "market", "authenticated", false],
["rotationView", "板块轮动", "rotation", "market", "authenticated", false],
["auctionView", "集合竞价", "auction", "market", "authenticated", false],
["themeLibraryView", "题材库", "themes", "market", "authenticated", false],
["popularityView", "人气热榜", "popularity", "market", "authenticated", false],
["dragonView", "龙虎榜", "dragon_tiger", "market", "authenticated", false],
["screenerView", "智能选股", "screener", "intelligence", "member", false],
["mentorView", "问师", "mentor", "intelligence", "member", false],
["heavenView", "问天", "heaven", "intelligence", "member", false],
["reviewWorkspaceView", "我的复盘", "review", "personal", "authenticated", false],
].map(([id, title, feature, group, access, isDefault]) => Object.freeze({
id,
title,
feature,
group,
access,
default: isDefault,
desktop_scroll: "page",
mobile_layout: "dedicated",
}));
const internalPages = [
Object.freeze({
id: "screenerTrackingView",
title: "策略持续跟踪",
feature: "screener",
group: "intelligence",
access: "member",
internal: true,
navigation_alias: "screenerView",
}),
];
const all = [...pages, ...internalPages];
const byId = new Map(all.map((page) => [page.id, page]));
const defaultPage = pages.find((page) => page.default);
const aliases = Object.freeze({ sectorView: "rotationView", breadthView: "limitPool" });
global.XiaobaiPages = Object.freeze({
schemaVersion: 1,
pages: Object.freeze(pages),
internalPages: Object.freeze(internalPages),
all: Object.freeze(all),
defaultPage,
aliases,
resolve(id) {
return aliases[id] || id;
},
get(id) {
return byId.get(id) || null;
},
has(id) {
return byId.has(id);
},
inGroup(id, group) {
return byId.get(id)?.group === group;
},
});
})(window);
+194
View File
@@ -0,0 +1,194 @@
(function exposeApplicationShell(global) {
"use strict";
const SIDEBAR_STORAGE_KEY = "xiaobai-sidebar-collapsed";
function create(options) {
const state = options.state;
const registry = options.pages;
let initialized = false;
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));
}
function updateSidebarControl() {
const button = document.querySelector("#sidebarCollapseButton");
if (!button) return;
const automaticallyCollapsed = global.innerWidth <= 1023 && global.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 toggleSidebar() {
const collapsed = document.body.classList.toggle("sidebar-collapsed");
try {
global.localStorage.setItem(SIDEBAR_STORAGE_KEY, collapsed ? "1" : "0");
} catch (_error) {
// The shell remains usable when storage is unavailable.
}
updateSidebarControl();
}
function syncNavigation(viewId) {
const page = registry.get(viewId);
const navigationId = page?.navigation_alias || viewId;
const marketView = page?.group === "market";
document.body.dataset.activeView = viewId;
document.querySelectorAll(".module-tab").forEach((button) => {
button.classList.toggle("active", button.dataset.view === navigationId);
button.classList.toggle(
"mobile-active",
global.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);
options.onNavigationSync?.(viewId);
}
function setStatus(text) {
const status = document.querySelector("#statusText");
if (status) status.textContent = text;
}
function setPageStatus(viewId, tradeDate = "") {
const page = registry.get(viewId);
const label = page?.title || "小白复盘";
setStatus(tradeDate && tradeDate !== "--" ? `${label} · 数据日期 ${tradeDate}` : `${label} · 等待数据`);
}
function openModalDialog(dialog) {
if (!(dialog instanceof HTMLDialogElement)) return;
document.querySelectorAll("dialog[open]").forEach((openDialog) => {
if (openDialog !== dialog) openDialog.close();
});
if (!dialog.open) dialog.showModal();
}
function mount(viewId, mountOptions = {}) {
const page = registry.get(viewId);
const view = document.getElementById(viewId);
if (!page || !view?.classList.contains("workspace-view")) return false;
const previousView = state.activeView;
options.onBeforeMount?.(viewId, previousView);
state.activeView = viewId;
document.querySelectorAll(".workspace-view").forEach((candidate) => {
const active = candidate.id === viewId;
candidate.classList.toggle("active-view", active);
candidate.classList.remove("view-entering");
if (active && options.motionEnabled?.()) {
void candidate.offsetWidth;
candidate.classList.add("view-entering");
candidate.addEventListener(
"animationend",
() => candidate.classList.remove("view-entering"),
{ once: true },
);
const body = candidate.querySelector("tbody");
if (body) options.animateRows?.(body);
}
});
syncNavigation(viewId);
setPageStatus(viewId, options.tradeDate?.() || "");
if (mountOptions.updateUrl !== false) {
const url = new URL(global.location.href);
url.searchParams.set("view", viewId);
url.hash = "";
global.history.replaceState(null, "", url);
}
global.scrollTo({ top: 0, behavior: "auto" });
options.onAfterMount?.(viewId, previousView);
return true;
}
function initialize() {
if (initialized) return;
initialized = true;
let collapsed = false;
try {
collapsed = global.localStorage.getItem(SIDEBAR_STORAGE_KEY) === "1";
} catch (_error) {
collapsed = false;
}
document.body.classList.toggle("sidebar-collapsed", collapsed);
updateSidebarControl();
syncNavigation(state.activeView);
document.querySelectorAll(".module-tab").forEach((button) => {
button.addEventListener("click", () => options.onNavigate?.(button.dataset.view));
});
document.querySelectorAll("[data-open-view]").forEach((button) => {
button.addEventListener("click", () => options.onNavigate?.(button.dataset.openView));
});
document.querySelector("#mobileMarketViewSelect")?.addEventListener("change", (event) => {
options.onNavigate?.(event.target.value);
});
document.querySelector("#sidebarCollapseButton")?.addEventListener("click", toggleSidebar);
document.querySelector("#headerMenuButton")?.addEventListener("click", (event) => {
event.stopPropagation();
toggleHeaderCommandMenu();
});
document.querySelector("#headerCommandGroup")?.addEventListener("click", (event) => {
if (event.target.closest("button") && !event.target.closest(".account-menu-shell")) {
toggleHeaderCommandMenu(false);
}
});
document.querySelector("#overviewToggle")?.addEventListener("click", (event) => {
const overview = document.querySelector(".overview-strip");
if (!overview) return;
const expanded = overview.dataset.overviewExpanded !== "true";
overview.dataset.overviewExpanded = String(expanded);
event.currentTarget.setAttribute("aria-expanded", String(expanded));
event.currentTarget.title = expanded ? "收起市场详情" : "展开市场详情";
const label = event.currentTarget.querySelector("span");
if (label) label.textContent = expanded ? "收起详情" : "展开详情";
event.currentTarget.querySelector("i")?.setAttribute(
"data-lucide",
expanded ? "chevron-up" : "chevron-down",
);
options.refreshIcons?.();
});
document.addEventListener("click", (event) => {
if (!event.target.closest(".header-actions")) toggleHeaderCommandMenu(false);
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") toggleHeaderCommandMenu(false);
});
global.addEventListener("resize", () => {
if (global.innerWidth > 720) toggleHeaderCommandMenu(false);
updateSidebarControl();
syncNavigation(state.activeView);
});
}
return Object.freeze({
initialize,
mount,
openModalDialog,
page: (viewId) => registry.get(viewId),
setPageStatus,
setStatus,
syncNavigation,
toggleHeaderCommandMenu,
toggleSidebar,
updateSidebarControl,
});
}
global.XiaobaiShell = Object.freeze({ create });
})(window);