diff --git a/backend/bootstrap/config.py b/backend/bootstrap/config.py
index 08d4d11..243ebb6 100644
--- a/backend/bootstrap/config.py
+++ b/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/frontend/app.js b/frontend/app.js
new file mode 100644
index 0000000..8e18c64
--- /dev/null
+++ b/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) => `
+