From b284531dfa8c62d029e213ad6acc3be61beafee8 Mon Sep 17 00:00:00 2001 From: leefer Date: Wed, 29 Jul 2026 22:15:36 +0800 Subject: [PATCH] refactor: establish frontend page modules --- docs/governance/architecture-inventory.json | 8 +- docs/governance/stage-17-page-modules.md | 53 ++++++++++ static/app.js | 111 ++++++++++++-------- static/index.html | 16 ++- static/pages/auction/page.js | 4 + static/pages/dragon-tiger/page.js | 3 + static/pages/heaven/page.js | 4 + static/pages/ladder/page.js | 1 + static/pages/mentor/page.js | 3 + static/pages/pools/page.js | 7 ++ static/pages/popularity/page.js | 3 + static/pages/review/page.js | 3 + static/pages/rotation/page.js | 3 + static/pages/runtime.js | 60 +++++++++++ static/pages/screener/page.js | 5 + static/pages/sentiment/page.js | 3 + static/pages/themes/page.js | 3 + static/shared/components.js | 54 ++++++++++ tests/test_frontend_boundaries.py | 56 ++++++++++ 19 files changed, 350 insertions(+), 50 deletions(-) create mode 100644 docs/governance/stage-17-page-modules.md create mode 100644 static/pages/auction/page.js create mode 100644 static/pages/dragon-tiger/page.js create mode 100644 static/pages/heaven/page.js create mode 100644 static/pages/ladder/page.js create mode 100644 static/pages/mentor/page.js create mode 100644 static/pages/pools/page.js create mode 100644 static/pages/popularity/page.js create mode 100644 static/pages/review/page.js create mode 100644 static/pages/rotation/page.js create mode 100644 static/pages/runtime.js create mode 100644 static/pages/screener/page.js create mode 100644 static/pages/sentiment/page.js create mode 100644 static/pages/themes/page.js create mode 100644 static/shared/components.js diff --git a/docs/governance/architecture-inventory.json b/docs/governance/architecture-inventory.json index 2d5102d..fd68534 100644 --- a/docs/governance/architecture-inventory.json +++ b/docs/governance/architecture-inventory.json @@ -255,8 +255,8 @@ "code_hotspots": [ { "path": "static/app.js", - "bytes": 441609, - "lines": 9262 + "bytes": 441313, + "lines": 9283 }, { "path": "static/styles.css", @@ -275,8 +275,8 @@ }, { "path": "static/index.html", - "bytes": 133880, - "lines": 1876 + "bytes": 134831, + "lines": 1890 }, { "path": "database.py", diff --git a/docs/governance/stage-17-page-modules.md b/docs/governance/stage-17-page-modules.md new file mode 100644 index 0000000..95f974e --- /dev/null +++ b/docs/governance/stage-17-page-modules.md @@ -0,0 +1,53 @@ +# Stage 17: Frontend Page Modules and Shared Components + +## Scope + +This stage establishes frontend feature ownership without changing page markup, API contracts, +permissions, visual design, or user interaction. The build-free deployment model is retained. + +## Page Module Runtime + +`static/pages/runtime.js` owns the page lifecycle registry. Each feature registers its views in +`static/pages//page.js` and declares named enter and leave actions. The application +injects the existing feature functions into that runtime, so page modules do not reach into +another feature's state or DOM. + +The lifecycle boundary now owns: + +- page-specific data loading after a successful shell mount; +- member-aware entry for Screener, Mentor, and Wentian; +- auction timer cleanup when leaving Auction; +- canvas, dust, and performance cleanup when leaving Wentian; +- the internal Screener Tracking view's ownership relationship. + +`openView` is now a generic coordinator. It validates the route, asks the page runtime to leave +the previous page, mounts through the shared shell, and enters the next page. It contains no +page-name branch chain. + +## Shared Components + +`static/shared/components.js` is the common rendering boundary for small, stable DOM patterns. +The first migrated component is the empty state used across market rotation, themes, +Dragon-Tiger, review, Screener, Wentian history, alerts, entity details, and administration. +It centralizes escaping and class composition while preserving the exact existing markup. + +Collection rendering and text assignment are exposed for later incremental migrations. They +remain dependency-free and use `XiaobaiUI` for safe escaping. + +## Enforcement + +Frontend boundary tests verify that: + +- shared components load after UI primitives and before page code; +- the page runtime loads before every feature registration and before `app.js`; +- every public and internal workspace belongs to exactly one feature page module; +- `openView` contains no feature-specific view comparisons; +- shared empty-state rendering is used by multiple feature families; +- provider requests still exit only through `shared/api.js`. + +## Compatibility and Residual Risk + +Feature renderers and event handlers still reside in `app.js`; moving them all at once would +create a high-risk rewrite across already accepted workflows. The new lifecycle and component +boundaries let those functions move feature by feature later without changing navigation or +loading behavior. Dedicated mobile composition remains the next governance phase. diff --git a/static/app.js b/static/app.js index 30cbc63..04e4f30 100644 --- a/static/app.js +++ b/static/app.js @@ -10,6 +10,11 @@ const { todayString, } = window.XiaobaiUI; +const { + emptyStateHtml, + renderEmptyState, +} = window.XiaobaiComponents; + const HEART_BREATH_INHALE_MS = 3_000; const HEART_BREATH_HOLD_MS = 2_000; const HEART_BREATH_EXHALE_MS = 4_000; @@ -214,6 +219,37 @@ const applicationShell = window.XiaobaiShell.create({ onNavigationSync: () => toggleAccountDropdown(false), }); +const pageModules = window.XiaobaiPageModules.create({ + pages: window.XiaobaiPages, + actions: { + closeTransientUi: () => closeStockPreview(), + applyAccess: () => applyMembershipAccess(), + clearAuction: () => clearAuctionTimer(), + stopHeaven: () => { + stopQiFieldCanvas(); + stopHeartDust(); + cancelHeavenPerformance(); + }, + loadSentiment: () => loadSentimentHistory(), + loadRotation: () => loadRotationHistory(), + loadAuction: () => loadAuctionCenter(), + loadThemes: () => loadThemeLibrary(), + loadPopularity: () => loadPopularity(), + loadDragonTiger: () => loadDragonTiger(), + loadReview: () => loadReviewWorkspace(), + loadScreener: () => { + if (hasMemberAccess() && state.dashboard) loadScreenerSetup(); + }, + loadMentor: () => { + if (hasMemberAccess()) loadMentorSetup(); + }, + loadHeaven: () => { + if (!hasMemberAccess()) return; + loadHeavenSetup(false, "", document.querySelector("#heavenStockInput").value.trim()); + }, + }, +}); + const elements = { tradeDate: document.querySelector("#tradeDate"), loading: document.querySelector("#loadingOverlay"), @@ -1928,7 +1964,7 @@ async function loadRotationHistory(force = false) { } state.rotationLoading = true; const container = document.querySelector("#rotationHistory"); - container.innerHTML = '
正在读取轮动历史
'; + renderEmptyState(container, "正在读取轮动历史"); try { const query = new URLSearchParams({ trade_date: elements.tradeDate.value, @@ -1937,7 +1973,7 @@ async function loadRotationHistory(force = false) { state.rotationHistoryKey = key; renderRotationHistory(); } catch (error) { - container.innerHTML = `
${escapeHtml(error.message || "轮动历史加载失败")}
`; + renderEmptyState(container, error.message || "轮动历史加载失败"); showToast(error.message || "轮动历史加载失败"); } finally { state.rotationLoading = false; @@ -1950,7 +1986,7 @@ function renderRotationHistory() { const container = document.querySelector("#rotationHistory"); const tracker = document.querySelector("#rotationTracker"); if (!rows.length) { - container.innerHTML = '
尚无连续交易日的板块数据
'; + renderEmptyState(container, "尚无连续交易日的板块数据"); setText("rotationHistoryRange", "暂无轮动历史"); tracker.hidden = true; return; @@ -2098,13 +2134,13 @@ function renderLadderMini(ladders) {
${escapeHtml(group.label)}${number(group.count)} 只

${escapeHtml(visibleNames || "--")}${suffix}

`; - }).join("") || '
暂无梯队数据
'; + }).join("") || emptyStateHtml("暂无梯队数据"); } function renderSectorMini(sectors) { document.querySelector("#sectorMini").innerHTML = sectors.slice(0, 7).map((sector) => `
${escapeHtml(sector.name)}${number(sector.count)}
- `).join("") || '
暂无板块数据
'; + `).join("") || emptyStateHtml("暂无板块数据"); } function renderLadderBoard(ladders) { @@ -2460,7 +2496,7 @@ async function loadThemeLibrary(force = false) { if (initialCode) await selectTheme(initialCode, true); } catch (error) { setText("themeDateLabel", error.message || "题材数据暂不可用"); - document.querySelector("#themeDirectory").innerHTML = `
${escapeHtml(error.message || "题材数据加载失败")}
`; + renderEmptyState("themeDirectory", error.message || "题材数据加载失败"); showToast(error.message || "题材数据加载失败"); } finally { state.themeLoading = false; @@ -2496,7 +2532,7 @@ function renderThemeDirectory() { ${escapeHtml(item.name)}${number(item.member_count)} 只成分${item.hot_rank ? ` · 人气第 ${number(item.hot_rank)}` : ""} ${item.has_quote ? `${signed(item.change)}%` : "--"} `; - }).join("") || '
没有匹配的题材
'; + }).join("") || emptyStateHtml("没有匹配的题材"); } async function selectTheme(code, keepSelection = false) { @@ -2847,7 +2883,7 @@ function renderDragonTraderList() { `).join(""); container.innerHTML = traders.length ? `${cardMarkup}
${hitZoneMarkup}
` - : `
${escapeHtml(state.dragonFilter === "unclassified" ? "待归类席位请在下方管理" : emptyMessage)}
`; + : emptyStateHtml(state.dragonFilter === "unclassified" ? "待归类席位请在下方管理" : emptyMessage, { className: "dragon-empty" }); container.querySelectorAll("[data-dragon-card]").forEach((card) => { card.addEventListener("animationend", () => card.classList.remove("dealing"), { once: true }); }); @@ -2905,7 +2941,7 @@ function renderDragonTraderDetail(trader) { const container = document.querySelector("#dragonTraderDetail"); if (!trader) { container.hidden = true; - container.innerHTML = '
选择一位游资查看操作明细
'; + renderEmptyState(container, "选择一位游资查看操作明细", { className: "dragon-empty" }); return; } container.hidden = false; @@ -2958,7 +2994,7 @@ function renderUnclassifiedSeats() { - `).join("") || '
当前席位均已归类
'; + `).join("") || emptyStateHtml("当前席位均已归类"); list.querySelectorAll(".unclassified-seat-row").forEach((form) => { form.addEventListener("submit", saveSeatAlias); }); @@ -3411,7 +3447,7 @@ function renderNotesHistory(notes, container, compact) {
计划

${escapeHtml(note.plan || "--")}

- `).join("") || '
暂无复盘记录
'; + `).join("") || emptyStateHtml("暂无复盘记录"); container.querySelectorAll("[data-note-delete]").forEach((button) => { button.addEventListener("click", () => deleteNote(number(button.dataset.noteDelete), compact)); }); @@ -3801,7 +3837,7 @@ function renderCuratedStrategyLibrary() { ${String(rank).padStart(2, "0")}${escapeHtml(strategy.name)}${escapeHtml(school)} · ${escapeHtml(meta.category || "策略")}${escapeHtml(runState.label)} ${escapeHtml(meta.quality || "--")}${escapeHtml(meta.frequency || "--")}风险 ${escapeHtml(meta.risk || "--")} `; - }).join("") : '
没有符合条件的策略
'; + }).join("") : emptyStateHtml("没有符合条件的策略"); renderCuratedStrategyDetail(); } @@ -4115,7 +4151,7 @@ function renderStrategyList() { ${escapeHtml(strategy.name)}${escapeHtml(strategy.description || "--")} ${strategy.regimes.map((item) => regimeLabel(item)).join(" / ")} - `).join("") || '
暂无已保存的自定义公式
'; + `).join("") || emptyStateHtml("暂无已保存的自定义公式"); list.querySelectorAll("[data-strategy-id]").forEach((button) => { button.addEventListener("click", () => { state.customStrategyDraft = state.screenerSetup.strategies.find((item) => item.id === number(button.dataset.strategyId)); @@ -5244,7 +5280,7 @@ function renderHeavenLineChecks(chart) { status.textContent = checks.length ? `${passedCount}/6 通过${manualCount ? ` · ${manualCount} 爻含补录` : ""}` : "等待载入"; status.className = passedCount === 6 ? (manualCount ? "is-manual" : "is-passed") : "is-failed"; if (!checks.length) { - container.innerHTML = '
载入股票后查看六爻数据状态
'; + renderEmptyState(container, "载入股票后查看六爻数据状态"); return; } const lineValueLabel = { 6: "老阴 · 动", 7: "少阳 · 静", 8: "少阴 · 静", 9: "老阳 · 动" }; @@ -5881,7 +5917,7 @@ function selectHeavenReadingTab(tab) { async function loadHeavenReadingHistory(mode) { const list = document.querySelector("#heavenReadingHistoryList"); - list.innerHTML = '
正在读取历史记录
'; + renderEmptyState(list, "正在读取历史记录"); try { const query = new URLSearchParams({ mode, limit: "100" }); const payload = await apiRequest(`/api/heaven/readings?${query}`); @@ -5891,7 +5927,7 @@ async function loadHeavenReadingHistory(mode) { } renderHeavenReadingHistory(); } catch (error) { - list.innerHTML = `
${escapeHtml(error.message || "历史记录加载失败")}
`; + renderEmptyState(list, error.message || "历史记录加载失败"); } } @@ -5947,7 +5983,7 @@ function renderHeavenReadingHistory() { ${escapeHtml(item.subject_detail || displayCompactDate(item.context_date))} - `).join("") || '
暂无历史解读
'; + `).join("") || emptyStateHtml("暂无历史解读"); const selected = items.find((item) => number(item.id) === state.heavenReadingSelectedId); const detail = document.querySelector("#heavenReadingHistoryDetail"); detail.innerHTML = selected ? ` @@ -5955,7 +5991,7 @@ function renderHeavenReadingHistory() {

${escapeHtml(selected.subject_detail || displayCompactDate(selected.context_date))}

${formatMentorAnswer(selected.answer || "")}
- ` : '
选择一条记录查看完整解读
'; + ` : emptyStateHtml("选择一条记录查看完整解读"); refreshIcons(); } @@ -7798,7 +7834,7 @@ function renderAlerts() { `; - }).join("") || '
暂无提醒
'; + }).join("") || emptyStateHtml("暂无提醒"); bindStockRows(container); refreshIcons(); } @@ -7911,7 +7947,7 @@ function renderAssistantMessages() {
${message.role === "assistant" ? (message.content ? formatMentorAnswer(message.content) : '正在整理复盘数据') : escapeHtml(message.content)}
${message.streaming ? '' : ""} - `).join("") || '
可以从市场、策略或自己的交易记录开始复盘
'; + `).join("") || emptyStateHtml("可以从市场、策略或自己的交易记录开始复盘"); updateAssistantControls(); requestAnimationFrame(() => { container.scrollTop = container.scrollHeight; }); } @@ -8075,7 +8111,7 @@ async function openEntityDetail(item) { setText("entityDetailType", item.type_label || "--"); setText("entityDetailDate", "正在加载行情"); document.querySelector("#entityDetailChange").className = ""; - document.querySelector("#entityDetailMetrics").innerHTML = '
正在加载交易数据
'; + renderEmptyState("entityDetailMetrics", "正在加载交易数据"); openModalDialog(elements.entityDetailDialog); clearEntityDetailChart("正在加载日 K 数据"); try { @@ -8098,7 +8134,7 @@ async function openEntityDetail(item) { } catch (error) { if (requestSequence !== state.entityDetailRequestSequence) return; setText("entityDetailDate", "行情加载失败"); - document.querySelector("#entityDetailMetrics").innerHTML = `
${escapeHtml(error.message || "交易数据加载失败")}
`; + renderEmptyState("entityDetailMetrics", error.message || "交易数据加载失败"); if (state.entityDetailChartMode === "daily") clearEntityDetailChart(error.message || "行情加载失败"); showToast(error.message || "详情加载失败"); } @@ -8166,7 +8202,7 @@ function syncDetailChartButtons(scope, mode) { function renderEntityDetailMetrics(metrics) { const container = document.querySelector("#entityDetailMetrics"); if (!metrics.length) { - container.innerHTML = '
暂无交易数据
'; + renderEmptyState(container, "暂无交易数据"); return; } container.innerHTML = metrics.map((metric) => { @@ -8299,7 +8335,7 @@ async function openStock(code, fallback = null) { document.querySelector("#reasonInput").value = row.reason || ""; document.querySelector("#stockNoteContent").value = ""; document.querySelector("#stockNotePlan").value = ""; - document.querySelector("#stockNotes").innerHTML = '
正在加载笔记
'; + renderEmptyState("stockNotes", "正在加载笔记"); updateWatchButton(); openModalDialog(elements.stockDialog); clearPriceChart("正在加载日 K 数据"); @@ -8421,26 +8457,11 @@ function applyMembershipAccess() { } function openView(viewId, updateHash = true) { - if (!applicationShell.page(viewId)) return; - closeStockPreview(); - if (viewId !== "auctionView") clearAuctionTimer(); - if (viewId !== "heavenView") { - stopQiFieldCanvas(); - stopHeartDust(); - cancelHeavenPerformance(); - } + if (!applicationShell.page(viewId) || !pageModules.has(viewId)) return; + const previousView = state.activeView; + pageModules.beforeMount(viewId, previousView); if (!applicationShell.mount(viewId, { updateUrl: updateHash })) return; - applyMembershipAccess(); - if (viewId === "dragonView") loadDragonTiger(); - if (viewId === "reviewWorkspaceView") loadReviewWorkspace(); - if (viewId === "screenerView" && hasMemberAccess() && state.dashboard) loadScreenerSetup(); - if (viewId === "mentorView" && hasMemberAccess()) loadMentorSetup(); - if (viewId === "heavenView" && hasMemberAccess()) loadHeavenSetup(false, "", document.querySelector("#heavenStockInput").value.trim()); - if (viewId === "sentimentCycleView") loadSentimentHistory(); - if (viewId === "rotationView") loadRotationHistory(); - if (viewId === "auctionView") loadAuctionCenter(); - if (viewId === "themeLibraryView") loadThemeLibrary(); - if (viewId === "popularityView") loadPopularity(); + pageModules.afterMount(viewId, previousView); } function initializeAutoTableSorting() { @@ -8697,7 +8718,7 @@ function renderModelPool(models, primaryId = "", fallbackId = "") {
未测试
- `).join("") || '
模型池为空,请先添加模型
'; + `).join("") || emptyStateHtml("模型池为空,请先添加模型"); updateModelRoleOptions(primaryId, fallbackId); container.querySelectorAll("[data-test-model]").forEach((button) => button.addEventListener("click", () => testPlatformModel(button.closest("[data-model-id]")))); container.querySelectorAll("[data-delete-model]").forEach((button) => button.addEventListener("click", () => deletePlatformModel(button.closest("[data-model-id]")))); @@ -8777,7 +8798,7 @@ function renderAdminUsers(users) { `; - }).join("") || '
暂无注册用户
'; + }).join("") || emptyStateHtml("暂无注册用户"); container.querySelectorAll(".membership-form").forEach((form) => form.addEventListener("submit", saveMembership)); } diff --git a/static/index.html b/static/index.html index cdcea87..ca466dc 100644 --- a/static/index.html +++ b/static/index.html @@ -1866,11 +1866,25 @@ + + + + + + + + + + + + + + - + diff --git a/static/pages/auction/page.js b/static/pages/auction/page.js new file mode 100644 index 0000000..b25237b --- /dev/null +++ b/static/pages/auction/page.js @@ -0,0 +1,4 @@ +window.XiaobaiPageModules.register("auction", ["auctionView"], { + enter: ["loadAuction"], + leave: ["clearAuction"], +}); diff --git a/static/pages/dragon-tiger/page.js b/static/pages/dragon-tiger/page.js new file mode 100644 index 0000000..855e400 --- /dev/null +++ b/static/pages/dragon-tiger/page.js @@ -0,0 +1,3 @@ +window.XiaobaiPageModules.register("dragon_tiger", ["dragonView"], { + enter: ["loadDragonTiger"], +}); diff --git a/static/pages/heaven/page.js b/static/pages/heaven/page.js new file mode 100644 index 0000000..077f384 --- /dev/null +++ b/static/pages/heaven/page.js @@ -0,0 +1,4 @@ +window.XiaobaiPageModules.register("heaven", ["heavenView"], { + enter: ["loadHeaven"], + leave: ["stopHeaven"], +}); diff --git a/static/pages/ladder/page.js b/static/pages/ladder/page.js new file mode 100644 index 0000000..9fa95aa --- /dev/null +++ b/static/pages/ladder/page.js @@ -0,0 +1 @@ +window.XiaobaiPageModules.register("ladder", ["ladderView"]); diff --git a/static/pages/mentor/page.js b/static/pages/mentor/page.js new file mode 100644 index 0000000..1802db8 --- /dev/null +++ b/static/pages/mentor/page.js @@ -0,0 +1,3 @@ +window.XiaobaiPageModules.register("mentor", ["mentorView"], { + enter: ["loadMentor"], +}); diff --git a/static/pages/pools/page.js b/static/pages/pools/page.js new file mode 100644 index 0000000..c0676ee --- /dev/null +++ b/static/pages/pools/page.js @@ -0,0 +1,7 @@ +window.XiaobaiPageModules.register("pools", [ + "limitPool", + "brokenView", + "downView", + "yesterdayView", + "performanceView", +]); diff --git a/static/pages/popularity/page.js b/static/pages/popularity/page.js new file mode 100644 index 0000000..5179da6 --- /dev/null +++ b/static/pages/popularity/page.js @@ -0,0 +1,3 @@ +window.XiaobaiPageModules.register("popularity", ["popularityView"], { + enter: ["loadPopularity"], +}); diff --git a/static/pages/review/page.js b/static/pages/review/page.js new file mode 100644 index 0000000..23da3fa --- /dev/null +++ b/static/pages/review/page.js @@ -0,0 +1,3 @@ +window.XiaobaiPageModules.register("review", ["reviewWorkspaceView"], { + enter: ["loadReview"], +}); diff --git a/static/pages/rotation/page.js b/static/pages/rotation/page.js new file mode 100644 index 0000000..605255c --- /dev/null +++ b/static/pages/rotation/page.js @@ -0,0 +1,3 @@ +window.XiaobaiPageModules.register("rotation", ["rotationView"], { + enter: ["loadRotation"], +}); diff --git a/static/pages/runtime.js b/static/pages/runtime.js new file mode 100644 index 0000000..17a4b78 --- /dev/null +++ b/static/pages/runtime.js @@ -0,0 +1,60 @@ +(function exposePageModuleRuntime(global) { + "use strict"; + + const definitions = new Map(); + let sealed = false; + + function register(feature, viewIds, lifecycle = {}) { + if (sealed) throw new Error("Page module registry is already sealed"); + if (!feature || !Array.isArray(viewIds) || !viewIds.length) { + throw new Error("Page modules require a feature and at least one view ID"); + } + viewIds.forEach((viewId) => { + if (definitions.has(viewId)) throw new Error(`Duplicate page module: ${viewId}`); + definitions.set(viewId, Object.freeze({ + feature, + viewId, + enter: Object.freeze([...(lifecycle.enter || [])]), + leave: Object.freeze([...(lifecycle.leave || [])]), + })); + }); + } + + function create(options) { + sealed = true; + const pages = options.pages; + const actions = Object.freeze({ ...(options.actions || {}) }); + const missing = pages.all.filter((page) => !definitions.has(page.id)).map((page) => page.id); + if (missing.length) throw new Error(`Missing page modules: ${missing.join(", ")}`); + + function run(actionNames, context) { + actionNames.forEach((actionName) => { + const action = actions[actionName]; + if (typeof action !== "function") throw new Error(`Unknown page action: ${actionName}`); + action(context); + }); + } + + function beforeMount(viewId, previousView) { + actions.closeTransientUi?.({ viewId, previousView }); + if (previousView && previousView !== viewId) { + run(definitions.get(previousView)?.leave || [], { viewId, previousView }); + } + } + + function afterMount(viewId, previousView) { + const context = { viewId, previousView }; + actions.applyAccess?.(context); + run(definitions.get(viewId)?.enter || [], context); + } + + return Object.freeze({ + afterMount, + beforeMount, + get: (viewId) => definitions.get(viewId) || null, + has: (viewId) => definitions.has(viewId), + }); + } + + global.XiaobaiPageModules = Object.freeze({ create, register }); +})(window); diff --git a/static/pages/screener/page.js b/static/pages/screener/page.js new file mode 100644 index 0000000..9314221 --- /dev/null +++ b/static/pages/screener/page.js @@ -0,0 +1,5 @@ +window.XiaobaiPageModules.register("screener", ["screenerView"], { + enter: ["loadScreener"], +}); + +window.XiaobaiPageModules.register("screener", ["screenerTrackingView"]); diff --git a/static/pages/sentiment/page.js b/static/pages/sentiment/page.js new file mode 100644 index 0000000..e1b6689 --- /dev/null +++ b/static/pages/sentiment/page.js @@ -0,0 +1,3 @@ +window.XiaobaiPageModules.register("sentiment", ["sentimentCycleView"], { + enter: ["loadSentiment"], +}); diff --git a/static/pages/themes/page.js b/static/pages/themes/page.js new file mode 100644 index 0000000..fd249f2 --- /dev/null +++ b/static/pages/themes/page.js @@ -0,0 +1,3 @@ +window.XiaobaiPageModules.register("themes", ["themeLibraryView"], { + enter: ["loadThemes"], +}); diff --git a/static/shared/components.js b/static/shared/components.js new file mode 100644 index 0000000..1008b28 --- /dev/null +++ b/static/shared/components.js @@ -0,0 +1,54 @@ +(function exposeSharedComponents(global) { + "use strict"; + + const ui = global.XiaobaiUI; + if (!ui) throw new Error("XiaobaiUI must load before shared components"); + + function resolveElement(target, root = document) { + if (target instanceof Element) return target; + if (typeof target !== "string" || !target) return null; + return target.startsWith("#") ? root.querySelector(target) : root.getElementById?.(target); + } + + function classNames(...values) { + return values.flatMap((value) => String(value || "").split(/\s+/)).filter(Boolean).join(" "); + } + + function emptyStateHtml(message, options = {}) { + const classes = classNames("empty-state", options.className); + const attributes = options.role ? ` role="${ui.escapeHtml(options.role)}"` : ""; + return `
${ui.escapeHtml(message)}
`; + } + + function renderEmptyState(target, message, options = {}) { + const element = resolveElement(target, options.root); + if (!element) return false; + element.innerHTML = emptyStateHtml(message, options); + return true; + } + + function setText(target, value, options = {}) { + const element = resolveElement(target, options.root); + if (!element) return false; + element.textContent = value == null ? "" : String(value); + return true; + } + + function renderCollection(target, items, renderItem, options = {}) { + const element = resolveElement(target, options.root); + if (!element) return 0; + const rows = Array.isArray(items) ? items : []; + element.innerHTML = rows.length + ? rows.map((item, index) => renderItem(item, index)).join("") + : emptyStateHtml(options.emptyMessage || "", options.emptyOptions); + return rows.length; + } + + global.XiaobaiComponents = Object.freeze({ + classNames, + emptyStateHtml, + renderCollection, + renderEmptyState, + setText, + }); +})(window); diff --git a/tests/test_frontend_boundaries.py b/tests/test_frontend_boundaries.py index 4396998..3841765 100644 --- a/tests/test_frontend_boundaries.py +++ b/tests/test_frontend_boundaries.py @@ -22,12 +22,19 @@ class FrontendBoundaryTests(unittest.TestCase): def test_shared_dependencies_load_before_application(self) -> None: html = (STATIC / "index.html").read_text(encoding="utf-8") + ui_position = html.index('/ui-core.js') + components_position = html.index('/shared/components.js') pages_position = html.index('/pages.config.js') + runtime_position = html.index('/pages/runtime.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(ui_position, components_position) + self.assertLess(components_position, pages_position) self.assertLess(pages_position, state_position) + self.assertLess(pages_position, runtime_position) + self.assertLess(runtime_position, state_position) self.assertLess(state_position, api_position) self.assertLess(api_position, shell_position) self.assertLess(shell_position, app_position) @@ -72,6 +79,55 @@ class FrontendBoundaryTests(unittest.TestCase): self.assertIn("function openModalDialog(dialog)", shell) self.assertNotIn('document.querySelectorAll(".module-tab").forEach', app) + def test_every_registered_view_has_one_feature_page_module(self) -> None: + html = (STATIC / "index.html").read_text(encoding="utf-8") + runtime_position = html.index('/pages/runtime.js') + app_position = html.index('/app.js') + expected = { + page["id"]: page["feature"] + for page in json.loads( + (ROOT / "config" / "pages.config.json").read_text(encoding="utf-8") + )["pages"] + } + expected["screenerTrackingView"] = "screener" + actual: dict[str, str] = {} + for path in (STATIC / "pages").glob("*/page.js"): + script_url = f'/pages/{path.parent.name}/page.js' + self.assertIn(script_url, html) + self.assertLess(runtime_position, html.index(script_url)) + self.assertLess(html.index(script_url), app_position) + script = path.read_text(encoding="utf-8") + for match in re.finditer( + r'XiaobaiPageModules\.register\("([^"]+)",\s*\[(.*?)\]', + script, + re.DOTALL, + ): + feature = match.group(1) + for view_id in re.findall(r'"([A-Za-z][A-Za-z0-9]+)"', match.group(2)): + self.assertNotIn(view_id, actual) + actual[view_id] = feature + self.assertEqual(actual, expected) + + def test_page_lifecycle_is_owned_outside_application_monolith(self) -> None: + app = (STATIC / "app.js").read_text(encoding="utf-8") + runtime = (STATIC / "pages" / "runtime.js").read_text(encoding="utf-8") + start = app.index("function openView(") + end = app.index("\nfunction initializeAutoTableSorting", start) + open_view = app[start:end] + self.assertIn("pageModules.beforeMount(viewId, previousView);", open_view) + self.assertIn("pageModules.afterMount(viewId, previousView);", open_view) + self.assertNotRegex(open_view, r'viewId\s*[!=]==?\s*"') + self.assertIn("function beforeMount(viewId, previousView)", runtime) + self.assertIn("function afterMount(viewId, previousView)", runtime) + + def test_shared_empty_state_component_is_used_by_multiple_features(self) -> None: + components = (STATIC / "shared" / "components.js").read_text(encoding="utf-8") + app = (STATIC / "app.js").read_text(encoding="utf-8") + self.assertIn("function emptyStateHtml(message, options = {})", components) + self.assertIn("function renderEmptyState(target, message, options = {})", components) + self.assertGreaterEqual(app.count("renderEmptyState("), 8) + self.assertGreaterEqual(app.count("emptyStateHtml("), 8) + if __name__ == "__main__": unittest.main()