总览支持向客户端下发买一全平:确认后转发,无效买一拒绝。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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/<int:iid>/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/<int:iid>/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/<int:iid>/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)
|
||||
|
||||
+123
-78
@@ -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
|
||||
|
||||
+425
-407
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+267
-188
@@ -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 '<span class="' + pnlClass(v) + '">' + escapeHtml(fmtPnl(v)) + "</span>";
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function positionSummary(brief) {
|
||||
const list = brief || [];
|
||||
if (!list.length) return '<span class="muted">暂无持仓</span>';
|
||||
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 "<li>" + id + " · " + sheets + "张 · " + fmtPnlHtml(p.pnl_usdt) + "</li>";
|
||||
});
|
||||
const more =
|
||||
list.length > 8
|
||||
? '<li class="muted">…共 ' + list.length + " 笔</li>"
|
||||
: "";
|
||||
return "<ul class=\"pos-brief\">" + items.join("") + more + "</ul>";
|
||||
}
|
||||
|
||||
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 '<span class="muted">—</span>';
|
||||
const items = list.slice(0, 8).map(function (p) {
|
||||
const line = bidLiquidityLine(p);
|
||||
const cls = line.weak ? "liq-warn" : "liq-ok";
|
||||
return '<li class="' + cls + '">' + escapeHtml(line.text) + "</li>";
|
||||
});
|
||||
const more =
|
||||
list.length > 8
|
||||
? '<li class="muted">…共 ' + list.length + " 笔</li>"
|
||||
: "";
|
||||
const anyWeak = list.some(isLiquidityWeak);
|
||||
const wrapCls = anyWeak ? "pos-brief liq-brief liq-brief--warn" : "pos-brief liq-brief";
|
||||
return '<ul class="' + wrapCls + '">' + items.join("") + more + "</ul>";
|
||||
}
|
||||
|
||||
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
|
||||
? '<span class="status-on">在线</span>'
|
||||
: '<span class="status-off">离线</span>';
|
||||
const err = row.error
|
||||
? '<div class="muted">错误: ' + escapeHtml(row.error) + "</div>"
|
||||
: "";
|
||||
return (
|
||||
"<tr>" +
|
||||
"<td><strong>" +
|
||||
escapeHtml(row.name || "") +
|
||||
"</strong>" +
|
||||
err +
|
||||
"</td>" +
|
||||
"<td>" +
|
||||
status +
|
||||
"</td>" +
|
||||
"<td>" +
|
||||
escapeHtml(inst.mode || "—") +
|
||||
"</td>" +
|
||||
"<td>" +
|
||||
fmtPnlHtml(pnl) +
|
||||
"</td>" +
|
||||
"<td>" +
|
||||
positionSummary(brief) +
|
||||
"</td>" +
|
||||
"<td>" +
|
||||
bidLiquiditySummary(brief) +
|
||||
"</td>" +
|
||||
'<td class="muted">' +
|
||||
escapeHtml(row.pulled_at || "—") +
|
||||
"</td>" +
|
||||
'<td><div class="overview-actions">' +
|
||||
'<a class="btn" href="/desk?id=' +
|
||||
encodeURIComponent(row.id) +
|
||||
'">内嵌打开</a>' +
|
||||
'<button type="button" class="btn" data-open-tab="' +
|
||||
encodeURIComponent(row.id) +
|
||||
'">新标签</button>' +
|
||||
"</div></td>" +
|
||||
"</tr>"
|
||||
);
|
||||
}
|
||||
|
||||
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 || "无法打开");
|
||||
});
|
||||
});
|
||||
}
|
||||
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 '<span class="' + pnlClass(v) + '">' + escapeHtml(fmtPnl(v)) + "</span>";
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function positionSummary(brief, instanceId, online) {
|
||||
const list = brief || [];
|
||||
if (!list.length) return '<span class="muted">暂无持仓</span>';
|
||||
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 =
|
||||
'<button type="button" class="btn btn-sm btn-danger" disabled title="' +
|
||||
escapeHtml(reason) +
|
||||
'">平仓</button>';
|
||||
} else {
|
||||
closeBtn =
|
||||
'<button type="button" class="btn btn-sm btn-danger" data-close-inst="' +
|
||||
encodeURIComponent(String(instanceId)) +
|
||||
'" data-close-opt="' +
|
||||
encodeURIComponent(id) +
|
||||
'" title="买一限价全平">平仓</button>';
|
||||
}
|
||||
}
|
||||
return (
|
||||
"<li><span class=\"pos-brief-main\">" +
|
||||
escapeHtml(id) +
|
||||
" · " +
|
||||
sheets +
|
||||
"张 · " +
|
||||
fmtPnlHtml(p.pnl_usdt) +
|
||||
"</span>" +
|
||||
closeBtn +
|
||||
"</li>"
|
||||
);
|
||||
});
|
||||
const more =
|
||||
list.length > 8
|
||||
? '<li class="muted">…共 ' + list.length + " 笔</li>"
|
||||
: "";
|
||||
return "<ul class=\"pos-brief\">" + items.join("") + more + "</ul>";
|
||||
}
|
||||
|
||||
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 '<span class="muted">—</span>';
|
||||
const items = list.slice(0, 8).map(function (p) {
|
||||
const line = bidLiquidityLine(p);
|
||||
const cls = line.weak ? "liq-warn" : "liq-ok";
|
||||
return '<li class="' + cls + '">' + escapeHtml(line.text) + "</li>";
|
||||
});
|
||||
const more =
|
||||
list.length > 8
|
||||
? '<li class="muted">…共 ' + list.length + " 笔</li>"
|
||||
: "";
|
||||
const anyWeak = list.some(isLiquidityWeak);
|
||||
const wrapCls = anyWeak ? "pos-brief liq-brief liq-brief--warn" : "pos-brief liq-brief";
|
||||
return '<ul class="' + wrapCls + '">' + items.join("") + more + "</ul>";
|
||||
}
|
||||
|
||||
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
|
||||
? '<span class="status-on">在线</span>'
|
||||
: '<span class="status-off">离线</span>';
|
||||
const err = row.error
|
||||
? '<div class="muted">错误: ' + escapeHtml(row.error) + "</div>"
|
||||
: "";
|
||||
return (
|
||||
"<tr>" +
|
||||
"<td><strong>" +
|
||||
escapeHtml(row.name || "") +
|
||||
"</strong>" +
|
||||
err +
|
||||
"</td>" +
|
||||
"<td>" +
|
||||
status +
|
||||
"</td>" +
|
||||
"<td>" +
|
||||
escapeHtml(inst.mode || "—") +
|
||||
"</td>" +
|
||||
"<td>" +
|
||||
fmtPnlHtml(pnl) +
|
||||
"</td>" +
|
||||
"<td>" +
|
||||
positionSummary(brief, row.id, online) +
|
||||
"</td>" +
|
||||
"<td>" +
|
||||
bidLiquiditySummary(brief) +
|
||||
"</td>" +
|
||||
'<td class="muted">' +
|
||||
escapeHtml(row.pulled_at || "—") +
|
||||
"</td>" +
|
||||
'<td><div class="overview-actions">' +
|
||||
'<a class="btn" href="/desk?id=' +
|
||||
encodeURIComponent(row.id) +
|
||||
'">内嵌打开</a>' +
|
||||
'<button type="button" class="btn" data-open-tab="' +
|
||||
encodeURIComponent(row.id) +
|
||||
'">新标签</button>' +
|
||||
"</div></td>" +
|
||||
"</tr>"
|
||||
);
|
||||
}
|
||||
|
||||
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 =
|
||||
'<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);
|
||||
|
||||
Reference in New Issue
Block a user