Files
xiaobaifupan/static/app.js
T

5453 lines
243 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const state = {
user: null,
csrfToken: "",
authMode: "login",
started: false,
dashboard: null,
filter: "all",
query: "",
sortKey: "streak",
sortDirection: "desc",
activeView: "limitPool",
dragonTiger: null,
dragonFilter: "all",
dragonQuery: "",
selectedDragonTraderId: "",
rotationHistory: null,
rotationHistoryKey: "",
rotationSelectedSector: "",
rotationLoading: false,
expandedLadderLevels: new Set(),
stockDetail: null,
activeStock: null,
stockPreviewCode: "",
stockPreviewPayload: null,
stockPreviewChart: "intraday",
stockPreviewFallback: null,
watchlist: [],
notes: [],
initialStockOpened: false,
screenerSetup: null,
selectedRegime: "",
selectedStrategy: null,
screenerResult: null,
screenerMobileView: "strategy",
sentimentHistory: null,
sentimentRange: 20,
sentimentHistoryKey: "",
sentimentLoading: false,
mentorSetup: null,
selectedMentorId: "",
mentorMessages: [],
mentorLoading: false,
heavenSetup: null,
heavenManualData: null,
personalField: null,
heavenPanel: "trend",
heavenInterpretations: { trend: "", fortune: "", heart: "" },
heartStage: "intro",
heartTimer: null,
heartSeconds: 30,
heartBreathingEndsAt: 0,
heartLines: [],
heartThrows: [],
heartHexagram: null,
heartCurtainTimer: null,
heartStageToken: 0,
heartRevealToken: 0,
heavenPerformanceKey: "",
heavenPerformancePanels: new Set(),
heavenPerformanceActive: "",
dashboardLoading: false,
dashboardRequestSequence: 0,
heavenRequestSequence: 0,
dashboardRequestDate: "",
adminModels: [],
globalSearchResults: [],
globalSearchActiveIndex: -1,
globalSearchRequestSequence: 0,
};
const elements = {
tradeDate: document.querySelector("#tradeDate"),
loading: document.querySelector("#loadingOverlay"),
toast: document.querySelector("#toast"),
stockDialog: document.querySelector("#stockDialog"),
globalSearchDialog: document.querySelector("#globalSearchDialog"),
globalSearchInput: document.querySelector("#globalSearchInput"),
globalSearchResults: document.querySelector("#globalSearchResults"),
entityDetailDialog: document.querySelector("#entityDetailDialog"),
entityDetailChart: document.querySelector("#entityDetailChart"),
settingsDialog: document.querySelector("#settingsDialog"),
adminDialog: document.querySelector("#adminDialog"),
priceChart: document.querySelector("#priceChart"),
stockPreview: document.querySelector("#stockPreview"),
stockPreviewBackdrop: document.querySelector("#stockPreviewBackdrop"),
stockPreviewChart: document.querySelector("#stockPreviewChart"),
};
const metricAnimationFrames = new WeakMap();
const stockPreviewCache = new Map();
const STOCK_PREVIEW_DELAY = 380;
const STOCK_PREVIEW_CACHE_MS = 5 * 60 * 1000;
const LIVE_REFRESH_DEFAULT_MS = 10 * 1000;
let qiFieldAnimationFrame = 0;
let qiFieldSoloElement = "";
let heavenPerformanceToken = 0;
let heartHoldTimer = null;
let heartHoldTriggered = false;
let heartHoldStartedAt = 0;
let heartHoldAnimationFrame = 0;
let heartCastingBusy = false;
let heartDustAnimationFrame = 0;
let heartDustParticles = [];
const heartCoinRotations = [0, 0, 0];
const MARKET_VIEWS = new Set([
"limitPool",
"brokenView",
"downView",
"yesterdayView",
"performanceView",
"sentimentCycleView",
"ladderView",
"rotationView",
"dragonView",
]);
let rowAnimationObserver = null;
let stockPreviewOpenTimer = null;
let stockPreviewCloseTimer = null;
let stockPreviewAbortController = null;
let stockPreviewAnchor = null;
let sentimentChartAnimationFrame = null;
let heavenResizeTimer = null;
let globalSearchTimer = null;
const heartSound = {
enabled: false,
context: null,
ensure() {
if (!this.context) {
const AudioContextClass = window.AudioContext || window.webkitAudioContext;
if (!AudioContextClass) return null;
this.context = new AudioContextClass();
}
if (this.context.state === "suspended") this.context.resume();
return this.context;
},
tone(frequency, duration, gain, type = "sine", delay = 0) {
if (!this.enabled) return;
const context = this.ensure();
if (!context) return;
const start = context.currentTime + delay;
const oscillator = context.createOscillator();
const volume = context.createGain();
oscillator.type = type;
oscillator.frequency.value = frequency;
volume.gain.setValueAtTime(0.0001, start);
volume.gain.linearRampToValueAtTime(gain, start + 0.015);
volume.gain.exponentialRampToValueAtTime(0.0001, start + duration);
oscillator.connect(volume).connect(context.destination);
oscillator.start(start);
oscillator.stop(start + duration + 0.05);
},
chime(frequency = 640) {
this.tone(frequency, 4.8, 0.12);
this.tone(frequency * 2.02, 3.6, 0.045);
this.tone(frequency * 3.96, 2.2, 0.018);
},
coin(delay = 0) {
this.tone(2350 + Math.random() * 260, 0.28, 0.055, "triangle", delay);
this.tone(3250 + Math.random() * 260, 0.18, 0.025, "triangle", delay + 0.01);
},
};
const HEART_WHISPERS = [
["应无所住,而生其心", 10, 12, 0],
["不是风动,不是幡动,仁者心动", 89, 8, 1],
["菩提本无树,明镜亦非台", 16, 52, 2],
["本来无一物,何处惹尘埃", 84, 54, 3],
["心外无物,心外无理", 22, 18, 4],
["知行合一", 78, 30, 5],
["此心光明,亦复何言", 90, 60, 6],
];
window.addEventListener("resize", () => {
clearTimeout(heavenResizeTimer);
heavenResizeTimer = setTimeout(() => {
if (state.activeView !== "heavenView") return;
if (state.heavenPanel === "fortune" && state.heavenSetup?.field) {
renderQiFieldCanvas(state.heavenSetup.field.balance || [], { intro: false });
drawQiUseConnections(false);
}
if (state.heavenPanel === "heart") startHeartDust();
}, 120);
});
document.addEventListener("DOMContentLoaded", initialize);
async function initialize() {
refreshIcons();
initializeApplicationShell();
const searchParams = new URLSearchParams(window.location.search);
const requestedDate = searchParams.get("date");
elements.tradeDate.value = /^\d{4}-\d{2}-\d{2}$/.test(requestedDate || "") && requestedDate <= todayString()
? requestedDate
: todayString();
elements.tradeDate.max = todayString();
document.querySelector("#journalDate").value = elements.tradeDate.value;
document.querySelector("#journalDate").max = todayString();
document.querySelector("#backfillStart").value = todayString();
document.querySelector("#backfillEnd").value = todayString();
document.querySelector("#backfillStart").max = todayString();
document.querySelector("#backfillEnd").max = todayString();
document.querySelector("#qiObservationDate").value = elements.tradeDate.value;
document.querySelector("#qiObservationDate").max = todayString();
document.querySelector("#accountBirthDate").max = todayString();
bindEvents();
try {
const session = await apiRequest("/api/auth/me");
if (!session.authenticated) {
if (session.registration_required) selectAuthMode("register");
showAuthGate();
return;
}
await applyAuthenticatedSession(session);
} catch (error) {
showAuthGate(error.message || "无法连接本地服务");
}
}
async function startAuthenticatedApp() {
if (state.started) return;
state.started = true;
const searchParams = new URLSearchParams(window.location.search);
const requestedHeavenPanel = searchParams.get("heaven");
if (["trend", "fortune", "heart"].includes(requestedHeavenPanel)) {
state.heavenPanel = requestedHeavenPanel;
}
const legacyViewAliases = { sectorView: "rotationView", breadthView: "limitPool" };
const requestedView = legacyViewAliases[searchParams.get("view")] || searchParams.get("view");
if (requestedView && document.getElementById(requestedView)?.classList.contains("workspace-view")) {
openView(requestedView, false);
if (requestedView !== searchParams.get("view")) {
const url = new URL(window.location.href);
url.searchParams.set("view", requestedView);
history.replaceState(null, "", url);
}
}
loadDashboard();
if (new URLSearchParams(window.location.search).get("settings") === "1") {
setTimeout(openSettings, 0);
}
}
function selectAuthMode(mode) {
state.authMode = mode === "register" ? "register" : "login";
document.querySelectorAll("[data-auth-mode]").forEach((button) => {
button.classList.toggle("active", button.dataset.authMode === state.authMode);
});
const registering = state.authMode === "register";
document.querySelector("#authConfirmField").hidden = !registering;
document.querySelector("#authPasswordConfirm").required = registering;
document.querySelector("#authPassword").autocomplete = registering ? "new-password" : "current-password";
document.querySelector("#authSubmitButton").textContent = registering ? "注册并进入" : "登录";
document.querySelector("#authError").hidden = true;
}
async function submitAuthForm(event) {
event.preventDefault();
const username = document.querySelector("#authUsername").value.trim();
const password = document.querySelector("#authPassword").value;
const errorElement = document.querySelector("#authError");
if (state.authMode === "register" && password !== document.querySelector("#authPasswordConfirm").value) {
errorElement.textContent = "两次输入的密码不一致。";
errorElement.hidden = false;
return;
}
const button = document.querySelector("#authSubmitButton");
button.disabled = true;
try {
const session = await apiRequest(`/api/auth/${state.authMode}`, "POST", { username, password });
document.querySelector("#authForm").reset();
await applyAuthenticatedSession(session);
} catch (error) {
errorElement.textContent = error.message || "账号操作失败";
errorElement.hidden = false;
} finally {
button.disabled = false;
}
}
async function applyAuthenticatedSession(session) {
state.user = session.user;
state.csrfToken = session.csrf_token || "";
setText("accountName", session.user?.username || "账号");
const isAdmin = session.user?.role === "admin";
updateAccountIdentityBadges(session.user?.membership || {});
document.querySelector("#settingsButton").hidden = !isAdmin;
document.querySelector("#syncButton").hidden = !isAdmin;
const factorSyncButton = document.querySelector("#factorSyncButton");
if (factorSyncButton) factorSyncButton.hidden = false;
document.querySelector("#authGate").hidden = true;
applyMembershipAccess();
await startAuthenticatedApp();
}
function showAuthGate(message = "") {
state.user = null;
state.csrfToken = "";
const gate = document.querySelector("#authGate");
gate.hidden = false;
const errorElement = document.querySelector("#authError");
errorElement.textContent = message;
errorElement.hidden = !message;
document.querySelector("#authUsername").focus();
}
async function logoutAccount() {
toggleAccountDropdown(false);
try {
await apiRequest("/api/auth/logout", "POST", {});
} catch (error) {
showToast(error.message || "退出失败");
return;
}
window.location.reload();
}
function bindEvents() {
document.querySelectorAll("[data-auth-mode]").forEach((button) => {
button.addEventListener("click", () => selectAuthMode(button.dataset.authMode));
});
document.querySelector("#authForm").addEventListener("submit", submitAuthForm);
document.querySelector("#refreshButton").addEventListener("click", () => loadDashboard(false));
document.querySelector("#syncButton").addEventListener("click", startAdminRefresh);
elements.tradeDate.addEventListener("change", () => {
state.dashboardRequestSequence += 1;
state.heavenRequestSequence += 1;
state.heavenManualData = null;
document.querySelector("#qiObservationDate").value = elements.tradeDate.value;
setDateInUrl(elements.tradeDate.value);
loadDashboard();
});
document.querySelector("#prevDate").addEventListener("click", () => shiftDate(-1));
document.querySelector("#nextDate").addEventListener("click", () => shiftDate(1));
document.querySelector("#stockSearch").addEventListener("input", (event) => {
state.query = event.target.value.trim().toLowerCase();
renderLimitTable();
});
document.querySelectorAll("[data-filter]").forEach((button) => {
button.addEventListener("click", () => {
document.querySelectorAll("[data-filter]").forEach((item) => item.classList.remove("active"));
button.classList.add("active");
state.filter = button.dataset.filter;
renderLimitTable();
});
});
document.querySelectorAll(".module-tab").forEach((button) => {
button.addEventListener("click", () => openView(button.dataset.view));
});
document.querySelector("#mobileMarketViewSelect").addEventListener("change", (event) => {
openView(event.target.value);
});
document.querySelector("#sidebarCollapseButton").addEventListener("click", toggleSidebar);
document.querySelector("#headerMenuButton").addEventListener("click", (event) => {
event.stopPropagation();
toggleHeaderCommandMenu();
});
document.querySelector("#globalSearchButton").addEventListener("click", openGlobalSearch);
document.querySelector("#closeGlobalSearch").addEventListener("click", closeGlobalSearch);
document.querySelector("#closeEntityDetail").addEventListener("click", () => elements.entityDetailDialog.close());
elements.globalSearchDialog.addEventListener("click", (event) => {
if (event.target === elements.globalSearchDialog) closeGlobalSearch();
});
elements.globalSearchInput.addEventListener("input", scheduleGlobalSearch);
elements.globalSearchInput.addEventListener("keydown", handleGlobalSearchInputKeydown);
elements.globalSearchResults.addEventListener("click", (event) => {
const result = event.target.closest("[data-search-result-index]");
if (result) openGlobalSearchResult(number(result.dataset.searchResultIndex));
});
document.querySelector("#headerCommandGroup").addEventListener("click", (event) => {
if (event.target.closest("button") && !event.target.closest(".account-menu-shell")) toggleHeaderCommandMenu(false);
});
document.addEventListener("click", (event) => {
if (!event.target.closest(".header-actions")) toggleHeaderCommandMenu(false);
if (!event.target.closest(".account-menu-shell")) toggleAccountDropdown(false);
});
window.addEventListener("keydown", handleGlobalSearchShortcut);
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
toggleHeaderCommandMenu(false);
toggleAccountDropdown(false, true);
}
handleAccountMenuKeydown(event);
});
window.addEventListener("resize", () => {
if (window.innerWidth > 720) toggleHeaderCommandMenu(false);
if (!elements.stockPreview.hidden) closeStockPreview();
updateSidebarControl();
if (state.activeView === "dragonView") layoutDragonCards();
});
document.querySelectorAll("[data-open-view]").forEach((button) => {
button.addEventListener("click", () => openView(button.dataset.openView));
});
document.querySelectorAll("[data-open-account]").forEach((button) => {
button.addEventListener("click", () => openSettings("membership"));
});
document.querySelectorAll("#limitTable th[data-sort]").forEach((header) => {
header.addEventListener("click", () => changeSort(header.dataset.sort));
});
document.querySelector("#exportButton").addEventListener("click", exportStocks);
document.querySelector("#brokenExportButton").addEventListener("click", exportBroken);
document.querySelector("#downExportButton").addEventListener("click", exportDown);
document.querySelector("#yesterdayExportButton").addEventListener("click", exportYesterday);
document.querySelector("#rotationExportButton").addEventListener("click", exportRotation);
document.querySelector("#clearRotationSelection").addEventListener("click", () => {
state.rotationSelectedSector = "";
renderRotationHistory();
});
document.querySelector("#sentimentExportButton").addEventListener("click", exportSentimentHistory);
document.querySelectorAll("[data-sentiment-range]").forEach((button) => {
button.addEventListener("click", () => {
state.sentimentRange = number(button.dataset.sentimentRange) || 20;
document.querySelectorAll("[data-sentiment-range]").forEach((item) => {
item.classList.toggle("active", item === button);
});
loadSentimentHistory(true);
});
});
document.querySelector("#settingsButton").addEventListener("click", () => openAdminSettings());
document.querySelector("#accountButton").addEventListener("click", (event) => {
event.stopPropagation();
toggleAccountDropdown();
});
document.querySelector("#accountVipBadge").addEventListener("click", () => openSettings("membership"));
document.querySelectorAll("[data-account-panel]").forEach((button) => {
button.addEventListener("click", () => openSettings(button.dataset.accountPanel));
});
document.querySelector("#switchAccountMenuButton").addEventListener("click", switchAccount);
document.querySelector("#logoutMenuButton").addEventListener("click", logoutAccount);
document.querySelector("#closeSettingsDialog").addEventListener("click", () => elements.settingsDialog.close());
document.querySelector("#closeAdminDialog").addEventListener("click", () => elements.adminDialog.close());
document.querySelector("#closeStockDialog").addEventListener("click", () => elements.stockDialog.close());
document.querySelector("#closeStockPreview").addEventListener("click", closeStockPreview);
elements.stockPreviewBackdrop.addEventListener("click", closeStockPreview);
document.querySelector("#openStockDetailFromPreview").addEventListener("click", openStockDetailFromPreview);
document.querySelectorAll("[data-preview-chart]").forEach((button) => {
button.addEventListener("click", () => selectStockPreviewChart(button.dataset.previewChart));
});
elements.stockPreview.addEventListener("pointerenter", cancelStockPreviewClose);
elements.stockPreview.addEventListener("pointerleave", scheduleStockPreviewClose);
document.addEventListener("pointerover", handleStockPreviewPointerOver);
document.addEventListener("pointerout", handleStockPreviewPointerOut);
document.addEventListener("focusin", handleStockPreviewFocus);
document.addEventListener("focusout", handleStockPreviewFocusOut);
document.addEventListener("click", handleMobileStockPreviewClick, true);
document.addEventListener("keydown", handleStockPreviewKeydown);
document.addEventListener("scroll", repositionStockPreview, true);
document.querySelector("#dragonRefreshButton").addEventListener("click", () => loadDragonTiger(false));
document.querySelector("#dragonExportButton").addEventListener("click", exportDragonTiger);
document.querySelector("#dragonSearch").addEventListener("input", (event) => {
state.dragonQuery = event.target.value.trim().toLowerCase();
renderDragonTraderList();
});
document.querySelectorAll("[data-dragon-filter]").forEach((button) => {
button.addEventListener("click", () => {
state.dragonFilter = button.dataset.dragonFilter;
document.querySelectorAll("[data-dragon-filter]").forEach((item) => {
item.classList.toggle("active", item === button);
});
renderDragonTraderList();
});
});
document.querySelector("#journalForm").addEventListener("submit", saveJournal);
document.querySelector("#stockNoteForm").addEventListener("submit", saveStockNote);
document.querySelector("#watchStockButton").addEventListener("click", toggleActiveWatchlist);
document.querySelector("#stockHeavenButton").addEventListener("click", openActiveStockInHeaven);
document.querySelector("#reasonForm").addEventListener("submit", saveReasonOverride);
document.querySelector("#backfillButton").addEventListener("click", backfillData);
document.querySelector("#factorSyncButton").addEventListener("click", syncFactorData);
document.querySelector("#screenerRunButton").addEventListener("click", runScreener);
document.querySelectorAll("[data-screener-mobile-view]").forEach((button) => {
button.addEventListener("click", () => selectScreenerMobileView(button.dataset.screenerMobileView));
});
document.querySelector("#compileStrategyButton").addEventListener("click", compileStrategy);
document.querySelector("#saveStrategyButton").addEventListener("click", saveCurrentStrategy);
document.querySelector("#deleteStrategyButton").addEventListener("click", deleteCurrentStrategy);
document.querySelector("#screenerExportButton").addEventListener("click", exportScreenerResults);
document.querySelector("#runBacktestToggle").addEventListener("change", updateBacktestTaskStatus);
document.querySelector("#mentorChatForm").addEventListener("submit", sendMentorQuestion);
document.querySelector("#clearMentorChatButton").addEventListener("click", clearMentorConversation);
document.querySelectorAll("[data-mentor-prompt]").forEach((button) => {
button.addEventListener("click", () => useMentorQuickPrompt(button.dataset.mentorPrompt));
});
document.querySelectorAll("[data-heaven-panel]").forEach((button) => {
button.addEventListener("click", () => selectHeavenPanel(button.dataset.heavenPanel, true));
});
document.querySelector("#loadHeavenSelectionButton").addEventListener("click", loadHeavenSelection);
document.querySelector("#heavenCalibrationForm").addEventListener("submit", applyHeavenCalibration);
document.querySelector("#resetHeavenCalibrationButton").addEventListener("click", resetHeavenCalibration);
document.querySelector("#heavenStockInput").addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault();
loadHeavenSelection();
}
});
document.querySelector("#interpretTrendButton").addEventListener("click", () => interpretHeaven("trend"));
document.querySelector("#interpretFortuneButton").addEventListener("click", () => interpretHeaven("fortune"));
document.querySelector("#qiObservationDate").addEventListener("change", () => {
state.personalField = null;
state.heavenManualData = null;
state.heavenInterpretations.fortune = "";
loadHeavenSetup(
true,
"",
document.querySelector("#heavenStockInput").value.trim(),
);
});
document.querySelector("#openPersonalSettingsButton").addEventListener("click", () => openSettings("profile"));
document.querySelector("#accountBirthForm").addEventListener("submit", saveAccountBirthProfile);
document.querySelector("#deleteBirthProfileButton").addEventListener("click", deleteAccountBirthProfile);
document.querySelector("#passwordForm").addEventListener("submit", changeAccountPassword);
document.querySelector("#sectorPhaseForm").addEventListener("submit", saveSectorPhaseOverride);
document.querySelector("#startBreathingButton").addEventListener("click", startHeartBreathing);
document.querySelector("#beginCastingButton").addEventListener("click", beginHeartCasting);
document.querySelector("#heartSoundToggle").addEventListener("click", toggleHeartSound);
initializeHeartCoinHold();
initializeHeartLineInspection();
document.querySelector("#interpretHeartButton").addEventListener("click", () => interpretHeaven("heart"));
document.querySelector("#restartHeartButton").addEventListener("click", resetHeartRitual);
document.querySelectorAll("[data-heart-return]").forEach((button) => {
button.addEventListener("click", resetHeartRitual);
});
document.querySelector("#adminSectionSelect").addEventListener("change", (event) => selectAdminPanel(event.target.value));
document.querySelector("#systemMarketForm").addEventListener("submit", saveMarketSettings);
document.querySelector("#systemModelsForm").addEventListener("submit", saveModelPool);
document.querySelector("#membershipSettingsForm").addEventListener("submit", saveMembershipSettings);
document.querySelector("#addPlatformModel").addEventListener("click", addPlatformModel);
document.querySelector("#adminRefreshButton").addEventListener("click", startAdminRefresh);
window.addEventListener("resize", () => {
if (elements.stockDialog.open && state.stockDetail?.prices) drawPriceChart(state.stockDetail.prices);
if (state.activeView === "sentimentCycleView" && state.sentimentHistory) {
drawSentimentTrendChart(state.sentimentHistory.rows || []);
}
});
initializeAutoTableSorting();
}
async function loadDashboard(force = false, background = false) {
const requestedDate = elements.tradeDate.value;
if (state.dashboardLoading && state.dashboardRequestDate === requestedDate) return;
state.dashboardLoading = true;
state.dashboardRequestDate = requestedDate;
const requestSequence = ++state.dashboardRequestSequence;
if (force) stockPreviewCache.clear();
if (!background) {
setLoading(true, "正在读取本地复盘数据");
setStatus("正在读取复盘数据");
}
try {
const query = new URLSearchParams({ trade_date: elements.tradeDate.value });
if (force) query.set("force", "1");
const payload = await apiRequest(`/api/dashboard?${query}`);
if (
requestSequence !== state.dashboardRequestSequence
|| requestedDate !== elements.tradeDate.value
) return;
applyDashboard(payload, background);
} catch (error) {
if (background) {
setStatus("实时刷新暂时中断,正在等待重试");
} else {
showToast(error.message || "无法连接本地服务");
setStatus("加载失败");
}
} finally {
if (requestSequence === state.dashboardRequestSequence) {
state.dashboardLoading = false;
state.dashboardRequestDate = "";
if (!background) setLoading(false);
updateDateButtons();
}
}
}
async function startAdminRefresh() {
const buttons = [document.querySelector("#syncButton"), document.querySelector("#adminRefreshButton")].filter(Boolean);
buttons.forEach((button) => { button.disabled = true; });
try {
const payload = await apiRequest("/api/admin/refresh", "POST", { trade_date: elements.tradeDate.value });
showToast(payload.message || "后台刷新已提交");
setStatus("后台刷新运行中,当前页面保持不变");
} catch (error) {
showToast(error.message || "后台刷新启动失败");
} finally {
buttons.forEach((button) => { button.disabled = false; });
}
}
function applyDashboard(payload, background = false) {
state.dashboard = payload;
const selectedDate = payload.meta.requested_date || payload.meta.trade_date;
elements.tradeDate.value = selectedDate;
document.querySelector("#qiObservationDate").value = selectedDate;
document.querySelector("#journalDate").value = selectedDate;
renderDashboard();
const source = payload.meta.source === "tushare" ? "Tushare" : "演示数据";
const cacheText = payload.meta.realtime
? "rt_k 实时行情"
: payload.meta.cached ? "SQLite 缓存" : "已写入 SQLite";
setStatus(`${source} · ${cacheText}`);
if (!background) {
if (state.activeView === "dragonView") loadDragonTiger();
if (state.activeView === "screenerView") loadScreenerSetup();
if (state.activeView === "mentorView") loadMentorSetup(true);
if (state.activeView === "heavenView") loadHeavenSetup(true);
if (state.activeView === "sentimentCycleView") loadSentimentHistory(true);
if (state.activeView === "rotationView") loadRotationHistory(true);
}
const requestedStock = new URLSearchParams(window.location.search).get("stock");
if (!state.initialStockOpened && /^\d{6}$/.test(requestedStock || "")) {
state.initialStockOpened = true;
openStock(requestedStock);
}
}
function dashboardSourceLabel(meta = {}) {
if (meta.source === "demo") return "演示数据";
if (meta.realtime && !["closed", "after_hours"].includes(String(meta.market_status || ""))) return "Tushare 实时行情";
if (meta.source === "tushare") return "Tushare 盘后行情";
return "本地行情";
}
function renderDashboard() {
const { meta, overview, ladders, sectors } = state.dashboard;
animateMetric("tapeUp", overview.up_count, (value) => Math.round(value));
animateMetric("tapeDown", overview.down_count, (value) => Math.round(value));
setText("tapeLimit", `${overview.limit_up_count} / 跌停 ${overview.limit_down_count}`);
animateMetric("tapeAmount", overview.amount_billion, (value) => `${formatNumber(value, 1)} 亿`);
animateMetric("limitUpMetric", overview.limit_up_count, (value) => `${Math.round(value)} 家`);
animateMetric("limitDownMetric", overview.limit_down_count, (value) => `${Math.round(value)} 家`);
animateMetric("brokenMetric", overview.broken_count, (value) => `${Math.round(value)} 家`);
animateMetric("sealRateMetric", overview.seal_rate, (value) => `${formatNumber(value, 1)}%`);
animateMetric("amountMetric", overview.amount_billion, (value) => `${formatNumber(value, 1)} 亿`);
setText("dataDateMetric", dashboardDataTimestamp(meta));
animateMetric("sentimentScore", overview.sentiment_score, (value) => Math.round(value));
setText("sentimentText", sentimentLabel(overview.sentiment_score));
updateSentimentGauge(overview.sentiment_score);
setText("updatedAt", `${dashboardSourceLabel(meta)} · 更新 ${formatTimestamp(meta.updated_at)}`);
renderLimitTable();
renderLadderMini(ladders || []);
renderSectorMini(sectors || []);
renderBrokenTable(state.dashboard.broken || []);
renderDownTable(state.dashboard.down_limits || []);
renderYesterdayTable(state.dashboard.yesterday_limits || []);
renderPerformance(state.dashboard.limit_performance || []);
renderLadderBoard(ladders || []);
renderRotationTable(state.dashboard.sector_rotation || [], sectors || []);
}
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} · 动量 ${latest.momentum > 0 ? "+" : ""}${formatNumber(latest.momentum, 1)}`);
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-cycle-score-marker ${sentimentPhaseClass(latest.phase)}`;
document.querySelector("#sentimentComponentList").innerHTML = Object.values(latest.components || {}).map((item) => `
<article class="sentiment-component-item">
<div><strong>${escapeHtml(item.label)}</strong><span>权重 ${number(item.weight)}</span><b>${formatNumber(item.score, 1)}</b></div>
<div class="sentiment-component-track" aria-hidden="true"><i data-component-score="${clamp(item.score, 0, 100)}" style="width:0%"></i></div>
<small>${escapeHtml(item.summary)}</small>
</article>
`).join("");
requestAnimationFrame(() => {
animateSentimentComponents();
animateSentimentTrendChart(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");
context.setTransform(ratio, 0, 0, ratio, 0, 0);
context.clearRect(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 ? "#ccd7de" : "#e6ebef";
context.lineWidth = 1;
context.beginPath();
context.moveTo(padding.left, lineY);
context.lineTo(width - padding.right, lineY);
context.stroke();
context.fillStyle = "#758590";
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();
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 = "#1268c4";
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 = row.direction === "降温" ? "#d64955" : row.direction === "升温" ? "#087f67" : "#1268c4";
context.fill();
});
context.restore();
const labelStep = Math.max(1, Math.ceil(rows.length / 6));
context.textAlign = "center";
context.textBaseline = "top";
context.fillStyle = "#758590";
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 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 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 body = document.querySelector("#limitTableBody");
body.innerHTML = rows.map((row, index) => `
<tr data-code="${escapeHtml(row.code)}">
<td class="row-number">${index + 1}</td>
<td class="stock-code">${escapeHtml(row.code)}</td>
<td class="stock-name">${escapeHtml(row.name)}</td>
<td class="number streak-value">${streakLabel(row.streak)}</td>
<td class="number up">${signed(row.change)}%</td>
<td class="number">${formatNumber(row.price, 2)}</td>
<td>${escapeHtml(row.sector || "其他")}</td>
<td class="reason-column" title="${escapeHtml(row.reason || "")}">${escapeHtml(row.reason || "--")}</td>
<td>${escapeHtml(row.first_time || "--")}</td>
<td>${escapeHtml(row.last_time || "--")}</td>
<td class="number">${number(row.open_times)}</td>
<td class="number">${formatNumber(row.turnover_rate, 2)}%</td>
<td class="number">${formatNumber(row.amount_billion, 2)} 亿</td>
<td class="number">${formatNumber(row.seal_amount_million, 0)} 万</td>
</tr>
`).join("");
bindStockRows(body);
setText("resultCount", `${rows.length} 只`);
document.querySelector("#emptyState").hidden = rows.length !== 0;
updateSortHeaders();
}
function renderBrokenTable(rows) {
setText("brokenCount", `${rows.length} 只`);
const body = document.querySelector("#brokenTableBody");
body.innerHTML = rows.map((row, index) => `
<tr data-code="${escapeHtml(row.code)}">
<td class="row-number">${index + 1}</td><td class="stock-code">${escapeHtml(row.code)}</td>
<td class="stock-name">${escapeHtml(row.name)}</td><td class="number up">${signed(row.change)}%</td>
<td class="number">${formatNumber(row.price, 2)}</td><td>${escapeHtml(row.sector)}</td>
<td class="reason-column">${escapeHtml(row.reason || "--")}</td><td>${escapeHtml(row.first_time || "--")}</td>
<td>${escapeHtml(row.last_time || "--")}</td><td class="number warning">${number(row.open_times)}</td>
<td class="number">${formatNumber(row.turnover_rate, 2)}%</td><td class="number">${formatNumber(row.amount_billion, 2)} 亿</td>
</tr>
`).join("");
bindStockRows(body);
}
function renderDownTable(rows) {
setText("downCount", `${rows.length} 只`);
const body = document.querySelector("#downTableBody");
body.innerHTML = rows.map((row, index) => `
<tr data-code="${escapeHtml(row.code)}">
<td class="row-number">${index + 1}</td><td class="stock-code">${escapeHtml(row.code)}</td>
<td class="stock-name">${escapeHtml(row.name)}</td><td class="number down">${signed(row.change)}%</td>
<td class="number">${formatNumber(row.price, 2)}</td><td>${escapeHtml(row.sector)}</td>
<td class="reason-column">${escapeHtml(row.reason || "--")}</td><td class="number">${number(row.streak)}</td>
<td class="number">${formatNumber(row.turnover_rate, 2)}%</td><td class="number">${formatNumber(row.amount_billion, 2)} 亿</td>
</tr>
`).join("");
bindStockRows(body);
}
function renderYesterdayTable(rows) {
setText("yesterdayCount", `${rows.length} 只`);
setText("previousTradeDate", `数据日期 ${state.dashboard.meta.previous_trade_date || "--"}`);
document.querySelector("#yesterdayTableBody").innerHTML = rows.map((row, index) => `
<tr data-code="${escapeHtml(row.code)}">
<td class="row-number">${index + 1}</td><td class="stock-code">${escapeHtml(row.code)}</td>
<td class="stock-name">${escapeHtml(row.name)}</td><td class="number">${streakLabel(row.prior_streak)}</td>
<td class="number ${changeClass(row.current_change)}">${signed(row.current_change)}%</td>
<td><span class="outcome-tag ${outcomeClass(row.outcome)}">${escapeHtml(row.outcome)}</span></td>
<td class="number">${number(row.current_streak) ? streakLabel(row.current_streak) : "--"}</td>
<td>${escapeHtml(row.sector || "其他")}</td><td class="reason-column">${escapeHtml(row.reason || "--")}</td>
</tr>
`).join("");
bindStockRows(document.querySelector("#yesterdayTableBody"));
}
function renderPerformance(rows) {
document.querySelector("#performanceCards").innerHTML = rows.map((row) => `
<article class="performance-card">
<div class="performance-card-header"><strong>${escapeHtml(row.label)}</strong><span>${number(row.count)} 只</span></div>
<div class="performance-rate"><strong>${formatNumber(row.advance_rate, 1)}%</strong><span>晋级率</span></div>
<div class="performance-meta"><span>收红 ${formatNumber(row.positive_rate, 1)}%</span><span>均涨 ${signed(row.average_change)}%</span></div>
</article>
`).join("") || '<div class="empty-state">暂无昨日涨停统计</div>';
document.querySelector("#performanceTableBody").innerHTML = rows.map((row) => `
<tr><td class="stock-name">${escapeHtml(row.label)}</td><td class="number">${number(row.count)}</td>
<td class="number up">${number(row.advanced)}</td><td class="number up">${formatNumber(row.advance_rate, 1)}%</td>
<td class="number">${formatNumber(row.positive_rate, 1)}%</td>
<td class="number ${changeClass(row.average_change)}">${signed(row.average_change)}%</td></tr>
`).join("");
renderMarketBreadth(state.dashboard?.overview || {});
}
function renderMarketBreadth(overview) {
const up = number(overview.up_count);
const down = number(overview.down_count);
const flat = Math.max(0, number(overview.flat_count));
const total = Math.max(1, up + down + flat);
const upRate = up / total * 100;
const flatRate = flat / total * 100;
const downRate = down / total * 100;
const ratio = down > 0 ? up / down : up > 0 ? up : 0;
const difference = up - down;
const panel = document.querySelector(".market-breadth-panel");
panel.classList.remove("breadth-enter");
void panel.offsetWidth;
panel.classList.add("breadth-enter");
setText("breadthSummary", `${up + down + flat} 只股票参与统计`);
animateMetric("breadthRatio", upRate, (value) => `红盘 ${formatNumber(value, 1)}%`);
animateMetric("breadthUpCount", up, (value) => `${Math.round(value)} 家`);
animateMetric("breadthFlatCount", flat, (value) => `${Math.round(value)} 家`);
animateMetric("breadthDownCount", down, (value) => `${Math.round(value)} 家`);
animateMetric("breadthAdvanceDecline", ratio, (value) => `${formatNumber(value, 2)} : 1`);
animateMetric("breadthDifference", difference, (value) => `${value > 0 ? "+" : ""}${Math.round(value)} 家`);
const differenceElement = document.querySelector("#breadthDifference");
differenceElement.classList.remove("up", "down", "warning");
differenceElement.classList.add(changeClass(difference));
const bars = [
["breadthUpBar", upRate],
["breadthFlatBar", flatRate],
["breadthDownBar", downRate],
];
bars.forEach(([id, width]) => {
const bar = document.getElementById(id);
const targetWidth = `${Math.max(width, width > 0 ? 0.8 : 0)}%`;
bar.style.transition = "none";
bar.style.width = "0%";
requestAnimationFrame(() => requestAnimationFrame(() => {
bar.style.transition = "width 760ms var(--ease-out)";
bar.style.width = targetWidth;
}));
bar.title = `${formatNumber(width, 1)}%`;
});
}
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");
container.innerHTML = '<div class="empty-state">正在读取轮动历史</div>';
try {
const query = new URLSearchParams({
trade_date: elements.tradeDate.value,
});
state.rotationHistory = await apiRequest(`/api/rotation/history?${query}`);
state.rotationHistoryKey = key;
renderRotationHistory();
} catch (error) {
container.innerHTML = `<div class="empty-state">${escapeHtml(error.message || "轮动历史加载失败")}</div>`;
showToast(error.message || "轮动历史加载失败");
} finally {
state.rotationLoading = false;
}
}
function renderRotationHistory() {
const rows = state.rotationHistory?.rows || [];
const selected = state.rotationSelectedSector;
const container = document.querySelector("#rotationHistory");
if (!rows.length) {
container.innerHTML = '<div class="empty-state">尚无连续交易日的板块数据</div>';
setText("rotationHistoryRange", "暂无轮动历史");
return;
}
setText(
"rotationHistoryRange",
`最近 ${rows.length} 个交易日 · ${displayCompactDate(rows[0].trade_date)}${displayCompactDate(rows[rows.length - 1].trade_date)} · 由近到远`,
);
setText("rotationSelectionHint", selected ? `正在追踪:${selected}` : "左近右远 · 点击板块查看连续性");
document.querySelector("#clearRotationSelection").hidden = !selected;
container.innerHTML = rows.map((day) => {
const hasSelected = selected && (day.sectors || []).some((sector) => sector.name === selected);
return `
<article class="rotation-day ${selected ? "has-selection" : ""} ${hasSelected ? "selected-day" : ""}">
<header><time>${escapeHtml(displayCompactDate(day.trade_date))}</time><span>${(day.sectors || []).length} 个热点</span></header>
<div class="rotation-day-sectors">${(day.sectors || []).map((sector) => `
<button type="button" class="rotation-sector-chip ${selected === sector.name ? "selected" : ""}" data-rotation-sector="${escapeHtml(sector.name)}">
<span>${number(sector.rank)}</span><strong>${escapeHtml(sector.name)}</strong><small>${number(sector.count)}板 · ${formatNumber(sector.strength, 0)}</small>
</button>`).join("")}</div>
</article>`;
}).join("");
container.querySelectorAll("[data-rotation-sector]").forEach((button) => {
button.addEventListener("click", () => {
state.rotationSelectedSector = button.dataset.rotationSector === state.rotationSelectedSector
? ""
: button.dataset.rotationSector;
renderRotationHistory();
});
});
}
function renderRotationTable(rows, sectors) {
const sectorMap = new Map(sectors.map((sector) => [sector.name, sector]));
const body = document.querySelector("#rotationTableBody");
body.innerHTML = rows.map((row) => {
const sector = sectorMap.get(row.name) || {};
const strength = number(row.strength ?? sector.strength);
return `
<tr><td class="number">${number(row.rank)}</td><td class="stock-name">${escapeHtml(row.name)}</td>
<td><span class="trend-tag ${trendClass(row.trend)}">${escapeHtml(row.trend)}</span></td>
<td class="number up">${number(row.count)}</td><td class="number">${number(row.previous_count)}</td>
<td class="number ${number(row.delta) >= 0 ? "delta-positive" : "delta-negative"}">${number(row.delta) > 0 ? "+" : ""}${number(row.delta)}</td>
<td><div class="rotation-strength"><span class="strength-cell"><i style="width:${clamp(strength, 0, 100)}%"></i></span><b>${formatNumber(strength, 0)}</b></div></td>
<td class="number streak-value">${streakLabel(row.max_streak || 1)}</td>
<td class="number ${changeClass(sector.change)}">${signed(sector.change)}%</td>
<td>${escapeHtml(row.leader || sector.leader || "--")}</td><td class="number">${formatNumber(row.amount_billion, 1)} 亿</td></tr>
`;
}).join("");
animateRows(body);
}
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 names = group.stocks.slice(0, 3).map((stock) => stock.name).join("、");
return `<div class="mini-row"><strong>${escapeHtml(group.label)}</strong>
<span title="${escapeHtml(names)}">${escapeHtml(names || "--")}</span><small>${group.count}只</small></div>`;
}).join("") || '<div class="empty-state">暂无梯队数据</div>';
}
function renderSectorMini(sectors) {
document.querySelector("#sectorMini").innerHTML = sectors.slice(0, 7).map((sector) => `
<div class="sector-mini-row"><span title="${escapeHtml(sector.name)}">${escapeHtml(sector.name)}</span>
<span class="strength-track"><i style="width:${clamp(sector.strength, 0, 100)}%"></i></span><strong>${number(sector.count)}</strong></div>
`).join("") || '<div class="empty-state">暂无板块数据</div>';
}
function renderLadderBoard(ladders) {
const container = document.querySelector("#ladderBoard");
const ordered = [...ladders].sort((left, right) => number(right.level) - number(left.level));
const maxLevel = Math.max(1, ...ordered.map((group) => number(group.level)));
container.innerHTML = ordered.map((group) => {
const level = number(group.level);
const limit = level === 1 ? 8 : 6;
const expanded = state.expandedLadderLevels.has(level);
const stocks = expanded ? group.stocks : group.stocks.slice(0, limit);
const remaining = Math.max(0, group.stocks.length - stocks.length);
return `
<section class="ladder-step" data-ladder-level-card="${level}" style="--ladder-level:${level};--ladder-max:${maxLevel};--ladder-indent:${Math.min((level - 1) * 38, 270)}px">
<div class="ladder-step-header"><span class="ladder-level-mark">${level}</span><div><strong>${escapeHtml(group.label)}</strong><small>${number(group.count)} 只</small></div></div>
<div class="ladder-step-stocks">${stocks.map((stock) => `<button type="button" class="ladder-stock" data-code="${escapeHtml(stock.code)}">
<span><strong>${escapeHtml(stock.name)}</strong><small><span class="stock-code">${escapeHtml(stock.code)}</span> · ${escapeHtml(stock.reason || stock.sector || "--")}</small></span><time>${escapeHtml(stock.first_time || "--")}</time></button>`).join("")}</div>
${group.stocks.length > limit ? `<button class="ladder-more" type="button" data-ladder-level="${level}">${expanded ? "收起" : `更多 ${remaining} 只`}<i data-lucide="chevron-${expanded ? "up" : "down"}"></i></button>` : ""}
</section>`;
}).join("") || '<div class="empty-state">暂无连板数据</div>';
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();
}
async function loadDragonTiger(force = false) {
const requestedDate = elements.tradeDate.value;
if (
!force
&& ["success", "demo"].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.source === "tushare" ? "龙虎榜明细" : "演示数据";
setStatus(`${statusLabel} · 龙虎榜已加载`);
} catch (error) {
showToast(error.message || "龙虎榜加载失败");
setStatus("龙虎榜加载失败");
}
}
function renderDragonTiger() {
const payload = state.dragonTiger;
if (!payload) return;
const summary = payload.summary || {};
setText("dragonDateLabel", `数据日期 ${payload.meta.trade_date}`);
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 (payload.meta?.status === "error") emptyMessage = "游资接口调用失败,请查看上方提示并检查 Tushare 积分权限";
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>`
: `<div class="empty-state dragon-empty">${escapeHtml(state.dragonFilter === "unclassified" ? "待归类席位请在下方管理" : emptyMessage)}</div>`;
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.innerHTML = '<div class="empty-state dragon-empty">选择一位游资查看操作明细</div>';
return;
}
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">
<table class="data-table dragon-operation-table">
<thead><tr><th>股票</th><th>方向</th><th class="number">涨幅</th><th class="number">买入</th><th class="number">卖出</th><th class="number">净额</th><th>关联席位</th><th class="reason-column">标签 / 上榜原因</th></tr></thead>
<tbody>${(trader.operations || []).map((operation) => `
<tr data-code="${escapeHtml(operation.code)}">
<td><span class="dragon-stock"><strong>${escapeHtml(operation.name)}</strong><small class="stock-code">${escapeHtml(operation.code)}</small></span></td>
<td><span class="direction-label ${changeClass(operation.net_buy_million)}">${escapeHtml(operation.direction)}</span></td>
<td class="number ${operation.change == null ? "" : changeClass(operation.change)}">${operation.change == null ? "--" : `${signed(operation.change)}%`}</td>
<td class="number">${formatMoneyMillion(operation.buy_million)}</td>
<td class="number">${formatMoneyMillion(operation.sell_million)}</td>
<td class="number ${changeClass(operation.net_buy_million)}">${formatMoneyMillion(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 || "--")}</td>
</tr>`).join("")}</tbody>
</table>
</div>`;
bindStockRows(container);
markAutoSortableHeaders(container);
}
function renderUnclassifiedSeats() {
const seats = state.dragonTiger?.unclassified_seats || [];
document.querySelector("#dragonUnclassifiedSection").hidden = seats.length === 0;
document.querySelector("#dragonUnclassifiedFilter").hidden = 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("") || '<div class="empty-state">当前席位均已归类</div>';
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;
}
}
async function loadReviewWorkspace() {
try {
const [watchlistPayload, notesPayload] = await Promise.all([
apiRequest("/api/watchlist"),
apiRequest("/api/notes?scope=daily"),
]);
state.watchlist = watchlistPayload.items || [];
state.notes = notesPayload.items || [];
renderWatchlist();
renderNotesHistory(state.notes, document.querySelector("#notesHistory"), false);
setText("notesCount", `${state.notes.length} 条`);
} 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="mark-swatch ${escapeHtml(item.color)}" title="${escapeHtml(item.color)}"></span></td>
<td class="stock-code">${escapeHtml(item.code)}</td><td class="stock-name">${escapeHtml(item.name)}</td>
<td>${escapeHtml(item.sector || "其他")}</td><td><button class="table-action" type="button" data-watch-detail="${escapeHtml(item.code)}">详情</button>
<button class="table-action down" type="button" data-watch-delete="${escapeHtml(item.code)}">移除</button></td></tr>
`).join("");
document.querySelector("#watchlistEmpty").hidden = state.watchlist.length > 0;
body.querySelectorAll("[data-watch-detail]").forEach((button) => {
button.addEventListener("click", () => {
const item = state.watchlist.find((row) => row.code === button.dataset.watchDetail);
openStock(button.dataset.watchDetail, item);
});
});
body.querySelectorAll("[data-watch-delete]").forEach((button) => {
button.addEventListener("click", () => removeWatchlist(button.dataset.watchDelete));
});
bindStockRows(body);
}
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,
content: document.querySelector("#journalContent").value,
plan: document.querySelector("#journalPlan").value,
});
document.querySelector("#journalContent").value = "";
document.querySelector("#journalPlan").value = "";
await loadReviewWorkspace();
showToast("每日复盘已保存");
} catch (error) {
showToast(error.message);
}
}
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>
<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("") || '<div class="empty-state">暂无复盘记录</div>';
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);
}
}
async function backfillData() {
const button = document.querySelector("#backfillButton");
button.disabled = true;
setLoading(true, "正在回补历史交易日");
try {
const payload = await apiRequest("/api/backfill", "POST", {
start_date: document.querySelector("#backfillStart").value,
end_date: document.querySelector("#backfillEnd").value,
});
showToast(`历史回补完成,共处理 ${payload.results.length} 个工作日`);
await openAdminSettings(true);
} catch (error) {
showToast(error.message);
} finally {
setLoading(false);
button.disabled = false;
}
}
async function loadScreenerSetup() {
try {
const query = new URLSearchParams({ trade_date: elements.tradeDate.value });
const payload = await apiRequest(`/api/screener/setup?${query}`);
state.screenerSetup = payload;
if (!state.screenerResult && payload.latest_result) state.screenerResult = payload.latest_result;
if (!state.selectedRegime) state.selectedRegime = payload.regime.id;
const selectedId = state.selectedStrategy?.id;
state.selectedStrategy = payload.strategies.find((item) => item.id === selectedId)
|| payload.strategies.find((item) => item.regimes.includes(state.selectedRegime))
|| payload.strategies[0]
|| null;
renderScreenerSetup();
if (state.screenerResult) renderScreenerResult();
} catch (error) {
showToast(error.message || "选股配置加载失败");
}
}
function renderScreenerSetup() {
const setup = state.screenerSetup;
if (!setup) return;
setText("screenerDateLabel", `数据日期 ${displayCompactDate(setup.trade_date)}`);
setText("regimeLabel", setup.regime.label);
setText("regimeConfidence", `置信度 ${formatNumber(setup.regime.confidence, 0)}%`);
setText("regimeReason", setup.regime.reason);
document.querySelector("#regimeEvidenceList").textContent = setup.regime.evidence.join(" · ");
setText("factorDateCount", `${number(setup.factor_data.date_count)} 日`);
setText("factorDateRange", setup.factor_data.ready
? `${displayCompactDate(setup.factor_data.start_date)}${displayCompactDate(setup.factor_data.end_date)}`
: "尚未达到 21 个交易日");
setText("factorTaskStatus", setup.factor_data.ready ? `已就绪 · ${number(setup.factor_data.date_count)} 日` : "需要同步");
const compilerText = setup.llm.configured ? "模型编译就绪" : "本地编译就绪";
setText("compilerStatus", compilerText);
setText(
"screenerRunStatus",
state.screenerResult ? `已有结果 · ${(state.screenerResult.candidates || []).length} 只` : "等待执行",
);
setText("strategyCount", `${setup.strategies.length} 套`);
updateBacktestTaskStatus();
selectScreenerMobileView(state.screenerMobileView);
const selector = document.querySelector("#regimeSelector");
selector.innerHTML = setup.regimes.map((item) => `
<button type="button" class="regime-option ${item.id === state.selectedRegime ? "active" : ""}" data-regime="${item.id}">${escapeHtml(item.label)}</button>
`).join("");
selector.querySelectorAll("[data-regime]").forEach((button) => {
button.addEventListener("click", () => selectRegime(button.dataset.regime));
});
renderStrategyList();
populateStrategyEditor(state.selectedStrategy);
}
function selectScreenerMobileView(view) {
state.screenerMobileView = view === "results" ? "results" : "strategy";
const workspace = document.querySelector("#screenerView");
workspace.classList.toggle("mobile-strategy", state.screenerMobileView === "strategy");
workspace.classList.toggle("mobile-results", state.screenerMobileView === "results");
document.querySelectorAll("[data-screener-mobile-view]").forEach((button) => {
const active = button.dataset.screenerMobileView === state.screenerMobileView;
button.classList.toggle("active", active);
button.setAttribute("aria-selected", String(active));
});
}
function updateBacktestTaskStatus() {
const enabled = document.querySelector("#runBacktestToggle")?.checked;
const backtest = state.screenerResult?.backtest;
setText("backtestTaskStatus", backtest ? `已完成 · ${number(backtest.samples)} 样本` : enabled ? "随选股执行" : "本次不执行");
}
function selectRegime(regime) {
state.selectedRegime = regime;
const recommended = state.screenerSetup.strategies.find((item) => item.regimes.includes(regime));
if (recommended) state.selectedStrategy = recommended;
renderScreenerSetup();
}
function renderStrategyList() {
const list = document.querySelector("#strategyList");
list.innerHTML = state.screenerSetup.strategies.map((strategy) => `
<button type="button" class="strategy-item ${strategy.id === state.selectedStrategy?.id ? "active" : ""}" data-strategy-id="${strategy.id}">
<strong>${escapeHtml(strategy.name)}</strong><span>${escapeHtml(strategy.description || "--")}</span>
<small>${strategy.regimes.map((item) => regimeLabel(item)).join(" / ")}${strategy.builtin ? " · 内置" : ""}</small>
</button>
`).join("");
list.querySelectorAll("[data-strategy-id]").forEach((button) => {
button.addEventListener("click", () => {
state.selectedStrategy = state.screenerSetup.strategies.find((item) => item.id === number(button.dataset.strategyId));
state.selectedRegime = state.selectedStrategy.regimes[0] || state.selectedRegime;
renderScreenerSetup();
});
});
}
function populateStrategyEditor(strategy) {
const deleteButton = document.querySelector("#deleteStrategyButton");
deleteButton.hidden = !strategy?.id || Boolean(strategy.builtin);
if (!strategy) {
setText("activeStrategyHeading", "--");
return;
}
setText("activeStrategyHeading", strategy.name || "未命名策略");
document.querySelector("#strategyNameInput").value = strategy.name || "";
document.querySelector("#strategyDescriptionInput").value = strategy.description || "";
document.querySelector("#strategyPrompt").value = strategy.builtin ? strategy.description || "" : document.querySelector("#strategyPrompt").value;
document.querySelector("#formulaEditor").value = JSON.stringify(strategy.formula, null, 2);
}
async function syncFactorData() {
const button = document.querySelector("#factorSyncButton");
button.disabled = true;
setText("factorTaskStatus", "同步中");
setLoading(true, "正在同步 45 个交易日因子数据");
setStatus("正在同步选股因子");
try {
const payload = await apiRequest("/api/screener/sync", "POST", {
trade_date: elements.tradeDate.value,
lookback: 45,
});
const result = payload.result;
showToast(`因子同步完成:${result.calendar_dates} 个交易日,新增 ${result.bars} 条行情`);
await loadScreenerSetup();
setText("factorTaskStatus", `已就绪 · ${number(result.calendar_dates)} 日`);
setStatus("选股因子已同步");
} catch (error) {
showToast(error.message);
setStatus("选股因子同步失败");
setText("factorTaskStatus", "同步失败");
} finally {
setLoading(false);
button.disabled = false;
}
}
async function compileStrategy() {
const prompt = document.querySelector("#strategyPrompt").value.trim();
const button = document.querySelector("#compileStrategyButton");
button.disabled = true;
setText("compilerStatus", "正在编译");
setStatus("正在编译选股策略");
try {
const payload = await apiRequest("/api/screener/compile", "POST", {
prompt,
regime: state.selectedRegime,
});
const strategy = payload.strategy;
state.selectedStrategy = { ...strategy, id: null, builtin: false };
document.querySelector("#deleteStrategyButton").hidden = true;
setText("activeStrategyHeading", strategy.name || "未命名策略");
document.querySelector("#strategyNameInput").value = strategy.name;
document.querySelector("#strategyDescriptionInput").value = strategy.description;
document.querySelector("#formulaEditor").value = JSON.stringify(strategy.formula, null, 2);
setText(
"compilerStatus",
strategy.compiler === "local" ? "本地编译完成" : "模型编译完成",
);
if (strategy.notice) showToast(strategy.notice);
setStatus("选股策略已编译");
} catch (error) {
showToast(error.message);
setStatus("策略编译失败");
setText("compilerStatus", "编译失败");
} finally {
button.disabled = false;
}
}
async function saveCurrentStrategy() {
try {
const formula = parseFormulaEditor();
const payload = await apiRequest("/api/screener/strategies", "POST", {
name: document.querySelector("#strategyNameInput").value,
description: document.querySelector("#strategyDescriptionInput").value,
regimes: [state.selectedRegime],
formula,
});
state.screenerSetup.strategies = payload.strategies;
state.selectedStrategy = payload.strategies.find((item) => item.id === payload.id);
renderScreenerSetup();
showToast("自定义策略已保存");
} catch (error) {
showToast(error.message);
}
}
async function deleteCurrentStrategy() {
const strategy = state.selectedStrategy;
if (!strategy?.id || strategy.builtin) {
showToast("只能删除已保存的自定义策略");
return;
}
if (!window.confirm(`确定删除策略“${strategy.name}”吗?此操作不可撤销。`)) return;
const button = document.querySelector("#deleteStrategyButton");
button.disabled = true;
try {
const payload = await apiRequest(`/api/screener/strategies/${strategy.id}`, "DELETE");
state.screenerSetup.strategies = payload.strategies;
state.selectedStrategy = payload.strategies.find((item) => item.regimes.includes(state.selectedRegime))
|| payload.strategies[0]
|| null;
renderScreenerSetup();
showToast("自定义策略已删除");
} catch (error) {
showToast(error.message || "策略删除失败");
} finally {
button.disabled = false;
}
}
async function runScreener() {
if (!state.screenerSetup?.factor_data?.ready) {
showToast("请先同步至少 21 个交易日的因子数据");
return;
}
const button = document.querySelector("#screenerRunButton");
button.disabled = true;
setText("screenerRunStatus", "正在计算");
setText("backtestTaskStatus", document.querySelector("#runBacktestToggle").checked ? "正在回测" : "本次不执行");
setLoading(true, "正在计算因子排名与滚动回测", "screener");
setStatus("正在执行智能选股");
try {
const formula = parseFormulaEditor();
const payload = await apiRequest("/api/screener/run", "POST", {
trade_date: elements.tradeDate.value,
regime: state.selectedRegime,
strategy_name: document.querySelector("#strategyNameInput").value,
formula,
run_backtest: document.querySelector("#runBacktestToggle").checked,
});
state.screenerResult = payload.result;
renderScreenerResult();
setText("screenerRunStatus", `完成 · ${payload.result.candidates.length} 只`);
updateBacktestTaskStatus();
if (window.innerWidth <= 720) selectScreenerMobileView("results");
setStatus(`智能选股完成 · ${payload.result.candidates.length} 只候选`);
} catch (error) {
showToast(error.message);
setStatus("智能选股失败");
setText("screenerRunStatus", "执行失败");
updateBacktestTaskStatus();
} finally {
setLoading(false);
button.disabled = false;
}
}
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;
state.mentorSetup = payload;
const selectedExists = payload.mentors.some((item) => item.id === state.selectedMentorId);
state.selectedMentorId = selectedExists ? state.selectedMentorId : payload.mentors[0]?.id || "";
state.mentorMessages = loadStoredMentorMessages();
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("mentorCount", `${setup.mentors.length} 位`);
setText("mentorDataDate", `数据日期 ${displayCompactDate(setup.trade_date)}`);
setText("activeMentorName", selected?.name || "--");
document.querySelector("#mentorList").innerHTML = setup.mentors.map((mentor) => `
<button type="button" class="mentor-option ${mentor.id === state.selectedMentorId ? "active" : ""}" data-mentor-id="${escapeHtml(mentor.id)}">
<strong>${escapeHtml(mentor.name)}</strong>
<span title="${escapeHtml(mentor.description)}">${escapeHtml(mentor.description || "--")}</span>
<small>${mentor.focus.slice(0, 3).map((item) => `<i>${escapeHtml(item)}</i>`).join("")}</small>
</button>
`).join("");
document.querySelectorAll("[data-mentor-id]").forEach((button) => {
button.addEventListener("click", () => selectMentor(button.dataset.mentorId));
});
renderMentorMessages();
}
function selectMentor(mentorId) {
if (mentorId === state.selectedMentorId) return;
saveStoredMentorMessages();
state.selectedMentorId = mentorId;
state.mentorMessages = loadStoredMentorMessages();
hideMentorNotice();
renderMentorWorkspace();
}
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">
<strong>${escapeHtml(selected?.name || "问师")}</strong>
<p>${escapeHtml(selected?.tagline || selected?.description || "选择一个问题开始对话")}</p>
</div>
`;
} else {
container.innerHTML = state.mentorMessages.map((message) => `
<article class="mentor-message ${message.role}">
<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.meta ? `<small>${escapeHtml(message.meta)}</small>` : ""}
</article>
`).join("");
if (state.mentorLoading) {
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;
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 });
input.value = "";
state.mentorLoading = true;
hideMentorNotice();
renderMentorMessages();
setStatus("问师正在读取复盘数据");
try {
const payload = await apiRequest("/api/mentors/chat", "POST", {
mentor_id: state.selectedMentorId,
trade_date: elements.tradeDate.value,
question,
history,
});
const modelRole = payload.compiler === "fallback" ? "辅助模型" : "主模型";
state.mentorMessages.push({
role: "assistant",
content: payload.answer,
meta: `${displayCompactDate(payload.data_trade_date)} · ${modelRole} ${payload.model} · ${number(payload.latency_ms)}ms`,
});
saveStoredMentorMessages();
if (payload.notice) showMentorNotice(payload.notice);
setStatus("问师回答完成");
} catch (error) {
showMentorNotice(error.message || "问师回答失败");
showToast(error.message || "问师回答失败");
setStatus("问师回答失败");
} finally {
state.mentorLoading = false;
renderMentorMessages();
input.focus();
}
}
function useMentorQuickPrompt(prompt) {
const input = document.querySelector("#mentorQuestion");
input.value = prompt || "";
input.focus();
}
function clearMentorConversation() {
if (!state.mentorMessages.length || !window.confirm("确定清空当前老师的对话记录吗?")) return;
state.mentorMessages = [];
try { localStorage.removeItem(mentorStorageKey()); } catch {}
hideMentorNotice();
renderMentorMessages();
}
function mentorStorageKey() {
const dateKey = (state.mentorSetup?.trade_date || elements.tradeDate.value).replaceAll("-", "");
return `xiaobai-mentor-chat:${state.selectedMentorId}:${dateKey}`;
}
function loadStoredMentorMessages() {
if (!state.selectedMentorId) return [];
try {
const messages = JSON.parse(localStorage.getItem(mentorStorageKey()) || "[]");
if (!Array.isArray(messages)) return [];
return messages.filter((item) => ["user", "assistant"].includes(item?.role) && typeof item.content === "string").slice(-20);
} catch {
return [];
}
}
function saveStoredMentorMessages() {
if (!state.selectedMentorId) return;
try { localStorage.setItem(mentorStorageKey(), JSON.stringify(state.mentorMessages.slice(-20))); } catch {}
}
function showMentorNotice(message) {
const notice = document.querySelector("#mentorNotice");
notice.textContent = message;
notice.hidden = false;
}
function hideMentorNotice() {
document.querySelector("#mentorNotice").hidden = true;
}
function formatMentorAnswer(content) {
return escapeHtml(content).split("\n").map((line) => {
const heading = line.match(/^#{1,3}\s+(.+)$/);
if (heading) return `<strong class="mentor-answer-heading">${formatMentorInline(heading[1])}</strong>`;
if (/^-{3,}$/.test(line.trim())) return '<span class="mentor-answer-rule"></span>';
if (line.startsWith("&gt; ")) return `<span class="mentor-answer-quote">${formatMentorInline(line.slice(5))}</span>`;
return formatMentorInline(line);
}).join("<br>");
}
function formatMentorInline(content) {
return content.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
}
async function loadHeavenSetup(force = false, sector = "", stockCode = "") {
const calendarDate = document.querySelector("#qiObservationDate")?.value || elements.tradeDate.value;
const requestedDate = calendarDate.replaceAll("-", "");
const manualData = state.heavenManualData;
const calibrationKey = manualData ? JSON.stringify(manualData) : "auto";
const requestedKey = `${requestedDate}:${sector}:${stockCode}:${calibrationKey}`;
if (!force && state.heavenSetup?.requestedKey === requestedKey) {
renderHeavenWorkspace();
return;
}
const requestSequence = ++state.heavenRequestSequence;
const heavenView = document.querySelector("#heavenView");
const loadButton = document.querySelector("#loadHeavenSelectionButton");
const calibrationButtons = [
document.querySelector("#applyHeavenCalibrationButton"),
document.querySelector("#resetHeavenCalibrationButton"),
].filter(Boolean);
cancelHeavenPerformance();
heavenView?.classList.add("heaven-data-loading");
if (loadButton) loadButton.disabled = true;
calibrationButtons.forEach((button) => { button.disabled = true; });
try {
if (state.heavenSetup?.requestedKey && state.heavenSetup.requestedKey !== requestedKey) {
state.personalField = null;
}
const query = new URLSearchParams({
trade_date: calendarDate,
});
if (sector) query.set("sector", sector);
if (stockCode) query.set("stock_code", stockCode);
if (manualData) query.set("manual_data", JSON.stringify(manualData));
const payload = await apiRequest(`/api/heaven/setup?${query}`);
if (
requestSequence !== state.heavenRequestSequence
|| calendarDate !== document.querySelector("#qiObservationDate")?.value
) return;
const previousFocus = state.heavenSetup
? `${state.heavenSetup.chart?.sector || ""}:${state.heavenSetup.chart?.stock?.code || ""}`
: "";
payload.requestedDate = requestedDate;
payload.requestedKey = requestedKey;
state.heavenSetup = payload;
state.heavenManualData = Object.keys(payload.chart?.manual_data || {}).length
? payload.chart.manual_data
: null;
state.personalField = payload.personal_profile || null;
state.heavenPerformanceKey = `${requestedKey}:${requestSequence}`;
state.heavenPerformancePanels = new Set();
state.heavenPerformanceActive = "";
const nextFocus = `${payload.chart?.sector || ""}:${payload.chart?.stock?.code || ""}`;
if (previousFocus && previousFocus !== nextFocus) state.heavenInterpretations.trend = "";
hideHeavenNotice();
renderHeavenWorkspace();
if (payload.chart.selection_notice) showHeavenNotice(payload.chart.selection_notice);
} catch (error) {
if (requestSequence !== state.heavenRequestSequence) return;
showHeavenNotice(error.message || "问天数据加载失败");
showToast(error.message || "问天数据加载失败");
} finally {
if (requestSequence === state.heavenRequestSequence) {
heavenView?.classList.remove("heaven-data-loading");
if (loadButton) loadButton.disabled = false;
calibrationButtons.forEach((button) => { button.disabled = false; });
}
}
}
function loadHeavenSelection() {
const stockCode = document.querySelector("#heavenStockInput").value.trim();
state.heavenManualData = null;
loadHeavenSetup(true, "", stockCode);
}
function applyHeavenCalibration(event) {
event.preventDefault();
const data = { ...(state.heavenManualData || {}) };
delete data.note;
document.querySelectorAll("[data-heaven-manual-field]").forEach((input) => {
const current = String(input.value || "").trim();
const original = String(input.dataset.originalValue || "").trim();
if (!current) return;
if (current !== original || input.dataset.manual === "true") {
data[input.dataset.heavenManualField] = input.type === "number" ? Number(current) : current;
}
});
const note = document.querySelector("#heavenCalibrationNote").value.trim();
if (note) data.note = note;
if (!Object.keys(data).some((key) => key !== "note")) {
showToast("请先补充或修改至少一项量化数据");
return;
}
state.heavenManualData = data;
loadHeavenSetup(true, "", document.querySelector("#heavenStockInput").value.trim());
}
function resetHeavenCalibration() {
state.heavenManualData = null;
document.querySelector("#heavenCalibrationNote").value = "";
loadHeavenSetup(true, "", document.querySelector("#heavenStockInput").value.trim());
}
function selectHeavenPanel(panel, updateUrl = false) {
state.heavenPanel = panel;
document.querySelectorAll("[data-heaven-panel]").forEach((button) => {
button.classList.toggle("active", button.dataset.heavenPanel === panel);
});
document.querySelectorAll(".heaven-panel").forEach((item) => {
item.classList.toggle("active-heaven-panel", item.id === `heaven${capitalize(panel)}Panel`);
});
if (
panel === "fortune"
&& state.heavenSetup?.field
&& state.heavenPerformancePanels.has("fortune")
) {
requestAnimationFrame(() => renderQiFieldCanvas(state.heavenSetup.field.balance || [], { intro: false }));
} else {
stopQiFieldCanvas();
}
if (panel === "heart") {
initializeHeartAtmosphere();
showHeartRitualCurtain();
startHeartDust();
setHeartLamp(state.heartStage);
} else {
stopHeartDust();
}
if (panel !== "heart") requestAnimationFrame(() => queueHeavenPerformance(panel));
if (updateUrl) {
const url = new URL(window.location.href);
url.searchParams.set("heaven", panel);
history.replaceState(null, "", url);
}
}
function showHeartRitualCurtain() {
const curtain = document.querySelector("#heartRitualCurtain");
if (!curtain || curtain.classList.contains("is-visible")) return;
if (state.heartCurtainTimer) clearTimeout(state.heartCurtainTimer);
document.querySelectorAll(".heart-stage.active-heart-stage .heart-rise").forEach((item) => item.classList.remove("is-visible"));
curtain.classList.remove("is-leaving");
curtain.classList.add("is-visible");
state.heartCurtainTimer = setTimeout(() => {
curtain.classList.add("is-leaving");
activateHeartRises(document.querySelector(".heart-stage.active-heart-stage"));
state.heartCurtainTimer = setTimeout(() => {
curtain.classList.remove("is-visible", "is-leaving");
state.heartCurtainTimer = null;
}, motionEnabled() ? 1450 : 10);
}, motionEnabled() ? 3000 : 20);
}
function renderHeavenWorkspace() {
const setup = state.heavenSetup;
if (!setup) return;
const calendarDate = setup.calendar_date || setup.trade_date;
setText(
"heavenDataDate",
calendarDate === setup.trade_date
? `数据日期 ${displayCompactDate(setup.trade_date)}`
: `行情 ${displayCompactDate(setup.trade_date)} · 历法 ${displayCompactDate(calendarDate)}`,
);
renderMarketHexagram(setup.chart);
renderFivePhaseField(setup.field);
renderPersonalFortune();
renderHeartStage();
selectHeavenPanel(state.heavenPanel);
}
function cancelHeavenPerformance() {
heavenPerformanceToken += 1;
state.heavenPerformanceActive = "";
document.querySelectorAll("#heavenTrendPanel, #heavenFortunePanel").forEach((panel) => {
panel.classList.remove("heaven-performance-pending", "heaven-performance-running");
panel.classList.add("heaven-performance-complete");
});
}
function queueHeavenPerformance(panel) {
if (!state.heavenSetup || !["trend", "fortune"].includes(panel)) return;
const performanceId = `${state.heavenPerformanceKey}:${panel}`;
if (
state.heavenPerformancePanels.has(panel)
|| state.heavenPerformanceActive === performanceId
|| state.heavenPanel !== panel
) return;
const token = ++heavenPerformanceToken;
state.heavenPerformanceActive = performanceId;
const runner = panel === "trend"
? playTrendPerformance(state.heavenSetup.chart, token)
: playFortunePerformance(state.heavenSetup.field, token);
runner.then((completed) => {
if (!completed || token !== heavenPerformanceToken) return;
state.heavenPerformancePanels.add(panel);
state.heavenPerformanceActive = "";
});
}
function heavenPerformanceDelay(duration, token) {
return new Promise((resolve) => {
setTimeout(() => resolve(token === heavenPerformanceToken), motionEnabled() ? duration : 0);
});
}
async function typeHeavenText(element, text, token, speed = 38) {
if (!element) return false;
if (!motionEnabled()) {
element.textContent = text;
return token === heavenPerformanceToken;
}
element.textContent = "";
element.classList.add("heaven-typing");
for (const character of text) {
if (token !== heavenPerformanceToken) return false;
element.append(document.createTextNode(character));
if (!await heavenPerformanceDelay(speed, token)) return false;
}
element.classList.remove("heaven-typing");
return true;
}
function countHeavenNumber(element, target, token, duration = 1300, suffix = "") {
return new Promise((resolve) => {
if (!element || !motionEnabled()) {
if (element) element.textContent = `${target > 0 ? "+" : ""}${target}${suffix}`;
resolve(token === heavenPerformanceToken);
return;
}
const startedAt = performance.now();
const step = (now) => {
if (token !== heavenPerformanceToken) {
resolve(false);
return;
}
const progress = Math.min(1, (now - startedAt) / duration);
const eased = 1 - (1 - progress) ** 3;
const value = Math.round(target * eased);
element.textContent = `${value > 0 ? "+" : ""}${value}${suffix}`;
if (progress < 1) requestAnimationFrame(step);
else resolve(true);
};
requestAnimationFrame(step);
});
}
async function playTrendPerformance(chart, token) {
const panel = document.querySelector("#heavenTrendPanel");
if (!panel || state.heavenPanel !== "trend") return false;
panel.classList.remove(
"heaven-performance-complete",
"performance-title-ready",
"performance-change-ready",
"performance-score-ready",
"performance-text-ready",
);
panel.classList.add("heaven-performance-pending", "heaven-performance-running");
panel.querySelectorAll(".talent-line-group, .hexagram-line-row, .talent-reading, .heaven-index-strip > *").forEach((item) => {
item.classList.remove("is-ready");
});
if (!chart?.available) {
panel.classList.remove("heaven-performance-pending", "heaven-performance-running");
panel.classList.add("heaven-performance-complete");
return true;
}
const guaci = chart.hexagram.text || "";
const scoreElement = document.querySelector("#heavenMomentumScore");
const guaciElement = document.querySelector("#marketHexagramText");
if (scoreElement) scoreElement.textContent = "0";
if (guaciElement) guaciElement.textContent = "";
if (!await heavenPerformanceDelay(220, token)) return false;
const groups = [...panel.querySelectorAll(".talent-line-group")].reverse();
const readings = [...panel.querySelectorAll(".talent-reading")];
for (let index = 0; index < groups.length; index += 1) {
const group = groups[index];
group.classList.add("is-ready");
if (!await heavenPerformanceDelay(280, token)) return false;
const rows = [...group.querySelectorAll(".hexagram-line-row")].reverse();
for (const row of rows) {
row.classList.add("is-ready");
if (!await heavenPerformanceDelay(560, token)) return false;
}
readings[index]?.classList.add("is-ready");
if (!await heavenPerformanceDelay(220, token)) return false;
}
panel.classList.add("performance-title-ready");
if (!await heavenPerformanceDelay(650, token)) return false;
panel.classList.add("performance-change-ready");
if (!await heavenPerformanceDelay(420, token)) return false;
panel.classList.add("performance-score-ready");
if (!await countHeavenNumber(scoreElement, number(chart.momentum_score), token)) return false;
panel.querySelectorAll(".heaven-index-strip > *").forEach((item, index) => {
setTimeout(() => {
if (token === heavenPerformanceToken) item.classList.add("is-ready");
}, motionEnabled() ? index * 90 : 0);
});
if (!await heavenPerformanceDelay(620, token)) return false;
if (!await typeHeavenText(guaciElement, guaci, token, 30)) return false;
panel.classList.add("performance-text-ready");
panel.classList.remove("heaven-performance-pending", "heaven-performance-running");
panel.classList.add("heaven-performance-complete");
return true;
}
async function playFortunePerformance(field, token) {
const panel = document.querySelector("#heavenFortunePanel");
if (!panel || !field || state.heavenPanel !== "fortune") return false;
panel.classList.remove("heaven-performance-complete", "performance-climate-ready", "performance-use-ready");
panel.classList.add("heaven-performance-pending", "heaven-performance-running");
panel.querySelectorAll(".phase-balance-row, .qi-framework-layer, .human-field-grid > div, .personal-fortune-panel").forEach((item) => {
item.classList.remove("is-ready");
});
const climateTone = document.querySelector("#qiClimateTone");
const climateText = climateTone?.textContent || "";
if (climateTone) climateTone.textContent = "";
renderQiFieldCanvas(field.balance || [], { intro: true });
if (!await heavenPerformanceDelay(900, token)) return false;
panel.classList.add("performance-climate-ready");
if (!await heavenPerformanceDelay(720, token)) return false;
if (!await typeHeavenText(climateTone, climateText, token, 58)) return false;
const balanceRows = [...panel.querySelectorAll(".phase-balance-row")];
for (const row of balanceRows) {
row.classList.add("is-ready");
const percent = number(row.dataset.phasePercent);
if (!await countHeavenNumber(row.querySelector(":scope > b"), percent, token, 520, "%")) return false;
if (!await heavenPerformanceDelay(90, token)) return false;
}
const layers = [...panel.querySelectorAll(".qi-framework-layer")];
for (const layer of layers) {
layer.classList.add("is-ready");
if (!await heavenPerformanceDelay(250, token)) return false;
}
panel.querySelectorAll(".human-field-grid > div").forEach((item, index) => {
setTimeout(() => {
if (token === heavenPerformanceToken) item.classList.add("is-ready");
}, motionEnabled() ? index * 150 : 0);
});
if (!await heavenPerformanceDelay(820, token)) return false;
panel.querySelector(".personal-fortune-panel")?.classList.add("is-ready");
panel.classList.add("performance-use-ready");
drawQiUseConnections(true);
panel.classList.remove("heaven-performance-pending", "heaven-performance-running");
panel.classList.add("heaven-performance-complete");
return true;
}
function heavenSourcePhrase(item = {}) {
const stateLabel = item.realtime ? "当下之象" : "既成之象";
const layerLabel = {
指数: "天象合参",
行业: "人势同观",
个股: "地脉验真",
用户补充: "人工验数",
}[item.layer] || "三才合参";
return `${layerLabel} · ${stateLabel}`;
}
function renderHeavenLineChecks(chart) {
const checks = [...(chart.data_checks || [])].sort((left, right) => number(right.line) - number(left.line));
const container = document.querySelector("#heavenLineChecks");
const status = document.querySelector("#heavenCalibrationStatus");
const passedCount = checks.filter((item) => item.passed).length;
const manualCount = checks.filter((item) => item.status === "manual").length;
status.textContent = checks.length ? `${passedCount}/6 通过${manualCount ? ` · ${manualCount} 爻含补录` : ""}` : "等待载入";
status.className = passedCount === 6 ? (manualCount ? "is-manual" : "is-passed") : "is-failed";
if (!checks.length) {
container.innerHTML = '<div class="empty-state">载入股票后查看六爻数据状态</div>';
return;
}
const lineValueLabel = { 6: "老阴 · 动", 7: "少阳 · 静", 8: "少阴 · 静", 9: "老阳 · 动" };
container.innerHTML = checks.map((check) => {
const stateLabel = check.status === "manual" ? "补录通过" : check.passed ? "自动通过" : "未通过";
const score = check.score === null || check.score === undefined ? "--" : signedScore(check.score);
const fields = (check.fields || []).map((field) => {
const rawValue = field.value === null || field.value === undefined ? "" : String(field.value);
const source = field.manual ? "用户补录" : rawValue ? "自动行情" : "等待补充";
const common = `data-heaven-manual-field="${escapeHtml(field.key)}" data-original-value="${escapeHtml(rawValue)}" data-manual="${field.manual ? "true" : "false"}"`;
const control = field.type === "select"
? `<select ${common}>${(field.options || []).map((option) => `<option value="${escapeHtml(option)}" ${option === rawValue ? "selected" : ""}>${escapeHtml(option)}</option>`).join("")}</select>`
: field.type === "text"
? `<input type="text" maxlength="50" value="${escapeHtml(rawValue)}" placeholder="待补充" ${common}>`
: `<input type="number" inputmode="decimal" value="${escapeHtml(rawValue)}" placeholder="待补充" min="${field.min ?? ""}" max="${field.max ?? ""}" step="${field.integer ? "1" : "any"}" ${common}>`;
return `<label class="heaven-manual-field ${field.manual ? "is-manual" : ""}">
<span>${escapeHtml(field.label)}<small>${escapeHtml(source)}</small></span>
<span class="heaven-field-control">${control}${field.unit ? `<b>${escapeHtml(field.unit)}</b>` : ""}</span>
</label>`;
}).join("");
const reasons = (check.reasons || []).map((reason) => `<li>${escapeHtml(reason)}</li>`).join("");
return `<details class="heaven-line-check is-${escapeHtml(check.status)}" ${check.passed ? "" : "open"}>
<summary>
<span class="heaven-check-state"><i class="status-dot ${escapeHtml(check.status)}"></i><b>${escapeHtml(stateLabel)}</b></span>
<span class="heaven-check-name"><strong>${escapeHtml(check.position)} · ${escapeHtml(check.layer)}</strong><small>${escapeHtml(check.formula)}</small></span>
<span class="heaven-check-result"><b>${check.line_value ? escapeHtml(lineValueLabel[check.line_value] || check.line_value) : "待定"}</b><small>得分 ${escapeHtml(score)}</small></span>
<i data-lucide="chevron-down" aria-hidden="true"></i>
</summary>
<div class="heaven-line-check-body">
${reasons ? `<ul class="heaven-check-reasons">${reasons}</ul>` : `<p class="heaven-check-evidence">${(check.evidence || []).map(escapeHtml).join("") || "数据已通过安全门"}</p>`}
<div class="heaven-manual-fields">${fields}</div>
</div>
</details>`;
}).join("");
document.querySelector("#heavenCalibrationNote").value = chart.manual_data?.note || "";
window.lucide?.createIcons();
}
function renderMarketHexagram(chart) {
const stockInput = document.querySelector("#heavenStockInput");
if (document.activeElement !== stockInput) stockInput.value = chart.stock.code || "";
const selectionRequired = Boolean(chart.selection_required);
const emptyState = document.querySelector("#heavenTrendEmpty");
const trendLayout = document.querySelector("#heavenTrendPanel .heaven-trend-layout");
const calibrationPanel = document.querySelector("#heavenCalibrationPanel");
const stockIdentity = document.querySelector("#heavenStockIdentity");
if (emptyState) emptyState.hidden = !selectionRequired;
if (trendLayout) trendLayout.hidden = selectionRequired;
if (calibrationPanel) calibrationPanel.hidden = selectionRequired;
if (stockIdentity) stockIdentity.hidden = selectionRequired;
if (selectionRequired) {
document.querySelector("#interpretTrendButton").disabled = true;
setText("heavenStockName", "--");
setText("heavenStockSector", "--");
renderHeavenInterpretation("trend", "");
return;
}
setText("heavenStockName", chart.stock.name || "--");
setText(
"heavenStockSector",
chart.sector_taxonomy === "sw_l2"
? `申万二级 · ${chart.sector}`
: chart.sector || "--",
);
renderHeavenLineChecks(chart);
const interpretButton = document.querySelector("#interpretTrendButton");
const scoreMeter = document.querySelector(".trend-score-meter");
const scoreNeedle = document.querySelector("#heavenMomentumNeedle");
const renderTrendEvidence = () => {
const rows = chart.quality?.sources || [];
document.querySelector("#heavenTrendEvidence").innerHTML = rows.length
? rows.map((item) => `
<div>
<strong>${escapeHtml(item.lines)} · ${escapeHtml(item.layer)}</strong>
<span>${escapeHtml(heavenSourcePhrase(item))}</span>
<small>${escapeHtml(item.detail || "")}</small>
</div>
`).join("")
: '<p>暂无可核验的数据来源。</p>';
};
renderTrendEvidence();
if (!chart.available) {
interpretButton.disabled = true;
setText("marketHexagramName", "暂不成卦");
setText("marketTransformedName", "--");
setText("marketHexagramText", chart.quality?.principle || "六爻数据尚未齐备。");
setText("marketMovementSummary", (chart.quality?.issues || []).join("") || "等待有效行情数据");
setText("heavenMomentumScore", "--");
setText("heavenMomentumLabel", "数据未齐");
scoreMeter?.setAttribute("aria-valuenow", "0");
if (scoreNeedle) scoreNeedle.style.setProperty("--momentum-position", "50%");
document.querySelector("#marketHexagramLines").innerHTML = "";
const sourceRows = chart.quality?.sources || [];
document.querySelector("#threeTalentReadings").innerHTML = [
...(chart.quality?.issues || []).map((issue) => `
<div class="talent-reading"><strong>未通过</strong><span>${escapeHtml(issue)}</span></div>
`),
...sourceRows.map((item) => `
<div class="talent-reading">
<strong>${escapeHtml(item.lines)} · ${escapeHtml(item.layer)}</strong>
<span>${escapeHtml(heavenSourcePhrase(item))}</span>
<small>${escapeHtml(item.detail || "")}</small>
</div>
`),
].join("");
document.querySelector("#heavenIndexStrip").innerHTML = "<p>天象尚未应时,待三才数据齐备后再观。</p>";
renderHeavenInterpretation("trend", "");
return;
}
interpretButton.disabled = false;
setText("marketHexagramName", `${chart.hexagram.outer_trigram}${chart.hexagram.inner_trigram}下 · ${chart.hexagram.name}`);
setText("marketTransformedName", chart.hexagram.transformed.name);
setText("marketHexagramText", chart.hexagram.text);
setText("marketMovementSummary", `${chart.movement.label}${chart.movement.explanation}`);
setText("heavenMomentumScore", `${chart.momentum_score > 0 ? "+" : ""}${chart.momentum_score}`);
setText("heavenMomentumLabel", chart.momentum_label);
const momentumPosition = clamp((number(chart.momentum_score) + 100) / 2, 0, 100);
scoreMeter?.setAttribute("aria-valuenow", String(number(chart.momentum_score)));
if (scoreNeedle) scoreNeedle.style.setProperty("--momentum-position", `${momentumPosition}%`);
renderMarketHexagramLines(chart.hexagram.lines);
document.querySelector("#threeTalentReadings").innerHTML = chart.pair_readings.map((item) => `
<div class="talent-reading">
<strong>${escapeHtml(item.level)}</strong><span>${escapeHtml(item.state)}</span>
<div class="talent-balance">
<small>内 ${signedScore(item.inner)}</small><i><b style="--talent-value:${clamp((number(item.inner) + 1) * 50, 0, 100)}%"></b></i>
<small>外 ${signedScore(item.outer)}</small><i><b style="--talent-value:${clamp((number(item.outer) + 1) * 50, 0, 100)}%"></b></i>
</div>
</div>
`).join("");
const indexContext = chart.index_context || {};
document.querySelector("#heavenIndexStrip").innerHTML = (indexContext.indices || []).length
? indexContext.indices.map((item) => `
<div><span>${escapeHtml(item.name)}</span><strong class="${changeClass(item.pct_chg)}">${signed(item.pct_chg)}%</strong><small>5日 ${signed(item.return_5d)}%</small></div>
`).join("")
: `<p>${escapeHtml(indexContext.notice || "指数数据暂不可用")}</p>`;
renderHeavenInterpretation("trend", state.heavenInterpretations.trend);
}
function renderMarketHexagramLines(lines) {
const groups = [
{ talent: "天", caption: "指数 · 外显为上,内核为下", lines: [lines[5], lines[4]] },
{ talent: "人", caption: "行业 · 外显为上,内核为下", lines: [lines[3], lines[2]] },
{ talent: "地", caption: "个股 · 外显为上,内核为下", lines: [lines[1], lines[0]] },
];
document.querySelector("#marketHexagramLines").innerHTML = groups.map((group, groupIndex) => `
<section class="talent-line-group" style="--group-delay:${groupIndex * 70}ms">
<div class="talent-seal" aria-hidden="true">${group.talent}</div>
<div class="talent-line-content">
<p>${group.caption}</p>
${group.lines.map((line) => `
<div class="hexagram-line-row ${line.moving ? "moving" : ""}">
<span class="hexagram-position">${escapeHtml(line.position_name)}</span>
${hexagramLineGraphic(line.value)}
<div class="hexagram-line-detail">
<strong>${escapeHtml(line.role || line.line_name)} · ${line.value}${line.moving ? " 变" : ""}</strong>
<small>${(line.evidence || []).map(escapeHtml).join("")}</small>
</div>
</div>
`).join("")}
</div>
</section>
`).join("");
}
function stopQiFieldCanvas() {
if (qiFieldAnimationFrame) cancelAnimationFrame(qiFieldAnimationFrame);
qiFieldAnimationFrame = 0;
}
function renderQiFieldCanvas(balance, options = {}) {
stopQiFieldCanvas();
const canvas = document.querySelector("#qiFieldCanvas");
const shell = canvas?.parentElement;
if (!canvas || !shell || !shell.clientWidth || !shell.clientHeight) return;
const context = canvas.getContext("2d");
const ratio = Math.min(2, window.devicePixelRatio || 1);
const width = shell.clientWidth;
const height = shell.clientHeight;
canvas.width = Math.round(width * ratio);
canvas.height = Math.round(height * ratio);
context.setTransform(ratio, 0, 0, ratio, 0, 0);
const phaseColors = { : "#4a7c59", : "#b53a30", : "#b08a3e", : "#9c7c3c", : "#31505f" };
const positions = {
: [0.50, 0.23],
: [0.50, 0.77],
: [0.28, 0.50],
: [0.72, 0.50],
: [0.50, 0.50],
};
const introStartedAt = options.intro && motionEnabled() ? performance.now() : 0;
const items = balance.map((item, index) => ({
...item,
color: phaseColors[item.element] || "#6d685b",
x: positions[item.element]?.[0] || 0.5,
y: positions[item.element]?.[1] || 0.5,
phase: index * 1.7,
alpha: introStartedAt ? 0 : 1,
}));
const draw = (now = 0) => {
context.clearRect(0, 0, width, height);
context.globalCompositeOperation = "multiply";
items.forEach((item, index) => {
const strength = Math.max(0.14, number(item.percent) / 100);
const introProgress = introStartedAt ? clamp((now - introStartedAt) / 2600, 0, 1) : 1;
const introEase = 1 - (1 - introProgress) ** 3;
const breath = motionEnabled() ? Math.sin(now * 0.00055 + item.phase) : 0;
const radius = Math.min(width, height) * (0.13 + Math.sqrt(strength) * 0.12) * (1 + breath * 0.06);
const targetAlpha = qiFieldSoloElement ? (qiFieldSoloElement === item.element ? 1 : 0.1) : 1;
item.alpha += (targetAlpha - item.alpha) * 0.06;
const targetX = width * item.x + (motionEnabled() ? Math.sin(now * (0.00012 + index * 0.000015) + item.phase) * 10 : 0);
const targetY = height * item.y + (motionEnabled() ? Math.cos(now * (0.0001 + index * 0.000013) + item.phase) * 8 : 0);
const centerX = width * 0.5;
const centerY = height * 0.47;
const x = centerX + (targetX - centerX) * introEase;
const y = centerY + (targetY - centerY) * introEase;
const gradient = context.createRadialGradient(x, y, 0, x, y, radius);
const rgb = item.color.match(/[a-f\d]{2}/gi).map((part) => parseInt(part, 16));
const alpha = item.alpha * introEase;
gradient.addColorStop(0, `rgba(${rgb[0]},${rgb[1]},${rgb[2]},${(0.28 + strength * 0.22) * alpha})`);
gradient.addColorStop(0.5, `rgba(${rgb[0]},${rgb[1]},${rgb[2]},${(0.13 + strength * 0.12) * alpha})`);
gradient.addColorStop(1, `rgba(${rgb[0]},${rgb[1]},${rgb[2]},0)`);
context.fillStyle = gradient;
context.fillRect(x - radius, y - radius, radius * 2, radius * 2);
});
context.globalCompositeOperation = "source-over";
if (motionEnabled() && state.activeView === "heavenView" && state.heavenPanel === "fortune") {
qiFieldAnimationFrame = requestAnimationFrame(draw);
}
};
draw(performance.now());
}
function renderFivePhaseField(field) {
setText("fortuneLunarDate", `${field.date} · ${field.lunar_date}`);
setText("fortunePillars", `${field.pillars.year}年 · ${field.pillars.month}月 · ${field.pillars.day}日`);
const metrics = [
["中运", field.movement.label, field.movement.basis],
["司天", field.six_qi.sitian, "岁半以前主气候背景"],
["在泉", field.six_qi.zaiquan, "岁半以后主气候背景"],
[field.six_qi.step_name, `主 ${field.six_qi.host_qi}`, `客 ${field.six_qi.guest_qi}`],
["当前节气", field.solar_terms.current, field.solar_terms.current_at],
["下一节气", field.solar_terms.next, field.solar_terms.next_at],
];
document.querySelector("#fortuneMetrics").innerHTML = metrics.map(([label, value, detail]) => `
<div class="fortune-metric"><span>${escapeHtml(label)}</span><strong>${escapeHtml(value)}</strong><small>${escapeHtml(detail)}</small></div>
`).join("");
const framework = field.framework || {};
setText("qiFrameworkPrinciple", framework.principle || "--");
const layerLabels = { year: "年运与岁气", current: "客主加临", day: "日辰触发" };
document.querySelector("#qiFrameworkLayers").innerHTML = (framework.layers || []).map((layer) => `
<div class="qi-framework-layer" data-qi-layer="${escapeHtml(layer.id)}">
<span>${escapeHtml(layerLabels[layer.id] || layer.label)}</span>
<strong>${escapeHtml(layer.dominant)}气</strong>
<small>${escapeHtml(layer.summary)}</small>
<div>${(layer.balance || []).map((item) => `<i class="phase-${phaseClass(item.element)}" style="--qi-segment:${number(item.percent)}%" title="${escapeHtml(item.element)}"></i>`).join("")}</div>
</div>
`).join("");
const human = field.human_field || {};
const dominantPhase = (field.balance || [])[0];
setText("qiClimateKeyword", dominantPhase ? `${dominantPhase.element}气偏显` : "气场待察");
setText("qiClimateTone", (human.emotional_tendency || [])[0] || "留意当下身心反应");
setText("humanFieldSummary", human.summary || "--");
setText("humanEmotionList", (human.emotional_tendency || []).join("") || "--");
setText("humanBiasList", (human.decision_biases || []).join("") || "--");
setText("humanOperation", human.operation_tendency || "--");
setText(
"humanBalanceActions",
[...(human.risk_reminders || []), ...(human.balancing_actions || [])].join("") || "--",
);
document.querySelector("#fivePhaseBalance").innerHTML = field.balance.map((item) => `
<div class="phase-balance-row" data-phase-element="${escapeHtml(item.element)}" data-phase-percent="${number(item.percent)}" tabindex="0">
<strong class="phase-symbol phase-${phaseClass(item.element)}">${escapeHtml(item.element)}</strong>
<div><div class="phase-track"><span class="phase-${phaseClass(item.element)}" style="--phase-width:${number(item.percent)}%"></span></div><small>${escapeHtml(item.motion)} · ${escapeHtml(item.mind)}</small></div>
<b>${number(item.percent)}%</b>
</div>
`).join("");
document.querySelectorAll("#fivePhaseBalance .phase-balance-row").forEach((row) => {
const focusPhase = () => { qiFieldSoloElement = row.dataset.phaseElement || ""; };
const clearPhase = () => { qiFieldSoloElement = ""; };
row.addEventListener("mouseenter", focusPhase);
row.addEventListener("mouseleave", clearPhase);
row.addEventListener("focus", focusPhase);
row.addEventListener("blur", clearPhase);
row.addEventListener("click", () => {
qiFieldSoloElement = qiFieldSoloElement === row.dataset.phaseElement ? "" : row.dataset.phaseElement;
});
});
setText("phaseSectorTitle", "五行行业归属");
setText("phaseSectorContext", "传统取象 · 手动归类优先");
renderQiUseMap(field);
renderSectorPhaseOverrides(state.heavenSetup?.sector_phase_overrides || []);
setText("fortuneNotice", field.notice);
renderHeavenInterpretation("fortune", state.heavenInterpretations.fortune);
}
function renderQiUseMap(field) {
const sourceContainer = document.querySelector("#qiUseSources");
const sectorContainer = document.querySelector("#phaseSectorList");
if (!sourceContainer || !sectorContainer) return;
const balance = field.balance || [];
const phaseOrder = new Map(balance.map((item, index) => [item.element, index]));
const catalog = [...(field.sector_catalog || [])].sort(
(left, right) => (phaseOrder.get(left.element) ?? 99) - (phaseOrder.get(right.element) ?? 99),
);
const catalogElements = new Set(catalog.map((item) => item.element));
sourceContainer.innerHTML = balance.map((item) => `
<div class="qi-use-source ${catalogElements.has(item.element) ? "has-catalog" : "no-catalog"}" data-qi-source="${escapeHtml(item.element)}">
<strong class="phase-text-${phaseClass(item.element)}">${escapeHtml(item.element)}</strong>
<span><b>${escapeHtml(item.motion)}</b><small>${number(item.percent)}%</small></span>
</div>
`).join("");
sectorContainer.innerHTML = catalog.length ? catalog.map((group) => {
const element = group.element;
const items = group.industries || [];
return `
<details class="qi-sector-group" data-qi-sector="${escapeHtml(element)}">
<summary>
<span class="qi-sector-group-title"><i class="phase-${phaseClass(element)}"></i><strong>${escapeHtml(element)}属性</strong></span>
<span class="qi-sector-group-count">${number(group.count)} 类</span>
<i class="qi-sector-chevron" data-lucide="chevron-down" aria-hidden="true"></i>
</summary>
<div class="qi-sector-fold">
<ul class="qi-sector-tags" aria-label="${escapeHtml(element)}属性行业">
${items.map((item) => `<li class="phase-border-${phaseClass(element)}">${escapeHtml(item.name)}${item.classification_source === "manual" ? '<small>手动</small>' : ""}</li>`).join("")}
</ul>
</div>
</details>
`;
}).join("") : '<p class="qi-use-empty">行业五行归类尚未建立。</p>';
sectorContainer.querySelectorAll(".qi-sector-group").forEach((group) => {
group.addEventListener("toggle", () => requestAnimationFrame(() => drawQiUseConnections(false)));
});
refreshIcons();
requestAnimationFrame(() => drawQiUseConnections(false));
}
function drawQiUseConnections(animate = false) {
const map = document.querySelector("#qiUseMap");
const svg = document.querySelector("#qiUseConnections");
if (!map || !svg || !map.clientWidth || !map.clientHeight) return;
const bounds = map.getBoundingClientRect();
svg.setAttribute("viewBox", `0 0 ${bounds.width} ${bounds.height}`);
svg.innerHTML = "";
document.querySelectorAll("#phaseSectorList [data-qi-sector]").forEach((group) => {
const element = group.dataset.qiSector;
const source = document.querySelector(`#qiUseSources [data-qi-source="${CSS.escape(element)}"]`);
const target = group.querySelector("summary");
if (!source || !target) return;
const from = source.getBoundingClientRect();
const to = target.getBoundingClientRect();
const x1 = from.right - bounds.left - 4;
const y1 = from.top + from.height / 2 - bounds.top;
const x2 = to.left - bounds.left + 2;
const y2 = to.top + to.height / 2 - bounds.top;
const bend = Math.max(46, (x2 - x1) * 0.42);
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
path.setAttribute("d", `M ${x1} ${y1} C ${x1 + bend} ${y1}, ${x2 - bend} ${y2}, ${x2} ${y2}`);
path.setAttribute("pathLength", "1");
path.classList.add(`phase-stroke-${phaseClass(element)}`);
if (animate && motionEnabled()) path.classList.add("is-drawing");
else path.classList.add("is-flowing");
svg.appendChild(path);
if (animate && motionEnabled()) {
requestAnimationFrame(() => path.classList.add("is-visible"));
setTimeout(() => {
if (!path.isConnected) return;
path.classList.remove("is-drawing", "is-visible");
path.classList.add("is-flowing");
}, 1900);
}
});
}
function renderSectorPhaseOverrides(items) {
const container = document.querySelector("#sectorPhaseOverrides");
container.innerHTML = items.length ? items.map((item) => `
<div class="sector-phase-override-row">
<span class="phase-symbol phase-${phaseClass(item.element)}">${escapeHtml(item.element)}</span>
<strong>${escapeHtml(item.name)}</strong>
<button class="icon-button" type="button" data-sector-phase-delete="${escapeHtml(item.name)}" title="删除手动归类" aria-label="删除 ${escapeHtml(item.name)} 的手动归类">×</button>
</div>
`).join("") : '<p class="sector-phase-empty">暂无手动归类</p>';
container.querySelectorAll("[data-sector-phase-delete]").forEach((button) => {
button.addEventListener("click", () => deleteSectorPhaseOverride(button.dataset.sectorPhaseDelete));
});
}
async function saveSectorPhaseOverride(event) {
event.preventDefault();
const name = document.querySelector("#sectorPhaseName").value.trim();
const element = document.querySelector("#sectorPhaseElement").value;
if (!name) return;
const button = event.currentTarget.querySelector("button[type='submit']");
button.disabled = true;
try {
await apiRequest("/api/heaven/sector-phases", "POST", { name, element });
document.querySelector("#sectorPhaseName").value = "";
await loadHeavenSetup(true);
showToast(`已将 ${name} 归为${element}`);
} catch (error) {
showToast(error.message || "手动归类保存失败");
} finally {
button.disabled = false;
}
}
async function deleteSectorPhaseOverride(name) {
try {
await apiRequest(`/api/heaven/sector-phases/${encodeURIComponent(name)}`, "DELETE");
await loadHeavenSetup(true);
showToast(`已删除 ${name} 的手动归类`);
} catch (error) {
showToast(error.message || "手动归类删除失败");
}
}
async function saveAccountBirthProfile(event) {
event.preventDefault();
const birthDate = document.querySelector("#accountBirthDate").value;
const birthTime = document.querySelector("#accountBirthTime").value;
if (!birthDate || !birthTime) {
showToast("请填写完整出生日期和时间");
return;
}
const button = event.currentTarget.querySelector("button[type='submit']");
button.disabled = true;
const originalText = button.textContent;
button.textContent = "正在排盘";
try {
await apiRequest("/api/account/birth-profile", "POST", {
trade_date: document.querySelector("#qiObservationDate").value || elements.tradeDate.value,
birth_datetime: `${birthDate}T${birthTime}`,
gender: document.querySelector("#accountBirthGender").value,
});
event.currentTarget.reset();
setText("birthProfileStatus", "已加密保存");
document.querySelector("#deleteBirthProfileButton").disabled = false;
state.heavenInterpretations.fortune = "";
await loadHeavenSetup(true);
showToast("个人命理资料已保存到当前账号");
} catch (error) {
showToast(error.message || "个人命理资料保存失败");
} finally {
button.disabled = false;
button.textContent = originalText;
}
}
async function deleteAccountBirthProfile() {
if (!window.confirm("确定删除当前账号保存的个人命理资料吗?")) return;
try {
await apiRequest("/api/account/birth-profile", "DELETE");
state.personalField = null;
state.heavenInterpretations.fortune = "";
setText("birthProfileStatus", "尚未设置");
document.querySelector("#deleteBirthProfileButton").disabled = true;
renderPersonalFortune();
showToast("个人命理资料已删除");
} catch (error) {
showToast(error.message || "个人命理资料删除失败");
}
}
function renderPersonalFortune() {
const container = document.querySelector("#personalFortuneResult");
const empty = document.querySelector("#personalProfileEmpty");
const personal = state.personalField;
if (!personal) {
empty.hidden = false;
container.hidden = true;
container.innerHTML = "";
return;
}
empty.hidden = true;
container.hidden = false;
const tenGods = personal.ten_god_tendency || { favorable: [], caution: [] };
const elementTendency = personal.balance_tendency || { favorable: [], caution: [] };
const preferenceTags = (items, emptyText = "--") => items.length
? items.map((item) => `<em class="phase-text-${phaseClass(item)}">${escapeHtml(item)}</em>`).join("")
: emptyText;
container.innerHTML = `
<div class="personal-primary-grid">
<div class="personal-day-master">
<span>日主</span>
<strong class="personal-day-master-character phase-text-${phaseClass(personal.day_master.element)}">${escapeHtml(personal.day_master.stem)}</strong>
<b class="personal-day-master-element phase-text-${phaseClass(personal.day_master.element)}">${escapeHtml(personal.day_master.element)}</b>
<small>${escapeHtml(personal.day_master.strength)}</small>
</div>
<div class="personal-preferences">
<section class="personal-ten-gods">
<span>十神喜恶</span>
<div class="ten-god-favorable"><strong>偏宜</strong><p>${tenGods.favorable.map((item) => `<em>${escapeHtml(item)}</em>`).join("") || "--"}</p></div>
<div class="ten-god-caution"><strong>偏慎</strong><p>${tenGods.caution.map((item) => `<em>${escapeHtml(item)}</em>`).join("") || "--"}</p></div>
</section>
<section class="personal-element-tendency">
<span>五行喜忌</span>
<div class="element-favorable"><strong>偏喜</strong><p>${preferenceTags(elementTendency.favorable || [])}</p></div>
<div class="element-caution"><strong>偏忌</strong><p>${preferenceTags(elementTendency.caution || [])}</p></div>
</section>
</div>
</div>
<div class="personal-current-effect">
<span>当前作用 · 流年 ${escapeHtml(personal.current.ten_gods.year.stem)} · 流月 ${escapeHtml(personal.current.ten_gods.month.stem)} · 流日 ${escapeHtml(personal.current.ten_gods.day.stem)}</span>
<strong>${escapeHtml(personal.current.tone)}</strong>
<p>${escapeHtml(personal.current.operation_note)}</p>
<small>${escapeHtml(personal.balance_tendency.method)}</small>
</div>
<details class="personal-elements-details">
<summary>查看个人五行结构</summary>
<div class="personal-element-balance">
${personal.element_balance.map((item) => `<div><span class="phase-symbol phase-${phaseClass(item.element)}">${escapeHtml(item.element)}</span><i><b class="phase-${phaseClass(item.element)}" style="width:${number(item.percent)}%"></b></i><strong>${number(item.percent)}%</strong></div>`).join("")}
</div>
</details>
`;
}
function renderHexagramLines(containerId, lines, includeEvidence = false) {
const container = document.querySelector(`#${containerId}`);
container.innerHTML = [...lines].reverse().map((line) => `
<div class="hexagram-line-row ${line.moving ? "moving" : ""}">
<span class="hexagram-position">${escapeHtml(line.position_name)}</span>
${hexagramLineGraphic(line.value)}
<div class="hexagram-line-detail">
<strong>${escapeHtml(line.role || line.line_name)} · ${line.value}${line.moving ? " 变" : ""}</strong>
${includeEvidence ? `<small>${(line.evidence || []).map(escapeHtml).join("")}</small>` : `<small>${escapeHtml(line.text || "")}</small>`}
</div>
</div>
`).join("");
}
function hexagramLineGraphic(value) {
const yang = value % 2 === 1;
return `
<span class="hex-line ${yang ? "yang-line" : "yin-line"}">
<i></i>${yang ? "" : "<i></i>"}${[6, 9].includes(value) ? `<b>${value === 9 ? "○" : "×"}</b>` : ""}
</span>
`;
}
async function interpretHeaven(mode) {
const button = document.querySelector(mode === "trend" ? "#interpretTrendButton" : mode === "fortune" ? "#interpretFortuneButton" : "#interpretHeartButton");
if (button.disabled) return;
button.disabled = true;
const originalText = button.textContent;
button.textContent = mode === "trend" ? "正在观势" : mode === "fortune" ? "正在察运" : "正在解卦";
hideHeavenNotice();
try {
const payload = {
mode,
trade_date: document.querySelector("#qiObservationDate").value || elements.tradeDate.value,
sector: state.heavenSetup?.chart?.sector || "",
stock_code: state.heavenSetup?.chart?.stock?.code || "",
};
if (mode === "trend" && state.heavenManualData) payload.manual_data = state.heavenManualData;
if (mode === "heart") payload.lines = state.heartLines;
const result = await apiRequest("/api/heaven/interpret", "POST", payload);
const modelRole = result.compiler === "fallback" ? "辅助模型" : "主模型";
state.heavenInterpretations[mode] = {
answer: result.answer,
meta: `${modelRole} ${result.model} · ${number(result.latency_ms)}ms`,
};
if (result.notice) showHeavenNotice(result.notice);
if (mode === "heart") {
if (await transitionHeartStage("interpretation")) await playHeartReadSequence();
} else {
renderHeavenInterpretation(mode, state.heavenInterpretations[mode]);
}
} catch (error) {
showHeavenNotice(error.message || "问天解读失败");
showToast(error.message || "问天解读失败");
} finally {
button.disabled = false;
button.textContent = originalText;
}
}
function renderHeavenInterpretation(mode, result) {
const container = document.querySelector(`#${mode}Interpretation`);
if (!result) {
container.hidden = true;
container.innerHTML = "";
return;
}
container.hidden = false;
container.innerHTML = `<div>${formatMentorAnswer(result.answer)}</div><small>${escapeHtml(result.meta)}</small>`;
}
function initializeHeartAtmosphere() {
const whisperContainer = document.querySelector("#heartWhispers");
if (whisperContainer && !whisperContainer.children.length) {
whisperContainer.innerHTML = HEART_WHISPERS.map(([text, x, y, index]) => `
<span style="--whisper-x:${x}%;--whisper-y:${y}%;--whisper-duration:${16 + index * 1.4}s;--whisper-delay:${index * -2.1}s">${escapeHtml(text)}</span>
`).join("");
}
activateHeartRises(document.querySelector(".heart-stage.active-heart-stage"));
}
function toggleHeartSound() {
heartSound.enabled = !heartSound.enabled;
const button = document.querySelector("#heartSoundToggle");
button.setAttribute("aria-pressed", String(heartSound.enabled));
button.setAttribute("aria-label", heartSound.enabled ? "关闭观心声音" : "开启观心声音");
button.innerHTML = `<i data-lucide="${heartSound.enabled ? "volume-2" : "volume-x"}"></i><span>${heartSound.enabled ? "有声" : "静音"}</span>`;
if (heartSound.enabled) {
heartSound.ensure();
heartSound.chime(520);
}
refreshIcons();
}
function setHeartLamp(stage) {
const lamp = document.querySelector("#heartLamp");
if (lamp) lamp.dataset.heartStage = stage;
}
function startHeartDust() {
stopHeartDust();
const canvas = document.querySelector("#heartDustCanvas");
const panel = document.querySelector("#heavenHeartPanel");
if (!canvas || !panel || !panel.clientWidth || !panel.clientHeight) return;
const context = canvas.getContext("2d");
const ratio = Math.min(2, window.devicePixelRatio || 1);
const width = panel.clientWidth;
const height = panel.clientHeight;
canvas.width = Math.round(width * ratio);
canvas.height = Math.round(height * ratio);
canvas.style.height = `${height}px`;
context.setTransform(ratio, 0, 0, ratio, 0, 0);
if (!heartDustParticles.length) {
heartDustParticles = Array.from({ length: 60 }, (_, index) => ({
x: Math.random(),
y: Math.random(),
radius: 0.6 + Math.random() * 1.5,
alpha: 0.03 + Math.random() * 0.09,
vx: (Math.random() - 0.5) * 0.00006,
vy: -(0.00002 + Math.random() * 0.00008),
phase: Math.random() * Math.PI * 2,
gold: index % 2 === 0,
}));
}
const draw = (now) => {
context.clearRect(0, 0, width, height);
heartDustParticles.forEach((particle) => {
if (motionEnabled()) {
particle.x += particle.vx;
particle.y += particle.vy;
particle.phase += 0.006;
}
if (particle.y < -0.02) {
particle.y = 1.02;
particle.x = Math.random();
}
if (particle.x < -0.02) particle.x = 1.02;
if (particle.x > 1.02) particle.x = -0.02;
const alpha = particle.alpha * (0.65 + 0.35 * Math.sin(particle.phase));
context.beginPath();
context.arc(particle.x * width, particle.y * height, particle.radius, 0, Math.PI * 2);
context.fillStyle = particle.gold ? `rgba(220,195,140,${alpha})` : `rgba(190,200,225,${alpha * 0.8})`;
context.fill();
});
if (motionEnabled() && state.activeView === "heavenView" && state.heavenPanel === "heart") {
heartDustAnimationFrame = requestAnimationFrame(draw);
} else {
heartDustAnimationFrame = 0;
}
};
heartDustAnimationFrame = requestAnimationFrame(draw);
}
function stopHeartDust() {
if (heartDustAnimationFrame) cancelAnimationFrame(heartDustAnimationFrame);
heartDustAnimationFrame = 0;
}
function activateHeartRises(stage) {
if (!stage) return;
stage.querySelectorAll(".heart-rise").forEach((item) => {
item.classList.remove("is-visible");
const delay = motionEnabled() ? number(item.dataset.heartDelay) : 0;
setTimeout(() => {
if (stage.classList.contains("active-heart-stage")) item.classList.add("is-visible");
}, delay);
});
}
async function transitionHeartStage(nextStage) {
const token = ++state.heartStageToken;
state.heartRevealToken += 1;
const current = document.querySelector(".heart-stage.active-heart-stage");
current?.classList.add("is-leaving");
if (current && !await waitForHeartMotion(1050, token)) return false;
state.heartStage = nextStage;
renderHeartStage();
return token === state.heartStageToken;
}
function waitForHeartMotion(duration, token = state.heartStageToken) {
return new Promise((resolve) => {
setTimeout(() => resolve(token === state.heartStageToken), motionEnabled() ? duration : 0);
});
}
async function startHeartBreathing() {
if (state.heartTimer) clearInterval(state.heartTimer);
state.heartSeconds = 30;
state.heartBreathingEndsAt = 0;
document.querySelector("#beginCastingButton")?.classList.remove("is-ready");
if (!await transitionHeartStage("breathing")) return;
state.heartBreathingEndsAt = Date.now() + 30_000;
const ember = document.querySelector("#heartIncenseEmber");
ember?.classList.remove("is-burning");
if (ember) void ember.offsetWidth;
ember?.classList.add("is-burning");
updateBreathingDisplay();
state.heartTimer = setInterval(() => {
state.heartSeconds = Math.max(0, Math.ceil((state.heartBreathingEndsAt - Date.now()) / 1000));
updateBreathingDisplay();
if (state.heartSeconds <= 0) finishHeartBreathing();
}, 200);
}
function finishHeartBreathing() {
if (state.heartTimer) clearInterval(state.heartTimer);
state.heartTimer = null;
state.heartBreathingEndsAt = 0;
state.heartSeconds = 0;
updateBreathingDisplay();
const button = document.querySelector("#beginCastingButton");
button.disabled = false;
button.classList.add("is-ready");
heartSound.chime(520);
}
function updateBreathingDisplay() {
setText("breathingSeconds", Math.max(0, state.heartSeconds));
const remainingMs = state.heartBreathingEndsAt
? Math.max(0, state.heartBreathingEndsAt - Date.now())
: Math.max(0, state.heartSeconds * 1000);
const elapsedMs = 30_000 - remainingMs;
const inhaling = Math.floor(elapsedMs / 4000) % 2 === 0;
const phase = state.heartSeconds <= 0 ? "settled" : inhaling ? "inhale" : "exhale";
const scene = document.querySelector("#breathingScene");
scene.dataset.phase = phase;
setText("breathingPhase", phase === "settled" ? "已静" : inhaling ? "吸气" : "呼气");
document.querySelector("#breathingProgress").style.width = `${clamp(elapsedMs / 300, 0, 100)}%`;
const prompt = state.heartSeconds <= 0
? "静心已成,可以起卦"
: state.heartSeconds > 20
? inhaling ? "缓慢吸气,放下对答案的预设" : "缓慢呼气,让预设随之松开"
: state.heartSeconds > 10
? inhaling ? "吸气,只留下真正想问的事" : "呼气,不急着寻找答案"
: inhaling ? "吸气,让心停在此刻" : "呼气,不追逐经过的念头";
setText("breathingPrompt", prompt);
}
async function beginHeartCasting() {
if (state.heartSeconds > 0) return;
state.heartLines = [];
state.heartThrows = [];
state.heartHexagram = null;
state.heavenInterpretations.heart = "";
heartCastingBusy = false;
resetHeartCoins();
await transitionHeartStage("casting");
}
function initializeHeartCoinHold() {
const button = document.querySelector("#tossCoinsButton");
const coins = [...document.querySelectorAll(".heart-coin")];
const cancelHold = (cancelled = true) => {
if (heartHoldTimer) clearTimeout(heartHoldTimer);
heartHoldTimer = null;
cancelAnimationFrame(heartHoldAnimationFrame);
heartHoldAnimationFrame = 0;
button.classList.remove("is-holding");
button.style.setProperty("--hold-progress", "0turn");
coins.forEach((coin) => coin.classList.remove("is-shaking"));
if (cancelled) heartHoldStartedAt = 0;
};
button.addEventListener("pointerdown", (event) => {
if (button.disabled || heartCastingBusy || (event.button !== 0 && event.pointerType !== "touch")) return;
event.preventDefault();
heartSound.ensure();
heartHoldTriggered = false;
heartHoldStartedAt = performance.now();
button.setPointerCapture?.(event.pointerId);
button.classList.add("is-holding");
coins.forEach((coin) => coin.classList.add("is-shaking"));
const charge = () => {
if (!heartHoldStartedAt) return;
const progress = Math.min(1, (performance.now() - heartHoldStartedAt) / 1400);
button.style.setProperty("--hold-progress", `${progress}turn`);
if (progress < 1) heartHoldAnimationFrame = requestAnimationFrame(charge);
};
heartHoldAnimationFrame = requestAnimationFrame(charge);
});
button.addEventListener("pointerup", async () => {
if (!heartHoldStartedAt) return;
const heldFor = performance.now() - heartHoldStartedAt;
heartHoldStartedAt = 0;
cancelHold(false);
heartHoldTriggered = true;
if (heldFor < 550) await waitForMotion(550 - heldFor);
await tossHeartCoins();
});
button.addEventListener("pointercancel", () => cancelHold(true));
button.addEventListener("click", (event) => {
if (heartHoldTriggered) {
heartHoldTriggered = false;
event.preventDefault();
return;
}
if (event.detail === 0 && !heartCastingBusy) tossHeartCoins();
});
}
async function tossHeartCoins() {
if (heartCastingBusy) return;
if (state.heartLines.length >= 6) {
heartCastingBusy = true;
await finalizeHeartHexagram();
return;
}
const stageToken = state.heartStageToken;
const button = document.querySelector("#tossCoinsButton");
heartCastingBusy = true;
button.disabled = true;
const random = new Uint32Array(3);
crypto.getRandomValues(random);
const coins = [...random].map((value) => value % 2 === 1);
await animateHeartCoins(coins);
if (stageToken !== state.heartStageToken || state.heartStage !== "casting") {
heartCastingBusy = false;
return;
}
const heads = coins.filter(Boolean).length;
const lineValue = 6 + heads;
state.heartLines.push(lineValue);
state.heartThrows.push(coins.map((head) => head ? "正" : "背"));
renderHeartCasting();
if (state.heartLines.length === 6) {
await finalizeHeartHexagram();
} else {
await waitForMotion(720);
heartCastingBusy = false;
button.disabled = false;
}
}
async function animateHeartCoins(results) {
const coinElements = [...document.querySelectorAll(".heart-coin")];
setText("castingPrompt", "铜钱离手");
const animations = coinElements.map((coin, index) => {
coin.getAnimations().forEach((animation) => animation.cancel());
const inner = coin.querySelector(".heart-coin-inner");
inner.getAnimations().forEach((animation) => animation.cancel());
const current = heartCoinRotations[index];
const faceRotation = results[index] ? 0 : 180;
const delta = ((faceRotation - (current % 360)) + 360) % 360;
const target = current + 1440 + index * 360 + delta;
heartCoinRotations[index] = target;
const duration = motionEnabled() ? 1500 + index * 160 : 10;
const delay = motionEnabled() ? index * 150 : 0;
coin.dataset.face = results[index] ? "front" : "back";
coin.querySelector(".front").textContent = "字";
const spin = inner.animate(
[{ transform: `rotateY(${current}deg)` }, { transform: `rotateY(${target}deg)` }],
{ duration, delay, easing: "cubic-bezier(.25,.55,.3,1)", fill: "forwards" },
);
const tilt = Math.random() * 10 - 5;
const flight = coin.animate([
{ transform: "translateY(0) rotateZ(0deg)" },
{ transform: `translateY(-30vh) rotateZ(${tilt}deg)`, offset: 0.42 },
{ transform: `translateY(0) rotateZ(${tilt}deg)`, offset: 0.78 },
{ transform: "translateY(-13px) rotateZ(0deg)", offset: 0.9 },
{ transform: "translateY(0) rotateZ(0deg)" },
], { duration, delay, easing: "cubic-bezier(.3,.6,.35,1)", fill: "forwards" });
setTimeout(() => {
const ring = coin.querySelector(".heart-coin-ring");
ring.classList.remove("is-bursting");
void ring.offsetWidth;
ring.classList.add("is-bursting");
heartSound.coin();
}, delay + duration * 0.79);
return Promise.allSettled([spin.finished, flight.finished]);
});
await Promise.all(animations);
setText("castingPrompt", "听其落定");
await waitForMotion(420);
}
async function finalizeHeartHexagram() {
const stageToken = state.heartStageToken;
const button = document.querySelector("#tossCoinsButton");
button.disabled = true;
button.textContent = "正在成卦";
try {
const payload = await apiRequest("/api/heaven/hexagram", "POST", { lines: state.heartLines });
if (stageToken !== state.heartStageToken || state.heartStage !== "casting") return;
state.heartHexagram = payload.hexagram;
document.querySelector(".heart-hexagram-shell")?.classList.add("is-complete");
setText("castingPrompt", "卦成了");
heartSound.chime(660);
await waitForMotion(2200);
if (!await transitionHeartStage("reveal")) return;
await playHeartRevealSequence();
} catch (error) {
showHeavenNotice(error.message || "成卦失败");
button.disabled = false;
button.innerHTML = '<i class="heart-hold-charge" aria-hidden="true"></i><span>按住<br>重新成卦</span>';
heartCastingBusy = false;
}
}
function renderHeartStage() {
document.querySelectorAll(".heart-stage").forEach((stage) => stage.classList.remove("active-heart-stage"));
const stageMap = {
intro: "heartIntro",
breathing: "heartBreathing",
casting: "heartCasting",
reveal: "heartReveal",
interpretation: "heartInterpretationStage",
};
document.querySelectorAll(".heart-stage").forEach((stage) => stage.classList.remove("is-leaving"));
const activeStage = document.querySelector(`#${stageMap[state.heartStage]}`);
activeStage.classList.add("active-heart-stage");
setHeartLamp(state.heartStage);
activateHeartRises(activeStage);
if (state.heartStage === "breathing") {
document.querySelector("#beginCastingButton").disabled = state.heartSeconds > 0;
updateBreathingDisplay();
}
if (state.heartStage === "casting") renderHeartCasting();
if (state.heartStage === "reveal" && state.heartHexagram) renderHeartReveal();
if (state.heartStage === "interpretation" && state.heartHexagram) renderHeartRead();
}
function renderHeartCasting() {
setText("castingProgress", `${state.heartLines.length} / 6`);
const latestThrow = state.heartThrows[state.heartThrows.length - 1] || ["静", "静", "静"];
document.querySelectorAll(".heart-coin").forEach((coin, index) => {
coin.setAttribute("aria-label", latestThrow[index] === "静" ? `第 ${index + 1} 枚铜钱待掷` : `第 ${index + 1} 枚铜钱${latestThrow[index]}`);
});
const nextPosition = LINE_POSITIONS_CLIENT[state.heartLines.length] || "成卦";
setText(
"castingPrompt",
state.heartLines.length < 6
? `心中默念所问之事,然后掷出${nextPosition}`
: "六爻已具,正在成卦",
);
const button = document.querySelector("#tossCoinsButton");
button.innerHTML = state.heartLines.length < 6
? `<i aria-hidden="true"></i><span>按住<br>摇${nextPosition}</span>`
: '<i aria-hidden="true"></i><span>正在<br>成卦</span>';
button.disabled = heartCastingBusy || state.heartLines.length >= 6;
const rows = [];
for (let index = 5; index >= 0; index -= 1) {
const value = state.heartLines[index];
rows.push(`
<div class="hexagram-line-row ${[6, 9].includes(value) ? "moving" : ""} ${value ? "" : "empty-line"} ${value && index === state.heartLines.length - 1 ? "new-line" : ""}">
<span class="hexagram-position">${LINE_POSITIONS_CLIENT[index]}</span>
${value ? hexagramLineGraphic(value) : '<span class="hex-line placeholder-line heart-yao-ghost"><i></i><i></i></span>'}
<div class="hexagram-line-detail"><strong>${value ? `${lineValueName(value)} · ${value}` : "未得"}</strong></div>
</div>
`);
}
document.querySelector("#heartCastingLines").innerHTML = rows.join("");
}
function renderHeartReveal() {
const hexagram = state.heartHexagram;
setText("heartHexagramName", `${hexagram.outer_trigram}${hexagram.inner_trigram}下 · ${hexagram.name}`);
setText("heartTransformedName", hexagram.transformed.name);
setText("heartHexagramText", hexagram.text);
renderHexagramLines("heartHexagramLines", hexagram.lines, false);
document.querySelector("#heartHexagramLines").querySelectorAll(".hexagram-line-row").forEach((row) => row.classList.add("heart-reveal-line"));
document.querySelector("#heartLineTexts").innerHTML = hexagram.lines.map((line) => `
<button class="heart-line-text ${line.moving ? "moving" : ""}" type="button" aria-expanded="false">
<strong>${escapeHtml(line.line_name)}${line.moving ? " · 动" : ""}</strong>
<p>${escapeHtml(line.text)}</p>
</button>
`).join("");
document.querySelector("#heartReveal").classList.remove("is-sequence-ready", "is-title-ready", "is-thought-typing", "is-thought-ready");
const prompt = document.querySelector("#heartFirstThoughtPrompt");
prompt.dataset.fullText = "看见卦象与爻辞后,心里升起的第一念是什么?";
prompt.textContent = "";
const button = document.querySelector("#interpretHeartButton");
button.disabled = true;
button.classList.remove("is-ready");
}
async function playHeartRevealSequence() {
const token = ++state.heartRevealToken;
const stageToken = state.heartStageToken;
const stage = document.querySelector("#heartReveal");
const lines = [...stage.querySelectorAll(".heart-reveal-line")].reverse();
lines.forEach((line) => line.classList.remove("is-revealed"));
if (!await waitForHeartMotion(280, stageToken)) return;
for (const line of lines) {
if (token !== state.heartRevealToken || state.heartStage !== "reveal") return;
line.classList.add("is-revealed");
if (!await waitForHeartMotion(520, stageToken)) return;
}
stage.classList.add("is-title-ready", "is-sequence-ready");
heartSound.chime(520);
if (!await waitForHeartMotion(1200, stageToken)) return;
const prompt = document.querySelector("#heartFirstThoughtPrompt");
stage.classList.add("is-thought-typing");
if (!await typeHeartText(prompt, prompt.dataset.fullText, token, 72)) return;
stage.classList.add("is-thought-ready");
if (!await waitForHeartMotion(2400, stageToken)) return;
const button = document.querySelector("#interpretHeartButton");
button.disabled = false;
button.classList.add("is-ready");
}
function initializeHeartLineInspection() {
const container = document.querySelector("#heartLineTexts");
container.addEventListener("click", (event) => {
const item = event.target.closest(".heart-line-text");
if (!item) return;
const inspected = item.classList.toggle("is-inspected");
item.setAttribute("aria-expanded", String(inspected));
});
}
async function typeHeartText(element, text, token, speed = 72) {
if (!element) return false;
if (!motionEnabled()) {
element.textContent = text;
return true;
}
element.textContent = "";
element.classList.add("heart-typing");
for (const character of text) {
if (token !== state.heartRevealToken || state.heartStage !== "reveal") return false;
element.append(document.createTextNode(character));
await new Promise((resolve) => setTimeout(resolve, speed));
}
element.classList.remove("heart-typing");
return true;
}
function renderHeartRead() {
const hexagram = state.heartHexagram;
setText("heartReadTitle", hexagram.name);
setText("heartReadChange", hexagram.transformed.name === hexagram.name ? "六爻安静,无之卦" : `之卦 · ${hexagram.transformed.name}`);
setText("heartReadGuaci", hexagram.text);
document.querySelector("#heartReadLines").innerHTML = [...hexagram.lines].reverse().map((line) => `
<div class="heart-read-line ${line.moving ? "moving" : ""}">
<span>${escapeHtml(line.position_name)}</span>${hexagramLineGraphic(line.value)}
</div>
`).join("");
document.querySelector("#heartReadTexts").innerHTML = hexagram.lines.map((line) => `
<article class="heart-read-text ${line.moving ? "moving" : ""}">
<strong>${escapeHtml(line.line_name)}${line.moving ? " · 动" : ""}</strong><p>${escapeHtml(line.text)}</p>
</article>
`).join("");
renderHeavenInterpretation("heart", state.heavenInterpretations.heart);
const stage = document.querySelector("#heartInterpretationStage");
stage.classList.remove("is-read-heading-ready", "is-read-complete");
}
async function playHeartReadSequence() {
const token = state.heartStageToken;
const stage = document.querySelector("#heartInterpretationStage");
if (!await waitForHeartMotion(420, token)) return;
stage.classList.add("is-read-heading-ready");
const lines = [...stage.querySelectorAll(".heart-read-line")].reverse();
const texts = [...stage.querySelectorAll(".heart-read-text")];
for (let index = 0; index < 6; index += 1) {
lines[index]?.classList.add("is-visible");
texts[index]?.classList.add("is-visible");
if (!await waitForHeartMotion(680, token)) return;
}
stage.classList.add("is-read-complete");
}
function resetHeartCoins() {
heartCoinRotations.fill(0);
document.querySelectorAll(".heart-coin").forEach((coin) => {
coin.getAnimations().forEach((animation) => animation.cancel());
const inner = coin.querySelector(".heart-coin-inner");
inner.getAnimations().forEach((animation) => animation.cancel());
inner.style.transform = "";
coin.style.transform = "";
coin.dataset.face = "";
coin.querySelector(".front").textContent = "观";
coin.querySelector(".heart-coin-ring").classList.remove("is-bursting");
});
const shell = document.querySelector(".heart-hexagram-shell");
shell?.classList.remove("is-complete");
}
async function resetHeartRitual() {
if (state.heartTimer) clearInterval(state.heartTimer);
state.heartTimer = null;
state.heartSeconds = 30;
state.heartBreathingEndsAt = 0;
state.heartLines = [];
state.heartThrows = [];
state.heartHexagram = null;
state.heavenInterpretations.heart = "";
state.heartRevealToken += 1;
heartCastingBusy = false;
resetHeartCoins();
hideHeavenNotice();
await transitionHeartStage("intro");
}
function showHeavenNotice(message) {
const notice = document.querySelector("#heavenNotice");
notice.textContent = message;
notice.hidden = false;
}
function hideHeavenNotice() {
document.querySelector("#heavenNotice").hidden = true;
}
function phaseClass(element) {
return { : "wood", : "fire", : "earth", : "metal", : "water" }[element] || "earth";
}
function signedScore(value) {
const parsed = number(value);
return `${parsed > 0 ? "+" : ""}${formatNumber(parsed, 2)}`;
}
function lineValueName(value) {
return { 6: "老阴", 7: "少阳", 8: "少阴", 9: "老阳" }[value] || "";
}
function capitalize(value) {
return value.charAt(0).toUpperCase() + value.slice(1);
}
const LINE_POSITIONS_CLIENT = ["初爻", "二爻", "三爻", "四爻", "五爻", "上爻"];
function renderScreenerResult() {
const result = state.screenerResult;
if (!result) return;
const candidates = result.candidates || [];
setText("screenerResultCount", `${candidates.length} 只`);
const meta = result.meta || {};
setText(
"screenerDisclaimer",
meta.realtime
? `rt_k 实时截面 · 历史因子截至 ${displayCompactDate(meta.history_cutoff)} · ${result.disclaimer}`
: `盘后数据 ${displayCompactDate(meta.trade_date)} · ${result.disclaimer}`,
);
document.querySelector("#screenerEmpty").hidden = candidates.length > 0;
const body = document.querySelector("#screenerTableBody");
body.innerHTML = candidates.map((row, index) => `
<tr data-code="${escapeHtml(row.code)}"><td class="row-number">${index + 1}</td><td class="stock-code">${escapeHtml(row.code)}</td>
<td class="stock-name">${escapeHtml(row.name)}</td><td>${escapeHtml(row.sector)}</td>
<td class="number streak-value">${formatNumber(row.score_display, 1)}</td>
<td class="number probability-value"><strong>${row.historical_probability === null ? "样本不足" : `${formatNumber(row.historical_probability, 1)}%`}</strong><small>${number(row.probability_samples)} 个样本</small></td>
<td class="number ${changeClass(row.pct_chg)}">${signed(row.pct_chg)}%</td>
<td class="number ${changeClass(row.return_5d)}">${signed(row.return_5d)}%</td>
<td class="number">${formatNumber(row.volume_ratio_5d, 2)}</td><td class="number">${formatNumber(row.sector_strength, 1)}</td>
<td class="reason-column" title="${escapeHtml(row.reason)}">${escapeHtml(row.reason)}</td>
<td class="risk-cell" title="${escapeHtml(row.risk_flags.join(""))}">${escapeHtml(row.risk_flags.join("") || "--")}</td>
<td><button class="table-action" type="button" data-screen-detail="${escapeHtml(row.code)}">详情</button></td></tr>
`).join("");
body.querySelectorAll("[data-screen-detail]").forEach((button) => {
button.addEventListener("click", () => {
const row = candidates.find((item) => item.code === button.dataset.screenDetail);
openStock(row.code, row);
});
});
bindStockRows(body);
renderBacktest(result.backtest);
setText("screenerRunStatus", `完成 · ${candidates.length} 只`);
updateBacktestTaskStatus();
}
function renderBacktest(backtest) {
const panel = document.querySelector("#backtestPanel");
panel.hidden = !backtest;
if (!backtest) return;
setText("backtestDefinition", backtest.definition);
document.querySelector("#backtestMetrics").innerHTML = [
["历史样本", `${number(backtest.samples)} 个`],
["条件胜率", `${formatNumber(backtest.win_rate, 1)}%`],
["平均3日收益", `${signed(backtest.average_3d_return)}%`],
["平均最大回撤", `${signed(backtest.average_drawdown)}%`],
].map(([label, value]) => `<div class="dragon-metric"><span>${label}</span><strong>${value}</strong></div>`).join("");
}
function parseFormulaEditor() {
try {
return JSON.parse(document.querySelector("#formulaEditor").value);
} catch {
throw new Error("受控公式不是有效的 JSON");
}
}
function exportScreenerResults() {
exportRows("智能选股", state.screenerResult?.candidates || [], [
["股票代码", "code"], ["股票名称", "name"], ["板块", "sector"], ["综合分", "score_display"],
["历史条件估计%", "historical_probability"], ["当日涨幅%", "pct_chg"], ["5日涨幅%", "return_5d"],
["10日涨幅%", "return_10d"], ["量比", "volume_ratio_5d"], ["板块强度", "sector_strength"],
["主要贡献", "reason"], ["风险标记", "risk_flags"],
]);
}
function regimeLabel(regime) {
return state.screenerSetup?.regimes?.find((item) => item.id === regime)?.label || regime;
}
function drawPriceChart(prices) {
const canvas = elements.priceChart;
if (!prices?.length) {
clearPriceChart("暂无日 K 数据");
return;
}
const rect = canvas.getBoundingClientRect();
const ratio = window.devicePixelRatio || 1;
const width = Math.max(320, rect.width);
const height = Math.max(220, rect.height);
canvas.width = Math.round(width * ratio);
canvas.height = Math.round(height * ratio);
const context = canvas.getContext("2d");
context.setTransform(ratio, 0, 0, ratio, 0, 0);
context.clearRect(0, 0, width, height);
context.fillStyle = "#fbfcfd";
context.fillRect(0, 0, width, height);
const left = 48;
const right = 12;
const top = 14;
const bottom = 22;
const volumeHeight = 54;
const gap = 12;
const priceBottom = height - bottom - volumeHeight - gap;
const plotWidth = width - left - right;
const highs = prices.map((item) => number(item.high));
const lows = prices.map((item) => number(item.low));
const maximum = Math.max(...highs);
const minimum = Math.min(...lows);
const range = Math.max(maximum - minimum, maximum * 0.01, 0.01);
const volumes = prices.map((item) => number(item.volume));
const maxVolume = Math.max(...volumes, 1);
const priceY = (value) => top + (maximum - value) / range * (priceBottom - top);
const step = plotWidth / prices.length;
const candleWidth = clamp(step * 0.62, 2, 8);
context.strokeStyle = "#e2e8ec";
context.fillStyle = "#6c7983";
context.font = "11px Microsoft YaHei";
context.textAlign = "right";
for (let line = 0; line <= 4; line += 1) {
const y = top + (priceBottom - top) * line / 4;
context.beginPath();
context.moveTo(left, y);
context.lineTo(width - right, y);
context.stroke();
context.fillText((maximum - range * line / 4).toFixed(2), left - 5, y + 4);
}
prices.forEach((item, index) => {
const x = left + step * index + step / 2;
const rising = number(item.close) >= number(item.open);
const color = rising ? "#ef5143" : "#079667";
context.strokeStyle = color;
context.fillStyle = color;
context.beginPath();
context.moveTo(x, priceY(item.high));
context.lineTo(x, priceY(item.low));
context.stroke();
const openY = priceY(item.open);
const closeY = priceY(item.close);
const bodyTop = Math.min(openY, closeY);
const bodyHeight = Math.max(1, Math.abs(closeY - openY));
if (rising) context.strokeRect(x - candleWidth / 2, bodyTop, candleWidth, bodyHeight);
else context.fillRect(x - candleWidth / 2, bodyTop, candleWidth, bodyHeight);
const volumeBarHeight = number(item.volume) / maxVolume * volumeHeight;
context.globalAlpha = 0.75;
context.fillRect(x - candleWidth / 2, height - bottom - volumeBarHeight, candleWidth, volumeBarHeight);
context.globalAlpha = 1;
});
context.textAlign = "center";
context.fillStyle = "#6c7983";
const labelIndexes = [0, Math.floor((prices.length - 1) / 2), prices.length - 1];
labelIndexes.forEach((index) => {
const x = left + step * index + step / 2;
context.fillText(String(prices[index].trade_date).slice(5), x, height - 5);
});
}
function clearPriceChart(message) {
const canvas = elements.priceChart;
const context = canvas.getContext("2d");
const rect = canvas.getBoundingClientRect();
canvas.width = Math.max(320, Math.round(rect.width));
canvas.height = Math.max(220, Math.round(rect.height));
context.fillStyle = "#fbfcfd";
context.fillRect(0, 0, canvas.width, canvas.height);
context.fillStyle = "#647380";
context.font = "13px Microsoft YaHei";
context.textAlign = "center";
context.fillText(message, canvas.width / 2, canvas.height / 2);
}
function prepareStockPreviewCanvas() {
const canvas = elements.stockPreviewChart;
const rect = canvas.getBoundingClientRect();
const ratio = window.devicePixelRatio || 1;
const width = Math.max(300, rect.width || 488);
const height = Math.max(210, rect.height || 232);
canvas.width = Math.round(width * ratio);
canvas.height = Math.round(height * ratio);
const context = canvas.getContext("2d");
context.setTransform(ratio, 0, 0, ratio, 0, 0);
context.clearRect(0, 0, width, height);
context.fillStyle = "#ffffff";
context.fillRect(0, 0, width, height);
context.font = '11px -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei UI", sans-serif';
return { canvas, context, width, height };
}
function drawPreviewGrid(context, width, top, bottom, left, right, maximum, range) {
context.strokeStyle = "#e7ebef";
context.fillStyle = "#74808d";
context.textAlign = "right";
context.lineWidth = 1;
for (let line = 0; line <= 3; line += 1) {
const y = top + (bottom - top) * line / 3;
context.beginPath();
context.moveTo(left, y);
context.lineTo(width - right, y);
context.stroke();
context.fillText((maximum - range * line / 3).toFixed(2), left - 5, y + 4);
}
}
function drawIntradayPreviewChart(points, dailyPrices) {
const { context, width, height } = prepareStockPreviewCanvas();
const left = 45;
const right = 10;
const top = 12;
const volumeHeight = 38;
const bottom = 18;
const gap = 9;
const priceBottom = height - bottom - volumeHeight - gap;
const closes = points.map((point) => number(point.close));
const previousClose = number(dailyPrices.at(-2)?.close || points[0]?.open || closes[0]);
const maximum = Math.max(...points.map((point) => number(point.high || point.close)), previousClose);
const minimum = Math.min(...points.map((point) => number(point.low || point.close)), previousClose);
const padding = Math.max((maximum - minimum) * 0.08, maximum * 0.001, 0.01);
const chartMaximum = maximum + padding;
const chartMinimum = minimum - padding;
const range = Math.max(chartMaximum - chartMinimum, 0.01);
const plotWidth = width - left - right;
const priceY = (value) => top + (chartMaximum - value) / range * (priceBottom - top);
const pointX = (index) => left + plotWidth * index / Math.max(1, points.length - 1);
drawPreviewGrid(context, width, top, priceBottom, left, right, chartMaximum, range);
context.save();
context.setLineDash([4, 4]);
context.strokeStyle = "#aeb7c1";
context.beginPath();
context.moveTo(left, priceY(previousClose));
context.lineTo(width - right, priceY(previousClose));
context.stroke();
context.restore();
context.strokeStyle = "#1d65c1";
context.lineWidth = 1.7;
context.beginPath();
points.forEach((point, index) => {
const x = pointX(index);
const y = priceY(point.close);
if (index === 0) context.moveTo(x, y);
else context.lineTo(x, y);
});
context.stroke();
const maxVolume = Math.max(...points.map((point) => number(point.volume)), 1);
const barWidth = clamp(plotWidth / Math.max(points.length, 1) * 0.72, 1, 3);
points.forEach((point, index) => {
const x = pointX(index);
const barHeight = number(point.volume) / maxVolume * volumeHeight;
context.fillStyle = number(point.close) >= number(point.open) ? "rgba(201,63,69,.58)" : "rgba(8,122,85,.58)";
context.fillRect(x - barWidth / 2, height - bottom - barHeight, barWidth, barHeight);
});
context.fillStyle = "#74808d";
context.textAlign = "center";
[0, Math.floor((points.length - 1) / 2), points.length - 1].forEach((index) => {
context.fillText(points[index]?.time || "--", pointX(index), height - 4);
});
const latest = closes.at(-1);
setText(
"stockPreviewSummary",
`分时 ${points.length} 点,最新 ${formatNumber(latest, 2)},最高 ${formatNumber(maximum, 2)},最低 ${formatNumber(minimum, 2)}。`,
);
}
function drawDailyPreviewChart(prices) {
const { context, width, height } = prepareStockPreviewCanvas();
const visible = prices.slice(-45);
const visibleStart = prices.length - visible.length;
const left = 45;
const right = 10;
const top = 24;
const volumeHeight = 34;
const bottom = 18;
const gap = 8;
const priceBottom = height - bottom - volumeHeight - gap;
const maximum = Math.max(...visible.map((item) => number(item.high)));
const minimum = Math.min(...visible.map((item) => number(item.low)));
const padding = Math.max((maximum - minimum) * 0.05, maximum * 0.002, 0.01);
const chartMaximum = maximum + padding;
const chartMinimum = minimum - padding;
const range = Math.max(chartMaximum - chartMinimum, 0.01);
const plotWidth = width - left - right;
const step = plotWidth / Math.max(visible.length, 1);
const candleWidth = clamp(step * 0.58, 2, 7);
const priceY = (value) => top + (chartMaximum - value) / range * (priceBottom - top);
drawPreviewGrid(context, width, top, priceBottom, left, right, chartMaximum, range);
const maxVolume = Math.max(...visible.map((item) => number(item.volume)), 1);
visible.forEach((item, index) => {
const x = left + step * index + step / 2;
const rising = number(item.close) >= number(item.open);
const color = rising ? "#c93f45" : "#087a55";
context.strokeStyle = color;
context.fillStyle = color;
context.beginPath();
context.moveTo(x, priceY(item.high));
context.lineTo(x, priceY(item.low));
context.stroke();
const openY = priceY(item.open);
const closeY = priceY(item.close);
const bodyTop = Math.min(openY, closeY);
const bodyHeight = Math.max(1, Math.abs(closeY - openY));
if (rising) context.strokeRect(x - candleWidth / 2, bodyTop, candleWidth, bodyHeight);
else context.fillRect(x - candleWidth / 2, bodyTop, candleWidth, bodyHeight);
const volumeBarHeight = number(item.volume) / maxVolume * volumeHeight;
context.globalAlpha = 0.62;
context.fillRect(x - candleWidth / 2, height - bottom - volumeBarHeight, candleWidth, volumeBarHeight);
context.globalAlpha = 1;
});
const movingAverages = [
{ days: 5, color: "#1d65c1" },
{ days: 10, color: "#a76500" },
{ days: 20, color: "#626c78" },
];
movingAverages.forEach(({ days, color }) => {
context.strokeStyle = color;
context.lineWidth = 1.25;
context.beginPath();
let started = false;
visible.forEach((_item, index) => {
const absoluteIndex = visibleStart + index;
if (absoluteIndex < days - 1) return;
const values = prices.slice(absoluteIndex - days + 1, absoluteIndex + 1);
const average = values.reduce((sum, item) => sum + number(item.close), 0) / days;
const x = left + step * index + step / 2;
const y = priceY(average);
if (!started) {
context.moveTo(x, y);
started = true;
} else context.lineTo(x, y);
});
context.stroke();
});
context.textAlign = "left";
movingAverages.forEach(({ days, color }, index) => {
context.fillStyle = color;
context.fillText(`MA${days}`, left + index * 42, 12);
});
context.fillStyle = "#74808d";
context.textAlign = "center";
[0, Math.floor((visible.length - 1) / 2), visible.length - 1].forEach((index) => {
const x = left + step * index + step / 2;
context.fillText(String(visible[index]?.trade_date || "").slice(5), x, height - 4);
});
const firstClose = number(visible[0]?.close);
const latestClose = number(visible.at(-1)?.close);
const periodChange = firstClose ? (latestClose / firstClose - 1) * 100 : 0;
setText(
"stockPreviewSummary",
`近 ${visible.length} 日涨跌 ${signed(periodChange)}%,区间最高 ${formatNumber(maximum, 2)},最低 ${formatNumber(minimum, 2)}。`,
);
}
function clearStockPreviewChart(message) {
const { context, width, height } = prepareStockPreviewCanvas();
if (!message) return;
context.fillStyle = "#74808d";
context.textAlign = "center";
context.fillText(message, width / 2, height / 2);
}
function bindStockRows(container) {
animateRows(container);
decorateStockPreviewTargets(container);
container.querySelectorAll("[data-code]").forEach((rowElement) => {
rowElement.addEventListener("click", (event) => {
const interactive = event.target.closest("button, a, input, select, textarea, summary");
if (interactive && interactive !== rowElement) return;
openStock(rowElement.dataset.code, findStockFallback(rowElement.dataset.code));
});
});
}
function decorateStockPreviewTargets(container) {
container.querySelectorAll(".stock-code").forEach((trigger) => {
const code = stockCodeFromTrigger(trigger);
if (!code) return;
trigger.classList.add("stock-preview-trigger");
trigger.tabIndex = 0;
trigger.setAttribute("role", "button");
trigger.setAttribute("aria-label", `预览 ${code} 行情`);
trigger.title = "悬停预览行情,点击查看完整详情";
});
}
function stockCodeFromTrigger(trigger) {
const candidate = trigger?.dataset?.stockPreviewCode
|| trigger?.closest?.("[data-code]")?.dataset?.code
|| trigger?.textContent?.trim();
const matched = String(candidate || "").match(/\b(\d{6})\b/);
return matched ? matched[1] : "";
}
function findStockFallback(code) {
const dashboardRows = [
...(state.dashboard?.limits || []),
...(state.dashboard?.broken || []),
...(state.dashboard?.down_limits || []),
...(state.dashboard?.yesterday_limits || []),
];
const screenerRows = state.screenerResult?.candidates || [];
const dragonRows = (state.dragonTiger?.traders || []).flatMap((trader) => trader.operations || []);
const row = [...dashboardRows, ...screenerRows, ...dragonRows, ...(state.watchlist || [])]
.find((item) => String(item.code) === String(code));
if (!row) return { code, name: "--", sector: "其他" };
return {
...row,
code,
change: row.change ?? row.current_change ?? row.pct_chg ?? 0,
sector: row.sector || row.industry || "其他",
};
}
function supportsStockPreviewHover() {
return window.matchMedia("(hover: hover) and (pointer: fine)").matches
&& window.innerWidth > 720;
}
function handleStockPreviewPointerOver(event) {
if (!supportsStockPreviewHover()) return;
const trigger = event.target.closest?.(".stock-preview-trigger");
if (!trigger || trigger === event.relatedTarget?.closest?.(".stock-preview-trigger")) return;
const code = stockCodeFromTrigger(trigger);
if (!code) return;
cancelStockPreviewClose();
clearTimeout(stockPreviewOpenTimer);
stockPreviewOpenTimer = setTimeout(() => showStockPreview(code, trigger), STOCK_PREVIEW_DELAY);
}
function handleStockPreviewPointerOut(event) {
if (!supportsStockPreviewHover()) return;
const trigger = event.target.closest?.(".stock-preview-trigger");
if (!trigger || trigger === event.relatedTarget?.closest?.(".stock-preview-trigger")) return;
clearTimeout(stockPreviewOpenTimer);
if (event.relatedTarget instanceof Node && elements.stockPreview.contains(event.relatedTarget)) return;
scheduleStockPreviewClose();
}
function handleStockPreviewFocus(event) {
if (!supportsStockPreviewHover()) return;
const trigger = event.target.closest?.(".stock-preview-trigger");
if (!trigger) return;
const code = stockCodeFromTrigger(trigger);
if (!code) return;
clearTimeout(stockPreviewOpenTimer);
stockPreviewOpenTimer = setTimeout(() => showStockPreview(code, trigger), 120);
}
function handleStockPreviewFocusOut(event) {
const trigger = event.target.closest?.(".stock-preview-trigger");
if (!trigger) return;
if (event.relatedTarget instanceof Node && elements.stockPreview.contains(event.relatedTarget)) return;
clearTimeout(stockPreviewOpenTimer);
scheduleStockPreviewClose();
}
function handleMobileStockPreviewClick(event) {
if (window.innerWidth > 720) return;
const trigger = event.target.closest?.(".stock-preview-trigger");
if (!trigger) return;
const code = stockCodeFromTrigger(trigger);
if (!code) return;
event.preventDefault();
event.stopPropagation();
showStockPreview(code, trigger);
}
function handleStockPreviewKeydown(event) {
if (event.key === "Escape" && !elements.stockPreview.hidden) {
closeStockPreview();
stockPreviewAnchor?.focus?.();
return;
}
if (event.key !== "Enter") return;
const trigger = event.target.closest?.(".stock-preview-trigger");
if (!trigger) return;
const code = stockCodeFromTrigger(trigger);
if (!code) return;
event.preventDefault();
if (window.innerWidth <= 720) showStockPreview(code, trigger);
else openStock(code, findStockFallback(code));
}
function cancelStockPreviewClose() {
clearTimeout(stockPreviewCloseTimer);
}
function scheduleStockPreviewClose() {
clearTimeout(stockPreviewCloseTimer);
stockPreviewCloseTimer = setTimeout(closeStockPreview, 160);
}
async function showStockPreview(code, trigger) {
clearTimeout(stockPreviewOpenTimer);
cancelStockPreviewClose();
if (!/^\d{6}$/.test(String(code))) return;
stockPreviewAnchor = trigger;
state.stockPreviewCode = String(code);
state.stockPreviewFallback = findStockFallback(code);
state.stockPreviewPayload = null;
state.stockPreviewChart = "intraday";
renderStockPreviewLoading();
elements.stockPreview.hidden = false;
const mobile = window.innerWidth <= 720;
elements.stockPreviewBackdrop.hidden = !mobile;
document.body.classList.toggle("stock-preview-open", mobile);
requestAnimationFrame(repositionStockPreview);
const cacheKey = `${code}:${elements.tradeDate.value}`;
const cached = stockPreviewCache.get(cacheKey);
if (cached && cached.expiresAt > Date.now()) {
renderStockPreview(cached.payload);
return;
}
if (cached) stockPreviewCache.delete(cacheKey);
stockPreviewAbortController?.abort();
stockPreviewAbortController = new AbortController();
try {
const query = new URLSearchParams({ trade_date: elements.tradeDate.value });
const payload = await apiRequest(
`/api/stock/${encodeURIComponent(code)}/preview?${query}`,
"GET",
null,
{ signal: stockPreviewAbortController.signal },
);
if (state.stockPreviewCode !== String(code) || elements.stockPreview.hidden) return;
const cacheMs = payload.meta?.realtime ? LIVE_REFRESH_DEFAULT_MS : STOCK_PREVIEW_CACHE_MS;
stockPreviewCache.set(cacheKey, { payload, expiresAt: Date.now() + cacheMs });
while (stockPreviewCache.size > 48) stockPreviewCache.delete(stockPreviewCache.keys().next().value);
renderStockPreview(payload);
} catch (error) {
if (error.name === "AbortError" || state.stockPreviewCode !== String(code)) return;
renderStockPreviewError(error.message || "行情预览加载失败");
}
}
function renderStockPreviewLoading() {
const fallback = state.stockPreviewFallback || {};
setText("stockPreviewCode", state.stockPreviewCode || "--");
setText("stockPreviewName", fallback.name || "正在加载");
setText("stockPreviewSector", fallback.sector || "--");
setText("stockPreviewPrice", meaningfulNumber(fallback.price) ? formatNumber(fallback.price, 2) : "--");
const change = number(fallback.change);
setText("stockPreviewChange", meaningfulNumber(fallback.change) ? `${signed(change)}%` : "--");
document.querySelector("#stockPreviewChange").className = changeClass(change);
setText("stockPreviewDate", elements.tradeDate.value);
setText("stockPreviewSource", "正在读取行情");
setText("stockPreviewSummary", "等待行情数据");
document.querySelector("#stockPreviewLoading").hidden = false;
clearStockPreviewChart("");
}
function renderStockPreview(payload) {
state.stockPreviewPayload = payload;
const fallback = state.stockPreviewFallback || {};
const stock = payload.stock || {};
const price = stock.price ?? fallback.price;
const change = stock.change ?? fallback.change;
setText("stockPreviewCode", stock.code || state.stockPreviewCode);
setText("stockPreviewName", stock.name && stock.name !== "--" ? stock.name : fallback.name || "--");
setText("stockPreviewSector", stock.industry && stock.industry !== "其他" ? stock.industry : fallback.sector || "其他");
setText("stockPreviewPrice", meaningfulNumber(price) ? formatNumber(price, 2) : "--");
setText("stockPreviewChange", meaningfulNumber(change) ? `${signed(change)}%` : "--");
document.querySelector("#stockPreviewChange").className = changeClass(change);
setText("stockPreviewDate", payload.meta?.trade_date || elements.tradeDate.value);
document.querySelector("#stockPreviewLoading").hidden = true;
const intradayAvailable = (payload.intraday || []).length > 0;
const source = payload.meta?.realtime
? "Tushare 实时行情"
: payload.meta?.source === "tushare" ? "Tushare 日K" : "演示日K";
setText(
"stockPreviewSource",
intradayAvailable ? `${source} · 1分钟` : `${source} · 分时不可用`,
);
selectStockPreviewChart(intradayAvailable ? "intraday" : "daily");
requestAnimationFrame(repositionStockPreview);
}
function renderStockPreviewError(message) {
document.querySelector("#stockPreviewLoading").hidden = true;
setText("stockPreviewSource", "行情加载失败");
setText("stockPreviewSummary", message);
clearStockPreviewChart("加载失败");
}
function selectStockPreviewChart(chart) {
state.stockPreviewChart = chart === "daily" ? "daily" : "intraday";
document.querySelectorAll("[data-preview-chart]").forEach((button) => {
const active = button.dataset.previewChart === state.stockPreviewChart;
button.classList.toggle("active", active);
button.setAttribute("aria-selected", String(active));
});
const payload = state.stockPreviewPayload;
if (!payload) return;
if (state.stockPreviewChart === "intraday") {
if ((payload.intraday || []).length) drawIntradayPreviewChart(payload.intraday, payload.prices || []);
else {
clearStockPreviewChart("分时数据不可用");
setText("stockPreviewSummary", payload.meta?.intraday_notice || "该交易日暂无分时数据。");
}
} else if ((payload.prices || []).length) {
drawDailyPreviewChart(payload.prices);
} else {
clearStockPreviewChart("暂无日K数据");
setText("stockPreviewSummary", "该股票暂无可用的日K数据。");
}
}
function closeStockPreview() {
clearTimeout(stockPreviewOpenTimer);
clearTimeout(stockPreviewCloseTimer);
stockPreviewAbortController?.abort();
stockPreviewAbortController = null;
elements.stockPreview.hidden = true;
elements.stockPreviewBackdrop.hidden = true;
document.body.classList.remove("stock-preview-open");
state.stockPreviewPayload = null;
state.stockPreviewCode = "";
}
function openStockDetailFromPreview() {
const code = state.stockPreviewCode;
const fallback = state.stockPreviewFallback;
if (!code) return;
closeStockPreview();
openStock(code, fallback);
}
function repositionStockPreview() {
if (elements.stockPreview.hidden || window.innerWidth <= 720 || !stockPreviewAnchor?.isConnected) return;
const anchor = stockPreviewAnchor.getBoundingClientRect();
const preview = elements.stockPreview.getBoundingClientRect();
const gap = 12;
let left = anchor.right + gap;
if (left + preview.width > window.innerWidth - 8) left = anchor.left - preview.width - gap;
left = clamp(left, 8, Math.max(8, window.innerWidth - preview.width - 8));
const top = clamp(anchor.top - 48, 64, Math.max(64, window.innerHeight - preview.height - 8));
elements.stockPreview.style.left = `${Math.round(left)}px`;
elements.stockPreview.style.top = `${Math.round(top)}px`;
}
function openGlobalSearch() {
if (!state.user) return;
toggleHeaderCommandMenu(false);
if (!elements.globalSearchDialog.open) elements.globalSearchDialog.showModal();
requestAnimationFrame(() => {
elements.globalSearchInput.focus();
elements.globalSearchInput.select();
});
}
function handleGlobalSearchShortcut(event) {
if (!event.ctrlKey || event.altKey || event.shiftKey || event.key.toLowerCase() !== "k") return;
if (!state.user) return;
if (event.defaultPrevented) {
showToast("Ctrl+K 已被其他功能占用,请点击顶部搜索按钮");
return;
}
event.preventDefault();
openGlobalSearch();
}
function closeGlobalSearch() {
clearTimeout(globalSearchTimer);
if (elements.globalSearchDialog.open) elements.globalSearchDialog.close();
}
function scheduleGlobalSearch() {
clearTimeout(globalSearchTimer);
const query = elements.globalSearchInput.value.trim();
state.globalSearchActiveIndex = -1;
if (!query) {
state.globalSearchResults = [];
renderGlobalSearchEmpty("输入名称或代码开始搜索", "使用方向键选择,回车打开详情", "corner-down-left");
return;
}
elements.globalSearchResults.innerHTML = '<div class="global-search-loading"><span class="spinner" aria-hidden="true"></span><span>正在搜索</span></div>';
globalSearchTimer = setTimeout(() => runGlobalSearch(query), 160);
}
async function runGlobalSearch(query) {
const requestSequence = ++state.globalSearchRequestSequence;
const params = new URLSearchParams({ q: query, trade_date: elements.tradeDate.value });
try {
const payload = await apiRequest(`/api/search?${params}`);
if (requestSequence !== state.globalSearchRequestSequence || elements.globalSearchInput.value.trim() !== query) return;
renderGlobalSearchResults(payload.groups || {});
} catch (error) {
if (requestSequence !== state.globalSearchRequestSequence) return;
state.globalSearchResults = [];
renderGlobalSearchEmpty(error.message || "搜索失败", "请稍后重试", "circle-alert");
}
}
function renderGlobalSearchResults(groups) {
const definitions = [
["stocks", "股票"],
["sectors", "板块"],
["themes", "题材"],
["indices", "指数"],
];
const iconNames = { stock: "chart-candlestick", sector: "layout-grid", theme: "lightbulb", index: "chart-line" };
const flattened = [];
const sections = [];
definitions.forEach(([key, label]) => {
const items = Array.isArray(groups[key]) ? groups[key] : [];
if (!items.length) return;
const rows = items.map((item) => {
const index = flattened.length;
flattened.push(item);
return `<button class="global-search-result" type="button" role="option" aria-selected="false" data-search-result-index="${index}">
<span class="global-search-result-icon"><i data-lucide="${iconNames[item.type] || "search"}"></i></span>
<span class="global-search-result-copy"><strong>${escapeHtml(item.name || "--")}</strong><span>${escapeHtml(item.subtitle || item.type_label || label)}</span></span>
<span class="global-search-result-code">${escapeHtml(item.code || "")}</span>
</button>`;
}).join("");
sections.push(`<section class="global-search-group" aria-label="${label}"><h3 class="global-search-group-title">${label}</h3>${rows}</section>`);
});
state.globalSearchResults = flattened;
state.globalSearchActiveIndex = flattened.length ? 0 : -1;
if (!flattened.length) {
renderGlobalSearchEmpty("没有找到相关结果", "可尝试输入完整名称或六位股票代码", "search-x");
return;
}
elements.globalSearchResults.innerHTML = sections.join("");
updateGlobalSearchSelection(false);
refreshIcons();
}
function renderGlobalSearchEmpty(title, hint, iconName) {
elements.globalSearchResults.innerHTML = `<div class="global-search-empty"><i data-lucide="${iconName}"></i><p>${escapeHtml(title)}</p><span>${escapeHtml(hint)}</span></div>`;
refreshIcons();
}
function handleGlobalSearchInputKeydown(event) {
if (event.key === "Escape") {
event.preventDefault();
closeGlobalSearch();
return;
}
if (!["ArrowDown", "ArrowUp", "Enter"].includes(event.key)) return;
if (!state.globalSearchResults.length) return;
event.preventDefault();
if (event.key === "Enter") {
openGlobalSearchResult(state.globalSearchActiveIndex);
return;
}
const direction = event.key === "ArrowDown" ? 1 : -1;
state.globalSearchActiveIndex = (state.globalSearchActiveIndex + direction + state.globalSearchResults.length) % state.globalSearchResults.length;
updateGlobalSearchSelection(true);
}
function updateGlobalSearchSelection(scrollIntoView) {
elements.globalSearchResults.querySelectorAll("[data-search-result-index]").forEach((item) => {
const selected = number(item.dataset.searchResultIndex) === state.globalSearchActiveIndex;
item.classList.toggle("is-active", selected);
item.setAttribute("aria-selected", String(selected));
if (selected && scrollIntoView) item.scrollIntoView({ block: "nearest" });
});
}
function openGlobalSearchResult(index) {
const item = state.globalSearchResults[index];
if (!item) return;
closeGlobalSearch();
if (item.type === "stock") {
openStock(item.id, { code: item.code, name: item.name, sector: item.industry || "其他" });
return;
}
openEntityDetail(item);
}
async function openEntityDetail(item) {
setText("entityDetailCode", item.code || item.id || "--");
setText("entityDetailName", item.name || "--");
setText("entityDetailValue", "--");
setText("entityDetailChange", "--");
setText("entityDetailType", item.type_label || "--");
setText("entityDetailDate", "正在加载行情");
document.querySelector("#entityDetailChange").className = "";
document.querySelector("#entityDetailMetrics").innerHTML = '<div class="empty-state">正在加载交易数据</div>';
if (!elements.entityDetailDialog.open) elements.entityDetailDialog.showModal();
clearEntityDetailChart("正在加载日 K 数据");
try {
const params = new URLSearchParams({ type: item.type, id: item.id, trade_date: elements.tradeDate.value });
const payload = await apiRequest(`/api/search/detail?${params}`);
const entity = payload.entity || {};
setText("entityDetailCode", entity.code || item.code || "--");
setText("entityDetailName", entity.name || item.name || "--");
setText("entityDetailValue", meaningfulNumber(entity.value) && number(entity.value) !== 0 ? formatNumber(entity.value, 2) : "--");
setText("entityDetailChange", `${signed(entity.change)}%`);
setText("entityDetailType", entity.type_label || item.type_label || "--");
setText("entityDetailDate", `${payload.meta?.realtime ? "实时" : "收盘"} · ${payload.meta?.trade_date || "--"}`);
document.querySelector("#entityDetailChange").className = changeClass(entity.change);
renderEntityDetailMetrics(payload.metrics || []);
requestAnimationFrame(() => drawEntityDetailChart(payload.series || []));
} catch (error) {
setText("entityDetailDate", "行情加载失败");
document.querySelector("#entityDetailMetrics").innerHTML = `<div class="empty-state">${escapeHtml(error.message || "交易数据加载失败")}</div>`;
clearEntityDetailChart(error.message || "行情加载失败");
showToast(error.message || "详情加载失败");
}
}
function renderEntityDetailMetrics(metrics) {
const container = document.querySelector("#entityDetailMetrics");
if (!metrics.length) {
container.innerHTML = '<div class="empty-state">暂无交易数据</div>';
return;
}
container.innerHTML = metrics.map((metric) => {
const value = typeof metric.value === "number" ? formatNumber(metric.value, Number.isInteger(metric.value) ? 0 : 2) : String(metric.value ?? "--");
const tone = metric.tone === "change" ? changeClass(metric.value) : "";
return `<div><dt>${escapeHtml(metric.label)}</dt><dd class="${tone}">${escapeHtml(value)}${escapeHtml(metric.unit || "")}</dd></div>`;
}).join("");
}
function drawEntityDetailChart(series) {
const canvas = elements.entityDetailChart;
const candles = (series || []).filter((item) => number(item.close) > 0).map((item) => {
const close = number(item.close);
const open = number(item.open) || close;
const high = Math.max(number(item.high) || close, open, close);
const low = Math.min(number(item.low) || close, open, close);
return { ...item, open, high, low, close };
});
if (!candles.length) {
clearEntityDetailChart("暂无日 K 数据");
return;
}
const rect = canvas.getBoundingClientRect();
const ratio = window.devicePixelRatio || 1;
const width = Math.max(320, rect.width);
const height = Math.max(220, rect.height);
canvas.width = Math.round(width * ratio);
canvas.height = Math.round(height * ratio);
const context = canvas.getContext("2d");
context.setTransform(ratio, 0, 0, ratio, 0, 0);
context.clearRect(0, 0, width, height);
context.fillStyle = "#fbfcfd";
context.fillRect(0, 0, width, height);
const left = 48;
const right = 12;
const top = 14;
const bottom = 22;
const volumeHeight = 54;
const gap = 12;
const priceBottom = height - bottom - volumeHeight - gap;
const plotWidth = width - left - right;
const maximum = Math.max(...candles.map((item) => item.high));
const minimum = Math.min(...candles.map((item) => item.low));
const range = Math.max(maximum - minimum, maximum * 0.01, 0.01);
const maxVolume = Math.max(...candles.map((item) => number(item.volume)), 1);
const priceY = (value) => top + (maximum - value) / range * (priceBottom - top);
const step = plotWidth / candles.length;
const candleWidth = clamp(step * 0.62, 2, 8);
context.strokeStyle = "#e2e8ec";
context.fillStyle = "#6c7983";
context.font = "11px Microsoft YaHei";
context.textAlign = "right";
for (let line = 0; line <= 4; line += 1) {
const lineY = top + (priceBottom - top) * line / 4;
context.beginPath();
context.moveTo(left, lineY);
context.lineTo(width - right, lineY);
context.stroke();
context.fillText((maximum - range * line / 4).toFixed(2), left - 6, lineY + 4);
}
candles.forEach((item, index) => {
const x = left + step * index + step / 2;
const rising = item.close >= item.open;
const color = rising ? "#ef5143" : "#079667";
context.strokeStyle = color;
context.fillStyle = color;
context.lineWidth = 1;
context.beginPath();
context.moveTo(x, priceY(item.high));
context.lineTo(x, priceY(item.low));
context.stroke();
const openY = priceY(item.open);
const closeY = priceY(item.close);
const bodyTop = Math.min(openY, closeY);
const bodyHeight = Math.max(1, Math.abs(closeY - openY));
if (rising) context.strokeRect(x - candleWidth / 2, bodyTop, candleWidth, bodyHeight);
else context.fillRect(x - candleWidth / 2, bodyTop, candleWidth, bodyHeight);
const volumeBarHeight = number(item.volume) / maxVolume * volumeHeight;
context.globalAlpha = 0.72;
context.fillRect(x - candleWidth / 2, height - bottom - volumeBarHeight, candleWidth, volumeBarHeight);
context.globalAlpha = 1;
});
context.textAlign = "center";
context.fillStyle = "#6c7983";
[0, Math.floor((candles.length - 1) / 2), candles.length - 1].forEach((index) => {
const x = left + step * index + step / 2;
context.fillText(String(candles[index].trade_date || "").slice(5), x, height - 5);
});
}
function clearEntityDetailChart(message) {
const canvas = elements.entityDetailChart;
const rect = canvas.getBoundingClientRect();
const width = Math.max(320, Math.round(rect.width || 680));
const height = Math.max(220, Math.round(rect.height || 300));
canvas.width = width;
canvas.height = height;
const context = canvas.getContext("2d");
context.fillStyle = "#fbfcfd";
context.fillRect(0, 0, width, height);
context.fillStyle = "#647380";
context.font = "13px Microsoft YaHei";
context.textAlign = "center";
context.fillText(message, width / 2, height / 2);
}
function meaningfulNumber(value) {
return value !== null && value !== undefined && value !== "" && Number.isFinite(Number(value));
}
async function openStock(code, fallback = null) {
closeStockPreview();
const pools = [state.dashboard?.limits || [], state.dashboard?.broken || [], state.dashboard?.down_limits || []];
const row = pools.flat().find((item) => String(item.code) === String(code)) || fallback || { code, name: "--", sector: "其他" };
state.activeStock = row;
state.stockDetail = null;
setText("detailCode", row.code);
setText("detailName", row.name);
setText("detailPrice", formatNumber(row.price, 2));
setText("detailChange", `${signed(row.change)}%`);
const changeElement = document.querySelector("#detailChange");
changeElement.className = changeClass(row.change);
setText("detailStreak", row.status === "涨停" ? streakLabel(row.streak) : row.status || "--");
setText("detailReason", row.reason || "--");
setText("detailSector", row.sector || "其他");
setText("detailFirst", row.first_time || "--");
setText("detailLast", row.last_time || "--");
setText("detailOpen", `${number(row.open_times)} 次`);
setText("detailTurnover", `${formatNumber(row.turnover_rate, 2)}%`);
setText("detailAmount", `${formatNumber(row.amount_billion, 2)} 亿`);
setText("detailSeal", `${formatNumber(row.seal_amount_million, 0)} 万`);
setText("chartSource", "正在加载行情");
setText("flowNet", "--");
setText("flowLarge", "--");
setText("flowMedium", "--");
setText("flowSmall", "--");
document.querySelector("#reasonInput").value = row.reason || "";
document.querySelector("#stockNoteContent").value = "";
document.querySelector("#stockNotePlan").value = "";
document.querySelector("#stockNotes").innerHTML = '<div class="empty-state">正在加载笔记</div>';
updateWatchButton();
if (!elements.stockDialog.open) elements.stockDialog.showModal();
clearPriceChart("正在加载日 K 数据");
try {
const query = new URLSearchParams({ trade_date: elements.tradeDate.value });
const payload = await apiRequest(`/api/stock/${encodeURIComponent(code)}?${query}`);
state.stockDetail = payload;
const stock = payload.stock || {};
state.activeStock = { ...row, name: stock.name || row.name, sector: stock.industry || row.sector };
setText("detailName", stock.name || row.name);
setText("detailPrice", formatNumber(stock.price || row.price, 2));
setText("detailChange", `${signed(stock.change ?? row.change)}%`);
setText("chartSource", `${payload.meta.source === "tushare" ? "Tushare" : "演示"} · ${payload.prices.length} 个交易日`);
renderMoneyflow(payload.moneyflow || {});
renderStockNotes(payload.notes || []);
updateWatchButton();
requestAnimationFrame(() => drawPriceChart(payload.prices || []));
} catch (error) {
setText("chartSource", "行情加载失败");
clearPriceChart(error.message || "行情加载失败");
showToast(error.message || "个股详情加载失败");
}
}
function openActiveStockInHeaven() {
const code = state.activeStock?.code;
if (!/^\d{6}$/.test(String(code || ""))) return;
elements.stockDialog.close();
state.heavenPanel = "trend";
state.heavenManualData = null;
const input = document.querySelector("#heavenStockInput");
input.value = code;
openView("heavenView");
selectHeavenPanel("trend", true);
}
function hasMemberAccess() {
return state.user?.role === "admin" || Boolean(state.user?.membership?.active);
}
function updateAccountIdentityBadges(membership = {}) {
const isAdmin = state.user?.role === "admin" || Boolean(membership.is_admin);
const subscribed = Boolean(membership.subscribed);
document.querySelector("#accountAdminBadge").hidden = !isAdmin;
const vipBadge = document.querySelector("#accountVipBadge");
vipBadge.hidden = false;
vipBadge.classList.toggle("is-nonmember", !subscribed);
setText("accountVipLabel", subscribed ? "会员" : "非会员");
vipBadge.title = subscribed ? "查看会员状态" : "查看会员权益";
}
function applyMembershipAccess() {
const unlocked = hasMemberAccess();
document.querySelectorAll(".member-feature-view").forEach((view) => {
view.classList.toggle("member-locked", !unlocked);
const gate = view.querySelector(".member-gate");
if (gate) gate.hidden = unlocked;
view.querySelectorAll("button, input, textarea, select").forEach((control) => {
if (control.closest(".member-gate")) return;
control.disabled = !unlocked;
});
});
}
function openView(viewId, updateHash = true) {
closeStockPreview();
state.activeView = viewId;
if (viewId !== "heavenView") {
stopQiFieldCanvas();
stopHeartDust();
cancelHeavenPerformance();
}
document.querySelectorAll(".workspace-view").forEach((view) => {
const active = view.id === viewId;
view.classList.toggle("active-view", active);
view.classList.remove("view-entering");
if (active && motionEnabled()) {
void view.offsetWidth;
view.classList.add("view-entering");
view.addEventListener("animationend", () => view.classList.remove("view-entering"), { once: true });
const body = view.querySelector("tbody");
if (body) animateRows(body);
}
});
syncNavigationState(viewId);
if (updateHash) {
const url = new URL(window.location.href);
url.searchParams.set("view", viewId);
url.hash = "";
history.replaceState(null, "", url);
}
window.scrollTo({ top: 0, behavior: "auto" });
applyMembershipAccess();
if (viewId === "dragonView") loadDragonTiger();
if (viewId === "reviewWorkspaceView") loadReviewWorkspace();
if (viewId === "screenerView" && hasMemberAccess()) loadScreenerSetup();
if (viewId === "mentorView" && hasMemberAccess()) loadMentorSetup();
if (viewId === "heavenView" && hasMemberAccess()) loadHeavenSetup(false, "", document.querySelector("#heavenStockInput").value.trim());
if (viewId === "sentimentCycleView") loadSentimentHistory();
if (viewId === "rotationView") loadRotationHistory();
}
function initializeAutoTableSorting() {
markAutoSortableHeaders(document);
document.addEventListener("click", (event) => {
const header = event.target.closest?.("th[data-auto-sort]");
if (!header || header.closest("#limitTable")) return;
const table = header.closest("table");
const body = table?.tBodies?.[0];
if (!body || body.rows.length < 2) return;
const direction = header.classList.contains("sort-asc") ? "desc" : "asc";
table.querySelectorAll("th.sort-asc, th.sort-desc").forEach((item) => {
item.classList.remove("sort-asc", "sort-desc");
item.removeAttribute("aria-sort");
});
header.classList.add(`sort-${direction}`);
header.setAttribute("aria-sort", direction === "asc" ? "ascending" : "descending");
const columnIndex = header.cellIndex;
const rows = [...body.rows].map((row, index) => ({ row, index }));
rows.sort((left, right) => {
const leftValue = autoSortValue(left.row.cells[columnIndex]);
const rightValue = autoSortValue(right.row.cells[columnIndex]);
let result;
if (leftValue.kind === "number" && rightValue.kind === "number") result = leftValue.value - rightValue.value;
else result = String(leftValue.value).localeCompare(String(rightValue.value), "zh-CN", { numeric: true, sensitivity: "base" });
if (result === 0) result = left.index - right.index;
return direction === "asc" ? result : -result;
});
rows.forEach(({ row }) => body.appendChild(row));
const firstHeader = [...header.parentElement.cells][0]?.textContent.trim();
if (["#", "排名"].includes(firstHeader)) {
[...body.rows].forEach((row, index) => {
if (row.cells[0]) row.cells[0].textContent = String(index + 1);
});
}
});
}
function markAutoSortableHeaders(root) {
root.querySelectorAll?.(".data-table:not(#limitTable) thead th").forEach((header) => {
if (number(header.colSpan) > 1) return;
const label = header.textContent.trim();
if (!label || ["#", "操作"].includes(label)) return;
header.dataset.autoSort = "true";
header.title = `${label}:点击排序`;
});
}
function autoSortValue(cell) {
const text = String(cell?.dataset?.sortValue || cell?.textContent || "").trim();
if (!text || text === "--" || text.includes("样本不足")) return { kind: "text", value: "\uffff" };
const boardMatch = text.match(/(\d+)\s*板/);
if (boardMatch) return { kind: "number", value: Number(boardMatch[1]) };
const normalized = text.replaceAll(",", "").replace(/[+%]/g, "");
const numericMatch = normalized.match(/^-?\d+(?:\.\d+)?/);
if (numericMatch) {
let value = Number(numericMatch[0]);
if (text.includes("亿")) value *= 10000;
return { kind: "number", value };
}
return { kind: "text", value: text };
}
function changeSort(key) {
if (state.sortKey === key) state.sortDirection = state.sortDirection === "asc" ? "desc" : "asc";
else {
state.sortKey = key;
state.sortDirection = ["name", "code", "sector", "first_time", "last_time"].includes(key) ? "asc" : "desc";
}
renderLimitTable();
}
function compareRows(left, right) {
const leftValue = left[state.sortKey] ?? "";
const rightValue = right[state.sortKey] ?? "";
let result = typeof leftValue === "number" || typeof rightValue === "number"
? number(leftValue) - number(rightValue)
: String(leftValue).localeCompare(String(rightValue), "zh-CN", { numeric: true });
if (result === 0 && state.sortKey !== "first_time") result = String(left.first_time || "").localeCompare(String(right.first_time || ""));
return state.sortDirection === "asc" ? result : -result;
}
function updateSortHeaders() {
document.querySelectorAll("#limitTable th[data-sort]").forEach((header) => {
header.classList.remove("sort-asc", "sort-desc");
if (header.dataset.sort === state.sortKey) header.classList.add(state.sortDirection === "asc" ? "sort-asc" : "sort-desc");
});
}
function shiftDate(delta) {
const current = parseLocalDate(elements.tradeDate.value);
current.setDate(current.getDate() + delta);
const next = localDateString(current);
if (next > todayString()) return;
elements.tradeDate.value = next;
state.heavenManualData = null;
document.querySelector("#qiObservationDate").value = next;
setDateInUrl(next);
loadDashboard();
}
function setDateInUrl(value) {
const url = new URL(window.location.href);
url.searchParams.set("date", value);
history.replaceState(null, "", url);
}
function updateDateButtons() {
document.querySelector("#nextDate").disabled = elements.tradeDate.value >= todayString();
}
function selectAccountPanel(panel) {
const selected = ["profile", "membership", "password"].includes(panel) ? panel : "profile";
const titles = { profile: "个人资料", membership: "会员状态", password: "修改密码" };
setText("accountDialogTitle", titles[selected]);
document.querySelectorAll("[data-account-panel-content]").forEach((section) => {
section.hidden = section.dataset.accountPanelContent !== selected;
});
document.querySelector("#connectionStatus").hidden = selected !== "membership";
return selected;
}
async function openSettings(panel = "profile") {
selectAccountPanel(panel);
toggleAccountDropdown(false);
toggleHeaderCommandMenu(false);
const status = document.querySelector("#connectionStatus");
status.className = "connection-status";
status.textContent = "正在读取账号状态";
if (!elements.settingsDialog.open) elements.settingsDialog.showModal();
try {
const payload = await apiRequest("/api/account/status");
const access = payload.llm_access || {};
const membership = access.membership || {};
if (state.user) {
state.user.membership = membership;
updateAccountIdentityBadges(membership);
applyMembershipAccess();
}
const sourceLabel = membership.active && access.platform_configured ? "平台会员模型" : "尚未开通会员算力";
status.textContent = `公共行情${payload.configured ? "已就绪" : "使用演示数据"} · ${sourceLabel}`;
status.classList.toggle("connected", Boolean(payload.configured));
setText("membershipBadge", membership.subscribed ? "会员有效" : membership.is_admin ? "管理员权限" : "普通用户");
setText("membershipStateValue", membership.subscribed ? "已开通" : membership.is_admin ? "管理员可用" : "未开通");
setText("membershipRemainingValue", membership.subscribed && membership.expires_at
? `${number(membership.remaining_days)} 天`
: membership.is_admin || membership.subscribed ? "长期有效" : "--");
setText("membershipDetail", membership.subscribed
? `${membership.plan || "会员"}${membership.expires_at ? ` · 有效至 ${membershipDateDisplay(membership.expires_at, true)}` : " · 长期有效"}`
: membership.is_admin
? "管理员拥有智能功能管理权限,但不会因此显示为已开通会员。"
: "开通会员后可使用智能选股、问师、问天及平台 LLM 算力。");
setText("membershipQuotaHint", `会员默认每日 LLM 用量 ${number(access.daily_limit)} 次,由管理员统一设置。`);
setText("membershipUsage", membership.active
? `今日已用 ${number(access.used_today)} 次`
: "今日 LLM 用量:--");
setText("membershipUsageSummary", membership.active ? `${number(access.used_today)} / ${number(access.daily_limit)}` : "--");
setText("membershipRemainingUsage", membership.is_admin ? "不限" : membership.active ? `${number(access.remaining_calls)} 次` : "--");
const birth = payload.birth_profile || {};
if (birth.birth_datetime) {
const [birthDate, birthTime] = String(birth.birth_datetime).split("T");
document.querySelector("#accountBirthDate").value = birthDate || "";
document.querySelector("#accountBirthTime").value = (birthTime || "").slice(0, 5);
document.querySelector("#accountBirthGender").value = birth.gender || "unspecified";
}
setText("birthProfileStatus", payload.birth_profile_configured ? "已加密保存" : "尚未设置");
document.querySelector("#deleteBirthProfileButton").disabled = !payload.birth_profile_configured;
} catch (error) {
status.hidden = false;
status.textContent = "无法连接本地后端";
showToast(error.message || "账号信息加载失败");
}
}
async function changeAccountPassword(event) {
event.preventDefault();
const form = event.currentTarget;
const button = form.querySelector("button[type='submit']");
button.disabled = true;
try {
await apiRequest("/api/account/password", "POST", {
current_password: document.querySelector("#currentPassword").value,
new_password: document.querySelector("#newPassword").value,
confirm_password: document.querySelector("#confirmPassword").value,
});
form.reset();
showToast("密码已更新");
} catch (error) {
showToast(error.message || "密码更新失败");
} finally {
button.disabled = false;
}
}
async function switchAccount() {
const button = document.querySelector("#switchAccountMenuButton");
button.disabled = true;
toggleAccountDropdown(false);
try {
await apiRequest("/api/auth/logout", "POST", {});
window.location.reload();
} catch (error) {
showToast(error.message || "切换账号失败");
button.disabled = false;
}
}
async function openAdminSettings(refreshOnly = false) {
if (state.user?.role !== "admin") return;
if (!refreshOnly && !elements.adminDialog.open) elements.adminDialog.showModal();
const status = document.querySelector("#adminConnectionStatus");
status.textContent = "正在读取系统状态";
try {
const payload = await apiRequest("/api/admin/settings");
const data = payload.data || {};
const llm = payload.llm || {};
const membership = payload.membership || {};
status.textContent = `公共行情${data.configured ? "已配置" : "未配置"} · ${number(data.snapshot_dates)} 个交易日 · ${number(data.snapshot_records)} 条记录`;
status.classList.toggle("connected", Boolean(data.configured));
setText("systemDataStatus", data.background_refresh_enabled ? "后台刷新已启用" : "后台刷新已暂停");
document.querySelector("#systemTokenInput").value = "";
document.querySelector("#systemBackgroundRefresh").checked = Boolean(data.background_refresh_enabled);
document.querySelector("#memberDailyLimit").value = number(membership.member_daily_limit) || 50;
renderModelPool(llm.models || [], llm.primary_model_id || "", llm.fallback_model_id || "");
renderAdminUsers(payload.users || []);
} catch (error) {
status.textContent = error.message || "系统配置读取失败";
}
}
function selectAdminPanel(panel) {
const selected = ["market", "models", "members"].includes(panel) ? panel : "market";
document.querySelector("#adminSectionSelect").value = selected;
document.querySelectorAll("[data-admin-panel]").forEach((item) => {
item.hidden = item.dataset.adminPanel !== selected;
});
}
function renderModelPool(models, primaryId = "", fallbackId = "") {
state.adminModels = models.map((item) => ({ ...item, api_key: item.api_key || "" }));
const container = document.querySelector("#modelPoolList");
container.innerHTML = state.adminModels.map((item, index) => `
<article class="model-pool-row" data-model-id="${escapeHtml(item.id)}">
<div class="model-pool-heading"><strong>${escapeHtml(item.name || `模型 ${index + 1}`)}</strong><span>${item.configured ? "已保存密钥" : "待配置"}</span></div>
<div class="model-pool-fields">
<label class="form-field"><span>显示名称 *</span><input data-model-field="name" maxlength="50" value="${escapeHtml(item.name || "")}" required></label>
<label class="form-field"><span>API Base URL *</span><input data-model-field="base_url" type="url" value="${escapeHtml(item.base_url || "https://api.openai.com/v1")}" required></label>
<label class="form-field"><span>模型标识 *</span><input data-model-field="model" maxlength="100" value="${escapeHtml(item.model || "")}" required></label>
<label class="form-field"><span>API Key${item.configured ? "" : " *"}</span><input data-model-field="api_key" type="password" autocomplete="off" maxlength="300" placeholder="${item.configured ? "留空保留已保存的 Key" : "输入 API Key"}" ${item.configured ? "" : "required"}></label>
</div>
<div class="model-test-row"><button class="button" type="button" data-test-model>测试连接</button><span class="model-test-status" aria-live="polite">未测试</span><button class="icon-button model-delete-button" type="button" data-delete-model aria-label="删除模型" title="删除模型"><i data-lucide="trash-2"></i></button></div>
</article>
`).join("") || '<div class="empty-state">模型池为空,请先添加模型</div>';
updateModelRoleOptions(primaryId, fallbackId);
container.querySelectorAll("[data-test-model]").forEach((button) => button.addEventListener("click", () => testPlatformModel(button.closest("[data-model-id]"))));
container.querySelectorAll("[data-delete-model]").forEach((button) => button.addEventListener("click", () => deletePlatformModel(button.closest("[data-model-id]"))));
container.querySelectorAll("[data-model-field='name']").forEach((input) => input.addEventListener("input", updateModelRoleLabels));
refreshIcons();
}
function collectModelPool() {
const saved = new Map(state.adminModels.map((item) => [item.id, item]));
return [...document.querySelectorAll("#modelPoolList [data-model-id]")].map((row) => ({
id: row.dataset.modelId,
name: row.querySelector("[data-model-field='name']").value.trim(),
base_url: row.querySelector("[data-model-field='base_url']").value.trim(),
model: row.querySelector("[data-model-field='model']").value.trim(),
api_key: row.querySelector("[data-model-field='api_key']").value.trim(),
configured: Boolean(saved.get(row.dataset.modelId)?.configured),
}));
}
function updateModelRoleOptions(primaryId = document.querySelector("#platformPrimaryModelSelect").value, fallbackId = document.querySelector("#platformFallbackModelSelect").value) {
const models = collectModelPool();
const options = models.map((item) => `<option value="${escapeHtml(item.id)}">${escapeHtml(item.name || item.model || "未命名模型")}</option>`).join("");
const primary = document.querySelector("#platformPrimaryModelSelect");
const fallback = document.querySelector("#platformFallbackModelSelect");
primary.innerHTML = models.length ? options : '<option value="">暂无模型</option>';
fallback.innerHTML = `<option value="">不启用辅助模型</option>${options}`;
primary.value = models.some((item) => item.id === primaryId) ? primaryId : models[0]?.id || "";
fallback.value = models.some((item) => item.id === fallbackId) && fallbackId !== primary.value ? fallbackId : "";
}
function updateModelRoleLabels() {
updateModelRoleOptions();
}
function addPlatformModel() {
const models = collectModelPool();
const id = `model-${Date.now()}-${Math.floor(Math.random() * 10000)}`;
models.push({ id, name: `模型 ${models.length + 1}`, base_url: "https://api.openai.com/v1", model: "", api_key: "", configured: false });
renderModelPool(models, document.querySelector("#platformPrimaryModelSelect").value || id, document.querySelector("#platformFallbackModelSelect").value);
document.querySelector(`[data-model-id="${CSS.escape(id)}"] [data-model-field="name"]`)?.focus();
}
function deletePlatformModel(row) {
if (!row) return;
const id = row.dataset.modelId;
const primary = document.querySelector("#platformPrimaryModelSelect").value;
const fallback = document.querySelector("#platformFallbackModelSelect").value;
if (id === primary || id === fallback) {
showToast("请先为主模型或辅助模型选择其他模型,再删除当前模型");
return;
}
const models = collectModelPool().filter((item) => item.id !== id);
renderModelPool(models, primary, fallback);
}
function renderAdminUsers(users) {
const container = document.querySelector("#adminUsersList");
container.innerHTML = users.map((user) => {
const admin = user.role === "admin";
const member = Boolean(user.membership_subscribed);
const identityLabels = [admin ? "管理员" : "", member ? "会员有效" : "普通用户"].filter(Boolean).join(" · ");
const expiry = member
? (user.membership_expires_at ? `有效至 ${membershipDateDisplay(user.membership_expires_at)}` : "永久有效")
: user.membership_status === "suspended"
? "会员已停用"
: user.membership_status === "active" && user.membership_expires_at
? `已于 ${membershipDateDisplay(user.membership_expires_at)} 到期`
: "尚未开通";
return `<article class="admin-user-row" data-admin-user="${number(user.id)}">
<div class="admin-user-identity"><strong>${escapeHtml(user.username)}</strong><span>${escapeHtml(identityLabels)}</span><small>${escapeHtml(expiry)}</small></div>
<div class="admin-user-usage">今日调用 <b>${number(user.used_today)}</b></div>
<form class="membership-form">
<input type="hidden" name="user_id" value="${number(user.id)}">
<label><span>状态</span><select name="status"><option value="inactive" ${user.membership_status === "inactive" ? "selected" : ""}>未开通</option><option value="active" ${user.membership_status === "active" ? "selected" : ""}>有效</option><option value="suspended" ${user.membership_status === "suspended" ? "selected" : ""}>停用</option></select></label>
<label><span>开通 / 续期时长</span><select name="duration"><option value="">选择时长</option><option value="1_month">1个月</option><option value="3_months">3个月</option><option value="12_months">12个月</option><option value="3_years">3年</option><option value="permanent">永久</option></select></label>
<div class="membership-expiry"><span>当前到期</span><strong>${escapeHtml(expiry)}</strong></div>
<button class="button" type="submit">应用</button>
</form>
</article>`;
}).join("") || '<div class="empty-state">暂无注册用户</div>';
container.querySelectorAll(".membership-form").forEach((form) => form.addEventListener("submit", saveMembership));
}
async function saveMembership(event) {
event.preventDefault();
const form = event.currentTarget;
const data = Object.fromEntries(new FormData(form).entries());
const button = form.querySelector("button[type='submit']");
button.disabled = true;
try {
const payload = await apiRequest("/api/admin/membership", "POST", data);
renderAdminUsers(payload.users || []);
showToast("会员状态已更新");
} catch (error) {
showToast(error.message || "会员状态保存失败");
} finally {
button.disabled = false;
}
}
async function saveMarketSettings(event) {
event.preventDefault();
const button = event.currentTarget.querySelector("button[type='submit']");
button.disabled = true;
try {
await apiRequest("/api/admin/settings", "POST", {
tushare_token: document.querySelector("#systemTokenInput").value.trim(),
background_refresh_enabled: document.querySelector("#systemBackgroundRefresh").checked,
});
document.querySelector("#systemTokenInput").value = "";
showToast("行情配置已保存");
await openAdminSettings(true);
} catch (error) {
showToast(error.message || "系统配置保存失败");
} finally {
button.disabled = false;
}
}
async function saveModelPool(event) {
event.preventDefault();
const button = event.currentTarget.querySelector("button[type='submit']");
button.disabled = true;
try {
await apiRequest("/api/admin/settings", "POST", {
models: collectModelPool(),
primary_model_id: document.querySelector("#platformPrimaryModelSelect").value,
fallback_model_id: document.querySelector("#platformFallbackModelSelect").value,
});
showToast("模型池已保存");
await openAdminSettings(true);
} catch (error) {
showToast(error.message || "模型池保存失败");
} finally {
button.disabled = false;
}
}
async function saveMembershipSettings(event) {
event.preventDefault();
const button = event.currentTarget.querySelector("button[type='submit']");
button.disabled = true;
try {
await apiRequest("/api/admin/settings", "POST", {
member_daily_limit: number(document.querySelector("#memberDailyLimit").value),
});
showToast("会员调用额度已保存");
await openAdminSettings(true);
} catch (error) {
showToast(error.message || "会员调用额度保存失败");
} finally {
button.disabled = false;
}
}
async function testPlatformModel(row) {
if (!row) return;
const button = row.querySelector("[data-test-model]");
const status = row.querySelector(".model-test-status");
const profile = collectModelPool().find((item) => item.id === row.dataset.modelId) || {};
button.disabled = true;
status.textContent = "连接中";
try {
const payload = await apiRequest("/api/admin/settings/test", "POST", { model_id: row.dataset.modelId, profile });
status.textContent = `已连通 · ${number(payload.result.latency_ms)} ms`;
status.className = "model-test-status success";
} catch (error) {
status.textContent = error.message;
status.className = "model-test-status failure";
} finally {
button.disabled = false;
}
}
function membershipDateDisplay(value) {
if (!value) return "";
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return String(value).slice(0, 10);
return new Intl.DateTimeFormat("zh-CN", { year: "numeric", month: "2-digit", day: "2-digit" }).format(parsed);
}
function 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("炸板池", state.dashboard?.broken || [], commonReviewColumns());
}
function exportDown() {
exportRows("跌停板", state.dashboard?.down_limits || [], commonReviewColumns());
}
function exportYesterday() {
exportRows("昨日涨停", state.dashboard?.yesterday_limits || [], [
["股票代码", "code"], ["股票名称", "name"], ["昨日高度", "prior_streak"],
["今日涨幅%", "current_change"], ["今日结果", "outcome"], ["当前高度", "current_streak"],
["所属板块", "sector"], ["涨停逻辑", "reason"],
]);
}
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 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('"', '""')}"`;
}
function outcomeClass(outcome) {
return { "晋级": "outcome-advance", "炸板": "outcome-broken", "跌停": "outcome-down", "断板": "outcome-open" }[outcome] || "outcome-open";
}
function trendClass(trend) {
return { "升温": "trend-hot", "降温": "trend-cool", "持平": "trend-flat" }[trend] || "trend-flat";
}
function changeClass(value) {
return number(value) > 0 ? "up" : number(value) < 0 ? "down" : "";
}
function sentimentLabel(score) {
const value = number(score);
if (value >= 80) return "情绪高涨";
if (value >= 60) return "情绪偏强";
if (value >= 40) return "情绪中性";
if (value >= 20) return "情绪偏弱";
return "情绪冰点";
}
function streakLabel(streak) {
const value = Math.max(1, number(streak));
return value === 1 ? "首板" : `${value}板`;
}
function signed(value) {
const parsed = number(value);
return `${parsed > 0 ? "+" : ""}${formatNumber(parsed, 2)}`;
}
function formatNumber(value, digits = 0) {
return new Intl.NumberFormat("zh-CN", { minimumFractionDigits: digits, maximumFractionDigits: digits }).format(number(value));
}
function formatTimestamp(value) {
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return "--";
return parsed.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", second: "2-digit" });
}
function dashboardDataTimestamp(meta = {}) {
const tradeDate = displayCompactDate(meta.trade_date);
if (tradeDate === "--") return "--";
const intraday = tradeDate === todayString() && Boolean(meta.realtime) && !["closed", "after_hours"].includes(String(meta.market_status || ""));
if (intraday) {
const updated = new Date(meta.updated_at);
if (!Number.isNaN(updated.getTime())) {
const dateText = `${updated.getFullYear()}-${String(updated.getMonth() + 1).padStart(2, "0")}-${String(updated.getDate()).padStart(2, "0")}`;
const timeText = updated.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false });
return `${dateText} ${timeText}`;
}
}
return `${tradeDate} 15:00`;
}
function formatMoneyMillion(value) {
const parsed = number(value);
const sign = parsed > 0 ? "+" : "";
if (Math.abs(parsed) >= 100) return `${sign}${formatNumber(parsed / 100, 2)} 亿`;
return `${sign}${formatNumber(parsed * 100, 0)} 万`;
}
function displayCompactDate(value) {
const text = String(value || "").replaceAll("-", "");
if (text.length !== 8) return value || "--";
return `${text.slice(0, 4)}-${text.slice(4, 6)}-${text.slice(6, 8)}`;
}
async function apiRequest(url, method = "GET", body = null, requestOptions = {}) {
const options = { method, headers: {}, signal: requestOptions.signal };
if (method !== "GET" && state.csrfToken) {
options.headers["X-CSRF-Token"] = state.csrfToken;
}
if (body !== null) {
options.headers["Content-Type"] = "application/json";
options.body = JSON.stringify(body);
}
const response = await fetch(url, options);
const payload = await response.json();
if (response.status === 401 && !url.startsWith("/api/auth/")) {
showAuthGate("登录状态已失效,请重新登录。");
}
if (!response.ok || payload.error) throw new Error(payload.error || "请求失败");
return payload;
}
function setLoading(loading, text = "正在加载复盘数据", context = "default") {
elements.loading.hidden = !loading;
elements.loading.dataset.context = loading ? context : "default";
setText("loadingTitle", text);
setText(
"loadingHint",
context === "screener"
? "正在完成因子筛选、候选排序与历史样本回测,这通常需要一点时间"
: "请稍候",
);
}
function setStatus(text) {
setText("statusText", text);
}
let toastTimer;
function showToast(message) {
clearTimeout(toastTimer);
elements.toast.textContent = message;
elements.toast.hidden = false;
toastTimer = setTimeout(() => { elements.toast.hidden = true; }, 3600);
}
function setText(id, value) {
const element = document.getElementById(id);
if (element) element.textContent = value;
}
function motionEnabled() {
return !window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function refreshIcons() {
if (!window.lucide?.createIcons) return;
window.lucide.createIcons({ attrs: { "aria-hidden": "true" } });
}
function initializeApplicationShell() {
let collapsed = false;
try {
collapsed = window.localStorage.getItem("xiaobai-sidebar-collapsed") === "1";
} catch (_error) {
collapsed = false;
}
document.body.classList.toggle("sidebar-collapsed", collapsed);
updateSidebarControl();
syncNavigationState(state.activeView);
}
function toggleSidebar() {
const collapsed = document.body.classList.toggle("sidebar-collapsed");
try {
window.localStorage.setItem("xiaobai-sidebar-collapsed", collapsed ? "1" : "0");
} catch (_error) {
// The visual state still works when storage is unavailable.
}
updateSidebarControl();
}
function updateSidebarControl() {
const button = document.querySelector("#sidebarCollapseButton");
if (!button) return;
const automaticallyCollapsed = window.innerWidth <= 1023 && window.innerWidth > 720;
const collapsed = document.body.classList.contains("sidebar-collapsed") || automaticallyCollapsed;
button.setAttribute("aria-expanded", String(!collapsed));
button.setAttribute("aria-label", collapsed ? "展开侧栏" : "收起侧栏");
button.title = collapsed ? "展开侧栏" : "收起侧栏";
const label = button.querySelector("span");
if (label) label.textContent = collapsed ? "展开侧栏" : "收起侧栏";
}
function toggleHeaderCommandMenu(force) {
const menu = document.querySelector("#headerCommandGroup");
const button = document.querySelector("#headerMenuButton");
if (!menu || !button) return;
const open = typeof force === "boolean" ? force : !menu.classList.contains("is-open");
menu.classList.toggle("is-open", open);
button.setAttribute("aria-expanded", String(open));
}
function toggleAccountDropdown(force, returnFocus = false) {
const menu = document.querySelector("#accountDropdown");
const button = document.querySelector("#accountButton");
if (!menu || !button) return;
const open = typeof force === "boolean" ? force : menu.hidden;
menu.hidden = !open;
button.setAttribute("aria-expanded", String(open));
document.querySelector(".account-menu-shell")?.classList.toggle("is-open", open);
if (open) {
setText("accountMenuName", state.user?.username || "当前账号");
const membership = state.user?.membership || {};
setText("accountMenuRole", state.user?.role === "admin" ? (membership.subscribed ? "管理员 · 会员" : "管理员") : membership.subscribed ? "会员用户" : "普通用户");
} else if (returnFocus) {
button.focus();
}
}
function handleAccountMenuKeydown(event) {
const menu = document.querySelector("#accountDropdown");
if (!menu) return;
if (menu.hidden) {
if (document.activeElement?.id === "accountButton" && event.key === "ArrowDown") {
event.preventDefault();
toggleAccountDropdown(true);
menu.querySelector('[role="menuitem"]')?.focus();
}
return;
}
const items = [...menu.querySelectorAll('[role="menuitem"]:not(:disabled)')];
if (!items.length) return;
const current = items.indexOf(document.activeElement);
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
const offset = event.key === "ArrowDown" ? 1 : -1;
items[(current + offset + items.length) % items.length].focus();
} else if (event.key === "Home" || event.key === "End") {
event.preventDefault();
items[event.key === "Home" ? 0 : items.length - 1].focus();
}
}
function syncNavigationState(viewId) {
const marketView = MARKET_VIEWS.has(viewId);
document.body.dataset.activeView = viewId;
document.querySelectorAll(".module-tab").forEach((button) => {
button.classList.toggle("active", button.dataset.view === viewId);
button.classList.toggle("mobile-active", marketView && button.dataset.view === "limitPool");
});
const selector = document.querySelector("#mobileMarketSelector");
const select = document.querySelector("#mobileMarketViewSelect");
if (selector) selector.hidden = !marketView;
if (select && marketView) select.value = viewId;
toggleHeaderCommandMenu(false);
toggleAccountDropdown(false);
}
function updateSentimentGauge(rawScore) {
const gauge = document.querySelector("#sentimentGauge");
if (!gauge) return;
const score = clamp(rawScore, 0, 100);
const previous = Number(gauge.dataset.score);
gauge.dataset.score = String(score);
gauge.style.setProperty("--score", score);
if (!motionEnabled() || !Number.isFinite(previous) || Math.abs(previous - score) < 15) return;
gauge.classList.remove("sentiment-pulse");
void gauge.offsetWidth;
gauge.classList.add("sentiment-pulse");
gauge.addEventListener("animationend", () => gauge.classList.remove("sentiment-pulse"), { once: true });
}
function animateMetric(id, rawValue, formatter = (value) => value) {
const element = document.getElementById(id);
const target = Number(rawValue);
if (!element || !Number.isFinite(target)) {
setText(id, formatter(rawValue));
return;
}
const storedValue = Number(element.dataset.metricValue);
const previous = Number.isFinite(storedValue) ? storedValue : 0;
element.dataset.metricValue = String(target);
const existingFrame = metricAnimationFrames.get(element);
if (existingFrame) cancelAnimationFrame(existingFrame);
if (!motionEnabled() || previous === target) {
element.textContent = formatter(target);
return;
}
element.classList.remove("metric-changed");
void element.offsetWidth;
element.classList.add("metric-changed");
const startedAt = performance.now();
const duration = 560;
const update = (now) => {
const progress = Math.min(1, (now - startedAt) / duration);
const eased = 1 - (1 - progress) ** 3;
element.textContent = formatter(previous + (target - previous) * eased);
if (progress < 1) {
metricAnimationFrames.set(element, requestAnimationFrame(update));
} else {
element.textContent = formatter(target);
metricAnimationFrames.delete(element);
setTimeout(() => element.classList.remove("metric-changed"), 80);
}
};
metricAnimationFrames.set(element, requestAnimationFrame(update));
}
function animateRows(container) {
if (!container) return;
const rows = [...container.children].filter((item) => item.matches("tr, [data-code]"));
if (!motionEnabled()) {
rows.forEach((row) => row.classList.remove("row-pending", "row-enter"));
return;
}
const unseenRows = rows.filter((row) => row.dataset.motionSeen !== "1");
unseenRows.slice(0, 12).forEach((row, index) => {
row.dataset.motionSeen = "1";
row.classList.remove("row-pending", "row-enter");
row.style.setProperty("--row-delay", `${index * 24}ms`);
requestAnimationFrame(() => row.classList.add("row-enter"));
row.addEventListener("animationend", () => row.classList.remove("row-enter"), { once: true });
});
if (!("IntersectionObserver" in window)) {
unseenRows.slice(12).forEach((row) => { row.dataset.motionSeen = "1"; });
return;
}
if (!rowAnimationObserver) {
rowAnimationObserver = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
const row = entry.target;
rowAnimationObserver.unobserve(row);
row.dataset.motionSeen = "1";
row.classList.remove("row-pending");
row.style.setProperty("--row-delay", "0ms");
requestAnimationFrame(() => row.classList.add("row-enter"));
row.addEventListener("animationend", () => row.classList.remove("row-enter"), { once: true });
});
}, { threshold: 0.08, rootMargin: "0px 0px 40px 0px" });
}
unseenRows.slice(12).forEach((row) => {
row.classList.add("row-pending");
rowAnimationObserver.observe(row);
});
}
function waitForMotion(duration) {
return new Promise((resolve) => setTimeout(resolve, motionEnabled() ? duration : 0));
}
function number(value) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
}
function clamp(value, minimum, maximum) {
return Math.min(maximum, Math.max(minimum, number(value)));
}
function escapeHtml(value) {
return String(value ?? "").replace(/[&<>"']/g, (character) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#039;",
})[character]);
}
function todayString() {
return localDateString(new Date());
}
function localDateString(value) {
const year = value.getFullYear();
const month = String(value.getMonth() + 1).padStart(2, "0");
const day = String(value.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
function parseLocalDate(value) {
const [year, month, day] = value.split("-").map(Number);
return new Date(year, month - 1, day);
}