条件单列表改为中控后台并行缓存并以 SSE 推浏览器,下发仍走中控到实例。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -11,6 +11,8 @@ HUB_PULL_TIMEOUT_SEC=5
|
|||||||
HUB_SSE_HEARTBEAT_SEC=25
|
HUB_SSE_HEARTBEAT_SEC=25
|
||||||
# 数据统计后台刷新间隔(秒);不设则回落 HUB_PULL_INTERVAL_SEC
|
# 数据统计后台刷新间隔(秒);不设则回落 HUB_PULL_INTERVAL_SEC
|
||||||
HUB_STATS_POLL_INTERVAL_SEC=15
|
HUB_STATS_POLL_INTERVAL_SEC=15
|
||||||
|
# 条件单列表后台并行拉取间隔(秒)
|
||||||
|
HUB_COND_POLL_INTERVAL_SEC=10
|
||||||
|
|
||||||
# 中控企业微信(离线/故障告警,与客户端通知分开)
|
# 中控企业微信(离线/故障告警,与客户端通知分开)
|
||||||
HUB_WECHAT_ENABLED=false
|
HUB_WECHAT_ENABLED=false
|
||||||
|
|||||||
@@ -43,13 +43,13 @@ from lib.bridge_client_lib import (
|
|||||||
fetch_backup_file,
|
fetch_backup_file,
|
||||||
fetch_backup_list,
|
fetch_backup_list,
|
||||||
fetch_client_version,
|
fetch_client_version,
|
||||||
fetch_cond_orders,
|
|
||||||
post_client_update,
|
post_client_update,
|
||||||
post_close_full,
|
post_close_full,
|
||||||
post_cond_cancel,
|
post_cond_cancel,
|
||||||
post_cond_exit,
|
post_cond_exit,
|
||||||
pull_all_enabled,
|
pull_all_enabled,
|
||||||
)
|
)
|
||||||
|
from lib.cond_orders_cache_lib import cond_orders_store
|
||||||
from lib.cash_sample_lib import record_cash_samples_from_overview
|
from lib.cash_sample_lib import record_cash_samples_from_overview
|
||||||
from lib.db_lib import db_path as hub_db_path, init_db
|
from lib.db_lib import db_path as hub_db_path, init_db
|
||||||
from lib.env_lib import apply_env_updates, env_get
|
from lib.env_lib import apply_env_updates, env_get
|
||||||
@@ -560,8 +560,13 @@ def start_stats_cache() -> None:
|
|||||||
stats_store.start()
|
stats_store.start()
|
||||||
|
|
||||||
|
|
||||||
|
def start_cond_orders_cache() -> None:
|
||||||
|
cond_orders_store.start()
|
||||||
|
|
||||||
|
|
||||||
start_overview_cache()
|
start_overview_cache()
|
||||||
start_stats_cache()
|
start_stats_cache()
|
||||||
|
start_cond_orders_cache()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
start_hub_backup_scheduler(
|
start_hub_backup_scheduler(
|
||||||
@@ -690,49 +695,66 @@ def cond_orders_page():
|
|||||||
@app.route("/api/cond_orders/candidates")
|
@app.route("/api/cond_orders/candidates")
|
||||||
@login_required
|
@login_required
|
||||||
def api_cond_order_candidates():
|
def api_cond_order_candidates():
|
||||||
"""可选客户端:在线且无持仓、未被条件单锁定."""
|
"""可选客户端:读热缓存(总览快照推导),不现场拉实例."""
|
||||||
rows = pull_all_enabled()
|
force = (request.args.get("force") or "").strip().lower() in ("1", "true", "yes")
|
||||||
out = []
|
if force:
|
||||||
for row in rows:
|
cond_orders_store.request_refresh()
|
||||||
snap = row.get("snapshot") if isinstance(row.get("snapshot"), dict) else {}
|
overview_store.request_refresh()
|
||||||
brief = snap.get("positions_brief") or snap.get("positions") or []
|
snap = cond_orders_store.snapshot_dict()
|
||||||
has_pos = isinstance(brief, list) and len(brief) > 0
|
return jsonify(
|
||||||
locked = bool(snap.get("cond_order_locked"))
|
|
||||||
selectable = bool(row.get("online")) and not has_pos and not locked
|
|
||||||
inst = snap.get("instance") if isinstance(snap.get("instance"), dict) else {}
|
|
||||||
mode = str(inst.get("mode") or "").strip().lower()
|
|
||||||
if mode not in ("sim", "live"):
|
|
||||||
mode = ""
|
|
||||||
out.append(
|
|
||||||
{
|
{
|
||||||
"id": row.get("id"),
|
"ok": True,
|
||||||
"name": row.get("name"),
|
"instances": snap.get("candidates") or [],
|
||||||
"online": bool(row.get("online")),
|
"cond_version": snap.get("cond_version"),
|
||||||
"has_position": has_pos,
|
"updated_at": snap.get("updated_at"),
|
||||||
"cond_order_locked": locked,
|
"refreshing": snap.get("refreshing"),
|
||||||
"selectable": selectable,
|
"from_cache": True,
|
||||||
"mode": mode,
|
|
||||||
"error": row.get("error") or "",
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return jsonify({"ok": True, "instances": out})
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/cond_orders", methods=["GET"])
|
@app.route("/api/cond_orders", methods=["GET"])
|
||||||
@login_required
|
@login_required
|
||||||
def api_cond_orders_list():
|
def api_cond_orders_list():
|
||||||
instances = list_instances(enabled_only=True)
|
"""条件单列表只读热缓存;后台常驻并行从实例拉取."""
|
||||||
all_orders = []
|
force = (request.args.get("force") or "").strip().lower() in ("1", "true", "yes")
|
||||||
for inst in instances:
|
if force:
|
||||||
data = fetch_cond_orders(inst)
|
cond_orders_store.request_refresh()
|
||||||
for o in data.get("orders") or []:
|
snap = cond_orders_store.snapshot_dict()
|
||||||
if not isinstance(o, dict):
|
return jsonify(
|
||||||
continue
|
{
|
||||||
item = dict(o)
|
"ok": True,
|
||||||
item["instance_id"] = inst.get("id")
|
"orders": snap.get("orders") or [],
|
||||||
item["instance_name"] = inst.get("name")
|
"cond_version": snap.get("cond_version"),
|
||||||
all_orders.append(item)
|
"updated_at": snap.get("updated_at"),
|
||||||
return jsonify({"ok": True, "orders": all_orders})
|
"refreshing": snap.get("refreshing"),
|
||||||
|
"from_cache": True,
|
||||||
|
"poll_interval_sec": snap.get("poll_interval_sec"),
|
||||||
|
"error": snap.get("error"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/cond_orders/refresh", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def api_cond_orders_refresh():
|
||||||
|
cond_orders_store.request_refresh()
|
||||||
|
overview_store.request_refresh()
|
||||||
|
return jsonify({"ok": True, "msg": "已触发条件单后台刷新"})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/cond_orders/stream")
|
||||||
|
@login_required
|
||||||
|
def api_cond_orders_stream():
|
||||||
|
return Response(
|
||||||
|
stream_with_context(cond_orders_store.iter_sse()),
|
||||||
|
mimetype="text/event-stream",
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"Connection": "keep-alive",
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/cond_orders", methods=["POST"])
|
@app.route("/api/cond_orders", methods=["POST"])
|
||||||
@@ -749,8 +771,9 @@ def api_cond_orders_create():
|
|||||||
if not instance_ids:
|
if not instance_ids:
|
||||||
return jsonify({"ok": False, "msg": "请选择客户端"})
|
return jsonify({"ok": False, "msg": "请选择客户端"})
|
||||||
|
|
||||||
# 再次按快照过滤有仓/锁定
|
# 再次按热缓存快照过滤有仓/锁定(下发本身仍 POST 到实例)
|
||||||
rows = {r.get("id"): r for r in pull_all_enabled()}
|
ov = overview_store.snapshot_dict()
|
||||||
|
rows = {r.get("id"): r for r in (ov.get("instances") or [])}
|
||||||
allowed = []
|
allowed = []
|
||||||
rejected = []
|
rejected = []
|
||||||
for iid in instance_ids:
|
for iid in instance_ids:
|
||||||
@@ -792,6 +815,9 @@ def api_cond_orders_create():
|
|||||||
}
|
}
|
||||||
results = dispatch_cond_order_to_ids(allowed, payload)
|
results = dispatch_cond_order_to_ids(allowed, payload)
|
||||||
ok_n = sum(1 for r in results if r.get("ok"))
|
ok_n = sum(1 for r in results if r.get("ok"))
|
||||||
|
if ok_n > 0:
|
||||||
|
cond_orders_store.request_refresh()
|
||||||
|
overview_store.request_refresh()
|
||||||
return jsonify(
|
return jsonify(
|
||||||
{
|
{
|
||||||
"ok": ok_n > 0,
|
"ok": ok_n > 0,
|
||||||
@@ -813,7 +839,11 @@ def api_cond_orders_cancel(hub_order_id: str):
|
|||||||
inst = get_instance(int(iid))
|
inst = get_instance(int(iid))
|
||||||
if not inst:
|
if not inst:
|
||||||
return jsonify({"ok": False, "msg": "实例不存在"}), 404
|
return jsonify({"ok": False, "msg": "实例不存在"}), 404
|
||||||
return jsonify(post_cond_cancel(inst, hub_order_id))
|
result = post_cond_cancel(inst, hub_order_id)
|
||||||
|
if isinstance(result, dict) and result.get("ok"):
|
||||||
|
cond_orders_store.request_refresh()
|
||||||
|
overview_store.request_refresh()
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/cond_orders/<hub_order_id>/exit", methods=["POST"])
|
@app.route("/api/cond_orders/<hub_order_id>/exit", methods=["POST"])
|
||||||
@@ -831,7 +861,11 @@ def api_cond_orders_exit(hub_order_id: str):
|
|||||||
"profit_exit_enabled": data.get("profit_exit_enabled"),
|
"profit_exit_enabled": data.get("profit_exit_enabled"),
|
||||||
"profit_exit_mult": data.get("profit_exit_mult"),
|
"profit_exit_mult": data.get("profit_exit_mult"),
|
||||||
}
|
}
|
||||||
return jsonify(post_cond_exit(inst, hub_order_id, payload))
|
result = post_cond_exit(inst, hub_order_id, payload)
|
||||||
|
if isinstance(result, dict) and result.get("ok"):
|
||||||
|
cond_orders_store.request_refresh()
|
||||||
|
overview_store.request_refresh()
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
@app.route("/manifest.webmanifest")
|
@app.route("/manifest.webmanifest")
|
||||||
|
|||||||
@@ -215,6 +215,40 @@ def fetch_cond_orders(inst: dict[str, Any]) -> dict[str, Any]:
|
|||||||
return {"ok": False, "msg": str(e) or "拉取失败", "orders": [], "instance_id": inst.get("id")}
|
return {"ok": False, "msg": str(e) or "拉取失败", "orders": [], "instance_id": inst.get("id")}
|
||||||
|
|
||||||
|
|
||||||
|
def pull_all_cond_orders() -> list[dict[str, Any]]:
|
||||||
|
"""并行拉取各机条件单并展平为带 instance 字段的订单列表."""
|
||||||
|
instances = list_instances(enabled_only=True)
|
||||||
|
if not instances:
|
||||||
|
return []
|
||||||
|
by_id: dict[Any, dict[str, Any]] = {}
|
||||||
|
with ThreadPoolExecutor(max_workers=min(8, len(instances))) as pool:
|
||||||
|
futs = {pool.submit(fetch_cond_orders, inst): inst for inst in instances}
|
||||||
|
for fut in as_completed(futs):
|
||||||
|
inst = futs[fut]
|
||||||
|
try:
|
||||||
|
data = fut.result()
|
||||||
|
except Exception as e:
|
||||||
|
data = {
|
||||||
|
"ok": False,
|
||||||
|
"msg": str(e) or "拉取失败",
|
||||||
|
"orders": [],
|
||||||
|
"instance_id": inst.get("id"),
|
||||||
|
"instance_name": inst.get("name"),
|
||||||
|
}
|
||||||
|
by_id[inst.get("id")] = data if isinstance(data, dict) else {}
|
||||||
|
all_orders: list[dict[str, Any]] = []
|
||||||
|
for inst in instances:
|
||||||
|
data = by_id.get(inst["id"]) or {}
|
||||||
|
for o in data.get("orders") or []:
|
||||||
|
if not isinstance(o, dict):
|
||||||
|
continue
|
||||||
|
item = dict(o)
|
||||||
|
item["instance_id"] = inst.get("id")
|
||||||
|
item["instance_name"] = inst.get("name")
|
||||||
|
all_orders.append(item)
|
||||||
|
return all_orders
|
||||||
|
|
||||||
|
|
||||||
def pull_all_enabled() -> list[dict[str, Any]]:
|
def pull_all_enabled() -> list[dict[str, Any]]:
|
||||||
instances = list_instances(enabled_only=True)
|
instances = list_instances(enabled_only=True)
|
||||||
if not instances:
|
if not instances:
|
||||||
|
|||||||
@@ -0,0 +1,232 @@
|
|||||||
|
"""条件单热缓存:后台常驻并行从实例拉取;浏览器只读缓存 + SSE.
|
||||||
|
下发/撤单/改出场仍由中控 POST 到实例,成功后触发刷新.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import queue
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def _poll_interval_sec() -> float:
|
||||||
|
try:
|
||||||
|
return max(
|
||||||
|
3.0,
|
||||||
|
float(
|
||||||
|
os.getenv("HUB_COND_POLL_INTERVAL_SEC")
|
||||||
|
or os.getenv("HUB_PULL_INTERVAL_SEC")
|
||||||
|
or 10
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 10.0
|
||||||
|
|
||||||
|
|
||||||
|
def _sse_heartbeat_sec() -> float:
|
||||||
|
try:
|
||||||
|
return max(10.0, float(os.getenv("HUB_SSE_HEARTBEAT_SEC") or 25))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 25.0
|
||||||
|
|
||||||
|
|
||||||
|
def _candidates_from_snaps(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
for row in rows or []:
|
||||||
|
snap = row.get("snapshot") if isinstance(row.get("snapshot"), dict) else {}
|
||||||
|
brief = snap.get("positions_brief") or snap.get("positions") or []
|
||||||
|
has_pos = isinstance(brief, list) and len(brief) > 0
|
||||||
|
locked = bool(snap.get("cond_order_locked"))
|
||||||
|
selectable = bool(row.get("online")) and not has_pos and not locked
|
||||||
|
inst = snap.get("instance") if isinstance(snap.get("instance"), dict) else {}
|
||||||
|
mode = str(inst.get("mode") or "").strip().lower()
|
||||||
|
if mode not in ("sim", "live"):
|
||||||
|
mode = ""
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"id": row.get("id"),
|
||||||
|
"name": row.get("name"),
|
||||||
|
"online": bool(row.get("online")),
|
||||||
|
"has_position": has_pos,
|
||||||
|
"cond_order_locked": locked,
|
||||||
|
"selectable": selectable,
|
||||||
|
"mode": mode,
|
||||||
|
"error": row.get("error") or "",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
class CondOrdersStore:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
self.version = 0
|
||||||
|
self.orders: list[dict[str, Any]] = []
|
||||||
|
self.candidates: list[dict[str, Any]] = []
|
||||||
|
self.refreshing = True
|
||||||
|
self.last_error: str | None = None
|
||||||
|
self.updated_at: str = ""
|
||||||
|
self._ready = False
|
||||||
|
self._subscribers: list[queue.Queue[str | None]] = []
|
||||||
|
self._stop = threading.Event()
|
||||||
|
self._refresh = threading.Event()
|
||||||
|
self._thread: threading.Thread | None = None
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
if self._thread and self._thread.is_alive():
|
||||||
|
return
|
||||||
|
self._stop.clear()
|
||||||
|
with self._lock:
|
||||||
|
self.refreshing = True
|
||||||
|
self._ready = False
|
||||||
|
self._thread = threading.Thread(
|
||||||
|
target=self._loop,
|
||||||
|
daemon=True,
|
||||||
|
name="hub-cond-orders-pull",
|
||||||
|
)
|
||||||
|
self._thread.start()
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
self._stop.set()
|
||||||
|
self._refresh.set()
|
||||||
|
self._broadcast(close=True)
|
||||||
|
|
||||||
|
def request_refresh(self) -> None:
|
||||||
|
self._refresh.set()
|
||||||
|
|
||||||
|
def snapshot_dict(self) -> dict[str, Any]:
|
||||||
|
with self._lock:
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"orders": list(self.orders),
|
||||||
|
"candidates": list(self.candidates),
|
||||||
|
"cond_version": self.version,
|
||||||
|
"refreshing": self.refreshing,
|
||||||
|
"updated_at": self.updated_at,
|
||||||
|
"error": self.last_error,
|
||||||
|
"from_cache": True,
|
||||||
|
"poll_interval_sec": _poll_interval_sec(),
|
||||||
|
"ready": self._ready,
|
||||||
|
}
|
||||||
|
|
||||||
|
def event_dict(self) -> dict[str, Any]:
|
||||||
|
with self._lock:
|
||||||
|
return {
|
||||||
|
"cond_version": self.version,
|
||||||
|
"updated_at": self.updated_at,
|
||||||
|
"refreshing": self.refreshing,
|
||||||
|
"ok": True,
|
||||||
|
"error": self.last_error,
|
||||||
|
"poll_interval_sec": _poll_interval_sec(),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _overview_rows(self) -> list[dict[str, Any]]:
|
||||||
|
try:
|
||||||
|
from lib.overview_cache_lib import overview_store
|
||||||
|
|
||||||
|
snap = overview_store.snapshot_dict()
|
||||||
|
rows = snap.get("instances") if isinstance(snap, dict) else None
|
||||||
|
if isinstance(rows, list) and rows:
|
||||||
|
return rows
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
from lib.bridge_client_lib import pull_all_enabled
|
||||||
|
|
||||||
|
return pull_all_enabled()
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
def _loop(self) -> None:
|
||||||
|
while not self._stop.is_set():
|
||||||
|
self._refresh_once()
|
||||||
|
if self._stop.is_set():
|
||||||
|
break
|
||||||
|
self._refresh.clear()
|
||||||
|
self._refresh.wait(timeout=_poll_interval_sec())
|
||||||
|
|
||||||
|
def _refresh_once(self) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self.refreshing = True
|
||||||
|
self._broadcast()
|
||||||
|
try:
|
||||||
|
from lib.bridge_client_lib import pull_all_cond_orders
|
||||||
|
|
||||||
|
rows = self._overview_rows()
|
||||||
|
candidates = _candidates_from_snaps(rows)
|
||||||
|
orders = pull_all_cond_orders()
|
||||||
|
with self._lock:
|
||||||
|
self.version += 1
|
||||||
|
self.orders = orders if isinstance(orders, list) else []
|
||||||
|
self.candidates = candidates
|
||||||
|
self.updated_at = time.strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
self.last_error = None
|
||||||
|
self.refreshing = False
|
||||||
|
self._ready = True
|
||||||
|
except Exception as e:
|
||||||
|
with self._lock:
|
||||||
|
self.version += 1
|
||||||
|
self.last_error = str(e) or "cond_refresh_failed"
|
||||||
|
self.refreshing = False
|
||||||
|
self._ready = True
|
||||||
|
self._broadcast()
|
||||||
|
|
||||||
|
def _broadcast(self, *, close: bool = False) -> None:
|
||||||
|
with self._lock:
|
||||||
|
subs = list(self._subscribers)
|
||||||
|
event = None if close else json.dumps(self.event_dict(), ensure_ascii=False)
|
||||||
|
dead: list[queue.Queue[str | None]] = []
|
||||||
|
for q in subs:
|
||||||
|
try:
|
||||||
|
q.put_nowait(None if close else event)
|
||||||
|
except queue.Full:
|
||||||
|
try:
|
||||||
|
q.get_nowait()
|
||||||
|
except queue.Empty:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
q.put_nowait(event)
|
||||||
|
except queue.Full:
|
||||||
|
dead.append(q)
|
||||||
|
except Exception:
|
||||||
|
dead.append(q)
|
||||||
|
if dead:
|
||||||
|
with self._lock:
|
||||||
|
for q in dead:
|
||||||
|
if q in self._subscribers:
|
||||||
|
self._subscribers.remove(q)
|
||||||
|
|
||||||
|
def iter_sse(self) -> Iterator[str]:
|
||||||
|
q: queue.Queue[str | None] = queue.Queue(maxsize=32)
|
||||||
|
with self._lock:
|
||||||
|
self._subscribers.append(q)
|
||||||
|
try:
|
||||||
|
yield _sse_frame(self.event_dict())
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
raw = q.get(timeout=_sse_heartbeat_sec())
|
||||||
|
except queue.Empty:
|
||||||
|
yield ": heartbeat\n\n"
|
||||||
|
continue
|
||||||
|
if raw is None:
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
data = json.loads(raw)
|
||||||
|
except Exception:
|
||||||
|
data = self.event_dict()
|
||||||
|
yield _sse_frame(data)
|
||||||
|
finally:
|
||||||
|
with self._lock:
|
||||||
|
if q in self._subscribers:
|
||||||
|
self._subscribers.remove(q)
|
||||||
|
|
||||||
|
|
||||||
|
def _sse_frame(data: dict[str, Any]) -> str:
|
||||||
|
body = json.dumps(data, ensure_ascii=False)
|
||||||
|
return f"event: cond\ndata: {body}\n\n"
|
||||||
|
|
||||||
|
|
||||||
|
cond_orders_store = CondOrdersStore()
|
||||||
+96
-10
@@ -117,7 +117,7 @@
|
|||||||
});
|
});
|
||||||
const dd = await rr.json();
|
const dd = await rr.json();
|
||||||
alert(dd.msg || (dd.ok ? "已取消" : "失败"));
|
alert(dd.msg || (dd.ok ? "已取消" : "失败"));
|
||||||
refresh();
|
requestCondRefresh();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
box.querySelectorAll("[data-exit]").forEach(function (btn) {
|
box.querySelectorAll("[data-exit]").forEach(function (btn) {
|
||||||
@@ -148,7 +148,7 @@
|
|||||||
});
|
});
|
||||||
const dd = await rr.json();
|
const dd = await rr.json();
|
||||||
alert(dd.msg || (dd.ok ? "已更新" : "失败"));
|
alert(dd.msg || (dd.ok ? "已更新" : "失败"));
|
||||||
refresh();
|
requestCondRefresh();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -414,13 +414,25 @@
|
|||||||
fillOrderBox(document.getElementById("cond-list-done"), lastDone, "暂无已结束的条件单", "done");
|
fillOrderBox(document.getElementById("cond-list-done"), lastDone, "暂无已结束的条件单", "done");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadOrders() {
|
let condVersion = 0;
|
||||||
|
let condLoading = false;
|
||||||
|
let condEventSource = null;
|
||||||
|
let condReconnectTimer = null;
|
||||||
|
|
||||||
|
function setCondStatus(msg) {
|
||||||
|
const el = document.getElementById("cond-status");
|
||||||
|
if (el) el.textContent = msg || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadOrders(opts) {
|
||||||
|
opts = opts || {};
|
||||||
const activeBox = document.getElementById("cond-list-active");
|
const activeBox = document.getElementById("cond-list-active");
|
||||||
const doneBox = document.getElementById("cond-list-done");
|
const doneBox = document.getElementById("cond-list-done");
|
||||||
if (!activeBox && !doneBox) return;
|
if (!activeBox && !doneBox) return;
|
||||||
const r = await fetch("/api/cond_orders");
|
const r = await fetch("/api/cond_orders", { credentials: "same-origin" });
|
||||||
const d = await r.json();
|
const d = await r.json();
|
||||||
const list = d.orders || [];
|
const list = d.orders || [];
|
||||||
|
condVersion = Number(d.cond_version) || condVersion;
|
||||||
lastActive = [];
|
lastActive = [];
|
||||||
lastDone = [];
|
lastDone = [];
|
||||||
list.forEach(function (o) {
|
list.forEach(function (o) {
|
||||||
@@ -428,6 +440,19 @@
|
|||||||
else lastDone.push(o);
|
else lastDone.push(o);
|
||||||
});
|
});
|
||||||
paintCachedOrders();
|
paintCachedOrders();
|
||||||
|
if (!opts.silent) {
|
||||||
|
const live = condEventSource && condEventSource.readyState === 1;
|
||||||
|
const sec = Number(d.poll_interval_sec) || 10;
|
||||||
|
setCondStatus(
|
||||||
|
(live ? "SSE 已连接 · " : "") +
|
||||||
|
"进行中 " +
|
||||||
|
lastActive.length +
|
||||||
|
" · 已结束 " +
|
||||||
|
lastDone.length +
|
||||||
|
(d.updated_at ? " · " + d.updated_at : "") +
|
||||||
|
(d.refreshing ? " · 后台拉取中" : " · 后台每 " + sec + "s 并行拉")
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createOrder() {
|
async function createOrder() {
|
||||||
@@ -467,16 +492,72 @@
|
|||||||
const tog = document.getElementById("cond-add-toggle");
|
const tog = document.getElementById("cond-add-toggle");
|
||||||
if (tog) tog.checked = false;
|
if (tog) tog.checked = false;
|
||||||
}
|
}
|
||||||
refresh();
|
requestCondRefresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refresh() {
|
async function refresh(opts) {
|
||||||
await Promise.all([loadCandidates(), loadOrders()]);
|
opts = opts || {};
|
||||||
|
if (condLoading) return;
|
||||||
|
condLoading = true;
|
||||||
|
if (!opts.silent) setCondStatus("读取缓存…");
|
||||||
|
try {
|
||||||
|
await Promise.all([loadCandidates(), loadOrders({ silent: !!opts.silent })]);
|
||||||
|
} finally {
|
||||||
|
condLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestCondRefresh() {
|
||||||
|
try {
|
||||||
|
await fetch("/api/cond_orders/refresh", {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "same-origin",
|
||||||
|
});
|
||||||
|
} catch (_) {}
|
||||||
|
refresh({ silent: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCondStream() {
|
||||||
|
if (condEventSource) {
|
||||||
|
condEventSource.close();
|
||||||
|
condEventSource = null;
|
||||||
|
}
|
||||||
|
if (condReconnectTimer) {
|
||||||
|
clearTimeout(condReconnectTimer);
|
||||||
|
condReconnectTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectCondStream() {
|
||||||
|
if (typeof EventSource === "undefined") return;
|
||||||
|
closeCondStream();
|
||||||
|
condEventSource = new EventSource("/api/cond_orders/stream");
|
||||||
|
condEventSource.addEventListener("cond", function (ev) {
|
||||||
|
try {
|
||||||
|
const st = JSON.parse(ev.data || "{}");
|
||||||
|
const ver = Number(st.cond_version) || 0;
|
||||||
|
if (ver && ver !== condVersion) {
|
||||||
|
refresh({ silent: true });
|
||||||
|
} else if (st.refreshing) {
|
||||||
|
setCondStatus("后台并行拉取条件单…");
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
});
|
||||||
|
condEventSource.onerror = function () {
|
||||||
|
closeCondStream();
|
||||||
|
setCondStatus("SSE 断开,8s 后重连…");
|
||||||
|
condReconnectTimer = setTimeout(function () {
|
||||||
|
connectCondStream();
|
||||||
|
refresh({ silent: true });
|
||||||
|
}, 8000);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function boot() {
|
function boot() {
|
||||||
const btn = document.getElementById("btn-cond-refresh");
|
const btn = document.getElementById("btn-cond-refresh");
|
||||||
if (btn) btn.addEventListener("click", refresh);
|
if (btn) btn.addEventListener("click", function () {
|
||||||
|
requestCondRefresh();
|
||||||
|
});
|
||||||
const create = document.getElementById("btn-cond-create");
|
const create = document.getElementById("btn-cond-create");
|
||||||
if (create) create.addEventListener("click", createOrder);
|
if (create) create.addEventListener("click", createOrder);
|
||||||
const typeEl = document.getElementById("cf-opt-type");
|
const typeEl = document.getElementById("cf-opt-type");
|
||||||
@@ -484,9 +565,14 @@
|
|||||||
if (typeEl) typeEl.addEventListener("change", syncTriggerHint);
|
if (typeEl) typeEl.addEventListener("change", syncTriggerHint);
|
||||||
if (pxEl) pxEl.addEventListener("input", syncTriggerHint);
|
if (pxEl) pxEl.addEventListener("input", syncTriggerHint);
|
||||||
syncTriggerHint();
|
syncTriggerHint();
|
||||||
|
// 读缓存秒开 + SSE;不再前端定时现场拉实例
|
||||||
refresh();
|
refresh();
|
||||||
setInterval(refresh, 10000);
|
connectCondStream();
|
||||||
}
|
}
|
||||||
|
|
||||||
global.HubCondOrders = { boot: boot, refresh: refresh };
|
global.HubCondOrders = {
|
||||||
|
boot: boot,
|
||||||
|
refresh: refresh,
|
||||||
|
requestRefresh: requestCondRefresh,
|
||||||
|
};
|
||||||
})(window);
|
})(window);
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
<section class="page-head cond-head">
|
<section class="page-head cond-head">
|
||||||
<div>
|
<div>
|
||||||
<h1>条件单</h1>
|
<h1>条件单</h1>
|
||||||
<p class="muted cond-lead">中控只下发;盯盘、买币、开平仓都在客户端。有仓或已占用的机器不可选。</p>
|
<p class="muted cond-lead">中控下发到实例;列表由中控后台缓存并推送到浏览器。盯盘与开平仓在客户端。有仓或已占用的机器不可选。</p>
|
||||||
|
<p class="muted" id="cond-status">读取缓存…</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="cond-head-actions">
|
<div class="cond-head-actions">
|
||||||
<button type="button" class="btn" id="btn-cond-refresh">刷新</button>
|
<button type="button" class="btn" id="btn-cond-refresh">刷新</button>
|
||||||
@@ -93,6 +94,6 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script src="{{ url_for('static', filename='cond_orders.js') }}?v=6"></script>
|
<script src="{{ url_for('static', filename='cond_orders.js') }}?v=7"></script>
|
||||||
<script>HubCondOrders.boot();</script>
|
<script>HubCondOrders.boot();</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user