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 ` ${escapeHtml(displayCompactDate(row.trade_date))} ${number(row.score)} ${escapeHtml(row.phase)} ${escapeHtml(row.direction)} ${number(row.limit_up_count)} ${number(row.first_board_count)} ${number(row.second_board_count)} ${number(row.three_plus_count)} ${number(row.max_height)}板 ${number(row.broken_count)} ${number(row.limit_down_count)} ${number(row.previous_limit_count)} ${number(row.previous_positive_count)} ${formatNumber(row.previous_positive_rate, 1)}% `; }).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) => `
${escapeHtml(item.label)}权重 ${number(item.weight)}${formatNumber(item.score, 1)}
${escapeHtml(item.summary)}
`).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) => ` ${index + 1} ${escapeHtml(row.code)} ${escapeHtml(row.name)} ${streakLabel(row.streak)} ${signed(row.change)}% ${formatNumber(row.price, 2)} ${escapeHtml(row.sector || "其他")} ${escapeHtml(row.reason || "--")} ${escapeHtml(row.first_time || "--")} ${escapeHtml(row.last_time || "--")} ${number(row.open_times)} ${formatNumber(row.turnover_rate, 2)}% ${formatNumber(row.amount_billion, 2)} 亿 ${formatNumber(row.seal_amount_million, 0)} 万 `).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) => ` ${index + 1}${escapeHtml(row.code)} ${escapeHtml(row.name)}${signed(row.change)}% ${formatNumber(row.price, 2)}${escapeHtml(row.sector)} ${escapeHtml(row.reason || "--")}${escapeHtml(row.first_time || "--")} ${escapeHtml(row.last_time || "--")}${number(row.open_times)} ${formatNumber(row.turnover_rate, 2)}%${formatNumber(row.amount_billion, 2)} 亿 `).join(""); bindStockRows(body); } function renderDownTable(rows) { setText("downCount", `${rows.length} 只`); const body = document.querySelector("#downTableBody"); body.innerHTML = rows.map((row, index) => ` ${index + 1}${escapeHtml(row.code)} ${escapeHtml(row.name)}${signed(row.change)}% ${formatNumber(row.price, 2)}${escapeHtml(row.sector)} ${escapeHtml(row.reason || "--")}${number(row.streak)} ${formatNumber(row.turnover_rate, 2)}%${formatNumber(row.amount_billion, 2)} 亿 `).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) => ` ${index + 1}${escapeHtml(row.code)} ${escapeHtml(row.name)}${streakLabel(row.prior_streak)} ${signed(row.current_change)}% ${escapeHtml(row.outcome)} ${number(row.current_streak) ? streakLabel(row.current_streak) : "--"} ${escapeHtml(row.sector || "其他")}${escapeHtml(row.reason || "--")} `).join(""); bindStockRows(document.querySelector("#yesterdayTableBody")); } function renderPerformance(rows) { document.querySelector("#performanceCards").innerHTML = rows.map((row) => `
${escapeHtml(row.label)}${number(row.count)} 只
${formatNumber(row.advance_rate, 1)}%晋级率
收红 ${formatNumber(row.positive_rate, 1)}%均涨 ${signed(row.average_change)}%
`).join("") || '
暂无昨日涨停统计
'; document.querySelector("#performanceTableBody").innerHTML = rows.map((row) => ` ${escapeHtml(row.label)}${number(row.count)} ${number(row.advanced)}${formatNumber(row.advance_rate, 1)}% ${formatNumber(row.positive_rate, 1)}% ${signed(row.average_change)}% `).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 = '
正在读取轮动历史
'; 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 = `
${escapeHtml(error.message || "轮动历史加载失败")}
`; showToast(error.message || "轮动历史加载失败"); } finally { state.rotationLoading = false; } } function renderRotationHistory() { const rows = state.rotationHistory?.rows || []; const selected = state.rotationSelectedSector; const container = document.querySelector("#rotationHistory"); if (!rows.length) { container.innerHTML = '
尚无连续交易日的板块数据
'; 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 `
${(day.sectors || []).length} 个热点
${(day.sectors || []).map((sector) => ` `).join("")}
`; }).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 ` ${number(row.rank)}${escapeHtml(row.name)} ${escapeHtml(row.trend)} ${number(row.count)}${number(row.previous_count)} ${number(row.delta) > 0 ? "+" : ""}${number(row.delta)}
${formatNumber(strength, 0)}
${streakLabel(row.max_streak || 1)} ${signed(sector.change)}% ${escapeHtml(row.leader || sector.leader || "--")}${formatNumber(row.amount_billion, 1)} 亿 `; }).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 `
${escapeHtml(group.label)} ${escapeHtml(names || "--")}${group.count}只
`; }).join("") || '
暂无梯队数据
'; } function renderSectorMini(sectors) { document.querySelector("#sectorMini").innerHTML = sectors.slice(0, 7).map((sector) => `
${escapeHtml(sector.name)} ${number(sector.count)}
`).join("") || '
暂无板块数据
'; } 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 `
${level}
${escapeHtml(group.label)}${number(group.count)} 只
${stocks.map((stock) => ``).join("")}
${group.stocks.length > limit ? `` : ""}
`; }).join("") || '
暂无连板数据
'; 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]) => `
${label}${value}
`).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 ` `; }).join(""); const hitZoneMarkup = traders.map((trader) => ` `).join(""); container.innerHTML = traders.length ? `${cardMarkup}
${hitZoneMarkup}
` : `
${escapeHtml(state.dragonFilter === "unclassified" ? "待归类席位请在下方管理" : emptyMessage)}
`; 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 = '
选择一位游资查看操作明细
'; return; } container.innerHTML = `
当日操作明细

${escapeHtml(trader.name)}

${escapeHtml(trader.description || "按当日公开龙虎榜席位汇总")}

买入
${formatMoneyMillion(trader.buy_million)}
卖出
${formatMoneyMillion(trader.sell_million)}
净额
${formatMoneyMillion(trader.net_buy_million)}
${(trader.operations || []).map((operation) => ` `).join("")}
股票方向涨幅买入卖出净额关联席位标签 / 上榜原因
${escapeHtml(operation.name)}${escapeHtml(operation.code)} ${escapeHtml(operation.direction)} ${operation.change == null ? "--" : `${signed(operation.change)}%`} ${formatMoneyMillion(operation.buy_million)} ${formatMoneyMillion(operation.sell_million)} ${formatMoneyMillion(operation.net_buy_million)} ${escapeHtml(operation.seat_name)} ${escapeHtml(operation.tag && operation.tag !== "--" ? operation.tag : operation.reason || "--")}
`; 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) => `
${escapeHtml(seat.seat_name)} ${number(seat.operation_count)} 笔 · ${number(seat.stock_count)} 股 ${formatMoneyMillion(seat.net_buy_million)}
`).join("") || '
当前席位均已归类
'; 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) => ` ${escapeHtml(item.code)}${escapeHtml(item.name)} ${escapeHtml(item.sector || "其他")} `).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) => `
${note.stock_name ? `${escapeHtml(note.stock_name)}` : ""}
复盘

${escapeHtml(note.content || "--")}

计划

${escapeHtml(note.plan || "--")}

`).join("") || '
暂无复盘记录
'; 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) => ` `).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) => ` `).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) => ` `).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 = `
${escapeHtml(selected?.name || "问师")}

${escapeHtml(selected?.tagline || selected?.description || "选择一个问题开始对话")}

`; } else { container.innerHTML = state.mentorMessages.map((message) => `
${message.role === "user" ? "我" : escapeHtml(selected?.name || "问师")}
${message.role === "assistant" ? formatMentorAnswer(message.content) : escapeHtml(message.content)}
${message.meta ? `${escapeHtml(message.meta)}` : ""}
`).join(""); if (state.mentorLoading) { container.insertAdjacentHTML("beforeend", `
${escapeHtml(selected?.name || "问师")}

正在读取复盘数据并推演...

`); } } 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 `${formatMentorInline(heading[1])}`; if (/^-{3,}$/.test(line.trim())) return ''; if (line.startsWith("> ")) return `${formatMentorInline(line.slice(5))}`; return formatMentorInline(line); }).join("
"); } function formatMentorInline(content) { return content.replace(/\*\*(.+?)\*\*/g, "$1"); } 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 = '
载入股票后查看六爻数据状态
'; 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" ? `` : field.type === "text" ? `` : ``; return ``; }).join(""); const reasons = (check.reasons || []).map((reason) => `
  • ${escapeHtml(reason)}
  • `).join(""); return `
    ${escapeHtml(stateLabel)} ${escapeHtml(check.position)} · ${escapeHtml(check.layer)}${escapeHtml(check.formula)} ${check.line_value ? escapeHtml(lineValueLabel[check.line_value] || check.line_value) : "待定"}得分 ${escapeHtml(score)}
    ${reasons ? `` : `

    ${(check.evidence || []).map(escapeHtml).join(";") || "数据已通过安全门"}

    `}
    ${fields}
    `; }).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) => `
    ${escapeHtml(item.lines)} · ${escapeHtml(item.layer)} ${escapeHtml(heavenSourcePhrase(item))} ${escapeHtml(item.detail || "")}
    `).join("") : '

    暂无可核验的数据来源。

    '; }; 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) => `
    未通过${escapeHtml(issue)}
    `), ...sourceRows.map((item) => `
    ${escapeHtml(item.lines)} · ${escapeHtml(item.layer)} ${escapeHtml(heavenSourcePhrase(item))} ${escapeHtml(item.detail || "")}
    `), ].join(""); document.querySelector("#heavenIndexStrip").innerHTML = "

    天象尚未应时,待三才数据齐备后再观。

    "; 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) => `
    ${escapeHtml(item.level)}${escapeHtml(item.state)}
    内 ${signedScore(item.inner)} 外 ${signedScore(item.outer)}
    `).join(""); const indexContext = chart.index_context || {}; document.querySelector("#heavenIndexStrip").innerHTML = (indexContext.indices || []).length ? indexContext.indices.map((item) => `
    ${escapeHtml(item.name)}${signed(item.pct_chg)}%5日 ${signed(item.return_5d)}%
    `).join("") : `

    ${escapeHtml(indexContext.notice || "指数数据暂不可用")}

    `; 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) => `

    ${group.caption}

    ${group.lines.map((line) => `
    ${escapeHtml(line.position_name)} ${hexagramLineGraphic(line.value)}
    ${escapeHtml(line.role || line.line_name)} · ${line.value}${line.moving ? " 变" : ""} ${(line.evidence || []).map(escapeHtml).join(";")}
    `).join("")}
    `).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]) => `
    ${escapeHtml(label)}${escapeHtml(value)}${escapeHtml(detail)}
    `).join(""); const framework = field.framework || {}; setText("qiFrameworkPrinciple", framework.principle || "--"); const layerLabels = { year: "年运与岁气", current: "客主加临", day: "日辰触发" }; document.querySelector("#qiFrameworkLayers").innerHTML = (framework.layers || []).map((layer) => `
    ${escapeHtml(layerLabels[layer.id] || layer.label)} ${escapeHtml(layer.dominant)}气 ${escapeHtml(layer.summary)}
    ${(layer.balance || []).map((item) => ``).join("")}
    `).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) => `
    ${escapeHtml(item.element)}
    ${escapeHtml(item.motion)} · ${escapeHtml(item.mind)}
    ${number(item.percent)}%
    `).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) => `
    ${escapeHtml(item.element)} ${escapeHtml(item.motion)}${number(item.percent)}%
    `).join(""); sectorContainer.innerHTML = catalog.length ? catalog.map((group) => { const element = group.element; const items = group.industries || []; return `
    ${escapeHtml(element)}属性 ${number(group.count)} 类
    `; }).join("") : '

    行业五行归类尚未建立。

    '; 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) => `
    ${escapeHtml(item.element)} ${escapeHtml(item.name)}
    `).join("") : '

    暂无手动归类

    '; 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) => `${escapeHtml(item)}`).join("") : emptyText; container.innerHTML = `
    日主 ${escapeHtml(personal.day_master.stem)} ${escapeHtml(personal.day_master.element)} ${escapeHtml(personal.day_master.strength)}
    十神喜恶
    偏宜

    ${tenGods.favorable.map((item) => `${escapeHtml(item)}`).join("") || "--"}

    偏慎

    ${tenGods.caution.map((item) => `${escapeHtml(item)}`).join("") || "--"}

    五行喜忌
    偏喜

    ${preferenceTags(elementTendency.favorable || [])}

    偏忌

    ${preferenceTags(elementTendency.caution || [])}

    当前作用 · 流年 ${escapeHtml(personal.current.ten_gods.year.stem)} · 流月 ${escapeHtml(personal.current.ten_gods.month.stem)} · 流日 ${escapeHtml(personal.current.ten_gods.day.stem)} ${escapeHtml(personal.current.tone)}

    ${escapeHtml(personal.current.operation_note)}

    ${escapeHtml(personal.balance_tendency.method)}
    查看个人五行结构
    ${personal.element_balance.map((item) => `
    ${escapeHtml(item.element)}${number(item.percent)}%
    `).join("")}
    `; } function renderHexagramLines(containerId, lines, includeEvidence = false) { const container = document.querySelector(`#${containerId}`); container.innerHTML = [...lines].reverse().map((line) => `
    ${escapeHtml(line.position_name)} ${hexagramLineGraphic(line.value)}
    ${escapeHtml(line.role || line.line_name)} · ${line.value}${line.moving ? " 变" : ""} ${includeEvidence ? `${(line.evidence || []).map(escapeHtml).join(";")}` : `${escapeHtml(line.text || "")}`}
    `).join(""); } function hexagramLineGraphic(value) { const yang = value % 2 === 1; return ` ${yang ? "" : ""}${[6, 9].includes(value) ? `${value === 9 ? "○" : "×"}` : ""} `; } 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 = `
    ${formatMentorAnswer(result.answer)}
    ${escapeHtml(result.meta)}`; } function initializeHeartAtmosphere() { const whisperContainer = document.querySelector("#heartWhispers"); if (whisperContainer && !whisperContainer.children.length) { whisperContainer.innerHTML = HEART_WHISPERS.map(([text, x, y, index]) => ` ${escapeHtml(text)} `).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 = `${heartSound.enabled ? "有声" : "静音"}`; 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 = '按住
    重新成卦
    '; 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 ? `按住
    摇${nextPosition}
    ` : '正在
    成卦
    '; button.disabled = heartCastingBusy || state.heartLines.length >= 6; const rows = []; for (let index = 5; index >= 0; index -= 1) { const value = state.heartLines[index]; rows.push(`
    ${LINE_POSITIONS_CLIENT[index]} ${value ? hexagramLineGraphic(value) : ''}
    ${value ? `${lineValueName(value)} · ${value}` : "未得"}
    `); } 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) => ` `).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) => `
    ${escapeHtml(line.position_name)}${hexagramLineGraphic(line.value)}
    `).join(""); document.querySelector("#heartReadTexts").innerHTML = hexagram.lines.map((line) => `
    ${escapeHtml(line.line_name)}${line.moving ? " · 动" : ""}

    ${escapeHtml(line.text)}

    `).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) => ` ${index + 1}${escapeHtml(row.code)} ${escapeHtml(row.name)}${escapeHtml(row.sector)} ${formatNumber(row.score_display, 1)} ${row.historical_probability === null ? "样本不足" : `${formatNumber(row.historical_probability, 1)}%`}${number(row.probability_samples)} 个样本 ${signed(row.pct_chg)}% ${signed(row.return_5d)}% ${formatNumber(row.volume_ratio_5d, 2)}${formatNumber(row.sector_strength, 1)} ${escapeHtml(row.reason)} ${escapeHtml(row.risk_flags.join(";") || "--")} `).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]) => `
    ${label}${value}
    `).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 = '
    正在搜索
    '; 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 ``; }).join(""); sections.push(`

    ${label}

    ${rows}
    `); }); 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 = `

    ${escapeHtml(title)}

    ${escapeHtml(hint)}
    `; 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 = '
    正在加载交易数据
    '; 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 = `
    ${escapeHtml(error.message || "交易数据加载失败")}
    `; clearEntityDetailChart(error.message || "行情加载失败"); showToast(error.message || "详情加载失败"); } } function renderEntityDetailMetrics(metrics) { const container = document.querySelector("#entityDetailMetrics"); if (!metrics.length) { container.innerHTML = '
    暂无交易数据
    '; 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 `
    ${escapeHtml(metric.label)}
    ${escapeHtml(value)}${escapeHtml(metric.unit || "")}
    `; }).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 = '
    正在加载笔记
    '; 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) => `
    ${escapeHtml(item.name || `模型 ${index + 1}`)}${item.configured ? "已保存密钥" : "待配置"}
    未测试
    `).join("") || '
    模型池为空,请先添加模型
    '; 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) => ``).join(""); const primary = document.querySelector("#platformPrimaryModelSelect"); const fallback = document.querySelector("#platformFallbackModelSelect"); primary.innerHTML = models.length ? options : ''; fallback.innerHTML = `${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 `
    ${escapeHtml(user.username)}${escapeHtml(identityLabels)}${escapeHtml(expiry)}
    今日调用 ${number(user.used_today)}
    当前到期${escapeHtml(expiry)}
    `; }).join("") || '
    暂无注册用户
    '; 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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'", })[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); }