migration: preserve frontend shell pages and styles
This commit is contained in:
@@ -9,7 +9,7 @@ from typing import Any
|
||||
|
||||
|
||||
APP_DIR = Path(__file__).resolve().parents[2]
|
||||
STATIC_DIR = APP_DIR / "static"
|
||||
STATIC_DIR = APP_DIR / "frontend"
|
||||
DATA_DIR = APP_DIR / "data"
|
||||
ENV_FILE = APP_DIR / ".env"
|
||||
MENTOR_SKILLS_DIR = APP_DIR / "游资skills"
|
||||
|
||||
+1943
File diff suppressed because it is too large
Load Diff
@@ -18,12 +18,12 @@
|
||||
})();
|
||||
</script>
|
||||
<link rel="stylesheet" href="/shared/tokens.css?v=20260729-1">
|
||||
<link rel="stylesheet" href="/styles.css">
|
||||
<link rel="stylesheet" href="/renovation.css?v=20260725-5">
|
||||
<link rel="stylesheet" href="/redesign-v2.css?v=20260728-1">
|
||||
<link rel="stylesheet" href="/design-system.css?v=20260728-4">
|
||||
<link rel="stylesheet" href="/theme.css?v=20260728-2">
|
||||
<link rel="stylesheet" href="/wentian-v2.css?v=20260728-7">
|
||||
<link rel="stylesheet" href="/styles/styles.css">
|
||||
<link rel="stylesheet" href="/styles/renovation.css?v=20260725-5">
|
||||
<link rel="stylesheet" href="/styles/redesign-v2.css?v=20260728-1">
|
||||
<link rel="stylesheet" href="/styles/design-system.css?v=20260728-4">
|
||||
<link rel="stylesheet" href="/styles/theme.css?v=20260728-2">
|
||||
<link rel="stylesheet" href="/pages/heaven/page.css?v=20260728-7">
|
||||
</head>
|
||||
<body>
|
||||
<section id="authGate" class="auth-gate" aria-label="账号登录">
|
||||
@@ -1865,12 +1865,13 @@
|
||||
<div id="toast" class="toast" role="status" hidden></div>
|
||||
|
||||
<script src="/vendor/lucide.min.js" defer></script>
|
||||
<script src="/ui-core.js" defer></script>
|
||||
<script src="/shared/ui-core.js" defer></script>
|
||||
<script src="/shared/components.js?v=20260729-1" defer></script>
|
||||
<script src="/pages.config.js?v=20260729-1" defer></script>
|
||||
<script src="/pages/runtime.js?v=20260729-1" defer></script>
|
||||
<script src="/pages/sentiment/page.js?v=20260729-1" defer></script>
|
||||
<script src="/pages/pools/page.js?v=20260729-1" defer></script>
|
||||
<script src="/pages/market/runtime.js?v=20260731-1" defer></script>
|
||||
<script src="/pages/ladder/page.js?v=20260729-1" defer></script>
|
||||
<script src="/pages/rotation/page.js?v=20260729-1" defer></script>
|
||||
<script src="/pages/auction/page.js?v=20260729-1" defer></script>
|
||||
@@ -1884,7 +1885,8 @@
|
||||
<script src="/shared/state.js?v=20260729-1" defer></script>
|
||||
<script src="/shared/api.js?v=20260729-1" defer></script>
|
||||
<script src="/shared/shell.js?v=20260729-1" defer></script>
|
||||
<script src="/heaven-loading-v2.js?v=20260728-2" defer></script>
|
||||
<script src="/shared/export.js?v=20260731-1" defer></script>
|
||||
<script src="/pages/heaven/loading-v2.js?v=20260728-2" defer></script>
|
||||
<script src="/app.js?v=20260729-6" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,272 @@
|
||||
window.XiaobaiPageModules.register("auction", ["auctionView"], {
|
||||
enter: ["loadAuction"],
|
||||
leave: ["clearAuction"],
|
||||
});
|
||||
|
||||
/* PRESERVATION-SOURCE-BEGIN app.js:2217-2481 */
|
||||
async function loadAuctionCenter(force = false) {
|
||||
if (state.auctionLoading) return;
|
||||
state.auctionLoading = true;
|
||||
const button = document.querySelector("#auctionRefreshButton");
|
||||
button.disabled = true;
|
||||
setText("auctionDateLabel", "正在读取竞价数据");
|
||||
try {
|
||||
const query = new URLSearchParams({ trade_date: elements.tradeDate.value });
|
||||
if (force) query.set("force", "1");
|
||||
state.auctionData = await apiRequest(`/api/auction?${query}`);
|
||||
renderAuctionCenter();
|
||||
scheduleAuctionTransition(state.auctionData.meta || {});
|
||||
} catch (error) {
|
||||
document.querySelector("#auctionSummary").innerHTML = "";
|
||||
document.querySelector("#auctionThemeCarry").innerHTML = "";
|
||||
document.querySelector("#auctionNewThemes").innerHTML = "";
|
||||
document.querySelector("#auctionAmountTrend").innerHTML = "";
|
||||
document.querySelector("#auctionAmountCompare").innerHTML = "";
|
||||
document.querySelector("#auctionTableBody").innerHTML = "";
|
||||
document.querySelector("#auctionEmpty").hidden = false;
|
||||
setText("auctionDateLabel", error.message || "竞价数据暂不可用");
|
||||
showToast(error.message || "竞价数据加载失败");
|
||||
} finally {
|
||||
state.auctionLoading = false;
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function renderAuctionCenter() {
|
||||
const payload = state.auctionData;
|
||||
if (!payload) return;
|
||||
const summary = payload.summary || {};
|
||||
renderAuctionPhase(payload.meta || {});
|
||||
setText(
|
||||
"auctionDateLabel",
|
||||
`${payload.meta?.carried_forward ? "最近有效竞价" : "竞价日期"} ${payload.meta?.trade_date || "--"}`,
|
||||
);
|
||||
document.querySelector("#auctionSummary").innerHTML = [
|
||||
["竞价覆盖", `${formatNumber(summary.stock_count, 0)} 只`, ""],
|
||||
["重点异动", `${formatNumber(summary.focus_count, 0)} 只`, "up"],
|
||||
["竞价一字", `${formatNumber(summary.one_price_count, 0)} 只`, ""],
|
||||
["竞价成交额", `${formatNumber(summary.amount_billion, 2)} 亿`, ""],
|
||||
].map(([label, value, tone]) => `<div><span>${label}</span><strong class="${tone}">${value}</strong></div>`).join("");
|
||||
setText("auctionFocusCount", number(summary.focus_count));
|
||||
setText("auctionAllCount", number(summary.candidate_count));
|
||||
setText("auctionOnePriceCount", number(summary.one_price_count));
|
||||
setText("auctionWatchlistCount", number(payload.watchlist_rows?.length));
|
||||
renderAuctionInsights(payload);
|
||||
renderAuctionTable();
|
||||
}
|
||||
|
||||
function renderAuctionInsights(payload) {
|
||||
const themes = payload.themes || {};
|
||||
const carry = themes.carry || [];
|
||||
const tone = { "强承接": "strong", "有承接": "steady", "分歧": "mixed", "承接弱": "weak" };
|
||||
setText("auctionThemeBaseline", `基于 ${payload.candidate_meta?.baseline_date || "--"}`);
|
||||
document.querySelector("#auctionThemeCarry").innerHTML = carry.length
|
||||
? carry.map((item) => `
|
||||
<div class="auction-theme-row">
|
||||
<strong class="auction-theme-name">${escapeHtml(item.name)}</strong>
|
||||
<span class="auction-theme-info">${escapeHtml(item.leader || "--")} · 昨日 ${number(item.prior_limit_count)} 只涨停</span>
|
||||
<span class="auction-theme-status ${tone[item.status] || "mixed"}">${escapeHtml(item.status)}</span>
|
||||
<span class="auction-theme-median ${item.median_change == null ? "" : changeClass(item.median_change)}">${item.median_change == null ? "暂无有效候选" : `${signed(item.median_change)}%`}<small>中位</small></span>
|
||||
</div>`).join("")
|
||||
: '<div class="auction-inline-empty">暂无昨日强势题材基线</div>';
|
||||
|
||||
const newThemes = themes.new_themes || [];
|
||||
document.querySelector("#auctionNewThemes").innerHTML = newThemes.length
|
||||
? newThemes.map((item) => `<span title="${escapeHtml((item.leaders || []).join("、"))}">${escapeHtml(item.name)} <strong>${number(item.stock_count)}</strong></span>`).join("")
|
||||
: '<small>尚未形成多股共振的新线索</small>';
|
||||
|
||||
const history = payload.amount_history || [];
|
||||
const maximum = Math.max(...history.map((item) => number(item.amount_billion)), 1);
|
||||
const priorFive = history.slice(Math.max(0, history.length - 6), Math.max(0, history.length - 1));
|
||||
const fiveDayAverage = priorFive.length
|
||||
? priorFive.reduce((sum, item) => sum + number(item.amount_billion), 0) / priorFive.length
|
||||
: null;
|
||||
document.querySelector("#auctionAmountTrend").innerHTML = history.length
|
||||
? history.map((item, index) => {
|
||||
const height = Math.max(8, number(item.amount_billion) / maximum * 100);
|
||||
const current = index === history.length - 1 ? " current" : "";
|
||||
return `<div class="auction-amount-day${current}" title="${escapeHtml(item.trade_date)} · ${formatNumber(item.amount_billion, 2)} 亿 · ${number(item.stock_count)} 只">
|
||||
<span style="height:${height.toFixed(1)}%"></span><small>${escapeHtml(String(item.trade_date || "").slice(5))}</small>
|
||||
</div>`;
|
||||
}).join("") + (fiveDayAverage === null ? "" : `<div class="auction-amount-average" style="bottom:${(20 + Math.min(fiveDayAverage / maximum, 1) * 82).toFixed(1)}px"><small>5日均 ${formatNumber(fiveDayAverage, 1)}</small></div>`)
|
||||
: '<div class="auction-inline-empty">历史竞价量能尚未形成</div>';
|
||||
setText("auctionAmountValue", `${formatNumber(payload.summary?.amount_billion, 2)} 亿`);
|
||||
const comparison = [
|
||||
["较昨日", payload.summary?.amount_change_previous],
|
||||
["较5日均值", payload.summary?.amount_change_5d],
|
||||
];
|
||||
document.querySelector("#auctionAmountCompare").innerHTML = comparison.map(([label, value]) => `
|
||||
<span>${label}<strong class="${value == null ? "" : changeClass(value)}">${value == null ? "--" : `${signed(value)}%`}</strong></span>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
function renderAuctionPhase(meta) {
|
||||
const phase = meta.phase || "archive";
|
||||
const available = Boolean(meta.available);
|
||||
const copy = {
|
||||
pending: ["竞价尚未开始", "9:15 进入观察期,9:25 读取最终竞价结果。", "下一阶段 09:15"],
|
||||
observing: ["竞价观察期", "此阶段先观察盘前变化,系统将在 9:25 自动读取最终结果。", "09:25 定格"],
|
||||
selection: available
|
||||
? ["竞价筛选窗口", "最终竞价结果已经定格,请在 9:30 前完成筛选。", "有效至 09:30"]
|
||||
: ["等待最终竞价", "9:25 数据尚未到达,系统正在自动重试。", "即将更新"],
|
||||
finalized: ["今日竞价已定格", "9:30 后停止更新,仅保留用于复盘、回测与智能选股。", "已冻结"],
|
||||
archive: ["历史竞价归档", "当前展示所选交易日的最终竞价结果。", "归档数据"],
|
||||
}[phase] || ["竞价状态", "当前竞价状态待确认。", "--"];
|
||||
const notice = document.querySelector("#auctionPhaseNotice");
|
||||
notice.dataset.phase = phase;
|
||||
setText("auctionPhaseTitle", copy[0]);
|
||||
setText("auctionPhaseDetail", copy[1]);
|
||||
setText("auctionPhaseTime", copy[2]);
|
||||
const refresh = document.querySelector("#auctionRefreshButton");
|
||||
refresh.hidden = phase !== "selection";
|
||||
refresh.disabled = state.auctionLoading;
|
||||
}
|
||||
|
||||
function clearAuctionTimer() {
|
||||
if (state.auctionTimer) clearTimeout(state.auctionTimer);
|
||||
state.auctionTimer = null;
|
||||
}
|
||||
|
||||
function scheduleAuctionTransition(meta) {
|
||||
clearAuctionTimer();
|
||||
if (state.activeView !== "auctionView") return;
|
||||
let delay = 0;
|
||||
if (["selection", "finalized"].includes(meta.phase) && !meta.available) {
|
||||
delay = 10_000;
|
||||
} else if (meta.next_transition_at) {
|
||||
const transitionAt = new Date(meta.next_transition_at).getTime();
|
||||
if (Number.isFinite(transitionAt)) delay = Math.max(800, transitionAt - Date.now() + 500);
|
||||
}
|
||||
if (!delay) return;
|
||||
state.auctionTimer = setTimeout(() => {
|
||||
state.auctionTimer = null;
|
||||
if (state.activeView === "auctionView") loadAuctionCenter(true);
|
||||
}, Math.min(delay, 2_147_000_000));
|
||||
}
|
||||
|
||||
function renderAuctionTable() {
|
||||
const rows = currentAuctionRows();
|
||||
const columns = auctionColumns();
|
||||
const head = document.querySelector("#auctionTableHead");
|
||||
head.innerHTML = columns.map((column) => {
|
||||
const sorted = column.sortKey === state.auctionSortKey;
|
||||
const arrow = !column.sortKey ? "" : `<span class="arr">${sorted ? (state.auctionSortDirection === "desc" ? "▼" : "▲") : "↕"}</span>`;
|
||||
return `<th class="${column.numeric ? "number num " : ""}${column.sortKey ? "sortable " : ""}${sorted ? "sorted" : ""}"${column.sortKey ? ` data-auction-sort="${column.sortKey}"` : ""}>${column.label}${arrow}</th>`;
|
||||
}).join("");
|
||||
const body = document.querySelector("#auctionTableBody");
|
||||
body.innerHTML = rows.map((row) => `<tr data-code="${escapeHtml(row.code)}">${columns.map((column) => renderAuctionCell(row, column.key)).join("")}</tr>`).join("");
|
||||
bindStockRows(body);
|
||||
const datasetCopy = {
|
||||
focus: ["重点异动", "优先查看市场核心与显著预期差"],
|
||||
onePrice: ["竞价一字", "竞价封于当日真实涨停价,不参与普通异动评分"],
|
||||
watchlist: ["我的自选", "仅展示当前账号关注标的的竞价反馈"],
|
||||
all: ["全部候选", "昨日涨停、炸板与热榜前20候选"],
|
||||
}[state.auctionDataset] || ["竞价异动", ""];
|
||||
setText("auctionWorkspaceTitle", datasetCopy[0]);
|
||||
setText("auctionWorkspaceSubtitle", datasetCopy[1]);
|
||||
document.querySelector("#auctionExpectationControls").hidden = state.auctionDataset === "onePrice";
|
||||
const empty = document.querySelector("#auctionEmpty");
|
||||
const phase = state.auctionData?.meta?.phase || "archive";
|
||||
empty.textContent = phase === "selection" && !state.auctionData?.meta?.available
|
||||
? "正在等待 9:25 最终竞价数据"
|
||||
: state.auctionDataset === "watchlist"
|
||||
? "当前账号还没有可观察的自选股"
|
||||
: state.auctionDataset === "onePrice"
|
||||
? "当前没有竞价封于涨停价的股票"
|
||||
: "没有符合条件的竞价候选";
|
||||
empty.hidden = rows.length > 0;
|
||||
}
|
||||
|
||||
function currentAuctionRows() {
|
||||
const datasets = {
|
||||
focus: state.auctionData?.focus_rows || [],
|
||||
onePrice: state.auctionData?.one_price_rows || [],
|
||||
watchlist: state.auctionData?.watchlist_rows || [],
|
||||
all: state.auctionData?.rows || [],
|
||||
};
|
||||
let rows = [...(datasets[state.auctionDataset] || [])];
|
||||
const filter = state.auctionFilter;
|
||||
const labels = { above: "超预期", matched: "符合预期", below: "低于预期" };
|
||||
if (labels[filter]) rows = rows.filter((item) => item.expectation === labels[filter]);
|
||||
if (state.auctionQuery) {
|
||||
rows = rows.filter((item) => `${item.code} ${item.name} ${item.sector}`.toLocaleLowerCase("zh-CN").includes(state.auctionQuery));
|
||||
}
|
||||
const key = state.auctionSortKey;
|
||||
const direction = state.auctionSortDirection === "asc" ? 1 : -1;
|
||||
if (key) {
|
||||
rows.sort((left, right) => {
|
||||
const leftValue = left[key];
|
||||
const rightValue = right[key];
|
||||
if (leftValue == null && rightValue == null) return 0;
|
||||
if (leftValue == null) return 1;
|
||||
if (rightValue == null) return -1;
|
||||
const result = typeof leftValue === "number" || typeof rightValue === "number"
|
||||
? number(leftValue) - number(rightValue)
|
||||
: String(leftValue).localeCompare(String(rightValue), "zh-CN", { numeric: true });
|
||||
return result * direction;
|
||||
});
|
||||
}
|
||||
return rows.slice(0, 300);
|
||||
}
|
||||
|
||||
function auctionColumns() {
|
||||
const base = [
|
||||
{ key: "stock", label: "股票" },
|
||||
{ key: "context", label: "方向与来源" },
|
||||
{ key: "identity", label: "市场身份" },
|
||||
];
|
||||
const metrics = [
|
||||
{ key: "score", label: "关注分", numeric: true, sortKey: "attention_score" },
|
||||
{ key: "expectation", label: "预期判断" },
|
||||
{ key: "change", label: "竞价涨幅(%)", numeric: true, sortKey: "change" },
|
||||
{ key: "amount", label: "竞价额(百万)", numeric: true, sortKey: "amount_million" },
|
||||
{ key: "volume", label: "量比", numeric: true, sortKey: "volume_ratio" },
|
||||
];
|
||||
return state.auctionDataset === "onePrice" ? [...base, ...metrics.slice(2)] : [...base, ...metrics];
|
||||
}
|
||||
|
||||
function renderAuctionCell(row, key) {
|
||||
const unavailable = row.available === false;
|
||||
const onePrice = Boolean(row.is_one_price);
|
||||
const expectationTone = { "超预期": "above", "符合预期": "matched", "低于预期": "below" };
|
||||
if (key === "stock") return `<td><span class="auction-stock-cell-v2"><strong class="sname">${escapeHtml(row.name)}</strong><small class="stock-code scode">${escapeHtml(row.code)}</small></span></td>`;
|
||||
if (key === "context") return `<td><span class="auction-context-cell-v2"><strong>${escapeHtml(row.sector || "其他")}</strong>${renderAuctionSources(row.source_label || (state.auctionDataset === "watchlist" ? "我的自选" : "全市场"))}</span></td>`;
|
||||
if (key === "identity") return `<td>${renderAuctionCoreTags(row.core_tags)}</td>`;
|
||||
if (unavailable) return key === "expectation"
|
||||
? '<td><span class="table-muted">暂无竞价</span></td>'
|
||||
: `<td class="${["score", "change", "amount", "volume"].includes(key) ? "number num" : ""}"></td>`;
|
||||
if (key === "score") return `<td class="number num auction-score">${onePrice ? "" : formatNumber(row.attention_score, 1)}</td>`;
|
||||
if (key === "expectation") {
|
||||
const tag = onePrice
|
||||
? '<span class="auction-one-price-tag">竞价一字</span>'
|
||||
: `<span class="auction-expectation ${expectationTone[row.expectation] || "matched"}">${escapeHtml(row.expectation || "符合预期")}</span>`;
|
||||
return `<td>${tag}</td>`;
|
||||
}
|
||||
if (key === "change") return `<td class="number num ${changeClass(row.change)}">${signed(row.change)}</td>`;
|
||||
if (key === "amount") return `<td class="number num">${formatNumber(row.amount_million, 2)}</td>`;
|
||||
if (key === "volume") return `<td class="number num auction-volume-ratio">${formatNumber(row.volume_ratio, 2)}</td>`;
|
||||
return "<td></td>";
|
||||
}
|
||||
|
||||
function renderAuctionSources(value) {
|
||||
const sources = String(value || "").split(/[·、/]/).map((item) => item.trim()).filter(Boolean).slice(0, 3);
|
||||
return `<small class="auction-source-tags-v2">${sources.map((source) => `<b>${escapeHtml(source)}</b>`).join("")}</small>`;
|
||||
}
|
||||
|
||||
function renderAuctionCoreTags(tags) {
|
||||
const values = Array.isArray(tags) ? tags : [];
|
||||
return values.length
|
||||
? `<span class="auction-core-tags">${values.slice(0, 2).map((tag) => `<b>${escapeHtml(tag)}</b>`).join("")}</span>`
|
||||
: '<span class="auction-identity-empty" aria-label="无市场身份"></span>';
|
||||
}
|
||||
|
||||
function exportAuctionRows() {
|
||||
const rows = currentAuctionRows();
|
||||
exportRows("集合竞价", rows, [
|
||||
["股票代码", "code"], ["股票名称", "name"], ["行业", "sector"], ["来源", "source_label"],
|
||||
["市场身份", "core_tags"], ["关注分", "attention_score"], ["预期判断", "expectation"],
|
||||
["竞价涨幅%", "change"], ["竞价额百万", "amount_million"], ["量比", "volume_ratio"],
|
||||
]);
|
||||
}
|
||||
|
||||
/* PRESERVATION-SOURCE-END app.js:2217-2481 */
|
||||
@@ -0,0 +1,375 @@
|
||||
window.XiaobaiPageModules.register("dragon_tiger", ["dragonView"], {
|
||||
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) => {
|
||||
const active = button.dataset.dragonViewMode === state.dragonViewMode;
|
||||
button.classList.toggle("active", active);
|
||||
button.setAttribute("aria-pressed", String(active));
|
||||
});
|
||||
if (state.dragonViewMode === "profiles") {
|
||||
document.querySelector("#dragonDailyContent").hidden = true;
|
||||
document.querySelector("#dragonEmptyState").hidden = true;
|
||||
document.querySelector("#dragonProfilesContent").hidden = false;
|
||||
if (state.hotMoneyProfiles) renderHotMoneyProfiles();
|
||||
else loadHotMoneyProfiles();
|
||||
} else {
|
||||
document.querySelector("#dragonProfilesContent").hidden = true;
|
||||
if (state.dragonTiger) renderDragonTiger();
|
||||
else loadDragonTiger();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadHotMoneyProfiles(force = false) {
|
||||
if (!force && state.hotMoneyProfiles) {
|
||||
renderHotMoneyProfiles();
|
||||
return;
|
||||
}
|
||||
setStatus("正在加载游资档案");
|
||||
try {
|
||||
const query = new URLSearchParams();
|
||||
if (force) query.set("force", "1");
|
||||
const suffix = query.size ? `?${query}` : "";
|
||||
state.hotMoneyProfiles = await apiRequest(`/api/dragon-tiger/profiles${suffix}`);
|
||||
renderHotMoneyProfiles();
|
||||
const count = number(state.hotMoneyProfiles.summary?.profile_count);
|
||||
setStatus(`游资档案已加载 · 共 ${count} 位`);
|
||||
} catch (error) {
|
||||
showToast(error.message || "游资档案加载失败");
|
||||
setStatus("游资档案加载失败");
|
||||
}
|
||||
}
|
||||
|
||||
function renderHotMoneyProfiles() {
|
||||
const payload = state.hotMoneyProfiles;
|
||||
if (!payload) return;
|
||||
const profiles = payload.profiles || [];
|
||||
const summary = payload.summary || {};
|
||||
const query = state.hotMoneyProfileQuery;
|
||||
const visible = profiles.filter((profile) => {
|
||||
if (!query) return true;
|
||||
return [profile.name, profile.description, ...(profile.organizations || [])]
|
||||
.join(" ")
|
||||
.toLocaleLowerCase("zh-CN")
|
||||
.includes(query);
|
||||
});
|
||||
if (!visible.some((profile) => profile.id === state.selectedHotMoneyProfileId)) {
|
||||
state.selectedHotMoneyProfileId = visible[0]?.id || "";
|
||||
}
|
||||
const selected = visible.find((profile) => profile.id === state.selectedHotMoneyProfileId) || null;
|
||||
|
||||
setText("dragonDateLabel", `收录 ${number(summary.profile_count)} 位`);
|
||||
setText("hotMoneyProfileResultCount", query ? `${visible.length} / ${profiles.length} 位` : `${profiles.length} 位`);
|
||||
document.querySelector("#hotMoneyProfileSummary").innerHTML = [
|
||||
["收录游资", number(summary.profile_count)],
|
||||
["已有简介", number(summary.described_count)],
|
||||
["关联席位", number(summary.organization_count)],
|
||||
].map(([label, value]) => `<span><small>${label}</small><strong>${value}</strong></span>`).join("");
|
||||
|
||||
const list = document.querySelector("#hotMoneyProfileList");
|
||||
list.innerHTML = visible.length ? visible.map((profile, index) => `
|
||||
<button class="hot-money-profile-row-v2 ${profile.id === state.selectedHotMoneyProfileId ? "selected" : ""}"
|
||||
type="button" role="option" aria-selected="${profile.id === state.selectedHotMoneyProfileId}"
|
||||
data-hot-money-profile="${escapeHtml(profile.id)}">
|
||||
<span class="hot-money-profile-index-v2">${String(index + 1).padStart(2, "0")}</span>
|
||||
<span class="hot-money-profile-monogram-v2">${escapeHtml(profile.name.slice(0, 2))}</span>
|
||||
<span class="hot-money-profile-row-copy-v2">
|
||||
<strong>${escapeHtml(profile.name)}</strong>
|
||||
<small>${escapeHtml(profile.description || "暂未收录简介")}</small>
|
||||
</span>
|
||||
<span class="hot-money-profile-seat-count-v2">${number(profile.organization_count)} 席</span>
|
||||
</button>`).join("") : `
|
||||
<div class="hot-money-profile-list-empty-v2">
|
||||
<i data-lucide="search-x" aria-hidden="true"></i>
|
||||
<span>${profiles.length ? "没有符合条件的游资档案" : "游资名录暂不可用"}</span>
|
||||
</div>`;
|
||||
|
||||
const detail = document.querySelector("#hotMoneyProfileDetail");
|
||||
if (!selected) {
|
||||
detail.innerHTML = `
|
||||
<div class="hot-money-profile-empty-v2">
|
||||
<i data-lucide="contact" aria-hidden="true"></i>
|
||||
<strong>${profiles.length ? "选择一位游资查看档案" : "暂无可展示的游资档案"}</strong>
|
||||
</div>`;
|
||||
} else {
|
||||
const organizations = selected.organizations || [];
|
||||
detail.innerHTML = `
|
||||
<header class="hot-money-profile-detail-head-v2">
|
||||
<span class="hot-money-profile-avatar-v2">${escapeHtml(selected.name.slice(0, 2))}</span>
|
||||
<div>
|
||||
<small>游资档案</small>
|
||||
<h3>${escapeHtml(selected.name)}</h3>
|
||||
<span>${organizations.length ? `关联 ${organizations.length} 个公开席位` : "暂无关联席位"}</span>
|
||||
</div>
|
||||
</header>
|
||||
<section class="hot-money-profile-section-v2">
|
||||
<h4>人物简介</h4>
|
||||
<p class="${selected.description ? "" : "is-empty"}">${escapeHtml(selected.description || "名录暂未收录该游资的公开简介。")}</p>
|
||||
</section>
|
||||
<section class="hot-money-profile-section-v2 hot-money-profile-org-section-v2">
|
||||
<div class="hot-money-profile-section-title-v2">
|
||||
<h4>关联营业部</h4>
|
||||
<span>${organizations.length} 个</span>
|
||||
</div>
|
||||
<div class="hot-money-profile-organizations-v2">
|
||||
${organizations.length ? organizations.map((organization) => `
|
||||
<span><i data-lucide="building-2" aria-hidden="true"></i>${escapeHtml(organization)}</span>
|
||||
`).join("") : '<p class="is-empty">名录暂未收录关联营业部。</p>'}
|
||||
</div>
|
||||
</section>
|
||||
${payload.meta?.notice ? `<p class="hot-money-profile-notice-v2">${escapeHtml(payload.meta.notice)}</p>` : ""}`;
|
||||
}
|
||||
refreshIcons();
|
||||
}
|
||||
|
||||
async function loadDragonTiger(force = false) {
|
||||
const requestedDate = elements.tradeDate.value;
|
||||
if (
|
||||
!force
|
||||
&& ["success", "empty", "partial", "unavailable"].includes(state.dragonTiger?.meta?.status)
|
||||
&& (state.dragonTiger?.meta?.requested_date || state.dragonTiger?.meta?.trade_date) === requestedDate
|
||||
) {
|
||||
renderDragonTiger();
|
||||
return;
|
||||
}
|
||||
setStatus("正在加载龙虎榜");
|
||||
try {
|
||||
const query = new URLSearchParams({ trade_date: requestedDate });
|
||||
if (force) query.set("force", "1");
|
||||
const payload = await apiRequest(`/api/dragon-tiger?${query}`);
|
||||
state.dragonTiger = payload;
|
||||
renderDragonTiger();
|
||||
const statusLabel = payload.meta.status === "error"
|
||||
? "龙虎榜数据暂不可用"
|
||||
: payload.meta.status === "empty"
|
||||
? "当日暂无公开游资明细"
|
||||
: payload.meta.status === "partial"
|
||||
? "当日有龙虎榜,暂无命名游资明细"
|
||||
: payload.meta.status === "unavailable" ? "龙虎榜数据暂不可用" : "龙虎榜明细";
|
||||
setStatus(`${statusLabel} · 龙虎榜已加载`);
|
||||
} catch (error) {
|
||||
showToast(error.message || "龙虎榜加载失败");
|
||||
setStatus("龙虎榜加载失败");
|
||||
}
|
||||
}
|
||||
|
||||
function renderDragonTiger() {
|
||||
const payload = state.dragonTiger;
|
||||
if (!payload) return;
|
||||
const summary = payload.summary || {};
|
||||
if (state.dragonViewMode === "daily") setText("dragonDateLabel", `数据日期 ${payload.meta.trade_date}`);
|
||||
const status = payload.meta?.status || "empty";
|
||||
const hasRecognizedTraders = (payload.traders || []).some((item) => item.identity_type === "trader" && item.recognized !== false);
|
||||
const showEmptyState = !hasRecognizedTraders
|
||||
&& !(payload.unclassified_seats || []).length
|
||||
&& ["empty", "error", "unavailable"].includes(status);
|
||||
const dailyVisible = state.dragonViewMode === "daily";
|
||||
document.querySelector("#dragonProfilesContent").hidden = dailyVisible;
|
||||
document.querySelector("#dragonEmptyState").hidden = !dailyVisible || !showEmptyState;
|
||||
document.querySelector("#dragonDailyContent").hidden = !dailyVisible || showEmptyState;
|
||||
if (showEmptyState) {
|
||||
const unavailable = ["error", "unavailable"].includes(status);
|
||||
setText("dragonEmptyTitle", unavailable ? "龙虎榜数据暂不可用" : `${payload.meta?.trade_date || "该交易日"} 暂无龙虎榜明细`);
|
||||
setText("dragonEmptyDescription", unavailable
|
||||
? "当前数据暂未完成更新,可稍后重新检查或查看前一交易日。"
|
||||
: "龙虎榜明细通常在交易日盘后陆续披露,可稍后刷新或查看前一交易日。");
|
||||
}
|
||||
document.querySelector("#dragonSummary").innerHTML = [
|
||||
["上榜游资", `${number(summary.trader_count)} 位`, ""],
|
||||
["操作明细", `${number(summary.operation_count)} 条`, ""],
|
||||
["席位净买入", formatMoneyMillion(summary.seat_net_buy_million), changeClass(summary.seat_net_buy_million)],
|
||||
["活跃股票", `${number(summary.active_stock_count)} 只`, ""],
|
||||
].map(([label, value, className]) => `<div class="dragon-metric"><span>${label}</span><strong class="${className}">${value}</strong></div>`).join("");
|
||||
|
||||
renderDragonTraderList();
|
||||
renderUnclassifiedSeats();
|
||||
}
|
||||
|
||||
function renderDragonTraderList() {
|
||||
const payload = state.dragonTiger;
|
||||
if (!payload) return;
|
||||
let traders = [...(payload.traders || [])].filter((item) => item.identity_type === "trader" && item.recognized !== false);
|
||||
if (state.dragonFilter === "buy") traders = traders.filter((item) => number(item.net_buy_million) > 0);
|
||||
if (state.dragonFilter === "sell") traders = traders.filter((item) => number(item.net_buy_million) < 0);
|
||||
if (state.dragonFilter === "unclassified") traders = [];
|
||||
if (state.dragonQuery) {
|
||||
traders = traders.filter((item) => {
|
||||
const searchable = [
|
||||
item.name,
|
||||
...(item.operations || []).flatMap((operation) => [operation.code, operation.name, operation.seat_name]),
|
||||
].join(" ").toLowerCase();
|
||||
return searchable.includes(state.dragonQuery);
|
||||
});
|
||||
}
|
||||
|
||||
const container = document.querySelector("#dragonTraderList");
|
||||
let emptyMessage = "没有符合当前条件的游资操作";
|
||||
if (!Array.isArray(payload.traders)) emptyMessage = "龙虎榜数据格式暂不可用,请稍后重试";
|
||||
else if (["error", "unavailable"].includes(payload.meta?.status)) emptyMessage = "龙虎榜数据暂不可用,请稍后重试";
|
||||
else if (payload.meta?.status === "empty") emptyMessage = "该交易日暂无游资每日明细";
|
||||
else if (payload.meta?.status === "partial") emptyMessage = `当日有 ${number(payload.summary?.official_stock_count)} 只股票上榜,但暂无可识别的游资明细`;
|
||||
if (!traders.some((item) => item.id === state.selectedDragonTraderId)) {
|
||||
state.selectedDragonTraderId = traders[0]?.id || "";
|
||||
}
|
||||
const cardMarkup = traders.map((trader, index) => {
|
||||
const description = trader.description || `${number(trader.stock_count)} 只股票,${number(trader.operation_count)} 笔操作`;
|
||||
return `
|
||||
<article class="dragon-trader-card dealing ${trader.id === state.selectedDragonTraderId ? "selected" : ""}" data-dragon-card="${escapeHtml(trader.id)}" aria-hidden="true" style="--deal-delay:${Math.min(index * 38, 650)}ms">
|
||||
<span class="dragon-card-rank">${String(index + 1).padStart(2, "0")}</span>
|
||||
<span class="dragon-card-monogram">${escapeHtml(trader.name.slice(0, 2))}</span>
|
||||
<span class="dragon-card-copy"><strong>${escapeHtml(trader.name)}</strong><q title="${escapeHtml(description)}">${escapeHtml(description)}</q></span>
|
||||
<span class="dragon-card-stats"><small>${number(trader.stock_count)} 股 · ${number(trader.operation_count)} 笔</small><b class="${changeClass(trader.net_buy_million)}">${formatMoneyMillion(trader.net_buy_million)}</b></span>
|
||||
</article>`;
|
||||
}).join("");
|
||||
const hitZoneMarkup = traders.map((trader) => `
|
||||
<button type="button" class="dragon-card-hit-zone" data-dragon-trader="${escapeHtml(trader.id)}" aria-label="查看 ${escapeHtml(trader.name)} 当日操作" aria-pressed="${trader.id === state.selectedDragonTraderId}"></button>
|
||||
`).join("");
|
||||
container.innerHTML = traders.length
|
||||
? `${cardMarkup}<div class="dragon-card-hit-layer">${hitZoneMarkup}</div>`
|
||||
: emptyStateHtml(state.dragonFilter === "unclassified" ? "待归类席位请在下方管理" : emptyMessage, { className: "dragon-empty" });
|
||||
container.querySelectorAll("[data-dragon-card]").forEach((card) => {
|
||||
card.addEventListener("animationend", () => card.classList.remove("dealing"), { once: true });
|
||||
});
|
||||
container.querySelectorAll("[data-dragon-trader]").forEach((hitZone) => {
|
||||
const setHovered = (hovered) => {
|
||||
container.querySelector(`[data-dragon-card="${CSS.escape(hitZone.dataset.dragonTrader)}"]`)?.classList.toggle("hovered", hovered);
|
||||
};
|
||||
hitZone.addEventListener("pointerenter", () => setHovered(true));
|
||||
hitZone.addEventListener("pointerleave", () => setHovered(false));
|
||||
hitZone.addEventListener("focus", () => setHovered(true));
|
||||
hitZone.addEventListener("blur", () => setHovered(false));
|
||||
hitZone.addEventListener("click", () => {
|
||||
state.selectedDragonTraderId = hitZone.dataset.dragonTrader;
|
||||
container.querySelectorAll("[data-dragon-card]").forEach((card) => {
|
||||
card.classList.toggle("selected", card.dataset.dragonCard === state.selectedDragonTraderId);
|
||||
});
|
||||
container.querySelectorAll("[data-dragon-trader]").forEach((item) => {
|
||||
item.setAttribute("aria-pressed", String(item.dataset.dragonTrader === state.selectedDragonTraderId));
|
||||
});
|
||||
renderDragonTraderDetail(traders.find((item) => item.id === state.selectedDragonTraderId));
|
||||
});
|
||||
});
|
||||
requestAnimationFrame(() => layoutDragonCards(container));
|
||||
renderDragonTraderDetail(traders.find((item) => item.id === state.selectedDragonTraderId));
|
||||
}
|
||||
|
||||
function layoutDragonCards(container = document.querySelector("#dragonTraderList")) {
|
||||
if (!container) return;
|
||||
const cards = [...container.querySelectorAll(".dragon-trader-card")];
|
||||
const hitZones = [...container.querySelectorAll(".dragon-card-hit-zone")];
|
||||
if (!cards.length) return;
|
||||
const compact = window.innerWidth <= 720;
|
||||
const cardWidth = compact ? 148 : 176;
|
||||
const available = Math.max(cardWidth, container.clientWidth - (compact ? 30 : 72));
|
||||
const spread = Math.min(available - cardWidth, compact ? 310 : 1050);
|
||||
const step = cards.length > 1 ? Math.min(cardWidth + 14, spread / (cards.length - 1)) : 0;
|
||||
const center = (cards.length - 1) / 2;
|
||||
container.style.setProperty("--dragon-card-width", `${cardWidth}px`);
|
||||
cards.forEach((card, index) => {
|
||||
const x = (index - center) * step;
|
||||
card.style.setProperty("--card-x", `${x.toFixed(2)}px`);
|
||||
card.style.setProperty("--card-rotation", "0deg");
|
||||
card.style.setProperty("--card-y", "0px");
|
||||
card.style.zIndex = String(index + 1);
|
||||
const hitZone = hitZones[index];
|
||||
if (hitZone) {
|
||||
const zoneWidth = index === cards.length - 1 ? cardWidth : Math.max(18, step);
|
||||
hitZone.style.left = `calc(50% + ${(x - cardWidth / 2).toFixed(2)}px)`;
|
||||
hitZone.style.width = `${zoneWidth.toFixed(2)}px`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderDragonTraderDetail(trader) {
|
||||
const container = document.querySelector("#dragonTraderDetail");
|
||||
if (!trader) {
|
||||
container.hidden = true;
|
||||
renderEmptyState(container, "选择一位游资查看操作明细", { className: "dragon-empty" });
|
||||
return;
|
||||
}
|
||||
container.hidden = false;
|
||||
container.innerHTML = `
|
||||
<header class="dragon-detail-header">
|
||||
<div><span>当日操作明细</span><h3>${escapeHtml(trader.name)}</h3><p>${escapeHtml(trader.description || "按当日公开龙虎榜席位汇总")}</p></div>
|
||||
<dl><div><dt>买入</dt><dd class="up">${formatMoneyMillion(trader.buy_million)}</dd></div><div><dt>卖出</dt><dd class="down">${formatMoneyMillion(trader.sell_million)}</dd></div><div><dt>净额</dt><dd class="${changeClass(trader.net_buy_million)}">${formatMoneyMillion(trader.net_buy_million)}</dd></div></dl>
|
||||
</header>
|
||||
<div class="trader-operations table-frame tbl-wrap">
|
||||
<table class="data-table tbl dragon-operation-table">
|
||||
<colgroup><col class="dragon-col-index"><col class="dragon-col-stock"><col class="dragon-col-direction"><col class="dragon-col-number"><col class="dragon-col-number"><col class="dragon-col-number"><col class="dragon-col-number"><col class="dragon-col-seat"><col class="dragon-col-reason"></colgroup>
|
||||
<thead><tr><th class="row-number num">序号</th><th>股票</th><th>方向</th><th class="number num">涨幅(%)</th><th class="number num">买入(百万)</th><th class="number num">卖出(百万)</th><th class="number num">净额(百万)</th><th>关联席位</th><th class="reason-column">标签 / 上榜原因</th></tr></thead>
|
||||
<tbody>${(trader.operations || []).map((operation, index) => `
|
||||
<tr data-code="${escapeHtml(operation.code)}">
|
||||
<td class="row-number num">${index + 1}</td>
|
||||
<td><strong class="sname">${escapeHtml(operation.name)}</strong><span class="scode">${escapeHtml(operation.code)}</span></td>
|
||||
<td><span class="direction-label ${changeClass(operation.net_buy_million)}">${escapeHtml(operation.direction)}</span></td>
|
||||
<td class="number num ${operation.change == null ? "" : changeClass(operation.change)}">${operation.change == null ? "" : signed(operation.change)}</td>
|
||||
<td class="number num">${operation.buy_million == null ? "" : formatNumber(operation.buy_million, 2)}</td>
|
||||
<td class="number num">${operation.sell_million == null ? "" : formatNumber(operation.sell_million, 2)}</td>
|
||||
<td class="number num ${operation.net_buy_million == null ? "" : changeClass(operation.net_buy_million)}">${operation.net_buy_million == null ? "" : signed(operation.net_buy_million)}</td>
|
||||
<td class="seat-cell" title="${escapeHtml(operation.seat_name)}">${escapeHtml(operation.seat_name)}</td>
|
||||
<td class="reason-column" title="${escapeHtml([operation.tag, operation.reason].filter((item) => item && item !== "--").join(" · "))}">${escapeHtml(operation.tag && operation.tag !== "--" ? operation.tag : operation.reason && operation.reason !== "--" ? operation.reason : "")}</td>
|
||||
</tr>`).join("")}</tbody>
|
||||
</table>
|
||||
</div>`;
|
||||
bindStockRows(container);
|
||||
markAutoSortableHeaders(container);
|
||||
}
|
||||
|
||||
function renderUnclassifiedSeats() {
|
||||
const seats = state.dragonTiger?.unclassified_seats || [];
|
||||
const canManage = state.user?.role === "admin";
|
||||
document.querySelector("#dragonUnclassifiedSection").hidden = !canManage || seats.length === 0;
|
||||
document.querySelector("#dragonUnclassifiedFilter").hidden = !canManage || seats.length === 0;
|
||||
if (!seats.length && state.dragonFilter === "unclassified") {
|
||||
state.dragonFilter = "all";
|
||||
document.querySelectorAll("[data-dragon-filter]").forEach((button) => {
|
||||
button.classList.toggle("active", button.dataset.dragonFilter === "all");
|
||||
});
|
||||
renderDragonTraderList();
|
||||
}
|
||||
setText("unclassifiedCount", `${seats.length} 个`);
|
||||
const list = document.querySelector("#unclassifiedSeatList");
|
||||
list.innerHTML = seats.map((seat, index) => `
|
||||
<form class="unclassified-seat-row" data-unclassified-index="${index}">
|
||||
<span class="unclassified-seat-name" title="${escapeHtml(seat.seat_name)}">${escapeHtml(seat.seat_name)}</span>
|
||||
<span class="unclassified-seat-stats">${number(seat.operation_count)} 笔 · ${number(seat.stock_count)} 股</span>
|
||||
<strong class="${changeClass(seat.net_buy_million)}">${formatMoneyMillion(seat.net_buy_million)}</strong>
|
||||
<input type="text" maxlength="50" placeholder="输入游资名" aria-label="${escapeHtml(seat.seat_name)}的游资名" required>
|
||||
<button class="button" type="submit">归类</button>
|
||||
</form>
|
||||
`).join("") || emptyStateHtml("当前席位均已归类");
|
||||
list.querySelectorAll(".unclassified-seat-row").forEach((form) => {
|
||||
form.addEventListener("submit", saveSeatAlias);
|
||||
});
|
||||
}
|
||||
|
||||
function dragonIdentityLabel(type) {
|
||||
return { trader: "游资", institution: "机构", channel: "通道", unclassified: "待归类" }[type] || "席位";
|
||||
}
|
||||
|
||||
async function saveSeatAlias(event) {
|
||||
event.preventDefault();
|
||||
const form = event.currentTarget;
|
||||
const seat = state.dragonTiger?.unclassified_seats?.[number(form.dataset.unclassifiedIndex)];
|
||||
const alias = form.querySelector("input").value.trim();
|
||||
if (!seat || !alias) {
|
||||
showToast("请输入游资名");
|
||||
return;
|
||||
}
|
||||
const button = form.querySelector("button");
|
||||
button.disabled = true;
|
||||
try {
|
||||
await apiRequest("/api/seat-aliases", "POST", { seat_name: seat.seat_name, alias });
|
||||
state.dragonTiger = null;
|
||||
await loadDragonTiger();
|
||||
showToast(`已将席位归类为 ${alias}`);
|
||||
} catch (error) {
|
||||
showToast(error.message);
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
/* PRESERVATION-SOURCE-END app.js:2660-3028 */
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,96 @@
|
||||
window.XiaobaiPageModules.register("ladder", ["ladderView"]);
|
||||
|
||||
/* 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;
|
||||
setText("maxHeight", highest ? `最高 ${highest} 板` : "暂无");
|
||||
container.innerHTML = ladders.slice(0, 5).map((group) => {
|
||||
const allNames = group.stocks.map((stock) => stock.name).filter(Boolean);
|
||||
const visibleNames = allNames.slice(0, 3).join("、");
|
||||
const suffix = allNames.length > 3 ? ` <em>等 ${number(group.count)} 只</em>` : "";
|
||||
return `<div class="pool-side-group">
|
||||
<div><strong>${escapeHtml(group.label)}</strong><small>${number(group.count)} 只</small></div>
|
||||
<p title="${escapeHtml(allNames.join("、"))}">${escapeHtml(visibleNames || "--")}${suffix}</p>
|
||||
</div>`;
|
||||
}).join("") || emptyStateHtml("暂无梯队数据");
|
||||
}
|
||||
|
||||
function renderSectorMini(sectors) {
|
||||
document.querySelector("#sectorMini").innerHTML = sectors.slice(0, 7).map((sector) => `
|
||||
<div class="pool-hot-row"><strong title="${escapeHtml(sector.name)}">${escapeHtml(sector.name)}</strong><span>${number(sector.count)}</span></div>
|
||||
`).join("") || emptyStateHtml("暂无板块数据");
|
||||
}
|
||||
|
||||
function renderLadderBoard(ladders) {
|
||||
const container = document.querySelector("#ladderBoard");
|
||||
const insights = document.querySelector("#ladderInsights");
|
||||
const ordered = [...ladders].sort((left, right) => number(right.level) - number(left.level));
|
||||
const maxLevel = ordered.length ? Math.max(...ordered.map((group) => number(group.level))) : 0;
|
||||
const topVisibleLevel = Math.max(5, maxLevel);
|
||||
const groupMap = new Map(ordered.map((group) => [number(group.level), group]));
|
||||
const displayGroups = Array.from({ length: topVisibleLevel }, (_, index) => {
|
||||
const level = topVisibleLevel - index;
|
||||
return groupMap.get(level) || { level, label: level === 1 ? "首板" : level === 5 && maxLevel < 5 ? "5板+" : `${level}板`, count: 0, stocks: [] };
|
||||
});
|
||||
const total = ordered.reduce((sum, group) => sum + number(group.count), 0);
|
||||
const spaceStocks = ordered.find((group) => number(group.level) === maxLevel)?.stocks || [];
|
||||
const currentDate = displayCompactDate(state.dashboard?.meta?.trade_date || elements.tradeDate.value);
|
||||
const previousDate = displayCompactDate(state.dashboard?.meta?.previous_trade_date || "");
|
||||
setText("ladderDateRange", `数据日期 ${currentDate}`);
|
||||
container.innerHTML = displayGroups.map((group) => {
|
||||
const level = number(group.level);
|
||||
const limit = level === 1 || level === 2 ? 8 : 99;
|
||||
const expanded = state.expandedLadderLevels.has(level);
|
||||
const groupStocks = [...(group.stocks || [])].sort((left, right) => {
|
||||
if (state.ladderSortMode === "open") {
|
||||
return number(left.open_times) - number(right.open_times)
|
||||
|| String(left.first_time || "99:99:99").localeCompare(String(right.first_time || "99:99:99"));
|
||||
}
|
||||
return String(left.first_time || "99:99:99").localeCompare(String(right.first_time || "99:99:99"));
|
||||
});
|
||||
const stocks = expanded ? groupStocks : groupStocks.slice(0, limit);
|
||||
const remaining = Math.max(0, groupStocks.length - stocks.length);
|
||||
const label = group.label || (level === 1 ? "首板" : level === 5 && maxLevel < 5 ? "5板+" : `${level}板`);
|
||||
const color = { 1: "#2563eb", 2: "#16a34a", 3: "#d97706", 4: "#e04536" }[level] || "#9ca3af";
|
||||
return `
|
||||
<section class="market-ladder-tier ${number(group.count) ? "" : "is-gap"}" data-ladder-level-card="${level}">
|
||||
<div class="market-ladder-label" style="--tier-color:${color}"><div class="market-ladder-level"><span class="market-ladder-dot"></span>${escapeHtml(label)}</div><div class="market-ladder-count">${number(group.count)} 只</div>${number(group.count) && level > 1 ? `<div class="market-ladder-rate">${escapeHtml(label)} · <b>${formatNumber(number(group.count) / Math.max(number(groupMap.get(level - 1)?.count), 1) * 100, 1)}%</b></div>` : ""}</div>
|
||||
<div class="market-ladder-stocks">${stocks.length ? stocks.map((stock) => {
|
||||
const onePrice = String(stock.first_time || "").startsWith("09:25") && number(stock.open_times) === 0;
|
||||
const broken = number(stock.open_times) >= 6;
|
||||
const amount = number(stock.seal_amount_million) ? `封单 ${formatNumber(stock.seal_amount_million, 0)} 万` : `成交 ${formatNumber(stock.amount_billion, 1)} 亿`;
|
||||
return `<button type="button" class="market-ladder-stock" data-code="${escapeHtml(stock.code)}" aria-label="查看 ${escapeHtml(stock.name)} ${escapeHtml(stock.code)}详情">
|
||||
<span class="market-ladder-stock-first"><strong>${escapeHtml(stock.name)}</strong><small class="stock-code">${escapeHtml(stock.code)}</small><span class="market-ladder-tags">${onePrice ? '<em class="market-ladder-tag one-price">一字</em>' : ""}${broken ? `<em class="market-ladder-tag broken">烂板×${number(stock.open_times)}</em>` : ""}</span></span>
|
||||
<span class="market-ladder-stock-second"><b>${escapeHtml(stock.sector || stock.reason || "其他")}</b><small>${stock.first_time && stock.first_time !== "--" ? escapeHtml(stock.first_time) : "时间待校正"}</small><small>${amount}</small></span>
|
||||
</button>`;
|
||||
}).join("") : `<div class="market-ladder-gap-note">${level >= maxLevel ? `断层 · ${escapeHtml(label)}及以上空缺` : "该层暂时空缺"}</div>`}${groupStocks.length > limit ? `<button class="market-ladder-more" type="button" data-ladder-level="${level}">${expanded ? "收起" : `展开剩余 ${remaining} 只`}<i data-lucide="chevron-${expanded ? "up" : "down"}"></i></button>` : ""}</div>
|
||||
</section>`;
|
||||
}).join("");
|
||||
const structureRows = displayGroups.filter((group) => number(group.count) || number(group.level) <= maxLevel + 1);
|
||||
const maxCount = Math.max(1, ...structureRows.map((group) => number(group.count)));
|
||||
const rateRows = (state.dashboard?.limit_performance || []).map((row) => ({
|
||||
label: `${row.label || (number(row.level) === 1 ? "昨日首板" : `昨日${number(row.level)}板`)} → 今日`,
|
||||
value: clamp(number(row.advance_rate), 0, 100),
|
||||
}));
|
||||
const previousMax = Math.max(0, ...(state.dashboard?.yesterday_limits || []).map((row) => number(row.prior_streak)));
|
||||
const spaceChange = previousMax && maxLevel < previousMax ? `较昨日 ${previousMax} 板 ↓ 空间压缩` : previousMax && maxLevel > previousMax ? `较昨日 ${previousMax} 板 ↑ 高度抬升` : "高度与昨日接近";
|
||||
const spaceNote = maxLevel >= 5 ? "高位梯队仍有辨识度,重点观察承接而非单看高度。" : maxLevel >= 3 ? "空间位于中段,梯队延续性比绝对高度更重要。" : "高度受到压缩,先观察首板向二板的结构修复。";
|
||||
const strongestGroup = structureRows.reduce((best, group) => number(group.count) > number(best?.count) ? group : best, structureRows[0]);
|
||||
insights.innerHTML = `
|
||||
<section class="market-ladder-insight-card market-ladder-apex-card"><header><h3>空间板</h3><span>市场高度</span></header><div class="market-ladder-apex"><div><strong>${maxLevel ? `${maxLevel} 板` : "--"}</strong><em>${escapeHtml(spaceChange)}</em></div><p>${spaceStocks.length ? spaceStocks.map((stock) => `<b>${escapeHtml(stock.name)}</b>(${escapeHtml(stock.sector || "其他")})`).join(" · ") : "暂无空间板"}</p></div><p>${spaceNote}</p></section>
|
||||
<section class="market-ladder-insight-card"><header><h3>梯队结构</h3><span>完整度</span></header><div class="market-ladder-pyramid">${structureRows.map((group) => `<div class="market-ladder-pyramid-row ${number(group.count) ? "" : "is-gap"}"><span>${escapeHtml(group.label || `${number(group.level)}板`)}</span><i><b style="width:${Math.max(number(group.count) ? 8 : 100, number(group.count) / maxCount * 100)}%"></b></i><strong>${number(group.count) ? `${number(group.count)} 只` : "断层"}</strong></div>`).join("")}</div><p>断层越少,梯队从低位向高位传导越连贯。当前腰部为 <b>${escapeHtml(strongestGroup?.label || "--")}</b>。</p></section>
|
||||
<section class="market-ladder-insight-card"><header><h3>晋级率参考</h3><span>昨日梯队 → 今日</span></header><div class="market-ladder-rate-list">${rateRows.length ? rateRows.map((row) => `<div><span>${escapeHtml(row.label)}</span><i><b class="${row.value === 0 ? "is-zero" : row.value < 20 ? "is-low" : ""}" style="width:${Math.max(row.value, row.value > 0 ? 2 : 0)}%"></b></i><strong class="${row.value === 0 ? "is-zero" : row.value < 20 ? "is-low" : ""}">${formatNumber(row.value, 1)}%</strong></div>`).join("") : '<div class="empty-state">暂无可比梯队</div>'}</div><small class="market-ladder-source">数据来自“涨停表现”页 · 昨日梯队样本</small></section>`;
|
||||
container.querySelectorAll("[data-ladder-level]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const level = number(button.dataset.ladderLevel);
|
||||
if (state.expandedLadderLevels.has(level)) state.expandedLadderLevels.delete(level);
|
||||
else state.expandedLadderLevels.add(level);
|
||||
renderLadderBoard(state.dashboard?.ladders || []);
|
||||
});
|
||||
});
|
||||
bindStockRows(container);
|
||||
refreshIcons();
|
||||
}
|
||||
|
||||
/* PRESERVATION-SOURCE-END app.js:2125-2216 */
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,497 @@
|
||||
window.XiaobaiPageModules.register("mentor", ["mentorView"], {
|
||||
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) {
|
||||
renderMentorWorkspace();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const query = new URLSearchParams({ trade_date: elements.tradeDate.value });
|
||||
const payload = await apiRequest(`/api/mentors/setup?${query}`);
|
||||
payload.requestedDate = requestedDate;
|
||||
if (!payload.preferences_configured) {
|
||||
payload.mentors.sort((first, second) => {
|
||||
if (Boolean(first.private) !== Boolean(second.private)) return first.private ? -1 : 1;
|
||||
return String(first.name || "").localeCompare(String(second.name || ""), "zh-CN");
|
||||
});
|
||||
payload.mentors.forEach((mentor, index) => { mentor.sort_order = index; });
|
||||
}
|
||||
state.mentorSetup = payload;
|
||||
const selectedExists = payload.mentors.some((item) => item.id === state.selectedMentorId);
|
||||
state.selectedMentorId = selectedExists ? state.selectedMentorId : payload.mentors[0]?.id || "";
|
||||
state.mentorMessages = await loadMentorMessages();
|
||||
renderMentorWorkspace();
|
||||
} catch (error) {
|
||||
showMentorNotice(error.message || "问师模块加载失败");
|
||||
showToast(error.message || "问师模块加载失败");
|
||||
}
|
||||
}
|
||||
|
||||
function renderMentorWorkspace() {
|
||||
const setup = state.mentorSetup;
|
||||
if (!setup) return;
|
||||
const selected = setup.mentors.find((item) => item.id === state.selectedMentorId) || null;
|
||||
setText("mentorDataDate", `数据日期 ${displayCompactDate(setup.trade_date)}`);
|
||||
setText("activeMentorName", selected?.name || "--");
|
||||
setText("mobileActiveMentorName", selected?.name || "选择思维模型");
|
||||
document.querySelector("#activeMentorBadges").innerHTML = selected ? renderMentorBadges(selected, true) : "";
|
||||
setText("activeMentorEvidence", selected?.evidence?.note || selected?.description || "--");
|
||||
document.querySelector("#activeMentorFocus").innerHTML = (selected?.focus || []).slice(0, 4)
|
||||
.map((item) => `<span>${escapeHtml(item)}</span>`).join("");
|
||||
renderMentorDirectory();
|
||||
renderMentorMessages();
|
||||
}
|
||||
|
||||
function renderMentorDirectory() {
|
||||
const mentors = state.mentorSetup?.mentors || [];
|
||||
const query = state.mentorQuery;
|
||||
const filtered = mentors.filter((mentor) => {
|
||||
if (state.mentorSortMode) return true;
|
||||
if (state.mentorGrade !== "all" && mentor.evidence?.grade !== state.mentorGrade) return false;
|
||||
if (!query) return true;
|
||||
const haystack = [
|
||||
mentor.name,
|
||||
mentor.description,
|
||||
mentor.tagline,
|
||||
mentor.evidence?.label,
|
||||
mentor.evidence?.note,
|
||||
...(mentor.focus || []),
|
||||
].filter(Boolean).join(" ").toLocaleLowerCase("zh-CN");
|
||||
return haystack.includes(query);
|
||||
});
|
||||
setText("mentorCount", filtered.length === mentors.length ? `${mentors.length} 位` : `${filtered.length} / ${mentors.length} 位`);
|
||||
const sortToggle = document.querySelector("#mentorSortToggle");
|
||||
sortToggle.classList.toggle("active", state.mentorSortMode);
|
||||
sortToggle.setAttribute("aria-pressed", String(state.mentorSortMode));
|
||||
sortToggle.querySelector("span").textContent = state.mentorSortMode ? "完成" : "整理";
|
||||
document.querySelector("#mentorSortHint").hidden = !state.mentorSortMode;
|
||||
document.querySelector("#mentorSearchInput").disabled = state.mentorSortMode;
|
||||
document.querySelectorAll("[data-mentor-grade]").forEach((button) => {
|
||||
button.disabled = state.mentorSortMode;
|
||||
});
|
||||
const container = document.querySelector("#mentorList");
|
||||
container.classList.toggle("is-sorting", state.mentorSortMode);
|
||||
container.innerHTML = filtered.map((mentor) => {
|
||||
const group = mentors.filter((item) => Boolean(item.pinned) === Boolean(mentor.pinned));
|
||||
const groupIndex = group.findIndex((item) => item.id === mentor.id);
|
||||
return `
|
||||
<article class="mentor-option ${mentor.id === state.selectedMentorId ? "active" : ""} ${mentor.pinned ? "is-pinned" : ""}"
|
||||
data-mentor-card="${escapeHtml(mentor.id)}" draggable="${state.mentorSortMode && !state.mentorSavingPreferences}">
|
||||
<button type="button" class="mentor-option-main" data-mentor-id="${escapeHtml(mentor.id)}" aria-pressed="${mentor.id === state.selectedMentorId}" ${state.mentorLoading ? "disabled" : ""}>
|
||||
<span class="mentor-option-copy">
|
||||
<span class="mentor-option-heading">
|
||||
<strong>${escapeHtml(mentor.name)}</strong>
|
||||
<span class="mentor-option-badges">${renderMentorBadges(mentor)}</span>
|
||||
</span>
|
||||
<em title="${escapeHtml(mentor.description || "")}">${escapeHtml(mentor.description || mentor.tagline || "思维模型")}</em>
|
||||
<span class="mentor-option-meta">
|
||||
${mentor.evidence?.label ? `<span class="mentor-evidence-source" title="${escapeHtml(mentor.evidence?.note || "素材说明")}">${escapeHtml(mentor.evidence.label)}</span>` : ""}
|
||||
${(mentor.focus || []).slice(0, 2).map((item) => `<span>#${escapeHtml(item)}</span>`).join("")}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<span class="mentor-option-tools">
|
||||
<button type="button" class="mentor-pin-button ${mentor.pinned ? "active" : ""}" data-mentor-pin="${escapeHtml(mentor.id)}"
|
||||
aria-label="${mentor.pinned ? "取消置顶" : "置顶"}${escapeHtml(mentor.name)}" title="${mentor.pinned ? "取消置顶" : "置顶"}" ${state.mentorSavingPreferences ? "disabled" : ""}>
|
||||
<i data-lucide="pin"></i>
|
||||
</button>
|
||||
${state.mentorSortMode ? `
|
||||
<button type="button" class="mentor-order-button" data-mentor-move="up" data-mentor-target="${escapeHtml(mentor.id)}" aria-label="上移${escapeHtml(mentor.name)}" title="上移" ${groupIndex <= 0 || state.mentorSavingPreferences ? "disabled" : ""}><i data-lucide="chevron-up"></i></button>
|
||||
<button type="button" class="mentor-order-button" data-mentor-move="down" data-mentor-target="${escapeHtml(mentor.id)}" aria-label="下移${escapeHtml(mentor.name)}" title="下移" ${groupIndex >= group.length - 1 || state.mentorSavingPreferences ? "disabled" : ""}><i data-lucide="chevron-down"></i></button>
|
||||
` : ""}
|
||||
</span>
|
||||
</article>
|
||||
`;
|
||||
}).join("");
|
||||
document.querySelector("#mentorListEmpty").hidden = filtered.length > 0;
|
||||
document.querySelectorAll("[data-mentor-id]").forEach((button) => {
|
||||
button.addEventListener("click", () => selectMentor(button.dataset.mentorId));
|
||||
});
|
||||
document.querySelectorAll("[data-mentor-pin]").forEach((button) => {
|
||||
button.addEventListener("click", () => toggleMentorPin(button.dataset.mentorPin));
|
||||
});
|
||||
document.querySelectorAll("[data-mentor-move]").forEach((button) => {
|
||||
button.addEventListener("click", () => moveMentor(button.dataset.mentorTarget, button.dataset.mentorMove));
|
||||
});
|
||||
document.querySelectorAll("[data-mentor-card]").forEach((card) => {
|
||||
card.addEventListener("dragstart", handleMentorDragStart);
|
||||
card.addEventListener("dragover", handleMentorDragOver);
|
||||
card.addEventListener("drop", handleMentorDrop);
|
||||
card.addEventListener("dragend", clearMentorDragState);
|
||||
});
|
||||
refreshIcons();
|
||||
}
|
||||
|
||||
function toggleMentorSortMode() {
|
||||
state.mentorSortMode = !state.mentorSortMode;
|
||||
if (state.mentorSortMode) {
|
||||
state.mentorQuery = "";
|
||||
state.mentorGrade = "all";
|
||||
document.querySelector("#mentorSearchInput").value = "";
|
||||
document.querySelectorAll("[data-mentor-grade]").forEach((button) => {
|
||||
button.classList.toggle("active", button.dataset.mentorGrade === "all");
|
||||
});
|
||||
}
|
||||
renderMentorDirectory();
|
||||
}
|
||||
|
||||
async function toggleMentorPin(mentorId) {
|
||||
if (state.mentorSavingPreferences) return;
|
||||
const mentors = state.mentorSetup?.mentors || [];
|
||||
const index = mentors.findIndex((item) => item.id === mentorId);
|
||||
if (index < 0) return;
|
||||
const [mentor] = mentors.splice(index, 1);
|
||||
mentor.pinned = !mentor.pinned;
|
||||
if (mentor.pinned) {
|
||||
mentors.unshift(mentor);
|
||||
} else {
|
||||
const firstUnpinned = mentors.findIndex((item) => !item.pinned);
|
||||
mentors.splice(firstUnpinned < 0 ? mentors.length : firstUnpinned, 0, mentor);
|
||||
}
|
||||
normalizeMentorOrder();
|
||||
renderMentorWorkspace();
|
||||
await persistMentorPreferences();
|
||||
}
|
||||
|
||||
async function moveMentor(mentorId, direction) {
|
||||
if (state.mentorSavingPreferences) return;
|
||||
const mentors = state.mentorSetup?.mentors || [];
|
||||
const index = mentors.findIndex((item) => item.id === mentorId);
|
||||
if (index < 0) return;
|
||||
const step = direction === "up" ? -1 : 1;
|
||||
const targetIndex = index + step;
|
||||
if (targetIndex < 0 || targetIndex >= mentors.length) return;
|
||||
if (Boolean(mentors[index].pinned) !== Boolean(mentors[targetIndex].pinned)) return;
|
||||
[mentors[index], mentors[targetIndex]] = [mentors[targetIndex], mentors[index]];
|
||||
normalizeMentorOrder();
|
||||
renderMentorDirectory();
|
||||
await persistMentorPreferences();
|
||||
}
|
||||
|
||||
function handleMentorDragStart(event) {
|
||||
if (!state.mentorSortMode || state.mentorSavingPreferences) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
state.mentorDragId = event.currentTarget.dataset.mentorCard || "";
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
event.dataTransfer.setData("text/plain", state.mentorDragId);
|
||||
event.currentTarget.classList.add("is-dragging");
|
||||
}
|
||||
|
||||
function handleMentorDragOver(event) {
|
||||
const source = state.mentorSetup?.mentors.find((item) => item.id === state.mentorDragId);
|
||||
const target = state.mentorSetup?.mentors.find((item) => item.id === event.currentTarget.dataset.mentorCard);
|
||||
if (!source || !target || Boolean(source.pinned) !== Boolean(target.pinned)) return;
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = "move";
|
||||
event.currentTarget.classList.add("is-drag-over");
|
||||
}
|
||||
|
||||
async function handleMentorDrop(event) {
|
||||
event.preventDefault();
|
||||
const sourceId = state.mentorDragId || event.dataTransfer.getData("text/plain");
|
||||
const targetId = event.currentTarget.dataset.mentorCard || "";
|
||||
clearMentorDragState();
|
||||
if (!sourceId || !targetId || sourceId === targetId) return;
|
||||
const mentors = state.mentorSetup?.mentors || [];
|
||||
const sourceIndex = mentors.findIndex((item) => item.id === sourceId);
|
||||
const targetIndex = mentors.findIndex((item) => item.id === targetId);
|
||||
if (sourceIndex < 0 || targetIndex < 0) return;
|
||||
if (Boolean(mentors[sourceIndex].pinned) !== Boolean(mentors[targetIndex].pinned)) return;
|
||||
const [mentor] = mentors.splice(sourceIndex, 1);
|
||||
const insertionIndex = mentors.findIndex((item) => item.id === targetId);
|
||||
mentors.splice(insertionIndex, 0, mentor);
|
||||
normalizeMentorOrder();
|
||||
renderMentorDirectory();
|
||||
await persistMentorPreferences();
|
||||
}
|
||||
|
||||
function clearMentorDragState() {
|
||||
state.mentorDragId = "";
|
||||
document.querySelectorAll(".mentor-option.is-dragging, .mentor-option.is-drag-over").forEach((item) => {
|
||||
item.classList.remove("is-dragging", "is-drag-over");
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeMentorOrder() {
|
||||
(state.mentorSetup?.mentors || []).forEach((mentor, index) => {
|
||||
mentor.sort_order = index;
|
||||
});
|
||||
}
|
||||
|
||||
async function persistMentorPreferences() {
|
||||
const mentors = state.mentorSetup?.mentors || [];
|
||||
state.mentorSavingPreferences = true;
|
||||
renderMentorDirectory();
|
||||
try {
|
||||
await apiRequest("/api/mentors/preferences", "POST", {
|
||||
order: mentors.map((item) => item.id),
|
||||
pinned: mentors.filter((item) => item.pinned).map((item) => item.id),
|
||||
});
|
||||
} catch (error) {
|
||||
showToast(error.message || "问师顺序保存失败");
|
||||
await loadMentorSetup(true);
|
||||
} finally {
|
||||
state.mentorSavingPreferences = false;
|
||||
renderMentorDirectory();
|
||||
}
|
||||
}
|
||||
|
||||
function renderMentorBadges(mentor, expanded = false) {
|
||||
const badges = [];
|
||||
if (mentor.private) {
|
||||
badges.push('<span class="mentor-badge private" title="仅管理员本人可见"><i data-lucide="lock-keyhole"></i>仅自己</span>');
|
||||
}
|
||||
const grade = mentor.evidence?.grade;
|
||||
if (grade) {
|
||||
badges.push(`<span class="mentor-badge evidence grade-${escapeHtml(grade.toLowerCase())}" title="${escapeHtml(mentor.evidence?.note || "素材等级")}">${escapeHtml(grade)}</span>`);
|
||||
}
|
||||
return badges.join("");
|
||||
}
|
||||
|
||||
function toggleMentorDirectory(open) {
|
||||
const mobileOpen = Boolean(open) && window.innerWidth <= 720;
|
||||
state.mentorDirectoryOpen = mobileOpen;
|
||||
const sidebar = document.querySelector("#mentorView .mentor-sidebar");
|
||||
const backdrop = document.querySelector("#mentorDirectoryBackdrop");
|
||||
const toggle = document.querySelector("#mentorDirectoryToggle");
|
||||
sidebar.classList.toggle("is-open", mobileOpen);
|
||||
backdrop.hidden = !mobileOpen;
|
||||
toggle.setAttribute("aria-expanded", String(mobileOpen));
|
||||
document.body.classList.toggle("mentor-directory-open", mobileOpen);
|
||||
if (mobileOpen) requestAnimationFrame(() => document.querySelector("#mentorSearchInput").focus());
|
||||
}
|
||||
|
||||
async function selectMentor(mentorId) {
|
||||
if (mentorId === state.selectedMentorId) {
|
||||
toggleMentorDirectory(false);
|
||||
return;
|
||||
}
|
||||
state.selectedMentorId = mentorId;
|
||||
state.mentorMessages = [];
|
||||
hideMentorNotice();
|
||||
renderMentorWorkspace();
|
||||
toggleMentorDirectory(false);
|
||||
state.mentorMessages = await loadMentorMessages();
|
||||
renderMentorMessages();
|
||||
}
|
||||
|
||||
function renderMentorMessages() {
|
||||
const container = document.querySelector("#mentorMessages");
|
||||
const selected = state.mentorSetup?.mentors.find((item) => item.id === state.selectedMentorId);
|
||||
if (!state.mentorMessages.length && !state.mentorLoading) {
|
||||
container.innerHTML = `
|
||||
<div class="mentor-empty-state">
|
||||
<span class="mentor-empty-mark" aria-hidden="true"><i data-lucide="messages-square"></i></span>
|
||||
<strong>向「${escapeHtml(selected?.name || "问师")}」请教</strong>
|
||||
<p>${escapeHtml(selected?.tagline || selected?.description || "选择一个问题开始对话")}</p>
|
||||
</div>
|
||||
`;
|
||||
refreshIcons();
|
||||
} else {
|
||||
container.innerHTML = state.mentorMessages.map((message) => `
|
||||
<article class="mentor-message ${message.role} ${message.error ? "is-error" : ""}">
|
||||
<div class="mentor-message-label">${message.role === "user" ? "我" : escapeHtml(selected?.name || "问师")}</div>
|
||||
<div class="mentor-message-content">${message.role === "assistant" ? formatMentorAnswer(message.content) : escapeHtml(message.content)}</div>
|
||||
${message.streaming ? '<span class="assistant-stream-caret" aria-hidden="true"></span>' : ""}
|
||||
${message.meta && !message.streaming ? `<small>${escapeHtml(message.meta)}</small>` : ""}
|
||||
</article>
|
||||
`).join("");
|
||||
if (state.mentorLoading && !state.mentorMessages.some((message) => message.streaming)) {
|
||||
container.insertAdjacentHTML("beforeend", `
|
||||
<article class="mentor-message assistant loading-message">
|
||||
<div class="mentor-message-label">${escapeHtml(selected?.name || "问师")}</div>
|
||||
<p>正在读取复盘数据并推演...</p>
|
||||
</article>
|
||||
`);
|
||||
}
|
||||
}
|
||||
document.querySelector("#clearMentorChatButton").disabled = !state.mentorMessages.length || state.mentorLoading;
|
||||
document.querySelector("#mentorQuestion").disabled = state.mentorLoading || !state.selectedMentorId;
|
||||
document.querySelector("#sendMentorQuestion").disabled = state.mentorLoading || !state.selectedMentorId;
|
||||
document.querySelector("#mentorSortToggle").disabled = state.mentorLoading;
|
||||
requestAnimationFrame(() => { container.scrollTop = container.scrollHeight; });
|
||||
}
|
||||
|
||||
async function sendMentorQuestion(event) {
|
||||
event.preventDefault();
|
||||
if (state.mentorLoading || !state.selectedMentorId) return;
|
||||
const input = document.querySelector("#mentorQuestion");
|
||||
const question = input.value.trim();
|
||||
if (!question) return;
|
||||
const history = state.mentorMessages.slice(-6).map((item) => ({
|
||||
role: item.role,
|
||||
content: item.content.slice(0, 3500),
|
||||
}));
|
||||
state.mentorMessages.push({ role: "user", content: question });
|
||||
const responseMessage = { role: "assistant", content: "", streaming: true, meta: "" };
|
||||
state.mentorMessages.push(responseMessage);
|
||||
input.value = "";
|
||||
state.mentorLoading = true;
|
||||
state.mentorController = new AbortController();
|
||||
hideMentorNotice();
|
||||
renderMentorMessages();
|
||||
renderMentorDirectory();
|
||||
setStatus("问师正在读取复盘数据");
|
||||
try {
|
||||
await streamMentorRequest(
|
||||
{
|
||||
mentor_id: state.selectedMentorId,
|
||||
trade_date: elements.tradeDate.value,
|
||||
question,
|
||||
history,
|
||||
},
|
||||
state.mentorController.signal,
|
||||
(chunk) => {
|
||||
responseMessage.content += chunk;
|
||||
scheduleMentorRender();
|
||||
},
|
||||
(meta) => {
|
||||
responseMessage.meta = `${displayCompactDate(meta.data_trade_date || elements.tradeDate.value)} · 回答完成`;
|
||||
if (meta.notice) showMentorNotice(meta.notice);
|
||||
},
|
||||
);
|
||||
responseMessage.streaming = false;
|
||||
setStatus("问师回答完成");
|
||||
} catch (error) {
|
||||
responseMessage.streaming = false;
|
||||
responseMessage.error = true;
|
||||
if (!responseMessage.content) {
|
||||
state.mentorMessages = state.mentorMessages.filter((item) => item !== responseMessage);
|
||||
}
|
||||
showMentorNotice(error.message || "问师回答失败");
|
||||
showToast(error.message || "问师回答失败");
|
||||
setStatus("问师回答失败");
|
||||
} finally {
|
||||
state.mentorLoading = false;
|
||||
state.mentorController = null;
|
||||
renderMentorMessages();
|
||||
renderMentorDirectory();
|
||||
input.focus();
|
||||
}
|
||||
}
|
||||
|
||||
let mentorRenderFrame = 0;
|
||||
|
||||
function scheduleMentorRender() {
|
||||
if (mentorRenderFrame) return;
|
||||
mentorRenderFrame = requestAnimationFrame(() => {
|
||||
mentorRenderFrame = 0;
|
||||
renderMentorMessages();
|
||||
});
|
||||
}
|
||||
|
||||
async function streamMentorRequest(body, signal, onDelta, onMeta) {
|
||||
await window.XiaobaiAPI.streamNdjson("/api/mentors/chat", {
|
||||
method: "POST",
|
||||
body,
|
||||
signal,
|
||||
errorMessage: "问师暂不可用",
|
||||
onEvent: (event) => {
|
||||
if (event.type === "delta") onDelta(String(event.content || ""));
|
||||
if (event.type === "meta") onMeta(event);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function useMentorQuickPrompt(prompt) {
|
||||
const input = document.querySelector("#mentorQuestion");
|
||||
input.value = prompt || "";
|
||||
input.focus();
|
||||
}
|
||||
|
||||
async function clearMentorConversation() {
|
||||
if (!state.mentorMessages.length || !window.confirm("确定清空当前老师的对话记录吗?")) return;
|
||||
try {
|
||||
const query = new URLSearchParams({
|
||||
mentor_id: state.selectedMentorId,
|
||||
trade_date: state.mentorSetup?.trade_date || elements.tradeDate.value,
|
||||
});
|
||||
await apiRequest(`/api/mentors/messages?${query}`, "DELETE");
|
||||
state.mentorMessages = [];
|
||||
hideMentorNotice();
|
||||
renderMentorMessages();
|
||||
} catch (error) {
|
||||
showToast(error.message || "对话记录清空失败");
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMentorMessages() {
|
||||
if (!state.selectedMentorId) return [];
|
||||
try {
|
||||
const query = new URLSearchParams({
|
||||
mentor_id: state.selectedMentorId,
|
||||
trade_date: state.mentorSetup?.trade_date || elements.tradeDate.value,
|
||||
});
|
||||
const payload = await apiRequest(`/api/mentors/messages?${query}`);
|
||||
return (payload.items || []).filter(
|
||||
(item) => ["user", "assistant"].includes(item?.role) && typeof item.content === "string",
|
||||
).slice(-100);
|
||||
} catch (error) {
|
||||
showMentorNotice(error.message || "对话记录加载失败");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function showMentorNotice(message) {
|
||||
const notice = document.querySelector("#mentorNotice");
|
||||
notice.textContent = message;
|
||||
notice.hidden = false;
|
||||
}
|
||||
|
||||
function hideMentorNotice() {
|
||||
document.querySelector("#mentorNotice").hidden = true;
|
||||
}
|
||||
|
||||
function formatMentorAnswer(content) {
|
||||
const blocks = [];
|
||||
let listType = "";
|
||||
let listItems = [];
|
||||
const flushList = () => {
|
||||
if (!listItems.length) return;
|
||||
blocks.push(`<${listType} class="mentor-answer-list">${listItems.map((item) => `<li>${item}</li>`).join("")}</${listType}>`);
|
||||
listItems = [];
|
||||
listType = "";
|
||||
};
|
||||
String(content || "").replace(/\r\n?/g, "\n").replace(/\n{3,}/g, "\n\n").split("\n").forEach((rawLine) => {
|
||||
const line = rawLine.trim();
|
||||
if (!line) {
|
||||
flushList();
|
||||
return;
|
||||
}
|
||||
const heading = line.match(/^#{1,3}\s+(.+)$/);
|
||||
const bullet = line.match(/^[-*]\s+(.+)$/);
|
||||
const ordered = line.match(/^\d+[.、]\s*(.+)$/);
|
||||
if (heading) {
|
||||
flushList();
|
||||
blocks.push(`<strong class="mentor-answer-heading">${formatMentorInline(escapeHtml(heading[1]))}</strong>`);
|
||||
} else if (/^-{3,}$/.test(line)) {
|
||||
flushList();
|
||||
blocks.push('<span class="mentor-answer-rule"></span>');
|
||||
} else if (line.startsWith("> ")) {
|
||||
flushList();
|
||||
blocks.push(`<span class="mentor-answer-quote">${formatMentorInline(escapeHtml(line.slice(2)))}</span>`);
|
||||
} else if (bullet || ordered) {
|
||||
const nextType = bullet ? "ul" : "ol";
|
||||
if (listType && listType !== nextType) flushList();
|
||||
listType = nextType;
|
||||
listItems.push(formatMentorInline(escapeHtml((bullet || ordered)[1])));
|
||||
} else {
|
||||
flushList();
|
||||
blocks.push(`<p class="mentor-answer-paragraph">${formatMentorInline(escapeHtml(line))}</p>`);
|
||||
}
|
||||
});
|
||||
flushList();
|
||||
return blocks.join("");
|
||||
}
|
||||
|
||||
function formatMentorInline(content) {
|
||||
return content.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
|
||||
}
|
||||
|
||||
/* PRESERVATION-SOURCE-END app.js:4337-4827 */
|
||||
@@ -0,0 +1,409 @@
|
||||
window.XiaobaiPageModules.register("pools", [
|
||||
"limitPool",
|
||||
"brokenView",
|
||||
"downView",
|
||||
"yesterdayView",
|
||||
"performanceView",
|
||||
]);
|
||||
|
||||
/* PRESERVATION-SOURCE-BEGIN app.js:1517-1915 */
|
||||
function getVisibleStocks() {
|
||||
if (!state.dashboard) return [];
|
||||
let rows = [...(state.dashboard.limits || [])];
|
||||
if (state.filter === "1") rows = rows.filter((row) => number(row.streak) === 1);
|
||||
if (state.filter === "2") rows = rows.filter((row) => number(row.streak) === 2);
|
||||
if (state.filter === "3") rows = rows.filter((row) => number(row.streak) >= 3);
|
||||
if (state.query) {
|
||||
rows = rows.filter((row) => {
|
||||
const haystack = `${row.code} ${row.name} ${row.sector} ${row.reason}`.toLowerCase();
|
||||
return haystack.includes(state.query);
|
||||
});
|
||||
}
|
||||
return rows.sort((left, right) => compareRows(left, right));
|
||||
}
|
||||
|
||||
function renderLimitTable() {
|
||||
if (!state.dashboard) return;
|
||||
const rows = getVisibleStocks();
|
||||
const allRows = state.dashboard.limits || [];
|
||||
const body = document.querySelector("#limitTableBody");
|
||||
body.innerHTML = rows.map((row, index) => `
|
||||
<tr data-code="${escapeHtml(row.code)}">
|
||||
<td class="row-number num muted">${index + 1}</td>
|
||||
<td><span class="stock-cell"><strong class="stock-name sname">${escapeHtml(row.name)}</strong><small class="stock-code scode">${escapeHtml(row.code)}</small></span></td>
|
||||
<td class="number num"><span class="pool-streak-tag tag red">${streakLabel(row.streak)}</span></td>
|
||||
<td class="number num up">${signed(row.change)}</td>
|
||||
<td class="number num">${formatNumber(row.price, 2)}</td>
|
||||
<td>${escapeHtml(row.sector || "其他")}</td>
|
||||
<td class="number num muted">${escapeHtml(row.first_time || "")}</td>
|
||||
<td class="number num muted">${escapeHtml(row.last_time || "")}</td>
|
||||
<td class="number num">${limitOpenState(row)}</td>
|
||||
<td class="number num">${formatNumber(row.turnover_rate, 2)}</td>
|
||||
<td class="number num">${formatNumber(row.amount_billion, 2)}</td>
|
||||
<td class="number num">${formatLimitSealAmount(row.seal_amount_million)}</td>
|
||||
<td class="pool-reason-cell" title="${escapeHtml(row.reason || "")}">${escapeHtml(row.reason || "")}</td>
|
||||
</tr>
|
||||
`).join("");
|
||||
bindStockRows(body);
|
||||
setText("resultCount", `${rows.length} 只`);
|
||||
setText("limitPoolSubtitle", `${allRows.length} 只 · 数据日期 ${displayCompactDate(state.dashboard.meta?.trade_date || elements.tradeDate.value)}`);
|
||||
setText("limitAllCount", allRows.length);
|
||||
setText("limitFirstCount", allRows.filter((row) => number(row.streak) === 1).length);
|
||||
setText("limitSecondCount", allRows.filter((row) => number(row.streak) === 2).length);
|
||||
setText("limitThreePlusCount", allRows.filter((row) => number(row.streak) >= 3).length);
|
||||
document.querySelector("#emptyState").hidden = rows.length !== 0;
|
||||
updateSortHeaders();
|
||||
}
|
||||
|
||||
function limitOpenState(row) {
|
||||
const openTimes = number(row.open_times);
|
||||
const firstTime = String(row.first_time || "");
|
||||
if (firstTime.startsWith("09:25") && openTimes === 0) return '<span class="pool-state-tag one-word">一字</span>';
|
||||
if (openTimes >= 6) return `<span class="pool-state-tag broken">烂板×${openTimes}</span>`;
|
||||
return String(openTimes);
|
||||
}
|
||||
|
||||
function formatLimitSealAmount(value) {
|
||||
const amount = number(value);
|
||||
if (!amount) return "";
|
||||
return Math.round(amount).toLocaleString("zh-CN");
|
||||
}
|
||||
|
||||
function renderBrokenTable(rows) {
|
||||
const visibleRows = getVisibleBrokenRows(rows);
|
||||
setText("brokenCount", `${rows.length} 只`);
|
||||
setText("brokenMeta", ` · 触及涨停后未能封住 · 数据日期 ${displayCompactDate(state.dashboard?.meta?.trade_date || elements.tradeDate.value)}`);
|
||||
const body = document.querySelector("#brokenTableBody");
|
||||
body.innerHTML = visibleRows.map((row, index) => `
|
||||
<tr data-code="${escapeHtml(row.code)}">
|
||||
<td class="row-number num muted">${index + 1}</td>
|
||||
<td><span class="stock-cell"><strong class="stock-name sname">${escapeHtml(row.name)}</strong><small class="stock-code scode">${escapeHtml(row.code)}</small></span></td>
|
||||
<td class="number num ${changeClass(row.change)}" data-sort-value="${number(row.change)}">${signed(row.change)}</td>
|
||||
<td class="number num broken-limit-gap" data-sort-value="${row.limitGap}">${formatNumber(row.limitGap, 2)}</td>
|
||||
<td class="number num">${formatNumber(row.price, 2)}</td>
|
||||
<td>${escapeHtml(row.sector || "其他")}</td>
|
||||
<td class="number num muted">${escapeHtml(row.first_time || "")}</td>
|
||||
<td class="number num" data-sort-value="${number(row.open_times)}">${brokenOpenState(row)}</td>
|
||||
<td class="number num">${formatNumber(row.turnover_rate, 2)}</td>
|
||||
<td class="number num">${formatNumber(row.amount_billion, 2)}</td>
|
||||
<td class="pool-reason-cell" title="${escapeHtml(row.reason || "")}">${escapeHtml(row.reason || "")}</td>
|
||||
</tr>
|
||||
`).join("");
|
||||
bindStockRows(body);
|
||||
document.querySelector("#brokenEmptyState").hidden = visibleRows.length !== 0;
|
||||
updateBrokenSortHeaders();
|
||||
}
|
||||
|
||||
function getVisibleBrokenRows(rows = state.dashboard?.broken || []) {
|
||||
let visibleRows = rows.map((row) => ({ ...row, limitGap: brokenLimitGap(row) }));
|
||||
if (state.brokenQuery) {
|
||||
visibleRows = visibleRows.filter((row) => `${row.code} ${row.name} ${row.sector}`.toLowerCase().includes(state.brokenQuery));
|
||||
}
|
||||
if (!state.brokenSortKey) return visibleRows;
|
||||
return visibleRows.sort((left, right) => {
|
||||
const result = number(left[state.brokenSortKey]) - number(right[state.brokenSortKey]);
|
||||
return state.brokenSortDirection === "asc" ? result : -result;
|
||||
});
|
||||
}
|
||||
|
||||
function brokenLimitRate(row) {
|
||||
const name = String(row.name || "").toUpperCase();
|
||||
const code = String(row.code || "").replace(/\D/g, "");
|
||||
if (name.includes("ST")) return 10;
|
||||
if (/^(300|301|688|689)/.test(code)) return 20;
|
||||
if (/^(4|8|92)/.test(code)) return 30;
|
||||
return 10;
|
||||
}
|
||||
|
||||
function brokenLimitGap(row) {
|
||||
return Math.max(0, brokenLimitRate(row) - number(row.change));
|
||||
}
|
||||
|
||||
function brokenOpenState(row) {
|
||||
const openTimes = number(row.open_times);
|
||||
return openTimes >= 6
|
||||
? `<span class="broken-repeat-tag">反复炸 ×${openTimes}</span>`
|
||||
: String(openTimes);
|
||||
}
|
||||
|
||||
function changeBrokenSort(key) {
|
||||
if (state.brokenSortKey === key) state.brokenSortDirection = state.brokenSortDirection === "asc" ? "desc" : "asc";
|
||||
else {
|
||||
state.brokenSortKey = key;
|
||||
state.brokenSortDirection = "desc";
|
||||
}
|
||||
renderBrokenTable(state.dashboard?.broken || []);
|
||||
}
|
||||
|
||||
function updateBrokenSortHeaders() {
|
||||
document.querySelectorAll("#brokenTable th[data-broken-sort]").forEach((header) => {
|
||||
header.classList.remove("sort-asc", "sort-desc", "sorted");
|
||||
header.setAttribute("aria-sort", "none");
|
||||
if (header.dataset.brokenSort === state.brokenSortKey) {
|
||||
header.classList.add(state.brokenSortDirection === "asc" ? "sort-asc" : "sort-desc", "sorted");
|
||||
header.setAttribute("aria-sort", state.brokenSortDirection === "asc" ? "ascending" : "descending");
|
||||
}
|
||||
const arrow = header.querySelector(".arr");
|
||||
if (arrow) arrow.textContent = header.classList.contains("sorted") ? (state.brokenSortDirection === "asc" ? "▲" : "▼") : "↕";
|
||||
});
|
||||
}
|
||||
|
||||
function renderDownTable(rows) {
|
||||
const visibleRows = getVisibleDownRows(rows);
|
||||
setText("downCount", `${rows.length} 只`);
|
||||
setText("downMeta", ` · 观察退潮、高位风险与亏钱效应 · 数据日期 ${displayCompactDate(state.dashboard?.meta?.trade_date || elements.tradeDate.value)}`);
|
||||
renderDownSectorCluster(rows);
|
||||
const body = document.querySelector("#downTableBody");
|
||||
body.innerHTML = visibleRows.map((row, index) => `
|
||||
<tr data-code="${escapeHtml(row.code)}">
|
||||
<td class="row-number num muted">${index + 1}</td>
|
||||
<td><span class="stock-cell"><strong class="stock-name sname">${escapeHtml(row.name)}</strong><small class="stock-code scode">${escapeHtml(row.code)}</small></span></td>
|
||||
<td class="number num down" data-sort-value="${number(row.change)}">${signed(row.change)}</td>
|
||||
<td class="number num">${formatNumber(row.price, 2)}</td>
|
||||
<td>${escapeHtml(row.sector || "其他")}</td>
|
||||
<td class="number num">${formatNumber(row.turnover_rate, 2)}</td>
|
||||
<td class="number num">${formatNumber(row.amount_billion, 2)}</td>
|
||||
<td class="number num">${number(row.streak) > 0 ? number(row.streak) : ""}</td>
|
||||
<td class="pool-reason-cell" title="${escapeHtml(row.reason || "")}">${escapeHtml(row.reason || "")}</td>
|
||||
</tr>
|
||||
`).join("");
|
||||
bindStockRows(body);
|
||||
document.querySelector("#downEmptyState").hidden = visibleRows.length !== 0;
|
||||
updateDownSortHeaders();
|
||||
}
|
||||
|
||||
function getVisibleDownRows(rows = state.dashboard?.down_limits || []) {
|
||||
let visibleRows = [...rows];
|
||||
if (state.downQuery) {
|
||||
visibleRows = visibleRows.filter((row) => `${row.code} ${row.name} ${row.sector}`.toLowerCase().includes(state.downQuery));
|
||||
}
|
||||
if (!state.downSortKey) return visibleRows;
|
||||
return visibleRows.sort((left, right) => {
|
||||
const result = number(left[state.downSortKey]) - number(right[state.downSortKey]);
|
||||
return state.downSortDirection === "asc" ? result : -result;
|
||||
});
|
||||
}
|
||||
|
||||
function renderDownSectorCluster(rows) {
|
||||
const counts = new Map();
|
||||
rows.forEach((row) => {
|
||||
const sector = String(row.sector || "其他").trim() || "其他";
|
||||
if (sector === "其他") return;
|
||||
counts.set(sector, (counts.get(sector) || 0) + 1);
|
||||
});
|
||||
const cluster = [...counts.entries()].sort((left, right) => right[1] - left[1])[0];
|
||||
const element = document.querySelector("#downSectorCluster");
|
||||
element.hidden = !cluster || cluster[1] < 2;
|
||||
element.textContent = cluster && cluster[1] >= 2 ? `${cluster[0]}集中跌停 ×${cluster[1]}` : "";
|
||||
}
|
||||
|
||||
function changeDownSort(key) {
|
||||
if (state.downSortKey === key) state.downSortDirection = state.downSortDirection === "asc" ? "desc" : "asc";
|
||||
else {
|
||||
state.downSortKey = key;
|
||||
state.downSortDirection = "asc";
|
||||
}
|
||||
renderDownTable(state.dashboard?.down_limits || []);
|
||||
}
|
||||
|
||||
function updateDownSortHeaders() {
|
||||
document.querySelectorAll("#downTable th[data-down-sort]").forEach((header) => {
|
||||
header.classList.remove("sort-asc", "sort-desc", "sorted");
|
||||
header.setAttribute("aria-sort", "none");
|
||||
if (header.dataset.downSort === state.downSortKey) {
|
||||
header.classList.add(state.downSortDirection === "asc" ? "sort-asc" : "sort-desc", "sorted");
|
||||
header.setAttribute("aria-sort", state.downSortDirection === "asc" ? "ascending" : "descending");
|
||||
}
|
||||
const arrow = header.querySelector(".arr");
|
||||
if (arrow) arrow.textContent = header.classList.contains("sorted") ? (state.downSortDirection === "asc" ? "▲" : "▼") : "↕";
|
||||
});
|
||||
}
|
||||
|
||||
function renderYesterdayTable(rows) {
|
||||
const visibleRows = getVisibleYesterdayRows(rows);
|
||||
const currentDate = displayCompactDate(state.dashboard?.meta?.trade_date || elements.tradeDate.value);
|
||||
const previousDate = displayCompactDate(state.dashboard?.meta?.previous_trade_date || "");
|
||||
setText("yesterdayCount", `${rows.length} 只`);
|
||||
setText("yesterdayMeta", ` · 昨日 ${previousDate} → 今日 ${currentDate}`);
|
||||
renderYesterdaySummary(rows);
|
||||
const body = document.querySelector("#yesterdayTableBody");
|
||||
body.innerHTML = visibleRows.map((row, index) => `
|
||||
<tr data-code="${escapeHtml(row.code)}">
|
||||
<td class="row-number num muted">${index + 1}</td>
|
||||
<td><span class="stock-cell"><strong class="stock-name sname">${escapeHtml(row.name)}</strong><small class="stock-code scode">${escapeHtml(row.code)}</small></span></td>
|
||||
<td class="number num">${number(row.prior_streak)}</td>
|
||||
<td class="number num ${changeClass(row.current_change)}">${signed(row.current_change)}</td>
|
||||
<td><span class="yesterday-outcome-tag ${yesterdayOutcomeClass(row.outcome)}">${escapeHtml(row.outcome)}</span></td>
|
||||
<td class="number num">${number(row.current_streak) ? `<span class="yesterday-height-tag">${number(row.current_streak)}</span>` : ""}</td>
|
||||
<td>${escapeHtml(row.sector || "其他")}</td>
|
||||
<td class="pool-reason-cell" title="${escapeHtml(row.reason || "")}">${escapeHtml(row.reason || "")}</td>
|
||||
</tr>
|
||||
`).join("");
|
||||
bindStockRows(body);
|
||||
document.querySelector("#yesterdayEmptyState").hidden = visibleRows.length !== 0;
|
||||
updateYesterdayControls();
|
||||
}
|
||||
|
||||
function getVisibleYesterdayRows(rows = state.dashboard?.yesterday_limits || []) {
|
||||
let visibleRows = rows.filter((row) => {
|
||||
if (state.yesterdayFilter === "advance") return row.outcome === "晋级";
|
||||
if (state.yesterdayFilter === "positive") return number(row.current_change) > 0;
|
||||
if (state.yesterdayFilter === "fail") return row.outcome === "断板";
|
||||
if (state.yesterdayFilter === "risk") return ["炸板", "跌停"].includes(row.outcome);
|
||||
return true;
|
||||
});
|
||||
if (state.yesterdayQuery) {
|
||||
visibleRows = visibleRows.filter((row) => `${row.code} ${row.name} ${row.sector}`.toLowerCase().includes(state.yesterdayQuery));
|
||||
}
|
||||
if (!state.yesterdaySortKey) return visibleRows;
|
||||
return visibleRows.sort((left, right) => {
|
||||
const result = number(left[state.yesterdaySortKey]) - number(right[state.yesterdaySortKey]);
|
||||
return state.yesterdaySortDirection === "asc" ? result : -result;
|
||||
});
|
||||
}
|
||||
|
||||
function renderYesterdaySummary(rows) {
|
||||
const total = rows.length;
|
||||
const advance = rows.filter((row) => row.outcome === "晋级").length;
|
||||
const positive = rows.filter((row) => number(row.current_change) > 0).length;
|
||||
const fail = rows.filter((row) => row.outcome === "断板").length;
|
||||
const risk = rows.filter((row) => ["炸板", "跌停"].includes(row.outcome)).length;
|
||||
const rate = (value) => total ? value / total * 100 : 0;
|
||||
setText("yesterdayAllCount", total);
|
||||
setText("yesterdayAdvanceCount", advance);
|
||||
setText("yesterdayAdvanceRate", `晋级率 ${formatNumber(rate(advance), 1)}%`);
|
||||
setText("yesterdayPositiveCount", positive);
|
||||
setText("yesterdayPositiveRate", `兑现率 ${formatNumber(rate(positive), 1)}%`);
|
||||
setText("yesterdayFailCount", fail);
|
||||
setText("yesterdayFailRate", `占 ${formatNumber(rate(fail), 1)}%`);
|
||||
setText("yesterdayRiskCount", risk);
|
||||
setText("yesterdayRiskRate", `亏钱效应 ${formatNumber(rate(risk), 1)}%`);
|
||||
}
|
||||
|
||||
function yesterdayOutcomeClass(outcome) {
|
||||
return { "晋级": "advance", "断板": "fail", "炸板": "broken", "跌停": "down" }[outcome] || "fail";
|
||||
}
|
||||
|
||||
function changeYesterdaySort(key) {
|
||||
if (state.yesterdaySortKey === key) state.yesterdaySortDirection = state.yesterdaySortDirection === "asc" ? "desc" : "asc";
|
||||
else {
|
||||
state.yesterdaySortKey = key;
|
||||
state.yesterdaySortDirection = "desc";
|
||||
}
|
||||
renderYesterdayTable(state.dashboard?.yesterday_limits || []);
|
||||
}
|
||||
|
||||
function updateYesterdayControls() {
|
||||
document.querySelectorAll("[data-yesterday-filter]").forEach((button) => {
|
||||
const active = button.dataset.yesterdayFilter === state.yesterdayFilter;
|
||||
button.classList.toggle("active", active);
|
||||
button.setAttribute("aria-pressed", String(active));
|
||||
});
|
||||
document.querySelectorAll("#yesterdayTable th[data-yesterday-sort]").forEach((header) => {
|
||||
header.classList.remove("sort-asc", "sort-desc", "sorted");
|
||||
header.setAttribute("aria-sort", "none");
|
||||
if (header.dataset.yesterdaySort === state.yesterdaySortKey) {
|
||||
header.classList.add(state.yesterdaySortDirection === "asc" ? "sort-asc" : "sort-desc", "sorted");
|
||||
header.setAttribute("aria-sort", state.yesterdaySortDirection === "asc" ? "ascending" : "descending");
|
||||
}
|
||||
const arrow = header.querySelector(".arr");
|
||||
if (arrow) arrow.textContent = header.classList.contains("sorted") ? (state.yesterdaySortDirection === "asc" ? "▲" : "▼") : "↕";
|
||||
});
|
||||
}
|
||||
|
||||
function renderPerformance(rows) {
|
||||
rows = normalizePerformanceRows(rows);
|
||||
const currentDate = displayCompactDate(state.dashboard?.meta?.trade_date || elements.tradeDate.value);
|
||||
const previousDate = displayCompactDate(state.dashboard?.meta?.previous_trade_date || "");
|
||||
setText("performanceDateRange", `昨日 ${previousDate} → 今日 ${currentDate}`);
|
||||
document.querySelector("#performanceCards").innerHTML = rows.map((row) => `
|
||||
<article class="performance-stage-card" title="收红 ${formatNumber(row.positive_rate, 1)}% · 平均涨幅 ${signed(row.average_change)}%"
|
||||
aria-label="${escapeHtml(row.label)},晋级率 ${formatNumber(row.advance_rate, 1)}%,晋级 ${number(row.advanced)} 只,共 ${number(row.count)} 只,收红率 ${formatNumber(row.positive_rate, 1)}%,平均涨幅 ${signed(row.average_change)}%">
|
||||
<div class="performance-stage-label"><span>${escapeHtml(row.label)} → 今日</span><i class="performance-status-tag ${performanceRateState(row.advance_rate).className}">${performanceRateState(row.advance_rate).label}</i></div>
|
||||
<strong class="performance-stage-rate ${performanceRateState(row.advance_rate).className}">${formatNumber(row.advance_rate, 1)}%</strong>
|
||||
<span class="performance-stage-count">晋级 ${number(row.advanced)} / 共 ${number(row.count)} 只</span>
|
||||
<div class="performance-stage-track" aria-hidden="true"><i class="${performanceRateState(row.advance_rate).className}" style="width:${Math.max(number(row.advance_rate), number(row.advance_rate) > 0 ? 2 : 0)}%"></i></div>
|
||||
</article>
|
||||
`).join("") || '<div class="performance-empty-state">暂无昨日涨停统计</div>';
|
||||
renderPerformanceConclusion(rows);
|
||||
renderMarketBreadth(state.dashboard?.overview || {});
|
||||
}
|
||||
|
||||
function normalizePerformanceRows(rows) {
|
||||
const groups = new Map();
|
||||
(rows || []).forEach((row) => {
|
||||
const level = Math.max(1, number(row.level));
|
||||
const displayLevel = Math.min(level, 5);
|
||||
const group = groups.get(displayLevel) || {
|
||||
level: displayLevel,
|
||||
label: displayLevel === 1 ? "昨日首板" : displayLevel === 5 ? "昨日5板+" : `昨日${displayLevel}板`,
|
||||
count: 0,
|
||||
advanced: 0,
|
||||
positive: 0,
|
||||
changeTotal: 0,
|
||||
};
|
||||
const count = number(row.count);
|
||||
group.count += count;
|
||||
group.advanced += number(row.advanced);
|
||||
group.positive += count * number(row.positive_rate) / 100;
|
||||
group.changeTotal += count * number(row.average_change);
|
||||
groups.set(displayLevel, group);
|
||||
});
|
||||
return [...groups.values()]
|
||||
.sort((left, right) => right.level - left.level)
|
||||
.map((group) => ({
|
||||
level: group.level,
|
||||
label: group.label,
|
||||
count: group.count,
|
||||
advanced: group.advanced,
|
||||
advance_rate: group.count ? group.advanced / group.count * 100 : 0,
|
||||
positive_rate: group.count ? group.positive / group.count * 100 : 0,
|
||||
average_change: group.count ? group.changeTotal / group.count : 0,
|
||||
}));
|
||||
}
|
||||
|
||||
function performanceRateState(rate) {
|
||||
const value = number(rate);
|
||||
if (value === 0) return { label: "失效", className: "is-neutral" };
|
||||
if (value < 20) return { label: "危险", className: "is-warning" };
|
||||
return { label: "活跃", className: "is-active" };
|
||||
}
|
||||
|
||||
function renderPerformanceConclusion(rows) {
|
||||
const container = document.querySelector("#performanceConclusion");
|
||||
if (!rows.length) {
|
||||
container.innerHTML = '<div class="empty-state">暂无昨日梯队数据,暂不生成结论</div>';
|
||||
return;
|
||||
}
|
||||
const sorted = [...rows].sort((left, right) => number(right.level) - number(left.level));
|
||||
const highRows = sorted.filter((row) => number(row.level) >= 4);
|
||||
const highAdvanced = highRows.reduce((total, row) => total + number(row.advanced), 0);
|
||||
const highSamples = highRows.map((row) => escapeHtml(row.label)).join("、");
|
||||
const strongest = [...rows].sort((left, right) => (
|
||||
number(right.advance_rate) - number(left.advance_rate) || number(right.level) - number(left.level)
|
||||
))[0];
|
||||
const firstBoard = rows.find((row) => number(row.level) === 1);
|
||||
const overview = state.dashboard?.overview || {};
|
||||
const phase = overview.sentiment_phase || "观察";
|
||||
const up = number(overview.up_count);
|
||||
const down = number(overview.down_count);
|
||||
const breadthRate = up + down > 0 ? up / (up + down) * 100 : 50;
|
||||
const stance = breadthRate < 25 ? "宜守不宜攻" : breadthRate < 45 ? "控制仓位,聚焦核心" : "保持精选,跟随强势梯队";
|
||||
const highText = highRows.length
|
||||
? `高位晋级率<b class="${highAdvanced ? "up" : "is-neutral"}">${highAdvanced ? "仍有承接" : "全线失效"}</b>:${highSamples}${highAdvanced ? `共晋级 ${highAdvanced} 只` : "今日均未晋级"};`
|
||||
: "高位梯队暂无昨日样本,空间信号仍待确认;";
|
||||
const strongestText = strongest
|
||||
? `<b>${escapeHtml(strongest.label)}</b>晋级率最高,为 <b class="up">${formatNumber(strongest.advance_rate, 1)}%</b>(${number(strongest.advanced)} 只晋级 / 共 ${number(strongest.count)} 只);`
|
||||
: "暂无相对占优梯队;";
|
||||
const firstBoardText = firstBoard
|
||||
? `首板基数 ${number(firstBoard.count)} 只,晋级率 <b class="${performanceRateState(firstBoard.advance_rate).className}">${formatNumber(firstBoard.advance_rate, 1)}%</b>,低位接力${number(firstBoard.advance_rate) < 20 ? "胜率偏低" : "仍有活跃度"};`
|
||||
: "首板梯队暂无有效样本;";
|
||||
container.innerHTML = `
|
||||
<div>· ${highText}</div>
|
||||
<div>· ${strongestText}</div>
|
||||
<div>· ${firstBoardText}</div>
|
||||
<div>· 结论:<b>${stance}</b>,当前情绪周期「${escapeHtml(phase)}」。</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/* PRESERVATION-SOURCE-END app.js:1517-1915 */
|
||||
@@ -0,0 +1,82 @@
|
||||
window.XiaobaiPageModules.register("popularity", ["popularityView"], {
|
||||
enter: ["loadPopularity"],
|
||||
});
|
||||
|
||||
/* PRESERVATION-SOURCE-BEGIN app.js:2584-2659 */
|
||||
async function loadPopularity(force = false) {
|
||||
if (state.popularityLoading) return;
|
||||
state.popularityLoading = true;
|
||||
const button = document.querySelector("#popularityRefreshButton");
|
||||
button.disabled = true;
|
||||
setText("popularityDateLabel", "正在读取人气榜");
|
||||
try {
|
||||
const query = new URLSearchParams({ trade_date: elements.tradeDate.value });
|
||||
if (force) query.set("force", "1");
|
||||
state.popularityData = await apiRequest(`/api/popularity?${query}`);
|
||||
renderPopularity();
|
||||
} catch (error) {
|
||||
setText("popularityDateLabel", error.message || "人气榜暂不可用");
|
||||
document.querySelector("#popularityTableBody").innerHTML = "";
|
||||
document.querySelector("#popularityEmpty").hidden = false;
|
||||
showToast(error.message || "人气榜加载失败");
|
||||
} finally {
|
||||
state.popularityLoading = false;
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function renderPopularity() {
|
||||
const payload = state.popularityData;
|
||||
if (!payload) return;
|
||||
const summary = payload.summary || {};
|
||||
setText("popularityDateLabel", `${payload.meta?.carried_forward ? "最近有效榜单" : "榜单日期"} ${payload.meta?.trade_date || "--"}`);
|
||||
const topNames = (rows) => (rows || []).slice(0, 3).map((item) => item.name).filter(Boolean).join(" · ") || "--";
|
||||
document.querySelector("#popularitySummary").innerHTML = [
|
||||
["同花顺热度 Top3", topNames(payload.ths), `共 ${number(summary.ths_count)} 只上榜`],
|
||||
["东方财富热度 Top3", topNames(payload.dc), `共 ${number(summary.dc_count)} 只上榜`],
|
||||
["双榜共识", `${number(summary.dual_count)} 只`, "同时进入两榜,共识度更高"],
|
||||
].map(([label, value, detail], index) => `<article class="${index === 2 ? "consensus" : ""}"><span>${label}</span><strong>${escapeHtml(value)}</strong><small>${escapeHtml(detail)}</small></article>`).join("");
|
||||
renderPopularityTable();
|
||||
}
|
||||
|
||||
function renderPopularityTable() {
|
||||
const source = state.popularitySource;
|
||||
let rows = [...(state.popularityData?.[source] || [])];
|
||||
if (state.popularityQuery) {
|
||||
rows = rows.filter((item) => `${item.code} ${item.name} ${(item.concepts || []).join(" ")}`.toLocaleLowerCase("zh-CN").includes(state.popularityQuery));
|
||||
}
|
||||
const combined = source === "combined";
|
||||
const sourceName = source === "ths" ? "同花顺" : source === "dc" ? "东方财富" : "双榜综合";
|
||||
setText("popularityTableTitle", `${sourceName}榜`);
|
||||
setText("popularityTableNote", combined ? "按双榜排名综合排序 · 已隐藏重复的榜单状态" : "按榜单名次排序 · 状态显示是否同时进入另一榜");
|
||||
const headers = [
|
||||
["排名", "number num"], ["股票", ""], ["最新价(元)", "number num"], ["涨跌幅(%)", "number num"],
|
||||
...(source !== "dc" ? [["同花顺", "number num"]] : []),
|
||||
...(source !== "ths" ? [["东方财富", "number num"]] : []),
|
||||
["排名变化", "number num"], ["热门概念", ""], ...(!combined ? [["榜单状态", ""]] : []),
|
||||
];
|
||||
document.querySelector("#popularityTableHead").innerHTML = headers.map(([label, className]) => `<th scope="col" class="${className}">${label}</th>`).join("");
|
||||
const body = document.querySelector("#popularityTableBody");
|
||||
body.innerHTML = rows.map((row, index) => {
|
||||
const thsRank = source === "ths" ? row.rank : row.ths_rank;
|
||||
const dcRank = source === "dc" ? row.rank : row.dc_rank;
|
||||
const move = row.rank_change;
|
||||
const movement = move === null || move === undefined ? "新" : number(move) > 0 ? `↑${number(move)}` : number(move) < 0 ? `↓${Math.abs(number(move))}` : "持平";
|
||||
return `<tr data-code="${escapeHtml(row.code)}">
|
||||
<td class="number num popularity-rank-v2"><b>${index + 1}</b>${index < 3 ? '<span>热</span>' : ""}</td>
|
||||
<td><div class="popularity-stock-v2"><strong class="stock-name sname">${escapeHtml(row.name)}</strong><span class="stock-code scode">${escapeHtml(row.code)}</span></div></td>
|
||||
<td class="number num">${row.price == null ? "" : formatNumber(row.price, 2)}</td>
|
||||
<td class="number num ${row.change == null ? "" : changeClass(row.change)}">${row.change == null ? "" : signed(row.change)}</td>
|
||||
${source !== "dc" ? `<td class="number num popularity-list-rank-v2">${thsRank ? number(thsRank) : ""}</td>` : ""}
|
||||
${source !== "ths" ? `<td class="number num popularity-list-rank-v2">${dcRank ? number(dcRank) : ""}</td>` : ""}
|
||||
<td class="number num popularity-movement-v2 ${number(move) > 0 ? "up" : number(move) < 0 ? "down" : ""}">${movement}</td>
|
||||
<td class="popularity-concepts-v2" title="${escapeHtml((row.concepts || []).join("、"))}">${escapeHtml((row.concepts || []).slice(0, 3).join("、"))}</td>
|
||||
${!combined ? `<td><span class="popularity-source-tag-v2 ${row.dual_source ? "dual" : ""}">${row.dual_source ? "双榜共识" : "单榜入选"}</span></td>` : ""}
|
||||
</tr>`;
|
||||
}).join("");
|
||||
bindStockRows(body);
|
||||
markAutoSortableHeaders(body.closest("table"));
|
||||
document.querySelector("#popularityEmpty").hidden = rows.length > 0;
|
||||
}
|
||||
|
||||
/* PRESERVATION-SOURCE-END app.js:2584-2659 */
|
||||
@@ -0,0 +1,699 @@
|
||||
window.XiaobaiPageModules.register("review", ["reviewWorkspaceView"], {
|
||||
enter: ["loadReview"],
|
||||
});
|
||||
|
||||
/* PRESERVATION-SOURCE-BEGIN app.js:3029-3470 */
|
||||
async function loadReviewWorkspace() {
|
||||
try {
|
||||
const [watchlistPayload, notesPayload, tradesPayload] = await Promise.all([
|
||||
apiRequest(`/api/watchlist?trade_date=${encodeURIComponent(elements.tradeDate.value)}`),
|
||||
apiRequest("/api/notes?scope=daily"),
|
||||
apiRequest("/api/trades"),
|
||||
]);
|
||||
state.watchlist = watchlistPayload.items || [];
|
||||
state.notes = notesPayload.items || [];
|
||||
state.tradeEntries = tradesPayload.items || [];
|
||||
state.tradeSummary = tradesPayload.summary || {};
|
||||
setText("reviewDataDate", displayCompactDate(elements.tradeDate.value));
|
||||
renderWatchlist();
|
||||
renderNotesHistory(state.notes, document.querySelector("#notesHistory"), false);
|
||||
setText("notesCount", `${state.notes.length} 条`);
|
||||
renderTradeLog();
|
||||
populateJournalForm();
|
||||
} catch (error) {
|
||||
showToast(error.message || "我的复盘加载失败");
|
||||
}
|
||||
}
|
||||
|
||||
function renderWatchlist() {
|
||||
setText("watchlistCount", `${state.watchlist.length} 只`);
|
||||
const body = document.querySelector("#watchlistTableBody");
|
||||
body.innerHTML = state.watchlist.map((item) => `
|
||||
<tr data-code="${escapeHtml(item.code)}"><td><span class="review-watch-mark ${escapeHtml(item.color)}" title="${escapeHtml(item.color)}">★</span></td>
|
||||
<td><span class="stock-cell"><strong class="stock-name sname">${escapeHtml(item.name)}</strong><small class="stock-code scode">${escapeHtml(item.code)}</small></span></td>
|
||||
<td>${escapeHtml(item.sector || "其他")}</td>
|
||||
<td class="number num ${item.change == null ? "" : changeClass(item.change)}">${formatWatchMetric(item.change)}</td>
|
||||
<td class="number num ${item.return_5d == null ? "" : changeClass(item.return_5d)}">${formatWatchMetric(item.return_5d)}</td>
|
||||
<td class="number num"><strong class="watch-attention-score">${item.attention_score == null ? "" : formatNumber(item.attention_score, 1)}</strong></td>
|
||||
<td><span class="watch-remark" title="${escapeHtml(item.remark || "尚未填写跟踪备注")}">${escapeHtml(item.remark || "尚未填写")}</span></td>
|
||||
<td><span class="review-row-actions"><button class="table-action" type="button" data-watch-remark="${escapeHtml(item.code)}">备注</button>
|
||||
<button class="table-action down" type="button" data-watch-delete="${escapeHtml(item.code)}" aria-label="移除 ${escapeHtml(item.name)}">移除</button></span></td></tr>
|
||||
`).join("");
|
||||
document.querySelector("#watchlistEmpty").hidden = state.watchlist.length > 0;
|
||||
body.querySelectorAll("[data-watch-remark]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const item = state.watchlist.find((row) => row.code === button.dataset.watchRemark);
|
||||
openWatchlistDialog(item);
|
||||
});
|
||||
});
|
||||
body.querySelectorAll("[data-watch-delete]").forEach((button) => {
|
||||
button.addEventListener("click", () => removeWatchlist(button.dataset.watchDelete));
|
||||
});
|
||||
bindStockRows(body);
|
||||
}
|
||||
|
||||
function formatWatchMetric(value) {
|
||||
if (value == null || !Number.isFinite(Number(value))) return "";
|
||||
return signed(value);
|
||||
}
|
||||
|
||||
function openWatchlistDialog(item = null) {
|
||||
clearTimeout(watchlistSearchTimer);
|
||||
state.watchlistSelection = item ? {
|
||||
code: item.code,
|
||||
name: item.name,
|
||||
sector: item.sector || "其他",
|
||||
color: item.color || "red",
|
||||
} : null;
|
||||
state.watchlistSearchResults = [];
|
||||
setText("watchlistDialogTitle", item ? "编辑跟踪备注" : "添加自选");
|
||||
document.querySelector("#watchlistRemark").value = item?.remark || "";
|
||||
document.querySelector("#watchlistSearchInput").value = "";
|
||||
document.querySelector("#watchlistSearchResults").innerHTML = "";
|
||||
syncWatchlistSelection(Boolean(item));
|
||||
openModalDialog(elements.watchlistDialog);
|
||||
requestAnimationFrame(() => (item ? document.querySelector("#watchlistRemark") : document.querySelector("#watchlistSearchInput")).focus());
|
||||
}
|
||||
|
||||
function closeWatchlistDialog() {
|
||||
clearTimeout(watchlistSearchTimer);
|
||||
if (elements.watchlistDialog.open) elements.watchlistDialog.close();
|
||||
}
|
||||
|
||||
function clearWatchlistSelection() {
|
||||
state.watchlistSelection = null;
|
||||
syncWatchlistSelection(false);
|
||||
document.querySelector("#watchlistSearchInput").focus();
|
||||
}
|
||||
|
||||
function syncWatchlistSelection(editing = false) {
|
||||
const item = state.watchlistSelection;
|
||||
document.querySelector("#watchlistSearchField").hidden = Boolean(item);
|
||||
document.querySelector("#watchlistSelection").hidden = !item;
|
||||
document.querySelector("#changeWatchlistSelection").hidden = editing;
|
||||
document.querySelector("#saveWatchlist").disabled = !item;
|
||||
if (!item) return;
|
||||
setText("watchlistSelectionName", item.name || "--");
|
||||
setText("watchlistSelectionCode", item.code || "--");
|
||||
setText("watchlistSelectionSector", item.sector || "其他");
|
||||
refreshIcons();
|
||||
}
|
||||
|
||||
function scheduleWatchlistSearch() {
|
||||
clearTimeout(watchlistSearchTimer);
|
||||
const query = document.querySelector("#watchlistSearchInput").value.trim();
|
||||
if (!query) {
|
||||
document.querySelector("#watchlistSearchResults").innerHTML = "";
|
||||
return;
|
||||
}
|
||||
document.querySelector("#watchlistSearchResults").innerHTML = '<div class="watchlist-search-status">正在查找股票</div>';
|
||||
watchlistSearchTimer = setTimeout(() => runWatchlistSearch(query), 160);
|
||||
}
|
||||
|
||||
async function runWatchlistSearch(query) {
|
||||
const sequence = ++state.watchlistSearchRequestSequence;
|
||||
try {
|
||||
const params = new URLSearchParams({ q: query, trade_date: elements.tradeDate.value });
|
||||
const payload = await apiRequest(`/api/search?${params}`);
|
||||
if (sequence !== state.watchlistSearchRequestSequence) return;
|
||||
state.watchlistSearchResults = payload.groups?.stocks || [];
|
||||
document.querySelector("#watchlistSearchResults").innerHTML = state.watchlistSearchResults.map((item, index) => `
|
||||
<button type="button" data-watchlist-result="${index}"><span><strong>${escapeHtml(item.name)}</strong><small>${escapeHtml(item.industry || "其他")}</small></span><b>${escapeHtml(item.code)}</b></button>
|
||||
`).join("") || '<div class="watchlist-search-status">没有找到匹配的股票</div>';
|
||||
} catch (error) {
|
||||
document.querySelector("#watchlistSearchResults").innerHTML = `<div class="watchlist-search-status">${escapeHtml(error.message || "搜索失败")}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function handleWatchlistSearchResult(event) {
|
||||
const button = event.target.closest("[data-watchlist-result]");
|
||||
if (!button) return;
|
||||
const item = state.watchlistSearchResults[number(button.dataset.watchlistResult)];
|
||||
if (!item) return;
|
||||
state.watchlistSelection = {
|
||||
code: item.code,
|
||||
name: item.name,
|
||||
sector: item.industry || "其他",
|
||||
color: "red",
|
||||
};
|
||||
syncWatchlistSelection(false);
|
||||
}
|
||||
|
||||
async function saveWatchlistFromDialog(event) {
|
||||
event.preventDefault();
|
||||
const item = state.watchlistSelection;
|
||||
if (!item) return;
|
||||
const button = document.querySelector("#saveWatchlist");
|
||||
button.disabled = true;
|
||||
try {
|
||||
await apiRequest("/api/watchlist", "POST", {
|
||||
code: item.code,
|
||||
name: item.name,
|
||||
sector: item.sector || "其他",
|
||||
color: item.color || "red",
|
||||
remark: document.querySelector("#watchlistRemark").value.trim(),
|
||||
});
|
||||
closeWatchlistDialog();
|
||||
await loadReviewWorkspace();
|
||||
showToast(state.watchlist.some((row) => row.code === item.code) ? "自选跟踪已保存" : "已加入自选");
|
||||
} catch (error) {
|
||||
showToast(error.message || "自选保存失败");
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleActiveWatchlist() {
|
||||
const stock = state.activeStock;
|
||||
if (!stock?.code) return;
|
||||
const isWatched = Boolean(state.stockDetail?.stock?.watchlist || state.watchlist.some((item) => item.code === stock.code));
|
||||
try {
|
||||
if (isWatched) {
|
||||
await apiRequest(`/api/watchlist/${stock.code}`, "DELETE");
|
||||
state.watchlist = state.watchlist.filter((item) => item.code !== stock.code);
|
||||
if (state.stockDetail?.stock) state.stockDetail.stock.watchlist = null;
|
||||
showToast("已移出自选");
|
||||
} else {
|
||||
const payload = await apiRequest("/api/watchlist", "POST", {
|
||||
code: stock.code,
|
||||
name: stock.name || "--",
|
||||
sector: stock.sector || "其他",
|
||||
color: "red",
|
||||
});
|
||||
state.watchlist = payload.items || state.watchlist;
|
||||
if (state.stockDetail?.stock) state.stockDetail.stock.watchlist = state.watchlist.find((item) => item.code === stock.code);
|
||||
showToast("已加入自选");
|
||||
}
|
||||
updateWatchButton();
|
||||
renderWatchlist();
|
||||
} catch (error) {
|
||||
showToast(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function updateWatchButton() {
|
||||
const code = state.activeStock?.code;
|
||||
const watched = Boolean(state.stockDetail?.stock?.watchlist || state.watchlist.some((item) => item.code === code));
|
||||
setText("watchStockButton", watched ? "移出自选" : "加入自选");
|
||||
}
|
||||
|
||||
async function removeWatchlist(code) {
|
||||
try {
|
||||
await apiRequest(`/api/watchlist/${code}`, "DELETE");
|
||||
state.watchlist = state.watchlist.filter((item) => item.code !== code);
|
||||
renderWatchlist();
|
||||
showToast("已移出自选");
|
||||
} catch (error) {
|
||||
showToast(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveJournal(event) {
|
||||
event.preventDefault();
|
||||
try {
|
||||
await apiRequest("/api/notes", "POST", {
|
||||
trade_date: document.querySelector("#journalDate").value,
|
||||
id: state.editingDailyNoteId || undefined,
|
||||
summary: document.querySelector("#journalSummary").value,
|
||||
content: document.querySelector("#journalContent").value,
|
||||
plan: document.querySelector("#journalPlan").value,
|
||||
});
|
||||
await loadReviewWorkspace();
|
||||
showToast("每日复盘已保存");
|
||||
} catch (error) {
|
||||
showToast(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function populateJournalForm() {
|
||||
const selectedDate = document.querySelector("#journalDate").value.replaceAll("-", "");
|
||||
const note = state.notes.find((item) => String(item.trade_date).replaceAll("-", "") === selectedDate);
|
||||
state.editingDailyNoteId = number(note?.id);
|
||||
document.querySelector("#journalSummary").value = note?.summary || "";
|
||||
document.querySelector("#journalContent").value = note?.content || "";
|
||||
document.querySelector("#journalPlan").value = note?.plan || "";
|
||||
}
|
||||
|
||||
function openTradeLogDialog() {
|
||||
resetTradeLogForm();
|
||||
openModalDialog(elements.tradeLogDialog);
|
||||
requestAnimationFrame(() => document.querySelector("#tradeLogCode").focus());
|
||||
}
|
||||
|
||||
function closeTradeLogDialog() {
|
||||
if (elements.tradeLogDialog.open) elements.tradeLogDialog.close();
|
||||
else resetTradeLogForm();
|
||||
}
|
||||
|
||||
async function saveTradeLog(event) {
|
||||
event.preventDefault();
|
||||
const button = document.querySelector("#saveTradeLog");
|
||||
button.disabled = true;
|
||||
try {
|
||||
const payload = await apiRequest("/api/trades", "POST", {
|
||||
id: state.editingTradeId || undefined,
|
||||
trade_date: document.querySelector("#tradeLogDate").value,
|
||||
code: document.querySelector("#tradeLogCode").value.trim(),
|
||||
name: document.querySelector("#tradeLogName").value.trim(),
|
||||
action: document.querySelector("#tradeLogAction").value,
|
||||
price: document.querySelector("#tradeLogPrice").value,
|
||||
quantity: document.querySelector("#tradeLogQuantity").value,
|
||||
position_pct: document.querySelector("#tradeLogPosition").value,
|
||||
pnl_amount: document.querySelector("#tradeLogPnlAmount").value,
|
||||
pnl_pct: document.querySelector("#tradeLogPnlPct").value,
|
||||
emotion: document.querySelector("#tradeLogEmotion").value,
|
||||
tags: document.querySelector("#tradeLogTags").value,
|
||||
thesis: document.querySelector("#tradeLogThesis").value,
|
||||
execution: document.querySelector("#tradeLogExecution").value,
|
||||
});
|
||||
state.tradeEntries = payload.items || [];
|
||||
state.tradeSummary = payload.summary || {};
|
||||
renderTradeLog();
|
||||
closeTradeLogDialog();
|
||||
showToast("交易记录已保存");
|
||||
} catch (error) {
|
||||
showToast(error.message || "交易记录保存失败");
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function resetTradeLogForm() {
|
||||
state.editingTradeId = 0;
|
||||
document.querySelector("#tradeLogForm").reset();
|
||||
document.querySelector("#tradeLogDate").value = elements.tradeDate.value || todayString();
|
||||
document.querySelector("#tradeLogQuantity").value = "0";
|
||||
document.querySelector("#tradeLogPosition").value = "0";
|
||||
setText("tradeLogDialogTitle", "交易日志");
|
||||
setText("saveTradeLog", "保存交易");
|
||||
}
|
||||
|
||||
function editTradeLog(id) {
|
||||
const item = state.tradeEntries.find((entry) => number(entry.id) === id);
|
||||
if (!item) return;
|
||||
state.editingTradeId = id;
|
||||
document.querySelector("#tradeLogDate").value = displayCompactDate(item.trade_date);
|
||||
document.querySelector("#tradeLogCode").value = item.code;
|
||||
document.querySelector("#tradeLogName").value = item.name;
|
||||
document.querySelector("#tradeLogAction").value = item.action;
|
||||
document.querySelector("#tradeLogPrice").value = item.price;
|
||||
document.querySelector("#tradeLogQuantity").value = item.quantity;
|
||||
document.querySelector("#tradeLogPosition").value = item.position_pct;
|
||||
document.querySelector("#tradeLogPnlAmount").value = item.pnl_amount ?? "";
|
||||
document.querySelector("#tradeLogPnlPct").value = item.pnl_pct ?? "";
|
||||
document.querySelector("#tradeLogEmotion").value = item.emotion;
|
||||
document.querySelector("#tradeLogTags").value = (item.tags || []).join(", ");
|
||||
document.querySelector("#tradeLogThesis").value = item.thesis || "";
|
||||
document.querySelector("#tradeLogExecution").value = item.execution || "";
|
||||
setText("tradeLogDialogTitle", "编辑交易日志");
|
||||
setText("saveTradeLog", "保存修改");
|
||||
openModalDialog(elements.tradeLogDialog);
|
||||
requestAnimationFrame(() => document.querySelector("#tradeLogCode").focus());
|
||||
}
|
||||
|
||||
async function handleTradeLogAction(event) {
|
||||
const button = event.target.closest("[data-trade-action]");
|
||||
if (!button) return;
|
||||
const id = number(button.dataset.tradeId);
|
||||
if (button.dataset.tradeAction === "edit") {
|
||||
editTradeLog(id);
|
||||
return;
|
||||
}
|
||||
if (!window.confirm("确定删除这条交易记录吗?")) return;
|
||||
try {
|
||||
const payload = await apiRequest(`/api/trades/${id}`, "DELETE");
|
||||
state.tradeEntries = payload.items || [];
|
||||
state.tradeSummary = payload.summary || {};
|
||||
if (state.editingTradeId === id) resetTradeLogForm();
|
||||
renderTradeLog();
|
||||
showToast("交易记录已删除");
|
||||
} catch (error) {
|
||||
showToast(error.message || "交易记录删除失败");
|
||||
}
|
||||
}
|
||||
|
||||
function renderTradeLog() {
|
||||
const summary = state.tradeSummary || {};
|
||||
setText("tradeLogCount", `${state.tradeEntries.length} 条`);
|
||||
document.querySelector("#tradeLogSummary").innerHTML = [
|
||||
["记录", `${number(summary.total)} 条`],
|
||||
["已实现", `${number(summary.realized)} 条`],
|
||||
["胜率", summary.win_rate == null ? "--" : `${formatNumber(summary.win_rate, 1)}%`],
|
||||
["累计盈亏", summary.pnl_amount == null ? "--" : `${number(summary.pnl_amount) > 0 ? "+" : ""}${formatNumber(summary.pnl_amount, 2)}`],
|
||||
["平均仓位", summary.average_position == null ? "--" : `${formatNumber(summary.average_position, 1)}%`],
|
||||
].map(([label, value]) => `<div><span>${label}</span><strong>${value}</strong></div>`).join("");
|
||||
document.querySelector("#tradeLogEmpty").hidden = state.tradeEntries.length > 0;
|
||||
document.querySelector("#tradeLogTableBody").innerHTML = state.tradeEntries.map((item) => `
|
||||
<tr data-code="${escapeHtml(item.code)}">
|
||||
<td>${displayCompactDate(item.trade_date)}</td>
|
||||
<td><span class="stock-cell"><strong class="sname">${escapeHtml(item.name)}</strong><small class="stock-code scode">${escapeHtml(item.code)}</small></span></td>
|
||||
<td><span class="trade-action trade-action-${escapeHtml(item.action)}">${escapeHtml(item.action_label)}</span></td>
|
||||
<td class="number num">${item.position_pct == null ? "" : formatNumber(item.position_pct, 1)}</td>
|
||||
<td class="number num ${item.pnl_pct == null ? "" : changeClass(item.pnl_pct)}">${item.pnl_pct == null ? "" : signed(item.pnl_pct)}</td>
|
||||
<td class="number num ${item.pnl_amount == null ? "" : changeClass(item.pnl_amount)}">${item.pnl_amount == null ? "" : signed(item.pnl_amount)}</td>
|
||||
<td><span class="trade-emotion">${escapeHtml(item.emotion_label)}</span><div class="trade-tags">${(item.tags || []).map((tag) => `<em>${escapeHtml(tag)}</em>`).join("")}</div></td>
|
||||
<td class="trade-copy" title="交易逻辑:${escapeHtml(item.thesis || "")};执行复核:${escapeHtml(item.execution || "")}"><strong>${escapeHtml(item.thesis || "")}</strong><small>${escapeHtml(item.execution || "尚未填写执行复核")}</small></td>
|
||||
<td><div class="trade-row-actions"><button class="table-action" type="button" data-trade-action="edit" data-trade-id="${number(item.id)}">编辑</button><button class="table-action down" type="button" data-trade-action="delete" data-trade-id="${number(item.id)}">删除</button></div></td>
|
||||
</tr>
|
||||
`).join("");
|
||||
bindStockRows(document.querySelector("#tradeLogTableBody"));
|
||||
}
|
||||
|
||||
async function saveStockNote(event) {
|
||||
event.preventDefault();
|
||||
if (!state.activeStock?.code) return;
|
||||
try {
|
||||
await apiRequest("/api/notes", "POST", {
|
||||
code: state.activeStock.code,
|
||||
stock_name: state.activeStock.name || "--",
|
||||
trade_date: elements.tradeDate.value,
|
||||
content: document.querySelector("#stockNoteContent").value,
|
||||
plan: document.querySelector("#stockNotePlan").value,
|
||||
});
|
||||
document.querySelector("#stockNoteContent").value = "";
|
||||
document.querySelector("#stockNotePlan").value = "";
|
||||
const payload = await apiRequest(`/api/notes?scope=stock&code=${encodeURIComponent(state.activeStock.code)}`);
|
||||
state.stockDetail.notes = payload.items || [];
|
||||
renderStockNotes(state.stockDetail.notes);
|
||||
showToast("个股笔记已保存");
|
||||
} catch (error) {
|
||||
showToast(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveReasonOverride(event) {
|
||||
event.preventDefault();
|
||||
if (!state.activeStock?.code) return;
|
||||
const reason = document.querySelector("#reasonInput").value.trim();
|
||||
try {
|
||||
await apiRequest("/api/reasons", "POST", {
|
||||
trade_date: elements.tradeDate.value,
|
||||
code: state.activeStock.code,
|
||||
reason,
|
||||
});
|
||||
state.activeStock.reason = reason;
|
||||
for (const key of ["limits", "broken", "down_limits"]) {
|
||||
const row = state.dashboard?.[key]?.find((item) => item.code === state.activeStock.code);
|
||||
if (row) row.reason = reason;
|
||||
}
|
||||
setText("detailReason", reason);
|
||||
renderDashboard();
|
||||
showToast("事件逻辑已修订");
|
||||
} catch (error) {
|
||||
showToast(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function renderMoneyflow(flow) {
|
||||
for (const [id, value] of [["flowNet", flow.net_million], ["flowLarge", flow.large_million], ["flowMedium", flow.medium_million], ["flowSmall", flow.small_million]]) {
|
||||
const element = document.getElementById(id);
|
||||
element.textContent = formatMoneyMillion(value);
|
||||
element.className = changeClass(value);
|
||||
}
|
||||
}
|
||||
|
||||
function renderStockNotes(notes) {
|
||||
renderNotesHistory(notes, document.querySelector("#stockNotes"), true);
|
||||
}
|
||||
|
||||
function renderNotesHistory(notes, container, compact) {
|
||||
container.innerHTML = notes.map((note) => `
|
||||
<article class="note-row">
|
||||
<div><time>${displayCompactDate(note.trade_date)}</time>${note.stock_name ? `<small>${escapeHtml(note.stock_name)}</small>` : ""}</div>
|
||||
${!compact ? `<div class="note-block note-summary"><strong>盘面</strong><p>${escapeHtml(note.summary || "--")}</p></div>` : ""}
|
||||
<div class="note-block"><strong>复盘</strong><p>${escapeHtml(note.content || "--")}</p></div>
|
||||
<div class="note-block"><strong>计划</strong><p>${escapeHtml(note.plan || "--")}</p></div>
|
||||
<button class="table-action down" type="button" data-note-delete="${number(note.id)}">删除</button>
|
||||
</article>
|
||||
`).join("") || emptyStateHtml("暂无复盘记录");
|
||||
container.querySelectorAll("[data-note-delete]").forEach((button) => {
|
||||
button.addEventListener("click", () => deleteNote(number(button.dataset.noteDelete), compact));
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteNote(noteId, compact) {
|
||||
try {
|
||||
await apiRequest(`/api/notes/${noteId}`, "DELETE");
|
||||
if (compact && state.activeStock) {
|
||||
state.stockDetail.notes = state.stockDetail.notes.filter((note) => number(note.id) !== noteId);
|
||||
renderStockNotes(state.stockDetail.notes);
|
||||
} else {
|
||||
await loadReviewWorkspace();
|
||||
}
|
||||
showToast("笔记已删除");
|
||||
} catch (error) {
|
||||
showToast(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/* 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() });
|
||||
const payload = await apiRequest(`/api/alerts?${query}`);
|
||||
state.alerts = payload.items || [];
|
||||
state.alertUnreadCount = number(payload.unread_count);
|
||||
renderAlerts();
|
||||
if (openDialog) openModalDialog(elements.alertsDialog);
|
||||
} catch (error) {
|
||||
if (openDialog) showToast(error.message || "提醒加载失败");
|
||||
}
|
||||
}
|
||||
|
||||
function openAlerts() {
|
||||
toggleHeaderCommandMenu(false);
|
||||
toggleAccountDropdown(false);
|
||||
document.querySelector("#alertDate").value ||= todayString();
|
||||
openModalDialog(elements.alertsDialog);
|
||||
loadAlerts();
|
||||
}
|
||||
|
||||
function openStockReminder() {
|
||||
const stock = state.activeStock || {};
|
||||
document.querySelector("#alertTitle").value = `${stock.name || stock.code || "个股"}观察提醒`;
|
||||
document.querySelector("#alertCode").value = stock.code || "";
|
||||
document.querySelector("#alertDate").value = todayString();
|
||||
if (elements.stockDialog.open) elements.stockDialog.close();
|
||||
openAlerts();
|
||||
document.querySelector("#alertContent").focus();
|
||||
}
|
||||
|
||||
function selectAlertFilter(filter) {
|
||||
state.alertFilter = filter === "unread" ? "unread" : "all";
|
||||
document.querySelectorAll("[data-alert-filter]").forEach((button) => {
|
||||
button.classList.toggle("active", button.dataset.alertFilter === state.alertFilter);
|
||||
});
|
||||
loadAlerts();
|
||||
}
|
||||
|
||||
async function saveAlert(event) {
|
||||
event.preventDefault();
|
||||
const button = event.currentTarget.querySelector("button[type='submit']");
|
||||
button.disabled = true;
|
||||
try {
|
||||
const payload = await apiRequest("/api/alerts", "POST", {
|
||||
title: document.querySelector("#alertTitle").value.trim(),
|
||||
remind_date: document.querySelector("#alertDate").value,
|
||||
code: document.querySelector("#alertCode").value.trim(),
|
||||
content: document.querySelector("#alertContent").value.trim(),
|
||||
});
|
||||
event.currentTarget.reset();
|
||||
document.querySelector("#alertDate").value = todayString();
|
||||
state.alertFilter = "all";
|
||||
state.alerts = payload.items || [];
|
||||
state.alertUnreadCount = number(payload.unread_count);
|
||||
renderAlerts();
|
||||
showToast("提醒已保存");
|
||||
} catch (error) {
|
||||
showToast(error.message || "提醒保存失败");
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function markAllAlertsRead() {
|
||||
try {
|
||||
await apiRequest("/api/alerts/read-all", "POST", { as_of: todayString() });
|
||||
await loadAlerts();
|
||||
} catch (error) {
|
||||
showToast(error.message || "提醒状态更新失败");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAlertAction(event) {
|
||||
const button = event.target.closest("[data-alert-action]");
|
||||
if (!button) return;
|
||||
const id = number(button.dataset.alertId);
|
||||
if (!id) return;
|
||||
try {
|
||||
if (button.dataset.alertAction === "delete") {
|
||||
await apiRequest(`/api/alerts/${id}`, "DELETE");
|
||||
} else {
|
||||
await apiRequest(`/api/alerts/${id}/read`, "POST", {});
|
||||
}
|
||||
await loadAlerts();
|
||||
} catch (error) {
|
||||
showToast(error.message || "提醒操作失败");
|
||||
}
|
||||
}
|
||||
|
||||
function renderAlerts() {
|
||||
const badge = document.querySelector("#alertBadge");
|
||||
badge.hidden = state.alertUnreadCount <= 0;
|
||||
badge.textContent = state.alertUnreadCount > 99 ? "99+" : String(state.alertUnreadCount);
|
||||
document.querySelector("#alertButton").classList.toggle("has-alerts", state.alertUnreadCount > 0);
|
||||
setText("alertListCount", `${state.alerts.length} 条`);
|
||||
document.querySelectorAll("[data-alert-filter]").forEach((button) => {
|
||||
button.classList.toggle("active", button.dataset.alertFilter === state.alertFilter);
|
||||
});
|
||||
document.querySelector("#markAllAlertsRead").disabled = state.alertUnreadCount <= 0;
|
||||
const container = document.querySelector("#alertList");
|
||||
container.innerHTML = state.alerts.map((item) => {
|
||||
const upcoming = !item.due;
|
||||
const kindLabel = item.kind === "manual" ? "自定提醒" : item.kind === "strategy_t5" ? "跟踪完成" : "策略反馈";
|
||||
return `<article class="alert-item ${item.is_read ? "is-read" : "is-unread"} ${upcoming ? "is-upcoming" : ""}">
|
||||
<div class="alert-item-icon"><i data-lucide="${upcoming ? "calendar-clock" : item.kind === "manual" ? "bell" : "chart-no-axes-combined"}"></i></div>
|
||||
<div class="alert-item-copy">
|
||||
<div><span>${escapeHtml(kindLabel)}</span><time>${displayCompactDate(item.available_date)}</time></div>
|
||||
<strong>${escapeHtml(item.title)}</strong>
|
||||
${item.content ? `<p>${escapeHtml(item.content)}</p>` : ""}
|
||||
${item.code ? `<button class="stock-preview-trigger alert-stock-link" type="button" data-code="${escapeHtml(item.code)}">${escapeHtml(item.code)}</button>` : ""}
|
||||
</div>
|
||||
<div class="alert-item-actions">
|
||||
${!item.is_read && !upcoming ? `<button class="icon-button" type="button" data-alert-action="read" data-alert-id="${number(item.id)}" title="标为已读" aria-label="标为已读"><i data-lucide="check"></i></button>` : ""}
|
||||
<button class="icon-button" type="button" data-alert-action="delete" data-alert-id="${number(item.id)}" title="删除提醒" aria-label="删除提醒"><i data-lucide="trash-2"></i></button>
|
||||
</div>
|
||||
</article>`;
|
||||
}).join("") || emptyStateHtml("暂无提醒");
|
||||
bindStockRows(container);
|
||||
refreshIcons();
|
||||
}
|
||||
|
||||
async function openReviewAssistant() {
|
||||
toggleHeaderCommandMenu(false);
|
||||
toggleAccountDropdown(false);
|
||||
openModalDialog(elements.assistantDialog);
|
||||
updateAssistantControls();
|
||||
if (!hasMemberAccess()) {
|
||||
document.querySelector("#closeAssistantDialog").focus();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const payload = await apiRequest("/api/assistant/messages");
|
||||
state.assistantMessages = payload.items || [];
|
||||
renderAssistantMessages();
|
||||
} catch (error) {
|
||||
showToast(error.message || "对话记录加载失败");
|
||||
}
|
||||
document.querySelector("#assistantQuestion").focus();
|
||||
}
|
||||
|
||||
function useAssistantPrompt(prompt) {
|
||||
const input = document.querySelector("#assistantQuestion");
|
||||
input.value = prompt;
|
||||
input.focus();
|
||||
}
|
||||
|
||||
async function sendAssistantQuestion(event) {
|
||||
event.preventDefault();
|
||||
if (state.assistantLoading) return;
|
||||
const input = document.querySelector("#assistantQuestion");
|
||||
const question = input.value.trim();
|
||||
if (!question) return;
|
||||
input.value = "";
|
||||
state.assistantMessages.push({ role: "user", content: question, context_date: elements.tradeDate.value.replaceAll("-", "") });
|
||||
state.assistantMessages.push({ role: "assistant", content: "", streaming: true, context_date: elements.tradeDate.value.replaceAll("-", "") });
|
||||
state.assistantLoading = true;
|
||||
state.assistantController = new AbortController();
|
||||
updateAssistantControls();
|
||||
renderAssistantMessages();
|
||||
try {
|
||||
await streamAssistantRequest(question, state.assistantController.signal, (chunk) => {
|
||||
const message = state.assistantMessages.at(-1);
|
||||
if (message?.role === "assistant") message.content += chunk;
|
||||
scheduleAssistantRender();
|
||||
});
|
||||
const message = state.assistantMessages.at(-1);
|
||||
if (message) message.streaming = false;
|
||||
setStatus("复盘助手回答完成");
|
||||
} catch (error) {
|
||||
const message = state.assistantMessages.at(-1);
|
||||
if (message?.role === "assistant") {
|
||||
message.streaming = false;
|
||||
message.error = true;
|
||||
if (!message.content) message.content = error.name === "AbortError" ? "已停止生成。" : error.message || "回答失败,请稍后重试。";
|
||||
}
|
||||
if (error.name !== "AbortError") showToast(error.message || "复盘助手回答失败");
|
||||
} finally {
|
||||
state.assistantLoading = false;
|
||||
state.assistantController = null;
|
||||
updateAssistantControls();
|
||||
renderAssistantMessages();
|
||||
input.focus();
|
||||
}
|
||||
}
|
||||
|
||||
async function streamAssistantRequest(question, signal, onDelta) {
|
||||
await window.XiaobaiAPI.streamNdjson("/api/assistant/chat", {
|
||||
method: "POST",
|
||||
body: { question, trade_date: elements.tradeDate.value },
|
||||
signal,
|
||||
errorMessage: "复盘助手暂不可用",
|
||||
onEvent: (event) => {
|
||||
if (event.type === "delta") onDelta(String(event.content || ""));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function stopAssistantResponse() {
|
||||
state.assistantController?.abort();
|
||||
}
|
||||
|
||||
async function clearAssistantConversation() {
|
||||
if (state.assistantLoading || !state.assistantMessages.length) return;
|
||||
if (!window.confirm("确定清空复盘助手的对话记录吗?")) return;
|
||||
try {
|
||||
await apiRequest("/api/assistant/messages", "DELETE");
|
||||
state.assistantMessages = [];
|
||||
renderAssistantMessages();
|
||||
} catch (error) {
|
||||
showToast(error.message || "对话记录清空失败");
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleAssistantRender() {
|
||||
if (assistantRenderFrame) return;
|
||||
assistantRenderFrame = requestAnimationFrame(() => {
|
||||
assistantRenderFrame = 0;
|
||||
renderAssistantMessages();
|
||||
});
|
||||
}
|
||||
|
||||
function renderAssistantMessages() {
|
||||
const container = document.querySelector("#assistantMessages");
|
||||
container.innerHTML = state.assistantMessages.map((message) => `
|
||||
<article class="assistant-message ${message.role} ${message.error ? "is-error" : ""}">
|
||||
<div class="assistant-message-label">${message.role === "user" ? "我" : "复盘助手"}${message.context_date ? `<time>${displayCompactDate(message.context_date)}</time>` : ""}</div>
|
||||
<div class="assistant-message-content">${message.role === "assistant" ? (message.content ? formatMentorAnswer(message.content) : '<span class="assistant-thinking">正在整理复盘数据</span>') : escapeHtml(message.content)}</div>
|
||||
${message.streaming ? '<span class="assistant-stream-caret" aria-hidden="true"></span>' : ""}
|
||||
</article>
|
||||
`).join("") || emptyStateHtml("可以从市场、策略或自己的交易记录开始复盘");
|
||||
updateAssistantControls();
|
||||
requestAnimationFrame(() => { container.scrollTop = container.scrollHeight; });
|
||||
}
|
||||
|
||||
function updateAssistantControls() {
|
||||
const unlocked = hasMemberAccess();
|
||||
elements.assistantDialog.classList.toggle("member-locked", !unlocked);
|
||||
document.querySelector("#assistantMemberGate").hidden = unlocked;
|
||||
document.querySelector("#assistantMemberContent").setAttribute("aria-disabled", String(!unlocked));
|
||||
document.querySelector("#assistantQuestion").disabled = !unlocked || state.assistantLoading;
|
||||
document.querySelector("#sendAssistant").disabled = !unlocked || state.assistantLoading;
|
||||
document.querySelector("#stopAssistant").hidden = !unlocked || !state.assistantLoading;
|
||||
document.querySelector("#clearAssistantMessages").disabled = !unlocked || state.assistantLoading || !state.assistantMessages.length;
|
||||
document.querySelectorAll("[data-assistant-prompt]").forEach((button) => {
|
||||
button.disabled = !unlocked || state.assistantLoading;
|
||||
});
|
||||
}
|
||||
|
||||
/* PRESERVATION-SOURCE-END app.js:7720-7968 */
|
||||
@@ -0,0 +1,173 @@
|
||||
window.XiaobaiPageModules.register("rotation", ["rotationView"], {
|
||||
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`;
|
||||
if (!force && state.rotationHistoryKey === key && state.rotationHistory) {
|
||||
renderRotationHistory();
|
||||
return;
|
||||
}
|
||||
state.rotationLoading = true;
|
||||
const container = document.querySelector("#rotationHistory");
|
||||
renderEmptyState(container, "正在读取轮动历史");
|
||||
try {
|
||||
const query = new URLSearchParams({
|
||||
trade_date: elements.tradeDate.value,
|
||||
});
|
||||
state.rotationHistory = await apiRequest(`/api/rotation/history?${query}`);
|
||||
state.rotationHistoryKey = key;
|
||||
renderRotationHistory();
|
||||
} catch (error) {
|
||||
renderEmptyState(container, error.message || "轮动历史加载失败");
|
||||
showToast(error.message || "轮动历史加载失败");
|
||||
} finally {
|
||||
state.rotationLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function renderRotationHistory() {
|
||||
const rows = state.rotationHistory?.rows || [];
|
||||
const selected = state.rotationSelectedSector;
|
||||
const container = document.querySelector("#rotationHistory");
|
||||
const tracker = document.querySelector("#rotationTracker");
|
||||
if (!rows.length) {
|
||||
renderEmptyState(container, "尚无连续交易日的板块数据");
|
||||
setText("rotationHistoryRange", "暂无轮动历史");
|
||||
tracker.hidden = true;
|
||||
return;
|
||||
}
|
||||
const chronological = [...rows]
|
||||
.sort((left, right) => String(left.trade_date).localeCompare(String(right.trade_date)))
|
||||
.slice(-9);
|
||||
const displayRows = state.rotationOrder === "latest" ? [...chronological].reverse() : chronological;
|
||||
document.querySelectorAll("[data-rotation-order]").forEach((button) => {
|
||||
button.classList.toggle("active", button.dataset.rotationOrder === state.rotationOrder);
|
||||
});
|
||||
setText(
|
||||
"rotationHistoryRange",
|
||||
`最近 ${chronological.length} 个交易日 · ${displayCompactDate(chronological[0].trade_date)} → ${displayCompactDate(chronological[chronological.length - 1].trade_date)} · ${state.rotationOrder === "latest" ? "由近到远,左侧为最新交易日" : "由远到近,右侧为最新交易日"}`,
|
||||
);
|
||||
setText("rotationSelectionHint", selected ? `已联动高亮 ${selected}` : "点击任意板块追踪其连续性");
|
||||
if (selected) {
|
||||
const sequence = displayRows.map((day) => {
|
||||
const sector = (day.sectors || []).find((item) => item.name === selected);
|
||||
return { tradeDate: day.trade_date, sector };
|
||||
});
|
||||
const appearances = sequence.filter((item) => item.sector);
|
||||
const bestRank = appearances.length ? Math.min(...appearances.map((item) => number(item.sector.rank))) : 0;
|
||||
tracker.hidden = false;
|
||||
const continuity = appearances.length >= 3 ? "主线候选" : appearances.length === 1 ? "单日异动,持续性待验证" : "间断活跃";
|
||||
tracker.innerHTML = `
|
||||
<div class="rotation-tracker-copy"><strong>${escapeHtml(selected)}</strong><span>近 9 日在榜 <b>${appearances.length}</b> 天 · 最高排名 <b>#${bestRank || "--"}</b> · ${continuity}</span></div>
|
||||
<div class="rotation-tracker-spark" aria-label="${escapeHtml(selected)}九日强度轨迹">
|
||||
${sequence.map((item) => item.sector
|
||||
? `<span style="--spark-height:${Math.max(18, clamp(number(item.sector.strength), 0, 100))}%" title="${escapeHtml(displayCompactDate(item.tradeDate))} · 第 ${number(item.sector.rank)} 名 · 强度 ${formatNumber(item.sector.strength, 0)}"><i></i><small>#${number(item.sector.rank)}</small></span>`
|
||||
: `<span class="missing" title="${escapeHtml(displayCompactDate(item.tradeDate))} · 未上榜"><i></i><small>--</small></span>`).join("")}
|
||||
</div>
|
||||
<button class="rotation-track-cancel" type="button">取消追踪</button>`;
|
||||
tracker.querySelector(".rotation-track-cancel").addEventListener("click", () => {
|
||||
state.rotationSelectedSector = "";
|
||||
state.rotationSelectedDate = "";
|
||||
renderRotationHistory();
|
||||
loadRotationMembers("");
|
||||
});
|
||||
} else {
|
||||
tracker.hidden = true;
|
||||
tracker.innerHTML = "";
|
||||
}
|
||||
container.classList.toggle("tracking", Boolean(selected));
|
||||
const latestTradeDate = chronological[chronological.length - 1].trade_date;
|
||||
container.innerHTML = displayRows.map((day) => {
|
||||
const hasSelected = selected && (day.sectors || []).some((sector) => sector.name === selected);
|
||||
return `
|
||||
<article class="rotation-day ${selected ? "has-selection" : ""} ${hasSelected ? "selected-day" : ""} ${day.trade_date === latestTradeDate ? "latest-day" : ""}">
|
||||
<header><time>${escapeHtml(displayCompactDate(day.trade_date).slice(5))}</time><span>${(day.sectors || []).length} 个热点</span></header>
|
||||
<div class="rotation-day-sectors">${(day.sectors || []).map((sector) => {
|
||||
const strength = clamp(number(sector.strength), 0, 100);
|
||||
const heatClass = strength >= 90 ? "heat-strong" : strength >= 70 ? "heat-warm" : "heat-mild";
|
||||
return `
|
||||
<button type="button" class="rotation-sector-chip ${heatClass} ${selected === sector.name ? "selected" : ""}" data-rotation-sector="${escapeHtml(sector.name)}" data-rotation-date="${escapeHtml(day.trade_date)}">
|
||||
<span class="rotation-rank rank-${Math.min(number(sector.rank), 4)}">${number(sector.rank)}</span><strong>${escapeHtml(sector.name)}</strong><small><b>${number(sector.count)}</b> 家 · ${formatNumber(sector.strength, 0)}</small>
|
||||
<span class="rotation-cell-tooltip">${escapeHtml(displayCompactDate(day.trade_date).slice(5))} · 第 ${number(sector.rank)} 名 · 涨停 ${number(sector.count)} 家 · 强度 ${formatNumber(sector.strength, 0)}</span>
|
||||
</button>`;
|
||||
}).join("")}</div>
|
||||
</article>`;
|
||||
}).join("");
|
||||
container.querySelectorAll("[data-rotation-sector]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const clickedSector = button.dataset.rotationSector;
|
||||
const clickedDate = button.dataset.rotationDate;
|
||||
const isSameSelection = clickedSector === state.rotationSelectedSector
|
||||
&& clickedDate === state.rotationSelectedDate;
|
||||
state.rotationSelectedSector = isSameSelection ? "" : clickedSector;
|
||||
state.rotationSelectedDate = isSameSelection ? "" : clickedDate;
|
||||
renderRotationHistory();
|
||||
loadRotationMembers(state.rotationSelectedSector);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function loadRotationMembers(sector, force = false) {
|
||||
if (!sector) {
|
||||
state.rotationMembers = null;
|
||||
state.rotationMembersKey = "";
|
||||
renderRotationMembers();
|
||||
return;
|
||||
}
|
||||
const memberDate = state.rotationSelectedDate || elements.tradeDate.value;
|
||||
const key = `${memberDate}:${sector}`;
|
||||
if (!force && state.rotationMembersKey === key && state.rotationMembers) {
|
||||
renderRotationMembers();
|
||||
return;
|
||||
}
|
||||
state.rotationMembersLoading = true;
|
||||
renderRotationMembers();
|
||||
try {
|
||||
const query = new URLSearchParams({ trade_date: memberDate, sector });
|
||||
state.rotationMembers = await apiRequest(`/api/rotation/members?${query}`);
|
||||
state.rotationMembersKey = key;
|
||||
} catch (error) {
|
||||
state.rotationMembers = { error: error.message || "成分股加载失败", rows: [] };
|
||||
state.rotationMembersKey = key;
|
||||
} finally {
|
||||
state.rotationMembersLoading = false;
|
||||
renderRotationMembers();
|
||||
}
|
||||
}
|
||||
|
||||
function renderRotationMembers() {
|
||||
const body = document.querySelector("#rotationTableBody");
|
||||
const empty = document.querySelector("#rotationMembersEmpty");
|
||||
if (state.rotationMembersLoading) {
|
||||
body.innerHTML = "";
|
||||
empty.textContent = `正在核验${state.rotationSelectedSector}成分股`;
|
||||
empty.hidden = false;
|
||||
return;
|
||||
}
|
||||
const payload = state.rotationMembers;
|
||||
const rows = payload?.rows || [];
|
||||
if (!state.rotationSelectedSector || !payload || payload.error || !rows.length) {
|
||||
body.innerHTML = "";
|
||||
empty.textContent = payload?.error || (state.rotationSelectedSector ? "该板块暂无可用成分行情" : "点击上方任意板块查看成分股");
|
||||
empty.hidden = false;
|
||||
setText("rotationDetailTitle", "板块成分股");
|
||||
setText("rotationDetailMeta", state.rotationSelectedSector || "--");
|
||||
return;
|
||||
}
|
||||
empty.hidden = true;
|
||||
setText("rotationDetailTitle", `${payload.meta?.sector_name || state.rotationSelectedSector}成分股`);
|
||||
setText("rotationDetailMeta", `${displayCompactDate(payload.meta?.trade_date)} · ${number(payload.meta?.quoted_count)} / ${number(payload.meta?.member_count)} 只`);
|
||||
body.innerHTML = rows.map((row, index) => `
|
||||
<tr data-code="${escapeHtml(row.code)}"><td class="number num muted">${index + 1}</td><td class="stock-code">${escapeHtml(row.code)}</td><td class="stock-name">${escapeHtml(row.name)}</td>
|
||||
<td class="number num ${row.quoted ? changeClass(row.change) : "muted"}" data-sort-value="${row.quoted ? number(row.change) : -999}">${row.quoted ? signed(row.change) : ""}</td>
|
||||
<td class="number num">${row.quoted ? formatNumber(row.open, 2) : ""}</td><td class="number num">${row.quoted ? formatNumber(row.close, 2) : ""}</td>
|
||||
<td class="number num" data-sort-value="${number(row.amount_billion)}">${row.quoted ? formatNumber(row.amount_billion, 2) : ""}</td><td>${row.quoted ? "正常交易" : "当日无行情"}</td></tr>
|
||||
`).join("");
|
||||
animateRows(body);
|
||||
bindStockRows(body);
|
||||
}
|
||||
|
||||
/* PRESERVATION-SOURCE-END app.js:1958-2124 */
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,316 @@
|
||||
window.XiaobaiPageModules.register("sentiment", ["sentimentCycleView"], {
|
||||
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}`;
|
||||
if (!force && state.sentimentHistoryKey === key && state.sentimentHistory) {
|
||||
renderSentimentHistory();
|
||||
return;
|
||||
}
|
||||
state.sentimentLoading = true;
|
||||
const notice = document.querySelector("#sentimentHistoryNotice");
|
||||
notice.hidden = true;
|
||||
try {
|
||||
const query = new URLSearchParams({
|
||||
trade_date: elements.tradeDate.value,
|
||||
limit: String(state.sentimentRange),
|
||||
});
|
||||
state.sentimentHistory = await apiRequest(`/api/sentiment/history?${query}`);
|
||||
state.sentimentHistoryKey = key;
|
||||
renderSentimentHistory();
|
||||
} catch (error) {
|
||||
notice.textContent = error.message || "情绪周期数据加载失败";
|
||||
notice.hidden = false;
|
||||
showToast(notice.textContent);
|
||||
} finally {
|
||||
state.sentimentLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function renderSentimentHistory() {
|
||||
const payload = state.sentimentHistory;
|
||||
if (!payload) return;
|
||||
const rows = payload.rows || [];
|
||||
const latest = rows[rows.length - 1];
|
||||
const body = document.querySelector("#sentimentHistoryBody");
|
||||
const empty = document.querySelector("#sentimentHistoryEmpty");
|
||||
empty.hidden = rows.length > 0;
|
||||
body.innerHTML = [...rows].reverse().map((row) => {
|
||||
return `
|
||||
<tr class="${row.trade_date === latest?.trade_date ? "latest-row" : ""}">
|
||||
<td class="sentiment-date-cell">${escapeHtml(displayCompactDate(row.trade_date))}</td>
|
||||
<td class="number sentiment-score-cell ${sentimentScoreClass(row.score)}">${number(row.score)}</td>
|
||||
<td><span class="sentiment-phase-badge ${sentimentPhaseClass(row.phase)}">${escapeHtml(row.phase)}</span></td>
|
||||
<td><span class="sentiment-direction ${trendClass(row.direction)}">${escapeHtml(row.direction)}</span></td>
|
||||
<td class="number">${number(row.limit_up_count)}</td>
|
||||
<td class="number">${number(row.first_board_count)}</td>
|
||||
<td class="number">${number(row.second_board_count)}</td>
|
||||
<td class="number">${number(row.three_plus_count)}</td>
|
||||
<td class="number">${number(row.max_height)}板</td>
|
||||
<td class="number">${number(row.broken_count)}</td>
|
||||
<td class="number">${number(row.limit_down_count)}</td>
|
||||
<td class="number">${number(row.previous_limit_count)}</td>
|
||||
<td class="number">${number(row.previous_positive_count)}</td>
|
||||
<td class="number">${formatNumber(row.previous_positive_rate, 1)}%</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join("");
|
||||
|
||||
if (!latest) {
|
||||
setText("sentimentHistoryDateRange", "暂无历史数据");
|
||||
return;
|
||||
}
|
||||
setText(
|
||||
"sentimentHistoryDateRange",
|
||||
`${displayCompactDate(rows[0].trade_date)} 至 ${displayCompactDate(latest.trade_date)}`,
|
||||
);
|
||||
setText("sentimentCycleScore", number(latest.score));
|
||||
setText("sentimentCycleLabel", latest.label);
|
||||
setText("sentimentCycleDate", displayCompactDate(latest.trade_date));
|
||||
setText("sentimentCyclePhase", latest.phase);
|
||||
setText("sentimentCycleDirection", latest.direction);
|
||||
const dayChange = number(latest.day_change);
|
||||
const confidence = sentimentPhaseConfidence(latest);
|
||||
setText("sentimentPhaseConfidence", `置信度 ${confidence}%`);
|
||||
setText("sentimentDayChange", `${dayChange > 0 ? "+" : ""}${formatNumber(dayChange, 1)}`);
|
||||
setText("sentimentSealRate", `${formatNumber(latest.seal_rate, 1)}%`);
|
||||
setText("sentimentLimitUp", number(latest.limit_up_count));
|
||||
setText("sentimentBroken", number(latest.broken_count));
|
||||
setText("sentimentPhaseAdvice", sentimentPhaseAdvice(latest.phase));
|
||||
setText("sentimentCurrentTag", `当前 ${number(latest.score)} · ${latest.phase}`);
|
||||
setText("sentimentComponentSummary", `五维加权 → 温度 ${number(latest.score)}`);
|
||||
setText("sentimentPeriodNote", `近 ${state.sentimentRange} 个交易日,当前展示 ${rows.length} 日`);
|
||||
const changeElement = document.querySelector("#sentimentDayChange");
|
||||
changeElement.className = changeClass(dayChange);
|
||||
setText("sentimentPreviousPositive", `${number(latest.previous_positive_count)} / ${number(latest.previous_limit_count)} 只`);
|
||||
setText("sentimentPreviousAverage", `红盘率 ${formatNumber(latest.previous_positive_rate, 1)}% · 平均 ${signed(latest.average_previous_change)}%`);
|
||||
setText("sentimentHistoryDays", `${number(payload.available_days)} 个交易日`);
|
||||
setText("sentimentNormalization", `${latest.normalization} · 当前展示 ${rows.length} 日`);
|
||||
const marker = document.querySelector("#sentimentCycleScoreMarker");
|
||||
marker.className = `sentiment-current-phase-badge ${sentimentPhaseClass(latest.phase)}`;
|
||||
document.querySelector("#sentimentComponentList").innerHTML = Object.values(latest.components || {}).map((item) => `
|
||||
<article class="sentiment-component-item">
|
||||
<div class="sentiment-component-main">
|
||||
<strong>${escapeHtml(item.label)}</strong>
|
||||
<div class="sentiment-component-track" aria-hidden="true"><i data-component-score="${clamp(item.score, 0, 100)}" style="width:0%"></i></div>
|
||||
<b>${formatNumber(item.score, 1)} <em>× ${number(item.weight)}%</em></b>
|
||||
</div>
|
||||
<small>${escapeHtml(item.summary)}</small>
|
||||
</article>
|
||||
`).join("");
|
||||
requestAnimationFrame(() => {
|
||||
animateSentimentComponents();
|
||||
animateSentimentTrendChart(rows);
|
||||
bindSentimentChartTooltip(rows);
|
||||
});
|
||||
animateRows(body);
|
||||
}
|
||||
|
||||
function animateSentimentComponents() {
|
||||
document.querySelectorAll("#sentimentComponentList [data-component-score]").forEach((bar, index) => {
|
||||
const width = `${number(bar.dataset.componentScore)}%`;
|
||||
if (!motionEnabled()) {
|
||||
bar.style.width = width;
|
||||
return;
|
||||
}
|
||||
setTimeout(() => { bar.style.width = width; }, index * 70);
|
||||
});
|
||||
}
|
||||
|
||||
function animateSentimentTrendChart(rows) {
|
||||
if (sentimentChartAnimationFrame) cancelAnimationFrame(sentimentChartAnimationFrame);
|
||||
if (!motionEnabled()) {
|
||||
drawSentimentTrendChart(rows, 1);
|
||||
return;
|
||||
}
|
||||
const startedAt = performance.now();
|
||||
const duration = 780;
|
||||
const frame = (now) => {
|
||||
const rawProgress = Math.min(1, (now - startedAt) / duration);
|
||||
const progress = 1 - (1 - rawProgress) ** 3;
|
||||
drawSentimentTrendChart(rows, progress);
|
||||
if (rawProgress < 1) sentimentChartAnimationFrame = requestAnimationFrame(frame);
|
||||
else sentimentChartAnimationFrame = null;
|
||||
};
|
||||
sentimentChartAnimationFrame = requestAnimationFrame(frame);
|
||||
}
|
||||
|
||||
function drawSentimentTrendChart(rows, progress = 1) {
|
||||
const canvas = document.querySelector("#sentimentTrendChart");
|
||||
if (!canvas || !rows.length || state.activeView !== "sentimentCycleView") return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
if (!rect.width) return;
|
||||
const width = Math.max(320, rect.width);
|
||||
const height = Math.max(220, rect.height);
|
||||
const ratio = window.devicePixelRatio || 1;
|
||||
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 padding = { top: 18, right: 18, bottom: 34, left: 42 };
|
||||
const chartWidth = width - padding.left - padding.right;
|
||||
const chartHeight = height - padding.top - padding.bottom;
|
||||
const x = (index) => padding.left + (rows.length === 1 ? chartWidth / 2 : index / (rows.length - 1) * chartWidth);
|
||||
const y = (score) => padding.top + (100 - clamp(score, 0, 100)) / 100 * chartHeight;
|
||||
|
||||
context.font = '10px "Microsoft YaHei UI", sans-serif';
|
||||
context.textAlign = "right";
|
||||
context.textBaseline = "middle";
|
||||
for (let score = 0; score <= 100; score += 20) {
|
||||
const lineY = y(score);
|
||||
context.strokeStyle = score === 40 || score === 80 ? palette.zero : palette.grid;
|
||||
context.lineWidth = 1;
|
||||
context.beginPath();
|
||||
context.moveTo(padding.left, lineY);
|
||||
context.lineTo(width - padding.right, lineY);
|
||||
context.stroke();
|
||||
context.fillStyle = palette.axis;
|
||||
context.fillText(String(score), padding.left - 8, lineY);
|
||||
}
|
||||
|
||||
context.save();
|
||||
context.beginPath();
|
||||
context.rect(padding.left - 6, padding.top - 8, (chartWidth + 12) * clamp(progress, 0, 1), chartHeight + 18);
|
||||
context.clip();
|
||||
|
||||
const finalPhase = rows[rows.length - 1]?.phase;
|
||||
let phaseStart = rows.length - 1;
|
||||
while (phaseStart > 0 && rows[phaseStart - 1]?.phase === finalPhase) phaseStart -= 1;
|
||||
if (["退潮", "冰点"].includes(finalPhase)) {
|
||||
const startX = phaseStart === 0 ? padding.left : (x(phaseStart - 1) + x(phaseStart)) / 2;
|
||||
context.fillStyle = palette.alertArea;
|
||||
context.fillRect(startX, padding.top, width - padding.right - startX, chartHeight);
|
||||
context.fillStyle = palette.up;
|
||||
context.font = '10px "Microsoft YaHei UI", sans-serif';
|
||||
context.textAlign = "center";
|
||||
context.textBaseline = "top";
|
||||
context.fillText(finalPhase, (startX + width - padding.right) / 2, padding.top + 4);
|
||||
}
|
||||
|
||||
const movingAverage = rows.map((_row, index) => {
|
||||
const start = Math.max(0, index - 4);
|
||||
const sample = rows.slice(start, index + 1);
|
||||
return sample.reduce((sum, item) => sum + number(item.score), 0) / sample.length;
|
||||
});
|
||||
context.beginPath();
|
||||
movingAverage.forEach((score, index) => {
|
||||
if (index === 0) context.moveTo(x(index), y(score));
|
||||
else context.lineTo(x(index), y(score));
|
||||
});
|
||||
context.strokeStyle = palette.movingAverage;
|
||||
context.lineWidth = 1.5;
|
||||
context.setLineDash([5, 4]);
|
||||
context.stroke();
|
||||
context.setLineDash([]);
|
||||
|
||||
context.beginPath();
|
||||
rows.forEach((row, index) => {
|
||||
const pointX = x(index);
|
||||
const pointY = y(row.score);
|
||||
if (index === 0) context.moveTo(pointX, pointY);
|
||||
else context.lineTo(pointX, pointY);
|
||||
});
|
||||
context.lineTo(x(rows.length - 1), padding.top + chartHeight);
|
||||
context.lineTo(x(0), padding.top + chartHeight);
|
||||
context.closePath();
|
||||
context.fillStyle = palette.area;
|
||||
context.fill();
|
||||
|
||||
context.beginPath();
|
||||
rows.forEach((row, index) => {
|
||||
const pointX = x(index);
|
||||
const pointY = y(row.score);
|
||||
if (index === 0) context.moveTo(pointX, pointY);
|
||||
else context.lineTo(pointX, pointY);
|
||||
});
|
||||
context.strokeStyle = palette.line;
|
||||
context.lineWidth = 2.5;
|
||||
context.lineJoin = "round";
|
||||
context.lineCap = "round";
|
||||
context.stroke();
|
||||
|
||||
rows.forEach((row, index) => {
|
||||
context.beginPath();
|
||||
context.arc(x(index), y(row.score), index === rows.length - 1 ? 4.5 : 3, 0, Math.PI * 2);
|
||||
context.fillStyle = ["退潮", "冰点"].includes(row.phase) ? palette.up : row.phase === "修复" ? palette.repair : palette.line;
|
||||
context.fill();
|
||||
context.strokeStyle = palette.background;
|
||||
context.lineWidth = 1.5;
|
||||
context.stroke();
|
||||
});
|
||||
context.restore();
|
||||
|
||||
const labelStep = Math.max(1, Math.ceil(rows.length / 6));
|
||||
context.textAlign = "center";
|
||||
context.textBaseline = "top";
|
||||
context.fillStyle = palette.axis;
|
||||
rows.forEach((row, index) => {
|
||||
if (index % labelStep !== 0 && index !== rows.length - 1) return;
|
||||
const dateText = displayCompactDate(row.trade_date).slice(5);
|
||||
context.fillText(dateText, x(index), height - padding.bottom + 10);
|
||||
});
|
||||
}
|
||||
|
||||
function bindSentimentChartTooltip(rows) {
|
||||
const canvas = document.querySelector("#sentimentTrendChart");
|
||||
const tooltip = document.querySelector("#sentimentChartTooltip");
|
||||
if (!canvas || !tooltip || !rows.length) return;
|
||||
canvas.onmousemove = (event) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const padding = { left: 42, right: 18 };
|
||||
const chartWidth = Math.max(1, rect.width - padding.left - padding.right);
|
||||
const relativeX = clamp(event.clientX - rect.left - padding.left, 0, chartWidth);
|
||||
const index = rows.length === 1 ? 0 : Math.round(relativeX / chartWidth * (rows.length - 1));
|
||||
const row = rows[index];
|
||||
tooltip.innerHTML = `${escapeHtml(displayCompactDate(row.trade_date))} · 温度 <b>${number(row.score)}</b> · ${escapeHtml(row.phase)}`;
|
||||
tooltip.hidden = false;
|
||||
const targetLeft = padding.left + (rows.length === 1 ? chartWidth / 2 : index / (rows.length - 1) * chartWidth);
|
||||
tooltip.style.left = `${clamp(targetLeft + 10, 8, rect.width - tooltip.offsetWidth - 8)}px`;
|
||||
tooltip.style.top = `${clamp(event.clientY - rect.top - 34, 8, rect.height - 34)}px`;
|
||||
};
|
||||
canvas.onmouseleave = () => { tooltip.hidden = true; };
|
||||
}
|
||||
|
||||
function sentimentScoreClass(score) {
|
||||
const value = number(score);
|
||||
return value >= 60 ? "score-strong" : value < 40 ? "score-weak" : "score-neutral";
|
||||
}
|
||||
|
||||
function sentimentPhaseClass(phase) {
|
||||
return {
|
||||
"冰点": "phase-ice",
|
||||
"修复": "phase-repair",
|
||||
"发酵": "phase-fermentation",
|
||||
"高潮": "phase-climax",
|
||||
"分化": "phase-divergence",
|
||||
"退潮": "phase-retreat",
|
||||
}[phase] || "phase-divergence";
|
||||
}
|
||||
|
||||
function sentimentPhaseConfidence(row) {
|
||||
const explicit = number(row?.confidence || row?.phase_confidence);
|
||||
if (explicit > 0) return Math.round(clamp(explicit, 0, 100));
|
||||
const historyEvidence = Math.min(12, number(row?.history_days) * 0.6);
|
||||
const movementEvidence = Math.min(18, Math.abs(number(row?.day_change)) * 0.8);
|
||||
return Math.round(clamp(62 + historyEvidence + movementEvidence, 60, 92));
|
||||
}
|
||||
|
||||
function sentimentPhaseAdvice(phase) {
|
||||
return {
|
||||
"冰点": "情绪处于极弱区,先观察风险释放,允许没有候选结果。",
|
||||
"修复": "风险开始收敛,关注率先转强的核心,小仓验证修复强度。",
|
||||
"发酵": "主线与梯队正在形成,优先跟随核心,避免偏离主线。",
|
||||
"高潮": "情绪与一致性已处高位,聚焦核心并主动降低后排暴露。",
|
||||
"分化": "强弱开始分层,关注承接与回流,淘汰失去辨识度的方向。",
|
||||
"退潮": "情绪指标继续走弱。",
|
||||
}[phase] || "市场结构尚未形成清晰阶段,保持观察并等待确认。";
|
||||
}
|
||||
|
||||
/* PRESERVATION-SOURCE-END app.js:1207-1516 */
|
||||
@@ -0,0 +1,108 @@
|
||||
window.XiaobaiPageModules.register("themes", ["themeLibraryView"], {
|
||||
enter: ["loadThemes"],
|
||||
});
|
||||
|
||||
/* PRESERVATION-SOURCE-BEGIN app.js:2482-2583 */
|
||||
async function loadThemeLibrary(force = false) {
|
||||
if (state.themeLoading) return;
|
||||
state.themeLoading = true;
|
||||
const button = document.querySelector("#themeRefreshButton");
|
||||
button.disabled = true;
|
||||
setText("themeDateLabel", "正在整理题材库");
|
||||
try {
|
||||
const query = new URLSearchParams({ trade_date: elements.tradeDate.value });
|
||||
if (force) query.set("force", "1");
|
||||
state.themeLibrary = await apiRequest(`/api/themes?${query}`);
|
||||
renderThemeLibrary();
|
||||
const available = (state.themeLibrary.items || []).some((item) => item.code === state.selectedThemeCode);
|
||||
if (!available) state.selectedThemeCode = "";
|
||||
const initialCode = state.selectedThemeCode || state.themeLibrary.items?.[0]?.code || "";
|
||||
if (initialCode) await selectTheme(initialCode, true);
|
||||
} catch (error) {
|
||||
setText("themeDateLabel", error.message || "题材数据暂不可用");
|
||||
renderEmptyState("themeDirectory", error.message || "题材数据加载失败");
|
||||
showToast(error.message || "题材数据加载失败");
|
||||
} finally {
|
||||
state.themeLoading = false;
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function renderThemeLibrary() {
|
||||
const payload = state.themeLibrary;
|
||||
if (!payload) return;
|
||||
const summary = payload.summary || {};
|
||||
setText("themeDateLabel", `${payload.meta?.carried_forward ? "最近有效行情" : "行情日期"} ${payload.meta?.trade_date || "--"}`);
|
||||
document.querySelector("#themeSummary").innerHTML = [
|
||||
["收录题材", number(summary.theme_count), "个", ""],
|
||||
["当日上涨", number(summary.up_count), "个", "up"],
|
||||
["当日下跌", number(summary.down_count), "个", "down"],
|
||||
["人气题材", number(summary.hot_count), "个", "warning"],
|
||||
].map(([label, value, unit, tone]) => `<div><span>${label}</span><strong class="${tone}">${value}<small>${unit}</small></strong></div>`).join("");
|
||||
renderThemeDirectory();
|
||||
}
|
||||
|
||||
function renderThemeDirectory() {
|
||||
let items = [...(state.themeLibrary?.items || [])];
|
||||
if (state.themeQuery) {
|
||||
items = items.filter((item) => `${item.code} ${item.name}`.toLocaleLowerCase("zh-CN").includes(state.themeQuery));
|
||||
}
|
||||
setText("themeResultCount", `${items.length} 个`);
|
||||
document.querySelector("#themeDirectory").innerHTML = items.map((item, index) => {
|
||||
const active = item.code === state.selectedThemeCode;
|
||||
return `
|
||||
<button type="button" class="theme-directory-item-v2 ${active ? "active" : ""}" data-theme-code="${escapeHtml(item.code)}" aria-pressed="${active}">
|
||||
<span class="theme-rank-v2">${index + 1}</span>
|
||||
<span class="theme-directory-copy-v2"><strong class="market-preview-trigger" data-market-preview-type="theme" data-market-preview-id="${escapeHtml(item.code)}" title="悬停预览题材行情">${escapeHtml(item.name)}</strong><small>${number(item.member_count)} 只成分${item.hot_rank ? ` · 人气第 ${number(item.hot_rank)}` : ""}</small></span>
|
||||
<b class="${changeClass(item.change)}">${item.has_quote ? `${signed(item.change)}%` : "--"}</b>
|
||||
</button>`;
|
||||
}).join("") || emptyStateHtml("没有匹配的题材");
|
||||
}
|
||||
|
||||
async function selectTheme(code, keepSelection = false) {
|
||||
if (!code) return;
|
||||
state.selectedThemeCode = code;
|
||||
if (!keepSelection) renderThemeDirectory();
|
||||
document.querySelector("#themeDetailEmpty").hidden = false;
|
||||
document.querySelector("#themeDetailContent").hidden = true;
|
||||
setText("themeDetailEmpty", "正在读取题材详情");
|
||||
try {
|
||||
const query = new URLSearchParams({ code, trade_date: elements.tradeDate.value });
|
||||
state.themeDetail = await apiRequest(`/api/themes/detail?${query}`);
|
||||
renderThemeDetail();
|
||||
} catch (error) {
|
||||
setText("themeDetailEmpty", error.message || "题材详情加载失败");
|
||||
showToast(error.message || "题材详情加载失败");
|
||||
}
|
||||
}
|
||||
|
||||
function renderThemeDetail() {
|
||||
const payload = state.themeDetail;
|
||||
if (!payload) return;
|
||||
const theme = payload.theme || {};
|
||||
const summary = payload.summary || {};
|
||||
document.querySelector("#themeDetailEmpty").hidden = true;
|
||||
document.querySelector("#themeDetailContent").hidden = false;
|
||||
setText("themeDetailName", theme.name || "--");
|
||||
setText("themeDetailCode", `${theme.code || "--"} · ${payload.meta?.trade_date || "--"}`);
|
||||
setText("themeDetailChange", `${signed(theme.change)}%`);
|
||||
document.querySelector("#themeDetailChange").className = changeClass(theme.change);
|
||||
document.querySelector("#themeDetailMetrics").innerHTML = [
|
||||
["成分股", `${number(summary.member_count)} 只`, ""],
|
||||
["有行情", `${number(summary.quoted_count)} 只`, ""],
|
||||
["上涨", `${number(summary.up_count)} 只`, "up"],
|
||||
["下跌", `${number(summary.down_count)} 只`, "down"],
|
||||
["换手率", `${formatNumber(theme.turnover_rate, 2)}%`, ""],
|
||||
].map(([label, value, tone]) => `<div><span>${label}</span><strong class="${tone}">${value}</strong></div>`).join("");
|
||||
setText("themeMemberCount", `有行情 ${number(summary.quoted_count)} / ${number(summary.member_count)}`);
|
||||
const body = document.querySelector("#themeMemberTableBody");
|
||||
body.innerHTML = (payload.members || []).map((row, index) => `
|
||||
<tr data-code="${escapeHtml(row.code)}"><td class="row-number num muted">${index + 1}</td>
|
||||
<td><span class="stock-cell"><strong class="stock-name sname">${escapeHtml(row.name)}</strong><small class="stock-code scode">${escapeHtml(row.code)}</small></span></td>
|
||||
<td class="number num ${changeClass(row.change)}">${row.has_quote ? signed(row.change) : ""}</td>
|
||||
<td class="number num">${row.has_quote ? formatNumber(row.price, 2) : ""}</td><td class="number num">${row.has_quote ? formatNumber(row.amount_billion, 2) : ""}</td></tr>`).join("");
|
||||
bindStockRows(body);
|
||||
renderThemeDirectory();
|
||||
}
|
||||
|
||||
/* PRESERVATION-SOURCE-END app.js:2482-2583 */
|
||||
@@ -0,0 +1,149 @@
|
||||
/* PRESERVATION-SOURCE-BEGIN app.js:8905-9051 */
|
||||
function exportStocks() {
|
||||
exportRows("涨停池", getVisibleStocks(), [
|
||||
["股票代码", "code"], ["股票名称", "name"], ["连板", "streak"], ["涨幅%", "change"],
|
||||
["价格", "price"], ["所属板块", "sector"], ["涨停原因", "reason"], ["首封", "first_time"],
|
||||
["最后封板", "last_time"], ["开板次数", "open_times"], ["换手率%", "turnover_rate"],
|
||||
["成交额亿", "amount_billion"], ["封单额万", "seal_amount_million"],
|
||||
]);
|
||||
}
|
||||
|
||||
function exportBroken() {
|
||||
exportRows("炸板池", getVisibleBrokenRows(), [
|
||||
["股票代码", "code"], ["股票名称", "name"], ["现价涨幅%", "change"], ["距涨停%", "limitGap"],
|
||||
["价格", "price"], ["所属板块", "sector"], ["首次触板", "first_time"], ["开板次数", "open_times"],
|
||||
["换手率%", "turnover_rate"], ["成交额亿", "amount_billion"],
|
||||
]);
|
||||
}
|
||||
|
||||
function exportDown() {
|
||||
exportRows("跌停板", getVisibleDownRows(), [
|
||||
["股票代码", "code"], ["股票名称", "name"], ["跌幅%", "change"], ["价格", "price"],
|
||||
["所属板块", "sector"], ["换手率%", "turnover_rate"], ["成交额亿", "amount_billion"],
|
||||
]);
|
||||
}
|
||||
|
||||
function exportYesterday() {
|
||||
exportRows("昨日涨停", getVisibleYesterdayRows(), [
|
||||
["股票代码", "code"], ["股票名称", "name"], ["昨日高度", "prior_streak"],
|
||||
["今日涨幅%", "current_change"], ["今日结果", "outcome"], ["当前高度", "current_streak"],
|
||||
["所属板块", "sector"],
|
||||
]);
|
||||
}
|
||||
|
||||
function exportLadder() {
|
||||
const rows = (state.dashboard?.ladders || []).flatMap((group) => (group.stocks || []).map((stock) => ({
|
||||
level: group.label || group.level,
|
||||
...stock,
|
||||
})));
|
||||
exportRows("市场天梯", rows, [
|
||||
["梯队", "level"], ["股票代码", "code"], ["股票名称", "name"], ["所属板块", "sector"],
|
||||
["封板时间", "first_time"], ["开板次数", "open_times"], ["封单额万", "seal_amount_million"], ["成交额亿", "amount_billion"],
|
||||
]);
|
||||
}
|
||||
|
||||
function exportRotation() {
|
||||
const sectorMap = new Map((state.dashboard?.sectors || []).map((sector) => [sector.name, sector]));
|
||||
const rows = (state.dashboard?.sector_rotation || []).map((row) => ({
|
||||
...row,
|
||||
average_change: sectorMap.get(row.name)?.change ?? 0,
|
||||
}));
|
||||
exportRows("板块轮动", rows, [
|
||||
["排名", "rank"], ["板块", "name"], ["趋势", "trend"], ["今日涨停", "count"],
|
||||
["昨日涨停", "previous_count"], ["变化", "delta"], ["强度", "strength"],
|
||||
["最高板", "max_streak"], ["平均涨幅%", "average_change"],
|
||||
["领涨股", "leader"], ["涨停股成交额亿", "amount_billion"],
|
||||
]);
|
||||
}
|
||||
|
||||
function exportSentimentHistory() {
|
||||
const rows = state.sentimentHistory?.rows || [];
|
||||
if (!rows.length) {
|
||||
showToast("暂无可导出的情绪周期数据");
|
||||
return;
|
||||
}
|
||||
const exportRowsData = rows.map((row) => ({
|
||||
...row,
|
||||
breadth_score: row.components?.breadth?.score,
|
||||
limit_ecology_score: row.components?.limit_ecology?.score,
|
||||
profit_effect_score: row.components?.profit_effect?.score,
|
||||
ladder_structure_score: row.components?.ladder_structure?.score,
|
||||
liquidity_score: row.components?.liquidity?.score,
|
||||
}));
|
||||
exportRows("情绪周期", exportRowsData, [
|
||||
["交易日", "trade_date"], ["情绪温度", "score"], ["周期阶段", "phase"], ["方向", "direction"],
|
||||
["涨停", "limit_up_count"], ["首板", "first_board_count"], ["二板", "second_board_count"],
|
||||
["三板以上", "three_plus_count"], ["连板高度", "max_height"], ["炸板", "broken_count"],
|
||||
["跌停", "limit_down_count"], ["昨日涨停", "previous_limit_count"],
|
||||
["昨日涨停红盘", "previous_positive_count"], ["昨日涨停红盘率%", "previous_positive_rate"],
|
||||
["市场宽度", "breadth_score"], ["涨停生态", "limit_ecology_score"],
|
||||
["赚钱效应", "profit_effect_score"], ["连板结构", "ladder_structure_score"],
|
||||
["成交活跃度", "liquidity_score"],
|
||||
]);
|
||||
}
|
||||
|
||||
function exportDragonTiger() {
|
||||
const rows = (state.dragonTiger?.traders || []).flatMap((trader) => (
|
||||
(trader.operations || []).map((operation) => ({
|
||||
trader_name: trader.name,
|
||||
identity_type: dragonIdentityLabel(trader.identity_type),
|
||||
...operation,
|
||||
}))
|
||||
));
|
||||
exportRows("游资龙虎榜", rows, [
|
||||
["游资或席位", "trader_name"], ["身份", "identity_type"], ["股票代码", "code"],
|
||||
["股票名称", "name"], ["方向", "direction"], ["涨幅%", "change"],
|
||||
["买入百万元", "buy_million"], ["卖出百万元", "sell_million"], ["净额百万元", "net_buy_million"],
|
||||
["关联席位", "seat_name"], ["上榜原因", "reason"],
|
||||
]);
|
||||
}
|
||||
|
||||
function exportHotMoneyProfiles() {
|
||||
const rows = state.hotMoneyProfiles?.profiles || [];
|
||||
if (!rows.length) {
|
||||
showToast("暂无可导出的游资档案");
|
||||
return;
|
||||
}
|
||||
downloadCsv(
|
||||
`游资档案-${todayString()}.csv`,
|
||||
["游资名称", "简介", "关联营业部", "席位数量"],
|
||||
rows.map((profile) => [
|
||||
profile.name,
|
||||
profile.description,
|
||||
(profile.organizations || []).join(";"),
|
||||
number(profile.organization_count),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
function commonReviewColumns() {
|
||||
return [["股票代码", "code"], ["股票名称", "name"], ["状态", "status"], ["涨跌幅%", "change"],
|
||||
["价格", "price"], ["所属板块", "sector"], ["原因", "reason"], ["首次触板", "first_time"],
|
||||
["最后触板", "last_time"], ["开板次数", "open_times"], ["换手率%", "turnover_rate"], ["成交额亿", "amount_billion"]];
|
||||
}
|
||||
|
||||
function exportRows(label, rows, columns) {
|
||||
const headers = columns.map(([header]) => header);
|
||||
const data = rows.map((row) => columns.map(([, key]) => row[key] ?? ""));
|
||||
downloadCsv(`${label}-${state.dashboard.meta.trade_date}.csv`, headers, data);
|
||||
}
|
||||
|
||||
function downloadCsv(filename, headers, rows) {
|
||||
const lines = [headers, ...rows].map((row) => row.map(csvCell).join(","));
|
||||
const blob = new Blob(["\ufeff", lines.join("\r\n")], { type: "text/csv;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
showToast(`已导出 ${rows.length} 条数据`);
|
||||
}
|
||||
|
||||
function csvCell(value) {
|
||||
let text = String(value ?? "");
|
||||
if (/^[=+\-@]/.test(text)) text = `'${text}`;
|
||||
return `"${text.replaceAll('"', '""')}"`;
|
||||
}
|
||||
|
||||
/* PRESERVATION-SOURCE-END app.js:8905-9051 */
|
||||
@@ -13,9 +13,11 @@ module.exports = defineConfig({
|
||||
trace: "retain-on-failure",
|
||||
},
|
||||
webServer: {
|
||||
command: "python -m http.server 8876 --bind 127.0.0.1 --directory static",
|
||||
command: "python -m http.server 8876 --bind 127.0.0.1 --directory frontend",
|
||||
url: "http://127.0.0.1:8876/index.html",
|
||||
reuseExistingServer: true,
|
||||
timeout: 15_000,
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
},
|
||||
});
|
||||
|
||||
-9283
File diff suppressed because it is too large
Load Diff
@@ -1,4 +0,0 @@
|
||||
window.XiaobaiPageModules.register("auction", ["auctionView"], {
|
||||
enter: ["loadAuction"],
|
||||
leave: ["clearAuction"],
|
||||
});
|
||||
@@ -1,3 +0,0 @@
|
||||
window.XiaobaiPageModules.register("dragon_tiger", ["dragonView"], {
|
||||
enter: ["loadDragonTiger"],
|
||||
});
|
||||
@@ -1,4 +0,0 @@
|
||||
window.XiaobaiPageModules.register("heaven", ["heavenView"], {
|
||||
enter: ["loadHeaven"],
|
||||
leave: ["stopHeaven"],
|
||||
});
|
||||
@@ -1 +0,0 @@
|
||||
window.XiaobaiPageModules.register("ladder", ["ladderView"]);
|
||||
@@ -1,3 +0,0 @@
|
||||
window.XiaobaiPageModules.register("mentor", ["mentorView"], {
|
||||
enter: ["loadMentor"],
|
||||
});
|
||||
@@ -1,7 +0,0 @@
|
||||
window.XiaobaiPageModules.register("pools", [
|
||||
"limitPool",
|
||||
"brokenView",
|
||||
"downView",
|
||||
"yesterdayView",
|
||||
"performanceView",
|
||||
]);
|
||||
@@ -1,3 +0,0 @@
|
||||
window.XiaobaiPageModules.register("popularity", ["popularityView"], {
|
||||
enter: ["loadPopularity"],
|
||||
});
|
||||
@@ -1,3 +0,0 @@
|
||||
window.XiaobaiPageModules.register("review", ["reviewWorkspaceView"], {
|
||||
enter: ["loadReview"],
|
||||
});
|
||||
@@ -1,3 +0,0 @@
|
||||
window.XiaobaiPageModules.register("rotation", ["rotationView"], {
|
||||
enter: ["loadRotation"],
|
||||
});
|
||||
@@ -1,5 +0,0 @@
|
||||
window.XiaobaiPageModules.register("screener", ["screenerView"], {
|
||||
enter: ["loadScreener"],
|
||||
});
|
||||
|
||||
window.XiaobaiPageModules.register("screener", ["screenerTrackingView"]);
|
||||
@@ -1,3 +0,0 @@
|
||||
window.XiaobaiPageModules.register("sentiment", ["sentimentCycleView"], {
|
||||
enter: ["loadSentiment"],
|
||||
});
|
||||
@@ -1,3 +0,0 @@
|
||||
window.XiaobaiPageModules.register("themes", ["themeLibraryView"], {
|
||||
enter: ["loadThemes"],
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
"""Test support package for the preserved application."""
|
||||
@@ -0,0 +1,75 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
APP_ROOT = Path(__file__).resolve().parents[1]
|
||||
ORIGINAL_ROOT = APP_ROOT.parent
|
||||
FRONTEND_ROOT = APP_ROOT / "frontend"
|
||||
ORIGINAL_STATIC = ORIGINAL_ROOT / "static"
|
||||
|
||||
SOURCE_RANGE = re.compile(
|
||||
r"/\* PRESERVATION-SOURCE-BEGIN app\.js:(\d+)-(\d+) \*/\n"
|
||||
r"(.*?)"
|
||||
r"/\* PRESERVATION-SOURCE-END app\.js:\1-\2 \*/\n?",
|
||||
re.DOTALL,
|
||||
)
|
||||
|
||||
|
||||
def sha256(path: Path) -> str:
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def reassembled_frontend_runtime() -> str:
|
||||
chunks: dict[tuple[int, int], str] = {}
|
||||
for path in FRONTEND_ROOT.rglob("*.js"):
|
||||
for match in SOURCE_RANGE.finditer(path.read_text(encoding="utf-8")):
|
||||
source_range = (int(match.group(1)), int(match.group(2)))
|
||||
if source_range in chunks:
|
||||
raise AssertionError(f"duplicate app.js source range: {source_range}")
|
||||
chunks[source_range] = match.group(3)
|
||||
|
||||
assembled: list[str] = []
|
||||
next_line = 1
|
||||
for (start, end), content in sorted(chunks.items()):
|
||||
if start != next_line:
|
||||
raise AssertionError(
|
||||
f"app.js source coverage gap: expected line {next_line}, got {start}"
|
||||
)
|
||||
if len(content.splitlines(keepends=True)) != end - start + 1:
|
||||
raise AssertionError(f"app.js line count changed in range {start}-{end}")
|
||||
assembled.append(content)
|
||||
next_line = end + 1
|
||||
|
||||
original_line_count = len(
|
||||
(ORIGINAL_STATIC / "app.js").read_text(encoding="utf-8").splitlines()
|
||||
)
|
||||
if next_line != original_line_count + 1:
|
||||
raise AssertionError(
|
||||
f"app.js source coverage ended at {next_line - 1}, expected {original_line_count}"
|
||||
)
|
||||
return "".join(assembled)
|
||||
|
||||
|
||||
def assert_moved_asset_matches(
|
||||
testcase,
|
||||
original_relative: str,
|
||||
frontend_relative: str | None = None,
|
||||
) -> None:
|
||||
target_relative = frontend_relative or original_relative
|
||||
testcase.assertEqual(
|
||||
sha256(FRONTEND_ROOT / target_relative),
|
||||
sha256(ORIGINAL_STATIC / original_relative),
|
||||
original_relative,
|
||||
)
|
||||
|
||||
|
||||
def assert_page_prefix_matches(testcase, page_relative: str) -> None:
|
||||
original = (ORIGINAL_STATIC / page_relative).read_text(encoding="utf-8")
|
||||
migrated = (FRONTEND_ROOT / page_relative).read_text(encoding="utf-8")
|
||||
testcase.assertTrue(
|
||||
migrated.startswith(original.rstrip("\n") + "\n\n"),
|
||||
page_relative,
|
||||
)
|
||||
@@ -6,14 +6,14 @@ from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
STATIC = ROOT / "static"
|
||||
STATIC = ROOT / "frontend"
|
||||
TOKENS = STATIC / "shared" / "tokens.css"
|
||||
LEGACY_STYLESHEETS = (
|
||||
"styles.css",
|
||||
"renovation.css",
|
||||
"redesign-v2.css",
|
||||
"design-system.css",
|
||||
"theme.css",
|
||||
"styles/styles.css",
|
||||
"styles/renovation.css",
|
||||
"styles/redesign-v2.css",
|
||||
"styles/design-system.css",
|
||||
"styles/theme.css",
|
||||
)
|
||||
|
||||
|
||||
@@ -26,12 +26,12 @@ class CssGovernanceTests(unittest.TestCase):
|
||||
def test_token_layer_loads_before_application_styles(self) -> None:
|
||||
expected_order = (
|
||||
"/shared/tokens.css",
|
||||
"/styles.css",
|
||||
"/renovation.css",
|
||||
"/redesign-v2.css",
|
||||
"/design-system.css",
|
||||
"/theme.css",
|
||||
"/wentian-v2.css",
|
||||
"/styles/styles.css",
|
||||
"/styles/renovation.css",
|
||||
"/styles/redesign-v2.css",
|
||||
"/styles/design-system.css",
|
||||
"/styles/theme.css",
|
||||
"/pages/heaven/page.css",
|
||||
)
|
||||
positions = [self.html.index(path) for path in expected_order]
|
||||
self.assertEqual(positions, sorted(positions))
|
||||
@@ -63,7 +63,7 @@ class CssGovernanceTests(unittest.TestCase):
|
||||
|
||||
def test_wentian_tokens_remain_isolated(self) -> None:
|
||||
self.assertNotRegex(self.tokens, r"--wt-[a-z0-9-]+\s*:")
|
||||
wentian = (STATIC / "wentian-v2.css").read_text(encoding="utf-8")
|
||||
wentian = (STATIC / "pages" / "heaven" / "page.css").read_text(encoding="utf-8")
|
||||
self.assertRegex(wentian, r"--wt-[a-z0-9-]+\s*:")
|
||||
|
||||
def test_compatibility_aliases_cover_historical_layers(self) -> None:
|
||||
|
||||
@@ -5,9 +5,11 @@ import re
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from tests.preservation_helpers import reassembled_frontend_runtime
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
STATIC = ROOT / "static"
|
||||
STATIC = ROOT / "frontend"
|
||||
|
||||
|
||||
class FrontendBoundaryTests(unittest.TestCase):
|
||||
@@ -22,7 +24,7 @@ class FrontendBoundaryTests(unittest.TestCase):
|
||||
|
||||
def test_shared_dependencies_load_before_application(self) -> None:
|
||||
html = (STATIC / "index.html").read_text(encoding="utf-8")
|
||||
ui_position = html.index('/ui-core.js')
|
||||
ui_position = html.index('/shared/ui-core.js')
|
||||
components_position = html.index('/shared/components.js')
|
||||
pages_position = html.index('/pages.config.js')
|
||||
runtime_position = html.index('/pages/runtime.js')
|
||||
@@ -40,7 +42,7 @@ class FrontendBoundaryTests(unittest.TestCase):
|
||||
self.assertLess(shell_position, app_position)
|
||||
|
||||
def test_application_state_is_created_through_shared_boundary(self) -> None:
|
||||
app = (STATIC / "app.js").read_text(encoding="utf-8")
|
||||
app = reassembled_frontend_runtime()
|
||||
self.assertIn("const state = window.XiaobaiState.create({", app)
|
||||
self.assertNotIn("const state = {", app)
|
||||
|
||||
@@ -70,7 +72,7 @@ class FrontendBoundaryTests(unittest.TestCase):
|
||||
self.assertEqual(actual, expected)
|
||||
|
||||
def test_shell_owns_navigation_and_page_mounting(self) -> None:
|
||||
app = (STATIC / "app.js").read_text(encoding="utf-8")
|
||||
app = reassembled_frontend_runtime()
|
||||
shell = (STATIC / "shared" / "shell.js").read_text(encoding="utf-8")
|
||||
self.assertNotIn("function syncNavigationState", app)
|
||||
self.assertNotIn("function initializeApplicationShell", app)
|
||||
@@ -109,7 +111,7 @@ class FrontendBoundaryTests(unittest.TestCase):
|
||||
self.assertEqual(actual, expected)
|
||||
|
||||
def test_page_lifecycle_is_owned_outside_application_monolith(self) -> None:
|
||||
app = (STATIC / "app.js").read_text(encoding="utf-8")
|
||||
app = reassembled_frontend_runtime()
|
||||
runtime = (STATIC / "pages" / "runtime.js").read_text(encoding="utf-8")
|
||||
start = app.index("function openView(")
|
||||
end = app.index("\nfunction initializeAutoTableSorting", start)
|
||||
@@ -122,7 +124,7 @@ class FrontendBoundaryTests(unittest.TestCase):
|
||||
|
||||
def test_shared_empty_state_component_is_used_by_multiple_features(self) -> None:
|
||||
components = (STATIC / "shared" / "components.js").read_text(encoding="utf-8")
|
||||
app = (STATIC / "app.js").read_text(encoding="utf-8")
|
||||
app = reassembled_frontend_runtime()
|
||||
self.assertIn("function emptyStateHtml(message, options = {})", components)
|
||||
self.assertIn("function renderEmptyState(target, message, options = {})", components)
|
||||
self.assertGreaterEqual(app.count("renderEmptyState("), 8)
|
||||
|
||||
@@ -5,8 +5,10 @@ import unittest
|
||||
from html.parser import HTMLParser
|
||||
from pathlib import Path
|
||||
|
||||
from tests.preservation_helpers import reassembled_frontend_runtime
|
||||
|
||||
STATIC_DIR = Path(__file__).resolve().parents[1] / "static"
|
||||
|
||||
STATIC_DIR = Path(__file__).resolve().parents[1] / "frontend"
|
||||
|
||||
|
||||
class IdCollector(HTMLParser):
|
||||
@@ -22,11 +24,11 @@ class FrontendContractTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.html = (STATIC_DIR / "index.html").read_text(encoding="utf-8")
|
||||
cls.script = (STATIC_DIR / "app.js").read_text(encoding="utf-8")
|
||||
cls.script = reassembled_frontend_runtime()
|
||||
cls.shell = (STATIC_DIR / "shared" / "shell.js").read_text(encoding="utf-8")
|
||||
cls.ui_core = (STATIC_DIR / "ui-core.js").read_text(encoding="utf-8")
|
||||
cls.design_system = (STATIC_DIR / "design-system.css").read_text(encoding="utf-8")
|
||||
cls.theme = (STATIC_DIR / "theme.css").read_text(encoding="utf-8")
|
||||
cls.ui_core = (STATIC_DIR / "shared" / "ui-core.js").read_text(encoding="utf-8")
|
||||
cls.design_system = (STATIC_DIR / "styles" / "design-system.css").read_text(encoding="utf-8")
|
||||
cls.theme = (STATIC_DIR / "styles" / "theme.css").read_text(encoding="utf-8")
|
||||
cls.tokens = (STATIC_DIR / "shared" / "tokens.css").read_text(encoding="utf-8")
|
||||
collector = IdCollector()
|
||||
collector.feed(cls.html)
|
||||
@@ -158,7 +160,7 @@ class FrontendContractTests(unittest.TestCase):
|
||||
self.assertIn("必需数据已完整,本日没有股票同时满足", self.script)
|
||||
|
||||
def test_dialogs_and_dark_table_hover_have_shared_safety_constraints(self):
|
||||
redesign = (STATIC_DIR / "redesign-v2.css").read_text(encoding="utf-8")
|
||||
redesign = (STATIC_DIR / "styles" / "redesign-v2.css").read_text(encoding="utf-8")
|
||||
self.assertIn(".settings-dialog:not(.heaven-reading-dialog)[open] { margin: auto; }", redesign)
|
||||
self.assertIn("max-height: min(760px, calc(100dvh - 28px));", redesign)
|
||||
self.assertIn('#reviewWorkspaceView .data-table tbody tr:hover td', self.theme)
|
||||
@@ -166,8 +168,8 @@ class FrontendContractTests(unittest.TestCase):
|
||||
self.assertIn('#screenerView .screener-result-frame tbody tr:hover td:last-child', self.theme)
|
||||
|
||||
def test_global_toast_has_one_owner_and_cannot_stretch_between_insets(self):
|
||||
styles = (STATIC_DIR / "styles.css").read_text(encoding="utf-8")
|
||||
wentian = (STATIC_DIR / "wentian-v2.css").read_text(encoding="utf-8")
|
||||
styles = (STATIC_DIR / "styles" / "styles.css").read_text(encoding="utf-8")
|
||||
wentian = (STATIC_DIR / "pages" / "heaven" / "page.css").read_text(encoding="utf-8")
|
||||
self.assertIn("#toast.toast {", styles)
|
||||
self.assertIn("top: auto;", styles)
|
||||
self.assertIn("left: auto;", styles)
|
||||
@@ -183,7 +185,7 @@ class FrontendContractTests(unittest.TestCase):
|
||||
|
||||
def test_shared_ui_core_loads_before_application(self):
|
||||
self.assertLess(
|
||||
self.html.index('<script src="/ui-core.js"'),
|
||||
self.html.index('<script src="/shared/ui-core.js"'),
|
||||
self.html.index('<script src="/app.js'),
|
||||
)
|
||||
for function_name in (
|
||||
|
||||
@@ -4,6 +4,7 @@ import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from server import DashboardService
|
||||
from tests.preservation_helpers import reassembled_frontend_runtime
|
||||
|
||||
|
||||
class SearchDatabaseStub:
|
||||
@@ -77,9 +78,9 @@ class GlobalSearchTests(unittest.TestCase):
|
||||
)
|
||||
|
||||
def test_frontend_reuses_full_stock_detail_and_renders_market_daily_k(self):
|
||||
static_dir = Path(__file__).resolve().parents[1] / "static"
|
||||
static_dir = Path(__file__).resolve().parents[1] / "frontend"
|
||||
html = (static_dir / "index.html").read_text(encoding="utf-8")
|
||||
script = (static_dir / "app.js").read_text(encoding="utf-8")
|
||||
script = reassembled_frontend_runtime()
|
||||
|
||||
self.assertIn('id="globalSearchButton"', html)
|
||||
self.assertIn('id="globalSearchDialog"', html)
|
||||
|
||||
@@ -23,7 +23,7 @@ class AccountSliceStructureTests(unittest.TestCase):
|
||||
|
||||
def test_runtime_paths_still_point_at_app_root(self) -> None:
|
||||
self.assertEqual(APP_DIR, Path(__file__).resolve().parents[1])
|
||||
self.assertEqual(STATIC_DIR, APP_DIR / "static")
|
||||
self.assertEqual(STATIC_DIR, APP_DIR / "frontend")
|
||||
|
||||
def test_account_persistence_and_http_transport_have_single_owners(self) -> None:
|
||||
for method in (
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import unittest
|
||||
|
||||
from backend.bootstrap.config import APP_DIR, STATIC_DIR
|
||||
from tests.preservation_helpers import (
|
||||
FRONTEND_ROOT,
|
||||
ORIGINAL_STATIC,
|
||||
assert_moved_asset_matches,
|
||||
assert_page_prefix_matches,
|
||||
reassembled_frontend_runtime,
|
||||
)
|
||||
|
||||
|
||||
class FrontendPreservationSliceTests(unittest.TestCase):
|
||||
def test_split_runtime_reassembles_to_the_exact_original(self) -> None:
|
||||
self.assertEqual(
|
||||
reassembled_frontend_runtime(),
|
||||
(ORIGINAL_STATIC / "app.js").read_text(encoding="utf-8"),
|
||||
)
|
||||
|
||||
def test_index_diff_is_limited_to_asset_relocation_and_split_loading(self) -> None:
|
||||
migrated = (FRONTEND_ROOT / "index.html").read_text(encoding="utf-8")
|
||||
restored = migrated
|
||||
for current, original in (
|
||||
("/styles/styles.css", "/styles.css"),
|
||||
("/styles/renovation.css", "/renovation.css"),
|
||||
("/styles/redesign-v2.css", "/redesign-v2.css"),
|
||||
("/styles/design-system.css", "/design-system.css"),
|
||||
("/styles/theme.css", "/theme.css"),
|
||||
("/pages/heaven/page.css", "/wentian-v2.css"),
|
||||
("/shared/ui-core.js", "/ui-core.js"),
|
||||
("/pages/heaven/loading-v2.js", "/heaven-loading-v2.js"),
|
||||
):
|
||||
restored = restored.replace(current, original)
|
||||
restored = restored.replace(
|
||||
' <script src="/pages/market/runtime.js?v=20260731-1" defer></script>\n',
|
||||
"",
|
||||
)
|
||||
restored = restored.replace(
|
||||
' <script src="/shared/export.js?v=20260731-1" defer></script>\n',
|
||||
"",
|
||||
)
|
||||
self.assertEqual(
|
||||
restored,
|
||||
(ORIGINAL_STATIC / "index.html").read_text(encoding="utf-8"),
|
||||
)
|
||||
|
||||
def test_complete_stylesheet_stack_is_byte_identical_after_relocation(self) -> None:
|
||||
for original, migrated in (
|
||||
("shared/tokens.css", "shared/tokens.css"),
|
||||
("styles.css", "styles/styles.css"),
|
||||
("renovation.css", "styles/renovation.css"),
|
||||
("redesign-v2.css", "styles/redesign-v2.css"),
|
||||
("design-system.css", "styles/design-system.css"),
|
||||
("theme.css", "styles/theme.css"),
|
||||
("wentian-v2.css", "pages/heaven/page.css"),
|
||||
):
|
||||
with self.subTest(asset=original):
|
||||
assert_moved_asset_matches(self, original, migrated)
|
||||
|
||||
def test_shared_vendor_and_animation_assets_are_byte_identical(self) -> None:
|
||||
for original, migrated in (
|
||||
("ui-core.js", "shared/ui-core.js"),
|
||||
("heaven-loading-v2.js", "pages/heaven/loading-v2.js"),
|
||||
("heaven-loading.js", "heaven-loading.js"),
|
||||
("vendor/lucide.min.js", "vendor/lucide.min.js"),
|
||||
("pages.config.js", "pages.config.js"),
|
||||
("pages/runtime.js", "pages/runtime.js"),
|
||||
("shared/api.js", "shared/api.js"),
|
||||
("shared/components.js", "shared/components.js"),
|
||||
("shared/shell.js", "shared/shell.js"),
|
||||
("shared/state.js", "shared/state.js"),
|
||||
):
|
||||
with self.subTest(asset=original):
|
||||
assert_moved_asset_matches(self, original, migrated)
|
||||
|
||||
def test_original_page_registration_prefixes_are_preserved(self) -> None:
|
||||
for path in sorted((ORIGINAL_STATIC / "pages").glob("*/page.js")):
|
||||
relative = path.relative_to(ORIGINAL_STATIC).as_posix()
|
||||
with self.subTest(page=relative):
|
||||
assert_page_prefix_matches(self, relative)
|
||||
|
||||
def test_frontend_is_the_only_served_static_root(self) -> None:
|
||||
self.assertEqual(STATIC_DIR, APP_DIR / "frontend")
|
||||
self.assertFalse((APP_DIR / "static").exists())
|
||||
|
||||
def test_shared_api_remains_the_only_fetch_exit(self) -> None:
|
||||
consumers = []
|
||||
for path in FRONTEND_ROOT.rglob("*.js"):
|
||||
if "vendor" in path.parts:
|
||||
continue
|
||||
if re.search(r"\bfetch\s*\(", path.read_text(encoding="utf-8")):
|
||||
consumers.append(path.relative_to(FRONTEND_ROOT).as_posix())
|
||||
self.assertEqual(consumers, ["shared/api.js"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -5,6 +5,13 @@ import hashlib
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from tests.preservation_helpers import (
|
||||
ORIGINAL_STATIC,
|
||||
assert_moved_asset_matches,
|
||||
assert_page_prefix_matches,
|
||||
reassembled_frontend_runtime,
|
||||
)
|
||||
|
||||
|
||||
APP_ROOT = Path(__file__).resolve().parents[1]
|
||||
ORIGINAL_ROOT = APP_ROOT.parent
|
||||
@@ -73,19 +80,17 @@ class LadderRotationSliceSourceEquivalenceTests(unittest.TestCase):
|
||||
)
|
||||
|
||||
def test_api_and_frontend_assets_are_unchanged(self) -> None:
|
||||
for relative in (
|
||||
"config/api.config.json",
|
||||
"static/index.html",
|
||||
"static/app.js",
|
||||
"static/styles.css",
|
||||
"static/pages/ladder/page.js",
|
||||
"static/pages/rotation/page.js",
|
||||
):
|
||||
self.assertEqual(
|
||||
sha256(APP_ROOT / relative),
|
||||
sha256(ORIGINAL_ROOT / relative),
|
||||
relative,
|
||||
)
|
||||
self.assertEqual(
|
||||
sha256(APP_ROOT / "config/api.config.json"),
|
||||
sha256(ORIGINAL_ROOT / "config/api.config.json"),
|
||||
)
|
||||
self.assertEqual(
|
||||
reassembled_frontend_runtime(),
|
||||
(ORIGINAL_STATIC / "app.js").read_text(encoding="utf-8"),
|
||||
)
|
||||
assert_moved_asset_matches(self, "styles.css", "styles/styles.css")
|
||||
for page in ("pages/ladder/page.js", "pages/rotation/page.js"):
|
||||
assert_page_prefix_matches(self, page)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -13,6 +13,11 @@ from backend.data import realtime
|
||||
from backend.data.providers import ifind_client as canonical_ifind
|
||||
from backend.data.providers import tushare_client as canonical_tushare
|
||||
from backend.features.market import charts
|
||||
from tests.preservation_helpers import (
|
||||
ORIGINAL_STATIC,
|
||||
assert_moved_asset_matches,
|
||||
reassembled_frontend_runtime,
|
||||
)
|
||||
|
||||
|
||||
APP_ROOT = Path(__file__).resolve().parents[1]
|
||||
@@ -146,21 +151,19 @@ class MarketSliceSourceEquivalenceTests(unittest.TestCase):
|
||||
top_level_definitions(APP_ROOT / "backend/features/market/charts.py"),
|
||||
)
|
||||
|
||||
def test_unchanged_frontend_assets_match_the_original(self) -> None:
|
||||
for relative in (
|
||||
"index.html",
|
||||
"app.js",
|
||||
"styles.css",
|
||||
"renovation.css",
|
||||
"redesign-v2.css",
|
||||
"theme.css",
|
||||
"wentian-v2.css",
|
||||
def test_relocated_frontend_preserves_original_market_runtime_and_styles(self) -> None:
|
||||
self.assertEqual(
|
||||
reassembled_frontend_runtime(),
|
||||
(ORIGINAL_STATIC / "app.js").read_text(encoding="utf-8"),
|
||||
)
|
||||
for original, migrated in (
|
||||
("styles.css", "styles/styles.css"),
|
||||
("renovation.css", "styles/renovation.css"),
|
||||
("redesign-v2.css", "styles/redesign-v2.css"),
|
||||
("theme.css", "styles/theme.css"),
|
||||
("wentian-v2.css", "pages/heaven/page.css"),
|
||||
):
|
||||
self.assertEqual(
|
||||
sha256(APP_ROOT / "static" / relative),
|
||||
sha256(ORIGINAL_ROOT / "static" / relative),
|
||||
relative,
|
||||
)
|
||||
assert_moved_asset_matches(self, original, migrated)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -7,6 +7,12 @@ from pathlib import Path
|
||||
|
||||
import market_insights
|
||||
from backend.features.market import insights as canonical_insights
|
||||
from tests.preservation_helpers import (
|
||||
ORIGINAL_STATIC,
|
||||
assert_moved_asset_matches,
|
||||
assert_page_prefix_matches,
|
||||
reassembled_frontend_runtime,
|
||||
)
|
||||
|
||||
|
||||
APP_ROOT = Path(__file__).resolve().parents[1]
|
||||
@@ -174,21 +180,22 @@ class MarketInsightsSliceSourceEquivalenceTests(unittest.TestCase):
|
||||
self.assertEqual(migrated[name], original[name], name)
|
||||
|
||||
def test_api_and_frontend_assets_are_unchanged(self) -> None:
|
||||
for relative in (
|
||||
"config/api.config.json",
|
||||
"static/index.html",
|
||||
"static/app.js",
|
||||
"static/styles.css",
|
||||
"static/pages/auction/page.js",
|
||||
"static/pages/themes/page.js",
|
||||
"static/pages/popularity/page.js",
|
||||
"static/pages/dragon-tiger/page.js",
|
||||
self.assertEqual(
|
||||
sha256(APP_ROOT / "config/api.config.json"),
|
||||
sha256(ORIGINAL_ROOT / "config/api.config.json"),
|
||||
)
|
||||
self.assertEqual(
|
||||
reassembled_frontend_runtime(),
|
||||
(ORIGINAL_STATIC / "app.js").read_text(encoding="utf-8"),
|
||||
)
|
||||
assert_moved_asset_matches(self, "styles.css", "styles/styles.css")
|
||||
for page in (
|
||||
"pages/auction/page.js",
|
||||
"pages/themes/page.js",
|
||||
"pages/popularity/page.js",
|
||||
"pages/dragon-tiger/page.js",
|
||||
):
|
||||
self.assertEqual(
|
||||
sha256(APP_ROOT / relative),
|
||||
sha256(ORIGINAL_ROOT / relative),
|
||||
relative,
|
||||
)
|
||||
assert_page_prefix_matches(self, page)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -11,6 +11,12 @@ import screener
|
||||
import strategy_tracking
|
||||
from backend.features.screener import compiler, engine, strategies, tracking
|
||||
from backend.features.screener import service as screener_service
|
||||
from tests.preservation_helpers import (
|
||||
ORIGINAL_STATIC,
|
||||
assert_moved_asset_matches,
|
||||
assert_page_prefix_matches,
|
||||
reassembled_frontend_runtime,
|
||||
)
|
||||
|
||||
|
||||
APP_ROOT = Path(__file__).resolve().parents[1]
|
||||
@@ -182,17 +188,12 @@ class ScreenerSliceSourceEquivalenceTests(unittest.TestCase):
|
||||
)
|
||||
|
||||
def test_screener_frontend_assets_are_unchanged(self) -> None:
|
||||
for relative in (
|
||||
"static/index.html",
|
||||
"static/app.js",
|
||||
"static/styles.css",
|
||||
"static/pages/screener/page.js",
|
||||
):
|
||||
self.assertEqual(
|
||||
sha256(APP_ROOT / relative),
|
||||
sha256(ORIGINAL_ROOT / relative),
|
||||
relative,
|
||||
)
|
||||
self.assertEqual(
|
||||
reassembled_frontend_runtime(),
|
||||
(ORIGINAL_STATIC / "app.js").read_text(encoding="utf-8"),
|
||||
)
|
||||
assert_moved_asset_matches(self, "styles.css", "styles/styles.css")
|
||||
assert_page_prefix_matches(self, "pages/screener/page.js")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -7,6 +7,12 @@ from pathlib import Path
|
||||
|
||||
import sentiment_engine
|
||||
from backend.features.sentiment import engine as canonical_engine
|
||||
from tests.preservation_helpers import (
|
||||
ORIGINAL_STATIC,
|
||||
assert_moved_asset_matches,
|
||||
assert_page_prefix_matches,
|
||||
reassembled_frontend_runtime,
|
||||
)
|
||||
|
||||
|
||||
APP_ROOT = Path(__file__).resolve().parents[1]
|
||||
@@ -95,19 +101,17 @@ class SentimentPoolSliceSourceEquivalenceTests(unittest.TestCase):
|
||||
self.assertIs(sentiment_engine, canonical_engine)
|
||||
|
||||
def test_api_and_frontend_assets_are_unchanged(self) -> None:
|
||||
for relative in (
|
||||
"config/api.config.json",
|
||||
"static/index.html",
|
||||
"static/app.js",
|
||||
"static/styles.css",
|
||||
"static/pages/sentiment/page.js",
|
||||
"static/pages/pools/page.js",
|
||||
):
|
||||
self.assertEqual(
|
||||
sha256(APP_ROOT / relative),
|
||||
sha256(ORIGINAL_ROOT / relative),
|
||||
relative,
|
||||
)
|
||||
self.assertEqual(
|
||||
sha256(APP_ROOT / "config/api.config.json"),
|
||||
sha256(ORIGINAL_ROOT / "config/api.config.json"),
|
||||
)
|
||||
self.assertEqual(
|
||||
reassembled_frontend_runtime(),
|
||||
(ORIGINAL_STATIC / "app.js").read_text(encoding="utf-8"),
|
||||
)
|
||||
assert_moved_asset_matches(self, "styles.css", "styles/styles.css")
|
||||
for page in ("pages/sentiment/page.js", "pages/pools/page.js"):
|
||||
assert_page_prefix_matches(self, page)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -75,13 +75,15 @@ def code_hotspots() -> list[dict[str, Any]]:
|
||||
"screener.py",
|
||||
"market_insights.py",
|
||||
"tushare_client.py",
|
||||
"static/index.html",
|
||||
"static/app.js",
|
||||
"static/styles.css",
|
||||
"static/redesign-v2.css",
|
||||
"static/renovation.css",
|
||||
"static/theme.css",
|
||||
"static/wentian-v2.css",
|
||||
"frontend/index.html",
|
||||
"frontend/app.js",
|
||||
"frontend/styles/styles.css",
|
||||
"frontend/styles/redesign-v2.css",
|
||||
"frontend/styles/renovation.css",
|
||||
"frontend/styles/theme.css",
|
||||
"frontend/pages/heaven/page.css",
|
||||
"frontend/pages/heaven/page.js",
|
||||
"frontend/pages/market/runtime.js",
|
||||
]
|
||||
rows = []
|
||||
for name in candidates:
|
||||
@@ -97,7 +99,7 @@ def code_hotspots() -> list[dict[str, Any]]:
|
||||
|
||||
|
||||
def build() -> dict[str, Any]:
|
||||
html = source("static/index.html")
|
||||
html = source("frontend/index.html")
|
||||
server = source("server.py")
|
||||
database = source("database.py")
|
||||
pages = page_inventory(html)
|
||||
|
||||
@@ -27,9 +27,21 @@ def schema(connection: sqlite3.Connection) -> list[dict[str, Any]]:
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
|
||||
def table_rows(connection: sqlite3.Connection, table: str) -> list[dict[str, Any]]:
|
||||
def table_rows(
|
||||
connection: sqlite3.Connection,
|
||||
table: str,
|
||||
excluded_columns: set[str] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
quoted = '"' + table.replace('"', '""') + '"'
|
||||
rows = [dict(row) for row in connection.execute(f"SELECT * FROM {quoted}").fetchall()]
|
||||
excluded_columns = excluded_columns or set()
|
||||
rows = [
|
||||
{
|
||||
key: value
|
||||
for key, value in dict(row).items()
|
||||
if key not in excluded_columns
|
||||
}
|
||||
for row in connection.execute(f"SELECT * FROM {quoted}").fetchall()
|
||||
]
|
||||
return sorted(rows, key=lambda row: json.dumps(row, ensure_ascii=False, sort_keys=True, default=str))
|
||||
|
||||
|
||||
@@ -38,9 +50,23 @@ def main() -> None:
|
||||
parser.add_argument("--original", type=Path, required=True)
|
||||
parser.add_argument("--migrated", type=Path, required=True)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
parser.add_argument(
|
||||
"--exclude-column",
|
||||
action="append",
|
||||
default=[],
|
||||
metavar="TABLE.COLUMN",
|
||||
help="exclude a nondeterministic column from one table comparison",
|
||||
)
|
||||
parser.add_argument("tables", nargs="+")
|
||||
args = parser.parse_args()
|
||||
|
||||
excluded_by_table: dict[str, set[str]] = {}
|
||||
for item in args.exclude_column:
|
||||
table, separator, column = item.partition(".")
|
||||
if not separator or not table or not column:
|
||||
parser.error("--exclude-column must use TABLE.COLUMN")
|
||||
excluded_by_table.setdefault(table, set()).add(column)
|
||||
|
||||
original = sqlite3.connect(args.original)
|
||||
migrated = sqlite3.connect(args.migrated)
|
||||
original.row_factory = sqlite3.Row
|
||||
@@ -51,8 +77,9 @@ def main() -> None:
|
||||
tables = []
|
||||
all_equal = original_schema == migrated_schema
|
||||
for table in args.tables:
|
||||
original_rows = table_rows(original, table)
|
||||
migrated_rows = table_rows(migrated, table)
|
||||
excluded_columns = excluded_by_table.get(table, set())
|
||||
original_rows = table_rows(original, table, excluded_columns)
|
||||
migrated_rows = table_rows(migrated, table, excluded_columns)
|
||||
equal = original_rows == migrated_rows
|
||||
all_equal = all_equal and equal
|
||||
tables.append(
|
||||
@@ -63,6 +90,7 @@ def main() -> None:
|
||||
"original_sha256": digest(original_rows),
|
||||
"migrated_sha256": digest(migrated_rows),
|
||||
"equal": equal,
|
||||
"excluded_columns": sorted(excluded_columns),
|
||||
}
|
||||
)
|
||||
result = {
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REPOSITORY_ROOT = Path(__file__).resolve().parents[2]
|
||||
ORIGINAL_STATIC = REPOSITORY_ROOT / "static"
|
||||
FRONTEND_ROOT = REPOSITORY_ROOT / "app" / "frontend"
|
||||
EVIDENCE_PATH = (
|
||||
REPOSITORY_ROOT
|
||||
/ "docs"
|
||||
/ "migration"
|
||||
/ "evidence"
|
||||
/ "slice-10"
|
||||
/ "frontend-source-map.json"
|
||||
)
|
||||
EXPECTED_SOURCE_SHA256 = "c020d0bc68f8a96b352a0ffa62c913a0ea3f5ca0afc41f70969f833e447166a6"
|
||||
|
||||
RUNTIME_RANGES: dict[str, list[tuple[int, int]]] = {
|
||||
"pages/sentiment/page.js": [(1207, 1516)],
|
||||
"pages/pools/page.js": [(1517, 1915)],
|
||||
"pages/market/runtime.js": [(1916, 1957), (6893, 7719), (7969, 8426)],
|
||||
"pages/rotation/page.js": [(1958, 2124)],
|
||||
"pages/ladder/page.js": [(2125, 2216)],
|
||||
"pages/auction/page.js": [(2217, 2481)],
|
||||
"pages/themes/page.js": [(2482, 2583)],
|
||||
"pages/popularity/page.js": [(2584, 2659)],
|
||||
"pages/dragon-tiger/page.js": [(2660, 3028)],
|
||||
"pages/review/page.js": [(3029, 3470), (7720, 7968)],
|
||||
"pages/screener/page.js": [(3490, 4336), (6668, 6892)],
|
||||
"pages/mentor/page.js": [(4337, 4827)],
|
||||
"pages/heaven/page.js": [(4828, 6667)],
|
||||
"shared/export.js": [(8905, 9051)],
|
||||
}
|
||||
|
||||
|
||||
def digest(content: str) -> str:
|
||||
return hashlib.sha256(content.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def marker(start: int, end: int, kind: str) -> str:
|
||||
return f"/* PRESERVATION-SOURCE-{kind} app.js:{start}-{end} */"
|
||||
|
||||
|
||||
def wrapped_chunk(lines: list[str], start: int, end: int) -> str:
|
||||
content = "".join(lines[start - 1 : end])
|
||||
return f"{marker(start, end, 'BEGIN')}\n{content}{marker(start, end, 'END')}\n"
|
||||
|
||||
|
||||
def complement_ranges(total: int, moved: set[int]) -> list[tuple[int, int]]:
|
||||
ranges: list[tuple[int, int]] = []
|
||||
start = 0
|
||||
for number in range(1, total + 1):
|
||||
if number in moved:
|
||||
if start:
|
||||
ranges.append((start, number - 1))
|
||||
start = 0
|
||||
elif not start:
|
||||
start = number
|
||||
if start:
|
||||
ranges.append((start, total))
|
||||
return ranges
|
||||
|
||||
|
||||
def original_prefix(relative: str) -> str:
|
||||
source = ORIGINAL_STATIC / relative
|
||||
if not source.is_file():
|
||||
return ""
|
||||
return source.read_text(encoding="utf-8").rstrip("\n") + "\n\n"
|
||||
|
||||
|
||||
def extract_written_chunks(paths: list[Path]) -> dict[tuple[int, int], str]:
|
||||
pattern = re.compile(
|
||||
r"/\* PRESERVATION-SOURCE-BEGIN app\.js:(\d+)-(\d+) \*/\n"
|
||||
r"(.*?)"
|
||||
r"/\* PRESERVATION-SOURCE-END app\.js:\1-\2 \*/\n?",
|
||||
re.DOTALL,
|
||||
)
|
||||
chunks: dict[tuple[int, int], str] = {}
|
||||
for path in paths:
|
||||
content = path.read_text(encoding="utf-8")
|
||||
for match in pattern.finditer(content):
|
||||
key = (int(match.group(1)), int(match.group(2)))
|
||||
if key in chunks:
|
||||
raise RuntimeError(f"Duplicate preserved range {key}")
|
||||
chunks[key] = match.group(3)
|
||||
return chunks
|
||||
|
||||
|
||||
def main() -> None:
|
||||
source_path = ORIGINAL_STATIC / "app.js"
|
||||
target_path = FRONTEND_ROOT / "app.js"
|
||||
source_bytes = source_path.read_bytes()
|
||||
source_sha256 = hashlib.sha256(source_bytes).hexdigest()
|
||||
if source_sha256 != EXPECTED_SOURCE_SHA256:
|
||||
raise RuntimeError(
|
||||
f"Original app.js changed: expected {EXPECTED_SOURCE_SHA256}, got {source_sha256}"
|
||||
)
|
||||
if not target_path.is_file():
|
||||
raise RuntimeError(f"Missing target runtime: {target_path}")
|
||||
if hashlib.sha256(target_path.read_bytes()).hexdigest() != EXPECTED_SOURCE_SHA256:
|
||||
raise RuntimeError("Target app.js is not the exact pre-split original")
|
||||
|
||||
source = source_bytes.decode("utf-8")
|
||||
lines = source.splitlines(keepends=True)
|
||||
moved_lines: set[int] = set()
|
||||
for ranges in RUNTIME_RANGES.values():
|
||||
for start, end in ranges:
|
||||
overlap = moved_lines.intersection(range(start, end + 1))
|
||||
if overlap:
|
||||
raise RuntimeError(f"Overlapping source ranges at line {min(overlap)}")
|
||||
moved_lines.update(range(start, end + 1))
|
||||
|
||||
core_ranges = complement_ranges(len(lines), moved_lines)
|
||||
target_path.write_text(
|
||||
"\n".join(wrapped_chunk(lines, start, end).rstrip("\n") for start, end in core_ranges)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
written_paths = [target_path]
|
||||
for relative, ranges in RUNTIME_RANGES.items():
|
||||
path = FRONTEND_ROOT / relative
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
chunks = "\n".join(
|
||||
wrapped_chunk(lines, start, end).rstrip("\n") for start, end in ranges
|
||||
)
|
||||
path.write_text(original_prefix(relative) + chunks + "\n", encoding="utf-8")
|
||||
written_paths.append(path)
|
||||
|
||||
extracted = extract_written_chunks(written_paths)
|
||||
expected_ranges = {
|
||||
source_range for ranges in RUNTIME_RANGES.values() for source_range in ranges
|
||||
} | set(core_ranges)
|
||||
if set(extracted) != expected_ranges:
|
||||
raise RuntimeError("Written source ranges do not cover the original runtime exactly")
|
||||
|
||||
reassembled = [""] * len(lines)
|
||||
for (start, end), content in extracted.items():
|
||||
chunk_lines = content.splitlines(keepends=True)
|
||||
if len(chunk_lines) != end - start + 1:
|
||||
raise RuntimeError(f"Line count changed in preserved range {start}-{end}")
|
||||
reassembled[start - 1 : end] = chunk_lines
|
||||
reassembled_source = "".join(reassembled)
|
||||
if reassembled_source != source:
|
||||
raise RuntimeError("Split runtime cannot be reassembled byte-for-byte")
|
||||
|
||||
manifest = {
|
||||
"source": "static/app.js",
|
||||
"source_sha256": source_sha256,
|
||||
"source_line_count": len(lines),
|
||||
"reassembled_sha256": digest(reassembled_source),
|
||||
"all_source_lines_preserved": True,
|
||||
"core": {
|
||||
"target": "app/frontend/app.js",
|
||||
"ranges": [{"start": start, "end": end} for start, end in core_ranges],
|
||||
},
|
||||
"modules": [
|
||||
{
|
||||
"target": f"app/frontend/{relative}",
|
||||
"ranges": [
|
||||
{
|
||||
"start": start,
|
||||
"end": end,
|
||||
"sha256": digest("".join(lines[start - 1 : end])),
|
||||
}
|
||||
for start, end in ranges
|
||||
],
|
||||
}
|
||||
for relative, ranges in RUNTIME_RANGES.items()
|
||||
],
|
||||
}
|
||||
EVIDENCE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
EVIDENCE_PATH.write_text(
|
||||
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
|
||||
)
|
||||
print(json.dumps(manifest, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user