Files
xiaobai-review/frontend/shared/dashboard.js
T
2026-08-28 09:29:06 +00:00

275 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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; });
const requestedDate = elements.tradeDate.value;
setAdminRefreshStatus("running", `正在刷新 ${requestedDate} 的行情,请稍候…`, "loader-circle");
try {
const payload = await apiRequest("/api/admin/refresh", "POST", { trade_date: requestedDate });
if (!payload.started || !payload.job_key) {
setAdminRefreshStatus("warning", "已有刷新任务正在运行,请稍后再试。", "clock-3");
showToast(payload.message || "已有后台刷新任务正在运行");
return;
}
setStatus(`正在刷新 ${requestedDate} 的行情`);
const job = await waitForAdminRefresh(payload.job_key);
if (job.status === "failed") {
const reason = job.message || job.error_code || "数据源未返回结果";
setAdminRefreshStatus("failure", `刷新失败:${reason}`, "circle-x");
setStatus("后台刷新失败");
showToast("后台刷新失败");
return;
}
const query = new URLSearchParams({ trade_date: requestedDate });
const dashboard = await apiRequest(`/api/dashboard?${query}`);
applyDashboard(dashboard);
const meta = dashboard.meta || {};
const actualDate = String(meta.trade_date || "").slice(0, 10);
const requestedCompact = requestedDate.replaceAll("-", "");
const actualCompact = actualDate.replaceAll("-", "");
const updated = formatTimestamp(meta.updated_at);
if (actualCompact !== requestedCompact || meta.carried_forward) {
const reason = meta.notice ? `${meta.notice}` : "";
setAdminRefreshStatus("warning", `刷新已完成,但没有获取到 ${requestedDate} 的最新行情;当前仍是 ${actualDate || "未知日期"}${reason}`, "triangle-alert");
showToast("刷新完成,但未获取到所选日期的最新行情");
} else if (meta.notice) {
setAdminRefreshStatus("warning", `已刷新到 ${actualDate}${updated}),但数据源提示:${meta.notice}`, "triangle-alert");
showToast(`已刷新到 ${actualDate},请留意数据源提示`);
} else {
setAdminRefreshStatus("success", `刷新成功:已获取 ${actualDate} 的最新行情,更新时间 ${updated}`, "circle-check");
showToast(`刷新成功:已获取 ${actualDate} 的最新行情`);
}
} catch (error) {
const message = error.message || "后台刷新失败";
setAdminRefreshStatus("failure", `刷新失败:${message}`, "circle-x");
setStatus("后台刷新失败");
showToast(message);
} finally {
buttons.forEach((button) => { button.disabled = false; });
}
}
function setAdminRefreshStatus(tone, message, icon = "circle-dot") {
const status = document.querySelector("#adminRefreshStatus");
if (!status) return;
status.dataset.tone = tone;
status.innerHTML = `<i data-lucide="${icon}"></i><span>${escapeHtml(message)}</span>`;
refreshIcons();
}
async function waitForAdminRefresh(jobKey) {
for (let attempt = 0; attempt < 120; attempt += 1) {
const payload = await apiRequest("/api/admin/settings");
const job = (payload.data?.jobs || []).find((item) => item.idempotency_key === jobKey);
if (job && ["success", "failed"].includes(job.status)) return job;
await new Promise((resolve) => setTimeout(resolve, 1000));
}
throw new Error("刷新等待超时,请稍后重试");
}
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));
animateMetric("tapeLimit", overview.limit_up_count, (value) => `${Math.round(value)} 家`);
animateMetric("tapeLimitDown", overview.limit_down_count, (value) => `${Math.round(value)} 家`);
animateMetric("detailBroken", overview.broken_count, (value) => `${Math.round(value)} 家`);
animateMetric("detailSealRate", overview.seal_rate, (value) => `${formatNumber(value, 1)}%`);
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));
animateMetric("detailSentimentScore", overview.sentiment_score, (value) => Math.round(value));
const moodLabel = sentimentLabel(overview.sentiment_score);
setText("sentimentText", moodLabel);
setText("detailSentimentText", moodLabel);
document.querySelectorAll("#sentimentText, #detailSentimentText").forEach((sentimentChip) => {
sentimentChip.classList.toggle("is-hot", moodLabel === "情绪高涨");
sentimentChip.classList.toggle("is-strong", moodLabel === "情绪偏强");
sentimentChip.classList.toggle("is-cold", moodLabel === "情绪冰点" || moodLabel === "情绪偏弱");
});
const pageSubtitle = document.querySelector("#currentPageSubtitle");
const activePage = window.XiaobaiPages?.get(state.activeView);
if (pageSubtitle) {
const dateText = displayCompactDate(meta.trade_date);
if (activePage?.id === "mentorView") {
pageSubtitle.textContent = dateText === "--"
? "与不同交易思维模型持续对话 · 数据日期 --"
: `与不同交易思维模型持续对话 · 数据日期 ${dateText}`;
} else {
const groupLabel = activePage?.group === "market"
? "市场复盘"
: activePage?.group === "personal" ? "个人" : "智能工具";
pageSubtitle.textContent = dateText === "--" ? groupLabel : `${groupLabel} · ${dateText}`;
}
}
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();
}
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 sentimentLabel(score) {
const value = number(score);
if (value >= 80) return "情绪高涨";
if (value >= 60) return "情绪偏强";
if (value >= 40) return "情绪中性";
if (value >= 20) return "情绪偏弱";
return "情绪冰点";
}
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 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 bindDashboardEvents() {
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));
}