Files
xiaobai-review/frontend/shared/dashboard.js
T
5085cacf0d fix(HEL-412): 刷新降级不再整次失败,并补齐准备中提示
手动刷新与自动补跑共用可用数据判定:日线推算或上一交易日快照记为部分/准备中成功,避免前端误报刷新失败。HTTP JSON 解析错误不再把请求正文写入日志。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-09-02 18:08:40 +08:00

317 lines
14 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);
const freshness = dashboardFreshnessMessage(meta);
if (freshness || actualCompact !== requestedCompact || meta.carried_forward || meta.limit_data_source === "derived") {
setAdminRefreshStatus("warning", freshness || `部分正式数据尚未到齐,当前展示 ${actualDate || "最近可用数据"}`, "triangle-alert");
setStatus(freshness || "部分正式数据尚未到齐,当前展示最近可用数据");
return;
}
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("刷新等待超时,请稍后重试");
}
let dashboardCatchupTimer = 0;
function chineseMonthDay(value) {
const compact = String(value || "").replaceAll("-", "").replaceAll("/", "");
if (!/^\d{8}/.test(compact)) return "";
return `${Number(compact.slice(4, 6))}${Number(compact.slice(6, 8))} 日`;
}
function dashboardFreshnessMessage(meta = {}) {
if (meta.display_notice) return String(meta.display_notice);
const requested = String(meta.requested_date || "").replaceAll("-", "");
const actual = String(meta.trade_date || "").replaceAll("-", "");
const shown = chineseMonthDay(actual);
if (meta.data_status === "preparing" || (meta.carried_forward && actual && requested && actual !== requested)) {
return shown ? `今日数据正在准备,当前展示 ${shown}` : "今日数据正在准备,当前展示最近可用数据";
}
if (meta.data_status === "partial" || meta.limit_data_source === "derived") {
return meta.notice || "部分正式数据尚未到齐,当前展示日线推算结果";
}
return "";
}
function scheduleDashboardCatchup(meta = {}) {
window.clearTimeout(dashboardCatchupTimer);
const status = String(meta.data_status || "");
if (status !== "preparing" && status !== "partial") return;
dashboardCatchupTimer = window.setTimeout(() => {
loadDashboard(false, true, false);
}, 60000);
}
function applyDashboard(payload, background = false) {
state.dashboard = payload;
const selectedDate = payload.meta.requested_date || payload.meta.trade_date;
elements.tradeDate.value = selectedDate;
document.querySelector("#qiObservationDate").value = selectedDate;
document.querySelector("#journalDate").value = selectedDate;
renderDashboard();
const freshness = dashboardFreshnessMessage(payload.meta || {});
setStatus(freshness || `${dashboardSourceLabel(payload.meta)} · 数据已更新`);
const updatedAt = document.querySelector("#updatedAt");
if (updatedAt) updatedAt.dataset.tone = freshness ? "warning" : "ok";
scheduleDashboardCatchup(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);
const freshness = dashboardFreshnessMessage(meta);
setText("updatedAt", freshness
? freshness
: `${dashboardSourceLabel(meta)} · 更新 ${formatTimestamp(meta.updated_at)}`);
const updatedAt = document.querySelector("#updatedAt");
if (updatedAt) updatedAt.dataset.tone = freshness ? "warning" : "ok";
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));
}