总览支持向客户端下发买一全平:确认后转发,无效买一拒绝。

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