总览支持向客户端下发买一全平:确认后转发,无效买一拒绝。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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, "&")
|
.replace(/&/g, "&")
|
||||||
.replace(/</g, "<")
|
.replace(/</g, "<")
|
||||||
.replace(/>/g, ">")
|
.replace(/>/g, ">")
|
||||||
.replace(/"/g, """);
|
.replace(/"/g, """);
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
||||||
|
|||||||
Reference in New Issue
Block a user