第一期骨架:登录、实例登记、多机总览、内嵌/新标签打开。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,172 @@
|
|||||||
|
"""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))
|
||||||
|
|
||||||
|
load_dotenv(ROOT / ".env")
|
||||||
|
|
||||||
|
from lib.bridge_client_lib import pull_all_enabled
|
||||||
|
from lib.db_lib import init_db
|
||||||
|
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)
|
||||||
|
HUB_USERNAME = (os.getenv("HUB_USERNAME") or "admin").strip() or "admin"
|
||||||
|
HUB_PASSWORD = (os.getenv("HUB_PASSWORD") or "change-me").strip() or "change-me"
|
||||||
|
SECRET = (os.getenv("FLASK_SECRET_KEY") or "hub-dev-secret").strip()
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.secret_key = SECRET
|
||||||
|
app.config["TEMPLATES_AUTO_RELOAD"] = True
|
||||||
|
|
||||||
|
init_db()
|
||||||
|
|
||||||
|
|
||||||
|
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 "/").strip() or "/"
|
||||||
|
if not nxt.startswith("/"):
|
||||||
|
nxt = "/"
|
||||||
|
return redirect(nxt)
|
||||||
|
flash("用户名或密码错误")
|
||||||
|
if session.get("logged_in"):
|
||||||
|
return redirect("/")
|
||||||
|
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("/desk")
|
||||||
|
@login_required
|
||||||
|
def desk():
|
||||||
|
iid = request.args.get("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",
|
||||||
|
)
|
||||||
|
return render_template(
|
||||||
|
"desk.html",
|
||||||
|
instances=list_instances(enabled_only=True),
|
||||||
|
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",
|
||||||
|
)
|
||||||
|
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)
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
module.exports = {
|
||||||
|
apps: [
|
||||||
|
{
|
||||||
|
name: "crypto_okx_hub",
|
||||||
|
script: "app.py",
|
||||||
|
interpreter: "python3",
|
||||||
|
cwd: __dirname,
|
||||||
|
env: {
|
||||||
|
PYTHONUNBUFFERED: "1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"""拉取各 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
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""SQLite 注册表."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def db_path() -> str:
|
||||||
|
return (os.getenv("DB_PATH") or "hub.db").strip() or "hub.db"
|
||||||
|
|
||||||
|
|
||||||
|
def get_db() -> sqlite3.Connection:
|
||||||
|
conn = sqlite3.connect(db_path(), timeout=30)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def init_db() -> None:
|
||||||
|
conn = get_db()
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS hub_instances (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
base_url TEXT NOT NULL,
|
||||||
|
bridge_token TEXT NOT NULL,
|
||||||
|
embed_path TEXT NOT NULL DEFAULT '/embed',
|
||||||
|
tags TEXT DEFAULT '',
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def row_to_dict(row: sqlite3.Row | None) -> dict[str, Any] | None:
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return {k: row[k] for k in row.keys()}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
"""客户端实例 CRUD."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Optional
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from lib.db_lib import get_db, row_to_dict
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> str:
|
||||||
|
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_base_url(url: str) -> str:
|
||||||
|
u = (url or "").strip().rstrip("/")
|
||||||
|
if not u:
|
||||||
|
return ""
|
||||||
|
if "://" not in u:
|
||||||
|
u = "https://" + u
|
||||||
|
parsed = urlparse(u)
|
||||||
|
if not parsed.netloc:
|
||||||
|
return ""
|
||||||
|
return f"{parsed.scheme}://{parsed.netloc}"
|
||||||
|
|
||||||
|
|
||||||
|
def list_instances(*, enabled_only: bool = False) -> list[dict[str, Any]]:
|
||||||
|
conn = get_db()
|
||||||
|
try:
|
||||||
|
if enabled_only:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM hub_instances WHERE enabled=1 ORDER BY sort_order ASC, id ASC"
|
||||||
|
).fetchall()
|
||||||
|
else:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM hub_instances ORDER BY sort_order ASC, id ASC"
|
||||||
|
).fetchall()
|
||||||
|
return [row_to_dict(r) for r in rows if r]
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_instance(iid: int) -> Optional[dict[str, Any]]:
|
||||||
|
conn = get_db()
|
||||||
|
try:
|
||||||
|
row = conn.execute("SELECT * FROM hub_instances WHERE id=?", (int(iid),)).fetchone()
|
||||||
|
return row_to_dict(row)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def create_instance(
|
||||||
|
*,
|
||||||
|
name: str,
|
||||||
|
base_url: str,
|
||||||
|
bridge_token: str,
|
||||||
|
embed_path: str = "/embed",
|
||||||
|
tags: str = "",
|
||||||
|
enabled: bool = True,
|
||||||
|
sort_order: int = 0,
|
||||||
|
) -> tuple[Optional[dict[str, Any]], str]:
|
||||||
|
name = (name or "").strip()
|
||||||
|
token = (bridge_token or "").strip()
|
||||||
|
base = normalize_base_url(base_url)
|
||||||
|
path = (embed_path or "/embed").strip() or "/embed"
|
||||||
|
if not path.startswith("/"):
|
||||||
|
path = "/" + path
|
||||||
|
if not name:
|
||||||
|
return None, "名称不能为空"
|
||||||
|
if not base:
|
||||||
|
return None, "公网 URL 无效"
|
||||||
|
if not token:
|
||||||
|
return None, "Bridge Token 不能为空"
|
||||||
|
now = _now()
|
||||||
|
conn = get_db()
|
||||||
|
try:
|
||||||
|
cur = conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO hub_instances(
|
||||||
|
name, base_url, bridge_token, embed_path, tags, enabled, sort_order, created_at, updated_at
|
||||||
|
) VALUES (?,?,?,?,?,?,?,?,?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
name,
|
||||||
|
base,
|
||||||
|
token,
|
||||||
|
path,
|
||||||
|
(tags or "").strip(),
|
||||||
|
1 if enabled else 0,
|
||||||
|
int(sort_order or 0),
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return get_instance(int(cur.lastrowid)), ""
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def update_instance(iid: int, **fields: Any) -> tuple[Optional[dict[str, Any]], str]:
|
||||||
|
existing = get_instance(iid)
|
||||||
|
if not existing:
|
||||||
|
return None, "实例不存在"
|
||||||
|
name = fields.get("name", existing["name"])
|
||||||
|
base_url = fields.get("base_url", existing["base_url"])
|
||||||
|
token = fields.get("bridge_token", existing["bridge_token"])
|
||||||
|
embed_path = fields.get("embed_path", existing["embed_path"])
|
||||||
|
tags = fields.get("tags", existing.get("tags") or "")
|
||||||
|
enabled = fields.get("enabled", existing["enabled"])
|
||||||
|
sort_order = fields.get("sort_order", existing["sort_order"])
|
||||||
|
|
||||||
|
name = (name or "").strip()
|
||||||
|
token = (token or "").strip()
|
||||||
|
base = normalize_base_url(str(base_url))
|
||||||
|
path = (str(embed_path) or "/embed").strip() or "/embed"
|
||||||
|
if not path.startswith("/"):
|
||||||
|
path = "/" + path
|
||||||
|
if not name or not base or not token:
|
||||||
|
return None, "名称 / URL / Token 均必填"
|
||||||
|
|
||||||
|
conn = get_db()
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE hub_instances SET
|
||||||
|
name=?, base_url=?, bridge_token=?, embed_path=?, tags=?,
|
||||||
|
enabled=?, sort_order=?, updated_at=?
|
||||||
|
WHERE id=?
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
name,
|
||||||
|
base,
|
||||||
|
token,
|
||||||
|
path,
|
||||||
|
(tags or "").strip(),
|
||||||
|
1 if bool(int(enabled)) else 0,
|
||||||
|
int(sort_order or 0),
|
||||||
|
_now(),
|
||||||
|
int(iid),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return get_instance(iid), ""
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def delete_instance(iid: int) -> bool:
|
||||||
|
conn = get_db()
|
||||||
|
try:
|
||||||
|
cur = conn.execute("DELETE FROM hub_instances WHERE id=?", (int(iid),))
|
||||||
|
conn.commit()
|
||||||
|
return cur.rowcount > 0
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""SSO 打开链(与 crypto_okx lib.hub.hub_bridge_auth 算法一致)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import time
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
|
||||||
|
def make_sso_token(bridge_token: str, *, ttl_sec: int = 120) -> str:
|
||||||
|
exp = int(time.time()) + max(30, int(ttl_sec))
|
||||||
|
payload = str(exp)
|
||||||
|
sig = hmac.new(
|
||||||
|
bridge_token.encode("utf-8"),
|
||||||
|
payload.encode("utf-8"),
|
||||||
|
hashlib.sha256,
|
||||||
|
).hexdigest()
|
||||||
|
return f"{payload}.{sig}"
|
||||||
|
|
||||||
|
|
||||||
|
def build_embed_open_url(
|
||||||
|
base_url: str,
|
||||||
|
*,
|
||||||
|
bridge_token: str,
|
||||||
|
embed_path: str = "/embed",
|
||||||
|
ttl_sec: int = 120,
|
||||||
|
) -> str:
|
||||||
|
root = (base_url or "").rstrip("/")
|
||||||
|
path = embed_path or "/embed"
|
||||||
|
if not path.startswith("/"):
|
||||||
|
path = "/" + path
|
||||||
|
token = make_sso_token(bridge_token, ttl_sec=ttl_sec)
|
||||||
|
q = urlencode({"token": token, "next": path})
|
||||||
|
return f"{root}/hub_sso?{q}"
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
flask>=3.0,<4
|
||||||
|
requests>=2.31,<3
|
||||||
|
werkzeug>=3.0,<4
|
||||||
|
python-dotenv>=1.0,<2
|
||||||
+125
@@ -0,0 +1,125 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #0f1219;
|
||||||
|
--panel: #171c27;
|
||||||
|
--line: #2a3348;
|
||||||
|
--text: #e8ecf5;
|
||||||
|
--muted: #8b95ab;
|
||||||
|
--accent: #6c5ce7;
|
||||||
|
--ok: #3dd68c;
|
||||||
|
--bad: #ff6b6b;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: "Segoe UI", system-ui, sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
a { color: #9ec1ff; text-decoration: none; }
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
background: #12161f;
|
||||||
|
}
|
||||||
|
.topbar nav a { margin-left: 14px; color: var(--muted); }
|
||||||
|
.topbar nav a.active { color: #fff; }
|
||||||
|
.brand { font-weight: 700; }
|
||||||
|
.main { padding: 16px; max-width: 1200px; margin: 0 auto; }
|
||||||
|
.page-head { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.muted { color: var(--muted); font-size: 0.9rem; }
|
||||||
|
.flash {
|
||||||
|
background: #2a2140;
|
||||||
|
border: 1px solid #5b4a8a;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.login-card {
|
||||||
|
max-width: 360px;
|
||||||
|
margin: 10vh auto;
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
.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: #0d1017;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
button, .btn {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 0;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn-danger { background: #8b3a3a; }
|
||||||
|
.panel {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 14px;
|
||||||
|
margin: 14px 0;
|
||||||
|
}
|
||||||
|
.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; }
|
||||||
|
.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: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
.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: 200px 1fr; gap: 12px; min-height: 70vh; max-width: none; }
|
||||||
|
.desk-side {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
.desk-list { list-style: none; padding: 0; margin: 0; }
|
||||||
|
.desk-list li { margin: 6px 0; }
|
||||||
|
.desk-list li.active a { color: #fff; font-weight: 600; }
|
||||||
|
.desk-main { display: flex; flex-direction: column; min-height: 70vh; }
|
||||||
|
.desk-toolbar {
|
||||||
|
display: flex; gap: 10px; align-items: center; flex-wrap: wrap;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.desk-frame {
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 65vh;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
.empty { padding: 40px; color: var(--muted); }
|
||||||
|
@media (max-width: 800px) {
|
||||||
|
.desk { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
+109
@@ -0,0 +1,109 @@
|
|||||||
|
(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 renderCard(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 mode = inst.mode || "—";
|
||||||
|
const err = row.error ? '<div class="muted">错误: ' + escapeHtml(row.error) + "</div>" : "";
|
||||||
|
let rows = "";
|
||||||
|
(brief || []).slice(0, 6).forEach(function (p) {
|
||||||
|
rows +=
|
||||||
|
"<tr><td>" +
|
||||||
|
escapeHtml(p.inst_id || "") +
|
||||||
|
"</td><td>" +
|
||||||
|
escapeHtml(String(p.sheets != null ? p.sheets : "")) +
|
||||||
|
"</td><td>" +
|
||||||
|
escapeHtml(fmtPnl(p.pnl_usdt)) +
|
||||||
|
"</td></tr>";
|
||||||
|
});
|
||||||
|
if (!rows) rows = '<tr><td colspan="3" class="muted">暂无持仓</td></tr>';
|
||||||
|
return (
|
||||||
|
'<article class="card">' +
|
||||||
|
"<h3>" +
|
||||||
|
escapeHtml(row.name || "") +
|
||||||
|
"</h3>" +
|
||||||
|
"<div>" +
|
||||||
|
status +
|
||||||
|
" · " +
|
||||||
|
escapeHtml(mode) +
|
||||||
|
" · 浮盈 " +
|
||||||
|
escapeHtml(fmtPnl(pnl)) +
|
||||||
|
"</div>" +
|
||||||
|
'<div class="muted">拉取: ' +
|
||||||
|
escapeHtml(row.pulled_at || "") +
|
||||||
|
"</div>" +
|
||||||
|
err +
|
||||||
|
"<table><thead><tr><th>合约</th><th>张</th><th>盈亏</th></tr></thead><tbody>" +
|
||||||
|
rows +
|
||||||
|
"</tbody></table>" +
|
||||||
|
'<p><a class="btn" href="/desk?id=' +
|
||||||
|
encodeURIComponent(row.id) +
|
||||||
|
'">内嵌打开</a> ' +
|
||||||
|
'<button type="button" class="btn" data-open-tab="' +
|
||||||
|
encodeURIComponent(row.id) +
|
||||||
|
'">新标签</button></p>' +
|
||||||
|
"</article>"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s)
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = list.map(renderCard).join("");
|
||||||
|
grid.querySelectorAll("[data-open-tab]").forEach(function (btn) {
|
||||||
|
btn.addEventListener("click", async function () {
|
||||||
|
const id = btn.getAttribute("data-open-tab");
|
||||||
|
const rr = await fetch("/api/instances/" + id + "/open_url");
|
||||||
|
const dd = await rr.json();
|
||||||
|
if (dd.ok && dd.url) window.open(dd.url, "_blank", "noopener");
|
||||||
|
else alert(dd.msg || "无法打开");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (status) status.textContent = "已更新 · 共 " + list.length + " 台";
|
||||||
|
} catch (e) {
|
||||||
|
if (status) status.textContent = e.message || "加载失败";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function boot() {
|
||||||
|
const btn = document.getElementById("btn-refresh");
|
||||||
|
if (btn) btn.addEventListener("click", refresh);
|
||||||
|
refresh();
|
||||||
|
setInterval(refresh, 10000);
|
||||||
|
}
|
||||||
|
|
||||||
|
global.HubOverview = { boot: boot, refresh: refresh };
|
||||||
|
})(window);
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{% block title %}期权中控{% endblock %}</title>
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='hub.css') }}">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{% if session.get('logged_in') %}
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="brand">crypto_okx_hub</div>
|
||||||
|
<nav>
|
||||||
|
<a href="/" class="{% if request.path == '/' %}active{% endif %}">总览</a>
|
||||||
|
<a href="/instances" class="{% if request.path.startswith('/instances') %}active{% endif %}">实例</a>
|
||||||
|
<a href="/desk" class="{% if request.path.startswith('/desk') %}active{% endif %}">工作台</a>
|
||||||
|
<a href="/logout">退出</a>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
{% endif %}
|
||||||
|
<main class="main">
|
||||||
|
{% with messages = get_flashed_messages() %}
|
||||||
|
{% if messages %}<div class="flash">{{ messages[0] }}</div>{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
{% block body %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
{% block scripts %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}总览 · 期权中控{% endblock %}
|
||||||
|
{% block body %}
|
||||||
|
<section class="page-head">
|
||||||
|
<h1>多机持仓总览</h1>
|
||||||
|
<button type="button" class="btn" id="btn-refresh">刷新</button>
|
||||||
|
</section>
|
||||||
|
<p class="muted" id="overview-status">加载中…</p>
|
||||||
|
<div id="overview-grid" class="card-grid"></div>
|
||||||
|
{% endblock %}
|
||||||
|
{% block scripts %}
|
||||||
|
<script src="{{ url_for('static', filename='hub.js') }}"></script>
|
||||||
|
<script>HubOverview.boot();</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}工作台 · 期权中控{% endblock %}
|
||||||
|
{% block body %}
|
||||||
|
<section class="desk">
|
||||||
|
<aside class="desk-side">
|
||||||
|
<h2>客户端</h2>
|
||||||
|
<ul class="desk-list">
|
||||||
|
{% for inst in instances %}
|
||||||
|
<li class="{% if current and current.id == inst.id %}active{% endif %}">
|
||||||
|
<a href="/desk?id={{ inst.id }}">{{ inst.name }}</a>
|
||||||
|
</li>
|
||||||
|
{% else %}
|
||||||
|
<li class="muted">无启用实例</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</aside>
|
||||||
|
<div class="desk-main">
|
||||||
|
{% if current and open_url %}
|
||||||
|
<div class="desk-toolbar">
|
||||||
|
<strong>{{ current.name }}</strong>
|
||||||
|
<span class="muted">{{ current.base_url }}</span>
|
||||||
|
<a class="btn" href="{{ open_url }}" target="_blank" rel="noopener">新标签打开</a>
|
||||||
|
<a class="btn" href="/" >回总览</a>
|
||||||
|
</div>
|
||||||
|
<iframe class="desk-frame" src="{{ open_url }}" title="{{ current.name }}"></iframe>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty">请从左侧选择客户端,或到「实例」添加公网 crypto_okx.</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}实例 · 期权中控{% endblock %}
|
||||||
|
{% block body %}
|
||||||
|
<section class="page-head"><h1>客户端实例</h1></section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>添加实例</h2>
|
||||||
|
<form method="post" class="form-grid">
|
||||||
|
<input type="hidden" name="action" value="create">
|
||||||
|
<label>名称 <input name="name" required placeholder="ETH-主策略"></label>
|
||||||
|
<label>公网 URL <input name="base_url" required placeholder="https://okx-eth.example.com"></label>
|
||||||
|
<label>Bridge Token <input name="bridge_token" required placeholder="与客户端 HUB_BRIDGE_TOKEN 一致"></label>
|
||||||
|
<label>Embed 路径 <input name="embed_path" value="/embed"></label>
|
||||||
|
<label>标签 <input name="tags" placeholder="eth,sim"></label>
|
||||||
|
<label class="check"><input type="checkbox" name="enabled" value="1" checked> 启用拉取</label>
|
||||||
|
<button type="submit">添加</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>已登记 ({{ instances|length }})</h2>
|
||||||
|
{% if not instances %}
|
||||||
|
<p class="muted">暂无实例.先在 crypto_okx 配置 HUB_BRIDGE_TOKEN 并公网可访问.</p>
|
||||||
|
{% endif %}
|
||||||
|
{% for inst in instances %}
|
||||||
|
<details class="inst-row">
|
||||||
|
<summary>
|
||||||
|
<strong>{{ inst.name }}</strong>
|
||||||
|
<span class="muted">{{ inst.base_url }}</span>
|
||||||
|
{% if inst.enabled %}启用{% else %}停用{% endif %}
|
||||||
|
</summary>
|
||||||
|
<form method="post" class="form-grid">
|
||||||
|
<input type="hidden" name="action" value="update">
|
||||||
|
<input type="hidden" name="id" value="{{ inst.id }}">
|
||||||
|
<label>名称 <input name="name" value="{{ inst.name }}" required></label>
|
||||||
|
<label>公网 URL <input name="base_url" value="{{ inst.base_url }}" required></label>
|
||||||
|
<label>Bridge Token <input name="bridge_token" value="{{ inst.bridge_token }}" required></label>
|
||||||
|
<label>Embed 路径 <input name="embed_path" value="{{ inst.embed_path }}"></label>
|
||||||
|
<label>标签 <input name="tags" value="{{ inst.tags or '' }}"></label>
|
||||||
|
<label class="check"><input type="checkbox" name="enabled" value="1" {% if inst.enabled %}checked{% endif %}> 启用</label>
|
||||||
|
<button type="submit">保存</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" class="inline" onsubmit="return confirm('确认删除?')">
|
||||||
|
<input type="hidden" name="action" value="delete">
|
||||||
|
<input type="hidden" name="id" value="{{ inst.id }}">
|
||||||
|
<button type="submit" class="btn-danger">删除</button>
|
||||||
|
</form>
|
||||||
|
<p>
|
||||||
|
<a class="btn" href="/desk?id={{ inst.id }}">内嵌打开</a>
|
||||||
|
</p>
|
||||||
|
</details>
|
||||||
|
{% endfor %}
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}登录 · 期权中控{% endblock %}
|
||||||
|
{% block body %}
|
||||||
|
<section class="login-card">
|
||||||
|
<h1>期权中控登录</h1>
|
||||||
|
<p class="muted">关联客户端仓库 crypto_okx · 本仓不下单</p>
|
||||||
|
<form method="post" class="stack">
|
||||||
|
<label>用户名 <input name="username" autocomplete="username" required></label>
|
||||||
|
<label>密码 <input type="password" name="password" autocomplete="current-password" required></label>
|
||||||
|
<button type="submit">登录</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# crypto_okx_hub 设计文档(第一期)
|
# crypto_okx_hub 设计文档(第一期)
|
||||||
|
|
||||||
> 状态:**设计定稿,待实现**
|
> 状态:**第一期骨架已落地**(登录 / 实例 CRUD / 总览拉取 / 内嵌+新标签 SSO)
|
||||||
> 仓库:[https://git.bz121.com/dekun/crypto_okx_hub.git](https://git.bz121.com/dekun/crypto_okx_hub.git)
|
> 仓库:[https://git.bz121.com/dekun/crypto_okx_hub.git](https://git.bz121.com/dekun/crypto_okx_hub.git)
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -253,3 +253,4 @@ crypto_okx_hub/
|
|||||||
| 日期 | 说明 |
|
| 日期 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 2026-10-01 | 初稿:双仓、公网客户端、内嵌+新标签、总览第一期 |
|
| 2026-10-01 | 初稿:双仓、公网客户端、内嵌+新标签、总览第一期 |
|
||||||
|
| 2026-10-01 | 骨架实现:Flask 中控 + 客户端 Bridge/SSO |
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
配套说明:**[使用说明.md](./使用说明.md)** · **[设计文档.md](./设计文档.md)**
|
配套说明:**[使用说明.md](./使用说明.md)** · **[设计文档.md](./设计文档.md)**
|
||||||
关联客户端仓库:[crypto_okx](https://git.bz121.com/dekun/crypto_okx.git)
|
关联客户端仓库:[crypto_okx](https://git.bz121.com/dekun/crypto_okx.git)
|
||||||
|
|
||||||
> 当前仓库处于 **设计定稿 / 代码待实现**。下文为第一期部署约定;可执行安装脚本随实现一并补齐。
|
> 第一期骨架已可用:登录、登记实例、总览拉快照、工作台内嵌/新标签(SSO)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user