9263 lines
431 KiB
JavaScript
9263 lines
431 KiB
JavaScript
const {
|
||
clamp,
|
||
displayCompactDate,
|
||
escapeHtml,
|
||
formatNumber,
|
||
formatTimestamp,
|
||
localDateString,
|
||
number,
|
||
parseLocalDate,
|
||
todayString,
|
||
} = window.XiaobaiUI;
|
||
|
||
const HEART_BREATH_INHALE_MS = 3_000;
|
||
const HEART_BREATH_HOLD_MS = 2_000;
|
||
const HEART_BREATH_EXHALE_MS = 4_000;
|
||
const HEART_BREATH_PREPARE_MS = 1_000;
|
||
const HEART_BREATH_CYCLE_MS = HEART_BREATH_INHALE_MS + HEART_BREATH_HOLD_MS + HEART_BREATH_EXHALE_MS;
|
||
const HEART_BREATH_ACTIVE_MS = HEART_BREATH_CYCLE_MS * 5;
|
||
const HEART_BREATH_TOTAL_MS = HEART_BREATH_PREPARE_MS + HEART_BREATH_ACTIVE_MS;
|
||
const THEME_STORAGE_KEY = "xiaobaiTheme";
|
||
let activeThemeTransition = null;
|
||
let themeSwitchSequence = 0;
|
||
|
||
const state = window.XiaobaiState.create({
|
||
session: {
|
||
user: null,
|
||
csrfToken: "",
|
||
authMode: "login",
|
||
started: false,
|
||
activeView: "sentimentCycleView",
|
||
dashboardLoading: false,
|
||
dashboardRequestSequence: 0,
|
||
dashboardRequestDate: "",
|
||
adminModels: [],
|
||
globalSearchResults: [],
|
||
globalSearchActiveIndex: -1,
|
||
globalSearchRequestSequence: 0,
|
||
},
|
||
market: {
|
||
dashboard: null,
|
||
filter: "all",
|
||
query: "",
|
||
sortKey: "streak",
|
||
sortDirection: "desc",
|
||
brokenQuery: "",
|
||
brokenSortKey: "",
|
||
brokenSortDirection: "desc",
|
||
downQuery: "",
|
||
downSortKey: "",
|
||
downSortDirection: "asc",
|
||
yesterdayFilter: "all",
|
||
yesterdayQuery: "",
|
||
yesterdaySortKey: "",
|
||
yesterdaySortDirection: "desc",
|
||
dragonTiger: null,
|
||
dragonViewMode: "daily",
|
||
dragonFilter: "all",
|
||
dragonQuery: "",
|
||
selectedDragonTraderId: "",
|
||
hotMoneyProfiles: null,
|
||
hotMoneyProfileQuery: "",
|
||
selectedHotMoneyProfileId: "",
|
||
rotationHistory: null,
|
||
rotationHistoryKey: "",
|
||
rotationSelectedSector: "",
|
||
rotationSelectedDate: "",
|
||
rotationMembers: null,
|
||
rotationMembersKey: "",
|
||
rotationMembersLoading: false,
|
||
rotationOrder: localStorage.getItem("xiaobaiRotationOrder") === "latest" ? "latest" : "oldest",
|
||
rotationLoading: false,
|
||
auctionData: null,
|
||
auctionDataset: "focus",
|
||
auctionFilter: "all",
|
||
auctionQuery: "",
|
||
auctionSortKey: "attention_score",
|
||
auctionSortDirection: "desc",
|
||
auctionLoading: false,
|
||
auctionTimer: null,
|
||
themeLibrary: null,
|
||
themeQuery: "",
|
||
selectedThemeCode: "",
|
||
themeDetail: null,
|
||
themeLoading: false,
|
||
popularityData: null,
|
||
popularitySource: "combined",
|
||
popularityQuery: "",
|
||
popularityLoading: false,
|
||
expandedLadderLevels: new Set(),
|
||
ladderSortMode: "time",
|
||
sentimentHistory: null,
|
||
sentimentRange: 20,
|
||
sentimentHistoryKey: "",
|
||
sentimentLoading: false,
|
||
},
|
||
details: {
|
||
stockDetail: null,
|
||
activeStock: null,
|
||
stockDetailChartMode: "daily",
|
||
stockDetailIntraday: null,
|
||
stockDetailRequestSequence: 0,
|
||
entityDetailItem: null,
|
||
entityDetailPayload: null,
|
||
entityDetailChartMode: "daily",
|
||
entityDetailIntraday: null,
|
||
entityDetailRequestSequence: 0,
|
||
stockPreviewCode: "",
|
||
stockPreviewType: "stock",
|
||
stockPreviewItem: null,
|
||
stockPreviewPayload: null,
|
||
stockPreviewChart: "daily",
|
||
stockPreviewFallback: null,
|
||
initialStockOpened: false,
|
||
},
|
||
review: {
|
||
watchlist: [],
|
||
watchlistSelection: null,
|
||
watchlistSearchResults: [],
|
||
watchlistSearchRequestSequence: 0,
|
||
editingDailyNoteId: 0,
|
||
notes: [],
|
||
tradeEntries: [],
|
||
tradeSummary: {},
|
||
editingTradeId: 0,
|
||
alerts: [],
|
||
alertFilter: "all",
|
||
alertUnreadCount: 0,
|
||
assistantMessages: [],
|
||
assistantLoading: false,
|
||
assistantController: null,
|
||
},
|
||
screener: {
|
||
screenerSetup: null,
|
||
screenerSetupKey: "",
|
||
screenerSetupRequestKey: "",
|
||
screenerSetupPromise: null,
|
||
selectedRegime: "",
|
||
selectedStrategy: null,
|
||
customStrategyDraft: null,
|
||
screenerRunning: false,
|
||
screenerRunningMode: "",
|
||
screenerResults: { smart: null, curated: null, quant: null },
|
||
screenerResultContexts: { smart: null, curated: null, quant: null },
|
||
screenerResultStore: {},
|
||
screenerTracking: null,
|
||
screenerMode: ["smart", "curated", "quant"].includes(localStorage.getItem("xiaobaiScreenerMode"))
|
||
? localStorage.getItem("xiaobaiScreenerMode")
|
||
: "smart",
|
||
curatedCategory: "全部",
|
||
curatedSchool: "全部",
|
||
curatedQuery: "",
|
||
curatedViewMode: localStorage.getItem("xiaobaiCuratedViewMode") === "grid" ? "grid" : "list",
|
||
selectedCuratedStrategyId: 0,
|
||
quantFilters: [],
|
||
quantScores: [],
|
||
screenerMobileView: "strategy",
|
||
},
|
||
mentor: {
|
||
mentorSetup: null,
|
||
selectedMentorId: "",
|
||
mentorMessages: [],
|
||
mentorLoading: false,
|
||
mentorQuery: "",
|
||
mentorGrade: "all",
|
||
mentorDirectoryOpen: false,
|
||
mentorSortMode: false,
|
||
mentorSavingPreferences: false,
|
||
mentorController: null,
|
||
},
|
||
heaven: {
|
||
heavenSetup: null,
|
||
heavenManualData: null,
|
||
personalField: null,
|
||
heavenPanel: "trend",
|
||
heavenInterpretations: { trend: "", fortune: "", heart: "" },
|
||
heavenReadingMode: "trend",
|
||
heavenReadingTab: "current",
|
||
heavenReadingHistory: { trend: [], fortune: [], heart: [] },
|
||
heavenReadingSelectedId: 0,
|
||
heavenReadingLoading: false,
|
||
heavenReadingError: "",
|
||
heartStage: "intro",
|
||
heartTimer: null,
|
||
heartSeconds: HEART_BREATH_TOTAL_MS / 1000,
|
||
heartBreathingEndsAt: 0,
|
||
heartLines: [],
|
||
heartThrows: [],
|
||
heartHexagram: null,
|
||
heartCurtainTimer: null,
|
||
heartStageToken: 0,
|
||
heartRevealToken: 0,
|
||
heavenPerformanceKey: "",
|
||
heavenPerformancePanels: new Set(),
|
||
heavenPerformanceActive: "",
|
||
heavenRequestSequence: 0,
|
||
},
|
||
});
|
||
|
||
window.XiaobaiAPI.configure({
|
||
csrfToken: () => state.csrfToken,
|
||
onUnauthorized: () => showAuthGate("登录状态已失效,请重新登录。"),
|
||
});
|
||
|
||
const applicationShell = window.XiaobaiShell.create({
|
||
state,
|
||
pages: window.XiaobaiPages,
|
||
motionEnabled,
|
||
animateRows,
|
||
refreshIcons,
|
||
tradeDate: () => displayCompactDate(
|
||
state.dashboard?.meta?.trade_date || document.querySelector("#tradeDate")?.value || "",
|
||
),
|
||
onNavigate: (viewId) => openView(viewId),
|
||
onNavigationSync: () => toggleAccountDropdown(false),
|
||
});
|
||
|
||
const elements = {
|
||
tradeDate: document.querySelector("#tradeDate"),
|
||
loading: document.querySelector("#loadingOverlay"),
|
||
toast: document.querySelector("#toast"),
|
||
stockDialog: document.querySelector("#stockDialog"),
|
||
tradeLogDialog: document.querySelector("#tradeLogDialog"),
|
||
watchlistDialog: document.querySelector("#watchlistDialog"),
|
||
alertsDialog: document.querySelector("#alertsDialog"),
|
||
assistantDialog: document.querySelector("#assistantDialog"),
|
||
heavenReadingDialog: document.querySelector("#heavenReadingDialog"),
|
||
globalSearchDialog: document.querySelector("#globalSearchDialog"),
|
||
globalSearchInput: document.querySelector("#globalSearchInput"),
|
||
globalSearchResults: document.querySelector("#globalSearchResults"),
|
||
entityDetailDialog: document.querySelector("#entityDetailDialog"),
|
||
entityDetailChart: document.querySelector("#entityDetailChart"),
|
||
settingsDialog: document.querySelector("#settingsDialog"),
|
||
adminDialog: document.querySelector("#adminDialog"),
|
||
priceChart: document.querySelector("#priceChart"),
|
||
stockPreview: document.querySelector("#stockPreview"),
|
||
stockPreviewBackdrop: document.querySelector("#stockPreviewBackdrop"),
|
||
stockPreviewChart: document.querySelector("#stockPreviewChart"),
|
||
};
|
||
|
||
function openModalDialog(dialog) {
|
||
applicationShell.openModalDialog(dialog);
|
||
}
|
||
|
||
const metricAnimationFrames = new WeakMap();
|
||
const stockPreviewCache = new Map();
|
||
const STOCK_PREVIEW_DELAY = 380;
|
||
const STOCK_PREVIEW_CACHE_MS = 5 * 60 * 1000;
|
||
const LIVE_REFRESH_DEFAULT_MS = 10 * 1000;
|
||
let qiFieldAnimationFrame = 0;
|
||
let qiFieldSoloElement = "";
|
||
let heavenPerformanceToken = 0;
|
||
let heavenReadingAnimation = null;
|
||
let heartHoldTimer = null;
|
||
let heartHoldTriggered = false;
|
||
let heartHoldStartedAt = 0;
|
||
let heartHoldAnimationFrame = 0;
|
||
let heartCastingBusy = false;
|
||
let heartDustAnimationFrame = 0;
|
||
let heartDustParticles = [];
|
||
let heartIncenseAnimation = null;
|
||
const heartCoinRotations = [0, 0, 0];
|
||
let rowAnimationObserver = null;
|
||
let stockPreviewOpenTimer = null;
|
||
let stockPreviewCloseTimer = null;
|
||
let stockPreviewAbortController = null;
|
||
let stockPreviewAnchor = null;
|
||
let sentimentChartAnimationFrame = null;
|
||
let heavenResizeTimer = null;
|
||
let globalSearchTimer = null;
|
||
let watchlistSearchTimer = null;
|
||
let assistantRenderFrame = 0;
|
||
|
||
const heartSound = {
|
||
enabled: false,
|
||
context: null,
|
||
ensure() {
|
||
if (!this.context) {
|
||
const AudioContextClass = window.AudioContext || window.webkitAudioContext;
|
||
if (!AudioContextClass) return null;
|
||
this.context = new AudioContextClass();
|
||
}
|
||
if (this.context.state === "suspended") this.context.resume();
|
||
return this.context;
|
||
},
|
||
tone(frequency, duration, gain, type = "sine", delay = 0) {
|
||
if (!this.enabled) return;
|
||
const context = this.ensure();
|
||
if (!context) return;
|
||
const start = context.currentTime + delay;
|
||
const oscillator = context.createOscillator();
|
||
const volume = context.createGain();
|
||
oscillator.type = type;
|
||
oscillator.frequency.value = frequency;
|
||
volume.gain.setValueAtTime(0.0001, start);
|
||
volume.gain.linearRampToValueAtTime(gain, start + 0.015);
|
||
volume.gain.exponentialRampToValueAtTime(0.0001, start + duration);
|
||
oscillator.connect(volume).connect(context.destination);
|
||
oscillator.start(start);
|
||
oscillator.stop(start + duration + 0.05);
|
||
},
|
||
chime(frequency = 640) {
|
||
this.tone(frequency, 4.8, 0.12);
|
||
this.tone(frequency * 2.02, 3.6, 0.045);
|
||
this.tone(frequency * 3.96, 2.2, 0.018);
|
||
},
|
||
coin(delay = 0) {
|
||
this.tone(2350 + Math.random() * 260, 0.28, 0.055, "triangle", delay);
|
||
this.tone(3250 + Math.random() * 260, 0.18, 0.025, "triangle", delay + 0.01);
|
||
},
|
||
};
|
||
|
||
const HEART_WHISPERS = [
|
||
["应无所住,而生其心", 10, 12, 0],
|
||
["不是风动,不是幡动,仁者心动", 89, 8, 1],
|
||
["菩提本无树,明镜亦非台", 16, 52, 2],
|
||
["本来无一物,何处惹尘埃", 84, 54, 3],
|
||
["心外无物,心外无理", 22, 18, 4],
|
||
["知行合一", 78, 30, 5],
|
||
["此心光明,亦复何言", 90, 60, 6],
|
||
];
|
||
|
||
window.addEventListener("resize", () => {
|
||
clearTimeout(heavenResizeTimer);
|
||
heavenResizeTimer = setTimeout(() => {
|
||
if (state.activeView !== "heavenView") return;
|
||
if (state.heavenPanel === "fortune" && state.heavenSetup?.field) {
|
||
renderQiFieldCanvas(state.heavenSetup.field.balance || [], { intro: false });
|
||
drawQiUseConnections(false);
|
||
}
|
||
if (state.heavenPanel === "heart") startHeartDust();
|
||
}, 120);
|
||
});
|
||
|
||
document.addEventListener("DOMContentLoaded", initialize);
|
||
|
||
function syncThemeControl() {
|
||
const theme = document.documentElement.dataset.theme === "dark" ? "dark" : "light";
|
||
const button = document.querySelector("#themeToggle");
|
||
if (!button) return;
|
||
const dark = theme === "dark";
|
||
const label = dark ? "切换到日间模式" : "切换到夜间模式";
|
||
button.title = label;
|
||
button.setAttribute("aria-label", label);
|
||
button.setAttribute("aria-pressed", String(dark));
|
||
button.querySelector("i")?.setAttribute("data-lucide", dark ? "sun" : "moon");
|
||
}
|
||
|
||
function clearThemeTransitionEffects() {
|
||
document.querySelectorAll(".row-enter, .row-pending, .view-entering").forEach((element) => {
|
||
element.classList.remove("row-enter", "row-pending", "view-entering");
|
||
element.style.removeProperty("--row-delay");
|
||
});
|
||
}
|
||
|
||
function redrawThemeSensitiveVisuals() {
|
||
if (!elements.stockPreview.hidden && state.stockPreviewPayload) {
|
||
selectStockPreviewChart(state.stockPreviewChart);
|
||
}
|
||
if (elements.stockDialog.open) {
|
||
if (state.stockDetailChartMode === "intraday" && state.stockDetailIntraday?.points?.length) {
|
||
drawIntradayCanvas(
|
||
elements.priceChart,
|
||
state.stockDetailIntraday.points,
|
||
[],
|
||
state.stockDetailIntraday.meta?.previous_close,
|
||
);
|
||
} else if (state.stockDetail?.prices) drawPriceChart(state.stockDetail.prices);
|
||
}
|
||
if (elements.entityDetailDialog.open) {
|
||
if (state.entityDetailChartMode === "intraday" && state.entityDetailIntraday?.points?.length) {
|
||
drawIntradayCanvas(
|
||
elements.entityDetailChart,
|
||
state.entityDetailIntraday.points,
|
||
[],
|
||
state.entityDetailIntraday.meta?.previous_close,
|
||
);
|
||
} else if (state.entityDetailPayload?.series) {
|
||
drawEntityDetailChart(state.entityDetailPayload.series);
|
||
}
|
||
}
|
||
if (state.activeView === "sentimentCycleView" && state.sentimentHistory) {
|
||
drawSentimentTrendChart(state.sentimentHistory.rows || []);
|
||
}
|
||
if (state.activeView === "heavenView") {
|
||
if (state.heavenPanel === "fortune" && state.heavenSetup?.field) {
|
||
renderQiFieldCanvas(state.heavenSetup.field.balance || [], { intro: false });
|
||
drawQiUseConnections(false);
|
||
}
|
||
if (state.heavenPanel === "heart") startHeartDust();
|
||
}
|
||
}
|
||
|
||
function commitTheme(normalized, persist) {
|
||
document.documentElement.dataset.theme = normalized;
|
||
document.documentElement.style.colorScheme = normalized;
|
||
if (persist) {
|
||
try {
|
||
localStorage.setItem(THEME_STORAGE_KEY, normalized);
|
||
} catch (_error) {
|
||
// The selected theme still applies for the current page when storage is unavailable.
|
||
}
|
||
}
|
||
syncThemeControl();
|
||
refreshIcons();
|
||
redrawThemeSensitiveVisuals();
|
||
}
|
||
|
||
function applyTheme(theme, persist = true) {
|
||
const normalized = theme === "dark" ? "dark" : "light";
|
||
const root = document.documentElement;
|
||
if (root.dataset.theme === normalized) {
|
||
commitTheme(normalized, persist);
|
||
return;
|
||
}
|
||
const sequence = ++themeSwitchSequence;
|
||
activeThemeTransition?.skipTransition?.();
|
||
clearThemeTransitionEffects();
|
||
root.classList.add("theme-switching");
|
||
|
||
const update = () => commitTheme(normalized, persist);
|
||
const finish = () => {
|
||
if (sequence !== themeSwitchSequence) return;
|
||
clearThemeTransitionEffects();
|
||
root.classList.remove("theme-switching");
|
||
activeThemeTransition = null;
|
||
};
|
||
const reducedMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
|
||
if (!reducedMotion && typeof document.startViewTransition === "function") {
|
||
activeThemeTransition = document.startViewTransition(update);
|
||
activeThemeTransition.finished.then(finish, finish);
|
||
return;
|
||
}
|
||
update();
|
||
requestAnimationFrame(() => requestAnimationFrame(finish));
|
||
}
|
||
|
||
function toggleTheme() {
|
||
applyTheme(document.documentElement.dataset.theme === "dark" ? "light" : "dark");
|
||
}
|
||
|
||
async function initialize() {
|
||
syncThemeControl();
|
||
refreshIcons();
|
||
applicationShell.initialize();
|
||
elements.tradeDate.value = todayString();
|
||
const initialUrl = new URL(window.location.href);
|
||
if (initialUrl.searchParams.has("date")) {
|
||
initialUrl.searchParams.delete("date");
|
||
history.replaceState(null, "", initialUrl);
|
||
}
|
||
elements.tradeDate.max = todayString();
|
||
document.querySelector("#journalDate").value = elements.tradeDate.value;
|
||
document.querySelector("#journalDate").max = todayString();
|
||
document.querySelector("#tradeLogDate").value = elements.tradeDate.value;
|
||
document.querySelector("#tradeLogDate").max = todayString();
|
||
document.querySelector("#backfillStart").value = todayString();
|
||
document.querySelector("#backfillEnd").value = todayString();
|
||
document.querySelector("#backfillStart").max = todayString();
|
||
document.querySelector("#backfillEnd").max = todayString();
|
||
document.querySelector("#qiObservationDate").value = elements.tradeDate.value;
|
||
document.querySelector("#qiObservationDate").max = todayString();
|
||
document.querySelector("#accountBirthDate").max = todayString();
|
||
document.querySelector("#alertDate").value = todayString();
|
||
bindEvents();
|
||
try {
|
||
const session = await apiRequest("/api/auth/me");
|
||
if (!session.authenticated) {
|
||
if (session.registration_required) selectAuthMode("register");
|
||
showAuthGate();
|
||
return;
|
||
}
|
||
await applyAuthenticatedSession(session);
|
||
} catch (error) {
|
||
showAuthGate(error.message || "无法连接本地服务");
|
||
}
|
||
}
|
||
|
||
async function startAuthenticatedApp() {
|
||
if (state.started) return;
|
||
state.started = true;
|
||
const searchParams = new URLSearchParams(window.location.search);
|
||
const requestedHeavenPanel = searchParams.get("heaven");
|
||
if (["trend", "fortune", "heart"].includes(requestedHeavenPanel)) {
|
||
state.heavenPanel = requestedHeavenPanel;
|
||
}
|
||
const requestedView = window.XiaobaiPages.resolve(searchParams.get("view"));
|
||
if (
|
||
requestedView
|
||
&& window.XiaobaiPages.has(requestedView)
|
||
&& document.getElementById(requestedView)?.classList.contains("workspace-view")
|
||
) {
|
||
openView(requestedView, false);
|
||
if (requestedView !== searchParams.get("view")) {
|
||
const url = new URL(window.location.href);
|
||
url.searchParams.set("view", requestedView);
|
||
history.replaceState(null, "", url);
|
||
}
|
||
}
|
||
loadDashboard();
|
||
loadAlerts();
|
||
if (new URLSearchParams(window.location.search).get("settings") === "1") {
|
||
setTimeout(openSettings, 0);
|
||
}
|
||
}
|
||
|
||
function selectAuthMode(mode) {
|
||
state.authMode = mode === "register" ? "register" : "login";
|
||
document.querySelectorAll("[data-auth-mode]").forEach((button) => {
|
||
button.classList.toggle("active", button.dataset.authMode === state.authMode);
|
||
});
|
||
const registering = state.authMode === "register";
|
||
document.querySelector("#authConfirmField").hidden = !registering;
|
||
document.querySelector("#authPasswordConfirm").required = registering;
|
||
document.querySelector("#authPassword").autocomplete = registering ? "new-password" : "current-password";
|
||
document.querySelector("#authSubmitButton").textContent = registering ? "注册并进入" : "登录";
|
||
document.querySelector("#authError").hidden = true;
|
||
}
|
||
|
||
async function submitAuthForm(event) {
|
||
event.preventDefault();
|
||
const username = document.querySelector("#authUsername").value.trim();
|
||
const password = document.querySelector("#authPassword").value;
|
||
const errorElement = document.querySelector("#authError");
|
||
if (state.authMode === "register" && password !== document.querySelector("#authPasswordConfirm").value) {
|
||
errorElement.textContent = "两次输入的密码不一致。";
|
||
errorElement.hidden = false;
|
||
return;
|
||
}
|
||
const button = document.querySelector("#authSubmitButton");
|
||
button.disabled = true;
|
||
try {
|
||
const session = await apiRequest(`/api/auth/${state.authMode}`, "POST", { username, password });
|
||
document.querySelector("#authForm").reset();
|
||
await applyAuthenticatedSession(session);
|
||
} catch (error) {
|
||
errorElement.textContent = error.message || "账号操作失败";
|
||
errorElement.hidden = false;
|
||
} finally {
|
||
button.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function applyAuthenticatedSession(session) {
|
||
state.user = session.user;
|
||
state.csrfToken = session.csrf_token || "";
|
||
setText("accountName", session.user?.username || "账号");
|
||
const isAdmin = session.user?.role === "admin";
|
||
updateAccountIdentityBadges(session.user?.membership || {});
|
||
document.querySelector("#settingsButton").hidden = !isAdmin;
|
||
document.querySelector("#syncButton").hidden = !isAdmin;
|
||
document.querySelector("#reasonForm").hidden = !isAdmin;
|
||
document.querySelector("#sectorPhaseManager").hidden = !isAdmin;
|
||
document.querySelector("#authGate").hidden = true;
|
||
applyMembershipAccess();
|
||
await startAuthenticatedApp();
|
||
}
|
||
|
||
function showAuthGate(message = "") {
|
||
state.user = null;
|
||
state.csrfToken = "";
|
||
const gate = document.querySelector("#authGate");
|
||
gate.hidden = false;
|
||
const errorElement = document.querySelector("#authError");
|
||
errorElement.textContent = message;
|
||
errorElement.hidden = !message;
|
||
document.querySelector("#authUsername").focus();
|
||
}
|
||
|
||
async function logoutAccount() {
|
||
toggleAccountDropdown(false);
|
||
try {
|
||
await apiRequest("/api/auth/logout", "POST", {});
|
||
} catch (error) {
|
||
showToast(error.message || "退出失败");
|
||
return;
|
||
}
|
||
window.location.reload();
|
||
}
|
||
|
||
function bindEvents() {
|
||
document.querySelectorAll("[data-auth-mode]").forEach((button) => {
|
||
button.addEventListener("click", () => selectAuthMode(button.dataset.authMode));
|
||
});
|
||
document.querySelector("#authForm").addEventListener("submit", submitAuthForm);
|
||
document.querySelector("#refreshButton").addEventListener("click", async (event) => {
|
||
const button = event.currentTarget;
|
||
button.disabled = true;
|
||
try {
|
||
await loadDashboard(false, false, false);
|
||
} finally {
|
||
button.disabled = false;
|
||
}
|
||
});
|
||
document.querySelector("#syncButton").addEventListener("click", startAdminRefresh);
|
||
elements.tradeDate.addEventListener("change", () => {
|
||
state.dashboardRequestSequence += 1;
|
||
state.heavenRequestSequence += 1;
|
||
state.heavenManualData = null;
|
||
document.querySelector("#qiObservationDate").value = elements.tradeDate.value;
|
||
loadDashboard();
|
||
});
|
||
document.querySelector("#prevDate").addEventListener("click", () => shiftDate(-1));
|
||
document.querySelector("#nextDate").addEventListener("click", () => shiftDate(1));
|
||
document.querySelector("#stockSearch").addEventListener("input", (event) => {
|
||
state.query = event.target.value.trim().toLowerCase();
|
||
renderLimitTable();
|
||
});
|
||
document.querySelectorAll("[data-table-search]").forEach((input) => {
|
||
input.addEventListener("input", () => {
|
||
const query = input.value.trim().toLowerCase();
|
||
const body = document.querySelector(`#${CSS.escape(input.dataset.tableSearch)}`);
|
||
body?.querySelectorAll("tr").forEach((row) => {
|
||
row.hidden = Boolean(query) && !row.textContent.toLowerCase().includes(query);
|
||
});
|
||
});
|
||
});
|
||
|
||
document.querySelectorAll("[data-filter]").forEach((button) => {
|
||
button.addEventListener("click", () => {
|
||
document.querySelectorAll("[data-filter]").forEach((item) => item.classList.remove("active"));
|
||
button.classList.add("active");
|
||
state.filter = button.dataset.filter;
|
||
renderLimitTable();
|
||
});
|
||
});
|
||
|
||
document.querySelector("#globalSearchButton").addEventListener("click", openGlobalSearch);
|
||
document.querySelector("#themeToggle").addEventListener("click", toggleTheme);
|
||
document.querySelector("#alertButton").addEventListener("click", openAlerts);
|
||
document.querySelector("#assistantButton").addEventListener("click", openReviewAssistant);
|
||
document.querySelector("#closeAssistantDialog").addEventListener("click", () => elements.assistantDialog.close());
|
||
document.querySelector("#assistantForm").addEventListener("submit", sendAssistantQuestion);
|
||
document.querySelector("#stopAssistant").addEventListener("click", stopAssistantResponse);
|
||
document.querySelector("#clearAssistantMessages").addEventListener("click", clearAssistantConversation);
|
||
document.querySelectorAll("[data-assistant-prompt]").forEach((button) => {
|
||
button.addEventListener("click", () => useAssistantPrompt(button.dataset.assistantPrompt));
|
||
});
|
||
document.querySelector("#closeAlertsDialog").addEventListener("click", () => elements.alertsDialog.close());
|
||
document.querySelector("#alertForm").addEventListener("submit", saveAlert);
|
||
document.querySelector("#markAllAlertsRead").addEventListener("click", markAllAlertsRead);
|
||
document.querySelector("#alertList").addEventListener("click", handleAlertAction);
|
||
document.querySelectorAll("[data-alert-filter]").forEach((button) => {
|
||
button.addEventListener("click", () => selectAlertFilter(button.dataset.alertFilter));
|
||
});
|
||
document.querySelector("#closeGlobalSearch").addEventListener("click", closeGlobalSearch);
|
||
document.querySelector("#closeEntityDetail").addEventListener("click", () => elements.entityDetailDialog.close());
|
||
document.querySelectorAll("[data-entity-detail-chart]").forEach((button) => {
|
||
button.addEventListener("click", () => selectEntityDetailChart(button.dataset.entityDetailChart));
|
||
});
|
||
elements.globalSearchDialog.addEventListener("click", (event) => {
|
||
if (event.target === elements.globalSearchDialog) closeGlobalSearch();
|
||
});
|
||
elements.globalSearchInput.addEventListener("input", scheduleGlobalSearch);
|
||
elements.globalSearchInput.addEventListener("keydown", handleGlobalSearchInputKeydown);
|
||
elements.globalSearchResults.addEventListener("click", (event) => {
|
||
const result = event.target.closest("[data-search-result-index]");
|
||
if (result) openGlobalSearchResult(number(result.dataset.searchResultIndex));
|
||
});
|
||
document.addEventListener("click", (event) => {
|
||
if (!event.target.closest(".account-menu-shell")) toggleAccountDropdown(false);
|
||
});
|
||
window.addEventListener("keydown", handleGlobalSearchShortcut);
|
||
document.addEventListener("keydown", (event) => {
|
||
if (event.key === "Escape") {
|
||
toggleAccountDropdown(false, true);
|
||
toggleMentorDirectory(false);
|
||
}
|
||
handleAccountMenuKeydown(event);
|
||
});
|
||
window.addEventListener("resize", () => {
|
||
if (window.innerWidth > 720) toggleMentorDirectory(false);
|
||
if (!elements.stockPreview.hidden) closeStockPreview();
|
||
if (state.activeView === "dragonView") layoutDragonCards();
|
||
});
|
||
document.querySelectorAll("[data-open-account]").forEach((button) => {
|
||
button.addEventListener("click", () => openSettings("membership"));
|
||
});
|
||
document.querySelectorAll("#limitTable th[data-sort]").forEach((header) => {
|
||
header.addEventListener("click", () => changeSort(header.dataset.sort));
|
||
});
|
||
document.querySelector("#brokenSearch").addEventListener("input", (event) => {
|
||
state.brokenQuery = event.target.value.trim().toLowerCase();
|
||
renderBrokenTable(state.dashboard?.broken || []);
|
||
});
|
||
document.querySelectorAll("#brokenTable th[data-broken-sort]").forEach((header) => {
|
||
header.addEventListener("click", () => changeBrokenSort(header.dataset.brokenSort));
|
||
});
|
||
document.querySelector("#downSearch").addEventListener("input", (event) => {
|
||
state.downQuery = event.target.value.trim().toLowerCase();
|
||
renderDownTable(state.dashboard?.down_limits || []);
|
||
});
|
||
document.querySelectorAll("#downTable th[data-down-sort]").forEach((header) => {
|
||
header.addEventListener("click", () => changeDownSort(header.dataset.downSort));
|
||
});
|
||
document.querySelector("#yesterdaySearch").addEventListener("input", (event) => {
|
||
state.yesterdayQuery = event.target.value.trim().toLowerCase();
|
||
renderYesterdayTable(state.dashboard?.yesterday_limits || []);
|
||
});
|
||
document.querySelectorAll("[data-yesterday-filter]").forEach((button) => {
|
||
button.addEventListener("click", () => {
|
||
state.yesterdayFilter = button.dataset.yesterdayFilter;
|
||
renderYesterdayTable(state.dashboard?.yesterday_limits || []);
|
||
});
|
||
});
|
||
document.querySelectorAll("#yesterdayTable th[data-yesterday-sort]").forEach((header) => {
|
||
header.addEventListener("click", () => changeYesterdaySort(header.dataset.yesterdaySort));
|
||
});
|
||
document.querySelectorAll("[data-ladder-sort]").forEach((button) => {
|
||
button.addEventListener("click", () => {
|
||
state.ladderSortMode = button.dataset.ladderSort === "open" ? "open" : "time";
|
||
document.querySelectorAll("[data-ladder-sort]").forEach((item) => {
|
||
const active = item === button;
|
||
item.classList.toggle("active", active);
|
||
item.setAttribute("aria-pressed", String(active));
|
||
});
|
||
renderLadderBoard(state.dashboard?.ladders || []);
|
||
});
|
||
});
|
||
|
||
document.querySelector("#exportButton").addEventListener("click", exportStocks);
|
||
document.querySelector("#brokenExportButton").addEventListener("click", exportBroken);
|
||
document.querySelector("#downExportButton").addEventListener("click", exportDown);
|
||
document.querySelector("#yesterdayExportButton").addEventListener("click", exportYesterday);
|
||
document.querySelector("#ladderExportButton").addEventListener("click", exportLadder);
|
||
document.querySelector("#rotationExportButton").addEventListener("click", exportRotation);
|
||
document.querySelectorAll("[data-rotation-order]").forEach((button) => {
|
||
button.addEventListener("click", () => {
|
||
state.rotationOrder = button.dataset.rotationOrder === "latest" ? "latest" : "oldest";
|
||
localStorage.setItem("xiaobaiRotationOrder", state.rotationOrder);
|
||
renderRotationHistory();
|
||
});
|
||
});
|
||
document.querySelector("#sentimentExportButton").addEventListener("click", exportSentimentHistory);
|
||
document.querySelectorAll("[data-sentiment-range]").forEach((button) => {
|
||
button.addEventListener("click", () => {
|
||
state.sentimentRange = number(button.dataset.sentimentRange) || 20;
|
||
document.querySelectorAll("[data-sentiment-range]").forEach((item) => {
|
||
item.classList.toggle("active", item === button);
|
||
});
|
||
loadSentimentHistory(true);
|
||
});
|
||
});
|
||
document.querySelector("#settingsButton").addEventListener("click", () => openAdminSettings());
|
||
document.querySelector("#accountButton").addEventListener("click", (event) => {
|
||
event.stopPropagation();
|
||
toggleAccountDropdown();
|
||
});
|
||
document.querySelector("#accountVipBadge").addEventListener("click", () => openSettings("membership"));
|
||
document.querySelectorAll("[data-account-panel]").forEach((button) => {
|
||
button.addEventListener("click", () => openSettings(button.dataset.accountPanel));
|
||
});
|
||
document.querySelector("#switchAccountMenuButton").addEventListener("click", switchAccount);
|
||
document.querySelector("#logoutMenuButton").addEventListener("click", logoutAccount);
|
||
document.querySelector("#closeSettingsDialog").addEventListener("click", () => elements.settingsDialog.close());
|
||
document.querySelector("#closeAdminDialog").addEventListener("click", () => elements.adminDialog.close());
|
||
document.querySelector("#closeStockDialog").addEventListener("click", () => elements.stockDialog.close());
|
||
document.querySelectorAll("[data-stock-detail-chart]").forEach((button) => {
|
||
button.addEventListener("click", () => selectStockDetailChart(button.dataset.stockDetailChart));
|
||
});
|
||
document.querySelector("#closeStockPreview").addEventListener("click", closeStockPreview);
|
||
elements.stockPreviewBackdrop.addEventListener("click", closeStockPreview);
|
||
document.querySelector("#openStockDetailFromPreview").addEventListener("click", openStockDetailFromPreview);
|
||
document.querySelectorAll("[data-preview-chart]").forEach((button) => {
|
||
button.addEventListener("click", () => selectStockPreviewChart(button.dataset.previewChart));
|
||
});
|
||
elements.stockPreview.addEventListener("pointerenter", cancelStockPreviewClose);
|
||
elements.stockPreview.addEventListener("pointerleave", scheduleStockPreviewClose);
|
||
document.addEventListener("pointerover", handleStockPreviewPointerOver);
|
||
document.addEventListener("pointerout", handleStockPreviewPointerOut);
|
||
document.addEventListener("focusin", handleStockPreviewFocus);
|
||
document.addEventListener("focusout", handleStockPreviewFocusOut);
|
||
document.addEventListener("click", handleMobileStockPreviewClick, true);
|
||
document.addEventListener("keydown", handleStockPreviewKeydown);
|
||
document.addEventListener("scroll", repositionStockPreview, true);
|
||
document.querySelector("#auctionRefreshButton").addEventListener("click", () => loadAuctionCenter(true));
|
||
document.querySelector("#auctionExportButton").addEventListener("click", exportAuctionRows);
|
||
document.querySelector("#auctionSearch").addEventListener("input", (event) => {
|
||
state.auctionQuery = event.target.value.trim().toLocaleLowerCase("zh-CN");
|
||
renderAuctionTable();
|
||
});
|
||
document.querySelectorAll("[data-auction-dataset]").forEach((button) => {
|
||
button.addEventListener("click", () => {
|
||
state.auctionDataset = button.dataset.auctionDataset || "focus";
|
||
state.auctionFilter = "all";
|
||
state.auctionSortKey = state.auctionDataset === "onePrice" ? "amount_million" : "attention_score";
|
||
state.auctionSortDirection = "desc";
|
||
document.querySelectorAll("[data-auction-dataset]").forEach((item) => {
|
||
const active = item === button;
|
||
item.classList.toggle("active", active);
|
||
item.setAttribute("aria-selected", String(active));
|
||
});
|
||
document.querySelectorAll("[data-auction-filter]").forEach((item) => item.classList.toggle("active", item.dataset.auctionFilter === "all"));
|
||
renderAuctionTable();
|
||
});
|
||
});
|
||
document.querySelectorAll("[data-auction-filter]").forEach((button) => {
|
||
button.addEventListener("click", () => {
|
||
state.auctionFilter = button.dataset.auctionFilter || "all";
|
||
document.querySelectorAll("[data-auction-filter]").forEach((item) => item.classList.toggle("active", item === button));
|
||
renderAuctionTable();
|
||
});
|
||
});
|
||
document.querySelector("#auctionTable").addEventListener("click", (event) => {
|
||
const header = event.target.closest("th[data-auction-sort]");
|
||
if (!header) return;
|
||
const key = header.dataset.auctionSort;
|
||
if (state.auctionSortKey === key) state.auctionSortDirection = state.auctionSortDirection === "asc" ? "desc" : "asc";
|
||
else {
|
||
state.auctionSortKey = key;
|
||
state.auctionSortDirection = "desc";
|
||
}
|
||
renderAuctionTable();
|
||
});
|
||
document.querySelector("#openStrategyDrawerButton").addEventListener("click", openCustomStrategyDrawer);
|
||
document.querySelector("#closeStrategyDrawerButton").addEventListener("click", () => document.querySelector("#strategyDrawer").close());
|
||
document.querySelector("#strategyDrawer").addEventListener("click", (event) => {
|
||
if (event.target === event.currentTarget) event.currentTarget.close();
|
||
});
|
||
document.querySelector("#themeRefreshButton").addEventListener("click", () => loadThemeLibrary(true));
|
||
document.querySelector("#themeSearch").addEventListener("input", (event) => {
|
||
state.themeQuery = event.target.value.trim().toLocaleLowerCase("zh-CN");
|
||
renderThemeDirectory();
|
||
});
|
||
document.querySelector("#themeDirectory").addEventListener("click", (event) => {
|
||
const button = event.target.closest("[data-theme-code]");
|
||
if (button) selectTheme(button.dataset.themeCode);
|
||
});
|
||
document.querySelector("#popularityRefreshButton").addEventListener("click", () => loadPopularity(true));
|
||
document.querySelector("#popularitySearch").addEventListener("input", (event) => {
|
||
state.popularityQuery = event.target.value.trim().toLocaleLowerCase("zh-CN");
|
||
renderPopularityTable();
|
||
});
|
||
document.querySelectorAll("[data-popularity-source]").forEach((button) => {
|
||
button.addEventListener("click", () => {
|
||
state.popularitySource = button.dataset.popularitySource || "combined";
|
||
document.querySelectorAll("[data-popularity-source]").forEach((item) => {
|
||
const active = item === button;
|
||
item.classList.toggle("active", active);
|
||
item.setAttribute("aria-selected", String(active));
|
||
});
|
||
renderPopularityTable();
|
||
});
|
||
});
|
||
document.querySelector("#dragonRefreshButton").addEventListener("click", () => {
|
||
if (state.dragonViewMode === "profiles") loadHotMoneyProfiles(true);
|
||
else loadDragonTiger(true);
|
||
});
|
||
document.querySelector("#dragonEmptyRefreshButton").addEventListener("click", () => loadDragonTiger(true));
|
||
document.querySelector("#dragonPreviousButton").addEventListener("click", () => shiftDate(-1));
|
||
document.querySelector("#dragonExportButton").addEventListener("click", () => {
|
||
if (state.dragonViewMode === "profiles") exportHotMoneyProfiles();
|
||
else exportDragonTiger();
|
||
});
|
||
document.querySelectorAll("[data-dragon-view-mode]").forEach((button) => {
|
||
button.addEventListener("click", () => selectDragonViewMode(button.dataset.dragonViewMode));
|
||
});
|
||
document.querySelector("#dragonSearch").addEventListener("input", (event) => {
|
||
state.dragonQuery = event.target.value.trim().toLowerCase();
|
||
renderDragonTraderList();
|
||
});
|
||
document.querySelectorAll("[data-dragon-filter]").forEach((button) => {
|
||
button.addEventListener("click", () => {
|
||
state.dragonFilter = button.dataset.dragonFilter;
|
||
document.querySelectorAll("[data-dragon-filter]").forEach((item) => {
|
||
item.classList.toggle("active", item === button);
|
||
});
|
||
renderDragonTraderList();
|
||
});
|
||
});
|
||
document.querySelector("#hotMoneyProfileSearch").addEventListener("input", (event) => {
|
||
state.hotMoneyProfileQuery = event.target.value.trim().toLocaleLowerCase("zh-CN");
|
||
renderHotMoneyProfiles();
|
||
});
|
||
document.querySelector("#hotMoneyProfileList").addEventListener("click", (event) => {
|
||
const button = event.target.closest("[data-hot-money-profile]");
|
||
if (!button) return;
|
||
state.selectedHotMoneyProfileId = button.dataset.hotMoneyProfile;
|
||
renderHotMoneyProfiles();
|
||
});
|
||
document.querySelector("#journalForm").addEventListener("submit", saveJournal);
|
||
document.querySelector("#journalDate").addEventListener("change", populateJournalForm);
|
||
document.querySelector("#openWatchlistDialog").addEventListener("click", () => openWatchlistDialog());
|
||
document.querySelector("#closeWatchlistDialog").addEventListener("click", closeWatchlistDialog);
|
||
document.querySelector("#cancelWatchlistEdit").addEventListener("click", closeWatchlistDialog);
|
||
document.querySelector("#changeWatchlistSelection").addEventListener("click", clearWatchlistSelection);
|
||
document.querySelector("#watchlistSearchInput").addEventListener("input", scheduleWatchlistSearch);
|
||
document.querySelector("#watchlistForm").addEventListener("submit", saveWatchlistFromDialog);
|
||
document.querySelector("#watchlistSearchResults").addEventListener("click", handleWatchlistSearchResult);
|
||
document.querySelector("#reviewHistoryToggle").addEventListener("click", (event) => {
|
||
const panel = document.querySelector("#reviewHistoryPanel");
|
||
const expanded = event.currentTarget.getAttribute("aria-expanded") === "true";
|
||
event.currentTarget.setAttribute("aria-expanded", String(!expanded));
|
||
event.currentTarget.querySelector("span").textContent = expanded ? "历史复盘" : "收起历史";
|
||
panel.hidden = expanded;
|
||
if (!expanded) panel.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
||
});
|
||
document.querySelector("#openTradeLogDialog").addEventListener("click", openTradeLogDialog);
|
||
document.querySelector("#closeTradeLogDialog").addEventListener("click", closeTradeLogDialog);
|
||
document.querySelector("#tradeLogForm").addEventListener("submit", saveTradeLog);
|
||
document.querySelector("#cancelTradeEdit").addEventListener("click", closeTradeLogDialog);
|
||
elements.tradeLogDialog.addEventListener("close", resetTradeLogForm);
|
||
document.querySelector("#tradeLogTableBody").addEventListener("click", handleTradeLogAction);
|
||
document.querySelector("#stockNoteForm").addEventListener("submit", saveStockNote);
|
||
document.querySelector("#watchStockButton").addEventListener("click", toggleActiveWatchlist);
|
||
document.querySelector("#stockHeavenButton").addEventListener("click", openActiveStockInHeaven);
|
||
document.querySelector("#stockReminderButton").addEventListener("click", openStockReminder);
|
||
document.querySelector("#reasonForm").addEventListener("submit", saveReasonOverride);
|
||
document.querySelector("#backfillButton").addEventListener("click", backfillData);
|
||
document.querySelector("#openScreenerTrackingButton").addEventListener("click", async () => {
|
||
await loadScreenerTracking(true);
|
||
openView("screenerTrackingView");
|
||
});
|
||
document.querySelector("#closeScreenerTrackingButton").addEventListener("click", () => openView("screenerView"));
|
||
document.querySelector("#refreshTrackingButton").addEventListener("click", refreshScreenerTracking);
|
||
document.querySelector("#trackingTableBody").addEventListener("click", handleTrackingTableAction);
|
||
document.querySelectorAll("[data-screener-mobile-view]").forEach((button) => {
|
||
button.addEventListener("click", () => selectScreenerMobileView(button.dataset.screenerMobileView));
|
||
});
|
||
document.querySelector("#compileStrategyButton").addEventListener("click", compileStrategy);
|
||
document.querySelector("#saveStrategyButton").addEventListener("click", saveCurrentStrategy);
|
||
document.querySelector("#deleteStrategyButton").addEventListener("click", deleteCurrentStrategy);
|
||
document.querySelector("#screenerExportButton").addEventListener("click", exportScreenerResults);
|
||
document.querySelector("#runBacktestToggle").addEventListener("change", updateBacktestTaskStatus);
|
||
document.querySelectorAll("[data-screener-mode]").forEach((button) => {
|
||
button.addEventListener("click", () => selectScreenerMode(button.dataset.screenerMode));
|
||
});
|
||
document.querySelector("#curatedStrategyList").addEventListener("click", (event) => {
|
||
if (event.target.closest("button")) return;
|
||
const card = event.target.closest("[data-curated-strategy]");
|
||
if (!card) return;
|
||
state.selectedCuratedStrategyId = number(card.dataset.curatedStrategy);
|
||
renderCuratedStrategyLibrary();
|
||
renderScreenerResult();
|
||
});
|
||
document.querySelector("#curatedStrategySearch").addEventListener("input", (event) => {
|
||
state.curatedQuery = event.target.value.trim().toLocaleLowerCase("zh-CN");
|
||
renderCuratedStrategyLibrary();
|
||
});
|
||
document.querySelector("#curatedCategoryFilter").addEventListener("change", (event) => {
|
||
state.curatedCategory = event.target.value;
|
||
renderCuratedStrategyLibrary();
|
||
});
|
||
document.querySelector("#curatedSchoolFilters").addEventListener("click", (event) => {
|
||
const button = event.target.closest("[data-curated-school]");
|
||
if (!button) return;
|
||
state.curatedSchool = button.dataset.curatedSchool;
|
||
renderCuratedStrategyLibrary();
|
||
});
|
||
document.querySelectorAll("[data-curated-view]").forEach((button) => {
|
||
button.addEventListener("click", () => {
|
||
state.curatedViewMode = button.dataset.curatedView === "grid" ? "grid" : "list";
|
||
localStorage.setItem("xiaobaiCuratedViewMode", state.curatedViewMode);
|
||
renderCuratedStrategyLibrary();
|
||
});
|
||
});
|
||
document.querySelector("#quantResetButton").addEventListener("click", resetQuantBuilder);
|
||
document.querySelector("#addQuantFilterButton").addEventListener("click", () => addQuantFilter());
|
||
document.querySelector("#addQuantScoreButton").addEventListener("click", () => addQuantScore());
|
||
document.querySelector("#quantFilterRows").addEventListener("input", handleQuantBuilderInput);
|
||
document.querySelector("#quantFilterRows").addEventListener("change", handleQuantBuilderInput);
|
||
document.querySelector("#quantFilterRows").addEventListener("click", handleQuantBuilderClick);
|
||
document.querySelector("#quantScoreRows").addEventListener("input", handleQuantBuilderInput);
|
||
document.querySelector("#quantScoreRows").addEventListener("change", handleQuantBuilderInput);
|
||
document.querySelector("#quantScoreRows").addEventListener("click", handleQuantBuilderClick);
|
||
["quantListedDays", "quantLimit", "quantMinScore", "quantExcludeSt"].forEach((id) => {
|
||
document.querySelector(`#${id}`).addEventListener("input", renderQuantSummary);
|
||
document.querySelector(`#${id}`).addEventListener("change", renderQuantSummary);
|
||
});
|
||
document.querySelector("#quantRunButton").addEventListener("click", runQuantStrategy);
|
||
document.querySelector("#quantSaveButton").addEventListener("click", saveQuantAsStrategy);
|
||
document.querySelector("#quantBacktestToggle").addEventListener("change", updateBacktestTaskStatus);
|
||
document.querySelector("#mentorChatForm").addEventListener("submit", sendMentorQuestion);
|
||
document.querySelector("#clearMentorChatButton").addEventListener("click", clearMentorConversation);
|
||
document.querySelector("#mentorDirectoryToggle").addEventListener("click", () => {
|
||
toggleMentorDirectory(!state.mentorDirectoryOpen);
|
||
});
|
||
document.querySelector("#closeMentorDirectory").addEventListener("click", () => toggleMentorDirectory(false));
|
||
document.querySelector("#mentorDirectoryBackdrop").addEventListener("click", () => toggleMentorDirectory(false));
|
||
document.querySelector("#mentorSortToggle").addEventListener("click", toggleMentorSortMode);
|
||
document.querySelector("#mentorSearchInput").addEventListener("input", (event) => {
|
||
state.mentorQuery = event.target.value.trim().toLocaleLowerCase("zh-CN");
|
||
renderMentorDirectory();
|
||
});
|
||
document.querySelectorAll("[data-mentor-grade]").forEach((button) => {
|
||
button.addEventListener("click", () => {
|
||
state.mentorGrade = button.dataset.mentorGrade || "all";
|
||
document.querySelectorAll("[data-mentor-grade]").forEach((item) => {
|
||
item.classList.toggle("active", item === button);
|
||
});
|
||
renderMentorDirectory();
|
||
});
|
||
});
|
||
document.querySelectorAll("[data-mentor-prompt]").forEach((button) => {
|
||
button.addEventListener("click", () => useMentorQuickPrompt(button.dataset.mentorPrompt));
|
||
});
|
||
document.querySelectorAll("[data-heaven-panel]").forEach((button) => {
|
||
button.addEventListener("click", () => selectHeavenPanel(button.dataset.heavenPanel, true));
|
||
});
|
||
document.querySelector("#loadHeavenSelectionButton").addEventListener("click", loadHeavenSelection);
|
||
document.querySelector("#heavenCalibrationForm").addEventListener("submit", applyHeavenCalibration);
|
||
document.querySelector("#resetHeavenCalibrationButton").addEventListener("click", resetHeavenCalibration);
|
||
document.querySelector("#heavenStockInput").addEventListener("keydown", (event) => {
|
||
if (event.key === "Enter") {
|
||
event.preventDefault();
|
||
loadHeavenSelection();
|
||
}
|
||
});
|
||
document.querySelector("#interpretTrendButton").addEventListener("click", () => interpretHeaven("trend"));
|
||
document.querySelector("#interpretFortuneButton").addEventListener("click", () => interpretHeaven("fortune"));
|
||
document.querySelector("#historyTrendButton").addEventListener("click", () => openHeavenHistory("trend"));
|
||
document.querySelector("#historyFortuneButton").addEventListener("click", () => openHeavenHistory("fortune"));
|
||
document.querySelector("#qiObservationDate").addEventListener("change", () => {
|
||
state.personalField = null;
|
||
state.heavenManualData = null;
|
||
state.heavenInterpretations.fortune = "";
|
||
loadHeavenSetup(
|
||
true,
|
||
"",
|
||
document.querySelector("#heavenStockInput").value.trim(),
|
||
);
|
||
});
|
||
document.querySelector("#openPersonalSettingsButton").addEventListener("click", () => openSettings("profile"));
|
||
document.querySelector("#accountBirthForm").addEventListener("submit", saveAccountBirthProfile);
|
||
document.querySelector("#deleteBirthProfileButton").addEventListener("click", deleteAccountBirthProfile);
|
||
document.querySelector("#passwordForm").addEventListener("submit", changeAccountPassword);
|
||
document.querySelector("#sectorPhaseForm").addEventListener("submit", saveSectorPhaseOverride);
|
||
document.querySelector("#startBreathingButton").addEventListener("click", startHeartBreathing);
|
||
document.querySelector("#beginCastingButton").addEventListener("click", beginHeartCasting);
|
||
document.querySelector("#heartSoundToggle").addEventListener("click", toggleHeartSound);
|
||
document.querySelector("#historyHeartButton").addEventListener("click", () => openHeavenHistory("heart"));
|
||
initializeHeartCoinHold();
|
||
initializeHeartLineInspection();
|
||
document.querySelector("#interpretHeartButton").addEventListener("click", () => interpretHeaven("heart"));
|
||
document.querySelector("#viewHeartReadingButton").addEventListener("click", () => openHeavenReading("heart"));
|
||
document.querySelector("#restartHeartButton").addEventListener("click", resetHeartRitual);
|
||
document.querySelector("#closeHeavenReadingDialog").addEventListener("click", () => elements.heavenReadingDialog.close());
|
||
elements.heavenReadingDialog.addEventListener("close", stopHeavenReadingAnimation);
|
||
document.querySelectorAll("[data-heaven-reading-tab]").forEach((button) => {
|
||
button.addEventListener("click", () => selectHeavenReadingTab(button.dataset.heavenReadingTab));
|
||
});
|
||
document.querySelector("#heavenReadingHistoryList").addEventListener("click", handleHeavenHistorySelection);
|
||
document.querySelector("#heavenReadingHistoryDetail").addEventListener("click", handleHeavenHistoryAction);
|
||
document.querySelectorAll("[data-heart-return]").forEach((button) => {
|
||
button.addEventListener("click", resetHeartRitual);
|
||
});
|
||
document.querySelector("#adminSectionSelect").addEventListener("change", (event) => selectAdminPanel(event.target.value));
|
||
document.querySelector("#systemMarketForm").addEventListener("submit", saveMarketSettings);
|
||
document.querySelector("#systemModelsForm").addEventListener("submit", saveModelPool);
|
||
document.querySelector("#membershipSettingsForm").addEventListener("submit", saveMembershipSettings);
|
||
document.querySelector("#addPlatformModel").addEventListener("click", addPlatformModel);
|
||
document.querySelector("#adminRefreshButton").addEventListener("click", startAdminRefresh);
|
||
window.addEventListener("resize", redrawThemeSensitiveVisuals);
|
||
initializeAutoTableSorting();
|
||
}
|
||
|
||
async function loadDashboard(force = false, background = false, showOverlay = true) {
|
||
const requestedDate = elements.tradeDate.value;
|
||
if (state.dashboardLoading && state.dashboardRequestDate === requestedDate) return;
|
||
state.dashboardLoading = true;
|
||
state.dashboardRequestDate = requestedDate;
|
||
const requestSequence = ++state.dashboardRequestSequence;
|
||
if (force) stockPreviewCache.clear();
|
||
if (!background && showOverlay) {
|
||
setLoading(true, "正在加载市场数据");
|
||
setStatus("正在加载市场数据");
|
||
} else if (!background) {
|
||
setStatus("正在刷新行情");
|
||
}
|
||
try {
|
||
const query = new URLSearchParams({ trade_date: elements.tradeDate.value });
|
||
if (force) query.set("force", "1");
|
||
const payload = await apiRequest(`/api/dashboard?${query}`);
|
||
if (
|
||
requestSequence !== state.dashboardRequestSequence
|
||
|| requestedDate !== elements.tradeDate.value
|
||
) return;
|
||
applyDashboard(payload, background);
|
||
} catch (error) {
|
||
if (background) {
|
||
setStatus("实时刷新暂时中断,正在等待重试");
|
||
} else {
|
||
showToast(error.message || "无法连接本地服务");
|
||
setStatus("加载失败");
|
||
}
|
||
} finally {
|
||
if (requestSequence === state.dashboardRequestSequence) {
|
||
state.dashboardLoading = false;
|
||
state.dashboardRequestDate = "";
|
||
if (!background && showOverlay) setLoading(false);
|
||
updateDateButtons();
|
||
}
|
||
}
|
||
}
|
||
|
||
async function startAdminRefresh() {
|
||
const buttons = [document.querySelector("#syncButton"), document.querySelector("#adminRefreshButton")].filter(Boolean);
|
||
buttons.forEach((button) => { button.disabled = true; });
|
||
try {
|
||
const payload = await apiRequest("/api/admin/refresh", "POST", { trade_date: elements.tradeDate.value });
|
||
showToast(payload.message || "后台刷新已提交");
|
||
setStatus("后台刷新运行中,当前页面保持不变");
|
||
} catch (error) {
|
||
showToast(error.message || "后台刷新启动失败");
|
||
} finally {
|
||
buttons.forEach((button) => { button.disabled = false; });
|
||
}
|
||
}
|
||
|
||
function applyDashboard(payload, background = false) {
|
||
state.dashboard = payload;
|
||
const selectedDate = payload.meta.requested_date || payload.meta.trade_date;
|
||
elements.tradeDate.value = selectedDate;
|
||
document.querySelector("#qiObservationDate").value = selectedDate;
|
||
document.querySelector("#journalDate").value = selectedDate;
|
||
renderDashboard();
|
||
setStatus(`${dashboardSourceLabel(payload.meta)} · 数据已更新`);
|
||
if (!background) {
|
||
if (state.activeView === "dragonView") loadDragonTiger();
|
||
if (state.activeView === "screenerView") loadScreenerSetup();
|
||
if (state.activeView === "screenerTrackingView") loadScreenerTracking(true);
|
||
if (state.activeView === "mentorView") loadMentorSetup(true);
|
||
if (state.activeView === "heavenView") loadHeavenSetup(true);
|
||
if (state.activeView === "sentimentCycleView") loadSentimentHistory(true);
|
||
if (state.activeView === "rotationView") loadRotationHistory(true);
|
||
if (state.activeView === "auctionView") loadAuctionCenter(true);
|
||
if (state.activeView === "themeLibraryView") loadThemeLibrary(true);
|
||
if (state.activeView === "popularityView") loadPopularity(true);
|
||
}
|
||
const requestedStock = new URLSearchParams(window.location.search).get("stock");
|
||
if (!state.initialStockOpened && /^\d{6}$/.test(requestedStock || "")) {
|
||
state.initialStockOpened = true;
|
||
openStock(requestedStock);
|
||
}
|
||
}
|
||
|
||
function dashboardSourceLabel(meta = {}) {
|
||
if (meta.realtime && !["closed", "after_hours"].includes(String(meta.market_status || ""))) return "盘中行情";
|
||
if (meta.carried_forward) return "最近收盘行情";
|
||
if (meta.market_status === "historical") return "历史行情";
|
||
return "收盘行情";
|
||
}
|
||
|
||
function renderDashboard() {
|
||
const { meta, overview, ladders, sectors } = state.dashboard;
|
||
animateMetric("tapeUp", overview.up_count, (value) => Math.round(value));
|
||
animateMetric("tapeDown", overview.down_count, (value) => Math.round(value));
|
||
setText("tapeLimit", `${overview.limit_up_count} / 跌停 ${overview.limit_down_count}`);
|
||
animateMetric("tapeAmount", overview.amount_billion, (value) => `${formatNumber(value, 1)} 亿`);
|
||
animateMetric("limitUpMetric", overview.limit_up_count, (value) => `${Math.round(value)} 家`);
|
||
animateMetric("limitDownMetric", overview.limit_down_count, (value) => `${Math.round(value)} 家`);
|
||
animateMetric("brokenMetric", overview.broken_count, (value) => `${Math.round(value)} 家`);
|
||
animateMetric("sealRateMetric", overview.seal_rate, (value) => `${formatNumber(value, 1)}%`);
|
||
animateMetric("amountMetric", overview.amount_billion, (value) => `${formatNumber(value, 1)} 亿`);
|
||
setText("dataDateMetric", dashboardDataTimestamp(meta));
|
||
animateMetric("sentimentScore", overview.sentiment_score, (value) => Math.round(value));
|
||
setText("sentimentText", sentimentLabel(overview.sentiment_score));
|
||
updateSentimentGauge(overview.sentiment_score);
|
||
setText("updatedAt", `${dashboardSourceLabel(meta)} · 更新 ${formatTimestamp(meta.updated_at)}`);
|
||
|
||
renderLimitTable();
|
||
renderLadderMini(ladders || []);
|
||
renderSectorMini(sectors || []);
|
||
renderBrokenTable(state.dashboard.broken || []);
|
||
renderDownTable(state.dashboard.down_limits || []);
|
||
renderYesterdayTable(state.dashboard.yesterday_limits || []);
|
||
renderPerformance(state.dashboard.limit_performance || []);
|
||
renderLadderBoard(ladders || []);
|
||
renderRotationMembers();
|
||
}
|
||
|
||
async function loadSentimentHistory(force = false) {
|
||
if (!state.dashboard || state.sentimentLoading) return;
|
||
const key = `${elements.tradeDate.value}:${state.sentimentRange}`;
|
||
if (!force && state.sentimentHistoryKey === key && state.sentimentHistory) {
|
||
renderSentimentHistory();
|
||
return;
|
||
}
|
||
state.sentimentLoading = true;
|
||
const notice = document.querySelector("#sentimentHistoryNotice");
|
||
notice.hidden = true;
|
||
try {
|
||
const query = new URLSearchParams({
|
||
trade_date: elements.tradeDate.value,
|
||
limit: String(state.sentimentRange),
|
||
});
|
||
state.sentimentHistory = await apiRequest(`/api/sentiment/history?${query}`);
|
||
state.sentimentHistoryKey = key;
|
||
renderSentimentHistory();
|
||
} catch (error) {
|
||
notice.textContent = error.message || "情绪周期数据加载失败";
|
||
notice.hidden = false;
|
||
showToast(notice.textContent);
|
||
} finally {
|
||
state.sentimentLoading = false;
|
||
}
|
||
}
|
||
|
||
function renderSentimentHistory() {
|
||
const payload = state.sentimentHistory;
|
||
if (!payload) return;
|
||
const rows = payload.rows || [];
|
||
const latest = rows[rows.length - 1];
|
||
const body = document.querySelector("#sentimentHistoryBody");
|
||
const empty = document.querySelector("#sentimentHistoryEmpty");
|
||
empty.hidden = rows.length > 0;
|
||
body.innerHTML = [...rows].reverse().map((row) => {
|
||
return `
|
||
<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);
|
||
const dayChange = number(latest.day_change);
|
||
const confidence = sentimentPhaseConfidence(latest);
|
||
setText("sentimentPhaseConfidence", `置信度 ${confidence}%`);
|
||
setText("sentimentDayChange", `${dayChange > 0 ? "+" : ""}${formatNumber(dayChange, 1)}`);
|
||
setText("sentimentSealRate", `${formatNumber(latest.seal_rate, 1)}%`);
|
||
setText("sentimentLimitUp", number(latest.limit_up_count));
|
||
setText("sentimentBroken", number(latest.broken_count));
|
||
setText("sentimentPhaseAdvice", sentimentPhaseAdvice(latest.phase));
|
||
setText("sentimentCurrentTag", `当前 ${number(latest.score)} · ${latest.phase}`);
|
||
setText("sentimentComponentSummary", `五维加权 → 温度 ${number(latest.score)}`);
|
||
setText("sentimentPeriodNote", `近 ${state.sentimentRange} 个交易日,当前展示 ${rows.length} 日`);
|
||
const changeElement = document.querySelector("#sentimentDayChange");
|
||
changeElement.className = changeClass(dayChange);
|
||
setText("sentimentPreviousPositive", `${number(latest.previous_positive_count)} / ${number(latest.previous_limit_count)} 只`);
|
||
setText("sentimentPreviousAverage", `红盘率 ${formatNumber(latest.previous_positive_rate, 1)}% · 平均 ${signed(latest.average_previous_change)}%`);
|
||
setText("sentimentHistoryDays", `${number(payload.available_days)} 个交易日`);
|
||
setText("sentimentNormalization", `${latest.normalization} · 当前展示 ${rows.length} 日`);
|
||
const marker = document.querySelector("#sentimentCycleScoreMarker");
|
||
marker.className = `sentiment-current-phase-badge ${sentimentPhaseClass(latest.phase)}`;
|
||
document.querySelector("#sentimentComponentList").innerHTML = Object.values(latest.components || {}).map((item) => `
|
||
<article class="sentiment-component-item">
|
||
<div class="sentiment-component-main">
|
||
<strong>${escapeHtml(item.label)}</strong>
|
||
<div class="sentiment-component-track" aria-hidden="true"><i data-component-score="${clamp(item.score, 0, 100)}" style="width:0%"></i></div>
|
||
<b>${formatNumber(item.score, 1)} <em>× ${number(item.weight)}%</em></b>
|
||
</div>
|
||
<small>${escapeHtml(item.summary)}</small>
|
||
</article>
|
||
`).join("");
|
||
requestAnimationFrame(() => {
|
||
animateSentimentComponents();
|
||
animateSentimentTrendChart(rows);
|
||
bindSentimentChartTooltip(rows);
|
||
});
|
||
animateRows(body);
|
||
}
|
||
|
||
function animateSentimentComponents() {
|
||
document.querySelectorAll("#sentimentComponentList [data-component-score]").forEach((bar, index) => {
|
||
const width = `${number(bar.dataset.componentScore)}%`;
|
||
if (!motionEnabled()) {
|
||
bar.style.width = width;
|
||
return;
|
||
}
|
||
setTimeout(() => { bar.style.width = width; }, index * 70);
|
||
});
|
||
}
|
||
|
||
function animateSentimentTrendChart(rows) {
|
||
if (sentimentChartAnimationFrame) cancelAnimationFrame(sentimentChartAnimationFrame);
|
||
if (!motionEnabled()) {
|
||
drawSentimentTrendChart(rows, 1);
|
||
return;
|
||
}
|
||
const startedAt = performance.now();
|
||
const duration = 780;
|
||
const frame = (now) => {
|
||
const rawProgress = Math.min(1, (now - startedAt) / duration);
|
||
const progress = 1 - (1 - rawProgress) ** 3;
|
||
drawSentimentTrendChart(rows, progress);
|
||
if (rawProgress < 1) sentimentChartAnimationFrame = requestAnimationFrame(frame);
|
||
else sentimentChartAnimationFrame = null;
|
||
};
|
||
sentimentChartAnimationFrame = requestAnimationFrame(frame);
|
||
}
|
||
|
||
function drawSentimentTrendChart(rows, progress = 1) {
|
||
const canvas = document.querySelector("#sentimentTrendChart");
|
||
if (!canvas || !rows.length || state.activeView !== "sentimentCycleView") return;
|
||
const rect = canvas.getBoundingClientRect();
|
||
if (!rect.width) return;
|
||
const width = Math.max(320, rect.width);
|
||
const height = Math.max(220, rect.height);
|
||
const ratio = window.devicePixelRatio || 1;
|
||
canvas.width = Math.round(width * ratio);
|
||
canvas.height = Math.round(height * ratio);
|
||
const context = canvas.getContext("2d");
|
||
const palette = currentChartPalette();
|
||
context.setTransform(ratio, 0, 0, ratio, 0, 0);
|
||
context.clearRect(0, 0, width, height);
|
||
context.fillStyle = palette.background;
|
||
context.fillRect(0, 0, width, height);
|
||
const padding = { top: 18, right: 18, bottom: 34, left: 42 };
|
||
const chartWidth = width - padding.left - padding.right;
|
||
const chartHeight = height - padding.top - padding.bottom;
|
||
const x = (index) => padding.left + (rows.length === 1 ? chartWidth / 2 : index / (rows.length - 1) * chartWidth);
|
||
const y = (score) => padding.top + (100 - clamp(score, 0, 100)) / 100 * chartHeight;
|
||
|
||
context.font = '10px "Microsoft YaHei UI", sans-serif';
|
||
context.textAlign = "right";
|
||
context.textBaseline = "middle";
|
||
for (let score = 0; score <= 100; score += 20) {
|
||
const lineY = y(score);
|
||
context.strokeStyle = score === 40 || score === 80 ? palette.zero : palette.grid;
|
||
context.lineWidth = 1;
|
||
context.beginPath();
|
||
context.moveTo(padding.left, lineY);
|
||
context.lineTo(width - padding.right, lineY);
|
||
context.stroke();
|
||
context.fillStyle = palette.axis;
|
||
context.fillText(String(score), padding.left - 8, lineY);
|
||
}
|
||
|
||
context.save();
|
||
context.beginPath();
|
||
context.rect(padding.left - 6, padding.top - 8, (chartWidth + 12) * clamp(progress, 0, 1), chartHeight + 18);
|
||
context.clip();
|
||
|
||
const finalPhase = rows[rows.length - 1]?.phase;
|
||
let phaseStart = rows.length - 1;
|
||
while (phaseStart > 0 && rows[phaseStart - 1]?.phase === finalPhase) phaseStart -= 1;
|
||
if (["退潮", "冰点"].includes(finalPhase)) {
|
||
const startX = phaseStart === 0 ? padding.left : (x(phaseStart - 1) + x(phaseStart)) / 2;
|
||
context.fillStyle = palette.alertArea;
|
||
context.fillRect(startX, padding.top, width - padding.right - startX, chartHeight);
|
||
context.fillStyle = palette.up;
|
||
context.font = '10px "Microsoft YaHei UI", sans-serif';
|
||
context.textAlign = "center";
|
||
context.textBaseline = "top";
|
||
context.fillText(finalPhase, (startX + width - padding.right) / 2, padding.top + 4);
|
||
}
|
||
|
||
const movingAverage = rows.map((_row, index) => {
|
||
const start = Math.max(0, index - 4);
|
||
const sample = rows.slice(start, index + 1);
|
||
return sample.reduce((sum, item) => sum + number(item.score), 0) / sample.length;
|
||
});
|
||
context.beginPath();
|
||
movingAverage.forEach((score, index) => {
|
||
if (index === 0) context.moveTo(x(index), y(score));
|
||
else context.lineTo(x(index), y(score));
|
||
});
|
||
context.strokeStyle = palette.movingAverage;
|
||
context.lineWidth = 1.5;
|
||
context.setLineDash([5, 4]);
|
||
context.stroke();
|
||
context.setLineDash([]);
|
||
|
||
context.beginPath();
|
||
rows.forEach((row, index) => {
|
||
const pointX = x(index);
|
||
const pointY = y(row.score);
|
||
if (index === 0) context.moveTo(pointX, pointY);
|
||
else context.lineTo(pointX, pointY);
|
||
});
|
||
context.lineTo(x(rows.length - 1), padding.top + chartHeight);
|
||
context.lineTo(x(0), padding.top + chartHeight);
|
||
context.closePath();
|
||
context.fillStyle = palette.area;
|
||
context.fill();
|
||
|
||
context.beginPath();
|
||
rows.forEach((row, index) => {
|
||
const pointX = x(index);
|
||
const pointY = y(row.score);
|
||
if (index === 0) context.moveTo(pointX, pointY);
|
||
else context.lineTo(pointX, pointY);
|
||
});
|
||
context.strokeStyle = palette.line;
|
||
context.lineWidth = 2.5;
|
||
context.lineJoin = "round";
|
||
context.lineCap = "round";
|
||
context.stroke();
|
||
|
||
rows.forEach((row, index) => {
|
||
context.beginPath();
|
||
context.arc(x(index), y(row.score), index === rows.length - 1 ? 4.5 : 3, 0, Math.PI * 2);
|
||
context.fillStyle = ["退潮", "冰点"].includes(row.phase) ? palette.up : row.phase === "修复" ? palette.repair : palette.line;
|
||
context.fill();
|
||
context.strokeStyle = palette.background;
|
||
context.lineWidth = 1.5;
|
||
context.stroke();
|
||
});
|
||
context.restore();
|
||
|
||
const labelStep = Math.max(1, Math.ceil(rows.length / 6));
|
||
context.textAlign = "center";
|
||
context.textBaseline = "top";
|
||
context.fillStyle = palette.axis;
|
||
rows.forEach((row, index) => {
|
||
if (index % labelStep !== 0 && index !== rows.length - 1) return;
|
||
const dateText = displayCompactDate(row.trade_date).slice(5);
|
||
context.fillText(dateText, x(index), height - padding.bottom + 10);
|
||
});
|
||
}
|
||
|
||
function bindSentimentChartTooltip(rows) {
|
||
const canvas = document.querySelector("#sentimentTrendChart");
|
||
const tooltip = document.querySelector("#sentimentChartTooltip");
|
||
if (!canvas || !tooltip || !rows.length) return;
|
||
canvas.onmousemove = (event) => {
|
||
const rect = canvas.getBoundingClientRect();
|
||
const padding = { left: 42, right: 18 };
|
||
const chartWidth = Math.max(1, rect.width - padding.left - padding.right);
|
||
const relativeX = clamp(event.clientX - rect.left - padding.left, 0, chartWidth);
|
||
const index = rows.length === 1 ? 0 : Math.round(relativeX / chartWidth * (rows.length - 1));
|
||
const row = rows[index];
|
||
tooltip.innerHTML = `${escapeHtml(displayCompactDate(row.trade_date))} · 温度 <b>${number(row.score)}</b> · ${escapeHtml(row.phase)}`;
|
||
tooltip.hidden = false;
|
||
const targetLeft = padding.left + (rows.length === 1 ? chartWidth / 2 : index / (rows.length - 1) * chartWidth);
|
||
tooltip.style.left = `${clamp(targetLeft + 10, 8, rect.width - tooltip.offsetWidth - 8)}px`;
|
||
tooltip.style.top = `${clamp(event.clientY - rect.top - 34, 8, rect.height - 34)}px`;
|
||
};
|
||
canvas.onmouseleave = () => { tooltip.hidden = true; };
|
||
}
|
||
|
||
function sentimentScoreClass(score) {
|
||
const value = number(score);
|
||
return value >= 60 ? "score-strong" : value < 40 ? "score-weak" : "score-neutral";
|
||
}
|
||
|
||
function sentimentPhaseClass(phase) {
|
||
return {
|
||
"冰点": "phase-ice",
|
||
"修复": "phase-repair",
|
||
"发酵": "phase-fermentation",
|
||
"高潮": "phase-climax",
|
||
"分化": "phase-divergence",
|
||
"退潮": "phase-retreat",
|
||
}[phase] || "phase-divergence";
|
||
}
|
||
|
||
function sentimentPhaseConfidence(row) {
|
||
const explicit = number(row?.confidence || row?.phase_confidence);
|
||
if (explicit > 0) return Math.round(clamp(explicit, 0, 100));
|
||
const historyEvidence = Math.min(12, number(row?.history_days) * 0.6);
|
||
const movementEvidence = Math.min(18, Math.abs(number(row?.day_change)) * 0.8);
|
||
return Math.round(clamp(62 + historyEvidence + movementEvidence, 60, 92));
|
||
}
|
||
|
||
function sentimentPhaseAdvice(phase) {
|
||
return {
|
||
"冰点": "情绪处于极弱区,先观察风险释放,允许没有候选结果。",
|
||
"修复": "风险开始收敛,关注率先转强的核心,小仓验证修复强度。",
|
||
"发酵": "主线与梯队正在形成,优先跟随核心,避免偏离主线。",
|
||
"高潮": "情绪与一致性已处高位,聚焦核心并主动降低后排暴露。",
|
||
"分化": "强弱开始分层,关注承接与回流,淘汰失去辨识度的方向。",
|
||
"退潮": "情绪指标继续走弱。",
|
||
}[phase] || "市场结构尚未形成清晰阶段,保持观察并等待确认。";
|
||
}
|
||
|
||
function getVisibleStocks() {
|
||
if (!state.dashboard) return [];
|
||
let rows = [...(state.dashboard.limits || [])];
|
||
if (state.filter === "1") rows = rows.filter((row) => number(row.streak) === 1);
|
||
if (state.filter === "2") rows = rows.filter((row) => number(row.streak) === 2);
|
||
if (state.filter === "3") rows = rows.filter((row) => number(row.streak) >= 3);
|
||
if (state.query) {
|
||
rows = rows.filter((row) => {
|
||
const haystack = `${row.code} ${row.name} ${row.sector} ${row.reason}`.toLowerCase();
|
||
return haystack.includes(state.query);
|
||
});
|
||
}
|
||
return rows.sort((left, right) => compareRows(left, right));
|
||
}
|
||
|
||
function renderLimitTable() {
|
||
if (!state.dashboard) return;
|
||
const rows = getVisibleStocks();
|
||
const allRows = state.dashboard.limits || [];
|
||
const body = document.querySelector("#limitTableBody");
|
||
body.innerHTML = rows.map((row, index) => `
|
||
<tr data-code="${escapeHtml(row.code)}">
|
||
<td class="row-number num muted">${index + 1}</td>
|
||
<td><span class="stock-cell"><strong class="stock-name sname">${escapeHtml(row.name)}</strong><small class="stock-code scode">${escapeHtml(row.code)}</small></span></td>
|
||
<td class="number num"><span class="pool-streak-tag tag red">${streakLabel(row.streak)}</span></td>
|
||
<td class="number num up">${signed(row.change)}</td>
|
||
<td class="number num">${formatNumber(row.price, 2)}</td>
|
||
<td>${escapeHtml(row.sector || "其他")}</td>
|
||
<td class="number num muted">${escapeHtml(row.first_time || "")}</td>
|
||
<td class="number num muted">${escapeHtml(row.last_time || "")}</td>
|
||
<td class="number num">${limitOpenState(row)}</td>
|
||
<td class="number num">${formatNumber(row.turnover_rate, 2)}</td>
|
||
<td class="number num">${formatNumber(row.amount_billion, 2)}</td>
|
||
<td class="number num">${formatLimitSealAmount(row.seal_amount_million)}</td>
|
||
<td class="pool-reason-cell" title="${escapeHtml(row.reason || "")}">${escapeHtml(row.reason || "")}</td>
|
||
</tr>
|
||
`).join("");
|
||
bindStockRows(body);
|
||
setText("resultCount", `${rows.length} 只`);
|
||
setText("limitPoolSubtitle", `${allRows.length} 只 · 数据日期 ${displayCompactDate(state.dashboard.meta?.trade_date || elements.tradeDate.value)}`);
|
||
setText("limitAllCount", allRows.length);
|
||
setText("limitFirstCount", allRows.filter((row) => number(row.streak) === 1).length);
|
||
setText("limitSecondCount", allRows.filter((row) => number(row.streak) === 2).length);
|
||
setText("limitThreePlusCount", allRows.filter((row) => number(row.streak) >= 3).length);
|
||
document.querySelector("#emptyState").hidden = rows.length !== 0;
|
||
updateSortHeaders();
|
||
}
|
||
|
||
function limitOpenState(row) {
|
||
const openTimes = number(row.open_times);
|
||
const firstTime = String(row.first_time || "");
|
||
if (firstTime.startsWith("09:25") && openTimes === 0) return '<span class="pool-state-tag one-word">一字</span>';
|
||
if (openTimes >= 6) return `<span class="pool-state-tag broken">烂板×${openTimes}</span>`;
|
||
return String(openTimes);
|
||
}
|
||
|
||
function formatLimitSealAmount(value) {
|
||
const amount = number(value);
|
||
if (!amount) return "";
|
||
return Math.round(amount).toLocaleString("zh-CN");
|
||
}
|
||
|
||
function renderBrokenTable(rows) {
|
||
const visibleRows = getVisibleBrokenRows(rows);
|
||
setText("brokenCount", `${rows.length} 只`);
|
||
setText("brokenMeta", ` · 触及涨停后未能封住 · 数据日期 ${displayCompactDate(state.dashboard?.meta?.trade_date || elements.tradeDate.value)}`);
|
||
const body = document.querySelector("#brokenTableBody");
|
||
body.innerHTML = visibleRows.map((row, index) => `
|
||
<tr data-code="${escapeHtml(row.code)}">
|
||
<td class="row-number num muted">${index + 1}</td>
|
||
<td><span class="stock-cell"><strong class="stock-name sname">${escapeHtml(row.name)}</strong><small class="stock-code scode">${escapeHtml(row.code)}</small></span></td>
|
||
<td class="number num ${changeClass(row.change)}" data-sort-value="${number(row.change)}">${signed(row.change)}</td>
|
||
<td class="number num broken-limit-gap" data-sort-value="${row.limitGap}">${formatNumber(row.limitGap, 2)}</td>
|
||
<td class="number num">${formatNumber(row.price, 2)}</td>
|
||
<td>${escapeHtml(row.sector || "其他")}</td>
|
||
<td class="number num muted">${escapeHtml(row.first_time || "")}</td>
|
||
<td class="number num" data-sort-value="${number(row.open_times)}">${brokenOpenState(row)}</td>
|
||
<td class="number num">${formatNumber(row.turnover_rate, 2)}</td>
|
||
<td class="number num">${formatNumber(row.amount_billion, 2)}</td>
|
||
<td class="pool-reason-cell" title="${escapeHtml(row.reason || "")}">${escapeHtml(row.reason || "")}</td>
|
||
</tr>
|
||
`).join("");
|
||
bindStockRows(body);
|
||
document.querySelector("#brokenEmptyState").hidden = visibleRows.length !== 0;
|
||
updateBrokenSortHeaders();
|
||
}
|
||
|
||
function getVisibleBrokenRows(rows = state.dashboard?.broken || []) {
|
||
let visibleRows = rows.map((row) => ({ ...row, limitGap: brokenLimitGap(row) }));
|
||
if (state.brokenQuery) {
|
||
visibleRows = visibleRows.filter((row) => `${row.code} ${row.name} ${row.sector}`.toLowerCase().includes(state.brokenQuery));
|
||
}
|
||
if (!state.brokenSortKey) return visibleRows;
|
||
return visibleRows.sort((left, right) => {
|
||
const result = number(left[state.brokenSortKey]) - number(right[state.brokenSortKey]);
|
||
return state.brokenSortDirection === "asc" ? result : -result;
|
||
});
|
||
}
|
||
|
||
function brokenLimitRate(row) {
|
||
const name = String(row.name || "").toUpperCase();
|
||
const code = String(row.code || "").replace(/\D/g, "");
|
||
if (name.includes("ST")) return 10;
|
||
if (/^(300|301|688|689)/.test(code)) return 20;
|
||
if (/^(4|8|92)/.test(code)) return 30;
|
||
return 10;
|
||
}
|
||
|
||
function brokenLimitGap(row) {
|
||
return Math.max(0, brokenLimitRate(row) - number(row.change));
|
||
}
|
||
|
||
function brokenOpenState(row) {
|
||
const openTimes = number(row.open_times);
|
||
return openTimes >= 6
|
||
? `<span class="broken-repeat-tag">反复炸 ×${openTimes}</span>`
|
||
: String(openTimes);
|
||
}
|
||
|
||
function changeBrokenSort(key) {
|
||
if (state.brokenSortKey === key) state.brokenSortDirection = state.brokenSortDirection === "asc" ? "desc" : "asc";
|
||
else {
|
||
state.brokenSortKey = key;
|
||
state.brokenSortDirection = "desc";
|
||
}
|
||
renderBrokenTable(state.dashboard?.broken || []);
|
||
}
|
||
|
||
function updateBrokenSortHeaders() {
|
||
document.querySelectorAll("#brokenTable th[data-broken-sort]").forEach((header) => {
|
||
header.classList.remove("sort-asc", "sort-desc", "sorted");
|
||
header.setAttribute("aria-sort", "none");
|
||
if (header.dataset.brokenSort === state.brokenSortKey) {
|
||
header.classList.add(state.brokenSortDirection === "asc" ? "sort-asc" : "sort-desc", "sorted");
|
||
header.setAttribute("aria-sort", state.brokenSortDirection === "asc" ? "ascending" : "descending");
|
||
}
|
||
const arrow = header.querySelector(".arr");
|
||
if (arrow) arrow.textContent = header.classList.contains("sorted") ? (state.brokenSortDirection === "asc" ? "▲" : "▼") : "↕";
|
||
});
|
||
}
|
||
|
||
function renderDownTable(rows) {
|
||
const visibleRows = getVisibleDownRows(rows);
|
||
setText("downCount", `${rows.length} 只`);
|
||
setText("downMeta", ` · 观察退潮、高位风险与亏钱效应 · 数据日期 ${displayCompactDate(state.dashboard?.meta?.trade_date || elements.tradeDate.value)}`);
|
||
renderDownSectorCluster(rows);
|
||
const body = document.querySelector("#downTableBody");
|
||
body.innerHTML = visibleRows.map((row, index) => `
|
||
<tr data-code="${escapeHtml(row.code)}">
|
||
<td class="row-number num muted">${index + 1}</td>
|
||
<td><span class="stock-cell"><strong class="stock-name sname">${escapeHtml(row.name)}</strong><small class="stock-code scode">${escapeHtml(row.code)}</small></span></td>
|
||
<td class="number num down" data-sort-value="${number(row.change)}">${signed(row.change)}</td>
|
||
<td class="number num">${formatNumber(row.price, 2)}</td>
|
||
<td>${escapeHtml(row.sector || "其他")}</td>
|
||
<td class="number num">${formatNumber(row.turnover_rate, 2)}</td>
|
||
<td class="number num">${formatNumber(row.amount_billion, 2)}</td>
|
||
<td class="number num">${number(row.streak) > 0 ? number(row.streak) : ""}</td>
|
||
<td class="pool-reason-cell" title="${escapeHtml(row.reason || "")}">${escapeHtml(row.reason || "")}</td>
|
||
</tr>
|
||
`).join("");
|
||
bindStockRows(body);
|
||
document.querySelector("#downEmptyState").hidden = visibleRows.length !== 0;
|
||
updateDownSortHeaders();
|
||
}
|
||
|
||
function getVisibleDownRows(rows = state.dashboard?.down_limits || []) {
|
||
let visibleRows = [...rows];
|
||
if (state.downQuery) {
|
||
visibleRows = visibleRows.filter((row) => `${row.code} ${row.name} ${row.sector}`.toLowerCase().includes(state.downQuery));
|
||
}
|
||
if (!state.downSortKey) return visibleRows;
|
||
return visibleRows.sort((left, right) => {
|
||
const result = number(left[state.downSortKey]) - number(right[state.downSortKey]);
|
||
return state.downSortDirection === "asc" ? result : -result;
|
||
});
|
||
}
|
||
|
||
function renderDownSectorCluster(rows) {
|
||
const counts = new Map();
|
||
rows.forEach((row) => {
|
||
const sector = String(row.sector || "其他").trim() || "其他";
|
||
if (sector === "其他") return;
|
||
counts.set(sector, (counts.get(sector) || 0) + 1);
|
||
});
|
||
const cluster = [...counts.entries()].sort((left, right) => right[1] - left[1])[0];
|
||
const element = document.querySelector("#downSectorCluster");
|
||
element.hidden = !cluster || cluster[1] < 2;
|
||
element.textContent = cluster && cluster[1] >= 2 ? `${cluster[0]}集中跌停 ×${cluster[1]}` : "";
|
||
}
|
||
|
||
function changeDownSort(key) {
|
||
if (state.downSortKey === key) state.downSortDirection = state.downSortDirection === "asc" ? "desc" : "asc";
|
||
else {
|
||
state.downSortKey = key;
|
||
state.downSortDirection = "asc";
|
||
}
|
||
renderDownTable(state.dashboard?.down_limits || []);
|
||
}
|
||
|
||
function updateDownSortHeaders() {
|
||
document.querySelectorAll("#downTable th[data-down-sort]").forEach((header) => {
|
||
header.classList.remove("sort-asc", "sort-desc", "sorted");
|
||
header.setAttribute("aria-sort", "none");
|
||
if (header.dataset.downSort === state.downSortKey) {
|
||
header.classList.add(state.downSortDirection === "asc" ? "sort-asc" : "sort-desc", "sorted");
|
||
header.setAttribute("aria-sort", state.downSortDirection === "asc" ? "ascending" : "descending");
|
||
}
|
||
const arrow = header.querySelector(".arr");
|
||
if (arrow) arrow.textContent = header.classList.contains("sorted") ? (state.downSortDirection === "asc" ? "▲" : "▼") : "↕";
|
||
});
|
||
}
|
||
|
||
function renderYesterdayTable(rows) {
|
||
const visibleRows = getVisibleYesterdayRows(rows);
|
||
const currentDate = displayCompactDate(state.dashboard?.meta?.trade_date || elements.tradeDate.value);
|
||
const previousDate = displayCompactDate(state.dashboard?.meta?.previous_trade_date || "");
|
||
setText("yesterdayCount", `${rows.length} 只`);
|
||
setText("yesterdayMeta", ` · 昨日 ${previousDate} → 今日 ${currentDate}`);
|
||
renderYesterdaySummary(rows);
|
||
const body = document.querySelector("#yesterdayTableBody");
|
||
body.innerHTML = visibleRows.map((row, index) => `
|
||
<tr data-code="${escapeHtml(row.code)}">
|
||
<td class="row-number num muted">${index + 1}</td>
|
||
<td><span class="stock-cell"><strong class="stock-name sname">${escapeHtml(row.name)}</strong><small class="stock-code scode">${escapeHtml(row.code)}</small></span></td>
|
||
<td class="number num">${number(row.prior_streak)}</td>
|
||
<td class="number num ${changeClass(row.current_change)}">${signed(row.current_change)}</td>
|
||
<td><span class="yesterday-outcome-tag ${yesterdayOutcomeClass(row.outcome)}">${escapeHtml(row.outcome)}</span></td>
|
||
<td class="number num">${number(row.current_streak) ? `<span class="yesterday-height-tag">${number(row.current_streak)}</span>` : ""}</td>
|
||
<td>${escapeHtml(row.sector || "其他")}</td>
|
||
<td class="pool-reason-cell" title="${escapeHtml(row.reason || "")}">${escapeHtml(row.reason || "")}</td>
|
||
</tr>
|
||
`).join("");
|
||
bindStockRows(body);
|
||
document.querySelector("#yesterdayEmptyState").hidden = visibleRows.length !== 0;
|
||
updateYesterdayControls();
|
||
}
|
||
|
||
function getVisibleYesterdayRows(rows = state.dashboard?.yesterday_limits || []) {
|
||
let visibleRows = rows.filter((row) => {
|
||
if (state.yesterdayFilter === "advance") return row.outcome === "晋级";
|
||
if (state.yesterdayFilter === "positive") return number(row.current_change) > 0;
|
||
if (state.yesterdayFilter === "fail") return row.outcome === "断板";
|
||
if (state.yesterdayFilter === "risk") return ["炸板", "跌停"].includes(row.outcome);
|
||
return true;
|
||
});
|
||
if (state.yesterdayQuery) {
|
||
visibleRows = visibleRows.filter((row) => `${row.code} ${row.name} ${row.sector}`.toLowerCase().includes(state.yesterdayQuery));
|
||
}
|
||
if (!state.yesterdaySortKey) return visibleRows;
|
||
return visibleRows.sort((left, right) => {
|
||
const result = number(left[state.yesterdaySortKey]) - number(right[state.yesterdaySortKey]);
|
||
return state.yesterdaySortDirection === "asc" ? result : -result;
|
||
});
|
||
}
|
||
|
||
function renderYesterdaySummary(rows) {
|
||
const total = rows.length;
|
||
const advance = rows.filter((row) => row.outcome === "晋级").length;
|
||
const positive = rows.filter((row) => number(row.current_change) > 0).length;
|
||
const fail = rows.filter((row) => row.outcome === "断板").length;
|
||
const risk = rows.filter((row) => ["炸板", "跌停"].includes(row.outcome)).length;
|
||
const rate = (value) => total ? value / total * 100 : 0;
|
||
setText("yesterdayAllCount", total);
|
||
setText("yesterdayAdvanceCount", advance);
|
||
setText("yesterdayAdvanceRate", `晋级率 ${formatNumber(rate(advance), 1)}%`);
|
||
setText("yesterdayPositiveCount", positive);
|
||
setText("yesterdayPositiveRate", `兑现率 ${formatNumber(rate(positive), 1)}%`);
|
||
setText("yesterdayFailCount", fail);
|
||
setText("yesterdayFailRate", `占 ${formatNumber(rate(fail), 1)}%`);
|
||
setText("yesterdayRiskCount", risk);
|
||
setText("yesterdayRiskRate", `亏钱效应 ${formatNumber(rate(risk), 1)}%`);
|
||
}
|
||
|
||
function yesterdayOutcomeClass(outcome) {
|
||
return { "晋级": "advance", "断板": "fail", "炸板": "broken", "跌停": "down" }[outcome] || "fail";
|
||
}
|
||
|
||
function changeYesterdaySort(key) {
|
||
if (state.yesterdaySortKey === key) state.yesterdaySortDirection = state.yesterdaySortDirection === "asc" ? "desc" : "asc";
|
||
else {
|
||
state.yesterdaySortKey = key;
|
||
state.yesterdaySortDirection = "desc";
|
||
}
|
||
renderYesterdayTable(state.dashboard?.yesterday_limits || []);
|
||
}
|
||
|
||
function updateYesterdayControls() {
|
||
document.querySelectorAll("[data-yesterday-filter]").forEach((button) => {
|
||
const active = button.dataset.yesterdayFilter === state.yesterdayFilter;
|
||
button.classList.toggle("active", active);
|
||
button.setAttribute("aria-pressed", String(active));
|
||
});
|
||
document.querySelectorAll("#yesterdayTable th[data-yesterday-sort]").forEach((header) => {
|
||
header.classList.remove("sort-asc", "sort-desc", "sorted");
|
||
header.setAttribute("aria-sort", "none");
|
||
if (header.dataset.yesterdaySort === state.yesterdaySortKey) {
|
||
header.classList.add(state.yesterdaySortDirection === "asc" ? "sort-asc" : "sort-desc", "sorted");
|
||
header.setAttribute("aria-sort", state.yesterdaySortDirection === "asc" ? "ascending" : "descending");
|
||
}
|
||
const arrow = header.querySelector(".arr");
|
||
if (arrow) arrow.textContent = header.classList.contains("sorted") ? (state.yesterdaySortDirection === "asc" ? "▲" : "▼") : "↕";
|
||
});
|
||
}
|
||
|
||
function renderPerformance(rows) {
|
||
rows = normalizePerformanceRows(rows);
|
||
const currentDate = displayCompactDate(state.dashboard?.meta?.trade_date || elements.tradeDate.value);
|
||
const previousDate = displayCompactDate(state.dashboard?.meta?.previous_trade_date || "");
|
||
setText("performanceDateRange", `昨日 ${previousDate} → 今日 ${currentDate}`);
|
||
document.querySelector("#performanceCards").innerHTML = rows.map((row) => `
|
||
<article class="performance-stage-card" title="收红 ${formatNumber(row.positive_rate, 1)}% · 平均涨幅 ${signed(row.average_change)}%"
|
||
aria-label="${escapeHtml(row.label)},晋级率 ${formatNumber(row.advance_rate, 1)}%,晋级 ${number(row.advanced)} 只,共 ${number(row.count)} 只,收红率 ${formatNumber(row.positive_rate, 1)}%,平均涨幅 ${signed(row.average_change)}%">
|
||
<div class="performance-stage-label"><span>${escapeHtml(row.label)} → 今日</span><i class="performance-status-tag ${performanceRateState(row.advance_rate).className}">${performanceRateState(row.advance_rate).label}</i></div>
|
||
<strong class="performance-stage-rate ${performanceRateState(row.advance_rate).className}">${formatNumber(row.advance_rate, 1)}%</strong>
|
||
<span class="performance-stage-count">晋级 ${number(row.advanced)} / 共 ${number(row.count)} 只</span>
|
||
<div class="performance-stage-track" aria-hidden="true"><i class="${performanceRateState(row.advance_rate).className}" style="width:${Math.max(number(row.advance_rate), number(row.advance_rate) > 0 ? 2 : 0)}%"></i></div>
|
||
</article>
|
||
`).join("") || '<div class="performance-empty-state">暂无昨日涨停统计</div>';
|
||
renderPerformanceConclusion(rows);
|
||
renderMarketBreadth(state.dashboard?.overview || {});
|
||
}
|
||
|
||
function normalizePerformanceRows(rows) {
|
||
const groups = new Map();
|
||
(rows || []).forEach((row) => {
|
||
const level = Math.max(1, number(row.level));
|
||
const displayLevel = Math.min(level, 5);
|
||
const group = groups.get(displayLevel) || {
|
||
level: displayLevel,
|
||
label: displayLevel === 1 ? "昨日首板" : displayLevel === 5 ? "昨日5板+" : `昨日${displayLevel}板`,
|
||
count: 0,
|
||
advanced: 0,
|
||
positive: 0,
|
||
changeTotal: 0,
|
||
};
|
||
const count = number(row.count);
|
||
group.count += count;
|
||
group.advanced += number(row.advanced);
|
||
group.positive += count * number(row.positive_rate) / 100;
|
||
group.changeTotal += count * number(row.average_change);
|
||
groups.set(displayLevel, group);
|
||
});
|
||
return [...groups.values()]
|
||
.sort((left, right) => right.level - left.level)
|
||
.map((group) => ({
|
||
level: group.level,
|
||
label: group.label,
|
||
count: group.count,
|
||
advanced: group.advanced,
|
||
advance_rate: group.count ? group.advanced / group.count * 100 : 0,
|
||
positive_rate: group.count ? group.positive / group.count * 100 : 0,
|
||
average_change: group.count ? group.changeTotal / group.count : 0,
|
||
}));
|
||
}
|
||
|
||
function performanceRateState(rate) {
|
||
const value = number(rate);
|
||
if (value === 0) return { label: "失效", className: "is-neutral" };
|
||
if (value < 20) return { label: "危险", className: "is-warning" };
|
||
return { label: "活跃", className: "is-active" };
|
||
}
|
||
|
||
function renderPerformanceConclusion(rows) {
|
||
const container = document.querySelector("#performanceConclusion");
|
||
if (!rows.length) {
|
||
container.innerHTML = '<div class="empty-state">暂无昨日梯队数据,暂不生成结论</div>';
|
||
return;
|
||
}
|
||
const sorted = [...rows].sort((left, right) => number(right.level) - number(left.level));
|
||
const highRows = sorted.filter((row) => number(row.level) >= 4);
|
||
const highAdvanced = highRows.reduce((total, row) => total + number(row.advanced), 0);
|
||
const highSamples = highRows.map((row) => escapeHtml(row.label)).join("、");
|
||
const strongest = [...rows].sort((left, right) => (
|
||
number(right.advance_rate) - number(left.advance_rate) || number(right.level) - number(left.level)
|
||
))[0];
|
||
const firstBoard = rows.find((row) => number(row.level) === 1);
|
||
const overview = state.dashboard?.overview || {};
|
||
const phase = overview.sentiment_phase || "观察";
|
||
const up = number(overview.up_count);
|
||
const down = number(overview.down_count);
|
||
const breadthRate = up + down > 0 ? up / (up + down) * 100 : 50;
|
||
const stance = breadthRate < 25 ? "宜守不宜攻" : breadthRate < 45 ? "控制仓位,聚焦核心" : "保持精选,跟随强势梯队";
|
||
const highText = highRows.length
|
||
? `高位晋级率<b class="${highAdvanced ? "up" : "is-neutral"}">${highAdvanced ? "仍有承接" : "全线失效"}</b>:${highSamples}${highAdvanced ? `共晋级 ${highAdvanced} 只` : "今日均未晋级"};`
|
||
: "高位梯队暂无昨日样本,空间信号仍待确认;";
|
||
const strongestText = strongest
|
||
? `<b>${escapeHtml(strongest.label)}</b>晋级率最高,为 <b class="up">${formatNumber(strongest.advance_rate, 1)}%</b>(${number(strongest.advanced)} 只晋级 / 共 ${number(strongest.count)} 只);`
|
||
: "暂无相对占优梯队;";
|
||
const firstBoardText = firstBoard
|
||
? `首板基数 ${number(firstBoard.count)} 只,晋级率 <b class="${performanceRateState(firstBoard.advance_rate).className}">${formatNumber(firstBoard.advance_rate, 1)}%</b>,低位接力${number(firstBoard.advance_rate) < 20 ? "胜率偏低" : "仍有活跃度"};`
|
||
: "首板梯队暂无有效样本;";
|
||
container.innerHTML = `
|
||
<div>· ${highText}</div>
|
||
<div>· ${strongestText}</div>
|
||
<div>· ${firstBoardText}</div>
|
||
<div>· 结论:<b>${stance}</b>,当前情绪周期「${escapeHtml(phase)}」。</div>
|
||
`;
|
||
}
|
||
|
||
function renderMarketBreadth(overview) {
|
||
const up = number(overview.up_count);
|
||
const down = number(overview.down_count);
|
||
const flat = Math.max(0, number(overview.flat_count));
|
||
const total = Math.max(1, up + down + flat);
|
||
const upRate = up / total * 100;
|
||
const flatRate = flat / total * 100;
|
||
const downRate = down / total * 100;
|
||
const panel = document.querySelector(".market-breadth-panel");
|
||
panel.classList.remove("breadth-enter");
|
||
void panel.offsetWidth;
|
||
panel.classList.add("breadth-enter");
|
||
setText("breadthDataTime", dashboardDataTimestamp(state.dashboard?.meta || {}));
|
||
animateMetric("breadthRatio", upRate, (value) => `${formatNumber(value, 1)}%`);
|
||
animateMetric("breadthUpCount", up, (value) => formatNumber(Math.round(value)));
|
||
animateMetric("breadthDownCount", down, (value) => formatNumber(Math.round(value)));
|
||
setText("breadthUpLegend", `${formatNumber(up)}(${formatNumber(upRate, 1)}%)`);
|
||
setText("breadthFlatLegend", `${formatNumber(flat)}(${formatNumber(flatRate, 1)}%)`);
|
||
setText("breadthDownLegend", `${formatNumber(down)}(${formatNumber(downRate, 1)}%)`);
|
||
document.querySelector("#breadthFlatLegendItem").hidden = flat === 0;
|
||
const limitUp = number(overview.limit_up_count);
|
||
const limitDown = number(overview.limit_down_count);
|
||
const breadthLabel = upRate < 20 ? "宽度极差" : upRate < 40 ? "宽度偏弱" : upRate < 55 ? "宽度均衡" : "宽度偏强";
|
||
setText("breadthWarning", `△ ${breadthLabel},涨跌停 ${limitUp}:${limitDown}`);
|
||
const bars = [
|
||
["breadthUpBar", upRate],
|
||
["breadthFlatBar", flatRate],
|
||
["breadthDownBar", downRate],
|
||
];
|
||
bars.forEach(([id, width]) => {
|
||
const bar = document.getElementById(id);
|
||
const targetWidth = `${Math.max(width, width > 0 ? 0.8 : 0)}%`;
|
||
bar.style.transition = "none";
|
||
bar.style.width = "0%";
|
||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||
bar.style.transition = "width 760ms var(--ease-out)";
|
||
bar.style.width = targetWidth;
|
||
}));
|
||
bar.title = `${formatNumber(width, 1)}%`;
|
||
});
|
||
}
|
||
|
||
async function loadRotationHistory(force = false) {
|
||
if (!state.dashboard || state.rotationLoading) return;
|
||
const key = `${elements.tradeDate.value}:9`;
|
||
if (!force && state.rotationHistoryKey === key && state.rotationHistory) {
|
||
renderRotationHistory();
|
||
return;
|
||
}
|
||
state.rotationLoading = true;
|
||
const container = document.querySelector("#rotationHistory");
|
||
container.innerHTML = '<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");
|
||
const tracker = document.querySelector("#rotationTracker");
|
||
if (!rows.length) {
|
||
container.innerHTML = '<div class="empty-state">尚无连续交易日的板块数据</div>';
|
||
setText("rotationHistoryRange", "暂无轮动历史");
|
||
tracker.hidden = true;
|
||
return;
|
||
}
|
||
const chronological = [...rows]
|
||
.sort((left, right) => String(left.trade_date).localeCompare(String(right.trade_date)))
|
||
.slice(-9);
|
||
const displayRows = state.rotationOrder === "latest" ? [...chronological].reverse() : chronological;
|
||
document.querySelectorAll("[data-rotation-order]").forEach((button) => {
|
||
button.classList.toggle("active", button.dataset.rotationOrder === state.rotationOrder);
|
||
});
|
||
setText(
|
||
"rotationHistoryRange",
|
||
`最近 ${chronological.length} 个交易日 · ${displayCompactDate(chronological[0].trade_date)} → ${displayCompactDate(chronological[chronological.length - 1].trade_date)} · ${state.rotationOrder === "latest" ? "由近到远,左侧为最新交易日" : "由远到近,右侧为最新交易日"}`,
|
||
);
|
||
setText("rotationSelectionHint", selected ? `已联动高亮 ${selected}` : "点击任意板块追踪其连续性");
|
||
if (selected) {
|
||
const sequence = displayRows.map((day) => {
|
||
const sector = (day.sectors || []).find((item) => item.name === selected);
|
||
return { tradeDate: day.trade_date, sector };
|
||
});
|
||
const appearances = sequence.filter((item) => item.sector);
|
||
const bestRank = appearances.length ? Math.min(...appearances.map((item) => number(item.sector.rank))) : 0;
|
||
tracker.hidden = false;
|
||
const continuity = appearances.length >= 3 ? "主线候选" : appearances.length === 1 ? "单日异动,持续性待验证" : "间断活跃";
|
||
tracker.innerHTML = `
|
||
<div class="rotation-tracker-copy"><strong>${escapeHtml(selected)}</strong><span>近 9 日在榜 <b>${appearances.length}</b> 天 · 最高排名 <b>#${bestRank || "--"}</b> · ${continuity}</span></div>
|
||
<div class="rotation-tracker-spark" aria-label="${escapeHtml(selected)}九日强度轨迹">
|
||
${sequence.map((item) => item.sector
|
||
? `<span style="--spark-height:${Math.max(18, clamp(number(item.sector.strength), 0, 100))}%" title="${escapeHtml(displayCompactDate(item.tradeDate))} · 第 ${number(item.sector.rank)} 名 · 强度 ${formatNumber(item.sector.strength, 0)}"><i></i><small>#${number(item.sector.rank)}</small></span>`
|
||
: `<span class="missing" title="${escapeHtml(displayCompactDate(item.tradeDate))} · 未上榜"><i></i><small>--</small></span>`).join("")}
|
||
</div>
|
||
<button class="rotation-track-cancel" type="button">取消追踪</button>`;
|
||
tracker.querySelector(".rotation-track-cancel").addEventListener("click", () => {
|
||
state.rotationSelectedSector = "";
|
||
state.rotationSelectedDate = "";
|
||
renderRotationHistory();
|
||
loadRotationMembers("");
|
||
});
|
||
} else {
|
||
tracker.hidden = true;
|
||
tracker.innerHTML = "";
|
||
}
|
||
container.classList.toggle("tracking", Boolean(selected));
|
||
const latestTradeDate = chronological[chronological.length - 1].trade_date;
|
||
container.innerHTML = displayRows.map((day) => {
|
||
const hasSelected = selected && (day.sectors || []).some((sector) => sector.name === selected);
|
||
return `
|
||
<article class="rotation-day ${selected ? "has-selection" : ""} ${hasSelected ? "selected-day" : ""} ${day.trade_date === latestTradeDate ? "latest-day" : ""}">
|
||
<header><time>${escapeHtml(displayCompactDate(day.trade_date).slice(5))}</time><span>${(day.sectors || []).length} 个热点</span></header>
|
||
<div class="rotation-day-sectors">${(day.sectors || []).map((sector) => {
|
||
const strength = clamp(number(sector.strength), 0, 100);
|
||
const heatClass = strength >= 90 ? "heat-strong" : strength >= 70 ? "heat-warm" : "heat-mild";
|
||
return `
|
||
<button type="button" class="rotation-sector-chip ${heatClass} ${selected === sector.name ? "selected" : ""}" data-rotation-sector="${escapeHtml(sector.name)}" data-rotation-date="${escapeHtml(day.trade_date)}">
|
||
<span class="rotation-rank rank-${Math.min(number(sector.rank), 4)}">${number(sector.rank)}</span><strong>${escapeHtml(sector.name)}</strong><small><b>${number(sector.count)}</b> 家 · ${formatNumber(sector.strength, 0)}</small>
|
||
<span class="rotation-cell-tooltip">${escapeHtml(displayCompactDate(day.trade_date).slice(5))} · 第 ${number(sector.rank)} 名 · 涨停 ${number(sector.count)} 家 · 强度 ${formatNumber(sector.strength, 0)}</span>
|
||
</button>`;
|
||
}).join("")}</div>
|
||
</article>`;
|
||
}).join("");
|
||
container.querySelectorAll("[data-rotation-sector]").forEach((button) => {
|
||
button.addEventListener("click", () => {
|
||
const clickedSector = button.dataset.rotationSector;
|
||
const clickedDate = button.dataset.rotationDate;
|
||
const isSameSelection = clickedSector === state.rotationSelectedSector
|
||
&& clickedDate === state.rotationSelectedDate;
|
||
state.rotationSelectedSector = isSameSelection ? "" : clickedSector;
|
||
state.rotationSelectedDate = isSameSelection ? "" : clickedDate;
|
||
renderRotationHistory();
|
||
loadRotationMembers(state.rotationSelectedSector);
|
||
});
|
||
});
|
||
}
|
||
|
||
async function loadRotationMembers(sector, force = false) {
|
||
if (!sector) {
|
||
state.rotationMembers = null;
|
||
state.rotationMembersKey = "";
|
||
renderRotationMembers();
|
||
return;
|
||
}
|
||
const memberDate = state.rotationSelectedDate || elements.tradeDate.value;
|
||
const key = `${memberDate}:${sector}`;
|
||
if (!force && state.rotationMembersKey === key && state.rotationMembers) {
|
||
renderRotationMembers();
|
||
return;
|
||
}
|
||
state.rotationMembersLoading = true;
|
||
renderRotationMembers();
|
||
try {
|
||
const query = new URLSearchParams({ trade_date: memberDate, sector });
|
||
state.rotationMembers = await apiRequest(`/api/rotation/members?${query}`);
|
||
state.rotationMembersKey = key;
|
||
} catch (error) {
|
||
state.rotationMembers = { error: error.message || "成分股加载失败", rows: [] };
|
||
state.rotationMembersKey = key;
|
||
} finally {
|
||
state.rotationMembersLoading = false;
|
||
renderRotationMembers();
|
||
}
|
||
}
|
||
|
||
function renderRotationMembers() {
|
||
const body = document.querySelector("#rotationTableBody");
|
||
const empty = document.querySelector("#rotationMembersEmpty");
|
||
if (state.rotationMembersLoading) {
|
||
body.innerHTML = "";
|
||
empty.textContent = `正在核验${state.rotationSelectedSector}成分股`;
|
||
empty.hidden = false;
|
||
return;
|
||
}
|
||
const payload = state.rotationMembers;
|
||
const rows = payload?.rows || [];
|
||
if (!state.rotationSelectedSector || !payload || payload.error || !rows.length) {
|
||
body.innerHTML = "";
|
||
empty.textContent = payload?.error || (state.rotationSelectedSector ? "该板块暂无可用成分行情" : "点击上方任意板块查看成分股");
|
||
empty.hidden = false;
|
||
setText("rotationDetailTitle", "板块成分股");
|
||
setText("rotationDetailMeta", state.rotationSelectedSector || "--");
|
||
return;
|
||
}
|
||
empty.hidden = true;
|
||
setText("rotationDetailTitle", `${payload.meta?.sector_name || state.rotationSelectedSector}成分股`);
|
||
setText("rotationDetailMeta", `${displayCompactDate(payload.meta?.trade_date)} · ${number(payload.meta?.quoted_count)} / ${number(payload.meta?.member_count)} 只`);
|
||
body.innerHTML = rows.map((row, index) => `
|
||
<tr data-code="${escapeHtml(row.code)}"><td class="number num muted">${index + 1}</td><td class="stock-code">${escapeHtml(row.code)}</td><td class="stock-name">${escapeHtml(row.name)}</td>
|
||
<td class="number num ${row.quoted ? changeClass(row.change) : "muted"}" data-sort-value="${row.quoted ? number(row.change) : -999}">${row.quoted ? signed(row.change) : ""}</td>
|
||
<td class="number num">${row.quoted ? formatNumber(row.open, 2) : ""}</td><td class="number num">${row.quoted ? formatNumber(row.close, 2) : ""}</td>
|
||
<td class="number num" data-sort-value="${number(row.amount_billion)}">${row.quoted ? formatNumber(row.amount_billion, 2) : ""}</td><td>${row.quoted ? "正常交易" : "当日无行情"}</td></tr>
|
||
`).join("");
|
||
animateRows(body);
|
||
bindStockRows(body);
|
||
}
|
||
|
||
function renderLadderMini(ladders) {
|
||
const container = document.querySelector("#ladderMini");
|
||
const highest = ladders.length ? Math.max(...ladders.map((item) => number(item.level))) : 0;
|
||
setText("maxHeight", highest ? `最高 ${highest} 板` : "暂无");
|
||
container.innerHTML = ladders.slice(0, 5).map((group) => {
|
||
const allNames = group.stocks.map((stock) => stock.name).filter(Boolean);
|
||
const visibleNames = allNames.slice(0, 3).join("、");
|
||
const suffix = allNames.length > 3 ? ` <em>等 ${number(group.count)} 只</em>` : "";
|
||
return `<div class="pool-side-group">
|
||
<div><strong>${escapeHtml(group.label)}</strong><small>${number(group.count)} 只</small></div>
|
||
<p title="${escapeHtml(allNames.join("、"))}">${escapeHtml(visibleNames || "--")}${suffix}</p>
|
||
</div>`;
|
||
}).join("") || '<div class="empty-state">暂无梯队数据</div>';
|
||
}
|
||
|
||
function renderSectorMini(sectors) {
|
||
document.querySelector("#sectorMini").innerHTML = sectors.slice(0, 7).map((sector) => `
|
||
<div class="pool-hot-row"><strong title="${escapeHtml(sector.name)}">${escapeHtml(sector.name)}</strong><span>${number(sector.count)}</span></div>
|
||
`).join("") || '<div class="empty-state">暂无板块数据</div>';
|
||
}
|
||
|
||
function renderLadderBoard(ladders) {
|
||
const container = document.querySelector("#ladderBoard");
|
||
const insights = document.querySelector("#ladderInsights");
|
||
const ordered = [...ladders].sort((left, right) => number(right.level) - number(left.level));
|
||
const maxLevel = ordered.length ? Math.max(...ordered.map((group) => number(group.level))) : 0;
|
||
const topVisibleLevel = Math.max(5, maxLevel);
|
||
const groupMap = new Map(ordered.map((group) => [number(group.level), group]));
|
||
const displayGroups = Array.from({ length: topVisibleLevel }, (_, index) => {
|
||
const level = topVisibleLevel - index;
|
||
return groupMap.get(level) || { level, label: level === 1 ? "首板" : level === 5 && maxLevel < 5 ? "5板+" : `${level}板`, count: 0, stocks: [] };
|
||
});
|
||
const total = ordered.reduce((sum, group) => sum + number(group.count), 0);
|
||
const spaceStocks = ordered.find((group) => number(group.level) === maxLevel)?.stocks || [];
|
||
const currentDate = displayCompactDate(state.dashboard?.meta?.trade_date || elements.tradeDate.value);
|
||
const previousDate = displayCompactDate(state.dashboard?.meta?.previous_trade_date || "");
|
||
setText("ladderDateRange", `数据日期 ${currentDate}`);
|
||
container.innerHTML = displayGroups.map((group) => {
|
||
const level = number(group.level);
|
||
const limit = level === 1 || level === 2 ? 8 : 99;
|
||
const expanded = state.expandedLadderLevels.has(level);
|
||
const groupStocks = [...(group.stocks || [])].sort((left, right) => {
|
||
if (state.ladderSortMode === "open") {
|
||
return number(left.open_times) - number(right.open_times)
|
||
|| String(left.first_time || "99:99:99").localeCompare(String(right.first_time || "99:99:99"));
|
||
}
|
||
return String(left.first_time || "99:99:99").localeCompare(String(right.first_time || "99:99:99"));
|
||
});
|
||
const stocks = expanded ? groupStocks : groupStocks.slice(0, limit);
|
||
const remaining = Math.max(0, groupStocks.length - stocks.length);
|
||
const label = group.label || (level === 1 ? "首板" : level === 5 && maxLevel < 5 ? "5板+" : `${level}板`);
|
||
const color = { 1: "#2563eb", 2: "#16a34a", 3: "#d97706", 4: "#e04536" }[level] || "#9ca3af";
|
||
return `
|
||
<section class="market-ladder-tier ${number(group.count) ? "" : "is-gap"}" data-ladder-level-card="${level}">
|
||
<div class="market-ladder-label" style="--tier-color:${color}"><div class="market-ladder-level"><span class="market-ladder-dot"></span>${escapeHtml(label)}</div><div class="market-ladder-count">${number(group.count)} 只</div>${number(group.count) && level > 1 ? `<div class="market-ladder-rate">${escapeHtml(label)} · <b>${formatNumber(number(group.count) / Math.max(number(groupMap.get(level - 1)?.count), 1) * 100, 1)}%</b></div>` : ""}</div>
|
||
<div class="market-ladder-stocks">${stocks.length ? stocks.map((stock) => {
|
||
const onePrice = String(stock.first_time || "").startsWith("09:25") && number(stock.open_times) === 0;
|
||
const broken = number(stock.open_times) >= 6;
|
||
const amount = number(stock.seal_amount_million) ? `封单 ${formatNumber(stock.seal_amount_million, 0)} 万` : `成交 ${formatNumber(stock.amount_billion, 1)} 亿`;
|
||
return `<button type="button" class="market-ladder-stock" data-code="${escapeHtml(stock.code)}" aria-label="查看 ${escapeHtml(stock.name)} ${escapeHtml(stock.code)}详情">
|
||
<span class="market-ladder-stock-first"><strong>${escapeHtml(stock.name)}</strong><small class="stock-code">${escapeHtml(stock.code)}</small><span class="market-ladder-tags">${onePrice ? '<em class="market-ladder-tag one-price">一字</em>' : ""}${broken ? `<em class="market-ladder-tag broken">烂板×${number(stock.open_times)}</em>` : ""}</span></span>
|
||
<span class="market-ladder-stock-second"><b>${escapeHtml(stock.sector || stock.reason || "其他")}</b><small>${stock.first_time && stock.first_time !== "--" ? escapeHtml(stock.first_time) : "时间待校正"}</small><small>${amount}</small></span>
|
||
</button>`;
|
||
}).join("") : `<div class="market-ladder-gap-note">${level >= maxLevel ? `断层 · ${escapeHtml(label)}及以上空缺` : "该层暂时空缺"}</div>`}${groupStocks.length > limit ? `<button class="market-ladder-more" type="button" data-ladder-level="${level}">${expanded ? "收起" : `展开剩余 ${remaining} 只`}<i data-lucide="chevron-${expanded ? "up" : "down"}"></i></button>` : ""}</div>
|
||
</section>`;
|
||
}).join("");
|
||
const structureRows = displayGroups.filter((group) => number(group.count) || number(group.level) <= maxLevel + 1);
|
||
const maxCount = Math.max(1, ...structureRows.map((group) => number(group.count)));
|
||
const rateRows = (state.dashboard?.limit_performance || []).map((row) => ({
|
||
label: `${row.label || (number(row.level) === 1 ? "昨日首板" : `昨日${number(row.level)}板`)} → 今日`,
|
||
value: clamp(number(row.advance_rate), 0, 100),
|
||
}));
|
||
const previousMax = Math.max(0, ...(state.dashboard?.yesterday_limits || []).map((row) => number(row.prior_streak)));
|
||
const spaceChange = previousMax && maxLevel < previousMax ? `较昨日 ${previousMax} 板 ↓ 空间压缩` : previousMax && maxLevel > previousMax ? `较昨日 ${previousMax} 板 ↑ 高度抬升` : "高度与昨日接近";
|
||
const spaceNote = maxLevel >= 5 ? "高位梯队仍有辨识度,重点观察承接而非单看高度。" : maxLevel >= 3 ? "空间位于中段,梯队延续性比绝对高度更重要。" : "高度受到压缩,先观察首板向二板的结构修复。";
|
||
const strongestGroup = structureRows.reduce((best, group) => number(group.count) > number(best?.count) ? group : best, structureRows[0]);
|
||
insights.innerHTML = `
|
||
<section class="market-ladder-insight-card market-ladder-apex-card"><header><h3>空间板</h3><span>市场高度</span></header><div class="market-ladder-apex"><div><strong>${maxLevel ? `${maxLevel} 板` : "--"}</strong><em>${escapeHtml(spaceChange)}</em></div><p>${spaceStocks.length ? spaceStocks.map((stock) => `<b>${escapeHtml(stock.name)}</b>(${escapeHtml(stock.sector || "其他")})`).join(" · ") : "暂无空间板"}</p></div><p>${spaceNote}</p></section>
|
||
<section class="market-ladder-insight-card"><header><h3>梯队结构</h3><span>完整度</span></header><div class="market-ladder-pyramid">${structureRows.map((group) => `<div class="market-ladder-pyramid-row ${number(group.count) ? "" : "is-gap"}"><span>${escapeHtml(group.label || `${number(group.level)}板`)}</span><i><b style="width:${Math.max(number(group.count) ? 8 : 100, number(group.count) / maxCount * 100)}%"></b></i><strong>${number(group.count) ? `${number(group.count)} 只` : "断层"}</strong></div>`).join("")}</div><p>断层越少,梯队从低位向高位传导越连贯。当前腰部为 <b>${escapeHtml(strongestGroup?.label || "--")}</b>。</p></section>
|
||
<section class="market-ladder-insight-card"><header><h3>晋级率参考</h3><span>昨日梯队 → 今日</span></header><div class="market-ladder-rate-list">${rateRows.length ? rateRows.map((row) => `<div><span>${escapeHtml(row.label)}</span><i><b class="${row.value === 0 ? "is-zero" : row.value < 20 ? "is-low" : ""}" style="width:${Math.max(row.value, row.value > 0 ? 2 : 0)}%"></b></i><strong class="${row.value === 0 ? "is-zero" : row.value < 20 ? "is-low" : ""}">${formatNumber(row.value, 1)}%</strong></div>`).join("") : '<div class="empty-state">暂无可比梯队</div>'}</div><small class="market-ladder-source">数据来自“涨停表现”页 · 昨日梯队样本</small></section>`;
|
||
container.querySelectorAll("[data-ladder-level]").forEach((button) => {
|
||
button.addEventListener("click", () => {
|
||
const level = number(button.dataset.ladderLevel);
|
||
if (state.expandedLadderLevels.has(level)) state.expandedLadderLevels.delete(level);
|
||
else state.expandedLadderLevels.add(level);
|
||
renderLadderBoard(state.dashboard?.ladders || []);
|
||
});
|
||
});
|
||
bindStockRows(container);
|
||
refreshIcons();
|
||
}
|
||
|
||
async function loadAuctionCenter(force = false) {
|
||
if (state.auctionLoading) return;
|
||
state.auctionLoading = true;
|
||
const button = document.querySelector("#auctionRefreshButton");
|
||
button.disabled = true;
|
||
setText("auctionDateLabel", "正在读取竞价数据");
|
||
try {
|
||
const query = new URLSearchParams({ trade_date: elements.tradeDate.value });
|
||
if (force) query.set("force", "1");
|
||
state.auctionData = await apiRequest(`/api/auction?${query}`);
|
||
renderAuctionCenter();
|
||
scheduleAuctionTransition(state.auctionData.meta || {});
|
||
} catch (error) {
|
||
document.querySelector("#auctionSummary").innerHTML = "";
|
||
document.querySelector("#auctionThemeCarry").innerHTML = "";
|
||
document.querySelector("#auctionNewThemes").innerHTML = "";
|
||
document.querySelector("#auctionAmountTrend").innerHTML = "";
|
||
document.querySelector("#auctionAmountCompare").innerHTML = "";
|
||
document.querySelector("#auctionTableBody").innerHTML = "";
|
||
document.querySelector("#auctionEmpty").hidden = false;
|
||
setText("auctionDateLabel", error.message || "竞价数据暂不可用");
|
||
showToast(error.message || "竞价数据加载失败");
|
||
} finally {
|
||
state.auctionLoading = false;
|
||
button.disabled = false;
|
||
}
|
||
}
|
||
|
||
function renderAuctionCenter() {
|
||
const payload = state.auctionData;
|
||
if (!payload) return;
|
||
const summary = payload.summary || {};
|
||
renderAuctionPhase(payload.meta || {});
|
||
setText(
|
||
"auctionDateLabel",
|
||
`${payload.meta?.carried_forward ? "最近有效竞价" : "竞价日期"} ${payload.meta?.trade_date || "--"}`,
|
||
);
|
||
document.querySelector("#auctionSummary").innerHTML = [
|
||
["竞价覆盖", `${formatNumber(summary.stock_count, 0)} 只`, ""],
|
||
["重点异动", `${formatNumber(summary.focus_count, 0)} 只`, "up"],
|
||
["竞价一字", `${formatNumber(summary.one_price_count, 0)} 只`, ""],
|
||
["竞价成交额", `${formatNumber(summary.amount_billion, 2)} 亿`, ""],
|
||
].map(([label, value, tone]) => `<div><span>${label}</span><strong class="${tone}">${value}</strong></div>`).join("");
|
||
setText("auctionFocusCount", number(summary.focus_count));
|
||
setText("auctionAllCount", number(summary.candidate_count));
|
||
setText("auctionOnePriceCount", number(summary.one_price_count));
|
||
setText("auctionWatchlistCount", number(payload.watchlist_rows?.length));
|
||
renderAuctionInsights(payload);
|
||
renderAuctionTable();
|
||
}
|
||
|
||
function renderAuctionInsights(payload) {
|
||
const themes = payload.themes || {};
|
||
const carry = themes.carry || [];
|
||
const tone = { "强承接": "strong", "有承接": "steady", "分歧": "mixed", "承接弱": "weak" };
|
||
setText("auctionThemeBaseline", `基于 ${payload.candidate_meta?.baseline_date || "--"}`);
|
||
document.querySelector("#auctionThemeCarry").innerHTML = carry.length
|
||
? carry.map((item) => `
|
||
<div class="auction-theme-row">
|
||
<strong class="auction-theme-name">${escapeHtml(item.name)}</strong>
|
||
<span class="auction-theme-info">${escapeHtml(item.leader || "--")} · 昨日 ${number(item.prior_limit_count)} 只涨停</span>
|
||
<span class="auction-theme-status ${tone[item.status] || "mixed"}">${escapeHtml(item.status)}</span>
|
||
<span class="auction-theme-median ${item.median_change == null ? "" : changeClass(item.median_change)}">${item.median_change == null ? "暂无有效候选" : `${signed(item.median_change)}%`}<small>中位</small></span>
|
||
</div>`).join("")
|
||
: '<div class="auction-inline-empty">暂无昨日强势题材基线</div>';
|
||
|
||
const newThemes = themes.new_themes || [];
|
||
document.querySelector("#auctionNewThemes").innerHTML = newThemes.length
|
||
? newThemes.map((item) => `<span title="${escapeHtml((item.leaders || []).join("、"))}">${escapeHtml(item.name)} <strong>${number(item.stock_count)}</strong></span>`).join("")
|
||
: '<small>尚未形成多股共振的新线索</small>';
|
||
|
||
const history = payload.amount_history || [];
|
||
const maximum = Math.max(...history.map((item) => number(item.amount_billion)), 1);
|
||
const priorFive = history.slice(Math.max(0, history.length - 6), Math.max(0, history.length - 1));
|
||
const fiveDayAverage = priorFive.length
|
||
? priorFive.reduce((sum, item) => sum + number(item.amount_billion), 0) / priorFive.length
|
||
: null;
|
||
document.querySelector("#auctionAmountTrend").innerHTML = history.length
|
||
? history.map((item, index) => {
|
||
const height = Math.max(8, number(item.amount_billion) / maximum * 100);
|
||
const current = index === history.length - 1 ? " current" : "";
|
||
return `<div class="auction-amount-day${current}" title="${escapeHtml(item.trade_date)} · ${formatNumber(item.amount_billion, 2)} 亿 · ${number(item.stock_count)} 只">
|
||
<span style="height:${height.toFixed(1)}%"></span><small>${escapeHtml(String(item.trade_date || "").slice(5))}</small>
|
||
</div>`;
|
||
}).join("") + (fiveDayAverage === null ? "" : `<div class="auction-amount-average" style="bottom:${(20 + Math.min(fiveDayAverage / maximum, 1) * 82).toFixed(1)}px"><small>5日均 ${formatNumber(fiveDayAverage, 1)}</small></div>`)
|
||
: '<div class="auction-inline-empty">历史竞价量能尚未形成</div>';
|
||
setText("auctionAmountValue", `${formatNumber(payload.summary?.amount_billion, 2)} 亿`);
|
||
const comparison = [
|
||
["较昨日", payload.summary?.amount_change_previous],
|
||
["较5日均值", payload.summary?.amount_change_5d],
|
||
];
|
||
document.querySelector("#auctionAmountCompare").innerHTML = comparison.map(([label, value]) => `
|
||
<span>${label}<strong class="${value == null ? "" : changeClass(value)}">${value == null ? "--" : `${signed(value)}%`}</strong></span>
|
||
`).join("");
|
||
}
|
||
|
||
function renderAuctionPhase(meta) {
|
||
const phase = meta.phase || "archive";
|
||
const available = Boolean(meta.available);
|
||
const copy = {
|
||
pending: ["竞价尚未开始", "9:15 进入观察期,9:25 读取最终竞价结果。", "下一阶段 09:15"],
|
||
observing: ["竞价观察期", "此阶段先观察盘前变化,系统将在 9:25 自动读取最终结果。", "09:25 定格"],
|
||
selection: available
|
||
? ["竞价筛选窗口", "最终竞价结果已经定格,请在 9:30 前完成筛选。", "有效至 09:30"]
|
||
: ["等待最终竞价", "9:25 数据尚未到达,系统正在自动重试。", "即将更新"],
|
||
finalized: ["今日竞价已定格", "9:30 后停止更新,仅保留用于复盘、回测与智能选股。", "已冻结"],
|
||
archive: ["历史竞价归档", "当前展示所选交易日的最终竞价结果。", "归档数据"],
|
||
}[phase] || ["竞价状态", "当前竞价状态待确认。", "--"];
|
||
const notice = document.querySelector("#auctionPhaseNotice");
|
||
notice.dataset.phase = phase;
|
||
setText("auctionPhaseTitle", copy[0]);
|
||
setText("auctionPhaseDetail", copy[1]);
|
||
setText("auctionPhaseTime", copy[2]);
|
||
const refresh = document.querySelector("#auctionRefreshButton");
|
||
refresh.hidden = phase !== "selection";
|
||
refresh.disabled = state.auctionLoading;
|
||
}
|
||
|
||
function clearAuctionTimer() {
|
||
if (state.auctionTimer) clearTimeout(state.auctionTimer);
|
||
state.auctionTimer = null;
|
||
}
|
||
|
||
function scheduleAuctionTransition(meta) {
|
||
clearAuctionTimer();
|
||
if (state.activeView !== "auctionView") return;
|
||
let delay = 0;
|
||
if (["selection", "finalized"].includes(meta.phase) && !meta.available) {
|
||
delay = 10_000;
|
||
} else if (meta.next_transition_at) {
|
||
const transitionAt = new Date(meta.next_transition_at).getTime();
|
||
if (Number.isFinite(transitionAt)) delay = Math.max(800, transitionAt - Date.now() + 500);
|
||
}
|
||
if (!delay) return;
|
||
state.auctionTimer = setTimeout(() => {
|
||
state.auctionTimer = null;
|
||
if (state.activeView === "auctionView") loadAuctionCenter(true);
|
||
}, Math.min(delay, 2_147_000_000));
|
||
}
|
||
|
||
function renderAuctionTable() {
|
||
const rows = currentAuctionRows();
|
||
const columns = auctionColumns();
|
||
const head = document.querySelector("#auctionTableHead");
|
||
head.innerHTML = columns.map((column) => {
|
||
const sorted = column.sortKey === state.auctionSortKey;
|
||
const arrow = !column.sortKey ? "" : `<span class="arr">${sorted ? (state.auctionSortDirection === "desc" ? "▼" : "▲") : "↕"}</span>`;
|
||
return `<th class="${column.numeric ? "number num " : ""}${column.sortKey ? "sortable " : ""}${sorted ? "sorted" : ""}"${column.sortKey ? ` data-auction-sort="${column.sortKey}"` : ""}>${column.label}${arrow}</th>`;
|
||
}).join("");
|
||
const body = document.querySelector("#auctionTableBody");
|
||
body.innerHTML = rows.map((row) => `<tr data-code="${escapeHtml(row.code)}">${columns.map((column) => renderAuctionCell(row, column.key)).join("")}</tr>`).join("");
|
||
bindStockRows(body);
|
||
const datasetCopy = {
|
||
focus: ["重点异动", "优先查看市场核心与显著预期差"],
|
||
onePrice: ["竞价一字", "竞价封于当日真实涨停价,不参与普通异动评分"],
|
||
watchlist: ["我的自选", "仅展示当前账号关注标的的竞价反馈"],
|
||
all: ["全部候选", "昨日涨停、炸板与热榜前20候选"],
|
||
}[state.auctionDataset] || ["竞价异动", ""];
|
||
setText("auctionWorkspaceTitle", datasetCopy[0]);
|
||
setText("auctionWorkspaceSubtitle", datasetCopy[1]);
|
||
document.querySelector("#auctionExpectationControls").hidden = state.auctionDataset === "onePrice";
|
||
const empty = document.querySelector("#auctionEmpty");
|
||
const phase = state.auctionData?.meta?.phase || "archive";
|
||
empty.textContent = phase === "selection" && !state.auctionData?.meta?.available
|
||
? "正在等待 9:25 最终竞价数据"
|
||
: state.auctionDataset === "watchlist"
|
||
? "当前账号还没有可观察的自选股"
|
||
: state.auctionDataset === "onePrice"
|
||
? "当前没有竞价封于涨停价的股票"
|
||
: "没有符合条件的竞价候选";
|
||
empty.hidden = rows.length > 0;
|
||
}
|
||
|
||
function currentAuctionRows() {
|
||
const datasets = {
|
||
focus: state.auctionData?.focus_rows || [],
|
||
onePrice: state.auctionData?.one_price_rows || [],
|
||
watchlist: state.auctionData?.watchlist_rows || [],
|
||
all: state.auctionData?.rows || [],
|
||
};
|
||
let rows = [...(datasets[state.auctionDataset] || [])];
|
||
const filter = state.auctionFilter;
|
||
const labels = { above: "超预期", matched: "符合预期", below: "低于预期" };
|
||
if (labels[filter]) rows = rows.filter((item) => item.expectation === labels[filter]);
|
||
if (state.auctionQuery) {
|
||
rows = rows.filter((item) => `${item.code} ${item.name} ${item.sector}`.toLocaleLowerCase("zh-CN").includes(state.auctionQuery));
|
||
}
|
||
const key = state.auctionSortKey;
|
||
const direction = state.auctionSortDirection === "asc" ? 1 : -1;
|
||
if (key) {
|
||
rows.sort((left, right) => {
|
||
const leftValue = left[key];
|
||
const rightValue = right[key];
|
||
if (leftValue == null && rightValue == null) return 0;
|
||
if (leftValue == null) return 1;
|
||
if (rightValue == null) return -1;
|
||
const result = typeof leftValue === "number" || typeof rightValue === "number"
|
||
? number(leftValue) - number(rightValue)
|
||
: String(leftValue).localeCompare(String(rightValue), "zh-CN", { numeric: true });
|
||
return result * direction;
|
||
});
|
||
}
|
||
return rows.slice(0, 300);
|
||
}
|
||
|
||
function auctionColumns() {
|
||
const base = [
|
||
{ key: "stock", label: "股票" },
|
||
{ key: "context", label: "方向与来源" },
|
||
{ key: "identity", label: "市场身份" },
|
||
];
|
||
const metrics = [
|
||
{ key: "score", label: "关注分", numeric: true, sortKey: "attention_score" },
|
||
{ key: "expectation", label: "预期判断" },
|
||
{ key: "change", label: "竞价涨幅(%)", numeric: true, sortKey: "change" },
|
||
{ key: "amount", label: "竞价额(百万)", numeric: true, sortKey: "amount_million" },
|
||
{ key: "volume", label: "量比", numeric: true, sortKey: "volume_ratio" },
|
||
];
|
||
return state.auctionDataset === "onePrice" ? [...base, ...metrics.slice(2)] : [...base, ...metrics];
|
||
}
|
||
|
||
function renderAuctionCell(row, key) {
|
||
const unavailable = row.available === false;
|
||
const onePrice = Boolean(row.is_one_price);
|
||
const expectationTone = { "超预期": "above", "符合预期": "matched", "低于预期": "below" };
|
||
if (key === "stock") return `<td><span class="auction-stock-cell-v2"><strong class="sname">${escapeHtml(row.name)}</strong><small class="stock-code scode">${escapeHtml(row.code)}</small></span></td>`;
|
||
if (key === "context") return `<td><span class="auction-context-cell-v2"><strong>${escapeHtml(row.sector || "其他")}</strong>${renderAuctionSources(row.source_label || (state.auctionDataset === "watchlist" ? "我的自选" : "全市场"))}</span></td>`;
|
||
if (key === "identity") return `<td>${renderAuctionCoreTags(row.core_tags)}</td>`;
|
||
if (unavailable) return key === "expectation"
|
||
? '<td><span class="table-muted">暂无竞价</span></td>'
|
||
: `<td class="${["score", "change", "amount", "volume"].includes(key) ? "number num" : ""}"></td>`;
|
||
if (key === "score") return `<td class="number num auction-score">${onePrice ? "" : formatNumber(row.attention_score, 1)}</td>`;
|
||
if (key === "expectation") {
|
||
const tag = onePrice
|
||
? '<span class="auction-one-price-tag">竞价一字</span>'
|
||
: `<span class="auction-expectation ${expectationTone[row.expectation] || "matched"}">${escapeHtml(row.expectation || "符合预期")}</span>`;
|
||
return `<td>${tag}</td>`;
|
||
}
|
||
if (key === "change") return `<td class="number num ${changeClass(row.change)}">${signed(row.change)}</td>`;
|
||
if (key === "amount") return `<td class="number num">${formatNumber(row.amount_million, 2)}</td>`;
|
||
if (key === "volume") return `<td class="number num auction-volume-ratio">${formatNumber(row.volume_ratio, 2)}</td>`;
|
||
return "<td></td>";
|
||
}
|
||
|
||
function renderAuctionSources(value) {
|
||
const sources = String(value || "").split(/[·、/]/).map((item) => item.trim()).filter(Boolean).slice(0, 3);
|
||
return `<small class="auction-source-tags-v2">${sources.map((source) => `<b>${escapeHtml(source)}</b>`).join("")}</small>`;
|
||
}
|
||
|
||
function renderAuctionCoreTags(tags) {
|
||
const values = Array.isArray(tags) ? tags : [];
|
||
return values.length
|
||
? `<span class="auction-core-tags">${values.slice(0, 2).map((tag) => `<b>${escapeHtml(tag)}</b>`).join("")}</span>`
|
||
: '<span class="auction-identity-empty" aria-label="无市场身份"></span>';
|
||
}
|
||
|
||
function exportAuctionRows() {
|
||
const rows = currentAuctionRows();
|
||
exportRows("集合竞价", rows, [
|
||
["股票代码", "code"], ["股票名称", "name"], ["行业", "sector"], ["来源", "source_label"],
|
||
["市场身份", "core_tags"], ["关注分", "attention_score"], ["预期判断", "expectation"],
|
||
["竞价涨幅%", "change"], ["竞价额百万", "amount_million"], ["量比", "volume_ratio"],
|
||
]);
|
||
}
|
||
|
||
async function loadThemeLibrary(force = false) {
|
||
if (state.themeLoading) return;
|
||
state.themeLoading = true;
|
||
const button = document.querySelector("#themeRefreshButton");
|
||
button.disabled = true;
|
||
setText("themeDateLabel", "正在整理题材库");
|
||
try {
|
||
const query = new URLSearchParams({ trade_date: elements.tradeDate.value });
|
||
if (force) query.set("force", "1");
|
||
state.themeLibrary = await apiRequest(`/api/themes?${query}`);
|
||
renderThemeLibrary();
|
||
const available = (state.themeLibrary.items || []).some((item) => item.code === state.selectedThemeCode);
|
||
if (!available) state.selectedThemeCode = "";
|
||
const initialCode = state.selectedThemeCode || state.themeLibrary.items?.[0]?.code || "";
|
||
if (initialCode) await selectTheme(initialCode, true);
|
||
} catch (error) {
|
||
setText("themeDateLabel", error.message || "题材数据暂不可用");
|
||
document.querySelector("#themeDirectory").innerHTML = `<div class="empty-state">${escapeHtml(error.message || "题材数据加载失败")}</div>`;
|
||
showToast(error.message || "题材数据加载失败");
|
||
} finally {
|
||
state.themeLoading = false;
|
||
button.disabled = false;
|
||
}
|
||
}
|
||
|
||
function renderThemeLibrary() {
|
||
const payload = state.themeLibrary;
|
||
if (!payload) return;
|
||
const summary = payload.summary || {};
|
||
setText("themeDateLabel", `${payload.meta?.carried_forward ? "最近有效行情" : "行情日期"} ${payload.meta?.trade_date || "--"}`);
|
||
document.querySelector("#themeSummary").innerHTML = [
|
||
["收录题材", number(summary.theme_count), "个", ""],
|
||
["当日上涨", number(summary.up_count), "个", "up"],
|
||
["当日下跌", number(summary.down_count), "个", "down"],
|
||
["人气题材", number(summary.hot_count), "个", "warning"],
|
||
].map(([label, value, unit, tone]) => `<div><span>${label}</span><strong class="${tone}">${value}<small>${unit}</small></strong></div>`).join("");
|
||
renderThemeDirectory();
|
||
}
|
||
|
||
function renderThemeDirectory() {
|
||
let items = [...(state.themeLibrary?.items || [])];
|
||
if (state.themeQuery) {
|
||
items = items.filter((item) => `${item.code} ${item.name}`.toLocaleLowerCase("zh-CN").includes(state.themeQuery));
|
||
}
|
||
setText("themeResultCount", `${items.length} 个`);
|
||
document.querySelector("#themeDirectory").innerHTML = items.map((item, index) => {
|
||
const active = item.code === state.selectedThemeCode;
|
||
return `
|
||
<button type="button" class="theme-directory-item-v2 ${active ? "active" : ""}" data-theme-code="${escapeHtml(item.code)}" aria-pressed="${active}">
|
||
<span class="theme-rank-v2">${index + 1}</span>
|
||
<span class="theme-directory-copy-v2"><strong class="market-preview-trigger" data-market-preview-type="theme" data-market-preview-id="${escapeHtml(item.code)}" title="悬停预览题材行情">${escapeHtml(item.name)}</strong><small>${number(item.member_count)} 只成分${item.hot_rank ? ` · 人气第 ${number(item.hot_rank)}` : ""}</small></span>
|
||
<b class="${changeClass(item.change)}">${item.has_quote ? `${signed(item.change)}%` : "--"}</b>
|
||
</button>`;
|
||
}).join("") || '<div class="empty-state">没有匹配的题材</div>';
|
||
}
|
||
|
||
async function selectTheme(code, keepSelection = false) {
|
||
if (!code) return;
|
||
state.selectedThemeCode = code;
|
||
if (!keepSelection) renderThemeDirectory();
|
||
document.querySelector("#themeDetailEmpty").hidden = false;
|
||
document.querySelector("#themeDetailContent").hidden = true;
|
||
setText("themeDetailEmpty", "正在读取题材详情");
|
||
try {
|
||
const query = new URLSearchParams({ code, trade_date: elements.tradeDate.value });
|
||
state.themeDetail = await apiRequest(`/api/themes/detail?${query}`);
|
||
renderThemeDetail();
|
||
} catch (error) {
|
||
setText("themeDetailEmpty", error.message || "题材详情加载失败");
|
||
showToast(error.message || "题材详情加载失败");
|
||
}
|
||
}
|
||
|
||
function renderThemeDetail() {
|
||
const payload = state.themeDetail;
|
||
if (!payload) return;
|
||
const theme = payload.theme || {};
|
||
const summary = payload.summary || {};
|
||
document.querySelector("#themeDetailEmpty").hidden = true;
|
||
document.querySelector("#themeDetailContent").hidden = false;
|
||
setText("themeDetailName", theme.name || "--");
|
||
setText("themeDetailCode", `${theme.code || "--"} · ${payload.meta?.trade_date || "--"}`);
|
||
setText("themeDetailChange", `${signed(theme.change)}%`);
|
||
document.querySelector("#themeDetailChange").className = changeClass(theme.change);
|
||
document.querySelector("#themeDetailMetrics").innerHTML = [
|
||
["成分股", `${number(summary.member_count)} 只`, ""],
|
||
["有行情", `${number(summary.quoted_count)} 只`, ""],
|
||
["上涨", `${number(summary.up_count)} 只`, "up"],
|
||
["下跌", `${number(summary.down_count)} 只`, "down"],
|
||
["换手率", `${formatNumber(theme.turnover_rate, 2)}%`, ""],
|
||
].map(([label, value, tone]) => `<div><span>${label}</span><strong class="${tone}">${value}</strong></div>`).join("");
|
||
setText("themeMemberCount", `有行情 ${number(summary.quoted_count)} / ${number(summary.member_count)}`);
|
||
const body = document.querySelector("#themeMemberTableBody");
|
||
body.innerHTML = (payload.members || []).map((row, index) => `
|
||
<tr data-code="${escapeHtml(row.code)}"><td class="row-number num muted">${index + 1}</td>
|
||
<td><span class="stock-cell"><strong class="stock-name sname">${escapeHtml(row.name)}</strong><small class="stock-code scode">${escapeHtml(row.code)}</small></span></td>
|
||
<td class="number num ${changeClass(row.change)}">${row.has_quote ? signed(row.change) : ""}</td>
|
||
<td class="number num">${row.has_quote ? formatNumber(row.price, 2) : ""}</td><td class="number num">${row.has_quote ? formatNumber(row.amount_billion, 2) : ""}</td></tr>`).join("");
|
||
bindStockRows(body);
|
||
renderThemeDirectory();
|
||
}
|
||
|
||
async function loadPopularity(force = false) {
|
||
if (state.popularityLoading) return;
|
||
state.popularityLoading = true;
|
||
const button = document.querySelector("#popularityRefreshButton");
|
||
button.disabled = true;
|
||
setText("popularityDateLabel", "正在读取人气榜");
|
||
try {
|
||
const query = new URLSearchParams({ trade_date: elements.tradeDate.value });
|
||
if (force) query.set("force", "1");
|
||
state.popularityData = await apiRequest(`/api/popularity?${query}`);
|
||
renderPopularity();
|
||
} catch (error) {
|
||
setText("popularityDateLabel", error.message || "人气榜暂不可用");
|
||
document.querySelector("#popularityTableBody").innerHTML = "";
|
||
document.querySelector("#popularityEmpty").hidden = false;
|
||
showToast(error.message || "人气榜加载失败");
|
||
} finally {
|
||
state.popularityLoading = false;
|
||
button.disabled = false;
|
||
}
|
||
}
|
||
|
||
function renderPopularity() {
|
||
const payload = state.popularityData;
|
||
if (!payload) return;
|
||
const summary = payload.summary || {};
|
||
setText("popularityDateLabel", `${payload.meta?.carried_forward ? "最近有效榜单" : "榜单日期"} ${payload.meta?.trade_date || "--"}`);
|
||
const topNames = (rows) => (rows || []).slice(0, 3).map((item) => item.name).filter(Boolean).join(" · ") || "--";
|
||
document.querySelector("#popularitySummary").innerHTML = [
|
||
["同花顺热度 Top3", topNames(payload.ths), `共 ${number(summary.ths_count)} 只上榜`],
|
||
["东方财富热度 Top3", topNames(payload.dc), `共 ${number(summary.dc_count)} 只上榜`],
|
||
["双榜共识", `${number(summary.dual_count)} 只`, "同时进入两榜,共识度更高"],
|
||
].map(([label, value, detail], index) => `<article class="${index === 2 ? "consensus" : ""}"><span>${label}</span><strong>${escapeHtml(value)}</strong><small>${escapeHtml(detail)}</small></article>`).join("");
|
||
renderPopularityTable();
|
||
}
|
||
|
||
function renderPopularityTable() {
|
||
const source = state.popularitySource;
|
||
let rows = [...(state.popularityData?.[source] || [])];
|
||
if (state.popularityQuery) {
|
||
rows = rows.filter((item) => `${item.code} ${item.name} ${(item.concepts || []).join(" ")}`.toLocaleLowerCase("zh-CN").includes(state.popularityQuery));
|
||
}
|
||
const combined = source === "combined";
|
||
const sourceName = source === "ths" ? "同花顺" : source === "dc" ? "东方财富" : "双榜综合";
|
||
setText("popularityTableTitle", `${sourceName}榜`);
|
||
setText("popularityTableNote", combined ? "按双榜排名综合排序 · 已隐藏重复的榜单状态" : "按榜单名次排序 · 状态显示是否同时进入另一榜");
|
||
const headers = [
|
||
["排名", "number num"], ["股票", ""], ["最新价(元)", "number num"], ["涨跌幅(%)", "number num"],
|
||
...(source !== "dc" ? [["同花顺", "number num"]] : []),
|
||
...(source !== "ths" ? [["东方财富", "number num"]] : []),
|
||
["排名变化", "number num"], ["热门概念", ""], ...(!combined ? [["榜单状态", ""]] : []),
|
||
];
|
||
document.querySelector("#popularityTableHead").innerHTML = headers.map(([label, className]) => `<th scope="col" class="${className}">${label}</th>`).join("");
|
||
const body = document.querySelector("#popularityTableBody");
|
||
body.innerHTML = rows.map((row, index) => {
|
||
const thsRank = source === "ths" ? row.rank : row.ths_rank;
|
||
const dcRank = source === "dc" ? row.rank : row.dc_rank;
|
||
const move = row.rank_change;
|
||
const movement = move === null || move === undefined ? "新" : number(move) > 0 ? `↑${number(move)}` : number(move) < 0 ? `↓${Math.abs(number(move))}` : "持平";
|
||
return `<tr data-code="${escapeHtml(row.code)}">
|
||
<td class="number num popularity-rank-v2"><b>${index + 1}</b>${index < 3 ? '<span>热</span>' : ""}</td>
|
||
<td><div class="popularity-stock-v2"><strong class="stock-name sname">${escapeHtml(row.name)}</strong><span class="stock-code scode">${escapeHtml(row.code)}</span></div></td>
|
||
<td class="number num">${row.price == null ? "" : formatNumber(row.price, 2)}</td>
|
||
<td class="number num ${row.change == null ? "" : changeClass(row.change)}">${row.change == null ? "" : signed(row.change)}</td>
|
||
${source !== "dc" ? `<td class="number num popularity-list-rank-v2">${thsRank ? number(thsRank) : ""}</td>` : ""}
|
||
${source !== "ths" ? `<td class="number num popularity-list-rank-v2">${dcRank ? number(dcRank) : ""}</td>` : ""}
|
||
<td class="number num popularity-movement-v2 ${number(move) > 0 ? "up" : number(move) < 0 ? "down" : ""}">${movement}</td>
|
||
<td class="popularity-concepts-v2" title="${escapeHtml((row.concepts || []).join("、"))}">${escapeHtml((row.concepts || []).slice(0, 3).join("、"))}</td>
|
||
${!combined ? `<td><span class="popularity-source-tag-v2 ${row.dual_source ? "dual" : ""}">${row.dual_source ? "双榜共识" : "单榜入选"}</span></td>` : ""}
|
||
</tr>`;
|
||
}).join("");
|
||
bindStockRows(body);
|
||
markAutoSortableHeaders(body.closest("table"));
|
||
document.querySelector("#popularityEmpty").hidden = rows.length > 0;
|
||
}
|
||
|
||
function selectDragonViewMode(mode) {
|
||
state.dragonViewMode = mode === "profiles" ? "profiles" : "daily";
|
||
document.querySelectorAll("[data-dragon-view-mode]").forEach((button) => {
|
||
const active = button.dataset.dragonViewMode === state.dragonViewMode;
|
||
button.classList.toggle("active", active);
|
||
button.setAttribute("aria-pressed", String(active));
|
||
});
|
||
if (state.dragonViewMode === "profiles") {
|
||
document.querySelector("#dragonDailyContent").hidden = true;
|
||
document.querySelector("#dragonEmptyState").hidden = true;
|
||
document.querySelector("#dragonProfilesContent").hidden = false;
|
||
if (state.hotMoneyProfiles) renderHotMoneyProfiles();
|
||
else loadHotMoneyProfiles();
|
||
} else {
|
||
document.querySelector("#dragonProfilesContent").hidden = true;
|
||
if (state.dragonTiger) renderDragonTiger();
|
||
else loadDragonTiger();
|
||
}
|
||
}
|
||
|
||
async function loadHotMoneyProfiles(force = false) {
|
||
if (!force && state.hotMoneyProfiles) {
|
||
renderHotMoneyProfiles();
|
||
return;
|
||
}
|
||
setStatus("正在加载游资档案");
|
||
try {
|
||
const query = new URLSearchParams();
|
||
if (force) query.set("force", "1");
|
||
const suffix = query.size ? `?${query}` : "";
|
||
state.hotMoneyProfiles = await apiRequest(`/api/dragon-tiger/profiles${suffix}`);
|
||
renderHotMoneyProfiles();
|
||
const count = number(state.hotMoneyProfiles.summary?.profile_count);
|
||
setStatus(`游资档案已加载 · 共 ${count} 位`);
|
||
} catch (error) {
|
||
showToast(error.message || "游资档案加载失败");
|
||
setStatus("游资档案加载失败");
|
||
}
|
||
}
|
||
|
||
function renderHotMoneyProfiles() {
|
||
const payload = state.hotMoneyProfiles;
|
||
if (!payload) return;
|
||
const profiles = payload.profiles || [];
|
||
const summary = payload.summary || {};
|
||
const query = state.hotMoneyProfileQuery;
|
||
const visible = profiles.filter((profile) => {
|
||
if (!query) return true;
|
||
return [profile.name, profile.description, ...(profile.organizations || [])]
|
||
.join(" ")
|
||
.toLocaleLowerCase("zh-CN")
|
||
.includes(query);
|
||
});
|
||
if (!visible.some((profile) => profile.id === state.selectedHotMoneyProfileId)) {
|
||
state.selectedHotMoneyProfileId = visible[0]?.id || "";
|
||
}
|
||
const selected = visible.find((profile) => profile.id === state.selectedHotMoneyProfileId) || null;
|
||
|
||
setText("dragonDateLabel", `收录 ${number(summary.profile_count)} 位`);
|
||
setText("hotMoneyProfileResultCount", query ? `${visible.length} / ${profiles.length} 位` : `${profiles.length} 位`);
|
||
document.querySelector("#hotMoneyProfileSummary").innerHTML = [
|
||
["收录游资", number(summary.profile_count)],
|
||
["已有简介", number(summary.described_count)],
|
||
["关联席位", number(summary.organization_count)],
|
||
].map(([label, value]) => `<span><small>${label}</small><strong>${value}</strong></span>`).join("");
|
||
|
||
const list = document.querySelector("#hotMoneyProfileList");
|
||
list.innerHTML = visible.length ? visible.map((profile, index) => `
|
||
<button class="hot-money-profile-row-v2 ${profile.id === state.selectedHotMoneyProfileId ? "selected" : ""}"
|
||
type="button" role="option" aria-selected="${profile.id === state.selectedHotMoneyProfileId}"
|
||
data-hot-money-profile="${escapeHtml(profile.id)}">
|
||
<span class="hot-money-profile-index-v2">${String(index + 1).padStart(2, "0")}</span>
|
||
<span class="hot-money-profile-monogram-v2">${escapeHtml(profile.name.slice(0, 2))}</span>
|
||
<span class="hot-money-profile-row-copy-v2">
|
||
<strong>${escapeHtml(profile.name)}</strong>
|
||
<small>${escapeHtml(profile.description || "暂未收录简介")}</small>
|
||
</span>
|
||
<span class="hot-money-profile-seat-count-v2">${number(profile.organization_count)} 席</span>
|
||
</button>`).join("") : `
|
||
<div class="hot-money-profile-list-empty-v2">
|
||
<i data-lucide="search-x" aria-hidden="true"></i>
|
||
<span>${profiles.length ? "没有符合条件的游资档案" : "游资名录暂不可用"}</span>
|
||
</div>`;
|
||
|
||
const detail = document.querySelector("#hotMoneyProfileDetail");
|
||
if (!selected) {
|
||
detail.innerHTML = `
|
||
<div class="hot-money-profile-empty-v2">
|
||
<i data-lucide="contact" aria-hidden="true"></i>
|
||
<strong>${profiles.length ? "选择一位游资查看档案" : "暂无可展示的游资档案"}</strong>
|
||
</div>`;
|
||
} else {
|
||
const organizations = selected.organizations || [];
|
||
detail.innerHTML = `
|
||
<header class="hot-money-profile-detail-head-v2">
|
||
<span class="hot-money-profile-avatar-v2">${escapeHtml(selected.name.slice(0, 2))}</span>
|
||
<div>
|
||
<small>游资档案</small>
|
||
<h3>${escapeHtml(selected.name)}</h3>
|
||
<span>${organizations.length ? `关联 ${organizations.length} 个公开席位` : "暂无关联席位"}</span>
|
||
</div>
|
||
</header>
|
||
<section class="hot-money-profile-section-v2">
|
||
<h4>人物简介</h4>
|
||
<p class="${selected.description ? "" : "is-empty"}">${escapeHtml(selected.description || "名录暂未收录该游资的公开简介。")}</p>
|
||
</section>
|
||
<section class="hot-money-profile-section-v2 hot-money-profile-org-section-v2">
|
||
<div class="hot-money-profile-section-title-v2">
|
||
<h4>关联营业部</h4>
|
||
<span>${organizations.length} 个</span>
|
||
</div>
|
||
<div class="hot-money-profile-organizations-v2">
|
||
${organizations.length ? organizations.map((organization) => `
|
||
<span><i data-lucide="building-2" aria-hidden="true"></i>${escapeHtml(organization)}</span>
|
||
`).join("") : '<p class="is-empty">名录暂未收录关联营业部。</p>'}
|
||
</div>
|
||
</section>
|
||
${payload.meta?.notice ? `<p class="hot-money-profile-notice-v2">${escapeHtml(payload.meta.notice)}</p>` : ""}`;
|
||
}
|
||
refreshIcons();
|
||
}
|
||
|
||
async function loadDragonTiger(force = false) {
|
||
const requestedDate = elements.tradeDate.value;
|
||
if (
|
||
!force
|
||
&& ["success", "empty", "partial", "unavailable"].includes(state.dragonTiger?.meta?.status)
|
||
&& (state.dragonTiger?.meta?.requested_date || state.dragonTiger?.meta?.trade_date) === requestedDate
|
||
) {
|
||
renderDragonTiger();
|
||
return;
|
||
}
|
||
setStatus("正在加载龙虎榜");
|
||
try {
|
||
const query = new URLSearchParams({ trade_date: requestedDate });
|
||
if (force) query.set("force", "1");
|
||
const payload = await apiRequest(`/api/dragon-tiger?${query}`);
|
||
state.dragonTiger = payload;
|
||
renderDragonTiger();
|
||
const statusLabel = payload.meta.status === "error"
|
||
? "龙虎榜数据暂不可用"
|
||
: payload.meta.status === "empty"
|
||
? "当日暂无公开游资明细"
|
||
: payload.meta.status === "partial"
|
||
? "当日有龙虎榜,暂无命名游资明细"
|
||
: payload.meta.status === "unavailable" ? "龙虎榜数据暂不可用" : "龙虎榜明细";
|
||
setStatus(`${statusLabel} · 龙虎榜已加载`);
|
||
} catch (error) {
|
||
showToast(error.message || "龙虎榜加载失败");
|
||
setStatus("龙虎榜加载失败");
|
||
}
|
||
}
|
||
|
||
function renderDragonTiger() {
|
||
const payload = state.dragonTiger;
|
||
if (!payload) return;
|
||
const summary = payload.summary || {};
|
||
if (state.dragonViewMode === "daily") setText("dragonDateLabel", `数据日期 ${payload.meta.trade_date}`);
|
||
const status = payload.meta?.status || "empty";
|
||
const hasRecognizedTraders = (payload.traders || []).some((item) => item.identity_type === "trader" && item.recognized !== false);
|
||
const showEmptyState = !hasRecognizedTraders
|
||
&& !(payload.unclassified_seats || []).length
|
||
&& ["empty", "error", "unavailable"].includes(status);
|
||
const dailyVisible = state.dragonViewMode === "daily";
|
||
document.querySelector("#dragonProfilesContent").hidden = dailyVisible;
|
||
document.querySelector("#dragonEmptyState").hidden = !dailyVisible || !showEmptyState;
|
||
document.querySelector("#dragonDailyContent").hidden = !dailyVisible || showEmptyState;
|
||
if (showEmptyState) {
|
||
const unavailable = ["error", "unavailable"].includes(status);
|
||
setText("dragonEmptyTitle", unavailable ? "龙虎榜数据暂不可用" : `${payload.meta?.trade_date || "该交易日"} 暂无龙虎榜明细`);
|
||
setText("dragonEmptyDescription", unavailable
|
||
? "当前数据暂未完成更新,可稍后重新检查或查看前一交易日。"
|
||
: "龙虎榜明细通常在交易日盘后陆续披露,可稍后刷新或查看前一交易日。");
|
||
}
|
||
document.querySelector("#dragonSummary").innerHTML = [
|
||
["上榜游资", `${number(summary.trader_count)} 位`, ""],
|
||
["操作明细", `${number(summary.operation_count)} 条`, ""],
|
||
["席位净买入", formatMoneyMillion(summary.seat_net_buy_million), changeClass(summary.seat_net_buy_million)],
|
||
["活跃股票", `${number(summary.active_stock_count)} 只`, ""],
|
||
].map(([label, value, className]) => `<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.hidden = true;
|
||
container.innerHTML = '<div class="empty-state dragon-empty">选择一位游资查看操作明细</div>';
|
||
return;
|
||
}
|
||
container.hidden = false;
|
||
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 tbl-wrap">
|
||
<table class="data-table tbl dragon-operation-table">
|
||
<colgroup><col class="dragon-col-index"><col class="dragon-col-stock"><col class="dragon-col-direction"><col class="dragon-col-number"><col class="dragon-col-number"><col class="dragon-col-number"><col class="dragon-col-number"><col class="dragon-col-seat"><col class="dragon-col-reason"></colgroup>
|
||
<thead><tr><th class="row-number num">序号</th><th>股票</th><th>方向</th><th class="number num">涨幅(%)</th><th class="number num">买入(百万)</th><th class="number num">卖出(百万)</th><th class="number num">净额(百万)</th><th>关联席位</th><th class="reason-column">标签 / 上榜原因</th></tr></thead>
|
||
<tbody>${(trader.operations || []).map((operation, index) => `
|
||
<tr data-code="${escapeHtml(operation.code)}">
|
||
<td class="row-number num">${index + 1}</td>
|
||
<td><strong class="sname">${escapeHtml(operation.name)}</strong><span class="scode">${escapeHtml(operation.code)}</span></td>
|
||
<td><span class="direction-label ${changeClass(operation.net_buy_million)}">${escapeHtml(operation.direction)}</span></td>
|
||
<td class="number num ${operation.change == null ? "" : changeClass(operation.change)}">${operation.change == null ? "" : signed(operation.change)}</td>
|
||
<td class="number num">${operation.buy_million == null ? "" : formatNumber(operation.buy_million, 2)}</td>
|
||
<td class="number num">${operation.sell_million == null ? "" : formatNumber(operation.sell_million, 2)}</td>
|
||
<td class="number num ${operation.net_buy_million == null ? "" : changeClass(operation.net_buy_million)}">${operation.net_buy_million == null ? "" : signed(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 && operation.reason !== "--" ? 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?trade_date=${encodeURIComponent(elements.tradeDate.value)}`),
|
||
apiRequest("/api/notes?scope=daily"),
|
||
apiRequest("/api/trades"),
|
||
]);
|
||
state.watchlist = watchlistPayload.items || [];
|
||
state.notes = notesPayload.items || [];
|
||
state.tradeEntries = tradesPayload.items || [];
|
||
state.tradeSummary = tradesPayload.summary || {};
|
||
setText("reviewDataDate", displayCompactDate(elements.tradeDate.value));
|
||
renderWatchlist();
|
||
renderNotesHistory(state.notes, document.querySelector("#notesHistory"), false);
|
||
setText("notesCount", `${state.notes.length} 条`);
|
||
renderTradeLog();
|
||
populateJournalForm();
|
||
} catch (error) {
|
||
showToast(error.message || "我的复盘加载失败");
|
||
}
|
||
}
|
||
|
||
function renderWatchlist() {
|
||
setText("watchlistCount", `${state.watchlist.length} 只`);
|
||
const body = document.querySelector("#watchlistTableBody");
|
||
body.innerHTML = state.watchlist.map((item) => `
|
||
<tr data-code="${escapeHtml(item.code)}"><td><span class="review-watch-mark ${escapeHtml(item.color)}" title="${escapeHtml(item.color)}">★</span></td>
|
||
<td><span class="stock-cell"><strong class="stock-name sname">${escapeHtml(item.name)}</strong><small class="stock-code scode">${escapeHtml(item.code)}</small></span></td>
|
||
<td>${escapeHtml(item.sector || "其他")}</td>
|
||
<td class="number num ${item.change == null ? "" : changeClass(item.change)}">${formatWatchMetric(item.change)}</td>
|
||
<td class="number num ${item.return_5d == null ? "" : changeClass(item.return_5d)}">${formatWatchMetric(item.return_5d)}</td>
|
||
<td class="number num"><strong class="watch-attention-score">${item.attention_score == null ? "" : formatNumber(item.attention_score, 1)}</strong></td>
|
||
<td><span class="watch-remark" title="${escapeHtml(item.remark || "尚未填写跟踪备注")}">${escapeHtml(item.remark || "尚未填写")}</span></td>
|
||
<td><span class="review-row-actions"><button class="table-action" type="button" data-watch-remark="${escapeHtml(item.code)}">备注</button>
|
||
<button class="table-action down" type="button" data-watch-delete="${escapeHtml(item.code)}" aria-label="移除 ${escapeHtml(item.name)}">移除</button></span></td></tr>
|
||
`).join("");
|
||
document.querySelector("#watchlistEmpty").hidden = state.watchlist.length > 0;
|
||
body.querySelectorAll("[data-watch-remark]").forEach((button) => {
|
||
button.addEventListener("click", () => {
|
||
const item = state.watchlist.find((row) => row.code === button.dataset.watchRemark);
|
||
openWatchlistDialog(item);
|
||
});
|
||
});
|
||
body.querySelectorAll("[data-watch-delete]").forEach((button) => {
|
||
button.addEventListener("click", () => removeWatchlist(button.dataset.watchDelete));
|
||
});
|
||
bindStockRows(body);
|
||
}
|
||
|
||
function formatWatchMetric(value) {
|
||
if (value == null || !Number.isFinite(Number(value))) return "";
|
||
return signed(value);
|
||
}
|
||
|
||
function openWatchlistDialog(item = null) {
|
||
clearTimeout(watchlistSearchTimer);
|
||
state.watchlistSelection = item ? {
|
||
code: item.code,
|
||
name: item.name,
|
||
sector: item.sector || "其他",
|
||
color: item.color || "red",
|
||
} : null;
|
||
state.watchlistSearchResults = [];
|
||
setText("watchlistDialogTitle", item ? "编辑跟踪备注" : "添加自选");
|
||
document.querySelector("#watchlistRemark").value = item?.remark || "";
|
||
document.querySelector("#watchlistSearchInput").value = "";
|
||
document.querySelector("#watchlistSearchResults").innerHTML = "";
|
||
syncWatchlistSelection(Boolean(item));
|
||
openModalDialog(elements.watchlistDialog);
|
||
requestAnimationFrame(() => (item ? document.querySelector("#watchlistRemark") : document.querySelector("#watchlistSearchInput")).focus());
|
||
}
|
||
|
||
function closeWatchlistDialog() {
|
||
clearTimeout(watchlistSearchTimer);
|
||
if (elements.watchlistDialog.open) elements.watchlistDialog.close();
|
||
}
|
||
|
||
function clearWatchlistSelection() {
|
||
state.watchlistSelection = null;
|
||
syncWatchlistSelection(false);
|
||
document.querySelector("#watchlistSearchInput").focus();
|
||
}
|
||
|
||
function syncWatchlistSelection(editing = false) {
|
||
const item = state.watchlistSelection;
|
||
document.querySelector("#watchlistSearchField").hidden = Boolean(item);
|
||
document.querySelector("#watchlistSelection").hidden = !item;
|
||
document.querySelector("#changeWatchlistSelection").hidden = editing;
|
||
document.querySelector("#saveWatchlist").disabled = !item;
|
||
if (!item) return;
|
||
setText("watchlistSelectionName", item.name || "--");
|
||
setText("watchlistSelectionCode", item.code || "--");
|
||
setText("watchlistSelectionSector", item.sector || "其他");
|
||
refreshIcons();
|
||
}
|
||
|
||
function scheduleWatchlistSearch() {
|
||
clearTimeout(watchlistSearchTimer);
|
||
const query = document.querySelector("#watchlistSearchInput").value.trim();
|
||
if (!query) {
|
||
document.querySelector("#watchlistSearchResults").innerHTML = "";
|
||
return;
|
||
}
|
||
document.querySelector("#watchlistSearchResults").innerHTML = '<div class="watchlist-search-status">正在查找股票</div>';
|
||
watchlistSearchTimer = setTimeout(() => runWatchlistSearch(query), 160);
|
||
}
|
||
|
||
async function runWatchlistSearch(query) {
|
||
const sequence = ++state.watchlistSearchRequestSequence;
|
||
try {
|
||
const params = new URLSearchParams({ q: query, trade_date: elements.tradeDate.value });
|
||
const payload = await apiRequest(`/api/search?${params}`);
|
||
if (sequence !== state.watchlistSearchRequestSequence) return;
|
||
state.watchlistSearchResults = payload.groups?.stocks || [];
|
||
document.querySelector("#watchlistSearchResults").innerHTML = state.watchlistSearchResults.map((item, index) => `
|
||
<button type="button" data-watchlist-result="${index}"><span><strong>${escapeHtml(item.name)}</strong><small>${escapeHtml(item.industry || "其他")}</small></span><b>${escapeHtml(item.code)}</b></button>
|
||
`).join("") || '<div class="watchlist-search-status">没有找到匹配的股票</div>';
|
||
} catch (error) {
|
||
document.querySelector("#watchlistSearchResults").innerHTML = `<div class="watchlist-search-status">${escapeHtml(error.message || "搜索失败")}</div>`;
|
||
}
|
||
}
|
||
|
||
function handleWatchlistSearchResult(event) {
|
||
const button = event.target.closest("[data-watchlist-result]");
|
||
if (!button) return;
|
||
const item = state.watchlistSearchResults[number(button.dataset.watchlistResult)];
|
||
if (!item) return;
|
||
state.watchlistSelection = {
|
||
code: item.code,
|
||
name: item.name,
|
||
sector: item.industry || "其他",
|
||
color: "red",
|
||
};
|
||
syncWatchlistSelection(false);
|
||
}
|
||
|
||
async function saveWatchlistFromDialog(event) {
|
||
event.preventDefault();
|
||
const item = state.watchlistSelection;
|
||
if (!item) return;
|
||
const button = document.querySelector("#saveWatchlist");
|
||
button.disabled = true;
|
||
try {
|
||
await apiRequest("/api/watchlist", "POST", {
|
||
code: item.code,
|
||
name: item.name,
|
||
sector: item.sector || "其他",
|
||
color: item.color || "red",
|
||
remark: document.querySelector("#watchlistRemark").value.trim(),
|
||
});
|
||
closeWatchlistDialog();
|
||
await loadReviewWorkspace();
|
||
showToast(state.watchlist.some((row) => row.code === item.code) ? "自选跟踪已保存" : "已加入自选");
|
||
} catch (error) {
|
||
showToast(error.message || "自选保存失败");
|
||
button.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function toggleActiveWatchlist() {
|
||
const stock = state.activeStock;
|
||
if (!stock?.code) return;
|
||
const isWatched = Boolean(state.stockDetail?.stock?.watchlist || state.watchlist.some((item) => item.code === stock.code));
|
||
try {
|
||
if (isWatched) {
|
||
await apiRequest(`/api/watchlist/${stock.code}`, "DELETE");
|
||
state.watchlist = state.watchlist.filter((item) => item.code !== stock.code);
|
||
if (state.stockDetail?.stock) state.stockDetail.stock.watchlist = null;
|
||
showToast("已移出自选");
|
||
} else {
|
||
const payload = await apiRequest("/api/watchlist", "POST", {
|
||
code: stock.code,
|
||
name: stock.name || "--",
|
||
sector: stock.sector || "其他",
|
||
color: "red",
|
||
});
|
||
state.watchlist = payload.items || state.watchlist;
|
||
if (state.stockDetail?.stock) state.stockDetail.stock.watchlist = state.watchlist.find((item) => item.code === stock.code);
|
||
showToast("已加入自选");
|
||
}
|
||
updateWatchButton();
|
||
renderWatchlist();
|
||
} catch (error) {
|
||
showToast(error.message);
|
||
}
|
||
}
|
||
|
||
function updateWatchButton() {
|
||
const code = state.activeStock?.code;
|
||
const watched = Boolean(state.stockDetail?.stock?.watchlist || state.watchlist.some((item) => item.code === code));
|
||
setText("watchStockButton", watched ? "移出自选" : "加入自选");
|
||
}
|
||
|
||
async function removeWatchlist(code) {
|
||
try {
|
||
await apiRequest(`/api/watchlist/${code}`, "DELETE");
|
||
state.watchlist = state.watchlist.filter((item) => item.code !== code);
|
||
renderWatchlist();
|
||
showToast("已移出自选");
|
||
} catch (error) {
|
||
showToast(error.message);
|
||
}
|
||
}
|
||
|
||
async function saveJournal(event) {
|
||
event.preventDefault();
|
||
try {
|
||
await apiRequest("/api/notes", "POST", {
|
||
trade_date: document.querySelector("#journalDate").value,
|
||
id: state.editingDailyNoteId || undefined,
|
||
summary: document.querySelector("#journalSummary").value,
|
||
content: document.querySelector("#journalContent").value,
|
||
plan: document.querySelector("#journalPlan").value,
|
||
});
|
||
await loadReviewWorkspace();
|
||
showToast("每日复盘已保存");
|
||
} catch (error) {
|
||
showToast(error.message);
|
||
}
|
||
}
|
||
|
||
function populateJournalForm() {
|
||
const selectedDate = document.querySelector("#journalDate").value.replaceAll("-", "");
|
||
const note = state.notes.find((item) => String(item.trade_date).replaceAll("-", "") === selectedDate);
|
||
state.editingDailyNoteId = number(note?.id);
|
||
document.querySelector("#journalSummary").value = note?.summary || "";
|
||
document.querySelector("#journalContent").value = note?.content || "";
|
||
document.querySelector("#journalPlan").value = note?.plan || "";
|
||
}
|
||
|
||
function openTradeLogDialog() {
|
||
resetTradeLogForm();
|
||
openModalDialog(elements.tradeLogDialog);
|
||
requestAnimationFrame(() => document.querySelector("#tradeLogCode").focus());
|
||
}
|
||
|
||
function closeTradeLogDialog() {
|
||
if (elements.tradeLogDialog.open) elements.tradeLogDialog.close();
|
||
else resetTradeLogForm();
|
||
}
|
||
|
||
async function saveTradeLog(event) {
|
||
event.preventDefault();
|
||
const button = document.querySelector("#saveTradeLog");
|
||
button.disabled = true;
|
||
try {
|
||
const payload = await apiRequest("/api/trades", "POST", {
|
||
id: state.editingTradeId || undefined,
|
||
trade_date: document.querySelector("#tradeLogDate").value,
|
||
code: document.querySelector("#tradeLogCode").value.trim(),
|
||
name: document.querySelector("#tradeLogName").value.trim(),
|
||
action: document.querySelector("#tradeLogAction").value,
|
||
price: document.querySelector("#tradeLogPrice").value,
|
||
quantity: document.querySelector("#tradeLogQuantity").value,
|
||
position_pct: document.querySelector("#tradeLogPosition").value,
|
||
pnl_amount: document.querySelector("#tradeLogPnlAmount").value,
|
||
pnl_pct: document.querySelector("#tradeLogPnlPct").value,
|
||
emotion: document.querySelector("#tradeLogEmotion").value,
|
||
tags: document.querySelector("#tradeLogTags").value,
|
||
thesis: document.querySelector("#tradeLogThesis").value,
|
||
execution: document.querySelector("#tradeLogExecution").value,
|
||
});
|
||
state.tradeEntries = payload.items || [];
|
||
state.tradeSummary = payload.summary || {};
|
||
renderTradeLog();
|
||
closeTradeLogDialog();
|
||
showToast("交易记录已保存");
|
||
} catch (error) {
|
||
showToast(error.message || "交易记录保存失败");
|
||
} finally {
|
||
button.disabled = false;
|
||
}
|
||
}
|
||
|
||
function resetTradeLogForm() {
|
||
state.editingTradeId = 0;
|
||
document.querySelector("#tradeLogForm").reset();
|
||
document.querySelector("#tradeLogDate").value = elements.tradeDate.value || todayString();
|
||
document.querySelector("#tradeLogQuantity").value = "0";
|
||
document.querySelector("#tradeLogPosition").value = "0";
|
||
setText("tradeLogDialogTitle", "交易日志");
|
||
setText("saveTradeLog", "保存交易");
|
||
}
|
||
|
||
function editTradeLog(id) {
|
||
const item = state.tradeEntries.find((entry) => number(entry.id) === id);
|
||
if (!item) return;
|
||
state.editingTradeId = id;
|
||
document.querySelector("#tradeLogDate").value = displayCompactDate(item.trade_date);
|
||
document.querySelector("#tradeLogCode").value = item.code;
|
||
document.querySelector("#tradeLogName").value = item.name;
|
||
document.querySelector("#tradeLogAction").value = item.action;
|
||
document.querySelector("#tradeLogPrice").value = item.price;
|
||
document.querySelector("#tradeLogQuantity").value = item.quantity;
|
||
document.querySelector("#tradeLogPosition").value = item.position_pct;
|
||
document.querySelector("#tradeLogPnlAmount").value = item.pnl_amount ?? "";
|
||
document.querySelector("#tradeLogPnlPct").value = item.pnl_pct ?? "";
|
||
document.querySelector("#tradeLogEmotion").value = item.emotion;
|
||
document.querySelector("#tradeLogTags").value = (item.tags || []).join(", ");
|
||
document.querySelector("#tradeLogThesis").value = item.thesis || "";
|
||
document.querySelector("#tradeLogExecution").value = item.execution || "";
|
||
setText("tradeLogDialogTitle", "编辑交易日志");
|
||
setText("saveTradeLog", "保存修改");
|
||
openModalDialog(elements.tradeLogDialog);
|
||
requestAnimationFrame(() => document.querySelector("#tradeLogCode").focus());
|
||
}
|
||
|
||
async function handleTradeLogAction(event) {
|
||
const button = event.target.closest("[data-trade-action]");
|
||
if (!button) return;
|
||
const id = number(button.dataset.tradeId);
|
||
if (button.dataset.tradeAction === "edit") {
|
||
editTradeLog(id);
|
||
return;
|
||
}
|
||
if (!window.confirm("确定删除这条交易记录吗?")) return;
|
||
try {
|
||
const payload = await apiRequest(`/api/trades/${id}`, "DELETE");
|
||
state.tradeEntries = payload.items || [];
|
||
state.tradeSummary = payload.summary || {};
|
||
if (state.editingTradeId === id) resetTradeLogForm();
|
||
renderTradeLog();
|
||
showToast("交易记录已删除");
|
||
} catch (error) {
|
||
showToast(error.message || "交易记录删除失败");
|
||
}
|
||
}
|
||
|
||
function renderTradeLog() {
|
||
const summary = state.tradeSummary || {};
|
||
setText("tradeLogCount", `${state.tradeEntries.length} 条`);
|
||
document.querySelector("#tradeLogSummary").innerHTML = [
|
||
["记录", `${number(summary.total)} 条`],
|
||
["已实现", `${number(summary.realized)} 条`],
|
||
["胜率", summary.win_rate == null ? "--" : `${formatNumber(summary.win_rate, 1)}%`],
|
||
["累计盈亏", summary.pnl_amount == null ? "--" : `${number(summary.pnl_amount) > 0 ? "+" : ""}${formatNumber(summary.pnl_amount, 2)}`],
|
||
["平均仓位", summary.average_position == null ? "--" : `${formatNumber(summary.average_position, 1)}%`],
|
||
].map(([label, value]) => `<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 class="sname">${escapeHtml(item.name)}</strong><small class="stock-code scode">${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 num">${item.position_pct == null ? "" : formatNumber(item.position_pct, 1)}</td>
|
||
<td class="number num ${item.pnl_pct == null ? "" : changeClass(item.pnl_pct)}">${item.pnl_pct == null ? "" : signed(item.pnl_pct)}</td>
|
||
<td class="number num ${item.pnl_amount == null ? "" : changeClass(item.pnl_amount)}">${item.pnl_amount == null ? "" : signed(item.pnl_amount)}</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.execution || "")}"><strong>${escapeHtml(item.thesis || "")}</strong><small>${escapeHtml(item.execution || "尚未填写执行复核")}</small></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>
|
||
${!compact ? `<div class="note-block note-summary"><strong>盘面</strong><p>${escapeHtml(note.summary || "--")}</p></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;
|
||
}
|
||
}
|
||
|
||
function screenerStrategyKey(strategyId, strategyName) {
|
||
return strategyId != null && strategyId !== 0 ? `id:${strategyId}` : `name:${strategyName || ""}`;
|
||
}
|
||
|
||
function currentScreenerStrategy(mode) {
|
||
if (mode === "curated") return activeCuratedStrategy();
|
||
if (mode === "smart") return state.selectedStrategy;
|
||
return null;
|
||
}
|
||
|
||
function screenerResultContext(mode, result, { regime, strategyId = null, strategyName = "" } = {}) {
|
||
const normalizedMode = ["smart", "curated", "quant"].includes(mode) ? mode : "smart";
|
||
return {
|
||
mode: normalizedMode,
|
||
regime: regime || result?.meta?.regime || state.selectedRegime,
|
||
strategyName: strategyName || result?.meta?.strategy_name || "",
|
||
strategyKey: screenerStrategyKey(
|
||
strategyId,
|
||
strategyName || result?.meta?.strategy_name || "",
|
||
),
|
||
};
|
||
}
|
||
|
||
function screenerResultKey(context) {
|
||
if (!context) return "";
|
||
if (context.mode === "quant") return "quant";
|
||
if (context.mode === "curated") return JSON.stringify(["curated", context.strategyKey]);
|
||
return JSON.stringify(["smart", context.regime, context.strategyKey]);
|
||
}
|
||
|
||
function selectedScreenerResultKey(mode) {
|
||
const normalizedMode = ["smart", "curated", "quant"].includes(mode) ? mode : "smart";
|
||
if (normalizedMode === "quant") return "quant";
|
||
const strategy = currentScreenerStrategy(mode);
|
||
if (!strategy) return "";
|
||
return screenerResultKey(screenerResultContext(normalizedMode, null, {
|
||
regime: state.selectedRegime,
|
||
strategyId: strategy.id,
|
||
strategyName: strategy.name,
|
||
}));
|
||
}
|
||
|
||
function activeScreenerResultEntry(mode = state.screenerMode) {
|
||
const key = selectedScreenerResultKey(mode);
|
||
return key ? state.screenerResultStore[key] || null : null;
|
||
}
|
||
|
||
function screenerResultMatchesSelection(mode) {
|
||
return Boolean(activeScreenerResultEntry(mode));
|
||
}
|
||
|
||
function activeScreenerResult(mode = state.screenerMode) {
|
||
return activeScreenerResultEntry(mode)?.result || null;
|
||
}
|
||
|
||
function activeScreenerResultContext(mode = state.screenerMode) {
|
||
return activeScreenerResultEntry(mode)?.context || null;
|
||
}
|
||
|
||
function storeScreenerResult(
|
||
mode,
|
||
result,
|
||
{ regime, strategyId = null, strategyName = "" } = {},
|
||
updateLatest = true,
|
||
) {
|
||
const normalizedMode = ["smart", "curated", "quant"].includes(mode) ? mode : "smart";
|
||
const context = result
|
||
? screenerResultContext(normalizedMode, result, { regime, strategyId, strategyName })
|
||
: null;
|
||
const key = screenerResultKey(context);
|
||
if (key && result) state.screenerResultStore[key] = { result, context };
|
||
if (updateLatest) {
|
||
state.screenerResults[normalizedMode] = result || null;
|
||
state.screenerResultContexts[normalizedMode] = context;
|
||
}
|
||
}
|
||
|
||
function setScreenerResult(mode, result, options = {}) {
|
||
storeScreenerResult(mode, result, options, true);
|
||
}
|
||
|
||
function applyScreenerSetup(payload, requestKey) {
|
||
const dateChanged = Boolean(state.screenerSetupKey && state.screenerSetupKey !== requestKey);
|
||
if (dateChanged) {
|
||
state.screenerResults = { smart: null, curated: null, quant: null };
|
||
state.screenerResultContexts = { smart: null, curated: null, quant: null };
|
||
state.screenerResultStore = {};
|
||
}
|
||
state.screenerSetup = payload;
|
||
state.screenerSetupKey = requestKey;
|
||
|
||
const latestResults = { ...(payload.latest_results || {}) };
|
||
if (!latestResults.smart && payload.latest_result) latestResults.smart = payload.latest_result;
|
||
const smartLatestMeta = latestResults.smart?.meta || {};
|
||
const curatedLatestMeta = latestResults.curated?.meta || {};
|
||
state.selectedRegime = payload.regime.id;
|
||
|
||
const selectedId = state.selectedStrategy?.id;
|
||
const smartStrategies = payload.strategies.filter((item) => item.formula?.meta?.library !== "curated");
|
||
const curatedStrategies = payload.strategies.filter((item) => item.formula?.meta?.library === "curated");
|
||
const latestSmartStrategy = smartStrategies.find((item) => item.name === smartLatestMeta.strategy_name);
|
||
state.selectedStrategy = smartStrategies.find((item) => item.id === selectedId)
|
||
|| latestSmartStrategy
|
||
|| smartStrategies.find((item) => item.regimes.includes(state.selectedRegime))
|
||
|| smartStrategies[0]
|
||
|| null;
|
||
|
||
if (!curatedStrategies.some((item) => item.id === state.selectedCuratedStrategyId) || dateChanged) {
|
||
state.selectedCuratedStrategyId = curatedStrategies.find(
|
||
(item) => item.name === curatedLatestMeta.strategy_name,
|
||
)?.id || curatedStrategies[0]?.id || 0;
|
||
}
|
||
|
||
for (const result of [...(payload.recent_results || [])].reverse()) {
|
||
const mode = ["smart", "curated", "quant"].includes(result.meta?.mode)
|
||
? result.meta.mode
|
||
: "smart";
|
||
const strategies = mode === "curated" ? curatedStrategies : smartStrategies;
|
||
const strategy = strategies.find((item) => item.name === result.meta?.strategy_name);
|
||
storeScreenerResult(mode, result, {
|
||
regime: result.meta?.regime || payload.regime.id,
|
||
strategyId: strategy?.id,
|
||
strategyName: result.meta?.strategy_name || strategy?.name || "",
|
||
}, false);
|
||
}
|
||
|
||
for (const mode of ["smart", "curated", "quant"]) {
|
||
if (state.screenerResults[mode] || !latestResults[mode]) continue;
|
||
const result = latestResults[mode];
|
||
const meta = result.meta || {};
|
||
const strategy = mode === "curated"
|
||
? curatedStrategies.find((item) => item.name === meta.strategy_name)
|
||
: mode === "smart"
|
||
? smartStrategies.find((item) => item.name === meta.strategy_name)
|
||
: null;
|
||
setScreenerResult(mode, result, {
|
||
regime: meta.regime || payload.regime.id,
|
||
strategyId: strategy?.id,
|
||
strategyName: meta.strategy_name || strategy?.name || "",
|
||
});
|
||
}
|
||
if (!state.quantScores.length) resetQuantBuilder(false);
|
||
renderScreenerSetup();
|
||
renderScreenerResult();
|
||
}
|
||
|
||
async function loadScreenerSetup(force = false) {
|
||
const requestKey = elements.tradeDate.value.replaceAll("-", "");
|
||
if (!force && state.screenerSetup && state.screenerSetupKey === requestKey) {
|
||
renderScreenerSetup();
|
||
renderScreenerResult();
|
||
return state.screenerSetup;
|
||
}
|
||
if (!force && state.screenerSetupPromise && state.screenerSetupRequestKey === requestKey) {
|
||
return state.screenerSetupPromise;
|
||
}
|
||
const request = (async () => {
|
||
try {
|
||
const query = new URLSearchParams({ trade_date: elements.tradeDate.value });
|
||
const payload = await apiRequest(`/api/screener/setup?${query}`);
|
||
applyScreenerSetup(payload, requestKey);
|
||
await loadScreenerTracking();
|
||
return payload;
|
||
} catch (error) {
|
||
showToast(error.message || "选股配置加载失败");
|
||
return null;
|
||
} finally {
|
||
if (state.screenerSetupPromise === request) {
|
||
state.screenerSetupPromise = null;
|
||
state.screenerSetupRequestKey = "";
|
||
}
|
||
}
|
||
})();
|
||
state.screenerSetupRequestKey = requestKey;
|
||
state.screenerSetupPromise = request;
|
||
return request;
|
||
}
|
||
|
||
function renderScreenerSetup() {
|
||
const setup = state.screenerSetup;
|
||
if (!setup) return;
|
||
setText("screenerDateLabel", `数据日期 ${displayCompactDate(setup.trade_date)}`);
|
||
setText("regimeLabel", setup.regime.label);
|
||
setText("regimeConfidence", `置信度 ${formatNumber(setup.regime.confidence, 0)}%`);
|
||
setText("regimeStepStatus", `${setup.regime.label} · 置信度 ${formatNumber(setup.regime.confidence, 0)}%`);
|
||
setText("regimeReason", setup.regime.reason);
|
||
const evidence = (setup.regime.evidence || []).filter(Boolean);
|
||
if (!evidence.some((item) => String(item).includes("情绪温度"))) {
|
||
const temperature = formatNumber(state.dashboard?.overview?.sentiment_score, 0);
|
||
const direction = state.dashboard?.overview?.sentiment_direction;
|
||
evidence.unshift(`情绪温度 ${temperature}${direction ? `,较前一交易日${direction}` : ""}`);
|
||
}
|
||
document.querySelector("#regimeEvidenceList").textContent = evidence.join(" · ");
|
||
setText("factorDateCount", `${number(setup.factor_data.date_count)} 日`);
|
||
setText("factorDateRange", setup.factor_data.ready
|
||
? `${displayCompactDate(setup.factor_data.start_date)} 至 ${displayCompactDate(setup.factor_data.end_date)} · 竞价 ${number(setup.factor_data.auction_date_count)} 日`
|
||
: "尚未达到 21 个交易日");
|
||
setText("factorTaskStatus", setup.factor_data.ready ? `已就绪 · ${number(setup.factor_data.date_count)} 日` : "需要同步");
|
||
setText("compilerStatus", "策略生成已就绪");
|
||
setText(
|
||
"screenerRunStatus",
|
||
activeScreenerResult("smart") ? `已有结果 · ${(activeScreenerResult("smart").candidates || []).length} 只` : "等待执行",
|
||
);
|
||
setText("strategyCount", `${setup.strategies.filter((item) => item.formula?.meta?.library !== "curated").length} 套`);
|
||
updateBacktestTaskStatus();
|
||
selectScreenerMobileView(state.screenerMobileView);
|
||
|
||
const selector = document.querySelector("#regimeSelector");
|
||
selector.innerHTML = setup.regimes.map((item) => `
|
||
<span class="regime-option ${item.id === state.selectedRegime ? "active" : ""}">${escapeHtml(item.label)}</span>
|
||
`).join("");
|
||
renderStrategyList();
|
||
renderStrategySummary();
|
||
renderScreenerMode();
|
||
renderCuratedStrategyLibrary();
|
||
renderQuantBuilder();
|
||
renderScreenerProgress();
|
||
}
|
||
|
||
function renderStrategySummary() {
|
||
const strategy = state.selectedStrategy;
|
||
setText("activeStrategyHeading", strategy?.name || "--");
|
||
setText("activeStrategyEditorHeading", strategy?.name || "--");
|
||
setText("activeStrategyDescription", strategy?.description || "等待匹配当前市场阶段的策略。");
|
||
setText("strategyStepStatus", strategy?.name || "等待匹配");
|
||
document.querySelector("#activeStrategyRegimes").innerHTML = strategy
|
||
? `${strategy.regimes.map((item) => `<b>${escapeHtml(regimeLabel(item))}</b>`).join("")}<b class="neutral">${strategy.builtin ? "内置" : "自定义"}</b>`
|
||
: "";
|
||
}
|
||
|
||
function selectScreenerMode(mode) {
|
||
state.screenerMode = ["smart", "curated", "quant"].includes(mode) ? mode : "smart";
|
||
localStorage.setItem("xiaobaiScreenerMode", state.screenerMode);
|
||
state.screenerMobileView = "strategy";
|
||
renderScreenerMode();
|
||
selectScreenerMobileView("strategy");
|
||
}
|
||
|
||
function renderScreenerMode() {
|
||
const mode = state.screenerMode || "smart";
|
||
document.querySelectorAll("[data-screener-mode]").forEach((button) => {
|
||
const active = button.dataset.screenerMode === mode;
|
||
button.classList.toggle("active", active);
|
||
button.setAttribute("aria-selected", String(active));
|
||
});
|
||
document.querySelectorAll("[data-screener-panel]").forEach((panel) => {
|
||
panel.hidden = panel.dataset.screenerPanel !== mode;
|
||
});
|
||
const results = document.querySelector("#screenerView .screener-results-view");
|
||
const resultsSlot = document.querySelector(`[data-screener-results-slot="${mode}"]`);
|
||
if (results && resultsSlot && results.parentElement !== resultsSlot) resultsSlot.append(results);
|
||
const titles = { smart: "盘后候选结果", curated: "策略候选结果", quant: "自定义选股结果" };
|
||
setText("screenerResultTitle", titles[mode]);
|
||
renderScreenerResult();
|
||
}
|
||
|
||
function curatedStrategies() {
|
||
return (state.screenerSetup?.strategies || []).filter((item) => item.formula?.meta?.library === "curated");
|
||
}
|
||
|
||
function activeCuratedStrategy() {
|
||
const strategies = curatedStrategies();
|
||
return strategies.find((item) => item.id === state.selectedCuratedStrategyId) || strategies[0] || null;
|
||
}
|
||
|
||
function curatedStrategySchool(strategy) {
|
||
const category = String(strategy?.formula?.meta?.category || "");
|
||
if (["红利价值", "质量价值", "现金流价值", "成长质量", "小盘质量"].includes(category)) return "基本面";
|
||
if (["行业轮动", "形态突破", "趋势追踪"].includes(category)) return "趋势";
|
||
if (["短线竞价", "连板接力", "低吸反核"].includes(category)) return "短线";
|
||
if (["动量反转"].includes(category)) return "动量";
|
||
if (["元策略", "多因子"].includes(category)) return "量化";
|
||
if (["业绩事件", "热度观察"].includes(category)) return "事件";
|
||
if (["资金席位"].includes(category)) return "资金";
|
||
if (/红利|价值|质量|成长|财务|现金流/.test(category)) return "基本面";
|
||
if (/趋势|轮动|突破/.test(category)) return "趋势";
|
||
if (/竞价|连板|龙头|反核|首阴|反包|打板/.test(category)) return "短线";
|
||
if (/动量|反转/.test(category)) return "动量";
|
||
if (/因子|量化|元策略/.test(category)) return "量化";
|
||
if (/事件|热度|公告|业绩/.test(category)) return "事件";
|
||
if (/席位|资金/.test(category)) return "资金";
|
||
return "其他";
|
||
}
|
||
|
||
function curatedSchoolIcon(school) {
|
||
return {
|
||
基本面: "circle-dollar-sign", 趋势: "trending-up", 短线: "zap",
|
||
动量: "refresh-cw", 量化: "binary", 事件: "calendar-clock", 资金: "landmark", 其他: "boxes",
|
||
}[school] || "boxes";
|
||
}
|
||
|
||
function curatedStrategyRunState(strategy, result) {
|
||
const missingData = strategy?.missing_data || [];
|
||
if (!strategy?.data_ready || missingData.length) {
|
||
return { label: "数据不足", className: "missing", verifiedEmpty: false };
|
||
}
|
||
if (!result) return { label: "等待盘后", className: "pending", verifiedEmpty: false };
|
||
const count = (result.candidates || []).length;
|
||
if (count) return { label: `${count} 只候选`, className: "ready", verifiedEmpty: false };
|
||
return { label: "暂无信号", className: "quiet", verifiedEmpty: true };
|
||
}
|
||
|
||
function renderCuratedStrategyLibrary() {
|
||
if (!state.screenerSetup) return;
|
||
const strategies = curatedStrategies();
|
||
const categories = ["全部", ...new Set(strategies.map((item) => item.formula?.meta?.category || "其他"))];
|
||
const schools = ["全部", "基本面", "趋势", "短线", "动量", "量化", "事件", "资金"];
|
||
if (!categories.includes(state.curatedCategory)) state.curatedCategory = "全部";
|
||
if (!schools.includes(state.curatedSchool)) state.curatedSchool = "全部";
|
||
setText("curatedStrategyCount", `${strategies.length} 套`);
|
||
const categorySelect = document.querySelector("#curatedCategoryFilter");
|
||
categorySelect.innerHTML = categories.map((category) => `
|
||
<option value="${escapeHtml(category)}" ${category === state.curatedCategory ? "selected" : ""}>${escapeHtml(category)}</option>
|
||
`).join("");
|
||
document.querySelector("#curatedSchoolFilters").innerHTML = schools.map((school) => {
|
||
const count = school === "全部" ? strategies.length : strategies.filter((item) => curatedStrategySchool(item) === school).length;
|
||
return `<button class="${school === state.curatedSchool ? "active" : ""}" type="button" data-curated-school="${escapeHtml(school)}" aria-pressed="${school === state.curatedSchool}">${escapeHtml(school)}<small>${count}</small></button>`;
|
||
}).join("");
|
||
document.querySelectorAll("[data-curated-view]").forEach((button) => {
|
||
const active = button.dataset.curatedView === state.curatedViewMode;
|
||
button.classList.toggle("active", active);
|
||
button.setAttribute("aria-pressed", String(active));
|
||
});
|
||
const query = state.curatedQuery;
|
||
const visible = strategies.filter((item) => {
|
||
const meta = item.formula?.meta || {};
|
||
const categoryMatch = state.curatedCategory === "全部" || meta.category === state.curatedCategory;
|
||
const school = curatedStrategySchool(item);
|
||
const schoolMatch = state.curatedSchool === "全部" || school === state.curatedSchool;
|
||
const queryMatch = !query || `${item.name} ${item.description} ${meta.category} ${school} ${meta.suitable_environment} ${meta.failure_risk}`.toLocaleLowerCase("zh-CN").includes(query);
|
||
return categoryMatch && schoolMatch && queryMatch;
|
||
});
|
||
const list = document.querySelector("#curatedStrategyList");
|
||
list.classList.toggle("is-grid", state.curatedViewMode === "grid");
|
||
list.innerHTML = visible.length ? visible.map((strategy) => {
|
||
const meta = strategy.formula?.meta || {};
|
||
const school = curatedStrategySchool(strategy);
|
||
const rank = strategies.findIndex((item) => item.id === strategy.id) + 1;
|
||
const resultKey = screenerResultKey(screenerResultContext("curated", null, {
|
||
regime: strategy.regimes[0] || state.selectedRegime,
|
||
strategyId: strategy.id,
|
||
strategyName: strategy.name,
|
||
}));
|
||
const result = state.screenerResultStore[resultKey]?.result;
|
||
const runState = curatedStrategyRunState(strategy, result);
|
||
return `<article class="curated-strategy-card ${strategy.id === activeCuratedStrategy()?.id ? "active" : ""}" data-curated-strategy="${strategy.id}">
|
||
<span class="curated-strategy-icon" aria-hidden="true"><i data-lucide="${curatedSchoolIcon(school)}"></i></span>
|
||
<span class="curated-card-head"><i class="curated-strategy-rank">${String(rank).padStart(2, "0")}</i><span><strong>${escapeHtml(strategy.name)}</strong><small>${escapeHtml(school)} · ${escapeHtml(meta.category || "策略")}</small></span><em class="curated-card-result ${runState.className}">${escapeHtml(runState.label)}</em></span>
|
||
<span class="curated-card-tags"><em>${escapeHtml(meta.quality || "--")}</em><em>${escapeHtml(meta.frequency || "--")}</em><em>风险 ${escapeHtml(meta.risk || "--")}</em></span>
|
||
</article>`;
|
||
}).join("") : '<div class="empty-state">没有符合条件的策略</div>';
|
||
renderCuratedStrategyDetail();
|
||
}
|
||
|
||
function renderCuratedStrategyDetail() {
|
||
const strategy = activeCuratedStrategy();
|
||
if (!strategy) return;
|
||
const formula = strategy.formula || {};
|
||
const meta = formula.meta || {};
|
||
const result = activeScreenerResult("curated");
|
||
const resultMeta = result?.meta || {};
|
||
const health = resultMeta.health || {};
|
||
const runState = curatedStrategyRunState(strategy, result);
|
||
setText("curatedStrategyCategory", meta.category || "精选策略");
|
||
setText("curatedStrategyName", strategy.name);
|
||
setText("curatedStrategyDescription", strategy.description);
|
||
document.querySelector("#curatedStrategyBadges").innerHTML = [
|
||
`质量 ${meta.quality || "--"}`, meta.frequency || "--", `风险 ${meta.risk || "--"}`,
|
||
meta.data_group || "行情因子",
|
||
].map((value) => `<span>${escapeHtml(value)}</span>`).join("");
|
||
setText("curatedSuitableEnvironment", meta.suitable_environment || "以策略条件为准");
|
||
setText("curatedFailureRisk", meta.failure_risk || "策略可能随市场结构变化而失效");
|
||
const filters = formula.filters || [];
|
||
setText("curatedFilterCount", `${filters.length} 项`);
|
||
document.querySelector("#curatedFilterList").innerHTML = filters.map((item) => `
|
||
<div class="curated-rule-row"><span>${escapeHtml(factorLabel(item.field))}</span><strong>${escapeHtml(formatRuleValue(item))}</strong></div>
|
||
`).join("");
|
||
const scores = formula.score || [];
|
||
const total = scores.reduce((sum, item) => sum + number(item.weight), 0) || 1;
|
||
setText("curatedWeightTotal", `${formatNumber(total * 100, 0)}%`);
|
||
document.querySelector("#curatedScoreList").innerHTML = scores.map((item) => {
|
||
const percent = number(item.weight) / total * 100;
|
||
return `<div class="curated-score-row"><span>${escapeHtml(factorLabel(item.field))}</span><span class="curated-score-track"><i style="width:${Math.min(100, percent)}%"></i></span><strong>${formatNumber(percent, 0)}%</strong></div>`;
|
||
}).join("");
|
||
const candidateCount = (result?.candidates || []).length;
|
||
const statusLabel = runState.className === "ready" ? "运行正常" : runState.label;
|
||
const statusClass = runState.className;
|
||
let updatedLabel = "--";
|
||
if (resultMeta.updated_at) {
|
||
const updated = new Date(resultMeta.updated_at);
|
||
if (!Number.isNaN(updated.getTime())) {
|
||
updatedLabel = `${String(updated.getMonth() + 1).padStart(2, "0")}-${String(updated.getDate()).padStart(2, "0")} ${updated.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false })}`;
|
||
}
|
||
}
|
||
document.querySelector("#curatedHealthMetrics").innerHTML = [
|
||
["运行状态", statusLabel, statusClass],
|
||
["当日信号", result ? `${candidateCount} 只` : "--", ""],
|
||
["字段覆盖", health.coverage != null ? `${formatNumber(health.coverage, 1)}%` : strategy.data_ready ? "数据已就绪" : "--", ""],
|
||
["最近更新", updatedLabel, ""],
|
||
].map(([label, value, className]) => `<div><span>${escapeHtml(label)}</span><strong class="${className}">${escapeHtml(value)}</strong></div>`).join("");
|
||
const status = document.querySelector("#curatedDataStatus");
|
||
status.classList.toggle("missing", statusClass === "missing");
|
||
status.innerHTML = strategy.data_ready
|
||
? `<i data-lucide="${runState.verifiedEmpty ? "circle-check" : "database"}"></i><span><strong>${runState.verifiedEmpty ? "本日暂无信号" : "盘后自动更新"}</strong><small>${runState.verifiedEmpty ? `必需数据已完整,本日没有股票同时满足 ${filters.length} 项准入条件` : result ? health.required_field_count != null ? `已核验 ${number(health.required_field_count)} 项因子 · ${number(health.complete_rows)} 只股票` : "盘后定格结果已载入" : "等待当日行情定格后生成"}</small></span>`
|
||
: `<i data-lucide="circle-alert"></i><span><strong>数据尚未完备</strong><small>${escapeHtml((strategy.missing_data || []).join("、") || "等待后台同步")}</small></span>`;
|
||
refreshIcons();
|
||
}
|
||
|
||
function factorLabel(field) {
|
||
return state.screenerSetup?.factor_fields?.find((item) => item.id === field)?.label || field;
|
||
}
|
||
|
||
function formatRuleValue(item) {
|
||
const operator = { between: "介于", ">=": "不低于", "<=": "不高于", ">": "高于", "<": "低于", "==": "等于" }[item.op] || item.op;
|
||
const value = Array.isArray(item.value) ? item.value.join(" ~ ") : item.value;
|
||
return `${operator} ${value}`;
|
||
}
|
||
|
||
function groupedFactorOptions(selected = "") {
|
||
return (state.screenerSetup?.factor_groups || []).map((group) => `
|
||
<optgroup label="${escapeHtml(group.name)}">${group.fields.map((field) => `<option value="${escapeHtml(field.id)}" ${field.id === selected ? "selected" : ""}>${escapeHtml(field.label)}</option>`).join("")}</optgroup>
|
||
`).join("");
|
||
}
|
||
|
||
function quantId() {
|
||
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||
}
|
||
|
||
function resetQuantBuilder(render = true) {
|
||
state.quantFilters = [
|
||
{ id: quantId(), field: "amount_billion", op: ">=", value: "1" },
|
||
{ id: quantId(), field: "above_ma20", op: "==", value: "1" },
|
||
];
|
||
state.quantScores = [
|
||
{ id: quantId(), field: "relative_strength", weight: 30, direction: "desc" },
|
||
{ id: quantId(), field: "sector_strength", weight: 25, direction: "desc" },
|
||
{ id: quantId(), field: "volume_ratio_5d", weight: 20, direction: "desc" },
|
||
{ id: quantId(), field: "amount_billion", weight: 15, direction: "desc" },
|
||
{ id: quantId(), field: "volatility_10d", weight: 10, direction: "asc" },
|
||
];
|
||
if (render) renderQuantBuilder();
|
||
}
|
||
|
||
function addQuantFilter() {
|
||
const used = new Set(state.quantFilters.map((item) => item.field));
|
||
const field = state.screenerSetup.factor_fields.find((item) => !used.has(item.id))?.id || "pct_chg";
|
||
state.quantFilters.push({ id: quantId(), field, op: ">=", value: "0" });
|
||
renderQuantBuilder();
|
||
}
|
||
|
||
function addQuantScore() {
|
||
const used = new Set(state.quantScores.map((item) => item.field));
|
||
const field = state.screenerSetup.factor_fields.find((item) => !used.has(item.id))?.id || "pct_chg";
|
||
state.quantScores.push({ id: quantId(), field, weight: 10, direction: "desc" });
|
||
renderQuantBuilder();
|
||
}
|
||
|
||
function renderQuantBuilder() {
|
||
if (!state.screenerSetup) return;
|
||
document.querySelector("#quantFilterRows").innerHTML = state.quantFilters.map((item) => `
|
||
<div class="quant-rule-row quant-filter-row" data-quant-filter="${item.id}">
|
||
<select aria-label="过滤因子" data-quant-key="field">${groupedFactorOptions(item.field)}</select>
|
||
<select aria-label="比较方式" data-quant-key="op">${(state.screenerSetup.operators || []).map((op) => `<option value="${op}" ${op === item.op ? "selected" : ""}>${escapeHtml({ between: "区间", ">=": "≥", "<=": "≤", ">": ">", "<": "<", "==": "=" }[op] || op)}</option>`).join("")}</select>
|
||
<input class="quant-value-input" data-quant-key="value" aria-label="条件值" type="text" value="${escapeHtml(item.value)}" placeholder="区间用逗号分隔">
|
||
<button class="quant-remove-button" type="button" data-quant-action="remove" aria-label="删除过滤条件"><i data-lucide="trash-2"></i></button>
|
||
</div>
|
||
`).join("");
|
||
document.querySelector("#quantScoreRows").innerHTML = state.quantScores.map((item) => `
|
||
<div class="quant-rule-row quant-score-row" data-quant-score="${item.id}">
|
||
<span class="quant-factor-identity"><i aria-hidden="true">✓</i><span><select aria-label="评分因子" data-quant-key="field">${groupedFactorOptions(item.field)}</select><button type="button" data-quant-action="direction" data-direction="${item.direction === "desc" ? "asc" : "desc"}">${item.direction === "desc" ? "数值越高越优" : "数值越低越优"}</button></span></span>
|
||
<label class="quant-weight-control">
|
||
<span class="visually-hidden">权重百分比</span>
|
||
<input data-quant-key="weight" aria-label="权重百分比" type="range" min="1" max="100" step="1" value="${number(item.weight)}">
|
||
<output>${number(item.weight)}%</output>
|
||
</label>
|
||
<button class="quant-remove-button" type="button" data-quant-action="remove" aria-label="删除评分因子"><i data-lucide="trash-2"></i></button>
|
||
</div>
|
||
`).join("");
|
||
renderQuantSummary();
|
||
refreshIcons();
|
||
}
|
||
|
||
function handleQuantBuilderInput(event) {
|
||
const row = event.target.closest("[data-quant-filter], [data-quant-score]");
|
||
const key = event.target.dataset.quantKey;
|
||
if (!row || !key) return;
|
||
const collection = row.dataset.quantFilter ? state.quantFilters : state.quantScores;
|
||
const id = row.dataset.quantFilter || row.dataset.quantScore;
|
||
const item = collection.find((entry) => entry.id === id);
|
||
if (!item) return;
|
||
item[key] = key === "weight" ? number(event.target.value) : event.target.value;
|
||
if (key === "weight") {
|
||
const output = event.target.closest(".quant-weight-control")?.querySelector("output");
|
||
if (output) output.textContent = `${number(event.target.value)}%`;
|
||
}
|
||
renderQuantSummary();
|
||
}
|
||
|
||
function handleQuantBuilderClick(event) {
|
||
const button = event.target.closest("[data-quant-action]");
|
||
if (!button) return;
|
||
const row = button.closest("[data-quant-filter], [data-quant-score]");
|
||
const isFilter = Boolean(row?.dataset.quantFilter);
|
||
const id = row?.dataset.quantFilter || row?.dataset.quantScore;
|
||
const collection = isFilter ? state.quantFilters : state.quantScores;
|
||
const item = collection.find((entry) => entry.id === id);
|
||
if (button.dataset.quantAction === "remove") {
|
||
if (!isFilter && collection.length <= 1) {
|
||
showToast("至少保留一个评分因子");
|
||
return;
|
||
}
|
||
const index = collection.findIndex((entry) => entry.id === id);
|
||
if (index >= 0) collection.splice(index, 1);
|
||
renderQuantBuilder();
|
||
} else if (button.dataset.quantAction === "direction" && item) {
|
||
item.direction = button.dataset.direction;
|
||
renderQuantBuilder();
|
||
}
|
||
}
|
||
|
||
function buildQuantFormula() {
|
||
const filters = state.quantFilters.map((item) => {
|
||
let value;
|
||
if (item.op === "between") {
|
||
value = String(item.value).split(/[,,~~]/).map((part) => Number(part.trim()));
|
||
if (value.length !== 2 || value.some((part) => !Number.isFinite(part))) throw new Error(`${factorLabel(item.field)}需要两个有效区间值`);
|
||
if (value[0] > value[1]) value.reverse();
|
||
} else {
|
||
value = Number(item.value);
|
||
if (!Number.isFinite(value)) throw new Error(`${factorLabel(item.field)}的条件值无效`);
|
||
}
|
||
return { field: item.field, op: item.op, value };
|
||
});
|
||
const score = state.quantScores.map((item) => {
|
||
const weight = number(item.weight) / 100;
|
||
if (weight <= 0 || weight > 1) throw new Error(`${factorLabel(item.field)}的权重应为1%至100%`);
|
||
return { field: item.field, weight, direction: item.direction };
|
||
});
|
||
return {
|
||
meta: { library: "custom", category: "量化公式", frequency: "按需", risk: "自定义", data_group: "组合因子" },
|
||
universe: {
|
||
exclude_st: document.querySelector("#quantExcludeSt").checked,
|
||
listed_days_min: Math.max(0, Math.min(5000, number(document.querySelector("#quantListedDays").value))),
|
||
},
|
||
filters,
|
||
score,
|
||
limit: Math.max(1, Math.min(50, number(document.querySelector("#quantLimit").value))),
|
||
min_score: Math.max(0, Math.min(1, number(document.querySelector("#quantMinScore").value) / 100)),
|
||
};
|
||
}
|
||
|
||
function formulaMissingData(formula) {
|
||
const health = state.screenerSetup?.factor_data?.health || {};
|
||
const fields = new Set([...(formula.filters || []), ...(formula.score || [])].map((item) => item.field));
|
||
const missing = [];
|
||
if (!state.screenerSetup?.factor_data?.ready) missing.push("基础行情");
|
||
if (["pe_ttm", "pb", "ps_ttm", "dividend_yield_ttm", "total_mv_billion"].some((field) => fields.has(field)) && !health.valuation) missing.push("估值数据");
|
||
if (["roe", "roa", "roic", "gross_margin", "netprofit_yoy", "revenue_yoy", "ocf_to_opincome"].some((field) => fields.has(field)) && !health.fundamental) missing.push("财务质量");
|
||
if (fields.has("dividend_years") && !health.dividend_history) missing.push("历年分红");
|
||
if (["auction_change", "auction_amount_million", "auction_turnover_rate", "auction_volume_ratio"].some((field) => fields.has(field)) && !health.auction) missing.push("竞价数据");
|
||
return missing;
|
||
}
|
||
|
||
function renderQuantSummary() {
|
||
if (!state.screenerSetup) return;
|
||
const total = state.quantScores.reduce((sum, item) => sum + number(item.weight), 0);
|
||
setText("quantWeightTotal", `${formatNumber(total, 0)}%`);
|
||
const bar = document.querySelector("#quantWeightBar");
|
||
bar.style.width = `${Math.min(100, total)}%`;
|
||
bar.style.background = Math.abs(total - 100) < 0.01 ? "#2563eb" : "#d97706";
|
||
const message = document.querySelector("#quantValidationMessage");
|
||
try {
|
||
const formula = buildQuantFormula();
|
||
const missing = formulaMissingData(formula);
|
||
message.classList.toggle("error", Boolean(missing.length));
|
||
message.textContent = missing.length ? `需要先同步:${missing.join("、")}` : "公式有效,可执行并生成逐股贡献解释。";
|
||
document.querySelector("#quantRunButton").disabled = Boolean(missing.length);
|
||
} catch (error) {
|
||
message.classList.add("error");
|
||
message.textContent = error.message;
|
||
document.querySelector("#quantRunButton").disabled = true;
|
||
}
|
||
}
|
||
|
||
function renderScreenerProgress() {
|
||
const hasSetup = Boolean(state.screenerSetup?.regime);
|
||
const hasStrategy = Boolean(state.selectedStrategy);
|
||
const hasResult = Boolean(activeScreenerResult("smart"));
|
||
const states = {
|
||
regime: hasSetup ? "complete" : "current",
|
||
strategy: hasStrategy ? "complete" : hasSetup ? "current" : "pending",
|
||
run: state.screenerRunning ? "current" : hasResult ? "complete" : hasStrategy ? "current" : "pending",
|
||
result: hasResult ? "current" : "pending",
|
||
};
|
||
const steps = [...document.querySelectorAll("[data-screener-step]")];
|
||
steps.forEach((step, index) => {
|
||
const status = states[step.dataset.screenerStep] || "pending";
|
||
step.dataset.state = status;
|
||
if (status === "current") step.setAttribute("aria-current", "step");
|
||
else step.removeAttribute("aria-current");
|
||
const line = step.nextElementSibling;
|
||
if (line?.classList.contains("step-line")) line.classList.toggle("complete", status === "complete" && index < steps.length - 1);
|
||
});
|
||
}
|
||
|
||
function openStrategyDrawer(target = "editor") {
|
||
const drawer = document.querySelector("#strategyDrawer");
|
||
openModalDialog(drawer);
|
||
requestAnimationFrame(() => {
|
||
const focusTarget = target === "library"
|
||
? document.querySelector("#strategyList .strategy-item.active") || document.querySelector("#strategyList .strategy-item")
|
||
: document.querySelector("#strategyNameInput");
|
||
focusTarget?.focus();
|
||
});
|
||
}
|
||
|
||
function openCustomStrategyDrawer() {
|
||
if (!state.customStrategyDraft) {
|
||
state.customStrategyDraft = {
|
||
id: null,
|
||
builtin: false,
|
||
name: "自定义选股策略",
|
||
description: "",
|
||
regimes: [state.selectedRegime],
|
||
formula: buildQuantFormula(),
|
||
};
|
||
}
|
||
populateStrategyEditor(state.customStrategyDraft);
|
||
renderStrategyList();
|
||
openStrategyDrawer("editor");
|
||
}
|
||
|
||
function selectScreenerMobileView(view) {
|
||
state.screenerMobileView = view === "results" ? "results" : "strategy";
|
||
const workspace = document.querySelector("#screenerView");
|
||
workspace.classList.toggle("mobile-strategy", state.screenerMobileView === "strategy");
|
||
workspace.classList.toggle("mobile-results", state.screenerMobileView === "results");
|
||
document.querySelectorAll("[data-screener-mobile-view]").forEach((button) => {
|
||
const active = button.dataset.screenerMobileView === state.screenerMobileView;
|
||
button.classList.toggle("active", active);
|
||
button.setAttribute("aria-selected", String(active));
|
||
});
|
||
}
|
||
|
||
function updateBacktestTaskStatus() {
|
||
setText("backtestTaskStatus", activeScreenerResult("smart") ? "结果已归档" : "等待盘后生成");
|
||
renderScreenerProgress();
|
||
}
|
||
|
||
function selectRegime(regime) {
|
||
state.selectedRegime = regime;
|
||
const recommended = state.screenerSetup.strategies.find((item) => item.formula?.meta?.library !== "curated" && item.regimes.includes(regime));
|
||
if (recommended) state.selectedStrategy = recommended;
|
||
renderScreenerSetup();
|
||
}
|
||
|
||
function renderStrategyList() {
|
||
const list = document.querySelector("#strategyList");
|
||
const strategies = state.screenerSetup.strategies.filter((item) => !item.builtin && item.formula?.meta?.library !== "curated");
|
||
list.innerHTML = strategies.map((strategy) => `
|
||
<button type="button" class="strategy-item ${strategy.id === state.customStrategyDraft?.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(" / ")}</small>
|
||
</button>
|
||
`).join("") || '<div class="empty-state">暂无已保存的自定义公式</div>';
|
||
list.querySelectorAll("[data-strategy-id]").forEach((button) => {
|
||
button.addEventListener("click", () => {
|
||
state.customStrategyDraft = state.screenerSetup.strategies.find((item) => item.id === number(button.dataset.strategyId));
|
||
populateStrategyEditor(state.customStrategyDraft);
|
||
renderStrategyList();
|
||
});
|
||
});
|
||
}
|
||
|
||
function populateStrategyEditor(strategy) {
|
||
const deleteButton = document.querySelector("#deleteStrategyButton");
|
||
deleteButton.hidden = !strategy?.id || Boolean(strategy.builtin);
|
||
if (!strategy) {
|
||
setText("activeStrategyEditorHeading", "--");
|
||
return;
|
||
}
|
||
setText("activeStrategyEditorHeading", strategy.name || "未命名策略");
|
||
document.querySelector("#strategyNameInput").value = strategy.name || "";
|
||
document.querySelector("#strategyDescriptionInput").value = strategy.description || "";
|
||
document.querySelector("#strategyPrompt").value = strategy.builtin ? strategy.description || "" : document.querySelector("#strategyPrompt").value;
|
||
document.querySelector("#formulaEditor").value = JSON.stringify(strategy.formula, null, 2);
|
||
}
|
||
|
||
async function compileStrategy() {
|
||
const prompt = document.querySelector("#strategyPrompt").value.trim();
|
||
const button = document.querySelector("#compileStrategyButton");
|
||
button.disabled = true;
|
||
setText("compilerStatus", "正在编译");
|
||
setStatus("正在编译选股策略");
|
||
try {
|
||
const payload = await apiRequest("/api/screener/compile", "POST", {
|
||
prompt,
|
||
regime: state.selectedRegime,
|
||
});
|
||
const strategy = payload.strategy;
|
||
state.customStrategyDraft = { ...strategy, id: null, builtin: false };
|
||
document.querySelector("#deleteStrategyButton").hidden = true;
|
||
document.querySelector("#strategyNameInput").value = strategy.name;
|
||
document.querySelector("#strategyDescriptionInput").value = strategy.description;
|
||
document.querySelector("#formulaEditor").value = JSON.stringify(strategy.formula, null, 2);
|
||
setText("compilerStatus", "策略生成完成");
|
||
if (strategy.notice) showToast(strategy.notice);
|
||
setStatus("选股策略已编译");
|
||
} catch (error) {
|
||
showToast(error.message);
|
||
setStatus("策略编译失败");
|
||
setText("compilerStatus", "编译失败");
|
||
} finally {
|
||
button.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function saveCurrentStrategy() {
|
||
try {
|
||
const formula = parseFormulaEditor();
|
||
const payload = await apiRequest("/api/screener/strategies", "POST", {
|
||
name: document.querySelector("#strategyNameInput").value,
|
||
description: document.querySelector("#strategyDescriptionInput").value,
|
||
regimes: [state.selectedRegime],
|
||
formula,
|
||
});
|
||
state.screenerSetup.strategies = payload.strategies;
|
||
state.customStrategyDraft = payload.strategies.find((item) => item.id === payload.id);
|
||
renderStrategyList();
|
||
populateStrategyEditor(state.customStrategyDraft);
|
||
showToast("自定义策略已保存");
|
||
} catch (error) {
|
||
showToast(error.message);
|
||
}
|
||
}
|
||
|
||
async function deleteCurrentStrategy() {
|
||
const strategy = state.customStrategyDraft;
|
||
if (!strategy?.id || strategy.builtin) {
|
||
showToast("只能删除已保存的自定义策略");
|
||
return;
|
||
}
|
||
if (!window.confirm(`确定删除策略“${strategy.name}”吗?此操作不可撤销。`)) return;
|
||
|
||
const button = document.querySelector("#deleteStrategyButton");
|
||
button.disabled = true;
|
||
try {
|
||
const payload = await apiRequest(`/api/screener/strategies/${strategy.id}`, "DELETE");
|
||
state.screenerSetup.strategies = payload.strategies;
|
||
state.customStrategyDraft = null;
|
||
renderStrategyList();
|
||
openCustomStrategyDrawer();
|
||
showToast("自定义策略已删除");
|
||
} catch (error) {
|
||
showToast(error.message || "策略删除失败");
|
||
} finally {
|
||
button.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function runQuantStrategy() {
|
||
let formula;
|
||
try {
|
||
formula = buildQuantFormula();
|
||
} catch (error) {
|
||
showToast(error.message);
|
||
return;
|
||
}
|
||
await executeScreenerFormula({
|
||
mode: "quant",
|
||
formula,
|
||
strategyName: "自定义选股公式",
|
||
regime: state.selectedRegime,
|
||
runBacktest: document.querySelector("#quantBacktestToggle").checked,
|
||
button: document.querySelector("#quantRunButton"),
|
||
loadingText: "正在执行自定义公式并计算因子贡献",
|
||
});
|
||
}
|
||
|
||
function saveQuantAsStrategy() {
|
||
try {
|
||
const formula = buildQuantFormula();
|
||
state.customStrategyDraft = {
|
||
id: null,
|
||
builtin: false,
|
||
name: "自定义选股策略",
|
||
description: "由自定义因子工作台生成,可在高级公式中继续调整。",
|
||
regimes: [state.selectedRegime],
|
||
formula,
|
||
};
|
||
populateStrategyEditor(state.customStrategyDraft);
|
||
document.querySelector("#strategyPrompt").value = "自定义因子工作台生成的选股公式";
|
||
openStrategyDrawer("editor");
|
||
} catch (error) {
|
||
showToast(error.message);
|
||
}
|
||
}
|
||
|
||
async function executeScreenerFormula({ mode, formula, strategyName, strategyId = null, regime, runBacktest, button, loadingText }) {
|
||
if (!state.screenerSetup?.factor_data?.ready) {
|
||
showToast("请先同步至少 21 个交易日的因子数据");
|
||
return;
|
||
}
|
||
const missing = formulaMissingData(formula);
|
||
if (missing.length) {
|
||
showToast(`请先同步${missing.join("、")}`);
|
||
return;
|
||
}
|
||
const executionMode = ["smart", "curated", "quant"].includes(mode) ? mode : state.screenerMode;
|
||
button.disabled = true;
|
||
state.screenerRunning = true;
|
||
state.screenerRunningMode = executionMode;
|
||
if (executionMode === "smart") {
|
||
setText("screenerRunStatus", "正在计算");
|
||
setText("backtestTaskStatus", runBacktest ? "正在回测" : "本次不执行");
|
||
}
|
||
setLoading(true, loadingText, "screener");
|
||
setStatus(`正在执行${strategyName}`);
|
||
try {
|
||
const payload = await apiRequest("/api/screener/run", "POST", {
|
||
trade_date: elements.tradeDate.value,
|
||
regime,
|
||
strategy_name: strategyName,
|
||
formula,
|
||
mode: executionMode,
|
||
run_backtest: runBacktest,
|
||
});
|
||
setScreenerResult(executionMode, payload.result, { regime, strategyId, strategyName });
|
||
renderScreenerResult();
|
||
if (executionMode === "smart") setText("screenerRunStatus", `完成 · ${payload.result.candidates.length} 只`);
|
||
updateBacktestTaskStatus();
|
||
if (window.innerWidth <= 720) selectScreenerMobileView("results");
|
||
setStatus(`${strategyName}完成 · ${payload.result.candidates.length} 只候选`);
|
||
} catch (error) {
|
||
showToast(error.message);
|
||
setStatus("选股执行失败");
|
||
if (executionMode === "smart") setText("screenerRunStatus", "执行失败");
|
||
updateBacktestTaskStatus();
|
||
} finally {
|
||
state.screenerRunning = false;
|
||
state.screenerRunningMode = "";
|
||
setLoading(false);
|
||
button.disabled = false;
|
||
updateBacktestTaskStatus();
|
||
}
|
||
}
|
||
|
||
async function loadMentorSetup(force = false) {
|
||
const requestedDate = elements.tradeDate.value.replaceAll("-", "");
|
||
if (!force && state.mentorSetup?.requestedDate === requestedDate) {
|
||
renderMentorWorkspace();
|
||
return;
|
||
}
|
||
try {
|
||
const query = new URLSearchParams({ trade_date: elements.tradeDate.value });
|
||
const payload = await apiRequest(`/api/mentors/setup?${query}`);
|
||
payload.requestedDate = requestedDate;
|
||
if (!payload.preferences_configured) {
|
||
payload.mentors.sort((first, second) => {
|
||
if (Boolean(first.private) !== Boolean(second.private)) return first.private ? -1 : 1;
|
||
return String(first.name || "").localeCompare(String(second.name || ""), "zh-CN");
|
||
});
|
||
payload.mentors.forEach((mentor, index) => { mentor.sort_order = index; });
|
||
}
|
||
state.mentorSetup = payload;
|
||
const selectedExists = payload.mentors.some((item) => item.id === state.selectedMentorId);
|
||
state.selectedMentorId = selectedExists ? state.selectedMentorId : payload.mentors[0]?.id || "";
|
||
state.mentorMessages = await loadMentorMessages();
|
||
renderMentorWorkspace();
|
||
} catch (error) {
|
||
showMentorNotice(error.message || "问师模块加载失败");
|
||
showToast(error.message || "问师模块加载失败");
|
||
}
|
||
}
|
||
|
||
function renderMentorWorkspace() {
|
||
const setup = state.mentorSetup;
|
||
if (!setup) return;
|
||
const selected = setup.mentors.find((item) => item.id === state.selectedMentorId) || null;
|
||
setText("mentorDataDate", `数据日期 ${displayCompactDate(setup.trade_date)}`);
|
||
setText("activeMentorName", selected?.name || "--");
|
||
setText("mobileActiveMentorName", selected?.name || "选择思维模型");
|
||
document.querySelector("#activeMentorBadges").innerHTML = selected ? renderMentorBadges(selected, true) : "";
|
||
setText("activeMentorEvidence", selected?.evidence?.note || selected?.description || "--");
|
||
document.querySelector("#activeMentorFocus").innerHTML = (selected?.focus || []).slice(0, 4)
|
||
.map((item) => `<span>${escapeHtml(item)}</span>`).join("");
|
||
renderMentorDirectory();
|
||
renderMentorMessages();
|
||
}
|
||
|
||
function renderMentorDirectory() {
|
||
const mentors = state.mentorSetup?.mentors || [];
|
||
const query = state.mentorQuery;
|
||
const filtered = mentors.filter((mentor) => {
|
||
if (state.mentorSortMode) return true;
|
||
if (state.mentorGrade !== "all" && mentor.evidence?.grade !== state.mentorGrade) return false;
|
||
if (!query) return true;
|
||
const haystack = [
|
||
mentor.name,
|
||
mentor.description,
|
||
mentor.tagline,
|
||
mentor.evidence?.label,
|
||
mentor.evidence?.note,
|
||
...(mentor.focus || []),
|
||
].filter(Boolean).join(" ").toLocaleLowerCase("zh-CN");
|
||
return haystack.includes(query);
|
||
});
|
||
setText("mentorCount", filtered.length === mentors.length ? `${mentors.length} 位` : `${filtered.length} / ${mentors.length} 位`);
|
||
const sortToggle = document.querySelector("#mentorSortToggle");
|
||
sortToggle.classList.toggle("active", state.mentorSortMode);
|
||
sortToggle.setAttribute("aria-pressed", String(state.mentorSortMode));
|
||
sortToggle.querySelector("span").textContent = state.mentorSortMode ? "完成" : "整理";
|
||
document.querySelector("#mentorSortHint").hidden = !state.mentorSortMode;
|
||
document.querySelector("#mentorSearchInput").disabled = state.mentorSortMode;
|
||
document.querySelectorAll("[data-mentor-grade]").forEach((button) => {
|
||
button.disabled = state.mentorSortMode;
|
||
});
|
||
const container = document.querySelector("#mentorList");
|
||
container.classList.toggle("is-sorting", state.mentorSortMode);
|
||
container.innerHTML = filtered.map((mentor) => {
|
||
const group = mentors.filter((item) => Boolean(item.pinned) === Boolean(mentor.pinned));
|
||
const groupIndex = group.findIndex((item) => item.id === mentor.id);
|
||
return `
|
||
<article class="mentor-option ${mentor.id === state.selectedMentorId ? "active" : ""} ${mentor.pinned ? "is-pinned" : ""}"
|
||
data-mentor-card="${escapeHtml(mentor.id)}" draggable="${state.mentorSortMode && !state.mentorSavingPreferences}">
|
||
<button type="button" class="mentor-option-main" data-mentor-id="${escapeHtml(mentor.id)}" aria-pressed="${mentor.id === state.selectedMentorId}" ${state.mentorLoading ? "disabled" : ""}>
|
||
<span class="mentor-option-copy">
|
||
<span class="mentor-option-heading">
|
||
<strong>${escapeHtml(mentor.name)}</strong>
|
||
<span class="mentor-option-badges">${renderMentorBadges(mentor)}</span>
|
||
</span>
|
||
<em title="${escapeHtml(mentor.description || "")}">${escapeHtml(mentor.description || mentor.tagline || "思维模型")}</em>
|
||
<span class="mentor-option-meta">
|
||
${mentor.evidence?.label ? `<span class="mentor-evidence-source" title="${escapeHtml(mentor.evidence?.note || "素材说明")}">${escapeHtml(mentor.evidence.label)}</span>` : ""}
|
||
${(mentor.focus || []).slice(0, 2).map((item) => `<span>#${escapeHtml(item)}</span>`).join("")}
|
||
</span>
|
||
</span>
|
||
</button>
|
||
<span class="mentor-option-tools">
|
||
<button type="button" class="mentor-pin-button ${mentor.pinned ? "active" : ""}" data-mentor-pin="${escapeHtml(mentor.id)}"
|
||
aria-label="${mentor.pinned ? "取消置顶" : "置顶"}${escapeHtml(mentor.name)}" title="${mentor.pinned ? "取消置顶" : "置顶"}" ${state.mentorSavingPreferences ? "disabled" : ""}>
|
||
<i data-lucide="pin"></i>
|
||
</button>
|
||
${state.mentorSortMode ? `
|
||
<button type="button" class="mentor-order-button" data-mentor-move="up" data-mentor-target="${escapeHtml(mentor.id)}" aria-label="上移${escapeHtml(mentor.name)}" title="上移" ${groupIndex <= 0 || state.mentorSavingPreferences ? "disabled" : ""}><i data-lucide="chevron-up"></i></button>
|
||
<button type="button" class="mentor-order-button" data-mentor-move="down" data-mentor-target="${escapeHtml(mentor.id)}" aria-label="下移${escapeHtml(mentor.name)}" title="下移" ${groupIndex >= group.length - 1 || state.mentorSavingPreferences ? "disabled" : ""}><i data-lucide="chevron-down"></i></button>
|
||
` : ""}
|
||
</span>
|
||
</article>
|
||
`;
|
||
}).join("");
|
||
document.querySelector("#mentorListEmpty").hidden = filtered.length > 0;
|
||
document.querySelectorAll("[data-mentor-id]").forEach((button) => {
|
||
button.addEventListener("click", () => selectMentor(button.dataset.mentorId));
|
||
});
|
||
document.querySelectorAll("[data-mentor-pin]").forEach((button) => {
|
||
button.addEventListener("click", () => toggleMentorPin(button.dataset.mentorPin));
|
||
});
|
||
document.querySelectorAll("[data-mentor-move]").forEach((button) => {
|
||
button.addEventListener("click", () => moveMentor(button.dataset.mentorTarget, button.dataset.mentorMove));
|
||
});
|
||
document.querySelectorAll("[data-mentor-card]").forEach((card) => {
|
||
card.addEventListener("dragstart", handleMentorDragStart);
|
||
card.addEventListener("dragover", handleMentorDragOver);
|
||
card.addEventListener("drop", handleMentorDrop);
|
||
card.addEventListener("dragend", clearMentorDragState);
|
||
});
|
||
refreshIcons();
|
||
}
|
||
|
||
function toggleMentorSortMode() {
|
||
state.mentorSortMode = !state.mentorSortMode;
|
||
if (state.mentorSortMode) {
|
||
state.mentorQuery = "";
|
||
state.mentorGrade = "all";
|
||
document.querySelector("#mentorSearchInput").value = "";
|
||
document.querySelectorAll("[data-mentor-grade]").forEach((button) => {
|
||
button.classList.toggle("active", button.dataset.mentorGrade === "all");
|
||
});
|
||
}
|
||
renderMentorDirectory();
|
||
}
|
||
|
||
async function toggleMentorPin(mentorId) {
|
||
if (state.mentorSavingPreferences) return;
|
||
const mentors = state.mentorSetup?.mentors || [];
|
||
const index = mentors.findIndex((item) => item.id === mentorId);
|
||
if (index < 0) return;
|
||
const [mentor] = mentors.splice(index, 1);
|
||
mentor.pinned = !mentor.pinned;
|
||
if (mentor.pinned) {
|
||
mentors.unshift(mentor);
|
||
} else {
|
||
const firstUnpinned = mentors.findIndex((item) => !item.pinned);
|
||
mentors.splice(firstUnpinned < 0 ? mentors.length : firstUnpinned, 0, mentor);
|
||
}
|
||
normalizeMentorOrder();
|
||
renderMentorWorkspace();
|
||
await persistMentorPreferences();
|
||
}
|
||
|
||
async function moveMentor(mentorId, direction) {
|
||
if (state.mentorSavingPreferences) return;
|
||
const mentors = state.mentorSetup?.mentors || [];
|
||
const index = mentors.findIndex((item) => item.id === mentorId);
|
||
if (index < 0) return;
|
||
const step = direction === "up" ? -1 : 1;
|
||
const targetIndex = index + step;
|
||
if (targetIndex < 0 || targetIndex >= mentors.length) return;
|
||
if (Boolean(mentors[index].pinned) !== Boolean(mentors[targetIndex].pinned)) return;
|
||
[mentors[index], mentors[targetIndex]] = [mentors[targetIndex], mentors[index]];
|
||
normalizeMentorOrder();
|
||
renderMentorDirectory();
|
||
await persistMentorPreferences();
|
||
}
|
||
|
||
function handleMentorDragStart(event) {
|
||
if (!state.mentorSortMode || state.mentorSavingPreferences) {
|
||
event.preventDefault();
|
||
return;
|
||
}
|
||
state.mentorDragId = event.currentTarget.dataset.mentorCard || "";
|
||
event.dataTransfer.effectAllowed = "move";
|
||
event.dataTransfer.setData("text/plain", state.mentorDragId);
|
||
event.currentTarget.classList.add("is-dragging");
|
||
}
|
||
|
||
function handleMentorDragOver(event) {
|
||
const source = state.mentorSetup?.mentors.find((item) => item.id === state.mentorDragId);
|
||
const target = state.mentorSetup?.mentors.find((item) => item.id === event.currentTarget.dataset.mentorCard);
|
||
if (!source || !target || Boolean(source.pinned) !== Boolean(target.pinned)) return;
|
||
event.preventDefault();
|
||
event.dataTransfer.dropEffect = "move";
|
||
event.currentTarget.classList.add("is-drag-over");
|
||
}
|
||
|
||
async function handleMentorDrop(event) {
|
||
event.preventDefault();
|
||
const sourceId = state.mentorDragId || event.dataTransfer.getData("text/plain");
|
||
const targetId = event.currentTarget.dataset.mentorCard || "";
|
||
clearMentorDragState();
|
||
if (!sourceId || !targetId || sourceId === targetId) return;
|
||
const mentors = state.mentorSetup?.mentors || [];
|
||
const sourceIndex = mentors.findIndex((item) => item.id === sourceId);
|
||
const targetIndex = mentors.findIndex((item) => item.id === targetId);
|
||
if (sourceIndex < 0 || targetIndex < 0) return;
|
||
if (Boolean(mentors[sourceIndex].pinned) !== Boolean(mentors[targetIndex].pinned)) return;
|
||
const [mentor] = mentors.splice(sourceIndex, 1);
|
||
const insertionIndex = mentors.findIndex((item) => item.id === targetId);
|
||
mentors.splice(insertionIndex, 0, mentor);
|
||
normalizeMentorOrder();
|
||
renderMentorDirectory();
|
||
await persistMentorPreferences();
|
||
}
|
||
|
||
function clearMentorDragState() {
|
||
state.mentorDragId = "";
|
||
document.querySelectorAll(".mentor-option.is-dragging, .mentor-option.is-drag-over").forEach((item) => {
|
||
item.classList.remove("is-dragging", "is-drag-over");
|
||
});
|
||
}
|
||
|
||
function normalizeMentorOrder() {
|
||
(state.mentorSetup?.mentors || []).forEach((mentor, index) => {
|
||
mentor.sort_order = index;
|
||
});
|
||
}
|
||
|
||
async function persistMentorPreferences() {
|
||
const mentors = state.mentorSetup?.mentors || [];
|
||
state.mentorSavingPreferences = true;
|
||
renderMentorDirectory();
|
||
try {
|
||
await apiRequest("/api/mentors/preferences", "POST", {
|
||
order: mentors.map((item) => item.id),
|
||
pinned: mentors.filter((item) => item.pinned).map((item) => item.id),
|
||
});
|
||
} catch (error) {
|
||
showToast(error.message || "问师顺序保存失败");
|
||
await loadMentorSetup(true);
|
||
} finally {
|
||
state.mentorSavingPreferences = false;
|
||
renderMentorDirectory();
|
||
}
|
||
}
|
||
|
||
function renderMentorBadges(mentor, expanded = false) {
|
||
const badges = [];
|
||
if (mentor.private) {
|
||
badges.push('<span class="mentor-badge private" title="仅管理员本人可见"><i data-lucide="lock-keyhole"></i>仅自己</span>');
|
||
}
|
||
const grade = mentor.evidence?.grade;
|
||
if (grade) {
|
||
badges.push(`<span class="mentor-badge evidence grade-${escapeHtml(grade.toLowerCase())}" title="${escapeHtml(mentor.evidence?.note || "素材等级")}">${escapeHtml(grade)}</span>`);
|
||
}
|
||
return badges.join("");
|
||
}
|
||
|
||
function toggleMentorDirectory(open) {
|
||
const mobileOpen = Boolean(open) && window.innerWidth <= 720;
|
||
state.mentorDirectoryOpen = mobileOpen;
|
||
const sidebar = document.querySelector("#mentorView .mentor-sidebar");
|
||
const backdrop = document.querySelector("#mentorDirectoryBackdrop");
|
||
const toggle = document.querySelector("#mentorDirectoryToggle");
|
||
sidebar.classList.toggle("is-open", mobileOpen);
|
||
backdrop.hidden = !mobileOpen;
|
||
toggle.setAttribute("aria-expanded", String(mobileOpen));
|
||
document.body.classList.toggle("mentor-directory-open", mobileOpen);
|
||
if (mobileOpen) requestAnimationFrame(() => document.querySelector("#mentorSearchInput").focus());
|
||
}
|
||
|
||
async function selectMentor(mentorId) {
|
||
if (mentorId === state.selectedMentorId) {
|
||
toggleMentorDirectory(false);
|
||
return;
|
||
}
|
||
state.selectedMentorId = mentorId;
|
||
state.mentorMessages = [];
|
||
hideMentorNotice();
|
||
renderMentorWorkspace();
|
||
toggleMentorDirectory(false);
|
||
state.mentorMessages = await loadMentorMessages();
|
||
renderMentorMessages();
|
||
}
|
||
|
||
function renderMentorMessages() {
|
||
const container = document.querySelector("#mentorMessages");
|
||
const selected = state.mentorSetup?.mentors.find((item) => item.id === state.selectedMentorId);
|
||
if (!state.mentorMessages.length && !state.mentorLoading) {
|
||
container.innerHTML = `
|
||
<div class="mentor-empty-state">
|
||
<span class="mentor-empty-mark" aria-hidden="true"><i data-lucide="messages-square"></i></span>
|
||
<strong>向「${escapeHtml(selected?.name || "问师")}」请教</strong>
|
||
<p>${escapeHtml(selected?.tagline || selected?.description || "选择一个问题开始对话")}</p>
|
||
</div>
|
||
`;
|
||
refreshIcons();
|
||
} else {
|
||
container.innerHTML = state.mentorMessages.map((message) => `
|
||
<article class="mentor-message ${message.role} ${message.error ? "is-error" : ""}">
|
||
<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.streaming ? '<span class="assistant-stream-caret" aria-hidden="true"></span>' : ""}
|
||
${message.meta && !message.streaming ? `<small>${escapeHtml(message.meta)}</small>` : ""}
|
||
</article>
|
||
`).join("");
|
||
if (state.mentorLoading && !state.mentorMessages.some((message) => message.streaming)) {
|
||
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;
|
||
document.querySelector("#mentorSortToggle").disabled = state.mentorLoading;
|
||
requestAnimationFrame(() => { container.scrollTop = container.scrollHeight; });
|
||
}
|
||
|
||
async function sendMentorQuestion(event) {
|
||
event.preventDefault();
|
||
if (state.mentorLoading || !state.selectedMentorId) return;
|
||
const input = document.querySelector("#mentorQuestion");
|
||
const question = input.value.trim();
|
||
if (!question) return;
|
||
const history = state.mentorMessages.slice(-6).map((item) => ({
|
||
role: item.role,
|
||
content: item.content.slice(0, 3500),
|
||
}));
|
||
state.mentorMessages.push({ role: "user", content: question });
|
||
const responseMessage = { role: "assistant", content: "", streaming: true, meta: "" };
|
||
state.mentorMessages.push(responseMessage);
|
||
input.value = "";
|
||
state.mentorLoading = true;
|
||
state.mentorController = new AbortController();
|
||
hideMentorNotice();
|
||
renderMentorMessages();
|
||
renderMentorDirectory();
|
||
setStatus("问师正在读取复盘数据");
|
||
try {
|
||
await streamMentorRequest(
|
||
{
|
||
mentor_id: state.selectedMentorId,
|
||
trade_date: elements.tradeDate.value,
|
||
question,
|
||
history,
|
||
},
|
||
state.mentorController.signal,
|
||
(chunk) => {
|
||
responseMessage.content += chunk;
|
||
scheduleMentorRender();
|
||
},
|
||
(meta) => {
|
||
responseMessage.meta = `${displayCompactDate(meta.data_trade_date || elements.tradeDate.value)} · 回答完成`;
|
||
if (meta.notice) showMentorNotice(meta.notice);
|
||
},
|
||
);
|
||
responseMessage.streaming = false;
|
||
setStatus("问师回答完成");
|
||
} catch (error) {
|
||
responseMessage.streaming = false;
|
||
responseMessage.error = true;
|
||
if (!responseMessage.content) {
|
||
state.mentorMessages = state.mentorMessages.filter((item) => item !== responseMessage);
|
||
}
|
||
showMentorNotice(error.message || "问师回答失败");
|
||
showToast(error.message || "问师回答失败");
|
||
setStatus("问师回答失败");
|
||
} finally {
|
||
state.mentorLoading = false;
|
||
state.mentorController = null;
|
||
renderMentorMessages();
|
||
renderMentorDirectory();
|
||
input.focus();
|
||
}
|
||
}
|
||
|
||
let mentorRenderFrame = 0;
|
||
|
||
function scheduleMentorRender() {
|
||
if (mentorRenderFrame) return;
|
||
mentorRenderFrame = requestAnimationFrame(() => {
|
||
mentorRenderFrame = 0;
|
||
renderMentorMessages();
|
||
});
|
||
}
|
||
|
||
async function streamMentorRequest(body, signal, onDelta, onMeta) {
|
||
await window.XiaobaiAPI.streamNdjson("/api/mentors/chat", {
|
||
method: "POST",
|
||
body,
|
||
signal,
|
||
errorMessage: "问师暂不可用",
|
||
onEvent: (event) => {
|
||
if (event.type === "delta") onDelta(String(event.content || ""));
|
||
if (event.type === "meta") onMeta(event);
|
||
},
|
||
});
|
||
}
|
||
|
||
function useMentorQuickPrompt(prompt) {
|
||
const input = document.querySelector("#mentorQuestion");
|
||
input.value = prompt || "";
|
||
input.focus();
|
||
}
|
||
|
||
async function clearMentorConversation() {
|
||
if (!state.mentorMessages.length || !window.confirm("确定清空当前老师的对话记录吗?")) return;
|
||
try {
|
||
const query = new URLSearchParams({
|
||
mentor_id: state.selectedMentorId,
|
||
trade_date: state.mentorSetup?.trade_date || elements.tradeDate.value,
|
||
});
|
||
await apiRequest(`/api/mentors/messages?${query}`, "DELETE");
|
||
state.mentorMessages = [];
|
||
hideMentorNotice();
|
||
renderMentorMessages();
|
||
} catch (error) {
|
||
showToast(error.message || "对话记录清空失败");
|
||
}
|
||
}
|
||
|
||
async function loadMentorMessages() {
|
||
if (!state.selectedMentorId) return [];
|
||
try {
|
||
const query = new URLSearchParams({
|
||
mentor_id: state.selectedMentorId,
|
||
trade_date: state.mentorSetup?.trade_date || elements.tradeDate.value,
|
||
});
|
||
const payload = await apiRequest(`/api/mentors/messages?${query}`);
|
||
return (payload.items || []).filter(
|
||
(item) => ["user", "assistant"].includes(item?.role) && typeof item.content === "string",
|
||
).slice(-100);
|
||
} catch (error) {
|
||
showMentorNotice(error.message || "对话记录加载失败");
|
||
return [];
|
||
}
|
||
}
|
||
|
||
function showMentorNotice(message) {
|
||
const notice = document.querySelector("#mentorNotice");
|
||
notice.textContent = message;
|
||
notice.hidden = false;
|
||
}
|
||
|
||
function hideMentorNotice() {
|
||
document.querySelector("#mentorNotice").hidden = true;
|
||
}
|
||
|
||
function formatMentorAnswer(content) {
|
||
const blocks = [];
|
||
let listType = "";
|
||
let listItems = [];
|
||
const flushList = () => {
|
||
if (!listItems.length) return;
|
||
blocks.push(`<${listType} class="mentor-answer-list">${listItems.map((item) => `<li>${item}</li>`).join("")}</${listType}>`);
|
||
listItems = [];
|
||
listType = "";
|
||
};
|
||
String(content || "").replace(/\r\n?/g, "\n").replace(/\n{3,}/g, "\n\n").split("\n").forEach((rawLine) => {
|
||
const line = rawLine.trim();
|
||
if (!line) {
|
||
flushList();
|
||
return;
|
||
}
|
||
const heading = line.match(/^#{1,3}\s+(.+)$/);
|
||
const bullet = line.match(/^[-*]\s+(.+)$/);
|
||
const ordered = line.match(/^\d+[.、]\s*(.+)$/);
|
||
if (heading) {
|
||
flushList();
|
||
blocks.push(`<strong class="mentor-answer-heading">${formatMentorInline(escapeHtml(heading[1]))}</strong>`);
|
||
} else if (/^-{3,}$/.test(line)) {
|
||
flushList();
|
||
blocks.push('<span class="mentor-answer-rule"></span>');
|
||
} else if (line.startsWith("> ")) {
|
||
flushList();
|
||
blocks.push(`<span class="mentor-answer-quote">${formatMentorInline(escapeHtml(line.slice(2)))}</span>`);
|
||
} else if (bullet || ordered) {
|
||
const nextType = bullet ? "ul" : "ol";
|
||
if (listType && listType !== nextType) flushList();
|
||
listType = nextType;
|
||
listItems.push(formatMentorInline(escapeHtml((bullet || ordered)[1])));
|
||
} else {
|
||
flushList();
|
||
blocks.push(`<p class="mentor-answer-paragraph">${formatMentorInline(escapeHtml(line))}</p>`);
|
||
}
|
||
});
|
||
flushList();
|
||
return blocks.join("");
|
||
}
|
||
|
||
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.heavenInterpretations.fortune = payload.daily_fortune_reading || "";
|
||
state.heavenManualData = Object.keys(payload.chart?.manual_data || {}).length
|
||
? payload.chart.manual_data
|
||
: null;
|
||
state.personalField = payload.personal_profile || null;
|
||
state.heavenPerformanceKey = `${requestedKey}:${requestSequence}`;
|
||
state.heavenPerformancePanels = new Set();
|
||
state.heavenPerformanceActive = "";
|
||
const nextFocus = `${payload.chart?.sector || ""}:${payload.chart?.stock?.code || ""}`;
|
||
if (previousFocus && previousFocus !== nextFocus) state.heavenInterpretations.trend = "";
|
||
hideHeavenNotice();
|
||
renderHeavenWorkspace();
|
||
if (payload.chart.selection_notice) showHeavenNotice(payload.chart.selection_notice);
|
||
} catch (error) {
|
||
if (requestSequence !== state.heavenRequestSequence) return;
|
||
showHeavenNotice(error.message || "问天数据加载失败");
|
||
showToast(error.message || "问天数据加载失败");
|
||
} finally {
|
||
if (requestSequence === state.heavenRequestSequence) {
|
||
heavenView?.classList.remove("heaven-data-loading");
|
||
if (loadButton) loadButton.disabled = false;
|
||
calibrationButtons.forEach((button) => { button.disabled = false; });
|
||
}
|
||
}
|
||
}
|
||
|
||
function loadHeavenSelection() {
|
||
const stockCode = document.querySelector("#heavenStockInput").value.trim();
|
||
state.heavenManualData = null;
|
||
loadHeavenSetup(true, "", stockCode);
|
||
}
|
||
|
||
function applyHeavenCalibration(event) {
|
||
event.preventDefault();
|
||
const data = { ...(state.heavenManualData || {}) };
|
||
delete data.note;
|
||
document.querySelectorAll("[data-heaven-manual-field]").forEach((input) => {
|
||
const current = String(input.value || "").trim();
|
||
const original = String(input.dataset.originalValue || "").trim();
|
||
if (!current) return;
|
||
if (current !== original || input.dataset.manual === "true") {
|
||
data[input.dataset.heavenManualField] = input.type === "number" ? Number(current) : current;
|
||
}
|
||
});
|
||
const note = document.querySelector("#heavenCalibrationNote").value.trim();
|
||
if (note) data.note = note;
|
||
if (!Object.keys(data).some((key) => key !== "note")) {
|
||
showToast("请先补充或修改至少一项量化数据");
|
||
return;
|
||
}
|
||
state.heavenManualData = data;
|
||
loadHeavenSetup(true, "", document.querySelector("#heavenStockInput").value.trim());
|
||
}
|
||
|
||
function resetHeavenCalibration() {
|
||
state.heavenManualData = null;
|
||
document.querySelector("#heavenCalibrationNote").value = "";
|
||
loadHeavenSetup(true, "", document.querySelector("#heavenStockInput").value.trim());
|
||
}
|
||
|
||
function selectHeavenPanel(panel, updateUrl = false) {
|
||
state.heavenPanel = panel;
|
||
if (state.heavenSetup) {
|
||
const calendarDate = state.heavenSetup.calendar_date || state.heavenSetup.trade_date;
|
||
const dateLabel = panel === "trend"
|
||
? (calendarDate === state.heavenSetup.trade_date
|
||
? `行情 ${displayCompactDate(state.heavenSetup.trade_date)}`
|
||
: `行情 ${displayCompactDate(state.heavenSetup.trade_date)} · 历法 ${displayCompactDate(calendarDate)}`)
|
||
: `历法 ${displayCompactDate(calendarDate)}`;
|
||
setText("heavenDataDate", dateLabel);
|
||
}
|
||
document.querySelectorAll("[data-heaven-panel]").forEach((button) => {
|
||
const active = button.dataset.heavenPanel === panel;
|
||
button.classList.toggle("active", active);
|
||
button.classList.toggle("on", active);
|
||
button.setAttribute("aria-current", active ? "page" : "false");
|
||
});
|
||
document.querySelectorAll(".heaven-panel").forEach((item) => {
|
||
item.classList.toggle("active-heaven-panel", item.id === `heaven${capitalize(panel)}Panel`);
|
||
});
|
||
if (
|
||
panel === "fortune"
|
||
&& state.heavenSetup?.field
|
||
&& state.heavenPerformancePanels.has("fortune")
|
||
) {
|
||
requestAnimationFrame(() => renderQiFieldCanvas(state.heavenSetup.field.balance || [], { intro: false }));
|
||
} else {
|
||
stopQiFieldCanvas();
|
||
}
|
||
if (panel === "heart") {
|
||
initializeHeartAtmosphere();
|
||
setHeartLamp(state.heartStage);
|
||
} else {
|
||
stopHeartDust();
|
||
}
|
||
if (panel !== "heart") requestAnimationFrame(() => queueHeavenPerformance(panel));
|
||
if (updateUrl) {
|
||
const url = new URL(window.location.href);
|
||
url.searchParams.set("heaven", panel);
|
||
history.replaceState(null, "", url);
|
||
}
|
||
}
|
||
|
||
function showHeartRitualCurtain() {
|
||
const curtain = document.querySelector("#heartRitualCurtain");
|
||
if (!curtain || curtain.classList.contains("is-visible")) return;
|
||
if (state.heartCurtainTimer) clearTimeout(state.heartCurtainTimer);
|
||
document.querySelectorAll(".heart-stage.active-heart-stage .heart-rise").forEach((item) => item.classList.remove("is-visible"));
|
||
curtain.classList.remove("is-leaving");
|
||
curtain.classList.add("is-visible");
|
||
state.heartCurtainTimer = setTimeout(() => {
|
||
curtain.classList.add("is-leaving");
|
||
activateHeartRises(document.querySelector(".heart-stage.active-heart-stage"));
|
||
state.heartCurtainTimer = setTimeout(() => {
|
||
curtain.classList.remove("is-visible", "is-leaving");
|
||
state.heartCurtainTimer = null;
|
||
}, motionEnabled() ? 1450 : 10);
|
||
}, motionEnabled() ? 3000 : 20);
|
||
}
|
||
|
||
function renderHeavenWorkspace() {
|
||
const setup = state.heavenSetup;
|
||
if (!setup) return;
|
||
initializeWentianV2Atmosphere();
|
||
renderMarketHexagram(setup.chart);
|
||
renderFivePhaseField(setup.field);
|
||
renderPersonalFortune();
|
||
renderHeartStage();
|
||
selectHeavenPanel(state.heavenPanel);
|
||
}
|
||
|
||
function buildWentianStars(id, count) {
|
||
const element = document.getElementById(id);
|
||
if (!element || element.children.length) return;
|
||
element.innerHTML = Array.from({ length: count }, () => {
|
||
const size = (Math.random() * 1.6 + 0.8).toFixed(1);
|
||
return `<i style="left:${(Math.random() * 100).toFixed(1)}%;top:${(Math.random() * 100).toFixed(1)}%;width:${size}px;height:${size}px;animation-delay:${(Math.random() * 4).toFixed(1)}s;animation-duration:${(3 + Math.random() * 4).toFixed(1)}s"></i>`;
|
||
}).join("");
|
||
}
|
||
|
||
function buildWentianBagua(svg) {
|
||
if (!svg || svg.children.length) return;
|
||
const trigrams = ["乾", "兑", "离", "震", "巽", "坎", "艮", "坤"];
|
||
let characters = "";
|
||
let ticks = "";
|
||
for (let index = 0; index < 8; index += 1) {
|
||
const angle = (index * 45 - 90) * Math.PI / 180;
|
||
const x = 150 + 129 * Math.cos(angle);
|
||
const y = 150 + 129 * Math.sin(angle);
|
||
characters += `<text x="${x.toFixed(1)}" y="${y.toFixed(1)}" fill="#c9a55c" font-size="17" text-anchor="middle" dominant-baseline="middle" transform="rotate(${index * 45} ${x.toFixed(1)} ${y.toFixed(1)})">${trigrams[index]}</text>`;
|
||
}
|
||
for (let index = 0; index < 24; index += 1) {
|
||
const angle = (index * 15 - 90) * Math.PI / 180;
|
||
ticks += `<line x1="${(150 + 100 * Math.cos(angle)).toFixed(1)}" y1="${(150 + 100 * Math.sin(angle)).toFixed(1)}" x2="${(150 + 108 * Math.cos(angle)).toFixed(1)}" y2="${(150 + 108 * Math.sin(angle)).toFixed(1)}" stroke="#c9a55c" stroke-width="1"/>`;
|
||
}
|
||
svg.innerHTML = `<g class="ring"><circle cx="150" cy="150" r="146" fill="none" stroke="#c9a55c" stroke-width="1" stroke-dasharray="2 6"/><circle cx="150" cy="150" r="112" fill="none" stroke="#c9a55c" stroke-width="1" stroke-dasharray="10 5"/>${characters}</g><g class="ring2">${ticks}</g>`;
|
||
}
|
||
|
||
function buildWentianFortuneOrbit(svg) {
|
||
if (!svg || svg.children.length) return;
|
||
const sixQi = ["厥阴木", "少阴火", "少阳火", "太阴土", "阳明金", "太阳水"];
|
||
const movements = ["木运", "火运", "土运", "金运", "水运"];
|
||
const polarText = (items, radius, fontSize, offset = -90) => items.map((label, index) => {
|
||
const degrees = offset + index * 360 / items.length;
|
||
const angle = degrees * Math.PI / 180;
|
||
const x = 150 + radius * Math.cos(angle);
|
||
const y = 150 + radius * Math.sin(angle);
|
||
return `<text x="${x.toFixed(1)}" y="${y.toFixed(1)}" fill="#c9a55c" font-size="${fontSize}" text-anchor="middle" dominant-baseline="middle" transform="rotate(${degrees + 90} ${x.toFixed(1)} ${y.toFixed(1)})">${label}</text>`;
|
||
}).join("");
|
||
const ticks = Array.from({ length: 30 }, (_, index) => {
|
||
const angle = (index * 12 - 90) * Math.PI / 180;
|
||
const inner = index % 5 === 0 ? 96 : 101;
|
||
return `<line x1="${(150 + inner * Math.cos(angle)).toFixed(1)}" y1="${(150 + inner * Math.sin(angle)).toFixed(1)}" x2="${(150 + 108 * Math.cos(angle)).toFixed(1)}" y2="${(150 + 108 * Math.sin(angle)).toFixed(1)}" stroke="#c9a55c" stroke-width="${index % 5 === 0 ? 1.4 : 0.7}"/>`;
|
||
}).join("");
|
||
svg.innerHTML = `<g class="ring"><circle cx="150" cy="150" r="146" fill="none" stroke="#c9a55c" stroke-width="1" stroke-dasharray="2 6"/><circle cx="150" cy="150" r="116" fill="none" stroke="#c9a55c" stroke-width="1" stroke-dasharray="10 5"/>${polarText(sixQi, 132, 8.5)}</g><g class="ring2"><circle cx="150" cy="150" r="88" fill="none" stroke="#c9a55c" stroke-width="1" stroke-dasharray="4 5"/>${ticks}${polarText(movements, 70, 10)}<text x="150" y="150" fill="#c9a55c" font-size="11" text-anchor="middle" dominant-baseline="middle">五运六气</text></g>`;
|
||
}
|
||
|
||
function initializeWentianV2Atmosphere() {
|
||
buildWentianStars("stars", 90);
|
||
buildWentianStars("fortuneStars", 100);
|
||
buildWentianStars("heartStars", 110);
|
||
buildWentianBagua(document.querySelector("#baguaSvg"));
|
||
buildWentianFortuneOrbit(document.querySelector("#fortuneBagua"));
|
||
buildWentianBagua(document.querySelector("#heartBagua"));
|
||
}
|
||
|
||
function renderCompactHexagrams(hexagram) {
|
||
const original = document.querySelector("#heavenOriginalHexLines");
|
||
const changed = document.querySelector("#heavenChangedHexLines");
|
||
if (!original || !changed) return;
|
||
if (!hexagram?.lines?.length) {
|
||
original.innerHTML = "";
|
||
changed.innerHTML = "";
|
||
setText("heavenOriginalHexName", "待定");
|
||
setText("heavenChangedHexName", "待定");
|
||
setText("heavenOriginalHexDetail", "六爻尚未齐备");
|
||
setText("heavenChangedHexDetail", "待动爻化变");
|
||
return;
|
||
}
|
||
const values = hexagram.lines.map((line) => number(line.value));
|
||
const changedValues = values.map((value) => value === 6 ? 7 : value === 9 ? 8 : value);
|
||
const lines = (items, showMoving) => [...items].reverse().map((value) => {
|
||
const moving = showMoving && [6, 9].includes(value);
|
||
return `<div class="compact-hex-line ${value % 2 ? "yang" : "yin"} ${moving ? "moving" : ""}"><i></i>${value % 2 ? "" : "<i></i>"}</div>`;
|
||
}).join("");
|
||
original.innerHTML = lines(values, true);
|
||
changed.innerHTML = lines(changedValues, false);
|
||
setText("heavenOriginalHexName", hexagram.name || "--");
|
||
setText("heavenChangedHexName", hexagram.transformed?.name || "--");
|
||
setText("heavenOriginalHexDetail", `${hexagram.outer_trigram || "--"}上 · ${hexagram.inner_trigram || "--"}下`);
|
||
setText("heavenChangedHexDetail", `${hexagram.transformed?.outer_trigram || "--"}上 · ${hexagram.transformed?.inner_trigram || "--"}下`);
|
||
}
|
||
|
||
function cancelHeavenPerformance() {
|
||
heavenPerformanceToken += 1;
|
||
state.heavenPerformanceActive = "";
|
||
document.querySelectorAll("#heavenTrendPanel, #heavenFortunePanel").forEach((panel) => {
|
||
panel.classList.remove("heaven-performance-pending", "heaven-performance-running");
|
||
panel.classList.add("heaven-performance-complete");
|
||
});
|
||
}
|
||
|
||
function queueHeavenPerformance(panel) {
|
||
if (!state.heavenSetup || !["trend", "fortune"].includes(panel)) return;
|
||
const performanceId = `${state.heavenPerformanceKey}:${panel}`;
|
||
if (
|
||
state.heavenPerformancePanels.has(panel)
|
||
|| state.heavenPerformanceActive === performanceId
|
||
|| state.heavenPanel !== panel
|
||
) return;
|
||
const token = ++heavenPerformanceToken;
|
||
state.heavenPerformanceActive = performanceId;
|
||
const runner = panel === "trend"
|
||
? playTrendPerformance(state.heavenSetup.chart, token)
|
||
: playFortunePerformance(state.heavenSetup.field, token);
|
||
runner.then((completed) => {
|
||
if (!completed || token !== heavenPerformanceToken) return;
|
||
state.heavenPerformancePanels.add(panel);
|
||
state.heavenPerformanceActive = "";
|
||
});
|
||
}
|
||
|
||
function heavenPerformanceDelay(duration, token) {
|
||
return new Promise((resolve) => {
|
||
setTimeout(() => resolve(token === heavenPerformanceToken), motionEnabled() ? duration : 0);
|
||
});
|
||
}
|
||
|
||
async function typeHeavenText(element, text, token, speed = 38) {
|
||
if (!element) return false;
|
||
if (!motionEnabled()) {
|
||
element.textContent = text;
|
||
return token === heavenPerformanceToken;
|
||
}
|
||
element.textContent = "";
|
||
element.classList.add("heaven-typing");
|
||
for (const character of text) {
|
||
if (token !== heavenPerformanceToken) return false;
|
||
element.append(document.createTextNode(character));
|
||
if (!await heavenPerformanceDelay(speed, token)) return false;
|
||
}
|
||
element.classList.remove("heaven-typing");
|
||
return true;
|
||
}
|
||
|
||
function countHeavenNumber(element, target, token, duration = 1300, suffix = "") {
|
||
return new Promise((resolve) => {
|
||
if (!element || !motionEnabled()) {
|
||
if (element) element.textContent = `${target > 0 ? "+" : ""}${target}${suffix}`;
|
||
resolve(token === heavenPerformanceToken);
|
||
return;
|
||
}
|
||
const startedAt = performance.now();
|
||
const step = (now) => {
|
||
if (token !== heavenPerformanceToken) {
|
||
resolve(false);
|
||
return;
|
||
}
|
||
const progress = Math.min(1, (now - startedAt) / duration);
|
||
const eased = 1 - (1 - progress) ** 3;
|
||
const value = Math.round(target * eased);
|
||
element.textContent = `${value > 0 ? "+" : ""}${value}${suffix}`;
|
||
if (progress < 1) requestAnimationFrame(step);
|
||
else resolve(true);
|
||
};
|
||
requestAnimationFrame(step);
|
||
});
|
||
}
|
||
|
||
async function playTrendPerformance(chart, token) {
|
||
const panel = document.querySelector("#heavenTrendPanel");
|
||
if (!panel || state.heavenPanel !== "trend") return false;
|
||
panel.classList.remove(
|
||
"heaven-performance-complete",
|
||
"performance-title-ready",
|
||
"performance-change-ready",
|
||
"performance-score-ready",
|
||
"performance-text-ready",
|
||
);
|
||
panel.classList.add("heaven-performance-pending", "heaven-performance-running");
|
||
panel.querySelectorAll(".talent-line-group, .hexagram-line-row, .talent-reading, .heaven-index-strip > *").forEach((item) => {
|
||
item.classList.remove("is-ready");
|
||
});
|
||
if (!chart?.available) {
|
||
panel.classList.remove("heaven-performance-pending", "heaven-performance-running");
|
||
panel.classList.add("heaven-performance-complete");
|
||
return true;
|
||
}
|
||
|
||
const guaci = chart.hexagram.text || "";
|
||
const scoreElement = document.querySelector("#heavenMomentumScore");
|
||
const guaciElement = document.querySelector("#marketHexagramText");
|
||
if (scoreElement) scoreElement.textContent = "0";
|
||
if (guaciElement) guaciElement.textContent = "";
|
||
if (!await heavenPerformanceDelay(220, token)) return false;
|
||
|
||
const groups = [...panel.querySelectorAll(".talent-line-group")].reverse();
|
||
const readings = [...panel.querySelectorAll(".talent-reading")];
|
||
for (let index = 0; index < groups.length; index += 1) {
|
||
const group = groups[index];
|
||
group.classList.add("is-ready");
|
||
if (!await heavenPerformanceDelay(280, token)) return false;
|
||
const rows = [...group.querySelectorAll(".hexagram-line-row")].reverse();
|
||
for (const row of rows) {
|
||
row.classList.add("is-ready");
|
||
if (!await heavenPerformanceDelay(560, token)) return false;
|
||
}
|
||
readings[index]?.classList.add("is-ready");
|
||
if (!await heavenPerformanceDelay(220, token)) return false;
|
||
}
|
||
|
||
panel.classList.add("performance-title-ready");
|
||
if (!await heavenPerformanceDelay(650, token)) return false;
|
||
panel.classList.add("performance-change-ready");
|
||
if (!await heavenPerformanceDelay(420, token)) return false;
|
||
panel.classList.add("performance-score-ready");
|
||
if (!await countHeavenNumber(scoreElement, number(chart.momentum_score), token)) return false;
|
||
panel.querySelectorAll(".heaven-index-strip > *").forEach((item, index) => {
|
||
setTimeout(() => {
|
||
if (token === heavenPerformanceToken) item.classList.add("is-ready");
|
||
}, motionEnabled() ? index * 90 : 0);
|
||
});
|
||
if (!await heavenPerformanceDelay(620, token)) return false;
|
||
if (!await typeHeavenText(guaciElement, guaci, token, 30)) return false;
|
||
panel.classList.add("performance-text-ready");
|
||
panel.classList.remove("heaven-performance-pending", "heaven-performance-running");
|
||
panel.classList.add("heaven-performance-complete");
|
||
return true;
|
||
}
|
||
|
||
async function playFortunePerformance(field, token) {
|
||
const panel = document.querySelector("#heavenFortunePanel");
|
||
if (!panel || !field || state.heavenPanel !== "fortune") return false;
|
||
panel.classList.remove("heaven-performance-complete", "performance-climate-ready", "performance-use-ready");
|
||
panel.classList.add("heaven-performance-pending", "heaven-performance-running");
|
||
panel.querySelectorAll(".phase-balance-row, .qi-framework-layer, .human-field-grid > div, .personal-fortune-panel").forEach((item) => {
|
||
item.classList.remove("is-ready");
|
||
});
|
||
const climateTone = document.querySelector("#qiClimateTone");
|
||
const climateText = climateTone?.textContent || "";
|
||
if (climateTone) climateTone.textContent = "";
|
||
renderQiFieldCanvas(field.balance || [], { intro: true });
|
||
if (!await heavenPerformanceDelay(900, token)) return false;
|
||
panel.classList.add("performance-climate-ready");
|
||
if (!await heavenPerformanceDelay(720, token)) return false;
|
||
if (!await typeHeavenText(climateTone, climateText, token, 58)) return false;
|
||
|
||
const balanceRows = [...panel.querySelectorAll(".phase-balance-row")];
|
||
for (const row of balanceRows) {
|
||
row.classList.add("is-ready");
|
||
const percent = number(row.dataset.phasePercent);
|
||
if (!await countHeavenNumber(row.querySelector(":scope > b"), percent, token, 520, "%")) return false;
|
||
if (!await heavenPerformanceDelay(90, token)) return false;
|
||
}
|
||
const layers = [...panel.querySelectorAll(".qi-framework-layer")];
|
||
for (const layer of layers) {
|
||
layer.classList.add("is-ready");
|
||
if (!await heavenPerformanceDelay(250, token)) return false;
|
||
}
|
||
panel.querySelectorAll(".human-field-grid > div").forEach((item, index) => {
|
||
setTimeout(() => {
|
||
if (token === heavenPerformanceToken) item.classList.add("is-ready");
|
||
}, motionEnabled() ? index * 150 : 0);
|
||
});
|
||
if (!await heavenPerformanceDelay(820, token)) return false;
|
||
panel.querySelector(".personal-fortune-panel")?.classList.add("is-ready");
|
||
panel.classList.add("performance-use-ready");
|
||
drawQiUseConnections(true);
|
||
panel.classList.remove("heaven-performance-pending", "heaven-performance-running");
|
||
panel.classList.add("heaven-performance-complete");
|
||
return true;
|
||
}
|
||
|
||
function heavenSourcePhrase(item = {}) {
|
||
const stateLabel = item.realtime ? "当下之象" : "既成之象";
|
||
const layerLabel = {
|
||
指数: "天象合参",
|
||
行业: "人势同观",
|
||
个股: "地脉验真",
|
||
用户补充: "人工验数",
|
||
}[item.layer] || "三才合参";
|
||
return `${layerLabel} · ${stateLabel}`;
|
||
}
|
||
|
||
function renderHeavenLineChecks(chart) {
|
||
const checks = [...(chart.data_checks || [])].sort((left, right) => number(right.line) - number(left.line));
|
||
const container = document.querySelector("#heavenLineChecks");
|
||
const status = document.querySelector("#heavenCalibrationStatus");
|
||
const passedCount = checks.filter((item) => item.passed).length;
|
||
const manualCount = checks.filter((item) => item.status === "manual").length;
|
||
status.textContent = checks.length ? `${passedCount}/6 通过${manualCount ? ` · ${manualCount} 爻含补录` : ""}` : "等待载入";
|
||
status.className = passedCount === 6 ? (manualCount ? "is-manual" : "is-passed") : "is-failed";
|
||
if (!checks.length) {
|
||
container.innerHTML = '<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 || "--",
|
||
);
|
||
setText("heavenStockTaxonomy", chart.sector_taxonomy === "sw_l2" ? "申万二级 ·" : "所属行业 ·");
|
||
renderHeavenLineChecks(chart);
|
||
|
||
const interpretButton = document.querySelector("#interpretTrendButton");
|
||
const scoreMeter = document.querySelector(".trend-score-meter");
|
||
const scoreNeedle = document.querySelector("#heavenMomentumNeedle");
|
||
const renderTrendEvidence = () => {
|
||
const rows = chart.quality?.sources || [];
|
||
document.querySelector("#heavenTrendEvidence").innerHTML = rows.length
|
||
? rows.map((item) => `
|
||
<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", "数据未齐");
|
||
renderCompactHexagrams(null);
|
||
scoreMeter?.setAttribute("aria-valuenow", "0");
|
||
if (scoreNeedle) scoreNeedle.style.setProperty("--momentum-position", "50%");
|
||
document.querySelector("#marketHexagramLines").innerHTML = "";
|
||
const sourceRows = chart.quality?.sources || [];
|
||
document.querySelector("#threeTalentReadings").innerHTML = [
|
||
...(chart.quality?.issues || []).map((issue) => `
|
||
<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);
|
||
renderCompactHexagrams(chart.hexagram);
|
||
setText("marketHexagramText", chart.hexagram.text);
|
||
setText("marketMovementSummary", `${chart.movement.label}。${chart.movement.explanation}`);
|
||
setText("heavenMomentumScore", `${chart.momentum_score > 0 ? "+" : ""}${chart.momentum_score}`);
|
||
setText("heavenMomentumLabel", chart.momentum_label);
|
||
const momentumPosition = clamp((number(chart.momentum_score) + 100) / 2, 0, 100);
|
||
scoreMeter?.setAttribute("aria-valuenow", String(number(chart.momentum_score)));
|
||
if (scoreNeedle) scoreNeedle.style.setProperty("--momentum-position", `${momentumPosition}%`);
|
||
renderMarketHexagramLines(chart.hexagram.lines);
|
||
document.querySelector("#threeTalentReadings").innerHTML = chart.pair_readings.map((item) => `
|
||
<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 wentianClimateVerdict(field) {
|
||
const balance = field?.balance || [];
|
||
const dominant = balance[0]?.element;
|
||
const secondary = balance[1]?.element;
|
||
const tertiary = balance[2]?.element;
|
||
const pair = [dominant, secondary].filter(Boolean).sort().join("");
|
||
const primary = {
|
||
木火: "风火相煽", 木土: "风湿相搏", 木金: "风燥相激", 木水: "风寒相薄",
|
||
土火: "湿热交蒸", 火金: "燥热相煽", 水火: "寒热相争", 土金: "燥湿相搏",
|
||
土水: "寒湿交织", 水金: "寒燥相参",
|
||
}[pair] || ({ 木: "风木疏展", 火: "热火升明", 土: "湿滞偏重", 金: "燥金肃降", 水: "寒水潜藏" }[dominant] || "气机交会");
|
||
const following = { 木: "风象暗动", 火: "热象内蕴", 土: "湿滞内结", 金: "燥气相参", 水: "寒意潜行" }[tertiary]
|
||
|| ({ 木: "风象相随", 火: "热象相随", 土: "湿象相随", 金: "燥象相随", 水: "寒象相随" }[secondary] || "诸气相参");
|
||
return `${primary} · ${following}`;
|
||
}
|
||
|
||
function renderFivePhaseField(field) {
|
||
if (!field) return;
|
||
setText("fortuneLunarDate", `${field.date} · ${field.lunar_date}`);
|
||
setText("fortunePillars", `${field.pillars.year}年 · ${field.pillars.month}月 · ${field.pillars.day}日`);
|
||
const metrics = [
|
||
["中运", field.movement.label, field.movement.basis],
|
||
["司天", field.six_qi.sitian, "岁半以前主气候背景"],
|
||
["在泉", field.six_qi.zaiquan, "岁半以后主气候背景"],
|
||
[field.six_qi.step_name, `主 ${field.six_qi.host_qi}`, `客 ${field.six_qi.guest_qi}`],
|
||
["当前节气", field.solar_terms.current, field.solar_terms.current_at],
|
||
["下一节气", field.solar_terms.next, field.solar_terms.next_at],
|
||
];
|
||
document.querySelector("#fortuneMetrics").innerHTML = metrics.map(([label, value, detail]) => `
|
||
<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", wentianClimateVerdict(field));
|
||
setText("qiClimateTone", (human.emotional_tendency || [])[0] || "留意当下身心反应");
|
||
setText("humanFieldSummary", human.summary || "--");
|
||
setText("humanEmotionList", (human.emotional_tendency || []).join(";") || "--");
|
||
setText("humanBiasList", (human.decision_biases || []).join(";") || "--");
|
||
setText("humanOperation", human.operation_tendency || "--");
|
||
setText(
|
||
"humanBalanceActions",
|
||
[...(human.risk_reminders || []), ...(human.balancing_actions || [])].join(";") || "--",
|
||
);
|
||
document.querySelector("#fivePhaseBalance").innerHTML = field.balance.map((item) => `
|
||
<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);
|
||
renderFortuneSectorCatalog(field);
|
||
renderSectorPhaseOverrides(state.heavenSetup?.sector_phase_overrides || []);
|
||
setText("fortuneNotice", field.notice);
|
||
renderHeavenInterpretation("fortune", state.heavenInterpretations.fortune);
|
||
}
|
||
|
||
function renderFortuneSectorCatalog(field) {
|
||
const container = document.querySelector("#fortuneSectorGroups");
|
||
if (!container) return;
|
||
const phaseOrder = new Map((field.balance || []).map((item, index) => [item.element, index]));
|
||
const canonical = { 木: 0, 火: 1, 土: 2, 金: 3, 水: 4 };
|
||
const catalog = [...(field.sector_catalog || [])].sort((left, right) => (
|
||
(canonical[left.element] ?? phaseOrder.get(left.element) ?? 99)
|
||
- (canonical[right.element] ?? phaseOrder.get(right.element) ?? 99)
|
||
));
|
||
container.innerHTML = catalog.map((group) => `
|
||
<section class="fortune-sector-group">
|
||
<header><i class="phase-${phaseClass(group.element)}"></i><strong class="phase-text-${phaseClass(group.element)}">${escapeHtml(group.element)}属性</strong><small>${number(group.count || group.industries?.length)} 类</small></header>
|
||
<ul>${(group.industries || []).map((item) => `<li>${escapeHtml(item.name)}</li>`).join("")}</ul>
|
||
</section>
|
||
`).join("") || '<p class="personal-profile-empty">行业五行归类尚未建立</p>';
|
||
}
|
||
|
||
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) => (items || []).map((item) => `<em>${escapeHtml(item)}</em>`).join("") || "--";
|
||
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><span>十神喜恶</span><div class="personal-preference-line"><strong>偏宜</strong><p>${preferenceTags(tenGods.favorable)}</p></div><div class="personal-preference-line"><strong>偏慎</strong><p>${preferenceTags(tenGods.caution)}</p></div></section>
|
||
<section><span>五行喜忌</span><div class="personal-preference-line"><strong>偏喜</strong><p>${preferenceTags(elementTendency.favorable)}</p></div><div class="personal-preference-line"><strong>偏忌</strong><p>${preferenceTags(elementTendency.caution)}</p></div></section>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
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>
|
||
`;
|
||
}
|
||
|
||
const HEAVEN_READING_META = {
|
||
trend: { panel: "观势", action: "解势", done: "查看解势", status: "势已成" },
|
||
fortune: { panel: "观气", action: "解运", done: "已解运", status: "气已定" },
|
||
heart: { panel: "观心", action: "我已察念,开始解卦", done: "查看解卦", status: "卦已解" },
|
||
};
|
||
|
||
function heavenReadingMeta(mode = state.heavenReadingMode) {
|
||
return HEAVEN_READING_META[mode] || HEAVEN_READING_META.trend;
|
||
}
|
||
|
||
function heavenReadingAnimationData() {
|
||
const field = state.heavenSetup?.field || {};
|
||
return {
|
||
yearPillar: field.pillars?.year || "",
|
||
movement: field.movement?.label || "",
|
||
sixQi: {
|
||
sitian: field.six_qi?.sitian || "",
|
||
zaiquan: field.six_qi?.zaiquan || "",
|
||
step: number(field.six_qi?.step) || 1,
|
||
},
|
||
};
|
||
}
|
||
|
||
function syncHeavenReadingAnimation() {
|
||
const canvas = document.querySelector("#heavenReadingCanvas");
|
||
const shouldRun = state.heavenReadingLoading && state.heavenReadingTab === "current";
|
||
if (!canvas || !window.HeavenLoadingCanvas) return;
|
||
if (!shouldRun) {
|
||
stopHeavenReadingAnimation();
|
||
return;
|
||
}
|
||
if (!heavenReadingAnimation) heavenReadingAnimation = new window.HeavenLoadingCanvas(canvas);
|
||
const scene = state.heavenReadingMode === "fortune" ? "fortune" : "hexagram";
|
||
heavenReadingAnimation.start(scene, heavenReadingAnimationData());
|
||
}
|
||
|
||
function stopHeavenReadingAnimation() {
|
||
heavenReadingAnimation?.stop();
|
||
}
|
||
|
||
function finishHeavenReadingAnimation() {
|
||
if (!elements.heavenReadingDialog.open || !heavenReadingAnimation?.running) {
|
||
stopHeavenReadingAnimation();
|
||
return Promise.resolve();
|
||
}
|
||
return heavenReadingAnimation.complete();
|
||
}
|
||
|
||
function openHeavenReading(mode, options = {}) {
|
||
state.heavenReadingMode = mode;
|
||
state.heavenReadingTab = "current";
|
||
state.heavenReadingError = options.error || "";
|
||
state.heavenReadingLoading = Object.hasOwn(options, "loading")
|
||
? Boolean(options.loading)
|
||
: false;
|
||
renderHeavenReadingDialog();
|
||
openModalDialog(elements.heavenReadingDialog);
|
||
requestAnimationFrame(() => {
|
||
syncHeavenReadingAnimation();
|
||
document.querySelector("#closeHeavenReadingDialog").focus();
|
||
});
|
||
}
|
||
|
||
async function openHeavenHistory(mode) {
|
||
state.heavenReadingMode = mode;
|
||
state.heavenReadingTab = "history";
|
||
state.heavenReadingSelectedId = 0;
|
||
renderHeavenReadingDialog();
|
||
openModalDialog(elements.heavenReadingDialog);
|
||
await loadHeavenReadingHistory(mode);
|
||
}
|
||
|
||
function selectHeavenReadingTab(tab) {
|
||
state.heavenReadingTab = tab === "history" ? "history" : "current";
|
||
renderHeavenReadingDialog();
|
||
if (state.heavenReadingTab === "history") loadHeavenReadingHistory(state.heavenReadingMode);
|
||
}
|
||
|
||
async function loadHeavenReadingHistory(mode) {
|
||
const list = document.querySelector("#heavenReadingHistoryList");
|
||
list.innerHTML = '<div class="empty-state">正在读取历史记录</div>';
|
||
try {
|
||
const query = new URLSearchParams({ mode, limit: "100" });
|
||
const payload = await apiRequest(`/api/heaven/readings?${query}`);
|
||
state.heavenReadingHistory[mode] = payload.items || [];
|
||
if (!state.heavenReadingHistory[mode].some((item) => number(item.id) === state.heavenReadingSelectedId)) {
|
||
state.heavenReadingSelectedId = number(state.heavenReadingHistory[mode][0]?.id);
|
||
}
|
||
renderHeavenReadingHistory();
|
||
} catch (error) {
|
||
list.innerHTML = `<div class="empty-state">${escapeHtml(error.message || "历史记录加载失败")}</div>`;
|
||
}
|
||
}
|
||
|
||
function renderHeavenReadingDialog() {
|
||
const meta = heavenReadingMeta();
|
||
setText("heavenReadingEyebrow", `问天 · ${meta.panel}`);
|
||
setText("heavenReadingDialogTitle", state.heavenReadingTab === "history" ? "历史记录" : meta.status);
|
||
document.querySelectorAll("[data-heaven-reading-tab]").forEach((button) => {
|
||
const active = button.dataset.heavenReadingTab === state.heavenReadingTab;
|
||
button.classList.toggle("active", active);
|
||
button.setAttribute("aria-selected", String(active));
|
||
});
|
||
document.querySelector("#heavenReadingCurrent").hidden = state.heavenReadingTab !== "current";
|
||
document.querySelector("#heavenReadingHistory").hidden = state.heavenReadingTab !== "history";
|
||
if (state.heavenReadingTab === "current") {
|
||
renderHeavenReadingCurrent();
|
||
} else {
|
||
stopHeavenReadingAnimation();
|
||
renderHeavenReadingHistory();
|
||
}
|
||
refreshIcons();
|
||
}
|
||
|
||
function renderHeavenReadingCurrent() {
|
||
const reading = state.heavenInterpretations[state.heavenReadingMode];
|
||
const loading = document.querySelector("#heavenReadingLoading");
|
||
const empty = document.querySelector("#heavenReadingEmpty");
|
||
const result = document.querySelector("#heavenReadingResult");
|
||
const error = document.querySelector("#heavenReadingError");
|
||
loading.hidden = !state.heavenReadingLoading;
|
||
syncHeavenReadingAnimation();
|
||
error.hidden = !state.heavenReadingError;
|
||
error.textContent = state.heavenReadingError;
|
||
result.hidden = state.heavenReadingLoading || !reading;
|
||
empty.hidden = state.heavenReadingLoading || Boolean(reading) || Boolean(state.heavenReadingError);
|
||
if (!reading || state.heavenReadingLoading) return;
|
||
setText("heavenReadingResultStatus", heavenReadingMeta().status);
|
||
setText("heavenReadingSubject", reading.subject || `${heavenReadingMeta().panel}解读`);
|
||
setText("heavenReadingSubjectDetail", reading.subject_detail || displayCompactDate(reading.context_date || ""));
|
||
setText("heavenReadingCreatedAt", reading.created_at ? formatTimestamp(reading.created_at) : "刚刚完成");
|
||
document.querySelector("#heavenReadingAnswer").innerHTML = formatMentorAnswer(reading.answer || "");
|
||
}
|
||
|
||
function renderHeavenReadingHistory() {
|
||
const mode = state.heavenReadingMode;
|
||
const items = state.heavenReadingHistory[mode] || [];
|
||
setText("heavenReadingHistoryTitle", `${heavenReadingMeta(mode).panel}记录`);
|
||
setText("heavenReadingHistoryCount", `${items.length} 条`);
|
||
const list = document.querySelector("#heavenReadingHistoryList");
|
||
list.innerHTML = items.map((item) => `
|
||
<button class="heaven-reading-history-item ${number(item.id) === state.heavenReadingSelectedId ? "active" : ""}" type="button" data-heaven-reading-id="${number(item.id)}">
|
||
<span>${escapeHtml(item.subject)}</span>
|
||
<small>${escapeHtml(item.subject_detail || displayCompactDate(item.context_date))}</small>
|
||
<time>${formatTimestamp(item.created_at)}</time>
|
||
</button>
|
||
`).join("") || '<div class="empty-state">暂无历史解读</div>';
|
||
const selected = items.find((item) => number(item.id) === state.heavenReadingSelectedId);
|
||
const detail = document.querySelector("#heavenReadingHistoryDetail");
|
||
detail.innerHTML = selected ? `
|
||
<header><div><span>${escapeHtml(heavenReadingMeta(mode).status)}</span><h3>${escapeHtml(selected.subject)}</h3></div><time>${formatTimestamp(selected.created_at)}</time></header>
|
||
<p>${escapeHtml(selected.subject_detail || displayCompactDate(selected.context_date))}</p>
|
||
<div class="heaven-reading-answer">${formatMentorAnswer(selected.answer || "")}</div>
|
||
<footer><button class="button" type="button" data-delete-heaven-reading="${number(selected.id)}"><i data-lucide="trash-2"></i><span>删除记录</span></button></footer>
|
||
` : '<div class="empty-state">选择一条记录查看完整解读</div>';
|
||
refreshIcons();
|
||
}
|
||
|
||
function handleHeavenHistorySelection(event) {
|
||
const button = event.target.closest("[data-heaven-reading-id]");
|
||
if (!button) return;
|
||
state.heavenReadingSelectedId = number(button.dataset.heavenReadingId);
|
||
renderHeavenReadingHistory();
|
||
}
|
||
|
||
async function handleHeavenHistoryAction(event) {
|
||
const button = event.target.closest("[data-delete-heaven-reading]");
|
||
if (!button || !window.confirm("确定删除这条解读记录吗?")) return;
|
||
const id = number(button.dataset.deleteHeavenReading);
|
||
try {
|
||
await apiRequest(`/api/heaven/readings/${id}`, "DELETE");
|
||
const mode = state.heavenReadingMode;
|
||
state.heavenReadingHistory[mode] = (state.heavenReadingHistory[mode] || []).filter((item) => number(item.id) !== id);
|
||
if (number(state.heavenInterpretations[mode]?.id) === id) {
|
||
state.heavenInterpretations[mode] = "";
|
||
if (mode === "fortune" && state.heavenSetup) state.heavenSetup.daily_fortune_reading = null;
|
||
updateHeavenInterpretationControls();
|
||
}
|
||
state.heavenReadingSelectedId = number(state.heavenReadingHistory[mode][0]?.id);
|
||
renderHeavenReadingHistory();
|
||
} catch (error) {
|
||
showToast(error.message || "解读记录删除失败");
|
||
}
|
||
}
|
||
|
||
async function interpretHeaven(mode) {
|
||
const existing = state.heavenInterpretations[mode];
|
||
if (existing) {
|
||
openHeavenReading(mode, { loading: false });
|
||
return;
|
||
}
|
||
const button = document.querySelector(mode === "trend" ? "#interpretTrendButton" : mode === "fortune" ? "#interpretFortuneButton" : "#interpretHeartButton");
|
||
if (button.disabled) return;
|
||
state.heavenReadingMode = mode;
|
||
state.heavenReadingLoading = true;
|
||
state.heavenReadingError = "";
|
||
openHeavenReading(mode, { loading: true });
|
||
updateHeavenInterpretationControls();
|
||
hideHeavenNotice();
|
||
try {
|
||
const payload = {
|
||
mode,
|
||
trade_date: document.querySelector("#qiObservationDate").value || elements.tradeDate.value,
|
||
sector: state.heavenSetup?.chart?.sector || "",
|
||
stock_code: state.heavenSetup?.chart?.stock?.code || "",
|
||
};
|
||
if (mode === "trend" && state.heavenManualData) payload.manual_data = state.heavenManualData;
|
||
if (mode === "heart") payload.lines = state.heartLines;
|
||
const result = await apiRequest("/api/heaven/interpret", "POST", payload);
|
||
state.heavenInterpretations[mode] = result.reading || {
|
||
answer: result.answer,
|
||
subject: `${heavenReadingMeta(mode).panel}解读`,
|
||
context_date: payload.trade_date,
|
||
created_at: new Date().toISOString(),
|
||
};
|
||
state.heavenReadingHistory[mode] = [];
|
||
await finishHeavenReadingAnimation();
|
||
state.heavenReadingLoading = false;
|
||
renderHeavenReadingDialog();
|
||
if (result.notice) showHeavenNotice(result.notice);
|
||
if (mode === "heart") {
|
||
if (await transitionHeartStage("interpretation")) await playHeartReadSequence();
|
||
} else {
|
||
renderHeavenInterpretation(mode, state.heavenInterpretations[mode]);
|
||
}
|
||
} catch (error) {
|
||
stopHeavenReadingAnimation();
|
||
state.heavenReadingLoading = false;
|
||
state.heavenReadingError = error.message || "问天解读失败";
|
||
renderHeavenReadingDialog();
|
||
showHeavenNotice(state.heavenReadingError);
|
||
showToast(state.heavenReadingError);
|
||
} finally {
|
||
state.heavenReadingLoading = false;
|
||
updateHeavenInterpretationControls();
|
||
}
|
||
}
|
||
|
||
function updateHeavenInterpretationControls() {
|
||
const loading = state.heavenReadingLoading;
|
||
const trendButton = document.querySelector("#interpretTrendButton");
|
||
const fortuneButton = document.querySelector("#interpretFortuneButton");
|
||
const heartButton = document.querySelector("#interpretHeartButton");
|
||
trendButton.disabled = loading || !state.heavenSetup?.chart?.available;
|
||
fortuneButton.disabled = loading || !state.heavenSetup?.field;
|
||
heartButton.disabled = loading || state.heartLines.length !== 6;
|
||
trendButton.textContent = loading && state.heavenReadingMode === "trend" ? "正在观势" : state.heavenInterpretations.trend ? HEAVEN_READING_META.trend.done : HEAVEN_READING_META.trend.action;
|
||
fortuneButton.textContent = loading && state.heavenReadingMode === "fortune" ? "正在察运" : state.heavenInterpretations.fortune ? HEAVEN_READING_META.fortune.done : HEAVEN_READING_META.fortune.action;
|
||
heartButton.textContent = loading && state.heavenReadingMode === "heart" ? "正在解卦" : state.heavenInterpretations.heart ? HEAVEN_READING_META.heart.done : HEAVEN_READING_META.heart.action;
|
||
document.querySelector("#viewHeartReadingButton").disabled = !state.heavenInterpretations.heart;
|
||
}
|
||
|
||
function renderHeavenInterpretation() {
|
||
updateHeavenInterpretationControls();
|
||
}
|
||
|
||
function initializeHeartAtmosphere() {
|
||
const whisperContainer = document.querySelector("#heartWhispers");
|
||
if (whisperContainer && !whisperContainer.children.length) {
|
||
whisperContainer.innerHTML = HEART_WHISPERS.map(([text, x, y, index]) => `
|
||
<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 = HEART_BREATH_TOTAL_MS / 1000;
|
||
state.heartBreathingEndsAt = 0;
|
||
document.querySelector("#beginCastingButton")?.classList.remove("is-ready");
|
||
if (!await transitionHeartStage("breathing")) return;
|
||
state.heartBreathingEndsAt = Date.now() + HEART_BREATH_TOTAL_MS;
|
||
const ember = document.querySelector("#heartIncenseEmber");
|
||
heartIncenseAnimation?.cancel();
|
||
ember?.classList.remove("is-burning");
|
||
if (ember) void ember.offsetWidth;
|
||
ember?.classList.add("is-burning");
|
||
heartIncenseAnimation = ember?.animate(
|
||
[{ top: "0%" }, { top: "100%" }],
|
||
{
|
||
duration: HEART_BREATH_ACTIVE_MS,
|
||
delay: HEART_BREATH_PREPARE_MS,
|
||
easing: "linear",
|
||
fill: "forwards",
|
||
},
|
||
) || null;
|
||
updateBreathingDisplay();
|
||
state.heartTimer = setInterval(() => {
|
||
state.heartSeconds = Math.max(0, Math.ceil((state.heartBreathingEndsAt - Date.now()) / 1000));
|
||
updateBreathingDisplay();
|
||
if (state.heartSeconds <= 0) finishHeartBreathing();
|
||
}, 200);
|
||
}
|
||
|
||
function finishHeartBreathing() {
|
||
if (state.heartTimer) clearInterval(state.heartTimer);
|
||
state.heartTimer = null;
|
||
state.heartBreathingEndsAt = 0;
|
||
state.heartSeconds = 0;
|
||
updateBreathingDisplay();
|
||
const button = document.querySelector("#beginCastingButton");
|
||
button.disabled = false;
|
||
button.classList.add("is-ready");
|
||
heartSound.chime(520);
|
||
}
|
||
|
||
function updateBreathingDisplay() {
|
||
const remainingMs = state.heartBreathingEndsAt
|
||
? Math.max(0, state.heartBreathingEndsAt - Date.now())
|
||
: Math.max(0, state.heartSeconds * 1000);
|
||
const elapsedMs = HEART_BREATH_TOTAL_MS - remainingMs;
|
||
const activeElapsedMs = Math.max(0, elapsedMs - HEART_BREATH_PREPARE_MS);
|
||
const cycleElapsedMs = activeElapsedMs % HEART_BREATH_CYCLE_MS;
|
||
const breathPhase = elapsedMs < HEART_BREATH_PREPARE_MS
|
||
? "prepare"
|
||
: cycleElapsedMs < HEART_BREATH_INHALE_MS
|
||
? "inhale"
|
||
: cycleElapsedMs < HEART_BREATH_INHALE_MS + HEART_BREATH_HOLD_MS
|
||
? "hold"
|
||
: "exhale";
|
||
const phase = state.heartSeconds <= 0 ? "settled" : breathPhase;
|
||
const scene = document.querySelector("#breathingScene");
|
||
scene.dataset.phase = phase;
|
||
setText("breathingPhase", phase === "settled" ? "静" : phase === "prepare" ? "静" : phase === "inhale" ? "吸" : phase === "hold" ? "顿" : "呼");
|
||
const prompt = state.heartSeconds <= 0
|
||
? "静心已成,可以起卦"
|
||
: phase === "prepare"
|
||
? "放松片刻,准备呼吸"
|
||
: phase === "hold"
|
||
? "停驻片刻,让念头自然沉下"
|
||
: activeElapsedMs < 18_000
|
||
? phase === "inhale" ? "缓慢吸气,放下对答案的预设" : "缓慢呼气,让预设随之松开"
|
||
: activeElapsedMs < 36_000
|
||
? phase === "inhale" ? "吸气,只留下真正想问的事" : "呼气,不急着寻找答案"
|
||
: phase === "inhale" ? "吸气,让心停在此刻" : "呼气,不追逐经过的念头";
|
||
setText("breathingPrompt", prompt);
|
||
}
|
||
|
||
async function beginHeartCasting() {
|
||
if (state.heartSeconds > 0) return;
|
||
state.heartLines = [];
|
||
state.heartThrows = [];
|
||
state.heartHexagram = null;
|
||
state.heavenInterpretations.heart = "";
|
||
heartCastingBusy = false;
|
||
resetHeartCoins();
|
||
await transitionHeartStage("casting");
|
||
}
|
||
|
||
function initializeHeartCoinHold() {
|
||
const button = document.querySelector("#tossCoinsButton");
|
||
const coins = [...document.querySelectorAll(".heart-coin")];
|
||
const cancelHold = (cancelled = true) => {
|
||
if (heartHoldTimer) clearTimeout(heartHoldTimer);
|
||
heartHoldTimer = null;
|
||
cancelAnimationFrame(heartHoldAnimationFrame);
|
||
heartHoldAnimationFrame = 0;
|
||
button.classList.remove("is-holding");
|
||
button.style.setProperty("--hold-progress", "0turn");
|
||
coins.forEach((coin) => coin.classList.remove("is-shaking"));
|
||
if (cancelled) heartHoldStartedAt = 0;
|
||
};
|
||
button.addEventListener("pointerdown", (event) => {
|
||
if (button.disabled || heartCastingBusy || (event.button !== 0 && event.pointerType !== "touch")) return;
|
||
event.preventDefault();
|
||
heartSound.ensure();
|
||
heartHoldTriggered = false;
|
||
heartHoldStartedAt = performance.now();
|
||
button.setPointerCapture?.(event.pointerId);
|
||
button.classList.add("is-holding");
|
||
coins.forEach((coin) => coin.classList.add("is-shaking"));
|
||
const charge = () => {
|
||
if (!heartHoldStartedAt) return;
|
||
const progress = Math.min(1, (performance.now() - heartHoldStartedAt) / 1400);
|
||
button.style.setProperty("--hold-progress", `${progress}turn`);
|
||
if (progress < 1) heartHoldAnimationFrame = requestAnimationFrame(charge);
|
||
};
|
||
heartHoldAnimationFrame = requestAnimationFrame(charge);
|
||
});
|
||
button.addEventListener("pointerup", async () => {
|
||
if (!heartHoldStartedAt) return;
|
||
const heldFor = performance.now() - heartHoldStartedAt;
|
||
heartHoldStartedAt = 0;
|
||
cancelHold(false);
|
||
heartHoldTriggered = true;
|
||
if (heldFor < 550) await waitForMotion(550 - heldFor);
|
||
await tossHeartCoins();
|
||
});
|
||
button.addEventListener("pointercancel", () => cancelHold(true));
|
||
button.addEventListener("click", (event) => {
|
||
if (heartHoldTriggered) {
|
||
heartHoldTriggered = false;
|
||
event.preventDefault();
|
||
return;
|
||
}
|
||
if (event.detail === 0 && !heartCastingBusy) tossHeartCoins();
|
||
});
|
||
}
|
||
|
||
async function tossHeartCoins() {
|
||
if (heartCastingBusy) return;
|
||
if (state.heartLines.length >= 6) {
|
||
heartCastingBusy = true;
|
||
await finalizeHeartHexagram();
|
||
return;
|
||
}
|
||
const stageToken = state.heartStageToken;
|
||
const button = document.querySelector("#tossCoinsButton");
|
||
heartCastingBusy = true;
|
||
button.disabled = true;
|
||
const random = new Uint32Array(3);
|
||
crypto.getRandomValues(random);
|
||
const coins = [...random].map((value) => value % 2 === 1);
|
||
await animateHeartCoins(coins);
|
||
if (stageToken !== state.heartStageToken || state.heartStage !== "casting") {
|
||
heartCastingBusy = false;
|
||
return;
|
||
}
|
||
const heads = coins.filter(Boolean).length;
|
||
const lineValue = 6 + heads;
|
||
state.heartLines.push(lineValue);
|
||
state.heartThrows.push(coins.map((head) => head ? "正" : "背"));
|
||
renderHeartCasting();
|
||
if (state.heartLines.length === 6) {
|
||
await finalizeHeartHexagram();
|
||
} else {
|
||
await waitForMotion(720);
|
||
heartCastingBusy = false;
|
||
button.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function animateHeartCoins(results) {
|
||
const coinElements = [...document.querySelectorAll(".heart-coin")];
|
||
setText("castingPrompt", "铜钱离手");
|
||
const animations = coinElements.map((coin, index) => {
|
||
coin.getAnimations().forEach((animation) => animation.cancel());
|
||
const inner = coin.querySelector(".heart-coin-inner");
|
||
inner.getAnimations().forEach((animation) => animation.cancel());
|
||
const current = heartCoinRotations[index];
|
||
const faceRotation = results[index] ? 0 : 180;
|
||
const delta = ((faceRotation - (current % 360)) + 360) % 360;
|
||
const target = current + 1440 + index * 360 + delta;
|
||
heartCoinRotations[index] = target;
|
||
const duration = motionEnabled() ? 1500 + index * 160 : 10;
|
||
const delay = motionEnabled() ? index * 150 : 0;
|
||
coin.dataset.face = results[index] ? "front" : "back";
|
||
const spin = inner.animate(
|
||
[{ transform: `rotateY(${current}deg)` }, { transform: `rotateY(${target}deg)` }],
|
||
{ duration, delay, easing: "cubic-bezier(.25,.55,.3,1)", fill: "forwards" },
|
||
);
|
||
const tilt = Math.random() * 10 - 5;
|
||
const flight = coin.animate([
|
||
{ transform: "translateY(0) rotateZ(0deg)" },
|
||
{ transform: `translateY(-30vh) rotateZ(${tilt}deg)`, offset: 0.42 },
|
||
{ transform: `translateY(0) rotateZ(${tilt}deg)`, offset: 0.78 },
|
||
{ transform: "translateY(-13px) rotateZ(0deg)", offset: 0.9 },
|
||
{ transform: "translateY(0) rotateZ(0deg)" },
|
||
], { duration, delay, easing: "cubic-bezier(.3,.6,.35,1)", fill: "forwards" });
|
||
setTimeout(() => {
|
||
const ring = coin.querySelector(".heart-coin-ring");
|
||
ring.classList.remove("is-bursting");
|
||
void ring.offsetWidth;
|
||
ring.classList.add("is-bursting");
|
||
heartSound.coin();
|
||
}, delay + duration * 0.79);
|
||
return Promise.allSettled([spin.finished, flight.finished]);
|
||
});
|
||
await Promise.all(animations);
|
||
setText("castingPrompt", "听其落定");
|
||
await waitForMotion(420);
|
||
}
|
||
|
||
async function finalizeHeartHexagram() {
|
||
const stageToken = state.heartStageToken;
|
||
const button = document.querySelector("#tossCoinsButton");
|
||
button.disabled = true;
|
||
button.textContent = "正在成卦";
|
||
try {
|
||
const payload = await apiRequest("/api/heaven/hexagram", "POST", { lines: state.heartLines });
|
||
if (stageToken !== state.heartStageToken || state.heartStage !== "casting") return;
|
||
state.heartHexagram = payload.hexagram;
|
||
updateHeavenInterpretationControls();
|
||
document.querySelector(".heart-hexagram-shell")?.classList.add("is-complete");
|
||
setText("castingPrompt", "卦成了");
|
||
heartSound.chime(660);
|
||
await waitForMotion(2200);
|
||
if (!await transitionHeartStage("reveal")) return;
|
||
await playHeartRevealSequence();
|
||
} catch (error) {
|
||
showHeavenNotice(error.message || "成卦失败");
|
||
button.disabled = false;
|
||
button.innerHTML = '<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");
|
||
document.querySelectorAll("[data-heart-step]").forEach((step) => {
|
||
step.classList.toggle("active", step.dataset.heartStep === state.heartStage);
|
||
});
|
||
setHeartLamp(state.heartStage);
|
||
activateHeartRises(activeStage);
|
||
if (state.heartStage === "breathing") {
|
||
document.querySelector("#beginCastingButton").disabled = state.heartSeconds > 0;
|
||
updateBreathingDisplay();
|
||
}
|
||
if (state.heartStage === "casting") renderHeartCasting();
|
||
if (state.heartStage === "reveal" && state.heartHexagram) renderHeartReveal();
|
||
if (state.heartStage === "interpretation" && state.heartHexagram) renderHeartRead();
|
||
}
|
||
|
||
function renderHeartCasting() {
|
||
setText("castingProgress", `${state.heartLines.length} / 6`);
|
||
const latestThrow = state.heartThrows[state.heartThrows.length - 1] || ["静", "静", "静"];
|
||
document.querySelectorAll(".heart-coin").forEach((coin, index) => {
|
||
coin.setAttribute("aria-label", latestThrow[index] === "静" ? `第 ${index + 1} 枚铜钱待掷` : `第 ${index + 1} 枚铜钱${latestThrow[index]}`);
|
||
});
|
||
const nextPosition = LINE_POSITIONS_CLIENT[state.heartLines.length] || "成卦";
|
||
setText(
|
||
"castingPrompt",
|
||
state.heartLines.length < 6
|
||
? `心中默念所问之事,然后掷出${nextPosition}`
|
||
: "六爻已具,正在成卦",
|
||
);
|
||
const button = document.querySelector("#tossCoinsButton");
|
||
button.innerHTML = state.heartLines.length < 6
|
||
? `<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 heart-yao-empty" aria-hidden="true"></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("#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(".heart-coin-ring").classList.remove("is-bursting");
|
||
});
|
||
const shell = document.querySelector(".heart-hexagram-shell");
|
||
shell?.classList.remove("is-complete");
|
||
}
|
||
|
||
async function resetHeartRitual() {
|
||
if (state.heartTimer) clearInterval(state.heartTimer);
|
||
state.heartTimer = null;
|
||
state.heartSeconds = HEART_BREATH_TOTAL_MS / 1000;
|
||
state.heartBreathingEndsAt = 0;
|
||
state.heartLines = [];
|
||
state.heartThrows = [];
|
||
state.heartHexagram = null;
|
||
state.heavenInterpretations.heart = "";
|
||
heartIncenseAnimation?.cancel();
|
||
heartIncenseAnimation = null;
|
||
document.querySelector("#heartIncenseEmber")?.classList.remove("is-burning");
|
||
updateHeavenInterpretationControls();
|
||
state.heartRevealToken += 1;
|
||
heartCastingBusy = false;
|
||
resetHeartCoins();
|
||
hideHeavenNotice();
|
||
await transitionHeartStage("intro");
|
||
}
|
||
|
||
function showHeavenNotice(message) {
|
||
const notice = document.querySelector("#heavenNotice");
|
||
notice.textContent = message;
|
||
notice.hidden = false;
|
||
}
|
||
|
||
function hideHeavenNotice() {
|
||
document.querySelector("#heavenNotice").hidden = true;
|
||
}
|
||
|
||
function phaseClass(element) {
|
||
return { 木: "wood", 火: "fire", 土: "earth", 金: "metal", 水: "water" }[element] || "earth";
|
||
}
|
||
|
||
function signedScore(value) {
|
||
const parsed = number(value);
|
||
return `${parsed > 0 ? "+" : ""}${formatNumber(parsed, 2)}`;
|
||
}
|
||
|
||
function lineValueName(value) {
|
||
return { 6: "老阴", 7: "少阳", 8: "少阴", 9: "老阳" }[value] || "";
|
||
}
|
||
|
||
function capitalize(value) {
|
||
return value.charAt(0).toUpperCase() + value.slice(1);
|
||
}
|
||
|
||
const LINE_POSITIONS_CLIENT = ["初爻", "二爻", "三爻", "四爻", "五爻", "上爻"];
|
||
|
||
function renderScreenerResult() {
|
||
const mode = state.screenerMode || "smart";
|
||
const result = activeScreenerResult(mode);
|
||
const context = activeScreenerResultContext(mode);
|
||
const source = document.querySelector("#screenerResultSource");
|
||
const emptyMessages = {
|
||
smart: "当日盘后候选尚未生成",
|
||
curated: "所选策略的当日候选尚未生成",
|
||
quant: "尚未执行自定义选股",
|
||
};
|
||
if (!result) {
|
||
setText("screenerResultCount", "0 只");
|
||
source.hidden = true;
|
||
source.textContent = "";
|
||
setText("screenerDisclaimer", "历史统计不代表未来收益");
|
||
document.querySelector("#screenerTableBody").innerHTML = "";
|
||
document.querySelector("#screenerEmpty").textContent = emptyMessages[mode];
|
||
document.querySelector("#screenerEmpty").hidden = false;
|
||
renderBacktest(null);
|
||
if (mode === "smart") setText("screenerRunStatus", "等待执行");
|
||
updateBacktestTaskStatus();
|
||
renderScreenerProgress();
|
||
return;
|
||
}
|
||
const candidates = result.candidates || [];
|
||
setText("screenerResultCount", `${candidates.length} 只`);
|
||
const modeLabels = { smart: "阶段选股", curated: "策略选股", quant: "自定义选股" };
|
||
const sourceParts = [modeLabels[mode]];
|
||
if (mode === "smart" && context?.regime) sourceParts.push(regimeLabel(context.regime));
|
||
sourceParts.push(mode === "quant" ? "自定义因子权重" : context?.strategyName || result.meta?.strategy_name || "未命名策略");
|
||
source.textContent = sourceParts.join(" · ");
|
||
source.hidden = false;
|
||
const meta = result.meta || {};
|
||
setText(
|
||
"screenerDisclaimer",
|
||
meta.realtime
|
||
? `盘中行情 · 历史样本截至 ${displayCompactDate(meta.history_cutoff)} · ${result.disclaimer}`
|
||
: `盘后数据 ${displayCompactDate(meta.trade_date)} · ${result.disclaimer}`,
|
||
);
|
||
const empty = document.querySelector("#screenerEmpty");
|
||
empty.textContent = mode === "curated" ? "暂无符合条件个股" : emptyMessages[mode];
|
||
empty.hidden = candidates.length > 0;
|
||
const body = document.querySelector("#screenerTableBody");
|
||
const runId = number(meta.run_id);
|
||
body.innerHTML = candidates.map((row, index) => `
|
||
<tr data-code="${escapeHtml(row.code)}"><td class="row-number num muted">${index + 1}</td>
|
||
<td><span class="stock-cell"><strong class="stock-name sname">${escapeHtml(row.name)}</strong><small class="stock-code scode">${escapeHtml(row.code)}</small></span></td><td>${escapeHtml(row.sector)}</td>
|
||
<td class="number num streak-value">${formatNumber(row.score_display, 1)}</td>
|
||
<td class="number num probability-value"><strong>${row.historical_probability === null ? "" : formatNumber(row.historical_probability, 1)}</strong><small>${number(row.probability_samples)} 个样本</small></td>
|
||
<td class="number num ${changeClass(row.pct_chg)}">${signed(row.pct_chg)}</td>
|
||
<td class="number num ${changeClass(row.return_5d)}">${signed(row.return_5d)}</td>
|
||
<td class="number num">${formatNumber(row.volume_ratio_5d, 2)}</td><td class="number num">${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><span class="screener-row-actions"><button class="table-action" type="button" data-screen-detail="${escapeHtml(row.code)}">详情</button><button class="table-action tracking-action ${isCandidateTracked(runId, row.code) ? "tracked" : ""}" type="button" data-add-tracking="${escapeHtml(row.code)}" ${!runId || isCandidateTracked(runId, row.code) ? "disabled" : ""}>${isCandidateTracked(runId, row.code) ? "已跟踪" : "加入跟踪"}</button></span></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);
|
||
});
|
||
});
|
||
body.querySelectorAll("[data-add-tracking]").forEach((button) => {
|
||
button.addEventListener("click", () => addCandidateToTracking(button.dataset.addTracking, button));
|
||
});
|
||
bindStockRows(body);
|
||
renderBacktest(result.backtest);
|
||
if (mode === "smart") setText("screenerRunStatus", `完成 · ${candidates.length} 只`);
|
||
updateBacktestTaskStatus();
|
||
renderScreenerProgress();
|
||
}
|
||
|
||
async function loadScreenerTracking(force = false) {
|
||
if (state.screenerTracking && !force) {
|
||
renderScreenerTracking();
|
||
return;
|
||
}
|
||
try {
|
||
state.screenerTracking = await apiRequest("/api/screener/tracking?limit=12");
|
||
renderScreenerTracking();
|
||
if (activeScreenerResult()) renderScreenerResult();
|
||
} catch (error) {
|
||
showToast(error.message || "策略跟踪加载失败");
|
||
}
|
||
}
|
||
|
||
function isCandidateTracked(runId, code) {
|
||
if (!runId) return false;
|
||
return (state.screenerTracking?.batches || []).some((batch) =>
|
||
number(batch.run_id) === number(runId)
|
||
&& (batch.items || []).some((item) => item.code === code));
|
||
}
|
||
|
||
async function addCandidateToTracking(code, button) {
|
||
const runId = number(activeScreenerResult()?.meta?.run_id);
|
||
if (!runId) {
|
||
showToast("本次结果缺少选股批次,请重新执行后再加入跟踪");
|
||
return;
|
||
}
|
||
button.disabled = true;
|
||
try {
|
||
const payload = await apiRequest("/api/screener/tracking", "POST", { run_id: runId, code });
|
||
state.screenerTracking = payload.tracking;
|
||
renderScreenerTracking();
|
||
renderScreenerResult();
|
||
showToast(`${code} 已加入策略跟踪`);
|
||
} catch (error) {
|
||
button.disabled = false;
|
||
showToast(error.message || "加入跟踪失败");
|
||
}
|
||
}
|
||
|
||
async function refreshScreenerTracking() {
|
||
const button = document.querySelector("#refreshTrackingButton");
|
||
button.disabled = true;
|
||
setStatus("正在更新策略跟踪");
|
||
try {
|
||
const payload = await apiRequest("/api/screener/tracking/refresh", "POST", {
|
||
trade_date: elements.tradeDate.value,
|
||
});
|
||
state.screenerTracking = payload.tracking;
|
||
renderScreenerTracking();
|
||
if (payload.notice) showToast(payload.notice);
|
||
setStatus("策略跟踪已更新");
|
||
} catch (error) {
|
||
showToast(error.message || "策略跟踪刷新失败");
|
||
setStatus("策略跟踪刷新失败");
|
||
} finally {
|
||
button.disabled = false;
|
||
}
|
||
}
|
||
|
||
function renderScreenerTracking() {
|
||
const payload = state.screenerTracking || { batches: [], summary: {} };
|
||
const batches = payload.batches || [];
|
||
const rows = batches.flatMap((batch) => (batch.items || []).map((item) => ({
|
||
...item,
|
||
run_id: batch.run_id,
|
||
selection_date: batch.selection_date,
|
||
strategy_name: batch.strategy_name,
|
||
})));
|
||
setText("trackingBatchCount", `${batches.length} 批`);
|
||
const summary = payload.summary || {};
|
||
document.querySelector("#trackingSummary").innerHTML = [
|
||
["跟踪标的", `${number(summary.total)} 只`],
|
||
["已有 T+1", `${number(summary.observed)} 只`],
|
||
["T+1 胜率", trackingPercent(summary.t1_win_rate)],
|
||
["T+5 胜率", trackingPercent(summary.t5_win_rate)],
|
||
["T+5 平均", trackingReturn(summary.average_t5)],
|
||
].map(([label, value]) => `<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 class="sname">${escapeHtml(row.name)}</strong><small class="stock-code scode">${escapeHtml(row.code)}</small></span></td>
|
||
<td class="number num">${row.entry_price == null ? "" : formatNumber(row.entry_price, 2)}</td>
|
||
${["t1_open", "t1_close", "t3_close", "t5_close", "max_gain", "max_drawdown"].map((key) => `<td class="number num ${row[key] == null ? "" : changeClass(row[key])}">${trackingReturn(row[key], false)}</td>`).join("")}
|
||
<td><span class="tracking-status ${row.status === "已完成" ? "complete" : row.observed_days ? "active" : "pending"}">${escapeHtml(row.status)}</span></td>
|
||
<td><button class="table-action danger" type="button" data-remove-tracking="${row.id}">移除</button></td>
|
||
</tr>
|
||
`).join("");
|
||
bindStockRows(document.querySelector("#trackingTableBody"));
|
||
}
|
||
|
||
async function handleTrackingTableAction(event) {
|
||
const button = event.target.closest("[data-remove-tracking]");
|
||
if (!button) return;
|
||
if (!window.confirm("确定停止跟踪这只股票吗?")) return;
|
||
button.disabled = true;
|
||
try {
|
||
const payload = await apiRequest(`/api/screener/tracking/${button.dataset.removeTracking}`, "DELETE");
|
||
state.screenerTracking = payload.tracking;
|
||
renderScreenerTracking();
|
||
if (activeScreenerResult()) renderScreenerResult();
|
||
showToast("已移出策略跟踪");
|
||
} catch (error) {
|
||
button.disabled = false;
|
||
showToast(error.message || "移除跟踪失败");
|
||
}
|
||
}
|
||
|
||
function trackingReturn(value, includeUnit = true) {
|
||
return value == null ? (includeUnit ? "--" : "") : `${signed(value)}${includeUnit ? "%" : ""}`;
|
||
}
|
||
|
||
function trackingPercent(value) {
|
||
return value == null ? "--" : `${formatNumber(value, 1)}%`;
|
||
}
|
||
|
||
function renderBacktest(backtest) {
|
||
const panel = document.querySelector("#backtestPanel");
|
||
panel.hidden = !backtest;
|
||
if (!backtest) return;
|
||
setText("backtestDefinition", backtest.definition);
|
||
document.querySelector("#backtestMetrics").innerHTML = [
|
||
["历史样本", `${number(backtest.samples)} 个`],
|
||
["条件胜率", `${formatNumber(backtest.win_rate, 1)}%`],
|
||
["平均3日收益", `${signed(backtest.average_3d_return)}%`],
|
||
["平均最大回撤", `${signed(backtest.average_drawdown)}%`],
|
||
].map(([label, value]) => `<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() {
|
||
const modeLabels = { smart: "阶段选股", curated: "策略选股", quant: "量化选股" };
|
||
exportRows(modeLabels[state.screenerMode] || "智能选股", activeScreenerResult()?.candidates || [], [
|
||
["股票代码", "code"], ["股票名称", "name"], ["板块", "sector"], ["综合分", "score_display"],
|
||
["历史条件估计%", "historical_probability"], ["当日涨幅%", "pct_chg"], ["5日涨幅%", "return_5d"],
|
||
["10日涨幅%", "return_10d"], ["量比", "volume_ratio_5d"], ["板块强度", "sector_strength"],
|
||
["主要贡献", "reason"], ["风险标记", "risk_flags"],
|
||
]);
|
||
}
|
||
|
||
function regimeLabel(regime) {
|
||
return state.screenerSetup?.regimes?.find((item) => item.id === regime)?.label || regime;
|
||
}
|
||
|
||
function currentChartPalette() {
|
||
const style = getComputedStyle(document.documentElement);
|
||
const color = (token, fallback) => style.getPropertyValue(token).trim() || fallback;
|
||
return {
|
||
background: color("--chart-background", "#fbfcfd"),
|
||
grid: color("--chart-grid", "#e2e8ec"),
|
||
axis: color("--chart-axis", "#6c7983"),
|
||
zero: color("--chart-zero", "#aeb7c1"),
|
||
line: color("--chart-line", "#1d65c1"),
|
||
average: color("--chart-average", "#b7791f"),
|
||
up: color("--chart-up", "#c93f45"),
|
||
down: color("--chart-down", "#087a55"),
|
||
upVolume: color("--chart-up-volume", "rgba(201, 63, 69, .58)"),
|
||
downVolume: color("--chart-down-volume", "rgba(8, 122, 85, .58)"),
|
||
area: color("--chart-area", "rgba(37, 99, 235, .07)"),
|
||
alertArea: color("--chart-alert-area", "rgba(224, 69, 54, .05)"),
|
||
movingAverage: color("--chart-moving-average", "#d1d5db"),
|
||
repair: color("--chart-repair", "#f59e0b"),
|
||
ma10: color("--chart-ma-10", "#a76500"),
|
||
ma20: color("--chart-ma-20", "#626c78"),
|
||
};
|
||
}
|
||
|
||
function drawCandlestick(context, x, item, priceY, candleWidth, palette = currentChartPalette()) {
|
||
const rising = number(item.close) >= number(item.open);
|
||
const color = rising ? palette.up : palette.down;
|
||
const highY = priceY(item.high);
|
||
const lowY = priceY(item.low);
|
||
const openY = priceY(item.open);
|
||
const closeY = priceY(item.close);
|
||
const bodyTop = Math.min(openY, closeY);
|
||
const bodyBottom = Math.max(openY, closeY);
|
||
const bodyHeight = Math.max(1, bodyBottom - bodyTop);
|
||
|
||
context.strokeStyle = color;
|
||
context.fillStyle = color;
|
||
context.lineWidth = 1;
|
||
context.beginPath();
|
||
context.moveTo(x, highY);
|
||
context.lineTo(x, bodyTop);
|
||
context.moveTo(x, bodyBottom);
|
||
context.lineTo(x, lowY);
|
||
context.stroke();
|
||
|
||
const bodyLeft = x - candleWidth / 2;
|
||
if (rising) {
|
||
context.fillStyle = palette.background;
|
||
context.fillRect(bodyLeft, bodyTop, candleWidth, bodyHeight);
|
||
context.strokeStyle = color;
|
||
context.strokeRect(bodyLeft, bodyTop, candleWidth, bodyHeight);
|
||
} else {
|
||
context.fillStyle = color;
|
||
context.fillRect(bodyLeft, bodyTop, candleWidth, bodyHeight);
|
||
}
|
||
return color;
|
||
}
|
||
|
||
function drawPriceChart(prices) {
|
||
const canvas = elements.priceChart;
|
||
if (!prices?.length) {
|
||
clearPriceChart("暂无日 K 数据");
|
||
return;
|
||
}
|
||
const rect = canvas.getBoundingClientRect();
|
||
const ratio = window.devicePixelRatio || 1;
|
||
const width = Math.max(320, rect.width);
|
||
const height = Math.max(220, rect.height);
|
||
canvas.width = Math.round(width * ratio);
|
||
canvas.height = Math.round(height * ratio);
|
||
const context = canvas.getContext("2d");
|
||
const palette = currentChartPalette();
|
||
context.setTransform(ratio, 0, 0, ratio, 0, 0);
|
||
context.clearRect(0, 0, width, height);
|
||
context.fillStyle = palette.background;
|
||
context.fillRect(0, 0, width, height);
|
||
|
||
const left = 48;
|
||
const right = 12;
|
||
const top = 14;
|
||
const bottom = 22;
|
||
const volumeHeight = 54;
|
||
const gap = 12;
|
||
const priceBottom = height - bottom - volumeHeight - gap;
|
||
const plotWidth = width - left - right;
|
||
const highs = prices.map((item) => number(item.high));
|
||
const lows = prices.map((item) => number(item.low));
|
||
const maximum = Math.max(...highs);
|
||
const minimum = Math.min(...lows);
|
||
const range = Math.max(maximum - minimum, maximum * 0.01, 0.01);
|
||
const volumes = prices.map((item) => number(item.volume));
|
||
const maxVolume = Math.max(...volumes, 1);
|
||
const priceY = (value) => top + (maximum - value) / range * (priceBottom - top);
|
||
const step = plotWidth / prices.length;
|
||
const candleWidth = clamp(step * 0.62, 2, 8);
|
||
|
||
context.strokeStyle = palette.grid;
|
||
context.fillStyle = palette.axis;
|
||
context.font = "11px Microsoft YaHei";
|
||
context.textAlign = "right";
|
||
for (let line = 0; line <= 4; line += 1) {
|
||
const y = top + (priceBottom - top) * line / 4;
|
||
context.beginPath();
|
||
context.moveTo(left, y);
|
||
context.lineTo(width - right, y);
|
||
context.stroke();
|
||
context.fillText((maximum - range * line / 4).toFixed(2), left - 5, y + 4);
|
||
}
|
||
|
||
prices.forEach((item, index) => {
|
||
const x = left + step * index + step / 2;
|
||
const color = drawCandlestick(context, x, item, priceY, candleWidth, palette);
|
||
const volumeBarHeight = number(item.volume) / maxVolume * volumeHeight;
|
||
context.fillStyle = color;
|
||
context.globalAlpha = 0.75;
|
||
context.fillRect(x - candleWidth / 2, height - bottom - volumeBarHeight, candleWidth, volumeBarHeight);
|
||
context.globalAlpha = 1;
|
||
});
|
||
|
||
context.textAlign = "center";
|
||
context.fillStyle = palette.axis;
|
||
const labelIndexes = [0, Math.floor((prices.length - 1) / 2), prices.length - 1];
|
||
labelIndexes.forEach((index) => {
|
||
const x = left + step * index + step / 2;
|
||
context.fillText(String(prices[index].trade_date).slice(5), x, height - 5);
|
||
});
|
||
}
|
||
|
||
function clearPriceChart(message) {
|
||
const canvas = elements.priceChart;
|
||
const context = canvas.getContext("2d");
|
||
const rect = canvas.getBoundingClientRect();
|
||
canvas.width = Math.max(320, Math.round(rect.width));
|
||
canvas.height = Math.max(220, Math.round(rect.height));
|
||
const palette = currentChartPalette();
|
||
context.fillStyle = palette.background;
|
||
context.fillRect(0, 0, canvas.width, canvas.height);
|
||
context.fillStyle = palette.axis;
|
||
context.font = "13px Microsoft YaHei";
|
||
context.textAlign = "center";
|
||
context.fillText(message, canvas.width / 2, canvas.height / 2);
|
||
}
|
||
|
||
function prepareStockPreviewCanvas() {
|
||
const canvas = elements.stockPreviewChart;
|
||
const rect = canvas.getBoundingClientRect();
|
||
const ratio = window.devicePixelRatio || 1;
|
||
const width = Math.max(300, rect.width || 488);
|
||
const height = Math.max(210, rect.height || 232);
|
||
canvas.width = Math.round(width * ratio);
|
||
canvas.height = Math.round(height * ratio);
|
||
const context = canvas.getContext("2d");
|
||
const palette = currentChartPalette();
|
||
context.setTransform(ratio, 0, 0, ratio, 0, 0);
|
||
context.clearRect(0, 0, width, height);
|
||
context.fillStyle = palette.background;
|
||
context.fillRect(0, 0, width, height);
|
||
context.font = '11px -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei UI", sans-serif';
|
||
return { canvas, context, width, height, palette };
|
||
}
|
||
|
||
function drawPreviewGrid(context, width, top, bottom, left, right, maximum, range) {
|
||
const palette = currentChartPalette();
|
||
context.strokeStyle = palette.grid;
|
||
context.fillStyle = palette.axis;
|
||
context.textAlign = "right";
|
||
context.lineWidth = 1;
|
||
for (let line = 0; line <= 3; line += 1) {
|
||
const y = top + (bottom - top) * line / 3;
|
||
context.beginPath();
|
||
context.moveTo(left, y);
|
||
context.lineTo(width - right, y);
|
||
context.stroke();
|
||
context.fillText((maximum - range * line / 3).toFixed(2), left - 5, y + 4);
|
||
}
|
||
}
|
||
|
||
function intradayMinuteOffset(value) {
|
||
const [hour, minute] = String(value || "").split(":").map((part) => number(part));
|
||
const clockMinute = hour * 60 + minute;
|
||
const morningStart = 9 * 60 + 30;
|
||
const morningEnd = 11 * 60 + 30;
|
||
const afternoonStart = 13 * 60;
|
||
const afternoonEnd = 15 * 60;
|
||
if (clockMinute <= morningEnd) return clamp(clockMinute - morningStart, 0, 120);
|
||
if (clockMinute < afternoonStart) return 120;
|
||
return 120 + clamp(clockMinute - afternoonStart, 0, afternoonEnd - afternoonStart);
|
||
}
|
||
|
||
function drawIntradayCanvas(canvas, points, dailyPrices = [], referenceClose = 0) {
|
||
const rect = canvas.getBoundingClientRect();
|
||
const ratio = window.devicePixelRatio || 1;
|
||
const width = Math.max(300, rect.width || 488);
|
||
const height = Math.max(210, rect.height || 232);
|
||
canvas.width = Math.round(width * ratio);
|
||
canvas.height = Math.round(height * ratio);
|
||
const context = canvas.getContext("2d");
|
||
const palette = currentChartPalette();
|
||
context.setTransform(ratio, 0, 0, ratio, 0, 0);
|
||
context.clearRect(0, 0, width, height);
|
||
context.fillStyle = palette.background;
|
||
context.fillRect(0, 0, width, height);
|
||
context.font = '11px -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei UI", sans-serif';
|
||
const left = 45;
|
||
const right = 10;
|
||
const top = 12;
|
||
const volumeHeight = 38;
|
||
const bottom = 18;
|
||
const gap = 9;
|
||
const priceBottom = height - bottom - volumeHeight - gap;
|
||
const closes = points.map((point) => number(point.close));
|
||
const previousClose = number(referenceClose || dailyPrices.at(-2)?.close || points[0]?.open || closes[0]);
|
||
const maximum = Math.max(...points.map((point) => number(point.high || point.close)), previousClose);
|
||
const minimum = Math.min(...points.map((point) => number(point.low || point.close)), previousClose);
|
||
const deviation = Math.max(
|
||
Math.abs(maximum - previousClose),
|
||
Math.abs(previousClose - minimum),
|
||
previousClose * 0.003,
|
||
0.01,
|
||
) * 1.08;
|
||
const chartMaximum = previousClose + deviation;
|
||
const chartMinimum = previousClose - deviation;
|
||
const range = Math.max(chartMaximum - chartMinimum, 0.01);
|
||
const plotWidth = width - left - right;
|
||
const priceY = (value) => top + (chartMaximum - value) / range * (priceBottom - top);
|
||
const pointX = (index) => left + plotWidth * intradayMinuteOffset(points[index]?.time) / 240;
|
||
drawPreviewGrid(context, width, top, priceBottom, left, right, chartMaximum, range);
|
||
|
||
context.save();
|
||
context.setLineDash([4, 4]);
|
||
context.strokeStyle = palette.zero;
|
||
context.beginPath();
|
||
context.moveTo(left, priceY(previousClose));
|
||
context.lineTo(width - right, priceY(previousClose));
|
||
context.stroke();
|
||
context.restore();
|
||
context.fillStyle = palette.axis;
|
||
context.textAlign = "right";
|
||
context.fillText("0.00%", width - right, priceY(previousClose) - 4);
|
||
|
||
context.strokeStyle = palette.line;
|
||
context.lineWidth = 1.7;
|
||
context.beginPath();
|
||
points.forEach((point, index) => {
|
||
const x = pointX(index);
|
||
const y = priceY(point.close);
|
||
if (index === 0) context.moveTo(x, y);
|
||
else context.lineTo(x, y);
|
||
});
|
||
context.stroke();
|
||
|
||
const averages = points.map((point) => number(point.average)).filter((value) => value > 0);
|
||
if (averages.length) {
|
||
context.strokeStyle = palette.average;
|
||
context.lineWidth = 1.25;
|
||
context.beginPath();
|
||
let averageStarted = false;
|
||
points.forEach((point, index) => {
|
||
const average = number(point.average);
|
||
if (average <= 0) return;
|
||
const x = pointX(index);
|
||
const y = priceY(average);
|
||
if (!averageStarted) {
|
||
context.moveTo(x, y);
|
||
averageStarted = true;
|
||
} else context.lineTo(x, y);
|
||
});
|
||
context.stroke();
|
||
}
|
||
|
||
const maxVolume = Math.max(...points.map((point) => number(point.volume)), 1);
|
||
const barWidth = clamp(plotWidth / Math.max(points.length, 1) * 0.72, 1, 3);
|
||
points.forEach((point, index) => {
|
||
const x = pointX(index);
|
||
const barHeight = number(point.volume) / maxVolume * volumeHeight;
|
||
context.fillStyle = number(point.close) >= number(point.open) ? palette.upVolume : palette.downVolume;
|
||
context.fillRect(x - barWidth / 2, height - bottom - barHeight, barWidth, barHeight);
|
||
});
|
||
|
||
context.fillStyle = palette.axis;
|
||
context.textAlign = "center";
|
||
[
|
||
{ offset: 0, label: "09:30" },
|
||
{ offset: 120, label: "11:30 / 13:00" },
|
||
{ offset: 240, label: "15:00" },
|
||
].forEach((marker) => {
|
||
context.fillText(marker.label, left + plotWidth * marker.offset / 240, height - 4);
|
||
});
|
||
return {
|
||
latest: closes.at(-1),
|
||
maximum,
|
||
minimum,
|
||
};
|
||
}
|
||
|
||
function drawIntradayPreviewChart(points, dailyPrices, referenceClose = 0) {
|
||
const summary = drawIntradayCanvas(elements.stockPreviewChart, points, dailyPrices, referenceClose);
|
||
setText(
|
||
"stockPreviewSummary",
|
||
`分时 ${points.length} 点,最新 ${formatNumber(summary.latest, 2)},最高 ${formatNumber(summary.maximum, 2)},最低 ${formatNumber(summary.minimum, 2)}。`,
|
||
);
|
||
}
|
||
|
||
function drawDailyPreviewChart(prices) {
|
||
const { context, width, height, palette } = prepareStockPreviewCanvas();
|
||
const visible = prices.slice(-45);
|
||
const visibleStart = prices.length - visible.length;
|
||
const left = 45;
|
||
const right = 10;
|
||
const top = 24;
|
||
const volumeHeight = 34;
|
||
const bottom = 18;
|
||
const gap = 8;
|
||
const priceBottom = height - bottom - volumeHeight - gap;
|
||
const maximum = Math.max(...visible.map((item) => number(item.high)));
|
||
const minimum = Math.min(...visible.map((item) => number(item.low)));
|
||
const padding = Math.max((maximum - minimum) * 0.05, maximum * 0.002, 0.01);
|
||
const chartMaximum = maximum + padding;
|
||
const chartMinimum = minimum - padding;
|
||
const range = Math.max(chartMaximum - chartMinimum, 0.01);
|
||
const plotWidth = width - left - right;
|
||
const step = plotWidth / Math.max(visible.length, 1);
|
||
const candleWidth = clamp(step * 0.58, 2, 7);
|
||
const priceY = (value) => top + (chartMaximum - value) / range * (priceBottom - top);
|
||
drawPreviewGrid(context, width, top, priceBottom, left, right, chartMaximum, range);
|
||
|
||
const maxVolume = Math.max(...visible.map((item) => number(item.volume)), 1);
|
||
visible.forEach((item, index) => {
|
||
const x = left + step * index + step / 2;
|
||
const color = drawCandlestick(context, x, item, priceY, candleWidth, palette);
|
||
const volumeBarHeight = number(item.volume) / maxVolume * volumeHeight;
|
||
context.fillStyle = color;
|
||
context.globalAlpha = 0.62;
|
||
context.fillRect(x - candleWidth / 2, height - bottom - volumeBarHeight, candleWidth, volumeBarHeight);
|
||
context.globalAlpha = 1;
|
||
});
|
||
|
||
const movingAverages = [
|
||
{ days: 5, color: palette.line },
|
||
{ days: 10, color: palette.ma10 },
|
||
{ days: 20, color: palette.ma20 },
|
||
];
|
||
movingAverages.forEach(({ days, color }) => {
|
||
context.strokeStyle = color;
|
||
context.lineWidth = 1.25;
|
||
context.beginPath();
|
||
let started = false;
|
||
visible.forEach((_item, index) => {
|
||
const absoluteIndex = visibleStart + index;
|
||
if (absoluteIndex < days - 1) return;
|
||
const values = prices.slice(absoluteIndex - days + 1, absoluteIndex + 1);
|
||
const average = values.reduce((sum, item) => sum + number(item.close), 0) / days;
|
||
const x = left + step * index + step / 2;
|
||
const y = priceY(average);
|
||
if (!started) {
|
||
context.moveTo(x, y);
|
||
started = true;
|
||
} else context.lineTo(x, y);
|
||
});
|
||
context.stroke();
|
||
});
|
||
|
||
context.textAlign = "left";
|
||
movingAverages.forEach(({ days, color }, index) => {
|
||
context.fillStyle = color;
|
||
context.fillText(`MA${days}`, left + index * 42, 12);
|
||
});
|
||
context.fillStyle = palette.axis;
|
||
context.textAlign = "center";
|
||
[0, Math.floor((visible.length - 1) / 2), visible.length - 1].forEach((index) => {
|
||
const x = left + step * index + step / 2;
|
||
context.fillText(String(visible[index]?.trade_date || "").slice(5), x, height - 4);
|
||
});
|
||
const firstClose = number(visible[0]?.close);
|
||
const latestClose = number(visible.at(-1)?.close);
|
||
const periodChange = firstClose ? (latestClose / firstClose - 1) * 100 : 0;
|
||
setText(
|
||
"stockPreviewSummary",
|
||
`近 ${visible.length} 日涨跌 ${signed(periodChange)}%,区间最高 ${formatNumber(maximum, 2)},最低 ${formatNumber(minimum, 2)}。`,
|
||
);
|
||
}
|
||
|
||
function clearStockPreviewChart(message) {
|
||
const { context, width, height } = prepareStockPreviewCanvas();
|
||
if (!message) return;
|
||
context.fillStyle = "#74808d";
|
||
context.textAlign = "center";
|
||
context.fillText(message, width / 2, height / 2);
|
||
}
|
||
|
||
function bindStockRows(container) {
|
||
animateRows(container);
|
||
decorateStockPreviewTargets(container);
|
||
container.querySelectorAll("[data-code]").forEach((rowElement) => {
|
||
rowElement.addEventListener("click", (event) => {
|
||
const interactive = event.target.closest("button, a, input, select, textarea, summary");
|
||
if (interactive && interactive !== rowElement) return;
|
||
openStock(rowElement.dataset.code, findStockFallback(rowElement.dataset.code));
|
||
});
|
||
});
|
||
}
|
||
|
||
function decorateStockPreviewTargets(container) {
|
||
container.querySelectorAll(".stock-code").forEach((trigger) => {
|
||
const code = stockCodeFromTrigger(trigger);
|
||
if (!code) return;
|
||
trigger.classList.add("stock-preview-trigger");
|
||
trigger.tabIndex = 0;
|
||
trigger.setAttribute("role", "button");
|
||
trigger.setAttribute("aria-label", `预览 ${code} 行情`);
|
||
trigger.title = "悬停预览行情,点击查看完整详情";
|
||
});
|
||
}
|
||
|
||
function stockCodeFromTrigger(trigger) {
|
||
const candidate = trigger?.dataset?.stockPreviewCode
|
||
|| trigger?.closest?.("[data-code]")?.dataset?.code
|
||
|| trigger?.textContent?.trim();
|
||
const matched = String(candidate || "").match(/\b(\d{6})\b/);
|
||
return matched ? matched[1] : "";
|
||
}
|
||
|
||
function marketPreviewTargetFromTrigger(trigger) {
|
||
if (trigger?.classList?.contains("market-preview-trigger")) {
|
||
const type = String(trigger.dataset.marketPreviewType || "").trim().toLowerCase();
|
||
const id = String(trigger.dataset.marketPreviewId || "").trim().toUpperCase();
|
||
if (type === "theme" && id) {
|
||
const item = (state.themeLibrary?.items || []).find((row) => String(row.code) === id) || {};
|
||
return {
|
||
type,
|
||
id,
|
||
code: id,
|
||
name: item.name || trigger.textContent?.trim() || "--",
|
||
type_label: "题材",
|
||
change: item.change,
|
||
value: item.close,
|
||
};
|
||
}
|
||
}
|
||
const code = stockCodeFromTrigger(trigger);
|
||
return code ? { type: "stock", id: code, code } : null;
|
||
}
|
||
|
||
function previewTriggerFromEvent(event) {
|
||
return event.target.closest?.(".stock-preview-trigger, .market-preview-trigger");
|
||
}
|
||
|
||
function showMarketPreview(target, trigger) {
|
||
if (!target) return;
|
||
if (target.type === "stock") showStockPreview(target.id, trigger);
|
||
else showEntityPreview(target, trigger);
|
||
}
|
||
|
||
function findStockFallback(code) {
|
||
const dashboardRows = [
|
||
...(state.dashboard?.limits || []),
|
||
...(state.dashboard?.broken || []),
|
||
...(state.dashboard?.down_limits || []),
|
||
...(state.dashboard?.yesterday_limits || []),
|
||
];
|
||
const screenerRows = Object.values(state.screenerResultStore)
|
||
.flatMap((entry) => entry?.result?.candidates || []);
|
||
const dragonRows = (state.dragonTiger?.traders || []).flatMap((trader) => trader.operations || []);
|
||
const auctionRows = state.auctionData?.rows || [];
|
||
const themeRows = state.themeDetail?.members || [];
|
||
const popularityRows = state.popularityData?.combined || [];
|
||
const row = [...dashboardRows, ...screenerRows, ...dragonRows, ...auctionRows, ...themeRows, ...popularityRows, ...(state.watchlist || [])]
|
||
.find((item) => String(item.code) === String(code));
|
||
if (!row) return { code, name: "--", sector: "其他" };
|
||
return {
|
||
...row,
|
||
code,
|
||
change: row.change ?? row.current_change ?? row.pct_chg ?? 0,
|
||
sector: row.sector || row.industry || "其他",
|
||
};
|
||
}
|
||
|
||
function supportsStockPreviewHover() {
|
||
return window.matchMedia("(hover: hover) and (pointer: fine)").matches
|
||
&& window.innerWidth > 720;
|
||
}
|
||
|
||
function handleStockPreviewPointerOver(event) {
|
||
if (!supportsStockPreviewHover()) return;
|
||
const trigger = previewTriggerFromEvent(event);
|
||
if (!trigger || trigger === event.relatedTarget?.closest?.(".stock-preview-trigger, .market-preview-trigger")) return;
|
||
const target = marketPreviewTargetFromTrigger(trigger);
|
||
if (!target) return;
|
||
cancelStockPreviewClose();
|
||
clearTimeout(stockPreviewOpenTimer);
|
||
stockPreviewOpenTimer = setTimeout(() => showMarketPreview(target, trigger), STOCK_PREVIEW_DELAY);
|
||
}
|
||
|
||
function handleStockPreviewPointerOut(event) {
|
||
if (!supportsStockPreviewHover()) return;
|
||
const trigger = previewTriggerFromEvent(event);
|
||
if (!trigger || trigger === event.relatedTarget?.closest?.(".stock-preview-trigger, .market-preview-trigger")) return;
|
||
clearTimeout(stockPreviewOpenTimer);
|
||
if (event.relatedTarget instanceof Node && elements.stockPreview.contains(event.relatedTarget)) return;
|
||
scheduleStockPreviewClose();
|
||
}
|
||
|
||
function handleStockPreviewFocus(event) {
|
||
if (!supportsStockPreviewHover()) return;
|
||
const trigger = event.target.closest?.(".stock-preview-trigger");
|
||
if (!trigger) return;
|
||
const code = stockCodeFromTrigger(trigger);
|
||
if (!code) return;
|
||
clearTimeout(stockPreviewOpenTimer);
|
||
stockPreviewOpenTimer = setTimeout(() => showStockPreview(code, trigger), 120);
|
||
}
|
||
|
||
function handleStockPreviewFocusOut(event) {
|
||
const trigger = event.target.closest?.(".stock-preview-trigger");
|
||
if (!trigger) return;
|
||
if (event.relatedTarget instanceof Node && elements.stockPreview.contains(event.relatedTarget)) return;
|
||
clearTimeout(stockPreviewOpenTimer);
|
||
scheduleStockPreviewClose();
|
||
}
|
||
|
||
function handleMobileStockPreviewClick(event) {
|
||
if (window.innerWidth > 720) return;
|
||
const trigger = event.target.closest?.(".stock-preview-trigger");
|
||
if (!trigger) return;
|
||
const code = stockCodeFromTrigger(trigger);
|
||
if (!code) return;
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
showStockPreview(code, trigger);
|
||
}
|
||
|
||
function handleStockPreviewKeydown(event) {
|
||
if (event.key === "Escape" && !elements.stockPreview.hidden) {
|
||
closeStockPreview();
|
||
stockPreviewAnchor?.focus?.();
|
||
return;
|
||
}
|
||
if (event.key !== "Enter") return;
|
||
const trigger = event.target.closest?.(".stock-preview-trigger");
|
||
if (!trigger) return;
|
||
const code = stockCodeFromTrigger(trigger);
|
||
if (!code) return;
|
||
event.preventDefault();
|
||
if (window.innerWidth <= 720) showStockPreview(code, trigger);
|
||
else openStock(code, findStockFallback(code));
|
||
}
|
||
|
||
function cancelStockPreviewClose() {
|
||
clearTimeout(stockPreviewCloseTimer);
|
||
}
|
||
|
||
function scheduleStockPreviewClose() {
|
||
clearTimeout(stockPreviewCloseTimer);
|
||
stockPreviewCloseTimer = setTimeout(closeStockPreview, 160);
|
||
}
|
||
|
||
async function showStockPreview(code, trigger) {
|
||
clearTimeout(stockPreviewOpenTimer);
|
||
cancelStockPreviewClose();
|
||
if (!/^\d{6}$/.test(String(code))) return;
|
||
stockPreviewAnchor = trigger;
|
||
state.stockPreviewCode = String(code);
|
||
state.stockPreviewType = "stock";
|
||
state.stockPreviewItem = null;
|
||
state.stockPreviewFallback = findStockFallback(code);
|
||
state.stockPreviewPayload = null;
|
||
state.stockPreviewChart = "daily";
|
||
renderStockPreviewLoading();
|
||
elements.stockPreview.hidden = false;
|
||
const mobile = window.innerWidth <= 720;
|
||
elements.stockPreviewBackdrop.hidden = !mobile;
|
||
document.body.classList.toggle("stock-preview-open", mobile);
|
||
requestAnimationFrame(repositionStockPreview);
|
||
|
||
const cacheKey = `${code}:latest`;
|
||
const cached = stockPreviewCache.get(cacheKey);
|
||
if (cached && cached.expiresAt > Date.now()) {
|
||
renderStockPreview(cached.payload);
|
||
return;
|
||
}
|
||
if (cached) stockPreviewCache.delete(cacheKey);
|
||
stockPreviewAbortController?.abort();
|
||
stockPreviewAbortController = new AbortController();
|
||
try {
|
||
const payload = await apiRequest(
|
||
`/api/stock/${encodeURIComponent(code)}/preview`,
|
||
"GET",
|
||
null,
|
||
{ signal: stockPreviewAbortController.signal },
|
||
);
|
||
if (state.stockPreviewCode !== String(code) || elements.stockPreview.hidden) return;
|
||
const cacheMs = payload.meta?.realtime ? LIVE_REFRESH_DEFAULT_MS : STOCK_PREVIEW_CACHE_MS;
|
||
stockPreviewCache.set(cacheKey, { payload, expiresAt: Date.now() + cacheMs });
|
||
while (stockPreviewCache.size > 48) stockPreviewCache.delete(stockPreviewCache.keys().next().value);
|
||
renderStockPreview(payload);
|
||
} catch (error) {
|
||
if (error.name === "AbortError" || state.stockPreviewCode !== String(code)) return;
|
||
renderStockPreviewError(error.message || "行情预览加载失败");
|
||
}
|
||
}
|
||
|
||
async function showEntityPreview(item, trigger) {
|
||
const type = String(item?.type || "").trim().toLowerCase();
|
||
const id = String(item?.id || item?.code || "").trim().toUpperCase();
|
||
if (type !== "theme" || !id) return;
|
||
clearTimeout(stockPreviewOpenTimer);
|
||
cancelStockPreviewClose();
|
||
stockPreviewAnchor = trigger;
|
||
state.stockPreviewCode = id;
|
||
state.stockPreviewType = type;
|
||
state.stockPreviewItem = { ...item, id, code: item.code || id, type, type_label: item.type_label || "题材" };
|
||
state.stockPreviewFallback = {
|
||
code: item.code || id,
|
||
name: item.name || "--",
|
||
sector: item.type_label || "题材",
|
||
price: item.value,
|
||
change: item.change,
|
||
};
|
||
state.stockPreviewPayload = null;
|
||
state.stockPreviewChart = "daily";
|
||
renderStockPreviewLoading();
|
||
elements.stockPreview.hidden = false;
|
||
const mobile = window.innerWidth <= 720;
|
||
elements.stockPreviewBackdrop.hidden = !mobile;
|
||
document.body.classList.toggle("stock-preview-open", mobile);
|
||
requestAnimationFrame(repositionStockPreview);
|
||
|
||
const cacheKey = `${type}:${id}:latest`;
|
||
const cached = stockPreviewCache.get(cacheKey);
|
||
if (cached && cached.expiresAt > Date.now()) {
|
||
renderStockPreview(cached.payload);
|
||
return;
|
||
}
|
||
if (cached) stockPreviewCache.delete(cacheKey);
|
||
stockPreviewAbortController?.abort();
|
||
stockPreviewAbortController = new AbortController();
|
||
try {
|
||
const params = new URLSearchParams({ type, id, trade_date: todayString() });
|
||
const detail = await apiRequest(
|
||
`/api/search/detail?${params}`,
|
||
"GET",
|
||
null,
|
||
{ signal: stockPreviewAbortController.signal },
|
||
);
|
||
if (state.stockPreviewType !== type || state.stockPreviewCode !== id || elements.stockPreview.hidden) return;
|
||
const entity = detail.entity || {};
|
||
const payload = {
|
||
stock: {
|
||
code: entity.code || id,
|
||
name: entity.name || item.name || "--",
|
||
industry: entity.type_label || item.type_label || "题材",
|
||
price: entity.value,
|
||
change: entity.change,
|
||
},
|
||
prices: detail.series || [],
|
||
intraday: [],
|
||
meta: {
|
||
trade_date: detail.meta?.trade_date || "",
|
||
realtime: Boolean(detail.meta?.realtime),
|
||
intraday_status: "idle",
|
||
intraday_notice: "",
|
||
},
|
||
};
|
||
stockPreviewCache.set(cacheKey, { payload, expiresAt: Date.now() + STOCK_PREVIEW_CACHE_MS });
|
||
while (stockPreviewCache.size > 48) stockPreviewCache.delete(stockPreviewCache.keys().next().value);
|
||
renderStockPreview(payload);
|
||
} catch (error) {
|
||
if (error.name === "AbortError" || state.stockPreviewType !== type || state.stockPreviewCode !== id) return;
|
||
renderStockPreviewError(error.message || "题材行情预览加载失败");
|
||
}
|
||
}
|
||
|
||
function renderStockPreviewLoading() {
|
||
const fallback = state.stockPreviewFallback || {};
|
||
selectStockPreviewChart("daily");
|
||
setText("stockPreviewCode", state.stockPreviewCode || "--");
|
||
setText("stockPreviewName", fallback.name || "正在加载");
|
||
setText("stockPreviewSector", fallback.sector || "--");
|
||
setText("stockPreviewPrice", "--");
|
||
setText("stockPreviewChange", "--");
|
||
document.querySelector("#stockPreviewChange").className = "";
|
||
setText("stockPreviewDate", "最新行情");
|
||
setText("stockPreviewSource", "正在读取行情");
|
||
setText("stockPreviewSummary", "等待行情数据");
|
||
document.querySelector("#stockPreviewLoading").hidden = false;
|
||
clearStockPreviewChart("");
|
||
}
|
||
|
||
function renderStockPreview(payload) {
|
||
state.stockPreviewPayload = payload;
|
||
const fallback = state.stockPreviewFallback || {};
|
||
const stock = payload.stock || {};
|
||
const price = stock.price;
|
||
const change = stock.change;
|
||
setText("stockPreviewCode", stock.code || state.stockPreviewCode);
|
||
setText("stockPreviewName", stock.name && stock.name !== "--" ? stock.name : fallback.name || "--");
|
||
setText("stockPreviewSector", stock.industry && stock.industry !== "其他" ? stock.industry : fallback.sector || "其他");
|
||
setText("stockPreviewPrice", meaningfulNumber(price) ? formatNumber(price, 2) : "--");
|
||
setText("stockPreviewChange", meaningfulNumber(change) ? `${signed(change)}%` : "--");
|
||
document.querySelector("#stockPreviewChange").className = changeClass(change);
|
||
document.querySelector("#stockPreviewLoading").hidden = true;
|
||
selectStockPreviewChart("daily");
|
||
requestAnimationFrame(repositionStockPreview);
|
||
}
|
||
|
||
function renderStockPreviewError(message) {
|
||
document.querySelector("#stockPreviewLoading").hidden = true;
|
||
setText("stockPreviewSource", "行情加载失败");
|
||
setText("stockPreviewSummary", message);
|
||
clearStockPreviewChart("加载失败");
|
||
}
|
||
|
||
function selectStockPreviewChart(chart) {
|
||
state.stockPreviewChart = chart === "daily" ? "daily" : "intraday";
|
||
document.querySelectorAll("[data-preview-chart]").forEach((button) => {
|
||
const active = button.dataset.previewChart === state.stockPreviewChart;
|
||
button.classList.toggle("active", active);
|
||
button.setAttribute("aria-selected", String(active));
|
||
});
|
||
const payload = state.stockPreviewPayload;
|
||
if (!payload) return;
|
||
if (state.stockPreviewChart === "intraday") {
|
||
if (state.stockPreviewType !== "stock" && payload.meta?.intraday_status === "idle") {
|
||
payload.meta.intraday_status = "loading";
|
||
setText("stockPreviewDate", "正在加载分时");
|
||
setText("stockPreviewSource", "正在读取最新分时");
|
||
setText("stockPreviewSummary", "等待分时行情数据");
|
||
clearStockPreviewChart("");
|
||
loadEntityPreviewIntraday();
|
||
return;
|
||
}
|
||
if (state.stockPreviewType !== "stock" && payload.meta?.intraday_status === "loading") return;
|
||
setText("stockPreviewDate", payload.meta?.intraday_trade_date || payload.meta?.trade_date || "最新行情");
|
||
setText(
|
||
"stockPreviewSource",
|
||
(payload.intraday || []).length ? "最新分时 · 1分钟" : "分时暂不可用",
|
||
);
|
||
if ((payload.intraday || []).length) {
|
||
drawIntradayPreviewChart(
|
||
payload.intraday,
|
||
payload.prices || [],
|
||
payload.meta?.intraday_previous_close,
|
||
);
|
||
}
|
||
else {
|
||
clearStockPreviewChart("分时数据不可用");
|
||
setText("stockPreviewSummary", payload.meta?.intraday_notice || "该交易日暂无分时数据。");
|
||
}
|
||
} else if ((payload.prices || []).length) {
|
||
setText("stockPreviewDate", payload.meta?.trade_date || "最新行情");
|
||
setText("stockPreviewSource", `日 K 行情 · ${payload.prices.length} 个交易日`);
|
||
drawDailyPreviewChart(payload.prices);
|
||
} else {
|
||
setText("stockPreviewDate", payload.meta?.trade_date || "最新行情");
|
||
setText("stockPreviewSource", "日 K 行情暂不可用");
|
||
clearStockPreviewChart("暂无日K数据");
|
||
setText("stockPreviewSummary", "该股票暂无可用的日K数据。");
|
||
}
|
||
}
|
||
|
||
async function loadEntityPreviewIntraday() {
|
||
const type = state.stockPreviewType;
|
||
const id = state.stockPreviewCode;
|
||
const payload = state.stockPreviewPayload;
|
||
if (type === "stock" || !id || !payload) return;
|
||
stockPreviewAbortController?.abort();
|
||
stockPreviewAbortController = new AbortController();
|
||
try {
|
||
const params = new URLSearchParams({ type, id });
|
||
const intraday = await apiRequest(
|
||
`/api/chart/intraday?${params}`,
|
||
"GET",
|
||
null,
|
||
{ signal: stockPreviewAbortController.signal },
|
||
);
|
||
if (state.stockPreviewType !== type || state.stockPreviewCode !== id || elements.stockPreview.hidden) return;
|
||
payload.intraday = intraday.points || [];
|
||
payload.meta.intraday_status = payload.intraday.length ? "available" : "empty";
|
||
payload.meta.intraday_trade_date = intraday.meta?.trade_date || "";
|
||
payload.meta.intraday_previous_close = intraday.meta?.previous_close || 0;
|
||
payload.meta.intraday_notice = payload.intraday.length ? "" : "该题材暂无可用分时数据。";
|
||
if (state.stockPreviewChart === "intraday") selectStockPreviewChart("intraday");
|
||
} catch (error) {
|
||
if (error.name === "AbortError" || state.stockPreviewType !== type || state.stockPreviewCode !== id) return;
|
||
payload.meta.intraday_status = "unavailable";
|
||
payload.meta.intraday_notice = error.message || "题材分时行情暂不可用。";
|
||
if (state.stockPreviewChart === "intraday") selectStockPreviewChart("intraday");
|
||
}
|
||
}
|
||
|
||
function closeStockPreview() {
|
||
clearTimeout(stockPreviewOpenTimer);
|
||
clearTimeout(stockPreviewCloseTimer);
|
||
stockPreviewAbortController?.abort();
|
||
stockPreviewAbortController = null;
|
||
elements.stockPreview.hidden = true;
|
||
elements.stockPreviewBackdrop.hidden = true;
|
||
document.body.classList.remove("stock-preview-open");
|
||
state.stockPreviewPayload = null;
|
||
state.stockPreviewCode = "";
|
||
state.stockPreviewType = "stock";
|
||
state.stockPreviewItem = null;
|
||
}
|
||
|
||
function openStockDetailFromPreview() {
|
||
const code = state.stockPreviewCode;
|
||
const fallback = state.stockPreviewFallback;
|
||
const type = state.stockPreviewType;
|
||
const item = state.stockPreviewItem;
|
||
if (!code) return;
|
||
closeStockPreview();
|
||
if (type === "stock") openStock(code, fallback);
|
||
else if (item) openEntityDetail(item);
|
||
}
|
||
|
||
function repositionStockPreview() {
|
||
if (elements.stockPreview.hidden || window.innerWidth <= 720 || !stockPreviewAnchor?.isConnected) return;
|
||
const anchor = stockPreviewAnchor.getBoundingClientRect();
|
||
const preview = elements.stockPreview.getBoundingClientRect();
|
||
const gap = 12;
|
||
let left = anchor.right + gap;
|
||
if (left + preview.width > window.innerWidth - 8) left = anchor.left - preview.width - gap;
|
||
left = clamp(left, 8, Math.max(8, window.innerWidth - preview.width - 8));
|
||
const top = clamp(anchor.top - 48, 64, Math.max(64, window.innerHeight - preview.height - 8));
|
||
elements.stockPreview.style.left = `${Math.round(left)}px`;
|
||
elements.stockPreview.style.top = `${Math.round(top)}px`;
|
||
}
|
||
|
||
async function loadAlerts(openDialog = false) {
|
||
try {
|
||
const query = new URLSearchParams({ status: state.alertFilter, as_of: todayString() });
|
||
const payload = await apiRequest(`/api/alerts?${query}`);
|
||
state.alerts = payload.items || [];
|
||
state.alertUnreadCount = number(payload.unread_count);
|
||
renderAlerts();
|
||
if (openDialog) openModalDialog(elements.alertsDialog);
|
||
} catch (error) {
|
||
if (openDialog) showToast(error.message || "提醒加载失败");
|
||
}
|
||
}
|
||
|
||
function openAlerts() {
|
||
toggleHeaderCommandMenu(false);
|
||
toggleAccountDropdown(false);
|
||
document.querySelector("#alertDate").value ||= todayString();
|
||
openModalDialog(elements.alertsDialog);
|
||
loadAlerts();
|
||
}
|
||
|
||
function openStockReminder() {
|
||
const stock = state.activeStock || {};
|
||
document.querySelector("#alertTitle").value = `${stock.name || stock.code || "个股"}观察提醒`;
|
||
document.querySelector("#alertCode").value = stock.code || "";
|
||
document.querySelector("#alertDate").value = todayString();
|
||
if (elements.stockDialog.open) elements.stockDialog.close();
|
||
openAlerts();
|
||
document.querySelector("#alertContent").focus();
|
||
}
|
||
|
||
function selectAlertFilter(filter) {
|
||
state.alertFilter = filter === "unread" ? "unread" : "all";
|
||
document.querySelectorAll("[data-alert-filter]").forEach((button) => {
|
||
button.classList.toggle("active", button.dataset.alertFilter === state.alertFilter);
|
||
});
|
||
loadAlerts();
|
||
}
|
||
|
||
async function saveAlert(event) {
|
||
event.preventDefault();
|
||
const button = event.currentTarget.querySelector("button[type='submit']");
|
||
button.disabled = true;
|
||
try {
|
||
const payload = await apiRequest("/api/alerts", "POST", {
|
||
title: document.querySelector("#alertTitle").value.trim(),
|
||
remind_date: document.querySelector("#alertDate").value,
|
||
code: document.querySelector("#alertCode").value.trim(),
|
||
content: document.querySelector("#alertContent").value.trim(),
|
||
});
|
||
event.currentTarget.reset();
|
||
document.querySelector("#alertDate").value = todayString();
|
||
state.alertFilter = "all";
|
||
state.alerts = payload.items || [];
|
||
state.alertUnreadCount = number(payload.unread_count);
|
||
renderAlerts();
|
||
showToast("提醒已保存");
|
||
} catch (error) {
|
||
showToast(error.message || "提醒保存失败");
|
||
} finally {
|
||
button.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function markAllAlertsRead() {
|
||
try {
|
||
await apiRequest("/api/alerts/read-all", "POST", { as_of: todayString() });
|
||
await loadAlerts();
|
||
} catch (error) {
|
||
showToast(error.message || "提醒状态更新失败");
|
||
}
|
||
}
|
||
|
||
async function handleAlertAction(event) {
|
||
const button = event.target.closest("[data-alert-action]");
|
||
if (!button) return;
|
||
const id = number(button.dataset.alertId);
|
||
if (!id) return;
|
||
try {
|
||
if (button.dataset.alertAction === "delete") {
|
||
await apiRequest(`/api/alerts/${id}`, "DELETE");
|
||
} else {
|
||
await apiRequest(`/api/alerts/${id}/read`, "POST", {});
|
||
}
|
||
await loadAlerts();
|
||
} catch (error) {
|
||
showToast(error.message || "提醒操作失败");
|
||
}
|
||
}
|
||
|
||
function renderAlerts() {
|
||
const badge = document.querySelector("#alertBadge");
|
||
badge.hidden = state.alertUnreadCount <= 0;
|
||
badge.textContent = state.alertUnreadCount > 99 ? "99+" : String(state.alertUnreadCount);
|
||
document.querySelector("#alertButton").classList.toggle("has-alerts", state.alertUnreadCount > 0);
|
||
setText("alertListCount", `${state.alerts.length} 条`);
|
||
document.querySelectorAll("[data-alert-filter]").forEach((button) => {
|
||
button.classList.toggle("active", button.dataset.alertFilter === state.alertFilter);
|
||
});
|
||
document.querySelector("#markAllAlertsRead").disabled = state.alertUnreadCount <= 0;
|
||
const container = document.querySelector("#alertList");
|
||
container.innerHTML = state.alerts.map((item) => {
|
||
const upcoming = !item.due;
|
||
const kindLabel = item.kind === "manual" ? "自定提醒" : item.kind === "strategy_t5" ? "跟踪完成" : "策略反馈";
|
||
return `<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() {
|
||
toggleHeaderCommandMenu(false);
|
||
toggleAccountDropdown(false);
|
||
openModalDialog(elements.assistantDialog);
|
||
updateAssistantControls();
|
||
if (!hasMemberAccess()) {
|
||
document.querySelector("#closeAssistantDialog").focus();
|
||
return;
|
||
}
|
||
try {
|
||
const payload = await apiRequest("/api/assistant/messages");
|
||
state.assistantMessages = payload.items || [];
|
||
renderAssistantMessages();
|
||
} catch (error) {
|
||
showToast(error.message || "对话记录加载失败");
|
||
}
|
||
document.querySelector("#assistantQuestion").focus();
|
||
}
|
||
|
||
function useAssistantPrompt(prompt) {
|
||
const input = document.querySelector("#assistantQuestion");
|
||
input.value = prompt;
|
||
input.focus();
|
||
}
|
||
|
||
async function sendAssistantQuestion(event) {
|
||
event.preventDefault();
|
||
if (state.assistantLoading) return;
|
||
const input = document.querySelector("#assistantQuestion");
|
||
const question = input.value.trim();
|
||
if (!question) return;
|
||
input.value = "";
|
||
state.assistantMessages.push({ role: "user", content: question, context_date: elements.tradeDate.value.replaceAll("-", "") });
|
||
state.assistantMessages.push({ role: "assistant", content: "", streaming: true, context_date: elements.tradeDate.value.replaceAll("-", "") });
|
||
state.assistantLoading = true;
|
||
state.assistantController = new AbortController();
|
||
updateAssistantControls();
|
||
renderAssistantMessages();
|
||
try {
|
||
await streamAssistantRequest(question, state.assistantController.signal, (chunk) => {
|
||
const message = state.assistantMessages.at(-1);
|
||
if (message?.role === "assistant") message.content += chunk;
|
||
scheduleAssistantRender();
|
||
});
|
||
const message = state.assistantMessages.at(-1);
|
||
if (message) message.streaming = false;
|
||
setStatus("复盘助手回答完成");
|
||
} catch (error) {
|
||
const message = state.assistantMessages.at(-1);
|
||
if (message?.role === "assistant") {
|
||
message.streaming = false;
|
||
message.error = true;
|
||
if (!message.content) message.content = error.name === "AbortError" ? "已停止生成。" : error.message || "回答失败,请稍后重试。";
|
||
}
|
||
if (error.name !== "AbortError") showToast(error.message || "复盘助手回答失败");
|
||
} finally {
|
||
state.assistantLoading = false;
|
||
state.assistantController = null;
|
||
updateAssistantControls();
|
||
renderAssistantMessages();
|
||
input.focus();
|
||
}
|
||
}
|
||
|
||
async function streamAssistantRequest(question, signal, onDelta) {
|
||
await window.XiaobaiAPI.streamNdjson("/api/assistant/chat", {
|
||
method: "POST",
|
||
body: { question, trade_date: elements.tradeDate.value },
|
||
signal,
|
||
errorMessage: "复盘助手暂不可用",
|
||
onEvent: (event) => {
|
||
if (event.type === "delta") onDelta(String(event.content || ""));
|
||
},
|
||
});
|
||
}
|
||
|
||
function stopAssistantResponse() {
|
||
state.assistantController?.abort();
|
||
}
|
||
|
||
async function clearAssistantConversation() {
|
||
if (state.assistantLoading || !state.assistantMessages.length) return;
|
||
if (!window.confirm("确定清空复盘助手的对话记录吗?")) return;
|
||
try {
|
||
await apiRequest("/api/assistant/messages", "DELETE");
|
||
state.assistantMessages = [];
|
||
renderAssistantMessages();
|
||
} catch (error) {
|
||
showToast(error.message || "对话记录清空失败");
|
||
}
|
||
}
|
||
|
||
function scheduleAssistantRender() {
|
||
if (assistantRenderFrame) return;
|
||
assistantRenderFrame = requestAnimationFrame(() => {
|
||
assistantRenderFrame = 0;
|
||
renderAssistantMessages();
|
||
});
|
||
}
|
||
|
||
function renderAssistantMessages() {
|
||
const container = document.querySelector("#assistantMessages");
|
||
container.innerHTML = state.assistantMessages.map((message) => `
|
||
<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() {
|
||
const unlocked = hasMemberAccess();
|
||
elements.assistantDialog.classList.toggle("member-locked", !unlocked);
|
||
document.querySelector("#assistantMemberGate").hidden = unlocked;
|
||
document.querySelector("#assistantMemberContent").setAttribute("aria-disabled", String(!unlocked));
|
||
document.querySelector("#assistantQuestion").disabled = !unlocked || state.assistantLoading;
|
||
document.querySelector("#sendAssistant").disabled = !unlocked || state.assistantLoading;
|
||
document.querySelector("#stopAssistant").hidden = !unlocked || !state.assistantLoading;
|
||
document.querySelector("#clearAssistantMessages").disabled = !unlocked || state.assistantLoading || !state.assistantMessages.length;
|
||
document.querySelectorAll("[data-assistant-prompt]").forEach((button) => {
|
||
button.disabled = !unlocked || state.assistantLoading;
|
||
});
|
||
}
|
||
|
||
function openGlobalSearch() {
|
||
if (!state.user) return;
|
||
toggleHeaderCommandMenu(false);
|
||
openModalDialog(elements.globalSearchDialog);
|
||
requestAnimationFrame(() => {
|
||
elements.globalSearchInput.focus();
|
||
elements.globalSearchInput.select();
|
||
});
|
||
}
|
||
|
||
function handleGlobalSearchShortcut(event) {
|
||
if (!event.ctrlKey || event.altKey || event.shiftKey || event.key.toLowerCase() !== "k") return;
|
||
if (!state.user) return;
|
||
if (event.defaultPrevented) {
|
||
showToast("Ctrl+K 已被其他功能占用,请点击顶部搜索按钮");
|
||
return;
|
||
}
|
||
event.preventDefault();
|
||
openGlobalSearch();
|
||
}
|
||
|
||
function closeGlobalSearch() {
|
||
clearTimeout(globalSearchTimer);
|
||
if (elements.globalSearchDialog.open) elements.globalSearchDialog.close();
|
||
}
|
||
|
||
function scheduleGlobalSearch() {
|
||
clearTimeout(globalSearchTimer);
|
||
const query = elements.globalSearchInput.value.trim();
|
||
state.globalSearchActiveIndex = -1;
|
||
if (!query) {
|
||
state.globalSearchResults = [];
|
||
renderGlobalSearchEmpty("输入名称或代码开始搜索", "使用方向键选择,回车打开详情", "corner-down-left");
|
||
return;
|
||
}
|
||
elements.globalSearchResults.innerHTML = '<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) {
|
||
state.entityDetailItem = item;
|
||
state.entityDetailPayload = null;
|
||
state.entityDetailIntraday = null;
|
||
state.entityDetailChartMode = "daily";
|
||
const requestSequence = ++state.entityDetailRequestSequence;
|
||
syncDetailChartButtons("entity", "daily");
|
||
setText("entityDetailCode", item.code || item.id || "--");
|
||
setText("entityDetailName", item.name || "--");
|
||
setText("entityDetailValue", "--");
|
||
setText("entityDetailChange", "--");
|
||
setText("entityDetailType", item.type_label || "--");
|
||
setText("entityDetailDate", "正在加载行情");
|
||
document.querySelector("#entityDetailChange").className = "";
|
||
document.querySelector("#entityDetailMetrics").innerHTML = '<div class="empty-state">正在加载交易数据</div>';
|
||
openModalDialog(elements.entityDetailDialog);
|
||
clearEntityDetailChart("正在加载日 K 数据");
|
||
try {
|
||
const params = new URLSearchParams({ type: item.type, id: item.id, trade_date: elements.tradeDate.value });
|
||
const payload = await apiRequest(`/api/search/detail?${params}`);
|
||
if (requestSequence !== state.entityDetailRequestSequence) return;
|
||
state.entityDetailPayload = payload;
|
||
const entity = payload.entity || {};
|
||
setText("entityDetailCode", entity.code || item.code || "--");
|
||
setText("entityDetailName", entity.name || item.name || "--");
|
||
setText("entityDetailValue", meaningfulNumber(entity.value) && number(entity.value) !== 0 ? formatNumber(entity.value, 2) : "--");
|
||
setText("entityDetailChange", `${signed(entity.change)}%`);
|
||
setText("entityDetailType", entity.type_label || item.type_label || "--");
|
||
document.querySelector("#entityDetailChange").className = changeClass(entity.change);
|
||
renderEntityDetailMetrics(payload.metrics || []);
|
||
if (state.entityDetailChartMode === "daily") {
|
||
setText("entityDetailDate", `${payload.meta?.realtime ? "实时" : "收盘"} · ${payload.meta?.trade_date || "--"}`);
|
||
requestAnimationFrame(() => drawEntityDetailChart(payload.series || []));
|
||
}
|
||
} catch (error) {
|
||
if (requestSequence !== state.entityDetailRequestSequence) return;
|
||
setText("entityDetailDate", "行情加载失败");
|
||
document.querySelector("#entityDetailMetrics").innerHTML = `<div class="empty-state">${escapeHtml(error.message || "交易数据加载失败")}</div>`;
|
||
if (state.entityDetailChartMode === "daily") clearEntityDetailChart(error.message || "行情加载失败");
|
||
showToast(error.message || "详情加载失败");
|
||
}
|
||
}
|
||
|
||
async function selectEntityDetailChart(mode) {
|
||
const selected = mode === "intraday" ? "intraday" : "daily";
|
||
state.entityDetailChartMode = selected;
|
||
syncDetailChartButtons("entity", selected);
|
||
if (selected === "daily") {
|
||
const payload = state.entityDetailPayload;
|
||
if (payload) {
|
||
setText("entityDetailDate", `${payload.meta?.realtime ? "实时" : "收盘"} · ${payload.meta?.trade_date || "--"}`);
|
||
requestAnimationFrame(() => drawEntityDetailChart(payload.series || []));
|
||
} else clearEntityDetailChart("正在加载日 K 数据");
|
||
return;
|
||
}
|
||
|
||
if (state.entityDetailIntraday) {
|
||
renderEntityIntraday(state.entityDetailIntraday);
|
||
return;
|
||
}
|
||
const item = state.entityDetailItem;
|
||
if (!item) return;
|
||
const requestSequence = state.entityDetailRequestSequence;
|
||
setText("entityDetailDate", "正在加载分时");
|
||
clearEntityDetailChart("正在加载分时数据");
|
||
try {
|
||
const params = new URLSearchParams({ type: item.type, id: item.id });
|
||
const payload = await apiRequest(`/api/chart/intraday?${params}`);
|
||
if (requestSequence !== state.entityDetailRequestSequence) return;
|
||
state.entityDetailIntraday = payload;
|
||
if (state.entityDetailChartMode === "intraday") renderEntityIntraday(payload);
|
||
} catch (error) {
|
||
if (requestSequence !== state.entityDetailRequestSequence || state.entityDetailChartMode !== "intraday") return;
|
||
setText("entityDetailDate", "分时暂不可用");
|
||
clearEntityDetailChart(error.message || "分时行情暂不可用");
|
||
}
|
||
}
|
||
|
||
function renderEntityIntraday(payload) {
|
||
const points = payload.points || [];
|
||
if (!points.length) {
|
||
setText("entityDetailDate", "分时暂不可用");
|
||
clearEntityDetailChart("分时行情暂不可用");
|
||
return;
|
||
}
|
||
setText("entityDetailDate", `分时 · ${payload.meta?.trade_date || "--"}`);
|
||
requestAnimationFrame(() => {
|
||
if (state.entityDetailChartMode !== "intraday") return;
|
||
drawIntradayCanvas(elements.entityDetailChart, points, [], payload.meta?.previous_close);
|
||
});
|
||
}
|
||
|
||
function syncDetailChartButtons(scope, mode) {
|
||
const selector = scope === "stock" ? "[data-stock-detail-chart]" : "[data-entity-detail-chart]";
|
||
const datasetKey = scope === "stock" ? "stockDetailChart" : "entityDetailChart";
|
||
document.querySelectorAll(selector).forEach((button) => {
|
||
const active = button.dataset[datasetKey] === mode;
|
||
button.classList.toggle("active", active);
|
||
button.setAttribute("aria-pressed", String(active));
|
||
});
|
||
}
|
||
|
||
function renderEntityDetailMetrics(metrics) {
|
||
const container = document.querySelector("#entityDetailMetrics");
|
||
if (!metrics.length) {
|
||
container.innerHTML = '<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, canvas = elements.entityDetailChart) {
|
||
const candles = (series || []).filter((item) => number(item.close) > 0).map((item) => {
|
||
const close = number(item.close);
|
||
const open = number(item.open) || close;
|
||
const high = Math.max(number(item.high) || close, open, close);
|
||
const low = Math.min(number(item.low) || close, open, close);
|
||
return { ...item, open, high, low, close };
|
||
});
|
||
if (!candles.length) {
|
||
clearEntityDetailChart("暂无日 K 数据", canvas);
|
||
return;
|
||
}
|
||
const rect = canvas.getBoundingClientRect();
|
||
const ratio = window.devicePixelRatio || 1;
|
||
const width = Math.max(320, rect.width);
|
||
const height = Math.max(220, rect.height);
|
||
canvas.width = Math.round(width * ratio);
|
||
canvas.height = Math.round(height * ratio);
|
||
const context = canvas.getContext("2d");
|
||
const palette = currentChartPalette();
|
||
context.setTransform(ratio, 0, 0, ratio, 0, 0);
|
||
context.clearRect(0, 0, width, height);
|
||
context.fillStyle = palette.background;
|
||
context.fillRect(0, 0, width, height);
|
||
const left = 48;
|
||
const right = 12;
|
||
const top = 14;
|
||
const bottom = 22;
|
||
const volumeHeight = 54;
|
||
const gap = 12;
|
||
const priceBottom = height - bottom - volumeHeight - gap;
|
||
const plotWidth = width - left - right;
|
||
const maximum = Math.max(...candles.map((item) => item.high));
|
||
const minimum = Math.min(...candles.map((item) => item.low));
|
||
const range = Math.max(maximum - minimum, maximum * 0.01, 0.01);
|
||
const maxVolume = Math.max(...candles.map((item) => number(item.volume)), 1);
|
||
const priceY = (value) => top + (maximum - value) / range * (priceBottom - top);
|
||
const step = plotWidth / candles.length;
|
||
const candleWidth = clamp(step * 0.62, 2, 8);
|
||
context.strokeStyle = palette.grid;
|
||
context.fillStyle = palette.axis;
|
||
context.font = "11px Microsoft YaHei";
|
||
context.textAlign = "right";
|
||
for (let line = 0; line <= 4; line += 1) {
|
||
const lineY = top + (priceBottom - top) * line / 4;
|
||
context.beginPath();
|
||
context.moveTo(left, lineY);
|
||
context.lineTo(width - right, lineY);
|
||
context.stroke();
|
||
context.fillText((maximum - range * line / 4).toFixed(2), left - 6, lineY + 4);
|
||
}
|
||
|
||
candles.forEach((item, index) => {
|
||
const x = left + step * index + step / 2;
|
||
const color = drawCandlestick(context, x, item, priceY, candleWidth, palette);
|
||
const volumeBarHeight = number(item.volume) / maxVolume * volumeHeight;
|
||
context.fillStyle = color;
|
||
context.globalAlpha = 0.72;
|
||
context.fillRect(x - candleWidth / 2, height - bottom - volumeBarHeight, candleWidth, volumeBarHeight);
|
||
context.globalAlpha = 1;
|
||
});
|
||
|
||
context.textAlign = "center";
|
||
context.fillStyle = palette.axis;
|
||
[0, Math.floor((candles.length - 1) / 2), candles.length - 1].forEach((index) => {
|
||
const x = left + step * index + step / 2;
|
||
context.fillText(String(candles[index].trade_date || "").slice(5), x, height - 5);
|
||
});
|
||
}
|
||
|
||
function clearEntityDetailChart(message, canvas = elements.entityDetailChart) {
|
||
const rect = canvas.getBoundingClientRect();
|
||
const width = Math.max(320, Math.round(rect.width || 680));
|
||
const height = Math.max(220, Math.round(rect.height || 300));
|
||
canvas.width = width;
|
||
canvas.height = height;
|
||
const context = canvas.getContext("2d");
|
||
const palette = currentChartPalette();
|
||
context.fillStyle = palette.background;
|
||
context.fillRect(0, 0, width, height);
|
||
context.fillStyle = palette.axis;
|
||
context.font = "13px Microsoft YaHei";
|
||
context.textAlign = "center";
|
||
context.fillText(message, width / 2, height / 2);
|
||
}
|
||
|
||
function meaningfulNumber(value) {
|
||
return value !== null && value !== undefined && value !== "" && Number.isFinite(Number(value));
|
||
}
|
||
|
||
async function openStock(code, fallback = null) {
|
||
closeStockPreview();
|
||
const pools = [state.dashboard?.limits || [], state.dashboard?.broken || [], state.dashboard?.down_limits || []];
|
||
const row = pools.flat().find((item) => String(item.code) === String(code)) || fallback || { code, name: "--", sector: "其他" };
|
||
state.activeStock = row;
|
||
state.stockDetail = null;
|
||
state.stockDetailIntraday = null;
|
||
state.stockDetailChartMode = "daily";
|
||
const requestSequence = ++state.stockDetailRequestSequence;
|
||
syncDetailChartButtons("stock", "daily");
|
||
setText("detailCode", row.code);
|
||
setText("detailName", row.name);
|
||
setText("detailPrice", formatNumber(row.price, 2));
|
||
setText("detailChange", `${signed(row.change)}%`);
|
||
const changeElement = document.querySelector("#detailChange");
|
||
changeElement.className = changeClass(row.change);
|
||
setText("detailStreak", row.status === "涨停" ? streakLabel(row.streak) : row.status || "--");
|
||
setText("detailReason", row.reason || "--");
|
||
setText("detailSector", row.sector || "其他");
|
||
setText("detailFirst", row.first_time || "--");
|
||
setText("detailLast", row.last_time || "--");
|
||
setText("detailOpen", `${number(row.open_times)} 次`);
|
||
setText("detailTurnover", `${formatNumber(row.turnover_rate, 2)}%`);
|
||
setText("detailAmount", `${formatNumber(row.amount_billion, 2)} 亿`);
|
||
setText("detailSeal", `${formatNumber(row.seal_amount_million, 0)} 万`);
|
||
setText("chartSource", "正在加载行情");
|
||
setText("flowNet", "--");
|
||
setText("flowLarge", "--");
|
||
setText("flowMedium", "--");
|
||
setText("flowSmall", "--");
|
||
document.querySelector("#reasonInput").value = row.reason || "";
|
||
document.querySelector("#stockNoteContent").value = "";
|
||
document.querySelector("#stockNotePlan").value = "";
|
||
document.querySelector("#stockNotes").innerHTML = '<div class="empty-state">正在加载笔记</div>';
|
||
updateWatchButton();
|
||
openModalDialog(elements.stockDialog);
|
||
clearPriceChart("正在加载日 K 数据");
|
||
try {
|
||
const query = new URLSearchParams({ trade_date: elements.tradeDate.value });
|
||
const payload = await apiRequest(`/api/stock/${encodeURIComponent(code)}?${query}`);
|
||
if (requestSequence !== state.stockDetailRequestSequence) return;
|
||
state.stockDetail = payload;
|
||
const stock = payload.stock || {};
|
||
state.activeStock = { ...row, name: stock.name || row.name, sector: stock.industry || row.sector };
|
||
setText("detailName", stock.name || row.name);
|
||
setText("detailPrice", formatNumber(stock.price || row.price, 2));
|
||
setText("detailChange", `${signed(stock.change ?? row.change)}%`);
|
||
renderMoneyflow(payload.moneyflow || {});
|
||
renderStockNotes(payload.notes || []);
|
||
updateWatchButton();
|
||
if (state.stockDetailChartMode === "daily") {
|
||
setText("chartSource", `日 K 行情 · ${payload.prices.length} 个交易日`);
|
||
requestAnimationFrame(() => drawPriceChart(payload.prices || []));
|
||
}
|
||
} catch (error) {
|
||
if (requestSequence !== state.stockDetailRequestSequence) return;
|
||
setText("chartSource", "行情加载失败");
|
||
if (state.stockDetailChartMode === "daily") clearPriceChart(error.message || "行情加载失败");
|
||
showToast(error.message || "个股详情加载失败");
|
||
}
|
||
}
|
||
|
||
async function selectStockDetailChart(mode) {
|
||
const selected = mode === "intraday" ? "intraday" : "daily";
|
||
state.stockDetailChartMode = selected;
|
||
syncDetailChartButtons("stock", selected);
|
||
if (selected === "daily") {
|
||
const prices = state.stockDetail?.prices || [];
|
||
setText("chartSource", prices.length ? `日 K 行情 · ${prices.length} 个交易日` : "正在加载行情");
|
||
if (prices.length) requestAnimationFrame(() => drawPriceChart(prices));
|
||
else clearPriceChart("正在加载日 K 数据");
|
||
return;
|
||
}
|
||
|
||
if (state.stockDetailIntraday) {
|
||
renderStockDetailIntraday(state.stockDetailIntraday);
|
||
return;
|
||
}
|
||
const code = String(state.activeStock?.code || "");
|
||
if (!/^\d{6}$/.test(code)) return;
|
||
const requestSequence = state.stockDetailRequestSequence;
|
||
setText("chartSource", "正在加载分时");
|
||
clearPriceChart("正在加载分时数据");
|
||
try {
|
||
const params = new URLSearchParams({ type: "stock", id: code });
|
||
const payload = await apiRequest(`/api/chart/intraday?${params}`);
|
||
if (requestSequence !== state.stockDetailRequestSequence) return;
|
||
state.stockDetailIntraday = payload;
|
||
if (state.stockDetailChartMode === "intraday") renderStockDetailIntraday(payload);
|
||
} catch (error) {
|
||
if (requestSequence !== state.stockDetailRequestSequence || state.stockDetailChartMode !== "intraday") return;
|
||
setText("chartSource", "分时暂不可用");
|
||
clearPriceChart(error.message || "分时行情暂不可用");
|
||
}
|
||
}
|
||
|
||
function renderStockDetailIntraday(payload) {
|
||
const points = payload.points || [];
|
||
if (!points.length) {
|
||
setText("chartSource", "分时暂不可用");
|
||
clearPriceChart("分时行情暂不可用");
|
||
return;
|
||
}
|
||
setText("chartSource", `分时 · ${payload.meta?.trade_date || "--"}`);
|
||
requestAnimationFrame(() => {
|
||
if (state.stockDetailChartMode !== "intraday") return;
|
||
drawIntradayCanvas(elements.priceChart, points, [], payload.meta?.previous_close);
|
||
});
|
||
}
|
||
|
||
function openActiveStockInHeaven() {
|
||
const code = state.activeStock?.code;
|
||
if (!/^\d{6}$/.test(String(code || ""))) return;
|
||
elements.stockDialog.close();
|
||
state.heavenPanel = "trend";
|
||
state.heavenManualData = null;
|
||
const input = document.querySelector("#heavenStockInput");
|
||
input.value = code;
|
||
openView("heavenView");
|
||
selectHeavenPanel("trend", true);
|
||
}
|
||
|
||
function hasMemberAccess() {
|
||
return state.user?.role === "admin" || Boolean(state.user?.membership?.active);
|
||
}
|
||
|
||
function updateAccountIdentityBadges(membership = {}) {
|
||
const isAdmin = state.user?.role === "admin" || Boolean(membership.is_admin);
|
||
const subscribed = Boolean(membership.subscribed);
|
||
document.querySelector("#accountAdminBadge").hidden = !isAdmin;
|
||
const vipBadge = document.querySelector("#accountVipBadge");
|
||
vipBadge.hidden = false;
|
||
vipBadge.classList.toggle("is-nonmember", !subscribed);
|
||
setText("accountVipLabel", subscribed ? "会员" : "非会员");
|
||
vipBadge.title = subscribed ? "查看会员状态" : "查看会员权益";
|
||
}
|
||
|
||
function applyMembershipAccess() {
|
||
const unlocked = hasMemberAccess();
|
||
document.querySelectorAll(".member-feature-view").forEach((view) => {
|
||
view.classList.toggle("member-locked", !unlocked);
|
||
const gate = view.querySelector(".member-gate");
|
||
if (gate) gate.hidden = unlocked;
|
||
view.querySelectorAll("button, input, textarea, select").forEach((control) => {
|
||
if (control.closest(".member-gate") || control.hasAttribute("data-member-navigation")) return;
|
||
control.disabled = !unlocked;
|
||
});
|
||
});
|
||
const assistantButton = document.querySelector("#assistantButton");
|
||
assistantButton.classList.toggle("member-locked-control", !unlocked);
|
||
assistantButton.title = unlocked ? "复盘助手" : "复盘助手(会员可用)";
|
||
updateAssistantControls();
|
||
}
|
||
|
||
function openView(viewId, updateHash = true) {
|
||
if (!applicationShell.page(viewId)) return;
|
||
closeStockPreview();
|
||
if (viewId !== "auctionView") clearAuctionTimer();
|
||
if (viewId !== "heavenView") {
|
||
stopQiFieldCanvas();
|
||
stopHeartDust();
|
||
cancelHeavenPerformance();
|
||
}
|
||
if (!applicationShell.mount(viewId, { updateUrl: updateHash })) return;
|
||
applyMembershipAccess();
|
||
if (viewId === "dragonView") loadDragonTiger();
|
||
if (viewId === "reviewWorkspaceView") loadReviewWorkspace();
|
||
if (viewId === "screenerView" && hasMemberAccess() && state.dashboard) loadScreenerSetup();
|
||
if (viewId === "mentorView" && hasMemberAccess()) loadMentorSetup();
|
||
if (viewId === "heavenView" && hasMemberAccess()) loadHeavenSetup(false, "", document.querySelector("#heavenStockInput").value.trim());
|
||
if (viewId === "sentimentCycleView") loadSentimentHistory();
|
||
if (viewId === "rotationView") loadRotationHistory();
|
||
if (viewId === "auctionView") loadAuctionCenter();
|
||
if (viewId === "themeLibraryView") loadThemeLibrary();
|
||
if (viewId === "popularityView") loadPopularity();
|
||
}
|
||
|
||
function initializeAutoTableSorting() {
|
||
markAutoSortableHeaders(document);
|
||
document.addEventListener("click", (event) => {
|
||
const header = event.target.closest?.("th[data-auto-sort]");
|
||
if (!header || header.closest("#limitTable")) return;
|
||
const table = header.closest("table");
|
||
const body = table?.tBodies?.[0];
|
||
if (!body || body.rows.length < 2) return;
|
||
const direction = header.classList.contains("sort-asc") ? "desc" : "asc";
|
||
table.querySelectorAll("th.sort-asc, th.sort-desc").forEach((item) => {
|
||
item.classList.remove("sort-asc", "sort-desc", "sorted");
|
||
item.removeAttribute("aria-sort");
|
||
const arrow = item.querySelector(".arr");
|
||
if (arrow) arrow.textContent = "↕";
|
||
});
|
||
header.classList.add(`sort-${direction}`, "sorted");
|
||
header.setAttribute("aria-sort", direction === "asc" ? "ascending" : "descending");
|
||
const activeArrow = header.querySelector(".arr");
|
||
if (activeArrow) activeArrow.textContent = direction === "asc" ? "▲" : "▼";
|
||
const columnIndex = header.cellIndex;
|
||
const rows = [...body.rows].map((row, index) => ({ row, index }));
|
||
rows.sort((left, right) => {
|
||
const leftValue = autoSortValue(left.row.cells[columnIndex]);
|
||
const rightValue = autoSortValue(right.row.cells[columnIndex]);
|
||
let result;
|
||
if (leftValue.kind === "number" && rightValue.kind === "number") result = leftValue.value - rightValue.value;
|
||
else result = String(leftValue.value).localeCompare(String(rightValue.value), "zh-CN", { numeric: true, sensitivity: "base" });
|
||
if (result === 0) result = left.index - right.index;
|
||
return direction === "asc" ? result : -result;
|
||
});
|
||
rows.forEach(({ row }) => body.appendChild(row));
|
||
const firstHeader = [...header.parentElement.cells][0]?.textContent.trim();
|
||
if (["#", "排名"].includes(firstHeader)) {
|
||
[...body.rows].forEach((row, index) => {
|
||
if (row.cells[0]) row.cells[0].textContent = String(index + 1);
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
function markAutoSortableHeaders(root) {
|
||
root.querySelectorAll?.(".data-table:not(#limitTable) thead th").forEach((header) => {
|
||
if (header.closest("#brokenTable, #downTable, #yesterdayTable, #rotationTable")) return;
|
||
if (number(header.colSpan) > 1) return;
|
||
const label = header.textContent.trim();
|
||
if (!label || ["#", "操作"].includes(label)) return;
|
||
header.dataset.autoSort = "true";
|
||
header.classList.add("sortable");
|
||
if (!header.querySelector(".arr")) header.insertAdjacentHTML("beforeend", '<span class="arr">↕</span>');
|
||
header.title = `${label}:点击排序`;
|
||
});
|
||
}
|
||
|
||
function autoSortValue(cell) {
|
||
const text = String(cell?.dataset?.sortValue || cell?.textContent || "").trim();
|
||
if (!text || text === "--" || text.includes("样本不足")) return { kind: "text", value: "\uffff" };
|
||
const boardMatch = text.match(/(\d+)\s*板/);
|
||
if (boardMatch) return { kind: "number", value: Number(boardMatch[1]) };
|
||
const normalized = text.replaceAll(",", "").replace(/[+%]/g, "");
|
||
const numericMatch = normalized.match(/^-?\d+(?:\.\d+)?/);
|
||
if (numericMatch) {
|
||
let value = Number(numericMatch[0]);
|
||
if (text.includes("亿")) value *= 10000;
|
||
return { kind: "number", value };
|
||
}
|
||
return { kind: "text", value: text };
|
||
}
|
||
|
||
function changeSort(key) {
|
||
if (state.sortKey === key) state.sortDirection = state.sortDirection === "asc" ? "desc" : "asc";
|
||
else {
|
||
state.sortKey = key;
|
||
state.sortDirection = ["name", "code", "sector", "first_time", "last_time"].includes(key) ? "asc" : "desc";
|
||
}
|
||
renderLimitTable();
|
||
}
|
||
|
||
function compareRows(left, right) {
|
||
const leftValue = left[state.sortKey] ?? "";
|
||
const rightValue = right[state.sortKey] ?? "";
|
||
let result = typeof leftValue === "number" || typeof rightValue === "number"
|
||
? number(leftValue) - number(rightValue)
|
||
: String(leftValue).localeCompare(String(rightValue), "zh-CN", { numeric: true });
|
||
if (result === 0 && state.sortKey !== "first_time") result = String(left.first_time || "").localeCompare(String(right.first_time || ""));
|
||
return state.sortDirection === "asc" ? result : -result;
|
||
}
|
||
|
||
function updateSortHeaders() {
|
||
document.querySelectorAll("#limitTable th[data-sort]").forEach((header) => {
|
||
header.classList.remove("sort-asc", "sort-desc", "sorted");
|
||
const active = header.dataset.sort === state.sortKey;
|
||
if (active) header.classList.add(state.sortDirection === "asc" ? "sort-asc" : "sort-desc", "sorted");
|
||
const arrow = header.querySelector(".arr");
|
||
if (arrow) arrow.textContent = active ? (state.sortDirection === "asc" ? "▲" : "▼") : "↕";
|
||
});
|
||
}
|
||
|
||
function shiftDate(delta) {
|
||
const current = parseLocalDate(elements.tradeDate.value);
|
||
current.setDate(current.getDate() + delta);
|
||
const next = localDateString(current);
|
||
if (next > todayString()) return;
|
||
elements.tradeDate.value = next;
|
||
state.heavenManualData = null;
|
||
document.querySelector("#qiObservationDate").value = next;
|
||
loadDashboard();
|
||
}
|
||
|
||
function updateDateButtons() {
|
||
document.querySelector("#nextDate").disabled = elements.tradeDate.value >= todayString();
|
||
}
|
||
|
||
function selectAccountPanel(panel) {
|
||
const selected = ["profile", "membership", "password"].includes(panel) ? panel : "profile";
|
||
const titles = { profile: "个人资料", membership: "会员状态", password: "修改密码" };
|
||
setText("accountDialogTitle", titles[selected]);
|
||
document.querySelectorAll("[data-account-panel-content]").forEach((section) => {
|
||
section.hidden = section.dataset.accountPanelContent !== selected;
|
||
});
|
||
document.querySelector("#connectionStatus").hidden = selected !== "membership";
|
||
return selected;
|
||
}
|
||
|
||
async function openSettings(panel = "profile") {
|
||
selectAccountPanel(panel);
|
||
toggleAccountDropdown(false);
|
||
toggleHeaderCommandMenu(false);
|
||
const status = document.querySelector("#connectionStatus");
|
||
status.className = "connection-status";
|
||
status.textContent = "正在读取账号状态";
|
||
openModalDialog(elements.settingsDialog);
|
||
try {
|
||
const payload = await apiRequest("/api/account/status");
|
||
const access = payload.llm_access || {};
|
||
const membership = access.membership || {};
|
||
if (state.user) {
|
||
state.user.membership = membership;
|
||
updateAccountIdentityBadges(membership);
|
||
applyMembershipAccess();
|
||
}
|
||
status.textContent = membership.active ? "账户权益已同步" : "账户信息已同步";
|
||
status.classList.toggle("connected", true);
|
||
setText("membershipBadge", membership.subscribed ? "会员有效" : membership.is_admin ? "管理员权限" : "普通用户");
|
||
setText("membershipStateValue", membership.subscribed ? "已开通" : membership.is_admin ? "管理员可用" : "未开通");
|
||
setText("membershipRemainingValue", membership.subscribed && membership.expires_at
|
||
? `${number(membership.remaining_days)} 天`
|
||
: membership.is_admin || membership.subscribed ? "长期有效" : "--");
|
||
setText("membershipDetail", membership.subscribed
|
||
? `${membership.plan || "会员"}${membership.expires_at ? ` · 有效至 ${membershipDateDisplay(membership.expires_at, true)}` : " · 长期有效"}`
|
||
: membership.is_admin
|
||
? "管理员拥有智能功能管理权限,但不会因此显示为已开通会员。"
|
||
: "开通会员后可使用智能选股、问师、问天、复盘助手等智能功能。");
|
||
setText("membershipQuotaHint", `会员默认每日智能分析额度 ${number(access.daily_limit)} 次,由管理员统一设置。`);
|
||
setText("membershipUsage", membership.active
|
||
? `今日已用 ${number(access.used_today)} 次`
|
||
: "今日智能分析:--");
|
||
setText("membershipUsageSummary", membership.active ? `${number(access.used_today)} / ${number(access.daily_limit)}` : "--");
|
||
setText("membershipRemainingUsage", membership.is_admin ? "不限" : membership.active ? `${number(access.remaining_calls)} 次` : "--");
|
||
const birth = payload.birth_profile || {};
|
||
if (birth.birth_datetime) {
|
||
const [birthDate, birthTime] = String(birth.birth_datetime).split("T");
|
||
document.querySelector("#accountBirthDate").value = birthDate || "";
|
||
document.querySelector("#accountBirthTime").value = (birthTime || "").slice(0, 5);
|
||
document.querySelector("#accountBirthGender").value = birth.gender || "unspecified";
|
||
}
|
||
setText("birthProfileStatus", payload.birth_profile_configured ? "已加密保存" : "尚未设置");
|
||
document.querySelector("#deleteBirthProfileButton").disabled = !payload.birth_profile_configured;
|
||
} catch (error) {
|
||
status.hidden = false;
|
||
status.textContent = "账户状态暂时无法同步";
|
||
showToast(error.message || "账号信息加载失败");
|
||
}
|
||
}
|
||
|
||
async function changeAccountPassword(event) {
|
||
event.preventDefault();
|
||
const form = event.currentTarget;
|
||
const button = form.querySelector("button[type='submit']");
|
||
button.disabled = true;
|
||
try {
|
||
await apiRequest("/api/account/password", "POST", {
|
||
current_password: document.querySelector("#currentPassword").value,
|
||
new_password: document.querySelector("#newPassword").value,
|
||
confirm_password: document.querySelector("#confirmPassword").value,
|
||
});
|
||
form.reset();
|
||
showToast("密码已更新");
|
||
} catch (error) {
|
||
showToast(error.message || "密码更新失败");
|
||
} finally {
|
||
button.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function switchAccount() {
|
||
const button = document.querySelector("#switchAccountMenuButton");
|
||
button.disabled = true;
|
||
toggleAccountDropdown(false);
|
||
try {
|
||
await apiRequest("/api/auth/logout", "POST", {});
|
||
window.location.reload();
|
||
} catch (error) {
|
||
showToast(error.message || "切换账号失败");
|
||
button.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function openAdminSettings(refreshOnly = false) {
|
||
if (state.user?.role !== "admin") return;
|
||
if (!refreshOnly) openModalDialog(elements.adminDialog);
|
||
const status = document.querySelector("#adminConnectionStatus");
|
||
status.textContent = "正在读取系统状态";
|
||
try {
|
||
const payload = await apiRequest("/api/admin/settings");
|
||
const data = payload.data || {};
|
||
const ifind = data.ifind || {};
|
||
const llm = payload.llm || {};
|
||
const membership = payload.membership || {};
|
||
status.textContent = `Tushare ${data.configured ? "已配置" : "未配置"} · iFinD ${ifind.configured ? "已配置" : "未配置"} · ${number(data.snapshot_dates)} 个交易日`;
|
||
status.classList.toggle("connected", Boolean(data.configured));
|
||
setText("systemDataStatus", data.background_refresh_enabled ? "后台刷新已启用" : "后台刷新已暂停");
|
||
document.querySelector("#systemTokenInput").value = "";
|
||
document.querySelector("#systemIfindTokenInput").value = "";
|
||
document.querySelector("#systemBackgroundRefresh").checked = Boolean(data.background_refresh_enabled);
|
||
document.querySelector("#memberDailyLimit").value = number(membership.member_daily_limit) || 50;
|
||
renderModelPool(llm.models || [], llm.primary_model_id || "", llm.fallback_model_id || "");
|
||
renderAdminUsers(payload.users || []);
|
||
} catch (error) {
|
||
status.textContent = error.message || "系统配置读取失败";
|
||
}
|
||
}
|
||
|
||
function selectAdminPanel(panel) {
|
||
const selected = ["market", "models", "members"].includes(panel) ? panel : "market";
|
||
document.querySelector("#adminSectionSelect").value = selected;
|
||
document.querySelectorAll("[data-admin-panel]").forEach((item) => {
|
||
item.hidden = item.dataset.adminPanel !== selected;
|
||
});
|
||
}
|
||
|
||
function renderModelPool(models, primaryId = "", fallbackId = "") {
|
||
state.adminModels = models.map((item) => ({ ...item, api_key: item.api_key || "" }));
|
||
const container = document.querySelector("#modelPoolList");
|
||
container.innerHTML = state.adminModels.map((item, index) => `
|
||
<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(),
|
||
ifind_refresh_token: document.querySelector("#systemIfindTokenInput").value.trim(),
|
||
background_refresh_enabled: document.querySelector("#systemBackgroundRefresh").checked,
|
||
});
|
||
document.querySelector("#systemTokenInput").value = "";
|
||
document.querySelector("#systemIfindTokenInput").value = "";
|
||
showToast("行情配置已保存");
|
||
await openAdminSettings(true);
|
||
} catch (error) {
|
||
showToast(error.message || "系统配置保存失败");
|
||
} finally {
|
||
button.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function saveModelPool(event) {
|
||
event.preventDefault();
|
||
const button = event.currentTarget.querySelector("button[type='submit']");
|
||
button.disabled = true;
|
||
try {
|
||
await apiRequest("/api/admin/settings", "POST", {
|
||
models: collectModelPool(),
|
||
primary_model_id: document.querySelector("#platformPrimaryModelSelect").value,
|
||
fallback_model_id: document.querySelector("#platformFallbackModelSelect").value,
|
||
});
|
||
showToast("模型池已保存");
|
||
await openAdminSettings(true);
|
||
} catch (error) {
|
||
showToast(error.message || "模型池保存失败");
|
||
} finally {
|
||
button.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function saveMembershipSettings(event) {
|
||
event.preventDefault();
|
||
const button = event.currentTarget.querySelector("button[type='submit']");
|
||
button.disabled = true;
|
||
try {
|
||
await apiRequest("/api/admin/settings", "POST", {
|
||
member_daily_limit: number(document.querySelector("#memberDailyLimit").value),
|
||
});
|
||
showToast("会员调用额度已保存");
|
||
await openAdminSettings(true);
|
||
} catch (error) {
|
||
showToast(error.message || "会员调用额度保存失败");
|
||
} finally {
|
||
button.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function testPlatformModel(row) {
|
||
if (!row) return;
|
||
const button = row.querySelector("[data-test-model]");
|
||
const status = row.querySelector(".model-test-status");
|
||
const profile = collectModelPool().find((item) => item.id === row.dataset.modelId) || {};
|
||
button.disabled = true;
|
||
status.textContent = "连接中";
|
||
try {
|
||
const payload = await apiRequest("/api/admin/settings/test", "POST", { model_id: row.dataset.modelId, profile });
|
||
status.textContent = `已连通 · ${number(payload.result.latency_ms)} ms`;
|
||
status.className = "model-test-status success";
|
||
} catch (error) {
|
||
status.textContent = error.message;
|
||
status.className = "model-test-status failure";
|
||
} finally {
|
||
button.disabled = false;
|
||
}
|
||
}
|
||
|
||
function membershipDateDisplay(value) {
|
||
if (!value) return "";
|
||
const parsed = new Date(value);
|
||
if (Number.isNaN(parsed.getTime())) return String(value).slice(0, 10);
|
||
return new Intl.DateTimeFormat("zh-CN", { year: "numeric", month: "2-digit", day: "2-digit" }).format(parsed);
|
||
}
|
||
|
||
function exportStocks() {
|
||
exportRows("涨停池", getVisibleStocks(), [
|
||
["股票代码", "code"], ["股票名称", "name"], ["连板", "streak"], ["涨幅%", "change"],
|
||
["价格", "price"], ["所属板块", "sector"], ["涨停原因", "reason"], ["首封", "first_time"],
|
||
["最后封板", "last_time"], ["开板次数", "open_times"], ["换手率%", "turnover_rate"],
|
||
["成交额亿", "amount_billion"], ["封单额万", "seal_amount_million"],
|
||
]);
|
||
}
|
||
|
||
function exportBroken() {
|
||
exportRows("炸板池", getVisibleBrokenRows(), [
|
||
["股票代码", "code"], ["股票名称", "name"], ["现价涨幅%", "change"], ["距涨停%", "limitGap"],
|
||
["价格", "price"], ["所属板块", "sector"], ["首次触板", "first_time"], ["开板次数", "open_times"],
|
||
["换手率%", "turnover_rate"], ["成交额亿", "amount_billion"],
|
||
]);
|
||
}
|
||
|
||
function exportDown() {
|
||
exportRows("跌停板", getVisibleDownRows(), [
|
||
["股票代码", "code"], ["股票名称", "name"], ["跌幅%", "change"], ["价格", "price"],
|
||
["所属板块", "sector"], ["换手率%", "turnover_rate"], ["成交额亿", "amount_billion"],
|
||
]);
|
||
}
|
||
|
||
function exportYesterday() {
|
||
exportRows("昨日涨停", getVisibleYesterdayRows(), [
|
||
["股票代码", "code"], ["股票名称", "name"], ["昨日高度", "prior_streak"],
|
||
["今日涨幅%", "current_change"], ["今日结果", "outcome"], ["当前高度", "current_streak"],
|
||
["所属板块", "sector"],
|
||
]);
|
||
}
|
||
|
||
function exportLadder() {
|
||
const rows = (state.dashboard?.ladders || []).flatMap((group) => (group.stocks || []).map((stock) => ({
|
||
level: group.label || group.level,
|
||
...stock,
|
||
})));
|
||
exportRows("市场天梯", rows, [
|
||
["梯队", "level"], ["股票代码", "code"], ["股票名称", "name"], ["所属板块", "sector"],
|
||
["封板时间", "first_time"], ["开板次数", "open_times"], ["封单额万", "seal_amount_million"], ["成交额亿", "amount_billion"],
|
||
]);
|
||
}
|
||
|
||
function exportRotation() {
|
||
const sectorMap = new Map((state.dashboard?.sectors || []).map((sector) => [sector.name, sector]));
|
||
const rows = (state.dashboard?.sector_rotation || []).map((row) => ({
|
||
...row,
|
||
average_change: sectorMap.get(row.name)?.change ?? 0,
|
||
}));
|
||
exportRows("板块轮动", rows, [
|
||
["排名", "rank"], ["板块", "name"], ["趋势", "trend"], ["今日涨停", "count"],
|
||
["昨日涨停", "previous_count"], ["变化", "delta"], ["强度", "strength"],
|
||
["最高板", "max_streak"], ["平均涨幅%", "average_change"],
|
||
["领涨股", "leader"], ["涨停股成交额亿", "amount_billion"],
|
||
]);
|
||
}
|
||
|
||
function exportSentimentHistory() {
|
||
const rows = state.sentimentHistory?.rows || [];
|
||
if (!rows.length) {
|
||
showToast("暂无可导出的情绪周期数据");
|
||
return;
|
||
}
|
||
const exportRowsData = rows.map((row) => ({
|
||
...row,
|
||
breadth_score: row.components?.breadth?.score,
|
||
limit_ecology_score: row.components?.limit_ecology?.score,
|
||
profit_effect_score: row.components?.profit_effect?.score,
|
||
ladder_structure_score: row.components?.ladder_structure?.score,
|
||
liquidity_score: row.components?.liquidity?.score,
|
||
}));
|
||
exportRows("情绪周期", exportRowsData, [
|
||
["交易日", "trade_date"], ["情绪温度", "score"], ["周期阶段", "phase"], ["方向", "direction"],
|
||
["涨停", "limit_up_count"], ["首板", "first_board_count"], ["二板", "second_board_count"],
|
||
["三板以上", "three_plus_count"], ["连板高度", "max_height"], ["炸板", "broken_count"],
|
||
["跌停", "limit_down_count"], ["昨日涨停", "previous_limit_count"],
|
||
["昨日涨停红盘", "previous_positive_count"], ["昨日涨停红盘率%", "previous_positive_rate"],
|
||
["市场宽度", "breadth_score"], ["涨停生态", "limit_ecology_score"],
|
||
["赚钱效应", "profit_effect_score"], ["连板结构", "ladder_structure_score"],
|
||
["成交活跃度", "liquidity_score"],
|
||
]);
|
||
}
|
||
|
||
function exportDragonTiger() {
|
||
const rows = (state.dragonTiger?.traders || []).flatMap((trader) => (
|
||
(trader.operations || []).map((operation) => ({
|
||
trader_name: trader.name,
|
||
identity_type: dragonIdentityLabel(trader.identity_type),
|
||
...operation,
|
||
}))
|
||
));
|
||
exportRows("游资龙虎榜", rows, [
|
||
["游资或席位", "trader_name"], ["身份", "identity_type"], ["股票代码", "code"],
|
||
["股票名称", "name"], ["方向", "direction"], ["涨幅%", "change"],
|
||
["买入百万元", "buy_million"], ["卖出百万元", "sell_million"], ["净额百万元", "net_buy_million"],
|
||
["关联席位", "seat_name"], ["上榜原因", "reason"],
|
||
]);
|
||
}
|
||
|
||
function exportHotMoneyProfiles() {
|
||
const rows = state.hotMoneyProfiles?.profiles || [];
|
||
if (!rows.length) {
|
||
showToast("暂无可导出的游资档案");
|
||
return;
|
||
}
|
||
downloadCsv(
|
||
`游资档案-${todayString()}.csv`,
|
||
["游资名称", "简介", "关联营业部", "席位数量"],
|
||
rows.map((profile) => [
|
||
profile.name,
|
||
profile.description,
|
||
(profile.organizations || []).join(";"),
|
||
number(profile.organization_count),
|
||
]),
|
||
);
|
||
}
|
||
|
||
function commonReviewColumns() {
|
||
return [["股票代码", "code"], ["股票名称", "name"], ["状态", "status"], ["涨跌幅%", "change"],
|
||
["价格", "price"], ["所属板块", "sector"], ["原因", "reason"], ["首次触板", "first_time"],
|
||
["最后触板", "last_time"], ["开板次数", "open_times"], ["换手率%", "turnover_rate"], ["成交额亿", "amount_billion"]];
|
||
}
|
||
|
||
function exportRows(label, rows, columns) {
|
||
const headers = columns.map(([header]) => header);
|
||
const data = rows.map((row) => columns.map(([, key]) => row[key] ?? ""));
|
||
downloadCsv(`${label}-${state.dashboard.meta.trade_date}.csv`, headers, data);
|
||
}
|
||
|
||
function downloadCsv(filename, headers, rows) {
|
||
const lines = [headers, ...rows].map((row) => row.map(csvCell).join(","));
|
||
const blob = new Blob(["\ufeff", lines.join("\r\n")], { type: "text/csv;charset=utf-8" });
|
||
const url = URL.createObjectURL(blob);
|
||
const anchor = document.createElement("a");
|
||
anchor.href = url;
|
||
anchor.download = filename;
|
||
anchor.click();
|
||
URL.revokeObjectURL(url);
|
||
showToast(`已导出 ${rows.length} 条数据`);
|
||
}
|
||
|
||
function csvCell(value) {
|
||
let text = String(value ?? "");
|
||
if (/^[=+\-@]/.test(text)) text = `'${text}`;
|
||
return `"${text.replaceAll('"', '""')}"`;
|
||
}
|
||
|
||
function outcomeClass(outcome) {
|
||
return { "晋级": "outcome-advance", "炸板": "outcome-broken", "跌停": "outcome-down", "断板": "outcome-open" }[outcome] || "outcome-open";
|
||
}
|
||
|
||
function trendClass(trend) {
|
||
return { "升温": "trend-hot", "降温": "trend-cool", "新进": "trend-new", "持平": "trend-flat" }[trend] || "trend-flat";
|
||
}
|
||
|
||
function changeClass(value) {
|
||
return number(value) > 0 ? "up" : number(value) < 0 ? "down" : "";
|
||
}
|
||
|
||
function sentimentLabel(score) {
|
||
const value = number(score);
|
||
if (value >= 80) return "情绪高涨";
|
||
if (value >= 60) return "情绪偏强";
|
||
if (value >= 40) return "情绪中性";
|
||
if (value >= 20) return "情绪偏弱";
|
||
return "情绪冰点";
|
||
}
|
||
|
||
function streakLabel(streak) {
|
||
const value = Math.max(1, number(streak));
|
||
return value === 1 ? "首板" : `${value}板`;
|
||
}
|
||
|
||
function signed(value) {
|
||
const parsed = number(value);
|
||
return `${parsed > 0 ? "+" : ""}${formatNumber(parsed, 2)}`;
|
||
}
|
||
|
||
function dashboardDataTimestamp(meta = {}) {
|
||
const tradeDate = displayCompactDate(meta.trade_date);
|
||
if (tradeDate === "--") return "--";
|
||
const intraday = tradeDate === todayString() && Boolean(meta.realtime) && !["closed", "after_hours"].includes(String(meta.market_status || ""));
|
||
if (intraday) {
|
||
const updated = new Date(meta.updated_at);
|
||
if (!Number.isNaN(updated.getTime())) {
|
||
const dateText = `${updated.getFullYear()}-${String(updated.getMonth() + 1).padStart(2, "0")}-${String(updated.getDate()).padStart(2, "0")}`;
|
||
const timeText = updated.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false });
|
||
return `${dateText} ${timeText}`;
|
||
}
|
||
}
|
||
return `${tradeDate} 15:00`;
|
||
}
|
||
|
||
function formatMoneyMillion(value) {
|
||
const parsed = number(value);
|
||
const sign = parsed > 0 ? "+" : "";
|
||
if (Math.abs(parsed) >= 100) return `${sign}${formatNumber(parsed / 100, 2)} 亿`;
|
||
return `${sign}${formatNumber(parsed * 100, 0)} 万`;
|
||
}
|
||
|
||
async function apiRequest(url, method = "GET", body = null, requestOptions = {}) {
|
||
return window.XiaobaiAPI.request(url, method, body, requestOptions);
|
||
}
|
||
|
||
function setLoading(loading, text = "正在加载复盘数据", context = "default") {
|
||
elements.loading.hidden = !loading;
|
||
elements.loading.dataset.context = loading ? context : "default";
|
||
setText("loadingTitle", text);
|
||
setText(
|
||
"loadingHint",
|
||
context === "screener"
|
||
? "正在完成因子筛选、候选排序与历史样本回测,这通常需要一点时间"
|
||
: "请稍候",
|
||
);
|
||
}
|
||
|
||
function setStatus(text) {
|
||
applicationShell.setStatus(text);
|
||
}
|
||
|
||
let toastTimer;
|
||
function showToast(message) {
|
||
clearTimeout(toastTimer);
|
||
elements.toast.textContent = message;
|
||
elements.toast.hidden = false;
|
||
toastTimer = setTimeout(() => { elements.toast.hidden = true; }, 3600);
|
||
}
|
||
|
||
function setText(id, value) {
|
||
const element = document.getElementById(id);
|
||
if (element) element.textContent = value;
|
||
}
|
||
|
||
function motionEnabled() {
|
||
return !window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||
}
|
||
|
||
function refreshIcons() {
|
||
if (!window.lucide?.createIcons) return;
|
||
window.lucide.createIcons({ attrs: { "aria-hidden": "true" } });
|
||
}
|
||
|
||
function toggleHeaderCommandMenu(force) {
|
||
applicationShell.toggleHeaderCommandMenu(force);
|
||
}
|
||
|
||
function toggleAccountDropdown(force, returnFocus = false) {
|
||
const menu = document.querySelector("#accountDropdown");
|
||
const button = document.querySelector("#accountButton");
|
||
if (!menu || !button) return;
|
||
const open = typeof force === "boolean" ? force : menu.hidden;
|
||
menu.hidden = !open;
|
||
button.setAttribute("aria-expanded", String(open));
|
||
document.querySelector(".account-menu-shell")?.classList.toggle("is-open", open);
|
||
if (open) {
|
||
setText("accountMenuName", state.user?.username || "当前账号");
|
||
const membership = state.user?.membership || {};
|
||
setText("accountMenuRole", state.user?.role === "admin" ? (membership.subscribed ? "管理员 · 会员" : "管理员") : membership.subscribed ? "会员用户" : "普通用户");
|
||
} else if (returnFocus) {
|
||
button.focus();
|
||
}
|
||
}
|
||
|
||
function handleAccountMenuKeydown(event) {
|
||
const menu = document.querySelector("#accountDropdown");
|
||
if (!menu) return;
|
||
if (menu.hidden) {
|
||
if (document.activeElement?.id === "accountButton" && event.key === "ArrowDown") {
|
||
event.preventDefault();
|
||
toggleAccountDropdown(true);
|
||
menu.querySelector('[role="menuitem"]')?.focus();
|
||
}
|
||
return;
|
||
}
|
||
const items = [...menu.querySelectorAll('[role="menuitem"]:not(:disabled)')];
|
||
if (!items.length) return;
|
||
const current = items.indexOf(document.activeElement);
|
||
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
||
event.preventDefault();
|
||
const offset = event.key === "ArrowDown" ? 1 : -1;
|
||
items[(current + offset + items.length) % items.length].focus();
|
||
} else if (event.key === "Home" || event.key === "End") {
|
||
event.preventDefault();
|
||
items[event.key === "Home" ? 0 : items.length - 1].focus();
|
||
}
|
||
}
|
||
|
||
function updateSentimentGauge(rawScore) {
|
||
const gauge = document.querySelector("#sentimentGauge");
|
||
if (!gauge) return;
|
||
const score = clamp(rawScore, 0, 100);
|
||
const previous = Number(gauge.dataset.score);
|
||
gauge.dataset.score = String(score);
|
||
gauge.style.setProperty("--score", score);
|
||
if (!motionEnabled() || !Number.isFinite(previous) || Math.abs(previous - score) < 15) return;
|
||
gauge.classList.remove("sentiment-pulse");
|
||
void gauge.offsetWidth;
|
||
gauge.classList.add("sentiment-pulse");
|
||
gauge.addEventListener("animationend", () => gauge.classList.remove("sentiment-pulse"), { once: true });
|
||
}
|
||
|
||
function animateMetric(id, rawValue, formatter = (value) => value) {
|
||
const element = document.getElementById(id);
|
||
const target = Number(rawValue);
|
||
if (!element || !Number.isFinite(target)) {
|
||
setText(id, formatter(rawValue));
|
||
return;
|
||
}
|
||
const storedValue = Number(element.dataset.metricValue);
|
||
const previous = Number.isFinite(storedValue) ? storedValue : 0;
|
||
element.dataset.metricValue = String(target);
|
||
const existingFrame = metricAnimationFrames.get(element);
|
||
if (existingFrame) cancelAnimationFrame(existingFrame);
|
||
if (!motionEnabled() || previous === target) {
|
||
element.textContent = formatter(target);
|
||
return;
|
||
}
|
||
element.classList.remove("metric-changed");
|
||
void element.offsetWidth;
|
||
element.classList.add("metric-changed");
|
||
const startedAt = performance.now();
|
||
const duration = 560;
|
||
const update = (now) => {
|
||
const progress = Math.min(1, (now - startedAt) / duration);
|
||
const eased = 1 - (1 - progress) ** 3;
|
||
element.textContent = formatter(previous + (target - previous) * eased);
|
||
if (progress < 1) {
|
||
metricAnimationFrames.set(element, requestAnimationFrame(update));
|
||
} else {
|
||
element.textContent = formatter(target);
|
||
metricAnimationFrames.delete(element);
|
||
setTimeout(() => element.classList.remove("metric-changed"), 80);
|
||
}
|
||
};
|
||
metricAnimationFrames.set(element, requestAnimationFrame(update));
|
||
}
|
||
|
||
function animateRows(container) {
|
||
if (!container) return;
|
||
const rows = [...container.children].filter((item) => item.matches("tr, [data-code]"));
|
||
if (!motionEnabled()) {
|
||
rows.forEach((row) => row.classList.remove("row-pending", "row-enter"));
|
||
return;
|
||
}
|
||
const unseenRows = rows.filter((row) => row.dataset.motionSeen !== "1");
|
||
unseenRows.slice(0, 12).forEach((row, index) => {
|
||
row.dataset.motionSeen = "1";
|
||
row.classList.remove("row-pending", "row-enter");
|
||
row.style.setProperty("--row-delay", `${index * 24}ms`);
|
||
requestAnimationFrame(() => row.classList.add("row-enter"));
|
||
row.addEventListener("animationend", () => row.classList.remove("row-enter"), { once: true });
|
||
});
|
||
if (!("IntersectionObserver" in window)) {
|
||
unseenRows.slice(12).forEach((row) => { row.dataset.motionSeen = "1"; });
|
||
return;
|
||
}
|
||
if (!rowAnimationObserver) {
|
||
rowAnimationObserver = new IntersectionObserver((entries) => {
|
||
entries.forEach((entry) => {
|
||
if (!entry.isIntersecting) return;
|
||
const row = entry.target;
|
||
rowAnimationObserver.unobserve(row);
|
||
row.dataset.motionSeen = "1";
|
||
row.classList.remove("row-pending");
|
||
row.style.setProperty("--row-delay", "0ms");
|
||
requestAnimationFrame(() => row.classList.add("row-enter"));
|
||
row.addEventListener("animationend", () => row.classList.remove("row-enter"), { once: true });
|
||
});
|
||
}, { threshold: 0.08, rootMargin: "0px 0px 40px 0px" });
|
||
}
|
||
unseenRows.slice(12).forEach((row) => {
|
||
row.classList.add("row-pending");
|
||
rowAnimationObserver.observe(row);
|
||
});
|
||
}
|
||
|
||
function waitForMotion(duration) {
|
||
return new Promise((resolve) => setTimeout(resolve, motionEnabled() ? duration : 0));
|
||
}
|