refactor: establish standalone application boundary

This commit is contained in:
leefer
2026-08-03 21:42:25 +08:00
parent cc5fb8d73e
commit e1e76cd51e
324 changed files with 63090 additions and 44743 deletions
+13 -1859
View File
File diff suppressed because it is too large Load Diff
+39
View File
@@ -0,0 +1,39 @@
const PAGE_REGISTRY_URL = "/pages.config.js?v=20260803-2";
function loadRuntimeScripts(sources) {
return Promise.all(sources.map((src) => new Promise((resolve, reject) => {
const script = document.createElement("script");
script.src = src;
script.async = false;
script.addEventListener("load", resolve, { once: true });
script.addEventListener("error", () => reject(new Error(`无法加载运行脚本:${src}`)), { once: true });
document.body.append(script);
})));
}
async function loadPageFragment(fragment) {
const response = await fetch(fragment.url, { credentials: "same-origin" });
if (!response.ok) {
throw new Error(`无法加载页面片段:${fragment.url}HTTP ${response.status}`);
}
return response.text();
}
async function bootstrap() {
await import(PAGE_REGISTRY_URL);
const registry = window.XiaobaiPages;
const mount = document.querySelector(".app-main");
if (!registry || !mount) throw new Error("页面注册表或挂载点不存在");
const fragments = await Promise.all(registry.fragments.map(loadPageFragment));
fragments.forEach((markup) => mount.insertAdjacentHTML("beforeend", markup));
await loadRuntimeScripts(registry.runtimeScripts);
document.body.dataset.runtimeReady = "true";
}
bootstrap().catch((error) => {
document.body.dataset.bootstrapError = "true";
const status = document.querySelector("#statusText");
if (status) status.textContent = error?.message || "页面初始化失败";
setTimeout(() => { throw error; });
});
+31 -1291
View File
File diff suppressed because it is too large Load Diff
+61
View File
@@ -41,6 +41,65 @@
}),
];
const fragments = [
["pools", "/pages/pools/page.html?v=20260803-1", ["limitPool", "brokenView", "downView", "yesterdayView", "performanceView"]],
["sentiment", "/pages/sentiment/page.html?v=20260803-1", ["sentimentCycleView"]],
["heaven", "/pages/heaven/page.html?v=20260803-1", ["heavenView"]],
["ladder", "/pages/ladder/page.html?v=20260803-1", ["ladderView"]],
["screener", "/pages/screener/page.html?v=20260803-1", ["screenerView", "screenerTrackingView"]],
["mentor", "/pages/mentor/page.html?v=20260803-1", ["mentorView"]],
["rotation", "/pages/rotation/page.html?v=20260803-1", ["rotationView"]],
["auction", "/pages/auction/page.html?v=20260803-1", ["auctionView"]],
["themes", "/pages/themes/page.html?v=20260803-1", ["themeLibraryView"]],
["popularity", "/pages/popularity/page.html?v=20260803-1", ["popularityView"]],
["dragon_tiger", "/pages/dragon-tiger/page.html?v=20260803-1", ["dragonView"]],
["review", "/pages/review/page.html?v=20260803-1", ["reviewWorkspaceView"]],
].map(([feature, url, viewIds]) => Object.freeze({
feature,
url,
viewIds: Object.freeze(viewIds),
}));
const runtimeScripts = [
"/vendor/lucide.min.js",
"/shared/ui-core.js",
"/shared/components.js?v=20260729-1",
"/pages/runtime.js?v=20260729-1",
"/pages/sentiment/page.js?v=20260729-1",
"/pages/pools/page.js?v=20260729-1",
"/pages/market/breadth.js?v=20260803-1",
"/pages/market/charts.js?v=20260803-1",
"/pages/market/entity-detail.js?v=20260803-1",
"/pages/market/stock-detail.js?v=20260803-1",
"/pages/market/preview.js?v=20260803-1",
"/pages/market/search.js?v=20260803-1",
"/pages/market/bindings.js?v=20260803-1",
"/pages/ladder/page.js?v=20260729-1",
"/pages/rotation/page.js?v=20260729-1",
"/pages/auction/page.js?v=20260729-1",
"/pages/themes/page.js?v=20260729-1",
"/pages/popularity/page.js?v=20260729-1",
"/pages/dragon-tiger/page.js?v=20260729-1",
"/pages/screener/page.js?v=20260729-1",
"/pages/mentor/page.js?v=20260729-1",
"/pages/heaven/page.js?v=20260729-1",
"/pages/review/page.js?v=20260729-1",
"/shared/state.js?v=20260729-1",
"/shared/api.js?v=20260729-1",
"/shared/shell.js?v=20260729-1",
"/shared/export.js?v=20260731-1",
"/pages/heaven/loading-v2.js?v=20260728-2",
"/shared/context.js?v=20260803-1",
"/shared/feedback.js?v=20260803-1",
"/shared/application.js?v=20260803-1",
"/shared/table.js?v=20260803-1",
"/shared/theme.js?v=20260803-1",
"/shared/dashboard.js?v=20260803-1",
"/shared/session.js?v=20260803-1",
"/shared/admin.js?v=20260803-1",
"/app.js?v=20260803-2",
];
const all = [...pages, ...internalPages];
const byId = new Map(all.map((page) => [page.id, page]));
const defaultPage = pages.find((page) => page.default);
@@ -51,6 +110,8 @@
pages: Object.freeze(pages),
internalPages: Object.freeze(internalPages),
all: Object.freeze(all),
fragments: Object.freeze(fragments),
runtimeScripts: Object.freeze(runtimeScripts),
defaultPage,
aliases,
resolve(id) {
File diff suppressed because it is too large Load Diff
+74
View File
@@ -0,0 +1,74 @@
<section id="auctionView" class="workspace-view page redesigned-auction-view">
<header class="auction-page-head-v2 auc-head">
<div class="auction-title-cluster">
<div class="auction-title-line"><h2>集合竞价中心</h2><span id="auctionDateLabel">--</span></div>
<div id="auctionPhaseNotice" class="auction-phase-notice-v2" data-phase="archive" role="status" aria-live="polite">
<span class="auction-phase-marker" aria-hidden="true"></span>
<strong id="auctionPhaseTitle">竞价状态</strong>
<span id="auctionPhaseDetail">正在确认当前竞价阶段</span>
<time id="auctionPhaseTime">--</time>
</div>
</div>
<div class="auction-header-actions-v2">
<button id="auctionRefreshButton" class="auction-refresh-button" type="button" hidden><i data-lucide="refresh-cw"></i>刷新竞价</button>
</div>
</header>
<div class="auction-workspace-v2 auc-grid">
<section class="auction-primary-card card" aria-labelledby="auctionWorkspaceTitle">
<div class="visually-hidden"><h3 id="auctionWorkspaceTitle">重点异动</h3><span id="auctionWorkspaceSubtitle">优先查看市场核心与显著预期差</span></div>
<div class="auction-tabs-v2 tabs" role="tablist" aria-label="竞价数据集">
<button class="active" type="button" role="tab" aria-selected="true" data-auction-dataset="focus">重点异动 <strong id="auctionFocusCount">0</strong></button>
<button type="button" role="tab" aria-selected="false" data-auction-dataset="watchlist">我的自选 <strong id="auctionWatchlistCount">0</strong></button>
<button type="button" role="tab" aria-selected="false" data-auction-dataset="all">全部候选 <strong id="auctionAllCount">0</strong></button>
<button type="button" role="tab" aria-selected="false" data-auction-dataset="onePrice">竞价一字 <strong id="auctionOnePriceCount">0</strong></button>
<div id="auctionSummary" class="auction-summary-v2" aria-live="polite"></div>
</div>
<div id="auctionExpectationFilterbar" class="auction-tools-v2 tbl-tools">
<div id="auctionExpectationControls" class="auction-expectation-v2">
<span>预期筛选</span>
<div class="auction-filter-segments seg" role="group" aria-label="竞价预期差筛选">
<button class="active" type="button" data-auction-filter="all">全部</button>
<button type="button" data-auction-filter="above">超预期</button>
<button type="button" data-auction-filter="matched">符合预期</button>
<button type="button" data-auction-filter="below">低于预期</button>
</div>
</div>
<div class="auction-tool-actions">
<label class="auction-search-v2 search"><i data-lucide="search" aria-hidden="true"></i><span class="visually-hidden">搜索竞价候选</span><input id="auctionSearch" type="search" placeholder="代码、名称或行业"></label>
<button id="auctionExportButton" class="auction-export-button" type="button">导出 CSV</button>
</div>
</div>
<div class="auction-table-frame-v2 tbl-wrap">
<table id="auctionTable" class="data-table tbl auction-table-v2">
<thead><tr id="auctionTableHead"></tr></thead>
<tbody id="auctionTableBody"></tbody>
</table>
<div id="auctionEmpty" class="empty-state" hidden>没有符合条件的竞价候选</div>
</div>
</section>
<aside class="auction-side-v2" aria-label="竞价旁证">
<section class="auction-side-card auction-theme-card card" aria-labelledby="auctionThemeTitle">
<header class="auction-card-head-v2 card-h">
<div><h3 id="auctionThemeTitle">题材承接</h3><span>昨日强势方向</span></div>
<span id="auctionThemeBaseline" class="auction-card-tag">--</span>
</header>
<div id="auctionThemeCarry" class="auction-theme-list-v2"></div>
<div class="auction-new-theme-v2"><strong>今日新线索</strong><div id="auctionNewThemes" class="auction-theme-chips-v2"></div></div>
</section>
<section class="auction-side-card auction-volume-card card" aria-labelledby="auctionAmountTitle">
<header class="auction-card-head-v2 card-h">
<div><h3 id="auctionAmountTitle">竞价成交额对比</h3></div>
<span class="auction-card-tag">近 10 个交易日</span>
</header>
<div class="auction-volume-body">
<div class="auction-volume-summary"><strong id="auctionAmountValue" class="auction-amount-value-v2">--</strong><div id="auctionAmountCompare" class="auction-amount-compare-v2"></div></div>
<div id="auctionAmountTrend" class="auction-amount-trend-v2" aria-label="近十个交易日集合竞价成交额"></div>
</div>
<div class="auction-volume-legend"><span><i></i>历史交易日</span><span><i class="current"></i>当日</span><span><i class="average"></i>5 日均值</span></div>
</section>
</aside>
</div>
</section>
+43 -2
View File
@@ -1,9 +1,9 @@
window.XiaobaiPageModules.register("auction", ["auctionView"], {
bind: bindAuctionEvents,
enter: ["loadAuction"],
leave: ["clearAuction"],
});
/* PRESERVATION-SOURCE-BEGIN app.js:2217-2481 */
async function loadAuctionCenter(force = false) {
if (state.auctionLoading) return;
state.auctionLoading = true;
@@ -269,4 +269,45 @@ function exportAuctionRows() {
]);
}
/* PRESERVATION-SOURCE-END app.js:2217-2481 */
function bindAuctionEvents() {
document.querySelector("#auctionRefreshButton").addEventListener("click", () => loadAuctionCenter(true));
document.querySelector("#auctionExportButton").addEventListener("click", exportAuctionRows);
document.querySelector("#auctionSearch").addEventListener("input", (event) => {
state.auctionQuery = event.target.value.trim().toLocaleLowerCase("zh-CN");
renderAuctionTable();
});
document.querySelectorAll("[data-auction-dataset]").forEach((button) => {
button.addEventListener("click", () => {
state.auctionDataset = button.dataset.auctionDataset || "focus";
state.auctionFilter = "all";
state.auctionSortKey = state.auctionDataset === "onePrice" ? "amount_million" : "attention_score";
state.auctionSortDirection = "desc";
document.querySelectorAll("[data-auction-dataset]").forEach((item) => {
const active = item === button;
item.classList.toggle("active", active);
item.setAttribute("aria-selected", String(active));
});
document.querySelectorAll("[data-auction-filter]").forEach((item) => item.classList.toggle("active", item.dataset.auctionFilter === "all"));
renderAuctionTable();
});
});
document.querySelectorAll("[data-auction-filter]").forEach((button) => {
button.addEventListener("click", () => {
state.auctionFilter = button.dataset.auctionFilter || "all";
document.querySelectorAll("[data-auction-filter]").forEach((item) => item.classList.toggle("active", item === button));
renderAuctionTable();
});
});
document.querySelector("#auctionTable").addEventListener("click", (event) => {
const header = event.target.closest("th[data-auction-sort]");
if (!header) return;
const key = header.dataset.auctionSort;
if (state.auctionSortKey === key) state.auctionSortDirection = state.auctionSortDirection === "asc" ? "desc" : "asc";
else {
state.auctionSortKey = key;
state.auctionSortDirection = "desc";
}
renderAuctionTable();
});
}
File diff suppressed because it is too large Load Diff
+85
View File
@@ -0,0 +1,85 @@
<section id="dragonView" class="workspace-view page redesigned-dragon-view">
<header class="dragon-page-head-v2 lad-head">
<div class="dragon-title-v2">
<h2>龙虎榜</h2>
<span>游资动向与席位明细</span>
<strong id="dragonDateLabel">--</strong>
</div>
<div class="dragon-head-actions-v2">
<div class="dragon-view-tabs-v2" role="group" aria-label="龙虎榜视图">
<button id="dragonDailyButton" class="active" type="button" data-dragon-view-mode="daily" aria-pressed="true">每日明细</button>
<button id="dragonProfilesButton" type="button" data-dragon-view-mode="profiles" aria-pressed="false">游资档案</button>
</div>
<button id="dragonRefreshButton" class="button dragon-action-v2" type="button"><i data-lucide="refresh-cw"></i><span>刷新</span></button>
<button id="dragonExportButton" class="button dragon-action-v2" type="button"><i data-lucide="download"></i><span>导出 CSV</span></button>
</div>
</header>
<section id="dragonEmptyState" class="dragon-empty-state-v2" hidden aria-live="polite">
<div class="dragon-empty-symbol-v2" aria-hidden="true"><i data-lucide="list-tree"></i></div>
<h3 id="dragonEmptyTitle">当日暂无龙虎榜明细</h3>
<p id="dragonEmptyDescription">龙虎榜明细通常在交易日盘后陆续披露,可稍后刷新或查看前一交易日。</p>
<div class="dragon-empty-actions-v2">
<button id="dragonPreviousButton" class="button" type="button"><i data-lucide="arrow-left"></i><span>查看前一交易日</span></button>
<button id="dragonEmptyRefreshButton" class="button primary" type="button"><i data-lucide="refresh-cw"></i><span>重新检查</span></button>
</div>
</section>
<div id="dragonDailyContent" class="dragon-daily-content-v2">
<div id="dragonSummary" class="dragon-summary-v2"></div>
<div class="dragon-filterbar-v2">
<div class="dragon-filter-copy-v2"><h3>每日明细</h3><span>按公开席位归集当日游资操作</span></div>
<div class="dragon-filter-actions-v2">
<div class="dragon-segments-v2" role="group" aria-label="龙虎榜筛选">
<button type="button" class="dragon-filter-v2 active" data-dragon-filter="all">全部游资</button>
<button type="button" class="dragon-filter-v2" data-dragon-filter="buy">净买入</button>
<button type="button" class="dragon-filter-v2" data-dragon-filter="sell">净卖出</button>
<button id="dragonUnclassifiedFilter" type="button" class="dragon-filter-v2" data-dragon-filter="unclassified">待归类</button>
</div>
<label class="dragon-search-v2"><i data-lucide="search" aria-hidden="true"></i><span class="visually-hidden">搜索游资龙虎榜</span><input id="dragonSearch" type="search" placeholder="搜索游资、席位或股票" autocomplete="off"></label>
</div>
</div>
<section class="dragon-card-stage dragon-card-stage-v2 card">
<div class="dragon-stage-heading-v2"><div><h3>活跃游资</h3><span>点击卡牌查看当日操作</span></div><small>净买入为红 · 净卖出为绿</small></div>
<div id="dragonTraderList" class="dragon-trader-list"></div>
</section>
<section id="dragonTraderDetail" class="dragon-trader-detail dragon-trader-detail-v2 card"><div class="empty-state dragon-empty">选择一位游资查看操作明细</div></section>
<details id="dragonUnclassifiedSection" class="unclassified-section dragon-unclassified-v2">
<summary class="unclassified-heading">
<div><h3 id="unclassifiedTitle">待归类席位</h3><span>为营业部设置游资名后,同名席位会自动合并</span></div>
<strong id="unclassifiedCount">0 个</strong>
</summary>
<div id="unclassifiedSeatList" class="unclassified-seat-list"></div>
</details>
</div>
<section id="dragonProfilesContent" class="hot-money-profiles-v2" hidden aria-label="游资档案">
<header class="hot-money-profile-toolbar-v2">
<div class="dragon-filter-copy-v2">
<h3>游资名录</h3>
<span>公开收录的游资简介与关联营业部</span>
</div>
<div id="hotMoneyProfileSummary" class="hot-money-profile-summary-v2" aria-label="名录统计"></div>
<label class="dragon-search-v2 hot-money-profile-search-v2">
<i data-lucide="search" aria-hidden="true"></i>
<span class="visually-hidden">搜索游资档案</span>
<input id="hotMoneyProfileSearch" type="search" placeholder="搜索游资、简介或营业部" autocomplete="off">
</label>
</header>
<div class="hot-money-profile-workspace-v2">
<section class="hot-money-profile-directory-v2" aria-label="游资名录列表">
<header class="hot-money-profile-directory-head-v2">
<strong>全部游资</strong>
<span id="hotMoneyProfileResultCount">0 位</span>
</header>
<div id="hotMoneyProfileList" class="hot-money-profile-list-v2" role="listbox" aria-label="选择游资档案"></div>
</section>
<article id="hotMoneyProfileDetail" class="hot-money-profile-detail-v2" aria-live="polite">
<div class="hot-money-profile-empty-v2">
<i data-lucide="contact" aria-hidden="true"></i>
<strong>选择一位游资查看档案</strong>
</div>
</article>
</div>
</section>
</section>
+43 -2
View File
@@ -1,8 +1,8 @@
window.XiaobaiPageModules.register("dragon_tiger", ["dragonView"], {
bind: bindDragonTigerEvents,
enter: ["loadDragonTiger"],
});
/* PRESERVATION-SOURCE-BEGIN app.js:2660-3028 */
function selectDragonViewMode(mode) {
state.dragonViewMode = mode === "profiles" ? "profiles" : "daily";
document.querySelectorAll("[data-dragon-view-mode]").forEach((button) => {
@@ -372,4 +372,45 @@ async function saveSeatAlias(event) {
}
}
/* PRESERVATION-SOURCE-END app.js:2660-3028 */
function bindDragonTigerEvents() {
document.querySelector("#dragonRefreshButton").addEventListener("click", () => {
if (state.dragonViewMode === "profiles") loadHotMoneyProfiles(true);
else loadDragonTiger(true);
});
document.querySelector("#dragonEmptyRefreshButton").addEventListener("click", () => loadDragonTiger(true));
document.querySelector("#dragonPreviousButton").addEventListener("click", () => shiftDate(-1));
document.querySelector("#dragonExportButton").addEventListener("click", () => {
if (state.dragonViewMode === "profiles") exportHotMoneyProfiles();
else exportDragonTiger();
});
document.querySelectorAll("[data-dragon-view-mode]").forEach((button) => {
button.addEventListener("click", () => selectDragonViewMode(button.dataset.dragonViewMode));
});
document.querySelector("#dragonSearch").addEventListener("input", (event) => {
state.dragonQuery = event.target.value.trim().toLowerCase();
renderDragonTraderList();
});
document.querySelectorAll("[data-dragon-filter]").forEach((button) => {
button.addEventListener("click", () => {
state.dragonFilter = button.dataset.dragonFilter;
document.querySelectorAll("[data-dragon-filter]").forEach((item) => {
item.classList.toggle("active", item === button);
});
renderDragonTraderList();
});
});
document.querySelector("#hotMoneyProfileSearch").addEventListener("input", (event) => {
state.hotMoneyProfileQuery = event.target.value.trim().toLocaleLowerCase("zh-CN");
renderHotMoneyProfiles();
});
document.querySelector("#hotMoneyProfileList").addEventListener("click", (event) => {
const button = event.target.closest("[data-hot-money-profile]");
if (!button) return;
state.selectedHotMoneyProfileId = button.dataset.hotMoneyProfile;
renderHotMoneyProfiles();
});
window.addEventListener("resize", () => {
if (state.activeView === "dragonView") layoutDragonCards();
});
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+255
View File
@@ -0,0 +1,255 @@
<section id="heavenView" class="workspace-view member-feature-view heaven-shell wt">
<div class="member-gate" hidden><div class="member-gate-icon"><i data-lucide="lock-keyhole"></i></div><div><strong>问天仅对会员开放</strong><span>开通会员后可使用观势、观气、观心及平台解读。会员状态可从顶部账号标识进入。</span></div></div>
<header class="wt-head">
<div class="wt-title-line">
<h1 class="wt-serif">问 天</h1>
<span id="heavenDataDate">--</span>
</div>
<div class="verse wt-serif">观天之道 · 执天之行</div>
<nav class="wt-tabs" aria-label="问天模块">
<button class="wt-tab wt-serif on" type="button" data-heaven-panel="trend" aria-current="page">观势<small>三才六爻 · 量化成卦</small></button>
<button class="wt-tab wt-serif" type="button" data-heaven-panel="fortune">观气<small>五运六气 · 日辰生克</small></button>
<button class="wt-tab wt-serif" type="button" data-heaven-panel="heart">观心<small>静心占卜 · 第一念</small></button>
</nav>
</header>
<p class="heaven-proverb wt-serif">遇事不决可问春风,春风不语即随本心</p>
<div id="heavenNotice" class="inline-notice" role="status" hidden></div>
<section id="heavenTrendPanel" class="heaven-panel active-heaven-panel">
<div class="heaven-controls">
<div class="heaven-stock-query">
<label class="form-field" for="heavenStockInput"><span>股票代码或名称</span><input id="heavenStockInput" type="text" inputmode="text" maxlength="30" autocomplete="off" placeholder="例如 600000 或 浦发银行"></label>
<div id="heavenStockIdentity" class="heaven-stock-identity" aria-live="polite" hidden>
<span>当前标的:</span>
<strong id="heavenStockName">--</strong>
<span id="heavenStockTaxonomy">申万二级 ·</span>
<strong id="heavenStockSector">--</strong>
</div>
</div>
<div class="heaven-trend-actions">
<button id="loadHeavenSelectionButton" class="button" type="button">载入</button>
<button id="historyTrendButton" class="button" type="button">历史记录</button>
<button id="interpretTrendButton" class="button primary" type="button" disabled>解势</button>
</div>
</div>
<div class="cast-hint wt-serif">载入,以指数为天、行业为人、个股为地,六爻皆由行情量化而成</div>
<div class="wt-stage">
<div class="stars" id="stars" aria-hidden="true"></div>
<svg class="bagua" id="baguaSvg" width="560" height="560" viewBox="0 0 300 300" aria-hidden="true"></svg>
<div id="heavenTrendEmpty" class="wt-empty" role="status">
<div class="big wt-serif">三才六爻</div>
<p class="wt-serif">指数外显为上爻 · 内核为五爻 · 行业外显为四爻 · 内核为三爻 · 个股外显为二爻 · 内核为初爻<br>六爻皆由行情量化而成 —— 输入标的,点「载入」成卦</p>
</div>
<div class="heaven-trend-layout stage-in" hidden>
<section class="hexagram-board">
<div class="hexagram-heading">
<div><span class="metric-label">三才六爻</span><h3 id="marketHexagramName">--</h3></div>
<div class="hexagram-change"><span>动而之卦</span><strong id="marketTransformedName">--</strong></div>
</div>
<div id="marketHexagramLines" class="hexagram-lines"></div>
<p id="marketHexagramText" class="hexagram-text">--</p>
<p id="marketMovementSummary" class="market-movement-summary">--</p>
</section>
<section class="trend-reading-panel">
<div class="trend-score-line">
<div><span class="metric-label">势值</span><strong id="heavenMomentumScore">--</strong></div>
<span id="heavenMomentumLabel">--</span>
</div>
<div class="trend-score-meter" role="meter" aria-label="势值" aria-valuemin="-100" aria-valuemax="100">
<div class="trend-score-track"><i id="heavenMomentumNeedle"></i></div>
<div class="trend-score-marks"><span>-100 · 势衰</span><span>0</span><span>+100 · 势盛</span></div>
</div>
<div id="threeTalentReadings" class="three-talent-readings"></div>
<div class="heaven-hex-transition" aria-label="本卦与之卦">
<figure class="compact-hex-figure">
<div id="heavenOriginalHexLines" class="compact-hex-lines" aria-hidden="true"></div>
<figcaption><strong>本卦 · <b id="heavenOriginalHexName">--</b></strong><small id="heavenOriginalHexDetail">--</small></figcaption>
</figure>
<div class="compact-hex-change"><i aria-hidden="true"></i><span>动而之卦</span></div>
<figure class="compact-hex-figure">
<div id="heavenChangedHexLines" class="compact-hex-lines" aria-hidden="true"></div>
<figcaption><strong>之卦 · <b id="heavenChangedHexName">--</b></strong><small id="heavenChangedHexDetail">--</small></figcaption>
</figure>
</div>
</section>
</div>
</div>
<details id="heavenCalibrationPanel" class="heaven-calibration-panel" hidden>
<summary class="heaven-calibration-heading">
<div><span class="metric-label">量化数据安全门</span><h3 id="heavenCalibrationTitle">六爻数据校验</h3></div>
<div class="heaven-calibration-summary">
<span><i class="status-dot passed"></i>通过</span>
<span><i class="status-dot failed"></i>未通过</span>
<span><i class="status-dot manual"></i>用户补录</span>
<strong id="heavenCalibrationStatus">等待载入</strong>
<b class="fold-label">展开</b>
</div>
</summary>
<div class="calibration-body">
<p>系统只接收客观行情数据,所有补录仍按原量化公式计算阴阳与动爻。</p>
<form id="heavenCalibrationForm">
<div id="heavenLineChecks" class="heaven-line-checks" aria-live="polite"></div>
<label class="form-field calibration-note-field"><span>补录说明</span><input id="heavenCalibrationNote" type="text" maxlength="200" placeholder="可选:记录数据来源或补录原因"></label>
<div class="dialog-actions heaven-calibration-actions">
<button id="resetHeavenCalibrationButton" class="button" type="button">恢复自动数据</button>
<button id="applyHeavenCalibrationButton" class="button primary" type="submit">重新校验并成卦</button>
</div>
</form>
</div>
</details>
</section>
<section id="heavenFortunePanel" class="heaven-panel">
<div class="fortune-heading">
<div class="fortune-calendar-heading"><span id="fortuneLunarDate" class="metric-label">--</span><h3 id="fortunePillars" class="wt-serif">--</h3></div>
<div class="fortune-heading-actions">
<label class="qi-time-field"><span>观测日期</span><input id="qiObservationDate" type="date"></label>
<button id="historyFortuneButton" class="button" type="button">历史记录</button>
<button id="interpretFortuneButton" class="button primary" type="button" disabled>解运</button>
</div>
</div>
<div class="fortune-stage">
<div class="stars" id="fortuneStars" aria-hidden="true"></div>
<svg class="bagua fortune-bagua" id="fortuneBagua" width="560" height="560" viewBox="0 0 300 300" aria-hidden="true"></svg>
<section class="qi-climate-panel">
<span class="qi-section-mark">壹 · 天</span>
<p class="qi-climate-caption wt-serif">今日气候</p>
<h3 id="qiClimateKeyword" class="wt-serif">气机待察</h3>
<strong id="qiClimateTone" class="wt-serif">--</strong>
<p id="humanFieldSummary" class="human-field-summary">--</p>
</section>
<aside class="fortune-basics">
<section class="qi-framework-panel">
<div class="workspace-heading"><div><span>贰 · 气</span><h3 class="wt-serif">三层气机</h3></div><button class="text-fold-button" type="button" data-fold-target="qiFrameworkLayers">收起</button></div>
<p id="qiFrameworkPrinciple" class="qi-framework-principle">--</p>
<div id="qiFrameworkLayers" class="qi-framework-layers"></div>
</section>
<section class="personal-fortune-panel">
<div class="workspace-heading"><div><span>叁 · 人</span><h3 class="wt-serif">个人合参</h3></div></div>
<div id="personalProfileEmpty" class="personal-profile-empty"><span>当前账号尚未设置个人命理资料</span></div>
<div id="personalFortuneResult" class="personal-fortune-result" hidden></div>
</section>
</aside>
</div>
<details id="fortuneSectorCatalog" class="fortune-sector-catalog">
<summary>
<span><small>五行取象</small><strong class="wt-serif">五行对应行业</strong></span>
<span class="fortune-sector-summary-hint">展开查看全部行业 <i aria-hidden="true"></i></span>
</summary>
<div id="fortuneSectorGroups" class="fortune-sector-groups"></div>
</details>
<p id="fortuneNotice" class="heaven-footnote"></p>
<div class="wentian-legacy-hooks" hidden aria-hidden="true">
<canvas id="qiFieldCanvas"></canvas>
<div id="heavenIndexStrip"></div><div id="heavenTrendEvidence"></div>
<div id="fortuneMetrics"></div><div id="fivePhaseBalance"></div>
<div id="humanEmotionList"></div><div id="humanBiasList"></div><div id="humanOperation"></div><div id="humanBalanceActions"></div>
<div id="phaseSectorTitle"></div><div id="phaseSectorContext"></div>
<div id="qiUseMap"><div id="qiUseSources"></div><svg id="qiUseConnections"></svg><div id="phaseSectorList"></div></div>
<button id="openPersonalSettingsButton" type="button"></button>
<details id="sectorPhaseManager"><summary>归类管理</summary><form id="sectorPhaseForm"><input id="sectorPhaseName"><select id="sectorPhaseElement"><option value="木"></option></select><button type="submit">保存</button></form><div id="sectorPhaseOverrides"></div></details>
</div>
</section>
<section id="heavenHeartPanel" class="heaven-panel">
<div class="heart-journey" aria-label="观心进程">
<span class="active" data-heart-step="intro"><i></i>静心</span><b></b>
<span data-heart-step="breathing"><i></i>呼吸</span><b></b>
<span data-heart-step="casting"><i></i>起卦</span><b></b>
<span data-heart-step="reveal"><i></i>察念</span><b></b>
<span data-heart-step="interpretation"><i></i>解卦</span>
</div>
<div class="heart-stage-shell">
<canvas id="heartDustCanvas" hidden></canvas><div id="heartLamp" hidden></div><div id="heartRitualCurtain" hidden></div><div id="heartLineTexts" hidden></div>
<div class="stars" id="heartStars" aria-hidden="true"></div>
<svg class="bagua heart-bagua" id="heartBagua" width="560" height="560" viewBox="0 0 300 300" aria-hidden="true"></svg>
<div id="heartWhispers" class="heart-whispers" aria-hidden="true"></div>
<div class="heart-toolbar-controls">
<button id="historyHeartButton" class="button" type="button">历史记录</button>
<button id="heartSoundToggle" class="button" type="button" aria-pressed="false">静音</button>
</div>
<div id="heartIntro" class="heart-stage active-heart-stage">
<div class="heart-stage-inner">
<span class="heart-stage-index heart-rise" data-heart-delay="0">观心 · 一</span>
<h3 class="heart-rise wt-serif" data-heart-delay="420">把所问之事留在心里</h3>
<div class="heart-guidance heart-rise" data-heart-delay="900">
<p>只问一事,不必说出来。</p>
<p>心里默念它发生的对象与时间。</p>
<p>不求一个喜欢的答案,只看自己真正担心什么。</p>
</div>
<blockquote class="heart-motto heart-rise wt-serif" data-heart-delay="1500">遇事不决可问春风,春风不语即随本心</blockquote>
<button id="startBreathingButton" class="button primary heart-rise" data-heart-delay="2200" type="button">开始静心</button>
</div>
</div>
<div id="heartBreathing" class="heart-stage">
<button class="button heart-return-button" type="button" data-heart-return>← 返回</button>
<div class="heart-stage-inner breathing-stage">
<span class="heart-stage-index">观心 · 二</span>
<div id="breathingScene" class="breathing-scene" data-phase="prepare">
<div class="heart-breath-ripple" aria-hidden="true"><span></span><span></span><span></span><i></i></div>
<b id="breathingPhase" class="breathing-phase" role="status" aria-live="polite"></b>
</div>
<h3 id="breathingPrompt" class="wt-serif">放松片刻,准备呼吸</h3>
<div class="heart-incense" aria-hidden="true"><i id="heartIncenseEmber"></i></div>
<button id="beginCastingButton" class="button primary" type="button" disabled>静心完成,开始起卦</button>
</div>
</div>
<div id="heartCasting" class="heart-stage">
<button class="button heart-return-button" type="button" data-heart-return>← 返回</button>
<div class="heart-casting-layout">
<section class="heart-hexagram-shell">
<div class="workspace-heading"><h3 class="wt-serif">从初爻起</h3><span id="castingProgress">0 / 6</span></div>
<div id="heartCastingLines" class="hexagram-lines ritual-lines"></div>
</section>
<section class="casting-action-panel">
<span class="heart-stage-index">观心 · 三</span>
<div id="coinResult" class="heart-coins" aria-label="三枚铜钱">
<div class="heart-coin" data-coin-index="0"><div class="heart-coin-inner"><span class="heart-coin-face front" aria-label="字面"><b class="coin-glyph coin-glyph-top"></b><b class="coin-glyph coin-glyph-right"></b><b class="coin-glyph coin-glyph-bottom"></b><b class="coin-glyph coin-glyph-left"></b><i class="coin-hole"></i></span><span class="heart-coin-face back" aria-label="背面"><b class="coin-glyph coin-glyph-top"></b><b class="coin-glyph coin-glyph-bottom"></b><i class="coin-hole"></i></span></div><i class="heart-coin-ring"></i></div>
<div class="heart-coin" data-coin-index="1"><div class="heart-coin-inner"><span class="heart-coin-face front" aria-label="字面"><b class="coin-glyph coin-glyph-top"></b><b class="coin-glyph coin-glyph-right"></b><b class="coin-glyph coin-glyph-bottom"></b><b class="coin-glyph coin-glyph-left"></b><i class="coin-hole"></i></span><span class="heart-coin-face back" aria-label="背面"><b class="coin-glyph coin-glyph-top"></b><b class="coin-glyph coin-glyph-bottom"></b><i class="coin-hole"></i></span></div><i class="heart-coin-ring"></i></div>
<div class="heart-coin" data-coin-index="2"><div class="heart-coin-inner"><span class="heart-coin-face front" aria-label="字面"><b class="coin-glyph coin-glyph-top"></b><b class="coin-glyph coin-glyph-right"></b><b class="coin-glyph coin-glyph-bottom"></b><b class="coin-glyph coin-glyph-left"></b><i class="coin-hole"></i></span><span class="heart-coin-face back" aria-label="背面"><b class="coin-glyph coin-glyph-top"></b><b class="coin-glyph coin-glyph-bottom"></b><i class="coin-hole"></i></span></div><i class="heart-coin-ring"></i></div>
</div>
<h3 id="castingPrompt" class="wt-serif">心中默念所问之事,然后掷出初爻</h3>
<button id="tossCoinsButton" class="heart-cast-button" type="button"><i class="heart-hold-charge" aria-hidden="true"></i><span>按住<br>摇初爻</span></button>
</section>
</div>
</div>
<div id="heartReveal" class="heart-stage">
<button class="button heart-return-button" type="button" data-heart-return>← 返回</button>
<div class="heart-reveal-layout">
<section class="hexagram-board heart-reveal-board">
<div class="hexagram-heading">
<div><span class="metric-label">本卦</span><h3 id="heartHexagramName">--</h3></div>
<div class="hexagram-change"><span>之卦</span><strong id="heartTransformedName">--</strong></div>
</div>
<div id="heartHexagramLines" class="hexagram-lines"></div>
<p id="heartHexagramText" class="hexagram-text">--</p>
</section>
<section class="heart-first-thought">
<span class="heart-stage-index">观心 · 四</span>
<h3 class="wt-serif">先不解卦</h3>
<p id="heartFirstThoughtPrompt">看见卦象与爻辞后,心里升起的第一念是什么?</p>
<p>不要修饰,也不必记录。只需看见它。</p>
<button id="interpretHeartButton" class="button primary" type="button">我已察念,开始解卦</button>
</section>
</div>
</div>
<div id="heartInterpretationStage" class="heart-stage">
<div class="heart-interpretation-heading">
<div><span class="heart-stage-index">观心 · 五</span><h3 id="heartReadTitle" class="wt-serif">解卦</h3><small id="heartReadChange">--</small></div>
<div class="heart-read-actions"><button id="viewHeartReadingButton" class="button primary" type="button">查看解卦</button><button id="restartHeartButton" class="button" type="button">重新观心</button></div>
</div>
<p id="heartReadGuaci" class="heart-read-guaci">--</p>
<div class="heart-read-layout">
<div id="heartReadLines" class="heart-read-lines"></div>
<div id="heartReadTexts" class="heart-read-texts"></div>
</div>
<p class="heart-read-motto wt-serif">一念既察,卦只是镜。</p>
</div>
</div>
<p class="heaven-footnote heart-footnote">观心用于观察念头与执着,不用于替代交易计划或预测涨跌。</p>
</section>
</section>
+137 -2
View File
@@ -1,9 +1,95 @@
const HEART_BREATH_INHALE_MS = 3_000;
const HEART_BREATH_HOLD_MS = 2_000;
const HEART_BREATH_EXHALE_MS = 4_000;
const HEART_BREATH_PREPARE_MS = 1_000;
const HEART_BREATH_CYCLE_MS = HEART_BREATH_INHALE_MS + HEART_BREATH_HOLD_MS + HEART_BREATH_EXHALE_MS;
const HEART_BREATH_ACTIVE_MS = HEART_BREATH_CYCLE_MS * 5;
const HEART_BREATH_TOTAL_MS = HEART_BREATH_PREPARE_MS + HEART_BREATH_ACTIVE_MS;
let qiFieldAnimationFrame = 0;
let qiFieldSoloElement = "";
let heavenPerformanceToken = 0;
let heavenReadingAnimation = null;
let heartHoldTimer = null;
let heartHoldTriggered = false;
let heartHoldStartedAt = 0;
let heartHoldAnimationFrame = 0;
let heartCastingBusy = false;
let heartDustAnimationFrame = 0;
let heartDustParticles = [];
let heartIncenseAnimation = null;
const heartCoinRotations = [0, 0, 0];
let heavenResizeTimer = null;
const heartSound = {
enabled: false,
context: null,
ensure() {
if (!this.context) {
const AudioContextClass = window.AudioContext || window.webkitAudioContext;
if (!AudioContextClass) return null;
this.context = new AudioContextClass();
}
if (this.context.state === "suspended") this.context.resume();
return this.context;
},
tone(frequency, duration, gain, type = "sine", delay = 0) {
if (!this.enabled) return;
const context = this.ensure();
if (!context) return;
const start = context.currentTime + delay;
const oscillator = context.createOscillator();
const volume = context.createGain();
oscillator.type = type;
oscillator.frequency.value = frequency;
volume.gain.setValueAtTime(0.0001, start);
volume.gain.linearRampToValueAtTime(gain, start + 0.015);
volume.gain.exponentialRampToValueAtTime(0.0001, start + duration);
oscillator.connect(volume).connect(context.destination);
oscillator.start(start);
oscillator.stop(start + duration + 0.05);
},
chime(frequency = 640) {
this.tone(frequency, 4.8, 0.12);
this.tone(frequency * 2.02, 3.6, 0.045);
this.tone(frequency * 3.96, 2.2, 0.018);
},
coin(delay = 0) {
this.tone(2350 + Math.random() * 260, 0.28, 0.055, "triangle", delay);
this.tone(3250 + Math.random() * 260, 0.18, 0.025, "triangle", delay + 0.01);
},
};
const HEART_WHISPERS = [
["应无所住,而生其心", 10, 12, 0],
["不是风动,不是幡动,仁者心动", 89, 8, 1],
["菩提本无树,明镜亦非台", 16, 52, 2],
["本来无一物,何处惹尘埃", 84, 54, 3],
["心外无物,心外无理", 22, 18, 4],
["知行合一", 78, 30, 5],
["此心光明,亦复何言", 90, 60, 6],
];
window.addEventListener("resize", () => {
clearTimeout(heavenResizeTimer);
heavenResizeTimer = setTimeout(() => {
if (state.activeView !== "heavenView") return;
if (state.heavenPanel === "fortune" && state.heavenSetup?.field) {
renderQiFieldCanvas(state.heavenSetup.field.balance || [], { intro: false });
drawQiUseConnections(false);
}
if (state.heavenPanel === "heart") startHeartDust();
}, 120);
});
window.XiaobaiPageModules.register("heaven", ["heavenView"], {
bind: bindHeavenEvents,
enter: ["loadHeaven"],
leave: ["stopHeaven"],
});
/* PRESERVATION-SOURCE-BEGIN app.js:4828-6667 */
async function loadHeavenSetup(force = false, sector = "", stockCode = "") {
const calendarDate = document.querySelector("#qiObservationDate")?.value || elements.tradeDate.value;
const requestedDate = calendarDate.replaceAll("-", "");
@@ -1827,4 +1913,53 @@ function capitalize(value) {
const LINE_POSITIONS_CLIENT = ["初爻", "二爻", "三爻", "四爻", "五爻", "上爻"];
/* PRESERVATION-SOURCE-END app.js:4828-6667 */
function bindHeavenEvents() {
document.querySelectorAll("[data-heaven-panel]").forEach((button) => {
button.addEventListener("click", () => selectHeavenPanel(button.dataset.heavenPanel, true));
});
document.querySelector("#loadHeavenSelectionButton").addEventListener("click", loadHeavenSelection);
document.querySelector("#heavenCalibrationForm").addEventListener("submit", applyHeavenCalibration);
document.querySelector("#resetHeavenCalibrationButton").addEventListener("click", resetHeavenCalibration);
document.querySelector("#heavenStockInput").addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault();
loadHeavenSelection();
}
});
document.querySelector("#interpretTrendButton").addEventListener("click", () => interpretHeaven("trend"));
document.querySelector("#interpretFortuneButton").addEventListener("click", () => interpretHeaven("fortune"));
document.querySelector("#historyTrendButton").addEventListener("click", () => openHeavenHistory("trend"));
document.querySelector("#historyFortuneButton").addEventListener("click", () => openHeavenHistory("fortune"));
document.querySelector("#qiObservationDate").addEventListener("change", () => {
state.personalField = null;
state.heavenManualData = null;
state.heavenInterpretations.fortune = "";
loadHeavenSetup(
true,
"",
document.querySelector("#heavenStockInput").value.trim(),
);
});
document.querySelector("#openPersonalSettingsButton").addEventListener("click", () => openSettings("profile"));
document.querySelector("#sectorPhaseForm").addEventListener("submit", saveSectorPhaseOverride);
document.querySelector("#startBreathingButton").addEventListener("click", startHeartBreathing);
document.querySelector("#beginCastingButton").addEventListener("click", beginHeartCasting);
document.querySelector("#heartSoundToggle").addEventListener("click", toggleHeartSound);
document.querySelector("#historyHeartButton").addEventListener("click", () => openHeavenHistory("heart"));
initializeHeartCoinHold();
initializeHeartLineInspection();
document.querySelector("#interpretHeartButton").addEventListener("click", () => interpretHeaven("heart"));
document.querySelector("#viewHeartReadingButton").addEventListener("click", () => openHeavenReading("heart"));
document.querySelector("#restartHeartButton").addEventListener("click", resetHeartRitual);
document.querySelector("#closeHeavenReadingDialog").addEventListener("click", () => elements.heavenReadingDialog.close());
elements.heavenReadingDialog.addEventListener("close", stopHeavenReadingAnimation);
document.querySelectorAll("[data-heaven-reading-tab]").forEach((button) => {
button.addEventListener("click", () => selectHeavenReadingTab(button.dataset.heavenReadingTab));
});
document.querySelector("#heavenReadingHistoryList").addEventListener("click", handleHeavenHistorySelection);
document.querySelector("#heavenReadingHistoryDetail").addEventListener("click", handleHeavenHistoryAction);
document.querySelectorAll("[data-heart-return]").forEach((button) => {
button.addEventListener("click", resetHeartRitual);
});
}
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
<section id="ladderView" class="workspace-view page redesigned-ladder-view">
<div class="section-toolbar lad-head redesigned-page-head ladder-page-head">
<div class="section-title-group">
<h2>市场天梯</h2>
<span class="section-subtitle">按连板高度观察空间板与梯队完整度 · <b id="ladderDateRange">--</b></span>
</div>
<div class="ladder-head-actions">
<div id="ladderSortSegment" class="ladder-sort-segment" role="group" aria-label="天梯排序">
<button type="button" class="active" data-ladder-sort="time" aria-pressed="true">按封板时间</button>
<button type="button" data-ladder-sort="open" aria-pressed="false">按开板次数</button>
</div>
<button id="ladderExportButton" class="button" type="button">导出 CSV</button>
</div>
</div>
<div class="market-ladder-workspace lad-grid">
<div id="ladderBoard" class="market-ladder-board"></div>
<aside id="ladderInsights" class="market-ladder-insights" aria-label="天梯结构解读"></aside>
</div>
</section>
+16 -3
View File
@@ -1,6 +1,5 @@
window.XiaobaiPageModules.register("ladder", ["ladderView"]);
window.XiaobaiPageModules.register("ladder", ["ladderView"], { bind: bindLadderEvents });
/* PRESERVATION-SOURCE-BEGIN app.js:2125-2216 */
function renderLadderMini(ladders) {
const container = document.querySelector("#ladderMini");
const highest = ladders.length ? Math.max(...ladders.map((item) => number(item.level))) : 0;
@@ -93,4 +92,18 @@ function renderLadderBoard(ladders) {
refreshIcons();
}
/* PRESERVATION-SOURCE-END app.js:2125-2216 */
function bindLadderEvents() {
document.querySelectorAll("[data-ladder-sort]").forEach((button) => {
button.addEventListener("click", () => {
state.ladderSortMode = button.dataset.ladderSort === "open" ? "open" : "time";
document.querySelectorAll("[data-ladder-sort]").forEach((item) => {
const active = item === button;
item.classList.toggle("active", active);
item.setAttribute("aria-pressed", String(active));
});
renderLadderBoard(state.dashboard?.ladders || []);
});
});
document.querySelector("#ladderExportButton").addEventListener("click", exportLadder);
}
+40
View File
@@ -0,0 +1,40 @@
function bindMarketEvents() {
document.querySelector("#globalSearchButton").addEventListener("click", openGlobalSearch);
document.querySelector("#closeGlobalSearch").addEventListener("click", closeGlobalSearch);
document.querySelector("#closeEntityDetail").addEventListener("click", () => elements.entityDetailDialog.close());
document.querySelectorAll("[data-entity-detail-chart]").forEach((button) => {
button.addEventListener("click", () => selectEntityDetailChart(button.dataset.entityDetailChart));
});
elements.globalSearchDialog.addEventListener("click", (event) => {
if (event.target === elements.globalSearchDialog) closeGlobalSearch();
});
elements.globalSearchInput.addEventListener("input", scheduleGlobalSearch);
elements.globalSearchInput.addEventListener("keydown", handleGlobalSearchInputKeydown);
elements.globalSearchResults.addEventListener("click", (event) => {
const result = event.target.closest("[data-search-result-index]");
if (result) openGlobalSearchResult(number(result.dataset.searchResultIndex));
});
document.querySelector("#closeStockDialog").addEventListener("click", () => elements.stockDialog.close());
document.querySelectorAll("[data-stock-detail-chart]").forEach((button) => {
button.addEventListener("click", () => selectStockDetailChart(button.dataset.stockDetailChart));
});
document.querySelector("#closeStockPreview").addEventListener("click", closeStockPreview);
elements.stockPreviewBackdrop.addEventListener("click", closeStockPreview);
document.querySelector("#openStockDetailFromPreview").addEventListener("click", openStockDetailFromPreview);
document.querySelectorAll("[data-preview-chart]").forEach((button) => {
button.addEventListener("click", () => selectStockPreviewChart(button.dataset.previewChart));
});
elements.stockPreview.addEventListener("pointerenter", cancelStockPreviewClose);
elements.stockPreview.addEventListener("pointerleave", scheduleStockPreviewClose);
document.addEventListener("pointerover", handleStockPreviewPointerOver);
document.addEventListener("pointerout", handleStockPreviewPointerOut);
document.addEventListener("focusin", handleStockPreviewFocus);
document.addEventListener("focusout", handleStockPreviewFocusOut);
document.addEventListener("click", handleMobileStockPreviewClick, true);
document.addEventListener("keydown", handleStockPreviewKeydown);
document.addEventListener("scroll", repositionStockPreview, true);
window.addEventListener("keydown", handleGlobalSearchShortcut);
window.addEventListener("resize", () => {
if (!elements.stockPreview.hidden) closeStockPreview();
});
}
+41
View File
@@ -0,0 +1,41 @@
function renderMarketBreadth(overview) {
const up = number(overview.up_count);
const down = number(overview.down_count);
const flat = Math.max(0, number(overview.flat_count));
const total = Math.max(1, up + down + flat);
const upRate = up / total * 100;
const flatRate = flat / total * 100;
const downRate = down / total * 100;
const panel = document.querySelector(".market-breadth-panel");
panel.classList.remove("breadth-enter");
void panel.offsetWidth;
panel.classList.add("breadth-enter");
setText("breadthDataTime", dashboardDataTimestamp(state.dashboard?.meta || {}));
animateMetric("breadthRatio", upRate, (value) => `${formatNumber(value, 1)}%`);
animateMetric("breadthUpCount", up, (value) => formatNumber(Math.round(value)));
animateMetric("breadthDownCount", down, (value) => formatNumber(Math.round(value)));
setText("breadthUpLegend", `${formatNumber(up)}${formatNumber(upRate, 1)}%`);
setText("breadthFlatLegend", `${formatNumber(flat)}${formatNumber(flatRate, 1)}%`);
setText("breadthDownLegend", `${formatNumber(down)}${formatNumber(downRate, 1)}%`);
document.querySelector("#breadthFlatLegendItem").hidden = flat === 0;
const limitUp = number(overview.limit_up_count);
const limitDown = number(overview.limit_down_count);
const breadthLabel = upRate < 20 ? "宽度极差" : upRate < 40 ? "宽度偏弱" : upRate < 55 ? "宽度均衡" : "宽度偏强";
setText("breadthWarning", `${breadthLabel},涨跌停 ${limitUp}:${limitDown}`);
const bars = [
["breadthUpBar", upRate],
["breadthFlatBar", flatRate],
["breadthDownBar", downRate],
];
bars.forEach(([id, width]) => {
const bar = document.getElementById(id);
const targetWidth = `${Math.max(width, width > 0 ? 0.8 : 0)}%`;
bar.style.transition = "none";
bar.style.width = "0%";
requestAnimationFrame(() => requestAnimationFrame(() => {
bar.style.transition = "width 760ms var(--ease-out)";
bar.style.width = targetWidth;
}));
bar.title = `${formatNumber(width, 1)}%`;
});
}
+387
View File
@@ -0,0 +1,387 @@
function currentChartPalette() {
const style = getComputedStyle(document.documentElement);
const color = (token, fallback) => style.getPropertyValue(token).trim() || fallback;
return {
background: color("--chart-background", "#fbfcfd"),
grid: color("--chart-grid", "#e2e8ec"),
axis: color("--chart-axis", "#6c7983"),
zero: color("--chart-zero", "#aeb7c1"),
line: color("--chart-line", "#1d65c1"),
average: color("--chart-average", "#b7791f"),
up: color("--chart-up", "#c93f45"),
down: color("--chart-down", "#087a55"),
upVolume: color("--chart-up-volume", "rgba(201, 63, 69, .58)"),
downVolume: color("--chart-down-volume", "rgba(8, 122, 85, .58)"),
area: color("--chart-area", "rgba(37, 99, 235, .07)"),
alertArea: color("--chart-alert-area", "rgba(224, 69, 54, .05)"),
movingAverage: color("--chart-moving-average", "#d1d5db"),
repair: color("--chart-repair", "#f59e0b"),
ma10: color("--chart-ma-10", "#a76500"),
ma20: color("--chart-ma-20", "#626c78"),
};
}
function drawCandlestick(context, x, item, priceY, candleWidth, palette = currentChartPalette()) {
const rising = number(item.close) >= number(item.open);
const color = rising ? palette.up : palette.down;
const highY = priceY(item.high);
const lowY = priceY(item.low);
const openY = priceY(item.open);
const closeY = priceY(item.close);
const bodyTop = Math.min(openY, closeY);
const bodyBottom = Math.max(openY, closeY);
const bodyHeight = Math.max(1, bodyBottom - bodyTop);
context.strokeStyle = color;
context.fillStyle = color;
context.lineWidth = 1;
context.beginPath();
context.moveTo(x, highY);
context.lineTo(x, bodyTop);
context.moveTo(x, bodyBottom);
context.lineTo(x, lowY);
context.stroke();
const bodyLeft = x - candleWidth / 2;
if (rising) {
context.fillStyle = palette.background;
context.fillRect(bodyLeft, bodyTop, candleWidth, bodyHeight);
context.strokeStyle = color;
context.strokeRect(bodyLeft, bodyTop, candleWidth, bodyHeight);
} else {
context.fillStyle = color;
context.fillRect(bodyLeft, bodyTop, candleWidth, bodyHeight);
}
return color;
}
function drawPriceChart(prices) {
const canvas = elements.priceChart;
if (!prices?.length) {
clearPriceChart("暂无日 K 数据");
return;
}
const rect = canvas.getBoundingClientRect();
const ratio = window.devicePixelRatio || 1;
const width = Math.max(320, rect.width);
const height = Math.max(220, rect.height);
canvas.width = Math.round(width * ratio);
canvas.height = Math.round(height * ratio);
const context = canvas.getContext("2d");
const palette = currentChartPalette();
context.setTransform(ratio, 0, 0, ratio, 0, 0);
context.clearRect(0, 0, width, height);
context.fillStyle = palette.background;
context.fillRect(0, 0, width, height);
const left = 48;
const right = 12;
const top = 14;
const bottom = 22;
const volumeHeight = 54;
const gap = 12;
const priceBottom = height - bottom - volumeHeight - gap;
const plotWidth = width - left - right;
const highs = prices.map((item) => number(item.high));
const lows = prices.map((item) => number(item.low));
const maximum = Math.max(...highs);
const minimum = Math.min(...lows);
const range = Math.max(maximum - minimum, maximum * 0.01, 0.01);
const volumes = prices.map((item) => number(item.volume));
const maxVolume = Math.max(...volumes, 1);
const priceY = (value) => top + (maximum - value) / range * (priceBottom - top);
const step = plotWidth / prices.length;
const candleWidth = clamp(step * 0.62, 2, 8);
context.strokeStyle = palette.grid;
context.fillStyle = palette.axis;
context.font = "11px Microsoft YaHei";
context.textAlign = "right";
for (let line = 0; line <= 4; line += 1) {
const y = top + (priceBottom - top) * line / 4;
context.beginPath();
context.moveTo(left, y);
context.lineTo(width - right, y);
context.stroke();
context.fillText((maximum - range * line / 4).toFixed(2), left - 5, y + 4);
}
prices.forEach((item, index) => {
const x = left + step * index + step / 2;
const color = drawCandlestick(context, x, item, priceY, candleWidth, palette);
const volumeBarHeight = number(item.volume) / maxVolume * volumeHeight;
context.fillStyle = color;
context.globalAlpha = 0.75;
context.fillRect(x - candleWidth / 2, height - bottom - volumeBarHeight, candleWidth, volumeBarHeight);
context.globalAlpha = 1;
});
context.textAlign = "center";
context.fillStyle = palette.axis;
const labelIndexes = [0, Math.floor((prices.length - 1) / 2), prices.length - 1];
labelIndexes.forEach((index) => {
const x = left + step * index + step / 2;
context.fillText(String(prices[index].trade_date).slice(5), x, height - 5);
});
}
function clearPriceChart(message) {
const canvas = elements.priceChart;
const context = canvas.getContext("2d");
const rect = canvas.getBoundingClientRect();
canvas.width = Math.max(320, Math.round(rect.width));
canvas.height = Math.max(220, Math.round(rect.height));
const palette = currentChartPalette();
context.fillStyle = palette.background;
context.fillRect(0, 0, canvas.width, canvas.height);
context.fillStyle = palette.axis;
context.font = "13px Microsoft YaHei";
context.textAlign = "center";
context.fillText(message, canvas.width / 2, canvas.height / 2);
}
function prepareStockPreviewCanvas() {
const canvas = elements.stockPreviewChart;
const rect = canvas.getBoundingClientRect();
const ratio = window.devicePixelRatio || 1;
const width = Math.max(300, rect.width || 488);
const height = Math.max(210, rect.height || 232);
canvas.width = Math.round(width * ratio);
canvas.height = Math.round(height * ratio);
const context = canvas.getContext("2d");
const palette = currentChartPalette();
context.setTransform(ratio, 0, 0, ratio, 0, 0);
context.clearRect(0, 0, width, height);
context.fillStyle = palette.background;
context.fillRect(0, 0, width, height);
context.font = '11px -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei UI", sans-serif';
return { canvas, context, width, height, palette };
}
function drawPreviewGrid(context, width, top, bottom, left, right, maximum, range) {
const palette = currentChartPalette();
context.strokeStyle = palette.grid;
context.fillStyle = palette.axis;
context.textAlign = "right";
context.lineWidth = 1;
for (let line = 0; line <= 3; line += 1) {
const y = top + (bottom - top) * line / 3;
context.beginPath();
context.moveTo(left, y);
context.lineTo(width - right, y);
context.stroke();
context.fillText((maximum - range * line / 3).toFixed(2), left - 5, y + 4);
}
}
function intradayMinuteOffset(value) {
const [hour, minute] = String(value || "").split(":").map((part) => number(part));
const clockMinute = hour * 60 + minute;
const morningStart = 9 * 60 + 30;
const morningEnd = 11 * 60 + 30;
const afternoonStart = 13 * 60;
const afternoonEnd = 15 * 60;
if (clockMinute <= morningEnd) return clamp(clockMinute - morningStart, 0, 120);
if (clockMinute < afternoonStart) return 120;
return 120 + clamp(clockMinute - afternoonStart, 0, afternoonEnd - afternoonStart);
}
function drawIntradayCanvas(canvas, points, dailyPrices = [], referenceClose = 0) {
const rect = canvas.getBoundingClientRect();
const ratio = window.devicePixelRatio || 1;
const width = Math.max(300, rect.width || 488);
const height = Math.max(210, rect.height || 232);
canvas.width = Math.round(width * ratio);
canvas.height = Math.round(height * ratio);
const context = canvas.getContext("2d");
const palette = currentChartPalette();
context.setTransform(ratio, 0, 0, ratio, 0, 0);
context.clearRect(0, 0, width, height);
context.fillStyle = palette.background;
context.fillRect(0, 0, width, height);
context.font = '11px -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei UI", sans-serif';
const left = 45;
const right = 10;
const top = 12;
const volumeHeight = 38;
const bottom = 18;
const gap = 9;
const priceBottom = height - bottom - volumeHeight - gap;
const closes = points.map((point) => number(point.close));
const previousClose = number(referenceClose || dailyPrices.at(-2)?.close || points[0]?.open || closes[0]);
const maximum = Math.max(...points.map((point) => number(point.high || point.close)), previousClose);
const minimum = Math.min(...points.map((point) => number(point.low || point.close)), previousClose);
const deviation = Math.max(
Math.abs(maximum - previousClose),
Math.abs(previousClose - minimum),
previousClose * 0.003,
0.01,
) * 1.08;
const chartMaximum = previousClose + deviation;
const chartMinimum = previousClose - deviation;
const range = Math.max(chartMaximum - chartMinimum, 0.01);
const plotWidth = width - left - right;
const priceY = (value) => top + (chartMaximum - value) / range * (priceBottom - top);
const pointX = (index) => left + plotWidth * intradayMinuteOffset(points[index]?.time) / 240;
drawPreviewGrid(context, width, top, priceBottom, left, right, chartMaximum, range);
context.save();
context.setLineDash([4, 4]);
context.strokeStyle = palette.zero;
context.beginPath();
context.moveTo(left, priceY(previousClose));
context.lineTo(width - right, priceY(previousClose));
context.stroke();
context.restore();
context.fillStyle = palette.axis;
context.textAlign = "right";
context.fillText("0.00%", width - right, priceY(previousClose) - 4);
context.strokeStyle = palette.line;
context.lineWidth = 1.7;
context.beginPath();
points.forEach((point, index) => {
const x = pointX(index);
const y = priceY(point.close);
if (index === 0) context.moveTo(x, y);
else context.lineTo(x, y);
});
context.stroke();
const averages = points.map((point) => number(point.average)).filter((value) => value > 0);
if (averages.length) {
context.strokeStyle = palette.average;
context.lineWidth = 1.25;
context.beginPath();
let averageStarted = false;
points.forEach((point, index) => {
const average = number(point.average);
if (average <= 0) return;
const x = pointX(index);
const y = priceY(average);
if (!averageStarted) {
context.moveTo(x, y);
averageStarted = true;
} else context.lineTo(x, y);
});
context.stroke();
}
const maxVolume = Math.max(...points.map((point) => number(point.volume)), 1);
const barWidth = clamp(plotWidth / Math.max(points.length, 1) * 0.72, 1, 3);
points.forEach((point, index) => {
const x = pointX(index);
const barHeight = number(point.volume) / maxVolume * volumeHeight;
context.fillStyle = number(point.close) >= number(point.open) ? palette.upVolume : palette.downVolume;
context.fillRect(x - barWidth / 2, height - bottom - barHeight, barWidth, barHeight);
});
context.fillStyle = palette.axis;
context.textAlign = "center";
[
{ offset: 0, label: "09:30" },
{ offset: 120, label: "11:30 / 13:00" },
{ offset: 240, label: "15:00" },
].forEach((marker) => {
context.fillText(marker.label, left + plotWidth * marker.offset / 240, height - 4);
});
return {
latest: closes.at(-1),
maximum,
minimum,
};
}
function drawIntradayPreviewChart(points, dailyPrices, referenceClose = 0) {
const summary = drawIntradayCanvas(elements.stockPreviewChart, points, dailyPrices, referenceClose);
setText(
"stockPreviewSummary",
`分时 ${points.length} 点,最新 ${formatNumber(summary.latest, 2)},最高 ${formatNumber(summary.maximum, 2)},最低 ${formatNumber(summary.minimum, 2)}`,
);
}
function drawDailyPreviewChart(prices) {
const { context, width, height, palette } = prepareStockPreviewCanvas();
const visible = prices.slice(-45);
const visibleStart = prices.length - visible.length;
const left = 45;
const right = 10;
const top = 24;
const volumeHeight = 34;
const bottom = 18;
const gap = 8;
const priceBottom = height - bottom - volumeHeight - gap;
const maximum = Math.max(...visible.map((item) => number(item.high)));
const minimum = Math.min(...visible.map((item) => number(item.low)));
const padding = Math.max((maximum - minimum) * 0.05, maximum * 0.002, 0.01);
const chartMaximum = maximum + padding;
const chartMinimum = minimum - padding;
const range = Math.max(chartMaximum - chartMinimum, 0.01);
const plotWidth = width - left - right;
const step = plotWidth / Math.max(visible.length, 1);
const candleWidth = clamp(step * 0.58, 2, 7);
const priceY = (value) => top + (chartMaximum - value) / range * (priceBottom - top);
drawPreviewGrid(context, width, top, priceBottom, left, right, chartMaximum, range);
const maxVolume = Math.max(...visible.map((item) => number(item.volume)), 1);
visible.forEach((item, index) => {
const x = left + step * index + step / 2;
const color = drawCandlestick(context, x, item, priceY, candleWidth, palette);
const volumeBarHeight = number(item.volume) / maxVolume * volumeHeight;
context.fillStyle = color;
context.globalAlpha = 0.62;
context.fillRect(x - candleWidth / 2, height - bottom - volumeBarHeight, candleWidth, volumeBarHeight);
context.globalAlpha = 1;
});
const movingAverages = [
{ days: 5, color: palette.line },
{ days: 10, color: palette.ma10 },
{ days: 20, color: palette.ma20 },
];
movingAverages.forEach(({ days, color }) => {
context.strokeStyle = color;
context.lineWidth = 1.25;
context.beginPath();
let started = false;
visible.forEach((_item, index) => {
const absoluteIndex = visibleStart + index;
if (absoluteIndex < days - 1) return;
const values = prices.slice(absoluteIndex - days + 1, absoluteIndex + 1);
const average = values.reduce((sum, item) => sum + number(item.close), 0) / days;
const x = left + step * index + step / 2;
const y = priceY(average);
if (!started) {
context.moveTo(x, y);
started = true;
} else context.lineTo(x, y);
});
context.stroke();
});
context.textAlign = "left";
movingAverages.forEach(({ days, color }, index) => {
context.fillStyle = color;
context.fillText(`MA${days}`, left + index * 42, 12);
});
context.fillStyle = palette.axis;
context.textAlign = "center";
[0, Math.floor((visible.length - 1) / 2), visible.length - 1].forEach((index) => {
const x = left + step * index + step / 2;
context.fillText(String(visible[index]?.trade_date || "").slice(5), x, height - 4);
});
const firstClose = number(visible[0]?.close);
const latestClose = number(visible.at(-1)?.close);
const periodChange = firstClose ? (latestClose / firstClose - 1) * 100 : 0;
setText(
"stockPreviewSummary",
`${visible.length} 日涨跌 ${signed(periodChange)}%,区间最高 ${formatNumber(maximum, 2)},最低 ${formatNumber(minimum, 2)}`,
);
}
function clearStockPreviewChart(message) {
const { context, width, height } = prepareStockPreviewCanvas();
if (!message) return;
context.fillStyle = "#74808d";
context.textAlign = "center";
context.fillText(message, width / 2, height / 2);
}
+199
View File
@@ -0,0 +1,199 @@
async function openEntityDetail(item) {
state.entityDetailItem = item;
state.entityDetailPayload = null;
state.entityDetailIntraday = null;
state.entityDetailChartMode = "daily";
const requestSequence = ++state.entityDetailRequestSequence;
syncDetailChartButtons("entity", "daily");
setText("entityDetailCode", item.code || item.id || "--");
setText("entityDetailName", item.name || "--");
setText("entityDetailValue", "--");
setText("entityDetailChange", "--");
setText("entityDetailType", item.type_label || "--");
setText("entityDetailDate", "正在加载行情");
document.querySelector("#entityDetailChange").className = "";
renderEmptyState("entityDetailMetrics", "正在加载交易数据");
openModalDialog(elements.entityDetailDialog);
clearEntityDetailChart("正在加载日 K 数据");
try {
const params = new URLSearchParams({ type: item.type, id: item.id, trade_date: elements.tradeDate.value });
const payload = await apiRequest(`/api/search/detail?${params}`);
if (requestSequence !== state.entityDetailRequestSequence) return;
state.entityDetailPayload = payload;
const entity = payload.entity || {};
setText("entityDetailCode", entity.code || item.code || "--");
setText("entityDetailName", entity.name || item.name || "--");
setText("entityDetailValue", meaningfulNumber(entity.value) && number(entity.value) !== 0 ? formatNumber(entity.value, 2) : "--");
setText("entityDetailChange", `${signed(entity.change)}%`);
setText("entityDetailType", entity.type_label || item.type_label || "--");
document.querySelector("#entityDetailChange").className = changeClass(entity.change);
renderEntityDetailMetrics(payload.metrics || []);
if (state.entityDetailChartMode === "daily") {
setText("entityDetailDate", `${payload.meta?.realtime ? "实时" : "收盘"} · ${payload.meta?.trade_date || "--"}`);
requestAnimationFrame(() => drawEntityDetailChart(payload.series || []));
}
} catch (error) {
if (requestSequence !== state.entityDetailRequestSequence) return;
setText("entityDetailDate", "行情加载失败");
renderEmptyState("entityDetailMetrics", error.message || "交易数据加载失败");
if (state.entityDetailChartMode === "daily") clearEntityDetailChart(error.message || "行情加载失败");
showToast(error.message || "详情加载失败");
}
}
async function selectEntityDetailChart(mode) {
const selected = mode === "intraday" ? "intraday" : "daily";
state.entityDetailChartMode = selected;
syncDetailChartButtons("entity", selected);
if (selected === "daily") {
const payload = state.entityDetailPayload;
if (payload) {
setText("entityDetailDate", `${payload.meta?.realtime ? "实时" : "收盘"} · ${payload.meta?.trade_date || "--"}`);
requestAnimationFrame(() => drawEntityDetailChart(payload.series || []));
} else clearEntityDetailChart("正在加载日 K 数据");
return;
}
if (state.entityDetailIntraday) {
renderEntityIntraday(state.entityDetailIntraday);
return;
}
const item = state.entityDetailItem;
if (!item) return;
const requestSequence = state.entityDetailRequestSequence;
setText("entityDetailDate", "正在加载分时");
clearEntityDetailChart("正在加载分时数据");
try {
const params = new URLSearchParams({ type: item.type, id: item.id });
const payload = await apiRequest(`/api/chart/intraday?${params}`);
if (requestSequence !== state.entityDetailRequestSequence) return;
state.entityDetailIntraday = payload;
if (state.entityDetailChartMode === "intraday") renderEntityIntraday(payload);
} catch (error) {
if (requestSequence !== state.entityDetailRequestSequence || state.entityDetailChartMode !== "intraday") return;
setText("entityDetailDate", "分时暂不可用");
clearEntityDetailChart(error.message || "分时行情暂不可用");
}
}
function renderEntityIntraday(payload) {
const points = payload.points || [];
if (!points.length) {
setText("entityDetailDate", "分时暂不可用");
clearEntityDetailChart("分时行情暂不可用");
return;
}
setText("entityDetailDate", `分时 · ${payload.meta?.trade_date || "--"}`);
requestAnimationFrame(() => {
if (state.entityDetailChartMode !== "intraday") return;
drawIntradayCanvas(elements.entityDetailChart, points, [], payload.meta?.previous_close);
});
}
function syncDetailChartButtons(scope, mode) {
const selector = scope === "stock" ? "[data-stock-detail-chart]" : "[data-entity-detail-chart]";
const datasetKey = scope === "stock" ? "stockDetailChart" : "entityDetailChart";
document.querySelectorAll(selector).forEach((button) => {
const active = button.dataset[datasetKey] === mode;
button.classList.toggle("active", active);
button.setAttribute("aria-pressed", String(active));
});
}
function renderEntityDetailMetrics(metrics) {
const container = document.querySelector("#entityDetailMetrics");
if (!metrics.length) {
renderEmptyState(container, "暂无交易数据");
return;
}
container.innerHTML = metrics.map((metric) => {
const value = typeof metric.value === "number" ? formatNumber(metric.value, Number.isInteger(metric.value) ? 0 : 2) : String(metric.value ?? "--");
const tone = metric.tone === "change" ? changeClass(metric.value) : "";
return `<div><dt>${escapeHtml(metric.label)}</dt><dd class="${tone}">${escapeHtml(value)}${escapeHtml(metric.unit || "")}</dd></div>`;
}).join("");
}
function drawEntityDetailChart(series, canvas = elements.entityDetailChart) {
const candles = (series || []).filter((item) => number(item.close) > 0).map((item) => {
const close = number(item.close);
const open = number(item.open) || close;
const high = Math.max(number(item.high) || close, open, close);
const low = Math.min(number(item.low) || close, open, close);
return { ...item, open, high, low, close };
});
if (!candles.length) {
clearEntityDetailChart("暂无日 K 数据", canvas);
return;
}
const rect = canvas.getBoundingClientRect();
const ratio = window.devicePixelRatio || 1;
const width = Math.max(320, rect.width);
const height = Math.max(220, rect.height);
canvas.width = Math.round(width * ratio);
canvas.height = Math.round(height * ratio);
const context = canvas.getContext("2d");
const palette = currentChartPalette();
context.setTransform(ratio, 0, 0, ratio, 0, 0);
context.clearRect(0, 0, width, height);
context.fillStyle = palette.background;
context.fillRect(0, 0, width, height);
const left = 48;
const right = 12;
const top = 14;
const bottom = 22;
const volumeHeight = 54;
const gap = 12;
const priceBottom = height - bottom - volumeHeight - gap;
const plotWidth = width - left - right;
const maximum = Math.max(...candles.map((item) => item.high));
const minimum = Math.min(...candles.map((item) => item.low));
const range = Math.max(maximum - minimum, maximum * 0.01, 0.01);
const maxVolume = Math.max(...candles.map((item) => number(item.volume)), 1);
const priceY = (value) => top + (maximum - value) / range * (priceBottom - top);
const step = plotWidth / candles.length;
const candleWidth = clamp(step * 0.62, 2, 8);
context.strokeStyle = palette.grid;
context.fillStyle = palette.axis;
context.font = "11px Microsoft YaHei";
context.textAlign = "right";
for (let line = 0; line <= 4; line += 1) {
const lineY = top + (priceBottom - top) * line / 4;
context.beginPath();
context.moveTo(left, lineY);
context.lineTo(width - right, lineY);
context.stroke();
context.fillText((maximum - range * line / 4).toFixed(2), left - 6, lineY + 4);
}
candles.forEach((item, index) => {
const x = left + step * index + step / 2;
const color = drawCandlestick(context, x, item, priceY, candleWidth, palette);
const volumeBarHeight = number(item.volume) / maxVolume * volumeHeight;
context.fillStyle = color;
context.globalAlpha = 0.72;
context.fillRect(x - candleWidth / 2, height - bottom - volumeBarHeight, candleWidth, volumeBarHeight);
context.globalAlpha = 1;
});
context.textAlign = "center";
context.fillStyle = palette.axis;
[0, Math.floor((candles.length - 1) / 2), candles.length - 1].forEach((index) => {
const x = left + step * index + step / 2;
context.fillText(String(candles[index].trade_date || "").slice(5), x, height - 5);
});
}
function clearEntityDetailChart(message, canvas = elements.entityDetailChart) {
const rect = canvas.getBoundingClientRect();
const width = Math.max(320, Math.round(rect.width || 680));
const height = Math.max(220, Math.round(rect.height || 300));
canvas.width = width;
canvas.height = height;
const context = canvas.getContext("2d");
const palette = currentChartPalette();
context.fillStyle = palette.background;
context.fillRect(0, 0, width, height);
context.fillStyle = palette.axis;
context.font = "13px Microsoft YaHei";
context.textAlign = "center";
context.fillText(message, width / 2, height / 2);
}
File diff suppressed because it is too large Load Diff
+446
View File
@@ -0,0 +1,446 @@
const stockPreviewCache = new Map();
const STOCK_PREVIEW_DELAY = 380;
const STOCK_PREVIEW_CACHE_MS = 5 * 60 * 1000;
const LIVE_REFRESH_DEFAULT_MS = 10 * 1000;
let stockPreviewOpenTimer = null;
let stockPreviewCloseTimer = null;
let stockPreviewAbortController = null;
let stockPreviewAnchor = null;
function bindStockRows(container) {
animateRows(container);
decorateStockPreviewTargets(container);
container.querySelectorAll("[data-code]").forEach((rowElement) => {
rowElement.addEventListener("click", (event) => {
const interactive = event.target.closest("button, a, input, select, textarea, summary");
if (interactive && interactive !== rowElement) return;
openStock(rowElement.dataset.code, findStockFallback(rowElement.dataset.code));
});
});
}
function decorateStockPreviewTargets(container) {
container.querySelectorAll(".stock-code").forEach((trigger) => {
const code = stockCodeFromTrigger(trigger);
if (!code) return;
trigger.classList.add("stock-preview-trigger");
trigger.tabIndex = 0;
trigger.setAttribute("role", "button");
trigger.setAttribute("aria-label", `预览 ${code} 行情`);
trigger.title = "悬停预览行情,点击查看完整详情";
});
}
function stockCodeFromTrigger(trigger) {
const candidate = trigger?.dataset?.stockPreviewCode
|| trigger?.closest?.("[data-code]")?.dataset?.code
|| trigger?.textContent?.trim();
const matched = String(candidate || "").match(/\b(\d{6})\b/);
return matched ? matched[1] : "";
}
function marketPreviewTargetFromTrigger(trigger) {
if (trigger?.classList?.contains("market-preview-trigger")) {
const type = String(trigger.dataset.marketPreviewType || "").trim().toLowerCase();
const id = String(trigger.dataset.marketPreviewId || "").trim().toUpperCase();
if (type === "theme" && id) {
const item = (state.themeLibrary?.items || []).find((row) => String(row.code) === id) || {};
return {
type,
id,
code: id,
name: item.name || trigger.textContent?.trim() || "--",
type_label: "题材",
change: item.change,
value: item.close,
};
}
}
const code = stockCodeFromTrigger(trigger);
return code ? { type: "stock", id: code, code } : null;
}
function previewTriggerFromEvent(event) {
return event.target.closest?.(".stock-preview-trigger, .market-preview-trigger");
}
function showMarketPreview(target, trigger) {
if (!target) return;
if (target.type === "stock") showStockPreview(target.id, trigger);
else showEntityPreview(target, trigger);
}
function findStockFallback(code) {
const dashboardRows = [
...(state.dashboard?.limits || []),
...(state.dashboard?.broken || []),
...(state.dashboard?.down_limits || []),
...(state.dashboard?.yesterday_limits || []),
];
const screenerRows = Object.values(state.screenerResultStore)
.flatMap((entry) => entry?.result?.candidates || []);
const dragonRows = (state.dragonTiger?.traders || []).flatMap((trader) => trader.operations || []);
const auctionRows = state.auctionData?.rows || [];
const themeRows = state.themeDetail?.members || [];
const popularityRows = state.popularityData?.combined || [];
const row = [...dashboardRows, ...screenerRows, ...dragonRows, ...auctionRows, ...themeRows, ...popularityRows, ...(state.watchlist || [])]
.find((item) => String(item.code) === String(code));
if (!row) return { code, name: "--", sector: "其他" };
return {
...row,
code,
change: row.change ?? row.current_change ?? row.pct_chg ?? 0,
sector: row.sector || row.industry || "其他",
};
}
function supportsStockPreviewHover() {
return window.matchMedia("(hover: hover) and (pointer: fine)").matches
&& window.innerWidth > 720;
}
function handleStockPreviewPointerOver(event) {
if (!supportsStockPreviewHover()) return;
const trigger = previewTriggerFromEvent(event);
if (!trigger || trigger === event.relatedTarget?.closest?.(".stock-preview-trigger, .market-preview-trigger")) return;
const target = marketPreviewTargetFromTrigger(trigger);
if (!target) return;
cancelStockPreviewClose();
clearTimeout(stockPreviewOpenTimer);
stockPreviewOpenTimer = setTimeout(() => showMarketPreview(target, trigger), STOCK_PREVIEW_DELAY);
}
function handleStockPreviewPointerOut(event) {
if (!supportsStockPreviewHover()) return;
const trigger = previewTriggerFromEvent(event);
if (!trigger || trigger === event.relatedTarget?.closest?.(".stock-preview-trigger, .market-preview-trigger")) return;
clearTimeout(stockPreviewOpenTimer);
if (event.relatedTarget instanceof Node && elements.stockPreview.contains(event.relatedTarget)) return;
scheduleStockPreviewClose();
}
function handleStockPreviewFocus(event) {
if (!supportsStockPreviewHover()) return;
const trigger = event.target.closest?.(".stock-preview-trigger");
if (!trigger) return;
const code = stockCodeFromTrigger(trigger);
if (!code) return;
clearTimeout(stockPreviewOpenTimer);
stockPreviewOpenTimer = setTimeout(() => showStockPreview(code, trigger), 120);
}
function handleStockPreviewFocusOut(event) {
const trigger = event.target.closest?.(".stock-preview-trigger");
if (!trigger) return;
if (event.relatedTarget instanceof Node && elements.stockPreview.contains(event.relatedTarget)) return;
clearTimeout(stockPreviewOpenTimer);
scheduleStockPreviewClose();
}
function handleMobileStockPreviewClick(event) {
if (window.innerWidth > 720) return;
const trigger = event.target.closest?.(".stock-preview-trigger");
if (!trigger) return;
const code = stockCodeFromTrigger(trigger);
if (!code) return;
event.preventDefault();
event.stopPropagation();
showStockPreview(code, trigger);
}
function handleStockPreviewKeydown(event) {
if (event.key === "Escape" && !elements.stockPreview.hidden) {
closeStockPreview();
stockPreviewAnchor?.focus?.();
return;
}
if (event.key !== "Enter") return;
const trigger = event.target.closest?.(".stock-preview-trigger");
if (!trigger) return;
const code = stockCodeFromTrigger(trigger);
if (!code) return;
event.preventDefault();
if (window.innerWidth <= 720) showStockPreview(code, trigger);
else openStock(code, findStockFallback(code));
}
function cancelStockPreviewClose() {
clearTimeout(stockPreviewCloseTimer);
}
function scheduleStockPreviewClose() {
clearTimeout(stockPreviewCloseTimer);
stockPreviewCloseTimer = setTimeout(closeStockPreview, 160);
}
async function showStockPreview(code, trigger) {
clearTimeout(stockPreviewOpenTimer);
cancelStockPreviewClose();
if (!/^\d{6}$/.test(String(code))) return;
stockPreviewAnchor = trigger;
state.stockPreviewCode = String(code);
state.stockPreviewType = "stock";
state.stockPreviewItem = null;
state.stockPreviewFallback = findStockFallback(code);
state.stockPreviewPayload = null;
state.stockPreviewChart = "daily";
renderStockPreviewLoading();
elements.stockPreview.hidden = false;
const mobile = window.innerWidth <= 720;
elements.stockPreviewBackdrop.hidden = !mobile;
document.body.classList.toggle("stock-preview-open", mobile);
requestAnimationFrame(repositionStockPreview);
const cacheKey = `${code}:latest`;
const cached = stockPreviewCache.get(cacheKey);
if (cached && cached.expiresAt > Date.now()) {
renderStockPreview(cached.payload);
return;
}
if (cached) stockPreviewCache.delete(cacheKey);
stockPreviewAbortController?.abort();
stockPreviewAbortController = new AbortController();
try {
const payload = await apiRequest(
`/api/stock/${encodeURIComponent(code)}/preview`,
"GET",
null,
{ signal: stockPreviewAbortController.signal },
);
if (state.stockPreviewCode !== String(code) || elements.stockPreview.hidden) return;
const cacheMs = payload.meta?.realtime ? LIVE_REFRESH_DEFAULT_MS : STOCK_PREVIEW_CACHE_MS;
stockPreviewCache.set(cacheKey, { payload, expiresAt: Date.now() + cacheMs });
while (stockPreviewCache.size > 48) stockPreviewCache.delete(stockPreviewCache.keys().next().value);
renderStockPreview(payload);
} catch (error) {
if (error.name === "AbortError" || state.stockPreviewCode !== String(code)) return;
renderStockPreviewError(error.message || "行情预览加载失败");
}
}
async function showEntityPreview(item, trigger) {
const type = String(item?.type || "").trim().toLowerCase();
const id = String(item?.id || item?.code || "").trim().toUpperCase();
if (type !== "theme" || !id) return;
clearTimeout(stockPreviewOpenTimer);
cancelStockPreviewClose();
stockPreviewAnchor = trigger;
state.stockPreviewCode = id;
state.stockPreviewType = type;
state.stockPreviewItem = { ...item, id, code: item.code || id, type, type_label: item.type_label || "题材" };
state.stockPreviewFallback = {
code: item.code || id,
name: item.name || "--",
sector: item.type_label || "题材",
price: item.value,
change: item.change,
};
state.stockPreviewPayload = null;
state.stockPreviewChart = "daily";
renderStockPreviewLoading();
elements.stockPreview.hidden = false;
const mobile = window.innerWidth <= 720;
elements.stockPreviewBackdrop.hidden = !mobile;
document.body.classList.toggle("stock-preview-open", mobile);
requestAnimationFrame(repositionStockPreview);
const cacheKey = `${type}:${id}:latest`;
const cached = stockPreviewCache.get(cacheKey);
if (cached && cached.expiresAt > Date.now()) {
renderStockPreview(cached.payload);
return;
}
if (cached) stockPreviewCache.delete(cacheKey);
stockPreviewAbortController?.abort();
stockPreviewAbortController = new AbortController();
try {
const params = new URLSearchParams({ type, id, trade_date: todayString() });
const detail = await apiRequest(
`/api/search/detail?${params}`,
"GET",
null,
{ signal: stockPreviewAbortController.signal },
);
if (state.stockPreviewType !== type || state.stockPreviewCode !== id || elements.stockPreview.hidden) return;
const entity = detail.entity || {};
const payload = {
stock: {
code: entity.code || id,
name: entity.name || item.name || "--",
industry: entity.type_label || item.type_label || "题材",
price: entity.value,
change: entity.change,
},
prices: detail.series || [],
intraday: [],
meta: {
trade_date: detail.meta?.trade_date || "",
realtime: Boolean(detail.meta?.realtime),
intraday_status: "idle",
intraday_notice: "",
},
};
stockPreviewCache.set(cacheKey, { payload, expiresAt: Date.now() + STOCK_PREVIEW_CACHE_MS });
while (stockPreviewCache.size > 48) stockPreviewCache.delete(stockPreviewCache.keys().next().value);
renderStockPreview(payload);
} catch (error) {
if (error.name === "AbortError" || state.stockPreviewType !== type || state.stockPreviewCode !== id) return;
renderStockPreviewError(error.message || "题材行情预览加载失败");
}
}
function renderStockPreviewLoading() {
const fallback = state.stockPreviewFallback || {};
selectStockPreviewChart("daily");
setText("stockPreviewCode", state.stockPreviewCode || "--");
setText("stockPreviewName", fallback.name || "正在加载");
setText("stockPreviewSector", fallback.sector || "--");
setText("stockPreviewPrice", "--");
setText("stockPreviewChange", "--");
document.querySelector("#stockPreviewChange").className = "";
setText("stockPreviewDate", "最新行情");
setText("stockPreviewSource", "正在读取行情");
setText("stockPreviewSummary", "等待行情数据");
document.querySelector("#stockPreviewLoading").hidden = false;
clearStockPreviewChart("");
}
function renderStockPreview(payload) {
state.stockPreviewPayload = payload;
const fallback = state.stockPreviewFallback || {};
const stock = payload.stock || {};
const price = stock.price;
const change = stock.change;
setText("stockPreviewCode", stock.code || state.stockPreviewCode);
setText("stockPreviewName", stock.name && stock.name !== "--" ? stock.name : fallback.name || "--");
setText("stockPreviewSector", stock.industry && stock.industry !== "其他" ? stock.industry : fallback.sector || "其他");
setText("stockPreviewPrice", meaningfulNumber(price) ? formatNumber(price, 2) : "--");
setText("stockPreviewChange", meaningfulNumber(change) ? `${signed(change)}%` : "--");
document.querySelector("#stockPreviewChange").className = changeClass(change);
document.querySelector("#stockPreviewLoading").hidden = true;
selectStockPreviewChart("daily");
requestAnimationFrame(repositionStockPreview);
}
function renderStockPreviewError(message) {
document.querySelector("#stockPreviewLoading").hidden = true;
setText("stockPreviewSource", "行情加载失败");
setText("stockPreviewSummary", message);
clearStockPreviewChart("加载失败");
}
function selectStockPreviewChart(chart) {
state.stockPreviewChart = chart === "daily" ? "daily" : "intraday";
document.querySelectorAll("[data-preview-chart]").forEach((button) => {
const active = button.dataset.previewChart === state.stockPreviewChart;
button.classList.toggle("active", active);
button.setAttribute("aria-selected", String(active));
});
const payload = state.stockPreviewPayload;
if (!payload) return;
if (state.stockPreviewChart === "intraday") {
if (state.stockPreviewType !== "stock" && payload.meta?.intraday_status === "idle") {
payload.meta.intraday_status = "loading";
setText("stockPreviewDate", "正在加载分时");
setText("stockPreviewSource", "正在读取最新分时");
setText("stockPreviewSummary", "等待分时行情数据");
clearStockPreviewChart("");
loadEntityPreviewIntraday();
return;
}
if (state.stockPreviewType !== "stock" && payload.meta?.intraday_status === "loading") return;
setText("stockPreviewDate", payload.meta?.intraday_trade_date || payload.meta?.trade_date || "最新行情");
setText(
"stockPreviewSource",
(payload.intraday || []).length ? "最新分时 · 1分钟" : "分时暂不可用",
);
if ((payload.intraday || []).length) {
drawIntradayPreviewChart(
payload.intraday,
payload.prices || [],
payload.meta?.intraday_previous_close,
);
}
else {
clearStockPreviewChart("分时数据不可用");
setText("stockPreviewSummary", payload.meta?.intraday_notice || "该交易日暂无分时数据。");
}
} else if ((payload.prices || []).length) {
setText("stockPreviewDate", payload.meta?.trade_date || "最新行情");
setText("stockPreviewSource", `日 K 行情 · ${payload.prices.length} 个交易日`);
drawDailyPreviewChart(payload.prices);
} else {
setText("stockPreviewDate", payload.meta?.trade_date || "最新行情");
setText("stockPreviewSource", "日 K 行情暂不可用");
clearStockPreviewChart("暂无日K数据");
setText("stockPreviewSummary", "该股票暂无可用的日K数据。");
}
}
async function loadEntityPreviewIntraday() {
const type = state.stockPreviewType;
const id = state.stockPreviewCode;
const payload = state.stockPreviewPayload;
if (type === "stock" || !id || !payload) return;
stockPreviewAbortController?.abort();
stockPreviewAbortController = new AbortController();
try {
const params = new URLSearchParams({ type, id });
const intraday = await apiRequest(
`/api/chart/intraday?${params}`,
"GET",
null,
{ signal: stockPreviewAbortController.signal },
);
if (state.stockPreviewType !== type || state.stockPreviewCode !== id || elements.stockPreview.hidden) return;
payload.intraday = intraday.points || [];
payload.meta.intraday_status = payload.intraday.length ? "available" : "empty";
payload.meta.intraday_trade_date = intraday.meta?.trade_date || "";
payload.meta.intraday_previous_close = intraday.meta?.previous_close || 0;
payload.meta.intraday_notice = payload.intraday.length ? "" : "该题材暂无可用分时数据。";
if (state.stockPreviewChart === "intraday") selectStockPreviewChart("intraday");
} catch (error) {
if (error.name === "AbortError" || state.stockPreviewType !== type || state.stockPreviewCode !== id) return;
payload.meta.intraday_status = "unavailable";
payload.meta.intraday_notice = error.message || "题材分时行情暂不可用。";
if (state.stockPreviewChart === "intraday") selectStockPreviewChart("intraday");
}
}
function closeStockPreview() {
clearTimeout(stockPreviewOpenTimer);
clearTimeout(stockPreviewCloseTimer);
stockPreviewAbortController?.abort();
stockPreviewAbortController = null;
elements.stockPreview.hidden = true;
elements.stockPreviewBackdrop.hidden = true;
document.body.classList.remove("stock-preview-open");
state.stockPreviewPayload = null;
state.stockPreviewCode = "";
state.stockPreviewType = "stock";
state.stockPreviewItem = null;
}
function openStockDetailFromPreview() {
const code = state.stockPreviewCode;
const fallback = state.stockPreviewFallback;
const type = state.stockPreviewType;
const item = state.stockPreviewItem;
if (!code) return;
closeStockPreview();
if (type === "stock") openStock(code, fallback);
else if (item) openEntityDetail(item);
}
function repositionStockPreview() {
if (elements.stockPreview.hidden || window.innerWidth <= 720 || !stockPreviewAnchor?.isConnected) return;
const anchor = stockPreviewAnchor.getBoundingClientRect();
const preview = elements.stockPreview.getBoundingClientRect();
const gap = 12;
let left = anchor.right + gap;
if (left + preview.width > window.innerWidth - 8) left = anchor.left - preview.width - gap;
left = clamp(left, 8, Math.max(8, window.innerWidth - preview.width - 8));
const top = clamp(anchor.top - 48, 64, Math.max(64, window.innerHeight - preview.height - 8));
elements.stockPreview.style.left = `${Math.round(left)}px`;
elements.stockPreview.style.top = `${Math.round(top)}px`;
}
File diff suppressed because it is too large Load Diff
+131
View File
@@ -0,0 +1,131 @@
let globalSearchTimer = null;
function openGlobalSearch() {
if (!state.user) return;
toggleHeaderCommandMenu(false);
openModalDialog(elements.globalSearchDialog);
requestAnimationFrame(() => {
elements.globalSearchInput.focus();
elements.globalSearchInput.select();
});
}
function handleGlobalSearchShortcut(event) {
if (!event.ctrlKey || event.altKey || event.shiftKey || event.key.toLowerCase() !== "k") return;
if (!state.user) return;
if (event.defaultPrevented) {
showToast("Ctrl+K 已被其他功能占用,请点击顶部搜索按钮");
return;
}
event.preventDefault();
openGlobalSearch();
}
function closeGlobalSearch() {
clearTimeout(globalSearchTimer);
if (elements.globalSearchDialog.open) elements.globalSearchDialog.close();
}
function scheduleGlobalSearch() {
clearTimeout(globalSearchTimer);
const query = elements.globalSearchInput.value.trim();
state.globalSearchActiveIndex = -1;
if (!query) {
state.globalSearchResults = [];
renderGlobalSearchEmpty("输入名称或代码开始搜索", "使用方向键选择,回车打开详情", "corner-down-left");
return;
}
elements.globalSearchResults.innerHTML = '<div class="global-search-loading"><span class="spinner" aria-hidden="true"></span><span>正在搜索</span></div>';
globalSearchTimer = setTimeout(() => runGlobalSearch(query), 160);
}
async function runGlobalSearch(query) {
const requestSequence = ++state.globalSearchRequestSequence;
const params = new URLSearchParams({ q: query, trade_date: elements.tradeDate.value });
try {
const payload = await apiRequest(`/api/search?${params}`);
if (requestSequence !== state.globalSearchRequestSequence || elements.globalSearchInput.value.trim() !== query) return;
renderGlobalSearchResults(payload.groups || {});
} catch (error) {
if (requestSequence !== state.globalSearchRequestSequence) return;
state.globalSearchResults = [];
renderGlobalSearchEmpty(error.message || "搜索失败", "请稍后重试", "circle-alert");
}
}
function renderGlobalSearchResults(groups) {
const definitions = [
["stocks", "股票"],
["sectors", "板块"],
["themes", "题材"],
["indices", "指数"],
];
const iconNames = { stock: "chart-candlestick", sector: "layout-grid", theme: "lightbulb", index: "chart-line" };
const flattened = [];
const sections = [];
definitions.forEach(([key, label]) => {
const items = Array.isArray(groups[key]) ? groups[key] : [];
if (!items.length) return;
const rows = items.map((item) => {
const index = flattened.length;
flattened.push(item);
return `<button class="global-search-result" type="button" role="option" aria-selected="false" data-search-result-index="${index}">
<span class="global-search-result-icon"><i data-lucide="${iconNames[item.type] || "search"}"></i></span>
<span class="global-search-result-copy"><strong>${escapeHtml(item.name || "--")}</strong><span>${escapeHtml(item.subtitle || item.type_label || label)}</span></span>
<span class="global-search-result-code">${escapeHtml(item.code || "")}</span>
</button>`;
}).join("");
sections.push(`<section class="global-search-group" aria-label="${label}"><h3 class="global-search-group-title">${label}</h3>${rows}</section>`);
});
state.globalSearchResults = flattened;
state.globalSearchActiveIndex = flattened.length ? 0 : -1;
if (!flattened.length) {
renderGlobalSearchEmpty("没有找到相关结果", "可尝试输入完整名称或六位股票代码", "search-x");
return;
}
elements.globalSearchResults.innerHTML = sections.join("");
updateGlobalSearchSelection(false);
refreshIcons();
}
function renderGlobalSearchEmpty(title, hint, iconName) {
elements.globalSearchResults.innerHTML = `<div class="global-search-empty"><i data-lucide="${iconName}"></i><p>${escapeHtml(title)}</p><span>${escapeHtml(hint)}</span></div>`;
refreshIcons();
}
function handleGlobalSearchInputKeydown(event) {
if (event.key === "Escape") {
event.preventDefault();
closeGlobalSearch();
return;
}
if (!["ArrowDown", "ArrowUp", "Enter"].includes(event.key)) return;
if (!state.globalSearchResults.length) return;
event.preventDefault();
if (event.key === "Enter") {
openGlobalSearchResult(state.globalSearchActiveIndex);
return;
}
const direction = event.key === "ArrowDown" ? 1 : -1;
state.globalSearchActiveIndex = (state.globalSearchActiveIndex + direction + state.globalSearchResults.length) % state.globalSearchResults.length;
updateGlobalSearchSelection(true);
}
function updateGlobalSearchSelection(scrollIntoView) {
elements.globalSearchResults.querySelectorAll("[data-search-result-index]").forEach((item) => {
const selected = number(item.dataset.searchResultIndex) === state.globalSearchActiveIndex;
item.classList.toggle("is-active", selected);
item.setAttribute("aria-selected", String(selected));
if (selected && scrollIntoView) item.scrollIntoView({ block: "nearest" });
});
}
function openGlobalSearchResult(index) {
const item = state.globalSearchResults[index];
if (!item) return;
closeGlobalSearch();
if (item.type === "stock") {
openStock(item.id, { code: item.code, name: item.name, sector: item.industry || "其他" });
return;
}
openEntityDetail(item);
}
+124
View File
@@ -0,0 +1,124 @@
function meaningfulNumber(value) {
return value !== null && value !== undefined && value !== "" && Number.isFinite(Number(value));
}
async function openStock(code, fallback = null) {
closeStockPreview();
const pools = [state.dashboard?.limits || [], state.dashboard?.broken || [], state.dashboard?.down_limits || []];
const row = pools.flat().find((item) => String(item.code) === String(code)) || fallback || { code, name: "--", sector: "其他" };
state.activeStock = row;
state.stockDetail = null;
state.stockDetailIntraday = null;
state.stockDetailChartMode = "daily";
const requestSequence = ++state.stockDetailRequestSequence;
syncDetailChartButtons("stock", "daily");
setText("detailCode", row.code);
setText("detailName", row.name);
setText("detailPrice", formatNumber(row.price, 2));
setText("detailChange", `${signed(row.change)}%`);
const changeElement = document.querySelector("#detailChange");
changeElement.className = changeClass(row.change);
setText("detailStreak", row.status === "涨停" ? streakLabel(row.streak) : row.status || "--");
setText("detailReason", row.reason || "--");
setText("detailSector", row.sector || "其他");
setText("detailFirst", row.first_time || "--");
setText("detailLast", row.last_time || "--");
setText("detailOpen", `${number(row.open_times)}`);
setText("detailTurnover", `${formatNumber(row.turnover_rate, 2)}%`);
setText("detailAmount", `${formatNumber(row.amount_billion, 2)} 亿`);
setText("detailSeal", `${formatNumber(row.seal_amount_million, 0)}`);
setText("chartSource", "正在加载行情");
setText("flowNet", "--");
setText("flowLarge", "--");
setText("flowMedium", "--");
setText("flowSmall", "--");
document.querySelector("#reasonInput").value = row.reason || "";
document.querySelector("#stockNoteContent").value = "";
document.querySelector("#stockNotePlan").value = "";
renderEmptyState("stockNotes", "正在加载笔记");
updateWatchButton();
openModalDialog(elements.stockDialog);
clearPriceChart("正在加载日 K 数据");
try {
const query = new URLSearchParams({ trade_date: elements.tradeDate.value });
const payload = await apiRequest(`/api/stock/${encodeURIComponent(code)}?${query}`);
if (requestSequence !== state.stockDetailRequestSequence) return;
state.stockDetail = payload;
const stock = payload.stock || {};
state.activeStock = { ...row, name: stock.name || row.name, sector: stock.industry || row.sector };
setText("detailName", stock.name || row.name);
setText("detailPrice", formatNumber(stock.price || row.price, 2));
setText("detailChange", `${signed(stock.change ?? row.change)}%`);
renderMoneyflow(payload.moneyflow || {});
renderStockNotes(payload.notes || []);
updateWatchButton();
if (state.stockDetailChartMode === "daily") {
setText("chartSource", `日 K 行情 · ${payload.prices.length} 个交易日`);
requestAnimationFrame(() => drawPriceChart(payload.prices || []));
}
} catch (error) {
if (requestSequence !== state.stockDetailRequestSequence) return;
setText("chartSource", "行情加载失败");
if (state.stockDetailChartMode === "daily") clearPriceChart(error.message || "行情加载失败");
showToast(error.message || "个股详情加载失败");
}
}
async function selectStockDetailChart(mode) {
const selected = mode === "intraday" ? "intraday" : "daily";
state.stockDetailChartMode = selected;
syncDetailChartButtons("stock", selected);
if (selected === "daily") {
const prices = state.stockDetail?.prices || [];
setText("chartSource", prices.length ? `日 K 行情 · ${prices.length} 个交易日` : "正在加载行情");
if (prices.length) requestAnimationFrame(() => drawPriceChart(prices));
else clearPriceChart("正在加载日 K 数据");
return;
}
if (state.stockDetailIntraday) {
renderStockDetailIntraday(state.stockDetailIntraday);
return;
}
const code = String(state.activeStock?.code || "");
if (!/^\d{6}$/.test(code)) return;
const requestSequence = state.stockDetailRequestSequence;
setText("chartSource", "正在加载分时");
clearPriceChart("正在加载分时数据");
try {
const params = new URLSearchParams({ type: "stock", id: code });
const payload = await apiRequest(`/api/chart/intraday?${params}`);
if (requestSequence !== state.stockDetailRequestSequence) return;
state.stockDetailIntraday = payload;
if (state.stockDetailChartMode === "intraday") renderStockDetailIntraday(payload);
} catch (error) {
if (requestSequence !== state.stockDetailRequestSequence || state.stockDetailChartMode !== "intraday") return;
setText("chartSource", "分时暂不可用");
clearPriceChart(error.message || "分时行情暂不可用");
}
}
function renderStockDetailIntraday(payload) {
const points = payload.points || [];
if (!points.length) {
setText("chartSource", "分时暂不可用");
clearPriceChart("分时行情暂不可用");
return;
}
setText("chartSource", `分时 · ${payload.meta?.trade_date || "--"}`);
requestAnimationFrame(() => {
if (state.stockDetailChartMode !== "intraday") return;
drawIntradayCanvas(elements.priceChart, points, [], payload.meta?.previous_close);
});
}
function openActiveStockInHeaven() {
const code = state.activeStock?.code;
if (!/^\d{6}$/.test(String(code || ""))) return;
elements.stockDialog.close();
state.heavenPanel = "trend";
state.heavenManualData = null;
const input = document.querySelector("#heavenStockInput");
input.value = code;
openView("heavenView");
selectHeavenPanel("trend", true);
}
File diff suppressed because it is too large Load Diff
+72
View File
@@ -0,0 +1,72 @@
<section id="mentorView" class="workspace-view page member-feature-view redesigned-mentor-view">
<div class="member-gate" hidden><div class="member-gate-icon"><i data-lucide="lock-keyhole"></i></div><div><strong>问师仅对会员开放</strong><span>开通会员后可使用游资思维模型进行对话。会员状态可从顶部账号标识进入。</span></div></div>
<header class="section-toolbar lad-head mentor-page-header">
<div class="section-title-group mentor-page-title">
<h2>问师</h2>
<span class="section-subtitle">向思维模型请教 · <span id="mentorDataDate">--</span></span>
</div>
<div class="toolbar-controls mentor-page-controls">
<div class="mentor-evidence-filters seg" role="group" aria-label="按素材等级筛选">
<button class="active" type="button" data-mentor-grade="all">全部</button>
<button type="button" data-mentor-grade="A">A级</button>
<button type="button" data-mentor-grade="B">B级</button>
<button type="button" data-mentor-grade="C">C级</button>
</div>
</div>
</header>
<div id="mentorNotice" class="inline-notice" hidden></div>
<div class="mentor-layout mentor-grid">
<aside class="mentor-sidebar mentor-library-card card">
<button id="mentorDirectoryToggle" class="mentor-directory-toggle" type="button" aria-expanded="false" aria-controls="mentorDirectoryContent">
<span><i data-lucide="users-round"></i><span><small>当前思维模型</small><strong id="mobileActiveMentorName">--</strong></span></span>
<i data-lucide="chevron-up"></i>
</button>
<div id="mentorDirectoryBackdrop" class="mentor-directory-backdrop" hidden></div>
<div id="mentorDirectoryContent" class="mentor-directory-content">
<div class="workspace-heading card-h mentor-directory-heading">
<div class="mentor-directory-title"><h3>模型库</h3><span>语料完整度决定回答质量</span></div>
<div class="mentor-directory-actions">
<strong id="mentorCount" class="mentor-count">0 位</strong>
<button id="mentorSortToggle" class="mentor-sort-toggle" type="button" aria-pressed="false" title="整理顺序"><i data-lucide="list-ordered"></i><span>整理</span></button>
<button id="closeMentorDirectory" class="icon-button mentor-directory-close" type="button" aria-label="关闭思维模型目录" title="关闭"><i data-lucide="x"></i></button>
</div>
</div>
<label class="mentor-search-field">
<span class="visually-hidden">搜索思维模型</span>
<i data-lucide="search"></i>
<input id="mentorSearchInput" type="search" maxlength="50" placeholder="搜索姓名、模式或标签" autocomplete="off">
</label>
<p id="mentorSortHint" class="mentor-sort-hint" hidden>拖动卡片,或使用箭头调整顺序</p>
<div id="mentorList" class="mentor-list"></div>
<div id="mentorListEmpty" class="mentor-list-empty" hidden>没有符合条件的思维模型</div>
<p class="mentor-evidence-legend">素材等级反映蒸馏依据,不代表人物能力或收益水平。</p>
</div>
</aside>
<section class="mentor-chat-panel card">
<header class="mentor-chat-header card-h">
<div class="mentor-active-profile">
<div class="mentor-active-title"><h3 id="activeMentorName">--</h3><span id="activeMentorBadges" class="mentor-active-badges"></span></div>
<p id="activeMentorEvidence">--</p>
<div id="activeMentorFocus" class="mentor-active-focus"></div>
</div>
<button id="clearMentorChatButton" class="button ghost mentor-clear-button" type="button" disabled><i data-lucide="trash-2"></i><span>清空对话</span></button>
</header>
<div class="chat-box">
<div id="mentorMessages" class="mentor-messages chat-log" aria-live="polite"></div>
<div id="mentorQuickPrompts" class="mentor-quick-prompts">
<span class="mentor-prompt-label">试着这样问</span>
<button type="button" data-mentor-prompt="怎么看今天的市场环境?">市场环境</button>
<button type="button" data-mentor-prompt="当前的市场主线和情绪周期是什么?">主线与周期</button>
<button type="button" data-mentor-prompt="如果今天是空仓状态,你会怎么制定操作预案?">空仓预案</button>
<button type="button" data-mentor-prompt="现在最需要防范的风险是什么?">风险检查</button>
</div>
<form id="mentorChatForm" class="mentor-chat-form chat-input">
<label class="visually-hidden" for="mentorQuestion">向当前思维模型提问</label>
<textarea id="mentorQuestion" maxlength="2000" placeholder="输入市场、板块、个股代码或交易问题"></textarea>
<button id="sendMentorQuestion" class="button primary" type="submit"><i data-lucide="send-horizontal"></i><span>发送</span></button>
</form>
<p class="mentor-disclaimer">基于公开资料提炼的思维模型模拟,不代表本人观点,不构成投资建议。</p>
</div>
</section>
</div>
</section>
+34 -2
View File
@@ -1,8 +1,8 @@
window.XiaobaiPageModules.register("mentor", ["mentorView"], {
bind: bindMentorEvents,
enter: ["loadMentor"],
});
/* PRESERVATION-SOURCE-BEGIN app.js:4337-4827 */
async function loadMentorSetup(force = false) {
const requestedDate = elements.tradeDate.value.replaceAll("-", "");
if (!force && state.mentorSetup?.requestedDate === requestedDate) {
@@ -494,4 +494,36 @@ function formatMentorInline(content) {
return content.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
}
/* PRESERVATION-SOURCE-END app.js:4337-4827 */
function bindMentorEvents() {
document.querySelector("#mentorChatForm").addEventListener("submit", sendMentorQuestion);
document.querySelector("#clearMentorChatButton").addEventListener("click", clearMentorConversation);
document.querySelector("#mentorDirectoryToggle").addEventListener("click", () => {
toggleMentorDirectory(!state.mentorDirectoryOpen);
});
document.querySelector("#closeMentorDirectory").addEventListener("click", () => toggleMentorDirectory(false));
document.querySelector("#mentorDirectoryBackdrop").addEventListener("click", () => toggleMentorDirectory(false));
document.querySelector("#mentorSortToggle").addEventListener("click", toggleMentorSortMode);
document.querySelector("#mentorSearchInput").addEventListener("input", (event) => {
state.mentorQuery = event.target.value.trim().toLocaleLowerCase("zh-CN");
renderMentorDirectory();
});
document.querySelectorAll("[data-mentor-grade]").forEach((button) => {
button.addEventListener("click", () => {
state.mentorGrade = button.dataset.mentorGrade || "all";
document.querySelectorAll("[data-mentor-grade]").forEach((item) => {
item.classList.toggle("active", item === button);
});
renderMentorDirectory();
});
});
document.querySelectorAll("[data-mentor-prompt]").forEach((button) => {
button.addEventListener("click", () => useMentorQuickPrompt(button.dataset.mentorPrompt));
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") toggleMentorDirectory(false);
});
window.addEventListener("resize", () => {
if (window.innerWidth > 720) toggleMentorDirectory(false);
});
}
File diff suppressed because it is too large Load Diff
+235
View File
@@ -0,0 +1,235 @@
<section id="limitPool" class="workspace-view page redesigned-pool-view">
<div class="section-toolbar lad-head redesigned-page-head pool-page-head">
<div class="section-title-group">
<h2>涨停池</h2>
<span id="limitPoolSubtitle" class="section-subtitle">0 只 · 数据日期 --</span>
<span id="resultCount" class="visually-hidden">0 只</span>
</div>
<div class="toolbar-controls">
<div class="segmented seg pool-filter-segments" role="group" aria-label="连板筛选">
<button type="button" class="segment active" data-filter="all">全部 <span id="limitAllCount">0</span></button>
<button type="button" class="segment" data-filter="1">首板 <span id="limitFirstCount">0</span></button>
<button type="button" class="segment" data-filter="2">2板 <span id="limitSecondCount">0</span></button>
<button type="button" class="segment" data-filter="3">3板+ <span id="limitThreePlusCount">0</span></button>
</div>
<label class="search search-field pool-search-field">
<span class="visually-hidden">搜索股票</span>
<i data-lucide="search" aria-hidden="true"></i>
<input id="stockSearch" type="search" placeholder="搜索代码 / 名称 / 板块">
</label>
<button id="exportButton" class="button btn" type="button">导出 CSV</button>
</div>
</div>
<div class="main-grid redesigned-pool-grid">
<div class="table-frame card tbl-wrap redesigned-card pool-table-card">
<table class="data-table tbl" id="limitTable">
<thead>
<tr>
<th class="row-number num" aria-label="序号">序号</th>
<th data-sort="name">股票</th>
<th class="number num sortable" data-sort="streak">连板<span class="arr"></span></th>
<th class="number num sortable" data-sort="change">涨幅(%<span class="arr"></span></th>
<th class="number num sortable" data-sort="price">价格(元)<span class="arr"></span></th>
<th data-sort="sector">所属板块</th>
<th class="number num sortable" data-sort="first_time">首封<span class="arr"></span></th>
<th class="number num sortable" data-sort="last_time">最后封板<span class="arr"></span></th>
<th class="number num sortable" data-sort="open_times">开板(次)<span class="arr"></span></th>
<th class="number num sortable" data-sort="turnover_rate">换手率(%<span class="arr"></span></th>
<th class="number num sortable" data-sort="amount_billion">成交额(亿)<span class="arr"></span></th>
<th class="number num sortable" data-sort="seal_amount_million">封单额(万)<span class="arr"></span></th>
<th class="reason-column">涨停原因</th>
</tr>
</thead>
<tbody id="limitTableBody"></tbody>
</table>
<div id="emptyState" class="empty-state" hidden>没有符合条件的股票</div>
</div>
<aside class="insight-rail pool-insight-rail" aria-label="连板与板块概况">
<section class="rail-section card redesigned-card">
<div class="rail-heading card-h redesigned-card-head">
<h3>连板高度</h3>
<span id="maxHeight">--</span>
</div>
<div id="ladderMini" class="ladder-mini pool-side-list"></div>
</section>
<section class="rail-section card redesigned-card">
<div class="rail-heading card-h redesigned-card-head">
<h3>热点板块</h3>
<button class="text-button" type="button" data-open-view="rotationView">查看轮动</button>
</div>
<div id="sectorMini" class="sector-mini pool-hot-list"></div>
</section>
</aside>
</div>
</section>
<section id="brokenView" class="workspace-view page redesigned-broken-view">
<div class="section-toolbar lad-head redesigned-page-head broken-page-head">
<div class="section-title-group">
<h2>炸板池</h2>
<span class="section-subtitle"><b id="brokenCount">0 只</b><span id="brokenMeta"> · 触及涨停后未能封住 · 数据日期 --</span></span>
</div>
<div class="toolbar-controls">
<label class="pool-search-field broken-search-field">
<i data-lucide="search" aria-hidden="true"></i>
<span class="visually-hidden">搜索炸板股票</span>
<input id="brokenSearch" type="search" placeholder="搜索代码/名称/板块" autocomplete="off">
</label>
<button id="brokenExportButton" class="button broken-export-button" type="button">导出 CSV</button>
</div>
</div>
<div class="table-frame card tbl-wrap phase-table-frame broken-table-card">
<table id="brokenTable" class="data-table tbl">
<thead>
<tr>
<th class="row-number num">序号</th>
<th>股票</th>
<th class="number num sortable" data-broken-sort="change">现价涨幅(%<span class="arr"></span></th>
<th class="number num sortable" data-auto-sort="true">距涨停(%<span class="arr"></span></th>
<th class="number num sortable" data-auto-sort="true">价格(元)<span class="arr"></span></th>
<th>所属板块</th>
<th class="number num sortable" data-auto-sort="true">首次触板<span class="arr"></span></th>
<th class="number num sortable" data-broken-sort="open_times">开板(次)<span class="arr"></span></th>
<th class="number num sortable" data-broken-sort="turnover_rate">换手率(%<span class="arr"></span></th>
<th class="number num sortable" data-broken-sort="amount_billion">成交额(亿)<span class="arr"></span></th>
<th class="reason-column">炸板原因</th>
</tr>
</thead>
<tbody id="brokenTableBody"></tbody>
</table>
<div id="brokenEmptyState" class="empty-state" hidden>无匹配标的</div>
</div>
</section>
<section id="downView" class="workspace-view page redesigned-down-view">
<div class="section-toolbar lad-head redesigned-page-head down-page-head">
<div class="section-title-group">
<h2>跌停板</h2>
<span class="section-subtitle"><b id="downCount">0 只</b><span id="downMeta"> · 观察退潮、高位风险与亏钱效应 · 数据日期 --</span></span>
</div>
<div class="toolbar-controls">
<span id="downSectorCluster" class="down-sector-cluster" hidden></span>
<label class="pool-search-field down-search-field">
<i data-lucide="search" aria-hidden="true"></i>
<span class="visually-hidden">搜索跌停股票</span>
<input id="downSearch" type="search" placeholder="搜索代码/名称/板块" autocomplete="off">
</label>
<button id="downExportButton" class="button down-export-button" type="button">导出 CSV</button>
</div>
</div>
<div class="table-frame card tbl-wrap down-table-card">
<table id="downTable" class="data-table tbl">
<thead>
<tr>
<th class="row-number num">序号</th>
<th>股票</th>
<th class="number num sortable" data-down-sort="change">跌幅(%<span class="arr"></span></th>
<th class="number num sortable" data-auto-sort="true">价格(元)<span class="arr"></span></th>
<th>所属板块</th>
<th class="number num sortable" data-down-sort="turnover_rate">换手率(%<span class="arr"></span></th>
<th class="number num sortable" data-down-sort="amount_billion">成交额(亿)<span class="arr"></span></th>
<th class="number num sortable" data-auto-sort="true">连续跌停(天)<span class="arr"></span></th>
<th class="reason-column">风险线索</th>
</tr>
</thead>
<tbody id="downTableBody"></tbody>
</table>
<div id="downEmptyState" class="empty-state" hidden>无匹配标的</div>
</div>
</section>
<section id="yesterdayView" class="workspace-view page redesigned-yesterday-view">
<div class="section-toolbar lad-head redesigned-page-head yesterday-page-head">
<div class="section-title-group">
<h2>昨日涨停表现</h2>
<span class="section-subtitle"><b id="yesterdayCount">0 只</b><span id="yesterdayMeta"> · 昨日 -- → 今日 --</span></span>
</div>
<div class="toolbar-controls">
<label class="pool-search-field yesterday-search-field">
<i data-lucide="search" aria-hidden="true"></i>
<span class="visually-hidden">搜索昨日涨停股票</span>
<input id="yesterdaySearch" type="search" placeholder="搜索代码/名称/板块" autocomplete="off">
</label>
<button id="yesterdayExportButton" class="button yesterday-export-button" type="button">导出 CSV</button>
</div>
</div>
<section class="yesterday-table-card">
<div id="yesterdayResultSummary" class="yesterday-result-summary" role="group" aria-label="昨日涨停结果筛选">
<button class="yesterday-summary-cell active" type="button" data-yesterday-filter="all" aria-pressed="true">
<span class="summary-label">全部</span><strong><b id="yesterdayAllCount">0</b> <small></small></strong><em>点击各格可筛选</em>
</button>
<button class="yesterday-summary-cell" type="button" data-yesterday-filter="advance" aria-pressed="false">
<span class="summary-label"><i class="yesterday-outcome-tag advance">晋级</i></span><strong class="up"><b id="yesterdayAdvanceCount">0</b> <small></small></strong><em id="yesterdayAdvanceRate">晋级率 0.0%</em>
</button>
<button class="yesterday-summary-cell" type="button" data-yesterday-filter="positive" aria-pressed="false">
<span class="summary-label"><i class="yesterday-outcome-tag positive">红盘</i></span><strong class="up"><b id="yesterdayPositiveCount">0</b> <small></small></strong><em id="yesterdayPositiveRate">兑现率 0.0%</em>
</button>
<button class="yesterday-summary-cell" type="button" data-yesterday-filter="fail" aria-pressed="false">
<span class="summary-label"><i class="yesterday-outcome-tag fail">断板</i></span><strong><b id="yesterdayFailCount">0</b> <small></small></strong><em id="yesterdayFailRate">占 0.0%</em>
</button>
<button class="yesterday-summary-cell" type="button" data-yesterday-filter="risk" aria-pressed="false">
<span class="summary-label"><i class="yesterday-outcome-tag broken">炸板</i><span>+</span><i class="yesterday-outcome-tag down">跌停</i></span><strong class="down"><b id="yesterdayRiskCount">0</b> <small></small></strong><em id="yesterdayRiskRate">亏钱效应 0.0%</em>
</button>
</div>
<div class="table-frame card tbl-wrap yesterday-table-scroll">
<table id="yesterdayTable" class="data-table tbl">
<thead>
<tr>
<th class="row-number num">序号</th>
<th>股票</th>
<th class="number num sortable" data-yesterday-sort="prior_streak">昨日高度(板)<span class="arr"></span></th>
<th class="number num sortable" data-yesterday-sort="current_change">今日涨幅(%<span class="arr"></span></th>
<th>今日结果</th>
<th class="number num sortable" data-auto-sort="true">当前高度(板)<span class="arr"></span></th>
<th>所属板块</th>
<th class="reason-column">涨停逻辑</th>
</tr>
</thead>
<tbody id="yesterdayTableBody"></tbody>
</table>
<div id="yesterdayEmptyState" class="empty-state" hidden>当前筛选下无标的</div>
</div>
</section>
</section>
<section id="performanceView" class="workspace-view page redesigned-performance-view">
<div class="section-toolbar lad-head redesigned-page-head performance-page-head">
<div class="section-title-group">
<h2>涨停表现</h2>
<span class="section-subtitle">昨日梯队今日晋级率 + 市场宽度 · <b id="performanceDateRange">--</b></span>
</div>
</div>
<div id="performanceCards" class="performance-cards perf-cards"></div>
<div class="performance-insight-grid">
<section class="performance-panel-card market-breadth-panel card" aria-labelledby="marketBreadthTitle">
<div class="performance-panel-head">
<h3 id="marketBreadthTitle">市场宽度</h3>
<span id="breadthDataTime" class="performance-date-tag">--</span>
</div>
<div class="performance-width-box">
<div class="performance-width-summary">
<span>上涨 <b id="breadthUpCount" class="up">--</b></span>
<span>红盘 <b id="breadthRatio" class="up">--</b></span>
<span>下跌 <b id="breadthDownCount" class="down">--</b></span>
</div>
<div class="performance-width-bar" aria-label="上涨、平盘与下跌家数分布">
<i id="breadthUpBar" class="breadth-up"></i><i id="breadthFlatBar" class="breadth-flat"></i><i id="breadthDownBar" class="breadth-down"></i>
</div>
<div class="performance-width-legend">
<span><i class="up-swatch"></i>上涨 <b id="breadthUpLegend">--</b></span>
<span id="breadthFlatLegendItem"><i class="flat-swatch"></i>平盘 <b id="breadthFlatLegend">--</b></span>
<span><i class="down-swatch"></i>下跌 <b id="breadthDownLegend">--</b></span>
<span id="breadthWarning" class="performance-width-warning">--</span>
</div>
</div>
</section>
<section class="performance-panel-card performance-conclusion-card card" aria-labelledby="performanceConclusionTitle">
<div class="performance-panel-head">
<h3 id="performanceConclusionTitle">今日结论</h3>
<span class="performance-date-tag">自动生成</span>
</div>
<div id="performanceConclusion" class="performance-conclusion"><div class="empty-state">暂无可用结论</div></div>
</section>
</div>
</section>
+91 -3
View File
@@ -4,9 +4,8 @@ window.XiaobaiPageModules.register("pools", [
"downView",
"yesterdayView",
"performanceView",
]);
], { bind: bindPoolEvents });
/* PRESERVATION-SOURCE-BEGIN app.js:1517-1915 */
function getVisibleStocks() {
if (!state.dashboard) return [];
let rows = [...(state.dashboard.limits || [])];
@@ -406,4 +405,93 @@ function renderPerformanceConclusion(rows) {
`;
}
/* PRESERVATION-SOURCE-END app.js:1517-1915 */
function changeSort(key) {
if (state.sortKey === key) state.sortDirection = state.sortDirection === "asc" ? "desc" : "asc";
else {
state.sortKey = key;
state.sortDirection = ["name", "code", "sector", "first_time", "last_time"].includes(key) ? "asc" : "desc";
}
renderLimitTable();
}
function compareRows(left, right) {
const leftValue = left[state.sortKey] ?? "";
const rightValue = right[state.sortKey] ?? "";
let result = typeof leftValue === "number" || typeof rightValue === "number"
? number(leftValue) - number(rightValue)
: String(leftValue).localeCompare(String(rightValue), "zh-CN", { numeric: true });
if (result === 0 && state.sortKey !== "first_time") result = String(left.first_time || "").localeCompare(String(right.first_time || ""));
return state.sortDirection === "asc" ? result : -result;
}
function updateSortHeaders() {
document.querySelectorAll("#limitTable th[data-sort]").forEach((header) => {
header.classList.remove("sort-asc", "sort-desc", "sorted");
const active = header.dataset.sort === state.sortKey;
if (active) header.classList.add(state.sortDirection === "asc" ? "sort-asc" : "sort-desc", "sorted");
const arrow = header.querySelector(".arr");
if (arrow) arrow.textContent = active ? (state.sortDirection === "asc" ? "▲" : "▼") : "↕";
});
}
function bindPoolEvents() {
document.querySelector("#stockSearch").addEventListener("input", (event) => {
state.query = event.target.value.trim().toLowerCase();
renderLimitTable();
});
document.querySelectorAll("[data-table-search]").forEach((input) => {
input.addEventListener("input", () => {
const query = input.value.trim().toLowerCase();
const body = document.querySelector(`#${CSS.escape(input.dataset.tableSearch)}`);
body?.querySelectorAll("tr").forEach((row) => {
row.hidden = Boolean(query) && !row.textContent.toLowerCase().includes(query);
});
});
});
document.querySelectorAll("[data-filter]").forEach((button) => {
button.addEventListener("click", () => {
document.querySelectorAll("[data-filter]").forEach((item) => item.classList.remove("active"));
button.classList.add("active");
state.filter = button.dataset.filter;
renderLimitTable();
});
});
document.querySelectorAll("#limitTable th[data-sort]").forEach((header) => {
header.addEventListener("click", () => changeSort(header.dataset.sort));
});
document.querySelector("#brokenSearch").addEventListener("input", (event) => {
state.brokenQuery = event.target.value.trim().toLowerCase();
renderBrokenTable(state.dashboard?.broken || []);
});
document.querySelectorAll("#brokenTable th[data-broken-sort]").forEach((header) => {
header.addEventListener("click", () => changeBrokenSort(header.dataset.brokenSort));
});
document.querySelector("#downSearch").addEventListener("input", (event) => {
state.downQuery = event.target.value.trim().toLowerCase();
renderDownTable(state.dashboard?.down_limits || []);
});
document.querySelectorAll("#downTable th[data-down-sort]").forEach((header) => {
header.addEventListener("click", () => changeDownSort(header.dataset.downSort));
});
document.querySelector("#yesterdaySearch").addEventListener("input", (event) => {
state.yesterdayQuery = event.target.value.trim().toLowerCase();
renderYesterdayTable(state.dashboard?.yesterday_limits || []);
});
document.querySelectorAll("[data-yesterday-filter]").forEach((button) => {
button.addEventListener("click", () => {
state.yesterdayFilter = button.dataset.yesterdayFilter;
renderYesterdayTable(state.dashboard?.yesterday_limits || []);
});
});
document.querySelectorAll("#yesterdayTable th[data-yesterday-sort]").forEach((header) => {
header.addEventListener("click", () => changeYesterdaySort(header.dataset.yesterdaySort));
});
document.querySelector("#exportButton").addEventListener("click", exportStocks);
document.querySelector("#brokenExportButton").addEventListener("click", exportBroken);
document.querySelector("#downExportButton").addEventListener("click", exportDown);
document.querySelector("#yesterdayExportButton").addEventListener("click", exportYesterday);
document.querySelector("#reasonForm").addEventListener("submit", saveReasonOverride);
}
+921
View File
@@ -0,0 +1,921 @@
/* Canonical CSS owner: popularity. Historical layers consolidated 2026-08-02. */
.popularity-source-tag {
display: inline-flex;
align-items: center;
min-height: 23px;
padding: 2px 7px;
border: 1px solid var(--line);
border-radius: 4px;
background: var(--surface-muted);
color: var(--text-secondary);
font-size: 12px;
font-weight: 650;
}
.popularity-source-tag.dual {
border-color: rgb(230, 199, 115);
background: var(--amber-soft);
color: rgb(118, 83, 20);
}
.popularity-concepts {
max-width: 320px;
overflow: hidden;
text-overflow: ellipsis;
}
:where(#popularityView) #popularitySummary {
margin-bottom: 12px;
background: rgb(255, 255, 255);
}
#popularityView .data-table {
font-size: 12px;
}
#popularityView .data-table thead th {
height: 32px;
padding: 6px 9px;
font-size: 11.5px;
}
#popularityView .data-table tbody td {
height: 39px;
padding: 5px 9px;
}
.redesigned-popularity-view {
width: min(100%, 2200px);
margin: 0px auto;
padding: 12px 16px 14px;
}
.popularity-page-head-v2 {
min-height: 52px;
display: flex;
align-items: center;
justify-content: space-between;
}
.popularity-title-v2 {
display: flex;
align-items: center;
min-width: 0px;
gap: 9px;
}
.popularity-title-v2 h2 {
margin: 0px;
color: var(--r2-ink);
}
.popularity-title-v2 > span {
color: var(--r2-sub);
font-size: 12px;
}
.popularity-title-v2 > strong {
padding: 4px 8px;
border-radius: 5px;
background: var(--r2-amber-soft);
color: var(--r2-amber);
font-size: 11px;
font-weight: 650;
white-space: nowrap;
}
.popularity-title-v2 > small {
color: var(--r2-faint);
font-size: 10.5px;
white-space: nowrap;
}
.popularity-head-actions-v2 {
display: flex;
align-items: center;
flex: 0 0 auto;
gap: 8px;
}
.popularity-source-tabs-v2 {
display: flex;
align-items: center;
min-height: 34px;
padding: 3px;
border: 1px solid var(--r2-line);
border-radius: 8px;
background: rgb(244, 245, 247);
}
.popularity-source-tabs-v2 button {
min-height: 27px;
padding: 0px 13px;
border: 0px;
border-radius: 5px;
background: transparent;
color: var(--r2-sub);
font-size: 11px;
cursor: pointer;
}
.popularity-source-tabs-v2 button:hover {
color: var(--r2-ink);
}
.popularity-source-tabs-v2 button.active {
background: rgb(255, 255, 255);
color: var(--r2-ink);
font-weight: 700;
box-shadow: rgba(16, 24, 40, 0.09) 0px 1px 3px;
}
.popularity-source-tabs-v2 button:focus-visible {
outline: 2px solid var(--r2-blue);
outline-offset: 1px;
}
.popularity-refresh-v2 {
min-height: 34px;
padding: 0px 11px;
border-radius: 7px;
}
.popularity-refresh-v2 .lucide {
width: 15px;
height: 15px;
}
:where(#popularityView) .popularity-glance-v2 {
display: grid;
grid-template-columns: repeat(3, minmax(0px, 1fr));
gap: 12px;
margin-bottom: 12px;
}
:where(#popularityView) .popularity-glance-v2 article {
min-width: 0px;
min-height: 88px;
display: grid;
align-content: center;
gap: 5px;
position: relative;
overflow: hidden;
padding: 12px 15px;
border: 1px solid var(--r2-line);
border-radius: var(--r2-radius);
}
.popularity-glance-v2 article::before {
content: "";
position: absolute;
inset: 0px auto 0px 0px;
width: 3px;
background: rgb(199, 216, 251);
}
.popularity-glance-v2 article:nth-child(2)::before {
background: rgb(183, 221, 207);
}
.popularity-glance-v2 article.consensus::before {
background: rgb(230, 199, 115);
}
.popularity-glance-v2 article > span {
color: var(--r2-sub);
font-size: 11px;
}
.popularity-glance-v2 article > strong {
overflow: hidden;
color: var(--r2-ink);
font-size: 14px;
font-weight: 750;
text-overflow: ellipsis;
white-space: nowrap;
}
.popularity-glance-v2 article.consensus > strong {
color: var(--r2-amber);
font-size: 18px;
}
.popularity-glance-v2 article > small {
overflow: hidden;
color: var(--r2-faint);
font-size: 10.5px;
text-overflow: ellipsis;
white-space: nowrap;
}
.popularity-table-card-v2 {
min-width: 0px;
min-height: 0px;
display: flex;
flex-direction: column;
overflow: hidden;
border: 1px solid var(--r2-line);
border-radius: var(--r2-radius);
background: rgb(255, 255, 255);
box-shadow: var(--r2-shadow);
}
.popularity-table-head-v2 {
display: flex;
align-items: center;
min-height: 52px;
flex: 0 0 auto;
justify-content: space-between;
gap: 16px;
padding: 8px 13px;
border-bottom: 1px solid var(--r2-line-soft);
}
.popularity-table-head-v2 > div {
display: flex;
align-items: center;
min-width: 0px;
gap: 8px;
}
.popularity-table-head-v2 h3 {
margin: 0px;
color: var(--r2-ink);
font-size: 14px;
font-weight: 750;
white-space: nowrap;
}
.popularity-table-head-v2 > div > span {
overflow: hidden;
color: var(--r2-faint);
font-size: 10.5px;
text-overflow: ellipsis;
white-space: nowrap;
}
.popularity-search-v2 {
display: flex;
align-items: center;
width: 230px;
height: 33px;
flex: 0 0 auto;
gap: 7px;
padding: 0px 10px;
border: 1px solid rgb(216, 221, 229);
border-radius: 7px;
color: var(--r2-faint);
transition: border-color 160ms, box-shadow 160ms;
}
.popularity-search-v2:focus-within {
border-color: rgb(150, 181, 242);
box-shadow: rgba(37, 99, 235, 0.09) 0px 0px 0px 3px;
}
.popularity-search-v2 .lucide {
width: 15px;
height: 15px;
}
.popularity-search-v2 input {
min-width: 0px;
width: 100%;
height: 100%;
padding: 0px;
border: 0px;
outline: 0px;
background: transparent;
color: var(--r2-ink);
font: inherit;
}
.popularity-search-v2 input::placeholder {
color: var(--r2-faint);
}
.popularity-table-frame-v2 {
min-width: 0px;
min-height: 0px;
flex: 1 1 auto;
overflow: auto;
overscroll-behavior: contain;
scrollbar-gutter: stable;
}
.popularity-table-v2 thead th {
position: sticky;
top: 0px;
z-index: 2;
height: 35px;
padding: 7px 10px;
border-bottom-color: var(--r2-line);
color: var(--r2-sub);
font-size: 10.5px;
}
.popularity-table-v2 thead th:nth-child(1) {
width: 78px;
}
.popularity-table-v2 thead th:nth-child(2) {
width: 220px;
}
.popularity-table-v2 thead th:nth-child(3),
.popularity-table-v2 thead th:nth-child(4) {
width: 105px;
}
.popularity-table-v2 thead th:nth-child(5),
.popularity-table-v2 thead th:nth-child(6),
.popularity-table-v2 thead th:nth-child(7) {
width: 115px;
}
.popularity-table-v2 tbody td {
height: 43px;
padding: 7px 10px;
font-size: 11.5px;
}
.popularity-table-v2 tbody tr {
cursor: pointer;
}
.popularity-table-v2 tbody tr:hover {
background: rgb(247, 249, 252);
}
.popularity-rank-v2 {
white-space: nowrap;
}
.popularity-rank-v2 b {
color: var(--r2-ink);
font-size: 12px;
font-weight: 750;
}
.popularity-rank-v2 span {
display: inline-grid;
place-items: center;
width: 20px;
height: 20px;
margin-left: 6px;
border-radius: 5px;
background: var(--r2-up-soft);
color: var(--r2-up);
font-size: 9px;
font-weight: 700;
}
.popularity-stock-v2 {
display: flex;
align-items: center;
min-width: 0px;
gap: 6px;
}
.popularity-stock-v2 strong {
overflow: hidden;
color: var(--r2-ink);
font-size: 12px;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.popularity-stock-v2 .stock-code {
flex: 0 0 auto;
color: var(--r2-faint);
font-size: 10.5px;
}
.popularity-list-rank-v2 {
color: rgb(64, 85, 115);
}
.popularity-movement-v2 {
font-weight: 650;
}
.popularity-concepts-v2 {
overflow: hidden;
color: var(--r2-sub);
text-overflow: ellipsis;
white-space: nowrap;
}
.popularity-source-tag-v2 {
display: inline-flex;
align-items: center;
min-height: 22px;
padding: 2px 7px;
border-radius: 5px;
background: rgb(243, 244, 246);
color: var(--r2-sub);
font-size: 10px;
white-space: nowrap;
}
.popularity-source-tag-v2.dual {
background: var(--r2-amber-soft);
color: var(--r2-amber);
}
@media (min-width: 721px) {
body[data-active-view="popularityView"] .app-main {
display: flex;
flex-direction: column;
overflow: hidden;
}
body[data-active-view="popularityView"] .overview-strip {
flex: 0 0 auto;
}
body[data-active-view="popularityView"] #popularityView.active-view {
min-height: 0px;
flex: 1 1 auto;
display: flex;
flex-direction: column;
}
}
@media (min-width: 721px) and (max-height: 900px) {
.redesigned-popularity-view {
padding-top: 9px;
padding-bottom: 10px;
}
.popularity-page-head-v2 {
min-height: 45px;
margin-bottom: 7px;
}
.popularity-glance-v2 {
gap: 9px;
margin-bottom: 9px;
}
.popularity-glance-v2 article {
min-height: 76px;
padding-top: 8px;
padding-bottom: 8px;
}
.popularity-table-head-v2 {
min-height: 46px;
}
.popularity-table-v2 tbody td {
height: 39px;
padding-top: 5px;
padding-bottom: 5px;
}
}
@media (max-width: 960px) {
.popularity-title-v2 {
flex-wrap: wrap;
}
.popularity-title-v2 > small {
width: 100%;
}
.popularity-glance-v2 {
grid-template-columns: repeat(2, minmax(0px, 1fr));
}
.popularity-glance-v2 article.consensus {
grid-column: 1 / -1;
}
}
@media (max-width: 720px) {
.redesigned-popularity-view {
padding: 10px;
}
.popularity-page-head-v2 {
flex-direction: column;
}
.popularity-title-v2 {
gap: 6px 8px;
}
.popularity-title-v2 > span {
width: calc(100% - 110px);
}
.popularity-title-v2 > strong {
order: 4;
}
.popularity-title-v2 > small {
order: 5;
width: auto;
}
.popularity-head-actions-v2 {
width: 100%;
}
.popularity-source-tabs-v2 {
min-width: 0px;
flex: 1 1 auto;
}
.popularity-source-tabs-v2 button {
min-width: 0px;
flex: 1 1 auto;
padding-inline: 6px;
}
.popularity-refresh-v2 span {
display: none;
}
.popularity-glance-v2 {
grid-template-columns: minmax(0px, 1fr);
gap: 8px;
}
.popularity-glance-v2 article,
.popularity-glance-v2 article.consensus {
min-height: 74px;
grid-column: auto;
}
.popularity-table-card-v2 {
min-height: 520px;
}
.popularity-table-head-v2 {
align-items: stretch;
flex-direction: column;
gap: 7px;
}
.popularity-table-head-v2 > div {
align-items: flex-start;
flex-direction: column;
gap: 2px;
}
.popularity-search-v2 {
width: 100%;
}
.popularity-table-frame-v2 {
overflow-x: auto;
}
}
@media (prefers-reduced-motion: reduce) {
.popularity-search-v2 {
transition: none;
}
}
.hot3 {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
margin-bottom: 12px;
}
.hot3 .hc {
background: rgb(255, 255, 255);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 13px 16px;
box-shadow: var(--shadow);
}
.hot3 .hc .t {
font-size: 12px;
color: var(--sub);
}
.hot3 .hc .n {
font-size: 15px;
font-weight: 800;
margin-top: 4px;
}
.hot3 .hc .d {
font-size: 11px;
color: var(--faint);
margin-top: 4px;
line-height: 1.6;
}
#popularityView .popularity-glance-v2 {
background: var(--bg);
}
#popularityView #popularitySummary {
border: 0px;
border-radius: 0px;
box-shadow: none;
overflow: visible;
}
#popularityView .popularity-glance-v2 article {
background: var(--card);
box-shadow: none;
}
#popularityView .popularity-table-v2 {
table-layout: auto;
min-width: var(--table-medium);
}
#popularityView .popularity-table-v2 th {
width: auto;
}
#popularityView .popularity-table-v2 th:first-child {
width: var(--col-rank);
}
#popularityView .popularity-table-v2 th:nth-child(2) {
width: var(--col-stock);
}
#popularityView .popularity-table-v2 th.number {
width: var(--col-number);
}
@media (min-width: 721px) {
#popularityView .popularity-glance-v2,
#popularityView .popularity-page-head-v2 {
flex: 0 0 auto;
}
#popularityView .popularity-table-card-v2 {
min-height: 0px;
flex: 1 1 auto;
display: flex;
flex-direction: column;
overflow: hidden;
}
#popularityView .popularity-table-frame-v2 {
min-height: 0px;
flex: 1 1 auto;
overflow: auto;
}
}
:root[data-theme="dark"] #popularityView .popularity-glance-v2 {
background: var(--canvas);
}
:root[data-theme="dark"] #popularityView .popularity-glance-v2 article {
border-color: var(--border);
background: var(--surface);
}
:root[data-theme="dark"] #popularityView .popularity-source-tabs-v2 {
border-color: var(--border);
background: var(--surface-muted);
}
:root[data-theme="dark"] #popularityView .popularity-source-tabs-v2 button.active {
background: var(--surface-subtle);
color: var(--text-primary);
box-shadow: var(--control-shadow);
}
+35
View File
@@ -0,0 +1,35 @@
<section id="popularityView" class="workspace-view page redesigned-popularity-view">
<header class="popularity-page-head-v2 lad-head">
<div class="popularity-title-v2">
<h2>人气热榜</h2>
<span>同花顺 × 东方财富双榜</span>
<strong id="popularityDateLabel">--</strong>
<small>涨跌幅为当日行情</small>
</div>
<div class="popularity-head-actions-v2">
<div class="popularity-source-tabs-v2" role="tablist" aria-label="热榜来源">
<button class="active" type="button" role="tab" aria-selected="true" data-popularity-source="combined">双榜综合</button>
<button type="button" role="tab" aria-selected="false" data-popularity-source="ths">同花顺</button>
<button type="button" role="tab" aria-selected="false" data-popularity-source="dc">东方财富</button>
</div>
<button id="popularityRefreshButton" class="button popularity-refresh-v2" type="button" title="刷新热榜"><i data-lucide="refresh-cw"></i><span>刷新</span></button>
</div>
</header>
<div id="popularitySummary" class="popularity-glance-v2 hot3" aria-live="polite"></div>
<section class="popularity-table-card-v2 card">
<header class="popularity-table-head-v2">
<div><h3 id="popularityTableTitle">双榜综合榜</h3><span id="popularityTableNote">按双榜排名综合排序</span></div>
<label class="popularity-search-v2">
<i data-lucide="search" aria-hidden="true"></i>
<span class="visually-hidden">搜索热榜股票</span>
<input id="popularitySearch" type="search" placeholder="搜索代码、名称或概念" autocomplete="off">
</label>
</header>
<div class="popularity-table-frame-v2 tbl-wrap">
<table class="data-table tbl popularity-table-v2"><thead><tr id="popularityTableHead"></tr></thead><tbody id="popularityTableBody"></tbody></table>
<div id="popularityEmpty" class="empty-state" hidden>暂无符合条件的人气数据</div>
</div>
</section>
</section>
+20 -2
View File
@@ -1,8 +1,8 @@
window.XiaobaiPageModules.register("popularity", ["popularityView"], {
bind: bindPopularityEvents,
enter: ["loadPopularity"],
});
/* PRESERVATION-SOURCE-BEGIN app.js:2584-2659 */
async function loadPopularity(force = false) {
if (state.popularityLoading) return;
state.popularityLoading = true;
@@ -79,4 +79,22 @@ function renderPopularityTable() {
document.querySelector("#popularityEmpty").hidden = rows.length > 0;
}
/* PRESERVATION-SOURCE-END app.js:2584-2659 */
function bindPopularityEvents() {
document.querySelector("#popularityRefreshButton").addEventListener("click", () => loadPopularity(true));
document.querySelector("#popularitySearch").addEventListener("input", (event) => {
state.popularityQuery = event.target.value.trim().toLocaleLowerCase("zh-CN");
renderPopularityTable();
});
document.querySelectorAll("[data-popularity-source]").forEach((button) => {
button.addEventListener("click", () => {
state.popularitySource = button.dataset.popularitySource || "combined";
document.querySelectorAll("[data-popularity-source]").forEach((item) => {
const active = item === button;
item.classList.toggle("active", active);
item.setAttribute("aria-selected", String(active));
});
renderPopularityTable();
});
});
}
File diff suppressed because it is too large Load Diff
+57
View File
@@ -0,0 +1,57 @@
<section id="reviewWorkspaceView" class="workspace-view page redesigned-review-view">
<header class="section-toolbar lad-head review-page-header">
<div class="section-title-group review-page-title">
<h2>我的复盘</h2>
<span class="section-subtitle">复盘日期跟随顶栏日期:<b id="reviewDataDate">--</b> · 自选跟踪、交易记录与明日计划</span>
</div>
<button id="reviewHistoryToggle" class="button review-history-toggle" type="button" aria-expanded="false" aria-controls="reviewHistoryPanel"><i data-lucide="history"></i><span>历史复盘</span></button>
</header>
<div class="review-workspace review-grid">
<div class="review-left-stack">
<section class="workspace-section watchlist-section card">
<div class="workspace-heading card-h review-card-heading">
<div><h3>自选跟踪</h3><span id="watchlistCount" class="review-count-tag">0 只</span></div>
<button id="openWatchlistDialog" class="button review-add-watch" type="button"><i data-lucide="plus"></i><span>添加自选</span></button>
</div>
<div class="table-frame tbl-wrap workspace-table-frame">
<table class="data-table tbl review-watchlist-table">
<thead><tr><th>标记</th><th>股票</th><th>所属板块</th><th class="number num">今日涨幅(%</th><th class="number num">5日涨幅(%</th><th class="number num">竞价关注(分)</th><th>跟踪备注</th><th>操作</th></tr></thead>
<tbody id="watchlistTableBody"></tbody>
</table>
<div id="watchlistEmpty" class="empty-state">从股票详情中添加自选,持续跟踪关键标的</div>
</div>
</section>
<section class="workspace-section trade-journal-section card">
<div class="workspace-heading card-h trade-log-heading review-card-heading">
<div><h3>交易日志</h3><span id="tradeLogCount" class="review-count-tag">0 条</span></div>
<button id="openTradeLogDialog" class="button primary" type="button"><i data-lucide="notebook-pen"></i><span>交易日志</span></button>
</div>
<div id="tradeLogSummary" class="trade-log-summary"></div>
<div class="table-frame tbl-wrap trade-log-table-frame">
<table class="data-table tbl trade-log-table">
<thead><tr><th>日期</th><th>股票</th><th>动作</th><th class="number num">仓位(%</th><th class="number num">盈亏(%</th><th class="number num">盈亏金额(元)</th><th>情绪 / 标签</th><th>交易复核</th><th>操作</th></tr></thead>
<tbody id="tradeLogTableBody"></tbody>
</table>
<div id="tradeLogEmpty" class="empty-state">当前日期暂无交易记录,空仓也值得记录原因</div>
</div>
</section>
</div>
<section class="workspace-section journal-section card">
<div class="workspace-heading card-h review-card-heading">
<h3>每日复盘</h3>
<input id="journalDate" class="date-input" type="date" aria-label="复盘日期">
</div>
<form id="journalForm" class="journal-form">
<label class="form-field journal-summary-field"><span>今日盘面一句话</span><input id="journalSummary" maxlength="500" placeholder="用一句话概括今天最重要的市场特征"></label>
<label class="form-field"><span>今日做对了什么 / 做错了什么</span><textarea id="journalContent" maxlength="5000" placeholder="对照计划与纪律:&#10;· 做对的:&#10;· 做错的:&#10;· 漏掉的信号:"></textarea></label>
<label class="form-field"><span>明日策略</span><textarea id="journalPlan" maxlength="2000" placeholder="记录观察方向、触发条件、计划仓位和风险点"></textarea></label>
<p class="journal-form-hint">不求面面俱到,只记录影响下一次决策的事实。</p>
<div class="dialog-actions"><button class="button primary" type="submit"><i data-lucide="check"></i><span>完成复盘</span></button></div>
</form>
</section>
<section id="reviewHistoryPanel" class="workspace-section notes-history-section card" hidden>
<div class="workspace-heading card-h review-card-heading"><div><h3>最近复盘</h3><span id="notesCount" class="review-count-tag">0 条</span></div><small>按交易日倒序</small></div>
<div id="notesHistory" class="notes-history"><div class="empty-state">暂无复盘记录</div></div>
</section>
</div>
</section>
+51 -4
View File
@@ -1,8 +1,12 @@
let watchlistSearchTimer = null;
let assistantRenderFrame = 0;
window.XiaobaiPageModules.register("review", ["reviewWorkspaceView"], {
bind: bindReviewEvents,
enter: ["loadReview"],
});
/* PRESERVATION-SOURCE-BEGIN app.js:3029-3470 */
async function loadReviewWorkspace() {
try {
const [watchlistPayload, notesPayload, tradesPayload] = await Promise.all([
@@ -445,8 +449,6 @@ async function deleteNote(noteId, compact) {
}
}
/* PRESERVATION-SOURCE-END app.js:3029-3470 */
/* PRESERVATION-SOURCE-BEGIN app.js:7720-7968 */
async function loadAlerts(openDialog = false) {
try {
const query = new URLSearchParams({ status: state.alertFilter, as_of: todayString() });
@@ -696,4 +698,49 @@ function updateAssistantControls() {
});
}
/* PRESERVATION-SOURCE-END app.js:7720-7968 */
function bindReviewEvents() {
document.querySelector("#alertButton").addEventListener("click", openAlerts);
document.querySelector("#assistantButton").addEventListener("click", openReviewAssistant);
document.querySelector("#closeAssistantDialog").addEventListener("click", () => elements.assistantDialog.close());
document.querySelector("#assistantForm").addEventListener("submit", sendAssistantQuestion);
document.querySelector("#stopAssistant").addEventListener("click", stopAssistantResponse);
document.querySelector("#clearAssistantMessages").addEventListener("click", clearAssistantConversation);
document.querySelectorAll("[data-assistant-prompt]").forEach((button) => {
button.addEventListener("click", () => useAssistantPrompt(button.dataset.assistantPrompt));
});
document.querySelector("#closeAlertsDialog").addEventListener("click", () => elements.alertsDialog.close());
document.querySelector("#alertForm").addEventListener("submit", saveAlert);
document.querySelector("#markAllAlertsRead").addEventListener("click", markAllAlertsRead);
document.querySelector("#alertList").addEventListener("click", handleAlertAction);
document.querySelectorAll("[data-alert-filter]").forEach((button) => {
button.addEventListener("click", () => selectAlertFilter(button.dataset.alertFilter));
});
document.querySelector("#journalForm").addEventListener("submit", saveJournal);
document.querySelector("#journalDate").addEventListener("change", populateJournalForm);
document.querySelector("#openWatchlistDialog").addEventListener("click", () => openWatchlistDialog());
document.querySelector("#closeWatchlistDialog").addEventListener("click", closeWatchlistDialog);
document.querySelector("#cancelWatchlistEdit").addEventListener("click", closeWatchlistDialog);
document.querySelector("#changeWatchlistSelection").addEventListener("click", clearWatchlistSelection);
document.querySelector("#watchlistSearchInput").addEventListener("input", scheduleWatchlistSearch);
document.querySelector("#watchlistForm").addEventListener("submit", saveWatchlistFromDialog);
document.querySelector("#watchlistSearchResults").addEventListener("click", handleWatchlistSearchResult);
document.querySelector("#reviewHistoryToggle").addEventListener("click", (event) => {
const panel = document.querySelector("#reviewHistoryPanel");
const expanded = event.currentTarget.getAttribute("aria-expanded") === "true";
event.currentTarget.setAttribute("aria-expanded", String(!expanded));
event.currentTarget.querySelector("span").textContent = expanded ? "历史复盘" : "收起历史";
panel.hidden = expanded;
if (!expanded) panel.scrollIntoView({ behavior: "smooth", block: "nearest" });
});
document.querySelector("#openTradeLogDialog").addEventListener("click", openTradeLogDialog);
document.querySelector("#closeTradeLogDialog").addEventListener("click", closeTradeLogDialog);
document.querySelector("#tradeLogForm").addEventListener("submit", saveTradeLog);
document.querySelector("#cancelTradeEdit").addEventListener("click", closeTradeLogDialog);
elements.tradeLogDialog.addEventListener("close", resetTradeLogForm);
document.querySelector("#tradeLogTableBody").addEventListener("click", handleTradeLogAction);
document.querySelector("#stockNoteForm").addEventListener("submit", saveStockNote);
document.querySelector("#watchStockButton").addEventListener("click", toggleActiveWatchlist);
document.querySelector("#stockHeavenButton").addEventListener("click", openActiveStockInHeaven);
document.querySelector("#stockReminderButton").addEventListener("click", openStockReminder);
}
File diff suppressed because it is too large Load Diff
+54
View File
@@ -0,0 +1,54 @@
<section id="rotationView" class="workspace-view page redesigned-rotation-view">
<header class="rotation-page-head lad-head">
<div>
<h2>板块轮动</h2>
<p id="rotationHistoryRange">最近 9 个交易日 · 由远到近,右侧为最新交易日</p>
</div>
<div class="rotation-head-actions">
<div id="rotationOrderControl" class="rotation-order-control" role="group" aria-label="轮动日期排序">
<button class="active" type="button" data-rotation-order="oldest">由远到近</button>
<button type="button" data-rotation-order="latest">由近到远</button>
</div>
<button id="rotationExportButton" class="rotation-export-button" type="button">导出 CSV</button>
</div>
</header>
<section class="rotation-trajectory-card card" aria-labelledby="rotationTrajectoryTitle">
<header class="rotation-card-head card-h">
<div>
<h3 id="rotationTrajectoryTitle">热点轨迹</h3>
<span id="rotationSelectionHint">点击任意板块追踪其连续性</span>
</div>
<span class="rotation-top-tag">每日 Top 12 热点</span>
</header>
<div class="rotation-legend" aria-label="板块强度图例">
<span><i class="rotation-swatch strong"></i>强度高(90+</span>
<span><i class="rotation-swatch warm"></i>强度中(7089</span>
<span><i class="rotation-swatch mild"></i>强度低(&lt;70</span>
</div>
<div id="rotationTracker" class="rotation-tracker" hidden></div>
<div id="rotationHistory" class="rotation-history"><div class="empty-state">正在读取轮动历史</div></div>
</section>
<section class="rotation-detail-card card" aria-labelledby="rotationDetailTitle">
<header class="rotation-card-head card-h">
<div>
<h3 id="rotationDetailTitle">板块成分股</h3>
<span>点击上方板块,查看目标交易日有效申万成分与行情</span>
</div>
<span id="rotationDetailMeta" class="rotation-top-tag">--</span>
</header>
<div class="rotation-table-frame tbl-wrap">
<table id="rotationTable" class="data-table tbl rotation-table">
<thead><tr>
<th class="number num">序号</th><th>代码</th><th>股票</th>
<th class="number num" data-auto-sort="true" title="涨跌幅:点击排序">涨跌幅(%</th>
<th class="number num">开盘价(元)</th><th class="number num">收盘价(元)</th>
<th class="number num" data-auto-sort="true" title="成交额:点击排序">成交额(亿)</th><th>行情状态</th>
</tr></thead>
<tbody id="rotationTableBody"></tbody>
</table>
<div id="rotationMembersEmpty" class="empty-state">点击上方任意板块查看成分股</div>
</div>
</section>
</section>
+12 -2
View File
@@ -1,8 +1,8 @@
window.XiaobaiPageModules.register("rotation", ["rotationView"], {
bind: bindRotationEvents,
enter: ["loadRotation"],
});
/* PRESERVATION-SOURCE-BEGIN app.js:1958-2124 */
async function loadRotationHistory(force = false) {
if (!state.dashboard || state.rotationLoading) return;
const key = `${elements.tradeDate.value}:9`;
@@ -170,4 +170,14 @@ function renderRotationMembers() {
bindStockRows(body);
}
/* PRESERVATION-SOURCE-END app.js:1958-2124 */
function bindRotationEvents() {
document.querySelector("#rotationExportButton").addEventListener("click", exportRotation);
document.querySelectorAll("[data-rotation-order]").forEach((button) => {
button.addEventListener("click", () => {
state.rotationOrder = button.dataset.rotationOrder === "latest" ? "latest" : "oldest";
localStorage.setItem("xiaobaiRotationOrder", state.rotationOrder);
renderRotationHistory();
});
});
}
+16
View File
@@ -2,6 +2,7 @@
"use strict";
const definitions = new Map();
const featureBindings = new Map();
let sealed = false;
function register(feature, viewIds, lifecycle = {}) {
@@ -9,6 +10,13 @@
if (!feature || !Array.isArray(viewIds) || !viewIds.length) {
throw new Error("Page modules require a feature and at least one view ID");
}
if (typeof lifecycle.bind === "function") {
const existing = featureBindings.get(feature);
if (existing && existing !== lifecycle.bind) {
throw new Error(`Duplicate feature binding owner: ${feature}`);
}
featureBindings.set(feature, lifecycle.bind);
}
viewIds.forEach((viewId) => {
if (definitions.has(viewId)) throw new Error(`Duplicate page module: ${viewId}`);
definitions.set(viewId, Object.freeze({
@@ -26,6 +34,13 @@
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(", ")}`);
let eventsBound = false;
function bind() {
if (eventsBound) return;
eventsBound = true;
featureBindings.forEach((handler) => handler());
}
function run(actionNames, context) {
actionNames.forEach((actionName) => {
@@ -51,6 +66,7 @@
return Object.freeze({
afterMount,
beforeMount,
bind,
get: (viewId) => definitions.get(viewId) || null,
has: (viewId) => definitions.has(viewId),
});
File diff suppressed because it is too large Load Diff
+219
View File
@@ -0,0 +1,219 @@
<section id="screenerView" class="workspace-view page member-feature-view">
<div class="member-gate" hidden><div class="member-gate-icon"><i data-lucide="lock-keyhole"></i></div><div><strong>智能选股仅对会员开放</strong><span>开通会员后可同步因子、生成公式和执行策略。会员状态可从顶部账号标识进入。</span></div></div>
<div class="screener-page-bar scr-head">
<div class="section-toolbar screener-page-heading">
<div class="section-title-group">
<h2>智能选股</h2>
<span id="screenerDateLabel" class="section-subtitle">--</span>
</div>
</div>
<div class="screener-mode-tabs method" role="tablist" aria-label="智能选股模式">
<button class="active" type="button" role="tab" aria-selected="true" data-screener-mode="smart">阶段选股</button>
<button type="button" role="tab" aria-selected="false" data-screener-mode="curated">策略选股</button>
<button type="button" role="tab" aria-selected="false" data-screener-mode="quant">自定义选股</button>
</div>
<button id="openScreenerTrackingButton" class="button screener-tracking-entry" type="button"><i data-lucide="chart-no-axes-combined"></i>策略跟踪</button>
</div>
<div id="screenerNotice" class="inline-notice" hidden></div>
<div class="screener-mobile-tabs" role="tablist" aria-label="智能选股视图">
<button class="active" type="button" role="tab" aria-selected="true" data-screener-mobile-view="strategy">策略</button>
<button type="button" role="tab" aria-selected="false" data-screener-mobile-view="results">候选</button>
</div>
<div class="screener-strategy-view" data-screener-panel="smart">
<div id="screenerProgress" class="screener-stepper stepper" aria-label="智能选股执行进度" aria-live="polite">
<div class="screener-step step" data-screener-step="regime"><span class="step-marker no">1</span><div><strong class="tt">阶段识别</strong><small class="ds" id="regimeStepStatus">等待识别</small></div></div>
<span class="step-line ln" aria-hidden="true"></span>
<div class="screener-step step" data-screener-step="strategy"><span class="step-marker no">2</span><div><strong class="tt">策略匹配</strong><small class="ds" id="strategyStepStatus">等待匹配</small></div></div>
<span class="step-line ln" aria-hidden="true"></span>
<div class="screener-step step" data-screener-step="run"><span class="step-marker no">3</span><div><strong class="tt">执行选股</strong><small class="ds" id="screenerRunStatus">等待执行</small></div></div>
<span class="step-line ln" aria-hidden="true"></span>
<div class="screener-step step" data-screener-step="result"><span class="step-marker no">4</span><div><strong class="tt">结果与回测</strong><small class="ds" id="backtestTaskStatus">随选股执行</small></div></div>
</div>
<div class="screener-overview-grid ps-grid">
<section class="screener-overview-card screener-regime-card card">
<div class="screener-card-heading card-h"><h3><span>01</span> 当前阶段</h3><span class="screener-soft-label dtag">自动识别已开启</span></div>
<div class="screener-regime-body">
<div class="regime-summary"><strong id="regimeLabel">--</strong><span id="regimeConfidence">置信度 --</span></div>
<div class="regime-reading">
<div class="regime-market-line">
<div id="regimeEvidenceList" class="regime-evidence-line"></div>
</div>
<div id="regimeReason" class="regime-advice">--</div>
<div class="regime-control-line">
<div id="regimeSelector" class="regime-selector" aria-label="系统识别的市场阶段"></div>
<div class="factor-data-status"><span>盘后行情定格后自动更新</span><strong id="factorDateCount">0 日</strong><small id="factorDateRange">等待后台数据</small></div>
</div>
</div>
</div>
</section>
<section class="screener-overview-card screener-strategy-card card">
<div class="screener-card-heading card-h"><h3><span>02</span> 匹配策略</h3><span class="screener-soft-label dtag">自然语言转受控公式</span></div>
<div class="screener-strategy-summary">
<div class="screener-strategy-title"><strong id="activeStrategyHeading">--</strong><span id="activeStrategyRegimes"></span></div>
<p id="activeStrategyDescription">等待匹配当前市场阶段的策略。</p>
<div class="screener-strategy-actions"><span class="screener-auto-note">由系统按当前阶段自动匹配</span></div>
</div>
</section>
</div>
<div class="screener-runbar runbar">
<div class="screener-run-actions">
<button id="screenerExportButton" class="button" type="button">导出 CSV</button>
</div>
<div class="screener-pipeline-status" aria-live="polite">
<span>因子 <strong id="factorTaskStatus">等待检查</strong></span>
<span>编译 <strong id="compilerStatus">本地模板编译</strong></span>
</div>
</div>
<div data-screener-results-slot="smart"></div>
</div>
<div class="curated-screener-panel" data-screener-panel="curated" hidden>
<div class="curated-workspace">
<aside class="curated-library-pane" aria-label="精选策略库">
<div class="curated-library-heading">
<div><span>策略库</span><h3>盘后自动候选池</h3></div><strong id="curatedStrategyCount">29 套</strong>
</div>
<div class="curated-library-controls">
<label class="curated-search"><i data-lucide="search"></i><span class="visually-hidden">搜索策略</span><input id="curatedStrategySearch" type="search" placeholder="搜索策略" autocomplete="off"></label>
<label class="curated-category-select"><span class="visually-hidden">策略分类</span><select id="curatedCategoryFilter" aria-label="策略分类"></select><i data-lucide="chevron-down"></i></label>
<div class="curated-view-toggle" role="group" aria-label="策略排列方式">
<button class="active" type="button" data-curated-view="list" aria-label="列表排列" title="列表排列" aria-pressed="true"><i data-lucide="list"></i></button>
<button type="button" data-curated-view="grid" aria-label="图标排列" title="图标排列" aria-pressed="false"><i data-lucide="layout-grid"></i></button>
</div>
</div>
<div id="curatedSchoolFilters" class="curated-school-filters" aria-label="策略流派"></div>
<div id="curatedStrategyList" class="curated-strategy-list"></div>
</aside>
<section class="curated-detail-pane" aria-labelledby="curatedStrategyName">
<header class="curated-detail-header">
<div><span id="curatedStrategyCategory">精选策略</span><h3 id="curatedStrategyName">选择一套策略</h3><p id="curatedStrategyDescription">查看策略条件、数据状态和适用环境。</p></div>
<div id="curatedStrategyBadges" class="curated-strategy-badges"></div>
</header>
<div class="curated-environment-notes">
<p><strong>适用环境</strong><span id="curatedSuitableEnvironment"></span></p>
<p><strong>失效风险</strong><span id="curatedFailureRisk"></span></p>
</div>
<div class="curated-health-grid" id="curatedHealthMetrics" aria-label="策略运行状态"></div>
<div class="curated-detail-grid">
<section class="curated-condition-section"><div class="mini-section-heading"><h4>准入条件</h4><span id="curatedFilterCount">0 项</span></div><div id="curatedFilterList" class="curated-rule-list"></div></section>
<section class="curated-condition-section"><div class="mini-section-heading"><h4>评分权重</h4><span id="curatedWeightTotal">100%</span></div><div id="curatedScoreList" class="curated-score-list"></div></section>
</div>
<div class="curated-execution-bar">
<div id="curatedDataStatus" class="curated-data-status"><i data-lucide="database"></i><span><strong>检查数据中</strong><small>同步后显示可用状态</small></span></div>
<span class="screener-auto-note">候选池由后台盘后自动更新</span>
</div>
</section>
</div>
<div data-screener-results-slot="curated"></div>
</div>
<div class="quant-screener-panel" data-screener-panel="quant" hidden>
<section class="custom-screener-tools card">
<div><span>自定义能力</span><strong>用自然语言生成公式,或直接编辑受控公式</strong><small>自定义选股仅在点击执行后计算,不影响系统盘后候选池。</small></div>
<div>
<button id="openStrategyDrawerButton" class="button" type="button"><i data-lucide="sparkles"></i>自然语言生成公式</button>
<button id="quantSaveButton" class="button" type="button"><i data-lucide="braces"></i>编辑 / 保存公式</button>
</div>
</section>
<section class="quant-builder-pane">
<header class="quant-panel-heading"><h3>因子与权重</h3><div><button id="addQuantScoreButton" class="button" type="button"><i data-lucide="plus"></i>添加因子</button><button id="quantResetButton" class="button ghost" type="button"><i data-lucide="rotate-ccw"></i>重置</button></div></header>
<div id="quantScoreRows" class="quant-rule-rows"></div>
<div class="quant-weight-status"><span>权重合计</span><div><i id="quantWeightBar"></i></div><strong id="quantWeightTotal">100%</strong></div>
</section>
<div class="quant-right-stack">
<aside class="quant-summary-pane">
<header><h3>过滤条件</h3><button id="addQuantFilterButton" class="button ghost" type="button"><i data-lucide="plus"></i>添加条件</button></header>
<div class="quant-filter-body">
<div class="quant-universe-grid">
<strong>选股范围</strong>
<label class="form-field"><span>上市不少于</span><input id="quantListedDays" type="number" min="0" max="5000" step="30" value="120"><small></small></label>
<label class="form-field"><span>输出</span><input id="quantLimit" type="number" min="1" max="50" value="15"><small></small></label>
<label class="form-field"><span>综合分不低于</span><input id="quantMinScore" type="number" min="0" max="100" step="1" value="50"></label>
<label class="checkbox-control quant-st-toggle"><input id="quantExcludeSt" type="checkbox" checked>剔除 ST / 退市</label>
</div>
<div id="quantFilterRows" class="quant-rule-rows"></div>
<div class="quant-execution-actions">
<button id="quantRunButton" class="button primary" type="button"><i data-lucide="play"></i>执行自定义选股</button>
<label class="checkbox-control"><input id="quantBacktestToggle" type="checkbox" checked>滚动回测</label>
<span>结果按加权总分排序,并生成逐股贡献解释</span>
</div>
<p id="quantValidationMessage" class="quant-validation-message" role="status" aria-live="polite"></p>
</div>
</aside>
</div>
<div class="custom-results-slot" data-screener-results-slot="quant"></div>
</div>
<div class="screener-results-view">
<section id="backtestPanel" class="backtest-panel screener-backtest-strip" hidden>
<i data-lucide="triangle-alert" aria-hidden="true"></i>
<div id="backtestMetrics" class="dragon-summary"></div>
<p id="backtestDefinition">--</p>
</section>
<div class="section-toolbar result-toolbar">
<div class="section-title-group"><h2 id="screenerResultTitle">候选结果</h2><span id="screenerResultCount" class="count-badge">0 只</span><span id="screenerResultSource" class="screener-result-source" hidden></span></div>
<span id="screenerDisclaimer" class="section-subtitle">历史统计不代表未来收益</span>
</div>
<div class="table-frame card tbl-wrap screener-result-frame">
<table class="data-table tbl">
<colgroup class="screener-result-columns"><col><col><col><col><col><col><col><col><col><col><col><col></colgroup>
<thead><tr>
<th class="num">排名</th><th>股票</th><th>板块</th><th class="number num sortable">综合分<span class="arr"></span></th>
<th class="number num sortable">历史估计(%<span class="arr"></span></th><th class="number num sortable">当日涨幅(%<span class="arr"></span></th><th class="number num sortable">5日涨幅(%<span class="arr"></span></th>
<th class="number num sortable">量比<span class="arr"></span></th><th class="number num sortable">板块强度<span class="arr"></span></th><th>主要贡献</th><th>风险</th><th>操作</th>
</tr></thead>
<tbody id="screenerTableBody"></tbody>
</table>
<div id="screenerEmpty" class="empty-state">尚未执行选股</div>
</div>
</div>
<dialog id="strategyDrawer" class="strategy-drawer" aria-labelledby="strategyDrawerTitle">
<div class="strategy-drawer-header"><div><span>自定义选股</span><h2 id="strategyDrawerTitle">自然语言与受控公式</h2></div><button id="closeStrategyDrawerButton" class="icon-button" type="button" aria-label="关闭策略编辑"><i data-lucide="x"></i></button></div>
<div class="strategy-drawer-body">
<aside class="strategy-sidebar">
<div class="workspace-heading card-h"><h3>策略库</h3><span id="strategyCount">0 套</span></div>
<div id="strategyList" class="strategy-list"></div>
</aside>
<div class="strategy-workbench">
<div class="strategy-workbench-heading"><div><span>当前策略</span><h3 id="activeStrategyEditorHeading">--</h3></div><small>自然语言生成受控公式</small></div>
<div class="strategy-meta-fields">
<label class="form-field"><span>策略名称</span><input id="strategyNameInput" type="text" maxlength="60"></label>
<label class="form-field"><span>策略说明</span><input id="strategyDescriptionInput" type="text" maxlength="1000"></label>
</div>
<label class="form-field strategy-prompt-field"><span>自然语言策略</span><textarea id="strategyPrompt" maxlength="3000" placeholder="例如:修复阶段筛选低吸、放量、有主力资金流入的主线前排"></textarea></label>
<div class="strategy-actions">
<label class="checkbox-control"><input id="runBacktestToggle" type="checkbox" checked>滚动回测</label>
<button id="compileStrategyButton" class="button" type="button">生成公式</button>
<button id="saveStrategyButton" class="button primary" type="button">保存策略</button>
<button id="deleteStrategyButton" class="button danger-button" type="button" hidden>删除策略</button>
</div>
<details class="strategy-advanced">
<summary><div><strong>高级公式</strong><span>查看或手动调整受控 DSL</span></div><i data-lucide="chevron-down" aria-hidden="true"></i></summary>
<label class="form-field formula-field"><span>受控公式 DSL</span><textarea id="formulaEditor" spellcheck="false"></textarea></label>
</details>
</div>
</div>
</dialog>
</section>
<section id="screenerTrackingView" class="workspace-view page member-feature-view screener-tracking-view" data-internal-view>
<div class="member-gate" hidden><div class="member-gate-icon"><i data-lucide="lock-keyhole"></i></div><div><strong>策略跟踪仅对会员开放</strong><span>开通会员后可将选股候选加入持续跟踪。</span></div></div>
<header class="tracking-page-header">
<button id="closeScreenerTrackingButton" class="tracking-back-button" type="button" data-member-navigation><i data-lucide="arrow-left"></i>返回智能选股</button>
<div><h2 id="strategyTrackingTitle">策略持续跟踪</h2><p>仅跟踪手动加入的候选,以入选价观察后续五个交易日。</p></div>
<button id="refreshTrackingButton" class="button" type="button"><i data-lucide="refresh-cw"></i>更新跟踪</button>
</header>
<div class="tracking-overview-card">
<div class="tracking-overview-title"><span>跟踪概览</span><strong id="trackingBatchCount">0 批</strong></div>
<div id="trackingSummary" class="tracking-summary"></div>
</div>
<section class="strategy-tracking-panel" aria-labelledby="strategyTrackingTitle">
<div class="section-toolbar result-toolbar">
<div class="section-title-group"><h2>跟踪明细</h2><span class="count-badge">T+5</span></div>
<span class="section-subtitle">收益均以加入跟踪时的入选价为基准</span>
</div>
<div class="table-frame tracking-table-frame">
<table class="data-table tbl tracking-table">
<thead><tr><th>入选日</th><th>策略</th><th>股票</th><th class="number num">入选价(元)</th><th class="number num">T+1 开(%</th><th class="number num">T+1 收(%</th><th class="number num">T+3%</th><th class="number num">T+5%</th><th class="number num">最大涨幅(%</th><th class="number num">最大回撤(%</th><th>状态</th><th>操作</th></tr></thead>
<tbody id="trackingTableBody"></tbody>
</table>
<div id="trackingEmpty" class="empty-state">尚未加入跟踪,请从智能选股候选结果中手动添加。</div>
</div>
</section>
</section>
+72 -4
View File
@@ -1,10 +1,10 @@
window.XiaobaiPageModules.register("screener", ["screenerView"], {
bind: bindScreenerEvents,
enter: ["loadScreener"],
});
window.XiaobaiPageModules.register("screener", ["screenerTrackingView"]);
/* PRESERVATION-SOURCE-BEGIN app.js:3490-4336 */
function screenerStrategyKey(strategyId, strategyName) {
return strategyId != null && strategyId !== 0 ? `id:${strategyId}` : `name:${strategyName || ""}`;
}
@@ -841,8 +841,6 @@ async function executeScreenerFormula({ mode, formula, strategyName, strategyId
}
}
/* PRESERVATION-SOURCE-END app.js:3490-4336 */
/* PRESERVATION-SOURCE-BEGIN app.js:6668-6892 */
function renderScreenerResult() {
const mode = state.screenerMode || "smart";
const result = activeScreenerResult(mode);
@@ -1068,4 +1066,74 @@ function regimeLabel(regime) {
return state.screenerSetup?.regimes?.find((item) => item.id === regime)?.label || regime;
}
/* PRESERVATION-SOURCE-END app.js:6668-6892 */
function bindScreenerEvents() {
document.querySelector("#openStrategyDrawerButton").addEventListener("click", openCustomStrategyDrawer);
document.querySelector("#closeStrategyDrawerButton").addEventListener("click", () => document.querySelector("#strategyDrawer").close());
document.querySelector("#strategyDrawer").addEventListener("click", (event) => {
if (event.target === event.currentTarget) event.currentTarget.close();
});
document.querySelector("#openScreenerTrackingButton").addEventListener("click", async () => {
await loadScreenerTracking(true);
openView("screenerTrackingView");
});
document.querySelector("#closeScreenerTrackingButton").addEventListener("click", () => openView("screenerView"));
document.querySelector("#refreshTrackingButton").addEventListener("click", refreshScreenerTracking);
document.querySelector("#trackingTableBody").addEventListener("click", handleTrackingTableAction);
document.querySelectorAll("[data-screener-mobile-view]").forEach((button) => {
button.addEventListener("click", () => selectScreenerMobileView(button.dataset.screenerMobileView));
});
document.querySelector("#compileStrategyButton").addEventListener("click", compileStrategy);
document.querySelector("#saveStrategyButton").addEventListener("click", saveCurrentStrategy);
document.querySelector("#deleteStrategyButton").addEventListener("click", deleteCurrentStrategy);
document.querySelector("#screenerExportButton").addEventListener("click", exportScreenerResults);
document.querySelector("#runBacktestToggle").addEventListener("change", updateBacktestTaskStatus);
document.querySelectorAll("[data-screener-mode]").forEach((button) => {
button.addEventListener("click", () => selectScreenerMode(button.dataset.screenerMode));
});
document.querySelector("#curatedStrategyList").addEventListener("click", (event) => {
if (event.target.closest("button")) return;
const card = event.target.closest("[data-curated-strategy]");
if (!card) return;
state.selectedCuratedStrategyId = number(card.dataset.curatedStrategy);
renderCuratedStrategyLibrary();
renderScreenerResult();
});
document.querySelector("#curatedStrategySearch").addEventListener("input", (event) => {
state.curatedQuery = event.target.value.trim().toLocaleLowerCase("zh-CN");
renderCuratedStrategyLibrary();
});
document.querySelector("#curatedCategoryFilter").addEventListener("change", (event) => {
state.curatedCategory = event.target.value;
renderCuratedStrategyLibrary();
});
document.querySelector("#curatedSchoolFilters").addEventListener("click", (event) => {
const button = event.target.closest("[data-curated-school]");
if (!button) return;
state.curatedSchool = button.dataset.curatedSchool;
renderCuratedStrategyLibrary();
});
document.querySelectorAll("[data-curated-view]").forEach((button) => {
button.addEventListener("click", () => {
state.curatedViewMode = button.dataset.curatedView === "grid" ? "grid" : "list";
localStorage.setItem("xiaobaiCuratedViewMode", state.curatedViewMode);
renderCuratedStrategyLibrary();
});
});
document.querySelector("#quantResetButton").addEventListener("click", resetQuantBuilder);
document.querySelector("#addQuantFilterButton").addEventListener("click", () => addQuantFilter());
document.querySelector("#addQuantScoreButton").addEventListener("click", () => addQuantScore());
document.querySelector("#quantFilterRows").addEventListener("input", handleQuantBuilderInput);
document.querySelector("#quantFilterRows").addEventListener("change", handleQuantBuilderInput);
document.querySelector("#quantFilterRows").addEventListener("click", handleQuantBuilderClick);
document.querySelector("#quantScoreRows").addEventListener("input", handleQuantBuilderInput);
document.querySelector("#quantScoreRows").addEventListener("change", handleQuantBuilderInput);
document.querySelector("#quantScoreRows").addEventListener("click", handleQuantBuilderClick);
["quantListedDays", "quantLimit", "quantMinScore", "quantExcludeSt"].forEach((id) => {
document.querySelector(`#${id}`).addEventListener("input", renderQuantSummary);
document.querySelector(`#${id}`).addEventListener("change", renderQuantSummary);
});
document.querySelector("#quantRunButton").addEventListener("click", runQuantStrategy);
document.querySelector("#quantSaveButton").addEventListener("click", saveQuantAsStrategy);
document.querySelector("#quantBacktestToggle").addEventListener("change", updateBacktestTaskStatus);
}
File diff suppressed because it is too large Load Diff
+81
View File
@@ -0,0 +1,81 @@
<section id="sentimentCycleView" class="workspace-view page active-view redesigned-sentiment-view">
<div class="section-toolbar lad-head sentiment-cycle-toolbar redesigned-page-head">
<div class="section-title-group">
<h2>情绪周期</h2>
<span class="section-subtitle">用温度与阶段读懂市场情绪 · <b id="sentimentHistoryDateRange">--</b></span>
</div>
<div class="toolbar-controls">
<div class="segmented sentiment-range-selector" role="group" aria-label="统计交易日范围">
<button class="segment sentiment-range-button" type="button" data-sentiment-range="10">10日</button>
<button class="segment sentiment-range-button active" type="button" data-sentiment-range="20">20日</button>
<button class="segment sentiment-range-button" type="button" data-sentiment-range="60">60日</button>
</div>
<button id="sentimentExportButton" class="button" type="button">导出 CSV</button>
</div>
</div>
<div id="sentimentHistoryNotice" class="inline-notice" hidden></div>
<div class="sentiment-cycle-analysis emo-grid redesigned-emotion-grid">
<div class="sentiment-analysis-main">
<section class="sentiment-trend-panel card redesigned-card">
<div class="workspace-heading card-h redesigned-card-head"><h3>温度走势</h3><span id="sentimentPeriodNote">连续交易日情绪温度与阶段转折</span><span id="sentimentCurrentTag" class="sentiment-current-tag">--</span></div>
<div class="sentiment-chart-legend" aria-hidden="true"><span class="temperature-line">情绪温度</span><span class="retreat-point">退潮 / 冰点</span><span class="repair-point">修复转折</span><span class="temperature-average">五日均线</span></div>
<div class="sentiment-chart-shell">
<canvas id="sentimentTrendChart" aria-label="市场情绪温度走势图"></canvas>
<div id="sentimentChartTooltip" class="sentiment-chart-tooltip" hidden></div>
</div>
</section>
</div>
<aside class="sentiment-analysis-rail">
<section class="sentiment-cycle-summary card redesigned-card" aria-label="最新情绪状态">
<div class="workspace-heading card-h redesigned-card-head"><h3>当前阶段</h3><span class="sentiment-auto-tag">自动识别</span></div>
<div class="sentiment-phase-block">
<div id="sentimentCycleScoreMarker" class="sentiment-current-phase-badge">
<strong id="sentimentCyclePhase">--</strong>
<span id="sentimentPhaseConfidence">置信度 --</span>
</div>
<div class="sentiment-phase-info">
<p>温度 <b id="sentimentCycleScore">--</b>,较前日 <b id="sentimentDayChange">--</b> · <span id="sentimentCycleDirection">--</span> · 封板率 <b id="sentimentSealRate">--</b> · 涨停 <b id="sentimentLimitUp">--</b> / 炸板 <b id="sentimentBroken">--</b></p>
<div id="sentimentPhaseAdvice" class="sentiment-phase-advice">等待市场结构完成判定。</div>
</div>
</div>
<div class="sentiment-feedback-strip">
<span>昨日涨停反馈 <strong id="sentimentPreviousPositive">--</strong><small id="sentimentPreviousAverage">--</small></span>
<span>统计样本 <strong id="sentimentHistoryDays">--</strong><small id="sentimentNormalization">--</small></span>
</div>
<span id="sentimentCycleLabel" hidden>--</span><span id="sentimentCycleDate" hidden>--</span>
</section>
<section class="sentiment-components-panel card redesigned-card">
<div class="workspace-heading card-h redesigned-card-head"><h3>评分构成</h3><span id="sentimentComponentSummary">五维加权</span></div>
<div id="sentimentComponentList" class="sentiment-component-list"></div>
</section>
</aside>
</div>
<div class="section-toolbar sentiment-detail-toolbar">
<div class="section-title-group"><h2>交易日明细</h2><span class="section-subtitle">涨停梯队与昨日反馈</span></div>
</div>
<div class="table-frame card tbl-wrap sentiment-history-frame">
<table class="data-table tbl sentiment-history-table">
<colgroup>
<col class="sentiment-col-date"><col class="sentiment-col-score"><col class="sentiment-col-phase"><col class="sentiment-col-direction">
<col class="sentiment-col-count"><col class="sentiment-col-count"><col class="sentiment-col-count"><col class="sentiment-col-count"><col class="sentiment-col-height">
<col class="sentiment-col-count"><col class="sentiment-col-count">
<col class="sentiment-col-feedback"><col class="sentiment-col-feedback"><col class="sentiment-col-rate">
</colgroup>
<thead>
<tr class="sentiment-history-groups">
<th colspan="4" class="group-state">情绪状态</th>
<th colspan="5" class="group-ladder">涨停结构</th>
<th colspan="2" class="group-risk">风险释放</th>
<th colspan="3" class="group-feedback">昨日反馈</th>
</tr>
<tr class="sentiment-history-columns">
<th>交易日</th><th class="num sortable">温度<span class="arr"></span></th><th>阶段</th><th>方向</th>
<th class="num sortable">涨停(只)<span class="arr"></span></th><th class="num sortable">首板(只)<span class="arr"></span></th><th class="num sortable">二板(只)<span class="arr"></span></th><th class="num sortable">三板+(只)<span class="arr"></span></th><th class="num sortable">高度(板)<span class="arr"></span></th>
<th class="num sortable">炸板(只)<span class="arr"></span></th><th class="num sortable">跌停(只)<span class="arr"></span></th><th class="num sortable">昨涨停(只)<span class="arr"></span></th><th class="num sortable">昨红盘(只)<span class="arr"></span></th><th class="num sortable">红盘率(%<span class="arr"></span></th>
</tr>
</thead>
<tbody id="sentimentHistoryBody"></tbody>
</table>
<div id="sentimentHistoryEmpty" class="empty-state">尚无连续交易日数据</div>
</div>
</section>
+20 -2
View File
@@ -1,8 +1,10 @@
let sentimentChartAnimationFrame = null;
window.XiaobaiPageModules.register("sentiment", ["sentimentCycleView"], {
bind: bindSentimentEvents,
enter: ["loadSentiment"],
});
/* PRESERVATION-SOURCE-BEGIN app.js:1207-1516 */
async function loadSentimentHistory(force = false) {
if (!state.dashboard || state.sentimentLoading) return;
const key = `${elements.tradeDate.value}:${state.sentimentRange}`;
@@ -313,4 +315,20 @@ function sentimentPhaseAdvice(phase) {
}[phase] || "市场结构尚未形成清晰阶段,保持观察并等待确认。";
}
/* PRESERVATION-SOURCE-END app.js:1207-1516 */
function trendClass(trend) {
return { "升温": "trend-hot", "降温": "trend-cool", "新进": "trend-new", "持平": "trend-flat" }[trend] || "trend-flat";
}
function bindSentimentEvents() {
document.querySelector("#sentimentExportButton").addEventListener("click", exportSentimentHistory);
document.querySelectorAll("[data-sentiment-range]").forEach((button) => {
button.addEventListener("click", () => {
state.sentimentRange = number(button.dataset.sentimentRange) || 20;
document.querySelectorAll("[data-sentiment-range]").forEach((item) => {
item.classList.toggle("active", item === button);
});
loadSentimentHistory(true);
});
});
}
File diff suppressed because it is too large Load Diff
+55
View File
@@ -0,0 +1,55 @@
<section id="themeLibraryView" class="workspace-view page redesigned-theme-view">
<header class="theme-page-head-v2 lad-head">
<div class="theme-title-v2">
<div><h2>题材库</h2><span>题材行情与成分股</span></div>
<span id="themeDateLabel" class="theme-date-v2">--</span>
</div>
<div class="theme-head-actions-v2">
<label class="theme-search-v2">
<i data-lucide="search" aria-hidden="true"></i>
<span class="visually-hidden">搜索题材</span>
<input id="themeSearch" type="search" placeholder="搜索题材名称或代码" autocomplete="off">
</label>
<button id="themeRefreshButton" class="button theme-refresh-v2" type="button"><i data-lucide="refresh-cw"></i><span>刷新题材</span></button>
</div>
</header>
<div id="themeSummary" class="theme-summary-v2" aria-live="polite"></div>
<div class="theme-library-workspace-v2 theme-grid">
<aside class="theme-directory-card-v2 card" aria-label="题材列表">
<header class="theme-card-head-v2 card-h">
<div><h3>题材排行</h3><span>按当日热度与行情排列</span></div>
<strong id="themeResultCount">0 个</strong>
</header>
<div class="theme-directory-labels-v2" aria-hidden="true"><span>题材</span><span>当日涨跌</span></div>
<div id="themeDirectory" class="theme-directory-v2"></div>
</aside>
<section class="theme-detail-column-v2" aria-live="polite">
<div id="themeDetailEmpty" class="empty-state theme-detail-empty-v2">选择题材查看行情与成分股</div>
<div id="themeDetailContent" class="theme-detail-stack-v2" hidden>
<section class="theme-market-card-v2 card">
<header class="theme-detail-heading-v2">
<div class="theme-detail-identity-v2">
<span class="theme-detail-kicker-v2">题材行情</span>
<div class="theme-detail-name-line-v2"><h3 id="themeDetailName">--</h3><small id="themeDetailCode">--</small></div>
</div>
<div class="theme-change-v2"><span>当日涨幅</span><strong id="themeDetailChange">--</strong></div>
</header>
<div id="themeDetailMetrics" class="theme-detail-metrics-v2"></div>
</section>
<section class="theme-members-card-v2 card">
<header class="theme-members-heading-v2">
<div><h3>成分股</h3><span>点击股票查看完整行情详情</span></div>
<strong id="themeMemberCount">0 只</strong>
</header>
<div class="theme-members-frame-v2 tbl-wrap">
<table class="data-table tbl theme-members-table-v2"><thead><tr><th class="num">序号</th><th>股票</th><th class="number num sortable">涨跌幅(%<span class="arr"></span></th><th class="number num sortable">收盘价(元)<span class="arr"></span></th><th class="number num sortable">成交额(亿)<span class="arr"></span></th></tr></thead><tbody id="themeMemberTableBody"></tbody></table>
</div>
</section>
</div>
</section>
</div>
</section>
+13 -2
View File
@@ -1,8 +1,8 @@
window.XiaobaiPageModules.register("themes", ["themeLibraryView"], {
bind: bindThemeLibraryEvents,
enter: ["loadThemes"],
});
/* PRESERVATION-SOURCE-BEGIN app.js:2482-2583 */
async function loadThemeLibrary(force = false) {
if (state.themeLoading) return;
state.themeLoading = true;
@@ -105,4 +105,15 @@ function renderThemeDetail() {
renderThemeDirectory();
}
/* PRESERVATION-SOURCE-END app.js:2482-2583 */
function bindThemeLibraryEvents() {
document.querySelector("#themeRefreshButton").addEventListener("click", () => loadThemeLibrary(true));
document.querySelector("#themeSearch").addEventListener("input", (event) => {
state.themeQuery = event.target.value.trim().toLocaleLowerCase("zh-CN");
renderThemeDirectory();
});
document.querySelector("#themeDirectory").addEventListener("click", (event) => {
const button = event.target.closest("[data-theme-code]");
if (button) selectTheme(button.dataset.themeCode);
});
}
+256
View File
@@ -0,0 +1,256 @@
async function backfillData() {
const button = document.querySelector("#backfillButton");
button.disabled = true;
setLoading(true, "正在回补历史交易日");
try {
const payload = await apiRequest("/api/backfill", "POST", {
start_date: document.querySelector("#backfillStart").value,
end_date: document.querySelector("#backfillEnd").value,
});
showToast(`历史回补完成,共处理 ${payload.results.length} 个工作日`);
await openAdminSettings(true);
} catch (error) {
showToast(error.message);
} finally {
setLoading(false);
button.disabled = false;
}
}
async function openAdminSettings(refreshOnly = false) {
if (state.user?.role !== "admin") return;
if (!refreshOnly) openModalDialog(elements.adminDialog);
const status = document.querySelector("#adminConnectionStatus");
status.textContent = "正在读取系统状态";
try {
const payload = await apiRequest("/api/admin/settings");
const data = payload.data || {};
const ifind = data.ifind || {};
const llm = payload.llm || {};
const membership = payload.membership || {};
status.textContent = `Tushare ${data.configured ? "已配置" : "未配置"} · iFinD ${ifind.configured ? "已配置" : "未配置"} · ${number(data.snapshot_dates)} 个交易日`;
status.classList.toggle("connected", Boolean(data.configured));
setText("systemDataStatus", data.background_refresh_enabled ? "后台刷新已启用" : "后台刷新已暂停");
document.querySelector("#systemTokenInput").value = "";
document.querySelector("#systemIfindTokenInput").value = "";
document.querySelector("#systemBackgroundRefresh").checked = Boolean(data.background_refresh_enabled);
document.querySelector("#memberDailyLimit").value = number(membership.member_daily_limit) || 50;
renderModelPool(llm.models || [], llm.primary_model_id || "", llm.fallback_model_id || "");
renderAdminUsers(payload.users || []);
} catch (error) {
status.textContent = error.message || "系统配置读取失败";
}
}
function selectAdminPanel(panel) {
const selected = ["market", "models", "members"].includes(panel) ? panel : "market";
document.querySelector("#adminSectionSelect").value = selected;
document.querySelectorAll("[data-admin-panel]").forEach((item) => {
item.hidden = item.dataset.adminPanel !== selected;
});
}
function renderModelPool(models, primaryId = "", fallbackId = "") {
state.adminModels = models.map((item) => ({ ...item, api_key: item.api_key || "" }));
const container = document.querySelector("#modelPoolList");
container.innerHTML = state.adminModels.map((item, index) => `
<article class="model-pool-row" data-model-id="${escapeHtml(item.id)}">
<div class="model-pool-heading"><strong>${escapeHtml(item.name || `模型 ${index + 1}`)}</strong><span>${item.configured ? "已保存密钥" : "待配置"}</span></div>
<div class="model-pool-fields">
<label class="form-field"><span>显示名称 *</span><input data-model-field="name" maxlength="50" value="${escapeHtml(item.name || "")}" required></label>
<label class="form-field"><span>API Base URL *</span><input data-model-field="base_url" type="url" value="${escapeHtml(item.base_url || "https://api.openai.com/v1")}" required></label>
<label class="form-field"><span>模型标识 *</span><input data-model-field="model" maxlength="100" value="${escapeHtml(item.model || "")}" required></label>
<label class="form-field"><span>API Key${item.configured ? "" : " *"}</span><input data-model-field="api_key" type="password" autocomplete="off" maxlength="300" placeholder="${item.configured ? "留空保留已保存的 Key" : "输入 API Key"}" ${item.configured ? "" : "required"}></label>
</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>
`).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]"))));
container.querySelectorAll("[data-model-field='name']").forEach((input) => input.addEventListener("input", updateModelRoleLabels));
refreshIcons();
}
function collectModelPool() {
const saved = new Map(state.adminModels.map((item) => [item.id, item]));
return [...document.querySelectorAll("#modelPoolList [data-model-id]")].map((row) => ({
id: row.dataset.modelId,
name: row.querySelector("[data-model-field='name']").value.trim(),
base_url: row.querySelector("[data-model-field='base_url']").value.trim(),
model: row.querySelector("[data-model-field='model']").value.trim(),
api_key: row.querySelector("[data-model-field='api_key']").value.trim(),
configured: Boolean(saved.get(row.dataset.modelId)?.configured),
}));
}
function updateModelRoleOptions(primaryId = document.querySelector("#platformPrimaryModelSelect").value, fallbackId = document.querySelector("#platformFallbackModelSelect").value) {
const models = collectModelPool();
const options = models.map((item) => `<option value="${escapeHtml(item.id)}">${escapeHtml(item.name || item.model || "未命名模型")}</option>`).join("");
const primary = document.querySelector("#platformPrimaryModelSelect");
const fallback = document.querySelector("#platformFallbackModelSelect");
primary.innerHTML = models.length ? options : '<option value="">暂无模型</option>';
fallback.innerHTML = `<option value="">不启用辅助模型</option>${options}`;
primary.value = models.some((item) => item.id === primaryId) ? primaryId : models[0]?.id || "";
fallback.value = models.some((item) => item.id === fallbackId) && fallbackId !== primary.value ? fallbackId : "";
}
function updateModelRoleLabels() {
updateModelRoleOptions();
}
function addPlatformModel() {
const models = collectModelPool();
const id = `model-${Date.now()}-${Math.floor(Math.random() * 10000)}`;
models.push({ id, name: `模型 ${models.length + 1}`, base_url: "https://api.openai.com/v1", model: "", api_key: "", configured: false });
renderModelPool(models, document.querySelector("#platformPrimaryModelSelect").value || id, document.querySelector("#platformFallbackModelSelect").value);
document.querySelector(`[data-model-id="${CSS.escape(id)}"] [data-model-field="name"]`)?.focus();
}
function deletePlatformModel(row) {
if (!row) return;
const id = row.dataset.modelId;
const primary = document.querySelector("#platformPrimaryModelSelect").value;
const fallback = document.querySelector("#platformFallbackModelSelect").value;
if (id === primary || id === fallback) {
showToast("请先为主模型或辅助模型选择其他模型,再删除当前模型");
return;
}
const models = collectModelPool().filter((item) => item.id !== id);
renderModelPool(models, primary, fallback);
}
function renderAdminUsers(users) {
const container = document.querySelector("#adminUsersList");
container.innerHTML = users.map((user) => {
const admin = user.role === "admin";
const member = Boolean(user.membership_subscribed);
const identityLabels = [admin ? "管理员" : "", member ? "会员有效" : "普通用户"].filter(Boolean).join(" · ");
const expiry = member
? (user.membership_expires_at ? `有效至 ${membershipDateDisplay(user.membership_expires_at)}` : "永久有效")
: user.membership_status === "suspended"
? "会员已停用"
: user.membership_status === "active" && user.membership_expires_at
? `已于 ${membershipDateDisplay(user.membership_expires_at)} 到期`
: "尚未开通";
return `<article class="admin-user-row" data-admin-user="${number(user.id)}">
<div class="admin-user-identity"><strong>${escapeHtml(user.username)}</strong><span>${escapeHtml(identityLabels)}</span><small>${escapeHtml(expiry)}</small></div>
<div class="admin-user-usage">今日调用 <b>${number(user.used_today)}</b></div>
<form class="membership-form">
<input type="hidden" name="user_id" value="${number(user.id)}">
<label><span>状态</span><select name="status"><option value="inactive" ${user.membership_status === "inactive" ? "selected" : ""}>未开通</option><option value="active" ${user.membership_status === "active" ? "selected" : ""}>有效</option><option value="suspended" ${user.membership_status === "suspended" ? "selected" : ""}>停用</option></select></label>
<label><span>开通 / 续期时长</span><select name="duration"><option value="">选择时长</option><option value="1_month">1个月</option><option value="3_months">3个月</option><option value="12_months">12个月</option><option value="3_years">3年</option><option value="permanent">永久</option></select></label>
<div class="membership-expiry"><span>当前到期</span><strong>${escapeHtml(expiry)}</strong></div>
<button class="button" type="submit">应用</button>
</form>
</article>`;
}).join("") || emptyStateHtml("暂无注册用户");
container.querySelectorAll(".membership-form").forEach((form) => form.addEventListener("submit", saveMembership));
}
async function saveMembership(event) {
event.preventDefault();
const form = event.currentTarget;
const data = Object.fromEntries(new FormData(form).entries());
const button = form.querySelector("button[type='submit']");
button.disabled = true;
try {
const payload = await apiRequest("/api/admin/membership", "POST", data);
renderAdminUsers(payload.users || []);
showToast("会员状态已更新");
} catch (error) {
showToast(error.message || "会员状态保存失败");
} finally {
button.disabled = false;
}
}
async function saveMarketSettings(event) {
event.preventDefault();
const button = event.currentTarget.querySelector("button[type='submit']");
button.disabled = true;
try {
await apiRequest("/api/admin/settings", "POST", {
tushare_token: document.querySelector("#systemTokenInput").value.trim(),
ifind_refresh_token: document.querySelector("#systemIfindTokenInput").value.trim(),
background_refresh_enabled: document.querySelector("#systemBackgroundRefresh").checked,
});
document.querySelector("#systemTokenInput").value = "";
document.querySelector("#systemIfindTokenInput").value = "";
showToast("行情配置已保存");
await openAdminSettings(true);
} catch (error) {
showToast(error.message || "系统配置保存失败");
} finally {
button.disabled = false;
}
}
async function saveModelPool(event) {
event.preventDefault();
const button = event.currentTarget.querySelector("button[type='submit']");
button.disabled = true;
try {
await apiRequest("/api/admin/settings", "POST", {
models: collectModelPool(),
primary_model_id: document.querySelector("#platformPrimaryModelSelect").value,
fallback_model_id: document.querySelector("#platformFallbackModelSelect").value,
});
showToast("模型池已保存");
await openAdminSettings(true);
} catch (error) {
showToast(error.message || "模型池保存失败");
} finally {
button.disabled = false;
}
}
async function saveMembershipSettings(event) {
event.preventDefault();
const button = event.currentTarget.querySelector("button[type='submit']");
button.disabled = true;
try {
await apiRequest("/api/admin/settings", "POST", {
member_daily_limit: number(document.querySelector("#memberDailyLimit").value),
});
showToast("会员调用额度已保存");
await openAdminSettings(true);
} catch (error) {
showToast(error.message || "会员调用额度保存失败");
} finally {
button.disabled = false;
}
}
async function testPlatformModel(row) {
if (!row) return;
const button = row.querySelector("[data-test-model]");
const status = row.querySelector(".model-test-status");
const profile = collectModelPool().find((item) => item.id === row.dataset.modelId) || {};
button.disabled = true;
status.textContent = "连接中";
try {
const payload = await apiRequest("/api/admin/settings/test", "POST", { model_id: row.dataset.modelId, profile });
status.textContent = `已连通 · ${number(payload.result.latency_ms)} ms`;
status.className = "model-test-status success";
} catch (error) {
status.textContent = error.message;
status.className = "model-test-status failure";
} finally {
button.disabled = false;
}
}
function bindAdminEvents() {
document.querySelector("#settingsButton").addEventListener("click", () => openAdminSettings());
document.querySelector("#closeAdminDialog").addEventListener("click", () => elements.adminDialog.close());
document.querySelector("#backfillButton").addEventListener("click", backfillData);
document.querySelector("#adminSectionSelect").addEventListener("change", (event) => selectAdminPanel(event.target.value));
document.querySelector("#systemMarketForm").addEventListener("submit", saveMarketSettings);
document.querySelector("#systemModelsForm").addEventListener("submit", saveModelPool);
document.querySelector("#membershipSettingsForm").addEventListener("submit", saveMembershipSettings);
document.querySelector("#addPlatformModel").addEventListener("click", addPlatformModel);
document.querySelector("#adminRefreshButton").addEventListener("click", startAdminRefresh);
}
+53
View File
@@ -0,0 +1,53 @@
window.XiaobaiAPI.configure({
csrfToken: () => state.csrfToken,
onUnauthorized: () => showAuthGate("登录状态已失效,请重新登录。"),
});
const applicationShell = window.XiaobaiShell.create({
state,
pages: window.XiaobaiPages,
motionEnabled,
animateRows,
refreshIcons,
tradeDate: () => displayCompactDate(
state.dashboard?.meta?.trade_date || document.querySelector("#tradeDate")?.value || "",
),
onNavigate: (viewId) => openView(viewId),
onNavigationSync: () => toggleAccountDropdown(false),
});
const 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());
},
},
});
function openModalDialog(dialog) {
applicationShell.openModalDialog(dialog);
}
File diff suppressed because it is too large Load Diff
+187
View File
@@ -0,0 +1,187 @@
/* Canonical CSS owner: base. Historical layers consolidated 2026-08-02. */
@property --score {
syntax: "<number>";
inherits: false;
initial-value: 0;
}
* {
box-sizing: border-box;
margin: 0px;
padding: 0px;
}
[hidden] {
display: none !important;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0px;
margin: -1px;
overflow: hidden;
clip: rect(0px, 0px, 0px, 0px);
white-space: nowrap;
border: 0px;
}
html {
width: 100%;
min-width: 320px;
min-height: 100%;
height: 100%;
margin: 0px;
background: var(--r2-bg);
color: var(--r2-ink);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
font-size: 13px;
}
body {
width: 100%;
min-width: 320px;
min-height: 100%;
height: 100%;
margin: 0px;
padding: 0 0 var(--statusbar-height);
overflow-x: hidden;
display: block;
background: var(--bg);
color: var(--ink);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
font-size: 13px;
letter-spacing: 0px;
}
a {
color: inherit;
text-decoration: none;
}
time {
font-variant-numeric: tabular-nums;
}
[tabindex]:focus-visible {
outline: rgba(8, 127, 174, 0.48) solid 2px;
outline-offset: 2px;
}
dialog {
margin: auto;
padding: 0px;
border: 1px solid var(--border);
border-radius: 9px;
background: var(--surface);
color: var(--text);
box-shadow: var(--shadow);
}
dialog::backdrop {
background: rgba(27, 38, 49, 0.48);
backdrop-filter: blur(2px);
}
@media (min-width: 721px) {
body {
overflow-y: hidden;
}
}
@media (max-width: 720px), (max-width: 1023px) and (max-height: 600px) {
html {
width: 100%;
min-width: var(--mobile-min-width);
}
body {
width: 100%;
min-width: var(--mobile-min-width);
padding-bottom: var(--mobile-nav-height);
}
}
::view-transition-old(root) {
animation: theme-fade-out var(--motion-medium) ease both;
}
::view-transition-new(root) {
animation: theme-fade-in var(--motion-medium) ease both;
}
:root[data-theme="dark"] dialog::backdrop {
background: var(--dialog-backdrop);
}
:root[data-theme="dark"] dialog kbd {
border-color: var(--border);
background: var(--surface-subtle);
color: var(--text-secondary);
}
@media (prefers-reduced-motion: reduce) {
*,
::after,
::before {
scroll-behavior: auto !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
::view-transition-new(root),
::view-transition-old(root) {
animation: auto ease 0s 1 normal none running none;
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+380
View File
@@ -0,0 +1,380 @@
/* Canonical CSS owner: feedback. Historical layers consolidated 2026-08-02. */
@keyframes row-enter {
0% {
opacity: 0;
transform: translateY(4px);
}
100% {
opacity: 1;
transform: translateY(0px);
}
}
.loading-message p {
color: var(--text-muted);
}
@keyframes breathe-core {
0%,
100% {
transform: scale(0.86);
}
50% {
transform: scale(1);
}
}
.empty-line {
opacity: 0.45;
}
@keyframes line-arrive {
0% {
opacity: 0;
transform: scaleX(0.45);
}
100% {
opacity: 1;
transform: scaleX(1);
}
}
.loading-overlay {
position: fixed;
inset: 0px;
z-index: 50;
display: grid;
place-items: center;
background: rgba(244, 246, 248, 0.65);
backdrop-filter: blur(2px);
animation: overlay-enter var(--motion-fast) ease both;
}
.loading-overlay[hidden] {
display: none;
}
@keyframes loading-box-enter {
0% {
opacity: 0;
transform: translateY(5px) scale(0.985);
}
100% {
opacity: 1;
transform: translateY(0px) scale(1);
}
}
.spinner {
width: 22px;
height: 22px;
border-width: 3px;
border-style: solid;
border-right-color: rgb(217, 228, 233);
border-bottom-color: rgb(217, 228, 233);
border-left-color: rgb(217, 228, 233);
border-image: none;
border-top-color: var(--blue);
border-radius: 50%;
animation: 700ms linear 0s infinite normal none running spin;
}
@keyframes spin {
100% {
transform: rotate(360deg);
}
}
#toast.toast {
position: fixed;
inset: auto 18px 48px auto;
z-index: 60;
width: max-content;
height: auto;
max-width: min(420px, -36px + 100vw);
padding: 11px 14px;
border-radius: 4px;
background: rgb(33, 49, 60);
color: rgb(255, 255, 255);
box-shadow: var(--shadow);
opacity: 1;
pointer-events: none;
transform: none;
animation: toast-enter var(--motion-medium) var(--ease-out) both;
}
#toast.toast[hidden] {
display: none;
}
@keyframes toast-enter {
0% {
opacity: 0;
transform: translateY(8px);
}
100% {
opacity: 1;
transform: translateY(0px);
}
}
@media (max-width: 720px) {
@keyframes command-menu-enter {
0% {
opacity: 0;
transform: translateY(-5px) scale(0.98);
}
100% {
opacity: 1;
transform: translateY(0px) scale(1);
}
}
#toast.toast {
inset: auto 10px 76px auto;
width: max-content;
height: auto;
max-width: calc(-20px + 100vw);
transform: none;
}
}
.loading-box {
min-width: 220px;
align-items: center;
gap: 12px;
border: 1px solid var(--line);
border-radius: 6px;
background: var(--surface);
box-shadow: var(--shadow);
animation: loading-box-enter var(--motion-medium) var(--ease-out) both;
width: min(320px, -32px + 100vw);
min-height: 92px;
display: grid;
grid-template-columns: 30px minmax(0px, 1fr);
grid-template-rows: auto auto;
justify-content: stretch;
padding: 18px 20px;
}
.loading-copy {
min-width: 0px;
}
.loading-copy strong {
display: block;
font-size: 14px;
line-height: 1.45;
}
.loading-copy small {
display: block;
margin-top: 4px;
color: var(--text-secondary);
font-size: 11px;
line-height: 1.45;
}
.loading-progress {
grid-column: 1 / -1;
height: 3px;
margin-top: 13px;
overflow: hidden;
border-radius: 2px;
background: rgb(229, 234, 238);
}
.loading-progress i {
width: 44%;
height: 100%;
display: block;
background: var(--action);
animation: 1.25s ease-in-out 0s infinite normal none running loading-progress;
}
.loading-overlay[data-context="screener"] .loading-box {
width: min(430px, -32px + 100vw);
min-height: 138px;
padding: 24px 26px;
border-color: rgb(200, 214, 228);
box-shadow: rgba(24, 34, 45, 0.18) 0px 20px 50px;
}
.loading-overlay[data-context="screener"] .spinner {
width: 28px;
height: 28px;
}
.loading-overlay[data-context="screener"] .loading-copy strong {
font-size: 16px;
}
.loading-overlay[data-context="screener"] .loading-copy small {
margin-top: 7px;
font-size: 12px;
}
@keyframes loading-progress {
0% {
transform: translateX(-110%);
}
100% {
transform: translateX(250%);
}
}
@keyframes account-menu-in {
0% {
opacity: 0;
transform: translateY(-5px) scale(0.98);
}
100% {
opacity: 1;
transform: translateY(0px) scale(1);
}
}
@keyframes assistant-caret {
50% {
opacity: 0;
}
}
@keyframes xb-view-enter {
0% {
opacity: 0.35;
transform: translateY(3px);
}
100% {
opacity: 1;
transform: none;
}
}
.empty-state {
text-align: center;
min-height: 170px;
display: grid;
place-items: center;
padding: 24px;
color: var(--text-tertiary);
font-size: 11.5px;
}
@keyframes stage18-dialog-enter {
0% {
opacity: 0;
transform: translateY(7px) scale(0.992);
}
100% {
opacity: 1;
transform: translateY(0px) scale(1);
}
}
:root[data-theme="dark"] :is(.sub, .muted, .dtag, .table-muted, small, .empty-state) {
color: var(--text-secondary);
}
@@ -0,0 +1,226 @@
/* Canonical CSS owner: navigation. Historical layers consolidated 2026-08-02. */
.segment:last-child {
border-right: 0px;
}
@media (max-width: 860px) {
.toolbar-controls {
width: 100%;
flex-wrap: wrap;
}
}
@media (max-width: 520px) {
.segmented {
width: 100%;
}
.segment {
min-width: 0px;
flex: 1 1 0%;
}
}
@media (max-width: 720px) {
.toolbar-controls {
width: 100%;
align-items: stretch;
flex-wrap: wrap;
}
.segmented {
min-height: 42px;
}
.segment {
min-height: 40px;
padding: 0px 10px;
}
}
.toolbar-controls {
display: flex;
margin-left: auto;
gap: 7px;
}
.segmented {
height: 34px;
display: flex;
overflow: hidden;
min-height: 36px;
padding: 2px;
border: 0px;
border-radius: 7px;
background: rgb(236, 239, 244);
}
.segment {
background: var(--surface);
cursor: pointer;
white-space: nowrap;
min-height: 28px;
padding: 0px 10px;
border: 0px;
border-radius: 5px;
color: var(--text-secondary);
font-size: 11.5px;
}
.segment.active {
background: var(--surface-raised);
color: var(--text-primary);
box-shadow: rgba(16, 24, 40, 0.09) 0px 1px 2px;
}
.redesigned-page-head .toolbar-controls {
margin-left: auto;
}
.redesigned-page-head .segmented {
min-height: 28px;
display: inline-flex;
gap: 2px;
padding: 2px;
border: 0px;
border-radius: 8px;
background: rgb(243, 244, 246);
}
.redesigned-page-head .segment {
min-height: 24px;
padding: 4px 12px;
border: 0px;
border-radius: 6px;
background: transparent;
color: var(--r2-sub);
font-size: 12px;
}
.redesigned-page-head .segment.active {
background: rgb(255, 255, 255);
color: var(--r2-ink);
font-weight: 600;
box-shadow: rgba(0, 0, 0, 0.08) 0px 1px 2px;
}
@media (max-width: 720px), (max-width: 1023px) and (max-height: 600px) {
.redesigned-page-head .toolbar-controls {
width: 100%;
margin-left: 0px;
justify-content: space-between;
}
}
.tabs {
display: flex;
align-items: center;
gap: 2px;
border-bottom: 1px solid var(--line-soft);
padding: 0px 14px;
}
.tabs .tab {
padding: 10px 14px;
font-size: 13px;
color: var(--sub);
border-bottom: 2px solid transparent;
margin-bottom: -1px;
font-weight: 500;
}
.tabs .tab .n {
font-size: 11px;
color: var(--faint);
margin-left: 3px;
font-weight: 400;
}
.tabs .tab.active {
color: var(--blue);
border-bottom-color: var(--blue);
font-weight: 600;
}
.tabs .tab.active .n {
color: var(--blue);
}
.seg {
display: inline-flex;
background: rgb(243, 244, 246);
border-radius: 8px;
padding: 2px;
gap: 2px;
}
@media (min-width: 521px) {
.segment {
min-width: 52px;
}
}
@media (min-width: 721px) {
.toolbar-controls {
align-items: center;
}
}
+426
View File
@@ -0,0 +1,426 @@
/* Canonical CSS owner: tables. Historical layers consolidated 2026-08-02. */
.data-table th[data-sort] {
cursor: pointer;
user-select: none;
}
.data-table th[data-sort]:hover {
background: rgb(227, 237, 242);
color: var(--blue-dark);
}
.data-table th.sort-asc::after {
content: " ↑";
color: var(--blue);
}
.data-table th.sort-desc::after {
content: " ↓";
color: var(--blue);
}
.data-table tbody tr td {
transition: background-color var(--motion-fast) ease, color var(--motion-fast) ease;
}
.data-table tbody tr.row-enter {
animation-delay: var(--row-delay, 0ms);
}
.data-table tbody tr.row-pending {
opacity: 0;
transform: translateY(4px);
}
.data-table .number {
text-align: right;
font-variant-numeric: tabular-nums;
}
.data-table .row-number {
width: 38px;
color: var(--text-muted);
text-align: center;
}
.table-action {
padding: 3px 7px;
border: 0px;
background: transparent;
color: var(--blue);
cursor: pointer;
}
.table-muted {
color: var(--text-muted);
font-size: 12px;
}
.data-table:not(#limitTable) th[data-auto-sort] {
cursor: pointer;
user-select: none;
}
.data-table:not(#limitTable) th[data-auto-sort]:hover {
background: rgb(227, 237, 242);
color: var(--action-hover);
}
.data-table th[data-auto-sort].sort-asc::after {
content: " ↑";
color: var(--action);
}
.data-table th[data-auto-sort].sort-desc::after {
content: " ↓";
color: var(--action);
}
@media (prefers-reduced-motion: reduce) {
.data-table tbody tr.row-pending {
opacity: 1;
transform: none;
}
}
@media (max-width: 860px) {
.table-frame {
max-height: 520px;
border-right: 0px;
}
}
.data-table td {
background: var(--surface);
text-align: left;
height: 42px;
padding: 8px 11px;
border-right: 0px;
border-bottom: 1px solid rgb(232, 237, 241);
line-height: 1.4;
}
.data-table th {
text-align: left;
position: sticky;
top: 0px;
z-index: 2;
padding: 8px 11px;
border-right: 0px;
border-bottom: 1px solid rgb(232, 237, 241);
line-height: 1.4;
height: 38px;
background: rgb(244, 247, 249);
color: rgb(82, 98, 115);
font-size: 11.5px;
font-weight: 700;
}
.data-table tbody tr.selected td {
background: rgb(237, 245, 255);
}
@media (max-width: 720px) {
.data-table {
font-size: 12.5px;
}
.data-table td {
height: 44px;
padding: 8px 10px;
}
.data-table th {
height: 44px;
padding: 8px 10px;
font-size: 11.5px;
}
.main-grid > .table-frame {
min-height: 460px;
border-right: 0px;
}
}
.data-table {
border-spacing: 0px;
white-space: nowrap;
color: rgb(38, 51, 64);
width: 100%;
border-collapse: collapse;
font-size: 12.5px;
}
.data-table tbody tr:last-child td {
border-bottom: 0px;
}
.data-table tbody tr {
cursor: pointer;
transition: background var(--duration-fast);
}
.data-table tbody tr:hover td {
background: rgb(248, 250, 255);
}
.data-table .stock-name {
color: var(--text-primary);
font-weight: 700;
}
.main-grid > .table-frame {
border-right: 1px solid var(--border);
border-radius: var(--card-radius);
}
.table-frame {
position: relative;
min-width: 0px;
max-height: calc(-285px + 100vh);
border: 1px solid var(--card-border);
background: var(--card-bg);
box-shadow: var(--card-shadow);
overflow: hidden;
border-radius: var(--card-radius);
}
.data-table thead th {
padding: 0px 11px;
border-bottom: 1px solid var(--border);
background: var(--surface-subtle);
color: var(--text-secondary);
font-weight: 650;
white-space: nowrap;
height: 36px;
font-size: 11.5px;
}
.data-table tbody td {
padding: 6px 11px;
border-bottom: 1px solid rgb(237, 240, 244);
color: var(--xb-gray-700);
height: 41px;
}
.main-grid .data-table thead th {
height: 34px;
padding: 6px 10px;
}
.main-grid .data-table tbody td {
height: 40px;
padding: 5px 10px;
}
:is(#limitTable, #brokenTable, #downTable, #yesterdayTable) tbody tr {
transition: background-color 150ms;
}
.tbl-wrap {
overflow: auto;
}
.tbl thead th {
position: sticky;
top: 0px;
background: rgb(248, 250, 252);
color: var(--sub);
font-weight: 600;
font-size: 12px;
text-align: left;
padding: 8px 12px;
border-bottom: 1px solid var(--line);
white-space: nowrap;
z-index: 2;
}
.tbl thead th.sortable {
cursor: pointer;
user-select: none;
}
.tbl thead th.sortable:hover {
color: var(--blue);
}
.tbl thead th .arr {
font-size: 9px;
color: var(--faint);
margin-left: 3px;
}
.tbl thead th.sorted .arr {
color: var(--blue);
}
.tbl tbody td {
padding: 9px 12px;
border-bottom: 1px solid var(--line-soft);
white-space: nowrap;
vertical-align: middle;
}
.tbl tbody tr:hover {
background: rgb(248, 250, 255);
}
.tbl.compact tbody td {
padding: 5px 12px;
}
.tbl thead th.num {
text-align: right;
}
.tbl-tools {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 14px;
border-bottom: 1px solid var(--line-soft);
flex-wrap: wrap;
}
.tbl-tools .right {
margin-left: auto;
display: flex;
align-items: center;
gap: 8px;
}
:is(#limitTable, #brokenTable, #downTable, #yesterdayTable) th {
width: auto;
}
:is(#limitTable, #brokenTable, #downTable, #yesterdayTable) th.row-number {
width: var(--col-rank);
}
:is(#limitTable, #brokenTable, #downTable, #yesterdayTable) th:nth-child(2) {
width: var(--col-stock);
}
:is(#limitTable, #brokenTable, #downTable, #yesterdayTable) th.number {
width: var(--col-number);
}
:is(#limitTable, #brokenTable, #downTable, #yesterdayTable) thead th.num {
text-align: right;
font-variant-numeric: tabular-nums;
}
:root[data-theme="dark"] :is(table, .data-table, .tbl) {
color: var(--text-primary);
}
:root[data-theme="dark"] :is(table thead th, .data-table thead th, .tbl thead th) {
border-color: var(--border);
background: var(--surface-muted);
color: var(--text-secondary);
}
:root[data-theme="dark"] :is(table tbody td, .data-table tbody td, .tbl tbody td) {
border-color: var(--line-soft);
background-color: transparent;
}
:root[data-theme="dark"] :is(table tbody tr:hover td, .data-table tbody tr:hover td, .tbl tbody tr:hover td) {
background: var(--action-soft);
}
+216
View File
@@ -0,0 +1,216 @@
const {
clamp,
displayCompactDate,
escapeHtml,
formatNumber,
formatTimestamp,
localDateString,
number,
parseLocalDate,
todayString,
} = window.XiaobaiUI;
const {
emptyStateHtml,
renderEmptyState,
} = window.XiaobaiComponents;
const state = window.XiaobaiState.create({
session: {
user: null,
csrfToken: "",
authMode: "login",
started: false,
activeView: "sentimentCycleView",
dashboardLoading: false,
dashboardRequestSequence: 0,
dashboardRequestDate: "",
adminModels: [],
globalSearchResults: [],
globalSearchActiveIndex: -1,
globalSearchRequestSequence: 0,
},
market: {
dashboard: null,
filter: "all",
query: "",
sortKey: "streak",
sortDirection: "desc",
brokenQuery: "",
brokenSortKey: "",
brokenSortDirection: "desc",
downQuery: "",
downSortKey: "",
downSortDirection: "asc",
yesterdayFilter: "all",
yesterdayQuery: "",
yesterdaySortKey: "",
yesterdaySortDirection: "desc",
dragonTiger: null,
dragonViewMode: "daily",
dragonFilter: "all",
dragonQuery: "",
selectedDragonTraderId: "",
hotMoneyProfiles: null,
hotMoneyProfileQuery: "",
selectedHotMoneyProfileId: "",
rotationHistory: null,
rotationHistoryKey: "",
rotationSelectedSector: "",
rotationSelectedDate: "",
rotationMembers: null,
rotationMembersKey: "",
rotationMembersLoading: false,
rotationOrder: localStorage.getItem("xiaobaiRotationOrder") === "latest" ? "latest" : "oldest",
rotationLoading: false,
auctionData: null,
auctionDataset: "focus",
auctionFilter: "all",
auctionQuery: "",
auctionSortKey: "attention_score",
auctionSortDirection: "desc",
auctionLoading: false,
auctionTimer: null,
themeLibrary: null,
themeQuery: "",
selectedThemeCode: "",
themeDetail: null,
themeLoading: false,
popularityData: null,
popularitySource: "combined",
popularityQuery: "",
popularityLoading: false,
expandedLadderLevels: new Set(),
ladderSortMode: "time",
sentimentHistory: null,
sentimentRange: 20,
sentimentHistoryKey: "",
sentimentLoading: false,
},
details: {
stockDetail: null,
activeStock: null,
stockDetailChartMode: "daily",
stockDetailIntraday: null,
stockDetailRequestSequence: 0,
entityDetailItem: null,
entityDetailPayload: null,
entityDetailChartMode: "daily",
entityDetailIntraday: null,
entityDetailRequestSequence: 0,
stockPreviewCode: "",
stockPreviewType: "stock",
stockPreviewItem: null,
stockPreviewPayload: null,
stockPreviewChart: "daily",
stockPreviewFallback: null,
initialStockOpened: false,
},
review: {
watchlist: [],
watchlistSelection: null,
watchlistSearchResults: [],
watchlistSearchRequestSequence: 0,
editingDailyNoteId: 0,
notes: [],
tradeEntries: [],
tradeSummary: {},
editingTradeId: 0,
alerts: [],
alertFilter: "all",
alertUnreadCount: 0,
assistantMessages: [],
assistantLoading: false,
assistantController: null,
},
screener: {
screenerSetup: null,
screenerSetupKey: "",
screenerSetupRequestKey: "",
screenerSetupPromise: null,
selectedRegime: "",
selectedStrategy: null,
customStrategyDraft: null,
screenerRunning: false,
screenerRunningMode: "",
screenerResults: { smart: null, curated: null, quant: null },
screenerResultContexts: { smart: null, curated: null, quant: null },
screenerResultStore: {},
screenerTracking: null,
screenerMode: ["smart", "curated", "quant"].includes(localStorage.getItem("xiaobaiScreenerMode"))
? localStorage.getItem("xiaobaiScreenerMode")
: "smart",
curatedCategory: "全部",
curatedSchool: "全部",
curatedQuery: "",
curatedViewMode: localStorage.getItem("xiaobaiCuratedViewMode") === "grid" ? "grid" : "list",
selectedCuratedStrategyId: 0,
quantFilters: [],
quantScores: [],
screenerMobileView: "strategy",
},
mentor: {
mentorSetup: null,
selectedMentorId: "",
mentorMessages: [],
mentorLoading: false,
mentorQuery: "",
mentorGrade: "all",
mentorDirectoryOpen: false,
mentorSortMode: false,
mentorSavingPreferences: false,
mentorController: null,
},
heaven: {
heavenSetup: null,
heavenManualData: null,
personalField: null,
heavenPanel: "trend",
heavenInterpretations: { trend: "", fortune: "", heart: "" },
heavenReadingMode: "trend",
heavenReadingTab: "current",
heavenReadingHistory: { trend: [], fortune: [], heart: [] },
heavenReadingSelectedId: 0,
heavenReadingLoading: false,
heavenReadingError: "",
heartStage: "intro",
heartTimer: null,
heartSeconds: HEART_BREATH_TOTAL_MS / 1000,
heartBreathingEndsAt: 0,
heartLines: [],
heartThrows: [],
heartHexagram: null,
heartCurtainTimer: null,
heartStageToken: 0,
heartRevealToken: 0,
heavenPerformanceKey: "",
heavenPerformancePanels: new Set(),
heavenPerformanceActive: "",
heavenRequestSequence: 0,
},
});
const elements = {
tradeDate: document.querySelector("#tradeDate"),
loading: document.querySelector("#loadingOverlay"),
toast: document.querySelector("#toast"),
stockDialog: document.querySelector("#stockDialog"),
tradeLogDialog: document.querySelector("#tradeLogDialog"),
watchlistDialog: document.querySelector("#watchlistDialog"),
alertsDialog: document.querySelector("#alertsDialog"),
assistantDialog: document.querySelector("#assistantDialog"),
heavenReadingDialog: document.querySelector("#heavenReadingDialog"),
globalSearchDialog: document.querySelector("#globalSearchDialog"),
globalSearchInput: document.querySelector("#globalSearchInput"),
globalSearchResults: document.querySelector("#globalSearchResults"),
entityDetailDialog: document.querySelector("#entityDetailDialog"),
entityDetailChart: document.querySelector("#entityDetailChart"),
settingsDialog: document.querySelector("#settingsDialog"),
adminDialog: document.querySelector("#adminDialog"),
priceChart: document.querySelector("#priceChart"),
stockPreview: document.querySelector("#stockPreview"),
stockPreviewBackdrop: document.querySelector("#stockPreviewBackdrop"),
stockPreviewChart: document.querySelector("#stockPreviewChart"),
};
+194
View File
@@ -0,0 +1,194 @@
async function loadDashboard(force = false, background = false, showOverlay = true) {
const requestedDate = elements.tradeDate.value;
if (state.dashboardLoading && state.dashboardRequestDate === requestedDate) return;
state.dashboardLoading = true;
state.dashboardRequestDate = requestedDate;
const requestSequence = ++state.dashboardRequestSequence;
if (force) stockPreviewCache.clear();
if (!background && showOverlay) {
setLoading(true, "正在加载市场数据");
setStatus("正在加载市场数据");
} else if (!background) {
setStatus("正在刷新行情");
}
try {
const query = new URLSearchParams({ trade_date: elements.tradeDate.value });
if (force) query.set("force", "1");
const payload = await apiRequest(`/api/dashboard?${query}`);
if (
requestSequence !== state.dashboardRequestSequence
|| requestedDate !== elements.tradeDate.value
) return;
applyDashboard(payload, background);
} catch (error) {
if (background) {
setStatus("实时刷新暂时中断,正在等待重试");
} else {
showToast(error.message || "无法连接本地服务");
setStatus("加载失败");
}
} finally {
if (requestSequence === state.dashboardRequestSequence) {
state.dashboardLoading = false;
state.dashboardRequestDate = "";
if (!background && showOverlay) setLoading(false);
updateDateButtons();
}
}
}
async function startAdminRefresh() {
const buttons = [document.querySelector("#syncButton"), document.querySelector("#adminRefreshButton")].filter(Boolean);
buttons.forEach((button) => { button.disabled = true; });
try {
const payload = await apiRequest("/api/admin/refresh", "POST", { trade_date: elements.tradeDate.value });
showToast(payload.message || "后台刷新已提交");
setStatus("后台刷新运行中,当前页面保持不变");
} catch (error) {
showToast(error.message || "后台刷新启动失败");
} finally {
buttons.forEach((button) => { button.disabled = false; });
}
}
function applyDashboard(payload, background = false) {
state.dashboard = payload;
const selectedDate = payload.meta.requested_date || payload.meta.trade_date;
elements.tradeDate.value = selectedDate;
document.querySelector("#qiObservationDate").value = selectedDate;
document.querySelector("#journalDate").value = selectedDate;
renderDashboard();
setStatus(`${dashboardSourceLabel(payload.meta)} · 数据已更新`);
if (!background) {
if (state.activeView === "dragonView") loadDragonTiger();
if (state.activeView === "screenerView") loadScreenerSetup();
if (state.activeView === "screenerTrackingView") loadScreenerTracking(true);
if (state.activeView === "mentorView") loadMentorSetup(true);
if (state.activeView === "heavenView") loadHeavenSetup(true);
if (state.activeView === "sentimentCycleView") loadSentimentHistory(true);
if (state.activeView === "rotationView") loadRotationHistory(true);
if (state.activeView === "auctionView") loadAuctionCenter(true);
if (state.activeView === "themeLibraryView") loadThemeLibrary(true);
if (state.activeView === "popularityView") loadPopularity(true);
}
const requestedStock = new URLSearchParams(window.location.search).get("stock");
if (!state.initialStockOpened && /^\d{6}$/.test(requestedStock || "")) {
state.initialStockOpened = true;
openStock(requestedStock);
}
}
function dashboardSourceLabel(meta = {}) {
if (meta.realtime && !["closed", "after_hours"].includes(String(meta.market_status || ""))) return "盘中行情";
if (meta.carried_forward) return "最近收盘行情";
if (meta.market_status === "historical") return "历史行情";
return "收盘行情";
}
function renderDashboard() {
const { meta, overview, ladders, sectors } = state.dashboard;
animateMetric("tapeUp", overview.up_count, (value) => Math.round(value));
animateMetric("tapeDown", overview.down_count, (value) => Math.round(value));
setText("tapeLimit", `${overview.limit_up_count} / 跌停 ${overview.limit_down_count}`);
animateMetric("tapeAmount", overview.amount_billion, (value) => `${formatNumber(value, 1)} 亿`);
animateMetric("limitUpMetric", overview.limit_up_count, (value) => `${Math.round(value)}`);
animateMetric("limitDownMetric", overview.limit_down_count, (value) => `${Math.round(value)}`);
animateMetric("brokenMetric", overview.broken_count, (value) => `${Math.round(value)}`);
animateMetric("sealRateMetric", overview.seal_rate, (value) => `${formatNumber(value, 1)}%`);
animateMetric("amountMetric", overview.amount_billion, (value) => `${formatNumber(value, 1)} 亿`);
setText("dataDateMetric", dashboardDataTimestamp(meta));
animateMetric("sentimentScore", overview.sentiment_score, (value) => Math.round(value));
setText("sentimentText", sentimentLabel(overview.sentiment_score));
updateSentimentGauge(overview.sentiment_score);
setText("updatedAt", `${dashboardSourceLabel(meta)} · 更新 ${formatTimestamp(meta.updated_at)}`);
renderLimitTable();
renderLadderMini(ladders || []);
renderSectorMini(sectors || []);
renderBrokenTable(state.dashboard.broken || []);
renderDownTable(state.dashboard.down_limits || []);
renderYesterdayTable(state.dashboard.yesterday_limits || []);
renderPerformance(state.dashboard.limit_performance || []);
renderLadderBoard(ladders || []);
renderRotationMembers();
}
function shiftDate(delta) {
const current = parseLocalDate(elements.tradeDate.value);
current.setDate(current.getDate() + delta);
const next = localDateString(current);
if (next > todayString()) return;
elements.tradeDate.value = next;
state.heavenManualData = null;
document.querySelector("#qiObservationDate").value = next;
loadDashboard();
}
function updateDateButtons() {
document.querySelector("#nextDate").disabled = elements.tradeDate.value >= todayString();
}
function sentimentLabel(score) {
const value = number(score);
if (value >= 80) return "情绪高涨";
if (value >= 60) return "情绪偏强";
if (value >= 40) return "情绪中性";
if (value >= 20) return "情绪偏弱";
return "情绪冰点";
}
function dashboardDataTimestamp(meta = {}) {
const tradeDate = displayCompactDate(meta.trade_date);
if (tradeDate === "--") return "--";
const intraday = tradeDate === todayString() && Boolean(meta.realtime) && !["closed", "after_hours"].includes(String(meta.market_status || ""));
if (intraday) {
const updated = new Date(meta.updated_at);
if (!Number.isNaN(updated.getTime())) {
const dateText = `${updated.getFullYear()}-${String(updated.getMonth() + 1).padStart(2, "0")}-${String(updated.getDate()).padStart(2, "0")}`;
const timeText = updated.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false });
return `${dateText} ${timeText}`;
}
}
return `${tradeDate} 15:00`;
}
function updateSentimentGauge(rawScore) {
const gauge = document.querySelector("#sentimentGauge");
if (!gauge) return;
const score = clamp(rawScore, 0, 100);
const previous = Number(gauge.dataset.score);
gauge.dataset.score = String(score);
gauge.style.setProperty("--score", score);
if (!motionEnabled() || !Number.isFinite(previous) || Math.abs(previous - score) < 15) return;
gauge.classList.remove("sentiment-pulse");
void gauge.offsetWidth;
gauge.classList.add("sentiment-pulse");
gauge.addEventListener("animationend", () => gauge.classList.remove("sentiment-pulse"), { once: true });
}
function bindDashboardEvents() {
document.querySelector("#refreshButton").addEventListener("click", async (event) => {
const button = event.currentTarget;
button.disabled = true;
try {
await loadDashboard(false, false, false);
} finally {
button.disabled = false;
}
});
document.querySelector("#syncButton").addEventListener("click", startAdminRefresh);
elements.tradeDate.addEventListener("change", () => {
state.dashboardRequestSequence += 1;
state.heavenRequestSequence += 1;
state.heavenManualData = null;
document.querySelector("#qiObservationDate").value = elements.tradeDate.value;
loadDashboard();
});
document.querySelector("#prevDate").addEventListener("click", () => shiftDate(-1));
document.querySelector("#nextDate").addEventListener("click", () => shiftDate(1));
}
-3
View File
@@ -1,4 +1,3 @@
/* PRESERVATION-SOURCE-BEGIN app.js:8905-9051 */
function exportStocks() {
exportRows("涨停池", getVisibleStocks(), [
["股票代码", "code"], ["股票名称", "name"], ["连板", "streak"], ["涨幅%", "change"],
@@ -139,5 +138,3 @@ function csvCell(value) {
if (/^[=+\-@]/.test(text)) text = `'${text}`;
return `"${text.replaceAll('"', '""')}"`;
}
/* PRESERVATION-SOURCE-END app.js:8905-9051 */
+153
View File
@@ -0,0 +1,153 @@
const metricAnimationFrames = new WeakMap();
let rowAnimationObserver = null;
function changeClass(value) {
return number(value) > 0 ? "up" : number(value) < 0 ? "down" : "";
}
function streakLabel(streak) {
const value = Math.max(1, number(streak));
return value === 1 ? "首板" : `${value}`;
}
function signed(value) {
const parsed = number(value);
return `${parsed > 0 ? "+" : ""}${formatNumber(parsed, 2)}`;
}
function formatMoneyMillion(value) {
const parsed = number(value);
const sign = parsed > 0 ? "+" : "";
if (Math.abs(parsed) >= 100) return `${sign}${formatNumber(parsed / 100, 2)} 亿`;
return `${sign}${formatNumber(parsed * 100, 0)}`;
}
async function apiRequest(url, method = "GET", body = null, requestOptions = {}) {
return window.XiaobaiAPI.request(url, method, body, requestOptions);
}
function setLoading(loading, text = "正在加载复盘数据", context = "default") {
elements.loading.hidden = !loading;
elements.loading.dataset.context = loading ? context : "default";
setText("loadingTitle", text);
setText(
"loadingHint",
context === "screener"
? "正在完成因子筛选、候选排序与历史样本回测,这通常需要一点时间"
: "请稍候",
);
}
function setStatus(text) {
applicationShell.setStatus(text);
}
let toastTimer;
function showToast(message) {
clearTimeout(toastTimer);
elements.toast.textContent = message;
elements.toast.hidden = false;
toastTimer = setTimeout(() => { elements.toast.hidden = true; }, 3600);
}
function setText(id, value) {
const element = document.getElementById(id);
if (element) element.textContent = value;
}
function motionEnabled() {
return !window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function refreshIcons() {
if (!window.lucide?.createIcons) return;
window.lucide.createIcons({ attrs: { "aria-hidden": "true" } });
}
function toggleHeaderCommandMenu(force) {
applicationShell.toggleHeaderCommandMenu(force);
}
function animateMetric(id, rawValue, formatter = (value) => value) {
const element = document.getElementById(id);
const target = Number(rawValue);
if (!element || !Number.isFinite(target)) {
setText(id, formatter(rawValue));
return;
}
const storedValue = Number(element.dataset.metricValue);
const previous = Number.isFinite(storedValue) ? storedValue : 0;
element.dataset.metricValue = String(target);
const existingFrame = metricAnimationFrames.get(element);
if (existingFrame) cancelAnimationFrame(existingFrame);
if (!motionEnabled() || previous === target) {
element.textContent = formatter(target);
return;
}
element.classList.remove("metric-changed");
void element.offsetWidth;
element.classList.add("metric-changed");
const startedAt = performance.now();
const duration = 560;
const update = (now) => {
const progress = Math.min(1, (now - startedAt) / duration);
const eased = 1 - (1 - progress) ** 3;
element.textContent = formatter(previous + (target - previous) * eased);
if (progress < 1) {
metricAnimationFrames.set(element, requestAnimationFrame(update));
} else {
element.textContent = formatter(target);
metricAnimationFrames.delete(element);
setTimeout(() => element.classList.remove("metric-changed"), 80);
}
};
metricAnimationFrames.set(element, requestAnimationFrame(update));
}
function animateRows(container) {
if (!container) return;
const rows = [...container.children].filter((item) => item.matches("tr, [data-code]"));
if (!motionEnabled()) {
rows.forEach((row) => row.classList.remove("row-pending", "row-enter"));
return;
}
const unseenRows = rows.filter((row) => row.dataset.motionSeen !== "1");
unseenRows.slice(0, 12).forEach((row, index) => {
row.dataset.motionSeen = "1";
row.classList.remove("row-pending", "row-enter");
row.style.setProperty("--row-delay", `${index * 24}ms`);
requestAnimationFrame(() => row.classList.add("row-enter"));
row.addEventListener("animationend", () => row.classList.remove("row-enter"), { once: true });
});
if (!("IntersectionObserver" in window)) {
unseenRows.slice(12).forEach((row) => { row.dataset.motionSeen = "1"; });
return;
}
if (!rowAnimationObserver) {
rowAnimationObserver = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
const row = entry.target;
rowAnimationObserver.unobserve(row);
row.dataset.motionSeen = "1";
row.classList.remove("row-pending");
row.style.setProperty("--row-delay", "0ms");
requestAnimationFrame(() => row.classList.add("row-enter"));
row.addEventListener("animationend", () => row.classList.remove("row-enter"), { once: true });
});
}, { threshold: 0.08, rootMargin: "0px 0px 40px 0px" });
}
unseenRows.slice(12).forEach((row) => {
row.classList.add("row-pending");
rowAnimationObserver.observe(row);
});
}
function waitForMotion(duration) {
return new Promise((resolve) => setTimeout(resolve, motionEnabled() ? duration : 0));
}
+285
View File
@@ -0,0 +1,285 @@
function selectAuthMode(mode) {
state.authMode = mode === "register" ? "register" : "login";
document.querySelectorAll("[data-auth-mode]").forEach((button) => {
button.classList.toggle("active", button.dataset.authMode === state.authMode);
});
const registering = state.authMode === "register";
document.querySelector("#authConfirmField").hidden = !registering;
document.querySelector("#authPasswordConfirm").required = registering;
document.querySelector("#authPassword").autocomplete = registering ? "new-password" : "current-password";
document.querySelector("#authSubmitButton").textContent = registering ? "注册并进入" : "登录";
document.querySelector("#authError").hidden = true;
}
async function submitAuthForm(event) {
event.preventDefault();
const username = document.querySelector("#authUsername").value.trim();
const password = document.querySelector("#authPassword").value;
const errorElement = document.querySelector("#authError");
if (state.authMode === "register" && password !== document.querySelector("#authPasswordConfirm").value) {
errorElement.textContent = "两次输入的密码不一致。";
errorElement.hidden = false;
return;
}
const button = document.querySelector("#authSubmitButton");
button.disabled = true;
try {
const session = await apiRequest(`/api/auth/${state.authMode}`, "POST", { username, password });
document.querySelector("#authForm").reset();
await applyAuthenticatedSession(session);
} catch (error) {
errorElement.textContent = error.message || "账号操作失败";
errorElement.hidden = false;
} finally {
button.disabled = false;
}
}
async function applyAuthenticatedSession(session) {
state.user = session.user;
state.csrfToken = session.csrf_token || "";
setText("accountName", session.user?.username || "账号");
const isAdmin = session.user?.role === "admin";
updateAccountIdentityBadges(session.user?.membership || {});
document.querySelector("#settingsButton").hidden = !isAdmin;
document.querySelector("#syncButton").hidden = !isAdmin;
document.querySelector("#reasonForm").hidden = !isAdmin;
document.querySelector("#sectorPhaseManager").hidden = !isAdmin;
document.querySelector("#authGate").hidden = true;
applyMembershipAccess();
await startAuthenticatedApp();
}
function showAuthGate(message = "") {
state.user = null;
state.csrfToken = "";
const gate = document.querySelector("#authGate");
gate.hidden = false;
const errorElement = document.querySelector("#authError");
errorElement.textContent = message;
errorElement.hidden = !message;
document.querySelector("#authUsername").focus();
}
async function logoutAccount() {
toggleAccountDropdown(false);
try {
await apiRequest("/api/auth/logout", "POST", {});
} catch (error) {
showToast(error.message || "退出失败");
return;
}
window.location.reload();
}
function hasMemberAccess() {
return state.user?.role === "admin" || Boolean(state.user?.membership?.active);
}
function updateAccountIdentityBadges(membership = {}) {
const isAdmin = state.user?.role === "admin" || Boolean(membership.is_admin);
const subscribed = Boolean(membership.subscribed);
document.querySelector("#accountAdminBadge").hidden = !isAdmin;
const vipBadge = document.querySelector("#accountVipBadge");
vipBadge.hidden = false;
vipBadge.classList.toggle("is-nonmember", !subscribed);
setText("accountVipLabel", subscribed ? "会员" : "非会员");
vipBadge.title = subscribed ? "查看会员状态" : "查看会员权益";
}
function applyMembershipAccess() {
const unlocked = hasMemberAccess();
document.querySelectorAll(".member-feature-view").forEach((view) => {
view.classList.toggle("member-locked", !unlocked);
const gate = view.querySelector(".member-gate");
if (gate) gate.hidden = unlocked;
view.querySelectorAll("button, input, textarea, select").forEach((control) => {
if (control.closest(".member-gate") || control.hasAttribute("data-member-navigation")) return;
control.disabled = !unlocked;
});
});
const assistantButton = document.querySelector("#assistantButton");
assistantButton.classList.toggle("member-locked-control", !unlocked);
assistantButton.title = unlocked ? "复盘助手" : "复盘助手(会员可用)";
updateAssistantControls();
}
function selectAccountPanel(panel) {
const selected = ["profile", "membership", "password"].includes(panel) ? panel : "profile";
const titles = { profile: "个人资料", membership: "会员状态", password: "修改密码" };
setText("accountDialogTitle", titles[selected]);
document.querySelectorAll("[data-account-panel-content]").forEach((section) => {
section.hidden = section.dataset.accountPanelContent !== selected;
});
document.querySelector("#connectionStatus").hidden = selected !== "membership";
return selected;
}
async function openSettings(panel = "profile") {
selectAccountPanel(panel);
toggleAccountDropdown(false);
toggleHeaderCommandMenu(false);
const status = document.querySelector("#connectionStatus");
status.className = "connection-status";
status.textContent = "正在读取账号状态";
openModalDialog(elements.settingsDialog);
try {
const payload = await apiRequest("/api/account/status");
const access = payload.llm_access || {};
const membership = access.membership || {};
if (state.user) {
state.user.membership = membership;
updateAccountIdentityBadges(membership);
applyMembershipAccess();
}
status.textContent = membership.active ? "账户权益已同步" : "账户信息已同步";
status.classList.toggle("connected", true);
setText("membershipBadge", membership.subscribed ? "会员有效" : membership.is_admin ? "管理员权限" : "普通用户");
setText("membershipStateValue", membership.subscribed ? "已开通" : membership.is_admin ? "管理员可用" : "未开通");
setText("membershipRemainingValue", membership.subscribed && membership.expires_at
? `${number(membership.remaining_days)}`
: membership.is_admin || membership.subscribed ? "长期有效" : "--");
setText("membershipDetail", membership.subscribed
? `${membership.plan || "会员"}${membership.expires_at ? ` · 有效至 ${membershipDateDisplay(membership.expires_at, true)}` : " · 长期有效"}`
: membership.is_admin
? "管理员拥有智能功能管理权限,但不会因此显示为已开通会员。"
: "开通会员后可使用智能选股、问师、问天、复盘助手等智能功能。");
setText("membershipQuotaHint", `会员默认每日智能分析额度 ${number(access.daily_limit)} 次,由管理员统一设置。`);
setText("membershipUsage", membership.active
? `今日已用 ${number(access.used_today)}`
: "今日智能分析:--");
setText("membershipUsageSummary", membership.active ? `${number(access.used_today)} / ${number(access.daily_limit)}` : "--");
setText("membershipRemainingUsage", membership.is_admin ? "不限" : membership.active ? `${number(access.remaining_calls)}` : "--");
const birth = payload.birth_profile || {};
if (birth.birth_datetime) {
const [birthDate, birthTime] = String(birth.birth_datetime).split("T");
document.querySelector("#accountBirthDate").value = birthDate || "";
document.querySelector("#accountBirthTime").value = (birthTime || "").slice(0, 5);
document.querySelector("#accountBirthGender").value = birth.gender || "unspecified";
}
setText("birthProfileStatus", payload.birth_profile_configured ? "已加密保存" : "尚未设置");
document.querySelector("#deleteBirthProfileButton").disabled = !payload.birth_profile_configured;
} catch (error) {
status.hidden = false;
status.textContent = "账户状态暂时无法同步";
showToast(error.message || "账号信息加载失败");
}
}
async function changeAccountPassword(event) {
event.preventDefault();
const form = event.currentTarget;
const button = form.querySelector("button[type='submit']");
button.disabled = true;
try {
await apiRequest("/api/account/password", "POST", {
current_password: document.querySelector("#currentPassword").value,
new_password: document.querySelector("#newPassword").value,
confirm_password: document.querySelector("#confirmPassword").value,
});
form.reset();
showToast("密码已更新");
} catch (error) {
showToast(error.message || "密码更新失败");
} finally {
button.disabled = false;
}
}
async function switchAccount() {
const button = document.querySelector("#switchAccountMenuButton");
button.disabled = true;
toggleAccountDropdown(false);
try {
await apiRequest("/api/auth/logout", "POST", {});
window.location.reload();
} catch (error) {
showToast(error.message || "切换账号失败");
button.disabled = false;
}
}
function membershipDateDisplay(value) {
if (!value) return "";
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return String(value).slice(0, 10);
return new Intl.DateTimeFormat("zh-CN", { year: "numeric", month: "2-digit", day: "2-digit" }).format(parsed);
}
function toggleAccountDropdown(force, returnFocus = false) {
const menu = document.querySelector("#accountDropdown");
const button = document.querySelector("#accountButton");
if (!menu || !button) return;
const open = typeof force === "boolean" ? force : menu.hidden;
menu.hidden = !open;
button.setAttribute("aria-expanded", String(open));
document.querySelector(".account-menu-shell")?.classList.toggle("is-open", open);
if (open) {
setText("accountMenuName", state.user?.username || "当前账号");
const membership = state.user?.membership || {};
setText("accountMenuRole", state.user?.role === "admin" ? (membership.subscribed ? "管理员 · 会员" : "管理员") : membership.subscribed ? "会员用户" : "普通用户");
} else if (returnFocus) {
button.focus();
}
}
function handleAccountMenuKeydown(event) {
const menu = document.querySelector("#accountDropdown");
if (!menu) return;
if (menu.hidden) {
if (document.activeElement?.id === "accountButton" && event.key === "ArrowDown") {
event.preventDefault();
toggleAccountDropdown(true);
menu.querySelector('[role="menuitem"]')?.focus();
}
return;
}
const items = [...menu.querySelectorAll('[role="menuitem"]:not(:disabled)')];
if (!items.length) return;
const current = items.indexOf(document.activeElement);
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
const offset = event.key === "ArrowDown" ? 1 : -1;
items[(current + offset + items.length) % items.length].focus();
} else if (event.key === "Home" || event.key === "End") {
event.preventDefault();
items[event.key === "Home" ? 0 : items.length - 1].focus();
}
}
function bindSessionEvents() {
document.querySelectorAll("[data-auth-mode]").forEach((button) => {
button.addEventListener("click", () => selectAuthMode(button.dataset.authMode));
});
document.querySelector("#authForm").addEventListener("submit", submitAuthForm);
document.addEventListener("click", (event) => {
if (!event.target.closest(".account-menu-shell")) toggleAccountDropdown(false);
});
window.addEventListener("keydown", handleGlobalSearchShortcut);
document.querySelectorAll("[data-open-account]").forEach((button) => {
button.addEventListener("click", () => openSettings("membership"));
});
document.querySelector("#accountButton").addEventListener("click", (event) => {
event.stopPropagation();
toggleAccountDropdown();
});
document.querySelector("#accountVipBadge").addEventListener("click", () => openSettings("membership"));
document.querySelectorAll("[data-account-panel]").forEach((button) => {
button.addEventListener("click", () => openSettings(button.dataset.accountPanel));
});
document.querySelector("#switchAccountMenuButton").addEventListener("click", switchAccount);
document.querySelector("#logoutMenuButton").addEventListener("click", logoutAccount);
document.querySelector("#closeSettingsDialog").addEventListener("click", () => elements.settingsDialog.close());
document.querySelector("#accountBirthForm").addEventListener("submit", saveAccountBirthProfile);
document.querySelector("#deleteBirthProfileButton").addEventListener("click", deleteAccountBirthProfile);
document.querySelector("#passwordForm").addEventListener("submit", changeAccountPassword);
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") toggleAccountDropdown(false, true);
handleAccountMenuKeydown(event);
});
}
File diff suppressed because it is too large Load Diff
+81
View File
@@ -0,0 +1,81 @@
function initializeAutoTableSorting() {
markAutoSortableHeaders(document);
document.addEventListener("click", (event) => {
const header = event.target.closest?.("th[data-auto-sort]");
if (!header || header.closest("#limitTable")) return;
const table = header.closest("table");
const body = table?.tBodies?.[0];
if (!body || body.rows.length < 2) return;
const direction = header.classList.contains("sort-asc") ? "desc" : "asc";
table.querySelectorAll("th.sort-asc, th.sort-desc").forEach((item) => {
item.classList.remove("sort-asc", "sort-desc", "sorted");
item.removeAttribute("aria-sort");
const arrow = item.querySelector(".arr");
if (arrow) arrow.textContent = "↕";
});
header.classList.add(`sort-${direction}`, "sorted");
header.setAttribute("aria-sort", direction === "asc" ? "ascending" : "descending");
const activeArrow = header.querySelector(".arr");
if (activeArrow) activeArrow.textContent = direction === "asc" ? "▲" : "▼";
const columnIndex = header.cellIndex;
const rows = [...body.rows].map((row, index) => ({ row, index }));
rows.sort((left, right) => {
const leftValue = autoSortValue(left.row.cells[columnIndex]);
const rightValue = autoSortValue(right.row.cells[columnIndex]);
let result;
if (leftValue.kind === "number" && rightValue.kind === "number") result = leftValue.value - rightValue.value;
else result = String(leftValue.value).localeCompare(String(rightValue.value), "zh-CN", { numeric: true, sensitivity: "base" });
if (result === 0) result = left.index - right.index;
return direction === "asc" ? result : -result;
});
rows.forEach(({ row }) => body.appendChild(row));
const firstHeader = [...header.parentElement.cells][0]?.textContent.trim();
if (["#", "排名"].includes(firstHeader)) {
[...body.rows].forEach((row, index) => {
if (row.cells[0]) row.cells[0].textContent = String(index + 1);
});
}
});
}
function markAutoSortableHeaders(root) {
root.querySelectorAll?.(".data-table:not(#limitTable) thead th").forEach((header) => {
if (header.closest("#brokenTable, #downTable, #yesterdayTable, #rotationTable")) return;
if (number(header.colSpan) > 1) return;
const label = header.textContent.trim();
if (!label || ["#", "操作"].includes(label)) return;
header.dataset.autoSort = "true";
header.classList.add("sortable");
if (!header.querySelector(".arr")) header.insertAdjacentHTML("beforeend", '<span class="arr">↕</span>');
header.title = `${label}:点击排序`;
});
}
function autoSortValue(cell) {
const text = String(cell?.dataset?.sortValue || cell?.textContent || "").trim();
if (!text || text === "--" || text.includes("样本不足")) return { kind: "text", value: "\uffff" };
const boardMatch = text.match(/(\d+)\s*板/);
if (boardMatch) return { kind: "number", value: Number(boardMatch[1]) };
const normalized = text.replaceAll(",", "").replace(/[+%]/g, "");
const numericMatch = normalized.match(/^-?\d+(?:\.\d+)?/);
if (numericMatch) {
let value = Number(numericMatch[0]);
if (text.includes("亿")) value *= 10000;
return { kind: "number", value };
}
return { kind: "text", value: text };
}
function bindSharedTableEvents() {
document.querySelectorAll("[data-table-search]").forEach((input) => {
input.addEventListener("input", () => {
const query = input.value.trim().toLowerCase();
const body = document.querySelector(`#${CSS.escape(input.dataset.tableSearch)}`);
body?.querySelectorAll("tr").forEach((row) => {
row.hidden = Boolean(query) && !row.textContent.toLowerCase().includes(query);
});
});
});
initializeAutoTableSorting();
}
+115
View File
@@ -0,0 +1,115 @@
const THEME_STORAGE_KEY = "xiaobaiTheme";
let activeThemeTransition = null;
let themeSwitchSequence = 0;
function syncThemeControl() {
const theme = document.documentElement.dataset.theme === "dark" ? "dark" : "light";
const button = document.querySelector("#themeToggle");
if (!button) return;
const dark = theme === "dark";
const label = dark ? "切换到日间模式" : "切换到夜间模式";
button.title = label;
button.setAttribute("aria-label", label);
button.setAttribute("aria-pressed", String(dark));
button.querySelector("i")?.setAttribute("data-lucide", dark ? "sun" : "moon");
}
function clearThemeTransitionEffects() {
document.querySelectorAll(".row-enter, .row-pending, .view-entering").forEach((element) => {
element.classList.remove("row-enter", "row-pending", "view-entering");
element.style.removeProperty("--row-delay");
});
}
function redrawThemeSensitiveVisuals() {
if (!elements.stockPreview.hidden && state.stockPreviewPayload) {
selectStockPreviewChart(state.stockPreviewChart);
}
if (elements.stockDialog.open) {
if (state.stockDetailChartMode === "intraday" && state.stockDetailIntraday?.points?.length) {
drawIntradayCanvas(
elements.priceChart,
state.stockDetailIntraday.points,
[],
state.stockDetailIntraday.meta?.previous_close,
);
} else if (state.stockDetail?.prices) drawPriceChart(state.stockDetail.prices);
}
if (elements.entityDetailDialog.open) {
if (state.entityDetailChartMode === "intraday" && state.entityDetailIntraday?.points?.length) {
drawIntradayCanvas(
elements.entityDetailChart,
state.entityDetailIntraday.points,
[],
state.entityDetailIntraday.meta?.previous_close,
);
} else if (state.entityDetailPayload?.series) {
drawEntityDetailChart(state.entityDetailPayload.series);
}
}
if (state.activeView === "sentimentCycleView" && state.sentimentHistory) {
drawSentimentTrendChart(state.sentimentHistory.rows || []);
}
if (state.activeView === "heavenView") {
if (state.heavenPanel === "fortune" && state.heavenSetup?.field) {
renderQiFieldCanvas(state.heavenSetup.field.balance || [], { intro: false });
drawQiUseConnections(false);
}
if (state.heavenPanel === "heart") startHeartDust();
}
}
function commitTheme(normalized, persist) {
document.documentElement.dataset.theme = normalized;
document.documentElement.style.colorScheme = normalized;
if (persist) {
try {
localStorage.setItem(THEME_STORAGE_KEY, normalized);
} catch (_error) {
// The selected theme still applies for the current page when storage is unavailable.
}
}
syncThemeControl();
refreshIcons();
redrawThemeSensitiveVisuals();
}
function applyTheme(theme, persist = true) {
const normalized = theme === "dark" ? "dark" : "light";
const root = document.documentElement;
if (root.dataset.theme === normalized) {
commitTheme(normalized, persist);
return;
}
const sequence = ++themeSwitchSequence;
activeThemeTransition?.skipTransition?.();
clearThemeTransitionEffects();
root.classList.add("theme-switching");
const update = () => commitTheme(normalized, persist);
const finish = () => {
if (sequence !== themeSwitchSequence) return;
clearThemeTransitionEffects();
root.classList.remove("theme-switching");
activeThemeTransition = null;
};
const reducedMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
if (!reducedMotion && typeof document.startViewTransition === "function") {
activeThemeTransition = document.startViewTransition(update);
activeThemeTransition.finished.then(finish, finish);
return;
}
update();
requestAnimationFrame(() => requestAnimationFrame(finish));
}
function toggleTheme() {
applyTheme(document.documentElement.dataset.theme === "dark" ? "light" : "dark");
}
function bindThemeEvents() {
document.querySelector("#themeToggle").addEventListener("click", toggleTheme);
window.addEventListener("resize", redrawThemeSensitiveVisuals);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff