第一期骨架:登录、实例登记、多机总览、内嵌/新标签打开。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-10-01 10:54:07 +08:00
parent 55e77d6b2b
commit d20acaf852
17 changed files with 881 additions and 2 deletions
+172
View File
@@ -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)
+13
View File
@@ -0,0 +1,13 @@
module.exports = {
apps: [
{
name: "crypto_okx_hub",
script: "app.py",
interpreter: "python3",
cwd: __dirname,
env: {
PYTHONUNBUFFERED: "1",
},
},
],
};
View File
+78
View File
@@ -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
+46
View File
@@ -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()}
+156
View File
@@ -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()
+34
View File
@@ -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}"
+4
View File
@@ -0,0 +1,4 @@
flask>=3.0,<4
requests>=2.31,<3
werkzeug>=3.0,<4
python-dotenv>=1.0,<2
+125
View File
@@ -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
View File
@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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);
+29
View File
@@ -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>
+14
View File
@@ -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 %}
+31
View File
@@ -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 %}
+54
View File
@@ -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 %}
+13
View File
@@ -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 %}
+2 -1
View File
@@ -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 |
+1 -1
View File
@@ -5,7 +5,7 @@
配套说明:**[使用说明.md](./使用说明.md)** · **[设计文档.md](./设计文档.md)**
关联客户端仓库:[crypto_okx](https://git.bz121.com/dekun/crypto_okx.git)
> 当前仓库处于 **设计定稿 / 代码待实现**。下文为第一期部署约定;可执行安装脚本随实现一并补齐。
> 第一期骨架已可用:登录、登记实例、总览拉快照、工作台内嵌/新标签(SSO)。
---