5917 lines
264 KiB
JavaScript
5917 lines
264 KiB
JavaScript
const {
|
||
clamp,
|
||
displayCompactDate,
|
||
escapeHtml,
|
||
formatNumber,
|
||
formatTimestamp,
|
||
localDateString,
|
||
number,
|
||
parseLocalDate,
|
||
todayString,
|
||
} = window.XiaobaiUI;
|
||
|
||
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: [],
|
||
tradeEntries: [],
|
||
tradeSummary: {},
|
||
editingTradeId: 0,
|
||
initialStockOpened: false,
|
||
screenerSetup: null,
|
||
selectedRegime: "",
|
||
selectedStrategy: null,
|
||
screenerResult: null,
|
||
screenerTracking: null,
|
||
alerts: [],
|
||
alertFilter: "all",
|
||
alertUnreadCount: 0,
|
||
assistantMessages: [],
|
||
assistantLoading: false,
|
||
assistantController: null,
|
||
screenerMobileView: "strategy",
|
||
sentimentHistory: null,
|
||
sentimentRange: 20,
|
||
sentimentHistoryKey: "",
|
||
sentimentLoading: false,
|
||
mentorSetup: null,
|
||
selectedMentorId: "",
|
||
mentorMessages: [],
|
||
mentorLoading: false,
|
||
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"),
|
||
alertsDialog: document.querySelector("#alertsDialog"),
|
||
assistantDialog: document.querySelector("#assistantDialog"),
|
||
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;
|
||
let assistantRenderFrame = 0;
|
||
|
||
const heartSound = {
|
||
enabled: false,
|
||
context: null,
|
||
ensure() {
|
||
if (!this.context) {
|
||
const AudioContextClass = window.AudioContext || window.webkitAudioContext;
|
||
if (!AudioContextClass) return null;
|
||
this.context = new AudioContextClass();
|
||
}
|
||
if (this.context.state === "suspended") this.context.resume();
|
||
return this.context;
|
||
},
|
||
tone(frequency, duration, gain, type = "sine", delay = 0) {
|
||
if (!this.enabled) return;
|
||
const context = this.ensure();
|
||
if (!context) return;
|
||
const start = context.currentTime + delay;
|
||
const oscillator = context.createOscillator();
|
||
const volume = context.createGain();
|
||
oscillator.type = type;
|
||
oscillator.frequency.value = frequency;
|
||
volume.gain.setValueAtTime(0.0001, start);
|
||
volume.gain.linearRampToValueAtTime(gain, start + 0.015);
|
||
volume.gain.exponentialRampToValueAtTime(0.0001, start + duration);
|
||
oscillator.connect(volume).connect(context.destination);
|
||
oscillator.start(start);
|
||
oscillator.stop(start + duration + 0.05);
|
||
},
|
||
chime(frequency = 640) {
|
||
this.tone(frequency, 4.8, 0.12);
|
||
this.tone(frequency * 2.02, 3.6, 0.045);
|
||
this.tone(frequency * 3.96, 2.2, 0.018);
|
||
},
|
||
coin(delay = 0) {
|
||
this.tone(2350 + Math.random() * 260, 0.28, 0.055, "triangle", delay);
|
||
this.tone(3250 + Math.random() * 260, 0.18, 0.025, "triangle", delay + 0.01);
|
||
},
|
||
};
|
||
|
||
const HEART_WHISPERS = [
|
||
["应无所住,而生其心", 10, 12, 0],
|
||
["不是风动,不是幡动,仁者心动", 89, 8, 1],
|
||
["菩提本无树,明镜亦非台", 16, 52, 2],
|
||
["本来无一物,何处惹尘埃", 84, 54, 3],
|
||
["心外无物,心外无理", 22, 18, 4],
|
||
["知行合一", 78, 30, 5],
|
||
["此心光明,亦复何言", 90, 60, 6],
|
||
];
|
||
|
||
window.addEventListener("resize", () => {
|
||
clearTimeout(heavenResizeTimer);
|
||
heavenResizeTimer = setTimeout(() => {
|
||
if (state.activeView !== "heavenView") return;
|
||
if (state.heavenPanel === "fortune" && state.heavenSetup?.field) {
|
||
renderQiFieldCanvas(state.heavenSetup.field.balance || [], { intro: false });
|
||
drawQiUseConnections(false);
|
||
}
|
||
if (state.heavenPanel === "heart") startHeartDust();
|
||
}, 120);
|
||
});
|
||
|
||
document.addEventListener("DOMContentLoaded", initialize);
|
||
|
||
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("#tradeLogDate").value = elements.tradeDate.value;
|
||
document.querySelector("#tradeLogDate").max = todayString();
|
||
document.querySelector("#backfillStart").value = todayString();
|
||
document.querySelector("#backfillEnd").value = todayString();
|
||
document.querySelector("#backfillStart").max = todayString();
|
||
document.querySelector("#backfillEnd").max = todayString();
|
||
document.querySelector("#qiObservationDate").value = elements.tradeDate.value;
|
||
document.querySelector("#qiObservationDate").max = todayString();
|
||
document.querySelector("#accountBirthDate").max = todayString();
|
||
document.querySelector("#alertDate").value = todayString();
|
||
bindEvents();
|
||
try {
|
||
const session = await apiRequest("/api/auth/me");
|
||
if (!session.authenticated) {
|
||
if (session.registration_required) selectAuthMode("register");
|
||
showAuthGate();
|
||
return;
|
||
}
|
||
await applyAuthenticatedSession(session);
|
||
} catch (error) {
|
||
showAuthGate(error.message || "无法连接本地服务");
|
||
}
|
||
}
|
||
|
||
async function startAuthenticatedApp() {
|
||
if (state.started) return;
|
||
state.started = true;
|
||
const searchParams = new URLSearchParams(window.location.search);
|
||
const requestedHeavenPanel = searchParams.get("heaven");
|
||
if (["trend", "fortune", "heart"].includes(requestedHeavenPanel)) {
|
||
state.heavenPanel = requestedHeavenPanel;
|
||
}
|
||
const legacyViewAliases = { sectorView: "rotationView", breadthView: "limitPool" };
|
||
const requestedView = legacyViewAliases[searchParams.get("view")] || searchParams.get("view");
|
||
if (requestedView && document.getElementById(requestedView)?.classList.contains("workspace-view")) {
|
||
openView(requestedView, false);
|
||
if (requestedView !== searchParams.get("view")) {
|
||
const url = new URL(window.location.href);
|
||
url.searchParams.set("view", requestedView);
|
||
history.replaceState(null, "", url);
|
||
}
|
||
}
|
||
loadDashboard();
|
||
loadAlerts();
|
||
if (new URLSearchParams(window.location.search).get("settings") === "1") {
|
||
setTimeout(openSettings, 0);
|
||
}
|
||
}
|
||
|
||
function selectAuthMode(mode) {
|
||
state.authMode = mode === "register" ? "register" : "login";
|
||
document.querySelectorAll("[data-auth-mode]").forEach((button) => {
|
||
button.classList.toggle("active", button.dataset.authMode === state.authMode);
|
||
});
|
||
const registering = state.authMode === "register";
|
||
document.querySelector("#authConfirmField").hidden = !registering;
|
||
document.querySelector("#authPasswordConfirm").required = registering;
|
||
document.querySelector("#authPassword").autocomplete = registering ? "new-password" : "current-password";
|
||
document.querySelector("#authSubmitButton").textContent = registering ? "注册并进入" : "登录";
|
||
document.querySelector("#authError").hidden = true;
|
||
}
|
||
|
||
async function submitAuthForm(event) {
|
||
event.preventDefault();
|
||
const username = document.querySelector("#authUsername").value.trim();
|
||
const password = document.querySelector("#authPassword").value;
|
||
const errorElement = document.querySelector("#authError");
|
||
if (state.authMode === "register" && password !== document.querySelector("#authPasswordConfirm").value) {
|
||
errorElement.textContent = "两次输入的密码不一致。";
|
||
errorElement.hidden = false;
|
||
return;
|
||
}
|
||
const button = document.querySelector("#authSubmitButton");
|
||
button.disabled = true;
|
||
try {
|
||
const session = await apiRequest(`/api/auth/${state.authMode}`, "POST", { username, password });
|
||
document.querySelector("#authForm").reset();
|
||
await applyAuthenticatedSession(session);
|
||
} catch (error) {
|
||
errorElement.textContent = error.message || "账号操作失败";
|
||
errorElement.hidden = false;
|
||
} finally {
|
||
button.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function applyAuthenticatedSession(session) {
|
||
state.user = session.user;
|
||
state.csrfToken = session.csrf_token || "";
|
||
setText("accountName", session.user?.username || "账号");
|
||
const isAdmin = session.user?.role === "admin";
|
||
updateAccountIdentityBadges(session.user?.membership || {});
|
||
document.querySelector("#settingsButton").hidden = !isAdmin;
|
||
document.querySelector("#syncButton").hidden = !isAdmin;
|
||
document.querySelector("#reasonForm").hidden = !isAdmin;
|
||
document.querySelector("#sectorPhaseManager").hidden = !isAdmin;
|
||
const factorSyncButton = document.querySelector("#factorSyncButton");
|
||
if (factorSyncButton) factorSyncButton.hidden = false;
|
||
document.querySelector("#authGate").hidden = true;
|
||
applyMembershipAccess();
|
||
await startAuthenticatedApp();
|
||
}
|
||
|
||
function showAuthGate(message = "") {
|
||
state.user = null;
|
||
state.csrfToken = "";
|
||
const gate = document.querySelector("#authGate");
|
||
gate.hidden = false;
|
||
const errorElement = document.querySelector("#authError");
|
||
errorElement.textContent = message;
|
||
errorElement.hidden = !message;
|
||
document.querySelector("#authUsername").focus();
|
||
}
|
||
|
||
async function logoutAccount() {
|
||
toggleAccountDropdown(false);
|
||
try {
|
||
await apiRequest("/api/auth/logout", "POST", {});
|
||
} catch (error) {
|
||
showToast(error.message || "退出失败");
|
||
return;
|
||
}
|
||
window.location.reload();
|
||
}
|
||
|
||
function bindEvents() {
|
||
document.querySelectorAll("[data-auth-mode]").forEach((button) => {
|
||
button.addEventListener("click", () => selectAuthMode(button.dataset.authMode));
|
||
});
|
||
document.querySelector("#authForm").addEventListener("submit", submitAuthForm);
|
||
document.querySelector("#refreshButton").addEventListener("click", () => 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("#alertButton").addEventListener("click", openAlerts);
|
||
document.querySelector("#assistantButton").addEventListener("click", openReviewAssistant);
|
||
document.querySelector("#closeAssistantDialog").addEventListener("click", () => elements.assistantDialog.close());
|
||
document.querySelector("#assistantForm").addEventListener("submit", sendAssistantQuestion);
|
||
document.querySelector("#stopAssistant").addEventListener("click", stopAssistantResponse);
|
||
document.querySelector("#clearAssistantMessages").addEventListener("click", clearAssistantConversation);
|
||
document.querySelectorAll("[data-assistant-prompt]").forEach((button) => {
|
||
button.addEventListener("click", () => useAssistantPrompt(button.dataset.assistantPrompt));
|
||
});
|
||
document.querySelector("#closeAlertsDialog").addEventListener("click", () => elements.alertsDialog.close());
|
||
document.querySelector("#alertForm").addEventListener("submit", saveAlert);
|
||
document.querySelector("#markAllAlertsRead").addEventListener("click", markAllAlertsRead);
|
||
document.querySelector("#alertList").addEventListener("click", handleAlertAction);
|
||
document.querySelectorAll("[data-alert-filter]").forEach((button) => {
|
||
button.addEventListener("click", () => selectAlertFilter(button.dataset.alertFilter));
|
||
});
|
||
document.querySelector("#closeGlobalSearch").addEventListener("click", closeGlobalSearch);
|
||
document.querySelector("#closeEntityDetail").addEventListener("click", () => elements.entityDetailDialog.close());
|
||
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("#tradeLogForm").addEventListener("submit", saveTradeLog);
|
||
document.querySelector("#cancelTradeEdit").addEventListener("click", resetTradeLogForm);
|
||
document.querySelector("#tradeLogTableBody").addEventListener("click", handleTradeLogAction);
|
||
document.querySelector("#stockNoteForm").addEventListener("submit", saveStockNote);
|
||
document.querySelector("#watchStockButton").addEventListener("click", toggleActiveWatchlist);
|
||
document.querySelector("#stockHeavenButton").addEventListener("click", openActiveStockInHeaven);
|
||
document.querySelector("#stockReminderButton").addEventListener("click", openStockReminder);
|
||
document.querySelector("#reasonForm").addEventListener("submit", saveReasonOverride);
|
||
document.querySelector("#backfillButton").addEventListener("click", backfillData);
|
||
document.querySelector("#factorSyncButton").addEventListener("click", syncFactorData);
|
||
document.querySelector("#screenerRunButton").addEventListener("click", runScreener);
|
||
document.querySelector("#refreshTrackingButton").addEventListener("click", refreshScreenerTracking);
|
||
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();
|
||
setStatus(`${dashboardSourceLabel(payload.meta)} · 数据已更新`);
|
||
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.realtime && !["closed", "after_hours"].includes(String(meta.market_status || ""))) return "盘中行情";
|
||
if (meta.carried_forward) return "最近收盘行情";
|
||
if (meta.market_status === "historical") return "历史行情";
|
||
return "收盘行情";
|
||
}
|
||
|
||
function renderDashboard() {
|
||
const { meta, overview, ladders, sectors } = state.dashboard;
|
||
animateMetric("tapeUp", overview.up_count, (value) => Math.round(value));
|
||
animateMetric("tapeDown", overview.down_count, (value) => Math.round(value));
|
||
setText("tapeLimit", `${overview.limit_up_count} / 跌停 ${overview.limit_down_count}`);
|
||
animateMetric("tapeAmount", overview.amount_billion, (value) => `${formatNumber(value, 1)} 亿`);
|
||
animateMetric("limitUpMetric", overview.limit_up_count, (value) => `${Math.round(value)} 家`);
|
||
animateMetric("limitDownMetric", overview.limit_down_count, (value) => `${Math.round(value)} 家`);
|
||
animateMetric("brokenMetric", overview.broken_count, (value) => `${Math.round(value)} 家`);
|
||
animateMetric("sealRateMetric", overview.seal_rate, (value) => `${formatNumber(value, 1)}%`);
|
||
animateMetric("amountMetric", overview.amount_billion, (value) => `${formatNumber(value, 1)} 亿`);
|
||
setText("dataDateMetric", dashboardDataTimestamp(meta));
|
||
animateMetric("sentimentScore", overview.sentiment_score, (value) => Math.round(value));
|
||
setText("sentimentText", sentimentLabel(overview.sentiment_score));
|
||
updateSentimentGauge(overview.sentiment_score);
|
||
setText("updatedAt", `${dashboardSourceLabel(meta)} · 更新 ${formatTimestamp(meta.updated_at)}`);
|
||
|
||
renderLimitTable();
|
||
renderLadderMini(ladders || []);
|
||
renderSectorMini(sectors || []);
|
||
renderBrokenTable(state.dashboard.broken || []);
|
||
renderDownTable(state.dashboard.down_limits || []);
|
||
renderYesterdayTable(state.dashboard.yesterday_limits || []);
|
||
renderPerformance(state.dashboard.limit_performance || []);
|
||
renderLadderBoard(ladders || []);
|
||
renderRotationTable(state.dashboard.sector_rotation || [], sectors || []);
|
||
}
|
||
|
||
async function loadSentimentHistory(force = false) {
|
||
if (!state.dashboard || state.sentimentLoading) return;
|
||
const key = `${elements.tradeDate.value}:${state.sentimentRange}`;
|
||
if (!force && state.sentimentHistoryKey === key && state.sentimentHistory) {
|
||
renderSentimentHistory();
|
||
return;
|
||
}
|
||
state.sentimentLoading = true;
|
||
const notice = document.querySelector("#sentimentHistoryNotice");
|
||
notice.hidden = true;
|
||
try {
|
||
const query = new URLSearchParams({
|
||
trade_date: elements.tradeDate.value,
|
||
limit: String(state.sentimentRange),
|
||
});
|
||
state.sentimentHistory = await apiRequest(`/api/sentiment/history?${query}`);
|
||
state.sentimentHistoryKey = key;
|
||
renderSentimentHistory();
|
||
} catch (error) {
|
||
notice.textContent = error.message || "情绪周期数据加载失败";
|
||
notice.hidden = false;
|
||
showToast(notice.textContent);
|
||
} finally {
|
||
state.sentimentLoading = false;
|
||
}
|
||
}
|
||
|
||
function renderSentimentHistory() {
|
||
const payload = state.sentimentHistory;
|
||
if (!payload) return;
|
||
const rows = payload.rows || [];
|
||
const latest = rows[rows.length - 1];
|
||
const body = document.querySelector("#sentimentHistoryBody");
|
||
const empty = document.querySelector("#sentimentHistoryEmpty");
|
||
empty.hidden = rows.length > 0;
|
||
body.innerHTML = [...rows].reverse().map((row) => {
|
||
return `
|
||
<tr class="${row.trade_date === latest?.trade_date ? "latest-row" : ""}">
|
||
<td class="sentiment-date-cell">${escapeHtml(displayCompactDate(row.trade_date))}</td>
|
||
<td class="number sentiment-score-cell ${sentimentScoreClass(row.score)}">${number(row.score)}</td>
|
||
<td><span class="sentiment-phase-badge ${sentimentPhaseClass(row.phase)}">${escapeHtml(row.phase)}</span></td>
|
||
<td><span class="sentiment-direction ${trendClass(row.direction)}">${escapeHtml(row.direction)}</span></td>
|
||
<td class="number">${number(row.limit_up_count)}</td>
|
||
<td class="number">${number(row.first_board_count)}</td>
|
||
<td class="number">${number(row.second_board_count)}</td>
|
||
<td class="number">${number(row.three_plus_count)}</td>
|
||
<td class="number">${number(row.max_height)}板</td>
|
||
<td class="number">${number(row.broken_count)}</td>
|
||
<td class="number">${number(row.limit_down_count)}</td>
|
||
<td class="number">${number(row.previous_limit_count)}</td>
|
||
<td class="number">${number(row.previous_positive_count)}</td>
|
||
<td class="number">${formatNumber(row.previous_positive_rate, 1)}%</td>
|
||
</tr>
|
||
`;
|
||
}).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) => `
|
||
<article class="sentiment-component-item">
|
||
<div><strong>${escapeHtml(item.label)}</strong><span>权重 ${number(item.weight)}</span><b>${formatNumber(item.score, 1)}</b></div>
|
||
<div class="sentiment-component-track" aria-hidden="true"><i data-component-score="${clamp(item.score, 0, 100)}" style="width:0%"></i></div>
|
||
<small>${escapeHtml(item.summary)}</small>
|
||
</article>
|
||
`).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) => `
|
||
<tr data-code="${escapeHtml(row.code)}">
|
||
<td class="row-number">${index + 1}</td>
|
||
<td class="stock-code">${escapeHtml(row.code)}</td>
|
||
<td class="stock-name">${escapeHtml(row.name)}</td>
|
||
<td class="number streak-value">${streakLabel(row.streak)}</td>
|
||
<td class="number up">${signed(row.change)}%</td>
|
||
<td class="number">${formatNumber(row.price, 2)}</td>
|
||
<td>${escapeHtml(row.sector || "其他")}</td>
|
||
<td class="reason-column" title="${escapeHtml(row.reason || "")}">${escapeHtml(row.reason || "--")}</td>
|
||
<td>${escapeHtml(row.first_time || "--")}</td>
|
||
<td>${escapeHtml(row.last_time || "--")}</td>
|
||
<td class="number">${number(row.open_times)}</td>
|
||
<td class="number">${formatNumber(row.turnover_rate, 2)}%</td>
|
||
<td class="number">${formatNumber(row.amount_billion, 2)} 亿</td>
|
||
<td class="number">${formatNumber(row.seal_amount_million, 0)} 万</td>
|
||
</tr>
|
||
`).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) => `
|
||
<tr data-code="${escapeHtml(row.code)}">
|
||
<td class="row-number">${index + 1}</td><td class="stock-code">${escapeHtml(row.code)}</td>
|
||
<td class="stock-name">${escapeHtml(row.name)}</td><td class="number up">${signed(row.change)}%</td>
|
||
<td class="number">${formatNumber(row.price, 2)}</td><td>${escapeHtml(row.sector)}</td>
|
||
<td class="reason-column">${escapeHtml(row.reason || "--")}</td><td>${escapeHtml(row.first_time || "--")}</td>
|
||
<td>${escapeHtml(row.last_time || "--")}</td><td class="number warning">${number(row.open_times)}</td>
|
||
<td class="number">${formatNumber(row.turnover_rate, 2)}%</td><td class="number">${formatNumber(row.amount_billion, 2)} 亿</td>
|
||
</tr>
|
||
`).join("");
|
||
bindStockRows(body);
|
||
}
|
||
|
||
function renderDownTable(rows) {
|
||
setText("downCount", `${rows.length} 只`);
|
||
const body = document.querySelector("#downTableBody");
|
||
body.innerHTML = rows.map((row, index) => `
|
||
<tr data-code="${escapeHtml(row.code)}">
|
||
<td class="row-number">${index + 1}</td><td class="stock-code">${escapeHtml(row.code)}</td>
|
||
<td class="stock-name">${escapeHtml(row.name)}</td><td class="number down">${signed(row.change)}%</td>
|
||
<td class="number">${formatNumber(row.price, 2)}</td><td>${escapeHtml(row.sector)}</td>
|
||
<td class="reason-column">${escapeHtml(row.reason || "--")}</td><td class="number">${number(row.streak)}</td>
|
||
<td class="number">${formatNumber(row.turnover_rate, 2)}%</td><td class="number">${formatNumber(row.amount_billion, 2)} 亿</td>
|
||
</tr>
|
||
`).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) => `
|
||
<tr data-code="${escapeHtml(row.code)}">
|
||
<td class="row-number">${index + 1}</td><td class="stock-code">${escapeHtml(row.code)}</td>
|
||
<td class="stock-name">${escapeHtml(row.name)}</td><td class="number">${streakLabel(row.prior_streak)}</td>
|
||
<td class="number ${changeClass(row.current_change)}">${signed(row.current_change)}%</td>
|
||
<td><span class="outcome-tag ${outcomeClass(row.outcome)}">${escapeHtml(row.outcome)}</span></td>
|
||
<td class="number">${number(row.current_streak) ? streakLabel(row.current_streak) : "--"}</td>
|
||
<td>${escapeHtml(row.sector || "其他")}</td><td class="reason-column">${escapeHtml(row.reason || "--")}</td>
|
||
</tr>
|
||
`).join("");
|
||
bindStockRows(document.querySelector("#yesterdayTableBody"));
|
||
}
|
||
|
||
function renderPerformance(rows) {
|
||
document.querySelector("#performanceCards").innerHTML = rows.map((row) => `
|
||
<article class="performance-card">
|
||
<div class="performance-card-header"><strong>${escapeHtml(row.label)}</strong><span>${number(row.count)} 只</span></div>
|
||
<div class="performance-rate"><strong>${formatNumber(row.advance_rate, 1)}%</strong><span>晋级率</span></div>
|
||
<div class="performance-meta"><span>收红 ${formatNumber(row.positive_rate, 1)}%</span><span>均涨 ${signed(row.average_change)}%</span></div>
|
||
</article>
|
||
`).join("") || '<div class="empty-state">暂无昨日涨停统计</div>';
|
||
document.querySelector("#performanceTableBody").innerHTML = rows.map((row) => `
|
||
<tr><td class="stock-name">${escapeHtml(row.label)}</td><td class="number">${number(row.count)}</td>
|
||
<td class="number up">${number(row.advanced)}</td><td class="number up">${formatNumber(row.advance_rate, 1)}%</td>
|
||
<td class="number">${formatNumber(row.positive_rate, 1)}%</td>
|
||
<td class="number ${changeClass(row.average_change)}">${signed(row.average_change)}%</td></tr>
|
||
`).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 = '<div class="empty-state">正在读取轮动历史</div>';
|
||
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 = `<div class="empty-state">${escapeHtml(error.message || "轮动历史加载失败")}</div>`;
|
||
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 = '<div class="empty-state">尚无连续交易日的板块数据</div>';
|
||
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 `
|
||
<article class="rotation-day ${selected ? "has-selection" : ""} ${hasSelected ? "selected-day" : ""}">
|
||
<header><time>${escapeHtml(displayCompactDate(day.trade_date))}</time><span>${(day.sectors || []).length} 个热点</span></header>
|
||
<div class="rotation-day-sectors">${(day.sectors || []).map((sector) => `
|
||
<button type="button" class="rotation-sector-chip ${selected === sector.name ? "selected" : ""}" data-rotation-sector="${escapeHtml(sector.name)}">
|
||
<span>${number(sector.rank)}</span><strong>${escapeHtml(sector.name)}</strong><small>${number(sector.count)}板 · ${formatNumber(sector.strength, 0)}</small>
|
||
</button>`).join("")}</div>
|
||
</article>`;
|
||
}).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 `
|
||
<tr><td class="number">${number(row.rank)}</td><td class="stock-name">${escapeHtml(row.name)}</td>
|
||
<td><span class="trend-tag ${trendClass(row.trend)}">${escapeHtml(row.trend)}</span></td>
|
||
<td class="number up">${number(row.count)}</td><td class="number">${number(row.previous_count)}</td>
|
||
<td class="number ${number(row.delta) >= 0 ? "delta-positive" : "delta-negative"}">${number(row.delta) > 0 ? "+" : ""}${number(row.delta)}</td>
|
||
<td><div class="rotation-strength"><span class="strength-cell"><i style="width:${clamp(strength, 0, 100)}%"></i></span><b>${formatNumber(strength, 0)}</b></div></td>
|
||
<td class="number streak-value">${streakLabel(row.max_streak || 1)}</td>
|
||
<td class="number ${changeClass(sector.change)}">${signed(sector.change)}%</td>
|
||
<td>${escapeHtml(row.leader || sector.leader || "--")}</td><td class="number">${formatNumber(row.amount_billion, 1)} 亿</td></tr>
|
||
`;
|
||
}).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 `<div class="mini-row"><strong>${escapeHtml(group.label)}</strong>
|
||
<span title="${escapeHtml(names)}">${escapeHtml(names || "--")}</span><small>${group.count}只</small></div>`;
|
||
}).join("") || '<div class="empty-state">暂无梯队数据</div>';
|
||
}
|
||
|
||
function renderSectorMini(sectors) {
|
||
document.querySelector("#sectorMini").innerHTML = sectors.slice(0, 7).map((sector) => `
|
||
<div class="sector-mini-row"><span title="${escapeHtml(sector.name)}">${escapeHtml(sector.name)}</span>
|
||
<span class="strength-track"><i style="width:${clamp(sector.strength, 0, 100)}%"></i></span><strong>${number(sector.count)}</strong></div>
|
||
`).join("") || '<div class="empty-state">暂无板块数据</div>';
|
||
}
|
||
|
||
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 `
|
||
<section class="ladder-step" data-ladder-level-card="${level}" style="--ladder-level:${level};--ladder-max:${maxLevel};--ladder-indent:${Math.min((level - 1) * 38, 270)}px">
|
||
<div class="ladder-step-header"><span class="ladder-level-mark">${level}</span><div><strong>${escapeHtml(group.label)}</strong><small>${number(group.count)} 只</small></div></div>
|
||
<div class="ladder-step-stocks">${stocks.map((stock) => `<button type="button" class="ladder-stock" data-code="${escapeHtml(stock.code)}">
|
||
<span><strong>${escapeHtml(stock.name)}</strong><small><span class="stock-code">${escapeHtml(stock.code)}</span> · ${escapeHtml(stock.reason || stock.sector || "--")}</small></span><time>${escapeHtml(stock.first_time || "--")}</time></button>`).join("")}</div>
|
||
${group.stocks.length > limit ? `<button class="ladder-more" type="button" data-ladder-level="${level}">${expanded ? "收起" : `更多 ${remaining} 只`}<i data-lucide="chevron-${expanded ? "up" : "down"}"></i></button>` : ""}
|
||
</section>`;
|
||
}).join("") || '<div class="empty-state">暂无连板数据</div>';
|
||
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", "empty", "partial", "unavailable"].includes(state.dragonTiger?.meta?.status)
|
||
&& (state.dragonTiger?.meta?.requested_date || state.dragonTiger?.meta?.trade_date) === requestedDate
|
||
) {
|
||
renderDragonTiger();
|
||
return;
|
||
}
|
||
setStatus("正在加载龙虎榜");
|
||
try {
|
||
const query = new URLSearchParams({ trade_date: requestedDate });
|
||
if (force) query.set("force", "1");
|
||
const payload = await apiRequest(`/api/dragon-tiger?${query}`);
|
||
state.dragonTiger = payload;
|
||
renderDragonTiger();
|
||
const statusLabel = payload.meta.status === "error"
|
||
? "龙虎榜数据暂不可用"
|
||
: payload.meta.status === "empty"
|
||
? "当日暂无公开游资明细"
|
||
: payload.meta.status === "partial"
|
||
? "当日有龙虎榜,暂无命名游资明细"
|
||
: payload.meta.status === "unavailable" ? "龙虎榜数据暂不可用" : "龙虎榜明细";
|
||
setStatus(`${statusLabel} · 龙虎榜已加载`);
|
||
} catch (error) {
|
||
showToast(error.message || "龙虎榜加载失败");
|
||
setStatus("龙虎榜加载失败");
|
||
}
|
||
}
|
||
|
||
function renderDragonTiger() {
|
||
const payload = state.dragonTiger;
|
||
if (!payload) return;
|
||
const summary = payload.summary || {};
|
||
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]) => `<div class="dragon-metric"><span>${label}</span><strong class="${className}">${value}</strong></div>`).join("");
|
||
|
||
renderDragonTraderList();
|
||
renderUnclassifiedSeats();
|
||
}
|
||
|
||
function renderDragonTraderList() {
|
||
const payload = state.dragonTiger;
|
||
if (!payload) return;
|
||
let traders = [...(payload.traders || [])].filter((item) => item.identity_type === "trader" && item.recognized !== false);
|
||
if (state.dragonFilter === "buy") traders = traders.filter((item) => number(item.net_buy_million) > 0);
|
||
if (state.dragonFilter === "sell") traders = traders.filter((item) => number(item.net_buy_million) < 0);
|
||
if (state.dragonFilter === "unclassified") traders = [];
|
||
if (state.dragonQuery) {
|
||
traders = traders.filter((item) => {
|
||
const searchable = [
|
||
item.name,
|
||
...(item.operations || []).flatMap((operation) => [operation.code, operation.name, operation.seat_name]),
|
||
].join(" ").toLowerCase();
|
||
return searchable.includes(state.dragonQuery);
|
||
});
|
||
}
|
||
|
||
const container = document.querySelector("#dragonTraderList");
|
||
let emptyMessage = "没有符合当前条件的游资操作";
|
||
if (!Array.isArray(payload.traders)) emptyMessage = "龙虎榜数据格式暂不可用,请稍后重试";
|
||
else if (["error", "unavailable"].includes(payload.meta?.status)) emptyMessage = "龙虎榜数据暂不可用,请稍后重试";
|
||
else if (payload.meta?.status === "empty") emptyMessage = "该交易日暂无游资每日明细";
|
||
else if (payload.meta?.status === "partial") emptyMessage = `当日有 ${number(payload.summary?.official_stock_count)} 只股票上榜,但暂无可识别的游资明细`;
|
||
if (!traders.some((item) => item.id === state.selectedDragonTraderId)) {
|
||
state.selectedDragonTraderId = traders[0]?.id || "";
|
||
}
|
||
const cardMarkup = traders.map((trader, index) => {
|
||
const description = trader.description || `${number(trader.stock_count)} 只股票,${number(trader.operation_count)} 笔操作`;
|
||
return `
|
||
<article class="dragon-trader-card dealing ${trader.id === state.selectedDragonTraderId ? "selected" : ""}" data-dragon-card="${escapeHtml(trader.id)}" aria-hidden="true" style="--deal-delay:${Math.min(index * 38, 650)}ms">
|
||
<span class="dragon-card-rank">${String(index + 1).padStart(2, "0")}</span>
|
||
<span class="dragon-card-monogram">${escapeHtml(trader.name.slice(0, 2))}</span>
|
||
<span class="dragon-card-copy"><strong>${escapeHtml(trader.name)}</strong><q title="${escapeHtml(description)}">${escapeHtml(description)}</q></span>
|
||
<span class="dragon-card-stats"><small>${number(trader.stock_count)} 股 · ${number(trader.operation_count)} 笔</small><b class="${changeClass(trader.net_buy_million)}">${formatMoneyMillion(trader.net_buy_million)}</b></span>
|
||
</article>`;
|
||
}).join("");
|
||
const hitZoneMarkup = traders.map((trader) => `
|
||
<button type="button" class="dragon-card-hit-zone" data-dragon-trader="${escapeHtml(trader.id)}" aria-label="查看 ${escapeHtml(trader.name)} 当日操作" aria-pressed="${trader.id === state.selectedDragonTraderId}"></button>
|
||
`).join("");
|
||
container.innerHTML = traders.length
|
||
? `${cardMarkup}<div class="dragon-card-hit-layer">${hitZoneMarkup}</div>`
|
||
: `<div class="empty-state dragon-empty">${escapeHtml(state.dragonFilter === "unclassified" ? "待归类席位请在下方管理" : emptyMessage)}</div>`;
|
||
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 = '<div class="empty-state dragon-empty">选择一位游资查看操作明细</div>';
|
||
return;
|
||
}
|
||
container.innerHTML = `
|
||
<header class="dragon-detail-header">
|
||
<div><span>当日操作明细</span><h3>${escapeHtml(trader.name)}</h3><p>${escapeHtml(trader.description || "按当日公开龙虎榜席位汇总")}</p></div>
|
||
<dl><div><dt>买入</dt><dd class="up">${formatMoneyMillion(trader.buy_million)}</dd></div><div><dt>卖出</dt><dd class="down">${formatMoneyMillion(trader.sell_million)}</dd></div><div><dt>净额</dt><dd class="${changeClass(trader.net_buy_million)}">${formatMoneyMillion(trader.net_buy_million)}</dd></div></dl>
|
||
</header>
|
||
<div class="trader-operations table-frame">
|
||
<table class="data-table dragon-operation-table">
|
||
<thead><tr><th>股票</th><th>方向</th><th class="number">涨幅</th><th class="number">买入</th><th class="number">卖出</th><th class="number">净额</th><th>关联席位</th><th class="reason-column">标签 / 上榜原因</th></tr></thead>
|
||
<tbody>${(trader.operations || []).map((operation) => `
|
||
<tr data-code="${escapeHtml(operation.code)}">
|
||
<td><span class="dragon-stock"><strong>${escapeHtml(operation.name)}</strong><small class="stock-code">${escapeHtml(operation.code)}</small></span></td>
|
||
<td><span class="direction-label ${changeClass(operation.net_buy_million)}">${escapeHtml(operation.direction)}</span></td>
|
||
<td class="number ${operation.change == null ? "" : changeClass(operation.change)}">${operation.change == null ? "--" : `${signed(operation.change)}%`}</td>
|
||
<td class="number">${formatMoneyMillion(operation.buy_million)}</td>
|
||
<td class="number">${formatMoneyMillion(operation.sell_million)}</td>
|
||
<td class="number ${changeClass(operation.net_buy_million)}">${formatMoneyMillion(operation.net_buy_million)}</td>
|
||
<td class="seat-cell" title="${escapeHtml(operation.seat_name)}">${escapeHtml(operation.seat_name)}</td>
|
||
<td class="reason-column" title="${escapeHtml([operation.tag, operation.reason].filter((item) => item && item !== "--").join(" · "))}">${escapeHtml(operation.tag && operation.tag !== "--" ? operation.tag : operation.reason || "--")}</td>
|
||
</tr>`).join("")}</tbody>
|
||
</table>
|
||
</div>`;
|
||
bindStockRows(container);
|
||
markAutoSortableHeaders(container);
|
||
}
|
||
|
||
function renderUnclassifiedSeats() {
|
||
const seats = state.dragonTiger?.unclassified_seats || [];
|
||
const canManage = state.user?.role === "admin";
|
||
document.querySelector("#dragonUnclassifiedSection").hidden = !canManage || seats.length === 0;
|
||
document.querySelector("#dragonUnclassifiedFilter").hidden = !canManage || seats.length === 0;
|
||
if (!seats.length && state.dragonFilter === "unclassified") {
|
||
state.dragonFilter = "all";
|
||
document.querySelectorAll("[data-dragon-filter]").forEach((button) => {
|
||
button.classList.toggle("active", button.dataset.dragonFilter === "all");
|
||
});
|
||
renderDragonTraderList();
|
||
}
|
||
setText("unclassifiedCount", `${seats.length} 个`);
|
||
const list = document.querySelector("#unclassifiedSeatList");
|
||
list.innerHTML = seats.map((seat, index) => `
|
||
<form class="unclassified-seat-row" data-unclassified-index="${index}">
|
||
<span class="unclassified-seat-name" title="${escapeHtml(seat.seat_name)}">${escapeHtml(seat.seat_name)}</span>
|
||
<span class="unclassified-seat-stats">${number(seat.operation_count)} 笔 · ${number(seat.stock_count)} 股</span>
|
||
<strong class="${changeClass(seat.net_buy_million)}">${formatMoneyMillion(seat.net_buy_million)}</strong>
|
||
<input type="text" maxlength="50" placeholder="输入游资名" aria-label="${escapeHtml(seat.seat_name)}的游资名" required>
|
||
<button class="button" type="submit">归类</button>
|
||
</form>
|
||
`).join("") || '<div class="empty-state">当前席位均已归类</div>';
|
||
list.querySelectorAll(".unclassified-seat-row").forEach((form) => {
|
||
form.addEventListener("submit", saveSeatAlias);
|
||
});
|
||
}
|
||
|
||
function dragonIdentityLabel(type) {
|
||
return { trader: "游资", institution: "机构", channel: "通道", unclassified: "待归类" }[type] || "席位";
|
||
}
|
||
|
||
async function saveSeatAlias(event) {
|
||
event.preventDefault();
|
||
const form = event.currentTarget;
|
||
const seat = state.dragonTiger?.unclassified_seats?.[number(form.dataset.unclassifiedIndex)];
|
||
const alias = form.querySelector("input").value.trim();
|
||
if (!seat || !alias) {
|
||
showToast("请输入游资名");
|
||
return;
|
||
}
|
||
const button = form.querySelector("button");
|
||
button.disabled = true;
|
||
try {
|
||
await apiRequest("/api/seat-aliases", "POST", { seat_name: seat.seat_name, alias });
|
||
state.dragonTiger = null;
|
||
await loadDragonTiger();
|
||
showToast(`已将席位归类为 ${alias}`);
|
||
} catch (error) {
|
||
showToast(error.message);
|
||
button.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function loadReviewWorkspace() {
|
||
try {
|
||
const [watchlistPayload, notesPayload, tradesPayload] = await Promise.all([
|
||
apiRequest("/api/watchlist"),
|
||
apiRequest("/api/notes?scope=daily"),
|
||
apiRequest("/api/trades"),
|
||
]);
|
||
state.watchlist = watchlistPayload.items || [];
|
||
state.notes = notesPayload.items || [];
|
||
state.tradeEntries = tradesPayload.items || [];
|
||
state.tradeSummary = tradesPayload.summary || {};
|
||
renderWatchlist();
|
||
renderNotesHistory(state.notes, document.querySelector("#notesHistory"), false);
|
||
setText("notesCount", `${state.notes.length} 条`);
|
||
renderTradeLog();
|
||
} catch (error) {
|
||
showToast(error.message || "我的复盘加载失败");
|
||
}
|
||
}
|
||
|
||
function renderWatchlist() {
|
||
setText("watchlistCount", `${state.watchlist.length} 只`);
|
||
const body = document.querySelector("#watchlistTableBody");
|
||
body.innerHTML = state.watchlist.map((item) => `
|
||
<tr data-code="${escapeHtml(item.code)}"><td><span class="mark-swatch ${escapeHtml(item.color)}" title="${escapeHtml(item.color)}"></span></td>
|
||
<td class="stock-code">${escapeHtml(item.code)}</td><td class="stock-name">${escapeHtml(item.name)}</td>
|
||
<td>${escapeHtml(item.sector || "其他")}</td><td><button class="table-action" type="button" data-watch-detail="${escapeHtml(item.code)}">详情</button>
|
||
<button class="table-action down" type="button" data-watch-delete="${escapeHtml(item.code)}">移除</button></td></tr>
|
||
`).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 saveTradeLog(event) {
|
||
event.preventDefault();
|
||
const button = document.querySelector("#saveTradeLog");
|
||
button.disabled = true;
|
||
try {
|
||
const payload = await apiRequest("/api/trades", "POST", {
|
||
id: state.editingTradeId || undefined,
|
||
trade_date: document.querySelector("#tradeLogDate").value,
|
||
code: document.querySelector("#tradeLogCode").value.trim(),
|
||
name: document.querySelector("#tradeLogName").value.trim(),
|
||
action: document.querySelector("#tradeLogAction").value,
|
||
price: document.querySelector("#tradeLogPrice").value,
|
||
quantity: document.querySelector("#tradeLogQuantity").value,
|
||
position_pct: document.querySelector("#tradeLogPosition").value,
|
||
pnl_amount: document.querySelector("#tradeLogPnlAmount").value,
|
||
pnl_pct: document.querySelector("#tradeLogPnlPct").value,
|
||
emotion: document.querySelector("#tradeLogEmotion").value,
|
||
tags: document.querySelector("#tradeLogTags").value,
|
||
thesis: document.querySelector("#tradeLogThesis").value,
|
||
execution: document.querySelector("#tradeLogExecution").value,
|
||
});
|
||
state.tradeEntries = payload.items || [];
|
||
state.tradeSummary = payload.summary || {};
|
||
resetTradeLogForm();
|
||
renderTradeLog();
|
||
showToast("交易记录已保存");
|
||
} catch (error) {
|
||
showToast(error.message || "交易记录保存失败");
|
||
} finally {
|
||
button.disabled = false;
|
||
}
|
||
}
|
||
|
||
function resetTradeLogForm() {
|
||
state.editingTradeId = 0;
|
||
document.querySelector("#tradeLogForm").reset();
|
||
document.querySelector("#tradeLogDate").value = elements.tradeDate.value || todayString();
|
||
document.querySelector("#tradeLogQuantity").value = "0";
|
||
document.querySelector("#tradeLogPosition").value = "0";
|
||
document.querySelector("#cancelTradeEdit").hidden = true;
|
||
setText("saveTradeLog", "保存交易");
|
||
}
|
||
|
||
function editTradeLog(id) {
|
||
const item = state.tradeEntries.find((entry) => number(entry.id) === id);
|
||
if (!item) return;
|
||
state.editingTradeId = id;
|
||
document.querySelector("#tradeLogDate").value = displayCompactDate(item.trade_date);
|
||
document.querySelector("#tradeLogCode").value = item.code;
|
||
document.querySelector("#tradeLogName").value = item.name;
|
||
document.querySelector("#tradeLogAction").value = item.action;
|
||
document.querySelector("#tradeLogPrice").value = item.price;
|
||
document.querySelector("#tradeLogQuantity").value = item.quantity;
|
||
document.querySelector("#tradeLogPosition").value = item.position_pct;
|
||
document.querySelector("#tradeLogPnlAmount").value = item.pnl_amount ?? "";
|
||
document.querySelector("#tradeLogPnlPct").value = item.pnl_pct ?? "";
|
||
document.querySelector("#tradeLogEmotion").value = item.emotion;
|
||
document.querySelector("#tradeLogTags").value = (item.tags || []).join(", ");
|
||
document.querySelector("#tradeLogThesis").value = item.thesis || "";
|
||
document.querySelector("#tradeLogExecution").value = item.execution || "";
|
||
document.querySelector("#cancelTradeEdit").hidden = false;
|
||
setText("saveTradeLog", "保存修改");
|
||
document.querySelector("#tradeLogForm").scrollIntoView({ behavior: motionEnabled() ? "smooth" : "auto", block: "start" });
|
||
}
|
||
|
||
async function handleTradeLogAction(event) {
|
||
const button = event.target.closest("[data-trade-action]");
|
||
if (!button) return;
|
||
const id = number(button.dataset.tradeId);
|
||
if (button.dataset.tradeAction === "edit") {
|
||
editTradeLog(id);
|
||
return;
|
||
}
|
||
if (!window.confirm("确定删除这条交易记录吗?")) return;
|
||
try {
|
||
const payload = await apiRequest(`/api/trades/${id}`, "DELETE");
|
||
state.tradeEntries = payload.items || [];
|
||
state.tradeSummary = payload.summary || {};
|
||
if (state.editingTradeId === id) resetTradeLogForm();
|
||
renderTradeLog();
|
||
showToast("交易记录已删除");
|
||
} catch (error) {
|
||
showToast(error.message || "交易记录删除失败");
|
||
}
|
||
}
|
||
|
||
function renderTradeLog() {
|
||
const summary = state.tradeSummary || {};
|
||
setText("tradeLogCount", `${state.tradeEntries.length} 条`);
|
||
document.querySelector("#tradeLogSummary").innerHTML = [
|
||
["记录", `${number(summary.total)} 条`],
|
||
["已实现", `${number(summary.realized)} 条`],
|
||
["胜率", summary.win_rate == null ? "--" : `${formatNumber(summary.win_rate, 1)}%`],
|
||
["累计盈亏", summary.pnl_amount == null ? "--" : `${number(summary.pnl_amount) > 0 ? "+" : ""}${formatNumber(summary.pnl_amount, 2)}`],
|
||
["平均仓位", summary.average_position == null ? "--" : `${formatNumber(summary.average_position, 1)}%`],
|
||
].map(([label, value]) => `<div><span>${label}</span><strong>${value}</strong></div>`).join("");
|
||
document.querySelector("#tradeLogEmpty").hidden = state.tradeEntries.length > 0;
|
||
document.querySelector("#tradeLogTableBody").innerHTML = state.tradeEntries.map((item) => `
|
||
<tr data-code="${escapeHtml(item.code)}">
|
||
<td>${displayCompactDate(item.trade_date)}</td>
|
||
<td><span class="stock-cell"><strong>${escapeHtml(item.name)}</strong><small class="stock-code">${escapeHtml(item.code)}</small></span></td>
|
||
<td><span class="trade-action trade-action-${escapeHtml(item.action)}">${escapeHtml(item.action_label)}</span></td>
|
||
<td class="number">${formatNumber(item.price, 3)}</td>
|
||
<td class="number">${formatNumber(item.quantity, 0)}</td>
|
||
<td class="number">${formatNumber(item.position_pct, 1)}%</td>
|
||
<td class="number ${item.pnl_pct == null ? "" : changeClass(item.pnl_pct)}">${item.pnl_pct == null ? "--" : `${signed(item.pnl_pct)}%`}${item.pnl_amount == null ? "" : `<small>${number(item.pnl_amount) > 0 ? "+" : ""}${formatNumber(item.pnl_amount, 2)}</small>`}</td>
|
||
<td><span class="trade-emotion">${escapeHtml(item.emotion_label)}</span><div class="trade-tags">${(item.tags || []).map((tag) => `<em>${escapeHtml(tag)}</em>`).join("")}</div></td>
|
||
<td class="trade-copy" title="${escapeHtml(item.thesis || "")}">${escapeHtml(item.thesis || "--")}</td>
|
||
<td class="trade-copy" title="${escapeHtml(item.execution || "")}">${escapeHtml(item.execution || "--")}</td>
|
||
<td><div class="trade-row-actions"><button class="table-action" type="button" data-trade-action="edit" data-trade-id="${number(item.id)}">编辑</button><button class="table-action down" type="button" data-trade-action="delete" data-trade-id="${number(item.id)}">删除</button></div></td>
|
||
</tr>
|
||
`).join("");
|
||
bindStockRows(document.querySelector("#tradeLogTableBody"));
|
||
}
|
||
|
||
async function saveStockNote(event) {
|
||
event.preventDefault();
|
||
if (!state.activeStock?.code) return;
|
||
try {
|
||
await apiRequest("/api/notes", "POST", {
|
||
code: state.activeStock.code,
|
||
stock_name: state.activeStock.name || "--",
|
||
trade_date: elements.tradeDate.value,
|
||
content: document.querySelector("#stockNoteContent").value,
|
||
plan: document.querySelector("#stockNotePlan").value,
|
||
});
|
||
document.querySelector("#stockNoteContent").value = "";
|
||
document.querySelector("#stockNotePlan").value = "";
|
||
const payload = await apiRequest(`/api/notes?scope=stock&code=${encodeURIComponent(state.activeStock.code)}`);
|
||
state.stockDetail.notes = payload.items || [];
|
||
renderStockNotes(state.stockDetail.notes);
|
||
showToast("个股笔记已保存");
|
||
} catch (error) {
|
||
showToast(error.message);
|
||
}
|
||
}
|
||
|
||
async function saveReasonOverride(event) {
|
||
event.preventDefault();
|
||
if (!state.activeStock?.code) return;
|
||
const reason = document.querySelector("#reasonInput").value.trim();
|
||
try {
|
||
await apiRequest("/api/reasons", "POST", {
|
||
trade_date: elements.tradeDate.value,
|
||
code: state.activeStock.code,
|
||
reason,
|
||
});
|
||
state.activeStock.reason = reason;
|
||
for (const key of ["limits", "broken", "down_limits"]) {
|
||
const row = state.dashboard?.[key]?.find((item) => item.code === state.activeStock.code);
|
||
if (row) row.reason = reason;
|
||
}
|
||
setText("detailReason", reason);
|
||
renderDashboard();
|
||
showToast("事件逻辑已修订");
|
||
} catch (error) {
|
||
showToast(error.message);
|
||
}
|
||
}
|
||
|
||
function renderMoneyflow(flow) {
|
||
for (const [id, value] of [["flowNet", flow.net_million], ["flowLarge", flow.large_million], ["flowMedium", flow.medium_million], ["flowSmall", flow.small_million]]) {
|
||
const element = document.getElementById(id);
|
||
element.textContent = formatMoneyMillion(value);
|
||
element.className = changeClass(value);
|
||
}
|
||
}
|
||
|
||
function renderStockNotes(notes) {
|
||
renderNotesHistory(notes, document.querySelector("#stockNotes"), true);
|
||
}
|
||
|
||
function renderNotesHistory(notes, container, compact) {
|
||
container.innerHTML = notes.map((note) => `
|
||
<article class="note-row">
|
||
<div><time>${displayCompactDate(note.trade_date)}</time>${note.stock_name ? `<small>${escapeHtml(note.stock_name)}</small>` : ""}</div>
|
||
<div class="note-block"><strong>复盘</strong><p>${escapeHtml(note.content || "--")}</p></div>
|
||
<div class="note-block"><strong>计划</strong><p>${escapeHtml(note.plan || "--")}</p></div>
|
||
<button class="table-action down" type="button" data-note-delete="${number(note.id)}">删除</button>
|
||
</article>
|
||
`).join("") || '<div class="empty-state">暂无复盘记录</div>';
|
||
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();
|
||
await loadScreenerTracking();
|
||
} 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)} 日` : "需要同步");
|
||
setText("compilerStatus", "策略生成已就绪");
|
||
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) => `
|
||
<button type="button" class="regime-option ${item.id === state.selectedRegime ? "active" : ""}" data-regime="${item.id}">${escapeHtml(item.label)}</button>
|
||
`).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) => `
|
||
<button type="button" class="strategy-item ${strategy.id === state.selectedStrategy?.id ? "active" : ""}" data-strategy-id="${strategy.id}">
|
||
<strong>${escapeHtml(strategy.name)}</strong><span>${escapeHtml(strategy.description || "--")}</span>
|
||
<small>${strategy.regimes.map((item) => regimeLabel(item)).join(" / ")}${strategy.builtin ? " · 内置" : ""}</small>
|
||
</button>
|
||
`).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", "策略生成完成");
|
||
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();
|
||
await loadScreenerTracking(true);
|
||
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 = await loadMentorMessages();
|
||
renderMentorWorkspace();
|
||
} catch (error) {
|
||
showMentorNotice(error.message || "问师模块加载失败");
|
||
showToast(error.message || "问师模块加载失败");
|
||
}
|
||
}
|
||
|
||
function renderMentorWorkspace() {
|
||
const setup = state.mentorSetup;
|
||
if (!setup) return;
|
||
const selected = setup.mentors.find((item) => item.id === state.selectedMentorId) || null;
|
||
setText("mentorCount", `${setup.mentors.length} 位`);
|
||
setText("mentorDataDate", `数据日期 ${displayCompactDate(setup.trade_date)}`);
|
||
setText("activeMentorName", selected?.name || "--");
|
||
document.querySelector("#mentorList").innerHTML = setup.mentors.map((mentor) => `
|
||
<button type="button" class="mentor-option ${mentor.id === state.selectedMentorId ? "active" : ""}" data-mentor-id="${escapeHtml(mentor.id)}">
|
||
<strong>${escapeHtml(mentor.name)}</strong>
|
||
<span title="${escapeHtml(mentor.description)}">${escapeHtml(mentor.description || "--")}</span>
|
||
<small>${mentor.focus.slice(0, 3).map((item) => `<i>${escapeHtml(item)}</i>`).join("")}</small>
|
||
</button>
|
||
`).join("");
|
||
document.querySelectorAll("[data-mentor-id]").forEach((button) => {
|
||
button.addEventListener("click", () => selectMentor(button.dataset.mentorId));
|
||
});
|
||
renderMentorMessages();
|
||
}
|
||
|
||
async function selectMentor(mentorId) {
|
||
if (mentorId === state.selectedMentorId) return;
|
||
state.selectedMentorId = mentorId;
|
||
state.mentorMessages = [];
|
||
hideMentorNotice();
|
||
renderMentorWorkspace();
|
||
state.mentorMessages = await loadMentorMessages();
|
||
renderMentorMessages();
|
||
}
|
||
|
||
function renderMentorMessages() {
|
||
const container = document.querySelector("#mentorMessages");
|
||
const selected = state.mentorSetup?.mentors.find((item) => item.id === state.selectedMentorId);
|
||
if (!state.mentorMessages.length && !state.mentorLoading) {
|
||
container.innerHTML = `
|
||
<div class="mentor-empty-state">
|
||
<strong>${escapeHtml(selected?.name || "问师")}</strong>
|
||
<p>${escapeHtml(selected?.tagline || selected?.description || "选择一个问题开始对话")}</p>
|
||
</div>
|
||
`;
|
||
} else {
|
||
container.innerHTML = state.mentorMessages.map((message) => `
|
||
<article class="mentor-message ${message.role}">
|
||
<div class="mentor-message-label">${message.role === "user" ? "我" : escapeHtml(selected?.name || "问师")}</div>
|
||
<div class="mentor-message-content">${message.role === "assistant" ? formatMentorAnswer(message.content) : escapeHtml(message.content)}</div>
|
||
${message.meta ? `<small>${escapeHtml(message.meta)}</small>` : ""}
|
||
</article>
|
||
`).join("");
|
||
if (state.mentorLoading) {
|
||
container.insertAdjacentHTML("beforeend", `
|
||
<article class="mentor-message assistant loading-message">
|
||
<div class="mentor-message-label">${escapeHtml(selected?.name || "问师")}</div>
|
||
<p>正在读取复盘数据并推演...</p>
|
||
</article>
|
||
`);
|
||
}
|
||
}
|
||
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,
|
||
});
|
||
state.mentorMessages.push({
|
||
role: "assistant",
|
||
content: payload.answer,
|
||
meta: `${displayCompactDate(payload.data_trade_date)} · 回答完成`,
|
||
});
|
||
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();
|
||
}
|
||
|
||
async function clearMentorConversation() {
|
||
if (!state.mentorMessages.length || !window.confirm("确定清空当前老师的对话记录吗?")) return;
|
||
try {
|
||
const query = new URLSearchParams({
|
||
mentor_id: state.selectedMentorId,
|
||
trade_date: state.mentorSetup?.trade_date || elements.tradeDate.value,
|
||
});
|
||
await apiRequest(`/api/mentors/messages?${query}`, "DELETE");
|
||
state.mentorMessages = [];
|
||
hideMentorNotice();
|
||
renderMentorMessages();
|
||
} catch (error) {
|
||
showToast(error.message || "对话记录清空失败");
|
||
}
|
||
}
|
||
|
||
async function loadMentorMessages() {
|
||
if (!state.selectedMentorId) return [];
|
||
try {
|
||
const query = new URLSearchParams({
|
||
mentor_id: state.selectedMentorId,
|
||
trade_date: state.mentorSetup?.trade_date || elements.tradeDate.value,
|
||
});
|
||
const payload = await apiRequest(`/api/mentors/messages?${query}`);
|
||
return (payload.items || []).filter(
|
||
(item) => ["user", "assistant"].includes(item?.role) && typeof item.content === "string",
|
||
).slice(-100);
|
||
} catch (error) {
|
||
showMentorNotice(error.message || "对话记录加载失败");
|
||
return [];
|
||
}
|
||
}
|
||
|
||
function showMentorNotice(message) {
|
||
const notice = document.querySelector("#mentorNotice");
|
||
notice.textContent = message;
|
||
notice.hidden = false;
|
||
}
|
||
|
||
function hideMentorNotice() {
|
||
document.querySelector("#mentorNotice").hidden = true;
|
||
}
|
||
|
||
function formatMentorAnswer(content) {
|
||
return escapeHtml(content).split("\n").map((line) => {
|
||
const heading = line.match(/^#{1,3}\s+(.+)$/);
|
||
if (heading) return `<strong class="mentor-answer-heading">${formatMentorInline(heading[1])}</strong>`;
|
||
if (/^-{3,}$/.test(line.trim())) return '<span class="mentor-answer-rule"></span>';
|
||
if (line.startsWith("> ")) return `<span class="mentor-answer-quote">${formatMentorInline(line.slice(5))}</span>`;
|
||
return formatMentorInline(line);
|
||
}).join("<br>");
|
||
}
|
||
|
||
function formatMentorInline(content) {
|
||
return content.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
|
||
}
|
||
|
||
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 = '<div class="empty-state">载入股票后查看六爻数据状态</div>';
|
||
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"
|
||
? `<select ${common}>${(field.options || []).map((option) => `<option value="${escapeHtml(option)}" ${option === rawValue ? "selected" : ""}>${escapeHtml(option)}</option>`).join("")}</select>`
|
||
: field.type === "text"
|
||
? `<input type="text" maxlength="50" value="${escapeHtml(rawValue)}" placeholder="待补充" ${common}>`
|
||
: `<input type="number" inputmode="decimal" value="${escapeHtml(rawValue)}" placeholder="待补充" min="${field.min ?? ""}" max="${field.max ?? ""}" step="${field.integer ? "1" : "any"}" ${common}>`;
|
||
return `<label class="heaven-manual-field ${field.manual ? "is-manual" : ""}">
|
||
<span>${escapeHtml(field.label)}<small>${escapeHtml(source)}</small></span>
|
||
<span class="heaven-field-control">${control}${field.unit ? `<b>${escapeHtml(field.unit)}</b>` : ""}</span>
|
||
</label>`;
|
||
}).join("");
|
||
const reasons = (check.reasons || []).map((reason) => `<li>${escapeHtml(reason)}</li>`).join("");
|
||
return `<details class="heaven-line-check is-${escapeHtml(check.status)}" ${check.passed ? "" : "open"}>
|
||
<summary>
|
||
<span class="heaven-check-state"><i class="status-dot ${escapeHtml(check.status)}"></i><b>${escapeHtml(stateLabel)}</b></span>
|
||
<span class="heaven-check-name"><strong>${escapeHtml(check.position)} · ${escapeHtml(check.layer)}</strong><small>${escapeHtml(check.formula)}</small></span>
|
||
<span class="heaven-check-result"><b>${check.line_value ? escapeHtml(lineValueLabel[check.line_value] || check.line_value) : "待定"}</b><small>得分 ${escapeHtml(score)}</small></span>
|
||
<i data-lucide="chevron-down" aria-hidden="true"></i>
|
||
</summary>
|
||
<div class="heaven-line-check-body">
|
||
${reasons ? `<ul class="heaven-check-reasons">${reasons}</ul>` : `<p class="heaven-check-evidence">${(check.evidence || []).map(escapeHtml).join(";") || "数据已通过安全门"}</p>`}
|
||
<div class="heaven-manual-fields">${fields}</div>
|
||
</div>
|
||
</details>`;
|
||
}).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) => `
|
||
<div>
|
||
<strong>${escapeHtml(item.lines)} · ${escapeHtml(item.layer)}</strong>
|
||
<span>${escapeHtml(heavenSourcePhrase(item))}</span>
|
||
<small>${escapeHtml(item.detail || "")}</small>
|
||
</div>
|
||
`).join("")
|
||
: '<p>暂无可核验的数据来源。</p>';
|
||
};
|
||
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) => `
|
||
<div class="talent-reading"><strong>未通过</strong><span>${escapeHtml(issue)}</span></div>
|
||
`),
|
||
...sourceRows.map((item) => `
|
||
<div class="talent-reading">
|
||
<strong>${escapeHtml(item.lines)} · ${escapeHtml(item.layer)}</strong>
|
||
<span>${escapeHtml(heavenSourcePhrase(item))}</span>
|
||
<small>${escapeHtml(item.detail || "")}</small>
|
||
</div>
|
||
`),
|
||
].join("");
|
||
document.querySelector("#heavenIndexStrip").innerHTML = "<p>天象尚未应时,待三才数据齐备后再观。</p>";
|
||
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) => `
|
||
<div class="talent-reading">
|
||
<strong>${escapeHtml(item.level)}</strong><span>${escapeHtml(item.state)}</span>
|
||
<div class="talent-balance">
|
||
<small>内 ${signedScore(item.inner)}</small><i><b style="--talent-value:${clamp((number(item.inner) + 1) * 50, 0, 100)}%"></b></i>
|
||
<small>外 ${signedScore(item.outer)}</small><i><b style="--talent-value:${clamp((number(item.outer) + 1) * 50, 0, 100)}%"></b></i>
|
||
</div>
|
||
</div>
|
||
`).join("");
|
||
const indexContext = chart.index_context || {};
|
||
document.querySelector("#heavenIndexStrip").innerHTML = (indexContext.indices || []).length
|
||
? indexContext.indices.map((item) => `
|
||
<div><span>${escapeHtml(item.name)}</span><strong class="${changeClass(item.pct_chg)}">${signed(item.pct_chg)}%</strong><small>5日 ${signed(item.return_5d)}%</small></div>
|
||
`).join("")
|
||
: `<p>${escapeHtml(indexContext.notice || "指数数据暂不可用")}</p>`;
|
||
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) => `
|
||
<section class="talent-line-group" style="--group-delay:${groupIndex * 70}ms">
|
||
<div class="talent-seal" aria-hidden="true">${group.talent}</div>
|
||
<div class="talent-line-content">
|
||
<p>${group.caption}</p>
|
||
${group.lines.map((line) => `
|
||
<div class="hexagram-line-row ${line.moving ? "moving" : ""}">
|
||
<span class="hexagram-position">${escapeHtml(line.position_name)}</span>
|
||
${hexagramLineGraphic(line.value)}
|
||
<div class="hexagram-line-detail">
|
||
<strong>${escapeHtml(line.role || line.line_name)} · ${line.value}${line.moving ? " 变" : ""}</strong>
|
||
<small>${(line.evidence || []).map(escapeHtml).join(";")}</small>
|
||
</div>
|
||
</div>
|
||
`).join("")}
|
||
</div>
|
||
</section>
|
||
`).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]) => `
|
||
<div class="fortune-metric"><span>${escapeHtml(label)}</span><strong>${escapeHtml(value)}</strong><small>${escapeHtml(detail)}</small></div>
|
||
`).join("");
|
||
const framework = field.framework || {};
|
||
setText("qiFrameworkPrinciple", framework.principle || "--");
|
||
const layerLabels = { year: "年运与岁气", current: "客主加临", day: "日辰触发" };
|
||
document.querySelector("#qiFrameworkLayers").innerHTML = (framework.layers || []).map((layer) => `
|
||
<div class="qi-framework-layer" data-qi-layer="${escapeHtml(layer.id)}">
|
||
<span>${escapeHtml(layerLabels[layer.id] || layer.label)}</span>
|
||
<strong>${escapeHtml(layer.dominant)}气</strong>
|
||
<small>${escapeHtml(layer.summary)}</small>
|
||
<div>${(layer.balance || []).map((item) => `<i class="phase-${phaseClass(item.element)}" style="--qi-segment:${number(item.percent)}%" title="${escapeHtml(item.element)}"></i>`).join("")}</div>
|
||
</div>
|
||
`).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) => `
|
||
<div class="phase-balance-row" data-phase-element="${escapeHtml(item.element)}" data-phase-percent="${number(item.percent)}" tabindex="0">
|
||
<strong class="phase-symbol phase-${phaseClass(item.element)}">${escapeHtml(item.element)}</strong>
|
||
<div><div class="phase-track"><span class="phase-${phaseClass(item.element)}" style="--phase-width:${number(item.percent)}%"></span></div><small>${escapeHtml(item.motion)} · ${escapeHtml(item.mind)}</small></div>
|
||
<b>${number(item.percent)}%</b>
|
||
</div>
|
||
`).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) => `
|
||
<div class="qi-use-source ${catalogElements.has(item.element) ? "has-catalog" : "no-catalog"}" data-qi-source="${escapeHtml(item.element)}">
|
||
<strong class="phase-text-${phaseClass(item.element)}">${escapeHtml(item.element)}</strong>
|
||
<span><b>${escapeHtml(item.motion)}</b><small>${number(item.percent)}%</small></span>
|
||
</div>
|
||
`).join("");
|
||
sectorContainer.innerHTML = catalog.length ? catalog.map((group) => {
|
||
const element = group.element;
|
||
const items = group.industries || [];
|
||
return `
|
||
<details class="qi-sector-group" data-qi-sector="${escapeHtml(element)}">
|
||
<summary>
|
||
<span class="qi-sector-group-title"><i class="phase-${phaseClass(element)}"></i><strong>${escapeHtml(element)}属性</strong></span>
|
||
<span class="qi-sector-group-count">${number(group.count)} 类</span>
|
||
<i class="qi-sector-chevron" data-lucide="chevron-down" aria-hidden="true"></i>
|
||
</summary>
|
||
<div class="qi-sector-fold">
|
||
<ul class="qi-sector-tags" aria-label="${escapeHtml(element)}属性行业">
|
||
${items.map((item) => `<li class="phase-border-${phaseClass(element)}">${escapeHtml(item.name)}${item.classification_source === "manual" ? '<small>手动</small>' : ""}</li>`).join("")}
|
||
</ul>
|
||
</div>
|
||
</details>
|
||
`;
|
||
}).join("") : '<p class="qi-use-empty">行业五行归类尚未建立。</p>';
|
||
sectorContainer.querySelectorAll(".qi-sector-group").forEach((group) => {
|
||
group.addEventListener("toggle", () => requestAnimationFrame(() => drawQiUseConnections(false)));
|
||
});
|
||
refreshIcons();
|
||
requestAnimationFrame(() => drawQiUseConnections(false));
|
||
}
|
||
|
||
function drawQiUseConnections(animate = false) {
|
||
const map = document.querySelector("#qiUseMap");
|
||
const svg = document.querySelector("#qiUseConnections");
|
||
if (!map || !svg || !map.clientWidth || !map.clientHeight) return;
|
||
const bounds = map.getBoundingClientRect();
|
||
svg.setAttribute("viewBox", `0 0 ${bounds.width} ${bounds.height}`);
|
||
svg.innerHTML = "";
|
||
document.querySelectorAll("#phaseSectorList [data-qi-sector]").forEach((group) => {
|
||
const element = group.dataset.qiSector;
|
||
const source = document.querySelector(`#qiUseSources [data-qi-source="${CSS.escape(element)}"]`);
|
||
const target = group.querySelector("summary");
|
||
if (!source || !target) return;
|
||
const from = source.getBoundingClientRect();
|
||
const to = target.getBoundingClientRect();
|
||
const x1 = from.right - bounds.left - 4;
|
||
const y1 = from.top + from.height / 2 - bounds.top;
|
||
const x2 = to.left - bounds.left + 2;
|
||
const y2 = to.top + to.height / 2 - bounds.top;
|
||
const bend = Math.max(46, (x2 - x1) * 0.42);
|
||
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||
path.setAttribute("d", `M ${x1} ${y1} C ${x1 + bend} ${y1}, ${x2 - bend} ${y2}, ${x2} ${y2}`);
|
||
path.setAttribute("pathLength", "1");
|
||
path.classList.add(`phase-stroke-${phaseClass(element)}`);
|
||
if (animate && motionEnabled()) path.classList.add("is-drawing");
|
||
else path.classList.add("is-flowing");
|
||
svg.appendChild(path);
|
||
if (animate && motionEnabled()) {
|
||
requestAnimationFrame(() => path.classList.add("is-visible"));
|
||
setTimeout(() => {
|
||
if (!path.isConnected) return;
|
||
path.classList.remove("is-drawing", "is-visible");
|
||
path.classList.add("is-flowing");
|
||
}, 1900);
|
||
}
|
||
});
|
||
}
|
||
|
||
function renderSectorPhaseOverrides(items) {
|
||
const container = document.querySelector("#sectorPhaseOverrides");
|
||
const canManage = state.user?.role === "admin";
|
||
container.innerHTML = items.length ? items.map((item) => `
|
||
<div class="sector-phase-override-row">
|
||
<span class="phase-symbol phase-${phaseClass(item.element)}">${escapeHtml(item.element)}</span>
|
||
<strong>${escapeHtml(item.name)}</strong>
|
||
${canManage ? `<button class="icon-button" type="button" data-sector-phase-delete="${escapeHtml(item.name)}" title="删除手动归类" aria-label="删除 ${escapeHtml(item.name)} 的手动归类"><i data-lucide="trash-2"></i></button>` : ""}
|
||
</div>
|
||
`).join("") : '<p class="sector-phase-empty">暂无手动归类</p>';
|
||
container.querySelectorAll("[data-sector-phase-delete]").forEach((button) => {
|
||
button.addEventListener("click", () => deleteSectorPhaseOverride(button.dataset.sectorPhaseDelete));
|
||
});
|
||
refreshIcons();
|
||
}
|
||
|
||
async function saveSectorPhaseOverride(event) {
|
||
event.preventDefault();
|
||
const name = document.querySelector("#sectorPhaseName").value.trim();
|
||
const element = document.querySelector("#sectorPhaseElement").value;
|
||
if (!name) return;
|
||
const button = event.currentTarget.querySelector("button[type='submit']");
|
||
button.disabled = true;
|
||
try {
|
||
await apiRequest("/api/heaven/sector-phases", "POST", { name, element });
|
||
document.querySelector("#sectorPhaseName").value = "";
|
||
await loadHeavenSetup(true);
|
||
showToast(`已将 ${name} 归为${element}`);
|
||
} catch (error) {
|
||
showToast(error.message || "手动归类保存失败");
|
||
} finally {
|
||
button.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function deleteSectorPhaseOverride(name) {
|
||
try {
|
||
await apiRequest(`/api/heaven/sector-phases/${encodeURIComponent(name)}`, "DELETE");
|
||
await loadHeavenSetup(true);
|
||
showToast(`已删除 ${name} 的手动归类`);
|
||
} catch (error) {
|
||
showToast(error.message || "手动归类删除失败");
|
||
}
|
||
}
|
||
|
||
async function saveAccountBirthProfile(event) {
|
||
event.preventDefault();
|
||
const birthDate = document.querySelector("#accountBirthDate").value;
|
||
const birthTime = document.querySelector("#accountBirthTime").value;
|
||
if (!birthDate || !birthTime) {
|
||
showToast("请填写完整出生日期和时间");
|
||
return;
|
||
}
|
||
const button = event.currentTarget.querySelector("button[type='submit']");
|
||
button.disabled = true;
|
||
const originalText = button.textContent;
|
||
button.textContent = "正在排盘";
|
||
try {
|
||
await apiRequest("/api/account/birth-profile", "POST", {
|
||
trade_date: document.querySelector("#qiObservationDate").value || elements.tradeDate.value,
|
||
birth_datetime: `${birthDate}T${birthTime}`,
|
||
gender: document.querySelector("#accountBirthGender").value,
|
||
});
|
||
event.currentTarget.reset();
|
||
setText("birthProfileStatus", "已加密保存");
|
||
document.querySelector("#deleteBirthProfileButton").disabled = false;
|
||
state.heavenInterpretations.fortune = "";
|
||
await loadHeavenSetup(true);
|
||
showToast("个人命理资料已保存到当前账号");
|
||
} catch (error) {
|
||
showToast(error.message || "个人命理资料保存失败");
|
||
} finally {
|
||
button.disabled = false;
|
||
button.textContent = originalText;
|
||
}
|
||
}
|
||
|
||
async function deleteAccountBirthProfile() {
|
||
if (!window.confirm("确定删除当前账号保存的个人命理资料吗?")) return;
|
||
try {
|
||
await apiRequest("/api/account/birth-profile", "DELETE");
|
||
state.personalField = null;
|
||
state.heavenInterpretations.fortune = "";
|
||
setText("birthProfileStatus", "尚未设置");
|
||
document.querySelector("#deleteBirthProfileButton").disabled = true;
|
||
renderPersonalFortune();
|
||
showToast("个人命理资料已删除");
|
||
} catch (error) {
|
||
showToast(error.message || "个人命理资料删除失败");
|
||
}
|
||
}
|
||
|
||
function renderPersonalFortune() {
|
||
const container = document.querySelector("#personalFortuneResult");
|
||
const empty = document.querySelector("#personalProfileEmpty");
|
||
const personal = state.personalField;
|
||
if (!personal) {
|
||
empty.hidden = false;
|
||
container.hidden = true;
|
||
container.innerHTML = "";
|
||
return;
|
||
}
|
||
empty.hidden = true;
|
||
container.hidden = false;
|
||
const tenGods = personal.ten_god_tendency || { favorable: [], caution: [] };
|
||
const elementTendency = personal.balance_tendency || { favorable: [], caution: [] };
|
||
const preferenceTags = (items, emptyText = "--") => items.length
|
||
? items.map((item) => `<em class="phase-text-${phaseClass(item)}">${escapeHtml(item)}</em>`).join("")
|
||
: emptyText;
|
||
container.innerHTML = `
|
||
<div class="personal-primary-grid">
|
||
<div class="personal-day-master">
|
||
<span>日主</span>
|
||
<strong class="personal-day-master-character phase-text-${phaseClass(personal.day_master.element)}">${escapeHtml(personal.day_master.stem)}</strong>
|
||
<b class="personal-day-master-element phase-text-${phaseClass(personal.day_master.element)}">${escapeHtml(personal.day_master.element)}</b>
|
||
<small>${escapeHtml(personal.day_master.strength)}</small>
|
||
</div>
|
||
<div class="personal-preferences">
|
||
<section class="personal-ten-gods">
|
||
<span>十神喜恶</span>
|
||
<div class="ten-god-favorable"><strong>偏宜</strong><p>${tenGods.favorable.map((item) => `<em>${escapeHtml(item)}</em>`).join("") || "--"}</p></div>
|
||
<div class="ten-god-caution"><strong>偏慎</strong><p>${tenGods.caution.map((item) => `<em>${escapeHtml(item)}</em>`).join("") || "--"}</p></div>
|
||
</section>
|
||
<section class="personal-element-tendency">
|
||
<span>五行喜忌</span>
|
||
<div class="element-favorable"><strong>偏喜</strong><p>${preferenceTags(elementTendency.favorable || [])}</p></div>
|
||
<div class="element-caution"><strong>偏忌</strong><p>${preferenceTags(elementTendency.caution || [])}</p></div>
|
||
</section>
|
||
</div>
|
||
</div>
|
||
<div class="personal-current-effect">
|
||
<span>当前作用 · 流年 ${escapeHtml(personal.current.ten_gods.year.stem)} · 流月 ${escapeHtml(personal.current.ten_gods.month.stem)} · 流日 ${escapeHtml(personal.current.ten_gods.day.stem)}</span>
|
||
<strong>${escapeHtml(personal.current.tone)}</strong>
|
||
<p>${escapeHtml(personal.current.operation_note)}</p>
|
||
<small>${escapeHtml(personal.balance_tendency.method)}</small>
|
||
</div>
|
||
<details class="personal-elements-details">
|
||
<summary>查看个人五行结构</summary>
|
||
<div class="personal-element-balance">
|
||
${personal.element_balance.map((item) => `<div><span class="phase-symbol phase-${phaseClass(item.element)}">${escapeHtml(item.element)}</span><i><b class="phase-${phaseClass(item.element)}" style="width:${number(item.percent)}%"></b></i><strong>${number(item.percent)}%</strong></div>`).join("")}
|
||
</div>
|
||
</details>
|
||
`;
|
||
}
|
||
|
||
function renderHexagramLines(containerId, lines, includeEvidence = false) {
|
||
const container = document.querySelector(`#${containerId}`);
|
||
container.innerHTML = [...lines].reverse().map((line) => `
|
||
<div class="hexagram-line-row ${line.moving ? "moving" : ""}">
|
||
<span class="hexagram-position">${escapeHtml(line.position_name)}</span>
|
||
${hexagramLineGraphic(line.value)}
|
||
<div class="hexagram-line-detail">
|
||
<strong>${escapeHtml(line.role || line.line_name)} · ${line.value}${line.moving ? " 变" : ""}</strong>
|
||
${includeEvidence ? `<small>${(line.evidence || []).map(escapeHtml).join(";")}</small>` : `<small>${escapeHtml(line.text || "")}</small>`}
|
||
</div>
|
||
</div>
|
||
`).join("");
|
||
}
|
||
|
||
function hexagramLineGraphic(value) {
|
||
const yang = value % 2 === 1;
|
||
return `
|
||
<span class="hex-line ${yang ? "yang-line" : "yin-line"}">
|
||
<i></i>${yang ? "" : "<i></i>"}${[6, 9].includes(value) ? `<b>${value === 9 ? "○" : "×"}</b>` : ""}
|
||
</span>
|
||
`;
|
||
}
|
||
|
||
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);
|
||
state.heavenInterpretations[mode] = {
|
||
answer: result.answer,
|
||
meta: mode === "trend" ? "观势已成" : mode === "fortune" ? "观气已成" : "解卦已成",
|
||
};
|
||
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 = `<div>${formatMentorAnswer(result.answer)}</div><small>${escapeHtml(result.meta)}</small>`;
|
||
}
|
||
|
||
function initializeHeartAtmosphere() {
|
||
const whisperContainer = document.querySelector("#heartWhispers");
|
||
if (whisperContainer && !whisperContainer.children.length) {
|
||
whisperContainer.innerHTML = HEART_WHISPERS.map(([text, x, y, index]) => `
|
||
<span style="--whisper-x:${x}%;--whisper-y:${y}%;--whisper-duration:${16 + index * 1.4}s;--whisper-delay:${index * -2.1}s">${escapeHtml(text)}</span>
|
||
`).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 = `<i data-lucide="${heartSound.enabled ? "volume-2" : "volume-x"}"></i><span>${heartSound.enabled ? "有声" : "静音"}</span>`;
|
||
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 = '<i class="heart-hold-charge" aria-hidden="true"></i><span>按住<br>重新成卦</span>';
|
||
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
|
||
? `<i aria-hidden="true"></i><span>按住<br>摇${nextPosition}</span>`
|
||
: '<i aria-hidden="true"></i><span>正在<br>成卦</span>';
|
||
button.disabled = heartCastingBusy || state.heartLines.length >= 6;
|
||
const rows = [];
|
||
for (let index = 5; index >= 0; index -= 1) {
|
||
const value = state.heartLines[index];
|
||
rows.push(`
|
||
<div class="hexagram-line-row ${[6, 9].includes(value) ? "moving" : ""} ${value ? "" : "empty-line"} ${value && index === state.heartLines.length - 1 ? "new-line" : ""}">
|
||
<span class="hexagram-position">${LINE_POSITIONS_CLIENT[index]}</span>
|
||
${value ? hexagramLineGraphic(value) : '<span class="hex-line placeholder-line heart-yao-ghost"><i></i><i></i></span>'}
|
||
<div class="hexagram-line-detail"><strong>${value ? `${lineValueName(value)} · ${value}` : "未得"}</strong></div>
|
||
</div>
|
||
`);
|
||
}
|
||
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) => `
|
||
<button class="heart-line-text ${line.moving ? "moving" : ""}" type="button" aria-expanded="false">
|
||
<strong>${escapeHtml(line.line_name)}${line.moving ? " · 动" : ""}</strong>
|
||
<p>${escapeHtml(line.text)}</p>
|
||
</button>
|
||
`).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) => `
|
||
<div class="heart-read-line ${line.moving ? "moving" : ""}">
|
||
<span>${escapeHtml(line.position_name)}</span>${hexagramLineGraphic(line.value)}
|
||
</div>
|
||
`).join("");
|
||
document.querySelector("#heartReadTexts").innerHTML = hexagram.lines.map((line) => `
|
||
<article class="heart-read-text ${line.moving ? "moving" : ""}">
|
||
<strong>${escapeHtml(line.line_name)}${line.moving ? " · 动" : ""}</strong><p>${escapeHtml(line.text)}</p>
|
||
</article>
|
||
`).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
|
||
? `盘中行情 · 历史样本截至 ${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) => `
|
||
<tr data-code="${escapeHtml(row.code)}"><td class="row-number">${index + 1}</td><td class="stock-code">${escapeHtml(row.code)}</td>
|
||
<td class="stock-name">${escapeHtml(row.name)}</td><td>${escapeHtml(row.sector)}</td>
|
||
<td class="number streak-value">${formatNumber(row.score_display, 1)}</td>
|
||
<td class="number probability-value"><strong>${row.historical_probability === null ? "样本不足" : `${formatNumber(row.historical_probability, 1)}%`}</strong><small>${number(row.probability_samples)} 个样本</small></td>
|
||
<td class="number ${changeClass(row.pct_chg)}">${signed(row.pct_chg)}%</td>
|
||
<td class="number ${changeClass(row.return_5d)}">${signed(row.return_5d)}%</td>
|
||
<td class="number">${formatNumber(row.volume_ratio_5d, 2)}</td><td class="number">${formatNumber(row.sector_strength, 1)}</td>
|
||
<td class="reason-column" title="${escapeHtml(row.reason)}">${escapeHtml(row.reason)}</td>
|
||
<td class="risk-cell" title="${escapeHtml(row.risk_flags.join(";"))}">${escapeHtml(row.risk_flags.join(";") || "--")}</td>
|
||
<td><button class="table-action" type="button" data-screen-detail="${escapeHtml(row.code)}">详情</button></td></tr>
|
||
`).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();
|
||
}
|
||
|
||
async function loadScreenerTracking(force = false) {
|
||
if (state.screenerTracking && !force) {
|
||
renderScreenerTracking();
|
||
return;
|
||
}
|
||
try {
|
||
state.screenerTracking = await apiRequest("/api/screener/tracking?limit=12");
|
||
renderScreenerTracking();
|
||
} catch (error) {
|
||
showToast(error.message || "策略跟踪加载失败");
|
||
}
|
||
}
|
||
|
||
async function refreshScreenerTracking() {
|
||
const button = document.querySelector("#refreshTrackingButton");
|
||
button.disabled = true;
|
||
setStatus("正在更新策略跟踪");
|
||
try {
|
||
const payload = await apiRequest("/api/screener/tracking/refresh", "POST", {
|
||
trade_date: elements.tradeDate.value,
|
||
});
|
||
state.screenerTracking = payload.tracking;
|
||
renderScreenerTracking();
|
||
if (payload.notice) showToast(payload.notice);
|
||
setStatus("策略跟踪已更新");
|
||
} catch (error) {
|
||
showToast(error.message || "策略跟踪刷新失败");
|
||
setStatus("策略跟踪刷新失败");
|
||
} finally {
|
||
button.disabled = false;
|
||
}
|
||
}
|
||
|
||
function renderScreenerTracking() {
|
||
const payload = state.screenerTracking || { batches: [], summary: {} };
|
||
const batches = payload.batches || [];
|
||
const rows = batches.flatMap((batch) => (batch.items || []).map((item) => ({
|
||
...item,
|
||
selection_date: batch.selection_date,
|
||
strategy_name: batch.strategy_name,
|
||
})));
|
||
setText("trackingBatchCount", `${batches.length} 批`);
|
||
const summary = payload.summary || {};
|
||
document.querySelector("#trackingSummary").innerHTML = [
|
||
["跟踪标的", `${number(summary.total)} 只`],
|
||
["已有 T+1", `${number(summary.observed)} 只`],
|
||
["T+1 胜率", trackingPercent(summary.t1_win_rate)],
|
||
["T+5 胜率", trackingPercent(summary.t5_win_rate)],
|
||
["T+5 平均", trackingReturn(summary.average_t5)],
|
||
].map(([label, value]) => `<div><span>${label}</span><strong>${value}</strong></div>`).join("");
|
||
document.querySelector("#trackingEmpty").hidden = rows.length > 0;
|
||
document.querySelector("#trackingTableBody").innerHTML = rows.map((row) => `
|
||
<tr data-code="${escapeHtml(row.code)}">
|
||
<td>${displayCompactDate(row.selection_date)}</td>
|
||
<td class="tracking-strategy" title="${escapeHtml(row.strategy_name)}">${escapeHtml(row.strategy_name)}</td>
|
||
<td><span class="stock-cell"><strong>${escapeHtml(row.name)}</strong><small class="stock-code">${escapeHtml(row.code)}</small></span></td>
|
||
<td class="number">${formatNumber(row.entry_price, 2)}</td>
|
||
${["t1_open", "t1_close", "t3_close", "t5_close", "max_gain", "max_drawdown"].map((key) => `<td class="number ${row[key] == null ? "" : changeClass(row[key])}">${trackingReturn(row[key])}</td>`).join("")}
|
||
<td><span class="tracking-status ${row.status === "已完成" ? "complete" : row.observed_days ? "active" : "pending"}">${escapeHtml(row.status)}</span></td>
|
||
</tr>
|
||
`).join("");
|
||
bindStockRows(document.querySelector("#trackingTableBody"));
|
||
}
|
||
|
||
function trackingReturn(value) {
|
||
return value == null ? "--" : `${signed(value)}%`;
|
||
}
|
||
|
||
function trackingPercent(value) {
|
||
return value == null ? "--" : `${formatNumber(value, 1)}%`;
|
||
}
|
||
|
||
function renderBacktest(backtest) {
|
||
const panel = document.querySelector("#backtestPanel");
|
||
panel.hidden = !backtest;
|
||
if (!backtest) return;
|
||
setText("backtestDefinition", backtest.definition);
|
||
document.querySelector("#backtestMetrics").innerHTML = [
|
||
["历史样本", `${number(backtest.samples)} 个`],
|
||
["条件胜率", `${formatNumber(backtest.win_rate, 1)}%`],
|
||
["平均3日收益", `${signed(backtest.average_3d_return)}%`],
|
||
["平均最大回撤", `${signed(backtest.average_drawdown)}%`],
|
||
].map(([label, value]) => `<div class="dragon-metric"><span>${label}</span><strong>${value}</strong></div>`).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}:latest`;
|
||
const cached = stockPreviewCache.get(cacheKey);
|
||
if (cached && cached.expiresAt > Date.now()) {
|
||
renderStockPreview(cached.payload);
|
||
return;
|
||
}
|
||
if (cached) stockPreviewCache.delete(cacheKey);
|
||
stockPreviewAbortController?.abort();
|
||
stockPreviewAbortController = new AbortController();
|
||
try {
|
||
const payload = await apiRequest(
|
||
`/api/stock/${encodeURIComponent(code)}/preview`,
|
||
"GET",
|
||
null,
|
||
{ signal: stockPreviewAbortController.signal },
|
||
);
|
||
if (state.stockPreviewCode !== String(code) || elements.stockPreview.hidden) return;
|
||
const cacheMs = payload.meta?.realtime ? LIVE_REFRESH_DEFAULT_MS : STOCK_PREVIEW_CACHE_MS;
|
||
stockPreviewCache.set(cacheKey, { payload, expiresAt: Date.now() + cacheMs });
|
||
while (stockPreviewCache.size > 48) stockPreviewCache.delete(stockPreviewCache.keys().next().value);
|
||
renderStockPreview(payload);
|
||
} catch (error) {
|
||
if (error.name === "AbortError" || state.stockPreviewCode !== String(code)) return;
|
||
renderStockPreviewError(error.message || "行情预览加载失败");
|
||
}
|
||
}
|
||
|
||
function renderStockPreviewLoading() {
|
||
const fallback = state.stockPreviewFallback || {};
|
||
setText("stockPreviewCode", state.stockPreviewCode || "--");
|
||
setText("stockPreviewName", fallback.name || "正在加载");
|
||
setText("stockPreviewSector", fallback.sector || "--");
|
||
setText("stockPreviewPrice", "--");
|
||
setText("stockPreviewChange", "--");
|
||
document.querySelector("#stockPreviewChange").className = "";
|
||
setText("stockPreviewDate", "最新行情");
|
||
setText("stockPreviewSource", "正在读取行情");
|
||
setText("stockPreviewSummary", "等待行情数据");
|
||
document.querySelector("#stockPreviewLoading").hidden = false;
|
||
clearStockPreviewChart("");
|
||
}
|
||
|
||
function renderStockPreview(payload) {
|
||
state.stockPreviewPayload = payload;
|
||
const fallback = state.stockPreviewFallback || {};
|
||
const stock = payload.stock || {};
|
||
const price = stock.price;
|
||
const change = stock.change;
|
||
setText("stockPreviewCode", stock.code || state.stockPreviewCode);
|
||
setText("stockPreviewName", stock.name && stock.name !== "--" ? stock.name : fallback.name || "--");
|
||
setText("stockPreviewSector", stock.industry && stock.industry !== "其他" ? stock.industry : fallback.sector || "其他");
|
||
setText("stockPreviewPrice", meaningfulNumber(price) ? formatNumber(price, 2) : "--");
|
||
setText("stockPreviewChange", meaningfulNumber(change) ? `${signed(change)}%` : "--");
|
||
document.querySelector("#stockPreviewChange").className = changeClass(change);
|
||
setText("stockPreviewDate", payload.meta?.trade_date || "最新行情");
|
||
document.querySelector("#stockPreviewLoading").hidden = true;
|
||
const intradayAvailable = (payload.intraday || []).length > 0;
|
||
const source = payload.meta?.realtime ? "盘中行情" : "日 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`;
|
||
}
|
||
|
||
async function loadAlerts(openDialog = false) {
|
||
try {
|
||
const query = new URLSearchParams({ status: state.alertFilter, as_of: todayString() });
|
||
const payload = await apiRequest(`/api/alerts?${query}`);
|
||
state.alerts = payload.items || [];
|
||
state.alertUnreadCount = number(payload.unread_count);
|
||
renderAlerts();
|
||
if (openDialog && !elements.alertsDialog.open) elements.alertsDialog.showModal();
|
||
} catch (error) {
|
||
if (openDialog) showToast(error.message || "提醒加载失败");
|
||
}
|
||
}
|
||
|
||
function openAlerts() {
|
||
toggleHeaderCommandMenu(false);
|
||
toggleAccountDropdown(false);
|
||
document.querySelector("#alertDate").value ||= todayString();
|
||
elements.alertsDialog.showModal();
|
||
loadAlerts();
|
||
}
|
||
|
||
function openStockReminder() {
|
||
const stock = state.activeStock || {};
|
||
document.querySelector("#alertTitle").value = `${stock.name || stock.code || "个股"}观察提醒`;
|
||
document.querySelector("#alertCode").value = stock.code || "";
|
||
document.querySelector("#alertDate").value = todayString();
|
||
if (elements.stockDialog.open) elements.stockDialog.close();
|
||
openAlerts();
|
||
document.querySelector("#alertContent").focus();
|
||
}
|
||
|
||
function selectAlertFilter(filter) {
|
||
state.alertFilter = filter === "unread" ? "unread" : "all";
|
||
document.querySelectorAll("[data-alert-filter]").forEach((button) => {
|
||
button.classList.toggle("active", button.dataset.alertFilter === state.alertFilter);
|
||
});
|
||
loadAlerts();
|
||
}
|
||
|
||
async function saveAlert(event) {
|
||
event.preventDefault();
|
||
const button = event.currentTarget.querySelector("button[type='submit']");
|
||
button.disabled = true;
|
||
try {
|
||
const payload = await apiRequest("/api/alerts", "POST", {
|
||
title: document.querySelector("#alertTitle").value.trim(),
|
||
remind_date: document.querySelector("#alertDate").value,
|
||
code: document.querySelector("#alertCode").value.trim(),
|
||
content: document.querySelector("#alertContent").value.trim(),
|
||
});
|
||
event.currentTarget.reset();
|
||
document.querySelector("#alertDate").value = todayString();
|
||
state.alertFilter = "all";
|
||
state.alerts = payload.items || [];
|
||
state.alertUnreadCount = number(payload.unread_count);
|
||
renderAlerts();
|
||
showToast("提醒已保存");
|
||
} catch (error) {
|
||
showToast(error.message || "提醒保存失败");
|
||
} finally {
|
||
button.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function markAllAlertsRead() {
|
||
try {
|
||
await apiRequest("/api/alerts/read-all", "POST", { as_of: todayString() });
|
||
await loadAlerts();
|
||
} catch (error) {
|
||
showToast(error.message || "提醒状态更新失败");
|
||
}
|
||
}
|
||
|
||
async function handleAlertAction(event) {
|
||
const button = event.target.closest("[data-alert-action]");
|
||
if (!button) return;
|
||
const id = number(button.dataset.alertId);
|
||
if (!id) return;
|
||
try {
|
||
if (button.dataset.alertAction === "delete") {
|
||
await apiRequest(`/api/alerts/${id}`, "DELETE");
|
||
} else {
|
||
await apiRequest(`/api/alerts/${id}/read`, "POST", {});
|
||
}
|
||
await loadAlerts();
|
||
} catch (error) {
|
||
showToast(error.message || "提醒操作失败");
|
||
}
|
||
}
|
||
|
||
function renderAlerts() {
|
||
const badge = document.querySelector("#alertBadge");
|
||
badge.hidden = state.alertUnreadCount <= 0;
|
||
badge.textContent = state.alertUnreadCount > 99 ? "99+" : String(state.alertUnreadCount);
|
||
document.querySelector("#alertButton").classList.toggle("has-alerts", state.alertUnreadCount > 0);
|
||
setText("alertListCount", `${state.alerts.length} 条`);
|
||
document.querySelectorAll("[data-alert-filter]").forEach((button) => {
|
||
button.classList.toggle("active", button.dataset.alertFilter === state.alertFilter);
|
||
});
|
||
document.querySelector("#markAllAlertsRead").disabled = state.alertUnreadCount <= 0;
|
||
const container = document.querySelector("#alertList");
|
||
container.innerHTML = state.alerts.map((item) => {
|
||
const upcoming = !item.due;
|
||
const kindLabel = item.kind === "manual" ? "自定提醒" : item.kind === "strategy_t5" ? "跟踪完成" : "策略反馈";
|
||
return `<article class="alert-item ${item.is_read ? "is-read" : "is-unread"} ${upcoming ? "is-upcoming" : ""}">
|
||
<div class="alert-item-icon"><i data-lucide="${upcoming ? "calendar-clock" : item.kind === "manual" ? "bell" : "chart-no-axes-combined"}"></i></div>
|
||
<div class="alert-item-copy">
|
||
<div><span>${escapeHtml(kindLabel)}</span><time>${displayCompactDate(item.available_date)}</time></div>
|
||
<strong>${escapeHtml(item.title)}</strong>
|
||
${item.content ? `<p>${escapeHtml(item.content)}</p>` : ""}
|
||
${item.code ? `<button class="stock-preview-trigger alert-stock-link" type="button" data-code="${escapeHtml(item.code)}">${escapeHtml(item.code)}</button>` : ""}
|
||
</div>
|
||
<div class="alert-item-actions">
|
||
${!item.is_read && !upcoming ? `<button class="icon-button" type="button" data-alert-action="read" data-alert-id="${number(item.id)}" title="标为已读" aria-label="标为已读"><i data-lucide="check"></i></button>` : ""}
|
||
<button class="icon-button" type="button" data-alert-action="delete" data-alert-id="${number(item.id)}" title="删除提醒" aria-label="删除提醒"><i data-lucide="trash-2"></i></button>
|
||
</div>
|
||
</article>`;
|
||
}).join("") || '<div class="empty-state">暂无提醒</div>';
|
||
bindStockRows(container);
|
||
refreshIcons();
|
||
}
|
||
|
||
async function openReviewAssistant() {
|
||
if (!hasMemberAccess()) {
|
||
openSettings("membership", "复盘助手仅对会员开放");
|
||
return;
|
||
}
|
||
toggleHeaderCommandMenu(false);
|
||
toggleAccountDropdown(false);
|
||
if (!elements.assistantDialog.open) elements.assistantDialog.showModal();
|
||
try {
|
||
const payload = await apiRequest("/api/assistant/messages");
|
||
state.assistantMessages = payload.items || [];
|
||
renderAssistantMessages();
|
||
} catch (error) {
|
||
showToast(error.message || "对话记录加载失败");
|
||
}
|
||
document.querySelector("#assistantQuestion").focus();
|
||
}
|
||
|
||
function useAssistantPrompt(prompt) {
|
||
const input = document.querySelector("#assistantQuestion");
|
||
input.value = prompt;
|
||
input.focus();
|
||
}
|
||
|
||
async function sendAssistantQuestion(event) {
|
||
event.preventDefault();
|
||
if (state.assistantLoading) return;
|
||
const input = document.querySelector("#assistantQuestion");
|
||
const question = input.value.trim();
|
||
if (!question) return;
|
||
input.value = "";
|
||
state.assistantMessages.push({ role: "user", content: question, context_date: elements.tradeDate.value.replaceAll("-", "") });
|
||
state.assistantMessages.push({ role: "assistant", content: "", streaming: true, context_date: elements.tradeDate.value.replaceAll("-", "") });
|
||
state.assistantLoading = true;
|
||
state.assistantController = new AbortController();
|
||
updateAssistantControls();
|
||
renderAssistantMessages();
|
||
try {
|
||
await streamAssistantRequest(question, state.assistantController.signal, (chunk) => {
|
||
const message = state.assistantMessages.at(-1);
|
||
if (message?.role === "assistant") message.content += chunk;
|
||
scheduleAssistantRender();
|
||
});
|
||
const message = state.assistantMessages.at(-1);
|
||
if (message) message.streaming = false;
|
||
setStatus("复盘助手回答完成");
|
||
} catch (error) {
|
||
const message = state.assistantMessages.at(-1);
|
||
if (message?.role === "assistant") {
|
||
message.streaming = false;
|
||
message.error = true;
|
||
if (!message.content) message.content = error.name === "AbortError" ? "已停止生成。" : error.message || "回答失败,请稍后重试。";
|
||
}
|
||
if (error.name !== "AbortError") showToast(error.message || "复盘助手回答失败");
|
||
} finally {
|
||
state.assistantLoading = false;
|
||
state.assistantController = null;
|
||
updateAssistantControls();
|
||
renderAssistantMessages();
|
||
input.focus();
|
||
}
|
||
}
|
||
|
||
async function streamAssistantRequest(question, signal, onDelta) {
|
||
const response = await fetch("/api/assistant/chat", {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
...(state.csrfToken ? { "X-CSRF-Token": state.csrfToken } : {}),
|
||
},
|
||
body: JSON.stringify({ question, trade_date: elements.tradeDate.value }),
|
||
signal,
|
||
});
|
||
if (!response.ok) {
|
||
const payload = await response.json().catch(() => ({}));
|
||
throw new Error(payload.error || "复盘助手暂不可用");
|
||
}
|
||
if (!response.body) throw new Error("当前浏览器不支持流式回答");
|
||
const reader = response.body.getReader();
|
||
const decoder = new TextDecoder();
|
||
let buffer = "";
|
||
while (true) {
|
||
const { value, done } = await reader.read();
|
||
buffer += decoder.decode(value || new Uint8Array(), { stream: !done });
|
||
const lines = buffer.split("\n");
|
||
buffer = lines.pop() || "";
|
||
for (const line of lines) {
|
||
if (!line.trim()) continue;
|
||
const event = JSON.parse(line);
|
||
if (event.type === "delta") onDelta(String(event.content || ""));
|
||
if (event.type === "error") throw new Error(event.error || "复盘助手回答失败");
|
||
}
|
||
if (done) break;
|
||
}
|
||
if (buffer.trim()) {
|
||
const event = JSON.parse(buffer);
|
||
if (event.type === "delta") onDelta(String(event.content || ""));
|
||
if (event.type === "error") throw new Error(event.error || "复盘助手回答失败");
|
||
}
|
||
}
|
||
|
||
function stopAssistantResponse() {
|
||
state.assistantController?.abort();
|
||
}
|
||
|
||
async function clearAssistantConversation() {
|
||
if (state.assistantLoading || !state.assistantMessages.length) return;
|
||
if (!window.confirm("确定清空复盘助手的对话记录吗?")) return;
|
||
try {
|
||
await apiRequest("/api/assistant/messages", "DELETE");
|
||
state.assistantMessages = [];
|
||
renderAssistantMessages();
|
||
} catch (error) {
|
||
showToast(error.message || "对话记录清空失败");
|
||
}
|
||
}
|
||
|
||
function scheduleAssistantRender() {
|
||
if (assistantRenderFrame) return;
|
||
assistantRenderFrame = requestAnimationFrame(() => {
|
||
assistantRenderFrame = 0;
|
||
renderAssistantMessages();
|
||
});
|
||
}
|
||
|
||
function renderAssistantMessages() {
|
||
const container = document.querySelector("#assistantMessages");
|
||
container.innerHTML = state.assistantMessages.map((message) => `
|
||
<article class="assistant-message ${message.role} ${message.error ? "is-error" : ""}">
|
||
<div class="assistant-message-label">${message.role === "user" ? "我" : "复盘助手"}${message.context_date ? `<time>${displayCompactDate(message.context_date)}</time>` : ""}</div>
|
||
<div class="assistant-message-content">${message.role === "assistant" ? (message.content ? formatMentorAnswer(message.content) : '<span class="assistant-thinking">正在整理复盘数据</span>') : escapeHtml(message.content)}</div>
|
||
${message.streaming ? '<span class="assistant-stream-caret" aria-hidden="true"></span>' : ""}
|
||
</article>
|
||
`).join("") || '<div class="empty-state">可以从市场、策略或自己的交易记录开始复盘</div>';
|
||
updateAssistantControls();
|
||
requestAnimationFrame(() => { container.scrollTop = container.scrollHeight; });
|
||
}
|
||
|
||
function updateAssistantControls() {
|
||
document.querySelector("#assistantQuestion").disabled = state.assistantLoading;
|
||
document.querySelector("#sendAssistant").disabled = state.assistantLoading;
|
||
document.querySelector("#stopAssistant").hidden = !state.assistantLoading;
|
||
document.querySelector("#clearAssistantMessages").disabled = state.assistantLoading || !state.assistantMessages.length;
|
||
}
|
||
|
||
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 = '<div class="global-search-loading"><span class="spinner" aria-hidden="true"></span><span>正在搜索</span></div>';
|
||
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 `<button class="global-search-result" type="button" role="option" aria-selected="false" data-search-result-index="${index}">
|
||
<span class="global-search-result-icon"><i data-lucide="${iconNames[item.type] || "search"}"></i></span>
|
||
<span class="global-search-result-copy"><strong>${escapeHtml(item.name || "--")}</strong><span>${escapeHtml(item.subtitle || item.type_label || label)}</span></span>
|
||
<span class="global-search-result-code">${escapeHtml(item.code || "")}</span>
|
||
</button>`;
|
||
}).join("");
|
||
sections.push(`<section class="global-search-group" aria-label="${label}"><h3 class="global-search-group-title">${label}</h3>${rows}</section>`);
|
||
});
|
||
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 = `<div class="global-search-empty"><i data-lucide="${iconName}"></i><p>${escapeHtml(title)}</p><span>${escapeHtml(hint)}</span></div>`;
|
||
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 = '<div class="empty-state">正在加载交易数据</div>';
|
||
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 = `<div class="empty-state">${escapeHtml(error.message || "交易数据加载失败")}</div>`;
|
||
clearEntityDetailChart(error.message || "行情加载失败");
|
||
showToast(error.message || "详情加载失败");
|
||
}
|
||
}
|
||
|
||
function renderEntityDetailMetrics(metrics) {
|
||
const container = document.querySelector("#entityDetailMetrics");
|
||
if (!metrics.length) {
|
||
container.innerHTML = '<div class="empty-state">暂无交易数据</div>';
|
||
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 `<div><dt>${escapeHtml(metric.label)}</dt><dd class="${tone}">${escapeHtml(value)}${escapeHtml(metric.unit || "")}</dd></div>`;
|
||
}).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 = '<div class="empty-state">正在加载笔记</div>';
|
||
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", `日 K 行情 · ${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;
|
||
});
|
||
});
|
||
const assistantButton = document.querySelector("#assistantButton");
|
||
assistantButton.classList.toggle("member-locked-control", !unlocked);
|
||
assistantButton.title = 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", accessNotice = "") {
|
||
const selectedPanel = selectAccountPanel(panel);
|
||
toggleAccountDropdown(false);
|
||
toggleHeaderCommandMenu(false);
|
||
const membershipNotice = document.querySelector("#membershipAccessNotice");
|
||
membershipNotice.textContent = accessNotice;
|
||
membershipNotice.hidden = selectedPanel !== "membership" || !accessNotice;
|
||
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
|
||
? "管理员拥有智能功能管理权限,但不会因此显示为已开通会员。"
|
||
: "开通会员后可使用智能选股、问师、问天、复盘助手等智能功能。");
|
||
setText("membershipQuotaHint", `会员默认每日智能分析额度 ${number(access.daily_limit)} 次,由管理员统一设置。`);
|
||
setText("membershipUsage", membership.active
|
||
? `今日已用 ${number(access.used_today)} 次`
|
||
: "今日智能分析:--");
|
||
setText("membershipUsageSummary", membership.active ? `${number(access.used_today)} / ${number(access.daily_limit)}` : "--");
|
||
setText("membershipRemainingUsage", membership.is_admin ? "不限" : membership.active ? `${number(access.remaining_calls)} 次` : "--");
|
||
const birth = payload.birth_profile || {};
|
||
if (birth.birth_datetime) {
|
||
const [birthDate, birthTime] = String(birth.birth_datetime).split("T");
|
||
document.querySelector("#accountBirthDate").value = birthDate || "";
|
||
document.querySelector("#accountBirthTime").value = (birthTime || "").slice(0, 5);
|
||
document.querySelector("#accountBirthGender").value = birth.gender || "unspecified";
|
||
}
|
||
setText("birthProfileStatus", payload.birth_profile_configured ? "已加密保存" : "尚未设置");
|
||
document.querySelector("#deleteBirthProfileButton").disabled = !payload.birth_profile_configured;
|
||
} catch (error) {
|
||
status.hidden = false;
|
||
status.textContent = "无法连接本地后端";
|
||
showToast(error.message || "账号信息加载失败");
|
||
}
|
||
}
|
||
|
||
async function changeAccountPassword(event) {
|
||
event.preventDefault();
|
||
const form = event.currentTarget;
|
||
const button = form.querySelector("button[type='submit']");
|
||
button.disabled = true;
|
||
try {
|
||
await apiRequest("/api/account/password", "POST", {
|
||
current_password: document.querySelector("#currentPassword").value,
|
||
new_password: document.querySelector("#newPassword").value,
|
||
confirm_password: document.querySelector("#confirmPassword").value,
|
||
});
|
||
form.reset();
|
||
showToast("密码已更新");
|
||
} catch (error) {
|
||
showToast(error.message || "密码更新失败");
|
||
} finally {
|
||
button.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function switchAccount() {
|
||
const button = document.querySelector("#switchAccountMenuButton");
|
||
button.disabled = true;
|
||
toggleAccountDropdown(false);
|
||
try {
|
||
await apiRequest("/api/auth/logout", "POST", {});
|
||
window.location.reload();
|
||
} catch (error) {
|
||
showToast(error.message || "切换账号失败");
|
||
button.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function openAdminSettings(refreshOnly = false) {
|
||
if (state.user?.role !== "admin") return;
|
||
if (!refreshOnly && !elements.adminDialog.open) elements.adminDialog.showModal();
|
||
const status = document.querySelector("#adminConnectionStatus");
|
||
status.textContent = "正在读取系统状态";
|
||
try {
|
||
const payload = await apiRequest("/api/admin/settings");
|
||
const data = payload.data || {};
|
||
const 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) => `
|
||
<article class="model-pool-row" data-model-id="${escapeHtml(item.id)}">
|
||
<div class="model-pool-heading"><strong>${escapeHtml(item.name || `模型 ${index + 1}`)}</strong><span>${item.configured ? "已保存密钥" : "待配置"}</span></div>
|
||
<div class="model-pool-fields">
|
||
<label class="form-field"><span>显示名称 *</span><input data-model-field="name" maxlength="50" value="${escapeHtml(item.name || "")}" required></label>
|
||
<label class="form-field"><span>API Base URL *</span><input data-model-field="base_url" type="url" value="${escapeHtml(item.base_url || "https://api.openai.com/v1")}" required></label>
|
||
<label class="form-field"><span>模型标识 *</span><input data-model-field="model" maxlength="100" value="${escapeHtml(item.model || "")}" required></label>
|
||
<label class="form-field"><span>API Key${item.configured ? "" : " *"}</span><input data-model-field="api_key" type="password" autocomplete="off" maxlength="300" placeholder="${item.configured ? "留空保留已保存的 Key" : "输入 API Key"}" ${item.configured ? "" : "required"}></label>
|
||
</div>
|
||
<div class="model-test-row"><button class="button" type="button" data-test-model>测试连接</button><span class="model-test-status" aria-live="polite">未测试</span><button class="icon-button model-delete-button" type="button" data-delete-model aria-label="删除模型" title="删除模型"><i data-lucide="trash-2"></i></button></div>
|
||
</article>
|
||
`).join("") || '<div class="empty-state">模型池为空,请先添加模型</div>';
|
||
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) => `<option value="${escapeHtml(item.id)}">${escapeHtml(item.name || item.model || "未命名模型")}</option>`).join("");
|
||
const primary = document.querySelector("#platformPrimaryModelSelect");
|
||
const fallback = document.querySelector("#platformFallbackModelSelect");
|
||
primary.innerHTML = models.length ? options : '<option value="">暂无模型</option>';
|
||
fallback.innerHTML = `<option value="">不启用辅助模型</option>${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 `<article class="admin-user-row" data-admin-user="${number(user.id)}">
|
||
<div class="admin-user-identity"><strong>${escapeHtml(user.username)}</strong><span>${escapeHtml(identityLabels)}</span><small>${escapeHtml(expiry)}</small></div>
|
||
<div class="admin-user-usage">今日调用 <b>${number(user.used_today)}</b></div>
|
||
<form class="membership-form">
|
||
<input type="hidden" name="user_id" value="${number(user.id)}">
|
||
<label><span>状态</span><select name="status"><option value="inactive" ${user.membership_status === "inactive" ? "selected" : ""}>未开通</option><option value="active" ${user.membership_status === "active" ? "selected" : ""}>有效</option><option value="suspended" ${user.membership_status === "suspended" ? "selected" : ""}>停用</option></select></label>
|
||
<label><span>开通 / 续期时长</span><select name="duration"><option value="">选择时长</option><option value="1_month">1个月</option><option value="3_months">3个月</option><option value="12_months">12个月</option><option value="3_years">3年</option><option value="permanent">永久</option></select></label>
|
||
<div class="membership-expiry"><span>当前到期</span><strong>${escapeHtml(expiry)}</strong></div>
|
||
<button class="button" type="submit">应用</button>
|
||
</form>
|
||
</article>`;
|
||
}).join("") || '<div class="empty-state">暂无注册用户</div>';
|
||
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 dashboardDataTimestamp(meta = {}) {
|
||
const tradeDate = displayCompactDate(meta.trade_date);
|
||
if (tradeDate === "--") return "--";
|
||
const intraday = tradeDate === todayString() && Boolean(meta.realtime) && !["closed", "after_hours"].includes(String(meta.market_status || ""));
|
||
if (intraday) {
|
||
const updated = new Date(meta.updated_at);
|
||
if (!Number.isNaN(updated.getTime())) {
|
||
const dateText = `${updated.getFullYear()}-${String(updated.getMonth() + 1).padStart(2, "0")}-${String(updated.getDate()).padStart(2, "0")}`;
|
||
const timeText = updated.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false });
|
||
return `${dateText} ${timeText}`;
|
||
}
|
||
}
|
||
return `${tradeDate} 15:00`;
|
||
}
|
||
|
||
function formatMoneyMillion(value) {
|
||
const parsed = number(value);
|
||
const sign = parsed > 0 ? "+" : "";
|
||
if (Math.abs(parsed) >= 100) return `${sign}${formatNumber(parsed / 100, 2)} 亿`;
|
||
return `${sign}${formatNumber(parsed * 100, 0)} 万`;
|
||
}
|
||
|
||
async function apiRequest(url, method = "GET", body = null, requestOptions = {}) {
|
||
const options = { method, headers: {}, signal: requestOptions.signal };
|
||
if (method !== "GET" && state.csrfToken) {
|
||
options.headers["X-CSRF-Token"] = state.csrfToken;
|
||
}
|
||
if (body !== null) {
|
||
options.headers["Content-Type"] = "application/json";
|
||
options.body = JSON.stringify(body);
|
||
}
|
||
const response = await fetch(url, options);
|
||
const payload = await response.json();
|
||
if (response.status === 401 && !url.startsWith("/api/auth/")) {
|
||
showAuthGate("登录状态已失效,请重新登录。");
|
||
}
|
||
if (!response.ok || payload.error) throw new Error(payload.error || "请求失败");
|
||
return payload;
|
||
}
|
||
|
||
function setLoading(loading, text = "正在加载复盘数据", context = "default") {
|
||
elements.loading.hidden = !loading;
|
||
elements.loading.dataset.context = loading ? context : "default";
|
||
setText("loadingTitle", text);
|
||
setText(
|
||
"loadingHint",
|
||
context === "screener"
|
||
? "正在完成因子筛选、候选排序与历史样本回测,这通常需要一点时间"
|
||
: "请稍候",
|
||
);
|
||
}
|
||
|
||
function setStatus(text) {
|
||
setText("statusText", text);
|
||
}
|
||
|
||
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));
|
||
}
|