diff --git a/app.py b/app.py index 1f6fb4d..0965cd6 100644 --- a/app.py +++ b/app.py @@ -1,245 +1,268 @@ -"""crypto_okx_hub — 期权多机中控.""" -from __future__ import annotations - -import os -import sys -from functools import wraps -from pathlib import Path - -from dotenv import load_dotenv -from flask import ( - Flask, - flash, - jsonify, - redirect, - render_template, - request, - session, - url_for, -) - -ROOT = Path(__file__).resolve().parent -if str(ROOT) not in sys.path: - sys.path.insert(0, str(ROOT)) - -ENV_PATH = ROOT / ".env" -load_dotenv(ENV_PATH) - -from lib.bridge_client_lib import pull_all_enabled -from lib.db_lib import init_db -from lib.env_lib import apply_env_updates -from lib.instances_lib import ( - create_instance, - delete_instance, - get_instance, - list_instances, - update_instance, -) -from lib.sso_lib import build_embed_open_url - -APP_HOST = (os.getenv("APP_HOST") or "0.0.0.0").strip() or "0.0.0.0" -APP_PORT = int(os.getenv("APP_PORT") or 5100) -SECRET = (os.getenv("FLASK_SECRET_KEY") or "hub-dev-secret").strip() - -# 可变登录态:系统设置页可改,并立刻生效 -_auth = { - "username": (os.getenv("HUB_USERNAME") or "admin").strip() or "admin", - "password": (os.getenv("HUB_PASSWORD") or "admin123").strip() or "admin123", -} - -app = Flask(__name__) -app.secret_key = SECRET -app.config["TEMPLATES_AUTO_RELOAD"] = True - -init_db() - - -def hub_username() -> str: - return _auth["username"] - - -def hub_password() -> str: - return _auth["password"] - - -def set_hub_credentials(username: str, password: str) -> None: - u = (username or "").strip() or "admin" - p = password if password is not None else "" - _auth["username"] = u - _auth["password"] = p - os.environ["HUB_USERNAME"] = u - os.environ["HUB_PASSWORD"] = p - - -def login_required(f): - @wraps(f) - def wrapped(*args, **kwargs): - if session.get("logged_in"): - return f(*args, **kwargs) - if request.path.startswith("/api/"): - return jsonify({"ok": False, "msg": "未登录"}), 401 - return redirect(url_for("login", next=request.path)) - - return wrapped - - -@app.route("/login", methods=["GET", "POST"]) -def login(): - if request.method == "POST": - u = (request.form.get("username") or "").strip() - p = request.form.get("password") or "" - if u == hub_username() and p == hub_password(): - session["logged_in"] = True - session.permanent = True - nxt = (request.args.get("next") or request.form.get("next") or "/desk").strip() or "/desk" - if not nxt.startswith("/"): - nxt = "/desk" - return redirect(nxt) - flash("用户名或密码错误") - if session.get("logged_in"): - return redirect("/desk") - return render_template("login.html") - - -@app.route("/logout") -def logout(): - session.clear() - return redirect(url_for("login")) - - -@app.route("/") -@login_required -def dashboard(): - return render_template("dashboard.html") - - -@app.route("/instances", methods=["GET", "POST"]) -@login_required -def instances_page(): - if request.method == "POST": - action = (request.form.get("action") or "create").strip() - if action == "create": - _, err = create_instance( - name=request.form.get("name") or "", - base_url=request.form.get("base_url") or "", - bridge_token=request.form.get("bridge_token") or "", - embed_path=request.form.get("embed_path") or "/embed", - tags=request.form.get("tags") or "", - enabled=bool(request.form.get("enabled")), - ) - flash(err or "已添加") - elif action == "update": - iid = int(request.form.get("id") or 0) - _, err = update_instance( - iid, - name=request.form.get("name") or "", - base_url=request.form.get("base_url") or "", - bridge_token=request.form.get("bridge_token") or "", - embed_path=request.form.get("embed_path") or "/embed", - tags=request.form.get("tags") or "", - enabled=bool(request.form.get("enabled")), - ) - flash(err or "已保存") - elif action == "delete": - iid = int(request.form.get("id") or 0) - ok = delete_instance(iid) - flash("已删除" if ok else "删除失败") - return redirect(url_for("instances_page")) - return render_template("instances.html", instances=list_instances()) - - -@app.route("/settings", methods=["GET", "POST"]) -@login_required -def settings_page(): - if request.method == "POST": - current_pw = request.form.get("current_password") or "" - new_username = (request.form.get("new_username") or "").strip() - new_password = request.form.get("new_password") or "" - confirm = request.form.get("confirm_password") or "" - - if current_pw != hub_password(): - flash("当前密码错误") - return redirect(url_for("settings_page")) - if len(new_username) < 2 or len(new_username) > 64: - flash("用户名长度需为 2–64") - return redirect(url_for("settings_page")) - if any(ch.isspace() for ch in new_username): - flash("用户名不能包含空格") - return redirect(url_for("settings_page")) - - updates: dict[str, str] = {"HUB_USERNAME": new_username} - final_password = hub_password() - if new_password or confirm: - if len(new_password) < 6: - flash("新密码至少 6 位") - return redirect(url_for("settings_page")) - if new_password != confirm: - flash("两次输入的新密码不一致") - return redirect(url_for("settings_page")) - updates["HUB_PASSWORD"] = new_password - final_password = new_password - - try: - apply_env_updates(ENV_PATH, updates) - except OSError as exc: - flash(f"写入 .env 失败: {exc}") - return redirect(url_for("settings_page")) - - set_hub_credentials(new_username, final_password) - flash("账号已更新,立即生效") - return redirect(url_for("settings_page")) - - return render_template("settings.html", current_username=hub_username()) - - -@app.route("/desk") -@login_required -def desk(): - instances = list_instances(enabled_only=True) - iid = request.args.get("id") - # 未指定客户端时默认打开第一个 - if (not iid or not str(iid).isdigit()) and instances: - return redirect(url_for("desk", id=instances[0]["id"])) - inst = None - open_url = "" - if iid and str(iid).isdigit(): - inst = get_instance(int(iid)) - if inst: - open_url = build_embed_open_url( - inst["base_url"], - bridge_token=inst["bridge_token"], - embed_path=inst.get("embed_path") or "/embed", - ttl_sec=3600, - ) - return render_template( - "desk.html", - instances=instances, - current=inst, - open_url=open_url, - ) - - -@app.route("/api/overview") -@login_required -def api_overview(): - rows = pull_all_enabled() - return jsonify({"ok": True, "instances": rows}) - - -@app.route("/api/instances//open_url") -@login_required -def api_open_url(iid: int): - inst = get_instance(iid) - if not inst: - return jsonify({"ok": False, "msg": "不存在"}), 404 - url = build_embed_open_url( - inst["base_url"], - bridge_token=inst["bridge_token"], - embed_path=inst.get("embed_path") or "/embed", - ttl_sec=3600, - ) - return jsonify({"ok": True, "url": url, "base_url": inst["base_url"]}) - - -if __name__ == "__main__": - app.run(host=APP_HOST, port=APP_PORT, debug=True, threaded=True) +"""crypto_okx_hub — 期权多机中控.""" +from __future__ import annotations + +import os +import sys +from functools import wraps +from pathlib import Path + +from dotenv import load_dotenv +from flask import ( + Flask, + flash, + jsonify, + redirect, + render_template, + request, + session, + url_for, +) + +ROOT = Path(__file__).resolve().parent +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +ENV_PATH = ROOT / ".env" +load_dotenv(ENV_PATH) + +from lib.bridge_client_lib import post_close_full, pull_all_enabled +from lib.db_lib import init_db +from lib.env_lib import apply_env_updates +from lib.instances_lib import ( + create_instance, + delete_instance, + get_instance, + list_instances, + update_instance, +) +from lib.sso_lib import build_embed_open_url + +APP_HOST = (os.getenv("APP_HOST") or "0.0.0.0").strip() or "0.0.0.0" +APP_PORT = int(os.getenv("APP_PORT") or 5100) +SECRET = (os.getenv("FLASK_SECRET_KEY") or "hub-dev-secret").strip() + +# 可变登录态:系统设置页可改,并立刻生效 +_auth = { + "username": (os.getenv("HUB_USERNAME") or "admin").strip() or "admin", + "password": (os.getenv("HUB_PASSWORD") or "admin123").strip() or "admin123", +} + +app = Flask(__name__) +app.secret_key = SECRET +app.config["TEMPLATES_AUTO_RELOAD"] = True + +init_db() + + +def hub_username() -> str: + return _auth["username"] + + +def hub_password() -> str: + return _auth["password"] + + +def set_hub_credentials(username: str, password: str) -> None: + u = (username or "").strip() or "admin" + p = password if password is not None else "" + _auth["username"] = u + _auth["password"] = p + os.environ["HUB_USERNAME"] = u + os.environ["HUB_PASSWORD"] = p + + +def login_required(f): + @wraps(f) + def wrapped(*args, **kwargs): + if session.get("logged_in"): + return f(*args, **kwargs) + if request.path.startswith("/api/"): + return jsonify({"ok": False, "msg": "未登录"}), 401 + return redirect(url_for("login", next=request.path)) + + return wrapped + + +@app.route("/login", methods=["GET", "POST"]) +def login(): + if request.method == "POST": + u = (request.form.get("username") or "").strip() + p = request.form.get("password") or "" + if u == hub_username() and p == hub_password(): + session["logged_in"] = True + session.permanent = True + nxt = (request.args.get("next") or request.form.get("next") or "/desk").strip() or "/desk" + if not nxt.startswith("/"): + nxt = "/desk" + return redirect(nxt) + flash("用户名或密码错误") + if session.get("logged_in"): + return redirect("/desk") + return render_template("login.html") + + +@app.route("/logout") +def logout(): + session.clear() + return redirect(url_for("login")) + + +@app.route("/") +@login_required +def dashboard(): + return render_template("dashboard.html") + + +@app.route("/instances", methods=["GET", "POST"]) +@login_required +def instances_page(): + if request.method == "POST": + action = (request.form.get("action") or "create").strip() + if action == "create": + _, err = create_instance( + name=request.form.get("name") or "", + base_url=request.form.get("base_url") or "", + bridge_token=request.form.get("bridge_token") or "", + embed_path=request.form.get("embed_path") or "/embed", + tags=request.form.get("tags") or "", + enabled=bool(request.form.get("enabled")), + ) + flash(err or "已添加") + elif action == "update": + iid = int(request.form.get("id") or 0) + _, err = update_instance( + iid, + name=request.form.get("name") or "", + base_url=request.form.get("base_url") or "", + bridge_token=request.form.get("bridge_token") or "", + embed_path=request.form.get("embed_path") or "/embed", + tags=request.form.get("tags") or "", + enabled=bool(request.form.get("enabled")), + ) + flash(err or "已保存") + elif action == "delete": + iid = int(request.form.get("id") or 0) + ok = delete_instance(iid) + flash("已删除" if ok else "删除失败") + return redirect(url_for("instances_page")) + return render_template("instances.html", instances=list_instances()) + + +@app.route("/settings", methods=["GET", "POST"]) +@login_required +def settings_page(): + if request.method == "POST": + current_pw = request.form.get("current_password") or "" + new_username = (request.form.get("new_username") or "").strip() + new_password = request.form.get("new_password") or "" + confirm = request.form.get("confirm_password") or "" + + if current_pw != hub_password(): + flash("当前密码错误") + return redirect(url_for("settings_page")) + if len(new_username) < 2 or len(new_username) > 64: + flash("用户名长度需为 2–64") + return redirect(url_for("settings_page")) + if any(ch.isspace() for ch in new_username): + flash("用户名不能包含空格") + return redirect(url_for("settings_page")) + + updates: dict[str, str] = {"HUB_USERNAME": new_username} + final_password = hub_password() + if new_password or confirm: + if len(new_password) < 6: + flash("新密码至少 6 位") + return redirect(url_for("settings_page")) + if new_password != confirm: + flash("两次输入的新密码不一致") + return redirect(url_for("settings_page")) + updates["HUB_PASSWORD"] = new_password + final_password = new_password + + try: + apply_env_updates(ENV_PATH, updates) + except OSError as exc: + flash(f"写入 .env 失败: {exc}") + return redirect(url_for("settings_page")) + + set_hub_credentials(new_username, final_password) + flash("账号已更新,立即生效") + return redirect(url_for("settings_page")) + + return render_template("settings.html", current_username=hub_username()) + + +@app.route("/desk") +@login_required +def desk(): + instances = list_instances(enabled_only=True) + iid = request.args.get("id") + # 未指定客户端时默认打开第一个 + if (not iid or not str(iid).isdigit()) and instances: + return redirect(url_for("desk", id=instances[0]["id"])) + inst = None + open_url = "" + if iid and str(iid).isdigit(): + inst = get_instance(int(iid)) + if inst: + open_url = build_embed_open_url( + inst["base_url"], + bridge_token=inst["bridge_token"], + embed_path=inst.get("embed_path") or "/embed", + ttl_sec=3600, + ) + return render_template( + "desk.html", + instances=instances, + current=inst, + open_url=open_url, + ) + + +@app.route("/api/overview") +@login_required +def api_overview(): + rows = pull_all_enabled() + return jsonify({"ok": True, "instances": rows}) + + +@app.route("/api/instances//open_url") +@login_required +def api_open_url(iid: int): + inst = get_instance(iid) + if not inst: + return jsonify({"ok": False, "msg": "不存在"}), 404 + url = build_embed_open_url( + inst["base_url"], + bridge_token=inst["bridge_token"], + embed_path=inst.get("embed_path") or "/embed", + ttl_sec=3600, + ) + return jsonify({"ok": True, "url": url, "base_url": inst["base_url"]}) + + +@app.route("/api/instances//options/close", methods=["POST"]) +@login_required +def api_instance_options_close(iid: int): + """总览下发全平:中控只转发,客户端本地买一限价执行.""" + inst = get_instance(iid) + if not inst: + return jsonify({"ok": False, "msg": "实例不存在"}), 404 + if not inst.get("enabled"): + return jsonify({"ok": False, "msg": "实例未启用"}), 400 + data = request.get_json(silent=True) or {} + opt_inst = (data.get("inst_id") or "").strip() + if not opt_inst: + return jsonify({"ok": False, "msg": "缺少 inst_id"}) + result = post_close_full(inst, inst_id=opt_inst) + status = 200 + if not result.get("ok"): + # 客户端业务拒绝(残档等)仍 200 带 ok:false;仅传输层失败用 502 + msg = str(result.get("msg") or "") + if msg.startswith("HTTP ") or "超时" in msg or "下发失败" in msg or "缺少 URL" in msg: + status = 502 + return jsonify(result), status + + +if __name__ == "__main__": + app.run(host=APP_HOST, port=APP_PORT, debug=True, threaded=True) diff --git a/lib/bridge_client_lib.py b/lib/bridge_client_lib.py index fcdd3ee..4fa89a2 100644 --- a/lib/bridge_client_lib.py +++ b/lib/bridge_client_lib.py @@ -1,78 +1,123 @@ -"""拉取各 crypto_okx 客户端只读快照.""" -from __future__ import annotations - -import os -import time -from concurrent.futures import ThreadPoolExecutor, as_completed -from typing import Any - -import requests - -from lib.instances_lib import list_instances - - -def _timeout() -> float: - try: - return max(2.0, float(os.getenv("HUB_PULL_TIMEOUT_SEC") or 5)) - except (TypeError, ValueError): - return 5.0 - - -def fetch_one_snapshot(inst: dict[str, Any]) -> dict[str, Any]: - base = (inst.get("base_url") or "").rstrip("/") - token = (inst.get("bridge_token") or "").strip() - out: dict[str, Any] = { - "id": inst.get("id"), - "name": inst.get("name"), - "base_url": base, - "embed_path": inst.get("embed_path") or "/embed", - "tags": inst.get("tags") or "", - "online": False, - "error": "", - "snapshot": None, - "pulled_at": time.strftime("%Y-%m-%d %H:%M:%S"), - } - if not base or not token: - out["error"] = "缺少 URL 或 Token" - return out - url = f"{base}/api/hub/options/snapshot" - try: - r = requests.get( - url, - headers={"X-Hub-Token": token, "Accept": "application/json"}, - timeout=_timeout(), - ) - data = {} - try: - data = r.json() - except Exception: - data = {} - if r.status_code != 200 or not isinstance(data, dict) or not data.get("ok"): - out["error"] = (data.get("msg") if isinstance(data, dict) else None) or f"HTTP {r.status_code}" - out["snapshot"] = data if isinstance(data, dict) else None - return out - out["online"] = True - out["snapshot"] = data - return out - except requests.Timeout: - out["error"] = "超时" - return out - except Exception as e: - out["error"] = str(e) or "拉取失败" - return out - - -def pull_all_enabled() -> list[dict[str, Any]]: - instances = list_instances(enabled_only=True) - if not instances: - return [] - results: list[dict[str, Any]] = [] - with ThreadPoolExecutor(max_workers=min(8, len(instances))) as pool: - futs = {pool.submit(fetch_one_snapshot, inst): inst["id"] for inst in instances} - by_id: dict[Any, dict[str, Any]] = {} - for fut in as_completed(futs): - row = fut.result() - by_id[row.get("id")] = row - for inst in instances: - results.append(by_id.get(inst["id"]) or fetch_one_snapshot(inst)) - return results +"""拉取各 crypto_okx 客户端快照;以及受限写指令(买一全平).""" +from __future__ import annotations + +import os +import time +from concurrent.futures import ThreadPoolExecutor, as_completed +from typing import Any + +import requests + +from lib.instances_lib import list_instances + + +def _timeout() -> float: + try: + return max(2.0, float(os.getenv("HUB_PULL_TIMEOUT_SEC") or 5)) + except (TypeError, ValueError): + return 5.0 + + +def _close_timeout() -> float: + try: + return max(8.0, float(os.getenv("HUB_CLOSE_TIMEOUT_SEC") or 45)) + except (TypeError, ValueError): + return 45.0 + + +def fetch_one_snapshot(inst: dict[str, Any]) -> dict[str, Any]: + base = (inst.get("base_url") or "").rstrip("/") + token = (inst.get("bridge_token") or "").strip() + out: dict[str, Any] = { + "id": inst.get("id"), + "name": inst.get("name"), + "base_url": base, + "embed_path": inst.get("embed_path") or "/embed", + "tags": inst.get("tags") or "", + "online": False, + "error": "", + "snapshot": None, + "pulled_at": time.strftime("%Y-%m-%d %H:%M:%S"), + } + if not base or not token: + out["error"] = "缺少 URL 或 Token" + return out + url = f"{base}/api/hub/options/snapshot" + try: + r = requests.get( + url, + headers={"X-Hub-Token": token, "Accept": "application/json"}, + timeout=_timeout(), + ) + data = {} + try: + data = r.json() + except Exception: + data = {} + if r.status_code != 200 or not isinstance(data, dict) or not data.get("ok"): + out["error"] = (data.get("msg") if isinstance(data, dict) else None) or f"HTTP {r.status_code}" + out["snapshot"] = data if isinstance(data, dict) else None + return out + out["online"] = True + out["snapshot"] = data + return out + except requests.Timeout: + out["error"] = "超时" + return out + except Exception as e: + out["error"] = str(e) or "拉取失败" + return out + + +def post_close_full(inst: dict[str, Any], *, inst_id: str) -> dict[str, Any]: + """向客户端下发买一全平指令;下单在客户端本地执行.""" + base = (inst.get("base_url") or "").rstrip("/") + token = (inst.get("bridge_token") or "").strip() + iid = (inst_id or "").strip() + if not base or not token: + return {"ok": False, "msg": "缺少 URL 或 Token"} + if not iid: + return {"ok": False, "msg": "缺少 inst_id"} + url = f"{base}/api/hub/options/close" + try: + r = requests.post( + url, + headers={ + "X-Hub-Token": token, + "Accept": "application/json", + "Content-Type": "application/json", + }, + json={"inst_id": iid}, + timeout=_close_timeout(), + ) + try: + data = r.json() + except Exception: + data = {} + if isinstance(data, dict) and data: + if r.status_code >= 400 and "ok" not in data: + data = dict(data) + data["ok"] = False + data.setdefault("msg", f"HTTP {r.status_code}") + return data + return {"ok": False, "msg": f"HTTP {r.status_code}"} + except requests.Timeout: + return {"ok": False, "msg": "客户端超时(下单可能仍在进行,请刷新确认)"} + except Exception as e: + return {"ok": False, "msg": str(e) or "下发失败"} + + +def pull_all_enabled() -> list[dict[str, Any]]: + instances = list_instances(enabled_only=True) + if not instances: + return [] + results: list[dict[str, Any]] = [] + with ThreadPoolExecutor(max_workers=min(8, len(instances))) as pool: + futs = {pool.submit(fetch_one_snapshot, inst): inst["id"] for inst in instances} + by_id: dict[Any, dict[str, Any]] = {} + for fut in as_completed(futs): + row = fut.result() + by_id[row.get("id")] = row + for inst in instances: + results.append(by_id.get(inst["id"]) or fetch_one_snapshot(inst)) + return results diff --git a/static/hub.css b/static/hub.css index ba0a6eb..a7e4cce 100644 --- a/static/hub.css +++ b/static/hub.css @@ -1,407 +1,425 @@ -@import url("https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&family=Orbitron:wght@500;600&display=swap"); - -:root { - --bg: #070b14; - --bg-2: #0c1424; - --panel: rgba(16, 24, 40, 0.82); - --panel-solid: #101828; - --line: rgba(56, 189, 248, 0.22); - --line-strong: rgba(56, 189, 248, 0.45); - --text: #e8f1ff; - --muted: #8aa0b8; - --accent: #22d3ee; - --accent-2: #38bdf8; - --accent-deep: #0ea5e9; - --ok: #34d399; - --bad: #fb7185; - --shadow: 0 0 0 1px rgba(56, 189, 248, 0.08), 0 18px 50px rgba(0, 0, 0, 0.45); - --radius: 14px; - --font: "IBM Plex Sans", "Segoe UI", sans-serif; - --font-display: "Orbitron", "IBM Plex Sans", sans-serif; -} -* { box-sizing: border-box; } -html, body { min-height: 100%; } -body { - margin: 0; - font-family: var(--font); - color: var(--text); - background: - radial-gradient(1200px 600px at 12% -10%, rgba(14, 165, 233, 0.18), transparent 55%), - radial-gradient(900px 500px at 90% 10%, rgba(34, 211, 238, 0.1), transparent 50%), - linear-gradient(180deg, var(--bg-2), var(--bg) 40%, #05080f); - background-attachment: fixed; -} -body::before { - content: ""; - position: fixed; - inset: 0; - pointer-events: none; - z-index: 0; - opacity: 0.35; - background-image: - linear-gradient(rgba(56, 189, 248, 0.06) 1px, transparent 1px), - linear-gradient(90deg, rgba(56, 189, 248, 0.06) 1px, transparent 1px); - background-size: 48px 48px; - mask-image: radial-gradient(ellipse at center, #000 35%, transparent 80%); -} -body > * { position: relative; z-index: 1; } -a { color: var(--accent-2); text-decoration: none; } -a:hover { color: #7dd3fc; } - -.topbar { - display: flex; - justify-content: flex-start; - align-items: center; - padding: 12px 20px; - border-bottom: 1px solid var(--line); - background: rgba(8, 14, 26, 0.75); - backdrop-filter: blur(12px); -} -.topbar nav a { - margin-right: 16px; - color: var(--muted); - font-weight: 500; - letter-spacing: 0.02em; -} -.topbar nav a:last-child { margin-right: 0; } -.topbar nav a.active { - color: var(--accent); - text-shadow: 0 0 18px rgba(34, 211, 238, 0.35); -} - -.main { padding: 16px; max-width: 1200px; margin: 0 auto; } -body.page-desk .main, -body.page-dashboard .main { - max-width: 1800px; - width: 100%; - margin: 0 auto; - padding: 12px clamp(16px, 3vw, 48px) 20px; -} -body.page-login { - min-height: 100vh; -} -body.page-login .main { - max-width: none; - min-height: 100vh; - margin: 0; - padding: 24px; - display: flex; - align-items: center; - justify-content: center; -} - -.page-head { display: flex; align-items: center; gap: 12px; } -.page-head h1 { - margin: 0; - font-family: var(--font-display); - font-size: 1.35rem; - font-weight: 600; - letter-spacing: 0.04em; -} -.muted { color: var(--muted); font-size: 0.9rem; } -.flash { - background: rgba(14, 165, 233, 0.12); - border: 1px solid var(--line-strong); - padding: 8px 12px; - border-radius: 10px; - margin-bottom: 12px; - box-shadow: var(--shadow); -} - -/* —— 登录 —— */ -.login-wrap { - width: 100%; - max-width: 420px; -} -.login-card { - width: 100%; - margin: 0; - padding: 32px 28px 28px; - border-radius: 18px; - background: linear-gradient(160deg, rgba(18, 32, 54, 0.92), rgba(10, 16, 28, 0.95)); - border: 1px solid var(--line-strong); - box-shadow: var(--shadow), inset 0 1px 0 rgba(255, 255, 255, 0.04); - backdrop-filter: blur(16px); -} -.login-card h1 { - margin: 0 0 8px; - font-family: var(--font-display); - font-size: 1.45rem; - font-weight: 600; - letter-spacing: 0.06em; - background: linear-gradient(90deg, #e0f2fe, var(--accent)); - -webkit-background-clip: text; - background-clip: text; - color: transparent; -} -.login-card .login-sub { - margin: 0 0 22px; - color: var(--muted); - font-size: 0.88rem; - line-height: 1.45; -} -.login-card .stack label { - display: block; - margin: 0 0 14px; - font-size: 0.82rem; - color: #b6c7da; - font-weight: 500; -} -.login-card input { - margin-top: 6px; - padding: 11px 12px; - border-radius: 10px; - border: 1px solid var(--line); - background: rgba(5, 10, 18, 0.85); - transition: border-color 0.15s ease, box-shadow 0.15s ease; -} -.login-card input:focus { - outline: none; - border-color: var(--accent); - box-shadow: 0 0 0 3px rgba(34, 211, 238, 0.15); -} -.login-card button[type="submit"] { - width: 100%; - margin-top: 8px; - padding: 12px 14px; - font-weight: 600; - letter-spacing: 0.08em; - background: linear-gradient(90deg, var(--accent-deep), var(--accent)); - color: #041018; - box-shadow: 0 8px 24px rgba(14, 165, 233, 0.28); -} -.login-card button[type="submit"]:hover { - filter: brightness(1.05); -} - -.stack label, .form-grid label { display: block; margin: 8px 0; } -input[type="text"], input[type="password"], input:not([type]) { - width: 100%; - padding: 8px 10px; - border-radius: 8px; - border: 1px solid var(--line); - background: #0a1220; - color: var(--text); -} -button, .btn { - display: inline-block; - margin-top: 8px; - padding: 8px 12px; - border-radius: 8px; - border: 0; - background: linear-gradient(90deg, var(--accent-deep), #06b6d4); - color: #041018; - font-weight: 600; - cursor: pointer; -} -.btn:hover, button:hover { filter: brightness(1.06); } -.btn-danger { background: #9f1239; color: #fff; } -.panel { - background: var(--panel); - border: 1px solid var(--line); - border-radius: var(--radius); - padding: 14px; - margin: 14px 0; - box-shadow: var(--shadow); - backdrop-filter: blur(10px); -} -.form-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); - gap: 10px; - align-items: end; -} -.check { display: flex !important; align-items: center; gap: 8px; } -.inst-row { margin: 10px 0; border: 1px solid var(--line); border-radius: 10px; padding: 8px 12px; } -.overview-table-wrap { - overflow-x: auto; - margin-top: 12px; - border: 1px solid var(--line); - border-radius: var(--radius); - background: var(--panel); - box-shadow: var(--shadow); - backdrop-filter: blur(10px); -} -.overview-table { - width: 100%; - border-collapse: collapse; - font-size: 0.88rem; -} -.overview-table th, -.overview-table td { - padding: 10px 12px; - border-bottom: 1px solid rgba(56, 189, 248, 0.12); - text-align: left; - vertical-align: top; -} -.overview-table th { - color: var(--accent-2); - font-weight: 600; - background: rgba(8, 16, 30, 0.9); - white-space: nowrap; - letter-spacing: 0.03em; -} -.overview-table tr:last-child td { border-bottom: 0; } -.overview-table .status-on { color: var(--ok); } -.overview-table .status-off { color: var(--bad); } -.overview-actions { display: flex; gap: 8px; flex-wrap: wrap; } -.overview-actions .btn { margin-top: 0; } -.pos-brief { - margin: 0; - padding-left: 1.1em; - color: var(--muted); - font-size: 0.8rem; -} -.pos-brief li { margin: 2px 0; } -.card-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); - gap: 12px; - margin-top: 12px; -} -.card { - background: var(--panel); - border: 1px solid var(--line); - border-radius: var(--radius); - padding: 12px; - box-shadow: var(--shadow); -} -.card .status-on { color: var(--ok); } -.card .status-off { color: var(--bad); } -.card table { width: 100%; font-size: 0.8rem; border-collapse: collapse; } -.card td, .card th { border-bottom: 1px solid var(--line); padding: 4px 2px; text-align: left; } - -.desk { - display: grid; - grid-template-columns: 220px minmax(0, 1fr); - gap: 12px; - min-height: calc(100vh - 70px); - max-width: none; - transition: grid-template-columns 0.18s ease; -} -.desk.is-side-collapsed { - grid-template-columns: 48px minmax(0, 1fr); -} -.desk-side { - background: var(--panel); - border: 1px solid var(--line); - border-radius: var(--radius); - padding: 10px; - min-width: 0; - overflow: hidden; - box-shadow: var(--shadow); - backdrop-filter: blur(10px); -} -.desk-side-head { - display: flex; - align-items: center; - justify-content: space-between; - gap: 6px; - margin-bottom: 8px; -} -.desk-side-title { - margin: 0; - font-size: 0.95rem; - font-family: var(--font-display); - letter-spacing: 0.04em; - white-space: nowrap; - color: var(--accent-2); -} -.desk-side-toggle { - flex: 0 0 auto; - margin-top: 0; - width: 28px; - height: 28px; - padding: 0; - border-radius: 8px; - border: 1px solid var(--line); - background: rgba(8, 16, 30, 0.9); - color: var(--text); - font-size: 1.1rem; - line-height: 1; - cursor: pointer; -} -.desk-side-toggle:hover { border-color: var(--accent); color: var(--accent); } -.desk-side-toggle--bar { display: none; } -.desk.is-side-collapsed .desk-side-title, -.desk.is-side-collapsed .desk-list-name, -.desk.is-side-collapsed .desk-list-empty { display: none; } -.desk.is-side-collapsed .desk-side { padding: 8px 6px; } -.desk.is-side-collapsed .desk-side-head { - justify-content: center; - margin-bottom: 0; -} -.desk.is-side-collapsed .desk-side-toggle { transform: rotate(180deg); } -.desk.is-side-collapsed .desk-list { display: none; } -.desk.is-side-collapsed .desk-side-toggle--bar { - display: inline-flex; - align-items: center; - justify-content: center; -} -.desk-list { list-style: none; padding: 0; margin: 0; } -.desk-list li { margin: 6px 0; } -.desk-list li a { - display: block; - padding: 7px 9px; - border-radius: 8px; - color: var(--muted); - border: 1px solid transparent; -} -.desk-list li a:hover { - background: rgba(34, 211, 238, 0.08); - color: #fff; - border-color: rgba(34, 211, 238, 0.2); -} -.desk-list li.active a { - color: #fff; - font-weight: 600; - background: rgba(34, 211, 238, 0.16); - border-color: rgba(34, 211, 238, 0.35); -} -.desk-main { display: flex; flex-direction: column; min-height: calc(100vh - 70px); min-width: 0; } -.desk-toolbar { - display: flex; gap: 10px; align-items: center; flex-wrap: wrap; - margin-bottom: 8px; -} -.desk-frame { - flex: 1; - width: 100%; - min-height: calc(100vh - 130px); - border: 1px solid var(--line); - border-radius: var(--radius); - background: #000; - box-shadow: var(--shadow); -} -.empty { padding: 40px; color: var(--muted); } -.settings-card { max-width: 480px; } -.settings-form { max-width: 420px; } -input.readonly { - opacity: 0.75; - cursor: default; -} -.pnl-up { color: var(--ok); font-weight: 600; } -.pnl-down { color: var(--bad); font-weight: 600; } -.pnl-flat { color: var(--muted); } -.liq-ok { color: var(--text); } -.liq-warn { - color: #fbbf24; - font-weight: 600; -} -.liq-brief--warn { - border-left: 2px solid rgba(251, 191, 36, 0.7); - padding-left: 8px; -} - -@media (max-width: 800px) { - .desk, - .desk.is-side-collapsed { grid-template-columns: 1fr; } - .desk-side-toggle--bar { display: none !important; } -} -@media (min-width: 2000px) { - body.page-desk .main, - body.page-dashboard .main { - max-width: 1800px; - padding-left: clamp(32px, 6vw, 96px); - padding-right: clamp(32px, 6vw, 96px); - } -} +@import url("https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&family=Orbitron:wght@500;600&display=swap"); + +:root { + --bg: #070b14; + --bg-2: #0c1424; + --panel: rgba(16, 24, 40, 0.82); + --panel-solid: #101828; + --line: rgba(56, 189, 248, 0.22); + --line-strong: rgba(56, 189, 248, 0.45); + --text: #e8f1ff; + --muted: #8aa0b8; + --accent: #22d3ee; + --accent-2: #38bdf8; + --accent-deep: #0ea5e9; + --ok: #34d399; + --bad: #fb7185; + --shadow: 0 0 0 1px rgba(56, 189, 248, 0.08), 0 18px 50px rgba(0, 0, 0, 0.45); + --radius: 14px; + --font: "IBM Plex Sans", "Segoe UI", sans-serif; + --font-display: "Orbitron", "IBM Plex Sans", sans-serif; +} +* { box-sizing: border-box; } +html, body { min-height: 100%; } +body { + margin: 0; + font-family: var(--font); + color: var(--text); + background: + radial-gradient(1200px 600px at 12% -10%, rgba(14, 165, 233, 0.18), transparent 55%), + radial-gradient(900px 500px at 90% 10%, rgba(34, 211, 238, 0.1), transparent 50%), + linear-gradient(180deg, var(--bg-2), var(--bg) 40%, #05080f); + background-attachment: fixed; +} +body::before { + content: ""; + position: fixed; + inset: 0; + pointer-events: none; + z-index: 0; + opacity: 0.35; + background-image: + linear-gradient(rgba(56, 189, 248, 0.06) 1px, transparent 1px), + linear-gradient(90deg, rgba(56, 189, 248, 0.06) 1px, transparent 1px); + background-size: 48px 48px; + mask-image: radial-gradient(ellipse at center, #000 35%, transparent 80%); +} +body > * { position: relative; z-index: 1; } +a { color: var(--accent-2); text-decoration: none; } +a:hover { color: #7dd3fc; } + +.topbar { + display: flex; + justify-content: flex-start; + align-items: center; + padding: 12px 20px; + border-bottom: 1px solid var(--line); + background: rgba(8, 14, 26, 0.75); + backdrop-filter: blur(12px); +} +.topbar nav a { + margin-right: 16px; + color: var(--muted); + font-weight: 500; + letter-spacing: 0.02em; +} +.topbar nav a:last-child { margin-right: 0; } +.topbar nav a.active { + color: var(--accent); + text-shadow: 0 0 18px rgba(34, 211, 238, 0.35); +} + +.main { padding: 16px; max-width: 1200px; margin: 0 auto; } +body.page-desk .main, +body.page-dashboard .main { + max-width: 1800px; + width: 100%; + margin: 0 auto; + padding: 12px clamp(16px, 3vw, 48px) 20px; +} +body.page-login { + min-height: 100vh; +} +body.page-login .main { + max-width: none; + min-height: 100vh; + margin: 0; + padding: 24px; + display: flex; + align-items: center; + justify-content: center; +} + +.page-head { display: flex; align-items: center; gap: 12px; } +.page-head h1 { + margin: 0; + font-family: var(--font-display); + font-size: 1.35rem; + font-weight: 600; + letter-spacing: 0.04em; +} +.muted { color: var(--muted); font-size: 0.9rem; } +.flash { + background: rgba(14, 165, 233, 0.12); + border: 1px solid var(--line-strong); + padding: 8px 12px; + border-radius: 10px; + margin-bottom: 12px; + box-shadow: var(--shadow); +} + +/* —— 登录 —— */ +.login-wrap { + width: 100%; + max-width: 420px; +} +.login-card { + width: 100%; + margin: 0; + padding: 32px 28px 28px; + border-radius: 18px; + background: linear-gradient(160deg, rgba(18, 32, 54, 0.92), rgba(10, 16, 28, 0.95)); + border: 1px solid var(--line-strong); + box-shadow: var(--shadow), inset 0 1px 0 rgba(255, 255, 255, 0.04); + backdrop-filter: blur(16px); +} +.login-card h1 { + margin: 0 0 8px; + font-family: var(--font-display); + font-size: 1.45rem; + font-weight: 600; + letter-spacing: 0.06em; + background: linear-gradient(90deg, #e0f2fe, var(--accent)); + -webkit-background-clip: text; + background-clip: text; + color: transparent; +} +.login-card .login-sub { + margin: 0 0 22px; + color: var(--muted); + font-size: 0.88rem; + line-height: 1.45; +} +.login-card .stack label { + display: block; + margin: 0 0 14px; + font-size: 0.82rem; + color: #b6c7da; + font-weight: 500; +} +.login-card input { + margin-top: 6px; + padding: 11px 12px; + border-radius: 10px; + border: 1px solid var(--line); + background: rgba(5, 10, 18, 0.85); + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} +.login-card input:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(34, 211, 238, 0.15); +} +.login-card button[type="submit"] { + width: 100%; + margin-top: 8px; + padding: 12px 14px; + font-weight: 600; + letter-spacing: 0.08em; + background: linear-gradient(90deg, var(--accent-deep), var(--accent)); + color: #041018; + box-shadow: 0 8px 24px rgba(14, 165, 233, 0.28); +} +.login-card button[type="submit"]:hover { + filter: brightness(1.05); +} + +.stack label, .form-grid label { display: block; margin: 8px 0; } +input[type="text"], input[type="password"], input:not([type]) { + width: 100%; + padding: 8px 10px; + border-radius: 8px; + border: 1px solid var(--line); + background: #0a1220; + color: var(--text); +} +button, .btn { + display: inline-block; + margin-top: 8px; + padding: 8px 12px; + border-radius: 8px; + border: 0; + background: linear-gradient(90deg, var(--accent-deep), #06b6d4); + color: #041018; + font-weight: 600; + cursor: pointer; +} +.btn:hover, button:hover { filter: brightness(1.06); } +.btn-danger { background: #9f1239; color: #fff; } +.btn-sm { + padding: 3px 8px; + font-size: 0.75rem; + border-radius: 6px; + font-weight: 600; +} +.btn[disabled] { + opacity: 0.45; + cursor: not-allowed; + filter: none; +} +.pos-brief-main { min-width: 0; } +.panel { + background: var(--panel); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 14px; + margin: 14px 0; + box-shadow: var(--shadow); + backdrop-filter: blur(10px); +} +.form-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 10px; + align-items: end; +} +.check { display: flex !important; align-items: center; gap: 8px; } +.inst-row { margin: 10px 0; border: 1px solid var(--line); border-radius: 10px; padding: 8px 12px; } +.overview-table-wrap { + overflow-x: auto; + margin-top: 12px; + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--panel); + box-shadow: var(--shadow); + backdrop-filter: blur(10px); +} +.overview-table { + width: 100%; + border-collapse: collapse; + font-size: 0.88rem; +} +.overview-table th, +.overview-table td { + padding: 10px 12px; + border-bottom: 1px solid rgba(56, 189, 248, 0.12); + text-align: left; + vertical-align: top; +} +.overview-table th { + color: var(--accent-2); + font-weight: 600; + background: rgba(8, 16, 30, 0.9); + white-space: nowrap; + letter-spacing: 0.03em; +} +.overview-table tr:last-child td { border-bottom: 0; } +.overview-table .status-on { color: var(--ok); } +.overview-table .status-off { color: var(--bad); } +.overview-actions { display: flex; gap: 8px; flex-wrap: wrap; } +.overview-actions .btn { margin-top: 0; } +.pos-brief { + margin: 0; + padding-left: 1.1em; + color: var(--muted); + font-size: 0.8rem; +} +.pos-brief li { + margin: 2px 0; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} +.card-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 12px; + margin-top: 12px; +} +.card { + background: var(--panel); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 12px; + box-shadow: var(--shadow); +} +.card .status-on { color: var(--ok); } +.card .status-off { color: var(--bad); } +.card table { width: 100%; font-size: 0.8rem; border-collapse: collapse; } +.card td, .card th { border-bottom: 1px solid var(--line); padding: 4px 2px; text-align: left; } + +.desk { + display: grid; + grid-template-columns: 220px minmax(0, 1fr); + gap: 12px; + min-height: calc(100vh - 70px); + max-width: none; + transition: grid-template-columns 0.18s ease; +} +.desk.is-side-collapsed { + grid-template-columns: 48px minmax(0, 1fr); +} +.desk-side { + background: var(--panel); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 10px; + min-width: 0; + overflow: hidden; + box-shadow: var(--shadow); + backdrop-filter: blur(10px); +} +.desk-side-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 6px; + margin-bottom: 8px; +} +.desk-side-title { + margin: 0; + font-size: 0.95rem; + font-family: var(--font-display); + letter-spacing: 0.04em; + white-space: nowrap; + color: var(--accent-2); +} +.desk-side-toggle { + flex: 0 0 auto; + margin-top: 0; + width: 28px; + height: 28px; + padding: 0; + border-radius: 8px; + border: 1px solid var(--line); + background: rgba(8, 16, 30, 0.9); + color: var(--text); + font-size: 1.1rem; + line-height: 1; + cursor: pointer; +} +.desk-side-toggle:hover { border-color: var(--accent); color: var(--accent); } +.desk-side-toggle--bar { display: none; } +.desk.is-side-collapsed .desk-side-title, +.desk.is-side-collapsed .desk-list-name, +.desk.is-side-collapsed .desk-list-empty { display: none; } +.desk.is-side-collapsed .desk-side { padding: 8px 6px; } +.desk.is-side-collapsed .desk-side-head { + justify-content: center; + margin-bottom: 0; +} +.desk.is-side-collapsed .desk-side-toggle { transform: rotate(180deg); } +.desk.is-side-collapsed .desk-list { display: none; } +.desk.is-side-collapsed .desk-side-toggle--bar { + display: inline-flex; + align-items: center; + justify-content: center; +} +.desk-list { list-style: none; padding: 0; margin: 0; } +.desk-list li { margin: 6px 0; } +.desk-list li a { + display: block; + padding: 7px 9px; + border-radius: 8px; + color: var(--muted); + border: 1px solid transparent; +} +.desk-list li a:hover { + background: rgba(34, 211, 238, 0.08); + color: #fff; + border-color: rgba(34, 211, 238, 0.2); +} +.desk-list li.active a { + color: #fff; + font-weight: 600; + background: rgba(34, 211, 238, 0.16); + border-color: rgba(34, 211, 238, 0.35); +} +.desk-main { display: flex; flex-direction: column; min-height: calc(100vh - 70px); min-width: 0; } +.desk-toolbar { + display: flex; gap: 10px; align-items: center; flex-wrap: wrap; + margin-bottom: 8px; +} +.desk-frame { + flex: 1; + width: 100%; + min-height: calc(100vh - 130px); + border: 1px solid var(--line); + border-radius: var(--radius); + background: #000; + box-shadow: var(--shadow); +} +.empty { padding: 40px; color: var(--muted); } +.settings-card { max-width: 480px; } +.settings-form { max-width: 420px; } +input.readonly { + opacity: 0.75; + cursor: default; +} +.pnl-up { color: var(--ok); font-weight: 600; } +.pnl-down { color: var(--bad); font-weight: 600; } +.pnl-flat { color: var(--muted); } +.liq-ok { color: var(--text); } +.liq-warn { + color: #fbbf24; + font-weight: 600; +} +.liq-brief--warn { + border-left: 2px solid rgba(251, 191, 36, 0.7); + padding-left: 8px; +} + +@media (max-width: 800px) { + .desk, + .desk.is-side-collapsed { grid-template-columns: 1fr; } + .desk-side-toggle--bar { display: none !important; } +} +@media (min-width: 2000px) { + body.page-desk .main, + body.page-dashboard .main { + max-width: 1800px; + padding-left: clamp(32px, 6vw, 96px); + padding-right: clamp(32px, 6vw, 96px); + } +} diff --git a/static/hub.js b/static/hub.js index 16b10fa..a7e4089 100644 --- a/static/hub.js +++ b/static/hub.js @@ -1,188 +1,267 @@ -(function (global) { - function fmtPnl(v) { - if (v == null || v === "") return "—"; - const n = Number(v); - if (!Number.isFinite(n)) return "—"; - return (n > 0 ? "+" : "") + n.toFixed(2) + " U"; - } - - function pnlClass(v) { - const n = Number(v); - if (!Number.isFinite(n) || n === 0) return "pnl-flat"; - return n > 0 ? "pnl-up" : "pnl-down"; - } - - function fmtPnlHtml(v) { - return '' + escapeHtml(fmtPnl(v)) + ""; - } - - function escapeHtml(s) { - return String(s) - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """); - } - - function positionSummary(brief) { - const list = brief || []; - if (!list.length) return '暂无持仓'; - const items = list.slice(0, 8).map(function (p) { - const id = escapeHtml(p.inst_id || ""); - const sheets = escapeHtml(String(p.sheets != null ? p.sheets : "—")); - return "
  • " + id + " · " + sheets + "张 · " + fmtPnlHtml(p.pnl_usdt) + "
  • "; - }); - const more = - list.length > 8 - ? '
  • …共 ' + list.length + " 笔
  • " - : ""; - return "
      " + items.join("") + more + "
    "; - } - - function fmtBidPx(v) { - if (v == null || v === "") return "—"; - const n = Number(v); - if (!Number.isFinite(n)) return "—"; - let s = n.toFixed(4).replace(/\.?0+$/, ""); - return s || "0"; - } - - function isLiquidityWeak(p) { - if (!p) return false; - if (p.liquidity_ok === false || p.bid_invalid) return true; - const uncovered = Number(p.uncovered_sheets); - return Number.isFinite(uncovered) && uncovered > 0; - } - - function bidLiquidityLine(p) { - if (p.bid_invalid || (p.bid_px == null && (p.bid_sz == null || p.bid_sz === ""))) { - const reason = (p.bid_invalid_reason || "无买一/残档").toString(); - const short = reason.length > 18 ? reason.slice(0, 18) + "…" : reason; - return { text: short, weak: true }; - } - const px = fmtBidPx(p.bid_px); - const sz = p.bid_sz != null ? String(p.bid_sz) : "—"; - const uncovered = Number(p.uncovered_sheets); - let text = px + " / " + sz + "张"; - const weak = isLiquidityWeak(p); - if (Number.isFinite(uncovered) && uncovered > 0) { - text += "(差" + uncovered + ")"; - } - return { text: text, weak: weak }; - } - - function bidLiquiditySummary(brief) { - const list = brief || []; - if (!list.length) return '—'; - const items = list.slice(0, 8).map(function (p) { - const line = bidLiquidityLine(p); - const cls = line.weak ? "liq-warn" : "liq-ok"; - return '
  • ' + escapeHtml(line.text) + "
  • "; - }); - const more = - list.length > 8 - ? '
  • …共 ' + list.length + " 笔
  • " - : ""; - const anyWeak = list.some(isLiquidityWeak); - const wrapCls = anyWeak ? "pos-brief liq-brief liq-brief--warn" : "pos-brief liq-brief"; - return '
      ' + items.join("") + more + "
    "; - } - - function renderRow(row) { - const snap = row.snapshot || {}; - const inst = snap.instance || {}; - const pnl = - snap.pnl && snap.pnl.unrealized_usdt != null - ? snap.pnl.unrealized_usdt - : snap.upl_total_usdc; - const brief = snap.positions_brief || snap.positions || []; - const online = !!row.online; - const status = online - ? '在线' - : '离线'; - const err = row.error - ? '
    错误: ' + escapeHtml(row.error) + "
    " - : ""; - return ( - "" + - "" + - escapeHtml(row.name || "") + - "" + - err + - "" + - "" + - status + - "" + - "" + - escapeHtml(inst.mode || "—") + - "" + - "" + - fmtPnlHtml(pnl) + - "" + - "" + - positionSummary(brief) + - "" + - "" + - bidLiquiditySummary(brief) + - "" + - '' + - escapeHtml(row.pulled_at || "—") + - "" + - '
    ' + - '内嵌打开' + - '' + - "
    " + - "" - ); - } - - async function refresh() { - const status = document.getElementById("overview-status"); - const grid = document.getElementById("overview-grid"); - if (!grid) return; - if (status) status.textContent = "刷新中…"; - try { - const r = await fetch("/api/overview"); - const d = await r.json(); - if (!d.ok) throw new Error(d.msg || "失败"); - const list = d.instances || []; - if (!list.length) { - grid.innerHTML = - '

    暂无启用实例,请先到「实例」添加.

    '; - } else { - grid.innerHTML = - '
    ' + - "" + - "" + - "" + - list.map(renderRow).join("") + - "
    名称状态模式浮盈持仓买一/流动性拉取时间操作
    "; - grid.querySelectorAll("[data-open-tab]").forEach(function (btn) { - btn.addEventListener("click", async function () { - const id = btn.getAttribute("data-open-tab"); - const rr = await fetch("/api/instances/" + id + "/open_url"); - const dd = await rr.json(); - if (dd.ok && dd.url) window.open(dd.url, "_blank", "noopener"); - else alert(dd.msg || "无法打开"); - }); - }); - } - if (status) status.textContent = "已更新 · 共 " + list.length + " 台"; - } catch (e) { - if (status) status.textContent = e.message || "加载失败"; - } - } - - function boot() { - const btn = document.getElementById("btn-refresh"); - if (btn) btn.addEventListener("click", refresh); - refresh(); - setInterval(refresh, 10000); - } - - global.HubOverview = { boot: boot, refresh: refresh }; -})(window); +(function (global) { + function fmtPnl(v) { + if (v == null || v === "") return "—"; + const n = Number(v); + if (!Number.isFinite(n)) return "—"; + return (n > 0 ? "+" : "") + n.toFixed(2) + " U"; + } + + function pnlClass(v) { + const n = Number(v); + if (!Number.isFinite(n) || n === 0) return "pnl-flat"; + return n > 0 ? "pnl-up" : "pnl-down"; + } + + function fmtPnlHtml(v) { + return '' + escapeHtml(fmtPnl(v)) + ""; + } + + function escapeHtml(s) { + return String(s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } + + function positionSummary(brief, instanceId, online) { + const list = brief || []; + if (!list.length) return '暂无持仓'; + const items = list.slice(0, 8).map(function (p) { + const id = p.inst_id || ""; + const sheets = escapeHtml(String(p.sheets != null ? p.sheets : "—")); + const invalid = !!(p.bid_invalid || (p.bid_px == null && (p.bid_sz == null || p.bid_sz === ""))); + const reason = (p.bid_invalid_reason || "买一无效,禁止平仓").toString(); + let closeBtn = ""; + if (online && id) { + if (invalid) { + closeBtn = + ''; + } else { + closeBtn = + ''; + } + } + return ( + "
  • " + + escapeHtml(id) + + " · " + + sheets + + "张 · " + + fmtPnlHtml(p.pnl_usdt) + + "" + + closeBtn + + "
  • " + ); + }); + const more = + list.length > 8 + ? '
  • …共 ' + list.length + " 笔
  • " + : ""; + return "
      " + items.join("") + more + "
    "; + } + + function fmtBidPx(v) { + if (v == null || v === "") return "—"; + const n = Number(v); + if (!Number.isFinite(n)) return "—"; + let s = n.toFixed(4).replace(/\.?0+$/, ""); + return s || "0"; + } + + function isLiquidityWeak(p) { + if (!p) return false; + if (p.liquidity_ok === false || p.bid_invalid) return true; + const uncovered = Number(p.uncovered_sheets); + return Number.isFinite(uncovered) && uncovered > 0; + } + + function bidLiquidityLine(p) { + if (p.bid_invalid || (p.bid_px == null && (p.bid_sz == null || p.bid_sz === ""))) { + const reason = (p.bid_invalid_reason || "无买一/残档").toString(); + const short = reason.length > 18 ? reason.slice(0, 18) + "…" : reason; + return { text: short, weak: true }; + } + const px = fmtBidPx(p.bid_px); + const sz = p.bid_sz != null ? String(p.bid_sz) : "—"; + const uncovered = Number(p.uncovered_sheets); + let text = px + " / " + sz + "张"; + const weak = isLiquidityWeak(p); + if (Number.isFinite(uncovered) && uncovered > 0) { + text += "(差" + uncovered + ")"; + } + return { text: text, weak: weak }; + } + + function bidLiquiditySummary(brief) { + const list = brief || []; + if (!list.length) return '—'; + const items = list.slice(0, 8).map(function (p) { + const line = bidLiquidityLine(p); + const cls = line.weak ? "liq-warn" : "liq-ok"; + return '
  • ' + escapeHtml(line.text) + "
  • "; + }); + const more = + list.length > 8 + ? '
  • …共 ' + list.length + " 笔
  • " + : ""; + const anyWeak = list.some(isLiquidityWeak); + const wrapCls = anyWeak ? "pos-brief liq-brief liq-brief--warn" : "pos-brief liq-brief"; + return '
      ' + items.join("") + more + "
    "; + } + + function renderRow(row) { + const snap = row.snapshot || {}; + const inst = snap.instance || {}; + const pnl = + snap.pnl && snap.pnl.unrealized_usdt != null + ? snap.pnl.unrealized_usdt + : snap.upl_total_usdc; + const brief = snap.positions_brief || snap.positions || []; + const online = !!row.online; + const status = online + ? '在线' + : '离线'; + const err = row.error + ? '
    错误: ' + escapeHtml(row.error) + "
    " + : ""; + return ( + "" + + "" + + escapeHtml(row.name || "") + + "" + + err + + "" + + "" + + status + + "" + + "" + + escapeHtml(inst.mode || "—") + + "" + + "" + + fmtPnlHtml(pnl) + + "" + + "" + + positionSummary(brief, row.id, online) + + "" + + "" + + bidLiquiditySummary(brief) + + "" + + '' + + escapeHtml(row.pulled_at || "—") + + "" + + '
    ' + + '内嵌打开' + + '' + + "
    " + + "" + ); + } + + async function requestClose(btn) { + const instanceId = decodeURIComponent(btn.getAttribute("data-close-inst") || ""); + const optId = decodeURIComponent(btn.getAttribute("data-close-opt") || ""); + if (!instanceId || !optId) return; + if ( + !confirm( + "确认向该客户端下发买一限价全平?\n\n合约: " + + optId + + "\n\n无效买一将被拒绝;下单在客户端本地执行。" + ) + ) { + return; + } + const oldText = btn.textContent; + btn.disabled = true; + btn.textContent = "平仓中…"; + try { + const rr = await fetch("/api/instances/" + encodeURIComponent(instanceId) + "/options/close", { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify({ inst_id: optId }), + }); + let dd = {}; + try { + dd = await rr.json(); + } catch (e) { + dd = {}; + } + if (dd && dd.ok) { + let msg = dd.msg || "已提交买一全平"; + if (dd.submitted_sheets != null) msg += " · " + dd.submitted_sheets + " 张"; + if (dd.fully_closed) msg += " · 已全平"; + alert(msg); + await refresh(); + } else { + alert((dd && dd.msg) || "平仓失败"); + btn.disabled = false; + btn.textContent = oldText; + } + } catch (e) { + alert((e && e.message) || "请求失败"); + btn.disabled = false; + btn.textContent = oldText; + } + } + + async function refresh() { + const status = document.getElementById("overview-status"); + const grid = document.getElementById("overview-grid"); + if (!grid) return; + if (status) status.textContent = "刷新中…"; + try { + const r = await fetch("/api/overview"); + const d = await r.json(); + if (!d.ok) throw new Error(d.msg || "失败"); + const list = d.instances || []; + if (!list.length) { + grid.innerHTML = + '

    暂无启用实例,请先到「实例」添加.

    '; + } else { + grid.innerHTML = + '
    ' + + "" + + "" + + "" + + list.map(renderRow).join("") + + "
    名称状态模式浮盈持仓买一/流动性拉取时间操作
    "; + grid.querySelectorAll("[data-open-tab]").forEach(function (btn) { + btn.addEventListener("click", async function () { + const id = btn.getAttribute("data-open-tab"); + const rr = await fetch("/api/instances/" + id + "/open_url"); + const dd = await rr.json(); + if (dd.ok && dd.url) window.open(dd.url, "_blank", "noopener"); + else alert(dd.msg || "无法打开"); + }); + }); + grid.querySelectorAll("[data-close-opt]").forEach(function (btn) { + btn.addEventListener("click", function () { + requestClose(btn); + }); + }); + } + if (status) status.textContent = "已更新 · 共 " + list.length + " 台"; + } catch (e) { + if (status) status.textContent = e.message || "加载失败"; + } + } + + function boot() { + const btn = document.getElementById("btn-refresh"); + if (btn) btn.addEventListener("click", refresh); + refresh(); + setInterval(refresh, 10000); + } + + global.HubOverview = { boot: boot, refresh: refresh }; +})(window);