diff --git a/backend/features/system/http.py b/backend/features/system/http.py index 531cceb..036bf39 100644 --- a/backend/features/system/http.py +++ b/backend/features/system/http.py @@ -26,13 +26,15 @@ class SystemHttpMixin: def start_background_refresh(self) -> None: try: body = self.read_json_body(allow_empty=True) - started = self.application_service.request_background_sync( + refresh = self.application_service.request_background_sync( str(body.get("trade_date") or date.today().isoformat()) ) + started = bool(refresh.get("started")) self.send_json( { "ok": True, "started": started, + "job_key": str(refresh.get("job_key") or ""), "message": "后台刷新已开始" if started else "已有后台刷新任务正在运行", }, HTTPStatus.ACCEPTED, diff --git a/backend/jobs/service.py b/backend/jobs/service.py index 35bc31e..216d22a 100644 --- a/backend/jobs/service.py +++ b/backend/jobs/service.py @@ -20,15 +20,16 @@ class JobServiceMixin: workers_stopped = self.jobs.wait_for_idle(timeout_seconds) return scheduler_stopped and workers_stopped - def request_background_sync(self, trade_date: str) -> bool: + def request_background_sync(self, trade_date: str) -> dict[str, object]: normalized = normalize_date(trade_date) key = f"manual:{normalized}:{time.time_ns()}" - return self.jobs.submit( + started = self.jobs.submit( "market.refresh", key, lambda: self.sync_dashboard(normalized), {"trade_date": normalized, "trigger": "administrator"}, ) + return {"started": started, "job_key": key if started else ""} def _background_refresh_tick(self) -> None: if not ( diff --git a/config/architecture-inventory.json b/config/architecture-inventory.json index 12368d2..d06e930 100644 --- a/config/architecture-inventory.json +++ b/config/architecture-inventory.json @@ -461,8 +461,8 @@ }, { "path": "frontend/index.html", - "bytes": 47891, - "lines": 661 + "bytes": 48077, + "lines": 662 }, { "path": "backend/features/screener/catalog.py", @@ -554,6 +554,11 @@ "bytes": 13176, "lines": 293 }, + { + "path": "frontend/shared/dashboard.js", + "bytes": 12894, + "lines": 274 + }, { "path": "backend/features/market/insights_auction_data.py", "bytes": 12829, @@ -574,11 +579,6 @@ "bytes": 10539, "lines": 244 }, - { - "path": "frontend/shared/dashboard.js", - "bytes": 9993, - "lines": 220 - }, { "path": "backend/data/providers/tushare_sectors.py", "bytes": 9876, @@ -799,16 +799,16 @@ "bytes": 1919, "lines": 45 }, + { + "path": "backend/jobs/service.py", + "bytes": 1833, + "lines": 50 + }, { "path": "backend/features/system/routes.py", "bytes": 1791, "lines": 46 }, - { - "path": "backend/jobs/service.py", - "bytes": 1746, - "lines": 49 - }, { "path": "backend/features/alerts/routes.py", "bytes": 1687, diff --git a/frontend/index.html b/frontend/index.html index 94f4181..4f0ca64 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -609,6 +609,7 @@

所有用户读取同一份后台快照,页面不会随后台任务自动重绘。

+
尚未手动刷新
diff --git a/frontend/shared/auth.css b/frontend/shared/auth.css index 424d982..22d516c 100644 --- a/frontend/shared/auth.css +++ b/frontend/shared/auth.css @@ -471,6 +471,32 @@ line-height: 1.6; } +.admin-refresh-status { + display: flex; + align-items: flex-start; + gap: 8px; + margin-top: 12px; + padding: 10px 12px; + border: 1px solid var(--line); + border-radius: 10px; + background: var(--surface-muted); + color: var(--text-muted); + font-size: 12px; + line-height: 1.55; +} + +.admin-refresh-status svg { + flex: 0 0 auto; + width: 15px; + height: 15px; + margin-top: 2px; +} + +.admin-refresh-status[data-tone="running"] { color: var(--primary); } +.admin-refresh-status[data-tone="success"] { color: var(--down); } +.admin-refresh-status[data-tone="warning"] { color: var(--warning); } +.admin-refresh-status[data-tone="failure"] { color: var(--up); } + .account-button > span { flex: 0 0 auto; diff --git a/frontend/shared/dashboard.js b/frontend/shared/dashboard.js index 41e9e57..54b612f 100644 --- a/frontend/shared/dashboard.js +++ b/frontend/shared/dashboard.js @@ -40,17 +40,71 @@ async function loadDashboard(force = false, background = false, showOverlay = tr 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: elements.tradeDate.value }); - showToast(payload.message || "后台刷新已提交"); - setStatus("后台刷新运行中,当前页面保持不变"); + 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) { - showToast(error.message || "后台刷新启动失败"); + 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 = `${escapeHtml(message)}`; + 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;