const {
clamp,
displayCompactDate,
escapeHtml,
formatNumber,
formatTimestamp,
localDateString,
number,
parseLocalDate,
todayString,
} = window.XiaobaiUI;
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 = {
user: null,
csrfToken: "",
authMode: "login",
started: false,
dashboard: null,
filter: "all",
query: "",
sortKey: "streak",
sortDirection: "desc",
brokenQuery: "",
brokenSortKey: "",
brokenSortDirection: "desc",
downQuery: "",
downSortKey: "",
downSortDirection: "asc",
yesterdayFilter: "all",
yesterdayQuery: "",
yesterdaySortKey: "",
yesterdaySortDirection: "desc",
activeView: "limitPool",
dragonTiger: null,
dragonViewMode: "daily",
dragonFilter: "all",
dragonQuery: "",
selectedDragonTraderId: "",
hotMoneyProfiles: null,
hotMoneyProfileQuery: "",
selectedHotMoneyProfileId: "",
rotationHistory: null,
rotationHistoryKey: "",
rotationSelectedSector: "",
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",
stockDetail: null,
activeStock: null,
stockDetailChartMode: "daily",
stockDetailIntraday: null,
stockDetailRequestSequence: 0,
entityDetailItem: null,
entityDetailPayload: null,
entityDetailChartMode: "daily",
entityDetailIntraday: null,
entityDetailRequestSequence: 0,
stockPreviewCode: "",
stockPreviewPayload: null,
stockPreviewChart: "daily",
stockPreviewFallback: null,
watchlist: [],
watchlistSelection: null,
watchlistSearchResults: [],
watchlistSearchRequestSequence: 0,
editingDailyNoteId: 0,
notes: [],
tradeEntries: [],
tradeSummary: {},
editingTradeId: 0,
initialStockOpened: false,
screenerSetup: null,
screenerSetupKey: "",
screenerSetupRequestKey: "",
screenerSetupPromise: null,
selectedRegime: "",
selectedStrategy: 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: "全部",
curatedQuery: "",
selectedCuratedStrategyId: 0,
quantFilters: [],
quantScores: [],
alerts: [],
alertFilter: "all",
alertUnreadCount: 0,
assistantMessages: [],
assistantLoading: false,
assistantController: null,
screenerMobileView: "strategy",
sentimentHistory: null,
sentimentRange: 20,
sentimentHistoryKey: "",
sentimentLoading: false,
mentorSetup: null,
selectedMentorId: "",
mentorMessages: [],
mentorLoading: false,
mentorQuery: "",
mentorGrade: "all",
mentorDirectoryOpen: false,
mentorSortMode: false,
mentorSavingPreferences: false,
mentorController: null,
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: "",
dashboardLoading: false,
dashboardRequestSequence: 0,
heavenRequestSequence: 0,
dashboardRequestDate: "",
adminModels: [],
globalSearchResults: [],
globalSearchActiveIndex: -1,
globalSearchRequestSequence: 0,
};
const elements = {
tradeDate: document.querySelector("#tradeDate"),
loading: document.querySelector("#loadingOverlay"),
toast: document.querySelector("#toast"),
stockDialog: document.querySelector("#stockDialog"),
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"),
themeDetailChart: document.querySelector("#themeDetailChart"),
settingsDialog: document.querySelector("#settingsDialog"),
adminDialog: document.querySelector("#adminDialog"),
priceChart: document.querySelector("#priceChart"),
stockPreview: document.querySelector("#stockPreview"),
stockPreviewBackdrop: document.querySelector("#stockPreviewBackdrop"),
stockPreviewChart: document.querySelector("#stockPreviewChart"),
};
const metricAnimationFrames = new WeakMap();
const stockPreviewCache = new Map();
const STOCK_PREVIEW_DELAY = 380;
const STOCK_PREVIEW_CACHE_MS = 5 * 60 * 1000;
const LIVE_REFRESH_DEFAULT_MS = 10 * 1000;
let qiFieldAnimationFrame = 0;
let qiFieldSoloElement = "";
let heavenPerformanceToken = 0;
let 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];
const MARKET_VIEWS = new Set([
"limitPool",
"brokenView",
"downView",
"yesterdayView",
"performanceView",
"sentimentCycleView",
"auctionView",
"ladderView",
"rotationView",
"themeLibraryView",
"popularityView",
"dragonView",
]);
let rowAnimationObserver = null;
let stockPreviewOpenTimer = null;
let stockPreviewCloseTimer = null;
let stockPreviewAbortController = null;
let stockPreviewAnchor = null;
let sentimentChartAnimationFrame = null;
let heavenResizeTimer = null;
let globalSearchTimer = null;
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 === "themeLibraryView" && state.themeDetail?.series) {
drawEntityDetailChart(state.themeDetail.series, elements.themeDetailChart);
}
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();
initializeApplicationShell();
const searchParams = new URLSearchParams(window.location.search);
const requestedDate = searchParams.get("date");
elements.tradeDate.value = /^\d{4}-\d{2}-\d{2}$/.test(requestedDate || "") && requestedDate <= todayString()
? requestedDate
: todayString();
elements.tradeDate.max = todayString();
document.querySelector("#journalDate").value = elements.tradeDate.value;
document.querySelector("#journalDate").max = todayString();
document.querySelector("#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 legacyViewAliases = { sectorView: "rotationView", breadthView: "limitPool" };
const requestedView = legacyViewAliases[searchParams.get("view")] || searchParams.get("view");
if (requestedView && document.getElementById(requestedView)?.classList.contains("workspace-view")) {
openView(requestedView, false);
if (requestedView !== searchParams.get("view")) {
const url = new URL(window.location.href);
url.searchParams.set("view", requestedView);
history.replaceState(null, "", url);
}
}
loadDashboard();
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;
const factorSyncButton = document.querySelector("#factorSyncButton");
if (factorSyncButton) factorSyncButton.hidden = false;
document.querySelector("#authGate").hidden = true;
applyMembershipAccess();
await startAuthenticatedApp();
}
function showAuthGate(message = "") {
state.user = null;
state.csrfToken = "";
const gate = document.querySelector("#authGate");
gate.hidden = false;
const errorElement = document.querySelector("#authError");
errorElement.textContent = message;
errorElement.hidden = !message;
document.querySelector("#authUsername").focus();
}
async function logoutAccount() {
toggleAccountDropdown(false);
try {
await apiRequest("/api/auth/logout", "POST", {});
} catch (error) {
showToast(error.message || "退出失败");
return;
}
window.location.reload();
}
function bindEvents() {
document.querySelectorAll("[data-auth-mode]").forEach((button) => {
button.addEventListener("click", () => selectAuthMode(button.dataset.authMode));
});
document.querySelector("#authForm").addEventListener("submit", submitAuthForm);
document.querySelector("#refreshButton").addEventListener("click", 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;
setDateInUrl(elements.tradeDate.value);
loadDashboard();
});
document.querySelector("#prevDate").addEventListener("click", () => shiftDate(-1));
document.querySelector("#nextDate").addEventListener("click", () => shiftDate(1));
document.querySelector("#stockSearch").addEventListener("input", (event) => {
state.query = event.target.value.trim().toLowerCase();
renderLimitTable();
});
document.querySelectorAll("[data-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.querySelectorAll(".module-tab").forEach((button) => {
button.addEventListener("click", () => openView(button.dataset.view));
});
document.querySelector("#mobileMarketViewSelect").addEventListener("change", (event) => {
openView(event.target.value);
});
document.querySelector("#sidebarCollapseButton").addEventListener("click", toggleSidebar);
document.querySelector("#headerMenuButton").addEventListener("click", (event) => {
event.stopPropagation();
toggleHeaderCommandMenu();
});
document.querySelector("#globalSearchButton").addEventListener("click", openGlobalSearch);
document.querySelector("#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.querySelector("#headerCommandGroup").addEventListener("click", (event) => {
if (event.target.closest("button") && !event.target.closest(".account-menu-shell")) toggleHeaderCommandMenu(false);
});
document.addEventListener("click", (event) => {
if (!event.target.closest(".header-actions")) toggleHeaderCommandMenu(false);
if (!event.target.closest(".account-menu-shell")) toggleAccountDropdown(false);
});
window.addEventListener("keydown", handleGlobalSearchShortcut);
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
toggleHeaderCommandMenu(false);
toggleAccountDropdown(false, true);
toggleMentorDirectory(false);
}
handleAccountMenuKeydown(event);
});
window.addEventListener("resize", () => {
if (window.innerWidth > 720) toggleHeaderCommandMenu(false);
if (window.innerWidth > 720) toggleMentorDirectory(false);
if (!elements.stockPreview.hidden) closeStockPreview();
updateSidebarControl();
if (state.activeView === "dragonView") layoutDragonCards();
});
document.querySelectorAll("[data-open-view]").forEach((button) => {
button.addEventListener("click", () => openView(button.dataset.openView));
});
document.querySelectorAll("[data-open-account]").forEach((button) => {
button.addEventListener("click", () => openSettings("membership"));
});
document.querySelectorAll("#limitTable th[data-sort]").forEach((header) => {
header.addEventListener("click", () => changeSort(header.dataset.sort));
});
document.querySelector("#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("#overviewToggle").addEventListener("click", () => {
const overview = document.querySelector(".overview-strip");
const expanded = overview.dataset.overviewExpanded !== "true";
overview.dataset.overviewExpanded = String(expanded);
const toggle = document.querySelector("#overviewToggle");
toggle.setAttribute("aria-expanded", String(expanded));
toggle.title = expanded ? "收起市场详情" : "展开市场详情";
toggle.querySelector("span").textContent = expanded ? "收起详情" : "展开详情";
toggle.querySelector("i").setAttribute("data-lucide", expanded ? "chevron-up" : "chevron-down");
refreshIcons();
});
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("#changeStrategyButton").addEventListener("click", () => openStrategyDrawer("library"));
document.querySelector("#openStrategyDrawerButton").addEventListener("click", () => openStrategyDrawer("editor"));
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("#factorSyncButton").addEventListener("click", syncFactorData);
document.querySelector("#screenerRunButton").addEventListener("click", runScreener);
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("#curatedRunButton").addEventListener("click", runCuratedStrategy);
document.querySelector("#curatedBacktestToggle").addEventListener("change", updateBacktestTaskStatus);
document.querySelector("#closeCuratedDetailButton").addEventListener("click", () => document.querySelector("#curatedDetailDialog").close());
document.querySelector("#curatedDetailDialog").addEventListener("click", (event) => {
if (event.target === event.currentTarget) event.currentTarget.close();
});
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 || []);
renderRotationTable(state.dashboard.sector_rotation || [], sectors || []);
}
async function loadSentimentHistory(force = false) {
if (!state.dashboard || state.sentimentLoading) return;
const key = `${elements.tradeDate.value}:${state.sentimentRange}`;
if (!force && state.sentimentHistoryKey === key && state.sentimentHistory) {
renderSentimentHistory();
return;
}
state.sentimentLoading = true;
const notice = document.querySelector("#sentimentHistoryNotice");
notice.hidden = true;
try {
const query = new URLSearchParams({
trade_date: elements.tradeDate.value,
limit: String(state.sentimentRange),
});
state.sentimentHistory = await apiRequest(`/api/sentiment/history?${query}`);
state.sentimentHistoryKey = key;
renderSentimentHistory();
} catch (error) {
notice.textContent = error.message || "情绪周期数据加载失败";
notice.hidden = false;
showToast(notice.textContent);
} finally {
state.sentimentLoading = false;
}
}
function renderSentimentHistory() {
const payload = state.sentimentHistory;
if (!payload) return;
const rows = payload.rows || [];
const latest = rows[rows.length - 1];
const body = document.querySelector("#sentimentHistoryBody");
const empty = document.querySelector("#sentimentHistoryEmpty");
empty.hidden = rows.length > 0;
body.innerHTML = [...rows].reverse().map((row) => {
return `
| ${escapeHtml(displayCompactDate(row.trade_date))} |
${number(row.score)} |
${escapeHtml(row.phase)} |
${escapeHtml(row.direction)} |
${number(row.limit_up_count)} |
${number(row.first_board_count)} |
${number(row.second_board_count)} |
${number(row.three_plus_count)} |
${number(row.max_height)}板 |
${number(row.broken_count)} |
${number(row.limit_down_count)} |
${number(row.previous_limit_count)} |
${number(row.previous_positive_count)} |
${formatNumber(row.previous_positive_rate, 1)}% |
`;
}).join("");
if (!latest) {
setText("sentimentHistoryDateRange", "暂无历史数据");
return;
}
setText(
"sentimentHistoryDateRange",
`${displayCompactDate(rows[0].trade_date)} 至 ${displayCompactDate(latest.trade_date)}`,
);
setText("sentimentCycleScore", number(latest.score));
setText("sentimentCycleLabel", latest.label);
setText("sentimentCycleDate", displayCompactDate(latest.trade_date));
setText("sentimentCyclePhase", latest.phase);
setText("sentimentCycleDirection", latest.direction);
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.querySelectorAll("[data-sentiment-stage]").forEach((item) => {
const current = item.dataset.sentimentStage === latest.phase;
item.classList.toggle("current", current);
item.hidden = !current;
const label = item.querySelector("strong");
if (label) label.textContent = `${item.dataset.sentimentStage}${current ? "(当前)" : ""}`;
});
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) {
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 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");
container.innerHTML = '正在读取轮动历史
';
try {
const query = new URLSearchParams({
trade_date: elements.tradeDate.value,
});
state.rotationHistory = await apiRequest(`/api/rotation/history?${query}`);
state.rotationHistoryKey = key;
renderRotationHistory();
} catch (error) {
container.innerHTML = `${escapeHtml(error.message || "轮动历史加载失败")}
`;
showToast(error.message || "轮动历史加载失败");
} finally {
state.rotationLoading = false;
}
}
function renderRotationHistory() {
const rows = state.rotationHistory?.rows || [];
const selected = state.rotationSelectedSector;
const container = document.querySelector("#rotationHistory");
const tracker = document.querySelector("#rotationTracker");
if (!rows.length) {
container.innerHTML = '尚无连续交易日的板块数据
';
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 = "";
renderRotationHistory();
updateRotationTableSelection();
});
} 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", () => {
state.rotationSelectedSector = button.dataset.rotationSector === state.rotationSelectedSector
? ""
: button.dataset.rotationSector;
renderRotationHistory();
updateRotationTableSelection();
});
});
}
function renderRotationTable(rows, sectors) {
const sectorMap = new Map(sectors.map((sector) => [sector.name, sector]));
const body = document.querySelector("#rotationTableBody");
const currentDate = displayCompactDate(state.dashboard?.meta?.trade_date || elements.tradeDate.value);
setText("rotationDetailMeta", `${currentDate} · ${rows.length} 个板块`);
body.innerHTML = rows.map((row) => {
const sector = sectorMap.get(row.name) || {};
const strength = number(row.strength ?? sector.strength);
const currentCount = number(row.count);
const previousCount = number(row.previous_count);
const delta = row.delta == null ? currentCount - previousCount : number(row.delta);
const trend = previousCount === 0 && currentCount > 0
? "新进"
: currentCount > previousCount
? "升温"
: currentCount < previousCount ? "降温" : "持平";
return `
| ${number(row.rank)} | ${escapeHtml(row.name)} |
${trend} |
${currentCount} | ${previousCount} |
${delta > 0 ? "+" : ""}${delta} |
${formatNumber(strength, 0)} |
${streakLabel(row.max_streak || 1)} |
${signed(sector.change)}% |
${escapeHtml(row.leader || sector.leader || "--")} | ${formatNumber(row.amount_billion, 1)} 亿 |
`;
}).join("");
body.querySelectorAll("[data-rotation-detail-sector]").forEach((row) => {
row.addEventListener("click", () => {
const sector = row.dataset.rotationDetailSector;
state.rotationSelectedSector = state.rotationSelectedSector === sector ? "" : sector;
renderRotationHistory();
updateRotationTableSelection();
document.querySelector("#rotationHistory").scrollIntoView({ behavior: "smooth", block: "center" });
});
});
animateRows(body);
}
function updateRotationTableSelection() {
document.querySelectorAll("#rotationTableBody [data-rotation-detail-sector]").forEach((row) => {
row.classList.toggle("selected", row.dataset.rotationDetailSector === state.rotationSelectedSector);
});
}
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("") || '暂无梯队数据
';
}
function renderSectorMini(sectors) {
document.querySelector("#sectorMini").innerHTML = sectors.slice(0, 7).map((sector) => `
${escapeHtml(sector.name)}${number(sector.count)}
`).join("") || '暂无板块数据
';
}
function renderLadderBoard(ladders) {
const container = document.querySelector("#ladderBoard");
const 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 (meta.phase === "selection" && !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 || "题材数据暂不可用");
document.querySelector("#themeDirectory").innerHTML = `${escapeHtml(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("") || '没有匹配的题材
';
}
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);
requestAnimationFrame(() => drawEntityDetailChart(payload.series || [], elements.themeDetailChart));
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.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 `
${String(index + 1).padStart(2, "0")}
${escapeHtml(trader.name.slice(0, 2))}
${escapeHtml(trader.name)}${escapeHtml(description)}
${number(trader.stock_count)} 股 · ${number(trader.operation_count)} 笔${formatMoneyMillion(trader.net_buy_million)}
`;
}).join("");
const hitZoneMarkup = traders.map((trader) => `
`).join("");
container.innerHTML = traders.length
? `${cardMarkup}${hitZoneMarkup}
`
: `${escapeHtml(state.dragonFilter === "unclassified" ? "待归类席位请在下方管理" : emptyMessage)}
`;
container.querySelectorAll("[data-dragon-card]").forEach((card) => {
card.addEventListener("animationend", () => card.classList.remove("dealing"), { once: true });
});
container.querySelectorAll("[data-dragon-trader]").forEach((hitZone) => {
const setHovered = (hovered) => {
container.querySelector(`[data-dragon-card="${CSS.escape(hitZone.dataset.dragonTrader)}"]`)?.classList.toggle("hovered", hovered);
};
hitZone.addEventListener("pointerenter", () => setHovered(true));
hitZone.addEventListener("pointerleave", () => setHovered(false));
hitZone.addEventListener("focus", () => setHovered(true));
hitZone.addEventListener("blur", () => setHovered(false));
hitZone.addEventListener("click", () => {
state.selectedDragonTraderId = hitZone.dataset.dragonTrader;
container.querySelectorAll("[data-dragon-card]").forEach((card) => {
card.classList.toggle("selected", card.dataset.dragonCard === state.selectedDragonTraderId);
});
container.querySelectorAll("[data-dragon-trader]").forEach((item) => {
item.setAttribute("aria-pressed", String(item.dataset.dragonTrader === state.selectedDragonTraderId));
});
renderDragonTraderDetail(traders.find((item) => item.id === state.selectedDragonTraderId));
});
});
requestAnimationFrame(() => layoutDragonCards(container));
renderDragonTraderDetail(traders.find((item) => item.id === state.selectedDragonTraderId));
}
function layoutDragonCards(container = document.querySelector("#dragonTraderList")) {
if (!container) return;
const cards = [...container.querySelectorAll(".dragon-trader-card")];
const hitZones = [...container.querySelectorAll(".dragon-card-hit-zone")];
if (!cards.length) return;
const compact = window.innerWidth <= 720;
const cardWidth = compact ? 148 : 176;
const available = Math.max(cardWidth, container.clientWidth - (compact ? 30 : 72));
const spread = Math.min(available - cardWidth, compact ? 310 : 1050);
const step = cards.length > 1 ? Math.min(cardWidth + 14, spread / (cards.length - 1)) : 0;
const center = (cards.length - 1) / 2;
container.style.setProperty("--dragon-card-width", `${cardWidth}px`);
cards.forEach((card, index) => {
const x = (index - center) * step;
card.style.setProperty("--card-x", `${x.toFixed(2)}px`);
card.style.setProperty("--card-rotation", "0deg");
card.style.setProperty("--card-y", "0px");
card.style.zIndex = String(index + 1);
const hitZone = hitZones[index];
if (hitZone) {
const zoneWidth = index === cards.length - 1 ? cardWidth : Math.max(18, step);
hitZone.style.left = `calc(50% + ${(x - cardWidth / 2).toFixed(2)}px)`;
hitZone.style.width = `${zoneWidth.toFixed(2)}px`;
}
});
}
function renderDragonTraderDetail(trader) {
const container = document.querySelector("#dragonTraderDetail");
if (!trader) {
container.hidden = true;
container.innerHTML = '选择一位游资查看操作明细
';
return;
}
container.hidden = false;
container.innerHTML = `
| 序号 | 股票 | 方向 | 涨幅(%) | 买入(百万) | 卖出(百万) | 净额(百万) | 关联席位 | 标签 / 上榜原因 |
${(trader.operations || []).map((operation, index) => `
| ${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 : "")} |
`).join("")}
`;
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) => `
`).join("") || '当前席位均已归类
';
list.querySelectorAll(".unclassified-seat-row").forEach((form) => {
form.addEventListener("submit", saveSeatAlias);
});
}
function dragonIdentityLabel(type) {
return { trader: "游资", institution: "机构", channel: "通道", unclassified: "待归类" }[type] || "席位";
}
async function saveSeatAlias(event) {
event.preventDefault();
const form = event.currentTarget;
const seat = state.dragonTiger?.unclassified_seats?.[number(form.dataset.unclassifiedIndex)];
const alias = form.querySelector("input").value.trim();
if (!seat || !alias) {
showToast("请输入游资名");
return;
}
const button = form.querySelector("button");
button.disabled = true;
try {
await apiRequest("/api/seat-aliases", "POST", { seat_name: seat.seat_name, alias });
state.dragonTiger = null;
await loadDragonTiger();
showToast(`已将席位归类为 ${alias}`);
} catch (error) {
showToast(error.message);
button.disabled = false;
}
}
async function loadReviewWorkspace() {
try {
const [watchlistPayload, notesPayload, 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)} |
|
|
`).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));
if (!elements.watchlistDialog.open) elements.watchlistDialog.showModal();
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();
if (!elements.tradeLogDialog.open) elements.tradeLogDialog.showModal();
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", "保存修改");
if (!elements.tradeLogDialog.open) elements.tradeLogDialog.showModal();
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("") || '暂无复盘记录
';
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 || {};
const availableRegimes = new Set((payload.regimes || []).map((item) => item.id));
if (!state.selectedRegime || dateChanged) {
state.selectedRegime = availableRegimes.has(smartLatestMeta.regime)
? smartLatestMeta.regime
: 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) => `
`).join("");
selector.querySelectorAll("[data-regime]").forEach((button) => {
button.addEventListener("click", () => selectRegime(button.dataset.regime));
});
renderStrategyList();
populateStrategyEditor(state.selectedStrategy);
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 renderCuratedStrategyLibrary() {
if (!state.screenerSetup) return;
const strategies = curatedStrategies();
const categories = ["全部", ...new Set(strategies.map((item) => item.formula?.meta?.category || "其他"))];
if (!categories.includes(state.curatedCategory)) state.curatedCategory = "全部";
setText("curatedStrategyCount", `${strategies.length} 套`);
const filters = document.querySelector("#curatedCategoryFilters");
filters.innerHTML = categories.map((category) => `
`).join("");
filters.querySelectorAll("[data-curated-category]").forEach((button) => {
button.addEventListener("click", () => {
state.curatedCategory = button.dataset.curatedCategory;
renderCuratedStrategyLibrary();
});
});
const query = state.curatedQuery;
const visible = strategies.filter((item) => {
const meta = item.formula?.meta || {};
const categoryMatch = state.curatedCategory === "全部" || meta.category === state.curatedCategory;
const queryMatch = !query || `${item.name} ${item.description} ${meta.category}`.toLocaleLowerCase("zh-CN").includes(query);
return categoryMatch && queryMatch;
});
const list = document.querySelector("#curatedStrategyList");
list.innerHTML = visible.length ? visible.map((strategy) => {
const meta = strategy.formula?.meta || {};
const rank = strategies.findIndex((item) => item.id === strategy.id) + 1;
return `
${String(rank).padStart(2, "0")}${escapeHtml(strategy.name)}${escapeHtml(meta.category || "策略")}
质量 ${escapeHtml(meta.quality || "--")}${escapeHtml(meta.frequency || "--")}风险 ${escapeHtml(meta.risk || "--")}
${escapeHtml(strategy.description || "查看策略条件与适用环境。")}
`;
}).join("") : '没有符合条件的策略
';
list.querySelectorAll("[data-curated-inspect]").forEach((button) => {
button.addEventListener("click", () => {
state.selectedCuratedStrategyId = number(button.dataset.curatedInspect);
renderCuratedStrategyLibrary();
renderScreenerResult();
document.querySelector("#curatedDetailDialog").showModal();
});
});
list.querySelectorAll("[data-curated-run]").forEach((button) => {
button.addEventListener("click", () => {
state.selectedCuratedStrategyId = number(button.dataset.curatedRun);
renderCuratedStrategyLibrary();
renderScreenerResult();
runCuratedStrategy();
});
});
renderCuratedStrategyDetail();
}
function renderCuratedStrategyDetail() {
const strategy = activeCuratedStrategy();
if (!strategy) return;
const formula = strategy.formula || {};
const meta = formula.meta || {};
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("");
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 status = document.querySelector("#curatedDataStatus");
status.classList.toggle("missing", !strategy.data_ready);
status.innerHTML = strategy.data_ready
? '策略数据已就绪可按当前数据日期执行'
: `需要补充数据${escapeHtml((strategy.missing_data || []).join("、") || "请同步因子")}`;
const runButton = document.querySelector("#curatedRunButton");
runButton.disabled = !strategy.data_ready;
runButton.title = strategy.data_ready ? "执行当前策略" : `缺少${(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) => `
`).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");
if (!drawer.open) drawer.showModal();
requestAnimationFrame(() => {
const focusTarget = target === "library"
? document.querySelector("#strategyList .strategy-item.active") || document.querySelector("#strategyList .strategy-item")
: document.querySelector("#strategyNameInput");
focusTarget?.focus();
});
}
function selectScreenerMobileView(view) {
state.screenerMobileView = view === "results" ? "results" : "strategy";
const workspace = document.querySelector("#screenerView");
workspace.classList.toggle("mobile-strategy", state.screenerMobileView === "strategy");
workspace.classList.toggle("mobile-results", state.screenerMobileView === "results");
document.querySelectorAll("[data-screener-mobile-view]").forEach((button) => {
const active = button.dataset.screenerMobileView === state.screenerMobileView;
button.classList.toggle("active", active);
button.setAttribute("aria-selected", String(active));
});
}
function updateBacktestTaskStatus() {
const enabled = document.querySelector("#runBacktestToggle")?.checked;
const backtest = activeScreenerResult("smart")?.backtest;
setText("backtestTaskStatus", state.screenerRunning && state.screenerRunningMode === "smart" && enabled
? "正在回测"
: backtest ? `已完成 · ${number(backtest.samples)} 样本` : enabled ? "随选股执行" : "本次不执行");
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.formula?.meta?.library !== "curated");
list.innerHTML = strategies.map((strategy) => `
`).join("");
list.querySelectorAll("[data-strategy-id]").forEach((button) => {
button.addEventListener("click", () => {
state.selectedStrategy = state.screenerSetup.strategies.find((item) => item.id === number(button.dataset.strategyId));
state.selectedRegime = state.selectedStrategy.regimes[0] || state.selectedRegime;
renderScreenerSetup();
});
});
}
function populateStrategyEditor(strategy) {
const deleteButton = document.querySelector("#deleteStrategyButton");
deleteButton.hidden = !strategy?.id || Boolean(strategy.builtin);
if (!strategy) {
setText("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 syncFactorData() {
const button = document.querySelector("#factorSyncButton");
button.disabled = true;
setText("factorTaskStatus", "同步中");
setLoading(true, "正在同步 45 个交易日因子数据");
setStatus("正在同步选股因子");
try {
const payload = await apiRequest("/api/screener/sync", "POST", {
trade_date: elements.tradeDate.value,
lookback: 45,
});
const result = payload.result;
showToast(`因子同步完成:${result.calendar_dates} 个交易日,竞价覆盖 ${number(result.auction_dates)} 日`);
await loadScreenerSetup(true);
setText("factorTaskStatus", `已就绪 · ${number(result.calendar_dates)} 日`);
setStatus("选股因子已同步");
} catch (error) {
showToast(error.message);
setStatus("选股因子同步失败");
setText("factorTaskStatus", "同步失败");
} finally {
setLoading(false);
button.disabled = false;
}
}
async function compileStrategy() {
const prompt = document.querySelector("#strategyPrompt").value.trim();
const button = document.querySelector("#compileStrategyButton");
button.disabled = true;
setText("compilerStatus", "正在编译");
setStatus("正在编译选股策略");
try {
const payload = await apiRequest("/api/screener/compile", "POST", {
prompt,
regime: state.selectedRegime,
});
const strategy = payload.strategy;
state.selectedStrategy = { ...strategy, id: null, builtin: false };
document.querySelector("#deleteStrategyButton").hidden = true;
renderStrategySummary();
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.selectedStrategy = payload.strategies.find((item) => item.id === payload.id);
renderScreenerSetup();
showToast("自定义策略已保存");
} catch (error) {
showToast(error.message);
}
}
async function deleteCurrentStrategy() {
const strategy = state.selectedStrategy;
if (!strategy?.id || strategy.builtin) {
showToast("只能删除已保存的自定义策略");
return;
}
if (!window.confirm(`确定删除策略“${strategy.name}”吗?此操作不可撤销。`)) return;
const button = document.querySelector("#deleteStrategyButton");
button.disabled = true;
try {
const payload = await apiRequest(`/api/screener/strategies/${strategy.id}`, "DELETE");
state.screenerSetup.strategies = payload.strategies;
state.selectedStrategy = payload.strategies.find((item) => item.formula?.meta?.library !== "curated" && item.regimes.includes(state.selectedRegime))
|| payload.strategies.find((item) => item.formula?.meta?.library !== "curated")
|| null;
renderScreenerSetup();
showToast("自定义策略已删除");
} catch (error) {
showToast(error.message || "策略删除失败");
} finally {
button.disabled = false;
}
}
async function runCuratedStrategy() {
const strategy = activeCuratedStrategy();
if (!strategy) return;
if (!strategy.data_ready) {
showToast(`请先同步${(strategy.missing_data || []).join("、")}`);
return;
}
const regime = strategy.regimes.includes(state.selectedRegime) ? state.selectedRegime : strategy.regimes[0];
await executeScreenerFormula({
mode: "curated",
formula: strategy.formula,
strategyName: strategy.name,
strategyId: strategy.id,
regime,
runBacktest: document.querySelector("#curatedBacktestToggle").checked,
button: document.querySelector("#curatedRunButton"),
loadingText: `正在执行“${strategy.name}”并计算历史样本`,
});
}
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.selectedStrategy = {
id: null,
builtin: false,
name: "自定义量化策略",
description: "由量化因子工作台生成,可在高级公式中继续调整。",
regimes: [state.selectedRegime],
formula,
};
populateStrategyEditor(state.selectedStrategy);
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 runScreener() {
let formula;
try {
formula = parseFormulaEditor();
} catch (error) {
showToast(error.message);
return;
}
await executeScreenerFormula({
mode: "smart",
formula,
strategyName: document.querySelector("#strategyNameInput").value,
strategyId: state.selectedStrategy?.id,
regime: state.selectedRegime,
runBacktest: document.querySelector("#runBacktestToggle").checked,
button: document.querySelector("#screenerRunButton"),
loadingText: "正在计算因子排名与滚动回测",
});
}
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) {
const response = await fetch("/api/mentors/chat", {
method: "POST",
headers: {
"Content-Type": "application/json",
...(state.csrfToken ? { "X-CSRF-Token": state.csrfToken } : {}),
},
body: JSON.stringify(body),
signal,
});
if (!response.ok) {
const payload = await response.json().catch(() => ({}));
throw new Error(payload.error || "问师暂不可用");
}
if (!response.body) throw new Error("当前浏览器不支持流式回答");
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
const consume = (line) => {
if (!line.trim()) return;
const event = JSON.parse(line);
if (event.type === "delta") onDelta(String(event.content || ""));
if (event.type === "meta") onMeta(event);
if (event.type === "error") throw new Error(event.error || "问师回答失败");
};
while (true) {
const { value, done } = await reader.read();
buffer += decoder.decode(value || new Uint8Array(), { stream: !done });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
lines.forEach(consume);
if (done) break;
}
if (buffer.trim()) consume(buffer);
}
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("")}${listType}>`);
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) {
container.innerHTML = '载入股票后查看六爻数据状态
';
return;
}
const lineValueLabel = { 6: "老阴 · 动", 7: "少阳 · 静", 8: "少阴 · 静", 9: "老阳 · 动" };
container.innerHTML = checks.map((check) => {
const stateLabel = check.status === "manual" ? "补录通过" : check.passed ? "自动通过" : "未通过";
const score = check.score === null || check.score === undefined ? "--" : signedScore(check.score);
const fields = (check.fields || []).map((field) => {
const rawValue = field.value === null || field.value === undefined ? "" : String(field.value);
const source = field.manual ? "用户补录" : rawValue ? "自动行情" : "等待补充";
const common = `data-heaven-manual-field="${escapeHtml(field.key)}" data-original-value="${escapeHtml(rawValue)}" data-manual="${field.manual ? "true" : "false"}"`;
const control = field.type === "select"
? ``
: field.type === "text"
? ``
: ``;
return ``;
}).join("");
const reasons = (check.reasons || []).map((reason) => `${escapeHtml(reason)}`).join("");
return `
${escapeHtml(stateLabel)}
${escapeHtml(check.position)} · ${escapeHtml(check.layer)}${escapeHtml(check.formula)}
${check.line_value ? escapeHtml(lineValueLabel[check.line_value] || check.line_value) : "待定"}得分 ${escapeHtml(score)}
${reasons ? `
` : `
${(check.evidence || []).map(escapeHtml).join(";") || "数据已通过安全门"}
`}
${fields}
`;
}).join("");
document.querySelector("#heavenCalibrationNote").value = chart.manual_data?.note || "";
window.lucide?.createIcons();
}
function renderMarketHexagram(chart) {
const stockInput = document.querySelector("#heavenStockInput");
if (document.activeElement !== stockInput) stockInput.value = chart.stock.code || "";
const selectionRequired = Boolean(chart.selection_required);
const emptyState = document.querySelector("#heavenTrendEmpty");
const trendLayout = document.querySelector("#heavenTrendPanel .heaven-trend-layout");
const calibrationPanel = document.querySelector("#heavenCalibrationPanel");
const stockIdentity = document.querySelector("#heavenStockIdentity");
if (emptyState) emptyState.hidden = !selectionRequired;
if (trendLayout) trendLayout.hidden = selectionRequired;
if (calibrationPanel) calibrationPanel.hidden = selectionRequired;
if (stockIdentity) stockIdentity.hidden = selectionRequired;
if (selectionRequired) {
document.querySelector("#interpretTrendButton").disabled = true;
setText("heavenStockName", "--");
setText("heavenStockSector", "--");
renderHeavenInterpretation("trend", "");
return;
}
setText("heavenStockName", chart.stock.name || "--");
setText(
"heavenStockSector",
chart.sector || "--",
);
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.talent}
${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)} 类
`;
}).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();
if (!elements.heavenReadingDialog.open) elements.heavenReadingDialog.showModal();
requestAnimationFrame(() => {
syncHeavenReadingAnimation();
document.querySelector("#closeHeavenReadingDialog").focus();
});
}
async function openHeavenHistory(mode) {
state.heavenReadingMode = mode;
state.heavenReadingTab = "history";
state.heavenReadingSelectedId = 0;
renderHeavenReadingDialog();
if (!elements.heavenReadingDialog.open) elements.heavenReadingDialog.showModal();
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");
list.innerHTML = '正在读取历史记录
';
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) {
list.innerHTML = `${escapeHtml(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("") || '暂无历史解读
';
const selected = items.find((item) => number(item.id) === state.heavenReadingSelectedId);
const detail = document.querySelector("#heavenReadingHistoryDetail");
detail.innerHTML = selected ? `
${escapeHtml(selected.subject_detail || displayCompactDate(selected.context_date))}
${formatMentorAnswer(selected.answer || "")}
` : '选择一条记录查看完整解读
';
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 !== "quant" && 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}`,
);
document.querySelector("#screenerEmpty").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 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 = event.target.closest?.(".stock-preview-trigger");
if (!trigger || trigger === event.relatedTarget?.closest?.(".stock-preview-trigger")) return;
const code = stockCodeFromTrigger(trigger);
if (!code) return;
cancelStockPreviewClose();
clearTimeout(stockPreviewOpenTimer);
stockPreviewOpenTimer = setTimeout(() => showStockPreview(code, trigger), STOCK_PREVIEW_DELAY);
}
function handleStockPreviewPointerOut(event) {
if (!supportsStockPreviewHover()) return;
const trigger = event.target.closest?.(".stock-preview-trigger");
if (!trigger || trigger === event.relatedTarget?.closest?.(".stock-preview-trigger")) return;
clearTimeout(stockPreviewOpenTimer);
if (event.relatedTarget instanceof Node && elements.stockPreview.contains(event.relatedTarget)) return;
scheduleStockPreviewClose();
}
function handleStockPreviewFocus(event) {
if (!supportsStockPreviewHover()) return;
const trigger = event.target.closest?.(".stock-preview-trigger");
if (!trigger) return;
const code = stockCodeFromTrigger(trigger);
if (!code) return;
clearTimeout(stockPreviewOpenTimer);
stockPreviewOpenTimer = setTimeout(() => showStockPreview(code, trigger), 120);
}
function handleStockPreviewFocusOut(event) {
const trigger = event.target.closest?.(".stock-preview-trigger");
if (!trigger) return;
if (event.relatedTarget instanceof Node && elements.stockPreview.contains(event.relatedTarget)) return;
clearTimeout(stockPreviewOpenTimer);
scheduleStockPreviewClose();
}
function handleMobileStockPreviewClick(event) {
if (window.innerWidth > 720) return;
const trigger = event.target.closest?.(".stock-preview-trigger");
if (!trigger) return;
const code = stockCodeFromTrigger(trigger);
if (!code) return;
event.preventDefault();
event.stopPropagation();
showStockPreview(code, trigger);
}
function handleStockPreviewKeydown(event) {
if (event.key === "Escape" && !elements.stockPreview.hidden) {
closeStockPreview();
stockPreviewAnchor?.focus?.();
return;
}
if (event.key !== "Enter") return;
const trigger = event.target.closest?.(".stock-preview-trigger");
if (!trigger) return;
const code = stockCodeFromTrigger(trigger);
if (!code) return;
event.preventDefault();
if (window.innerWidth <= 720) showStockPreview(code, trigger);
else openStock(code, findStockFallback(code));
}
function cancelStockPreviewClose() {
clearTimeout(stockPreviewCloseTimer);
}
function scheduleStockPreviewClose() {
clearTimeout(stockPreviewCloseTimer);
stockPreviewCloseTimer = setTimeout(closeStockPreview, 160);
}
async function showStockPreview(code, trigger) {
clearTimeout(stockPreviewOpenTimer);
cancelStockPreviewClose();
if (!/^\d{6}$/.test(String(code))) return;
stockPreviewAnchor = trigger;
state.stockPreviewCode = String(code);
state.stockPreviewFallback = findStockFallback(code);
state.stockPreviewPayload = null;
state.stockPreviewChart = "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 || "行情预览加载失败");
}
}
function renderStockPreviewLoading() {
const fallback = state.stockPreviewFallback || {};
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") {
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数据。");
}
}
function closeStockPreview() {
clearTimeout(stockPreviewOpenTimer);
clearTimeout(stockPreviewCloseTimer);
stockPreviewAbortController?.abort();
stockPreviewAbortController = null;
elements.stockPreview.hidden = true;
elements.stockPreviewBackdrop.hidden = true;
document.body.classList.remove("stock-preview-open");
state.stockPreviewPayload = null;
state.stockPreviewCode = "";
}
function openStockDetailFromPreview() {
const code = state.stockPreviewCode;
const fallback = state.stockPreviewFallback;
if (!code) return;
closeStockPreview();
openStock(code, fallback);
}
function repositionStockPreview() {
if (elements.stockPreview.hidden || window.innerWidth <= 720 || !stockPreviewAnchor?.isConnected) return;
const anchor = stockPreviewAnchor.getBoundingClientRect();
const preview = elements.stockPreview.getBoundingClientRect();
const gap = 12;
let left = anchor.right + gap;
if (left + preview.width > window.innerWidth - 8) left = anchor.left - preview.width - gap;
left = clamp(left, 8, Math.max(8, window.innerWidth - preview.width - 8));
const top = clamp(anchor.top - 48, 64, Math.max(64, window.innerHeight - preview.height - 8));
elements.stockPreview.style.left = `${Math.round(left)}px`;
elements.stockPreview.style.top = `${Math.round(top)}px`;
}
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 && !elements.alertsDialog.open) elements.alertsDialog.showModal();
} catch (error) {
if (openDialog) showToast(error.message || "提醒加载失败");
}
}
function openAlerts() {
toggleHeaderCommandMenu(false);
toggleAccountDropdown(false);
document.querySelector("#alertDate").value ||= todayString();
elements.alertsDialog.showModal();
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("") || '暂无提醒
';
bindStockRows(container);
refreshIcons();
}
async function openReviewAssistant() {
toggleHeaderCommandMenu(false);
toggleAccountDropdown(false);
if (!elements.assistantDialog.open) elements.assistantDialog.showModal();
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) {
const response = await fetch("/api/assistant/chat", {
method: "POST",
headers: {
"Content-Type": "application/json",
...(state.csrfToken ? { "X-CSRF-Token": state.csrfToken } : {}),
},
body: JSON.stringify({ question, trade_date: elements.tradeDate.value }),
signal,
});
if (!response.ok) {
const payload = await response.json().catch(() => ({}));
throw new Error(payload.error || "复盘助手暂不可用");
}
if (!response.body) throw new Error("当前浏览器不支持流式回答");
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { value, done } = await reader.read();
buffer += decoder.decode(value || new Uint8Array(), { stream: !done });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (!line.trim()) continue;
const event = JSON.parse(line);
if (event.type === "delta") onDelta(String(event.content || ""));
if (event.type === "error") throw new Error(event.error || "复盘助手回答失败");
}
if (done) break;
}
if (buffer.trim()) {
const event = JSON.parse(buffer);
if (event.type === "delta") onDelta(String(event.content || ""));
if (event.type === "error") throw new Error(event.error || "复盘助手回答失败");
}
}
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("") || '可以从市场、策略或自己的交易记录开始复盘
';
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);
if (!elements.globalSearchDialog.open) elements.globalSearchDialog.showModal();
requestAnimationFrame(() => {
elements.globalSearchInput.focus();
elements.globalSearchInput.select();
});
}
function handleGlobalSearchShortcut(event) {
if (!event.ctrlKey || event.altKey || event.shiftKey || event.key.toLowerCase() !== "k") return;
if (!state.user) return;
if (event.defaultPrevented) {
showToast("Ctrl+K 已被其他功能占用,请点击顶部搜索按钮");
return;
}
event.preventDefault();
openGlobalSearch();
}
function closeGlobalSearch() {
clearTimeout(globalSearchTimer);
if (elements.globalSearchDialog.open) elements.globalSearchDialog.close();
}
function scheduleGlobalSearch() {
clearTimeout(globalSearchTimer);
const query = elements.globalSearchInput.value.trim();
state.globalSearchActiveIndex = -1;
if (!query) {
state.globalSearchResults = [];
renderGlobalSearchEmpty("输入名称或代码开始搜索", "使用方向键选择,回车打开详情", "corner-down-left");
return;
}
elements.globalSearchResults.innerHTML = '正在搜索
';
globalSearchTimer = setTimeout(() => runGlobalSearch(query), 160);
}
async function runGlobalSearch(query) {
const requestSequence = ++state.globalSearchRequestSequence;
const params = new URLSearchParams({ q: query, trade_date: elements.tradeDate.value });
try {
const payload = await apiRequest(`/api/search?${params}`);
if (requestSequence !== state.globalSearchRequestSequence || elements.globalSearchInput.value.trim() !== query) return;
renderGlobalSearchResults(payload.groups || {});
} catch (error) {
if (requestSequence !== state.globalSearchRequestSequence) return;
state.globalSearchResults = [];
renderGlobalSearchEmpty(error.message || "搜索失败", "请稍后重试", "circle-alert");
}
}
function renderGlobalSearchResults(groups) {
const definitions = [
["stocks", "股票"],
["sectors", "板块"],
["themes", "题材"],
["indices", "指数"],
];
const iconNames = { stock: "chart-candlestick", sector: "layout-grid", theme: "lightbulb", index: "chart-line" };
const flattened = [];
const sections = [];
definitions.forEach(([key, label]) => {
const items = Array.isArray(groups[key]) ? groups[key] : [];
if (!items.length) return;
const rows = items.map((item) => {
const index = flattened.length;
flattened.push(item);
return ``;
}).join("");
sections.push(``);
});
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 = "";
document.querySelector("#entityDetailMetrics").innerHTML = '正在加载交易数据
';
if (!elements.entityDetailDialog.open) elements.entityDetailDialog.showModal();
clearEntityDetailChart("正在加载日 K 数据");
try {
const params = new URLSearchParams({ type: item.type, id: item.id, trade_date: elements.tradeDate.value });
const payload = await apiRequest(`/api/search/detail?${params}`);
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", "行情加载失败");
document.querySelector("#entityDetailMetrics").innerHTML = `${escapeHtml(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) {
container.innerHTML = '暂无交易数据
';
return;
}
container.innerHTML = metrics.map((metric) => {
const value = typeof metric.value === "number" ? formatNumber(metric.value, Number.isInteger(metric.value) ? 0 : 2) : String(metric.value ?? "--");
const tone = metric.tone === "change" ? changeClass(metric.value) : "";
return `${escapeHtml(metric.label)}${escapeHtml(value)}${escapeHtml(metric.unit || "")}`;
}).join("");
}
function drawEntityDetailChart(series, 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 = "";
document.querySelector("#stockNotes").innerHTML = '正在加载笔记
';
updateWatchButton();
if (!elements.stockDialog.open) elements.stockDialog.showModal();
clearPriceChart("正在加载日 K 数据");
try {
const query = new URLSearchParams({ trade_date: elements.tradeDate.value });
const payload = await apiRequest(`/api/stock/${encodeURIComponent(code)}?${query}`);
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) {
closeStockPreview();
state.activeView = viewId;
if (viewId !== "auctionView") clearAuctionTimer();
if (viewId !== "heavenView") {
stopQiFieldCanvas();
stopHeartDust();
cancelHeavenPerformance();
}
document.querySelectorAll(".workspace-view").forEach((view) => {
const active = view.id === viewId;
view.classList.toggle("active-view", active);
view.classList.remove("view-entering");
if (active && motionEnabled()) {
void view.offsetWidth;
view.classList.add("view-entering");
view.addEventListener("animationend", () => view.classList.remove("view-entering"), { once: true });
const body = view.querySelector("tbody");
if (body) animateRows(body);
}
});
syncNavigationState(viewId);
setPageStatus(viewId);
if (updateHash) {
const url = new URL(window.location.href);
url.searchParams.set("view", viewId);
url.hash = "";
history.replaceState(null, "", url);
}
window.scrollTo({ top: 0, behavior: "auto" });
applyMembershipAccess();
if (viewId === "dragonView") loadDragonTiger();
if (viewId === "reviewWorkspaceView") loadReviewWorkspace();
if (viewId === "screenerView" && hasMemberAccess() && state.dashboard) loadScreenerSetup();
if (viewId === "mentorView" && hasMemberAccess()) loadMentorSetup();
if (viewId === "heavenView" && hasMemberAccess()) loadHeavenSetup(false, "", document.querySelector("#heavenStockInput").value.trim());
if (viewId === "sentimentCycleView") loadSentimentHistory();
if (viewId === "rotationView") loadRotationHistory();
if (viewId === "auctionView") loadAuctionCenter();
if (viewId === "themeLibraryView") loadThemeLibrary();
if (viewId === "popularityView") loadPopularity();
}
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;
setDateInUrl(next);
loadDashboard();
}
function setDateInUrl(value) {
const url = new URL(window.location.href);
url.searchParams.set("date", value);
history.replaceState(null, "", url);
}
function updateDateButtons() {
document.querySelector("#nextDate").disabled = elements.tradeDate.value >= todayString();
}
function selectAccountPanel(panel) {
const selected = ["profile", "membership", "password"].includes(panel) ? panel : "profile";
const titles = { profile: "个人资料", membership: "会员状态", password: "修改密码" };
setText("accountDialogTitle", titles[selected]);
document.querySelectorAll("[data-account-panel-content]").forEach((section) => {
section.hidden = section.dataset.accountPanelContent !== selected;
});
document.querySelector("#connectionStatus").hidden = selected !== "membership";
return selected;
}
async function openSettings(panel = "profile") {
selectAccountPanel(panel);
toggleAccountDropdown(false);
toggleHeaderCommandMenu(false);
const status = document.querySelector("#connectionStatus");
status.className = "connection-status";
status.textContent = "正在读取账号状态";
if (!elements.settingsDialog.open) elements.settingsDialog.showModal();
try {
const payload = await apiRequest("/api/account/status");
const access = payload.llm_access || {};
const membership = access.membership || {};
if (state.user) {
state.user.membership = membership;
updateAccountIdentityBadges(membership);
applyMembershipAccess();
}
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 && !elements.adminDialog.open) elements.adminDialog.showModal();
const status = document.querySelector("#adminConnectionStatus");
status.textContent = "正在读取系统状态";
try {
const payload = await apiRequest("/api/admin/settings");
const data = payload.data || {};
const 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("") || '模型池为空,请先添加模型
';
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)}
`;
}).join("") || '暂无注册用户
';
container.querySelectorAll(".membership-form").forEach((form) => form.addEventListener("submit", saveMembership));
}
async function saveMembership(event) {
event.preventDefault();
const form = event.currentTarget;
const data = Object.fromEntries(new FormData(form).entries());
const button = form.querySelector("button[type='submit']");
button.disabled = true;
try {
const payload = await apiRequest("/api/admin/membership", "POST", data);
renderAdminUsers(payload.users || []);
showToast("会员状态已更新");
} catch (error) {
showToast(error.message || "会员状态保存失败");
} finally {
button.disabled = false;
}
}
async function saveMarketSettings(event) {
event.preventDefault();
const button = event.currentTarget.querySelector("button[type='submit']");
button.disabled = true;
try {
await apiRequest("/api/admin/settings", "POST", {
tushare_token: document.querySelector("#systemTokenInput").value.trim(),
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 = {}) {
const options = { method, headers: {}, signal: requestOptions.signal };
if (method !== "GET" && state.csrfToken) {
options.headers["X-CSRF-Token"] = state.csrfToken;
}
if (body !== null) {
options.headers["Content-Type"] = "application/json";
options.body = JSON.stringify(body);
}
const response = await fetch(url, options);
const payload = await response.json();
if (response.status === 401 && !url.startsWith("/api/auth/")) {
showAuthGate("登录状态已失效,请重新登录。");
}
if (!response.ok || payload.error) throw new Error(payload.error || "请求失败");
return payload;
}
function setLoading(loading, text = "正在加载复盘数据", context = "default") {
elements.loading.hidden = !loading;
elements.loading.dataset.context = loading ? context : "default";
setText("loadingTitle", text);
setText(
"loadingHint",
context === "screener"
? "正在完成因子筛选、候选排序与历史样本回测,这通常需要一点时间"
: "请稍候",
);
}
function setStatus(text) {
setText("statusText", text);
}
function setPageStatus(viewId) {
const labels = {
sentimentCycleView: "情绪周期",
limitPool: "涨停池",
brokenView: "炸板池",
downView: "跌停板",
yesterdayView: "昨日涨停",
performanceView: "涨停表现",
ladderView: "市场天梯",
rotationView: "板块轮动",
auctionView: "集合竞价",
themeLibraryView: "题材库",
popularityView: "人气热榜",
dragonView: "龙虎榜",
screenerView: "智能选股",
screenerTrackingView: "策略持续跟踪",
mentorView: "问师",
heavenView: "问天",
reviewWorkspaceView: "我的复盘",
};
const label = labels[viewId] || "小白复盘";
const tradeDate = displayCompactDate(state.dashboard?.meta?.trade_date || elements.tradeDate?.value || "");
setStatus(tradeDate && tradeDate !== "--" ? `${label} · 数据日期 ${tradeDate}` : `${label} · 等待数据`);
}
let toastTimer;
function showToast(message) {
clearTimeout(toastTimer);
elements.toast.textContent = message;
elements.toast.hidden = false;
toastTimer = setTimeout(() => { elements.toast.hidden = true; }, 3600);
}
function setText(id, value) {
const element = document.getElementById(id);
if (element) element.textContent = value;
}
function motionEnabled() {
return !window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function refreshIcons() {
if (!window.lucide?.createIcons) return;
window.lucide.createIcons({ attrs: { "aria-hidden": "true" } });
}
function initializeApplicationShell() {
let collapsed = false;
try {
collapsed = window.localStorage.getItem("xiaobai-sidebar-collapsed") === "1";
} catch (_error) {
collapsed = false;
}
document.body.classList.toggle("sidebar-collapsed", collapsed);
updateSidebarControl();
syncNavigationState(state.activeView);
}
function toggleSidebar() {
const collapsed = document.body.classList.toggle("sidebar-collapsed");
try {
window.localStorage.setItem("xiaobai-sidebar-collapsed", collapsed ? "1" : "0");
} catch (_error) {
// The visual state still works when storage is unavailable.
}
updateSidebarControl();
}
function updateSidebarControl() {
const button = document.querySelector("#sidebarCollapseButton");
if (!button) return;
const automaticallyCollapsed = window.innerWidth <= 1023 && window.innerWidth > 720;
const collapsed = document.body.classList.contains("sidebar-collapsed") || automaticallyCollapsed;
button.setAttribute("aria-expanded", String(!collapsed));
button.setAttribute("aria-label", collapsed ? "展开侧栏" : "收起侧栏");
button.title = collapsed ? "展开侧栏" : "收起侧栏";
const label = button.querySelector("span");
if (label) label.textContent = collapsed ? "展开侧栏" : "收起侧栏";
}
function toggleHeaderCommandMenu(force) {
const menu = document.querySelector("#headerCommandGroup");
const button = document.querySelector("#headerMenuButton");
if (!menu || !button) return;
const open = typeof force === "boolean" ? force : !menu.classList.contains("is-open");
menu.classList.toggle("is-open", open);
button.setAttribute("aria-expanded", String(open));
}
function toggleAccountDropdown(force, returnFocus = false) {
const menu = document.querySelector("#accountDropdown");
const button = document.querySelector("#accountButton");
if (!menu || !button) return;
const open = typeof force === "boolean" ? force : menu.hidden;
menu.hidden = !open;
button.setAttribute("aria-expanded", String(open));
document.querySelector(".account-menu-shell")?.classList.toggle("is-open", open);
if (open) {
setText("accountMenuName", state.user?.username || "当前账号");
const membership = state.user?.membership || {};
setText("accountMenuRole", state.user?.role === "admin" ? (membership.subscribed ? "管理员 · 会员" : "管理员") : membership.subscribed ? "会员用户" : "普通用户");
} else if (returnFocus) {
button.focus();
}
}
function handleAccountMenuKeydown(event) {
const menu = document.querySelector("#accountDropdown");
if (!menu) return;
if (menu.hidden) {
if (document.activeElement?.id === "accountButton" && event.key === "ArrowDown") {
event.preventDefault();
toggleAccountDropdown(true);
menu.querySelector('[role="menuitem"]')?.focus();
}
return;
}
const items = [...menu.querySelectorAll('[role="menuitem"]:not(:disabled)')];
if (!items.length) return;
const current = items.indexOf(document.activeElement);
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
const offset = event.key === "ArrowDown" ? 1 : -1;
items[(current + offset + items.length) % items.length].focus();
} else if (event.key === "Home" || event.key === "End") {
event.preventDefault();
items[event.key === "Home" ? 0 : items.length - 1].focus();
}
}
function syncNavigationState(viewId) {
const marketView = MARKET_VIEWS.has(viewId);
document.body.dataset.activeView = viewId;
document.querySelectorAll(".module-tab").forEach((button) => {
button.classList.toggle(
"active",
button.dataset.view === viewId
|| (viewId === "screenerTrackingView" && button.dataset.view === "screenerView"),
);
button.classList.remove("mobile-active");
});
const selector = document.querySelector("#mobileMarketSelector");
const select = document.querySelector("#mobileMarketViewSelect");
if (selector) selector.hidden = !marketView;
if (select && marketView) select.value = viewId;
toggleHeaderCommandMenu(false);
toggleAccountDropdown(false);
}
function updateSentimentGauge(rawScore) {
const gauge = document.querySelector("#sentimentGauge");
if (!gauge) return;
const score = clamp(rawScore, 0, 100);
const previous = Number(gauge.dataset.score);
gauge.dataset.score = String(score);
gauge.style.setProperty("--score", score);
if (!motionEnabled() || !Number.isFinite(previous) || Math.abs(previous - score) < 15) return;
gauge.classList.remove("sentiment-pulse");
void gauge.offsetWidth;
gauge.classList.add("sentiment-pulse");
gauge.addEventListener("animationend", () => gauge.classList.remove("sentiment-pulse"), { once: true });
}
function animateMetric(id, rawValue, formatter = (value) => value) {
const element = document.getElementById(id);
const target = Number(rawValue);
if (!element || !Number.isFinite(target)) {
setText(id, formatter(rawValue));
return;
}
const storedValue = Number(element.dataset.metricValue);
const previous = Number.isFinite(storedValue) ? storedValue : 0;
element.dataset.metricValue = String(target);
const existingFrame = metricAnimationFrames.get(element);
if (existingFrame) cancelAnimationFrame(existingFrame);
if (!motionEnabled() || previous === target) {
element.textContent = formatter(target);
return;
}
element.classList.remove("metric-changed");
void element.offsetWidth;
element.classList.add("metric-changed");
const startedAt = performance.now();
const duration = 560;
const update = (now) => {
const progress = Math.min(1, (now - startedAt) / duration);
const eased = 1 - (1 - progress) ** 3;
element.textContent = formatter(previous + (target - previous) * eased);
if (progress < 1) {
metricAnimationFrames.set(element, requestAnimationFrame(update));
} else {
element.textContent = formatter(target);
metricAnimationFrames.delete(element);
setTimeout(() => element.classList.remove("metric-changed"), 80);
}
};
metricAnimationFrames.set(element, requestAnimationFrame(update));
}
function animateRows(container) {
if (!container) return;
const rows = [...container.children].filter((item) => item.matches("tr, [data-code]"));
if (!motionEnabled()) {
rows.forEach((row) => row.classList.remove("row-pending", "row-enter"));
return;
}
const unseenRows = rows.filter((row) => row.dataset.motionSeen !== "1");
unseenRows.slice(0, 12).forEach((row, index) => {
row.dataset.motionSeen = "1";
row.classList.remove("row-pending", "row-enter");
row.style.setProperty("--row-delay", `${index * 24}ms`);
requestAnimationFrame(() => row.classList.add("row-enter"));
row.addEventListener("animationend", () => row.classList.remove("row-enter"), { once: true });
});
if (!("IntersectionObserver" in window)) {
unseenRows.slice(12).forEach((row) => { row.dataset.motionSeen = "1"; });
return;
}
if (!rowAnimationObserver) {
rowAnimationObserver = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
const row = entry.target;
rowAnimationObserver.unobserve(row);
row.dataset.motionSeen = "1";
row.classList.remove("row-pending");
row.style.setProperty("--row-delay", "0ms");
requestAnimationFrame(() => row.classList.add("row-enter"));
row.addEventListener("animationend", () => row.classList.remove("row-enter"), { once: true });
});
}, { threshold: 0.08, rootMargin: "0px 0px 40px 0px" });
}
unseenRows.slice(12).forEach((row) => {
row.classList.add("row-pending");
rowAnimationObserver.observe(row);
});
}
function waitForMotion(duration) {
return new Promise((resolve) => setTimeout(resolve, motionEnabled() ? duration : 0));
}