diff --git a/app/backend/bootstrap/config.py b/app/backend/bootstrap/config.py index 08d4d11..243ebb6 100644 --- a/app/backend/bootstrap/config.py +++ b/app/backend/bootstrap/config.py @@ -9,7 +9,7 @@ from typing import Any APP_DIR = Path(__file__).resolve().parents[2] -STATIC_DIR = APP_DIR / "static" +STATIC_DIR = APP_DIR / "frontend" DATA_DIR = APP_DIR / "data" ENV_FILE = APP_DIR / ".env" MENTOR_SKILLS_DIR = APP_DIR / "游资skills" diff --git a/app/frontend/app.js b/app/frontend/app.js new file mode 100644 index 0000000..8e18c64 --- /dev/null +++ b/app/frontend/app.js @@ -0,0 +1,1943 @@ +/* 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 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)); +} +/* PRESERVATION-SOURCE-END app.js:9052-9283 */ diff --git a/app/static/heaven-loading.js b/app/frontend/heaven-loading.js similarity index 100% rename from app/static/heaven-loading.js rename to app/frontend/heaven-loading.js diff --git a/app/static/index.html b/app/frontend/index.html similarity index 99% rename from app/static/index.html rename to app/frontend/index.html index ca466dc..2996b1f 100644 --- a/app/static/index.html +++ b/app/frontend/index.html @@ -18,12 +18,12 @@ })(); - - - - - - + + + + + +
@@ -1865,12 +1865,13 @@ - + + @@ -1884,7 +1885,8 @@ - + + diff --git a/app/static/pages.config.js b/app/frontend/pages.config.js similarity index 100% rename from app/static/pages.config.js rename to app/frontend/pages.config.js diff --git a/app/frontend/pages/auction/page.js b/app/frontend/pages/auction/page.js new file mode 100644 index 0000000..500631d --- /dev/null +++ b/app/frontend/pages/auction/page.js @@ -0,0 +1,272 @@ +window.XiaobaiPageModules.register("auction", ["auctionView"], { + enter: ["loadAuction"], + leave: ["clearAuction"], +}); + +/* PRESERVATION-SOURCE-BEGIN app.js:2217-2481 */ +async function loadAuctionCenter(force = false) { + if (state.auctionLoading) return; + state.auctionLoading = true; + const button = document.querySelector("#auctionRefreshButton"); + button.disabled = true; + setText("auctionDateLabel", "正在读取竞价数据"); + try { + const query = new URLSearchParams({ trade_date: elements.tradeDate.value }); + if (force) query.set("force", "1"); + state.auctionData = await apiRequest(`/api/auction?${query}`); + renderAuctionCenter(); + scheduleAuctionTransition(state.auctionData.meta || {}); + } catch (error) { + document.querySelector("#auctionSummary").innerHTML = ""; + document.querySelector("#auctionThemeCarry").innerHTML = ""; + document.querySelector("#auctionNewThemes").innerHTML = ""; + document.querySelector("#auctionAmountTrend").innerHTML = ""; + document.querySelector("#auctionAmountCompare").innerHTML = ""; + document.querySelector("#auctionTableBody").innerHTML = ""; + document.querySelector("#auctionEmpty").hidden = false; + setText("auctionDateLabel", error.message || "竞价数据暂不可用"); + showToast(error.message || "竞价数据加载失败"); + } finally { + state.auctionLoading = false; + button.disabled = false; + } +} + +function renderAuctionCenter() { + const payload = state.auctionData; + if (!payload) return; + const summary = payload.summary || {}; + renderAuctionPhase(payload.meta || {}); + setText( + "auctionDateLabel", + `${payload.meta?.carried_forward ? "最近有效竞价" : "竞价日期"} ${payload.meta?.trade_date || "--"}`, + ); + document.querySelector("#auctionSummary").innerHTML = [ + ["竞价覆盖", `${formatNumber(summary.stock_count, 0)} 只`, ""], + ["重点异动", `${formatNumber(summary.focus_count, 0)} 只`, "up"], + ["竞价一字", `${formatNumber(summary.one_price_count, 0)} 只`, ""], + ["竞价成交额", `${formatNumber(summary.amount_billion, 2)} 亿`, ""], + ].map(([label, value, tone]) => `
${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"], + ]); +} + +/* PRESERVATION-SOURCE-END app.js:2217-2481 */ diff --git a/app/frontend/pages/dragon-tiger/page.js b/app/frontend/pages/dragon-tiger/page.js new file mode 100644 index 0000000..9a5d15c --- /dev/null +++ b/app/frontend/pages/dragon-tiger/page.js @@ -0,0 +1,375 @@ +window.XiaobaiPageModules.register("dragon_tiger", ["dragonView"], { + enter: ["loadDragonTiger"], +}); + +/* PRESERVATION-SOURCE-BEGIN app.js:2660-3028 */ +function selectDragonViewMode(mode) { + state.dragonViewMode = mode === "profiles" ? "profiles" : "daily"; + document.querySelectorAll("[data-dragon-view-mode]").forEach((button) => { + const active = button.dataset.dragonViewMode === state.dragonViewMode; + button.classList.toggle("active", active); + button.setAttribute("aria-pressed", String(active)); + }); + if (state.dragonViewMode === "profiles") { + document.querySelector("#dragonDailyContent").hidden = true; + document.querySelector("#dragonEmptyState").hidden = true; + document.querySelector("#dragonProfilesContent").hidden = false; + if (state.hotMoneyProfiles) renderHotMoneyProfiles(); + else loadHotMoneyProfiles(); + } else { + document.querySelector("#dragonProfilesContent").hidden = true; + if (state.dragonTiger) renderDragonTiger(); + else loadDragonTiger(); + } +} + +async function loadHotMoneyProfiles(force = false) { + if (!force && state.hotMoneyProfiles) { + renderHotMoneyProfiles(); + return; + } + setStatus("正在加载游资档案"); + try { + const query = new URLSearchParams(); + if (force) query.set("force", "1"); + const suffix = query.size ? `?${query}` : ""; + state.hotMoneyProfiles = await apiRequest(`/api/dragon-tiger/profiles${suffix}`); + renderHotMoneyProfiles(); + const count = number(state.hotMoneyProfiles.summary?.profile_count); + setStatus(`游资档案已加载 · 共 ${count} 位`); + } catch (error) { + showToast(error.message || "游资档案加载失败"); + setStatus("游资档案加载失败"); + } +} + +function renderHotMoneyProfiles() { + const payload = state.hotMoneyProfiles; + if (!payload) return; + const profiles = payload.profiles || []; + const summary = payload.summary || {}; + const query = state.hotMoneyProfileQuery; + const visible = profiles.filter((profile) => { + if (!query) return true; + return [profile.name, profile.description, ...(profile.organizations || [])] + .join(" ") + .toLocaleLowerCase("zh-CN") + .includes(query); + }); + if (!visible.some((profile) => profile.id === state.selectedHotMoneyProfileId)) { + state.selectedHotMoneyProfileId = visible[0]?.id || ""; + } + const selected = visible.find((profile) => profile.id === state.selectedHotMoneyProfileId) || null; + + setText("dragonDateLabel", `收录 ${number(summary.profile_count)} 位`); + setText("hotMoneyProfileResultCount", query ? `${visible.length} / ${profiles.length} 位` : `${profiles.length} 位`); + document.querySelector("#hotMoneyProfileSummary").innerHTML = [ + ["收录游资", number(summary.profile_count)], + ["已有简介", number(summary.described_count)], + ["关联席位", number(summary.organization_count)], + ].map(([label, value]) => `${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; + } +} + +/* PRESERVATION-SOURCE-END app.js:2660-3028 */ diff --git a/app/static/heaven-loading-v2.js b/app/frontend/pages/heaven/loading-v2.js similarity index 100% rename from app/static/heaven-loading-v2.js rename to app/frontend/pages/heaven/loading-v2.js diff --git a/app/static/wentian-v2.css b/app/frontend/pages/heaven/page.css similarity index 100% rename from app/static/wentian-v2.css rename to app/frontend/pages/heaven/page.css diff --git a/app/frontend/pages/heaven/page.js b/app/frontend/pages/heaven/page.js new file mode 100644 index 0000000..f873df4 --- /dev/null +++ b/app/frontend/pages/heaven/page.js @@ -0,0 +1,1847 @@ +window.XiaobaiPageModules.register("heaven", ["heavenView"], { + enter: ["loadHeaven"], + leave: ["stopHeaven"], +}); + +/* PRESERVATION-SOURCE-BEGIN app.js:4828-6667 */ +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 = ["初爻", "二爻", "三爻", "四爻", "五爻", "上爻"]; + +/* PRESERVATION-SOURCE-END app.js:4828-6667 */ diff --git a/app/frontend/pages/ladder/page.js b/app/frontend/pages/ladder/page.js new file mode 100644 index 0000000..fe11fdf --- /dev/null +++ b/app/frontend/pages/ladder/page.js @@ -0,0 +1,96 @@ +window.XiaobaiPageModules.register("ladder", ["ladderView"]); + +/* PRESERVATION-SOURCE-BEGIN app.js:2125-2216 */ +function renderLadderMini(ladders) { + const container = document.querySelector("#ladderMini"); + const highest = ladders.length ? Math.max(...ladders.map((item) => number(item.level))) : 0; + setText("maxHeight", highest ? `最高 ${highest} 板` : "暂无"); + container.innerHTML = ladders.slice(0, 5).map((group) => { + const allNames = group.stocks.map((stock) => stock.name).filter(Boolean); + const visibleNames = allNames.slice(0, 3).join("、"); + const suffix = allNames.length > 3 ? ` 等 ${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(); +} + +/* PRESERVATION-SOURCE-END app.js:2125-2216 */ diff --git a/app/frontend/pages/market/runtime.js b/app/frontend/pages/market/runtime.js new file mode 100644 index 0000000..f9b4153 --- /dev/null +++ b/app/frontend/pages/market/runtime.js @@ -0,0 +1,1333 @@ +/* PRESERVATION-SOURCE-BEGIN app.js:1916-1957 */ +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)}%`; + }); +} + +/* PRESERVATION-SOURCE-END app.js:1916-1957 */ +/* PRESERVATION-SOURCE-BEGIN app.js:6893-7719 */ +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`; +} + +/* PRESERVATION-SOURCE-END app.js:6893-7719 */ +/* PRESERVATION-SOURCE-BEGIN app.js:7969-8426 */ +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); +} + +/* PRESERVATION-SOURCE-END app.js:7969-8426 */ diff --git a/app/frontend/pages/mentor/page.js b/app/frontend/pages/mentor/page.js new file mode 100644 index 0000000..eef97df --- /dev/null +++ b/app/frontend/pages/mentor/page.js @@ -0,0 +1,497 @@ +window.XiaobaiPageModules.register("mentor", ["mentorView"], { + enter: ["loadMentor"], +}); + +/* PRESERVATION-SOURCE-BEGIN app.js:4337-4827 */ +async function loadMentorSetup(force = false) { + const requestedDate = elements.tradeDate.value.replaceAll("-", ""); + if (!force && state.mentorSetup?.requestedDate === requestedDate) { + renderMentorWorkspace(); + return; + } + try { + const query = new URLSearchParams({ trade_date: elements.tradeDate.value }); + const payload = await apiRequest(`/api/mentors/setup?${query}`); + payload.requestedDate = requestedDate; + if (!payload.preferences_configured) { + payload.mentors.sort((first, second) => { + if (Boolean(first.private) !== Boolean(second.private)) return first.private ? -1 : 1; + return String(first.name || "").localeCompare(String(second.name || ""), "zh-CN"); + }); + payload.mentors.forEach((mentor, index) => { mentor.sort_order = index; }); + } + state.mentorSetup = payload; + const selectedExists = payload.mentors.some((item) => item.id === state.selectedMentorId); + state.selectedMentorId = selectedExists ? state.selectedMentorId : payload.mentors[0]?.id || ""; + state.mentorMessages = await loadMentorMessages(); + renderMentorWorkspace(); + } catch (error) { + showMentorNotice(error.message || "问师模块加载失败"); + showToast(error.message || "问师模块加载失败"); + } +} + +function renderMentorWorkspace() { + const setup = state.mentorSetup; + if (!setup) return; + const selected = setup.mentors.find((item) => item.id === state.selectedMentorId) || null; + setText("mentorDataDate", `数据日期 ${displayCompactDate(setup.trade_date)}`); + setText("activeMentorName", selected?.name || "--"); + setText("mobileActiveMentorName", selected?.name || "选择思维模型"); + document.querySelector("#activeMentorBadges").innerHTML = selected ? renderMentorBadges(selected, true) : ""; + setText("activeMentorEvidence", selected?.evidence?.note || selected?.description || "--"); + document.querySelector("#activeMentorFocus").innerHTML = (selected?.focus || []).slice(0, 4) + .map((item) => `${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"); +} + +/* PRESERVATION-SOURCE-END app.js:4337-4827 */ diff --git a/app/frontend/pages/pools/page.js b/app/frontend/pages/pools/page.js new file mode 100644 index 0000000..26cf1f8 --- /dev/null +++ b/app/frontend/pages/pools/page.js @@ -0,0 +1,409 @@ +window.XiaobaiPageModules.register("pools", [ + "limitPool", + "brokenView", + "downView", + "yesterdayView", + "performanceView", +]); + +/* PRESERVATION-SOURCE-BEGIN app.js:1517-1915 */ +function getVisibleStocks() { + if (!state.dashboard) return []; + let rows = [...(state.dashboard.limits || [])]; + if (state.filter === "1") rows = rows.filter((row) => number(row.streak) === 1); + if (state.filter === "2") rows = rows.filter((row) => number(row.streak) === 2); + if (state.filter === "3") rows = rows.filter((row) => number(row.streak) >= 3); + if (state.query) { + rows = rows.filter((row) => { + const haystack = `${row.code} ${row.name} ${row.sector} ${row.reason}`.toLowerCase(); + return haystack.includes(state.query); + }); + } + return rows.sort((left, right) => compareRows(left, right)); +} + +function renderLimitTable() { + if (!state.dashboard) return; + const rows = getVisibleStocks(); + const allRows = state.dashboard.limits || []; + const body = document.querySelector("#limitTableBody"); + body.innerHTML = rows.map((row, index) => ` + + ${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)}」。
    + `; +} + +/* PRESERVATION-SOURCE-END app.js:1517-1915 */ diff --git a/app/frontend/pages/popularity/page.js b/app/frontend/pages/popularity/page.js new file mode 100644 index 0000000..51bdf71 --- /dev/null +++ b/app/frontend/pages/popularity/page.js @@ -0,0 +1,82 @@ +window.XiaobaiPageModules.register("popularity", ["popularityView"], { + enter: ["loadPopularity"], +}); + +/* PRESERVATION-SOURCE-BEGIN app.js:2584-2659 */ +async function loadPopularity(force = false) { + if (state.popularityLoading) return; + state.popularityLoading = true; + const button = document.querySelector("#popularityRefreshButton"); + button.disabled = true; + setText("popularityDateLabel", "正在读取人气榜"); + try { + const query = new URLSearchParams({ trade_date: elements.tradeDate.value }); + if (force) query.set("force", "1"); + state.popularityData = await apiRequest(`/api/popularity?${query}`); + renderPopularity(); + } catch (error) { + setText("popularityDateLabel", error.message || "人气榜暂不可用"); + document.querySelector("#popularityTableBody").innerHTML = ""; + document.querySelector("#popularityEmpty").hidden = false; + showToast(error.message || "人气榜加载失败"); + } finally { + state.popularityLoading = false; + button.disabled = false; + } +} + +function renderPopularity() { + const payload = state.popularityData; + if (!payload) return; + const summary = payload.summary || {}; + setText("popularityDateLabel", `${payload.meta?.carried_forward ? "最近有效榜单" : "榜单日期"} ${payload.meta?.trade_date || "--"}`); + const topNames = (rows) => (rows || []).slice(0, 3).map((item) => item.name).filter(Boolean).join(" · ") || "--"; + document.querySelector("#popularitySummary").innerHTML = [ + ["同花顺热度 Top3", topNames(payload.ths), `共 ${number(summary.ths_count)} 只上榜`], + ["东方财富热度 Top3", topNames(payload.dc), `共 ${number(summary.dc_count)} 只上榜`], + ["双榜共识", `${number(summary.dual_count)} 只`, "同时进入两榜,共识度更高"], + ].map(([label, value, detail], index) => `
    ${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; +} + +/* PRESERVATION-SOURCE-END app.js:2584-2659 */ diff --git a/app/frontend/pages/review/page.js b/app/frontend/pages/review/page.js new file mode 100644 index 0000000..801fd2e --- /dev/null +++ b/app/frontend/pages/review/page.js @@ -0,0 +1,699 @@ +window.XiaobaiPageModules.register("review", ["reviewWorkspaceView"], { + enter: ["loadReview"], +}); + +/* PRESERVATION-SOURCE-BEGIN app.js:3029-3470 */ +async function loadReviewWorkspace() { + try { + const [watchlistPayload, notesPayload, tradesPayload] = await Promise.all([ + apiRequest(`/api/watchlist?trade_date=${encodeURIComponent(elements.tradeDate.value)}`), + apiRequest("/api/notes?scope=daily"), + apiRequest("/api/trades"), + ]); + state.watchlist = watchlistPayload.items || []; + state.notes = notesPayload.items || []; + state.tradeEntries = tradesPayload.items || []; + state.tradeSummary = tradesPayload.summary || {}; + setText("reviewDataDate", displayCompactDate(elements.tradeDate.value)); + renderWatchlist(); + renderNotesHistory(state.notes, document.querySelector("#notesHistory"), false); + setText("notesCount", `${state.notes.length} 条`); + renderTradeLog(); + populateJournalForm(); + } catch (error) { + showToast(error.message || "我的复盘加载失败"); + } +} + +function renderWatchlist() { + setText("watchlistCount", `${state.watchlist.length} 只`); + const body = document.querySelector("#watchlistTableBody"); + body.innerHTML = state.watchlist.map((item) => ` + + ${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); + } +} + +/* PRESERVATION-SOURCE-END app.js:3029-3470 */ +/* PRESERVATION-SOURCE-BEGIN app.js:7720-7968 */ +async function loadAlerts(openDialog = false) { + try { + const query = new URLSearchParams({ status: state.alertFilter, as_of: todayString() }); + const payload = await apiRequest(`/api/alerts?${query}`); + state.alerts = payload.items || []; + state.alertUnreadCount = number(payload.unread_count); + renderAlerts(); + if (openDialog) openModalDialog(elements.alertsDialog); + } catch (error) { + if (openDialog) showToast(error.message || "提醒加载失败"); + } +} + +function openAlerts() { + toggleHeaderCommandMenu(false); + toggleAccountDropdown(false); + document.querySelector("#alertDate").value ||= todayString(); + openModalDialog(elements.alertsDialog); + loadAlerts(); +} + +function openStockReminder() { + const stock = state.activeStock || {}; + document.querySelector("#alertTitle").value = `${stock.name || stock.code || "个股"}观察提醒`; + document.querySelector("#alertCode").value = stock.code || ""; + document.querySelector("#alertDate").value = todayString(); + if (elements.stockDialog.open) elements.stockDialog.close(); + openAlerts(); + document.querySelector("#alertContent").focus(); +} + +function selectAlertFilter(filter) { + state.alertFilter = filter === "unread" ? "unread" : "all"; + document.querySelectorAll("[data-alert-filter]").forEach((button) => { + button.classList.toggle("active", button.dataset.alertFilter === state.alertFilter); + }); + loadAlerts(); +} + +async function saveAlert(event) { + event.preventDefault(); + const button = event.currentTarget.querySelector("button[type='submit']"); + button.disabled = true; + try { + const payload = await apiRequest("/api/alerts", "POST", { + title: document.querySelector("#alertTitle").value.trim(), + remind_date: document.querySelector("#alertDate").value, + code: document.querySelector("#alertCode").value.trim(), + content: document.querySelector("#alertContent").value.trim(), + }); + event.currentTarget.reset(); + document.querySelector("#alertDate").value = todayString(); + state.alertFilter = "all"; + state.alerts = payload.items || []; + state.alertUnreadCount = number(payload.unread_count); + renderAlerts(); + showToast("提醒已保存"); + } catch (error) { + showToast(error.message || "提醒保存失败"); + } finally { + button.disabled = false; + } +} + +async function markAllAlertsRead() { + try { + await apiRequest("/api/alerts/read-all", "POST", { as_of: todayString() }); + await loadAlerts(); + } catch (error) { + showToast(error.message || "提醒状态更新失败"); + } +} + +async function handleAlertAction(event) { + const button = event.target.closest("[data-alert-action]"); + if (!button) return; + const id = number(button.dataset.alertId); + if (!id) return; + try { + if (button.dataset.alertAction === "delete") { + await apiRequest(`/api/alerts/${id}`, "DELETE"); + } else { + await apiRequest(`/api/alerts/${id}/read`, "POST", {}); + } + await loadAlerts(); + } catch (error) { + showToast(error.message || "提醒操作失败"); + } +} + +function renderAlerts() { + const badge = document.querySelector("#alertBadge"); + badge.hidden = state.alertUnreadCount <= 0; + badge.textContent = state.alertUnreadCount > 99 ? "99+" : String(state.alertUnreadCount); + document.querySelector("#alertButton").classList.toggle("has-alerts", state.alertUnreadCount > 0); + setText("alertListCount", `${state.alerts.length} 条`); + document.querySelectorAll("[data-alert-filter]").forEach((button) => { + button.classList.toggle("active", button.dataset.alertFilter === state.alertFilter); + }); + document.querySelector("#markAllAlertsRead").disabled = state.alertUnreadCount <= 0; + const container = document.querySelector("#alertList"); + container.innerHTML = state.alerts.map((item) => { + const upcoming = !item.due; + const kindLabel = item.kind === "manual" ? "自定提醒" : item.kind === "strategy_t5" ? "跟踪完成" : "策略反馈"; + return `
    +
    +
    +
    ${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; + }); +} + +/* PRESERVATION-SOURCE-END app.js:7720-7968 */ diff --git a/app/frontend/pages/rotation/page.js b/app/frontend/pages/rotation/page.js new file mode 100644 index 0000000..0670b30 --- /dev/null +++ b/app/frontend/pages/rotation/page.js @@ -0,0 +1,173 @@ +window.XiaobaiPageModules.register("rotation", ["rotationView"], { + enter: ["loadRotation"], +}); + +/* PRESERVATION-SOURCE-BEGIN app.js:1958-2124 */ +async function loadRotationHistory(force = false) { + if (!state.dashboard || state.rotationLoading) return; + const key = `${elements.tradeDate.value}:9`; + if (!force && state.rotationHistoryKey === key && state.rotationHistory) { + renderRotationHistory(); + return; + } + state.rotationLoading = true; + const container = document.querySelector("#rotationHistory"); + renderEmptyState(container, "正在读取轮动历史"); + try { + const query = new URLSearchParams({ + trade_date: elements.tradeDate.value, + }); + state.rotationHistory = await apiRequest(`/api/rotation/history?${query}`); + state.rotationHistoryKey = key; + renderRotationHistory(); + } catch (error) { + renderEmptyState(container, error.message || "轮动历史加载失败"); + showToast(error.message || "轮动历史加载失败"); + } finally { + state.rotationLoading = false; + } +} + +function renderRotationHistory() { + const rows = state.rotationHistory?.rows || []; + const selected = state.rotationSelectedSector; + const container = document.querySelector("#rotationHistory"); + const tracker = document.querySelector("#rotationTracker"); + if (!rows.length) { + renderEmptyState(container, "尚无连续交易日的板块数据"); + setText("rotationHistoryRange", "暂无轮动历史"); + tracker.hidden = true; + return; + } + const chronological = [...rows] + .sort((left, right) => String(left.trade_date).localeCompare(String(right.trade_date))) + .slice(-9); + const displayRows = state.rotationOrder === "latest" ? [...chronological].reverse() : chronological; + document.querySelectorAll("[data-rotation-order]").forEach((button) => { + button.classList.toggle("active", button.dataset.rotationOrder === state.rotationOrder); + }); + setText( + "rotationHistoryRange", + `最近 ${chronological.length} 个交易日 · ${displayCompactDate(chronological[0].trade_date)} → ${displayCompactDate(chronological[chronological.length - 1].trade_date)} · ${state.rotationOrder === "latest" ? "由近到远,左侧为最新交易日" : "由远到近,右侧为最新交易日"}`, + ); + setText("rotationSelectionHint", selected ? `已联动高亮 ${selected}` : "点击任意板块追踪其连续性"); + if (selected) { + const sequence = displayRows.map((day) => { + const sector = (day.sectors || []).find((item) => item.name === selected); + return { tradeDate: day.trade_date, sector }; + }); + const appearances = sequence.filter((item) => item.sector); + const bestRank = appearances.length ? Math.min(...appearances.map((item) => number(item.sector.rank))) : 0; + tracker.hidden = false; + const continuity = appearances.length >= 3 ? "主线候选" : appearances.length === 1 ? "单日异动,持续性待验证" : "间断活跃"; + tracker.innerHTML = ` +
    ${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); +} + +/* PRESERVATION-SOURCE-END app.js:1958-2124 */ diff --git a/app/static/pages/runtime.js b/app/frontend/pages/runtime.js similarity index 100% rename from app/static/pages/runtime.js rename to app/frontend/pages/runtime.js diff --git a/app/frontend/pages/screener/page.js b/app/frontend/pages/screener/page.js new file mode 100644 index 0000000..a958bc9 --- /dev/null +++ b/app/frontend/pages/screener/page.js @@ -0,0 +1,1082 @@ +window.XiaobaiPageModules.register("screener", ["screenerView"], { + enter: ["loadScreener"], +}); + +window.XiaobaiPageModules.register("screener", ["screenerTrackingView"]); + +/* PRESERVATION-SOURCE-BEGIN app.js:3490-4336 */ +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(); + } +} + +/* PRESERVATION-SOURCE-END app.js:3490-4336 */ +/* PRESERVATION-SOURCE-BEGIN app.js:6668-6892 */ +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; +} + +/* PRESERVATION-SOURCE-END app.js:6668-6892 */ diff --git a/app/frontend/pages/sentiment/page.js b/app/frontend/pages/sentiment/page.js new file mode 100644 index 0000000..e9d5c8d --- /dev/null +++ b/app/frontend/pages/sentiment/page.js @@ -0,0 +1,316 @@ +window.XiaobaiPageModules.register("sentiment", ["sentimentCycleView"], { + enter: ["loadSentiment"], +}); + +/* PRESERVATION-SOURCE-BEGIN app.js:1207-1516 */ +async function loadSentimentHistory(force = false) { + if (!state.dashboard || state.sentimentLoading) return; + const key = `${elements.tradeDate.value}:${state.sentimentRange}`; + if (!force && state.sentimentHistoryKey === key && state.sentimentHistory) { + renderSentimentHistory(); + return; + } + state.sentimentLoading = true; + const notice = document.querySelector("#sentimentHistoryNotice"); + notice.hidden = true; + try { + const query = new URLSearchParams({ + trade_date: elements.tradeDate.value, + limit: String(state.sentimentRange), + }); + state.sentimentHistory = await apiRequest(`/api/sentiment/history?${query}`); + state.sentimentHistoryKey = key; + renderSentimentHistory(); + } catch (error) { + notice.textContent = error.message || "情绪周期数据加载失败"; + notice.hidden = false; + showToast(notice.textContent); + } finally { + state.sentimentLoading = false; + } +} + +function renderSentimentHistory() { + const payload = state.sentimentHistory; + if (!payload) return; + const rows = payload.rows || []; + const latest = rows[rows.length - 1]; + const body = document.querySelector("#sentimentHistoryBody"); + const empty = document.querySelector("#sentimentHistoryEmpty"); + empty.hidden = rows.length > 0; + body.innerHTML = [...rows].reverse().map((row) => { + return ` + + ${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] || "市场结构尚未形成清晰阶段,保持观察并等待确认。"; +} + +/* PRESERVATION-SOURCE-END app.js:1207-1516 */ diff --git a/app/frontend/pages/themes/page.js b/app/frontend/pages/themes/page.js new file mode 100644 index 0000000..ab68431 --- /dev/null +++ b/app/frontend/pages/themes/page.js @@ -0,0 +1,108 @@ +window.XiaobaiPageModules.register("themes", ["themeLibraryView"], { + enter: ["loadThemes"], +}); + +/* PRESERVATION-SOURCE-BEGIN app.js:2482-2583 */ +async function loadThemeLibrary(force = false) { + if (state.themeLoading) return; + state.themeLoading = true; + const button = document.querySelector("#themeRefreshButton"); + button.disabled = true; + setText("themeDateLabel", "正在整理题材库"); + try { + const query = new URLSearchParams({ trade_date: elements.tradeDate.value }); + if (force) query.set("force", "1"); + state.themeLibrary = await apiRequest(`/api/themes?${query}`); + renderThemeLibrary(); + const available = (state.themeLibrary.items || []).some((item) => item.code === state.selectedThemeCode); + if (!available) state.selectedThemeCode = ""; + const initialCode = state.selectedThemeCode || state.themeLibrary.items?.[0]?.code || ""; + if (initialCode) await selectTheme(initialCode, true); + } catch (error) { + setText("themeDateLabel", error.message || "题材数据暂不可用"); + renderEmptyState("themeDirectory", error.message || "题材数据加载失败"); + showToast(error.message || "题材数据加载失败"); + } finally { + state.themeLoading = false; + button.disabled = false; + } +} + +function renderThemeLibrary() { + const payload = state.themeLibrary; + if (!payload) return; + const summary = payload.summary || {}; + setText("themeDateLabel", `${payload.meta?.carried_forward ? "最近有效行情" : "行情日期"} ${payload.meta?.trade_date || "--"}`); + document.querySelector("#themeSummary").innerHTML = [ + ["收录题材", number(summary.theme_count), "个", ""], + ["当日上涨", number(summary.up_count), "个", "up"], + ["当日下跌", number(summary.down_count), "个", "down"], + ["人气题材", number(summary.hot_count), "个", "warning"], + ].map(([label, value, unit, tone]) => `
    ${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(); +} + +/* PRESERVATION-SOURCE-END app.js:2482-2583 */ diff --git a/app/static/shared/api.js b/app/frontend/shared/api.js similarity index 100% rename from app/static/shared/api.js rename to app/frontend/shared/api.js diff --git a/app/static/shared/components.js b/app/frontend/shared/components.js similarity index 100% rename from app/static/shared/components.js rename to app/frontend/shared/components.js diff --git a/app/frontend/shared/export.js b/app/frontend/shared/export.js new file mode 100644 index 0000000..6ff9ee1 --- /dev/null +++ b/app/frontend/shared/export.js @@ -0,0 +1,149 @@ +/* PRESERVATION-SOURCE-BEGIN app.js:8905-9051 */ +function exportStocks() { + exportRows("涨停池", getVisibleStocks(), [ + ["股票代码", "code"], ["股票名称", "name"], ["连板", "streak"], ["涨幅%", "change"], + ["价格", "price"], ["所属板块", "sector"], ["涨停原因", "reason"], ["首封", "first_time"], + ["最后封板", "last_time"], ["开板次数", "open_times"], ["换手率%", "turnover_rate"], + ["成交额亿", "amount_billion"], ["封单额万", "seal_amount_million"], + ]); +} + +function exportBroken() { + exportRows("炸板池", getVisibleBrokenRows(), [ + ["股票代码", "code"], ["股票名称", "name"], ["现价涨幅%", "change"], ["距涨停%", "limitGap"], + ["价格", "price"], ["所属板块", "sector"], ["首次触板", "first_time"], ["开板次数", "open_times"], + ["换手率%", "turnover_rate"], ["成交额亿", "amount_billion"], + ]); +} + +function exportDown() { + exportRows("跌停板", getVisibleDownRows(), [ + ["股票代码", "code"], ["股票名称", "name"], ["跌幅%", "change"], ["价格", "price"], + ["所属板块", "sector"], ["换手率%", "turnover_rate"], ["成交额亿", "amount_billion"], + ]); +} + +function exportYesterday() { + exportRows("昨日涨停", getVisibleYesterdayRows(), [ + ["股票代码", "code"], ["股票名称", "name"], ["昨日高度", "prior_streak"], + ["今日涨幅%", "current_change"], ["今日结果", "outcome"], ["当前高度", "current_streak"], + ["所属板块", "sector"], + ]); +} + +function exportLadder() { + const rows = (state.dashboard?.ladders || []).flatMap((group) => (group.stocks || []).map((stock) => ({ + level: group.label || group.level, + ...stock, + }))); + exportRows("市场天梯", rows, [ + ["梯队", "level"], ["股票代码", "code"], ["股票名称", "name"], ["所属板块", "sector"], + ["封板时间", "first_time"], ["开板次数", "open_times"], ["封单额万", "seal_amount_million"], ["成交额亿", "amount_billion"], + ]); +} + +function exportRotation() { + const sectorMap = new Map((state.dashboard?.sectors || []).map((sector) => [sector.name, sector])); + const rows = (state.dashboard?.sector_rotation || []).map((row) => ({ + ...row, + average_change: sectorMap.get(row.name)?.change ?? 0, + })); + exportRows("板块轮动", rows, [ + ["排名", "rank"], ["板块", "name"], ["趋势", "trend"], ["今日涨停", "count"], + ["昨日涨停", "previous_count"], ["变化", "delta"], ["强度", "strength"], + ["最高板", "max_streak"], ["平均涨幅%", "average_change"], + ["领涨股", "leader"], ["涨停股成交额亿", "amount_billion"], + ]); +} + +function exportSentimentHistory() { + const rows = state.sentimentHistory?.rows || []; + if (!rows.length) { + showToast("暂无可导出的情绪周期数据"); + return; + } + const exportRowsData = rows.map((row) => ({ + ...row, + breadth_score: row.components?.breadth?.score, + limit_ecology_score: row.components?.limit_ecology?.score, + profit_effect_score: row.components?.profit_effect?.score, + ladder_structure_score: row.components?.ladder_structure?.score, + liquidity_score: row.components?.liquidity?.score, + })); + exportRows("情绪周期", exportRowsData, [ + ["交易日", "trade_date"], ["情绪温度", "score"], ["周期阶段", "phase"], ["方向", "direction"], + ["涨停", "limit_up_count"], ["首板", "first_board_count"], ["二板", "second_board_count"], + ["三板以上", "three_plus_count"], ["连板高度", "max_height"], ["炸板", "broken_count"], + ["跌停", "limit_down_count"], ["昨日涨停", "previous_limit_count"], + ["昨日涨停红盘", "previous_positive_count"], ["昨日涨停红盘率%", "previous_positive_rate"], + ["市场宽度", "breadth_score"], ["涨停生态", "limit_ecology_score"], + ["赚钱效应", "profit_effect_score"], ["连板结构", "ladder_structure_score"], + ["成交活跃度", "liquidity_score"], + ]); +} + +function exportDragonTiger() { + const rows = (state.dragonTiger?.traders || []).flatMap((trader) => ( + (trader.operations || []).map((operation) => ({ + trader_name: trader.name, + identity_type: dragonIdentityLabel(trader.identity_type), + ...operation, + })) + )); + exportRows("游资龙虎榜", rows, [ + ["游资或席位", "trader_name"], ["身份", "identity_type"], ["股票代码", "code"], + ["股票名称", "name"], ["方向", "direction"], ["涨幅%", "change"], + ["买入百万元", "buy_million"], ["卖出百万元", "sell_million"], ["净额百万元", "net_buy_million"], + ["关联席位", "seat_name"], ["上榜原因", "reason"], + ]); +} + +function exportHotMoneyProfiles() { + const rows = state.hotMoneyProfiles?.profiles || []; + if (!rows.length) { + showToast("暂无可导出的游资档案"); + return; + } + downloadCsv( + `游资档案-${todayString()}.csv`, + ["游资名称", "简介", "关联营业部", "席位数量"], + rows.map((profile) => [ + profile.name, + profile.description, + (profile.organizations || []).join(";"), + number(profile.organization_count), + ]), + ); +} + +function commonReviewColumns() { + return [["股票代码", "code"], ["股票名称", "name"], ["状态", "status"], ["涨跌幅%", "change"], + ["价格", "price"], ["所属板块", "sector"], ["原因", "reason"], ["首次触板", "first_time"], + ["最后触板", "last_time"], ["开板次数", "open_times"], ["换手率%", "turnover_rate"], ["成交额亿", "amount_billion"]]; +} + +function exportRows(label, rows, columns) { + const headers = columns.map(([header]) => header); + const data = rows.map((row) => columns.map(([, key]) => row[key] ?? "")); + downloadCsv(`${label}-${state.dashboard.meta.trade_date}.csv`, headers, data); +} + +function downloadCsv(filename, headers, rows) { + const lines = [headers, ...rows].map((row) => row.map(csvCell).join(",")); + const blob = new Blob(["\ufeff", lines.join("\r\n")], { type: "text/csv;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = filename; + anchor.click(); + URL.revokeObjectURL(url); + showToast(`已导出 ${rows.length} 条数据`); +} + +function csvCell(value) { + let text = String(value ?? ""); + if (/^[=+\-@]/.test(text)) text = `'${text}`; + return `"${text.replaceAll('"', '""')}"`; +} + +/* PRESERVATION-SOURCE-END app.js:8905-9051 */ diff --git a/app/static/shared/shell.js b/app/frontend/shared/shell.js similarity index 100% rename from app/static/shared/shell.js rename to app/frontend/shared/shell.js diff --git a/app/static/shared/state.js b/app/frontend/shared/state.js similarity index 100% rename from app/static/shared/state.js rename to app/frontend/shared/state.js diff --git a/app/static/shared/tokens.css b/app/frontend/shared/tokens.css similarity index 100% rename from app/static/shared/tokens.css rename to app/frontend/shared/tokens.css diff --git a/app/static/ui-core.js b/app/frontend/shared/ui-core.js similarity index 100% rename from app/static/ui-core.js rename to app/frontend/shared/ui-core.js diff --git a/app/static/design-system.css b/app/frontend/styles/design-system.css similarity index 100% rename from app/static/design-system.css rename to app/frontend/styles/design-system.css diff --git a/app/static/redesign-v2.css b/app/frontend/styles/redesign-v2.css similarity index 100% rename from app/static/redesign-v2.css rename to app/frontend/styles/redesign-v2.css diff --git a/app/static/renovation.css b/app/frontend/styles/renovation.css similarity index 100% rename from app/static/renovation.css rename to app/frontend/styles/renovation.css diff --git a/app/static/styles.css b/app/frontend/styles/styles.css similarity index 100% rename from app/static/styles.css rename to app/frontend/styles/styles.css diff --git a/app/static/theme.css b/app/frontend/styles/theme.css similarity index 100% rename from app/static/theme.css rename to app/frontend/styles/theme.css diff --git a/app/static/vendor/lucide.min.js b/app/frontend/vendor/lucide.min.js similarity index 100% rename from app/static/vendor/lucide.min.js rename to app/frontend/vendor/lucide.min.js diff --git a/app/playwright.config.js b/app/playwright.config.js index a5541d1..fa65813 100644 --- a/app/playwright.config.js +++ b/app/playwright.config.js @@ -13,9 +13,11 @@ module.exports = defineConfig({ trace: "retain-on-failure", }, webServer: { - command: "python -m http.server 8876 --bind 127.0.0.1 --directory static", + command: "python -m http.server 8876 --bind 127.0.0.1 --directory frontend", url: "http://127.0.0.1:8876/index.html", reuseExistingServer: true, timeout: 15_000, + stdout: "ignore", + stderr: "ignore", }, }); diff --git a/app/static/app.js b/app/static/app.js deleted file mode 100644 index 04e4f30..0000000 --- a/app/static/app.js +++ /dev/null @@ -1,9283 +0,0 @@ -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)); -} diff --git a/app/static/pages/auction/page.js b/app/static/pages/auction/page.js deleted file mode 100644 index b25237b..0000000 --- a/app/static/pages/auction/page.js +++ /dev/null @@ -1,4 +0,0 @@ -window.XiaobaiPageModules.register("auction", ["auctionView"], { - enter: ["loadAuction"], - leave: ["clearAuction"], -}); diff --git a/app/static/pages/dragon-tiger/page.js b/app/static/pages/dragon-tiger/page.js deleted file mode 100644 index 855e400..0000000 --- a/app/static/pages/dragon-tiger/page.js +++ /dev/null @@ -1,3 +0,0 @@ -window.XiaobaiPageModules.register("dragon_tiger", ["dragonView"], { - enter: ["loadDragonTiger"], -}); diff --git a/app/static/pages/heaven/page.js b/app/static/pages/heaven/page.js deleted file mode 100644 index 077f384..0000000 --- a/app/static/pages/heaven/page.js +++ /dev/null @@ -1,4 +0,0 @@ -window.XiaobaiPageModules.register("heaven", ["heavenView"], { - enter: ["loadHeaven"], - leave: ["stopHeaven"], -}); diff --git a/app/static/pages/ladder/page.js b/app/static/pages/ladder/page.js deleted file mode 100644 index 9fa95aa..0000000 --- a/app/static/pages/ladder/page.js +++ /dev/null @@ -1 +0,0 @@ -window.XiaobaiPageModules.register("ladder", ["ladderView"]); diff --git a/app/static/pages/mentor/page.js b/app/static/pages/mentor/page.js deleted file mode 100644 index 1802db8..0000000 --- a/app/static/pages/mentor/page.js +++ /dev/null @@ -1,3 +0,0 @@ -window.XiaobaiPageModules.register("mentor", ["mentorView"], { - enter: ["loadMentor"], -}); diff --git a/app/static/pages/pools/page.js b/app/static/pages/pools/page.js deleted file mode 100644 index c0676ee..0000000 --- a/app/static/pages/pools/page.js +++ /dev/null @@ -1,7 +0,0 @@ -window.XiaobaiPageModules.register("pools", [ - "limitPool", - "brokenView", - "downView", - "yesterdayView", - "performanceView", -]); diff --git a/app/static/pages/popularity/page.js b/app/static/pages/popularity/page.js deleted file mode 100644 index 5179da6..0000000 --- a/app/static/pages/popularity/page.js +++ /dev/null @@ -1,3 +0,0 @@ -window.XiaobaiPageModules.register("popularity", ["popularityView"], { - enter: ["loadPopularity"], -}); diff --git a/app/static/pages/review/page.js b/app/static/pages/review/page.js deleted file mode 100644 index 23da3fa..0000000 --- a/app/static/pages/review/page.js +++ /dev/null @@ -1,3 +0,0 @@ -window.XiaobaiPageModules.register("review", ["reviewWorkspaceView"], { - enter: ["loadReview"], -}); diff --git a/app/static/pages/rotation/page.js b/app/static/pages/rotation/page.js deleted file mode 100644 index 605255c..0000000 --- a/app/static/pages/rotation/page.js +++ /dev/null @@ -1,3 +0,0 @@ -window.XiaobaiPageModules.register("rotation", ["rotationView"], { - enter: ["loadRotation"], -}); diff --git a/app/static/pages/screener/page.js b/app/static/pages/screener/page.js deleted file mode 100644 index 9314221..0000000 --- a/app/static/pages/screener/page.js +++ /dev/null @@ -1,5 +0,0 @@ -window.XiaobaiPageModules.register("screener", ["screenerView"], { - enter: ["loadScreener"], -}); - -window.XiaobaiPageModules.register("screener", ["screenerTrackingView"]); diff --git a/app/static/pages/sentiment/page.js b/app/static/pages/sentiment/page.js deleted file mode 100644 index e1b6689..0000000 --- a/app/static/pages/sentiment/page.js +++ /dev/null @@ -1,3 +0,0 @@ -window.XiaobaiPageModules.register("sentiment", ["sentimentCycleView"], { - enter: ["loadSentiment"], -}); diff --git a/app/static/pages/themes/page.js b/app/static/pages/themes/page.js deleted file mode 100644 index fd249f2..0000000 --- a/app/static/pages/themes/page.js +++ /dev/null @@ -1,3 +0,0 @@ -window.XiaobaiPageModules.register("themes", ["themeLibraryView"], { - enter: ["loadThemes"], -}); diff --git a/app/tests/__init__.py b/app/tests/__init__.py new file mode 100644 index 0000000..f435180 --- /dev/null +++ b/app/tests/__init__.py @@ -0,0 +1 @@ +"""Test support package for the preserved application.""" diff --git a/app/tests/preservation_helpers.py b/app/tests/preservation_helpers.py new file mode 100644 index 0000000..9bd25f5 --- /dev/null +++ b/app/tests/preservation_helpers.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import hashlib +import re +from pathlib import Path + + +APP_ROOT = Path(__file__).resolve().parents[1] +ORIGINAL_ROOT = APP_ROOT.parent +FRONTEND_ROOT = APP_ROOT / "frontend" +ORIGINAL_STATIC = ORIGINAL_ROOT / "static" + +SOURCE_RANGE = re.compile( + r"/\* PRESERVATION-SOURCE-BEGIN app\.js:(\d+)-(\d+) \*/\n" + r"(.*?)" + r"/\* PRESERVATION-SOURCE-END app\.js:\1-\2 \*/\n?", + re.DOTALL, +) + + +def sha256(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def reassembled_frontend_runtime() -> str: + chunks: dict[tuple[int, int], str] = {} + for path in FRONTEND_ROOT.rglob("*.js"): + for match in SOURCE_RANGE.finditer(path.read_text(encoding="utf-8")): + source_range = (int(match.group(1)), int(match.group(2))) + if source_range in chunks: + raise AssertionError(f"duplicate app.js source range: {source_range}") + chunks[source_range] = match.group(3) + + assembled: list[str] = [] + next_line = 1 + for (start, end), content in sorted(chunks.items()): + if start != next_line: + raise AssertionError( + f"app.js source coverage gap: expected line {next_line}, got {start}" + ) + if len(content.splitlines(keepends=True)) != end - start + 1: + raise AssertionError(f"app.js line count changed in range {start}-{end}") + assembled.append(content) + next_line = end + 1 + + original_line_count = len( + (ORIGINAL_STATIC / "app.js").read_text(encoding="utf-8").splitlines() + ) + if next_line != original_line_count + 1: + raise AssertionError( + f"app.js source coverage ended at {next_line - 1}, expected {original_line_count}" + ) + return "".join(assembled) + + +def assert_moved_asset_matches( + testcase, + original_relative: str, + frontend_relative: str | None = None, +) -> None: + target_relative = frontend_relative or original_relative + testcase.assertEqual( + sha256(FRONTEND_ROOT / target_relative), + sha256(ORIGINAL_STATIC / original_relative), + original_relative, + ) + + +def assert_page_prefix_matches(testcase, page_relative: str) -> None: + original = (ORIGINAL_STATIC / page_relative).read_text(encoding="utf-8") + migrated = (FRONTEND_ROOT / page_relative).read_text(encoding="utf-8") + testcase.assertTrue( + migrated.startswith(original.rstrip("\n") + "\n\n"), + page_relative, + ) diff --git a/app/tests/test_css_governance.py b/app/tests/test_css_governance.py index c730ebd..eefc0dd 100644 --- a/app/tests/test_css_governance.py +++ b/app/tests/test_css_governance.py @@ -6,14 +6,14 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[1] -STATIC = ROOT / "static" +STATIC = ROOT / "frontend" TOKENS = STATIC / "shared" / "tokens.css" LEGACY_STYLESHEETS = ( - "styles.css", - "renovation.css", - "redesign-v2.css", - "design-system.css", - "theme.css", + "styles/styles.css", + "styles/renovation.css", + "styles/redesign-v2.css", + "styles/design-system.css", + "styles/theme.css", ) @@ -26,12 +26,12 @@ class CssGovernanceTests(unittest.TestCase): def test_token_layer_loads_before_application_styles(self) -> None: expected_order = ( "/shared/tokens.css", - "/styles.css", - "/renovation.css", - "/redesign-v2.css", - "/design-system.css", - "/theme.css", - "/wentian-v2.css", + "/styles/styles.css", + "/styles/renovation.css", + "/styles/redesign-v2.css", + "/styles/design-system.css", + "/styles/theme.css", + "/pages/heaven/page.css", ) positions = [self.html.index(path) for path in expected_order] self.assertEqual(positions, sorted(positions)) @@ -63,7 +63,7 @@ class CssGovernanceTests(unittest.TestCase): def test_wentian_tokens_remain_isolated(self) -> None: self.assertNotRegex(self.tokens, r"--wt-[a-z0-9-]+\s*:") - wentian = (STATIC / "wentian-v2.css").read_text(encoding="utf-8") + wentian = (STATIC / "pages" / "heaven" / "page.css").read_text(encoding="utf-8") self.assertRegex(wentian, r"--wt-[a-z0-9-]+\s*:") def test_compatibility_aliases_cover_historical_layers(self) -> None: diff --git a/app/tests/test_frontend_boundaries.py b/app/tests/test_frontend_boundaries.py index 3841765..272b09e 100644 --- a/app/tests/test_frontend_boundaries.py +++ b/app/tests/test_frontend_boundaries.py @@ -5,9 +5,11 @@ import re import unittest from pathlib import Path +from tests.preservation_helpers import reassembled_frontend_runtime + ROOT = Path(__file__).resolve().parents[1] -STATIC = ROOT / "static" +STATIC = ROOT / "frontend" class FrontendBoundaryTests(unittest.TestCase): @@ -22,7 +24,7 @@ class FrontendBoundaryTests(unittest.TestCase): def test_shared_dependencies_load_before_application(self) -> None: html = (STATIC / "index.html").read_text(encoding="utf-8") - ui_position = html.index('/ui-core.js') + ui_position = html.index('/shared/ui-core.js') components_position = html.index('/shared/components.js') pages_position = html.index('/pages.config.js') runtime_position = html.index('/pages/runtime.js') @@ -40,7 +42,7 @@ class FrontendBoundaryTests(unittest.TestCase): self.assertLess(shell_position, app_position) def test_application_state_is_created_through_shared_boundary(self) -> None: - app = (STATIC / "app.js").read_text(encoding="utf-8") + app = reassembled_frontend_runtime() self.assertIn("const state = window.XiaobaiState.create({", app) self.assertNotIn("const state = {", app) @@ -70,7 +72,7 @@ class FrontendBoundaryTests(unittest.TestCase): self.assertEqual(actual, expected) def test_shell_owns_navigation_and_page_mounting(self) -> None: - app = (STATIC / "app.js").read_text(encoding="utf-8") + app = reassembled_frontend_runtime() shell = (STATIC / "shared" / "shell.js").read_text(encoding="utf-8") self.assertNotIn("function syncNavigationState", app) self.assertNotIn("function initializeApplicationShell", app) @@ -109,7 +111,7 @@ class FrontendBoundaryTests(unittest.TestCase): self.assertEqual(actual, expected) def test_page_lifecycle_is_owned_outside_application_monolith(self) -> None: - app = (STATIC / "app.js").read_text(encoding="utf-8") + app = reassembled_frontend_runtime() runtime = (STATIC / "pages" / "runtime.js").read_text(encoding="utf-8") start = app.index("function openView(") end = app.index("\nfunction initializeAutoTableSorting", start) @@ -122,7 +124,7 @@ class FrontendBoundaryTests(unittest.TestCase): def test_shared_empty_state_component_is_used_by_multiple_features(self) -> None: components = (STATIC / "shared" / "components.js").read_text(encoding="utf-8") - app = (STATIC / "app.js").read_text(encoding="utf-8") + app = reassembled_frontend_runtime() self.assertIn("function emptyStateHtml(message, options = {})", components) self.assertIn("function renderEmptyState(target, message, options = {})", components) self.assertGreaterEqual(app.count("renderEmptyState("), 8) diff --git a/app/tests/test_frontend_contract.py b/app/tests/test_frontend_contract.py index 63d43cd..acb4618 100644 --- a/app/tests/test_frontend_contract.py +++ b/app/tests/test_frontend_contract.py @@ -5,8 +5,10 @@ import unittest from html.parser import HTMLParser from pathlib import Path +from tests.preservation_helpers import reassembled_frontend_runtime -STATIC_DIR = Path(__file__).resolve().parents[1] / "static" + +STATIC_DIR = Path(__file__).resolve().parents[1] / "frontend" class IdCollector(HTMLParser): @@ -22,11 +24,11 @@ class FrontendContractTests(unittest.TestCase): @classmethod def setUpClass(cls) -> None: cls.html = (STATIC_DIR / "index.html").read_text(encoding="utf-8") - cls.script = (STATIC_DIR / "app.js").read_text(encoding="utf-8") + cls.script = reassembled_frontend_runtime() cls.shell = (STATIC_DIR / "shared" / "shell.js").read_text(encoding="utf-8") - cls.ui_core = (STATIC_DIR / "ui-core.js").read_text(encoding="utf-8") - cls.design_system = (STATIC_DIR / "design-system.css").read_text(encoding="utf-8") - cls.theme = (STATIC_DIR / "theme.css").read_text(encoding="utf-8") + cls.ui_core = (STATIC_DIR / "shared" / "ui-core.js").read_text(encoding="utf-8") + cls.design_system = (STATIC_DIR / "styles" / "design-system.css").read_text(encoding="utf-8") + cls.theme = (STATIC_DIR / "styles" / "theme.css").read_text(encoding="utf-8") cls.tokens = (STATIC_DIR / "shared" / "tokens.css").read_text(encoding="utf-8") collector = IdCollector() collector.feed(cls.html) @@ -158,7 +160,7 @@ class FrontendContractTests(unittest.TestCase): self.assertIn("必需数据已完整,本日没有股票同时满足", self.script) def test_dialogs_and_dark_table_hover_have_shared_safety_constraints(self): - redesign = (STATIC_DIR / "redesign-v2.css").read_text(encoding="utf-8") + redesign = (STATIC_DIR / "styles" / "redesign-v2.css").read_text(encoding="utf-8") self.assertIn(".settings-dialog:not(.heaven-reading-dialog)[open] { margin: auto; }", redesign) self.assertIn("max-height: min(760px, calc(100dvh - 28px));", redesign) self.assertIn('#reviewWorkspaceView .data-table tbody tr:hover td', self.theme) @@ -166,8 +168,8 @@ class FrontendContractTests(unittest.TestCase): self.assertIn('#screenerView .screener-result-frame tbody tr:hover td:last-child', self.theme) def test_global_toast_has_one_owner_and_cannot_stretch_between_insets(self): - styles = (STATIC_DIR / "styles.css").read_text(encoding="utf-8") - wentian = (STATIC_DIR / "wentian-v2.css").read_text(encoding="utf-8") + styles = (STATIC_DIR / "styles" / "styles.css").read_text(encoding="utf-8") + wentian = (STATIC_DIR / "pages" / "heaven" / "page.css").read_text(encoding="utf-8") self.assertIn("#toast.toast {", styles) self.assertIn("top: auto;", styles) self.assertIn("left: auto;", styles) @@ -183,7 +185,7 @@ class FrontendContractTests(unittest.TestCase): def test_shared_ui_core_loads_before_application(self): self.assertLess( - self.html.index('\n', + "", + ) + restored = restored.replace( + ' \n', + "", + ) + self.assertEqual( + restored, + (ORIGINAL_STATIC / "index.html").read_text(encoding="utf-8"), + ) + + def test_complete_stylesheet_stack_is_byte_identical_after_relocation(self) -> None: + for original, migrated in ( + ("shared/tokens.css", "shared/tokens.css"), + ("styles.css", "styles/styles.css"), + ("renovation.css", "styles/renovation.css"), + ("redesign-v2.css", "styles/redesign-v2.css"), + ("design-system.css", "styles/design-system.css"), + ("theme.css", "styles/theme.css"), + ("wentian-v2.css", "pages/heaven/page.css"), + ): + with self.subTest(asset=original): + assert_moved_asset_matches(self, original, migrated) + + def test_shared_vendor_and_animation_assets_are_byte_identical(self) -> None: + for original, migrated in ( + ("ui-core.js", "shared/ui-core.js"), + ("heaven-loading-v2.js", "pages/heaven/loading-v2.js"), + ("heaven-loading.js", "heaven-loading.js"), + ("vendor/lucide.min.js", "vendor/lucide.min.js"), + ("pages.config.js", "pages.config.js"), + ("pages/runtime.js", "pages/runtime.js"), + ("shared/api.js", "shared/api.js"), + ("shared/components.js", "shared/components.js"), + ("shared/shell.js", "shared/shell.js"), + ("shared/state.js", "shared/state.js"), + ): + with self.subTest(asset=original): + assert_moved_asset_matches(self, original, migrated) + + def test_original_page_registration_prefixes_are_preserved(self) -> None: + for path in sorted((ORIGINAL_STATIC / "pages").glob("*/page.js")): + relative = path.relative_to(ORIGINAL_STATIC).as_posix() + with self.subTest(page=relative): + assert_page_prefix_matches(self, relative) + + def test_frontend_is_the_only_served_static_root(self) -> None: + self.assertEqual(STATIC_DIR, APP_DIR / "frontend") + self.assertFalse((APP_DIR / "static").exists()) + + def test_shared_api_remains_the_only_fetch_exit(self) -> None: + consumers = [] + for path in FRONTEND_ROOT.rglob("*.js"): + if "vendor" in path.parts: + continue + if re.search(r"\bfetch\s*\(", path.read_text(encoding="utf-8")): + consumers.append(path.relative_to(FRONTEND_ROOT).as_posix()) + self.assertEqual(consumers, ["shared/api.js"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/app/tests/test_preservation_slice_ladder_rotation.py b/app/tests/test_preservation_slice_ladder_rotation.py index 63e6205..7b3507b 100644 --- a/app/tests/test_preservation_slice_ladder_rotation.py +++ b/app/tests/test_preservation_slice_ladder_rotation.py @@ -5,6 +5,13 @@ import hashlib import unittest from pathlib import Path +from tests.preservation_helpers import ( + ORIGINAL_STATIC, + assert_moved_asset_matches, + assert_page_prefix_matches, + reassembled_frontend_runtime, +) + APP_ROOT = Path(__file__).resolve().parents[1] ORIGINAL_ROOT = APP_ROOT.parent @@ -73,19 +80,17 @@ class LadderRotationSliceSourceEquivalenceTests(unittest.TestCase): ) def test_api_and_frontend_assets_are_unchanged(self) -> None: - for relative in ( - "config/api.config.json", - "static/index.html", - "static/app.js", - "static/styles.css", - "static/pages/ladder/page.js", - "static/pages/rotation/page.js", - ): - self.assertEqual( - sha256(APP_ROOT / relative), - sha256(ORIGINAL_ROOT / relative), - relative, - ) + self.assertEqual( + sha256(APP_ROOT / "config/api.config.json"), + sha256(ORIGINAL_ROOT / "config/api.config.json"), + ) + self.assertEqual( + reassembled_frontend_runtime(), + (ORIGINAL_STATIC / "app.js").read_text(encoding="utf-8"), + ) + assert_moved_asset_matches(self, "styles.css", "styles/styles.css") + for page in ("pages/ladder/page.js", "pages/rotation/page.js"): + assert_page_prefix_matches(self, page) if __name__ == "__main__": diff --git a/app/tests/test_preservation_slice_market.py b/app/tests/test_preservation_slice_market.py index 827d9af..7f35382 100644 --- a/app/tests/test_preservation_slice_market.py +++ b/app/tests/test_preservation_slice_market.py @@ -13,6 +13,11 @@ from backend.data import realtime from backend.data.providers import ifind_client as canonical_ifind from backend.data.providers import tushare_client as canonical_tushare from backend.features.market import charts +from tests.preservation_helpers import ( + ORIGINAL_STATIC, + assert_moved_asset_matches, + reassembled_frontend_runtime, +) APP_ROOT = Path(__file__).resolve().parents[1] @@ -146,21 +151,19 @@ class MarketSliceSourceEquivalenceTests(unittest.TestCase): top_level_definitions(APP_ROOT / "backend/features/market/charts.py"), ) - def test_unchanged_frontend_assets_match_the_original(self) -> None: - for relative in ( - "index.html", - "app.js", - "styles.css", - "renovation.css", - "redesign-v2.css", - "theme.css", - "wentian-v2.css", + def test_relocated_frontend_preserves_original_market_runtime_and_styles(self) -> None: + self.assertEqual( + reassembled_frontend_runtime(), + (ORIGINAL_STATIC / "app.js").read_text(encoding="utf-8"), + ) + for original, migrated in ( + ("styles.css", "styles/styles.css"), + ("renovation.css", "styles/renovation.css"), + ("redesign-v2.css", "styles/redesign-v2.css"), + ("theme.css", "styles/theme.css"), + ("wentian-v2.css", "pages/heaven/page.css"), ): - self.assertEqual( - sha256(APP_ROOT / "static" / relative), - sha256(ORIGINAL_ROOT / "static" / relative), - relative, - ) + assert_moved_asset_matches(self, original, migrated) if __name__ == "__main__": diff --git a/app/tests/test_preservation_slice_market_insights.py b/app/tests/test_preservation_slice_market_insights.py index 0b15008..47ed798 100644 --- a/app/tests/test_preservation_slice_market_insights.py +++ b/app/tests/test_preservation_slice_market_insights.py @@ -7,6 +7,12 @@ from pathlib import Path import market_insights from backend.features.market import insights as canonical_insights +from tests.preservation_helpers import ( + ORIGINAL_STATIC, + assert_moved_asset_matches, + assert_page_prefix_matches, + reassembled_frontend_runtime, +) APP_ROOT = Path(__file__).resolve().parents[1] @@ -174,21 +180,22 @@ class MarketInsightsSliceSourceEquivalenceTests(unittest.TestCase): self.assertEqual(migrated[name], original[name], name) def test_api_and_frontend_assets_are_unchanged(self) -> None: - for relative in ( - "config/api.config.json", - "static/index.html", - "static/app.js", - "static/styles.css", - "static/pages/auction/page.js", - "static/pages/themes/page.js", - "static/pages/popularity/page.js", - "static/pages/dragon-tiger/page.js", + self.assertEqual( + sha256(APP_ROOT / "config/api.config.json"), + sha256(ORIGINAL_ROOT / "config/api.config.json"), + ) + self.assertEqual( + reassembled_frontend_runtime(), + (ORIGINAL_STATIC / "app.js").read_text(encoding="utf-8"), + ) + assert_moved_asset_matches(self, "styles.css", "styles/styles.css") + for page in ( + "pages/auction/page.js", + "pages/themes/page.js", + "pages/popularity/page.js", + "pages/dragon-tiger/page.js", ): - self.assertEqual( - sha256(APP_ROOT / relative), - sha256(ORIGINAL_ROOT / relative), - relative, - ) + assert_page_prefix_matches(self, page) if __name__ == "__main__": diff --git a/app/tests/test_preservation_slice_screener.py b/app/tests/test_preservation_slice_screener.py index 8f14fdf..2748856 100644 --- a/app/tests/test_preservation_slice_screener.py +++ b/app/tests/test_preservation_slice_screener.py @@ -11,6 +11,12 @@ import screener import strategy_tracking from backend.features.screener import compiler, engine, strategies, tracking from backend.features.screener import service as screener_service +from tests.preservation_helpers import ( + ORIGINAL_STATIC, + assert_moved_asset_matches, + assert_page_prefix_matches, + reassembled_frontend_runtime, +) APP_ROOT = Path(__file__).resolve().parents[1] @@ -182,17 +188,12 @@ class ScreenerSliceSourceEquivalenceTests(unittest.TestCase): ) def test_screener_frontend_assets_are_unchanged(self) -> None: - for relative in ( - "static/index.html", - "static/app.js", - "static/styles.css", - "static/pages/screener/page.js", - ): - self.assertEqual( - sha256(APP_ROOT / relative), - sha256(ORIGINAL_ROOT / relative), - relative, - ) + self.assertEqual( + reassembled_frontend_runtime(), + (ORIGINAL_STATIC / "app.js").read_text(encoding="utf-8"), + ) + assert_moved_asset_matches(self, "styles.css", "styles/styles.css") + assert_page_prefix_matches(self, "pages/screener/page.js") if __name__ == "__main__": diff --git a/app/tests/test_preservation_slice_sentiment_pools.py b/app/tests/test_preservation_slice_sentiment_pools.py index 69aec93..5a08ac0 100644 --- a/app/tests/test_preservation_slice_sentiment_pools.py +++ b/app/tests/test_preservation_slice_sentiment_pools.py @@ -7,6 +7,12 @@ from pathlib import Path import sentiment_engine from backend.features.sentiment import engine as canonical_engine +from tests.preservation_helpers import ( + ORIGINAL_STATIC, + assert_moved_asset_matches, + assert_page_prefix_matches, + reassembled_frontend_runtime, +) APP_ROOT = Path(__file__).resolve().parents[1] @@ -95,19 +101,17 @@ class SentimentPoolSliceSourceEquivalenceTests(unittest.TestCase): self.assertIs(sentiment_engine, canonical_engine) def test_api_and_frontend_assets_are_unchanged(self) -> None: - for relative in ( - "config/api.config.json", - "static/index.html", - "static/app.js", - "static/styles.css", - "static/pages/sentiment/page.js", - "static/pages/pools/page.js", - ): - self.assertEqual( - sha256(APP_ROOT / relative), - sha256(ORIGINAL_ROOT / relative), - relative, - ) + self.assertEqual( + sha256(APP_ROOT / "config/api.config.json"), + sha256(ORIGINAL_ROOT / "config/api.config.json"), + ) + self.assertEqual( + reassembled_frontend_runtime(), + (ORIGINAL_STATIC / "app.js").read_text(encoding="utf-8"), + ) + assert_moved_asset_matches(self, "styles.css", "styles/styles.css") + for page in ("pages/sentiment/page.js", "pages/pools/page.js"): + assert_page_prefix_matches(self, page) if __name__ == "__main__": diff --git a/app/tools/build_architecture_inventory.py b/app/tools/build_architecture_inventory.py index 7e1e80b..3ead445 100644 --- a/app/tools/build_architecture_inventory.py +++ b/app/tools/build_architecture_inventory.py @@ -75,13 +75,15 @@ def code_hotspots() -> list[dict[str, Any]]: "screener.py", "market_insights.py", "tushare_client.py", - "static/index.html", - "static/app.js", - "static/styles.css", - "static/redesign-v2.css", - "static/renovation.css", - "static/theme.css", - "static/wentian-v2.css", + "frontend/index.html", + "frontend/app.js", + "frontend/styles/styles.css", + "frontend/styles/redesign-v2.css", + "frontend/styles/renovation.css", + "frontend/styles/theme.css", + "frontend/pages/heaven/page.css", + "frontend/pages/heaven/page.js", + "frontend/pages/market/runtime.js", ] rows = [] for name in candidates: @@ -97,7 +99,7 @@ def code_hotspots() -> list[dict[str, Any]]: def build() -> dict[str, Any]: - html = source("static/index.html") + html = source("frontend/index.html") server = source("server.py") database = source("database.py") pages = page_inventory(html) diff --git a/app/tools/compare_preservation_databases.py b/app/tools/compare_preservation_databases.py index efb0d67..e72f17b 100644 --- a/app/tools/compare_preservation_databases.py +++ b/app/tools/compare_preservation_databases.py @@ -27,9 +27,21 @@ def schema(connection: sqlite3.Connection) -> list[dict[str, Any]]: return [dict(row) for row in rows] -def table_rows(connection: sqlite3.Connection, table: str) -> list[dict[str, Any]]: +def table_rows( + connection: sqlite3.Connection, + table: str, + excluded_columns: set[str] | None = None, +) -> list[dict[str, Any]]: quoted = '"' + table.replace('"', '""') + '"' - rows = [dict(row) for row in connection.execute(f"SELECT * FROM {quoted}").fetchall()] + excluded_columns = excluded_columns or set() + rows = [ + { + key: value + for key, value in dict(row).items() + if key not in excluded_columns + } + for row in connection.execute(f"SELECT * FROM {quoted}").fetchall() + ] return sorted(rows, key=lambda row: json.dumps(row, ensure_ascii=False, sort_keys=True, default=str)) @@ -38,9 +50,23 @@ def main() -> None: parser.add_argument("--original", type=Path, required=True) parser.add_argument("--migrated", type=Path, required=True) parser.add_argument("--output", type=Path, required=True) + parser.add_argument( + "--exclude-column", + action="append", + default=[], + metavar="TABLE.COLUMN", + help="exclude a nondeterministic column from one table comparison", + ) parser.add_argument("tables", nargs="+") args = parser.parse_args() + excluded_by_table: dict[str, set[str]] = {} + for item in args.exclude_column: + table, separator, column = item.partition(".") + if not separator or not table or not column: + parser.error("--exclude-column must use TABLE.COLUMN") + excluded_by_table.setdefault(table, set()).add(column) + original = sqlite3.connect(args.original) migrated = sqlite3.connect(args.migrated) original.row_factory = sqlite3.Row @@ -51,8 +77,9 @@ def main() -> None: tables = [] all_equal = original_schema == migrated_schema for table in args.tables: - original_rows = table_rows(original, table) - migrated_rows = table_rows(migrated, table) + excluded_columns = excluded_by_table.get(table, set()) + original_rows = table_rows(original, table, excluded_columns) + migrated_rows = table_rows(migrated, table, excluded_columns) equal = original_rows == migrated_rows all_equal = all_equal and equal tables.append( @@ -63,6 +90,7 @@ def main() -> None: "original_sha256": digest(original_rows), "migrated_sha256": digest(migrated_rows), "equal": equal, + "excluded_columns": sorted(excluded_columns), } ) result = { diff --git a/app/tools/split_frontend_runtime.py b/app/tools/split_frontend_runtime.py new file mode 100644 index 0000000..e61e199 --- /dev/null +++ b/app/tools/split_frontend_runtime.py @@ -0,0 +1,184 @@ +from __future__ import annotations + +import hashlib +import json +import re +from pathlib import Path + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[2] +ORIGINAL_STATIC = REPOSITORY_ROOT / "static" +FRONTEND_ROOT = REPOSITORY_ROOT / "app" / "frontend" +EVIDENCE_PATH = ( + REPOSITORY_ROOT + / "docs" + / "migration" + / "evidence" + / "slice-10" + / "frontend-source-map.json" +) +EXPECTED_SOURCE_SHA256 = "c020d0bc68f8a96b352a0ffa62c913a0ea3f5ca0afc41f70969f833e447166a6" + +RUNTIME_RANGES: dict[str, list[tuple[int, int]]] = { + "pages/sentiment/page.js": [(1207, 1516)], + "pages/pools/page.js": [(1517, 1915)], + "pages/market/runtime.js": [(1916, 1957), (6893, 7719), (7969, 8426)], + "pages/rotation/page.js": [(1958, 2124)], + "pages/ladder/page.js": [(2125, 2216)], + "pages/auction/page.js": [(2217, 2481)], + "pages/themes/page.js": [(2482, 2583)], + "pages/popularity/page.js": [(2584, 2659)], + "pages/dragon-tiger/page.js": [(2660, 3028)], + "pages/review/page.js": [(3029, 3470), (7720, 7968)], + "pages/screener/page.js": [(3490, 4336), (6668, 6892)], + "pages/mentor/page.js": [(4337, 4827)], + "pages/heaven/page.js": [(4828, 6667)], + "shared/export.js": [(8905, 9051)], +} + + +def digest(content: str) -> str: + return hashlib.sha256(content.encode("utf-8")).hexdigest() + + +def marker(start: int, end: int, kind: str) -> str: + return f"/* PRESERVATION-SOURCE-{kind} app.js:{start}-{end} */" + + +def wrapped_chunk(lines: list[str], start: int, end: int) -> str: + content = "".join(lines[start - 1 : end]) + return f"{marker(start, end, 'BEGIN')}\n{content}{marker(start, end, 'END')}\n" + + +def complement_ranges(total: int, moved: set[int]) -> list[tuple[int, int]]: + ranges: list[tuple[int, int]] = [] + start = 0 + for number in range(1, total + 1): + if number in moved: + if start: + ranges.append((start, number - 1)) + start = 0 + elif not start: + start = number + if start: + ranges.append((start, total)) + return ranges + + +def original_prefix(relative: str) -> str: + source = ORIGINAL_STATIC / relative + if not source.is_file(): + return "" + return source.read_text(encoding="utf-8").rstrip("\n") + "\n\n" + + +def extract_written_chunks(paths: list[Path]) -> dict[tuple[int, int], str]: + pattern = re.compile( + r"/\* PRESERVATION-SOURCE-BEGIN app\.js:(\d+)-(\d+) \*/\n" + r"(.*?)" + r"/\* PRESERVATION-SOURCE-END app\.js:\1-\2 \*/\n?", + re.DOTALL, + ) + chunks: dict[tuple[int, int], str] = {} + for path in paths: + content = path.read_text(encoding="utf-8") + for match in pattern.finditer(content): + key = (int(match.group(1)), int(match.group(2))) + if key in chunks: + raise RuntimeError(f"Duplicate preserved range {key}") + chunks[key] = match.group(3) + return chunks + + +def main() -> None: + source_path = ORIGINAL_STATIC / "app.js" + target_path = FRONTEND_ROOT / "app.js" + source_bytes = source_path.read_bytes() + source_sha256 = hashlib.sha256(source_bytes).hexdigest() + if source_sha256 != EXPECTED_SOURCE_SHA256: + raise RuntimeError( + f"Original app.js changed: expected {EXPECTED_SOURCE_SHA256}, got {source_sha256}" + ) + if not target_path.is_file(): + raise RuntimeError(f"Missing target runtime: {target_path}") + if hashlib.sha256(target_path.read_bytes()).hexdigest() != EXPECTED_SOURCE_SHA256: + raise RuntimeError("Target app.js is not the exact pre-split original") + + source = source_bytes.decode("utf-8") + lines = source.splitlines(keepends=True) + moved_lines: set[int] = set() + for ranges in RUNTIME_RANGES.values(): + for start, end in ranges: + overlap = moved_lines.intersection(range(start, end + 1)) + if overlap: + raise RuntimeError(f"Overlapping source ranges at line {min(overlap)}") + moved_lines.update(range(start, end + 1)) + + core_ranges = complement_ranges(len(lines), moved_lines) + target_path.write_text( + "\n".join(wrapped_chunk(lines, start, end).rstrip("\n") for start, end in core_ranges) + + "\n", + encoding="utf-8", + ) + + written_paths = [target_path] + for relative, ranges in RUNTIME_RANGES.items(): + path = FRONTEND_ROOT / relative + path.parent.mkdir(parents=True, exist_ok=True) + chunks = "\n".join( + wrapped_chunk(lines, start, end).rstrip("\n") for start, end in ranges + ) + path.write_text(original_prefix(relative) + chunks + "\n", encoding="utf-8") + written_paths.append(path) + + extracted = extract_written_chunks(written_paths) + expected_ranges = { + source_range for ranges in RUNTIME_RANGES.values() for source_range in ranges + } | set(core_ranges) + if set(extracted) != expected_ranges: + raise RuntimeError("Written source ranges do not cover the original runtime exactly") + + reassembled = [""] * len(lines) + for (start, end), content in extracted.items(): + chunk_lines = content.splitlines(keepends=True) + if len(chunk_lines) != end - start + 1: + raise RuntimeError(f"Line count changed in preserved range {start}-{end}") + reassembled[start - 1 : end] = chunk_lines + reassembled_source = "".join(reassembled) + if reassembled_source != source: + raise RuntimeError("Split runtime cannot be reassembled byte-for-byte") + + manifest = { + "source": "static/app.js", + "source_sha256": source_sha256, + "source_line_count": len(lines), + "reassembled_sha256": digest(reassembled_source), + "all_source_lines_preserved": True, + "core": { + "target": "app/frontend/app.js", + "ranges": [{"start": start, "end": end} for start, end in core_ranges], + }, + "modules": [ + { + "target": f"app/frontend/{relative}", + "ranges": [ + { + "start": start, + "end": end, + "sha256": digest("".join(lines[start - 1 : end])), + } + for start, end in ranges + ], + } + for relative, ranges in RUNTIME_RANGES.items() + ], + } + EVIDENCE_PATH.parent.mkdir(parents=True, exist_ok=True) + EVIDENCE_PATH.write_text( + json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8" + ) + print(json.dumps(manifest, ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/docs/migration/evidence/slice-10/README.md b/docs/migration/evidence/slice-10/README.md new file mode 100644 index 0000000..4ad541f --- /dev/null +++ b/docs/migration/evidence/slice-10/README.md @@ -0,0 +1,66 @@ +# 切片 10:前端 Shell、页面、样式与移动端职责归位 + +> 基线:`38de3de`(切片 09) +> 回档标签:`xiaobai-preservation-slice-10-20260731` +> 结论:源码、静态资产、API、数据库、桌面/移动页面及动画差分通过;最终视觉仍等待全站人工验收 + +## 1. 原实现归位 + +本切片只移动和拆分原版前端源码,没有从`next/`取用代码,也没有重写页面、样式、动画、交互或响应式规则。 + +| 原位置 | 新位置 | 处理方式 | +|---|---|---| +| `static/index.html` | `app/frontend/index.html` | 原 DOM 骨架,仅调整静态资源路径和拆分脚本加载顺序 | +| `static/app.js` | `app/frontend/app.js`、`pages/*/page.js`、`pages/market/runtime.js`、`shared/export.js` | 按连续源码行机械拆分 | +| `static/styles.css`等公共样式 | `app/frontend/styles/` | 字节级移动 | +| `static/wentian-v2.css` | `app/frontend/pages/heaven/page.css` | 字节级移动 | +| `static/ui-core.js` | `app/frontend/shared/ui-core.js` | 字节级移动 | +| `static/heaven-loading-v2.js` | `app/frontend/pages/heaven/loading-v2.js` | 字节级移动 | +| `static/pages/`、`shared/`、`vendor/` | `app/frontend/`同职责目录 | 字节级移动 | + +`app/backend/bootstrap/config.py`现在只把静态根目录从`app/static/`切换到`app/frontend/`。原`app/static/`已不再存在,避免形成两套可被误改的前端实现。 + +## 2. 源码与静态资产等价 + +- 原`static/app.js`共 9,283 行,SHA-256 为`c020d0bc68f8a96b352a0ffa62c913a0ea3f5ca0afc41f70969f833e447166a6`。 +- 所有拆分文件中的保真源码片段按原行号重组后,SHA-256仍为同一值,9,283行无遗漏、无重排、无内容变化。 +- 七层原版样式、问天样式、加载动画、共享脚本、页面注册脚本及Lucide供应商文件在移动后保持字节一致。 +- `index.html`的差异仅限资源新路径和两个机械拆分脚本入口;反向替换后与原版逐字符一致。 +- 浏览器请求仍只有`shared/api.js`一个`fetch`出口。 +- 完整行号映射见`frontend-source-map.json`;拆分工具为`app/tools/split_frontend_runtime.py`,可重复验证但不用于运行时构建。 + +## 3. API 与数据库差分 + +- 同一管理员账号和同一固定日期下,对原版与迁移版执行21个全站只读请求。 +- 请求覆盖账号、行情、情绪、轮动、竞价、题材、人气、龙虎榜、选股、问师、问天、复盘与提醒;状态码和规范化业务JSON全部一致。 +- 两个数据库副本均包含62个schema对象,schema哈希一致。 +- 21张关键表的记录数、顺序和规范化内容逐行一致;`screener_strategies`仅排除每次初始化可能更新的`updated_at`。 +- 完整结果见`api-requests.json`、`api-diff.json`和`database-diff.json`,两份差分文件的`all_equal`均为`true`。 + +## 4. 真实浏览器差分 + +- 1920×1080日间模式检查情绪周期、集合竞价、智能选股、观势、观气、观心介绍及观心呼吸。 +- 1920×1080夜间模式检查情绪周期的背景、字体、表格、几何和横向溢出。 +- 390×844检查情绪周期与观势:移动Shell、底部五入口、页面纵向滚动、无横向溢出及问天特效均一致。 +- 观势星空节点90个、观气100个、观心110个;三页八卦节点均为2个。 +- 观势与观气保留`wt-tw`和`wt-spin`动画;观心呼吸保留`heart-incense-burn`、`heart-incense-glow`及动态波纹。 +- 观气五行行业为5组且默认折叠;观心介绍到呼吸流程可正常进入。 +- 原版和迁移版检查流程均未产生新增控制台error或warn。 +- 动画帧、焦点框和动态状态文字属于采样瞬时状态,因此截图文件哈希不要求相同;可见布局几何、计算样式、节点、动画名称和交互结果必须一致,本次均通过。 +- 两个服务使用相同主机名、不同端口时会共享并覆盖登录Cookie;曾导致迁移版切页被误判为失效。逐服务重新登录后行为一致,该问题属于并行验收环境限制,不是产品回归。 +- 机器可读记录见`browser-acceptance.json`,截图均保存在本目录。 + +## 5. 自动验证与保留边界 + +| 验证 | 结果 | +|---|---:| +| 前端专项测试 | 85项通过 | +| 原版`python -m unittest discover -s tests -q` | 231项通过 | +| 迁移版`python -m unittest discover -s tests -q` | 294项通过 | +| `npx.cmd playwright test --reporter=dot` | 45项通过(2.3分钟) | +| 全部拆分后JavaScript执行`node --check` | 通过 | +| `git diff --check` | 通过 | + +- `demo_data.py`、`frontend/heaven-loading.js`、五个疑似无引用前端函数和`wencai_saved_queries`继续保留到切片11逐项审计。 +- 没有修改`next/`、正式数据库、Docker/NAS或`8765`服务。 +- 自动差分只能证明已检查范围一致;依据迁移总纲,未经用户最终人工确认不得宣称全站视觉已经完全等价。 diff --git a/docs/migration/evidence/slice-10/api-diff.json b/docs/migration/evidence/slice-10/api-diff.json new file mode 100644 index 0000000..aee58c5 --- /dev/null +++ b/docs/migration/evidence/slice-10/api-diff.json @@ -0,0 +1,236 @@ +{ + "all_equal": true, + "endpoints": [ + { + "name": "当前账号", + "method": "GET", + "endpoint": "/api/auth/me", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "33e0c08463d5f14a142840f894d92985135c6ce2bee22c93c4dde34dc433cf5b", + "migrated_sha256": "33e0c08463d5f14a142840f894d92985135c6ce2bee22c93c4dde34dc433cf5b", + "equal": true, + "first_difference": null + }, + { + "name": "账号状态", + "method": "GET", + "endpoint": "/api/account/status", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "a1f81967a58dae0947191fe1a7465c87e70403339e36897329de94c541272e05", + "migrated_sha256": "a1f81967a58dae0947191fe1a7465c87e70403339e36897329de94c541272e05", + "equal": true, + "first_difference": null + }, + { + "name": "全市场总览", + "method": "GET", + "endpoint": "/api/dashboard?trade_date=2026-07-30", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "565730fb75cec6f6758321074dfeba7bff19db6611476d511f9bc7257a5870f5", + "migrated_sha256": "565730fb75cec6f6758321074dfeba7bff19db6611476d511f9bc7257a5870f5", + "equal": true, + "first_difference": null + }, + { + "name": "情绪周期历史", + "method": "GET", + "endpoint": "/api/sentiment/history?trade_date=2026-07-30&limit=9", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "1a577fb859a2fd06749169e1d0f08f78b51c5217b55506c7903487a956b6192b", + "migrated_sha256": "1a577fb859a2fd06749169e1d0f08f78b51c5217b55506c7903487a956b6192b", + "equal": true, + "first_difference": null + }, + { + "name": "板块轮动历史", + "method": "GET", + "endpoint": "/api/rotation/history?trade_date=2026-07-30&limit=9", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "eaf3ac41f479c0f57f161546f197c1321721b4a8390486a916232fe6ea1d3d37", + "migrated_sha256": "eaf3ac41f479c0f57f161546f197c1321721b4a8390486a916232fe6ea1d3d37", + "equal": true, + "first_difference": null + }, + { + "name": "集合竞价", + "method": "GET", + "endpoint": "/api/auction?trade_date=2026-07-30", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "bd75b100485c0afa0cdddc73d1880160eea8c0b8c91f556245c665d591285ed6", + "migrated_sha256": "bd75b100485c0afa0cdddc73d1880160eea8c0b8c91f556245c665d591285ed6", + "equal": true, + "first_difference": null + }, + { + "name": "题材库", + "method": "GET", + "endpoint": "/api/themes?trade_date=2026-07-30", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "f45e218d9bff5191445fe45b03fc62f3d9c440b8353404ae4b54901c20929552", + "migrated_sha256": "f45e218d9bff5191445fe45b03fc62f3d9c440b8353404ae4b54901c20929552", + "equal": true, + "first_difference": null + }, + { + "name": "人气热榜", + "method": "GET", + "endpoint": "/api/popularity?trade_date=2026-07-30", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "69987a80f02ac02bf9ff49b3a0a99782385f1933c643c9e3dbb5a75a1e20dd29", + "migrated_sha256": "69987a80f02ac02bf9ff49b3a0a99782385f1933c643c9e3dbb5a75a1e20dd29", + "equal": true, + "first_difference": null + }, + { + "name": "龙虎榜", + "method": "GET", + "endpoint": "/api/dragon-tiger?trade_date=2026-07-30", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "173baf4e1f0b56678aef3f74276f29af49271ea34c89c0c18283bfe27f5cf2a6", + "migrated_sha256": "173baf4e1f0b56678aef3f74276f29af49271ea34c89c0c18283bfe27f5cf2a6", + "equal": true, + "first_difference": null + }, + { + "name": "游资档案", + "method": "GET", + "endpoint": "/api/dragon-tiger/profiles", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "dfb1e534c018ec12fd8d8ee0e7fe0f234e73dc7715d7f0a948f74ef8d12d779a", + "migrated_sha256": "dfb1e534c018ec12fd8d8ee0e7fe0f234e73dc7715d7f0a948f74ef8d12d779a", + "equal": true, + "first_difference": null + }, + { + "name": "智能选股工作区", + "method": "GET", + "endpoint": "/api/screener/setup?trade_date=2026-07-30", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "e33a10e94accda4948c2af53887ea97736697f588ee1a92f561aa5c4bb28c2fd", + "migrated_sha256": "e33a10e94accda4948c2af53887ea97736697f588ee1a92f561aa5c4bb28c2fd", + "equal": true, + "first_difference": null + }, + { + "name": "策略持续跟踪", + "method": "GET", + "endpoint": "/api/screener/tracking?limit=12", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "f4476f53152b6a2ce01f2918da5999246cfd0ad7d0b71e80fdea8fa59c7456c2", + "migrated_sha256": "f4476f53152b6a2ce01f2918da5999246cfd0ad7d0b71e80fdea8fa59c7456c2", + "equal": true, + "first_difference": null + }, + { + "name": "问师模型库", + "method": "GET", + "endpoint": "/api/mentors/setup?trade_date=2026-07-30", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "0ff7794fe45f49ab980221fa855b64c70d5c9b83c78ef86edc041e713490b626", + "migrated_sha256": "0ff7794fe45f49ab980221fa855b64c70d5c9b83c78ef86edc041e713490b626", + "equal": true, + "first_difference": null + }, + { + "name": "问师历史", + "method": "GET", + "endpoint": "/api/mentors/messages?mentor_id=kobe92-perspective&trade_date=20260730", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "eef46741adfc3a9f76294d3b78f37a45f113092ac9d44ee77c7a038a88ff09a1", + "migrated_sha256": "eef46741adfc3a9f76294d3b78f37a45f113092ac9d44ee77c7a038a88ff09a1", + "equal": true, + "first_difference": null + }, + { + "name": "问天初始化", + "method": "GET", + "endpoint": "/api/heaven/setup?trade_date=2026-07-30", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "c814c2004c7dd672c397887725af000c150693a50900bb0ce1aa94599af1a3c8", + "migrated_sha256": "c814c2004c7dd672c397887725af000c150693a50900bb0ce1aa94599af1a3c8", + "equal": true, + "first_difference": null + }, + { + "name": "问天历史", + "method": "GET", + "endpoint": "/api/heaven/readings?mode=trend&limit=20", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "2aa83ea177bd5cb2351ac4f3a60fdcd17e02cfc14b5753e157e515f03fc2c6c1", + "migrated_sha256": "2aa83ea177bd5cb2351ac4f3a60fdcd17e02cfc14b5753e157e515f03fc2c6c1", + "equal": true, + "first_difference": null + }, + { + "name": "自选追踪", + "method": "GET", + "endpoint": "/api/watchlist?trade_date=2026-07-30", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "29c001cc6859312fd90082d0fb83818d263827e28ec21157b94c415b71c45078", + "migrated_sha256": "29c001cc6859312fd90082d0fb83818d263827e28ec21157b94c415b71c45078", + "equal": true, + "first_difference": null + }, + { + "name": "全部复盘笔记", + "method": "GET", + "endpoint": "/api/notes?scope=all", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "a2a1f08610d017de0e46080a1268f72b734eeb80f9cada311d7e03eb1416656a", + "migrated_sha256": "a2a1f08610d017de0e46080a1268f72b734eeb80f9cada311d7e03eb1416656a", + "equal": true, + "first_difference": null + }, + { + "name": "交易日志", + "method": "GET", + "endpoint": "/api/trades?end_date=2026-07-30", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "77287b34046e22748e94e61ead841e157f6b63f08de1b2fe61f5d715b2e989a4", + "migrated_sha256": "77287b34046e22748e94e61ead841e157f6b63f08de1b2fe61f5d715b2e989a4", + "equal": true, + "first_difference": null + }, + { + "name": "提醒中心", + "method": "GET", + "endpoint": "/api/alerts?status=all&as_of=2026-07-30", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "495a1f83ed661913807899e0955ef1b89193c30bc9a5be0587f7f03d60411d95", + "migrated_sha256": "495a1f83ed661913807899e0955ef1b89193c30bc9a5be0587f7f03d60411d95", + "equal": true, + "first_difference": null + }, + { + "name": "复盘助手历史", + "method": "GET", + "endpoint": "/api/assistant/messages", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "eef46741adfc3a9f76294d3b78f37a45f113092ac9d44ee77c7a038a88ff09a1", + "migrated_sha256": "eef46741adfc3a9f76294d3b78f37a45f113092ac9d44ee77c7a038a88ff09a1", + "equal": true, + "first_difference": null + } + ] +} diff --git a/docs/migration/evidence/slice-10/api-requests.json b/docs/migration/evidence/slice-10/api-requests.json new file mode 100644 index 0000000..dcf28c1 --- /dev/null +++ b/docs/migration/evidence/slice-10/api-requests.json @@ -0,0 +1,29 @@ +[ + {"name": "当前账号", "method": "GET", "endpoint": "/api/auth/me", "exclude_paths": ["$.csrf_token"]}, + {"name": "账号状态", "method": "GET", "endpoint": "/api/account/status"}, + {"name": "全市场总览", "method": "GET", "endpoint": "/api/dashboard?trade_date=2026-07-30"}, + {"name": "情绪周期历史", "method": "GET", "endpoint": "/api/sentiment/history?trade_date=2026-07-30&limit=9"}, + {"name": "板块轮动历史", "method": "GET", "endpoint": "/api/rotation/history?trade_date=2026-07-30&limit=9"}, + {"name": "集合竞价", "method": "GET", "endpoint": "/api/auction?trade_date=2026-07-30", "exclude_paths": ["$.meta.updated_at"]}, + {"name": "题材库", "method": "GET", "endpoint": "/api/themes?trade_date=2026-07-30"}, + {"name": "人气热榜", "method": "GET", "endpoint": "/api/popularity?trade_date=2026-07-30"}, + {"name": "龙虎榜", "method": "GET", "endpoint": "/api/dragon-tiger?trade_date=2026-07-30"}, + {"name": "游资档案", "method": "GET", "endpoint": "/api/dragon-tiger/profiles"}, + { + "name": "智能选股工作区", + "method": "GET", + "endpoint": "/api/screener/setup?trade_date=2026-07-30", + "sort_lists": {"$.strategies": "id"}, + "exclude_paths": ["$.strategies[].updated_at"] + }, + {"name": "策略持续跟踪", "method": "GET", "endpoint": "/api/screener/tracking?limit=12"}, + {"name": "问师模型库", "method": "GET", "endpoint": "/api/mentors/setup?trade_date=2026-07-30"}, + {"name": "问师历史", "method": "GET", "endpoint": "/api/mentors/messages?mentor_id=kobe92-perspective&trade_date=20260730"}, + {"name": "问天初始化", "method": "GET", "endpoint": "/api/heaven/setup?trade_date=2026-07-30"}, + {"name": "问天历史", "method": "GET", "endpoint": "/api/heaven/readings?mode=trend&limit=20"}, + {"name": "自选追踪", "method": "GET", "endpoint": "/api/watchlist?trade_date=2026-07-30"}, + {"name": "全部复盘笔记", "method": "GET", "endpoint": "/api/notes?scope=all"}, + {"name": "交易日志", "method": "GET", "endpoint": "/api/trades?end_date=2026-07-30"}, + {"name": "提醒中心", "method": "GET", "endpoint": "/api/alerts?status=all&as_of=2026-07-30"}, + {"name": "复盘助手历史", "method": "GET", "endpoint": "/api/assistant/messages"} +] diff --git a/docs/migration/evidence/slice-10/browser-acceptance.json b/docs/migration/evidence/slice-10/browser-acceptance.json new file mode 100644 index 0000000..c37e3cb --- /dev/null +++ b/docs/migration/evidence/slice-10/browser-acceptance.json @@ -0,0 +1,93 @@ +{ + "schema_version": 1, + "tested_at": "2026-07-31T12:00:00+08:00", + "result": "passed", + "environments": { + "original": "temporary_original_runtime", + "migrated": "temporary_app_runtime", + "account": "administrator", + "database": "isolated_copies" + }, + "viewports": [ + { + "width": 1920, + "height": 1080, + "theme": "light", + "views": [ + "sentiment", + "auction", + "screener", + "heaven_trend", + "heaven_fortune", + "heaven_heart_intro", + "heaven_heart_breath" + ], + "equal": true + }, + { + "width": 1920, + "height": 1080, + "theme": "dark", + "views": [ + "sentiment" + ], + "equal": true + }, + { + "width": 390, + "height": 844, + "theme": "light", + "views": [ + "sentiment", + "heaven_trend" + ], + "equal": true + } + ], + "heaven_animation_contract": { + "trend_star_nodes": 90, + "fortune_star_nodes": 100, + "heart_star_nodes": 110, + "bagua_nodes_per_view": 2, + "trend_and_fortune_animations": [ + "wt-tw", + "wt-spin" + ], + "heart_breath_animations": [ + "heart-incense-burn", + "heart-incense-glow" + ], + "fortune_industry_groups": 5, + "fortune_industries_collapsed_by_default": true, + "equal": true + }, + "mobile_contract": { + "primary_navigation_items": 5, + "horizontal_overflow": false, + "vertical_page_scroll": true, + "heaven_trend_geometry": { + "active": { + "height": 723, + "width": 374, + "x": 8, + "y": 50 + }, + "header": { + "width": 390, + "height": 50 + }, + "bottom_navigation": { + "width": 390, + "height": 58 + } + }, + "equal": true + }, + "console": { + "new_errors": 0, + "new_warnings": 0 + }, + "screenshot_policy": "Dynamic animation frames, focus outlines, and live status text may change pixel hashes. Acceptance compares visible geometry, computed styles, DOM state, animation names, interaction results, and overflow behavior.", + "known_test_environment_constraint": "Original and migrated services on the same hostname share cookies across ports. Each service must be logged in separately immediately before comparison.", + "all_equal": true +} diff --git a/docs/migration/evidence/slice-10/database-diff.json b/docs/migration/evidence/slice-10/database-diff.json new file mode 100644 index 0000000..e32d16a --- /dev/null +++ b/docs/migration/evidence/slice-10/database-diff.json @@ -0,0 +1,202 @@ +{ + "all_equal": true, + "schema": { + "object_count": 62, + "original_sha256": "60a4e044f0ddb6502e44b45f65bedc1a0a31d4bd596f386d4ccfe153a6c8ddd1", + "migrated_sha256": "60a4e044f0ddb6502e44b45f65bedc1a0a31d4bd596f386d4ccfe153a6c8ddd1", + "equal": true + }, + "tables": [ + { + "table": "users", + "original_count": 3, + "migrated_count": 3, + "original_sha256": "4a0f135bef8ebae454693d3f40e1157d84d814d18d849f33e2e760ed1d8a7f89", + "migrated_sha256": "4a0f135bef8ebae454693d3f40e1157d84d814d18d849f33e2e760ed1d8a7f89", + "equal": true, + "excluded_columns": [] + }, + { + "table": "system_settings", + "original_count": 1, + "migrated_count": 1, + "original_sha256": "86d333a5c7feaf7111cd79b2db29513326a9b0d3781607f78bf633579e2ca8e9", + "migrated_sha256": "86d333a5c7feaf7111cd79b2db29513326a9b0d3781607f78bf633579e2ca8e9", + "equal": true, + "excluded_columns": [] + }, + { + "table": "watchlist", + "original_count": 6, + "migrated_count": 6, + "original_sha256": "070161f5cdcc33addf289f11cb0ae54bfc0d669a39426fc33398b64512dba2fe", + "migrated_sha256": "070161f5cdcc33addf289f11cb0ae54bfc0d669a39426fc33398b64512dba2fe", + "equal": true, + "excluded_columns": [] + }, + { + "table": "review_notes", + "original_count": 3, + "migrated_count": 3, + "original_sha256": "86ed485c2ce922e3434f8ac742d38431ef430b5c22024cacb6f64f0e8e71ee98", + "migrated_sha256": "86ed485c2ce922e3434f8ac742d38431ef430b5c22024cacb6f64f0e8e71ee98", + "equal": true, + "excluded_columns": [] + }, + { + "table": "trade_entries", + "original_count": 0, + "migrated_count": 0, + "original_sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "migrated_sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "equal": true, + "excluded_columns": [] + }, + { + "table": "alerts", + "original_count": 2, + "migrated_count": 2, + "original_sha256": "40b1ee5678b32f29ae356c1b30958e66138d34afd2aa6b8364fa02c23df66553", + "migrated_sha256": "40b1ee5678b32f29ae356c1b30958e66138d34afd2aa6b8364fa02c23df66553", + "equal": true, + "excluded_columns": [] + }, + { + "table": "assistant_messages", + "original_count": 0, + "migrated_count": 0, + "original_sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "migrated_sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "equal": true, + "excluded_columns": [] + }, + { + "table": "mentor_messages", + "original_count": 28, + "migrated_count": 28, + "original_sha256": "b906b776d575ff6fe459b2aa0e8b396267feed36d3d85f7edd7947b284482aeb", + "migrated_sha256": "b906b776d575ff6fe459b2aa0e8b396267feed36d3d85f7edd7947b284482aeb", + "equal": true, + "excluded_columns": [] + }, + { + "table": "mentor_preferences", + "original_count": 45, + "migrated_count": 45, + "original_sha256": "c19c7d05e6745e4f1787d1eed5f39d7e555ec24f8e490b585c19158edc2505ec", + "migrated_sha256": "c19c7d05e6745e4f1787d1eed5f39d7e555ec24f8e490b585c19158edc2505ec", + "equal": true, + "excluded_columns": [] + }, + { + "table": "heaven_readings", + "original_count": 31, + "migrated_count": 31, + "original_sha256": "e6cfc86f010fb182d7522bb99e5d268e826f93896035172120df51a011590951", + "migrated_sha256": "e6cfc86f010fb182d7522bb99e5d268e826f93896035172120df51a011590951", + "equal": true, + "excluded_columns": [] + }, + { + "table": "user_birth_profiles", + "original_count": 1, + "migrated_count": 1, + "original_sha256": "2a74d4b2edd3a730a46f17fb6ab3926575c42662c1d968643b0d54586ab68b13", + "migrated_sha256": "2a74d4b2edd3a730a46f17fb6ab3926575c42662c1d968643b0d54586ab68b13", + "equal": true, + "excluded_columns": [] + }, + { + "table": "sector_phase_overrides", + "original_count": 0, + "migrated_count": 0, + "original_sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "migrated_sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "equal": true, + "excluded_columns": [] + }, + { + "table": "screener_strategies", + "original_count": 36, + "migrated_count": 36, + "original_sha256": "872846dcb746446bda37723e63db737a2a0cf0540eb6a1da264012248887f4c5", + "migrated_sha256": "872846dcb746446bda37723e63db737a2a0cf0540eb6a1da264012248887f4c5", + "equal": true, + "excluded_columns": [ + "updated_at" + ] + }, + { + "table": "screener_runs", + "original_count": 250, + "migrated_count": 250, + "original_sha256": "9f58ff2f85e5b85878153f7694161620d77cb9b0f351754f2974a8a0cc8ad8fb", + "migrated_sha256": "9f58ff2f85e5b85878153f7694161620d77cb9b0f351754f2974a8a0cc8ad8fb", + "equal": true, + "excluded_columns": [] + }, + { + "table": "strategy_tracks", + "original_count": 16, + "migrated_count": 16, + "original_sha256": "f0639b1386b69c92fd815f929864b3b2457a6bf4ca515bee2dca87e9ea29e8c4", + "migrated_sha256": "f0639b1386b69c92fd815f929864b3b2457a6bf4ca515bee2dca87e9ea29e8c4", + "equal": true, + "excluded_columns": [] + }, + { + "table": "auction_factors", + "original_count": 511914, + "migrated_count": 511914, + "original_sha256": "3d0470787adaf7c4cf5f15f5ad9fa1d67c8fcd4807285ac264eeacdcf5054cd1", + "migrated_sha256": "3d0470787adaf7c4cf5f15f5ad9fa1d67c8fcd4807285ac264eeacdcf5054cd1", + "equal": true, + "excluded_columns": [] + }, + { + "table": "popularity_factors", + "original_count": 232, + "migrated_count": 232, + "original_sha256": "3f4c61a13ceaa1ed9ecb28f86241a8a478a6757d6a44b46f432f73c0226bba7f", + "migrated_sha256": "3f4c61a13ceaa1ed9ecb28f86241a8a478a6757d6a44b46f432f73c0226bba7f", + "equal": true, + "excluded_columns": [] + }, + { + "table": "seat_aliases", + "original_count": 0, + "migrated_count": 0, + "original_sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "migrated_sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "equal": true, + "excluded_columns": [] + }, + { + "table": "reason_overrides", + "original_count": 0, + "migrated_count": 0, + "original_sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "migrated_sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "equal": true, + "excluded_columns": [] + }, + { + "table": "llm_usage", + "original_count": 72, + "migrated_count": 72, + "original_sha256": "03a070316a125cef904bbfb2bb06b06e792242d5713142e70354402c741393c3", + "migrated_sha256": "03a070316a125cef904bbfb2bb06b06e792242d5713142e70354402c741393c3", + "equal": true, + "excluded_columns": [] + }, + { + "table": "wencai_saved_queries", + "original_count": 0, + "migrated_count": 0, + "original_sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "migrated_sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "equal": true, + "excluded_columns": [] + } + ] +} diff --git a/docs/migration/evidence/slice-10/frontend-migrated-auction-light-1920x1080.png b/docs/migration/evidence/slice-10/frontend-migrated-auction-light-1920x1080.png new file mode 100644 index 0000000..e35ebf7 Binary files /dev/null and b/docs/migration/evidence/slice-10/frontend-migrated-auction-light-1920x1080.png differ diff --git a/docs/migration/evidence/slice-10/frontend-migrated-dark-sentiment-1920x1080.png b/docs/migration/evidence/slice-10/frontend-migrated-dark-sentiment-1920x1080.png new file mode 100644 index 0000000..8f7e22f Binary files /dev/null and b/docs/migration/evidence/slice-10/frontend-migrated-dark-sentiment-1920x1080.png differ diff --git a/docs/migration/evidence/slice-10/frontend-migrated-dark-sentiment-390x844.png b/docs/migration/evidence/slice-10/frontend-migrated-dark-sentiment-390x844.png new file mode 100644 index 0000000..f0c8a84 Binary files /dev/null and b/docs/migration/evidence/slice-10/frontend-migrated-dark-sentiment-390x844.png differ diff --git a/docs/migration/evidence/slice-10/frontend-migrated-heaven-fortune-light-1920x1080.png b/docs/migration/evidence/slice-10/frontend-migrated-heaven-fortune-light-1920x1080.png new file mode 100644 index 0000000..11fddec Binary files /dev/null and b/docs/migration/evidence/slice-10/frontend-migrated-heaven-fortune-light-1920x1080.png differ diff --git a/docs/migration/evidence/slice-10/frontend-migrated-heaven-heart-breath-light-1920x1080.png b/docs/migration/evidence/slice-10/frontend-migrated-heaven-heart-breath-light-1920x1080.png new file mode 100644 index 0000000..ce8edb4 Binary files /dev/null and b/docs/migration/evidence/slice-10/frontend-migrated-heaven-heart-breath-light-1920x1080.png differ diff --git a/docs/migration/evidence/slice-10/frontend-migrated-heaven-heart-intro-light-1920x1080.png b/docs/migration/evidence/slice-10/frontend-migrated-heaven-heart-intro-light-1920x1080.png new file mode 100644 index 0000000..1d57982 Binary files /dev/null and b/docs/migration/evidence/slice-10/frontend-migrated-heaven-heart-intro-light-1920x1080.png differ diff --git a/docs/migration/evidence/slice-10/frontend-migrated-heaven-trend-390x844.png b/docs/migration/evidence/slice-10/frontend-migrated-heaven-trend-390x844.png new file mode 100644 index 0000000..81581d5 Binary files /dev/null and b/docs/migration/evidence/slice-10/frontend-migrated-heaven-trend-390x844.png differ diff --git a/docs/migration/evidence/slice-10/frontend-migrated-heaven-trend-light-1920x1080.png b/docs/migration/evidence/slice-10/frontend-migrated-heaven-trend-light-1920x1080.png new file mode 100644 index 0000000..45fdc51 Binary files /dev/null and b/docs/migration/evidence/slice-10/frontend-migrated-heaven-trend-light-1920x1080.png differ diff --git a/docs/migration/evidence/slice-10/frontend-migrated-light-1920x1080.png b/docs/migration/evidence/slice-10/frontend-migrated-light-1920x1080.png new file mode 100644 index 0000000..438b6ac Binary files /dev/null and b/docs/migration/evidence/slice-10/frontend-migrated-light-1920x1080.png differ diff --git a/docs/migration/evidence/slice-10/frontend-migrated-screener-light-1920x1080.png b/docs/migration/evidence/slice-10/frontend-migrated-screener-light-1920x1080.png new file mode 100644 index 0000000..e4426f3 Binary files /dev/null and b/docs/migration/evidence/slice-10/frontend-migrated-screener-light-1920x1080.png differ diff --git a/docs/migration/evidence/slice-10/frontend-original-auction-light-1920x1080.png b/docs/migration/evidence/slice-10/frontend-original-auction-light-1920x1080.png new file mode 100644 index 0000000..c1b7ead Binary files /dev/null and b/docs/migration/evidence/slice-10/frontend-original-auction-light-1920x1080.png differ diff --git a/docs/migration/evidence/slice-10/frontend-original-dark-sentiment-1920x1080.png b/docs/migration/evidence/slice-10/frontend-original-dark-sentiment-1920x1080.png new file mode 100644 index 0000000..49884f9 Binary files /dev/null and b/docs/migration/evidence/slice-10/frontend-original-dark-sentiment-1920x1080.png differ diff --git a/docs/migration/evidence/slice-10/frontend-original-dark-sentiment-390x844.png b/docs/migration/evidence/slice-10/frontend-original-dark-sentiment-390x844.png new file mode 100644 index 0000000..8d89b46 Binary files /dev/null and b/docs/migration/evidence/slice-10/frontend-original-dark-sentiment-390x844.png differ diff --git a/docs/migration/evidence/slice-10/frontend-original-heaven-fortune-light-1920x1080.png b/docs/migration/evidence/slice-10/frontend-original-heaven-fortune-light-1920x1080.png new file mode 100644 index 0000000..af8b182 Binary files /dev/null and b/docs/migration/evidence/slice-10/frontend-original-heaven-fortune-light-1920x1080.png differ diff --git a/docs/migration/evidence/slice-10/frontend-original-heaven-heart-breath-light-1920x1080.png b/docs/migration/evidence/slice-10/frontend-original-heaven-heart-breath-light-1920x1080.png new file mode 100644 index 0000000..0af84ce Binary files /dev/null and b/docs/migration/evidence/slice-10/frontend-original-heaven-heart-breath-light-1920x1080.png differ diff --git a/docs/migration/evidence/slice-10/frontend-original-heaven-heart-intro-light-1920x1080.png b/docs/migration/evidence/slice-10/frontend-original-heaven-heart-intro-light-1920x1080.png new file mode 100644 index 0000000..16a753d Binary files /dev/null and b/docs/migration/evidence/slice-10/frontend-original-heaven-heart-intro-light-1920x1080.png differ diff --git a/docs/migration/evidence/slice-10/frontend-original-heaven-trend-390x844.png b/docs/migration/evidence/slice-10/frontend-original-heaven-trend-390x844.png new file mode 100644 index 0000000..c2df7bc Binary files /dev/null and b/docs/migration/evidence/slice-10/frontend-original-heaven-trend-390x844.png differ diff --git a/docs/migration/evidence/slice-10/frontend-original-heaven-trend-light-1920x1080.png b/docs/migration/evidence/slice-10/frontend-original-heaven-trend-light-1920x1080.png new file mode 100644 index 0000000..769014e Binary files /dev/null and b/docs/migration/evidence/slice-10/frontend-original-heaven-trend-light-1920x1080.png differ diff --git a/docs/migration/evidence/slice-10/frontend-original-light-1920x1080.png b/docs/migration/evidence/slice-10/frontend-original-light-1920x1080.png new file mode 100644 index 0000000..d79a397 Binary files /dev/null and b/docs/migration/evidence/slice-10/frontend-original-light-1920x1080.png differ diff --git a/docs/migration/evidence/slice-10/frontend-original-screener-light-1920x1080.png b/docs/migration/evidence/slice-10/frontend-original-screener-light-1920x1080.png new file mode 100644 index 0000000..738fcd1 Binary files /dev/null and b/docs/migration/evidence/slice-10/frontend-original-screener-light-1920x1080.png differ diff --git a/docs/migration/evidence/slice-10/frontend-source-map.json b/docs/migration/evidence/slice-10/frontend-source-map.json new file mode 100644 index 0000000..262782e --- /dev/null +++ b/docs/migration/evidence/slice-10/frontend-source-map.json @@ -0,0 +1,190 @@ +{ + "source": "static/app.js", + "source_sha256": "c020d0bc68f8a96b352a0ffa62c913a0ea3f5ca0afc41f70969f833e447166a6", + "source_line_count": 9283, + "reassembled_sha256": "c020d0bc68f8a96b352a0ffa62c913a0ea3f5ca0afc41f70969f833e447166a6", + "all_source_lines_preserved": true, + "core": { + "target": "app/frontend/app.js", + "ranges": [ + { + "start": 1, + "end": 1206 + }, + { + "start": 3471, + "end": 3489 + }, + { + "start": 8427, + "end": 8904 + }, + { + "start": 9052, + "end": 9283 + } + ] + }, + "modules": [ + { + "target": "app/frontend/pages/sentiment/page.js", + "ranges": [ + { + "start": 1207, + "end": 1516, + "sha256": "70a884362b3f97ceb28e2f3a83a70ce26e61c79e23bfeee62b301b9068f2b69c" + } + ] + }, + { + "target": "app/frontend/pages/pools/page.js", + "ranges": [ + { + "start": 1517, + "end": 1915, + "sha256": "ff8623609c1ef6eb793deaef2b627746727a4768962711d1712e35833b4f58c3" + } + ] + }, + { + "target": "app/frontend/pages/market/runtime.js", + "ranges": [ + { + "start": 1916, + "end": 1957, + "sha256": "703dafd0417926df8572a885ffa32a79ee16346019f0e14fcee9e03ad1f1baf7" + }, + { + "start": 6893, + "end": 7719, + "sha256": "a670986ac2099a181f5f348cd48bc4777da0d4709b3ac79239067ff5666261fb" + }, + { + "start": 7969, + "end": 8426, + "sha256": "467409c2cfc06ee96bb7f422be55f79920c3a9936229b2234dd8f78b537e7f26" + } + ] + }, + { + "target": "app/frontend/pages/rotation/page.js", + "ranges": [ + { + "start": 1958, + "end": 2124, + "sha256": "33f536213db33be0934527f13527712a9d521b5f89a5784b42887c5bf8631f2f" + } + ] + }, + { + "target": "app/frontend/pages/ladder/page.js", + "ranges": [ + { + "start": 2125, + "end": 2216, + "sha256": "2be84071921910acbec2328f1f10dd8565420ea03a1930ff255040bf99d679a8" + } + ] + }, + { + "target": "app/frontend/pages/auction/page.js", + "ranges": [ + { + "start": 2217, + "end": 2481, + "sha256": "7f16901ae8769b9570693211022538feb02f2cda4bffe6fa95ba6ce60c10b012" + } + ] + }, + { + "target": "app/frontend/pages/themes/page.js", + "ranges": [ + { + "start": 2482, + "end": 2583, + "sha256": "c134d762bda669483f0c094efae9281919b68b2ffb83c9457670bc9013ce1515" + } + ] + }, + { + "target": "app/frontend/pages/popularity/page.js", + "ranges": [ + { + "start": 2584, + "end": 2659, + "sha256": "db72e42385e4a4bd4314ab471b27c1bb804f13650ee463f0843d783b0ba50bff" + } + ] + }, + { + "target": "app/frontend/pages/dragon-tiger/page.js", + "ranges": [ + { + "start": 2660, + "end": 3028, + "sha256": "b33ccad80ce997bddd75e4025eec35aa246fa5e9f5ca9ccd5f17e587885274cd" + } + ] + }, + { + "target": "app/frontend/pages/review/page.js", + "ranges": [ + { + "start": 3029, + "end": 3470, + "sha256": "10dba1966b32941cb28ac46d1cb1b20ae74ea271fd7be22a23b46d290ece80ef" + }, + { + "start": 7720, + "end": 7968, + "sha256": "3af64079f997fabbf0ff4023088eb62f94c0091a823bd6d65c1f9e485d1c5ad8" + } + ] + }, + { + "target": "app/frontend/pages/screener/page.js", + "ranges": [ + { + "start": 3490, + "end": 4336, + "sha256": "76dfdd5610453f0abb5899baba9d751b5f597450ec656bf10580ce91447a68b9" + }, + { + "start": 6668, + "end": 6892, + "sha256": "a7d0fa98b07516169a8e46c2ae2f6efbaeab651887c479919210835f0877533e" + } + ] + }, + { + "target": "app/frontend/pages/mentor/page.js", + "ranges": [ + { + "start": 4337, + "end": 4827, + "sha256": "b2067c062fb6cb539dd6402074d02f1ae65903957106d7ebf8d63bc5487f244c" + } + ] + }, + { + "target": "app/frontend/pages/heaven/page.js", + "ranges": [ + { + "start": 4828, + "end": 6667, + "sha256": "57bd47c239c4ee9302c7cd7a1f86a5d0932df6065148a34bafe0c49bef644cb1" + } + ] + }, + { + "target": "app/frontend/shared/export.js", + "ranges": [ + { + "start": 8905, + "end": 9051, + "sha256": "17b867189087615a4414da4f9db3862306e05cb527c25c794beb9c4c74b583ab" + } + ] + } + ] +} diff --git a/docs/migration/保真迁移状态.json b/docs/migration/保真迁移状态.json index 1cf0da8..a70cc0b 100644 --- a/docs/migration/保真迁移状态.json +++ b/docs/migration/保真迁移状态.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "updated_at": "2026-07-31T11:52:29+08:00", + "updated_at": "2026-07-31T12:30:00+08:00", "status": "active", "migration_mode": "behavior_preserving_source_migration", "source_of_truth": "current_original_webapp_runtime_and_source", @@ -9,10 +9,10 @@ "failed_roots": [ "next" ], - "current_slice": "slice-10-frontend-shell-pages-components-css-mobile", - "last_completed_slice": "slice-09-review-watchlist-notes-journal-alerts-assistant", - "last_checkpoint": "xiaobai-preservation-slice-09-20260731", - "next_action": "capture_frontend_asset_dependency_map_then_relocate_original_shell_pages_components_css_and_mobile_responsibilities_without_runtime_changes", + "current_slice": "slice-11-uncertain-code-audit-final-acceptance-handoff", + "last_completed_slice": "slice-10-frontend-shell-pages-components-css-mobile", + "last_checkpoint": "xiaobai-preservation-slice-10-20260731", + "next_action": "commit_and_push_slice_10_then_audit_each_uncertain_asset_with_independent_evidence_and_retain_any_asset_not_proven_safe_to_remove", "authoritative_documents": [ "AGENTS.md", "docs/migration/原版保真迁移总纲.md", diff --git a/docs/migration/保真迁移账本.md b/docs/migration/保真迁移账本.md index 01c50d7..f19d339 100644 --- a/docs/migration/保真迁移账本.md +++ b/docs/migration/保真迁移账本.md @@ -1,6 +1,6 @@ # 小白复盘保真迁移账本 -> 当前状态:正式迁移,切片09“复盘、自选、交易日志、提醒与复盘助手”已完成 +> 当前状态:正式迁移,切片10“前端Shell、页面、样式与移动端职责归位”已完成 本账本是上下文恢复和人工审计的连续记录。任何迁移提交必须在同一提交中更新本文件及 `保真迁移状态.json`。 @@ -28,6 +28,8 @@ | 2026-07-31 | `xiaobai-preservation-slice-06-20260731` | 智能选股、自定义选股与策略持续跟踪原实现归位 | 自动、API、数据库与浏览器差分通过,进入切片07 | | 2026-07-31 | `xiaobai-preservation-slice-07-20260731` | 问师、模型Skill与LLM流式链路原实现归位 | 自动、API、数据库、Skill与浏览器差分通过,进入切片08 | | 2026-07-31 | `xiaobai-preservation-slice-08-20260731` | 问天、观势、观气与观心原实现归位 | 自动、API、数据库、动画与浏览器差分通过,进入切片09 | +| 2026-07-31 | `xiaobai-preservation-slice-09-20260731` | 复盘、自选、交易日志、提醒与复盘助手原实现归位 | 自动、API、数据库、账户隔离与浏览器差分通过,进入切片10 | +| 2026-07-31 | `xiaobai-preservation-slice-10-20260731` | 前端Shell、页面、CSS、动画与移动端职责归位 | 源码重组、自动、API、数据库、桌面、夜间、移动端与动画差分通过,进入切片11 | ## 资产处置登记 @@ -64,6 +66,10 @@ | `assistant_agent.py`与复盘服务 | 业务服务 | 我的复盘、自选、交易日志与复盘助手 | 机械移动并保留兼容别名 | `app/backend/features/review/` | Agent文件哈希、8个服务方法AST、5个真实API及会员/非会员页面等价 | 已移动 | | 复盘、自选、交易日志与助手消息方法 | 持久化 | 用户私有复盘数据 | 按职责机械移动并保持Mixin原接口 | `app/backend/features/review/repository.py` | 13个方法AST一致;相关表逐行一致;浏览器账户隔离通过 | 已移动 | | 提醒编排与提醒持久化方法 | 业务服务/持久化 | 提醒中心与策略跟踪提醒 | 按职责机械移动 | `app/backend/features/alerts/` | 5个服务方法、6个Repository方法AST及真实API一致 | 已移动 | +| `static/app.js` | 前端总运行时 | 全站页面、状态、交互与动画 | 按原连续行机械拆分 | `app/frontend/app.js`、`pages/*/page.js`、`pages/market/runtime.js`、`shared/export.js` | 9,283行重组SHA-256与原版一致;85项前端专项测试通过 | 已移动 | +| `static/index.html` | 前端DOM骨架 | 全站 | 只调整资源路径和拆分脚本加载 | `app/frontend/index.html` | 反向替换路径与脚本后逐字符一致;浏览器差分通过 | 已移动 | +| `static/styles.css`等七层样式 | 视觉运行资产 | 全部页面、主题和移动端 | 字节级移动 | `app/frontend/styles/`、`app/frontend/pages/heaven/page.css`、`app/frontend/shared/tokens.css` | 逐文件SHA-256一致;桌面、夜间和移动端计算样式一致 | 已移动 | +| `static/ui-core.js`、页面/共享脚本和供应商资产 | 前端共享/第三方资产 | Shell、页面注册、通用组件与图标 | 字节级移动 | `app/frontend/shared/`、`pages/`、`vendor/` | 逐文件SHA-256一致;45项Playwright和真实浏览器差分通过 | 已移动 | 处置只允许:`原样保留`、`移动`、`合并重复`、`待定`、`确认废弃`。 @@ -171,6 +177,18 @@ - 回档:标签`xiaobai-preservation-slice-09-20260731`。 - 完整证据:`docs/migration/evidence/slice-09/README.md`。 +已完成切片:`slice-10-frontend-shell-pages-components-css-mobile`。 + +- 原版基线:提交`38de3de`,即切片09回档点。 +- 迁移范围:完整前端DOM、9,283行运行时、页面脚本、共享脚本、七层CSS、问天加载动画、供应商资产及移动端规则。 +- 兼容边界:运行时只切换静态根目录和资源路径;原DOM、样式、动画及JS源码内容不重写。 +- 源码证明:原`app.js`与全部拆分片段重组后的SHA-256均为`c020d0bc68f8a96b352a0ffa62c913a0ea3f5ca0afc41f70969f833e447166a6`。 +- API与数据库:21个全站只读API全部一致;62个schema对象和21张关键表逐行一致。 +- 验收:85项前端专项、原版231项、迁移版294项Python测试、45项Playwright(2.3分钟),以及1920×1080日间/夜间、390×844移动端和问天动画真实浏览器差分通过。 +- 验收限制:同主机不同端口共享登录Cookie,双服务比较必须逐服务重新登录;动态动画帧、焦点和状态文字不以截图文件哈希作为唯一标准。 +- 回档:标签`xiaobai-preservation-slice-10-20260731`。 +- 完整证据:`docs/migration/evidence/slice-10/README.md`。 + ## 决策记录 | 日期 | 决策 | 原因 |