const { clamp, displayCompactDate, escapeHtml, formatNumber, formatTimestamp, localDateString, number, parseLocalDate, todayString, } = window.XiaobaiUI; const { emptyStateHtml, renderEmptyState, } = window.XiaobaiComponents; const HEART_BREATH_INHALE_MS = 3_000; const HEART_BREATH_HOLD_MS = 2_000; const HEART_BREATH_EXHALE_MS = 4_000; const HEART_BREATH_PREPARE_MS = 1_000; const HEART_BREATH_CYCLE_MS = HEART_BREATH_INHALE_MS + HEART_BREATH_HOLD_MS + HEART_BREATH_EXHALE_MS; const HEART_BREATH_ACTIVE_MS = HEART_BREATH_CYCLE_MS * 5; const HEART_BREATH_TOTAL_MS = HEART_BREATH_PREPARE_MS + HEART_BREATH_ACTIVE_MS; const THEME_STORAGE_KEY = "xiaobaiTheme"; let activeThemeTransition = null; let themeSwitchSequence = 0; const state = window.XiaobaiState.create({ session: { user: null, csrfToken: "", authMode: "login", started: false, activeView: "sentimentCycleView", dashboardLoading: false, dashboardRequestSequence: 0, dashboardRequestDate: "", adminModels: [], globalSearchResults: [], globalSearchActiveIndex: -1, globalSearchRequestSequence: 0, }, market: { dashboard: null, filter: "all", query: "", sortKey: "streak", sortDirection: "desc", brokenQuery: "", brokenSortKey: "", brokenSortDirection: "desc", downQuery: "", downSortKey: "", downSortDirection: "asc", yesterdayFilter: "all", yesterdayQuery: "", yesterdaySortKey: "", yesterdaySortDirection: "desc", dragonTiger: null, dragonViewMode: "daily", dragonFilter: "all", dragonQuery: "", selectedDragonTraderId: "", hotMoneyProfiles: null, hotMoneyProfileQuery: "", selectedHotMoneyProfileId: "", rotationHistory: null, rotationHistoryKey: "", rotationSelectedSector: "", rotationSelectedDate: "", rotationMembers: null, rotationMembersKey: "", rotationMembersLoading: false, rotationOrder: localStorage.getItem("xiaobaiRotationOrder") === "latest" ? "latest" : "oldest", rotationLoading: false, auctionData: null, auctionDataset: "focus", auctionFilter: "all", auctionQuery: "", auctionSortKey: "attention_score", auctionSortDirection: "desc", auctionLoading: false, auctionTimer: null, themeLibrary: null, themeQuery: "", selectedThemeCode: "", themeDetail: null, themeLoading: false, popularityData: null, popularitySource: "combined", popularityQuery: "", popularityLoading: false, expandedLadderLevels: new Set(), ladderSortMode: "time", sentimentHistory: null, sentimentRange: 20, sentimentHistoryKey: "", sentimentLoading: false, }, details: { stockDetail: null, activeStock: null, stockDetailChartMode: "daily", stockDetailIntraday: null, stockDetailRequestSequence: 0, entityDetailItem: null, entityDetailPayload: null, entityDetailChartMode: "daily", entityDetailIntraday: null, entityDetailRequestSequence: 0, stockPreviewCode: "", stockPreviewType: "stock", stockPreviewItem: null, stockPreviewPayload: null, stockPreviewChart: "daily", stockPreviewFallback: null, initialStockOpened: false, }, review: { watchlist: [], watchlistSelection: null, watchlistSearchResults: [], watchlistSearchRequestSequence: 0, editingDailyNoteId: 0, notes: [], tradeEntries: [], tradeSummary: {}, editingTradeId: 0, alerts: [], alertFilter: "all", alertUnreadCount: 0, assistantMessages: [], assistantLoading: false, assistantController: null, }, screener: { screenerSetup: null, screenerSetupKey: "", screenerSetupRequestKey: "", screenerSetupPromise: null, selectedRegime: "", selectedStrategy: null, customStrategyDraft: null, screenerRunning: false, screenerRunningMode: "", screenerResults: { smart: null, curated: null, quant: null }, screenerResultContexts: { smart: null, curated: null, quant: null }, screenerResultStore: {}, screenerTracking: null, screenerMode: ["smart", "curated", "quant"].includes(localStorage.getItem("xiaobaiScreenerMode")) ? localStorage.getItem("xiaobaiScreenerMode") : "smart", curatedCategory: "全部", curatedSchool: "全部", curatedQuery: "", curatedViewMode: localStorage.getItem("xiaobaiCuratedViewMode") === "grid" ? "grid" : "list", selectedCuratedStrategyId: 0, quantFilters: [], quantScores: [], screenerMobileView: "strategy", }, mentor: { mentorSetup: null, selectedMentorId: "", mentorMessages: [], mentorLoading: false, mentorQuery: "", mentorGrade: "all", mentorDirectoryOpen: false, mentorSortMode: false, mentorSavingPreferences: false, mentorController: null, }, heaven: { heavenSetup: null, heavenManualData: null, personalField: null, heavenPanel: "trend", heavenInterpretations: { trend: "", fortune: "", heart: "" }, heavenReadingMode: "trend", heavenReadingTab: "current", heavenReadingHistory: { trend: [], fortune: [], heart: [] }, heavenReadingSelectedId: 0, heavenReadingLoading: false, heavenReadingError: "", heartStage: "intro", heartTimer: null, heartSeconds: HEART_BREATH_TOTAL_MS / 1000, heartBreathingEndsAt: 0, heartLines: [], heartThrows: [], heartHexagram: null, heartCurtainTimer: null, heartStageToken: 0, heartRevealToken: 0, heavenPerformanceKey: "", heavenPerformancePanels: new Set(), heavenPerformanceActive: "", heavenRequestSequence: 0, }, }); window.XiaobaiAPI.configure({ csrfToken: () => state.csrfToken, onUnauthorized: () => showAuthGate("登录状态已失效,请重新登录。"), }); const applicationShell = window.XiaobaiShell.create({ state, pages: window.XiaobaiPages, motionEnabled, animateRows, refreshIcons, tradeDate: () => displayCompactDate( state.dashboard?.meta?.trade_date || document.querySelector("#tradeDate")?.value || "", ), onNavigate: (viewId) => openView(viewId), onNavigationSync: () => toggleAccountDropdown(false), }); const pageModules = window.XiaobaiPageModules.create({ pages: window.XiaobaiPages, actions: { closeTransientUi: () => closeStockPreview(), applyAccess: () => applyMembershipAccess(), clearAuction: () => clearAuctionTimer(), stopHeaven: () => { stopQiFieldCanvas(); stopHeartDust(); cancelHeavenPerformance(); }, loadSentiment: () => loadSentimentHistory(), loadRotation: () => loadRotationHistory(), loadAuction: () => loadAuctionCenter(), loadThemes: () => loadThemeLibrary(), loadPopularity: () => loadPopularity(), loadDragonTiger: () => loadDragonTiger(), loadReview: () => loadReviewWorkspace(), loadScreener: () => { if (hasMemberAccess() && state.dashboard) loadScreenerSetup(); }, loadMentor: () => { if (hasMemberAccess()) loadMentorSetup(); }, loadHeaven: () => { if (!hasMemberAccess()) return; loadHeavenSetup(false, "", document.querySelector("#heavenStockInput").value.trim()); }, }, }); const elements = { tradeDate: document.querySelector("#tradeDate"), loading: document.querySelector("#loadingOverlay"), toast: document.querySelector("#toast"), stockDialog: document.querySelector("#stockDialog"), tradeLogDialog: document.querySelector("#tradeLogDialog"), watchlistDialog: document.querySelector("#watchlistDialog"), alertsDialog: document.querySelector("#alertsDialog"), assistantDialog: document.querySelector("#assistantDialog"), heavenReadingDialog: document.querySelector("#heavenReadingDialog"), globalSearchDialog: document.querySelector("#globalSearchDialog"), globalSearchInput: document.querySelector("#globalSearchInput"), globalSearchResults: document.querySelector("#globalSearchResults"), entityDetailDialog: document.querySelector("#entityDetailDialog"), entityDetailChart: document.querySelector("#entityDetailChart"), settingsDialog: document.querySelector("#settingsDialog"), adminDialog: document.querySelector("#adminDialog"), priceChart: document.querySelector("#priceChart"), stockPreview: document.querySelector("#stockPreview"), stockPreviewBackdrop: document.querySelector("#stockPreviewBackdrop"), stockPreviewChart: document.querySelector("#stockPreviewChart"), }; function openModalDialog(dialog) { applicationShell.openModalDialog(dialog); } 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 heavenReadingAnimation = null; let heartHoldTimer = null; let heartHoldTriggered = false; let heartHoldStartedAt = 0; let heartHoldAnimationFrame = 0; let heartCastingBusy = false; let heartDustAnimationFrame = 0; let heartDustParticles = []; let heartIncenseAnimation = null; const heartCoinRotations = [0, 0, 0]; let rowAnimationObserver = null; let stockPreviewOpenTimer = null; let stockPreviewCloseTimer = null; let stockPreviewAbortController = null; let stockPreviewAnchor = null; let sentimentChartAnimationFrame = null; let heavenResizeTimer = null; let globalSearchTimer = null; let watchlistSearchTimer = null; let assistantRenderFrame = 0; 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); function syncThemeControl() { const theme = document.documentElement.dataset.theme === "dark" ? "dark" : "light"; const button = document.querySelector("#themeToggle"); if (!button) return; const dark = theme === "dark"; const label = dark ? "切换到日间模式" : "切换到夜间模式"; button.title = label; button.setAttribute("aria-label", label); button.setAttribute("aria-pressed", String(dark)); button.querySelector("i")?.setAttribute("data-lucide", dark ? "sun" : "moon"); } function clearThemeTransitionEffects() { document.querySelectorAll(".row-enter, .row-pending, .view-entering").forEach((element) => { element.classList.remove("row-enter", "row-pending", "view-entering"); element.style.removeProperty("--row-delay"); }); } function redrawThemeSensitiveVisuals() { if (!elements.stockPreview.hidden && state.stockPreviewPayload) { selectStockPreviewChart(state.stockPreviewChart); } if (elements.stockDialog.open) { if (state.stockDetailChartMode === "intraday" && state.stockDetailIntraday?.points?.length) { drawIntradayCanvas( elements.priceChart, state.stockDetailIntraday.points, [], state.stockDetailIntraday.meta?.previous_close, ); } else if (state.stockDetail?.prices) drawPriceChart(state.stockDetail.prices); } if (elements.entityDetailDialog.open) { if (state.entityDetailChartMode === "intraday" && state.entityDetailIntraday?.points?.length) { drawIntradayCanvas( elements.entityDetailChart, state.entityDetailIntraday.points, [], state.entityDetailIntraday.meta?.previous_close, ); } else if (state.entityDetailPayload?.series) { drawEntityDetailChart(state.entityDetailPayload.series); } } if (state.activeView === "sentimentCycleView" && state.sentimentHistory) { drawSentimentTrendChart(state.sentimentHistory.rows || []); } if (state.activeView === "heavenView") { if (state.heavenPanel === "fortune" && state.heavenSetup?.field) { renderQiFieldCanvas(state.heavenSetup.field.balance || [], { intro: false }); drawQiUseConnections(false); } if (state.heavenPanel === "heart") startHeartDust(); } } function commitTheme(normalized, persist) { document.documentElement.dataset.theme = normalized; document.documentElement.style.colorScheme = normalized; if (persist) { try { localStorage.setItem(THEME_STORAGE_KEY, normalized); } catch (_error) { // The selected theme still applies for the current page when storage is unavailable. } } syncThemeControl(); refreshIcons(); redrawThemeSensitiveVisuals(); } function applyTheme(theme, persist = true) { const normalized = theme === "dark" ? "dark" : "light"; const root = document.documentElement; if (root.dataset.theme === normalized) { commitTheme(normalized, persist); return; } const sequence = ++themeSwitchSequence; activeThemeTransition?.skipTransition?.(); clearThemeTransitionEffects(); root.classList.add("theme-switching"); const update = () => commitTheme(normalized, persist); const finish = () => { if (sequence !== themeSwitchSequence) return; clearThemeTransitionEffects(); root.classList.remove("theme-switching"); activeThemeTransition = null; }; const reducedMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches; if (!reducedMotion && typeof document.startViewTransition === "function") { activeThemeTransition = document.startViewTransition(update); activeThemeTransition.finished.then(finish, finish); return; } update(); requestAnimationFrame(() => requestAnimationFrame(finish)); } function toggleTheme() { applyTheme(document.documentElement.dataset.theme === "dark" ? "light" : "dark"); } async function initialize() { syncThemeControl(); refreshIcons(); applicationShell.initialize(); elements.tradeDate.value = todayString(); const initialUrl = new URL(window.location.href); if (initialUrl.searchParams.has("date")) { initialUrl.searchParams.delete("date"); history.replaceState(null, "", initialUrl); } elements.tradeDate.max = todayString(); document.querySelector("#journalDate").value = elements.tradeDate.value; document.querySelector("#journalDate").max = todayString(); document.querySelector("#tradeLogDate").value = elements.tradeDate.value; document.querySelector("#tradeLogDate").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(); document.querySelector("#alertDate").value = 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 requestedView = window.XiaobaiPages.resolve(searchParams.get("view")); if ( requestedView && window.XiaobaiPages.has(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(); loadAlerts(); 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; document.querySelector("#reasonForm").hidden = !isAdmin; document.querySelector("#sectorPhaseManager").hidden = !isAdmin; document.querySelector("#authGate").hidden = true; applyMembershipAccess(); await startAuthenticatedApp(); } function showAuthGate(message = "") { state.user = null; state.csrfToken = ""; const gate = document.querySelector("#authGate"); gate.hidden = false; const errorElement = document.querySelector("#authError"); errorElement.textContent = message; errorElement.hidden = !message; document.querySelector("#authUsername").focus(); } async function logoutAccount() { toggleAccountDropdown(false); try { await apiRequest("/api/auth/logout", "POST", {}); } catch (error) { showToast(error.message || "退出失败"); return; } window.location.reload(); } function 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", async (event) => { const button = event.currentTarget; button.disabled = true; try { await loadDashboard(false, false, false); } finally { button.disabled = false; } }); document.querySelector("#syncButton").addEventListener("click", startAdminRefresh); elements.tradeDate.addEventListener("change", () => { state.dashboardRequestSequence += 1; state.heavenRequestSequence += 1; state.heavenManualData = null; document.querySelector("#qiObservationDate").value = elements.tradeDate.value; loadDashboard(); }); document.querySelector("#prevDate").addEventListener("click", () => shiftDate(-1)); document.querySelector("#nextDate").addEventListener("click", () => shiftDate(1)); document.querySelector("#stockSearch").addEventListener("input", (event) => { state.query = event.target.value.trim().toLowerCase(); renderLimitTable(); }); document.querySelectorAll("[data-table-search]").forEach((input) => { input.addEventListener("input", () => { const query = input.value.trim().toLowerCase(); const body = document.querySelector(`#${CSS.escape(input.dataset.tableSearch)}`); body?.querySelectorAll("tr").forEach((row) => { row.hidden = Boolean(query) && !row.textContent.toLowerCase().includes(query); }); }); }); document.querySelectorAll("[data-filter]").forEach((button) => { button.addEventListener("click", () => { document.querySelectorAll("[data-filter]").forEach((item) => item.classList.remove("active")); button.classList.add("active"); state.filter = button.dataset.filter; renderLimitTable(); }); }); document.querySelector("#globalSearchButton").addEventListener("click", openGlobalSearch); document.querySelector("#themeToggle").addEventListener("click", toggleTheme); document.querySelector("#alertButton").addEventListener("click", openAlerts); document.querySelector("#assistantButton").addEventListener("click", openReviewAssistant); document.querySelector("#closeAssistantDialog").addEventListener("click", () => elements.assistantDialog.close()); document.querySelector("#assistantForm").addEventListener("submit", sendAssistantQuestion); document.querySelector("#stopAssistant").addEventListener("click", stopAssistantResponse); document.querySelector("#clearAssistantMessages").addEventListener("click", clearAssistantConversation); document.querySelectorAll("[data-assistant-prompt]").forEach((button) => { button.addEventListener("click", () => useAssistantPrompt(button.dataset.assistantPrompt)); }); document.querySelector("#closeAlertsDialog").addEventListener("click", () => elements.alertsDialog.close()); document.querySelector("#alertForm").addEventListener("submit", saveAlert); document.querySelector("#markAllAlertsRead").addEventListener("click", markAllAlertsRead); document.querySelector("#alertList").addEventListener("click", handleAlertAction); document.querySelectorAll("[data-alert-filter]").forEach((button) => { button.addEventListener("click", () => selectAlertFilter(button.dataset.alertFilter)); }); document.querySelector("#closeGlobalSearch").addEventListener("click", closeGlobalSearch); document.querySelector("#closeEntityDetail").addEventListener("click", () => elements.entityDetailDialog.close()); document.querySelectorAll("[data-entity-detail-chart]").forEach((button) => { button.addEventListener("click", () => selectEntityDetailChart(button.dataset.entityDetailChart)); }); elements.globalSearchDialog.addEventListener("click", (event) => { if (event.target === elements.globalSearchDialog) closeGlobalSearch(); }); elements.globalSearchInput.addEventListener("input", scheduleGlobalSearch); elements.globalSearchInput.addEventListener("keydown", handleGlobalSearchInputKeydown); elements.globalSearchResults.addEventListener("click", (event) => { const result = event.target.closest("[data-search-result-index]"); if (result) openGlobalSearchResult(number(result.dataset.searchResultIndex)); }); document.addEventListener("click", (event) => { if (!event.target.closest(".account-menu-shell")) toggleAccountDropdown(false); }); window.addEventListener("keydown", handleGlobalSearchShortcut); document.addEventListener("keydown", (event) => { if (event.key === "Escape") { toggleAccountDropdown(false, true); toggleMentorDirectory(false); } handleAccountMenuKeydown(event); }); window.addEventListener("resize", () => { if (window.innerWidth > 720) toggleMentorDirectory(false); if (!elements.stockPreview.hidden) closeStockPreview(); if (state.activeView === "dragonView") layoutDragonCards(); }); 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("#brokenSearch").addEventListener("input", (event) => { state.brokenQuery = event.target.value.trim().toLowerCase(); renderBrokenTable(state.dashboard?.broken || []); }); document.querySelectorAll("#brokenTable th[data-broken-sort]").forEach((header) => { header.addEventListener("click", () => changeBrokenSort(header.dataset.brokenSort)); }); document.querySelector("#downSearch").addEventListener("input", (event) => { state.downQuery = event.target.value.trim().toLowerCase(); renderDownTable(state.dashboard?.down_limits || []); }); document.querySelectorAll("#downTable th[data-down-sort]").forEach((header) => { header.addEventListener("click", () => changeDownSort(header.dataset.downSort)); }); document.querySelector("#yesterdaySearch").addEventListener("input", (event) => { state.yesterdayQuery = event.target.value.trim().toLowerCase(); renderYesterdayTable(state.dashboard?.yesterday_limits || []); }); document.querySelectorAll("[data-yesterday-filter]").forEach((button) => { button.addEventListener("click", () => { state.yesterdayFilter = button.dataset.yesterdayFilter; renderYesterdayTable(state.dashboard?.yesterday_limits || []); }); }); document.querySelectorAll("#yesterdayTable th[data-yesterday-sort]").forEach((header) => { header.addEventListener("click", () => changeYesterdaySort(header.dataset.yesterdaySort)); }); document.querySelectorAll("[data-ladder-sort]").forEach((button) => { button.addEventListener("click", () => { state.ladderSortMode = button.dataset.ladderSort === "open" ? "open" : "time"; document.querySelectorAll("[data-ladder-sort]").forEach((item) => { const active = item === button; item.classList.toggle("active", active); item.setAttribute("aria-pressed", String(active)); }); renderLadderBoard(state.dashboard?.ladders || []); }); }); document.querySelector("#exportButton").addEventListener("click", exportStocks); document.querySelector("#brokenExportButton").addEventListener("click", exportBroken); document.querySelector("#downExportButton").addEventListener("click", exportDown); document.querySelector("#yesterdayExportButton").addEventListener("click", exportYesterday); document.querySelector("#ladderExportButton").addEventListener("click", exportLadder); document.querySelector("#rotationExportButton").addEventListener("click", exportRotation); document.querySelectorAll("[data-rotation-order]").forEach((button) => { button.addEventListener("click", () => { state.rotationOrder = button.dataset.rotationOrder === "latest" ? "latest" : "oldest"; localStorage.setItem("xiaobaiRotationOrder", state.rotationOrder); renderRotationHistory(); }); }); 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.querySelectorAll("[data-stock-detail-chart]").forEach((button) => { button.addEventListener("click", () => selectStockDetailChart(button.dataset.stockDetailChart)); }); document.querySelector("#closeStockPreview").addEventListener("click", closeStockPreview); elements.stockPreviewBackdrop.addEventListener("click", closeStockPreview); document.querySelector("#openStockDetailFromPreview").addEventListener("click", openStockDetailFromPreview); document.querySelectorAll("[data-preview-chart]").forEach((button) => { button.addEventListener("click", () => selectStockPreviewChart(button.dataset.previewChart)); }); elements.stockPreview.addEventListener("pointerenter", cancelStockPreviewClose); elements.stockPreview.addEventListener("pointerleave", scheduleStockPreviewClose); document.addEventListener("pointerover", handleStockPreviewPointerOver); document.addEventListener("pointerout", handleStockPreviewPointerOut); document.addEventListener("focusin", handleStockPreviewFocus); document.addEventListener("focusout", handleStockPreviewFocusOut); document.addEventListener("click", handleMobileStockPreviewClick, true); document.addEventListener("keydown", handleStockPreviewKeydown); document.addEventListener("scroll", repositionStockPreview, true); document.querySelector("#auctionRefreshButton").addEventListener("click", () => loadAuctionCenter(true)); document.querySelector("#auctionExportButton").addEventListener("click", exportAuctionRows); document.querySelector("#auctionSearch").addEventListener("input", (event) => { state.auctionQuery = event.target.value.trim().toLocaleLowerCase("zh-CN"); renderAuctionTable(); }); document.querySelectorAll("[data-auction-dataset]").forEach((button) => { button.addEventListener("click", () => { state.auctionDataset = button.dataset.auctionDataset || "focus"; state.auctionFilter = "all"; state.auctionSortKey = state.auctionDataset === "onePrice" ? "amount_million" : "attention_score"; state.auctionSortDirection = "desc"; document.querySelectorAll("[data-auction-dataset]").forEach((item) => { const active = item === button; item.classList.toggle("active", active); item.setAttribute("aria-selected", String(active)); }); document.querySelectorAll("[data-auction-filter]").forEach((item) => item.classList.toggle("active", item.dataset.auctionFilter === "all")); renderAuctionTable(); }); }); document.querySelectorAll("[data-auction-filter]").forEach((button) => { button.addEventListener("click", () => { state.auctionFilter = button.dataset.auctionFilter || "all"; document.querySelectorAll("[data-auction-filter]").forEach((item) => item.classList.toggle("active", item === button)); renderAuctionTable(); }); }); document.querySelector("#auctionTable").addEventListener("click", (event) => { const header = event.target.closest("th[data-auction-sort]"); if (!header) return; const key = header.dataset.auctionSort; if (state.auctionSortKey === key) state.auctionSortDirection = state.auctionSortDirection === "asc" ? "desc" : "asc"; else { state.auctionSortKey = key; state.auctionSortDirection = "desc"; } renderAuctionTable(); }); document.querySelector("#openStrategyDrawerButton").addEventListener("click", openCustomStrategyDrawer); document.querySelector("#closeStrategyDrawerButton").addEventListener("click", () => document.querySelector("#strategyDrawer").close()); document.querySelector("#strategyDrawer").addEventListener("click", (event) => { if (event.target === event.currentTarget) event.currentTarget.close(); }); document.querySelector("#themeRefreshButton").addEventListener("click", () => loadThemeLibrary(true)); document.querySelector("#themeSearch").addEventListener("input", (event) => { state.themeQuery = event.target.value.trim().toLocaleLowerCase("zh-CN"); renderThemeDirectory(); }); document.querySelector("#themeDirectory").addEventListener("click", (event) => { const button = event.target.closest("[data-theme-code]"); if (button) selectTheme(button.dataset.themeCode); }); document.querySelector("#popularityRefreshButton").addEventListener("click", () => loadPopularity(true)); document.querySelector("#popularitySearch").addEventListener("input", (event) => { state.popularityQuery = event.target.value.trim().toLocaleLowerCase("zh-CN"); renderPopularityTable(); }); document.querySelectorAll("[data-popularity-source]").forEach((button) => { button.addEventListener("click", () => { state.popularitySource = button.dataset.popularitySource || "combined"; document.querySelectorAll("[data-popularity-source]").forEach((item) => { const active = item === button; item.classList.toggle("active", active); item.setAttribute("aria-selected", String(active)); }); renderPopularityTable(); }); }); document.querySelector("#dragonRefreshButton").addEventListener("click", () => { if (state.dragonViewMode === "profiles") loadHotMoneyProfiles(true); else loadDragonTiger(true); }); document.querySelector("#dragonEmptyRefreshButton").addEventListener("click", () => loadDragonTiger(true)); document.querySelector("#dragonPreviousButton").addEventListener("click", () => shiftDate(-1)); document.querySelector("#dragonExportButton").addEventListener("click", () => { if (state.dragonViewMode === "profiles") exportHotMoneyProfiles(); else exportDragonTiger(); }); document.querySelectorAll("[data-dragon-view-mode]").forEach((button) => { button.addEventListener("click", () => selectDragonViewMode(button.dataset.dragonViewMode)); }); document.querySelector("#dragonSearch").addEventListener("input", (event) => { state.dragonQuery = event.target.value.trim().toLowerCase(); renderDragonTraderList(); }); document.querySelectorAll("[data-dragon-filter]").forEach((button) => { button.addEventListener("click", () => { state.dragonFilter = button.dataset.dragonFilter; document.querySelectorAll("[data-dragon-filter]").forEach((item) => { item.classList.toggle("active", item === button); }); renderDragonTraderList(); }); }); document.querySelector("#hotMoneyProfileSearch").addEventListener("input", (event) => { state.hotMoneyProfileQuery = event.target.value.trim().toLocaleLowerCase("zh-CN"); renderHotMoneyProfiles(); }); document.querySelector("#hotMoneyProfileList").addEventListener("click", (event) => { const button = event.target.closest("[data-hot-money-profile]"); if (!button) return; state.selectedHotMoneyProfileId = button.dataset.hotMoneyProfile; renderHotMoneyProfiles(); }); document.querySelector("#journalForm").addEventListener("submit", saveJournal); document.querySelector("#journalDate").addEventListener("change", populateJournalForm); document.querySelector("#openWatchlistDialog").addEventListener("click", () => openWatchlistDialog()); document.querySelector("#closeWatchlistDialog").addEventListener("click", closeWatchlistDialog); document.querySelector("#cancelWatchlistEdit").addEventListener("click", closeWatchlistDialog); document.querySelector("#changeWatchlistSelection").addEventListener("click", clearWatchlistSelection); document.querySelector("#watchlistSearchInput").addEventListener("input", scheduleWatchlistSearch); document.querySelector("#watchlistForm").addEventListener("submit", saveWatchlistFromDialog); document.querySelector("#watchlistSearchResults").addEventListener("click", handleWatchlistSearchResult); document.querySelector("#reviewHistoryToggle").addEventListener("click", (event) => { const panel = document.querySelector("#reviewHistoryPanel"); const expanded = event.currentTarget.getAttribute("aria-expanded") === "true"; event.currentTarget.setAttribute("aria-expanded", String(!expanded)); event.currentTarget.querySelector("span").textContent = expanded ? "历史复盘" : "收起历史"; panel.hidden = expanded; if (!expanded) panel.scrollIntoView({ behavior: "smooth", block: "nearest" }); }); document.querySelector("#openTradeLogDialog").addEventListener("click", openTradeLogDialog); document.querySelector("#closeTradeLogDialog").addEventListener("click", closeTradeLogDialog); document.querySelector("#tradeLogForm").addEventListener("submit", saveTradeLog); document.querySelector("#cancelTradeEdit").addEventListener("click", closeTradeLogDialog); elements.tradeLogDialog.addEventListener("close", resetTradeLogForm); document.querySelector("#tradeLogTableBody").addEventListener("click", handleTradeLogAction); document.querySelector("#stockNoteForm").addEventListener("submit", saveStockNote); document.querySelector("#watchStockButton").addEventListener("click", toggleActiveWatchlist); document.querySelector("#stockHeavenButton").addEventListener("click", openActiveStockInHeaven); document.querySelector("#stockReminderButton").addEventListener("click", openStockReminder); document.querySelector("#reasonForm").addEventListener("submit", saveReasonOverride); document.querySelector("#backfillButton").addEventListener("click", backfillData); document.querySelector("#openScreenerTrackingButton").addEventListener("click", async () => { await loadScreenerTracking(true); openView("screenerTrackingView"); }); document.querySelector("#closeScreenerTrackingButton").addEventListener("click", () => openView("screenerView")); document.querySelector("#refreshTrackingButton").addEventListener("click", refreshScreenerTracking); document.querySelector("#trackingTableBody").addEventListener("click", handleTrackingTableAction); document.querySelectorAll("[data-screener-mobile-view]").forEach((button) => { button.addEventListener("click", () => selectScreenerMobileView(button.dataset.screenerMobileView)); }); document.querySelector("#compileStrategyButton").addEventListener("click", compileStrategy); document.querySelector("#saveStrategyButton").addEventListener("click", saveCurrentStrategy); document.querySelector("#deleteStrategyButton").addEventListener("click", deleteCurrentStrategy); document.querySelector("#screenerExportButton").addEventListener("click", exportScreenerResults); document.querySelector("#runBacktestToggle").addEventListener("change", updateBacktestTaskStatus); document.querySelectorAll("[data-screener-mode]").forEach((button) => { button.addEventListener("click", () => selectScreenerMode(button.dataset.screenerMode)); }); document.querySelector("#curatedStrategyList").addEventListener("click", (event) => { if (event.target.closest("button")) return; const card = event.target.closest("[data-curated-strategy]"); if (!card) return; state.selectedCuratedStrategyId = number(card.dataset.curatedStrategy); renderCuratedStrategyLibrary(); renderScreenerResult(); }); document.querySelector("#curatedStrategySearch").addEventListener("input", (event) => { state.curatedQuery = event.target.value.trim().toLocaleLowerCase("zh-CN"); renderCuratedStrategyLibrary(); }); document.querySelector("#curatedCategoryFilter").addEventListener("change", (event) => { state.curatedCategory = event.target.value; renderCuratedStrategyLibrary(); }); document.querySelector("#curatedSchoolFilters").addEventListener("click", (event) => { const button = event.target.closest("[data-curated-school]"); if (!button) return; state.curatedSchool = button.dataset.curatedSchool; renderCuratedStrategyLibrary(); }); document.querySelectorAll("[data-curated-view]").forEach((button) => { button.addEventListener("click", () => { state.curatedViewMode = button.dataset.curatedView === "grid" ? "grid" : "list"; localStorage.setItem("xiaobaiCuratedViewMode", state.curatedViewMode); renderCuratedStrategyLibrary(); }); }); document.querySelector("#quantResetButton").addEventListener("click", resetQuantBuilder); document.querySelector("#addQuantFilterButton").addEventListener("click", () => addQuantFilter()); document.querySelector("#addQuantScoreButton").addEventListener("click", () => addQuantScore()); document.querySelector("#quantFilterRows").addEventListener("input", handleQuantBuilderInput); document.querySelector("#quantFilterRows").addEventListener("change", handleQuantBuilderInput); document.querySelector("#quantFilterRows").addEventListener("click", handleQuantBuilderClick); document.querySelector("#quantScoreRows").addEventListener("input", handleQuantBuilderInput); document.querySelector("#quantScoreRows").addEventListener("change", handleQuantBuilderInput); document.querySelector("#quantScoreRows").addEventListener("click", handleQuantBuilderClick); ["quantListedDays", "quantLimit", "quantMinScore", "quantExcludeSt"].forEach((id) => { document.querySelector(`#${id}`).addEventListener("input", renderQuantSummary); document.querySelector(`#${id}`).addEventListener("change", renderQuantSummary); }); document.querySelector("#quantRunButton").addEventListener("click", runQuantStrategy); document.querySelector("#quantSaveButton").addEventListener("click", saveQuantAsStrategy); document.querySelector("#quantBacktestToggle").addEventListener("change", updateBacktestTaskStatus); document.querySelector("#mentorChatForm").addEventListener("submit", sendMentorQuestion); document.querySelector("#clearMentorChatButton").addEventListener("click", clearMentorConversation); document.querySelector("#mentorDirectoryToggle").addEventListener("click", () => { toggleMentorDirectory(!state.mentorDirectoryOpen); }); document.querySelector("#closeMentorDirectory").addEventListener("click", () => toggleMentorDirectory(false)); document.querySelector("#mentorDirectoryBackdrop").addEventListener("click", () => toggleMentorDirectory(false)); document.querySelector("#mentorSortToggle").addEventListener("click", toggleMentorSortMode); document.querySelector("#mentorSearchInput").addEventListener("input", (event) => { state.mentorQuery = event.target.value.trim().toLocaleLowerCase("zh-CN"); renderMentorDirectory(); }); document.querySelectorAll("[data-mentor-grade]").forEach((button) => { button.addEventListener("click", () => { state.mentorGrade = button.dataset.mentorGrade || "all"; document.querySelectorAll("[data-mentor-grade]").forEach((item) => { item.classList.toggle("active", item === button); }); renderMentorDirectory(); }); }); document.querySelectorAll("[data-mentor-prompt]").forEach((button) => { button.addEventListener("click", () => useMentorQuickPrompt(button.dataset.mentorPrompt)); }); document.querySelectorAll("[data-heaven-panel]").forEach((button) => { button.addEventListener("click", () => selectHeavenPanel(button.dataset.heavenPanel, true)); }); document.querySelector("#loadHeavenSelectionButton").addEventListener("click", loadHeavenSelection); document.querySelector("#heavenCalibrationForm").addEventListener("submit", applyHeavenCalibration); document.querySelector("#resetHeavenCalibrationButton").addEventListener("click", resetHeavenCalibration); document.querySelector("#heavenStockInput").addEventListener("keydown", (event) => { if (event.key === "Enter") { event.preventDefault(); loadHeavenSelection(); } }); document.querySelector("#interpretTrendButton").addEventListener("click", () => interpretHeaven("trend")); document.querySelector("#interpretFortuneButton").addEventListener("click", () => interpretHeaven("fortune")); document.querySelector("#historyTrendButton").addEventListener("click", () => openHeavenHistory("trend")); document.querySelector("#historyFortuneButton").addEventListener("click", () => openHeavenHistory("fortune")); document.querySelector("#qiObservationDate").addEventListener("change", () => { state.personalField = null; state.heavenManualData = null; state.heavenInterpretations.fortune = ""; loadHeavenSetup( true, "", document.querySelector("#heavenStockInput").value.trim(), ); }); document.querySelector("#openPersonalSettingsButton").addEventListener("click", () => openSettings("profile")); document.querySelector("#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); document.querySelector("#historyHeartButton").addEventListener("click", () => openHeavenHistory("heart")); initializeHeartCoinHold(); initializeHeartLineInspection(); document.querySelector("#interpretHeartButton").addEventListener("click", () => interpretHeaven("heart")); document.querySelector("#viewHeartReadingButton").addEventListener("click", () => openHeavenReading("heart")); document.querySelector("#restartHeartButton").addEventListener("click", resetHeartRitual); document.querySelector("#closeHeavenReadingDialog").addEventListener("click", () => elements.heavenReadingDialog.close()); elements.heavenReadingDialog.addEventListener("close", stopHeavenReadingAnimation); document.querySelectorAll("[data-heaven-reading-tab]").forEach((button) => { button.addEventListener("click", () => selectHeavenReadingTab(button.dataset.heavenReadingTab)); }); document.querySelector("#heavenReadingHistoryList").addEventListener("click", handleHeavenHistorySelection); document.querySelector("#heavenReadingHistoryDetail").addEventListener("click", handleHeavenHistoryAction); document.querySelectorAll("[data-heart-return]").forEach((button) => { button.addEventListener("click", resetHeartRitual); }); 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", redrawThemeSensitiveVisuals); initializeAutoTableSorting(); } async function loadDashboard(force = false, background = false, showOverlay = true) { const requestedDate = elements.tradeDate.value; if (state.dashboardLoading && state.dashboardRequestDate === requestedDate) return; state.dashboardLoading = true; state.dashboardRequestDate = requestedDate; const requestSequence = ++state.dashboardRequestSequence; if (force) stockPreviewCache.clear(); if (!background && showOverlay) { setLoading(true, "正在加载市场数据"); setStatus("正在加载市场数据"); } else if (!background) { setStatus("正在刷新行情"); } try { const query = new URLSearchParams({ trade_date: elements.tradeDate.value }); if (force) query.set("force", "1"); const payload = await apiRequest(`/api/dashboard?${query}`); if ( requestSequence !== state.dashboardRequestSequence || requestedDate !== elements.tradeDate.value ) return; applyDashboard(payload, background); } catch (error) { if (background) { setStatus("实时刷新暂时中断,正在等待重试"); } else { showToast(error.message || "无法连接本地服务"); setStatus("加载失败"); } } finally { if (requestSequence === state.dashboardRequestSequence) { state.dashboardLoading = false; state.dashboardRequestDate = ""; if (!background && showOverlay) setLoading(false); updateDateButtons(); } } } async function startAdminRefresh() { const buttons = [document.querySelector("#syncButton"), document.querySelector("#adminRefreshButton")].filter(Boolean); buttons.forEach((button) => { button.disabled = true; }); try { const payload = await apiRequest("/api/admin/refresh", "POST", { trade_date: elements.tradeDate.value }); showToast(payload.message || "后台刷新已提交"); setStatus("后台刷新运行中,当前页面保持不变"); } catch (error) { showToast(error.message || "后台刷新启动失败"); } finally { buttons.forEach((button) => { button.disabled = false; }); } } function applyDashboard(payload, background = false) { state.dashboard = payload; const selectedDate = payload.meta.requested_date || payload.meta.trade_date; elements.tradeDate.value = selectedDate; document.querySelector("#qiObservationDate").value = selectedDate; document.querySelector("#journalDate").value = selectedDate; renderDashboard(); setStatus(`${dashboardSourceLabel(payload.meta)} · 数据已更新`); if (!background) { if (state.activeView === "dragonView") loadDragonTiger(); if (state.activeView === "screenerView") loadScreenerSetup(); if (state.activeView === "screenerTrackingView") loadScreenerTracking(true); if (state.activeView === "mentorView") loadMentorSetup(true); if (state.activeView === "heavenView") loadHeavenSetup(true); if (state.activeView === "sentimentCycleView") loadSentimentHistory(true); if (state.activeView === "rotationView") loadRotationHistory(true); if (state.activeView === "auctionView") loadAuctionCenter(true); if (state.activeView === "themeLibraryView") loadThemeLibrary(true); if (state.activeView === "popularityView") loadPopularity(true); } const requestedStock = new URLSearchParams(window.location.search).get("stock"); if (!state.initialStockOpened && /^\d{6}$/.test(requestedStock || "")) { state.initialStockOpened = true; openStock(requestedStock); } } function dashboardSourceLabel(meta = {}) { if (meta.realtime && !["closed", "after_hours"].includes(String(meta.market_status || ""))) return "盘中行情"; if (meta.carried_forward) return "最近收盘行情"; if (meta.market_status === "historical") return "历史行情"; return "收盘行情"; } function renderDashboard() { const { meta, overview, ladders, sectors } = state.dashboard; animateMetric("tapeUp", overview.up_count, (value) => Math.round(value)); animateMetric("tapeDown", overview.down_count, (value) => Math.round(value)); setText("tapeLimit", `${overview.limit_up_count} / 跌停 ${overview.limit_down_count}`); animateMetric("tapeAmount", overview.amount_billion, (value) => `${formatNumber(value, 1)} 亿`); animateMetric("limitUpMetric", overview.limit_up_count, (value) => `${Math.round(value)} 家`); animateMetric("limitDownMetric", overview.limit_down_count, (value) => `${Math.round(value)} 家`); animateMetric("brokenMetric", overview.broken_count, (value) => `${Math.round(value)} 家`); animateMetric("sealRateMetric", overview.seal_rate, (value) => `${formatNumber(value, 1)}%`); animateMetric("amountMetric", overview.amount_billion, (value) => `${formatNumber(value, 1)} 亿`); setText("dataDateMetric", dashboardDataTimestamp(meta)); animateMetric("sentimentScore", overview.sentiment_score, (value) => Math.round(value)); setText("sentimentText", sentimentLabel(overview.sentiment_score)); updateSentimentGauge(overview.sentiment_score); setText("updatedAt", `${dashboardSourceLabel(meta)} · 更新 ${formatTimestamp(meta.updated_at)}`); renderLimitTable(); renderLadderMini(ladders || []); renderSectorMini(sectors || []); renderBrokenTable(state.dashboard.broken || []); renderDownTable(state.dashboard.down_limits || []); renderYesterdayTable(state.dashboard.yesterday_limits || []); renderPerformance(state.dashboard.limit_performance || []); renderLadderBoard(ladders || []); renderRotationMembers(); } 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); const dayChange = number(latest.day_change); const confidence = sentimentPhaseConfidence(latest); setText("sentimentPhaseConfidence", `置信度 ${confidence}%`); setText("sentimentDayChange", `${dayChange > 0 ? "+" : ""}${formatNumber(dayChange, 1)}`); setText("sentimentSealRate", `${formatNumber(latest.seal_rate, 1)}%`); setText("sentimentLimitUp", number(latest.limit_up_count)); setText("sentimentBroken", number(latest.broken_count)); setText("sentimentPhaseAdvice", sentimentPhaseAdvice(latest.phase)); setText("sentimentCurrentTag", `当前 ${number(latest.score)} · ${latest.phase}`); setText("sentimentComponentSummary", `五维加权 → 温度 ${number(latest.score)}`); setText("sentimentPeriodNote", `近 ${state.sentimentRange} 个交易日,当前展示 ${rows.length} 日`); const changeElement = document.querySelector("#sentimentDayChange"); changeElement.className = changeClass(dayChange); setText("sentimentPreviousPositive", `${number(latest.previous_positive_count)} / ${number(latest.previous_limit_count)} 只`); setText("sentimentPreviousAverage", `红盘率 ${formatNumber(latest.previous_positive_rate, 1)}% · 平均 ${signed(latest.average_previous_change)}%`); setText("sentimentHistoryDays", `${number(payload.available_days)} 个交易日`); setText("sentimentNormalization", `${latest.normalization} · 当前展示 ${rows.length} 日`); const marker = document.querySelector("#sentimentCycleScoreMarker"); marker.className = `sentiment-current-phase-badge ${sentimentPhaseClass(latest.phase)}`; document.querySelector("#sentimentComponentList").innerHTML = Object.values(latest.components || {}).map((item) => `
${escapeHtml(item.label)} ${formatNumber(item.score, 1)} × ${number(item.weight)}%
${escapeHtml(item.summary)}
`).join(""); requestAnimationFrame(() => { animateSentimentComponents(); animateSentimentTrendChart(rows); bindSentimentChartTooltip(rows); }); animateRows(body); } function animateSentimentComponents() { document.querySelectorAll("#sentimentComponentList [data-component-score]").forEach((bar, index) => { const width = `${number(bar.dataset.componentScore)}%`; if (!motionEnabled()) { bar.style.width = width; return; } setTimeout(() => { bar.style.width = width; }, index * 70); }); } function animateSentimentTrendChart(rows) { if (sentimentChartAnimationFrame) cancelAnimationFrame(sentimentChartAnimationFrame); if (!motionEnabled()) { drawSentimentTrendChart(rows, 1); return; } const startedAt = performance.now(); const duration = 780; const frame = (now) => { const rawProgress = Math.min(1, (now - startedAt) / duration); const progress = 1 - (1 - rawProgress) ** 3; drawSentimentTrendChart(rows, progress); if (rawProgress < 1) sentimentChartAnimationFrame = requestAnimationFrame(frame); else sentimentChartAnimationFrame = null; }; sentimentChartAnimationFrame = requestAnimationFrame(frame); } function drawSentimentTrendChart(rows, progress = 1) { const canvas = document.querySelector("#sentimentTrendChart"); if (!canvas || !rows.length || state.activeView !== "sentimentCycleView") return; const rect = canvas.getBoundingClientRect(); if (!rect.width) return; const width = Math.max(320, rect.width); const height = Math.max(220, rect.height); const ratio = window.devicePixelRatio || 1; canvas.width = Math.round(width * ratio); canvas.height = Math.round(height * ratio); const context = canvas.getContext("2d"); const palette = currentChartPalette(); context.setTransform(ratio, 0, 0, ratio, 0, 0); context.clearRect(0, 0, width, height); context.fillStyle = palette.background; context.fillRect(0, 0, width, height); const padding = { top: 18, right: 18, bottom: 34, left: 42 }; const chartWidth = width - padding.left - padding.right; const chartHeight = height - padding.top - padding.bottom; const x = (index) => padding.left + (rows.length === 1 ? chartWidth / 2 : index / (rows.length - 1) * chartWidth); const y = (score) => padding.top + (100 - clamp(score, 0, 100)) / 100 * chartHeight; context.font = '10px "Microsoft YaHei UI", sans-serif'; context.textAlign = "right"; context.textBaseline = "middle"; for (let score = 0; score <= 100; score += 20) { const lineY = y(score); context.strokeStyle = score === 40 || score === 80 ? palette.zero : palette.grid; context.lineWidth = 1; context.beginPath(); context.moveTo(padding.left, lineY); context.lineTo(width - padding.right, lineY); context.stroke(); context.fillStyle = palette.axis; context.fillText(String(score), padding.left - 8, lineY); } context.save(); context.beginPath(); context.rect(padding.left - 6, padding.top - 8, (chartWidth + 12) * clamp(progress, 0, 1), chartHeight + 18); context.clip(); const finalPhase = rows[rows.length - 1]?.phase; let phaseStart = rows.length - 1; while (phaseStart > 0 && rows[phaseStart - 1]?.phase === finalPhase) phaseStart -= 1; if (["退潮", "冰点"].includes(finalPhase)) { const startX = phaseStart === 0 ? padding.left : (x(phaseStart - 1) + x(phaseStart)) / 2; context.fillStyle = palette.alertArea; context.fillRect(startX, padding.top, width - padding.right - startX, chartHeight); context.fillStyle = palette.up; context.font = '10px "Microsoft YaHei UI", sans-serif'; context.textAlign = "center"; context.textBaseline = "top"; context.fillText(finalPhase, (startX + width - padding.right) / 2, padding.top + 4); } const movingAverage = rows.map((_row, index) => { const start = Math.max(0, index - 4); const sample = rows.slice(start, index + 1); return sample.reduce((sum, item) => sum + number(item.score), 0) / sample.length; }); context.beginPath(); movingAverage.forEach((score, index) => { if (index === 0) context.moveTo(x(index), y(score)); else context.lineTo(x(index), y(score)); }); context.strokeStyle = palette.movingAverage; context.lineWidth = 1.5; context.setLineDash([5, 4]); context.stroke(); context.setLineDash([]); context.beginPath(); rows.forEach((row, index) => { const pointX = x(index); const pointY = y(row.score); if (index === 0) context.moveTo(pointX, pointY); else context.lineTo(pointX, pointY); }); context.lineTo(x(rows.length - 1), padding.top + chartHeight); context.lineTo(x(0), padding.top + chartHeight); context.closePath(); context.fillStyle = palette.area; context.fill(); context.beginPath(); rows.forEach((row, index) => { const pointX = x(index); const pointY = y(row.score); if (index === 0) context.moveTo(pointX, pointY); else context.lineTo(pointX, pointY); }); context.strokeStyle = palette.line; context.lineWidth = 2.5; context.lineJoin = "round"; context.lineCap = "round"; context.stroke(); rows.forEach((row, index) => { context.beginPath(); context.arc(x(index), y(row.score), index === rows.length - 1 ? 4.5 : 3, 0, Math.PI * 2); context.fillStyle = ["退潮", "冰点"].includes(row.phase) ? palette.up : row.phase === "修复" ? palette.repair : palette.line; context.fill(); context.strokeStyle = palette.background; context.lineWidth = 1.5; context.stroke(); }); context.restore(); const labelStep = Math.max(1, Math.ceil(rows.length / 6)); context.textAlign = "center"; context.textBaseline = "top"; context.fillStyle = palette.axis; rows.forEach((row, index) => { if (index % labelStep !== 0 && index !== rows.length - 1) return; const dateText = displayCompactDate(row.trade_date).slice(5); context.fillText(dateText, x(index), height - padding.bottom + 10); }); } function bindSentimentChartTooltip(rows) { const canvas = document.querySelector("#sentimentTrendChart"); const tooltip = document.querySelector("#sentimentChartTooltip"); if (!canvas || !tooltip || !rows.length) return; canvas.onmousemove = (event) => { const rect = canvas.getBoundingClientRect(); const padding = { left: 42, right: 18 }; const chartWidth = Math.max(1, rect.width - padding.left - padding.right); const relativeX = clamp(event.clientX - rect.left - padding.left, 0, chartWidth); const index = rows.length === 1 ? 0 : Math.round(relativeX / chartWidth * (rows.length - 1)); const row = rows[index]; tooltip.innerHTML = `${escapeHtml(displayCompactDate(row.trade_date))} · 温度 ${number(row.score)} · ${escapeHtml(row.phase)}`; tooltip.hidden = false; const targetLeft = padding.left + (rows.length === 1 ? chartWidth / 2 : index / (rows.length - 1) * chartWidth); tooltip.style.left = `${clamp(targetLeft + 10, 8, rect.width - tooltip.offsetWidth - 8)}px`; tooltip.style.top = `${clamp(event.clientY - rect.top - 34, 8, rect.height - 34)}px`; }; canvas.onmouseleave = () => { tooltip.hidden = true; }; } function sentimentScoreClass(score) { const value = number(score); return value >= 60 ? "score-strong" : value < 40 ? "score-weak" : "score-neutral"; } function sentimentPhaseClass(phase) { return { "冰点": "phase-ice", "修复": "phase-repair", "发酵": "phase-fermentation", "高潮": "phase-climax", "分化": "phase-divergence", "退潮": "phase-retreat", }[phase] || "phase-divergence"; } function sentimentPhaseConfidence(row) { const explicit = number(row?.confidence || row?.phase_confidence); if (explicit > 0) return Math.round(clamp(explicit, 0, 100)); const historyEvidence = Math.min(12, number(row?.history_days) * 0.6); const movementEvidence = Math.min(18, Math.abs(number(row?.day_change)) * 0.8); return Math.round(clamp(62 + historyEvidence + movementEvidence, 60, 92)); } function sentimentPhaseAdvice(phase) { return { "冰点": "情绪处于极弱区,先观察风险释放,允许没有候选结果。", "修复": "风险开始收敛,关注率先转强的核心,小仓验证修复强度。", "发酵": "主线与梯队正在形成,优先跟随核心,避免偏离主线。", "高潮": "情绪与一致性已处高位,聚焦核心并主动降低后排暴露。", "分化": "强弱开始分层,关注承接与回流,淘汰失去辨识度的方向。", "退潮": "情绪指标继续走弱。", }[phase] || "市场结构尚未形成清晰阶段,保持观察并等待确认。"; } function getVisibleStocks() { if (!state.dashboard) return []; let rows = [...(state.dashboard.limits || [])]; if (state.filter === "1") rows = rows.filter((row) => number(row.streak) === 1); if (state.filter === "2") rows = rows.filter((row) => number(row.streak) === 2); if (state.filter === "3") rows = rows.filter((row) => number(row.streak) >= 3); if (state.query) { rows = rows.filter((row) => { const haystack = `${row.code} ${row.name} ${row.sector} ${row.reason}`.toLowerCase(); return haystack.includes(state.query); }); } return rows.sort((left, right) => compareRows(left, right)); } function renderLimitTable() { if (!state.dashboard) return; const rows = getVisibleStocks(); const allRows = state.dashboard.limits || []; const body = document.querySelector("#limitTableBody"); body.innerHTML = rows.map((row, index) => ` ${index + 1} ${escapeHtml(row.name)}${escapeHtml(row.code)} ${streakLabel(row.streak)} ${signed(row.change)} ${formatNumber(row.price, 2)} ${escapeHtml(row.sector || "其他")} ${escapeHtml(row.first_time || "")} ${escapeHtml(row.last_time || "")} ${limitOpenState(row)} ${formatNumber(row.turnover_rate, 2)} ${formatNumber(row.amount_billion, 2)} ${formatLimitSealAmount(row.seal_amount_million)} ${escapeHtml(row.reason || "")} `).join(""); bindStockRows(body); setText("resultCount", `${rows.length} 只`); setText("limitPoolSubtitle", `${allRows.length} 只 · 数据日期 ${displayCompactDate(state.dashboard.meta?.trade_date || elements.tradeDate.value)}`); setText("limitAllCount", allRows.length); setText("limitFirstCount", allRows.filter((row) => number(row.streak) === 1).length); setText("limitSecondCount", allRows.filter((row) => number(row.streak) === 2).length); setText("limitThreePlusCount", allRows.filter((row) => number(row.streak) >= 3).length); document.querySelector("#emptyState").hidden = rows.length !== 0; updateSortHeaders(); } function limitOpenState(row) { const openTimes = number(row.open_times); const firstTime = String(row.first_time || ""); if (firstTime.startsWith("09:25") && openTimes === 0) return '一字'; if (openTimes >= 6) return `烂板×${openTimes}`; return String(openTimes); } function formatLimitSealAmount(value) { const amount = number(value); if (!amount) return ""; return Math.round(amount).toLocaleString("zh-CN"); } function renderBrokenTable(rows) { const visibleRows = getVisibleBrokenRows(rows); setText("brokenCount", `${rows.length} 只`); setText("brokenMeta", ` · 触及涨停后未能封住 · 数据日期 ${displayCompactDate(state.dashboard?.meta?.trade_date || elements.tradeDate.value)}`); const body = document.querySelector("#brokenTableBody"); body.innerHTML = visibleRows.map((row, index) => ` ${index + 1} ${escapeHtml(row.name)}${escapeHtml(row.code)} ${signed(row.change)} ${formatNumber(row.limitGap, 2)} ${formatNumber(row.price, 2)} ${escapeHtml(row.sector || "其他")} ${escapeHtml(row.first_time || "")} ${brokenOpenState(row)} ${formatNumber(row.turnover_rate, 2)} ${formatNumber(row.amount_billion, 2)} ${escapeHtml(row.reason || "")} `).join(""); bindStockRows(body); document.querySelector("#brokenEmptyState").hidden = visibleRows.length !== 0; updateBrokenSortHeaders(); } function getVisibleBrokenRows(rows = state.dashboard?.broken || []) { let visibleRows = rows.map((row) => ({ ...row, limitGap: brokenLimitGap(row) })); if (state.brokenQuery) { visibleRows = visibleRows.filter((row) => `${row.code} ${row.name} ${row.sector}`.toLowerCase().includes(state.brokenQuery)); } if (!state.brokenSortKey) return visibleRows; return visibleRows.sort((left, right) => { const result = number(left[state.brokenSortKey]) - number(right[state.brokenSortKey]); return state.brokenSortDirection === "asc" ? result : -result; }); } function brokenLimitRate(row) { const name = String(row.name || "").toUpperCase(); const code = String(row.code || "").replace(/\D/g, ""); if (name.includes("ST")) return 10; if (/^(300|301|688|689)/.test(code)) return 20; if (/^(4|8|92)/.test(code)) return 30; return 10; } function brokenLimitGap(row) { return Math.max(0, brokenLimitRate(row) - number(row.change)); } function brokenOpenState(row) { const openTimes = number(row.open_times); return openTimes >= 6 ? `反复炸 ×${openTimes}` : String(openTimes); } function changeBrokenSort(key) { if (state.brokenSortKey === key) state.brokenSortDirection = state.brokenSortDirection === "asc" ? "desc" : "asc"; else { state.brokenSortKey = key; state.brokenSortDirection = "desc"; } renderBrokenTable(state.dashboard?.broken || []); } function updateBrokenSortHeaders() { document.querySelectorAll("#brokenTable th[data-broken-sort]").forEach((header) => { header.classList.remove("sort-asc", "sort-desc", "sorted"); header.setAttribute("aria-sort", "none"); if (header.dataset.brokenSort === state.brokenSortKey) { header.classList.add(state.brokenSortDirection === "asc" ? "sort-asc" : "sort-desc", "sorted"); header.setAttribute("aria-sort", state.brokenSortDirection === "asc" ? "ascending" : "descending"); } const arrow = header.querySelector(".arr"); if (arrow) arrow.textContent = header.classList.contains("sorted") ? (state.brokenSortDirection === "asc" ? "▲" : "▼") : "↕"; }); } function renderDownTable(rows) { const visibleRows = getVisibleDownRows(rows); setText("downCount", `${rows.length} 只`); setText("downMeta", ` · 观察退潮、高位风险与亏钱效应 · 数据日期 ${displayCompactDate(state.dashboard?.meta?.trade_date || elements.tradeDate.value)}`); renderDownSectorCluster(rows); const body = document.querySelector("#downTableBody"); body.innerHTML = visibleRows.map((row, index) => ` ${index + 1} ${escapeHtml(row.name)}${escapeHtml(row.code)} ${signed(row.change)} ${formatNumber(row.price, 2)} ${escapeHtml(row.sector || "其他")} ${formatNumber(row.turnover_rate, 2)} ${formatNumber(row.amount_billion, 2)} ${number(row.streak) > 0 ? number(row.streak) : ""} ${escapeHtml(row.reason || "")} `).join(""); bindStockRows(body); document.querySelector("#downEmptyState").hidden = visibleRows.length !== 0; updateDownSortHeaders(); } function getVisibleDownRows(rows = state.dashboard?.down_limits || []) { let visibleRows = [...rows]; if (state.downQuery) { visibleRows = visibleRows.filter((row) => `${row.code} ${row.name} ${row.sector}`.toLowerCase().includes(state.downQuery)); } if (!state.downSortKey) return visibleRows; return visibleRows.sort((left, right) => { const result = number(left[state.downSortKey]) - number(right[state.downSortKey]); return state.downSortDirection === "asc" ? result : -result; }); } function renderDownSectorCluster(rows) { const counts = new Map(); rows.forEach((row) => { const sector = String(row.sector || "其他").trim() || "其他"; if (sector === "其他") return; counts.set(sector, (counts.get(sector) || 0) + 1); }); const cluster = [...counts.entries()].sort((left, right) => right[1] - left[1])[0]; const element = document.querySelector("#downSectorCluster"); element.hidden = !cluster || cluster[1] < 2; element.textContent = cluster && cluster[1] >= 2 ? `${cluster[0]}集中跌停 ×${cluster[1]}` : ""; } function changeDownSort(key) { if (state.downSortKey === key) state.downSortDirection = state.downSortDirection === "asc" ? "desc" : "asc"; else { state.downSortKey = key; state.downSortDirection = "asc"; } renderDownTable(state.dashboard?.down_limits || []); } function updateDownSortHeaders() { document.querySelectorAll("#downTable th[data-down-sort]").forEach((header) => { header.classList.remove("sort-asc", "sort-desc", "sorted"); header.setAttribute("aria-sort", "none"); if (header.dataset.downSort === state.downSortKey) { header.classList.add(state.downSortDirection === "asc" ? "sort-asc" : "sort-desc", "sorted"); header.setAttribute("aria-sort", state.downSortDirection === "asc" ? "ascending" : "descending"); } const arrow = header.querySelector(".arr"); if (arrow) arrow.textContent = header.classList.contains("sorted") ? (state.downSortDirection === "asc" ? "▲" : "▼") : "↕"; }); } function renderYesterdayTable(rows) { const visibleRows = getVisibleYesterdayRows(rows); const currentDate = displayCompactDate(state.dashboard?.meta?.trade_date || elements.tradeDate.value); const previousDate = displayCompactDate(state.dashboard?.meta?.previous_trade_date || ""); setText("yesterdayCount", `${rows.length} 只`); setText("yesterdayMeta", ` · 昨日 ${previousDate} → 今日 ${currentDate}`); renderYesterdaySummary(rows); const body = document.querySelector("#yesterdayTableBody"); body.innerHTML = visibleRows.map((row, index) => ` ${index + 1} ${escapeHtml(row.name)}${escapeHtml(row.code)} ${number(row.prior_streak)} ${signed(row.current_change)} ${escapeHtml(row.outcome)} ${number(row.current_streak) ? `${number(row.current_streak)}` : ""} ${escapeHtml(row.sector || "其他")} ${escapeHtml(row.reason || "")} `).join(""); bindStockRows(body); document.querySelector("#yesterdayEmptyState").hidden = visibleRows.length !== 0; updateYesterdayControls(); } function getVisibleYesterdayRows(rows = state.dashboard?.yesterday_limits || []) { let visibleRows = rows.filter((row) => { if (state.yesterdayFilter === "advance") return row.outcome === "晋级"; if (state.yesterdayFilter === "positive") return number(row.current_change) > 0; if (state.yesterdayFilter === "fail") return row.outcome === "断板"; if (state.yesterdayFilter === "risk") return ["炸板", "跌停"].includes(row.outcome); return true; }); if (state.yesterdayQuery) { visibleRows = visibleRows.filter((row) => `${row.code} ${row.name} ${row.sector}`.toLowerCase().includes(state.yesterdayQuery)); } if (!state.yesterdaySortKey) return visibleRows; return visibleRows.sort((left, right) => { const result = number(left[state.yesterdaySortKey]) - number(right[state.yesterdaySortKey]); return state.yesterdaySortDirection === "asc" ? result : -result; }); } function renderYesterdaySummary(rows) { const total = rows.length; const advance = rows.filter((row) => row.outcome === "晋级").length; const positive = rows.filter((row) => number(row.current_change) > 0).length; const fail = rows.filter((row) => row.outcome === "断板").length; const risk = rows.filter((row) => ["炸板", "跌停"].includes(row.outcome)).length; const rate = (value) => total ? value / total * 100 : 0; setText("yesterdayAllCount", total); setText("yesterdayAdvanceCount", advance); setText("yesterdayAdvanceRate", `晋级率 ${formatNumber(rate(advance), 1)}%`); setText("yesterdayPositiveCount", positive); setText("yesterdayPositiveRate", `兑现率 ${formatNumber(rate(positive), 1)}%`); setText("yesterdayFailCount", fail); setText("yesterdayFailRate", `占 ${formatNumber(rate(fail), 1)}%`); setText("yesterdayRiskCount", risk); setText("yesterdayRiskRate", `亏钱效应 ${formatNumber(rate(risk), 1)}%`); } function yesterdayOutcomeClass(outcome) { return { "晋级": "advance", "断板": "fail", "炸板": "broken", "跌停": "down" }[outcome] || "fail"; } function changeYesterdaySort(key) { if (state.yesterdaySortKey === key) state.yesterdaySortDirection = state.yesterdaySortDirection === "asc" ? "desc" : "asc"; else { state.yesterdaySortKey = key; state.yesterdaySortDirection = "desc"; } renderYesterdayTable(state.dashboard?.yesterday_limits || []); } function updateYesterdayControls() { document.querySelectorAll("[data-yesterday-filter]").forEach((button) => { const active = button.dataset.yesterdayFilter === state.yesterdayFilter; button.classList.toggle("active", active); button.setAttribute("aria-pressed", String(active)); }); document.querySelectorAll("#yesterdayTable th[data-yesterday-sort]").forEach((header) => { header.classList.remove("sort-asc", "sort-desc", "sorted"); header.setAttribute("aria-sort", "none"); if (header.dataset.yesterdaySort === state.yesterdaySortKey) { header.classList.add(state.yesterdaySortDirection === "asc" ? "sort-asc" : "sort-desc", "sorted"); header.setAttribute("aria-sort", state.yesterdaySortDirection === "asc" ? "ascending" : "descending"); } const arrow = header.querySelector(".arr"); if (arrow) arrow.textContent = header.classList.contains("sorted") ? (state.yesterdaySortDirection === "asc" ? "▲" : "▼") : "↕"; }); } function renderPerformance(rows) { rows = normalizePerformanceRows(rows); const currentDate = displayCompactDate(state.dashboard?.meta?.trade_date || elements.tradeDate.value); const previousDate = displayCompactDate(state.dashboard?.meta?.previous_trade_date || ""); setText("performanceDateRange", `昨日 ${previousDate} → 今日 ${currentDate}`); document.querySelector("#performanceCards").innerHTML = rows.map((row) => `
${escapeHtml(row.label)} → 今日${performanceRateState(row.advance_rate).label}
${formatNumber(row.advance_rate, 1)}% 晋级 ${number(row.advanced)} / 共 ${number(row.count)} 只
`).join("") || '
暂无昨日涨停统计
'; renderPerformanceConclusion(rows); renderMarketBreadth(state.dashboard?.overview || {}); } function normalizePerformanceRows(rows) { const groups = new Map(); (rows || []).forEach((row) => { const level = Math.max(1, number(row.level)); const displayLevel = Math.min(level, 5); const group = groups.get(displayLevel) || { level: displayLevel, label: displayLevel === 1 ? "昨日首板" : displayLevel === 5 ? "昨日5板+" : `昨日${displayLevel}板`, count: 0, advanced: 0, positive: 0, changeTotal: 0, }; const count = number(row.count); group.count += count; group.advanced += number(row.advanced); group.positive += count * number(row.positive_rate) / 100; group.changeTotal += count * number(row.average_change); groups.set(displayLevel, group); }); return [...groups.values()] .sort((left, right) => right.level - left.level) .map((group) => ({ level: group.level, label: group.label, count: group.count, advanced: group.advanced, advance_rate: group.count ? group.advanced / group.count * 100 : 0, positive_rate: group.count ? group.positive / group.count * 100 : 0, average_change: group.count ? group.changeTotal / group.count : 0, })); } function performanceRateState(rate) { const value = number(rate); if (value === 0) return { label: "失效", className: "is-neutral" }; if (value < 20) return { label: "危险", className: "is-warning" }; return { label: "活跃", className: "is-active" }; } function renderPerformanceConclusion(rows) { const container = document.querySelector("#performanceConclusion"); if (!rows.length) { container.innerHTML = '
暂无昨日梯队数据,暂不生成结论
'; return; } const sorted = [...rows].sort((left, right) => number(right.level) - number(left.level)); const highRows = sorted.filter((row) => number(row.level) >= 4); const highAdvanced = highRows.reduce((total, row) => total + number(row.advanced), 0); const highSamples = highRows.map((row) => escapeHtml(row.label)).join("、"); const strongest = [...rows].sort((left, right) => ( number(right.advance_rate) - number(left.advance_rate) || number(right.level) - number(left.level) ))[0]; const firstBoard = rows.find((row) => number(row.level) === 1); const overview = state.dashboard?.overview || {}; const phase = overview.sentiment_phase || "观察"; const up = number(overview.up_count); const down = number(overview.down_count); const breadthRate = up + down > 0 ? up / (up + down) * 100 : 50; const stance = breadthRate < 25 ? "宜守不宜攻" : breadthRate < 45 ? "控制仓位,聚焦核心" : "保持精选,跟随强势梯队"; const highText = highRows.length ? `高位晋级率${highAdvanced ? "仍有承接" : "全线失效"}:${highSamples}${highAdvanced ? `共晋级 ${highAdvanced} 只` : "今日均未晋级"};` : "高位梯队暂无昨日样本,空间信号仍待确认;"; const strongestText = strongest ? `${escapeHtml(strongest.label)}晋级率最高,为 ${formatNumber(strongest.advance_rate, 1)}%(${number(strongest.advanced)} 只晋级 / 共 ${number(strongest.count)} 只);` : "暂无相对占优梯队;"; const firstBoardText = firstBoard ? `首板基数 ${number(firstBoard.count)} 只,晋级率 ${formatNumber(firstBoard.advance_rate, 1)}%,低位接力${number(firstBoard.advance_rate) < 20 ? "胜率偏低" : "仍有活跃度"};` : "首板梯队暂无有效样本;"; container.innerHTML = `
· ${highText}
· ${strongestText}
· ${firstBoardText}
· 结论:${stance},当前情绪周期「${escapeHtml(phase)}」。
`; } function renderMarketBreadth(overview) { const up = number(overview.up_count); const down = number(overview.down_count); const flat = Math.max(0, number(overview.flat_count)); const total = Math.max(1, up + down + flat); const upRate = up / total * 100; const flatRate = flat / total * 100; const downRate = down / total * 100; const panel = document.querySelector(".market-breadth-panel"); panel.classList.remove("breadth-enter"); void panel.offsetWidth; panel.classList.add("breadth-enter"); setText("breadthDataTime", dashboardDataTimestamp(state.dashboard?.meta || {})); animateMetric("breadthRatio", upRate, (value) => `${formatNumber(value, 1)}%`); animateMetric("breadthUpCount", up, (value) => formatNumber(Math.round(value))); animateMetric("breadthDownCount", down, (value) => formatNumber(Math.round(value))); setText("breadthUpLegend", `${formatNumber(up)}(${formatNumber(upRate, 1)}%)`); setText("breadthFlatLegend", `${formatNumber(flat)}(${formatNumber(flatRate, 1)}%)`); setText("breadthDownLegend", `${formatNumber(down)}(${formatNumber(downRate, 1)}%)`); document.querySelector("#breadthFlatLegendItem").hidden = flat === 0; const limitUp = number(overview.limit_up_count); const limitDown = number(overview.limit_down_count); const breadthLabel = upRate < 20 ? "宽度极差" : upRate < 40 ? "宽度偏弱" : upRate < 55 ? "宽度均衡" : "宽度偏强"; setText("breadthWarning", `△ ${breadthLabel},涨跌停 ${limitUp}:${limitDown}`); const bars = [ ["breadthUpBar", upRate], ["breadthFlatBar", flatRate], ["breadthDownBar", downRate], ]; bars.forEach(([id, width]) => { const bar = document.getElementById(id); const targetWidth = `${Math.max(width, width > 0 ? 0.8 : 0)}%`; bar.style.transition = "none"; bar.style.width = "0%"; requestAnimationFrame(() => requestAnimationFrame(() => { bar.style.transition = "width 760ms var(--ease-out)"; bar.style.width = targetWidth; })); bar.title = `${formatNumber(width, 1)}%`; }); } async function loadRotationHistory(force = false) { if (!state.dashboard || state.rotationLoading) return; const key = `${elements.tradeDate.value}:9`; if (!force && state.rotationHistoryKey === key && state.rotationHistory) { renderRotationHistory(); return; } state.rotationLoading = true; const container = document.querySelector("#rotationHistory"); renderEmptyState(container, "正在读取轮动历史"); try { const query = new URLSearchParams({ trade_date: elements.tradeDate.value, }); state.rotationHistory = await apiRequest(`/api/rotation/history?${query}`); state.rotationHistoryKey = key; renderRotationHistory(); } catch (error) { renderEmptyState(container, error.message || "轮动历史加载失败"); showToast(error.message || "轮动历史加载失败"); } finally { state.rotationLoading = false; } } function renderRotationHistory() { const rows = state.rotationHistory?.rows || []; const selected = state.rotationSelectedSector; const container = document.querySelector("#rotationHistory"); const tracker = document.querySelector("#rotationTracker"); if (!rows.length) { renderEmptyState(container, "尚无连续交易日的板块数据"); setText("rotationHistoryRange", "暂无轮动历史"); tracker.hidden = true; return; } const chronological = [...rows] .sort((left, right) => String(left.trade_date).localeCompare(String(right.trade_date))) .slice(-9); const displayRows = state.rotationOrder === "latest" ? [...chronological].reverse() : chronological; document.querySelectorAll("[data-rotation-order]").forEach((button) => { button.classList.toggle("active", button.dataset.rotationOrder === state.rotationOrder); }); setText( "rotationHistoryRange", `最近 ${chronological.length} 个交易日 · ${displayCompactDate(chronological[0].trade_date)} → ${displayCompactDate(chronological[chronological.length - 1].trade_date)} · ${state.rotationOrder === "latest" ? "由近到远,左侧为最新交易日" : "由远到近,右侧为最新交易日"}`, ); setText("rotationSelectionHint", selected ? `已联动高亮 ${selected}` : "点击任意板块追踪其连续性"); if (selected) { const sequence = displayRows.map((day) => { const sector = (day.sectors || []).find((item) => item.name === selected); return { tradeDate: day.trade_date, sector }; }); const appearances = sequence.filter((item) => item.sector); const bestRank = appearances.length ? Math.min(...appearances.map((item) => number(item.sector.rank))) : 0; tracker.hidden = false; const continuity = appearances.length >= 3 ? "主线候选" : appearances.length === 1 ? "单日异动,持续性待验证" : "间断活跃"; tracker.innerHTML = `
${escapeHtml(selected)}近 9 日在榜 ${appearances.length} 天 · 最高排名 #${bestRank || "--"} · ${continuity}
${sequence.map((item) => item.sector ? `#${number(item.sector.rank)}` : `--`).join("")}
`; tracker.querySelector(".rotation-track-cancel").addEventListener("click", () => { state.rotationSelectedSector = ""; state.rotationSelectedDate = ""; renderRotationHistory(); loadRotationMembers(""); }); } else { tracker.hidden = true; tracker.innerHTML = ""; } container.classList.toggle("tracking", Boolean(selected)); const latestTradeDate = chronological[chronological.length - 1].trade_date; container.innerHTML = displayRows.map((day) => { const hasSelected = selected && (day.sectors || []).some((sector) => sector.name === selected); return `
${(day.sectors || []).length} 个热点
${(day.sectors || []).map((sector) => { const strength = clamp(number(sector.strength), 0, 100); const heatClass = strength >= 90 ? "heat-strong" : strength >= 70 ? "heat-warm" : "heat-mild"; return ` `; }).join("")}
`; }).join(""); container.querySelectorAll("[data-rotation-sector]").forEach((button) => { button.addEventListener("click", () => { const clickedSector = button.dataset.rotationSector; const clickedDate = button.dataset.rotationDate; const isSameSelection = clickedSector === state.rotationSelectedSector && clickedDate === state.rotationSelectedDate; state.rotationSelectedSector = isSameSelection ? "" : clickedSector; state.rotationSelectedDate = isSameSelection ? "" : clickedDate; renderRotationHistory(); loadRotationMembers(state.rotationSelectedSector); }); }); } async function loadRotationMembers(sector, force = false) { if (!sector) { state.rotationMembers = null; state.rotationMembersKey = ""; renderRotationMembers(); return; } const memberDate = state.rotationSelectedDate || elements.tradeDate.value; const key = `${memberDate}:${sector}`; if (!force && state.rotationMembersKey === key && state.rotationMembers) { renderRotationMembers(); return; } state.rotationMembersLoading = true; renderRotationMembers(); try { const query = new URLSearchParams({ trade_date: memberDate, sector }); state.rotationMembers = await apiRequest(`/api/rotation/members?${query}`); state.rotationMembersKey = key; } catch (error) { state.rotationMembers = { error: error.message || "成分股加载失败", rows: [] }; state.rotationMembersKey = key; } finally { state.rotationMembersLoading = false; renderRotationMembers(); } } function renderRotationMembers() { const body = document.querySelector("#rotationTableBody"); const empty = document.querySelector("#rotationMembersEmpty"); if (state.rotationMembersLoading) { body.innerHTML = ""; empty.textContent = `正在核验${state.rotationSelectedSector}成分股`; empty.hidden = false; return; } const payload = state.rotationMembers; const rows = payload?.rows || []; if (!state.rotationSelectedSector || !payload || payload.error || !rows.length) { body.innerHTML = ""; empty.textContent = payload?.error || (state.rotationSelectedSector ? "该板块暂无可用成分行情" : "点击上方任意板块查看成分股"); empty.hidden = false; setText("rotationDetailTitle", "板块成分股"); setText("rotationDetailMeta", state.rotationSelectedSector || "--"); return; } empty.hidden = true; setText("rotationDetailTitle", `${payload.meta?.sector_name || state.rotationSelectedSector}成分股`); setText("rotationDetailMeta", `${displayCompactDate(payload.meta?.trade_date)} · ${number(payload.meta?.quoted_count)} / ${number(payload.meta?.member_count)} 只`); body.innerHTML = rows.map((row, index) => ` ${index + 1}${escapeHtml(row.code)}${escapeHtml(row.name)} ${row.quoted ? signed(row.change) : ""} ${row.quoted ? formatNumber(row.open, 2) : ""}${row.quoted ? formatNumber(row.close, 2) : ""} ${row.quoted ? formatNumber(row.amount_billion, 2) : ""}${row.quoted ? "正常交易" : "当日无行情"} `).join(""); animateRows(body); bindStockRows(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 allNames = group.stocks.map((stock) => stock.name).filter(Boolean); const visibleNames = allNames.slice(0, 3).join("、"); const suffix = allNames.length > 3 ? ` 等 ${number(group.count)} 只` : ""; return `
${escapeHtml(group.label)}${number(group.count)} 只

${escapeHtml(visibleNames || "--")}${suffix}

`; }).join("") || emptyStateHtml("暂无梯队数据"); } function renderSectorMini(sectors) { document.querySelector("#sectorMini").innerHTML = sectors.slice(0, 7).map((sector) => `
${escapeHtml(sector.name)}${number(sector.count)}
`).join("") || emptyStateHtml("暂无板块数据"); } function renderLadderBoard(ladders) { const container = document.querySelector("#ladderBoard"); const insights = document.querySelector("#ladderInsights"); const ordered = [...ladders].sort((left, right) => number(right.level) - number(left.level)); const maxLevel = ordered.length ? Math.max(...ordered.map((group) => number(group.level))) : 0; const topVisibleLevel = Math.max(5, maxLevel); const groupMap = new Map(ordered.map((group) => [number(group.level), group])); const displayGroups = Array.from({ length: topVisibleLevel }, (_, index) => { const level = topVisibleLevel - index; return groupMap.get(level) || { level, label: level === 1 ? "首板" : level === 5 && maxLevel < 5 ? "5板+" : `${level}板`, count: 0, stocks: [] }; }); const total = ordered.reduce((sum, group) => sum + number(group.count), 0); const spaceStocks = ordered.find((group) => number(group.level) === maxLevel)?.stocks || []; const currentDate = displayCompactDate(state.dashboard?.meta?.trade_date || elements.tradeDate.value); const previousDate = displayCompactDate(state.dashboard?.meta?.previous_trade_date || ""); setText("ladderDateRange", `数据日期 ${currentDate}`); container.innerHTML = displayGroups.map((group) => { const level = number(group.level); const limit = level === 1 || level === 2 ? 8 : 99; const expanded = state.expandedLadderLevels.has(level); const groupStocks = [...(group.stocks || [])].sort((left, right) => { if (state.ladderSortMode === "open") { return number(left.open_times) - number(right.open_times) || String(left.first_time || "99:99:99").localeCompare(String(right.first_time || "99:99:99")); } return String(left.first_time || "99:99:99").localeCompare(String(right.first_time || "99:99:99")); }); const stocks = expanded ? groupStocks : groupStocks.slice(0, limit); const remaining = Math.max(0, groupStocks.length - stocks.length); const label = group.label || (level === 1 ? "首板" : level === 5 && maxLevel < 5 ? "5板+" : `${level}板`); const color = { 1: "#2563eb", 2: "#16a34a", 3: "#d97706", 4: "#e04536" }[level] || "#9ca3af"; return `
${escapeHtml(label)}
${number(group.count)} 只
${number(group.count) && level > 1 ? `
${escapeHtml(label)} · ${formatNumber(number(group.count) / Math.max(number(groupMap.get(level - 1)?.count), 1) * 100, 1)}%
` : ""}
${stocks.length ? stocks.map((stock) => { const onePrice = String(stock.first_time || "").startsWith("09:25") && number(stock.open_times) === 0; const broken = number(stock.open_times) >= 6; const amount = number(stock.seal_amount_million) ? `封单 ${formatNumber(stock.seal_amount_million, 0)} 万` : `成交 ${formatNumber(stock.amount_billion, 1)} 亿`; return ``; }).join("") : `
${level >= maxLevel ? `断层 · ${escapeHtml(label)}及以上空缺` : "该层暂时空缺"}
`}${groupStocks.length > limit ? `` : ""}
`; }).join(""); const structureRows = displayGroups.filter((group) => number(group.count) || number(group.level) <= maxLevel + 1); const maxCount = Math.max(1, ...structureRows.map((group) => number(group.count))); const rateRows = (state.dashboard?.limit_performance || []).map((row) => ({ label: `${row.label || (number(row.level) === 1 ? "昨日首板" : `昨日${number(row.level)}板`)} → 今日`, value: clamp(number(row.advance_rate), 0, 100), })); const previousMax = Math.max(0, ...(state.dashboard?.yesterday_limits || []).map((row) => number(row.prior_streak))); const spaceChange = previousMax && maxLevel < previousMax ? `较昨日 ${previousMax} 板 ↓ 空间压缩` : previousMax && maxLevel > previousMax ? `较昨日 ${previousMax} 板 ↑ 高度抬升` : "高度与昨日接近"; const spaceNote = maxLevel >= 5 ? "高位梯队仍有辨识度,重点观察承接而非单看高度。" : maxLevel >= 3 ? "空间位于中段,梯队延续性比绝对高度更重要。" : "高度受到压缩,先观察首板向二板的结构修复。"; const strongestGroup = structureRows.reduce((best, group) => number(group.count) > number(best?.count) ? group : best, structureRows[0]); insights.innerHTML = `

空间板

市场高度
${maxLevel ? `${maxLevel} 板` : "--"}${escapeHtml(spaceChange)}

${spaceStocks.length ? spaceStocks.map((stock) => `${escapeHtml(stock.name)}(${escapeHtml(stock.sector || "其他")})`).join(" · ") : "暂无空间板"}

${spaceNote}

梯队结构

完整度
${structureRows.map((group) => `
${escapeHtml(group.label || `${number(group.level)}板`)}${number(group.count) ? `${number(group.count)} 只` : "断层"}
`).join("")}

断层越少,梯队从低位向高位传导越连贯。当前腰部为 ${escapeHtml(strongestGroup?.label || "--")}

晋级率参考

昨日梯队 → 今日
${rateRows.length ? rateRows.map((row) => `
${escapeHtml(row.label)}${formatNumber(row.value, 1)}%
`).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 loadAuctionCenter(force = false) { if (state.auctionLoading) return; state.auctionLoading = true; const button = document.querySelector("#auctionRefreshButton"); button.disabled = true; setText("auctionDateLabel", "正在读取竞价数据"); try { const query = new URLSearchParams({ trade_date: elements.tradeDate.value }); if (force) query.set("force", "1"); state.auctionData = await apiRequest(`/api/auction?${query}`); renderAuctionCenter(); scheduleAuctionTransition(state.auctionData.meta || {}); } catch (error) { document.querySelector("#auctionSummary").innerHTML = ""; document.querySelector("#auctionThemeCarry").innerHTML = ""; document.querySelector("#auctionNewThemes").innerHTML = ""; document.querySelector("#auctionAmountTrend").innerHTML = ""; document.querySelector("#auctionAmountCompare").innerHTML = ""; document.querySelector("#auctionTableBody").innerHTML = ""; document.querySelector("#auctionEmpty").hidden = false; setText("auctionDateLabel", error.message || "竞价数据暂不可用"); showToast(error.message || "竞价数据加载失败"); } finally { state.auctionLoading = false; button.disabled = false; } } function renderAuctionCenter() { const payload = state.auctionData; if (!payload) return; const summary = payload.summary || {}; renderAuctionPhase(payload.meta || {}); setText( "auctionDateLabel", `${payload.meta?.carried_forward ? "最近有效竞价" : "竞价日期"} ${payload.meta?.trade_date || "--"}`, ); document.querySelector("#auctionSummary").innerHTML = [ ["竞价覆盖", `${formatNumber(summary.stock_count, 0)} 只`, ""], ["重点异动", `${formatNumber(summary.focus_count, 0)} 只`, "up"], ["竞价一字", `${formatNumber(summary.one_price_count, 0)} 只`, ""], ["竞价成交额", `${formatNumber(summary.amount_billion, 2)} 亿`, ""], ].map(([label, value, tone]) => `
${label}${value}
`).join(""); setText("auctionFocusCount", number(summary.focus_count)); setText("auctionAllCount", number(summary.candidate_count)); setText("auctionOnePriceCount", number(summary.one_price_count)); setText("auctionWatchlistCount", number(payload.watchlist_rows?.length)); renderAuctionInsights(payload); renderAuctionTable(); } function renderAuctionInsights(payload) { const themes = payload.themes || {}; const carry = themes.carry || []; const tone = { "强承接": "strong", "有承接": "steady", "分歧": "mixed", "承接弱": "weak" }; setText("auctionThemeBaseline", `基于 ${payload.candidate_meta?.baseline_date || "--"}`); document.querySelector("#auctionThemeCarry").innerHTML = carry.length ? carry.map((item) => `
${escapeHtml(item.name)} ${escapeHtml(item.leader || "--")} · 昨日 ${number(item.prior_limit_count)} 只涨停 ${escapeHtml(item.status)} ${item.median_change == null ? "暂无有效候选" : `${signed(item.median_change)}%`}中位
`).join("") : '
暂无昨日强势题材基线
'; const newThemes = themes.new_themes || []; document.querySelector("#auctionNewThemes").innerHTML = newThemes.length ? newThemes.map((item) => `${escapeHtml(item.name)} ${number(item.stock_count)}`).join("") : '尚未形成多股共振的新线索'; const history = payload.amount_history || []; const maximum = Math.max(...history.map((item) => number(item.amount_billion)), 1); const priorFive = history.slice(Math.max(0, history.length - 6), Math.max(0, history.length - 1)); const fiveDayAverage = priorFive.length ? priorFive.reduce((sum, item) => sum + number(item.amount_billion), 0) / priorFive.length : null; document.querySelector("#auctionAmountTrend").innerHTML = history.length ? history.map((item, index) => { const height = Math.max(8, number(item.amount_billion) / maximum * 100); const current = index === history.length - 1 ? " current" : ""; return `
${escapeHtml(String(item.trade_date || "").slice(5))}
`; }).join("") + (fiveDayAverage === null ? "" : `
5日均 ${formatNumber(fiveDayAverage, 1)}
`) : '
历史竞价量能尚未形成
'; setText("auctionAmountValue", `${formatNumber(payload.summary?.amount_billion, 2)} 亿`); const comparison = [ ["较昨日", payload.summary?.amount_change_previous], ["较5日均值", payload.summary?.amount_change_5d], ]; document.querySelector("#auctionAmountCompare").innerHTML = comparison.map(([label, value]) => ` ${label}${value == null ? "--" : `${signed(value)}%`} `).join(""); } function renderAuctionPhase(meta) { const phase = meta.phase || "archive"; const available = Boolean(meta.available); const copy = { pending: ["竞价尚未开始", "9:15 进入观察期,9:25 读取最终竞价结果。", "下一阶段 09:15"], observing: ["竞价观察期", "此阶段先观察盘前变化,系统将在 9:25 自动读取最终结果。", "09:25 定格"], selection: available ? ["竞价筛选窗口", "最终竞价结果已经定格,请在 9:30 前完成筛选。", "有效至 09:30"] : ["等待最终竞价", "9:25 数据尚未到达,系统正在自动重试。", "即将更新"], finalized: ["今日竞价已定格", "9:30 后停止更新,仅保留用于复盘、回测与智能选股。", "已冻结"], archive: ["历史竞价归档", "当前展示所选交易日的最终竞价结果。", "归档数据"], }[phase] || ["竞价状态", "当前竞价状态待确认。", "--"]; const notice = document.querySelector("#auctionPhaseNotice"); notice.dataset.phase = phase; setText("auctionPhaseTitle", copy[0]); setText("auctionPhaseDetail", copy[1]); setText("auctionPhaseTime", copy[2]); const refresh = document.querySelector("#auctionRefreshButton"); refresh.hidden = phase !== "selection"; refresh.disabled = state.auctionLoading; } function clearAuctionTimer() { if (state.auctionTimer) clearTimeout(state.auctionTimer); state.auctionTimer = null; } function scheduleAuctionTransition(meta) { clearAuctionTimer(); if (state.activeView !== "auctionView") return; let delay = 0; if (["selection", "finalized"].includes(meta.phase) && !meta.available) { delay = 10_000; } else if (meta.next_transition_at) { const transitionAt = new Date(meta.next_transition_at).getTime(); if (Number.isFinite(transitionAt)) delay = Math.max(800, transitionAt - Date.now() + 500); } if (!delay) return; state.auctionTimer = setTimeout(() => { state.auctionTimer = null; if (state.activeView === "auctionView") loadAuctionCenter(true); }, Math.min(delay, 2_147_000_000)); } function renderAuctionTable() { const rows = currentAuctionRows(); const columns = auctionColumns(); const head = document.querySelector("#auctionTableHead"); head.innerHTML = columns.map((column) => { const sorted = column.sortKey === state.auctionSortKey; const arrow = !column.sortKey ? "" : `${sorted ? (state.auctionSortDirection === "desc" ? "▼" : "▲") : "↕"}`; return `${column.label}${arrow}`; }).join(""); const body = document.querySelector("#auctionTableBody"); body.innerHTML = rows.map((row) => `${columns.map((column) => renderAuctionCell(row, column.key)).join("")}`).join(""); bindStockRows(body); const datasetCopy = { focus: ["重点异动", "优先查看市场核心与显著预期差"], onePrice: ["竞价一字", "竞价封于当日真实涨停价,不参与普通异动评分"], watchlist: ["我的自选", "仅展示当前账号关注标的的竞价反馈"], all: ["全部候选", "昨日涨停、炸板与热榜前20候选"], }[state.auctionDataset] || ["竞价异动", ""]; setText("auctionWorkspaceTitle", datasetCopy[0]); setText("auctionWorkspaceSubtitle", datasetCopy[1]); document.querySelector("#auctionExpectationControls").hidden = state.auctionDataset === "onePrice"; const empty = document.querySelector("#auctionEmpty"); const phase = state.auctionData?.meta?.phase || "archive"; empty.textContent = phase === "selection" && !state.auctionData?.meta?.available ? "正在等待 9:25 最终竞价数据" : state.auctionDataset === "watchlist" ? "当前账号还没有可观察的自选股" : state.auctionDataset === "onePrice" ? "当前没有竞价封于涨停价的股票" : "没有符合条件的竞价候选"; empty.hidden = rows.length > 0; } function currentAuctionRows() { const datasets = { focus: state.auctionData?.focus_rows || [], onePrice: state.auctionData?.one_price_rows || [], watchlist: state.auctionData?.watchlist_rows || [], all: state.auctionData?.rows || [], }; let rows = [...(datasets[state.auctionDataset] || [])]; const filter = state.auctionFilter; const labels = { above: "超预期", matched: "符合预期", below: "低于预期" }; if (labels[filter]) rows = rows.filter((item) => item.expectation === labels[filter]); if (state.auctionQuery) { rows = rows.filter((item) => `${item.code} ${item.name} ${item.sector}`.toLocaleLowerCase("zh-CN").includes(state.auctionQuery)); } const key = state.auctionSortKey; const direction = state.auctionSortDirection === "asc" ? 1 : -1; if (key) { rows.sort((left, right) => { const leftValue = left[key]; const rightValue = right[key]; if (leftValue == null && rightValue == null) return 0; if (leftValue == null) return 1; if (rightValue == null) return -1; const result = typeof leftValue === "number" || typeof rightValue === "number" ? number(leftValue) - number(rightValue) : String(leftValue).localeCompare(String(rightValue), "zh-CN", { numeric: true }); return result * direction; }); } return rows.slice(0, 300); } function auctionColumns() { const base = [ { key: "stock", label: "股票" }, { key: "context", label: "方向与来源" }, { key: "identity", label: "市场身份" }, ]; const metrics = [ { key: "score", label: "关注分", numeric: true, sortKey: "attention_score" }, { key: "expectation", label: "预期判断" }, { key: "change", label: "竞价涨幅(%)", numeric: true, sortKey: "change" }, { key: "amount", label: "竞价额(百万)", numeric: true, sortKey: "amount_million" }, { key: "volume", label: "量比", numeric: true, sortKey: "volume_ratio" }, ]; return state.auctionDataset === "onePrice" ? [...base, ...metrics.slice(2)] : [...base, ...metrics]; } function renderAuctionCell(row, key) { const unavailable = row.available === false; const onePrice = Boolean(row.is_one_price); const expectationTone = { "超预期": "above", "符合预期": "matched", "低于预期": "below" }; if (key === "stock") return `${escapeHtml(row.name)}${escapeHtml(row.code)}`; if (key === "context") return `${escapeHtml(row.sector || "其他")}${renderAuctionSources(row.source_label || (state.auctionDataset === "watchlist" ? "我的自选" : "全市场"))}`; if (key === "identity") return `${renderAuctionCoreTags(row.core_tags)}`; if (unavailable) return key === "expectation" ? '暂无竞价' : ``; if (key === "score") return `${onePrice ? "" : formatNumber(row.attention_score, 1)}`; if (key === "expectation") { const tag = onePrice ? '竞价一字' : `${escapeHtml(row.expectation || "符合预期")}`; return `${tag}`; } if (key === "change") return `${signed(row.change)}`; if (key === "amount") return `${formatNumber(row.amount_million, 2)}`; if (key === "volume") return `${formatNumber(row.volume_ratio, 2)}`; return ""; } function renderAuctionSources(value) { const sources = String(value || "").split(/[·、/]/).map((item) => item.trim()).filter(Boolean).slice(0, 3); return `${sources.map((source) => `${escapeHtml(source)}`).join("")}`; } function renderAuctionCoreTags(tags) { const values = Array.isArray(tags) ? tags : []; return values.length ? `${values.slice(0, 2).map((tag) => `${escapeHtml(tag)}`).join("")}` : ''; } function exportAuctionRows() { const rows = currentAuctionRows(); exportRows("集合竞价", rows, [ ["股票代码", "code"], ["股票名称", "name"], ["行业", "sector"], ["来源", "source_label"], ["市场身份", "core_tags"], ["关注分", "attention_score"], ["预期判断", "expectation"], ["竞价涨幅%", "change"], ["竞价额百万", "amount_million"], ["量比", "volume_ratio"], ]); } async function loadThemeLibrary(force = false) { if (state.themeLoading) return; state.themeLoading = true; const button = document.querySelector("#themeRefreshButton"); button.disabled = true; setText("themeDateLabel", "正在整理题材库"); try { const query = new URLSearchParams({ trade_date: elements.tradeDate.value }); if (force) query.set("force", "1"); state.themeLibrary = await apiRequest(`/api/themes?${query}`); renderThemeLibrary(); const available = (state.themeLibrary.items || []).some((item) => item.code === state.selectedThemeCode); if (!available) state.selectedThemeCode = ""; const initialCode = state.selectedThemeCode || state.themeLibrary.items?.[0]?.code || ""; if (initialCode) await selectTheme(initialCode, true); } catch (error) { setText("themeDateLabel", error.message || "题材数据暂不可用"); renderEmptyState("themeDirectory", error.message || "题材数据加载失败"); showToast(error.message || "题材数据加载失败"); } finally { state.themeLoading = false; button.disabled = false; } } function renderThemeLibrary() { const payload = state.themeLibrary; if (!payload) return; const summary = payload.summary || {}; setText("themeDateLabel", `${payload.meta?.carried_forward ? "最近有效行情" : "行情日期"} ${payload.meta?.trade_date || "--"}`); document.querySelector("#themeSummary").innerHTML = [ ["收录题材", number(summary.theme_count), "个", ""], ["当日上涨", number(summary.up_count), "个", "up"], ["当日下跌", number(summary.down_count), "个", "down"], ["人气题材", number(summary.hot_count), "个", "warning"], ].map(([label, value, unit, tone]) => `
${label}${value}${unit}
`).join(""); renderThemeDirectory(); } function renderThemeDirectory() { let items = [...(state.themeLibrary?.items || [])]; if (state.themeQuery) { items = items.filter((item) => `${item.code} ${item.name}`.toLocaleLowerCase("zh-CN").includes(state.themeQuery)); } setText("themeResultCount", `${items.length} 个`); document.querySelector("#themeDirectory").innerHTML = items.map((item, index) => { const active = item.code === state.selectedThemeCode; return ` `; }).join("") || emptyStateHtml("没有匹配的题材"); } async function selectTheme(code, keepSelection = false) { if (!code) return; state.selectedThemeCode = code; if (!keepSelection) renderThemeDirectory(); document.querySelector("#themeDetailEmpty").hidden = false; document.querySelector("#themeDetailContent").hidden = true; setText("themeDetailEmpty", "正在读取题材详情"); try { const query = new URLSearchParams({ code, trade_date: elements.tradeDate.value }); state.themeDetail = await apiRequest(`/api/themes/detail?${query}`); renderThemeDetail(); } catch (error) { setText("themeDetailEmpty", error.message || "题材详情加载失败"); showToast(error.message || "题材详情加载失败"); } } function renderThemeDetail() { const payload = state.themeDetail; if (!payload) return; const theme = payload.theme || {}; const summary = payload.summary || {}; document.querySelector("#themeDetailEmpty").hidden = true; document.querySelector("#themeDetailContent").hidden = false; setText("themeDetailName", theme.name || "--"); setText("themeDetailCode", `${theme.code || "--"} · ${payload.meta?.trade_date || "--"}`); setText("themeDetailChange", `${signed(theme.change)}%`); document.querySelector("#themeDetailChange").className = changeClass(theme.change); document.querySelector("#themeDetailMetrics").innerHTML = [ ["成分股", `${number(summary.member_count)} 只`, ""], ["有行情", `${number(summary.quoted_count)} 只`, ""], ["上涨", `${number(summary.up_count)} 只`, "up"], ["下跌", `${number(summary.down_count)} 只`, "down"], ["换手率", `${formatNumber(theme.turnover_rate, 2)}%`, ""], ].map(([label, value, tone]) => `
${label}${value}
`).join(""); setText("themeMemberCount", `有行情 ${number(summary.quoted_count)} / ${number(summary.member_count)}`); const body = document.querySelector("#themeMemberTableBody"); body.innerHTML = (payload.members || []).map((row, index) => ` ${index + 1} ${escapeHtml(row.name)}${escapeHtml(row.code)} ${row.has_quote ? signed(row.change) : ""} ${row.has_quote ? formatNumber(row.price, 2) : ""}${row.has_quote ? formatNumber(row.amount_billion, 2) : ""}`).join(""); bindStockRows(body); renderThemeDirectory(); } async function loadPopularity(force = false) { if (state.popularityLoading) return; state.popularityLoading = true; const button = document.querySelector("#popularityRefreshButton"); button.disabled = true; setText("popularityDateLabel", "正在读取人气榜"); try { const query = new URLSearchParams({ trade_date: elements.tradeDate.value }); if (force) query.set("force", "1"); state.popularityData = await apiRequest(`/api/popularity?${query}`); renderPopularity(); } catch (error) { setText("popularityDateLabel", error.message || "人气榜暂不可用"); document.querySelector("#popularityTableBody").innerHTML = ""; document.querySelector("#popularityEmpty").hidden = false; showToast(error.message || "人气榜加载失败"); } finally { state.popularityLoading = false; button.disabled = false; } } function renderPopularity() { const payload = state.popularityData; if (!payload) return; const summary = payload.summary || {}; setText("popularityDateLabel", `${payload.meta?.carried_forward ? "最近有效榜单" : "榜单日期"} ${payload.meta?.trade_date || "--"}`); const topNames = (rows) => (rows || []).slice(0, 3).map((item) => item.name).filter(Boolean).join(" · ") || "--"; document.querySelector("#popularitySummary").innerHTML = [ ["同花顺热度 Top3", topNames(payload.ths), `共 ${number(summary.ths_count)} 只上榜`], ["东方财富热度 Top3", topNames(payload.dc), `共 ${number(summary.dc_count)} 只上榜`], ["双榜共识", `${number(summary.dual_count)} 只`, "同时进入两榜,共识度更高"], ].map(([label, value, detail], index) => `
${label}${escapeHtml(value)}${escapeHtml(detail)}
`).join(""); renderPopularityTable(); } function renderPopularityTable() { const source = state.popularitySource; let rows = [...(state.popularityData?.[source] || [])]; if (state.popularityQuery) { rows = rows.filter((item) => `${item.code} ${item.name} ${(item.concepts || []).join(" ")}`.toLocaleLowerCase("zh-CN").includes(state.popularityQuery)); } const combined = source === "combined"; const sourceName = source === "ths" ? "同花顺" : source === "dc" ? "东方财富" : "双榜综合"; setText("popularityTableTitle", `${sourceName}榜`); setText("popularityTableNote", combined ? "按双榜排名综合排序 · 已隐藏重复的榜单状态" : "按榜单名次排序 · 状态显示是否同时进入另一榜"); const headers = [ ["排名", "number num"], ["股票", ""], ["最新价(元)", "number num"], ["涨跌幅(%)", "number num"], ...(source !== "dc" ? [["同花顺", "number num"]] : []), ...(source !== "ths" ? [["东方财富", "number num"]] : []), ["排名变化", "number num"], ["热门概念", ""], ...(!combined ? [["榜单状态", ""]] : []), ]; document.querySelector("#popularityTableHead").innerHTML = headers.map(([label, className]) => `${label}`).join(""); const body = document.querySelector("#popularityTableBody"); body.innerHTML = rows.map((row, index) => { const thsRank = source === "ths" ? row.rank : row.ths_rank; const dcRank = source === "dc" ? row.rank : row.dc_rank; const move = row.rank_change; const movement = move === null || move === undefined ? "新" : number(move) > 0 ? `↑${number(move)}` : number(move) < 0 ? `↓${Math.abs(number(move))}` : "持平"; return ` ${index + 1}${index < 3 ? '' : ""}
${escapeHtml(row.name)}${escapeHtml(row.code)}
${row.price == null ? "" : formatNumber(row.price, 2)} ${row.change == null ? "" : signed(row.change)} ${source !== "dc" ? `${thsRank ? number(thsRank) : ""}` : ""} ${source !== "ths" ? `${dcRank ? number(dcRank) : ""}` : ""} ${movement} ${escapeHtml((row.concepts || []).slice(0, 3).join("、"))} ${!combined ? `${row.dual_source ? "双榜共识" : "单榜入选"}` : ""} `; }).join(""); bindStockRows(body); markAutoSortableHeaders(body.closest("table")); document.querySelector("#popularityEmpty").hidden = rows.length > 0; } function selectDragonViewMode(mode) { state.dragonViewMode = mode === "profiles" ? "profiles" : "daily"; document.querySelectorAll("[data-dragon-view-mode]").forEach((button) => { const active = button.dataset.dragonViewMode === state.dragonViewMode; button.classList.toggle("active", active); button.setAttribute("aria-pressed", String(active)); }); if (state.dragonViewMode === "profiles") { document.querySelector("#dragonDailyContent").hidden = true; document.querySelector("#dragonEmptyState").hidden = true; document.querySelector("#dragonProfilesContent").hidden = false; if (state.hotMoneyProfiles) renderHotMoneyProfiles(); else loadHotMoneyProfiles(); } else { document.querySelector("#dragonProfilesContent").hidden = true; if (state.dragonTiger) renderDragonTiger(); else loadDragonTiger(); } } async function loadHotMoneyProfiles(force = false) { if (!force && state.hotMoneyProfiles) { renderHotMoneyProfiles(); return; } setStatus("正在加载游资档案"); try { const query = new URLSearchParams(); if (force) query.set("force", "1"); const suffix = query.size ? `?${query}` : ""; state.hotMoneyProfiles = await apiRequest(`/api/dragon-tiger/profiles${suffix}`); renderHotMoneyProfiles(); const count = number(state.hotMoneyProfiles.summary?.profile_count); setStatus(`游资档案已加载 · 共 ${count} 位`); } catch (error) { showToast(error.message || "游资档案加载失败"); setStatus("游资档案加载失败"); } } function renderHotMoneyProfiles() { const payload = state.hotMoneyProfiles; if (!payload) return; const profiles = payload.profiles || []; const summary = payload.summary || {}; const query = state.hotMoneyProfileQuery; const visible = profiles.filter((profile) => { if (!query) return true; return [profile.name, profile.description, ...(profile.organizations || [])] .join(" ") .toLocaleLowerCase("zh-CN") .includes(query); }); if (!visible.some((profile) => profile.id === state.selectedHotMoneyProfileId)) { state.selectedHotMoneyProfileId = visible[0]?.id || ""; } const selected = visible.find((profile) => profile.id === state.selectedHotMoneyProfileId) || null; setText("dragonDateLabel", `收录 ${number(summary.profile_count)} 位`); setText("hotMoneyProfileResultCount", query ? `${visible.length} / ${profiles.length} 位` : `${profiles.length} 位`); document.querySelector("#hotMoneyProfileSummary").innerHTML = [ ["收录游资", number(summary.profile_count)], ["已有简介", number(summary.described_count)], ["关联席位", number(summary.organization_count)], ].map(([label, value]) => `${label}${value}`).join(""); const list = document.querySelector("#hotMoneyProfileList"); list.innerHTML = visible.length ? visible.map((profile, index) => ` `).join("") : `
${profiles.length ? "没有符合条件的游资档案" : "游资名录暂不可用"}
`; const detail = document.querySelector("#hotMoneyProfileDetail"); if (!selected) { detail.innerHTML = `
${profiles.length ? "选择一位游资查看档案" : "暂无可展示的游资档案"}
`; } else { const organizations = selected.organizations || []; detail.innerHTML = `
${escapeHtml(selected.name.slice(0, 2))}
游资档案

${escapeHtml(selected.name)}

${organizations.length ? `关联 ${organizations.length} 个公开席位` : "暂无关联席位"}

人物简介

${escapeHtml(selected.description || "名录暂未收录该游资的公开简介。")}

关联营业部

${organizations.length} 个
${organizations.length ? organizations.map((organization) => ` ${escapeHtml(organization)} `).join("") : '

名录暂未收录关联营业部。

'}
${payload.meta?.notice ? `

${escapeHtml(payload.meta.notice)}

` : ""}`; } refreshIcons(); } async function loadDragonTiger(force = false) { const requestedDate = elements.tradeDate.value; if ( !force && ["success", "empty", "partial", "unavailable"].includes(state.dragonTiger?.meta?.status) && (state.dragonTiger?.meta?.requested_date || state.dragonTiger?.meta?.trade_date) === requestedDate ) { renderDragonTiger(); return; } setStatus("正在加载龙虎榜"); try { const query = new URLSearchParams({ trade_date: requestedDate }); if (force) query.set("force", "1"); const payload = await apiRequest(`/api/dragon-tiger?${query}`); state.dragonTiger = payload; renderDragonTiger(); const statusLabel = payload.meta.status === "error" ? "龙虎榜数据暂不可用" : payload.meta.status === "empty" ? "当日暂无公开游资明细" : payload.meta.status === "partial" ? "当日有龙虎榜,暂无命名游资明细" : payload.meta.status === "unavailable" ? "龙虎榜数据暂不可用" : "龙虎榜明细"; setStatus(`${statusLabel} · 龙虎榜已加载`); } catch (error) { showToast(error.message || "龙虎榜加载失败"); setStatus("龙虎榜加载失败"); } } function renderDragonTiger() { const payload = state.dragonTiger; if (!payload) return; const summary = payload.summary || {}; if (state.dragonViewMode === "daily") setText("dragonDateLabel", `数据日期 ${payload.meta.trade_date}`); const status = payload.meta?.status || "empty"; const hasRecognizedTraders = (payload.traders || []).some((item) => item.identity_type === "trader" && item.recognized !== false); const showEmptyState = !hasRecognizedTraders && !(payload.unclassified_seats || []).length && ["empty", "error", "unavailable"].includes(status); const dailyVisible = state.dragonViewMode === "daily"; document.querySelector("#dragonProfilesContent").hidden = dailyVisible; document.querySelector("#dragonEmptyState").hidden = !dailyVisible || !showEmptyState; document.querySelector("#dragonDailyContent").hidden = !dailyVisible || showEmptyState; if (showEmptyState) { const unavailable = ["error", "unavailable"].includes(status); setText("dragonEmptyTitle", unavailable ? "龙虎榜数据暂不可用" : `${payload.meta?.trade_date || "该交易日"} 暂无龙虎榜明细`); setText("dragonEmptyDescription", unavailable ? "当前数据暂未完成更新,可稍后重新检查或查看前一交易日。" : "龙虎榜明细通常在交易日盘后陆续披露,可稍后刷新或查看前一交易日。"); } document.querySelector("#dragonSummary").innerHTML = [ ["上榜游资", `${number(summary.trader_count)} 位`, ""], ["操作明细", `${number(summary.operation_count)} 条`, ""], ["席位净买入", formatMoneyMillion(summary.seat_net_buy_million), changeClass(summary.seat_net_buy_million)], ["活跃股票", `${number(summary.active_stock_count)} 只`, ""], ].map(([label, value, className]) => `
${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 (["error", "unavailable"].includes(payload.meta?.status)) emptyMessage = "龙虎榜数据暂不可用,请稍后重试"; else if (payload.meta?.status === "empty") emptyMessage = "该交易日暂无游资每日明细"; else if (payload.meta?.status === "partial") emptyMessage = `当日有 ${number(payload.summary?.official_stock_count)} 只股票上榜,但暂无可识别的游资明细`; if (!traders.some((item) => item.id === state.selectedDragonTraderId)) { state.selectedDragonTraderId = traders[0]?.id || ""; } const cardMarkup = traders.map((trader, index) => { const description = trader.description || `${number(trader.stock_count)} 只股票,${number(trader.operation_count)} 笔操作`; return ` `; }).join(""); const hitZoneMarkup = traders.map((trader) => ` `).join(""); container.innerHTML = traders.length ? `${cardMarkup}
${hitZoneMarkup}
` : emptyStateHtml(state.dragonFilter === "unclassified" ? "待归类席位请在下方管理" : emptyMessage, { className: "dragon-empty" }); container.querySelectorAll("[data-dragon-card]").forEach((card) => { card.addEventListener("animationend", () => card.classList.remove("dealing"), { once: true }); }); container.querySelectorAll("[data-dragon-trader]").forEach((hitZone) => { const setHovered = (hovered) => { container.querySelector(`[data-dragon-card="${CSS.escape(hitZone.dataset.dragonTrader)}"]`)?.classList.toggle("hovered", hovered); }; hitZone.addEventListener("pointerenter", () => setHovered(true)); hitZone.addEventListener("pointerleave", () => setHovered(false)); hitZone.addEventListener("focus", () => setHovered(true)); hitZone.addEventListener("blur", () => setHovered(false)); hitZone.addEventListener("click", () => { state.selectedDragonTraderId = hitZone.dataset.dragonTrader; container.querySelectorAll("[data-dragon-card]").forEach((card) => { card.classList.toggle("selected", card.dataset.dragonCard === state.selectedDragonTraderId); }); container.querySelectorAll("[data-dragon-trader]").forEach((item) => { item.setAttribute("aria-pressed", String(item.dataset.dragonTrader === state.selectedDragonTraderId)); }); renderDragonTraderDetail(traders.find((item) => item.id === state.selectedDragonTraderId)); }); }); requestAnimationFrame(() => layoutDragonCards(container)); renderDragonTraderDetail(traders.find((item) => item.id === state.selectedDragonTraderId)); } function layoutDragonCards(container = document.querySelector("#dragonTraderList")) { if (!container) return; const cards = [...container.querySelectorAll(".dragon-trader-card")]; const hitZones = [...container.querySelectorAll(".dragon-card-hit-zone")]; if (!cards.length) return; const compact = window.innerWidth <= 720; const cardWidth = compact ? 148 : 176; const available = Math.max(cardWidth, container.clientWidth - (compact ? 30 : 72)); const spread = Math.min(available - cardWidth, compact ? 310 : 1050); const step = cards.length > 1 ? Math.min(cardWidth + 14, spread / (cards.length - 1)) : 0; const center = (cards.length - 1) / 2; container.style.setProperty("--dragon-card-width", `${cardWidth}px`); cards.forEach((card, index) => { const x = (index - center) * step; card.style.setProperty("--card-x", `${x.toFixed(2)}px`); card.style.setProperty("--card-rotation", "0deg"); card.style.setProperty("--card-y", "0px"); card.style.zIndex = String(index + 1); const hitZone = hitZones[index]; if (hitZone) { const zoneWidth = index === cards.length - 1 ? cardWidth : Math.max(18, step); hitZone.style.left = `calc(50% + ${(x - cardWidth / 2).toFixed(2)}px)`; hitZone.style.width = `${zoneWidth.toFixed(2)}px`; } }); } function renderDragonTraderDetail(trader) { const container = document.querySelector("#dragonTraderDetail"); if (!trader) { container.hidden = true; renderEmptyState(container, "选择一位游资查看操作明细", { className: "dragon-empty" }); return; } container.hidden = false; container.innerHTML = `
当日操作明细

${escapeHtml(trader.name)}

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

买入
${formatMoneyMillion(trader.buy_million)}
卖出
${formatMoneyMillion(trader.sell_million)}
净额
${formatMoneyMillion(trader.net_buy_million)}
${(trader.operations || []).map((operation, index) => ` `).join("")}
序号股票方向涨幅(%)买入(百万)卖出(百万)净额(百万)关联席位标签 / 上榜原因
${index + 1} ${escapeHtml(operation.name)}${escapeHtml(operation.code)} ${escapeHtml(operation.direction)} ${operation.change == null ? "" : signed(operation.change)} ${operation.buy_million == null ? "" : formatNumber(operation.buy_million, 2)} ${operation.sell_million == null ? "" : formatNumber(operation.sell_million, 2)} ${operation.net_buy_million == null ? "" : signed(operation.net_buy_million)} ${escapeHtml(operation.seat_name)} ${escapeHtml(operation.tag && operation.tag !== "--" ? operation.tag : operation.reason && operation.reason !== "--" ? operation.reason : "")}
`; bindStockRows(container); markAutoSortableHeaders(container); } function renderUnclassifiedSeats() { const seats = state.dragonTiger?.unclassified_seats || []; const canManage = state.user?.role === "admin"; document.querySelector("#dragonUnclassifiedSection").hidden = !canManage || seats.length === 0; document.querySelector("#dragonUnclassifiedFilter").hidden = !canManage || seats.length === 0; if (!seats.length && state.dragonFilter === "unclassified") { state.dragonFilter = "all"; document.querySelectorAll("[data-dragon-filter]").forEach((button) => { button.classList.toggle("active", button.dataset.dragonFilter === "all"); }); renderDragonTraderList(); } setText("unclassifiedCount", `${seats.length} 个`); const list = document.querySelector("#unclassifiedSeatList"); list.innerHTML = seats.map((seat, index) => `
${escapeHtml(seat.seat_name)} ${number(seat.operation_count)} 笔 · ${number(seat.stock_count)} 股 ${formatMoneyMillion(seat.net_buy_million)}
`).join("") || emptyStateHtml("当前席位均已归类"); list.querySelectorAll(".unclassified-seat-row").forEach((form) => { form.addEventListener("submit", saveSeatAlias); }); } function dragonIdentityLabel(type) { return { trader: "游资", institution: "机构", channel: "通道", unclassified: "待归类" }[type] || "席位"; } async function saveSeatAlias(event) { event.preventDefault(); const form = event.currentTarget; const seat = state.dragonTiger?.unclassified_seats?.[number(form.dataset.unclassifiedIndex)]; const alias = form.querySelector("input").value.trim(); if (!seat || !alias) { showToast("请输入游资名"); return; } const button = form.querySelector("button"); button.disabled = true; try { await apiRequest("/api/seat-aliases", "POST", { seat_name: seat.seat_name, alias }); state.dragonTiger = null; await loadDragonTiger(); showToast(`已将席位归类为 ${alias}`); } catch (error) { showToast(error.message); button.disabled = false; } } async function loadReviewWorkspace() { try { const [watchlistPayload, notesPayload, tradesPayload] = await Promise.all([ apiRequest(`/api/watchlist?trade_date=${encodeURIComponent(elements.tradeDate.value)}`), apiRequest("/api/notes?scope=daily"), apiRequest("/api/trades"), ]); state.watchlist = watchlistPayload.items || []; state.notes = notesPayload.items || []; state.tradeEntries = tradesPayload.items || []; state.tradeSummary = tradesPayload.summary || {}; setText("reviewDataDate", displayCompactDate(elements.tradeDate.value)); renderWatchlist(); renderNotesHistory(state.notes, document.querySelector("#notesHistory"), false); setText("notesCount", `${state.notes.length} 条`); renderTradeLog(); populateJournalForm(); } catch (error) { showToast(error.message || "我的复盘加载失败"); } } function renderWatchlist() { setText("watchlistCount", `${state.watchlist.length} 只`); const body = document.querySelector("#watchlistTableBody"); body.innerHTML = state.watchlist.map((item) => ` ${escapeHtml(item.name)}${escapeHtml(item.code)} ${escapeHtml(item.sector || "其他")} ${formatWatchMetric(item.change)} ${formatWatchMetric(item.return_5d)} ${item.attention_score == null ? "" : formatNumber(item.attention_score, 1)} ${escapeHtml(item.remark || "尚未填写")} `).join(""); document.querySelector("#watchlistEmpty").hidden = state.watchlist.length > 0; body.querySelectorAll("[data-watch-remark]").forEach((button) => { button.addEventListener("click", () => { const item = state.watchlist.find((row) => row.code === button.dataset.watchRemark); openWatchlistDialog(item); }); }); body.querySelectorAll("[data-watch-delete]").forEach((button) => { button.addEventListener("click", () => removeWatchlist(button.dataset.watchDelete)); }); bindStockRows(body); } function formatWatchMetric(value) { if (value == null || !Number.isFinite(Number(value))) return ""; return signed(value); } function openWatchlistDialog(item = null) { clearTimeout(watchlistSearchTimer); state.watchlistSelection = item ? { code: item.code, name: item.name, sector: item.sector || "其他", color: item.color || "red", } : null; state.watchlistSearchResults = []; setText("watchlistDialogTitle", item ? "编辑跟踪备注" : "添加自选"); document.querySelector("#watchlistRemark").value = item?.remark || ""; document.querySelector("#watchlistSearchInput").value = ""; document.querySelector("#watchlistSearchResults").innerHTML = ""; syncWatchlistSelection(Boolean(item)); openModalDialog(elements.watchlistDialog); requestAnimationFrame(() => (item ? document.querySelector("#watchlistRemark") : document.querySelector("#watchlistSearchInput")).focus()); } function closeWatchlistDialog() { clearTimeout(watchlistSearchTimer); if (elements.watchlistDialog.open) elements.watchlistDialog.close(); } function clearWatchlistSelection() { state.watchlistSelection = null; syncWatchlistSelection(false); document.querySelector("#watchlistSearchInput").focus(); } function syncWatchlistSelection(editing = false) { const item = state.watchlistSelection; document.querySelector("#watchlistSearchField").hidden = Boolean(item); document.querySelector("#watchlistSelection").hidden = !item; document.querySelector("#changeWatchlistSelection").hidden = editing; document.querySelector("#saveWatchlist").disabled = !item; if (!item) return; setText("watchlistSelectionName", item.name || "--"); setText("watchlistSelectionCode", item.code || "--"); setText("watchlistSelectionSector", item.sector || "其他"); refreshIcons(); } function scheduleWatchlistSearch() { clearTimeout(watchlistSearchTimer); const query = document.querySelector("#watchlistSearchInput").value.trim(); if (!query) { document.querySelector("#watchlistSearchResults").innerHTML = ""; return; } document.querySelector("#watchlistSearchResults").innerHTML = '
正在查找股票
'; watchlistSearchTimer = setTimeout(() => runWatchlistSearch(query), 160); } async function runWatchlistSearch(query) { const sequence = ++state.watchlistSearchRequestSequence; try { const params = new URLSearchParams({ q: query, trade_date: elements.tradeDate.value }); const payload = await apiRequest(`/api/search?${params}`); if (sequence !== state.watchlistSearchRequestSequence) return; state.watchlistSearchResults = payload.groups?.stocks || []; document.querySelector("#watchlistSearchResults").innerHTML = state.watchlistSearchResults.map((item, index) => ` `).join("") || '
没有找到匹配的股票
'; } catch (error) { document.querySelector("#watchlistSearchResults").innerHTML = `
${escapeHtml(error.message || "搜索失败")}
`; } } function handleWatchlistSearchResult(event) { const button = event.target.closest("[data-watchlist-result]"); if (!button) return; const item = state.watchlistSearchResults[number(button.dataset.watchlistResult)]; if (!item) return; state.watchlistSelection = { code: item.code, name: item.name, sector: item.industry || "其他", color: "red", }; syncWatchlistSelection(false); } async function saveWatchlistFromDialog(event) { event.preventDefault(); const item = state.watchlistSelection; if (!item) return; const button = document.querySelector("#saveWatchlist"); button.disabled = true; try { await apiRequest("/api/watchlist", "POST", { code: item.code, name: item.name, sector: item.sector || "其他", color: item.color || "red", remark: document.querySelector("#watchlistRemark").value.trim(), }); closeWatchlistDialog(); await loadReviewWorkspace(); showToast(state.watchlist.some((row) => row.code === item.code) ? "自选跟踪已保存" : "已加入自选"); } catch (error) { showToast(error.message || "自选保存失败"); button.disabled = false; } } async function toggleActiveWatchlist() { const stock = state.activeStock; if (!stock?.code) return; const isWatched = Boolean(state.stockDetail?.stock?.watchlist || state.watchlist.some((item) => item.code === stock.code)); try { if (isWatched) { await apiRequest(`/api/watchlist/${stock.code}`, "DELETE"); state.watchlist = state.watchlist.filter((item) => item.code !== stock.code); if (state.stockDetail?.stock) state.stockDetail.stock.watchlist = null; showToast("已移出自选"); } else { const payload = await apiRequest("/api/watchlist", "POST", { code: stock.code, name: stock.name || "--", sector: stock.sector || "其他", color: "red", }); state.watchlist = payload.items || state.watchlist; if (state.stockDetail?.stock) state.stockDetail.stock.watchlist = state.watchlist.find((item) => item.code === stock.code); showToast("已加入自选"); } updateWatchButton(); renderWatchlist(); } catch (error) { showToast(error.message); } } function updateWatchButton() { const code = state.activeStock?.code; const watched = Boolean(state.stockDetail?.stock?.watchlist || state.watchlist.some((item) => item.code === code)); setText("watchStockButton", watched ? "移出自选" : "加入自选"); } async function removeWatchlist(code) { try { await apiRequest(`/api/watchlist/${code}`, "DELETE"); state.watchlist = state.watchlist.filter((item) => item.code !== code); renderWatchlist(); showToast("已移出自选"); } catch (error) { showToast(error.message); } } async function saveJournal(event) { event.preventDefault(); try { await apiRequest("/api/notes", "POST", { trade_date: document.querySelector("#journalDate").value, id: state.editingDailyNoteId || undefined, summary: document.querySelector("#journalSummary").value, content: document.querySelector("#journalContent").value, plan: document.querySelector("#journalPlan").value, }); await loadReviewWorkspace(); showToast("每日复盘已保存"); } catch (error) { showToast(error.message); } } function populateJournalForm() { const selectedDate = document.querySelector("#journalDate").value.replaceAll("-", ""); const note = state.notes.find((item) => String(item.trade_date).replaceAll("-", "") === selectedDate); state.editingDailyNoteId = number(note?.id); document.querySelector("#journalSummary").value = note?.summary || ""; document.querySelector("#journalContent").value = note?.content || ""; document.querySelector("#journalPlan").value = note?.plan || ""; } function openTradeLogDialog() { resetTradeLogForm(); openModalDialog(elements.tradeLogDialog); requestAnimationFrame(() => document.querySelector("#tradeLogCode").focus()); } function closeTradeLogDialog() { if (elements.tradeLogDialog.open) elements.tradeLogDialog.close(); else resetTradeLogForm(); } async function saveTradeLog(event) { event.preventDefault(); const button = document.querySelector("#saveTradeLog"); button.disabled = true; try { const payload = await apiRequest("/api/trades", "POST", { id: state.editingTradeId || undefined, trade_date: document.querySelector("#tradeLogDate").value, code: document.querySelector("#tradeLogCode").value.trim(), name: document.querySelector("#tradeLogName").value.trim(), action: document.querySelector("#tradeLogAction").value, price: document.querySelector("#tradeLogPrice").value, quantity: document.querySelector("#tradeLogQuantity").value, position_pct: document.querySelector("#tradeLogPosition").value, pnl_amount: document.querySelector("#tradeLogPnlAmount").value, pnl_pct: document.querySelector("#tradeLogPnlPct").value, emotion: document.querySelector("#tradeLogEmotion").value, tags: document.querySelector("#tradeLogTags").value, thesis: document.querySelector("#tradeLogThesis").value, execution: document.querySelector("#tradeLogExecution").value, }); state.tradeEntries = payload.items || []; state.tradeSummary = payload.summary || {}; renderTradeLog(); closeTradeLogDialog(); showToast("交易记录已保存"); } catch (error) { showToast(error.message || "交易记录保存失败"); } finally { button.disabled = false; } } function resetTradeLogForm() { state.editingTradeId = 0; document.querySelector("#tradeLogForm").reset(); document.querySelector("#tradeLogDate").value = elements.tradeDate.value || todayString(); document.querySelector("#tradeLogQuantity").value = "0"; document.querySelector("#tradeLogPosition").value = "0"; setText("tradeLogDialogTitle", "交易日志"); setText("saveTradeLog", "保存交易"); } function editTradeLog(id) { const item = state.tradeEntries.find((entry) => number(entry.id) === id); if (!item) return; state.editingTradeId = id; document.querySelector("#tradeLogDate").value = displayCompactDate(item.trade_date); document.querySelector("#tradeLogCode").value = item.code; document.querySelector("#tradeLogName").value = item.name; document.querySelector("#tradeLogAction").value = item.action; document.querySelector("#tradeLogPrice").value = item.price; document.querySelector("#tradeLogQuantity").value = item.quantity; document.querySelector("#tradeLogPosition").value = item.position_pct; document.querySelector("#tradeLogPnlAmount").value = item.pnl_amount ?? ""; document.querySelector("#tradeLogPnlPct").value = item.pnl_pct ?? ""; document.querySelector("#tradeLogEmotion").value = item.emotion; document.querySelector("#tradeLogTags").value = (item.tags || []).join(", "); document.querySelector("#tradeLogThesis").value = item.thesis || ""; document.querySelector("#tradeLogExecution").value = item.execution || ""; setText("tradeLogDialogTitle", "编辑交易日志"); setText("saveTradeLog", "保存修改"); openModalDialog(elements.tradeLogDialog); requestAnimationFrame(() => document.querySelector("#tradeLogCode").focus()); } async function handleTradeLogAction(event) { const button = event.target.closest("[data-trade-action]"); if (!button) return; const id = number(button.dataset.tradeId); if (button.dataset.tradeAction === "edit") { editTradeLog(id); return; } if (!window.confirm("确定删除这条交易记录吗?")) return; try { const payload = await apiRequest(`/api/trades/${id}`, "DELETE"); state.tradeEntries = payload.items || []; state.tradeSummary = payload.summary || {}; if (state.editingTradeId === id) resetTradeLogForm(); renderTradeLog(); showToast("交易记录已删除"); } catch (error) { showToast(error.message || "交易记录删除失败"); } } function renderTradeLog() { const summary = state.tradeSummary || {}; setText("tradeLogCount", `${state.tradeEntries.length} 条`); document.querySelector("#tradeLogSummary").innerHTML = [ ["记录", `${number(summary.total)} 条`], ["已实现", `${number(summary.realized)} 条`], ["胜率", summary.win_rate == null ? "--" : `${formatNumber(summary.win_rate, 1)}%`], ["累计盈亏", summary.pnl_amount == null ? "--" : `${number(summary.pnl_amount) > 0 ? "+" : ""}${formatNumber(summary.pnl_amount, 2)}`], ["平均仓位", summary.average_position == null ? "--" : `${formatNumber(summary.average_position, 1)}%`], ].map(([label, value]) => `
${label}${value}
`).join(""); document.querySelector("#tradeLogEmpty").hidden = state.tradeEntries.length > 0; document.querySelector("#tradeLogTableBody").innerHTML = state.tradeEntries.map((item) => ` ${displayCompactDate(item.trade_date)} ${escapeHtml(item.name)}${escapeHtml(item.code)} ${escapeHtml(item.action_label)} ${item.position_pct == null ? "" : formatNumber(item.position_pct, 1)} ${item.pnl_pct == null ? "" : signed(item.pnl_pct)} ${item.pnl_amount == null ? "" : signed(item.pnl_amount)} ${escapeHtml(item.emotion_label)}
${(item.tags || []).map((tag) => `${escapeHtml(tag)}`).join("")}
${escapeHtml(item.thesis || "")}${escapeHtml(item.execution || "尚未填写执行复核")}
`).join(""); bindStockRows(document.querySelector("#tradeLogTableBody")); } async function saveStockNote(event) { event.preventDefault(); if (!state.activeStock?.code) return; try { await apiRequest("/api/notes", "POST", { code: state.activeStock.code, stock_name: state.activeStock.name || "--", trade_date: elements.tradeDate.value, content: document.querySelector("#stockNoteContent").value, plan: document.querySelector("#stockNotePlan").value, }); document.querySelector("#stockNoteContent").value = ""; document.querySelector("#stockNotePlan").value = ""; const payload = await apiRequest(`/api/notes?scope=stock&code=${encodeURIComponent(state.activeStock.code)}`); state.stockDetail.notes = payload.items || []; renderStockNotes(state.stockDetail.notes); showToast("个股笔记已保存"); } catch (error) { showToast(error.message); } } async function saveReasonOverride(event) { event.preventDefault(); if (!state.activeStock?.code) return; const reason = document.querySelector("#reasonInput").value.trim(); try { await apiRequest("/api/reasons", "POST", { trade_date: elements.tradeDate.value, code: state.activeStock.code, reason, }); state.activeStock.reason = reason; for (const key of ["limits", "broken", "down_limits"]) { const row = state.dashboard?.[key]?.find((item) => item.code === state.activeStock.code); if (row) row.reason = reason; } setText("detailReason", reason); renderDashboard(); showToast("事件逻辑已修订"); } catch (error) { showToast(error.message); } } function renderMoneyflow(flow) { for (const [id, value] of [["flowNet", flow.net_million], ["flowLarge", flow.large_million], ["flowMedium", flow.medium_million], ["flowSmall", flow.small_million]]) { const element = document.getElementById(id); element.textContent = formatMoneyMillion(value); element.className = changeClass(value); } } function renderStockNotes(notes) { renderNotesHistory(notes, document.querySelector("#stockNotes"), true); } function renderNotesHistory(notes, container, compact) { container.innerHTML = notes.map((note) => `
${note.stock_name ? `${escapeHtml(note.stock_name)}` : ""}
${!compact ? `
盘面

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

` : ""}
复盘

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

计划

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

`).join("") || emptyStateHtml("暂无复盘记录"); container.querySelectorAll("[data-note-delete]").forEach((button) => { button.addEventListener("click", () => deleteNote(number(button.dataset.noteDelete), compact)); }); } async function deleteNote(noteId, compact) { try { await apiRequest(`/api/notes/${noteId}`, "DELETE"); if (compact && state.activeStock) { state.stockDetail.notes = state.stockDetail.notes.filter((note) => number(note.id) !== noteId); renderStockNotes(state.stockDetail.notes); } else { await loadReviewWorkspace(); } showToast("笔记已删除"); } catch (error) { showToast(error.message); } } 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; } } function screenerStrategyKey(strategyId, strategyName) { return strategyId != null && strategyId !== 0 ? `id:${strategyId}` : `name:${strategyName || ""}`; } function currentScreenerStrategy(mode) { if (mode === "curated") return activeCuratedStrategy(); if (mode === "smart") return state.selectedStrategy; return null; } function screenerResultContext(mode, result, { regime, strategyId = null, strategyName = "" } = {}) { const normalizedMode = ["smart", "curated", "quant"].includes(mode) ? mode : "smart"; return { mode: normalizedMode, regime: regime || result?.meta?.regime || state.selectedRegime, strategyName: strategyName || result?.meta?.strategy_name || "", strategyKey: screenerStrategyKey( strategyId, strategyName || result?.meta?.strategy_name || "", ), }; } function screenerResultKey(context) { if (!context) return ""; if (context.mode === "quant") return "quant"; if (context.mode === "curated") return JSON.stringify(["curated", context.strategyKey]); return JSON.stringify(["smart", context.regime, context.strategyKey]); } function selectedScreenerResultKey(mode) { const normalizedMode = ["smart", "curated", "quant"].includes(mode) ? mode : "smart"; if (normalizedMode === "quant") return "quant"; const strategy = currentScreenerStrategy(mode); if (!strategy) return ""; return screenerResultKey(screenerResultContext(normalizedMode, null, { regime: state.selectedRegime, strategyId: strategy.id, strategyName: strategy.name, })); } function activeScreenerResultEntry(mode = state.screenerMode) { const key = selectedScreenerResultKey(mode); return key ? state.screenerResultStore[key] || null : null; } function screenerResultMatchesSelection(mode) { return Boolean(activeScreenerResultEntry(mode)); } function activeScreenerResult(mode = state.screenerMode) { return activeScreenerResultEntry(mode)?.result || null; } function activeScreenerResultContext(mode = state.screenerMode) { return activeScreenerResultEntry(mode)?.context || null; } function storeScreenerResult( mode, result, { regime, strategyId = null, strategyName = "" } = {}, updateLatest = true, ) { const normalizedMode = ["smart", "curated", "quant"].includes(mode) ? mode : "smart"; const context = result ? screenerResultContext(normalizedMode, result, { regime, strategyId, strategyName }) : null; const key = screenerResultKey(context); if (key && result) state.screenerResultStore[key] = { result, context }; if (updateLatest) { state.screenerResults[normalizedMode] = result || null; state.screenerResultContexts[normalizedMode] = context; } } function setScreenerResult(mode, result, options = {}) { storeScreenerResult(mode, result, options, true); } function applyScreenerSetup(payload, requestKey) { const dateChanged = Boolean(state.screenerSetupKey && state.screenerSetupKey !== requestKey); if (dateChanged) { state.screenerResults = { smart: null, curated: null, quant: null }; state.screenerResultContexts = { smart: null, curated: null, quant: null }; state.screenerResultStore = {}; } state.screenerSetup = payload; state.screenerSetupKey = requestKey; const latestResults = { ...(payload.latest_results || {}) }; if (!latestResults.smart && payload.latest_result) latestResults.smart = payload.latest_result; const smartLatestMeta = latestResults.smart?.meta || {}; const curatedLatestMeta = latestResults.curated?.meta || {}; state.selectedRegime = payload.regime.id; const selectedId = state.selectedStrategy?.id; const smartStrategies = payload.strategies.filter((item) => item.formula?.meta?.library !== "curated"); const curatedStrategies = payload.strategies.filter((item) => item.formula?.meta?.library === "curated"); const latestSmartStrategy = smartStrategies.find((item) => item.name === smartLatestMeta.strategy_name); state.selectedStrategy = smartStrategies.find((item) => item.id === selectedId) || latestSmartStrategy || smartStrategies.find((item) => item.regimes.includes(state.selectedRegime)) || smartStrategies[0] || null; if (!curatedStrategies.some((item) => item.id === state.selectedCuratedStrategyId) || dateChanged) { state.selectedCuratedStrategyId = curatedStrategies.find( (item) => item.name === curatedLatestMeta.strategy_name, )?.id || curatedStrategies[0]?.id || 0; } for (const result of [...(payload.recent_results || [])].reverse()) { const mode = ["smart", "curated", "quant"].includes(result.meta?.mode) ? result.meta.mode : "smart"; const strategies = mode === "curated" ? curatedStrategies : smartStrategies; const strategy = strategies.find((item) => item.name === result.meta?.strategy_name); storeScreenerResult(mode, result, { regime: result.meta?.regime || payload.regime.id, strategyId: strategy?.id, strategyName: result.meta?.strategy_name || strategy?.name || "", }, false); } for (const mode of ["smart", "curated", "quant"]) { if (state.screenerResults[mode] || !latestResults[mode]) continue; const result = latestResults[mode]; const meta = result.meta || {}; const strategy = mode === "curated" ? curatedStrategies.find((item) => item.name === meta.strategy_name) : mode === "smart" ? smartStrategies.find((item) => item.name === meta.strategy_name) : null; setScreenerResult(mode, result, { regime: meta.regime || payload.regime.id, strategyId: strategy?.id, strategyName: meta.strategy_name || strategy?.name || "", }); } if (!state.quantScores.length) resetQuantBuilder(false); renderScreenerSetup(); renderScreenerResult(); } async function loadScreenerSetup(force = false) { const requestKey = elements.tradeDate.value.replaceAll("-", ""); if (!force && state.screenerSetup && state.screenerSetupKey === requestKey) { renderScreenerSetup(); renderScreenerResult(); return state.screenerSetup; } if (!force && state.screenerSetupPromise && state.screenerSetupRequestKey === requestKey) { return state.screenerSetupPromise; } const request = (async () => { try { const query = new URLSearchParams({ trade_date: elements.tradeDate.value }); const payload = await apiRequest(`/api/screener/setup?${query}`); applyScreenerSetup(payload, requestKey); await loadScreenerTracking(); return payload; } catch (error) { showToast(error.message || "选股配置加载失败"); return null; } finally { if (state.screenerSetupPromise === request) { state.screenerSetupPromise = null; state.screenerSetupRequestKey = ""; } } })(); state.screenerSetupRequestKey = requestKey; state.screenerSetupPromise = request; return request; } 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("regimeStepStatus", `${setup.regime.label} · 置信度 ${formatNumber(setup.regime.confidence, 0)}%`); setText("regimeReason", setup.regime.reason); const evidence = (setup.regime.evidence || []).filter(Boolean); if (!evidence.some((item) => String(item).includes("情绪温度"))) { const temperature = formatNumber(state.dashboard?.overview?.sentiment_score, 0); const direction = state.dashboard?.overview?.sentiment_direction; evidence.unshift(`情绪温度 ${temperature}${direction ? `,较前一交易日${direction}` : ""}`); } document.querySelector("#regimeEvidenceList").textContent = 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)} · 竞价 ${number(setup.factor_data.auction_date_count)} 日` : "尚未达到 21 个交易日"); setText("factorTaskStatus", setup.factor_data.ready ? `已就绪 · ${number(setup.factor_data.date_count)} 日` : "需要同步"); setText("compilerStatus", "策略生成已就绪"); setText( "screenerRunStatus", activeScreenerResult("smart") ? `已有结果 · ${(activeScreenerResult("smart").candidates || []).length} 只` : "等待执行", ); setText("strategyCount", `${setup.strategies.filter((item) => item.formula?.meta?.library !== "curated").length} 套`); updateBacktestTaskStatus(); selectScreenerMobileView(state.screenerMobileView); const selector = document.querySelector("#regimeSelector"); selector.innerHTML = setup.regimes.map((item) => ` ${escapeHtml(item.label)} `).join(""); renderStrategyList(); renderStrategySummary(); renderScreenerMode(); renderCuratedStrategyLibrary(); renderQuantBuilder(); renderScreenerProgress(); } function renderStrategySummary() { const strategy = state.selectedStrategy; setText("activeStrategyHeading", strategy?.name || "--"); setText("activeStrategyEditorHeading", strategy?.name || "--"); setText("activeStrategyDescription", strategy?.description || "等待匹配当前市场阶段的策略。"); setText("strategyStepStatus", strategy?.name || "等待匹配"); document.querySelector("#activeStrategyRegimes").innerHTML = strategy ? `${strategy.regimes.map((item) => `${escapeHtml(regimeLabel(item))}`).join("")}${strategy.builtin ? "内置" : "自定义"}` : ""; } function selectScreenerMode(mode) { state.screenerMode = ["smart", "curated", "quant"].includes(mode) ? mode : "smart"; localStorage.setItem("xiaobaiScreenerMode", state.screenerMode); state.screenerMobileView = "strategy"; renderScreenerMode(); selectScreenerMobileView("strategy"); } function renderScreenerMode() { const mode = state.screenerMode || "smart"; document.querySelectorAll("[data-screener-mode]").forEach((button) => { const active = button.dataset.screenerMode === mode; button.classList.toggle("active", active); button.setAttribute("aria-selected", String(active)); }); document.querySelectorAll("[data-screener-panel]").forEach((panel) => { panel.hidden = panel.dataset.screenerPanel !== mode; }); const results = document.querySelector("#screenerView .screener-results-view"); const resultsSlot = document.querySelector(`[data-screener-results-slot="${mode}"]`); if (results && resultsSlot && results.parentElement !== resultsSlot) resultsSlot.append(results); const titles = { smart: "盘后候选结果", curated: "策略候选结果", quant: "自定义选股结果" }; setText("screenerResultTitle", titles[mode]); renderScreenerResult(); } function curatedStrategies() { return (state.screenerSetup?.strategies || []).filter((item) => item.formula?.meta?.library === "curated"); } function activeCuratedStrategy() { const strategies = curatedStrategies(); return strategies.find((item) => item.id === state.selectedCuratedStrategyId) || strategies[0] || null; } function curatedStrategySchool(strategy) { const category = String(strategy?.formula?.meta?.category || ""); if (["红利价值", "质量价值", "现金流价值", "成长质量", "小盘质量"].includes(category)) return "基本面"; if (["行业轮动", "形态突破", "趋势追踪"].includes(category)) return "趋势"; if (["短线竞价", "连板接力", "低吸反核"].includes(category)) return "短线"; if (["动量反转"].includes(category)) return "动量"; if (["元策略", "多因子"].includes(category)) return "量化"; if (["业绩事件", "热度观察"].includes(category)) return "事件"; if (["资金席位"].includes(category)) return "资金"; if (/红利|价值|质量|成长|财务|现金流/.test(category)) return "基本面"; if (/趋势|轮动|突破/.test(category)) return "趋势"; if (/竞价|连板|龙头|反核|首阴|反包|打板/.test(category)) return "短线"; if (/动量|反转/.test(category)) return "动量"; if (/因子|量化|元策略/.test(category)) return "量化"; if (/事件|热度|公告|业绩/.test(category)) return "事件"; if (/席位|资金/.test(category)) return "资金"; return "其他"; } function curatedSchoolIcon(school) { return { 基本面: "circle-dollar-sign", 趋势: "trending-up", 短线: "zap", 动量: "refresh-cw", 量化: "binary", 事件: "calendar-clock", 资金: "landmark", 其他: "boxes", }[school] || "boxes"; } function curatedStrategyRunState(strategy, result) { const missingData = strategy?.missing_data || []; if (!strategy?.data_ready || missingData.length) { return { label: "数据不足", className: "missing", verifiedEmpty: false }; } if (!result) return { label: "等待盘后", className: "pending", verifiedEmpty: false }; const count = (result.candidates || []).length; if (count) return { label: `${count} 只候选`, className: "ready", verifiedEmpty: false }; return { label: "暂无信号", className: "quiet", verifiedEmpty: true }; } function renderCuratedStrategyLibrary() { if (!state.screenerSetup) return; const strategies = curatedStrategies(); const categories = ["全部", ...new Set(strategies.map((item) => item.formula?.meta?.category || "其他"))]; const schools = ["全部", "基本面", "趋势", "短线", "动量", "量化", "事件", "资金"]; if (!categories.includes(state.curatedCategory)) state.curatedCategory = "全部"; if (!schools.includes(state.curatedSchool)) state.curatedSchool = "全部"; setText("curatedStrategyCount", `${strategies.length} 套`); const categorySelect = document.querySelector("#curatedCategoryFilter"); categorySelect.innerHTML = categories.map((category) => ` `).join(""); document.querySelector("#curatedSchoolFilters").innerHTML = schools.map((school) => { const count = school === "全部" ? strategies.length : strategies.filter((item) => curatedStrategySchool(item) === school).length; return ``; }).join(""); document.querySelectorAll("[data-curated-view]").forEach((button) => { const active = button.dataset.curatedView === state.curatedViewMode; button.classList.toggle("active", active); button.setAttribute("aria-pressed", String(active)); }); const query = state.curatedQuery; const visible = strategies.filter((item) => { const meta = item.formula?.meta || {}; const categoryMatch = state.curatedCategory === "全部" || meta.category === state.curatedCategory; const school = curatedStrategySchool(item); const schoolMatch = state.curatedSchool === "全部" || school === state.curatedSchool; const queryMatch = !query || `${item.name} ${item.description} ${meta.category} ${school} ${meta.suitable_environment} ${meta.failure_risk}`.toLocaleLowerCase("zh-CN").includes(query); return categoryMatch && schoolMatch && queryMatch; }); const list = document.querySelector("#curatedStrategyList"); list.classList.toggle("is-grid", state.curatedViewMode === "grid"); list.innerHTML = visible.length ? visible.map((strategy) => { const meta = strategy.formula?.meta || {}; const school = curatedStrategySchool(strategy); const rank = strategies.findIndex((item) => item.id === strategy.id) + 1; const resultKey = screenerResultKey(screenerResultContext("curated", null, { regime: strategy.regimes[0] || state.selectedRegime, strategyId: strategy.id, strategyName: strategy.name, })); const result = state.screenerResultStore[resultKey]?.result; const runState = curatedStrategyRunState(strategy, result); return `
${String(rank).padStart(2, "0")}${escapeHtml(strategy.name)}${escapeHtml(school)} · ${escapeHtml(meta.category || "策略")}${escapeHtml(runState.label)} ${escapeHtml(meta.quality || "--")}${escapeHtml(meta.frequency || "--")}风险 ${escapeHtml(meta.risk || "--")}
`; }).join("") : emptyStateHtml("没有符合条件的策略"); renderCuratedStrategyDetail(); } function renderCuratedStrategyDetail() { const strategy = activeCuratedStrategy(); if (!strategy) return; const formula = strategy.formula || {}; const meta = formula.meta || {}; const result = activeScreenerResult("curated"); const resultMeta = result?.meta || {}; const health = resultMeta.health || {}; const runState = curatedStrategyRunState(strategy, result); setText("curatedStrategyCategory", meta.category || "精选策略"); setText("curatedStrategyName", strategy.name); setText("curatedStrategyDescription", strategy.description); document.querySelector("#curatedStrategyBadges").innerHTML = [ `质量 ${meta.quality || "--"}`, meta.frequency || "--", `风险 ${meta.risk || "--"}`, meta.data_group || "行情因子", ].map((value) => `${escapeHtml(value)}`).join(""); setText("curatedSuitableEnvironment", meta.suitable_environment || "以策略条件为准"); setText("curatedFailureRisk", meta.failure_risk || "策略可能随市场结构变化而失效"); const filters = formula.filters || []; setText("curatedFilterCount", `${filters.length} 项`); document.querySelector("#curatedFilterList").innerHTML = filters.map((item) => `
${escapeHtml(factorLabel(item.field))}${escapeHtml(formatRuleValue(item))}
`).join(""); const scores = formula.score || []; const total = scores.reduce((sum, item) => sum + number(item.weight), 0) || 1; setText("curatedWeightTotal", `${formatNumber(total * 100, 0)}%`); document.querySelector("#curatedScoreList").innerHTML = scores.map((item) => { const percent = number(item.weight) / total * 100; return `
${escapeHtml(factorLabel(item.field))}${formatNumber(percent, 0)}%
`; }).join(""); const candidateCount = (result?.candidates || []).length; const statusLabel = runState.className === "ready" ? "运行正常" : runState.label; const statusClass = runState.className; let updatedLabel = "--"; if (resultMeta.updated_at) { const updated = new Date(resultMeta.updated_at); if (!Number.isNaN(updated.getTime())) { updatedLabel = `${String(updated.getMonth() + 1).padStart(2, "0")}-${String(updated.getDate()).padStart(2, "0")} ${updated.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false })}`; } } document.querySelector("#curatedHealthMetrics").innerHTML = [ ["运行状态", statusLabel, statusClass], ["当日信号", result ? `${candidateCount} 只` : "--", ""], ["字段覆盖", health.coverage != null ? `${formatNumber(health.coverage, 1)}%` : strategy.data_ready ? "数据已就绪" : "--", ""], ["最近更新", updatedLabel, ""], ].map(([label, value, className]) => `
${escapeHtml(label)}${escapeHtml(value)}
`).join(""); const status = document.querySelector("#curatedDataStatus"); status.classList.toggle("missing", statusClass === "missing"); status.innerHTML = strategy.data_ready ? `${runState.verifiedEmpty ? "本日暂无信号" : "盘后自动更新"}${runState.verifiedEmpty ? `必需数据已完整,本日没有股票同时满足 ${filters.length} 项准入条件` : result ? health.required_field_count != null ? `已核验 ${number(health.required_field_count)} 项因子 · ${number(health.complete_rows)} 只股票` : "盘后定格结果已载入" : "等待当日行情定格后生成"}` : `数据尚未完备${escapeHtml((strategy.missing_data || []).join("、") || "等待后台同步")}`; refreshIcons(); } function factorLabel(field) { return state.screenerSetup?.factor_fields?.find((item) => item.id === field)?.label || field; } function formatRuleValue(item) { const operator = { between: "介于", ">=": "不低于", "<=": "不高于", ">": "高于", "<": "低于", "==": "等于" }[item.op] || item.op; const value = Array.isArray(item.value) ? item.value.join(" ~ ") : item.value; return `${operator} ${value}`; } function groupedFactorOptions(selected = "") { return (state.screenerSetup?.factor_groups || []).map((group) => ` ${group.fields.map((field) => ``).join("")} `).join(""); } function quantId() { return `${Date.now()}-${Math.random().toString(16).slice(2)}`; } function resetQuantBuilder(render = true) { state.quantFilters = [ { id: quantId(), field: "amount_billion", op: ">=", value: "1" }, { id: quantId(), field: "above_ma20", op: "==", value: "1" }, ]; state.quantScores = [ { id: quantId(), field: "relative_strength", weight: 30, direction: "desc" }, { id: quantId(), field: "sector_strength", weight: 25, direction: "desc" }, { id: quantId(), field: "volume_ratio_5d", weight: 20, direction: "desc" }, { id: quantId(), field: "amount_billion", weight: 15, direction: "desc" }, { id: quantId(), field: "volatility_10d", weight: 10, direction: "asc" }, ]; if (render) renderQuantBuilder(); } function addQuantFilter() { const used = new Set(state.quantFilters.map((item) => item.field)); const field = state.screenerSetup.factor_fields.find((item) => !used.has(item.id))?.id || "pct_chg"; state.quantFilters.push({ id: quantId(), field, op: ">=", value: "0" }); renderQuantBuilder(); } function addQuantScore() { const used = new Set(state.quantScores.map((item) => item.field)); const field = state.screenerSetup.factor_fields.find((item) => !used.has(item.id))?.id || "pct_chg"; state.quantScores.push({ id: quantId(), field, weight: 10, direction: "desc" }); renderQuantBuilder(); } function renderQuantBuilder() { if (!state.screenerSetup) return; document.querySelector("#quantFilterRows").innerHTML = state.quantFilters.map((item) => `
`).join(""); document.querySelector("#quantScoreRows").innerHTML = state.quantScores.map((item) => `
`).join(""); renderQuantSummary(); refreshIcons(); } function handleQuantBuilderInput(event) { const row = event.target.closest("[data-quant-filter], [data-quant-score]"); const key = event.target.dataset.quantKey; if (!row || !key) return; const collection = row.dataset.quantFilter ? state.quantFilters : state.quantScores; const id = row.dataset.quantFilter || row.dataset.quantScore; const item = collection.find((entry) => entry.id === id); if (!item) return; item[key] = key === "weight" ? number(event.target.value) : event.target.value; if (key === "weight") { const output = event.target.closest(".quant-weight-control")?.querySelector("output"); if (output) output.textContent = `${number(event.target.value)}%`; } renderQuantSummary(); } function handleQuantBuilderClick(event) { const button = event.target.closest("[data-quant-action]"); if (!button) return; const row = button.closest("[data-quant-filter], [data-quant-score]"); const isFilter = Boolean(row?.dataset.quantFilter); const id = row?.dataset.quantFilter || row?.dataset.quantScore; const collection = isFilter ? state.quantFilters : state.quantScores; const item = collection.find((entry) => entry.id === id); if (button.dataset.quantAction === "remove") { if (!isFilter && collection.length <= 1) { showToast("至少保留一个评分因子"); return; } const index = collection.findIndex((entry) => entry.id === id); if (index >= 0) collection.splice(index, 1); renderQuantBuilder(); } else if (button.dataset.quantAction === "direction" && item) { item.direction = button.dataset.direction; renderQuantBuilder(); } } function buildQuantFormula() { const filters = state.quantFilters.map((item) => { let value; if (item.op === "between") { value = String(item.value).split(/[,,~~]/).map((part) => Number(part.trim())); if (value.length !== 2 || value.some((part) => !Number.isFinite(part))) throw new Error(`${factorLabel(item.field)}需要两个有效区间值`); if (value[0] > value[1]) value.reverse(); } else { value = Number(item.value); if (!Number.isFinite(value)) throw new Error(`${factorLabel(item.field)}的条件值无效`); } return { field: item.field, op: item.op, value }; }); const score = state.quantScores.map((item) => { const weight = number(item.weight) / 100; if (weight <= 0 || weight > 1) throw new Error(`${factorLabel(item.field)}的权重应为1%至100%`); return { field: item.field, weight, direction: item.direction }; }); return { meta: { library: "custom", category: "量化公式", frequency: "按需", risk: "自定义", data_group: "组合因子" }, universe: { exclude_st: document.querySelector("#quantExcludeSt").checked, listed_days_min: Math.max(0, Math.min(5000, number(document.querySelector("#quantListedDays").value))), }, filters, score, limit: Math.max(1, Math.min(50, number(document.querySelector("#quantLimit").value))), min_score: Math.max(0, Math.min(1, number(document.querySelector("#quantMinScore").value) / 100)), }; } function formulaMissingData(formula) { const health = state.screenerSetup?.factor_data?.health || {}; const fields = new Set([...(formula.filters || []), ...(formula.score || [])].map((item) => item.field)); const missing = []; if (!state.screenerSetup?.factor_data?.ready) missing.push("基础行情"); if (["pe_ttm", "pb", "ps_ttm", "dividend_yield_ttm", "total_mv_billion"].some((field) => fields.has(field)) && !health.valuation) missing.push("估值数据"); if (["roe", "roa", "roic", "gross_margin", "netprofit_yoy", "revenue_yoy", "ocf_to_opincome"].some((field) => fields.has(field)) && !health.fundamental) missing.push("财务质量"); if (fields.has("dividend_years") && !health.dividend_history) missing.push("历年分红"); if (["auction_change", "auction_amount_million", "auction_turnover_rate", "auction_volume_ratio"].some((field) => fields.has(field)) && !health.auction) missing.push("竞价数据"); return missing; } function renderQuantSummary() { if (!state.screenerSetup) return; const total = state.quantScores.reduce((sum, item) => sum + number(item.weight), 0); setText("quantWeightTotal", `${formatNumber(total, 0)}%`); const bar = document.querySelector("#quantWeightBar"); bar.style.width = `${Math.min(100, total)}%`; bar.style.background = Math.abs(total - 100) < 0.01 ? "#2563eb" : "#d97706"; const message = document.querySelector("#quantValidationMessage"); try { const formula = buildQuantFormula(); const missing = formulaMissingData(formula); message.classList.toggle("error", Boolean(missing.length)); message.textContent = missing.length ? `需要先同步:${missing.join("、")}` : "公式有效,可执行并生成逐股贡献解释。"; document.querySelector("#quantRunButton").disabled = Boolean(missing.length); } catch (error) { message.classList.add("error"); message.textContent = error.message; document.querySelector("#quantRunButton").disabled = true; } } function renderScreenerProgress() { const hasSetup = Boolean(state.screenerSetup?.regime); const hasStrategy = Boolean(state.selectedStrategy); const hasResult = Boolean(activeScreenerResult("smart")); const states = { regime: hasSetup ? "complete" : "current", strategy: hasStrategy ? "complete" : hasSetup ? "current" : "pending", run: state.screenerRunning ? "current" : hasResult ? "complete" : hasStrategy ? "current" : "pending", result: hasResult ? "current" : "pending", }; const steps = [...document.querySelectorAll("[data-screener-step]")]; steps.forEach((step, index) => { const status = states[step.dataset.screenerStep] || "pending"; step.dataset.state = status; if (status === "current") step.setAttribute("aria-current", "step"); else step.removeAttribute("aria-current"); const line = step.nextElementSibling; if (line?.classList.contains("step-line")) line.classList.toggle("complete", status === "complete" && index < steps.length - 1); }); } function openStrategyDrawer(target = "editor") { const drawer = document.querySelector("#strategyDrawer"); openModalDialog(drawer); requestAnimationFrame(() => { const focusTarget = target === "library" ? document.querySelector("#strategyList .strategy-item.active") || document.querySelector("#strategyList .strategy-item") : document.querySelector("#strategyNameInput"); focusTarget?.focus(); }); } function openCustomStrategyDrawer() { if (!state.customStrategyDraft) { state.customStrategyDraft = { id: null, builtin: false, name: "自定义选股策略", description: "", regimes: [state.selectedRegime], formula: buildQuantFormula(), }; } populateStrategyEditor(state.customStrategyDraft); renderStrategyList(); openStrategyDrawer("editor"); } 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() { setText("backtestTaskStatus", activeScreenerResult("smart") ? "结果已归档" : "等待盘后生成"); renderScreenerProgress(); } function selectRegime(regime) { state.selectedRegime = regime; const recommended = state.screenerSetup.strategies.find((item) => item.formula?.meta?.library !== "curated" && item.regimes.includes(regime)); if (recommended) state.selectedStrategy = recommended; renderScreenerSetup(); } function renderStrategyList() { const list = document.querySelector("#strategyList"); const strategies = state.screenerSetup.strategies.filter((item) => !item.builtin && item.formula?.meta?.library !== "curated"); list.innerHTML = strategies.map((strategy) => ` `).join("") || emptyStateHtml("暂无已保存的自定义公式"); list.querySelectorAll("[data-strategy-id]").forEach((button) => { button.addEventListener("click", () => { state.customStrategyDraft = state.screenerSetup.strategies.find((item) => item.id === number(button.dataset.strategyId)); populateStrategyEditor(state.customStrategyDraft); renderStrategyList(); }); }); } function populateStrategyEditor(strategy) { const deleteButton = document.querySelector("#deleteStrategyButton"); deleteButton.hidden = !strategy?.id || Boolean(strategy.builtin); if (!strategy) { setText("activeStrategyEditorHeading", "--"); return; } setText("activeStrategyEditorHeading", 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 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.customStrategyDraft = { ...strategy, id: null, builtin: false }; document.querySelector("#deleteStrategyButton").hidden = true; document.querySelector("#strategyNameInput").value = strategy.name; document.querySelector("#strategyDescriptionInput").value = strategy.description; document.querySelector("#formulaEditor").value = JSON.stringify(strategy.formula, null, 2); setText("compilerStatus", "策略生成完成"); 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.customStrategyDraft = payload.strategies.find((item) => item.id === payload.id); renderStrategyList(); populateStrategyEditor(state.customStrategyDraft); showToast("自定义策略已保存"); } catch (error) { showToast(error.message); } } async function deleteCurrentStrategy() { const strategy = state.customStrategyDraft; 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.customStrategyDraft = null; renderStrategyList(); openCustomStrategyDrawer(); showToast("自定义策略已删除"); } catch (error) { showToast(error.message || "策略删除失败"); } finally { button.disabled = false; } } async function runQuantStrategy() { let formula; try { formula = buildQuantFormula(); } catch (error) { showToast(error.message); return; } await executeScreenerFormula({ mode: "quant", formula, strategyName: "自定义选股公式", regime: state.selectedRegime, runBacktest: document.querySelector("#quantBacktestToggle").checked, button: document.querySelector("#quantRunButton"), loadingText: "正在执行自定义公式并计算因子贡献", }); } function saveQuantAsStrategy() { try { const formula = buildQuantFormula(); state.customStrategyDraft = { id: null, builtin: false, name: "自定义选股策略", description: "由自定义因子工作台生成,可在高级公式中继续调整。", regimes: [state.selectedRegime], formula, }; populateStrategyEditor(state.customStrategyDraft); document.querySelector("#strategyPrompt").value = "自定义因子工作台生成的选股公式"; openStrategyDrawer("editor"); } catch (error) { showToast(error.message); } } async function executeScreenerFormula({ mode, formula, strategyName, strategyId = null, regime, runBacktest, button, loadingText }) { if (!state.screenerSetup?.factor_data?.ready) { showToast("请先同步至少 21 个交易日的因子数据"); return; } const missing = formulaMissingData(formula); if (missing.length) { showToast(`请先同步${missing.join("、")}`); return; } const executionMode = ["smart", "curated", "quant"].includes(mode) ? mode : state.screenerMode; button.disabled = true; state.screenerRunning = true; state.screenerRunningMode = executionMode; if (executionMode === "smart") { setText("screenerRunStatus", "正在计算"); setText("backtestTaskStatus", runBacktest ? "正在回测" : "本次不执行"); } setLoading(true, loadingText, "screener"); setStatus(`正在执行${strategyName}`); try { const payload = await apiRequest("/api/screener/run", "POST", { trade_date: elements.tradeDate.value, regime, strategy_name: strategyName, formula, mode: executionMode, run_backtest: runBacktest, }); setScreenerResult(executionMode, payload.result, { regime, strategyId, strategyName }); renderScreenerResult(); if (executionMode === "smart") setText("screenerRunStatus", `完成 · ${payload.result.candidates.length} 只`); updateBacktestTaskStatus(); if (window.innerWidth <= 720) selectScreenerMobileView("results"); setStatus(`${strategyName}完成 · ${payload.result.candidates.length} 只候选`); } catch (error) { showToast(error.message); setStatus("选股执行失败"); if (executionMode === "smart") setText("screenerRunStatus", "执行失败"); updateBacktestTaskStatus(); } finally { state.screenerRunning = false; state.screenerRunningMode = ""; setLoading(false); button.disabled = false; updateBacktestTaskStatus(); } } async function loadMentorSetup(force = false) { const requestedDate = elements.tradeDate.value.replaceAll("-", ""); if (!force && state.mentorSetup?.requestedDate === requestedDate) { renderMentorWorkspace(); return; } try { const query = new URLSearchParams({ trade_date: elements.tradeDate.value }); const payload = await apiRequest(`/api/mentors/setup?${query}`); payload.requestedDate = requestedDate; if (!payload.preferences_configured) { payload.mentors.sort((first, second) => { if (Boolean(first.private) !== Boolean(second.private)) return first.private ? -1 : 1; return String(first.name || "").localeCompare(String(second.name || ""), "zh-CN"); }); payload.mentors.forEach((mentor, index) => { mentor.sort_order = index; }); } state.mentorSetup = payload; const selectedExists = payload.mentors.some((item) => item.id === state.selectedMentorId); state.selectedMentorId = selectedExists ? state.selectedMentorId : payload.mentors[0]?.id || ""; state.mentorMessages = await loadMentorMessages(); renderMentorWorkspace(); } catch (error) { showMentorNotice(error.message || "问师模块加载失败"); showToast(error.message || "问师模块加载失败"); } } function renderMentorWorkspace() { const setup = state.mentorSetup; if (!setup) return; const selected = setup.mentors.find((item) => item.id === state.selectedMentorId) || null; setText("mentorDataDate", `数据日期 ${displayCompactDate(setup.trade_date)}`); setText("activeMentorName", selected?.name || "--"); setText("mobileActiveMentorName", selected?.name || "选择思维模型"); document.querySelector("#activeMentorBadges").innerHTML = selected ? renderMentorBadges(selected, true) : ""; setText("activeMentorEvidence", selected?.evidence?.note || selected?.description || "--"); document.querySelector("#activeMentorFocus").innerHTML = (selected?.focus || []).slice(0, 4) .map((item) => `${escapeHtml(item)}`).join(""); renderMentorDirectory(); renderMentorMessages(); } function renderMentorDirectory() { const mentors = state.mentorSetup?.mentors || []; const query = state.mentorQuery; const filtered = mentors.filter((mentor) => { if (state.mentorSortMode) return true; if (state.mentorGrade !== "all" && mentor.evidence?.grade !== state.mentorGrade) return false; if (!query) return true; const haystack = [ mentor.name, mentor.description, mentor.tagline, mentor.evidence?.label, mentor.evidence?.note, ...(mentor.focus || []), ].filter(Boolean).join(" ").toLocaleLowerCase("zh-CN"); return haystack.includes(query); }); setText("mentorCount", filtered.length === mentors.length ? `${mentors.length} 位` : `${filtered.length} / ${mentors.length} 位`); const sortToggle = document.querySelector("#mentorSortToggle"); sortToggle.classList.toggle("active", state.mentorSortMode); sortToggle.setAttribute("aria-pressed", String(state.mentorSortMode)); sortToggle.querySelector("span").textContent = state.mentorSortMode ? "完成" : "整理"; document.querySelector("#mentorSortHint").hidden = !state.mentorSortMode; document.querySelector("#mentorSearchInput").disabled = state.mentorSortMode; document.querySelectorAll("[data-mentor-grade]").forEach((button) => { button.disabled = state.mentorSortMode; }); const container = document.querySelector("#mentorList"); container.classList.toggle("is-sorting", state.mentorSortMode); container.innerHTML = filtered.map((mentor) => { const group = mentors.filter((item) => Boolean(item.pinned) === Boolean(mentor.pinned)); const groupIndex = group.findIndex((item) => item.id === mentor.id); return `
${state.mentorSortMode ? ` ` : ""}
`; }).join(""); document.querySelector("#mentorListEmpty").hidden = filtered.length > 0; document.querySelectorAll("[data-mentor-id]").forEach((button) => { button.addEventListener("click", () => selectMentor(button.dataset.mentorId)); }); document.querySelectorAll("[data-mentor-pin]").forEach((button) => { button.addEventListener("click", () => toggleMentorPin(button.dataset.mentorPin)); }); document.querySelectorAll("[data-mentor-move]").forEach((button) => { button.addEventListener("click", () => moveMentor(button.dataset.mentorTarget, button.dataset.mentorMove)); }); document.querySelectorAll("[data-mentor-card]").forEach((card) => { card.addEventListener("dragstart", handleMentorDragStart); card.addEventListener("dragover", handleMentorDragOver); card.addEventListener("drop", handleMentorDrop); card.addEventListener("dragend", clearMentorDragState); }); refreshIcons(); } function toggleMentorSortMode() { state.mentorSortMode = !state.mentorSortMode; if (state.mentorSortMode) { state.mentorQuery = ""; state.mentorGrade = "all"; document.querySelector("#mentorSearchInput").value = ""; document.querySelectorAll("[data-mentor-grade]").forEach((button) => { button.classList.toggle("active", button.dataset.mentorGrade === "all"); }); } renderMentorDirectory(); } async function toggleMentorPin(mentorId) { if (state.mentorSavingPreferences) return; const mentors = state.mentorSetup?.mentors || []; const index = mentors.findIndex((item) => item.id === mentorId); if (index < 0) return; const [mentor] = mentors.splice(index, 1); mentor.pinned = !mentor.pinned; if (mentor.pinned) { mentors.unshift(mentor); } else { const firstUnpinned = mentors.findIndex((item) => !item.pinned); mentors.splice(firstUnpinned < 0 ? mentors.length : firstUnpinned, 0, mentor); } normalizeMentorOrder(); renderMentorWorkspace(); await persistMentorPreferences(); } async function moveMentor(mentorId, direction) { if (state.mentorSavingPreferences) return; const mentors = state.mentorSetup?.mentors || []; const index = mentors.findIndex((item) => item.id === mentorId); if (index < 0) return; const step = direction === "up" ? -1 : 1; const targetIndex = index + step; if (targetIndex < 0 || targetIndex >= mentors.length) return; if (Boolean(mentors[index].pinned) !== Boolean(mentors[targetIndex].pinned)) return; [mentors[index], mentors[targetIndex]] = [mentors[targetIndex], mentors[index]]; normalizeMentorOrder(); renderMentorDirectory(); await persistMentorPreferences(); } function handleMentorDragStart(event) { if (!state.mentorSortMode || state.mentorSavingPreferences) { event.preventDefault(); return; } state.mentorDragId = event.currentTarget.dataset.mentorCard || ""; event.dataTransfer.effectAllowed = "move"; event.dataTransfer.setData("text/plain", state.mentorDragId); event.currentTarget.classList.add("is-dragging"); } function handleMentorDragOver(event) { const source = state.mentorSetup?.mentors.find((item) => item.id === state.mentorDragId); const target = state.mentorSetup?.mentors.find((item) => item.id === event.currentTarget.dataset.mentorCard); if (!source || !target || Boolean(source.pinned) !== Boolean(target.pinned)) return; event.preventDefault(); event.dataTransfer.dropEffect = "move"; event.currentTarget.classList.add("is-drag-over"); } async function handleMentorDrop(event) { event.preventDefault(); const sourceId = state.mentorDragId || event.dataTransfer.getData("text/plain"); const targetId = event.currentTarget.dataset.mentorCard || ""; clearMentorDragState(); if (!sourceId || !targetId || sourceId === targetId) return; const mentors = state.mentorSetup?.mentors || []; const sourceIndex = mentors.findIndex((item) => item.id === sourceId); const targetIndex = mentors.findIndex((item) => item.id === targetId); if (sourceIndex < 0 || targetIndex < 0) return; if (Boolean(mentors[sourceIndex].pinned) !== Boolean(mentors[targetIndex].pinned)) return; const [mentor] = mentors.splice(sourceIndex, 1); const insertionIndex = mentors.findIndex((item) => item.id === targetId); mentors.splice(insertionIndex, 0, mentor); normalizeMentorOrder(); renderMentorDirectory(); await persistMentorPreferences(); } function clearMentorDragState() { state.mentorDragId = ""; document.querySelectorAll(".mentor-option.is-dragging, .mentor-option.is-drag-over").forEach((item) => { item.classList.remove("is-dragging", "is-drag-over"); }); } function normalizeMentorOrder() { (state.mentorSetup?.mentors || []).forEach((mentor, index) => { mentor.sort_order = index; }); } async function persistMentorPreferences() { const mentors = state.mentorSetup?.mentors || []; state.mentorSavingPreferences = true; renderMentorDirectory(); try { await apiRequest("/api/mentors/preferences", "POST", { order: mentors.map((item) => item.id), pinned: mentors.filter((item) => item.pinned).map((item) => item.id), }); } catch (error) { showToast(error.message || "问师顺序保存失败"); await loadMentorSetup(true); } finally { state.mentorSavingPreferences = false; renderMentorDirectory(); } } function renderMentorBadges(mentor, expanded = false) { const badges = []; if (mentor.private) { badges.push('仅自己'); } const grade = mentor.evidence?.grade; if (grade) { badges.push(`${escapeHtml(grade)}`); } return badges.join(""); } function toggleMentorDirectory(open) { const mobileOpen = Boolean(open) && window.innerWidth <= 720; state.mentorDirectoryOpen = mobileOpen; const sidebar = document.querySelector("#mentorView .mentor-sidebar"); const backdrop = document.querySelector("#mentorDirectoryBackdrop"); const toggle = document.querySelector("#mentorDirectoryToggle"); sidebar.classList.toggle("is-open", mobileOpen); backdrop.hidden = !mobileOpen; toggle.setAttribute("aria-expanded", String(mobileOpen)); document.body.classList.toggle("mentor-directory-open", mobileOpen); if (mobileOpen) requestAnimationFrame(() => document.querySelector("#mentorSearchInput").focus()); } async function selectMentor(mentorId) { if (mentorId === state.selectedMentorId) { toggleMentorDirectory(false); return; } state.selectedMentorId = mentorId; state.mentorMessages = []; hideMentorNotice(); renderMentorWorkspace(); toggleMentorDirectory(false); state.mentorMessages = await loadMentorMessages(); renderMentorMessages(); } function renderMentorMessages() { const container = document.querySelector("#mentorMessages"); const selected = state.mentorSetup?.mentors.find((item) => item.id === state.selectedMentorId); if (!state.mentorMessages.length && !state.mentorLoading) { container.innerHTML = `
向「${escapeHtml(selected?.name || "问师")}」请教

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

`; refreshIcons(); } else { container.innerHTML = state.mentorMessages.map((message) => `
${message.role === "user" ? "我" : escapeHtml(selected?.name || "问师")}
${message.role === "assistant" ? formatMentorAnswer(message.content) : escapeHtml(message.content)}
${message.streaming ? '' : ""} ${message.meta && !message.streaming ? `${escapeHtml(message.meta)}` : ""}
`).join(""); if (state.mentorLoading && !state.mentorMessages.some((message) => message.streaming)) { 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; document.querySelector("#mentorSortToggle").disabled = state.mentorLoading; requestAnimationFrame(() => { container.scrollTop = container.scrollHeight; }); } async function sendMentorQuestion(event) { event.preventDefault(); if (state.mentorLoading || !state.selectedMentorId) return; const input = document.querySelector("#mentorQuestion"); const question = input.value.trim(); if (!question) return; const history = state.mentorMessages.slice(-6).map((item) => ({ role: item.role, content: item.content.slice(0, 3500), })); state.mentorMessages.push({ role: "user", content: question }); const responseMessage = { role: "assistant", content: "", streaming: true, meta: "" }; state.mentorMessages.push(responseMessage); input.value = ""; state.mentorLoading = true; state.mentorController = new AbortController(); hideMentorNotice(); renderMentorMessages(); renderMentorDirectory(); setStatus("问师正在读取复盘数据"); try { await streamMentorRequest( { mentor_id: state.selectedMentorId, trade_date: elements.tradeDate.value, question, history, }, state.mentorController.signal, (chunk) => { responseMessage.content += chunk; scheduleMentorRender(); }, (meta) => { responseMessage.meta = `${displayCompactDate(meta.data_trade_date || elements.tradeDate.value)} · 回答完成`; if (meta.notice) showMentorNotice(meta.notice); }, ); responseMessage.streaming = false; setStatus("问师回答完成"); } catch (error) { responseMessage.streaming = false; responseMessage.error = true; if (!responseMessage.content) { state.mentorMessages = state.mentorMessages.filter((item) => item !== responseMessage); } showMentorNotice(error.message || "问师回答失败"); showToast(error.message || "问师回答失败"); setStatus("问师回答失败"); } finally { state.mentorLoading = false; state.mentorController = null; renderMentorMessages(); renderMentorDirectory(); input.focus(); } } let mentorRenderFrame = 0; function scheduleMentorRender() { if (mentorRenderFrame) return; mentorRenderFrame = requestAnimationFrame(() => { mentorRenderFrame = 0; renderMentorMessages(); }); } async function streamMentorRequest(body, signal, onDelta, onMeta) { await window.XiaobaiAPI.streamNdjson("/api/mentors/chat", { method: "POST", body, signal, errorMessage: "问师暂不可用", onEvent: (event) => { if (event.type === "delta") onDelta(String(event.content || "")); if (event.type === "meta") onMeta(event); }, }); } function useMentorQuickPrompt(prompt) { const input = document.querySelector("#mentorQuestion"); input.value = prompt || ""; input.focus(); } async function clearMentorConversation() { if (!state.mentorMessages.length || !window.confirm("确定清空当前老师的对话记录吗?")) return; try { const query = new URLSearchParams({ mentor_id: state.selectedMentorId, trade_date: state.mentorSetup?.trade_date || elements.tradeDate.value, }); await apiRequest(`/api/mentors/messages?${query}`, "DELETE"); state.mentorMessages = []; hideMentorNotice(); renderMentorMessages(); } catch (error) { showToast(error.message || "对话记录清空失败"); } } async function loadMentorMessages() { if (!state.selectedMentorId) return []; try { const query = new URLSearchParams({ mentor_id: state.selectedMentorId, trade_date: state.mentorSetup?.trade_date || elements.tradeDate.value, }); const payload = await apiRequest(`/api/mentors/messages?${query}`); return (payload.items || []).filter( (item) => ["user", "assistant"].includes(item?.role) && typeof item.content === "string", ).slice(-100); } catch (error) { showMentorNotice(error.message || "对话记录加载失败"); return []; } } function showMentorNotice(message) { const notice = document.querySelector("#mentorNotice"); notice.textContent = message; notice.hidden = false; } function hideMentorNotice() { document.querySelector("#mentorNotice").hidden = true; } function formatMentorAnswer(content) { const blocks = []; let listType = ""; let listItems = []; const flushList = () => { if (!listItems.length) return; blocks.push(`<${listType} class="mentor-answer-list">${listItems.map((item) => `
  • ${item}
  • `).join("")}`); listItems = []; listType = ""; }; String(content || "").replace(/\r\n?/g, "\n").replace(/\n{3,}/g, "\n\n").split("\n").forEach((rawLine) => { const line = rawLine.trim(); if (!line) { flushList(); return; } const heading = line.match(/^#{1,3}\s+(.+)$/); const bullet = line.match(/^[-*]\s+(.+)$/); const ordered = line.match(/^\d+[.、]\s*(.+)$/); if (heading) { flushList(); blocks.push(`${formatMentorInline(escapeHtml(heading[1]))}`); } else if (/^-{3,}$/.test(line)) { flushList(); blocks.push(''); } else if (line.startsWith("> ")) { flushList(); blocks.push(`${formatMentorInline(escapeHtml(line.slice(2)))}`); } else if (bullet || ordered) { const nextType = bullet ? "ul" : "ol"; if (listType && listType !== nextType) flushList(); listType = nextType; listItems.push(formatMentorInline(escapeHtml((bullet || ordered)[1]))); } else { flushList(); blocks.push(`

    ${formatMentorInline(escapeHtml(line))}

    `); } }); flushList(); return blocks.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.heavenInterpretations.fortune = payload.daily_fortune_reading || ""; 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; if (state.heavenSetup) { const calendarDate = state.heavenSetup.calendar_date || state.heavenSetup.trade_date; const dateLabel = panel === "trend" ? (calendarDate === state.heavenSetup.trade_date ? `行情 ${displayCompactDate(state.heavenSetup.trade_date)}` : `行情 ${displayCompactDate(state.heavenSetup.trade_date)} · 历法 ${displayCompactDate(calendarDate)}`) : `历法 ${displayCompactDate(calendarDate)}`; setText("heavenDataDate", dateLabel); } document.querySelectorAll("[data-heaven-panel]").forEach((button) => { const active = button.dataset.heavenPanel === panel; button.classList.toggle("active", active); button.classList.toggle("on", active); button.setAttribute("aria-current", active ? "page" : "false"); }); 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(); 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; initializeWentianV2Atmosphere(); renderMarketHexagram(setup.chart); renderFivePhaseField(setup.field); renderPersonalFortune(); renderHeartStage(); selectHeavenPanel(state.heavenPanel); } function buildWentianStars(id, count) { const element = document.getElementById(id); if (!element || element.children.length) return; element.innerHTML = Array.from({ length: count }, () => { const size = (Math.random() * 1.6 + 0.8).toFixed(1); return ``; }).join(""); } function buildWentianBagua(svg) { if (!svg || svg.children.length) return; const trigrams = ["乾", "兑", "离", "震", "巽", "坎", "艮", "坤"]; let characters = ""; let ticks = ""; for (let index = 0; index < 8; index += 1) { const angle = (index * 45 - 90) * Math.PI / 180; const x = 150 + 129 * Math.cos(angle); const y = 150 + 129 * Math.sin(angle); characters += `${trigrams[index]}`; } for (let index = 0; index < 24; index += 1) { const angle = (index * 15 - 90) * Math.PI / 180; ticks += ``; } svg.innerHTML = `${characters}${ticks}`; } function buildWentianFortuneOrbit(svg) { if (!svg || svg.children.length) return; const sixQi = ["厥阴木", "少阴火", "少阳火", "太阴土", "阳明金", "太阳水"]; const movements = ["木运", "火运", "土运", "金运", "水运"]; const polarText = (items, radius, fontSize, offset = -90) => items.map((label, index) => { const degrees = offset + index * 360 / items.length; const angle = degrees * Math.PI / 180; const x = 150 + radius * Math.cos(angle); const y = 150 + radius * Math.sin(angle); return `${label}`; }).join(""); const ticks = Array.from({ length: 30 }, (_, index) => { const angle = (index * 12 - 90) * Math.PI / 180; const inner = index % 5 === 0 ? 96 : 101; return ``; }).join(""); svg.innerHTML = `${polarText(sixQi, 132, 8.5)}${ticks}${polarText(movements, 70, 10)}五运六气`; } function initializeWentianV2Atmosphere() { buildWentianStars("stars", 90); buildWentianStars("fortuneStars", 100); buildWentianStars("heartStars", 110); buildWentianBagua(document.querySelector("#baguaSvg")); buildWentianFortuneOrbit(document.querySelector("#fortuneBagua")); buildWentianBagua(document.querySelector("#heartBagua")); } function renderCompactHexagrams(hexagram) { const original = document.querySelector("#heavenOriginalHexLines"); const changed = document.querySelector("#heavenChangedHexLines"); if (!original || !changed) return; if (!hexagram?.lines?.length) { original.innerHTML = ""; changed.innerHTML = ""; setText("heavenOriginalHexName", "待定"); setText("heavenChangedHexName", "待定"); setText("heavenOriginalHexDetail", "六爻尚未齐备"); setText("heavenChangedHexDetail", "待动爻化变"); return; } const values = hexagram.lines.map((line) => number(line.value)); const changedValues = values.map((value) => value === 6 ? 7 : value === 9 ? 8 : value); const lines = (items, showMoving) => [...items].reverse().map((value) => { const moving = showMoving && [6, 9].includes(value); return `
    ${value % 2 ? "" : ""}
    `; }).join(""); original.innerHTML = lines(values, true); changed.innerHTML = lines(changedValues, false); setText("heavenOriginalHexName", hexagram.name || "--"); setText("heavenChangedHexName", hexagram.transformed?.name || "--"); setText("heavenOriginalHexDetail", `${hexagram.outer_trigram || "--"}上 · ${hexagram.inner_trigram || "--"}下`); setText("heavenChangedHexDetail", `${hexagram.transformed?.outer_trigram || "--"}上 · ${hexagram.transformed?.inner_trigram || "--"}下`); } 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) { renderEmptyState(container, "载入股票后查看六爻数据状态"); 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 ? `
      ${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 || "--", ); setText("heavenStockTaxonomy", chart.sector_taxonomy === "sw_l2" ? "申万二级 ·" : "所属行业 ·"); 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", "数据未齐"); renderCompactHexagrams(null); 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); renderCompactHexagrams(chart.hexagram); 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 wentianClimateVerdict(field) { const balance = field?.balance || []; const dominant = balance[0]?.element; const secondary = balance[1]?.element; const tertiary = balance[2]?.element; const pair = [dominant, secondary].filter(Boolean).sort().join(""); const primary = { 木火: "风火相煽", 木土: "风湿相搏", 木金: "风燥相激", 木水: "风寒相薄", 土火: "湿热交蒸", 火金: "燥热相煽", 水火: "寒热相争", 土金: "燥湿相搏", 土水: "寒湿交织", 水金: "寒燥相参", }[pair] || ({ 木: "风木疏展", 火: "热火升明", 土: "湿滞偏重", 金: "燥金肃降", 水: "寒水潜藏" }[dominant] || "气机交会"); const following = { 木: "风象暗动", 火: "热象内蕴", 土: "湿滞内结", 金: "燥气相参", 水: "寒意潜行" }[tertiary] || ({ 木: "风象相随", 火: "热象相随", 土: "湿象相随", 金: "燥象相随", 水: "寒象相随" }[secondary] || "诸气相参"); return `${primary} · ${following}`; } function renderFivePhaseField(field) { if (!field) return; 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", wentianClimateVerdict(field)); 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); renderFortuneSectorCatalog(field); renderSectorPhaseOverrides(state.heavenSetup?.sector_phase_overrides || []); setText("fortuneNotice", field.notice); renderHeavenInterpretation("fortune", state.heavenInterpretations.fortune); } function renderFortuneSectorCatalog(field) { const container = document.querySelector("#fortuneSectorGroups"); if (!container) return; const phaseOrder = new Map((field.balance || []).map((item, index) => [item.element, index])); const canonical = { 木: 0, 火: 1, 土: 2, 金: 3, 水: 4 }; const catalog = [...(field.sector_catalog || [])].sort((left, right) => ( (canonical[left.element] ?? phaseOrder.get(left.element) ?? 99) - (canonical[right.element] ?? phaseOrder.get(right.element) ?? 99) )); container.innerHTML = catalog.map((group) => `
    ${escapeHtml(group.element)}属性${number(group.count || group.industries?.length)} 类
      ${(group.industries || []).map((item) => `
    • ${escapeHtml(item.name)}
    • `).join("")}
    `).join("") || '

    行业五行归类尚未建立

    '; } 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)} 类
      ${items.map((item) => `
    • ${escapeHtml(item.name)}${item.classification_source === "manual" ? '手动' : ""}
    • `).join("")}
    `; }).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"); const canManage = state.user?.role === "admin"; container.innerHTML = items.length ? items.map((item) => `
    ${escapeHtml(item.element)} ${escapeHtml(item.name)} ${canManage ? `` : ""}
    `).join("") : '

    暂无手动归类

    '; container.querySelectorAll("[data-sector-phase-delete]").forEach((button) => { button.addEventListener("click", () => deleteSectorPhaseOverride(button.dataset.sectorPhaseDelete)); }); refreshIcons(); } 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) => (items || []).map((item) => `${escapeHtml(item)}`).join("") || "--"; container.innerHTML = `
    日主 ${escapeHtml(personal.day_master?.stem || "--")} ${escapeHtml(personal.day_master?.element || "--")} ${escapeHtml(personal.day_master?.strength || "")}
    十神喜恶
    偏宜

    ${preferenceTags(tenGods.favorable)}

    偏慎

    ${preferenceTags(tenGods.caution)}

    五行喜忌
    偏喜

    ${preferenceTags(elementTendency.favorable)}

    偏忌

    ${preferenceTags(elementTendency.caution)}

    `; } 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 ? "○" : "×"}` : ""} `; } const HEAVEN_READING_META = { trend: { panel: "观势", action: "解势", done: "查看解势", status: "势已成" }, fortune: { panel: "观气", action: "解运", done: "已解运", status: "气已定" }, heart: { panel: "观心", action: "我已察念,开始解卦", done: "查看解卦", status: "卦已解" }, }; function heavenReadingMeta(mode = state.heavenReadingMode) { return HEAVEN_READING_META[mode] || HEAVEN_READING_META.trend; } function heavenReadingAnimationData() { const field = state.heavenSetup?.field || {}; return { yearPillar: field.pillars?.year || "", movement: field.movement?.label || "", sixQi: { sitian: field.six_qi?.sitian || "", zaiquan: field.six_qi?.zaiquan || "", step: number(field.six_qi?.step) || 1, }, }; } function syncHeavenReadingAnimation() { const canvas = document.querySelector("#heavenReadingCanvas"); const shouldRun = state.heavenReadingLoading && state.heavenReadingTab === "current"; if (!canvas || !window.HeavenLoadingCanvas) return; if (!shouldRun) { stopHeavenReadingAnimation(); return; } if (!heavenReadingAnimation) heavenReadingAnimation = new window.HeavenLoadingCanvas(canvas); const scene = state.heavenReadingMode === "fortune" ? "fortune" : "hexagram"; heavenReadingAnimation.start(scene, heavenReadingAnimationData()); } function stopHeavenReadingAnimation() { heavenReadingAnimation?.stop(); } function finishHeavenReadingAnimation() { if (!elements.heavenReadingDialog.open || !heavenReadingAnimation?.running) { stopHeavenReadingAnimation(); return Promise.resolve(); } return heavenReadingAnimation.complete(); } function openHeavenReading(mode, options = {}) { state.heavenReadingMode = mode; state.heavenReadingTab = "current"; state.heavenReadingError = options.error || ""; state.heavenReadingLoading = Object.hasOwn(options, "loading") ? Boolean(options.loading) : false; renderHeavenReadingDialog(); openModalDialog(elements.heavenReadingDialog); requestAnimationFrame(() => { syncHeavenReadingAnimation(); document.querySelector("#closeHeavenReadingDialog").focus(); }); } async function openHeavenHistory(mode) { state.heavenReadingMode = mode; state.heavenReadingTab = "history"; state.heavenReadingSelectedId = 0; renderHeavenReadingDialog(); openModalDialog(elements.heavenReadingDialog); await loadHeavenReadingHistory(mode); } function selectHeavenReadingTab(tab) { state.heavenReadingTab = tab === "history" ? "history" : "current"; renderHeavenReadingDialog(); if (state.heavenReadingTab === "history") loadHeavenReadingHistory(state.heavenReadingMode); } async function loadHeavenReadingHistory(mode) { const list = document.querySelector("#heavenReadingHistoryList"); renderEmptyState(list, "正在读取历史记录"); try { const query = new URLSearchParams({ mode, limit: "100" }); const payload = await apiRequest(`/api/heaven/readings?${query}`); state.heavenReadingHistory[mode] = payload.items || []; if (!state.heavenReadingHistory[mode].some((item) => number(item.id) === state.heavenReadingSelectedId)) { state.heavenReadingSelectedId = number(state.heavenReadingHistory[mode][0]?.id); } renderHeavenReadingHistory(); } catch (error) { renderEmptyState(list, error.message || "历史记录加载失败"); } } function renderHeavenReadingDialog() { const meta = heavenReadingMeta(); setText("heavenReadingEyebrow", `问天 · ${meta.panel}`); setText("heavenReadingDialogTitle", state.heavenReadingTab === "history" ? "历史记录" : meta.status); document.querySelectorAll("[data-heaven-reading-tab]").forEach((button) => { const active = button.dataset.heavenReadingTab === state.heavenReadingTab; button.classList.toggle("active", active); button.setAttribute("aria-selected", String(active)); }); document.querySelector("#heavenReadingCurrent").hidden = state.heavenReadingTab !== "current"; document.querySelector("#heavenReadingHistory").hidden = state.heavenReadingTab !== "history"; if (state.heavenReadingTab === "current") { renderHeavenReadingCurrent(); } else { stopHeavenReadingAnimation(); renderHeavenReadingHistory(); } refreshIcons(); } function renderHeavenReadingCurrent() { const reading = state.heavenInterpretations[state.heavenReadingMode]; const loading = document.querySelector("#heavenReadingLoading"); const empty = document.querySelector("#heavenReadingEmpty"); const result = document.querySelector("#heavenReadingResult"); const error = document.querySelector("#heavenReadingError"); loading.hidden = !state.heavenReadingLoading; syncHeavenReadingAnimation(); error.hidden = !state.heavenReadingError; error.textContent = state.heavenReadingError; result.hidden = state.heavenReadingLoading || !reading; empty.hidden = state.heavenReadingLoading || Boolean(reading) || Boolean(state.heavenReadingError); if (!reading || state.heavenReadingLoading) return; setText("heavenReadingResultStatus", heavenReadingMeta().status); setText("heavenReadingSubject", reading.subject || `${heavenReadingMeta().panel}解读`); setText("heavenReadingSubjectDetail", reading.subject_detail || displayCompactDate(reading.context_date || "")); setText("heavenReadingCreatedAt", reading.created_at ? formatTimestamp(reading.created_at) : "刚刚完成"); document.querySelector("#heavenReadingAnswer").innerHTML = formatMentorAnswer(reading.answer || ""); } function renderHeavenReadingHistory() { const mode = state.heavenReadingMode; const items = state.heavenReadingHistory[mode] || []; setText("heavenReadingHistoryTitle", `${heavenReadingMeta(mode).panel}记录`); setText("heavenReadingHistoryCount", `${items.length} 条`); const list = document.querySelector("#heavenReadingHistoryList"); list.innerHTML = items.map((item) => ` `).join("") || emptyStateHtml("暂无历史解读"); const selected = items.find((item) => number(item.id) === state.heavenReadingSelectedId); const detail = document.querySelector("#heavenReadingHistoryDetail"); detail.innerHTML = selected ? `
    ${escapeHtml(heavenReadingMeta(mode).status)}

    ${escapeHtml(selected.subject)}

    ${escapeHtml(selected.subject_detail || displayCompactDate(selected.context_date))}

    ${formatMentorAnswer(selected.answer || "")}
    ` : emptyStateHtml("选择一条记录查看完整解读"); refreshIcons(); } function handleHeavenHistorySelection(event) { const button = event.target.closest("[data-heaven-reading-id]"); if (!button) return; state.heavenReadingSelectedId = number(button.dataset.heavenReadingId); renderHeavenReadingHistory(); } async function handleHeavenHistoryAction(event) { const button = event.target.closest("[data-delete-heaven-reading]"); if (!button || !window.confirm("确定删除这条解读记录吗?")) return; const id = number(button.dataset.deleteHeavenReading); try { await apiRequest(`/api/heaven/readings/${id}`, "DELETE"); const mode = state.heavenReadingMode; state.heavenReadingHistory[mode] = (state.heavenReadingHistory[mode] || []).filter((item) => number(item.id) !== id); if (number(state.heavenInterpretations[mode]?.id) === id) { state.heavenInterpretations[mode] = ""; if (mode === "fortune" && state.heavenSetup) state.heavenSetup.daily_fortune_reading = null; updateHeavenInterpretationControls(); } state.heavenReadingSelectedId = number(state.heavenReadingHistory[mode][0]?.id); renderHeavenReadingHistory(); } catch (error) { showToast(error.message || "解读记录删除失败"); } } async function interpretHeaven(mode) { const existing = state.heavenInterpretations[mode]; if (existing) { openHeavenReading(mode, { loading: false }); return; } const button = document.querySelector(mode === "trend" ? "#interpretTrendButton" : mode === "fortune" ? "#interpretFortuneButton" : "#interpretHeartButton"); if (button.disabled) return; state.heavenReadingMode = mode; state.heavenReadingLoading = true; state.heavenReadingError = ""; openHeavenReading(mode, { loading: true }); updateHeavenInterpretationControls(); 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); state.heavenInterpretations[mode] = result.reading || { answer: result.answer, subject: `${heavenReadingMeta(mode).panel}解读`, context_date: payload.trade_date, created_at: new Date().toISOString(), }; state.heavenReadingHistory[mode] = []; await finishHeavenReadingAnimation(); state.heavenReadingLoading = false; renderHeavenReadingDialog(); if (result.notice) showHeavenNotice(result.notice); if (mode === "heart") { if (await transitionHeartStage("interpretation")) await playHeartReadSequence(); } else { renderHeavenInterpretation(mode, state.heavenInterpretations[mode]); } } catch (error) { stopHeavenReadingAnimation(); state.heavenReadingLoading = false; state.heavenReadingError = error.message || "问天解读失败"; renderHeavenReadingDialog(); showHeavenNotice(state.heavenReadingError); showToast(state.heavenReadingError); } finally { state.heavenReadingLoading = false; updateHeavenInterpretationControls(); } } function updateHeavenInterpretationControls() { const loading = state.heavenReadingLoading; const trendButton = document.querySelector("#interpretTrendButton"); const fortuneButton = document.querySelector("#interpretFortuneButton"); const heartButton = document.querySelector("#interpretHeartButton"); trendButton.disabled = loading || !state.heavenSetup?.chart?.available; fortuneButton.disabled = loading || !state.heavenSetup?.field; heartButton.disabled = loading || state.heartLines.length !== 6; trendButton.textContent = loading && state.heavenReadingMode === "trend" ? "正在观势" : state.heavenInterpretations.trend ? HEAVEN_READING_META.trend.done : HEAVEN_READING_META.trend.action; fortuneButton.textContent = loading && state.heavenReadingMode === "fortune" ? "正在察运" : state.heavenInterpretations.fortune ? HEAVEN_READING_META.fortune.done : HEAVEN_READING_META.fortune.action; heartButton.textContent = loading && state.heavenReadingMode === "heart" ? "正在解卦" : state.heavenInterpretations.heart ? HEAVEN_READING_META.heart.done : HEAVEN_READING_META.heart.action; document.querySelector("#viewHeartReadingButton").disabled = !state.heavenInterpretations.heart; } function renderHeavenInterpretation() { updateHeavenInterpretationControls(); } 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 = HEART_BREATH_TOTAL_MS / 1000; state.heartBreathingEndsAt = 0; document.querySelector("#beginCastingButton")?.classList.remove("is-ready"); if (!await transitionHeartStage("breathing")) return; state.heartBreathingEndsAt = Date.now() + HEART_BREATH_TOTAL_MS; const ember = document.querySelector("#heartIncenseEmber"); heartIncenseAnimation?.cancel(); ember?.classList.remove("is-burning"); if (ember) void ember.offsetWidth; ember?.classList.add("is-burning"); heartIncenseAnimation = ember?.animate( [{ top: "0%" }, { top: "100%" }], { duration: HEART_BREATH_ACTIVE_MS, delay: HEART_BREATH_PREPARE_MS, easing: "linear", fill: "forwards", }, ) || null; 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() { const remainingMs = state.heartBreathingEndsAt ? Math.max(0, state.heartBreathingEndsAt - Date.now()) : Math.max(0, state.heartSeconds * 1000); const elapsedMs = HEART_BREATH_TOTAL_MS - remainingMs; const activeElapsedMs = Math.max(0, elapsedMs - HEART_BREATH_PREPARE_MS); const cycleElapsedMs = activeElapsedMs % HEART_BREATH_CYCLE_MS; const breathPhase = elapsedMs < HEART_BREATH_PREPARE_MS ? "prepare" : cycleElapsedMs < HEART_BREATH_INHALE_MS ? "inhale" : cycleElapsedMs < HEART_BREATH_INHALE_MS + HEART_BREATH_HOLD_MS ? "hold" : "exhale"; const phase = state.heartSeconds <= 0 ? "settled" : breathPhase; const scene = document.querySelector("#breathingScene"); scene.dataset.phase = phase; setText("breathingPhase", phase === "settled" ? "静" : phase === "prepare" ? "静" : phase === "inhale" ? "吸" : phase === "hold" ? "顿" : "呼"); const prompt = state.heartSeconds <= 0 ? "静心已成,可以起卦" : phase === "prepare" ? "放松片刻,准备呼吸" : phase === "hold" ? "停驻片刻,让念头自然沉下" : activeElapsedMs < 18_000 ? phase === "inhale" ? "缓慢吸气,放下对答案的预设" : "缓慢呼气,让预设随之松开" : activeElapsedMs < 36_000 ? phase === "inhale" ? "吸气,只留下真正想问的事" : "呼气,不急着寻找答案" : phase === "inhale" ? "吸气,让心停在此刻" : "呼气,不追逐经过的念头"; 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"; 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; updateHeavenInterpretationControls(); 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"); document.querySelectorAll("[data-heart-step]").forEach((step) => { step.classList.toggle("active", step.dataset.heartStep === state.heartStage); }); 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("#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(".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 = HEART_BREATH_TOTAL_MS / 1000; state.heartBreathingEndsAt = 0; state.heartLines = []; state.heartThrows = []; state.heartHexagram = null; state.heavenInterpretations.heart = ""; heartIncenseAnimation?.cancel(); heartIncenseAnimation = null; document.querySelector("#heartIncenseEmber")?.classList.remove("is-burning"); updateHeavenInterpretationControls(); 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 mode = state.screenerMode || "smart"; const result = activeScreenerResult(mode); const context = activeScreenerResultContext(mode); const source = document.querySelector("#screenerResultSource"); const emptyMessages = { smart: "当日盘后候选尚未生成", curated: "所选策略的当日候选尚未生成", quant: "尚未执行自定义选股", }; if (!result) { setText("screenerResultCount", "0 只"); source.hidden = true; source.textContent = ""; setText("screenerDisclaimer", "历史统计不代表未来收益"); document.querySelector("#screenerTableBody").innerHTML = ""; document.querySelector("#screenerEmpty").textContent = emptyMessages[mode]; document.querySelector("#screenerEmpty").hidden = false; renderBacktest(null); if (mode === "smart") setText("screenerRunStatus", "等待执行"); updateBacktestTaskStatus(); renderScreenerProgress(); return; } const candidates = result.candidates || []; setText("screenerResultCount", `${candidates.length} 只`); const modeLabels = { smart: "阶段选股", curated: "策略选股", quant: "自定义选股" }; const sourceParts = [modeLabels[mode]]; if (mode === "smart" && context?.regime) sourceParts.push(regimeLabel(context.regime)); sourceParts.push(mode === "quant" ? "自定义因子权重" : context?.strategyName || result.meta?.strategy_name || "未命名策略"); source.textContent = sourceParts.join(" · "); source.hidden = false; const meta = result.meta || {}; setText( "screenerDisclaimer", meta.realtime ? `盘中行情 · 历史样本截至 ${displayCompactDate(meta.history_cutoff)} · ${result.disclaimer}` : `盘后数据 ${displayCompactDate(meta.trade_date)} · ${result.disclaimer}`, ); const empty = document.querySelector("#screenerEmpty"); empty.textContent = mode === "curated" ? "暂无符合条件个股" : emptyMessages[mode]; empty.hidden = candidates.length > 0; const body = document.querySelector("#screenerTableBody"); const runId = number(meta.run_id); body.innerHTML = candidates.map((row, index) => ` ${index + 1} ${escapeHtml(row.name)}${escapeHtml(row.code)}${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); }); }); body.querySelectorAll("[data-add-tracking]").forEach((button) => { button.addEventListener("click", () => addCandidateToTracking(button.dataset.addTracking, button)); }); bindStockRows(body); renderBacktest(result.backtest); if (mode === "smart") setText("screenerRunStatus", `完成 · ${candidates.length} 只`); updateBacktestTaskStatus(); renderScreenerProgress(); } async function loadScreenerTracking(force = false) { if (state.screenerTracking && !force) { renderScreenerTracking(); return; } try { state.screenerTracking = await apiRequest("/api/screener/tracking?limit=12"); renderScreenerTracking(); if (activeScreenerResult()) renderScreenerResult(); } catch (error) { showToast(error.message || "策略跟踪加载失败"); } } function isCandidateTracked(runId, code) { if (!runId) return false; return (state.screenerTracking?.batches || []).some((batch) => number(batch.run_id) === number(runId) && (batch.items || []).some((item) => item.code === code)); } async function addCandidateToTracking(code, button) { const runId = number(activeScreenerResult()?.meta?.run_id); if (!runId) { showToast("本次结果缺少选股批次,请重新执行后再加入跟踪"); return; } button.disabled = true; try { const payload = await apiRequest("/api/screener/tracking", "POST", { run_id: runId, code }); state.screenerTracking = payload.tracking; renderScreenerTracking(); renderScreenerResult(); showToast(`${code} 已加入策略跟踪`); } catch (error) { button.disabled = false; showToast(error.message || "加入跟踪失败"); } } async function refreshScreenerTracking() { const button = document.querySelector("#refreshTrackingButton"); button.disabled = true; setStatus("正在更新策略跟踪"); try { const payload = await apiRequest("/api/screener/tracking/refresh", "POST", { trade_date: elements.tradeDate.value, }); state.screenerTracking = payload.tracking; renderScreenerTracking(); if (payload.notice) showToast(payload.notice); setStatus("策略跟踪已更新"); } catch (error) { showToast(error.message || "策略跟踪刷新失败"); setStatus("策略跟踪刷新失败"); } finally { button.disabled = false; } } function renderScreenerTracking() { const payload = state.screenerTracking || { batches: [], summary: {} }; const batches = payload.batches || []; const rows = batches.flatMap((batch) => (batch.items || []).map((item) => ({ ...item, run_id: batch.run_id, selection_date: batch.selection_date, strategy_name: batch.strategy_name, }))); setText("trackingBatchCount", `${batches.length} 批`); const summary = payload.summary || {}; document.querySelector("#trackingSummary").innerHTML = [ ["跟踪标的", `${number(summary.total)} 只`], ["已有 T+1", `${number(summary.observed)} 只`], ["T+1 胜率", trackingPercent(summary.t1_win_rate)], ["T+5 胜率", trackingPercent(summary.t5_win_rate)], ["T+5 平均", trackingReturn(summary.average_t5)], ].map(([label, value]) => `
    ${label}${value}
    `).join(""); document.querySelector("#trackingEmpty").hidden = rows.length > 0; document.querySelector("#trackingTableBody").innerHTML = rows.map((row) => ` ${displayCompactDate(row.selection_date)} ${escapeHtml(row.strategy_name)} ${escapeHtml(row.name)}${escapeHtml(row.code)} ${row.entry_price == null ? "" : formatNumber(row.entry_price, 2)} ${["t1_open", "t1_close", "t3_close", "t5_close", "max_gain", "max_drawdown"].map((key) => `${trackingReturn(row[key], false)}`).join("")} ${escapeHtml(row.status)} `).join(""); bindStockRows(document.querySelector("#trackingTableBody")); } async function handleTrackingTableAction(event) { const button = event.target.closest("[data-remove-tracking]"); if (!button) return; if (!window.confirm("确定停止跟踪这只股票吗?")) return; button.disabled = true; try { const payload = await apiRequest(`/api/screener/tracking/${button.dataset.removeTracking}`, "DELETE"); state.screenerTracking = payload.tracking; renderScreenerTracking(); if (activeScreenerResult()) renderScreenerResult(); showToast("已移出策略跟踪"); } catch (error) { button.disabled = false; showToast(error.message || "移除跟踪失败"); } } function trackingReturn(value, includeUnit = true) { return value == null ? (includeUnit ? "--" : "") : `${signed(value)}${includeUnit ? "%" : ""}`; } function trackingPercent(value) { return value == null ? "--" : `${formatNumber(value, 1)}%`; } 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() { const modeLabels = { smart: "阶段选股", curated: "策略选股", quant: "量化选股" }; exportRows(modeLabels[state.screenerMode] || "智能选股", activeScreenerResult()?.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 currentChartPalette() { const style = getComputedStyle(document.documentElement); const color = (token, fallback) => style.getPropertyValue(token).trim() || fallback; return { background: color("--chart-background", "#fbfcfd"), grid: color("--chart-grid", "#e2e8ec"), axis: color("--chart-axis", "#6c7983"), zero: color("--chart-zero", "#aeb7c1"), line: color("--chart-line", "#1d65c1"), average: color("--chart-average", "#b7791f"), up: color("--chart-up", "#c93f45"), down: color("--chart-down", "#087a55"), upVolume: color("--chart-up-volume", "rgba(201, 63, 69, .58)"), downVolume: color("--chart-down-volume", "rgba(8, 122, 85, .58)"), area: color("--chart-area", "rgba(37, 99, 235, .07)"), alertArea: color("--chart-alert-area", "rgba(224, 69, 54, .05)"), movingAverage: color("--chart-moving-average", "#d1d5db"), repair: color("--chart-repair", "#f59e0b"), ma10: color("--chart-ma-10", "#a76500"), ma20: color("--chart-ma-20", "#626c78"), }; } function drawCandlestick(context, x, item, priceY, candleWidth, palette = currentChartPalette()) { const rising = number(item.close) >= number(item.open); const color = rising ? palette.up : palette.down; const highY = priceY(item.high); const lowY = priceY(item.low); const openY = priceY(item.open); const closeY = priceY(item.close); const bodyTop = Math.min(openY, closeY); const bodyBottom = Math.max(openY, closeY); const bodyHeight = Math.max(1, bodyBottom - bodyTop); context.strokeStyle = color; context.fillStyle = color; context.lineWidth = 1; context.beginPath(); context.moveTo(x, highY); context.lineTo(x, bodyTop); context.moveTo(x, bodyBottom); context.lineTo(x, lowY); context.stroke(); const bodyLeft = x - candleWidth / 2; if (rising) { context.fillStyle = palette.background; context.fillRect(bodyLeft, bodyTop, candleWidth, bodyHeight); context.strokeStyle = color; context.strokeRect(bodyLeft, bodyTop, candleWidth, bodyHeight); } else { context.fillStyle = color; context.fillRect(bodyLeft, bodyTop, candleWidth, bodyHeight); } return color; } function drawPriceChart(prices) { const canvas = elements.priceChart; if (!prices?.length) { clearPriceChart("暂无日 K 数据"); return; } const rect = canvas.getBoundingClientRect(); const ratio = window.devicePixelRatio || 1; const width = Math.max(320, rect.width); const height = Math.max(220, rect.height); canvas.width = Math.round(width * ratio); canvas.height = Math.round(height * ratio); const context = canvas.getContext("2d"); const palette = currentChartPalette(); context.setTransform(ratio, 0, 0, ratio, 0, 0); context.clearRect(0, 0, width, height); context.fillStyle = palette.background; context.fillRect(0, 0, width, height); const left = 48; const right = 12; const top = 14; const bottom = 22; const volumeHeight = 54; const gap = 12; const priceBottom = height - bottom - volumeHeight - gap; const plotWidth = width - left - right; const highs = prices.map((item) => number(item.high)); const lows = prices.map((item) => number(item.low)); const maximum = Math.max(...highs); const minimum = Math.min(...lows); const range = Math.max(maximum - minimum, maximum * 0.01, 0.01); const volumes = prices.map((item) => number(item.volume)); const maxVolume = Math.max(...volumes, 1); const priceY = (value) => top + (maximum - value) / range * (priceBottom - top); const step = plotWidth / prices.length; const candleWidth = clamp(step * 0.62, 2, 8); context.strokeStyle = palette.grid; context.fillStyle = palette.axis; context.font = "11px Microsoft YaHei"; context.textAlign = "right"; for (let line = 0; line <= 4; line += 1) { const y = top + (priceBottom - top) * line / 4; context.beginPath(); context.moveTo(left, y); context.lineTo(width - right, y); context.stroke(); context.fillText((maximum - range * line / 4).toFixed(2), left - 5, y + 4); } prices.forEach((item, index) => { const x = left + step * index + step / 2; const color = drawCandlestick(context, x, item, priceY, candleWidth, palette); const volumeBarHeight = number(item.volume) / maxVolume * volumeHeight; context.fillStyle = color; context.globalAlpha = 0.75; context.fillRect(x - candleWidth / 2, height - bottom - volumeBarHeight, candleWidth, volumeBarHeight); context.globalAlpha = 1; }); context.textAlign = "center"; context.fillStyle = palette.axis; const labelIndexes = [0, Math.floor((prices.length - 1) / 2), prices.length - 1]; labelIndexes.forEach((index) => { const x = left + step * index + step / 2; context.fillText(String(prices[index].trade_date).slice(5), x, height - 5); }); } function clearPriceChart(message) { const canvas = elements.priceChart; const context = canvas.getContext("2d"); const rect = canvas.getBoundingClientRect(); canvas.width = Math.max(320, Math.round(rect.width)); canvas.height = Math.max(220, Math.round(rect.height)); const palette = currentChartPalette(); context.fillStyle = palette.background; context.fillRect(0, 0, canvas.width, canvas.height); context.fillStyle = palette.axis; context.font = "13px Microsoft YaHei"; context.textAlign = "center"; context.fillText(message, canvas.width / 2, canvas.height / 2); } function prepareStockPreviewCanvas() { const canvas = elements.stockPreviewChart; const rect = canvas.getBoundingClientRect(); const ratio = window.devicePixelRatio || 1; const width = Math.max(300, rect.width || 488); const height = Math.max(210, rect.height || 232); canvas.width = Math.round(width * ratio); canvas.height = Math.round(height * ratio); const context = canvas.getContext("2d"); const palette = currentChartPalette(); context.setTransform(ratio, 0, 0, ratio, 0, 0); context.clearRect(0, 0, width, height); context.fillStyle = palette.background; context.fillRect(0, 0, width, height); context.font = '11px -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei UI", sans-serif'; return { canvas, context, width, height, palette }; } function drawPreviewGrid(context, width, top, bottom, left, right, maximum, range) { const palette = currentChartPalette(); context.strokeStyle = palette.grid; context.fillStyle = palette.axis; context.textAlign = "right"; context.lineWidth = 1; for (let line = 0; line <= 3; line += 1) { const y = top + (bottom - top) * line / 3; context.beginPath(); context.moveTo(left, y); context.lineTo(width - right, y); context.stroke(); context.fillText((maximum - range * line / 3).toFixed(2), left - 5, y + 4); } } function intradayMinuteOffset(value) { const [hour, minute] = String(value || "").split(":").map((part) => number(part)); const clockMinute = hour * 60 + minute; const morningStart = 9 * 60 + 30; const morningEnd = 11 * 60 + 30; const afternoonStart = 13 * 60; const afternoonEnd = 15 * 60; if (clockMinute <= morningEnd) return clamp(clockMinute - morningStart, 0, 120); if (clockMinute < afternoonStart) return 120; return 120 + clamp(clockMinute - afternoonStart, 0, afternoonEnd - afternoonStart); } function drawIntradayCanvas(canvas, points, dailyPrices = [], referenceClose = 0) { const rect = canvas.getBoundingClientRect(); const ratio = window.devicePixelRatio || 1; const width = Math.max(300, rect.width || 488); const height = Math.max(210, rect.height || 232); canvas.width = Math.round(width * ratio); canvas.height = Math.round(height * ratio); const context = canvas.getContext("2d"); const palette = currentChartPalette(); context.setTransform(ratio, 0, 0, ratio, 0, 0); context.clearRect(0, 0, width, height); context.fillStyle = palette.background; context.fillRect(0, 0, width, height); context.font = '11px -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei UI", sans-serif'; const left = 45; const right = 10; const top = 12; const volumeHeight = 38; const bottom = 18; const gap = 9; const priceBottom = height - bottom - volumeHeight - gap; const closes = points.map((point) => number(point.close)); const previousClose = number(referenceClose || dailyPrices.at(-2)?.close || points[0]?.open || closes[0]); const maximum = Math.max(...points.map((point) => number(point.high || point.close)), previousClose); const minimum = Math.min(...points.map((point) => number(point.low || point.close)), previousClose); const deviation = Math.max( Math.abs(maximum - previousClose), Math.abs(previousClose - minimum), previousClose * 0.003, 0.01, ) * 1.08; const chartMaximum = previousClose + deviation; const chartMinimum = previousClose - deviation; const range = Math.max(chartMaximum - chartMinimum, 0.01); const plotWidth = width - left - right; const priceY = (value) => top + (chartMaximum - value) / range * (priceBottom - top); const pointX = (index) => left + plotWidth * intradayMinuteOffset(points[index]?.time) / 240; drawPreviewGrid(context, width, top, priceBottom, left, right, chartMaximum, range); context.save(); context.setLineDash([4, 4]); context.strokeStyle = palette.zero; context.beginPath(); context.moveTo(left, priceY(previousClose)); context.lineTo(width - right, priceY(previousClose)); context.stroke(); context.restore(); context.fillStyle = palette.axis; context.textAlign = "right"; context.fillText("0.00%", width - right, priceY(previousClose) - 4); context.strokeStyle = palette.line; context.lineWidth = 1.7; context.beginPath(); points.forEach((point, index) => { const x = pointX(index); const y = priceY(point.close); if (index === 0) context.moveTo(x, y); else context.lineTo(x, y); }); context.stroke(); const averages = points.map((point) => number(point.average)).filter((value) => value > 0); if (averages.length) { context.strokeStyle = palette.average; context.lineWidth = 1.25; context.beginPath(); let averageStarted = false; points.forEach((point, index) => { const average = number(point.average); if (average <= 0) return; const x = pointX(index); const y = priceY(average); if (!averageStarted) { context.moveTo(x, y); averageStarted = true; } else context.lineTo(x, y); }); context.stroke(); } const maxVolume = Math.max(...points.map((point) => number(point.volume)), 1); const barWidth = clamp(plotWidth / Math.max(points.length, 1) * 0.72, 1, 3); points.forEach((point, index) => { const x = pointX(index); const barHeight = number(point.volume) / maxVolume * volumeHeight; context.fillStyle = number(point.close) >= number(point.open) ? palette.upVolume : palette.downVolume; context.fillRect(x - barWidth / 2, height - bottom - barHeight, barWidth, barHeight); }); context.fillStyle = palette.axis; context.textAlign = "center"; [ { offset: 0, label: "09:30" }, { offset: 120, label: "11:30 / 13:00" }, { offset: 240, label: "15:00" }, ].forEach((marker) => { context.fillText(marker.label, left + plotWidth * marker.offset / 240, height - 4); }); return { latest: closes.at(-1), maximum, minimum, }; } function drawIntradayPreviewChart(points, dailyPrices, referenceClose = 0) { const summary = drawIntradayCanvas(elements.stockPreviewChart, points, dailyPrices, referenceClose); setText( "stockPreviewSummary", `分时 ${points.length} 点,最新 ${formatNumber(summary.latest, 2)},最高 ${formatNumber(summary.maximum, 2)},最低 ${formatNumber(summary.minimum, 2)}。`, ); } function drawDailyPreviewChart(prices) { const { context, width, height, palette } = prepareStockPreviewCanvas(); const visible = prices.slice(-45); const visibleStart = prices.length - visible.length; const left = 45; const right = 10; const top = 24; const volumeHeight = 34; const bottom = 18; const gap = 8; const priceBottom = height - bottom - volumeHeight - gap; const maximum = Math.max(...visible.map((item) => number(item.high))); const minimum = Math.min(...visible.map((item) => number(item.low))); const padding = Math.max((maximum - minimum) * 0.05, maximum * 0.002, 0.01); const chartMaximum = maximum + padding; const chartMinimum = minimum - padding; const range = Math.max(chartMaximum - chartMinimum, 0.01); const plotWidth = width - left - right; const step = plotWidth / Math.max(visible.length, 1); const candleWidth = clamp(step * 0.58, 2, 7); const priceY = (value) => top + (chartMaximum - value) / range * (priceBottom - top); drawPreviewGrid(context, width, top, priceBottom, left, right, chartMaximum, range); const maxVolume = Math.max(...visible.map((item) => number(item.volume)), 1); visible.forEach((item, index) => { const x = left + step * index + step / 2; const color = drawCandlestick(context, x, item, priceY, candleWidth, palette); const volumeBarHeight = number(item.volume) / maxVolume * volumeHeight; context.fillStyle = color; context.globalAlpha = 0.62; context.fillRect(x - candleWidth / 2, height - bottom - volumeBarHeight, candleWidth, volumeBarHeight); context.globalAlpha = 1; }); const movingAverages = [ { days: 5, color: palette.line }, { days: 10, color: palette.ma10 }, { days: 20, color: palette.ma20 }, ]; movingAverages.forEach(({ days, color }) => { context.strokeStyle = color; context.lineWidth = 1.25; context.beginPath(); let started = false; visible.forEach((_item, index) => { const absoluteIndex = visibleStart + index; if (absoluteIndex < days - 1) return; const values = prices.slice(absoluteIndex - days + 1, absoluteIndex + 1); const average = values.reduce((sum, item) => sum + number(item.close), 0) / days; const x = left + step * index + step / 2; const y = priceY(average); if (!started) { context.moveTo(x, y); started = true; } else context.lineTo(x, y); }); context.stroke(); }); context.textAlign = "left"; movingAverages.forEach(({ days, color }, index) => { context.fillStyle = color; context.fillText(`MA${days}`, left + index * 42, 12); }); context.fillStyle = palette.axis; context.textAlign = "center"; [0, Math.floor((visible.length - 1) / 2), visible.length - 1].forEach((index) => { const x = left + step * index + step / 2; context.fillText(String(visible[index]?.trade_date || "").slice(5), x, height - 4); }); const firstClose = number(visible[0]?.close); const latestClose = number(visible.at(-1)?.close); const periodChange = firstClose ? (latestClose / firstClose - 1) * 100 : 0; setText( "stockPreviewSummary", `近 ${visible.length} 日涨跌 ${signed(periodChange)}%,区间最高 ${formatNumber(maximum, 2)},最低 ${formatNumber(minimum, 2)}。`, ); } function clearStockPreviewChart(message) { const { context, width, height } = prepareStockPreviewCanvas(); if (!message) return; context.fillStyle = "#74808d"; context.textAlign = "center"; context.fillText(message, width / 2, height / 2); } function bindStockRows(container) { animateRows(container); decorateStockPreviewTargets(container); container.querySelectorAll("[data-code]").forEach((rowElement) => { rowElement.addEventListener("click", (event) => { const interactive = event.target.closest("button, a, input, select, textarea, summary"); if (interactive && interactive !== rowElement) return; openStock(rowElement.dataset.code, findStockFallback(rowElement.dataset.code)); }); }); } function decorateStockPreviewTargets(container) { container.querySelectorAll(".stock-code").forEach((trigger) => { const code = stockCodeFromTrigger(trigger); if (!code) return; trigger.classList.add("stock-preview-trigger"); trigger.tabIndex = 0; trigger.setAttribute("role", "button"); trigger.setAttribute("aria-label", `预览 ${code} 行情`); trigger.title = "悬停预览行情,点击查看完整详情"; }); } function stockCodeFromTrigger(trigger) { const candidate = trigger?.dataset?.stockPreviewCode || trigger?.closest?.("[data-code]")?.dataset?.code || trigger?.textContent?.trim(); const matched = String(candidate || "").match(/\b(\d{6})\b/); return matched ? matched[1] : ""; } function marketPreviewTargetFromTrigger(trigger) { if (trigger?.classList?.contains("market-preview-trigger")) { const type = String(trigger.dataset.marketPreviewType || "").trim().toLowerCase(); const id = String(trigger.dataset.marketPreviewId || "").trim().toUpperCase(); if (type === "theme" && id) { const item = (state.themeLibrary?.items || []).find((row) => String(row.code) === id) || {}; return { type, id, code: id, name: item.name || trigger.textContent?.trim() || "--", type_label: "题材", change: item.change, value: item.close, }; } } const code = stockCodeFromTrigger(trigger); return code ? { type: "stock", id: code, code } : null; } function previewTriggerFromEvent(event) { return event.target.closest?.(".stock-preview-trigger, .market-preview-trigger"); } function showMarketPreview(target, trigger) { if (!target) return; if (target.type === "stock") showStockPreview(target.id, trigger); else showEntityPreview(target, trigger); } function findStockFallback(code) { const dashboardRows = [ ...(state.dashboard?.limits || []), ...(state.dashboard?.broken || []), ...(state.dashboard?.down_limits || []), ...(state.dashboard?.yesterday_limits || []), ]; const screenerRows = Object.values(state.screenerResultStore) .flatMap((entry) => entry?.result?.candidates || []); const dragonRows = (state.dragonTiger?.traders || []).flatMap((trader) => trader.operations || []); const auctionRows = state.auctionData?.rows || []; const themeRows = state.themeDetail?.members || []; const popularityRows = state.popularityData?.combined || []; const row = [...dashboardRows, ...screenerRows, ...dragonRows, ...auctionRows, ...themeRows, ...popularityRows, ...(state.watchlist || [])] .find((item) => String(item.code) === String(code)); if (!row) return { code, name: "--", sector: "其他" }; return { ...row, code, change: row.change ?? row.current_change ?? row.pct_chg ?? 0, sector: row.sector || row.industry || "其他", }; } function supportsStockPreviewHover() { return window.matchMedia("(hover: hover) and (pointer: fine)").matches && window.innerWidth > 720; } function handleStockPreviewPointerOver(event) { if (!supportsStockPreviewHover()) return; const trigger = previewTriggerFromEvent(event); if (!trigger || trigger === event.relatedTarget?.closest?.(".stock-preview-trigger, .market-preview-trigger")) return; const target = marketPreviewTargetFromTrigger(trigger); if (!target) return; cancelStockPreviewClose(); clearTimeout(stockPreviewOpenTimer); stockPreviewOpenTimer = setTimeout(() => showMarketPreview(target, trigger), STOCK_PREVIEW_DELAY); } function handleStockPreviewPointerOut(event) { if (!supportsStockPreviewHover()) return; const trigger = previewTriggerFromEvent(event); if (!trigger || trigger === event.relatedTarget?.closest?.(".stock-preview-trigger, .market-preview-trigger")) return; clearTimeout(stockPreviewOpenTimer); if (event.relatedTarget instanceof Node && elements.stockPreview.contains(event.relatedTarget)) return; scheduleStockPreviewClose(); } function handleStockPreviewFocus(event) { if (!supportsStockPreviewHover()) return; const trigger = event.target.closest?.(".stock-preview-trigger"); if (!trigger) return; const code = stockCodeFromTrigger(trigger); if (!code) return; clearTimeout(stockPreviewOpenTimer); stockPreviewOpenTimer = setTimeout(() => showStockPreview(code, trigger), 120); } function handleStockPreviewFocusOut(event) { const trigger = event.target.closest?.(".stock-preview-trigger"); if (!trigger) return; if (event.relatedTarget instanceof Node && elements.stockPreview.contains(event.relatedTarget)) return; clearTimeout(stockPreviewOpenTimer); scheduleStockPreviewClose(); } function handleMobileStockPreviewClick(event) { if (window.innerWidth > 720) return; const trigger = event.target.closest?.(".stock-preview-trigger"); if (!trigger) return; const code = stockCodeFromTrigger(trigger); if (!code) return; event.preventDefault(); event.stopPropagation(); showStockPreview(code, trigger); } function handleStockPreviewKeydown(event) { if (event.key === "Escape" && !elements.stockPreview.hidden) { closeStockPreview(); stockPreviewAnchor?.focus?.(); return; } if (event.key !== "Enter") return; const trigger = event.target.closest?.(".stock-preview-trigger"); if (!trigger) return; const code = stockCodeFromTrigger(trigger); if (!code) return; event.preventDefault(); if (window.innerWidth <= 720) showStockPreview(code, trigger); else openStock(code, findStockFallback(code)); } function cancelStockPreviewClose() { clearTimeout(stockPreviewCloseTimer); } function scheduleStockPreviewClose() { clearTimeout(stockPreviewCloseTimer); stockPreviewCloseTimer = setTimeout(closeStockPreview, 160); } async function showStockPreview(code, trigger) { clearTimeout(stockPreviewOpenTimer); cancelStockPreviewClose(); if (!/^\d{6}$/.test(String(code))) return; stockPreviewAnchor = trigger; state.stockPreviewCode = String(code); state.stockPreviewType = "stock"; state.stockPreviewItem = null; state.stockPreviewFallback = findStockFallback(code); state.stockPreviewPayload = null; state.stockPreviewChart = "daily"; renderStockPreviewLoading(); elements.stockPreview.hidden = false; const mobile = window.innerWidth <= 720; elements.stockPreviewBackdrop.hidden = !mobile; document.body.classList.toggle("stock-preview-open", mobile); requestAnimationFrame(repositionStockPreview); const cacheKey = `${code}:latest`; const cached = stockPreviewCache.get(cacheKey); if (cached && cached.expiresAt > Date.now()) { renderStockPreview(cached.payload); return; } if (cached) stockPreviewCache.delete(cacheKey); stockPreviewAbortController?.abort(); stockPreviewAbortController = new AbortController(); try { const payload = await apiRequest( `/api/stock/${encodeURIComponent(code)}/preview`, "GET", null, { signal: stockPreviewAbortController.signal }, ); if (state.stockPreviewCode !== String(code) || elements.stockPreview.hidden) return; const cacheMs = payload.meta?.realtime ? LIVE_REFRESH_DEFAULT_MS : STOCK_PREVIEW_CACHE_MS; stockPreviewCache.set(cacheKey, { payload, expiresAt: Date.now() + cacheMs }); while (stockPreviewCache.size > 48) stockPreviewCache.delete(stockPreviewCache.keys().next().value); renderStockPreview(payload); } catch (error) { if (error.name === "AbortError" || state.stockPreviewCode !== String(code)) return; renderStockPreviewError(error.message || "行情预览加载失败"); } } async function showEntityPreview(item, trigger) { const type = String(item?.type || "").trim().toLowerCase(); const id = String(item?.id || item?.code || "").trim().toUpperCase(); if (type !== "theme" || !id) return; clearTimeout(stockPreviewOpenTimer); cancelStockPreviewClose(); stockPreviewAnchor = trigger; state.stockPreviewCode = id; state.stockPreviewType = type; state.stockPreviewItem = { ...item, id, code: item.code || id, type, type_label: item.type_label || "题材" }; state.stockPreviewFallback = { code: item.code || id, name: item.name || "--", sector: item.type_label || "题材", price: item.value, change: item.change, }; state.stockPreviewPayload = null; state.stockPreviewChart = "daily"; renderStockPreviewLoading(); elements.stockPreview.hidden = false; const mobile = window.innerWidth <= 720; elements.stockPreviewBackdrop.hidden = !mobile; document.body.classList.toggle("stock-preview-open", mobile); requestAnimationFrame(repositionStockPreview); const cacheKey = `${type}:${id}:latest`; const cached = stockPreviewCache.get(cacheKey); if (cached && cached.expiresAt > Date.now()) { renderStockPreview(cached.payload); return; } if (cached) stockPreviewCache.delete(cacheKey); stockPreviewAbortController?.abort(); stockPreviewAbortController = new AbortController(); try { const params = new URLSearchParams({ type, id, trade_date: todayString() }); const detail = await apiRequest( `/api/search/detail?${params}`, "GET", null, { signal: stockPreviewAbortController.signal }, ); if (state.stockPreviewType !== type || state.stockPreviewCode !== id || elements.stockPreview.hidden) return; const entity = detail.entity || {}; const payload = { stock: { code: entity.code || id, name: entity.name || item.name || "--", industry: entity.type_label || item.type_label || "题材", price: entity.value, change: entity.change, }, prices: detail.series || [], intraday: [], meta: { trade_date: detail.meta?.trade_date || "", realtime: Boolean(detail.meta?.realtime), intraday_status: "idle", intraday_notice: "", }, }; stockPreviewCache.set(cacheKey, { payload, expiresAt: Date.now() + STOCK_PREVIEW_CACHE_MS }); while (stockPreviewCache.size > 48) stockPreviewCache.delete(stockPreviewCache.keys().next().value); renderStockPreview(payload); } catch (error) { if (error.name === "AbortError" || state.stockPreviewType !== type || state.stockPreviewCode !== id) return; renderStockPreviewError(error.message || "题材行情预览加载失败"); } } function renderStockPreviewLoading() { const fallback = state.stockPreviewFallback || {}; selectStockPreviewChart("daily"); setText("stockPreviewCode", state.stockPreviewCode || "--"); setText("stockPreviewName", fallback.name || "正在加载"); setText("stockPreviewSector", fallback.sector || "--"); setText("stockPreviewPrice", "--"); setText("stockPreviewChange", "--"); document.querySelector("#stockPreviewChange").className = ""; setText("stockPreviewDate", "最新行情"); setText("stockPreviewSource", "正在读取行情"); setText("stockPreviewSummary", "等待行情数据"); document.querySelector("#stockPreviewLoading").hidden = false; clearStockPreviewChart(""); } function renderStockPreview(payload) { state.stockPreviewPayload = payload; const fallback = state.stockPreviewFallback || {}; const stock = payload.stock || {}; const price = stock.price; const change = stock.change; setText("stockPreviewCode", stock.code || state.stockPreviewCode); setText("stockPreviewName", stock.name && stock.name !== "--" ? stock.name : fallback.name || "--"); setText("stockPreviewSector", stock.industry && stock.industry !== "其他" ? stock.industry : fallback.sector || "其他"); setText("stockPreviewPrice", meaningfulNumber(price) ? formatNumber(price, 2) : "--"); setText("stockPreviewChange", meaningfulNumber(change) ? `${signed(change)}%` : "--"); document.querySelector("#stockPreviewChange").className = changeClass(change); document.querySelector("#stockPreviewLoading").hidden = true; selectStockPreviewChart("daily"); requestAnimationFrame(repositionStockPreview); } function renderStockPreviewError(message) { document.querySelector("#stockPreviewLoading").hidden = true; setText("stockPreviewSource", "行情加载失败"); setText("stockPreviewSummary", message); clearStockPreviewChart("加载失败"); } function selectStockPreviewChart(chart) { state.stockPreviewChart = chart === "daily" ? "daily" : "intraday"; document.querySelectorAll("[data-preview-chart]").forEach((button) => { const active = button.dataset.previewChart === state.stockPreviewChart; button.classList.toggle("active", active); button.setAttribute("aria-selected", String(active)); }); const payload = state.stockPreviewPayload; if (!payload) return; if (state.stockPreviewChart === "intraday") { if (state.stockPreviewType !== "stock" && payload.meta?.intraday_status === "idle") { payload.meta.intraday_status = "loading"; setText("stockPreviewDate", "正在加载分时"); setText("stockPreviewSource", "正在读取最新分时"); setText("stockPreviewSummary", "等待分时行情数据"); clearStockPreviewChart(""); loadEntityPreviewIntraday(); return; } if (state.stockPreviewType !== "stock" && payload.meta?.intraday_status === "loading") return; setText("stockPreviewDate", payload.meta?.intraday_trade_date || payload.meta?.trade_date || "最新行情"); setText( "stockPreviewSource", (payload.intraday || []).length ? "最新分时 · 1分钟" : "分时暂不可用", ); if ((payload.intraday || []).length) { drawIntradayPreviewChart( payload.intraday, payload.prices || [], payload.meta?.intraday_previous_close, ); } else { clearStockPreviewChart("分时数据不可用"); setText("stockPreviewSummary", payload.meta?.intraday_notice || "该交易日暂无分时数据。"); } } else if ((payload.prices || []).length) { setText("stockPreviewDate", payload.meta?.trade_date || "最新行情"); setText("stockPreviewSource", `日 K 行情 · ${payload.prices.length} 个交易日`); drawDailyPreviewChart(payload.prices); } else { setText("stockPreviewDate", payload.meta?.trade_date || "最新行情"); setText("stockPreviewSource", "日 K 行情暂不可用"); clearStockPreviewChart("暂无日K数据"); setText("stockPreviewSummary", "该股票暂无可用的日K数据。"); } } async function loadEntityPreviewIntraday() { const type = state.stockPreviewType; const id = state.stockPreviewCode; const payload = state.stockPreviewPayload; if (type === "stock" || !id || !payload) return; stockPreviewAbortController?.abort(); stockPreviewAbortController = new AbortController(); try { const params = new URLSearchParams({ type, id }); const intraday = await apiRequest( `/api/chart/intraday?${params}`, "GET", null, { signal: stockPreviewAbortController.signal }, ); if (state.stockPreviewType !== type || state.stockPreviewCode !== id || elements.stockPreview.hidden) return; payload.intraday = intraday.points || []; payload.meta.intraday_status = payload.intraday.length ? "available" : "empty"; payload.meta.intraday_trade_date = intraday.meta?.trade_date || ""; payload.meta.intraday_previous_close = intraday.meta?.previous_close || 0; payload.meta.intraday_notice = payload.intraday.length ? "" : "该题材暂无可用分时数据。"; if (state.stockPreviewChart === "intraday") selectStockPreviewChart("intraday"); } catch (error) { if (error.name === "AbortError" || state.stockPreviewType !== type || state.stockPreviewCode !== id) return; payload.meta.intraday_status = "unavailable"; payload.meta.intraday_notice = error.message || "题材分时行情暂不可用。"; if (state.stockPreviewChart === "intraday") selectStockPreviewChart("intraday"); } } function closeStockPreview() { clearTimeout(stockPreviewOpenTimer); clearTimeout(stockPreviewCloseTimer); stockPreviewAbortController?.abort(); stockPreviewAbortController = null; elements.stockPreview.hidden = true; elements.stockPreviewBackdrop.hidden = true; document.body.classList.remove("stock-preview-open"); state.stockPreviewPayload = null; state.stockPreviewCode = ""; state.stockPreviewType = "stock"; state.stockPreviewItem = null; } function openStockDetailFromPreview() { const code = state.stockPreviewCode; const fallback = state.stockPreviewFallback; const type = state.stockPreviewType; const item = state.stockPreviewItem; if (!code) return; closeStockPreview(); if (type === "stock") openStock(code, fallback); else if (item) openEntityDetail(item); } function repositionStockPreview() { if (elements.stockPreview.hidden || window.innerWidth <= 720 || !stockPreviewAnchor?.isConnected) return; const anchor = stockPreviewAnchor.getBoundingClientRect(); const preview = elements.stockPreview.getBoundingClientRect(); const gap = 12; let left = anchor.right + gap; if (left + preview.width > window.innerWidth - 8) left = anchor.left - preview.width - gap; left = clamp(left, 8, Math.max(8, window.innerWidth - preview.width - 8)); const top = clamp(anchor.top - 48, 64, Math.max(64, window.innerHeight - preview.height - 8)); elements.stockPreview.style.left = `${Math.round(left)}px`; elements.stockPreview.style.top = `${Math.round(top)}px`; } async function loadAlerts(openDialog = false) { try { const query = new URLSearchParams({ status: state.alertFilter, as_of: todayString() }); const payload = await apiRequest(`/api/alerts?${query}`); state.alerts = payload.items || []; state.alertUnreadCount = number(payload.unread_count); renderAlerts(); if (openDialog) openModalDialog(elements.alertsDialog); } catch (error) { if (openDialog) showToast(error.message || "提醒加载失败"); } } function openAlerts() { toggleHeaderCommandMenu(false); toggleAccountDropdown(false); document.querySelector("#alertDate").value ||= todayString(); openModalDialog(elements.alertsDialog); loadAlerts(); } function openStockReminder() { const stock = state.activeStock || {}; document.querySelector("#alertTitle").value = `${stock.name || stock.code || "个股"}观察提醒`; document.querySelector("#alertCode").value = stock.code || ""; document.querySelector("#alertDate").value = todayString(); if (elements.stockDialog.open) elements.stockDialog.close(); openAlerts(); document.querySelector("#alertContent").focus(); } function selectAlertFilter(filter) { state.alertFilter = filter === "unread" ? "unread" : "all"; document.querySelectorAll("[data-alert-filter]").forEach((button) => { button.classList.toggle("active", button.dataset.alertFilter === state.alertFilter); }); loadAlerts(); } async function saveAlert(event) { event.preventDefault(); const button = event.currentTarget.querySelector("button[type='submit']"); button.disabled = true; try { const payload = await apiRequest("/api/alerts", "POST", { title: document.querySelector("#alertTitle").value.trim(), remind_date: document.querySelector("#alertDate").value, code: document.querySelector("#alertCode").value.trim(), content: document.querySelector("#alertContent").value.trim(), }); event.currentTarget.reset(); document.querySelector("#alertDate").value = todayString(); state.alertFilter = "all"; state.alerts = payload.items || []; state.alertUnreadCount = number(payload.unread_count); renderAlerts(); showToast("提醒已保存"); } catch (error) { showToast(error.message || "提醒保存失败"); } finally { button.disabled = false; } } async function markAllAlertsRead() { try { await apiRequest("/api/alerts/read-all", "POST", { as_of: todayString() }); await loadAlerts(); } catch (error) { showToast(error.message || "提醒状态更新失败"); } } async function handleAlertAction(event) { const button = event.target.closest("[data-alert-action]"); if (!button) return; const id = number(button.dataset.alertId); if (!id) return; try { if (button.dataset.alertAction === "delete") { await apiRequest(`/api/alerts/${id}`, "DELETE"); } else { await apiRequest(`/api/alerts/${id}/read`, "POST", {}); } await loadAlerts(); } catch (error) { showToast(error.message || "提醒操作失败"); } } function renderAlerts() { const badge = document.querySelector("#alertBadge"); badge.hidden = state.alertUnreadCount <= 0; badge.textContent = state.alertUnreadCount > 99 ? "99+" : String(state.alertUnreadCount); document.querySelector("#alertButton").classList.toggle("has-alerts", state.alertUnreadCount > 0); setText("alertListCount", `${state.alerts.length} 条`); document.querySelectorAll("[data-alert-filter]").forEach((button) => { button.classList.toggle("active", button.dataset.alertFilter === state.alertFilter); }); document.querySelector("#markAllAlertsRead").disabled = state.alertUnreadCount <= 0; const container = document.querySelector("#alertList"); container.innerHTML = state.alerts.map((item) => { const upcoming = !item.due; const kindLabel = item.kind === "manual" ? "自定提醒" : item.kind === "strategy_t5" ? "跟踪完成" : "策略反馈"; return `
    ${escapeHtml(kindLabel)}
    ${escapeHtml(item.title)} ${item.content ? `

    ${escapeHtml(item.content)}

    ` : ""} ${item.code ? `` : ""}
    ${!item.is_read && !upcoming ? `` : ""}
    `; }).join("") || emptyStateHtml("暂无提醒"); bindStockRows(container); refreshIcons(); } async function openReviewAssistant() { toggleHeaderCommandMenu(false); toggleAccountDropdown(false); openModalDialog(elements.assistantDialog); updateAssistantControls(); if (!hasMemberAccess()) { document.querySelector("#closeAssistantDialog").focus(); return; } try { const payload = await apiRequest("/api/assistant/messages"); state.assistantMessages = payload.items || []; renderAssistantMessages(); } catch (error) { showToast(error.message || "对话记录加载失败"); } document.querySelector("#assistantQuestion").focus(); } function useAssistantPrompt(prompt) { const input = document.querySelector("#assistantQuestion"); input.value = prompt; input.focus(); } async function sendAssistantQuestion(event) { event.preventDefault(); if (state.assistantLoading) return; const input = document.querySelector("#assistantQuestion"); const question = input.value.trim(); if (!question) return; input.value = ""; state.assistantMessages.push({ role: "user", content: question, context_date: elements.tradeDate.value.replaceAll("-", "") }); state.assistantMessages.push({ role: "assistant", content: "", streaming: true, context_date: elements.tradeDate.value.replaceAll("-", "") }); state.assistantLoading = true; state.assistantController = new AbortController(); updateAssistantControls(); renderAssistantMessages(); try { await streamAssistantRequest(question, state.assistantController.signal, (chunk) => { const message = state.assistantMessages.at(-1); if (message?.role === "assistant") message.content += chunk; scheduleAssistantRender(); }); const message = state.assistantMessages.at(-1); if (message) message.streaming = false; setStatus("复盘助手回答完成"); } catch (error) { const message = state.assistantMessages.at(-1); if (message?.role === "assistant") { message.streaming = false; message.error = true; if (!message.content) message.content = error.name === "AbortError" ? "已停止生成。" : error.message || "回答失败,请稍后重试。"; } if (error.name !== "AbortError") showToast(error.message || "复盘助手回答失败"); } finally { state.assistantLoading = false; state.assistantController = null; updateAssistantControls(); renderAssistantMessages(); input.focus(); } } async function streamAssistantRequest(question, signal, onDelta) { await window.XiaobaiAPI.streamNdjson("/api/assistant/chat", { method: "POST", body: { question, trade_date: elements.tradeDate.value }, signal, errorMessage: "复盘助手暂不可用", onEvent: (event) => { if (event.type === "delta") onDelta(String(event.content || "")); }, }); } function stopAssistantResponse() { state.assistantController?.abort(); } async function clearAssistantConversation() { if (state.assistantLoading || !state.assistantMessages.length) return; if (!window.confirm("确定清空复盘助手的对话记录吗?")) return; try { await apiRequest("/api/assistant/messages", "DELETE"); state.assistantMessages = []; renderAssistantMessages(); } catch (error) { showToast(error.message || "对话记录清空失败"); } } function scheduleAssistantRender() { if (assistantRenderFrame) return; assistantRenderFrame = requestAnimationFrame(() => { assistantRenderFrame = 0; renderAssistantMessages(); }); } function renderAssistantMessages() { const container = document.querySelector("#assistantMessages"); container.innerHTML = state.assistantMessages.map((message) => `
    ${message.role === "user" ? "我" : "复盘助手"}${message.context_date ? `` : ""}
    ${message.role === "assistant" ? (message.content ? formatMentorAnswer(message.content) : '正在整理复盘数据') : escapeHtml(message.content)}
    ${message.streaming ? '' : ""}
    `).join("") || emptyStateHtml("可以从市场、策略或自己的交易记录开始复盘"); updateAssistantControls(); requestAnimationFrame(() => { container.scrollTop = container.scrollHeight; }); } function updateAssistantControls() { const unlocked = hasMemberAccess(); elements.assistantDialog.classList.toggle("member-locked", !unlocked); document.querySelector("#assistantMemberGate").hidden = unlocked; document.querySelector("#assistantMemberContent").setAttribute("aria-disabled", String(!unlocked)); document.querySelector("#assistantQuestion").disabled = !unlocked || state.assistantLoading; document.querySelector("#sendAssistant").disabled = !unlocked || state.assistantLoading; document.querySelector("#stopAssistant").hidden = !unlocked || !state.assistantLoading; document.querySelector("#clearAssistantMessages").disabled = !unlocked || state.assistantLoading || !state.assistantMessages.length; document.querySelectorAll("[data-assistant-prompt]").forEach((button) => { button.disabled = !unlocked || state.assistantLoading; }); } function openGlobalSearch() { if (!state.user) return; toggleHeaderCommandMenu(false); openModalDialog(elements.globalSearchDialog); requestAnimationFrame(() => { elements.globalSearchInput.focus(); elements.globalSearchInput.select(); }); } function handleGlobalSearchShortcut(event) { if (!event.ctrlKey || event.altKey || event.shiftKey || event.key.toLowerCase() !== "k") return; if (!state.user) return; if (event.defaultPrevented) { showToast("Ctrl+K 已被其他功能占用,请点击顶部搜索按钮"); return; } event.preventDefault(); openGlobalSearch(); } function closeGlobalSearch() { clearTimeout(globalSearchTimer); if (elements.globalSearchDialog.open) elements.globalSearchDialog.close(); } function scheduleGlobalSearch() { clearTimeout(globalSearchTimer); const query = elements.globalSearchInput.value.trim(); state.globalSearchActiveIndex = -1; if (!query) { state.globalSearchResults = []; renderGlobalSearchEmpty("输入名称或代码开始搜索", "使用方向键选择,回车打开详情", "corner-down-left"); return; } elements.globalSearchResults.innerHTML = '
    正在搜索
    '; 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) { state.entityDetailItem = item; state.entityDetailPayload = null; state.entityDetailIntraday = null; state.entityDetailChartMode = "daily"; const requestSequence = ++state.entityDetailRequestSequence; syncDetailChartButtons("entity", "daily"); setText("entityDetailCode", item.code || item.id || "--"); setText("entityDetailName", item.name || "--"); setText("entityDetailValue", "--"); setText("entityDetailChange", "--"); setText("entityDetailType", item.type_label || "--"); setText("entityDetailDate", "正在加载行情"); document.querySelector("#entityDetailChange").className = ""; renderEmptyState("entityDetailMetrics", "正在加载交易数据"); openModalDialog(elements.entityDetailDialog); clearEntityDetailChart("正在加载日 K 数据"); try { const params = new URLSearchParams({ type: item.type, id: item.id, trade_date: elements.tradeDate.value }); const payload = await apiRequest(`/api/search/detail?${params}`); if (requestSequence !== state.entityDetailRequestSequence) return; state.entityDetailPayload = payload; const entity = payload.entity || {}; setText("entityDetailCode", entity.code || item.code || "--"); setText("entityDetailName", entity.name || item.name || "--"); setText("entityDetailValue", meaningfulNumber(entity.value) && number(entity.value) !== 0 ? formatNumber(entity.value, 2) : "--"); setText("entityDetailChange", `${signed(entity.change)}%`); setText("entityDetailType", entity.type_label || item.type_label || "--"); document.querySelector("#entityDetailChange").className = changeClass(entity.change); renderEntityDetailMetrics(payload.metrics || []); if (state.entityDetailChartMode === "daily") { setText("entityDetailDate", `${payload.meta?.realtime ? "实时" : "收盘"} · ${payload.meta?.trade_date || "--"}`); requestAnimationFrame(() => drawEntityDetailChart(payload.series || [])); } } catch (error) { if (requestSequence !== state.entityDetailRequestSequence) return; setText("entityDetailDate", "行情加载失败"); renderEmptyState("entityDetailMetrics", error.message || "交易数据加载失败"); if (state.entityDetailChartMode === "daily") clearEntityDetailChart(error.message || "行情加载失败"); showToast(error.message || "详情加载失败"); } } async function selectEntityDetailChart(mode) { const selected = mode === "intraday" ? "intraday" : "daily"; state.entityDetailChartMode = selected; syncDetailChartButtons("entity", selected); if (selected === "daily") { const payload = state.entityDetailPayload; if (payload) { setText("entityDetailDate", `${payload.meta?.realtime ? "实时" : "收盘"} · ${payload.meta?.trade_date || "--"}`); requestAnimationFrame(() => drawEntityDetailChart(payload.series || [])); } else clearEntityDetailChart("正在加载日 K 数据"); return; } if (state.entityDetailIntraday) { renderEntityIntraday(state.entityDetailIntraday); return; } const item = state.entityDetailItem; if (!item) return; const requestSequence = state.entityDetailRequestSequence; setText("entityDetailDate", "正在加载分时"); clearEntityDetailChart("正在加载分时数据"); try { const params = new URLSearchParams({ type: item.type, id: item.id }); const payload = await apiRequest(`/api/chart/intraday?${params}`); if (requestSequence !== state.entityDetailRequestSequence) return; state.entityDetailIntraday = payload; if (state.entityDetailChartMode === "intraday") renderEntityIntraday(payload); } catch (error) { if (requestSequence !== state.entityDetailRequestSequence || state.entityDetailChartMode !== "intraday") return; setText("entityDetailDate", "分时暂不可用"); clearEntityDetailChart(error.message || "分时行情暂不可用"); } } function renderEntityIntraday(payload) { const points = payload.points || []; if (!points.length) { setText("entityDetailDate", "分时暂不可用"); clearEntityDetailChart("分时行情暂不可用"); return; } setText("entityDetailDate", `分时 · ${payload.meta?.trade_date || "--"}`); requestAnimationFrame(() => { if (state.entityDetailChartMode !== "intraday") return; drawIntradayCanvas(elements.entityDetailChart, points, [], payload.meta?.previous_close); }); } function syncDetailChartButtons(scope, mode) { const selector = scope === "stock" ? "[data-stock-detail-chart]" : "[data-entity-detail-chart]"; const datasetKey = scope === "stock" ? "stockDetailChart" : "entityDetailChart"; document.querySelectorAll(selector).forEach((button) => { const active = button.dataset[datasetKey] === mode; button.classList.toggle("active", active); button.setAttribute("aria-pressed", String(active)); }); } function renderEntityDetailMetrics(metrics) { const container = document.querySelector("#entityDetailMetrics"); if (!metrics.length) { renderEmptyState(container, "暂无交易数据"); return; } container.innerHTML = metrics.map((metric) => { const value = typeof metric.value === "number" ? formatNumber(metric.value, Number.isInteger(metric.value) ? 0 : 2) : String(metric.value ?? "--"); const tone = metric.tone === "change" ? changeClass(metric.value) : ""; return `
    ${escapeHtml(metric.label)}
    ${escapeHtml(value)}${escapeHtml(metric.unit || "")}
    `; }).join(""); } function drawEntityDetailChart(series, canvas = elements.entityDetailChart) { const candles = (series || []).filter((item) => number(item.close) > 0).map((item) => { const close = number(item.close); const open = number(item.open) || close; const high = Math.max(number(item.high) || close, open, close); const low = Math.min(number(item.low) || close, open, close); return { ...item, open, high, low, close }; }); if (!candles.length) { clearEntityDetailChart("暂无日 K 数据", canvas); return; } const rect = canvas.getBoundingClientRect(); const ratio = window.devicePixelRatio || 1; const width = Math.max(320, rect.width); const height = Math.max(220, rect.height); canvas.width = Math.round(width * ratio); canvas.height = Math.round(height * ratio); const context = canvas.getContext("2d"); const palette = currentChartPalette(); context.setTransform(ratio, 0, 0, ratio, 0, 0); context.clearRect(0, 0, width, height); context.fillStyle = palette.background; context.fillRect(0, 0, width, height); const left = 48; const right = 12; const top = 14; const bottom = 22; const volumeHeight = 54; const gap = 12; const priceBottom = height - bottom - volumeHeight - gap; const plotWidth = width - left - right; const maximum = Math.max(...candles.map((item) => item.high)); const minimum = Math.min(...candles.map((item) => item.low)); const range = Math.max(maximum - minimum, maximum * 0.01, 0.01); const maxVolume = Math.max(...candles.map((item) => number(item.volume)), 1); const priceY = (value) => top + (maximum - value) / range * (priceBottom - top); const step = plotWidth / candles.length; const candleWidth = clamp(step * 0.62, 2, 8); context.strokeStyle = palette.grid; context.fillStyle = palette.axis; context.font = "11px Microsoft YaHei"; context.textAlign = "right"; for (let line = 0; line <= 4; line += 1) { const lineY = top + (priceBottom - top) * line / 4; context.beginPath(); context.moveTo(left, lineY); context.lineTo(width - right, lineY); context.stroke(); context.fillText((maximum - range * line / 4).toFixed(2), left - 6, lineY + 4); } candles.forEach((item, index) => { const x = left + step * index + step / 2; const color = drawCandlestick(context, x, item, priceY, candleWidth, palette); const volumeBarHeight = number(item.volume) / maxVolume * volumeHeight; context.fillStyle = color; context.globalAlpha = 0.72; context.fillRect(x - candleWidth / 2, height - bottom - volumeBarHeight, candleWidth, volumeBarHeight); context.globalAlpha = 1; }); context.textAlign = "center"; context.fillStyle = palette.axis; [0, Math.floor((candles.length - 1) / 2), candles.length - 1].forEach((index) => { const x = left + step * index + step / 2; context.fillText(String(candles[index].trade_date || "").slice(5), x, height - 5); }); } function clearEntityDetailChart(message, canvas = elements.entityDetailChart) { const rect = canvas.getBoundingClientRect(); const width = Math.max(320, Math.round(rect.width || 680)); const height = Math.max(220, Math.round(rect.height || 300)); canvas.width = width; canvas.height = height; const context = canvas.getContext("2d"); const palette = currentChartPalette(); context.fillStyle = palette.background; context.fillRect(0, 0, width, height); context.fillStyle = palette.axis; context.font = "13px Microsoft YaHei"; context.textAlign = "center"; context.fillText(message, width / 2, height / 2); } function meaningfulNumber(value) { return value !== null && value !== undefined && value !== "" && Number.isFinite(Number(value)); } async function openStock(code, fallback = null) { closeStockPreview(); const pools = [state.dashboard?.limits || [], state.dashboard?.broken || [], state.dashboard?.down_limits || []]; const row = pools.flat().find((item) => String(item.code) === String(code)) || fallback || { code, name: "--", sector: "其他" }; state.activeStock = row; state.stockDetail = null; state.stockDetailIntraday = null; state.stockDetailChartMode = "daily"; const requestSequence = ++state.stockDetailRequestSequence; syncDetailChartButtons("stock", "daily"); setText("detailCode", row.code); setText("detailName", row.name); setText("detailPrice", formatNumber(row.price, 2)); setText("detailChange", `${signed(row.change)}%`); const changeElement = document.querySelector("#detailChange"); changeElement.className = changeClass(row.change); setText("detailStreak", row.status === "涨停" ? streakLabel(row.streak) : row.status || "--"); setText("detailReason", row.reason || "--"); setText("detailSector", row.sector || "其他"); setText("detailFirst", row.first_time || "--"); setText("detailLast", row.last_time || "--"); setText("detailOpen", `${number(row.open_times)} 次`); setText("detailTurnover", `${formatNumber(row.turnover_rate, 2)}%`); setText("detailAmount", `${formatNumber(row.amount_billion, 2)} 亿`); setText("detailSeal", `${formatNumber(row.seal_amount_million, 0)} 万`); setText("chartSource", "正在加载行情"); setText("flowNet", "--"); setText("flowLarge", "--"); setText("flowMedium", "--"); setText("flowSmall", "--"); document.querySelector("#reasonInput").value = row.reason || ""; document.querySelector("#stockNoteContent").value = ""; document.querySelector("#stockNotePlan").value = ""; renderEmptyState("stockNotes", "正在加载笔记"); updateWatchButton(); openModalDialog(elements.stockDialog); clearPriceChart("正在加载日 K 数据"); try { const query = new URLSearchParams({ trade_date: elements.tradeDate.value }); const payload = await apiRequest(`/api/stock/${encodeURIComponent(code)}?${query}`); if (requestSequence !== state.stockDetailRequestSequence) return; state.stockDetail = payload; const stock = payload.stock || {}; state.activeStock = { ...row, name: stock.name || row.name, sector: stock.industry || row.sector }; setText("detailName", stock.name || row.name); setText("detailPrice", formatNumber(stock.price || row.price, 2)); setText("detailChange", `${signed(stock.change ?? row.change)}%`); renderMoneyflow(payload.moneyflow || {}); renderStockNotes(payload.notes || []); updateWatchButton(); if (state.stockDetailChartMode === "daily") { setText("chartSource", `日 K 行情 · ${payload.prices.length} 个交易日`); requestAnimationFrame(() => drawPriceChart(payload.prices || [])); } } catch (error) { if (requestSequence !== state.stockDetailRequestSequence) return; setText("chartSource", "行情加载失败"); if (state.stockDetailChartMode === "daily") clearPriceChart(error.message || "行情加载失败"); showToast(error.message || "个股详情加载失败"); } } async function selectStockDetailChart(mode) { const selected = mode === "intraday" ? "intraday" : "daily"; state.stockDetailChartMode = selected; syncDetailChartButtons("stock", selected); if (selected === "daily") { const prices = state.stockDetail?.prices || []; setText("chartSource", prices.length ? `日 K 行情 · ${prices.length} 个交易日` : "正在加载行情"); if (prices.length) requestAnimationFrame(() => drawPriceChart(prices)); else clearPriceChart("正在加载日 K 数据"); return; } if (state.stockDetailIntraday) { renderStockDetailIntraday(state.stockDetailIntraday); return; } const code = String(state.activeStock?.code || ""); if (!/^\d{6}$/.test(code)) return; const requestSequence = state.stockDetailRequestSequence; setText("chartSource", "正在加载分时"); clearPriceChart("正在加载分时数据"); try { const params = new URLSearchParams({ type: "stock", id: code }); const payload = await apiRequest(`/api/chart/intraday?${params}`); if (requestSequence !== state.stockDetailRequestSequence) return; state.stockDetailIntraday = payload; if (state.stockDetailChartMode === "intraday") renderStockDetailIntraday(payload); } catch (error) { if (requestSequence !== state.stockDetailRequestSequence || state.stockDetailChartMode !== "intraday") return; setText("chartSource", "分时暂不可用"); clearPriceChart(error.message || "分时行情暂不可用"); } } function renderStockDetailIntraday(payload) { const points = payload.points || []; if (!points.length) { setText("chartSource", "分时暂不可用"); clearPriceChart("分时行情暂不可用"); return; } setText("chartSource", `分时 · ${payload.meta?.trade_date || "--"}`); requestAnimationFrame(() => { if (state.stockDetailChartMode !== "intraday") return; drawIntradayCanvas(elements.priceChart, points, [], payload.meta?.previous_close); }); } function openActiveStockInHeaven() { const code = state.activeStock?.code; if (!/^\d{6}$/.test(String(code || ""))) return; elements.stockDialog.close(); state.heavenPanel = "trend"; state.heavenManualData = null; const input = document.querySelector("#heavenStockInput"); input.value = code; openView("heavenView"); selectHeavenPanel("trend", true); } function hasMemberAccess() { return state.user?.role === "admin" || Boolean(state.user?.membership?.active); } function updateAccountIdentityBadges(membership = {}) { const isAdmin = state.user?.role === "admin" || Boolean(membership.is_admin); const subscribed = Boolean(membership.subscribed); document.querySelector("#accountAdminBadge").hidden = !isAdmin; const vipBadge = document.querySelector("#accountVipBadge"); vipBadge.hidden = false; vipBadge.classList.toggle("is-nonmember", !subscribed); setText("accountVipLabel", subscribed ? "会员" : "非会员"); vipBadge.title = subscribed ? "查看会员状态" : "查看会员权益"; } function applyMembershipAccess() { const unlocked = hasMemberAccess(); document.querySelectorAll(".member-feature-view").forEach((view) => { view.classList.toggle("member-locked", !unlocked); const gate = view.querySelector(".member-gate"); if (gate) gate.hidden = unlocked; view.querySelectorAll("button, input, textarea, select").forEach((control) => { if (control.closest(".member-gate") || control.hasAttribute("data-member-navigation")) return; control.disabled = !unlocked; }); }); const assistantButton = document.querySelector("#assistantButton"); assistantButton.classList.toggle("member-locked-control", !unlocked); assistantButton.title = unlocked ? "复盘助手" : "复盘助手(会员可用)"; updateAssistantControls(); } function openView(viewId, updateHash = true) { if (!applicationShell.page(viewId) || !pageModules.has(viewId)) return; const previousView = state.activeView; pageModules.beforeMount(viewId, previousView); if (!applicationShell.mount(viewId, { updateUrl: updateHash })) return; pageModules.afterMount(viewId, previousView); } function initializeAutoTableSorting() { markAutoSortableHeaders(document); document.addEventListener("click", (event) => { const header = event.target.closest?.("th[data-auto-sort]"); if (!header || header.closest("#limitTable")) return; const table = header.closest("table"); const body = table?.tBodies?.[0]; if (!body || body.rows.length < 2) return; const direction = header.classList.contains("sort-asc") ? "desc" : "asc"; table.querySelectorAll("th.sort-asc, th.sort-desc").forEach((item) => { item.classList.remove("sort-asc", "sort-desc", "sorted"); item.removeAttribute("aria-sort"); const arrow = item.querySelector(".arr"); if (arrow) arrow.textContent = "↕"; }); header.classList.add(`sort-${direction}`, "sorted"); header.setAttribute("aria-sort", direction === "asc" ? "ascending" : "descending"); const activeArrow = header.querySelector(".arr"); if (activeArrow) activeArrow.textContent = direction === "asc" ? "▲" : "▼"; const columnIndex = header.cellIndex; const rows = [...body.rows].map((row, index) => ({ row, index })); rows.sort((left, right) => { const leftValue = autoSortValue(left.row.cells[columnIndex]); const rightValue = autoSortValue(right.row.cells[columnIndex]); let result; if (leftValue.kind === "number" && rightValue.kind === "number") result = leftValue.value - rightValue.value; else result = String(leftValue.value).localeCompare(String(rightValue.value), "zh-CN", { numeric: true, sensitivity: "base" }); if (result === 0) result = left.index - right.index; return direction === "asc" ? result : -result; }); rows.forEach(({ row }) => body.appendChild(row)); const firstHeader = [...header.parentElement.cells][0]?.textContent.trim(); if (["#", "排名"].includes(firstHeader)) { [...body.rows].forEach((row, index) => { if (row.cells[0]) row.cells[0].textContent = String(index + 1); }); } }); } function markAutoSortableHeaders(root) { root.querySelectorAll?.(".data-table:not(#limitTable) thead th").forEach((header) => { if (header.closest("#brokenTable, #downTable, #yesterdayTable, #rotationTable")) return; if (number(header.colSpan) > 1) return; const label = header.textContent.trim(); if (!label || ["#", "操作"].includes(label)) return; header.dataset.autoSort = "true"; header.classList.add("sortable"); if (!header.querySelector(".arr")) header.insertAdjacentHTML("beforeend", ''); 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", "sorted"); const active = header.dataset.sort === state.sortKey; if (active) header.classList.add(state.sortDirection === "asc" ? "sort-asc" : "sort-desc", "sorted"); const arrow = header.querySelector(".arr"); if (arrow) arrow.textContent = active ? (state.sortDirection === "asc" ? "▲" : "▼") : "↕"; }); } function shiftDate(delta) { const current = parseLocalDate(elements.tradeDate.value); current.setDate(current.getDate() + delta); const next = localDateString(current); if (next > todayString()) return; elements.tradeDate.value = next; state.heavenManualData = null; document.querySelector("#qiObservationDate").value = next; loadDashboard(); } function updateDateButtons() { document.querySelector("#nextDate").disabled = elements.tradeDate.value >= todayString(); } function selectAccountPanel(panel) { const selected = ["profile", "membership", "password"].includes(panel) ? panel : "profile"; const titles = { profile: "个人资料", membership: "会员状态", password: "修改密码" }; setText("accountDialogTitle", titles[selected]); document.querySelectorAll("[data-account-panel-content]").forEach((section) => { section.hidden = section.dataset.accountPanelContent !== selected; }); document.querySelector("#connectionStatus").hidden = selected !== "membership"; return selected; } async function openSettings(panel = "profile") { selectAccountPanel(panel); toggleAccountDropdown(false); toggleHeaderCommandMenu(false); const status = document.querySelector("#connectionStatus"); status.className = "connection-status"; status.textContent = "正在读取账号状态"; openModalDialog(elements.settingsDialog); try { const payload = await apiRequest("/api/account/status"); const access = payload.llm_access || {}; const membership = access.membership || {}; if (state.user) { state.user.membership = membership; updateAccountIdentityBadges(membership); applyMembershipAccess(); } status.textContent = membership.active ? "账户权益已同步" : "账户信息已同步"; status.classList.toggle("connected", true); setText("membershipBadge", membership.subscribed ? "会员有效" : membership.is_admin ? "管理员权限" : "普通用户"); setText("membershipStateValue", membership.subscribed ? "已开通" : membership.is_admin ? "管理员可用" : "未开通"); setText("membershipRemainingValue", membership.subscribed && membership.expires_at ? `${number(membership.remaining_days)} 天` : membership.is_admin || membership.subscribed ? "长期有效" : "--"); setText("membershipDetail", membership.subscribed ? `${membership.plan || "会员"}${membership.expires_at ? ` · 有效至 ${membershipDateDisplay(membership.expires_at, true)}` : " · 长期有效"}` : membership.is_admin ? "管理员拥有智能功能管理权限,但不会因此显示为已开通会员。" : "开通会员后可使用智能选股、问师、问天、复盘助手等智能功能。"); setText("membershipQuotaHint", `会员默认每日智能分析额度 ${number(access.daily_limit)} 次,由管理员统一设置。`); setText("membershipUsage", membership.active ? `今日已用 ${number(access.used_today)} 次` : "今日智能分析:--"); setText("membershipUsageSummary", membership.active ? `${number(access.used_today)} / ${number(access.daily_limit)}` : "--"); setText("membershipRemainingUsage", membership.is_admin ? "不限" : membership.active ? `${number(access.remaining_calls)} 次` : "--"); const birth = payload.birth_profile || {}; if (birth.birth_datetime) { const [birthDate, birthTime] = String(birth.birth_datetime).split("T"); document.querySelector("#accountBirthDate").value = birthDate || ""; document.querySelector("#accountBirthTime").value = (birthTime || "").slice(0, 5); document.querySelector("#accountBirthGender").value = birth.gender || "unspecified"; } setText("birthProfileStatus", payload.birth_profile_configured ? "已加密保存" : "尚未设置"); document.querySelector("#deleteBirthProfileButton").disabled = !payload.birth_profile_configured; } catch (error) { status.hidden = false; status.textContent = "账户状态暂时无法同步"; showToast(error.message || "账号信息加载失败"); } } async function changeAccountPassword(event) { event.preventDefault(); const form = event.currentTarget; const button = form.querySelector("button[type='submit']"); button.disabled = true; try { await apiRequest("/api/account/password", "POST", { current_password: document.querySelector("#currentPassword").value, new_password: document.querySelector("#newPassword").value, confirm_password: document.querySelector("#confirmPassword").value, }); form.reset(); showToast("密码已更新"); } catch (error) { showToast(error.message || "密码更新失败"); } finally { button.disabled = false; } } async function switchAccount() { const button = document.querySelector("#switchAccountMenuButton"); button.disabled = true; toggleAccountDropdown(false); try { await apiRequest("/api/auth/logout", "POST", {}); window.location.reload(); } catch (error) { showToast(error.message || "切换账号失败"); button.disabled = false; } } async function openAdminSettings(refreshOnly = false) { if (state.user?.role !== "admin") return; if (!refreshOnly) openModalDialog(elements.adminDialog); const status = document.querySelector("#adminConnectionStatus"); status.textContent = "正在读取系统状态"; try { const payload = await apiRequest("/api/admin/settings"); const data = payload.data || {}; const ifind = data.ifind || {}; const llm = payload.llm || {}; const membership = payload.membership || {}; status.textContent = `Tushare ${data.configured ? "已配置" : "未配置"} · iFinD ${ifind.configured ? "已配置" : "未配置"} · ${number(data.snapshot_dates)} 个交易日`; status.classList.toggle("connected", Boolean(data.configured)); setText("systemDataStatus", data.background_refresh_enabled ? "后台刷新已启用" : "后台刷新已暂停"); document.querySelector("#systemTokenInput").value = ""; document.querySelector("#systemIfindTokenInput").value = ""; document.querySelector("#systemBackgroundRefresh").checked = Boolean(data.background_refresh_enabled); document.querySelector("#memberDailyLimit").value = number(membership.member_daily_limit) || 50; renderModelPool(llm.models || [], llm.primary_model_id || "", llm.fallback_model_id || ""); renderAdminUsers(payload.users || []); } catch (error) { status.textContent = error.message || "系统配置读取失败"; } } function selectAdminPanel(panel) { const selected = ["market", "models", "members"].includes(panel) ? panel : "market"; document.querySelector("#adminSectionSelect").value = selected; document.querySelectorAll("[data-admin-panel]").forEach((item) => { item.hidden = item.dataset.adminPanel !== selected; }); } function renderModelPool(models, primaryId = "", fallbackId = "") { state.adminModels = models.map((item) => ({ ...item, api_key: item.api_key || "" })); const container = document.querySelector("#modelPoolList"); container.innerHTML = state.adminModels.map((item, index) => `
    ${escapeHtml(item.name || `模型 ${index + 1}`)}${item.configured ? "已保存密钥" : "待配置"}
    未测试
    `).join("") || emptyStateHtml("模型池为空,请先添加模型"); updateModelRoleOptions(primaryId, fallbackId); container.querySelectorAll("[data-test-model]").forEach((button) => button.addEventListener("click", () => testPlatformModel(button.closest("[data-model-id]")))); container.querySelectorAll("[data-delete-model]").forEach((button) => button.addEventListener("click", () => deletePlatformModel(button.closest("[data-model-id]")))); container.querySelectorAll("[data-model-field='name']").forEach((input) => input.addEventListener("input", updateModelRoleLabels)); refreshIcons(); } function collectModelPool() { const saved = new Map(state.adminModels.map((item) => [item.id, item])); return [...document.querySelectorAll("#modelPoolList [data-model-id]")].map((row) => ({ id: row.dataset.modelId, name: row.querySelector("[data-model-field='name']").value.trim(), base_url: row.querySelector("[data-model-field='base_url']").value.trim(), model: row.querySelector("[data-model-field='model']").value.trim(), api_key: row.querySelector("[data-model-field='api_key']").value.trim(), configured: Boolean(saved.get(row.dataset.modelId)?.configured), })); } function updateModelRoleOptions(primaryId = document.querySelector("#platformPrimaryModelSelect").value, fallbackId = document.querySelector("#platformFallbackModelSelect").value) { const models = collectModelPool(); const options = models.map((item) => ``).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("") || emptyStateHtml("暂无注册用户"); container.querySelectorAll(".membership-form").forEach((form) => form.addEventListener("submit", saveMembership)); } async function saveMembership(event) { event.preventDefault(); const form = event.currentTarget; const data = Object.fromEntries(new FormData(form).entries()); const button = form.querySelector("button[type='submit']"); button.disabled = true; try { const payload = await apiRequest("/api/admin/membership", "POST", data); renderAdminUsers(payload.users || []); showToast("会员状态已更新"); } catch (error) { showToast(error.message || "会员状态保存失败"); } finally { button.disabled = false; } } async function saveMarketSettings(event) { event.preventDefault(); const button = event.currentTarget.querySelector("button[type='submit']"); button.disabled = true; try { await apiRequest("/api/admin/settings", "POST", { tushare_token: document.querySelector("#systemTokenInput").value.trim(), ifind_refresh_token: document.querySelector("#systemIfindTokenInput").value.trim(), background_refresh_enabled: document.querySelector("#systemBackgroundRefresh").checked, }); document.querySelector("#systemTokenInput").value = ""; document.querySelector("#systemIfindTokenInput").value = ""; showToast("行情配置已保存"); await openAdminSettings(true); } catch (error) { showToast(error.message || "系统配置保存失败"); } finally { button.disabled = false; } } async function saveModelPool(event) { event.preventDefault(); const button = event.currentTarget.querySelector("button[type='submit']"); button.disabled = true; try { await apiRequest("/api/admin/settings", "POST", { models: collectModelPool(), primary_model_id: document.querySelector("#platformPrimaryModelSelect").value, fallback_model_id: document.querySelector("#platformFallbackModelSelect").value, }); showToast("模型池已保存"); await openAdminSettings(true); } catch (error) { showToast(error.message || "模型池保存失败"); } finally { button.disabled = false; } } async function saveMembershipSettings(event) { event.preventDefault(); const button = event.currentTarget.querySelector("button[type='submit']"); button.disabled = true; try { await apiRequest("/api/admin/settings", "POST", { member_daily_limit: number(document.querySelector("#memberDailyLimit").value), }); showToast("会员调用额度已保存"); await openAdminSettings(true); } catch (error) { showToast(error.message || "会员调用额度保存失败"); } finally { button.disabled = false; } } async function testPlatformModel(row) { if (!row) return; const button = row.querySelector("[data-test-model]"); const status = row.querySelector(".model-test-status"); const profile = collectModelPool().find((item) => item.id === row.dataset.modelId) || {}; button.disabled = true; status.textContent = "连接中"; try { const payload = await apiRequest("/api/admin/settings/test", "POST", { model_id: row.dataset.modelId, profile }); status.textContent = `已连通 · ${number(payload.result.latency_ms)} ms`; status.className = "model-test-status success"; } catch (error) { status.textContent = error.message; status.className = "model-test-status failure"; } finally { button.disabled = false; } } function 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("炸板池", getVisibleBrokenRows(), [ ["股票代码", "code"], ["股票名称", "name"], ["现价涨幅%", "change"], ["距涨停%", "limitGap"], ["价格", "price"], ["所属板块", "sector"], ["首次触板", "first_time"], ["开板次数", "open_times"], ["换手率%", "turnover_rate"], ["成交额亿", "amount_billion"], ]); } function exportDown() { exportRows("跌停板", getVisibleDownRows(), [ ["股票代码", "code"], ["股票名称", "name"], ["跌幅%", "change"], ["价格", "price"], ["所属板块", "sector"], ["换手率%", "turnover_rate"], ["成交额亿", "amount_billion"], ]); } function exportYesterday() { exportRows("昨日涨停", getVisibleYesterdayRows(), [ ["股票代码", "code"], ["股票名称", "name"], ["昨日高度", "prior_streak"], ["今日涨幅%", "current_change"], ["今日结果", "outcome"], ["当前高度", "current_streak"], ["所属板块", "sector"], ]); } function exportLadder() { const rows = (state.dashboard?.ladders || []).flatMap((group) => (group.stocks || []).map((stock) => ({ level: group.label || group.level, ...stock, }))); exportRows("市场天梯", rows, [ ["梯队", "level"], ["股票代码", "code"], ["股票名称", "name"], ["所属板块", "sector"], ["封板时间", "first_time"], ["开板次数", "open_times"], ["封单额万", "seal_amount_million"], ["成交额亿", "amount_billion"], ]); } function exportRotation() { const sectorMap = new Map((state.dashboard?.sectors || []).map((sector) => [sector.name, sector])); const rows = (state.dashboard?.sector_rotation || []).map((row) => ({ ...row, average_change: sectorMap.get(row.name)?.change ?? 0, })); exportRows("板块轮动", rows, [ ["排名", "rank"], ["板块", "name"], ["趋势", "trend"], ["今日涨停", "count"], ["昨日涨停", "previous_count"], ["变化", "delta"], ["强度", "strength"], ["最高板", "max_streak"], ["平均涨幅%", "average_change"], ["领涨股", "leader"], ["涨停股成交额亿", "amount_billion"], ]); } function exportSentimentHistory() { const rows = state.sentimentHistory?.rows || []; if (!rows.length) { showToast("暂无可导出的情绪周期数据"); return; } const exportRowsData = rows.map((row) => ({ ...row, breadth_score: row.components?.breadth?.score, limit_ecology_score: row.components?.limit_ecology?.score, profit_effect_score: row.components?.profit_effect?.score, ladder_structure_score: row.components?.ladder_structure?.score, liquidity_score: row.components?.liquidity?.score, })); exportRows("情绪周期", exportRowsData, [ ["交易日", "trade_date"], ["情绪温度", "score"], ["周期阶段", "phase"], ["方向", "direction"], ["涨停", "limit_up_count"], ["首板", "first_board_count"], ["二板", "second_board_count"], ["三板以上", "three_plus_count"], ["连板高度", "max_height"], ["炸板", "broken_count"], ["跌停", "limit_down_count"], ["昨日涨停", "previous_limit_count"], ["昨日涨停红盘", "previous_positive_count"], ["昨日涨停红盘率%", "previous_positive_rate"], ["市场宽度", "breadth_score"], ["涨停生态", "limit_ecology_score"], ["赚钱效应", "profit_effect_score"], ["连板结构", "ladder_structure_score"], ["成交活跃度", "liquidity_score"], ]); } function exportDragonTiger() { const rows = (state.dragonTiger?.traders || []).flatMap((trader) => ( (trader.operations || []).map((operation) => ({ trader_name: trader.name, identity_type: dragonIdentityLabel(trader.identity_type), ...operation, })) )); exportRows("游资龙虎榜", rows, [ ["游资或席位", "trader_name"], ["身份", "identity_type"], ["股票代码", "code"], ["股票名称", "name"], ["方向", "direction"], ["涨幅%", "change"], ["买入百万元", "buy_million"], ["卖出百万元", "sell_million"], ["净额百万元", "net_buy_million"], ["关联席位", "seat_name"], ["上榜原因", "reason"], ]); } function exportHotMoneyProfiles() { const rows = state.hotMoneyProfiles?.profiles || []; if (!rows.length) { showToast("暂无可导出的游资档案"); return; } downloadCsv( `游资档案-${todayString()}.csv`, ["游资名称", "简介", "关联营业部", "席位数量"], rows.map((profile) => [ profile.name, profile.description, (profile.organizations || []).join(";"), number(profile.organization_count), ]), ); } function commonReviewColumns() { return [["股票代码", "code"], ["股票名称", "name"], ["状态", "status"], ["涨跌幅%", "change"], ["价格", "price"], ["所属板块", "sector"], ["原因", "reason"], ["首次触板", "first_time"], ["最后触板", "last_time"], ["开板次数", "open_times"], ["换手率%", "turnover_rate"], ["成交额亿", "amount_billion"]]; } function exportRows(label, rows, columns) { const headers = columns.map(([header]) => header); const data = rows.map((row) => columns.map(([, key]) => row[key] ?? "")); downloadCsv(`${label}-${state.dashboard.meta.trade_date}.csv`, headers, data); } function downloadCsv(filename, headers, rows) { const lines = [headers, ...rows].map((row) => row.map(csvCell).join(",")); const blob = new Blob(["\ufeff", lines.join("\r\n")], { type: "text/csv;charset=utf-8" }); const url = URL.createObjectURL(blob); const anchor = document.createElement("a"); anchor.href = url; anchor.download = filename; anchor.click(); URL.revokeObjectURL(url); showToast(`已导出 ${rows.length} 条数据`); } function csvCell(value) { let text = String(value ?? ""); if (/^[=+\-@]/.test(text)) text = `'${text}`; return `"${text.replaceAll('"', '""')}"`; } function outcomeClass(outcome) { return { "晋级": "outcome-advance", "炸板": "outcome-broken", "跌停": "outcome-down", "断板": "outcome-open" }[outcome] || "outcome-open"; } function trendClass(trend) { return { "升温": "trend-hot", "降温": "trend-cool", "新进": "trend-new", "持平": "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 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)} 万`; } async function apiRequest(url, method = "GET", body = null, requestOptions = {}) { return window.XiaobaiAPI.request(url, method, body, requestOptions); } function setLoading(loading, text = "正在加载复盘数据", context = "default") { elements.loading.hidden = !loading; elements.loading.dataset.context = loading ? context : "default"; setText("loadingTitle", text); setText( "loadingHint", context === "screener" ? "正在完成因子筛选、候选排序与历史样本回测,这通常需要一点时间" : "请稍候", ); } function setStatus(text) { applicationShell.setStatus(text); } let toastTimer; function showToast(message) { clearTimeout(toastTimer); elements.toast.textContent = message; elements.toast.hidden = false; toastTimer = setTimeout(() => { elements.toast.hidden = true; }, 3600); } function setText(id, value) { const element = document.getElementById(id); if (element) element.textContent = value; } function motionEnabled() { return !window.matchMedia("(prefers-reduced-motion: reduce)").matches; } function refreshIcons() { if (!window.lucide?.createIcons) return; window.lucide.createIcons({ attrs: { "aria-hidden": "true" } }); } function toggleHeaderCommandMenu(force) { applicationShell.toggleHeaderCommandMenu(force); } function 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 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)); }