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;