diff --git a/lib/common/static/instance_embed.js b/lib/common/static/instance_embed.js index 1f38644..85301fd 100644 --- a/lib/common/static/instance_embed.js +++ b/lib/common/static/instance_embed.js @@ -60,45 +60,52 @@ else history.pushState({ embedTab: tab }, "", url); } - function runPageInit(tab) { + function runPageInit(tab, opts) { + const options = opts || {}; + const revisit = !!options.revisit; document.body.setAttribute("data-page", tab); if (typeof global.attachListWindowToExports === "function") { global.attachListWindowToExports(); } if (tab === "trade") { - if (typeof global.refreshOrderDefaults === "function") global.refreshOrderDefaults(); - if (typeof global.initOrderEntryModelSelect === "function") { + if (!revisit && typeof global.refreshOrderDefaults === "function") global.refreshOrderDefaults(); + if (!revisit && typeof global.initOrderEntryModelSelect === "function") { const root = pageRoot() || document; global.initOrderEntryModelSelect(root); } - if (global.ManualOrderRrPreview && typeof global.ManualOrderRrPreview.wire === "function") { + if (!revisit && global.ManualOrderRrPreview && typeof global.ManualOrderRrPreview.wire === "function") { global.ManualOrderRrPreview.wire(); } } - if (tab === "key_monitor" && global.KeyMonitorForm && typeof global.KeyMonitorForm.init === "function") { + if (!revisit && tab === "key_monitor" && global.KeyMonitorForm && typeof global.KeyMonitorForm.init === "function") { global.KeyMonitorForm.init(); } - if (tab === "strategy" && typeof global.initStrategyRollForm === "function") { + if (!revisit && tab === "strategy" && typeof global.initStrategyRollForm === "function") { global.initStrategyRollForm(); } - if (tab === "records") { + if (!revisit && tab === "records") { if (typeof global.loadJournals === "function") global.loadJournals(); if (typeof global.loadReviews === "function") global.loadReviews(); if (typeof global.toggleReviewMode === "function") global.toggleReviewMode(); } - if (tab === "stats") { + if (!revisit && tab === "stats") { if (typeof global.initStatsSegmentFromUrl === "function") global.initStatsSegmentFromUrl(); } - if (typeof global.refreshPriceSnapshotConditional === "function") { - global.refreshPriceSnapshotConditional(); + if (revisit && tab === "options" && global.OptionsPanelLive && typeof global.OptionsPanelLive.refreshSoft === "function") { + global.OptionsPanelLive.refreshSoft(); } - if (global.SymbolLivePrice && typeof global.SymbolLivePrice.init === "function") { - const root = pageRoot() || document; - global.SymbolLivePrice.init(root); - } - if (global.JournalUploadSlots && typeof global.JournalUploadSlots.init === "function") { - const root = pageRoot() || document; - global.JournalUploadSlots.init(root); + if (!revisit) { + if (typeof global.refreshPriceSnapshotConditional === "function") { + global.refreshPriceSnapshotConditional(); + } + if (global.SymbolLivePrice && typeof global.SymbolLivePrice.init === "function") { + const root = pageRoot() || document; + global.SymbolLivePrice.init(root); + } + if (global.JournalUploadSlots && typeof global.JournalUploadSlots.init === "function") { + const root = pageRoot() || document; + global.JournalUploadSlots.init(root); + } } } @@ -192,7 +199,7 @@ showPane(tab); setNavActive(tab); if (!options.skipUrl) syncUrl(tab, !!options.replace); - runPageInit(tab); + runPageInit(tab, { revisit: !!options.revisit }); } async function loadTab(tab, opts) { @@ -200,7 +207,7 @@ if (!tab) return; if (tabPanes.has(tab) && !options.force) { - activateTab(tab, options); + activateTab(tab, Object.assign({}, options, { revisit: true })); return; } diff --git a/lib/common/static/instance_live.js b/lib/common/static/instance_live.js new file mode 100644 index 0000000..5be5f86 --- /dev/null +++ b/lib/common/static/instance_live.js @@ -0,0 +1,94 @@ +/** + * embed 壳:SSE 收到后台 tick 后拉 JSON 快照更新 DOM,切换 tab 不再重复请求 HTML。 + */ +(function (global) { + let liveEventSource = null; + let liveReconnectTimer = null; + let localLiveVersion = -1; + let sseConnected = false; + + function isEmbedShell() { + return document.body && document.body.getAttribute("data-embed-shell") === "1"; + } + + function currentTab() { + if (global.InstanceEmbed && typeof global.InstanceEmbed.getTab === "function") { + return global.InstanceEmbed.getTab(); + } + return document.body.getAttribute("data-page") || "trade"; + } + + function refreshTabData(tab, opts) { + const options = opts || {}; + if (typeof global.refreshAccountSnapshot === "function") { + global.refreshAccountSnapshot(); + } + if (typeof global.refreshPriceSnapshotConditional === "function") { + global.refreshPriceSnapshotConditional(); + } + if (tab === "options" && global.OptionsPanelLive && typeof global.OptionsPanelLive.refreshSoft === "function") { + global.OptionsPanelLive.refreshSoft(options); + } + } + + function onLiveEvent(data) { + const ver = Number(data && data.live_version) || 0; + if (!ver || ver === localLiveVersion) return; + localLiveVersion = ver; + refreshTabData(currentTab(), { silent: true }); + } + + function closeLiveStream() { + if (liveEventSource) { + liveEventSource.close(); + liveEventSource = null; + } + if (liveReconnectTimer) { + clearTimeout(liveReconnectTimer); + liveReconnectTimer = null; + } + sseConnected = false; + } + + function connectLiveStream() { + if (!isEmbedShell()) return; + closeLiveStream(); + liveEventSource = new EventSource("/api/instance/live/stream"); + liveEventSource.addEventListener("live", function (ev) { + try { + onLiveEvent(JSON.parse(ev.data || "{}")); + } catch (_) {} + }); + liveEventSource.onopen = function () { + sseConnected = true; + }; + liveEventSource.onerror = function () { + sseConnected = false; + closeLiveStream(); + liveReconnectTimer = setTimeout(function () { + connectLiveStream(); + refreshTabData(currentTab(), { silent: true }); + }, 8000); + }; + } + + function startLive() { + if (!isEmbedShell()) return; + refreshTabData(currentTab()); + connectLiveStream(); + } + + global.InstanceLive = { + start: startLive, + refreshTabData: refreshTabData, + isConnected: function () { + return sseConnected; + }, + }; + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", startLive); + } else { + startLive(); + } +})(typeof window !== "undefined" ? window : globalThis); diff --git a/lib/common/static/options_panel.js b/lib/common/static/options_panel.js index f99644a..0eb1995 100644 --- a/lib/common/static/options_panel.js +++ b/lib/common/static/options_panel.js @@ -473,9 +473,6 @@ renderExpiries(); renderStrikes(); refreshAllPositions(); - requestAnimationFrame(function () { - loadChain(); - }); return; } requestAnimationFrame(function () { @@ -523,4 +520,11 @@ }); bootOptionsPanel(); + + window.OptionsPanelLive = { + refreshSoft: function () { + refreshAllPositions(); + }, + refreshChain: loadChain, + }; })(); diff --git a/lib/hub/hub_bridge.py b/lib/hub/hub_bridge.py index 0091529..6baa68f 100644 --- a/lib/hub/hub_bridge.py +++ b/lib/hub/hub_bridge.py @@ -68,6 +68,7 @@ def install_instance_theme_static(app) -> None: "strategy_roll.js": "application/javascript; charset=utf-8", "instance_page.css": "text/css; charset=utf-8", "instance_embed.js": "application/javascript; charset=utf-8", + "instance_live.js": "application/javascript; charset=utf-8", "order_entry_model.js": "application/javascript; charset=utf-8", "focus_chart_page.js": "application/javascript; charset=utf-8", "focus_chart_page.css": "text/css; charset=utf-8", diff --git a/lib/instance/instance_embed_lib.py b/lib/instance/instance_embed_lib.py index 58d7042..f3e1c19 100644 --- a/lib/instance/instance_embed_lib.py +++ b/lib/instance/instance_embed_lib.py @@ -144,7 +144,10 @@ def register_embed_routes( login_required: Callable, render_main_page_fn: Callable, ) -> None: + from lib.instance.instance_live_push_lib import register_instance_live_routes + app.config["RENDER_MAIN_PAGE_FN"] = render_main_page_fn + register_instance_live_routes(app, login_required) @login_required @app.route("/embed") diff --git a/lib/instance/instance_live_push_lib.py b/lib/instance/instance_live_push_lib.py new file mode 100644 index 0000000..0da8a9b --- /dev/null +++ b/lib/instance/instance_live_push_lib.py @@ -0,0 +1,117 @@ +"""实例 embed 壳:后台定时 tick + SSE 通知前端拉 JSON 快照(对齐中控 dashboard)。""" +from __future__ import annotations + +import json +import os +import queue +import threading +from collections.abc import Iterator +from typing import Any, Callable + +from flask import Flask, Response, stream_with_context + +INSTANCE_LIVE_TICK_SEC = float(os.getenv("INSTANCE_LIVE_TICK_SEC", "5")) +INSTANCE_SSE_HEARTBEAT_SEC = float(os.getenv("INSTANCE_SSE_HEARTBEAT_SEC", "25")) + + +class InstanceLivePush: + def __init__(self) -> None: + self._lock = threading.Lock() + self.version = 0 + self._subscribers: list[queue.Queue[str | None]] = [] + self._stop = threading.Event() + self._thread: threading.Thread | None = None + + def start(self) -> None: + if self._thread and self._thread.is_alive(): + return + self._stop.clear() + self._thread = threading.Thread(target=self._loop, daemon=True, name="instance-live-push") + self._thread.start() + + def stop(self) -> None: + self._stop.set() + self._broadcast(close=True) + + def tick(self, reason: str = "poll") -> int: + with self._lock: + self.version += 1 + ver = self.version + payload = json.dumps({"live_version": ver, "reason": reason}, ensure_ascii=False) + self._broadcast(payload) + return ver + + def event_dict(self) -> dict[str, Any]: + return {"live_version": self.version, "tick_sec": INSTANCE_LIVE_TICK_SEC} + + def _loop(self) -> None: + while not self._stop.is_set(): + self.tick("poll") + if self._stop.wait(INSTANCE_LIVE_TICK_SEC): + break + + def _broadcast(self, event: str | None = None, *, close: bool = False) -> None: + with self._lock: + subs = list(self._subscribers) + dead: list[queue.Queue[str | None]] = [] + for q in subs: + try: + q.put_nowait(None if close else event) + except Exception: + dead.append(q) + if dead: + with self._lock: + for q in dead: + if q in self._subscribers: + self._subscribers.remove(q) + + def _subscribe(self) -> queue.Queue[str | None]: + q: queue.Queue[str | None] = queue.Queue(maxsize=16) + with self._lock: + self._subscribers.append(q) + return q + + def _unsubscribe(self, q: queue.Queue[str | None]) -> None: + with self._lock: + if q in self._subscribers: + self._subscribers.remove(q) + + def iter_sse(self) -> Iterator[str]: + q = self._subscribe() + try: + yield self._format_event(self.event_dict() | {"reason": "connect"}) + while True: + try: + raw = q.get(timeout=INSTANCE_SSE_HEARTBEAT_SEC) + except queue.Empty: + yield ": heartbeat\n\n" + continue + if raw is None: + break + yield f"event: live\ndata: {raw}\n\n" + finally: + self._unsubscribe(q) + + @staticmethod + def _format_event(data: dict[str, Any]) -> str: + return "event: live\ndata: " + json.dumps(data, ensure_ascii=False) + "\n\n" + + +instance_live_push = InstanceLivePush() + + +def register_instance_live_routes(app: Flask, login_required: Callable) -> None: + instance_live_push.start() + + @login_required + @app.route("/api/instance/live/stream") + def api_instance_live_stream(): + return Response( + stream_with_context(instance_live_push.iter_sse()), + mimetype="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) diff --git a/lib/instance/templates/embed_boot_scripts.html b/lib/instance/templates/embed_boot_scripts.html index 21dd3aa..a340676 100644 --- a/lib/instance/templates/embed_boot_scripts.html +++ b/lib/instance/templates/embed_boot_scripts.html @@ -1268,7 +1268,6 @@ if(addOrderForm){ refreshOrderDefaults(); if(typeof initOrderEntryModelSelect === "function") initOrderEntryModelSelect(); refreshPriceSnapshotConditional(); -setInterval(refreshAccountSnapshot, Number(document.body.dataset.balanceRefreshMs || 30000)); function refreshPriceSnapshotConditional(){ const page = document.body.getAttribute("data-page") || ""; fetch("/api/price_snapshot").then(r=>r.json()).then(data=>{ @@ -1348,5 +1347,22 @@ function tickOrderHoldDurations(){ } setInterval(tickOrderHoldDurations, 1000); tickOrderHoldDurations(); -setInterval(refreshPriceSnapshotConditional, Number(document.body.dataset.priceRefreshMs || 5000)); +(function startInstanceDataRefresh(){ + const embedShell = document.body && document.body.getAttribute("data-embed-shell") === "1"; + const balanceMs = Number(document.body.dataset.balanceRefreshMs || 30000); + const priceMs = Number(document.body.dataset.priceRefreshMs || 5000); + if(embedShell){ + setInterval(function(){ + if(window.InstanceLive && InstanceLive.isConnected && InstanceLive.isConnected()) return; + refreshAccountSnapshot(); + }, balanceMs); + setInterval(function(){ + if(window.InstanceLive && InstanceLive.isConnected && InstanceLive.isConnected()) return; + refreshPriceSnapshotConditional(); + }, priceMs); + return; + } + setInterval(refreshAccountSnapshot, balanceMs); + setInterval(refreshPriceSnapshotConditional, priceMs); +})(); diff --git a/lib/instance/templates/embed_shell.html b/lib/instance/templates/embed_shell.html index 114e0d8..932b6d9 100644 --- a/lib/instance/templates/embed_shell.html +++ b/lib/instance/templates/embed_shell.html @@ -90,6 +90,7 @@ const ORDER_ENTRY_MODEL_CODE_TO_CATEGORY = {{ entry_model_code_to_category | toj {% include 'embed_boot_scripts.html' %} - + + diff --git a/tests/test_instance_live_push_lib.py b/tests/test_instance_live_push_lib.py new file mode 100644 index 0000000..59666b4 --- /dev/null +++ b/tests/test_instance_live_push_lib.py @@ -0,0 +1,20 @@ +"""instance_live_push_lib 单元测试。""" +from __future__ import annotations + +import json + +from lib.instance.instance_live_push_lib import InstanceLivePush + + +def test_tick_increments_version_and_connect_event() -> None: + push = InstanceLivePush() + v1 = push.tick("test") + v2 = push.tick("test") + assert v1 == 1 + assert v2 == 2 + gen = push.iter_sse() + first = next(gen) + assert first.startswith("event: live") + data = json.loads(first.split("data: ", 1)[1].strip()) + assert data["live_version"] == 2 + push.stop()