第一期骨架:登录、实例登记、多机总览、内嵌/新标签打开。
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 设计文档(第一期)
|
||||
|
||||
> 状态:**设计定稿,待实现**
|
||||
> 状态:**第一期骨架已落地**(登录 / 实例 CRUD / 总览拉取 / 内嵌+新标签 SSO)
|
||||
> 仓库:[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 | 骨架实现:Flask 中控 + 客户端 Bridge/SSO |
|
||||
|
||||
Reference in New Issue
Block a user