refactor: establish shared frontend shell
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
+25
-166
@@ -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
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
@@ -21,17 +22,56 @@ class FrontendBoundaryTests(unittest.TestCase):
|
||||
|
||||
def test_shared_dependencies_load_before_application(self) -> None:
|
||||
html = (STATIC / "index.html").read_text(encoding="utf-8")
|
||||
pages_position = html.index('/pages.config.js')
|
||||
state_position = html.index('/shared/state.js')
|
||||
api_position = html.index('/shared/api.js')
|
||||
shell_position = html.index('/shared/shell.js')
|
||||
app_position = html.index('/app.js')
|
||||
self.assertLess(pages_position, state_position)
|
||||
self.assertLess(state_position, api_position)
|
||||
self.assertLess(api_position, app_position)
|
||||
self.assertLess(api_position, shell_position)
|
||||
self.assertLess(shell_position, app_position)
|
||||
|
||||
def test_application_state_is_created_through_shared_boundary(self) -> None:
|
||||
app = (STATIC / "app.js").read_text(encoding="utf-8")
|
||||
self.assertIn("const state = window.XiaobaiState.create({", app)
|
||||
self.assertNotIn("const state = {", app)
|
||||
|
||||
def test_runtime_page_registry_matches_governance_registry(self) -> None:
|
||||
expected = json.loads(
|
||||
(ROOT / "config" / "pages.config.json").read_text(encoding="utf-8")
|
||||
)["pages"]
|
||||
runtime = (STATIC / "pages.config.js").read_text(encoding="utf-8")
|
||||
rows = re.findall(
|
||||
r'^\s*\["([^"]+)", "([^"]+)", "([^"]+)", "([^"]+)", "([^"]+)", (true|false)\],$',
|
||||
runtime,
|
||||
re.MULTILINE,
|
||||
)
|
||||
actual = [
|
||||
{
|
||||
"id": row[0],
|
||||
"title": row[1],
|
||||
"feature": row[2],
|
||||
"group": row[3],
|
||||
"access": row[4],
|
||||
"default": row[5] == "true",
|
||||
"desktop_scroll": "page",
|
||||
"mobile_layout": "dedicated",
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
self.assertEqual(actual, expected)
|
||||
|
||||
def test_shell_owns_navigation_and_page_mounting(self) -> None:
|
||||
app = (STATIC / "app.js").read_text(encoding="utf-8")
|
||||
shell = (STATIC / "shared" / "shell.js").read_text(encoding="utf-8")
|
||||
self.assertNotIn("function syncNavigationState", app)
|
||||
self.assertNotIn("function initializeApplicationShell", app)
|
||||
self.assertIn("function syncNavigation(viewId)", shell)
|
||||
self.assertIn("function mount(viewId, mountOptions = {})", shell)
|
||||
self.assertIn("function openModalDialog(dialog)", shell)
|
||||
self.assertNotIn('document.querySelectorAll(".module-tab").forEach', app)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -23,6 +23,7 @@ class FrontendContractTests(unittest.TestCase):
|
||||
def setUpClass(cls) -> None:
|
||||
cls.html = (STATIC_DIR / "index.html").read_text(encoding="utf-8")
|
||||
cls.script = (STATIC_DIR / "app.js").read_text(encoding="utf-8")
|
||||
cls.shell = (STATIC_DIR / "shared" / "shell.js").read_text(encoding="utf-8")
|
||||
cls.ui_core = (STATIC_DIR / "ui-core.js").read_text(encoding="utf-8")
|
||||
cls.design_system = (STATIC_DIR / "design-system.css").read_text(encoding="utf-8")
|
||||
cls.theme = (STATIC_DIR / "theme.css").read_text(encoding="utf-8")
|
||||
@@ -297,7 +298,7 @@ class FrontendContractTests(unittest.TestCase):
|
||||
self.assertIn('id="openTradeLogDialog"', self.html)
|
||||
self.assertIn('id="tradeLogDialog" class="settings-dialog trade-log-dialog"', self.html)
|
||||
self.assertIn('openModalDialog(elements.tradeLogDialog)', self.script)
|
||||
self.assertIn('document.querySelectorAll("dialog[open]")', self.script)
|
||||
self.assertIn('document.querySelectorAll("dialog[open]")', self.shell)
|
||||
self.assertIn('renderTradeLog();\n closeTradeLogDialog();', self.script)
|
||||
|
||||
def test_review_workspace_exposes_complete_watchlist_and_three_part_journal(self):
|
||||
|
||||
Reference in New Issue
Block a user