refactor: establish frontend page modules
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
window.XiaobaiPageModules.register("auction", ["auctionView"], {
|
||||||
|
enter: ["loadAuction"],
|
||||||
|
leave: ["clearAuction"],
|
||||||
|
});
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
window.XiaobaiPageModules.register("dragon_tiger", ["dragonView"], {
|
||||||
|
enter: ["loadDragonTiger"],
|
||||||
|
});
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
window.XiaobaiPageModules.register("heaven", ["heavenView"], {
|
||||||
|
enter: ["loadHeaven"],
|
||||||
|
leave: ["stopHeaven"],
|
||||||
|
});
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
window.XiaobaiPageModules.register("ladder", ["ladderView"]);
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
window.XiaobaiPageModules.register("mentor", ["mentorView"], {
|
||||||
|
enter: ["loadMentor"],
|
||||||
|
});
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
window.XiaobaiPageModules.register("pools", [
|
||||||
|
"limitPool",
|
||||||
|
"brokenView",
|
||||||
|
"downView",
|
||||||
|
"yesterdayView",
|
||||||
|
"performanceView",
|
||||||
|
]);
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
window.XiaobaiPageModules.register("popularity", ["popularityView"], {
|
||||||
|
enter: ["loadPopularity"],
|
||||||
|
});
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
window.XiaobaiPageModules.register("review", ["reviewWorkspaceView"], {
|
||||||
|
enter: ["loadReview"],
|
||||||
|
});
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
window.XiaobaiPageModules.register("rotation", ["rotationView"], {
|
||||||
|
enter: ["loadRotation"],
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
window.XiaobaiPageModules.register("screener", ["screenerView"], {
|
||||||
|
enter: ["loadScreener"],
|
||||||
|
});
|
||||||
|
|
||||||
|
window.XiaobaiPageModules.register("screener", ["screenerTrackingView"]);
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
window.XiaobaiPageModules.register("sentiment", ["sentimentCycleView"], {
|
||||||
|
enter: ["loadSentiment"],
|
||||||
|
});
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
window.XiaobaiPageModules.register("themes", ["themeLibraryView"], {
|
||||||
|
enter: ["loadThemes"],
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user