refactor: establish frontend page modules

This commit is contained in:
leefer
2026-07-29 22:15:36 +08:00
parent ed2b47fe90
commit b284531dfa
19 changed files with 350 additions and 50 deletions
+4 -4
View File
@@ -255,8 +255,8 @@
"code_hotspots": [ "code_hotspots": [
{ {
"path": "static/app.js", "path": "static/app.js",
"bytes": 441609, "bytes": 441313,
"lines": 9262 "lines": 9283
}, },
{ {
"path": "static/styles.css", "path": "static/styles.css",
@@ -275,8 +275,8 @@
}, },
{ {
"path": "static/index.html", "path": "static/index.html",
"bytes": 133880, "bytes": 134831,
"lines": 1876 "lines": 1890
}, },
{ {
"path": "database.py", "path": "database.py",
+53
View File
@@ -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/<feature>/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.
+66 -45
View File
@@ -10,6 +10,11 @@ const {
todayString, todayString,
} = window.XiaobaiUI; } = window.XiaobaiUI;
const {
emptyStateHtml,
renderEmptyState,
} = window.XiaobaiComponents;
const HEART_BREATH_INHALE_MS = 3_000; const HEART_BREATH_INHALE_MS = 3_000;
const HEART_BREATH_HOLD_MS = 2_000; const HEART_BREATH_HOLD_MS = 2_000;
const HEART_BREATH_EXHALE_MS = 4_000; const HEART_BREATH_EXHALE_MS = 4_000;
@@ -214,6 +219,37 @@ const applicationShell = window.XiaobaiShell.create({
onNavigationSync: () => toggleAccountDropdown(false), 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 = { const elements = {
tradeDate: document.querySelector("#tradeDate"), tradeDate: document.querySelector("#tradeDate"),
loading: document.querySelector("#loadingOverlay"), loading: document.querySelector("#loadingOverlay"),
@@ -1928,7 +1964,7 @@ async function loadRotationHistory(force = false) {
} }
state.rotationLoading = true; state.rotationLoading = true;
const container = document.querySelector("#rotationHistory"); const container = document.querySelector("#rotationHistory");
container.innerHTML = '<div class="empty-state">正在读取轮动历史</div>'; renderEmptyState(container, "正在读取轮动历史");
try { try {
const query = new URLSearchParams({ const query = new URLSearchParams({
trade_date: elements.tradeDate.value, trade_date: elements.tradeDate.value,
@@ -1937,7 +1973,7 @@ async function loadRotationHistory(force = false) {
state.rotationHistoryKey = key; state.rotationHistoryKey = key;
renderRotationHistory(); renderRotationHistory();
} catch (error) { } catch (error) {
container.innerHTML = `<div class="empty-state">${escapeHtml(error.message || "轮动历史加载失败")}</div>`; renderEmptyState(container, error.message || "轮动历史加载失败");
showToast(error.message || "轮动历史加载失败"); showToast(error.message || "轮动历史加载失败");
} finally { } finally {
state.rotationLoading = false; state.rotationLoading = false;
@@ -1950,7 +1986,7 @@ function renderRotationHistory() {
const container = document.querySelector("#rotationHistory"); const container = document.querySelector("#rotationHistory");
const tracker = document.querySelector("#rotationTracker"); const tracker = document.querySelector("#rotationTracker");
if (!rows.length) { if (!rows.length) {
container.innerHTML = '<div class="empty-state">尚无连续交易日的板块数据</div>'; renderEmptyState(container, "尚无连续交易日的板块数据");
setText("rotationHistoryRange", "暂无轮动历史"); setText("rotationHistoryRange", "暂无轮动历史");
tracker.hidden = true; tracker.hidden = true;
return; return;
@@ -2098,13 +2134,13 @@ function renderLadderMini(ladders) {
<div><strong>${escapeHtml(group.label)}</strong><small>${number(group.count)} </small></div> <div><strong>${escapeHtml(group.label)}</strong><small>${number(group.count)} </small></div>
<p title="${escapeHtml(allNames.join(""))}">${escapeHtml(visibleNames || "--")}${suffix}</p> <p title="${escapeHtml(allNames.join(""))}">${escapeHtml(visibleNames || "--")}${suffix}</p>
</div>`; </div>`;
}).join("") || '<div class="empty-state">暂无梯队数据</div>'; }).join("") || emptyStateHtml("暂无梯队数据");
} }
function renderSectorMini(sectors) { function renderSectorMini(sectors) {
document.querySelector("#sectorMini").innerHTML = sectors.slice(0, 7).map((sector) => ` document.querySelector("#sectorMini").innerHTML = sectors.slice(0, 7).map((sector) => `
<div class="pool-hot-row"><strong title="${escapeHtml(sector.name)}">${escapeHtml(sector.name)}</strong><span>${number(sector.count)}</span></div> <div class="pool-hot-row"><strong title="${escapeHtml(sector.name)}">${escapeHtml(sector.name)}</strong><span>${number(sector.count)}</span></div>
`).join("") || '<div class="empty-state">暂无板块数据</div>'; `).join("") || emptyStateHtml("暂无板块数据");
} }
function renderLadderBoard(ladders) { function renderLadderBoard(ladders) {
@@ -2460,7 +2496,7 @@ async function loadThemeLibrary(force = false) {
if (initialCode) await selectTheme(initialCode, true); if (initialCode) await selectTheme(initialCode, true);
} catch (error) { } catch (error) {
setText("themeDateLabel", error.message || "题材数据暂不可用"); setText("themeDateLabel", error.message || "题材数据暂不可用");
document.querySelector("#themeDirectory").innerHTML = `<div class="empty-state">${escapeHtml(error.message || "题材数据加载失败")}</div>`; renderEmptyState("themeDirectory", error.message || "题材数据加载失败");
showToast(error.message || "题材数据加载失败"); showToast(error.message || "题材数据加载失败");
} finally { } finally {
state.themeLoading = false; state.themeLoading = false;
@@ -2496,7 +2532,7 @@ function renderThemeDirectory() {
<span class="theme-directory-copy-v2"><strong class="market-preview-trigger" data-market-preview-type="theme" data-market-preview-id="${escapeHtml(item.code)}" title="悬停预览题材行情">${escapeHtml(item.name)}</strong><small>${number(item.member_count)} ${item.hot_rank ? ` · ${number(item.hot_rank)}` : ""}</small></span> <span class="theme-directory-copy-v2"><strong class="market-preview-trigger" data-market-preview-type="theme" data-market-preview-id="${escapeHtml(item.code)}" title="悬停预览题材行情">${escapeHtml(item.name)}</strong><small>${number(item.member_count)} ${item.hot_rank ? ` · ${number(item.hot_rank)}` : ""}</small></span>
<b class="${changeClass(item.change)}">${item.has_quote ? `${signed(item.change)}%` : "--"}</b> <b class="${changeClass(item.change)}">${item.has_quote ? `${signed(item.change)}%` : "--"}</b>
</button>`; </button>`;
}).join("") || '<div class="empty-state">没有匹配的题材</div>'; }).join("") || emptyStateHtml("没有匹配的题材");
} }
async function selectTheme(code, keepSelection = false) { async function selectTheme(code, keepSelection = false) {
@@ -2847,7 +2883,7 @@ function renderDragonTraderList() {
`).join(""); `).join("");
container.innerHTML = traders.length container.innerHTML = traders.length
? `${cardMarkup}<div class="dragon-card-hit-layer">${hitZoneMarkup}</div>` ? `${cardMarkup}<div class="dragon-card-hit-layer">${hitZoneMarkup}</div>`
: `<div class="empty-state dragon-empty">${escapeHtml(state.dragonFilter === "unclassified" ? "待归类席位请在下方管理" : emptyMessage)}</div>`; : emptyStateHtml(state.dragonFilter === "unclassified" ? "待归类席位请在下方管理" : emptyMessage, { className: "dragon-empty" });
container.querySelectorAll("[data-dragon-card]").forEach((card) => { container.querySelectorAll("[data-dragon-card]").forEach((card) => {
card.addEventListener("animationend", () => card.classList.remove("dealing"), { once: true }); card.addEventListener("animationend", () => card.classList.remove("dealing"), { once: true });
}); });
@@ -2905,7 +2941,7 @@ function renderDragonTraderDetail(trader) {
const container = document.querySelector("#dragonTraderDetail"); const container = document.querySelector("#dragonTraderDetail");
if (!trader) { if (!trader) {
container.hidden = true; container.hidden = true;
container.innerHTML = '<div class="empty-state dragon-empty">选择一位游资查看操作明细</div>'; renderEmptyState(container, "选择一位游资查看操作明细", { className: "dragon-empty" });
return; return;
} }
container.hidden = false; container.hidden = false;
@@ -2958,7 +2994,7 @@ function renderUnclassifiedSeats() {
<input type="text" maxlength="50" placeholder="输入游资名" aria-label="${escapeHtml(seat.seat_name)}的游资名" required> <input type="text" maxlength="50" placeholder="输入游资名" aria-label="${escapeHtml(seat.seat_name)}的游资名" required>
<button class="button" type="submit">归类</button> <button class="button" type="submit">归类</button>
</form> </form>
`).join("") || '<div class="empty-state">当前席位均已归类</div>'; `).join("") || emptyStateHtml("当前席位均已归类");
list.querySelectorAll(".unclassified-seat-row").forEach((form) => { list.querySelectorAll(".unclassified-seat-row").forEach((form) => {
form.addEventListener("submit", saveSeatAlias); form.addEventListener("submit", saveSeatAlias);
}); });
@@ -3411,7 +3447,7 @@ function renderNotesHistory(notes, container, compact) {
<div class="note-block"><strong>计划</strong><p>${escapeHtml(note.plan || "--")}</p></div> <div class="note-block"><strong>计划</strong><p>${escapeHtml(note.plan || "--")}</p></div>
<button class="table-action down" type="button" data-note-delete="${number(note.id)}">删除</button> <button class="table-action down" type="button" data-note-delete="${number(note.id)}">删除</button>
</article> </article>
`).join("") || '<div class="empty-state">暂无复盘记录</div>'; `).join("") || emptyStateHtml("暂无复盘记录");
container.querySelectorAll("[data-note-delete]").forEach((button) => { container.querySelectorAll("[data-note-delete]").forEach((button) => {
button.addEventListener("click", () => deleteNote(number(button.dataset.noteDelete), compact)); button.addEventListener("click", () => deleteNote(number(button.dataset.noteDelete), compact));
}); });
@@ -3801,7 +3837,7 @@ function renderCuratedStrategyLibrary() {
<span class="curated-card-head"><i class="curated-strategy-rank">${String(rank).padStart(2, "0")}</i><span><strong>${escapeHtml(strategy.name)}</strong><small>${escapeHtml(school)} · ${escapeHtml(meta.category || "")}</small></span><em class="curated-card-result ${runState.className}">${escapeHtml(runState.label)}</em></span> <span class="curated-card-head"><i class="curated-strategy-rank">${String(rank).padStart(2, "0")}</i><span><strong>${escapeHtml(strategy.name)}</strong><small>${escapeHtml(school)} · ${escapeHtml(meta.category || "")}</small></span><em class="curated-card-result ${runState.className}">${escapeHtml(runState.label)}</em></span>
<span class="curated-card-tags"><em>${escapeHtml(meta.quality || "--")}</em><em>${escapeHtml(meta.frequency || "--")}</em><em> ${escapeHtml(meta.risk || "--")}</em></span> <span class="curated-card-tags"><em>${escapeHtml(meta.quality || "--")}</em><em>${escapeHtml(meta.frequency || "--")}</em><em> ${escapeHtml(meta.risk || "--")}</em></span>
</article>`; </article>`;
}).join("") : '<div class="empty-state">没有符合条件的策略</div>'; }).join("") : emptyStateHtml("没有符合条件的策略");
renderCuratedStrategyDetail(); renderCuratedStrategyDetail();
} }
@@ -4115,7 +4151,7 @@ function renderStrategyList() {
<strong>${escapeHtml(strategy.name)}</strong><span>${escapeHtml(strategy.description || "--")}</span> <strong>${escapeHtml(strategy.name)}</strong><span>${escapeHtml(strategy.description || "--")}</span>
<small>${strategy.regimes.map((item) => regimeLabel(item)).join(" / ")}</small> <small>${strategy.regimes.map((item) => regimeLabel(item)).join(" / ")}</small>
</button> </button>
`).join("") || '<div class="empty-state">暂无已保存的自定义公式</div>'; `).join("") || emptyStateHtml("暂无已保存的自定义公式");
list.querySelectorAll("[data-strategy-id]").forEach((button) => { list.querySelectorAll("[data-strategy-id]").forEach((button) => {
button.addEventListener("click", () => { button.addEventListener("click", () => {
state.customStrategyDraft = state.screenerSetup.strategies.find((item) => item.id === number(button.dataset.strategyId)); 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.textContent = checks.length ? `${passedCount}/6 通过${manualCount ? ` · ${manualCount} 爻含补录` : ""}` : "等待载入";
status.className = passedCount === 6 ? (manualCount ? "is-manual" : "is-passed") : "is-failed"; status.className = passedCount === 6 ? (manualCount ? "is-manual" : "is-passed") : "is-failed";
if (!checks.length) { if (!checks.length) {
container.innerHTML = '<div class="empty-state">载入股票后查看六爻数据状态</div>'; renderEmptyState(container, "载入股票后查看六爻数据状态");
return; return;
} }
const lineValueLabel = { 6: "老阴 · 动", 7: "少阳 · 静", 8: "少阴 · 静", 9: "老阳 · 动" }; const lineValueLabel = { 6: "老阴 · 动", 7: "少阳 · 静", 8: "少阴 · 静", 9: "老阳 · 动" };
@@ -5881,7 +5917,7 @@ function selectHeavenReadingTab(tab) {
async function loadHeavenReadingHistory(mode) { async function loadHeavenReadingHistory(mode) {
const list = document.querySelector("#heavenReadingHistoryList"); const list = document.querySelector("#heavenReadingHistoryList");
list.innerHTML = '<div class="empty-state">正在读取历史记录</div>'; renderEmptyState(list, "正在读取历史记录");
try { try {
const query = new URLSearchParams({ mode, limit: "100" }); const query = new URLSearchParams({ mode, limit: "100" });
const payload = await apiRequest(`/api/heaven/readings?${query}`); const payload = await apiRequest(`/api/heaven/readings?${query}`);
@@ -5891,7 +5927,7 @@ async function loadHeavenReadingHistory(mode) {
} }
renderHeavenReadingHistory(); renderHeavenReadingHistory();
} catch (error) { } catch (error) {
list.innerHTML = `<div class="empty-state">${escapeHtml(error.message || "历史记录加载失败")}</div>`; renderEmptyState(list, error.message || "历史记录加载失败");
} }
} }
@@ -5947,7 +5983,7 @@ function renderHeavenReadingHistory() {
<small>${escapeHtml(item.subject_detail || displayCompactDate(item.context_date))}</small> <small>${escapeHtml(item.subject_detail || displayCompactDate(item.context_date))}</small>
<time>${formatTimestamp(item.created_at)}</time> <time>${formatTimestamp(item.created_at)}</time>
</button> </button>
`).join("") || '<div class="empty-state">暂无历史解读</div>'; `).join("") || emptyStateHtml("暂无历史解读");
const selected = items.find((item) => number(item.id) === state.heavenReadingSelectedId); const selected = items.find((item) => number(item.id) === state.heavenReadingSelectedId);
const detail = document.querySelector("#heavenReadingHistoryDetail"); const detail = document.querySelector("#heavenReadingHistoryDetail");
detail.innerHTML = selected ? ` detail.innerHTML = selected ? `
@@ -5955,7 +5991,7 @@ function renderHeavenReadingHistory() {
<p>${escapeHtml(selected.subject_detail || displayCompactDate(selected.context_date))}</p> <p>${escapeHtml(selected.subject_detail || displayCompactDate(selected.context_date))}</p>
<div class="heaven-reading-answer">${formatMentorAnswer(selected.answer || "")}</div> <div class="heaven-reading-answer">${formatMentorAnswer(selected.answer || "")}</div>
<footer><button class="button" type="button" data-delete-heaven-reading="${number(selected.id)}"><i data-lucide="trash-2"></i><span></span></button></footer> <footer><button class="button" type="button" data-delete-heaven-reading="${number(selected.id)}"><i data-lucide="trash-2"></i><span></span></button></footer>
` : '<div class="empty-state">选择一条记录查看完整解读</div>'; ` : emptyStateHtml("选择一条记录查看完整解读");
refreshIcons(); refreshIcons();
} }
@@ -7798,7 +7834,7 @@ function renderAlerts() {
<button class="icon-button" type="button" data-alert-action="delete" data-alert-id="${number(item.id)}" title="删除提醒" aria-label="删除提醒"><i data-lucide="trash-2"></i></button> <button class="icon-button" type="button" data-alert-action="delete" data-alert-id="${number(item.id)}" title="删除提醒" aria-label="删除提醒"><i data-lucide="trash-2"></i></button>
</div> </div>
</article>`; </article>`;
}).join("") || '<div class="empty-state">暂无提醒</div>'; }).join("") || emptyStateHtml("暂无提醒");
bindStockRows(container); bindStockRows(container);
refreshIcons(); refreshIcons();
} }
@@ -7911,7 +7947,7 @@ function renderAssistantMessages() {
<div class="assistant-message-content">${message.role === "assistant" ? (message.content ? formatMentorAnswer(message.content) : '<span class="assistant-thinking">正在整理复盘数据</span>') : escapeHtml(message.content)}</div> <div class="assistant-message-content">${message.role === "assistant" ? (message.content ? formatMentorAnswer(message.content) : '<span class="assistant-thinking">正在整理复盘数据</span>') : escapeHtml(message.content)}</div>
${message.streaming ? '<span class="assistant-stream-caret" aria-hidden="true"></span>' : ""} ${message.streaming ? '<span class="assistant-stream-caret" aria-hidden="true"></span>' : ""}
</article> </article>
`).join("") || '<div class="empty-state">可以从市场、策略或自己的交易记录开始复盘</div>'; `).join("") || emptyStateHtml("可以从市场、策略或自己的交易记录开始复盘");
updateAssistantControls(); updateAssistantControls();
requestAnimationFrame(() => { container.scrollTop = container.scrollHeight; }); requestAnimationFrame(() => { container.scrollTop = container.scrollHeight; });
} }
@@ -8075,7 +8111,7 @@ async function openEntityDetail(item) {
setText("entityDetailType", item.type_label || "--"); setText("entityDetailType", item.type_label || "--");
setText("entityDetailDate", "正在加载行情"); setText("entityDetailDate", "正在加载行情");
document.querySelector("#entityDetailChange").className = ""; document.querySelector("#entityDetailChange").className = "";
document.querySelector("#entityDetailMetrics").innerHTML = '<div class="empty-state">正在加载交易数据</div>'; renderEmptyState("entityDetailMetrics", "正在加载交易数据");
openModalDialog(elements.entityDetailDialog); openModalDialog(elements.entityDetailDialog);
clearEntityDetailChart("正在加载日 K 数据"); clearEntityDetailChart("正在加载日 K 数据");
try { try {
@@ -8098,7 +8134,7 @@ async function openEntityDetail(item) {
} catch (error) { } catch (error) {
if (requestSequence !== state.entityDetailRequestSequence) return; if (requestSequence !== state.entityDetailRequestSequence) return;
setText("entityDetailDate", "行情加载失败"); setText("entityDetailDate", "行情加载失败");
document.querySelector("#entityDetailMetrics").innerHTML = `<div class="empty-state">${escapeHtml(error.message || "交易数据加载失败")}</div>`; renderEmptyState("entityDetailMetrics", error.message || "交易数据加载失败");
if (state.entityDetailChartMode === "daily") clearEntityDetailChart(error.message || "行情加载失败"); if (state.entityDetailChartMode === "daily") clearEntityDetailChart(error.message || "行情加载失败");
showToast(error.message || "详情加载失败"); showToast(error.message || "详情加载失败");
} }
@@ -8166,7 +8202,7 @@ function syncDetailChartButtons(scope, mode) {
function renderEntityDetailMetrics(metrics) { function renderEntityDetailMetrics(metrics) {
const container = document.querySelector("#entityDetailMetrics"); const container = document.querySelector("#entityDetailMetrics");
if (!metrics.length) { if (!metrics.length) {
container.innerHTML = '<div class="empty-state">暂无交易数据</div>'; renderEmptyState(container, "暂无交易数据");
return; return;
} }
container.innerHTML = metrics.map((metric) => { container.innerHTML = metrics.map((metric) => {
@@ -8299,7 +8335,7 @@ async function openStock(code, fallback = null) {
document.querySelector("#reasonInput").value = row.reason || ""; document.querySelector("#reasonInput").value = row.reason || "";
document.querySelector("#stockNoteContent").value = ""; document.querySelector("#stockNoteContent").value = "";
document.querySelector("#stockNotePlan").value = ""; document.querySelector("#stockNotePlan").value = "";
document.querySelector("#stockNotes").innerHTML = '<div class="empty-state">正在加载笔记</div>'; renderEmptyState("stockNotes", "正在加载笔记");
updateWatchButton(); updateWatchButton();
openModalDialog(elements.stockDialog); openModalDialog(elements.stockDialog);
clearPriceChart("正在加载日 K 数据"); clearPriceChart("正在加载日 K 数据");
@@ -8421,26 +8457,11 @@ function applyMembershipAccess() {
} }
function openView(viewId, updateHash = true) { function openView(viewId, updateHash = true) {
if (!applicationShell.page(viewId)) return; if (!applicationShell.page(viewId) || !pageModules.has(viewId)) return;
closeStockPreview(); const previousView = state.activeView;
if (viewId !== "auctionView") clearAuctionTimer(); pageModules.beforeMount(viewId, previousView);
if (viewId !== "heavenView") {
stopQiFieldCanvas();
stopHeartDust();
cancelHeavenPerformance();
}
if (!applicationShell.mount(viewId, { updateUrl: updateHash })) return; if (!applicationShell.mount(viewId, { updateUrl: updateHash })) return;
applyMembershipAccess(); pageModules.afterMount(viewId, previousView);
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();
} }
function initializeAutoTableSorting() { function initializeAutoTableSorting() {
@@ -8697,7 +8718,7 @@ function renderModelPool(models, primaryId = "", fallbackId = "") {
</div> </div>
<div class="model-test-row"><button class="button" type="button" data-test-model>测试连接</button><span class="model-test-status" aria-live="polite"></span><button class="icon-button model-delete-button" type="button" data-delete-model aria-label="" title=""><i data-lucide="trash-2"></i></button></div> <div class="model-test-row"><button class="button" type="button" data-test-model>测试连接</button><span class="model-test-status" aria-live="polite"></span><button class="icon-button model-delete-button" type="button" data-delete-model aria-label="" title=""><i data-lucide="trash-2"></i></button></div>
</article> </article>
`).join("") || '<div class="empty-state">模型池为空,请先添加模型</div>'; `).join("") || emptyStateHtml("模型池为空,请先添加模型");
updateModelRoleOptions(primaryId, fallbackId); updateModelRoleOptions(primaryId, fallbackId);
container.querySelectorAll("[data-test-model]").forEach((button) => button.addEventListener("click", () => testPlatformModel(button.closest("[data-model-id]")))); 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]")))); container.querySelectorAll("[data-delete-model]").forEach((button) => button.addEventListener("click", () => deletePlatformModel(button.closest("[data-model-id]"))));
@@ -8777,7 +8798,7 @@ function renderAdminUsers(users) {
<button class="button" type="submit">应用</button> <button class="button" type="submit">应用</button>
</form> </form>
</article>`; </article>`;
}).join("") || '<div class="empty-state">暂无注册用户</div>'; }).join("") || emptyStateHtml("暂无注册用户");
container.querySelectorAll(".membership-form").forEach((form) => form.addEventListener("submit", saveMembership)); container.querySelectorAll(".membership-form").forEach((form) => form.addEventListener("submit", saveMembership));
} }
+15 -1
View File
@@ -1866,11 +1866,25 @@
<script src="/vendor/lucide.min.js" defer></script> <script src="/vendor/lucide.min.js" defer></script>
<script src="/ui-core.js" defer></script> <script src="/ui-core.js" defer></script>
<script src="/shared/components.js?v=20260729-1" defer></script>
<script src="/pages.config.js?v=20260729-1" defer></script> <script src="/pages.config.js?v=20260729-1" defer></script>
<script src="/pages/runtime.js?v=20260729-1" defer></script>
<script src="/pages/sentiment/page.js?v=20260729-1" defer></script>
<script src="/pages/pools/page.js?v=20260729-1" defer></script>
<script src="/pages/ladder/page.js?v=20260729-1" defer></script>
<script src="/pages/rotation/page.js?v=20260729-1" defer></script>
<script src="/pages/auction/page.js?v=20260729-1" defer></script>
<script src="/pages/themes/page.js?v=20260729-1" defer></script>
<script src="/pages/popularity/page.js?v=20260729-1" defer></script>
<script src="/pages/dragon-tiger/page.js?v=20260729-1" defer></script>
<script src="/pages/screener/page.js?v=20260729-1" defer></script>
<script src="/pages/mentor/page.js?v=20260729-1" defer></script>
<script src="/pages/heaven/page.js?v=20260729-1" defer></script>
<script src="/pages/review/page.js?v=20260729-1" defer></script>
<script src="/shared/state.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/api.js?v=20260729-1" defer></script>
<script src="/shared/shell.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="/heaven-loading-v2.js?v=20260728-2" defer></script>
<script src="/app.js?v=20260729-5" defer></script> <script src="/app.js?v=20260729-6" defer></script>
</body> </body>
</html> </html>
+4
View File
@@ -0,0 +1,4 @@
window.XiaobaiPageModules.register("auction", ["auctionView"], {
enter: ["loadAuction"],
leave: ["clearAuction"],
});
+3
View File
@@ -0,0 +1,3 @@
window.XiaobaiPageModules.register("dragon_tiger", ["dragonView"], {
enter: ["loadDragonTiger"],
});
+4
View File
@@ -0,0 +1,4 @@
window.XiaobaiPageModules.register("heaven", ["heavenView"], {
enter: ["loadHeaven"],
leave: ["stopHeaven"],
});
+1
View File
@@ -0,0 +1 @@
window.XiaobaiPageModules.register("ladder", ["ladderView"]);
+3
View File
@@ -0,0 +1,3 @@
window.XiaobaiPageModules.register("mentor", ["mentorView"], {
enter: ["loadMentor"],
});
+7
View File
@@ -0,0 +1,7 @@
window.XiaobaiPageModules.register("pools", [
"limitPool",
"brokenView",
"downView",
"yesterdayView",
"performanceView",
]);
+3
View File
@@ -0,0 +1,3 @@
window.XiaobaiPageModules.register("popularity", ["popularityView"], {
enter: ["loadPopularity"],
});
+3
View File
@@ -0,0 +1,3 @@
window.XiaobaiPageModules.register("review", ["reviewWorkspaceView"], {
enter: ["loadReview"],
});
+3
View File
@@ -0,0 +1,3 @@
window.XiaobaiPageModules.register("rotation", ["rotationView"], {
enter: ["loadRotation"],
});
+60
View File
@@ -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);
+5
View File
@@ -0,0 +1,5 @@
window.XiaobaiPageModules.register("screener", ["screenerView"], {
enter: ["loadScreener"],
});
window.XiaobaiPageModules.register("screener", ["screenerTrackingView"]);
+3
View File
@@ -0,0 +1,3 @@
window.XiaobaiPageModules.register("sentiment", ["sentimentCycleView"], {
enter: ["loadSentiment"],
});
+3
View File
@@ -0,0 +1,3 @@
window.XiaobaiPageModules.register("themes", ["themeLibraryView"], {
enter: ["loadThemes"],
});
+54
View File
@@ -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 `<div class="${ui.escapeHtml(classes)}"${attributes}>${ui.escapeHtml(message)}</div>`;
}
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);
+56
View File
@@ -22,12 +22,19 @@ class FrontendBoundaryTests(unittest.TestCase):
def test_shared_dependencies_load_before_application(self) -> None: def test_shared_dependencies_load_before_application(self) -> None:
html = (STATIC / "index.html").read_text(encoding="utf-8") 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') pages_position = html.index('/pages.config.js')
runtime_position = html.index('/pages/runtime.js')
state_position = html.index('/shared/state.js') state_position = html.index('/shared/state.js')
api_position = html.index('/shared/api.js') api_position = html.index('/shared/api.js')
shell_position = html.index('/shared/shell.js') shell_position = html.index('/shared/shell.js')
app_position = html.index('/app.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, state_position)
self.assertLess(pages_position, runtime_position)
self.assertLess(runtime_position, state_position)
self.assertLess(state_position, api_position) self.assertLess(state_position, api_position)
self.assertLess(api_position, shell_position) self.assertLess(api_position, shell_position)
self.assertLess(shell_position, app_position) self.assertLess(shell_position, app_position)
@@ -72,6 +79,55 @@ class FrontendBoundaryTests(unittest.TestCase):
self.assertIn("function openModalDialog(dialog)", shell) self.assertIn("function openModalDialog(dialog)", shell)
self.assertNotIn('document.querySelectorAll(".module-tab").forEach', app) 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__": if __name__ == "__main__":
unittest.main() unittest.main()