refactor: establish standalone application boundary
This commit is contained in:
+2305
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
+2475
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
+11655
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
+1172
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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)}%`;
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
+1325
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
+2777
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
+2491
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
+2169
File diff suppressed because it is too large
Load Diff
@@ -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="对照计划与纪律: · 做对的: · 做错的: · 漏掉的信号:"></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>
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+1247
File diff suppressed because it is too large
Load Diff
@@ -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>强度中(70–89)</span>
|
||||
<span><i class="rotation-swatch mild"></i>强度低(<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>
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
+6433
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+1676
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
+1536
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user