主站行情管理并入数据中枢
- 数据中枢数据源配置页新增「主站行情任务」卡:交易时段后台刷新开关、
手动后台刷新、历史区间回补(任务提交 + 轮询主站 job 记录亮灯),
数据与调度仍归主站,控制台只经桥接代操作
- 新增桥接端点 /api/hub-admin/market/{refresh,backfill};回补改为
market.backfill 后台任务(jobs.config.json 注册,锁独立,超时 30 分钟),
桥接调用秒回,不再阻塞
- 主站桌面端删除顶栏「行情管理」按钮与整个行情管理对话框,清理随之
失效的 CSS;移动端删除 system/admin 页与入口,管理员专区只留数据中枢
- Tushare Token / iFinD 凭证编辑沿用数据源页既有凭证区,无功能缺失
模型池收起修复
- 拉出模型清单后按钮切换为「收起列表」,收起只留一行摘要;再次点击
重新拉取并展开;勾选添加完成后清单自动收起(原逻辑保留)
- 交互全部沿用 HEL-558 已确认样式的既有按钮与提示组件,未新增视觉
自测
- verify_baseline 通过;pytest 492 项通过;数据中枢 240 项通过
- verify_datahub_console 新增 [11b] 行情任务桥接端到端段;UI 自测新增
行情任务卡开关往返、模型清单展开/收起/再展开/添加自动收起,日夜主题
与 1030 窄屏复验通过
- Playwright e2e 102/103:唯一失败项在基线提交上同样失败(本机字体度量
导致的头部溢出,与本卡无关)
Co-authored-by: multica-agent <github@multica.ai>
305 lines
13 KiB
JavaScript
305 lines
13 KiB
JavaScript
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")].filter(Boolean);
|
|
buttons.forEach((button) => { button.disabled = true; });
|
|
const requestedDate = elements.tradeDate.value;
|
|
try {
|
|
const payload = await apiRequest("/api/admin/refresh", "POST", { trade_date: requestedDate });
|
|
if (!payload.started || !payload.job_key) {
|
|
showToast(payload.message || "已有后台刷新任务正在运行");
|
|
return;
|
|
}
|
|
setStatus(`正在刷新 ${requestedDate} 的行情`);
|
|
const job = await waitForAdminRefresh(payload.job_key);
|
|
if (job.status === "failed") {
|
|
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 (meta.realtime && actualCompact === requestedCompact && !meta.carried_forward) {
|
|
showToast(`刷新成功:已获取 ${actualDate} 的盘中行情`);
|
|
return;
|
|
}
|
|
if (freshness || actualCompact !== requestedCompact || meta.carried_forward || meta.limit_data_source === "derived") {
|
|
setStatus(freshness || "部分正式数据尚未到齐,当前展示最近可用数据");
|
|
return;
|
|
}
|
|
if (meta.notice) {
|
|
showToast(`已刷新到 ${actualDate},请留意数据源提示`);
|
|
} else {
|
|
showToast(`刷新成功:已获取 ${actualDate} 的最新行情,更新时间 ${updated}`);
|
|
}
|
|
} catch (error) {
|
|
const message = error.message || "后台刷新失败";
|
|
setStatus("后台刷新失败");
|
|
showToast(message);
|
|
} finally {
|
|
buttons.forEach((button) => { button.disabled = false; });
|
|
}
|
|
}
|
|
|
|
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));
|
|
}
|