/* PRESERVATION-SOURCE-BEGIN app.js:1-1206 */ 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(); } /* PRESERVATION-SOURCE-END app.js:1-1206 */ /* PRESERVATION-SOURCE-BEGIN app.js:3471-3489 */ 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; } } /* PRESERVATION-SOURCE-END app.js:3471-3489 */ /* PRESERVATION-SOURCE-BEGIN app.js:8427-8904 */ 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); } /* PRESERVATION-SOURCE-END app.js:8427-8904 */ /* PRESERVATION-SOURCE-BEGIN app.js:9052-9283 */ 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)); } /* PRESERVATION-SOURCE-END app.js:9052-9283 */