const state = {
user: null,
csrfToken: "",
authMode: "login",
started: false,
dashboard: null,
filter: "all",
query: "",
sortKey: "streak",
sortDirection: "desc",
activeView: "limitPool",
dragonTiger: null,
dragonFilter: "all",
dragonQuery: "",
selectedDragonTraderId: "",
rotationHistory: null,
rotationHistoryKey: "",
rotationSelectedSector: "",
rotationLoading: false,
expandedLadderLevels: new Set(),
stockDetail: null,
activeStock: null,
stockPreviewCode: "",
stockPreviewPayload: null,
stockPreviewChart: "intraday",
stockPreviewFallback: null,
watchlist: [],
notes: [],
initialStockOpened: false,
screenerSetup: null,
selectedRegime: "",
selectedStrategy: null,
screenerResult: null,
screenerMobileView: "strategy",
sentimentHistory: null,
sentimentRange: 20,
sentimentHistoryKey: "",
sentimentLoading: false,
mentorSetup: null,
selectedMentorId: "",
mentorMessages: [],
mentorLoading: false,
heavenSetup: null,
heavenManualData: null,
personalField: null,
heavenPanel: "trend",
heavenInterpretations: { trend: "", fortune: "", heart: "" },
heartStage: "intro",
heartTimer: null,
heartSeconds: 30,
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"),
globalSearchDialog: document.querySelector("#globalSearchDialog"),
globalSearchInput: document.querySelector("#globalSearchInput"),
globalSearchResults: document.querySelector("#globalSearchResults"),
entityDetailDialog: document.querySelector("#entityDetailDialog"),
entityDetailChart: document.querySelector("#entityDetailChart"),
settingsDialog: document.querySelector("#settingsDialog"),
adminDialog: document.querySelector("#adminDialog"),
priceChart: document.querySelector("#priceChart"),
stockPreview: document.querySelector("#stockPreview"),
stockPreviewBackdrop: document.querySelector("#stockPreviewBackdrop"),
stockPreviewChart: document.querySelector("#stockPreviewChart"),
};
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 heartHoldTimer = null;
let heartHoldTriggered = false;
let heartHoldStartedAt = 0;
let heartHoldAnimationFrame = 0;
let heartCastingBusy = false;
let heartDustAnimationFrame = 0;
let heartDustParticles = [];
const heartCoinRotations = [0, 0, 0];
const MARKET_VIEWS = new Set([
"limitPool",
"brokenView",
"downView",
"yesterdayView",
"performanceView",
"sentimentCycleView",
"ladderView",
"rotationView",
"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;
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);
async function initialize() {
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("#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();
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();
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;
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", () => loadDashboard(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-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("#closeGlobalSearch").addEventListener("click", closeGlobalSearch);
document.querySelector("#closeEntityDetail").addEventListener("click", () => elements.entityDetailDialog.close());
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);
}
handleAccountMenuKeydown(event);
});
window.addEventListener("resize", () => {
if (window.innerWidth > 720) toggleHeaderCommandMenu(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("#exportButton").addEventListener("click", exportStocks);
document.querySelector("#brokenExportButton").addEventListener("click", exportBroken);
document.querySelector("#downExportButton").addEventListener("click", exportDown);
document.querySelector("#yesterdayExportButton").addEventListener("click", exportYesterday);
document.querySelector("#rotationExportButton").addEventListener("click", exportRotation);
document.querySelector("#clearRotationSelection").addEventListener("click", () => {
state.rotationSelectedSector = "";
renderRotationHistory();
});
document.querySelector("#sentimentExportButton").addEventListener("click", exportSentimentHistory);
document.querySelectorAll("[data-sentiment-range]").forEach((button) => {
button.addEventListener("click", () => {
state.sentimentRange = number(button.dataset.sentimentRange) || 20;
document.querySelectorAll("[data-sentiment-range]").forEach((item) => {
item.classList.toggle("active", item === button);
});
loadSentimentHistory(true);
});
});
document.querySelector("#settingsButton").addEventListener("click", () => openAdminSettings());
document.querySelector("#accountButton").addEventListener("click", (event) => {
event.stopPropagation();
toggleAccountDropdown();
});
document.querySelector("#accountVipBadge").addEventListener("click", () => openSettings("membership"));
document.querySelectorAll("[data-account-panel]").forEach((button) => {
button.addEventListener("click", () => openSettings(button.dataset.accountPanel));
});
document.querySelector("#switchAccountMenuButton").addEventListener("click", switchAccount);
document.querySelector("#logoutMenuButton").addEventListener("click", logoutAccount);
document.querySelector("#closeSettingsDialog").addEventListener("click", () => elements.settingsDialog.close());
document.querySelector("#closeAdminDialog").addEventListener("click", () => elements.adminDialog.close());
document.querySelector("#closeStockDialog").addEventListener("click", () => elements.stockDialog.close());
document.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("#dragonRefreshButton").addEventListener("click", () => loadDragonTiger(false));
document.querySelector("#dragonExportButton").addEventListener("click", exportDragonTiger);
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("#journalForm").addEventListener("submit", saveJournal);
document.querySelector("#stockNoteForm").addEventListener("submit", saveStockNote);
document.querySelector("#watchStockButton").addEventListener("click", toggleActiveWatchlist);
document.querySelector("#stockHeavenButton").addEventListener("click", openActiveStockInHeaven);
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.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.querySelector("#mentorChatForm").addEventListener("submit", sendMentorQuestion);
document.querySelector("#clearMentorChatButton").addEventListener("click", clearMentorConversation);
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("#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);
initializeHeartCoinHold();
initializeHeartLineInspection();
document.querySelector("#interpretHeartButton").addEventListener("click", () => interpretHeaven("heart"));
document.querySelector("#restartHeartButton").addEventListener("click", resetHeartRitual);
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", () => {
if (elements.stockDialog.open && state.stockDetail?.prices) drawPriceChart(state.stockDetail.prices);
if (state.activeView === "sentimentCycleView" && state.sentimentHistory) {
drawSentimentTrendChart(state.sentimentHistory.rows || []);
}
});
initializeAutoTableSorting();
}
async function loadDashboard(force = false, background = false) {
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) {
setLoading(true, "正在读取本地复盘数据");
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) 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();
const source = payload.meta.source === "tushare" ? "Tushare" : "演示数据";
const cacheText = payload.meta.realtime
? "rt_k 实时行情"
: payload.meta.cached ? "SQLite 缓存" : "已写入 SQLite";
setStatus(`${source} · ${cacheText}`);
if (!background) {
if (state.activeView === "dragonView") loadDragonTiger();
if (state.activeView === "screenerView") loadScreenerSetup();
if (state.activeView === "mentorView") loadMentorSetup(true);
if (state.activeView === "heavenView") loadHeavenSetup(true);
if (state.activeView === "sentimentCycleView") loadSentimentHistory(true);
if (state.activeView === "rotationView") loadRotationHistory(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.source === "demo") return "演示数据";
if (meta.realtime && !["closed", "after_hours"].includes(String(meta.market_status || ""))) return "Tushare 实时行情";
if (meta.source === "tushare") return "Tushare 盘后行情";
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} · 动量 ${latest.momentum > 0 ? "+" : ""}${formatNumber(latest.momentum, 1)}`);
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-cycle-score-marker ${sentimentPhaseClass(latest.phase)}`;
document.querySelector("#sentimentComponentList").innerHTML = Object.values(latest.components || {}).map((item) => `
${escapeHtml(item.label)}权重 ${number(item.weight)}${formatNumber(item.score, 1)}
${escapeHtml(item.summary)}
`).join("");
requestAnimationFrame(() => {
animateSentimentComponents();
animateSentimentTrendChart(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");
context.setTransform(ratio, 0, 0, ratio, 0, 0);
context.clearRect(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 ? "#ccd7de" : "#e6ebef";
context.lineWidth = 1;
context.beginPath();
context.moveTo(padding.left, lineY);
context.lineTo(width - padding.right, lineY);
context.stroke();
context.fillStyle = "#758590";
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();
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 = "#1268c4";
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 = row.direction === "降温" ? "#d64955" : row.direction === "升温" ? "#087f67" : "#1268c4";
context.fill();
});
context.restore();
const labelStep = Math.max(1, Math.ceil(rows.length / 6));
context.textAlign = "center";
context.textBaseline = "top";
context.fillStyle = "#758590";
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 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 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 body = document.querySelector("#limitTableBody");
body.innerHTML = rows.map((row, index) => `
| ${index + 1} |
${escapeHtml(row.code)} |
${escapeHtml(row.name)} |
${streakLabel(row.streak)} |
${signed(row.change)}% |
${formatNumber(row.price, 2)} |
${escapeHtml(row.sector || "其他")} |
${escapeHtml(row.reason || "--")} |
${escapeHtml(row.first_time || "--")} |
${escapeHtml(row.last_time || "--")} |
${number(row.open_times)} |
${formatNumber(row.turnover_rate, 2)}% |
${formatNumber(row.amount_billion, 2)} 亿 |
${formatNumber(row.seal_amount_million, 0)} 万 |
`).join("");
bindStockRows(body);
setText("resultCount", `${rows.length} 只`);
document.querySelector("#emptyState").hidden = rows.length !== 0;
updateSortHeaders();
}
function renderBrokenTable(rows) {
setText("brokenCount", `${rows.length} 只`);
const body = document.querySelector("#brokenTableBody");
body.innerHTML = rows.map((row, index) => `
| ${index + 1} | ${escapeHtml(row.code)} |
${escapeHtml(row.name)} | ${signed(row.change)}% |
${formatNumber(row.price, 2)} | ${escapeHtml(row.sector)} |
${escapeHtml(row.reason || "--")} | ${escapeHtml(row.first_time || "--")} |
${escapeHtml(row.last_time || "--")} | ${number(row.open_times)} |
${formatNumber(row.turnover_rate, 2)}% | ${formatNumber(row.amount_billion, 2)} 亿 |
`).join("");
bindStockRows(body);
}
function renderDownTable(rows) {
setText("downCount", `${rows.length} 只`);
const body = document.querySelector("#downTableBody");
body.innerHTML = rows.map((row, index) => `
| ${index + 1} | ${escapeHtml(row.code)} |
${escapeHtml(row.name)} | ${signed(row.change)}% |
${formatNumber(row.price, 2)} | ${escapeHtml(row.sector)} |
${escapeHtml(row.reason || "--")} | ${number(row.streak)} |
${formatNumber(row.turnover_rate, 2)}% | ${formatNumber(row.amount_billion, 2)} 亿 |
`).join("");
bindStockRows(body);
}
function renderYesterdayTable(rows) {
setText("yesterdayCount", `${rows.length} 只`);
setText("previousTradeDate", `数据日期 ${state.dashboard.meta.previous_trade_date || "--"}`);
document.querySelector("#yesterdayTableBody").innerHTML = rows.map((row, index) => `
| ${index + 1} | ${escapeHtml(row.code)} |
${escapeHtml(row.name)} | ${streakLabel(row.prior_streak)} |
${signed(row.current_change)}% |
${escapeHtml(row.outcome)} |
${number(row.current_streak) ? streakLabel(row.current_streak) : "--"} |
${escapeHtml(row.sector || "其他")} | ${escapeHtml(row.reason || "--")} |
`).join("");
bindStockRows(document.querySelector("#yesterdayTableBody"));
}
function renderPerformance(rows) {
document.querySelector("#performanceCards").innerHTML = rows.map((row) => `
${formatNumber(row.advance_rate, 1)}%晋级率
收红 ${formatNumber(row.positive_rate, 1)}%均涨 ${signed(row.average_change)}%
`).join("") || '暂无昨日涨停统计
';
document.querySelector("#performanceTableBody").innerHTML = rows.map((row) => `
| ${escapeHtml(row.label)} | ${number(row.count)} |
${number(row.advanced)} | ${formatNumber(row.advance_rate, 1)}% |
${formatNumber(row.positive_rate, 1)}% |
${signed(row.average_change)}% |
`).join("");
renderMarketBreadth(state.dashboard?.overview || {});
}
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 ratio = down > 0 ? up / down : up > 0 ? up : 0;
const difference = up - down;
const panel = document.querySelector(".market-breadth-panel");
panel.classList.remove("breadth-enter");
void panel.offsetWidth;
panel.classList.add("breadth-enter");
setText("breadthSummary", `${up + down + flat} 只股票参与统计`);
animateMetric("breadthRatio", upRate, (value) => `红盘 ${formatNumber(value, 1)}%`);
animateMetric("breadthUpCount", up, (value) => `${Math.round(value)} 家`);
animateMetric("breadthFlatCount", flat, (value) => `${Math.round(value)} 家`);
animateMetric("breadthDownCount", down, (value) => `${Math.round(value)} 家`);
animateMetric("breadthAdvanceDecline", ratio, (value) => `${formatNumber(value, 2)} : 1`);
animateMetric("breadthDifference", difference, (value) => `${value > 0 ? "+" : ""}${Math.round(value)} 家`);
const differenceElement = document.querySelector("#breadthDifference");
differenceElement.classList.remove("up", "down", "warning");
differenceElement.classList.add(changeClass(difference));
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");
if (!rows.length) {
container.innerHTML = '尚无连续交易日的板块数据
';
setText("rotationHistoryRange", "暂无轮动历史");
return;
}
setText(
"rotationHistoryRange",
`最近 ${rows.length} 个交易日 · ${displayCompactDate(rows[0].trade_date)} → ${displayCompactDate(rows[rows.length - 1].trade_date)} · 由近到远`,
);
setText("rotationSelectionHint", selected ? `正在追踪:${selected}` : "左近右远 · 点击板块查看连续性");
document.querySelector("#clearRotationSelection").hidden = !selected;
container.innerHTML = rows.map((day) => {
const hasSelected = selected && (day.sectors || []).some((sector) => sector.name === selected);
return `
${(day.sectors || []).length} 个热点
${(day.sectors || []).map((sector) => `
`).join("")}
`;
}).join("");
container.querySelectorAll("[data-rotation-sector]").forEach((button) => {
button.addEventListener("click", () => {
state.rotationSelectedSector = button.dataset.rotationSector === state.rotationSelectedSector
? ""
: button.dataset.rotationSector;
renderRotationHistory();
});
});
}
function renderRotationTable(rows, sectors) {
const sectorMap = new Map(sectors.map((sector) => [sector.name, sector]));
const body = document.querySelector("#rotationTableBody");
body.innerHTML = rows.map((row) => {
const sector = sectorMap.get(row.name) || {};
const strength = number(row.strength ?? sector.strength);
return `
| ${number(row.rank)} | ${escapeHtml(row.name)} |
${escapeHtml(row.trend)} |
${number(row.count)} | ${number(row.previous_count)} |
${number(row.delta) > 0 ? "+" : ""}${number(row.delta)} |
${formatNumber(strength, 0)} |
${streakLabel(row.max_streak || 1)} |
${signed(sector.change)}% |
${escapeHtml(row.leader || sector.leader || "--")} | ${formatNumber(row.amount_billion, 1)} 亿 |
`;
}).join("");
animateRows(body);
}
function renderLadderMini(ladders) {
const container = document.querySelector("#ladderMini");
const highest = ladders.length ? Math.max(...ladders.map((item) => number(item.level))) : 0;
setText("maxHeight", highest ? `最高 ${highest} 板` : "暂无");
container.innerHTML = ladders.slice(0, 5).map((group) => {
const names = group.stocks.slice(0, 3).map((stock) => stock.name).join("、");
return `${escapeHtml(group.label)}
${escapeHtml(names || "--")}${group.count}只
`;
}).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 ordered = [...ladders].sort((left, right) => number(right.level) - number(left.level));
const maxLevel = Math.max(1, ...ordered.map((group) => number(group.level)));
container.innerHTML = ordered.map((group) => {
const level = number(group.level);
const limit = level === 1 ? 8 : 6;
const expanded = state.expandedLadderLevels.has(level);
const stocks = expanded ? group.stocks : group.stocks.slice(0, limit);
const remaining = Math.max(0, group.stocks.length - stocks.length);
return `
${stocks.map((stock) => ``).join("")}
${group.stocks.length > limit ? `` : ""}
`;
}).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 loadDragonTiger(force = false) {
const requestedDate = elements.tradeDate.value;
if (
!force
&& ["success", "demo"].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.source === "tushare" ? "龙虎榜明细" : "演示数据";
setStatus(`${statusLabel} · 龙虎榜已加载`);
} catch (error) {
showToast(error.message || "龙虎榜加载失败");
setStatus("龙虎榜加载失败");
}
}
function renderDragonTiger() {
const payload = state.dragonTiger;
if (!payload) return;
const summary = payload.summary || {};
setText("dragonDateLabel", `数据日期 ${payload.meta.trade_date}`);
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 (payload.meta?.status === "error") emptyMessage = "游资接口调用失败,请查看上方提示并检查 Tushare 积分权限";
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.innerHTML = '选择一位游资查看操作明细
';
return;
}
container.innerHTML = `
| 股票 | 方向 | 涨幅 | 买入 | 卖出 | 净额 | 关联席位 | 标签 / 上榜原因 |
${(trader.operations || []).map((operation) => `
| ${escapeHtml(operation.name)}${escapeHtml(operation.code)} |
${escapeHtml(operation.direction)} |
${operation.change == null ? "--" : `${signed(operation.change)}%`} |
${formatMoneyMillion(operation.buy_million)} |
${formatMoneyMillion(operation.sell_million)} |
${formatMoneyMillion(operation.net_buy_million)} |
${escapeHtml(operation.seat_name)} |
${escapeHtml(operation.tag && operation.tag !== "--" ? operation.tag : operation.reason || "--")} |
`).join("")}
`;
bindStockRows(container);
markAutoSortableHeaders(container);
}
function renderUnclassifiedSeats() {
const seats = state.dragonTiger?.unclassified_seats || [];
document.querySelector("#dragonUnclassifiedSection").hidden = seats.length === 0;
document.querySelector("#dragonUnclassifiedFilter").hidden = 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] = await Promise.all([
apiRequest("/api/watchlist"),
apiRequest("/api/notes?scope=daily"),
]);
state.watchlist = watchlistPayload.items || [];
state.notes = notesPayload.items || [];
renderWatchlist();
renderNotesHistory(state.notes, document.querySelector("#notesHistory"), false);
setText("notesCount", `${state.notes.length} 条`);
} 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.code)} | ${escapeHtml(item.name)} |
${escapeHtml(item.sector || "其他")} |
|
`).join("");
document.querySelector("#watchlistEmpty").hidden = state.watchlist.length > 0;
body.querySelectorAll("[data-watch-detail]").forEach((button) => {
button.addEventListener("click", () => {
const item = state.watchlist.find((row) => row.code === button.dataset.watchDetail);
openStock(button.dataset.watchDetail, item);
});
});
body.querySelectorAll("[data-watch-delete]").forEach((button) => {
button.addEventListener("click", () => removeWatchlist(button.dataset.watchDelete));
});
bindStockRows(body);
}
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,
content: document.querySelector("#journalContent").value,
plan: document.querySelector("#journalPlan").value,
});
document.querySelector("#journalContent").value = "";
document.querySelector("#journalPlan").value = "";
await loadReviewWorkspace();
showToast("每日复盘已保存");
} catch (error) {
showToast(error.message);
}
}
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)}` : ""}
复盘${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;
}
}
async function loadScreenerSetup() {
try {
const query = new URLSearchParams({ trade_date: elements.tradeDate.value });
const payload = await apiRequest(`/api/screener/setup?${query}`);
state.screenerSetup = payload;
if (!state.screenerResult && payload.latest_result) state.screenerResult = payload.latest_result;
if (!state.selectedRegime) state.selectedRegime = payload.regime.id;
const selectedId = state.selectedStrategy?.id;
state.selectedStrategy = payload.strategies.find((item) => item.id === selectedId)
|| payload.strategies.find((item) => item.regimes.includes(state.selectedRegime))
|| payload.strategies[0]
|| null;
renderScreenerSetup();
if (state.screenerResult) renderScreenerResult();
} catch (error) {
showToast(error.message || "选股配置加载失败");
}
}
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("regimeReason", setup.regime.reason);
document.querySelector("#regimeEvidenceList").textContent = setup.regime.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)}`
: "尚未达到 21 个交易日");
setText("factorTaskStatus", setup.factor_data.ready ? `已就绪 · ${number(setup.factor_data.date_count)} 日` : "需要同步");
const compilerText = setup.llm.configured ? "模型编译就绪" : "本地编译就绪";
setText("compilerStatus", compilerText);
setText(
"screenerRunStatus",
state.screenerResult ? `已有结果 · ${(state.screenerResult.candidates || []).length} 只` : "等待执行",
);
setText("strategyCount", `${setup.strategies.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);
}
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 = state.screenerResult?.backtest;
setText("backtestTaskStatus", backtest ? `已完成 · ${number(backtest.samples)} 样本` : enabled ? "随选股执行" : "本次不执行");
}
function selectRegime(regime) {
state.selectedRegime = regime;
const recommended = state.screenerSetup.strategies.find((item) => item.regimes.includes(regime));
if (recommended) state.selectedStrategy = recommended;
renderScreenerSetup();
}
function renderStrategyList() {
const list = document.querySelector("#strategyList");
list.innerHTML = state.screenerSetup.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("activeStrategyHeading", "--");
return;
}
setText("activeStrategyHeading", 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} 个交易日,新增 ${result.bars} 条行情`);
await loadScreenerSetup();
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;
setText("activeStrategyHeading", strategy.name || "未命名策略");
document.querySelector("#strategyNameInput").value = strategy.name;
document.querySelector("#strategyDescriptionInput").value = strategy.description;
document.querySelector("#formulaEditor").value = JSON.stringify(strategy.formula, null, 2);
setText(
"compilerStatus",
strategy.compiler === "local" ? "本地编译完成" : "模型编译完成",
);
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.regimes.includes(state.selectedRegime))
|| payload.strategies[0]
|| null;
renderScreenerSetup();
showToast("自定义策略已删除");
} catch (error) {
showToast(error.message || "策略删除失败");
} finally {
button.disabled = false;
}
}
async function runScreener() {
if (!state.screenerSetup?.factor_data?.ready) {
showToast("请先同步至少 21 个交易日的因子数据");
return;
}
const button = document.querySelector("#screenerRunButton");
button.disabled = true;
setText("screenerRunStatus", "正在计算");
setText("backtestTaskStatus", document.querySelector("#runBacktestToggle").checked ? "正在回测" : "本次不执行");
setLoading(true, "正在计算因子排名与滚动回测", "screener");
setStatus("正在执行智能选股");
try {
const formula = parseFormulaEditor();
const payload = await apiRequest("/api/screener/run", "POST", {
trade_date: elements.tradeDate.value,
regime: state.selectedRegime,
strategy_name: document.querySelector("#strategyNameInput").value,
formula,
run_backtest: document.querySelector("#runBacktestToggle").checked,
});
state.screenerResult = payload.result;
renderScreenerResult();
setText("screenerRunStatus", `完成 · ${payload.result.candidates.length} 只`);
updateBacktestTaskStatus();
if (window.innerWidth <= 720) selectScreenerMobileView("results");
setStatus(`智能选股完成 · ${payload.result.candidates.length} 只候选`);
} catch (error) {
showToast(error.message);
setStatus("智能选股失败");
setText("screenerRunStatus", "执行失败");
updateBacktestTaskStatus();
} finally {
setLoading(false);
button.disabled = false;
}
}
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;
state.mentorSetup = payload;
const selectedExists = payload.mentors.some((item) => item.id === state.selectedMentorId);
state.selectedMentorId = selectedExists ? state.selectedMentorId : payload.mentors[0]?.id || "";
state.mentorMessages = loadStoredMentorMessages();
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("mentorCount", `${setup.mentors.length} 位`);
setText("mentorDataDate", `数据日期 ${displayCompactDate(setup.trade_date)}`);
setText("activeMentorName", selected?.name || "--");
document.querySelector("#mentorList").innerHTML = setup.mentors.map((mentor) => `
`).join("");
document.querySelectorAll("[data-mentor-id]").forEach((button) => {
button.addEventListener("click", () => selectMentor(button.dataset.mentorId));
});
renderMentorMessages();
}
function selectMentor(mentorId) {
if (mentorId === state.selectedMentorId) return;
saveStoredMentorMessages();
state.selectedMentorId = mentorId;
state.mentorMessages = loadStoredMentorMessages();
hideMentorNotice();
renderMentorWorkspace();
}
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 || "选择一个问题开始对话")}
`;
} else {
container.innerHTML = state.mentorMessages.map((message) => `
${message.role === "user" ? "我" : escapeHtml(selected?.name || "问师")}
${message.role === "assistant" ? formatMentorAnswer(message.content) : escapeHtml(message.content)}
${message.meta ? `${escapeHtml(message.meta)}` : ""}
`).join("");
if (state.mentorLoading) {
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;
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 });
input.value = "";
state.mentorLoading = true;
hideMentorNotice();
renderMentorMessages();
setStatus("问师正在读取复盘数据");
try {
const payload = await apiRequest("/api/mentors/chat", "POST", {
mentor_id: state.selectedMentorId,
trade_date: elements.tradeDate.value,
question,
history,
});
const modelRole = payload.compiler === "fallback" ? "辅助模型" : "主模型";
state.mentorMessages.push({
role: "assistant",
content: payload.answer,
meta: `${displayCompactDate(payload.data_trade_date)} · ${modelRole} ${payload.model} · ${number(payload.latency_ms)}ms`,
});
saveStoredMentorMessages();
if (payload.notice) showMentorNotice(payload.notice);
setStatus("问师回答完成");
} catch (error) {
showMentorNotice(error.message || "问师回答失败");
showToast(error.message || "问师回答失败");
setStatus("问师回答失败");
} finally {
state.mentorLoading = false;
renderMentorMessages();
input.focus();
}
}
function useMentorQuickPrompt(prompt) {
const input = document.querySelector("#mentorQuestion");
input.value = prompt || "";
input.focus();
}
function clearMentorConversation() {
if (!state.mentorMessages.length || !window.confirm("确定清空当前老师的对话记录吗?")) return;
state.mentorMessages = [];
try { localStorage.removeItem(mentorStorageKey()); } catch {}
hideMentorNotice();
renderMentorMessages();
}
function mentorStorageKey() {
const dateKey = (state.mentorSetup?.trade_date || elements.tradeDate.value).replaceAll("-", "");
return `xiaobai-mentor-chat:${state.selectedMentorId}:${dateKey}`;
}
function loadStoredMentorMessages() {
if (!state.selectedMentorId) return [];
try {
const messages = JSON.parse(localStorage.getItem(mentorStorageKey()) || "[]");
if (!Array.isArray(messages)) return [];
return messages.filter((item) => ["user", "assistant"].includes(item?.role) && typeof item.content === "string").slice(-20);
} catch {
return [];
}
}
function saveStoredMentorMessages() {
if (!state.selectedMentorId) return;
try { localStorage.setItem(mentorStorageKey(), JSON.stringify(state.mentorMessages.slice(-20))); } catch {}
}
function showMentorNotice(message) {
const notice = document.querySelector("#mentorNotice");
notice.textContent = message;
notice.hidden = false;
}
function hideMentorNotice() {
document.querySelector("#mentorNotice").hidden = true;
}
function formatMentorAnswer(content) {
return escapeHtml(content).split("\n").map((line) => {
const heading = line.match(/^#{1,3}\s+(.+)$/);
if (heading) return `${formatMentorInline(heading[1])}`;
if (/^-{3,}$/.test(line.trim())) return '';
if (line.startsWith("> ")) return `${formatMentorInline(line.slice(5))}`;
return formatMentorInline(line);
}).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.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;
document.querySelectorAll("[data-heaven-panel]").forEach((button) => {
button.classList.toggle("active", button.dataset.heavenPanel === panel);
});
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();
showHeartRitualCurtain();
startHeartDust();
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;
const calendarDate = setup.calendar_date || setup.trade_date;
setText(
"heavenDataDate",
calendarDate === setup.trade_date
? `数据日期 ${displayCompactDate(setup.trade_date)}`
: `行情 ${displayCompactDate(setup.trade_date)} · 历法 ${displayCompactDate(calendarDate)}`,
);
renderMarketHexagram(setup.chart);
renderFivePhaseField(setup.field);
renderPersonalFortune();
renderHeartStage();
selectHeavenPanel(state.heavenPanel);
}
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_taxonomy === "sw_l2"
? `申万二级 · ${chart.sector}`
: chart.sector || "--",
);
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", "数据未齐");
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);
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 renderFivePhaseField(field) {
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", dominantPhase ? `${dominantPhase.element}气偏显` : "气场待察");
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);
renderSectorPhaseOverrides(state.heavenSetup?.sector_phase_overrides || []);
setText("fortuneNotice", field.notice);
renderHeavenInterpretation("fortune", state.heavenInterpretations.fortune);
}
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");
container.innerHTML = items.length ? items.map((item) => `
${escapeHtml(item.element)}
${escapeHtml(item.name)}
`).join("") : '暂无手动归类
';
container.querySelectorAll("[data-sector-phase-delete]").forEach((button) => {
button.addEventListener("click", () => deleteSectorPhaseOverride(button.dataset.sectorPhaseDelete));
});
}
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, emptyText = "--") => items.length
? items.map((item) => `${escapeHtml(item)}`).join("")
: emptyText;
container.innerHTML = `
日主
${escapeHtml(personal.day_master.stem)}
${escapeHtml(personal.day_master.element)}
${escapeHtml(personal.day_master.strength)}
十神喜恶
偏宜${tenGods.favorable.map((item) => `${escapeHtml(item)}`).join("") || "--"}
偏慎${tenGods.caution.map((item) => `${escapeHtml(item)}`).join("") || "--"}
五行喜忌
偏喜${preferenceTags(elementTendency.favorable || [])}
偏忌${preferenceTags(elementTendency.caution || [])}
当前作用 · 流年 ${escapeHtml(personal.current.ten_gods.year.stem)} · 流月 ${escapeHtml(personal.current.ten_gods.month.stem)} · 流日 ${escapeHtml(personal.current.ten_gods.day.stem)}
${escapeHtml(personal.current.tone)}
${escapeHtml(personal.current.operation_note)}
${escapeHtml(personal.balance_tendency.method)}
查看个人五行结构
${personal.element_balance.map((item) => `
${escapeHtml(item.element)}${number(item.percent)}%
`).join("")}
`;
}
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 ? "○" : "×"}` : ""}
`;
}
async function interpretHeaven(mode) {
const button = document.querySelector(mode === "trend" ? "#interpretTrendButton" : mode === "fortune" ? "#interpretFortuneButton" : "#interpretHeartButton");
if (button.disabled) return;
button.disabled = true;
const originalText = button.textContent;
button.textContent = mode === "trend" ? "正在观势" : mode === "fortune" ? "正在察运" : "正在解卦";
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);
const modelRole = result.compiler === "fallback" ? "辅助模型" : "主模型";
state.heavenInterpretations[mode] = {
answer: result.answer,
meta: `${modelRole} ${result.model} · ${number(result.latency_ms)}ms`,
};
if (result.notice) showHeavenNotice(result.notice);
if (mode === "heart") {
if (await transitionHeartStage("interpretation")) await playHeartReadSequence();
} else {
renderHeavenInterpretation(mode, state.heavenInterpretations[mode]);
}
} catch (error) {
showHeavenNotice(error.message || "问天解读失败");
showToast(error.message || "问天解读失败");
} finally {
button.disabled = false;
button.textContent = originalText;
}
}
function renderHeavenInterpretation(mode, result) {
const container = document.querySelector(`#${mode}Interpretation`);
if (!result) {
container.hidden = true;
container.innerHTML = "";
return;
}
container.hidden = false;
container.innerHTML = `${formatMentorAnswer(result.answer)}
${escapeHtml(result.meta)}`;
}
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 = 30;
state.heartBreathingEndsAt = 0;
document.querySelector("#beginCastingButton")?.classList.remove("is-ready");
if (!await transitionHeartStage("breathing")) return;
state.heartBreathingEndsAt = Date.now() + 30_000;
const ember = document.querySelector("#heartIncenseEmber");
ember?.classList.remove("is-burning");
if (ember) void ember.offsetWidth;
ember?.classList.add("is-burning");
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() {
setText("breathingSeconds", Math.max(0, state.heartSeconds));
const remainingMs = state.heartBreathingEndsAt
? Math.max(0, state.heartBreathingEndsAt - Date.now())
: Math.max(0, state.heartSeconds * 1000);
const elapsedMs = 30_000 - remainingMs;
const inhaling = Math.floor(elapsedMs / 4000) % 2 === 0;
const phase = state.heartSeconds <= 0 ? "settled" : inhaling ? "inhale" : "exhale";
const scene = document.querySelector("#breathingScene");
scene.dataset.phase = phase;
setText("breathingPhase", phase === "settled" ? "已静" : inhaling ? "吸气" : "呼气");
document.querySelector("#breathingProgress").style.width = `${clamp(elapsedMs / 300, 0, 100)}%`;
const prompt = state.heartSeconds <= 0
? "静心已成,可以起卦"
: state.heartSeconds > 20
? inhaling ? "缓慢吸气,放下对答案的预设" : "缓慢呼气,让预设随之松开"
: state.heartSeconds > 10
? inhaling ? "吸气,只留下真正想问的事" : "呼气,不急着寻找答案"
: inhaling ? "吸气,让心停在此刻" : "呼气,不追逐经过的念头";
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";
coin.querySelector(".front").textContent = "字";
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;
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");
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("#heartLineTexts").innerHTML = hexagram.lines.map((line) => `
`).join("");
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(".front").textContent = "观";
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 = 30;
state.heartBreathingEndsAt = 0;
state.heartLines = [];
state.heartThrows = [];
state.heartHexagram = null;
state.heavenInterpretations.heart = "";
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 result = state.screenerResult;
if (!result) return;
const candidates = result.candidates || [];
setText("screenerResultCount", `${candidates.length} 只`);
const meta = result.meta || {};
setText(
"screenerDisclaimer",
meta.realtime
? `rt_k 实时截面 · 历史因子截至 ${displayCompactDate(meta.history_cutoff)} · ${result.disclaimer}`
: `盘后数据 ${displayCompactDate(meta.trade_date)} · ${result.disclaimer}`,
);
document.querySelector("#screenerEmpty").hidden = candidates.length > 0;
const body = document.querySelector("#screenerTableBody");
body.innerHTML = candidates.map((row, index) => `
| ${index + 1} | ${escapeHtml(row.code)} |
${escapeHtml(row.name)} | ${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);
});
});
bindStockRows(body);
renderBacktest(result.backtest);
setText("screenerRunStatus", `完成 · ${candidates.length} 只`);
updateBacktestTaskStatus();
}
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() {
exportRows("智能选股", state.screenerResult?.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 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");
context.setTransform(ratio, 0, 0, ratio, 0, 0);
context.clearRect(0, 0, width, height);
context.fillStyle = "#fbfcfd";
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 = "#e2e8ec";
context.fillStyle = "#6c7983";
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 rising = number(item.close) >= number(item.open);
const color = rising ? "#ef5143" : "#079667";
context.strokeStyle = color;
context.fillStyle = color;
context.beginPath();
context.moveTo(x, priceY(item.high));
context.lineTo(x, priceY(item.low));
context.stroke();
const openY = priceY(item.open);
const closeY = priceY(item.close);
const bodyTop = Math.min(openY, closeY);
const bodyHeight = Math.max(1, Math.abs(closeY - openY));
if (rising) context.strokeRect(x - candleWidth / 2, bodyTop, candleWidth, bodyHeight);
else context.fillRect(x - candleWidth / 2, bodyTop, candleWidth, bodyHeight);
const volumeBarHeight = number(item.volume) / maxVolume * volumeHeight;
context.globalAlpha = 0.75;
context.fillRect(x - candleWidth / 2, height - bottom - volumeBarHeight, candleWidth, volumeBarHeight);
context.globalAlpha = 1;
});
context.textAlign = "center";
context.fillStyle = "#6c7983";
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));
context.fillStyle = "#fbfcfd";
context.fillRect(0, 0, canvas.width, canvas.height);
context.fillStyle = "#647380";
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");
context.setTransform(ratio, 0, 0, ratio, 0, 0);
context.clearRect(0, 0, width, height);
context.fillStyle = "#ffffff";
context.fillRect(0, 0, width, height);
context.font = '11px -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei UI", sans-serif';
return { canvas, context, width, height };
}
function drawPreviewGrid(context, width, top, bottom, left, right, maximum, range) {
context.strokeStyle = "#e7ebef";
context.fillStyle = "#74808d";
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 drawIntradayPreviewChart(points, dailyPrices) {
const { context, width, height } = prepareStockPreviewCanvas();
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(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 padding = Math.max((maximum - minimum) * 0.08, maximum * 0.001, 0.01);
const chartMaximum = maximum + padding;
const chartMinimum = minimum - padding;
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 * index / Math.max(1, points.length - 1);
drawPreviewGrid(context, width, top, priceBottom, left, right, chartMaximum, range);
context.save();
context.setLineDash([4, 4]);
context.strokeStyle = "#aeb7c1";
context.beginPath();
context.moveTo(left, priceY(previousClose));
context.lineTo(width - right, priceY(previousClose));
context.stroke();
context.restore();
context.strokeStyle = "#1d65c1";
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 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) ? "rgba(201,63,69,.58)" : "rgba(8,122,85,.58)";
context.fillRect(x - barWidth / 2, height - bottom - barHeight, barWidth, barHeight);
});
context.fillStyle = "#74808d";
context.textAlign = "center";
[0, Math.floor((points.length - 1) / 2), points.length - 1].forEach((index) => {
context.fillText(points[index]?.time || "--", pointX(index), height - 4);
});
const latest = closes.at(-1);
setText(
"stockPreviewSummary",
`分时 ${points.length} 点,最新 ${formatNumber(latest, 2)},最高 ${formatNumber(maximum, 2)},最低 ${formatNumber(minimum, 2)}。`,
);
}
function drawDailyPreviewChart(prices) {
const { context, width, height } = 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 rising = number(item.close) >= number(item.open);
const color = rising ? "#c93f45" : "#087a55";
context.strokeStyle = color;
context.fillStyle = color;
context.beginPath();
context.moveTo(x, priceY(item.high));
context.lineTo(x, priceY(item.low));
context.stroke();
const openY = priceY(item.open);
const closeY = priceY(item.close);
const bodyTop = Math.min(openY, closeY);
const bodyHeight = Math.max(1, Math.abs(closeY - openY));
if (rising) context.strokeRect(x - candleWidth / 2, bodyTop, candleWidth, bodyHeight);
else context.fillRect(x - candleWidth / 2, bodyTop, candleWidth, bodyHeight);
const volumeBarHeight = number(item.volume) / maxVolume * volumeHeight;
context.globalAlpha = 0.62;
context.fillRect(x - candleWidth / 2, height - bottom - volumeBarHeight, candleWidth, volumeBarHeight);
context.globalAlpha = 1;
});
const movingAverages = [
{ days: 5, color: "#1d65c1" },
{ days: 10, color: "#a76500" },
{ days: 20, color: "#626c78" },
];
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 = "#74808d";
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 = state.screenerResult?.candidates || [];
const dragonRows = (state.dragonTiger?.traders || []).flatMap((trader) => trader.operations || []);
const row = [...dashboardRows, ...screenerRows, ...dragonRows, ...(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 = "intraday";
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}:${elements.tradeDate.value}`;
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 query = new URLSearchParams({ trade_date: elements.tradeDate.value });
const payload = await apiRequest(
`/api/stock/${encodeURIComponent(code)}/preview?${query}`,
"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", meaningfulNumber(fallback.price) ? formatNumber(fallback.price, 2) : "--");
const change = number(fallback.change);
setText("stockPreviewChange", meaningfulNumber(fallback.change) ? `${signed(change)}%` : "--");
document.querySelector("#stockPreviewChange").className = changeClass(change);
setText("stockPreviewDate", elements.tradeDate.value);
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 ?? fallback.price;
const change = stock.change ?? fallback.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);
setText("stockPreviewDate", payload.meta?.trade_date || elements.tradeDate.value);
document.querySelector("#stockPreviewLoading").hidden = true;
const intradayAvailable = (payload.intraday || []).length > 0;
const source = payload.meta?.realtime
? "Tushare 实时行情"
: payload.meta?.source === "tushare" ? "Tushare 日K" : "演示日K";
setText(
"stockPreviewSource",
intradayAvailable ? `${source} · 1分钟` : `${source} · 分时不可用`,
);
selectStockPreviewChart(intradayAvailable ? "intraday" : "daily");
requestAnimationFrame(repositionStockPreview);
}
function renderStockPreviewError(message) {
document.querySelector("#stockPreviewLoading").hidden = true;
setText("stockPreviewSource", "行情加载失败");
setText("stockPreviewSummary", message);
clearStockPreviewChart("加载失败");
}
function selectStockPreviewChart(chart) {
state.stockPreviewChart = chart === "daily" ? "daily" : "intraday";
document.querySelectorAll("[data-preview-chart]").forEach((button) => {
const active = button.dataset.previewChart === state.stockPreviewChart;
button.classList.toggle("active", active);
button.setAttribute("aria-selected", String(active));
});
const payload = state.stockPreviewPayload;
if (!payload) return;
if (state.stockPreviewChart === "intraday") {
if ((payload.intraday || []).length) drawIntradayPreviewChart(payload.intraday, payload.prices || []);
else {
clearStockPreviewChart("分时数据不可用");
setText("stockPreviewSummary", payload.meta?.intraday_notice || "该交易日暂无分时数据。");
}
} else if ((payload.prices || []).length) {
drawDailyPreviewChart(payload.prices);
} else {
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`;
}
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) {
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}`);
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 || "--");
setText("entityDetailDate", `${payload.meta?.realtime ? "实时" : "收盘"} · ${payload.meta?.trade_date || "--"}`);
document.querySelector("#entityDetailChange").className = changeClass(entity.change);
renderEntityDetailMetrics(payload.metrics || []);
requestAnimationFrame(() => drawEntityDetailChart(payload.series || []));
} catch (error) {
setText("entityDetailDate", "行情加载失败");
document.querySelector("#entityDetailMetrics").innerHTML = `${escapeHtml(error.message || "交易数据加载失败")}
`;
clearEntityDetailChart(error.message || "行情加载失败");
showToast(error.message || "详情加载失败");
}
}
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) {
const 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 数据");
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");
context.setTransform(ratio, 0, 0, ratio, 0, 0);
context.clearRect(0, 0, width, height);
context.fillStyle = "#fbfcfd";
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 = "#e2e8ec";
context.fillStyle = "#6c7983";
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 rising = item.close >= item.open;
const color = rising ? "#ef5143" : "#079667";
context.strokeStyle = color;
context.fillStyle = color;
context.lineWidth = 1;
context.beginPath();
context.moveTo(x, priceY(item.high));
context.lineTo(x, priceY(item.low));
context.stroke();
const openY = priceY(item.open);
const closeY = priceY(item.close);
const bodyTop = Math.min(openY, closeY);
const bodyHeight = Math.max(1, Math.abs(closeY - openY));
if (rising) context.strokeRect(x - candleWidth / 2, bodyTop, candleWidth, bodyHeight);
else context.fillRect(x - candleWidth / 2, bodyTop, candleWidth, bodyHeight);
const volumeBarHeight = number(item.volume) / maxVolume * volumeHeight;
context.globalAlpha = 0.72;
context.fillRect(x - candleWidth / 2, height - bottom - volumeBarHeight, candleWidth, volumeBarHeight);
context.globalAlpha = 1;
});
context.textAlign = "center";
context.fillStyle = "#6c7983";
[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) {
const 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");
context.fillStyle = "#fbfcfd";
context.fillRect(0, 0, width, height);
context.fillStyle = "#647380";
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;
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}`);
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)}%`);
setText("chartSource", `${payload.meta.source === "tushare" ? "Tushare" : "演示"} · ${payload.prices.length} 个交易日`);
renderMoneyflow(payload.moneyflow || {});
renderStockNotes(payload.notes || []);
updateWatchButton();
requestAnimationFrame(() => drawPriceChart(payload.prices || []));
} catch (error) {
setText("chartSource", "行情加载失败");
clearPriceChart(error.message || "行情加载失败");
showToast(error.message || "个股详情加载失败");
}
}
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")) return;
control.disabled = !unlocked;
});
});
}
function openView(viewId, updateHash = true) {
closeStockPreview();
state.activeView = viewId;
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);
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()) 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();
}
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");
item.removeAttribute("aria-sort");
});
header.classList.add(`sort-${direction}`);
header.setAttribute("aria-sort", direction === "asc" ? "ascending" : "descending");
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 (number(header.colSpan) > 1) return;
const label = header.textContent.trim();
if (!label || ["#", "操作"].includes(label)) return;
header.dataset.autoSort = "true";
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");
if (header.dataset.sort === state.sortKey) header.classList.add(state.sortDirection === "asc" ? "sort-asc" : "sort-desc");
});
}
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();
}
const sourceLabel = membership.active && access.platform_configured ? "平台会员模型" : "尚未开通会员算力";
status.textContent = `公共行情${payload.configured ? "已就绪" : "使用演示数据"} · ${sourceLabel}`;
status.classList.toggle("connected", Boolean(payload.configured));
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
? "管理员拥有智能功能管理权限,但不会因此显示为已开通会员。"
: "开通会员后可使用智能选股、问师、问天及平台 LLM 算力。");
setText("membershipQuotaHint", `会员默认每日 LLM 用量 ${number(access.daily_limit)} 次,由管理员统一设置。`);
setText("membershipUsage", membership.active
? `今日已用 ${number(access.used_today)} 次`
: "今日 LLM 用量:--");
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 llm = payload.llm || {};
const membership = payload.membership || {};
status.textContent = `公共行情${data.configured ? "已配置" : "未配置"} · ${number(data.snapshot_dates)} 个交易日 · ${number(data.snapshot_records)} 条记录`;
status.classList.toggle("connected", Boolean(data.configured));
setText("systemDataStatus", data.background_refresh_enabled ? "后台刷新已启用" : "后台刷新已暂停");
document.querySelector("#systemTokenInput").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(),
background_refresh_enabled: document.querySelector("#systemBackgroundRefresh").checked,
});
document.querySelector("#systemTokenInput").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("炸板池", state.dashboard?.broken || [], commonReviewColumns());
}
function exportDown() {
exportRows("跌停板", state.dashboard?.down_limits || [], commonReviewColumns());
}
function exportYesterday() {
exportRows("昨日涨停", state.dashboard?.yesterday_limits || [], [
["股票代码", "code"], ["股票名称", "name"], ["昨日高度", "prior_streak"],
["今日涨幅%", "current_change"], ["今日结果", "outcome"], ["当前高度", "current_streak"],
["所属板块", "sector"], ["涨停逻辑", "reason"],
]);
}
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 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-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 formatNumber(value, digits = 0) {
return new Intl.NumberFormat("zh-CN", { minimumFractionDigits: digits, maximumFractionDigits: digits }).format(number(value));
}
function formatTimestamp(value) {
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return "--";
return parsed.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", second: "2-digit" });
}
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)} 万`;
}
function displayCompactDate(value) {
const text = String(value || "").replaceAll("-", "");
if (text.length !== 8) return value || "--";
return `${text.slice(0, 4)}-${text.slice(4, 6)}-${text.slice(6, 8)}`;
}
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);
}
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);
button.classList.toggle("mobile-active", marketView && button.dataset.view === "limitPool");
});
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));
}
function number(value) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
}
function clamp(value, minimum, maximum) {
return Math.min(maximum, Math.max(minimum, number(value)));
}
function escapeHtml(value) {
return String(value ?? "").replace(/[&<>"']/g, (character) => ({
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
})[character]);
}
function todayString() {
return localDateString(new Date());
}
function localDateString(value) {
const year = value.getFullYear();
const month = String(value.getMonth() + 1).padStart(2, "0");
const day = String(value.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
function parseLocalDate(value) {
const [year, month, day] = value.split("-").map(Number);
return new Date(year, month - 1, day);
}