diff --git a/app.py b/app.py new file mode 100644 index 0000000..a001454 --- /dev/null +++ b/app.py @@ -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//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) diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs new file mode 100644 index 0000000..a6d2314 --- /dev/null +++ b/ecosystem.config.cjs @@ -0,0 +1,13 @@ +module.exports = { + apps: [ + { + name: "crypto_okx_hub", + script: "app.py", + interpreter: "python3", + cwd: __dirname, + env: { + PYTHONUNBUFFERED: "1", + }, + }, + ], +}; diff --git a/lib/__init__.py b/lib/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lib/bridge_client_lib.py b/lib/bridge_client_lib.py new file mode 100644 index 0000000..fcdd3ee --- /dev/null +++ b/lib/bridge_client_lib.py @@ -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 diff --git a/lib/db_lib.py b/lib/db_lib.py new file mode 100644 index 0000000..5637c9c --- /dev/null +++ b/lib/db_lib.py @@ -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()} diff --git a/lib/instances_lib.py b/lib/instances_lib.py new file mode 100644 index 0000000..f359940 --- /dev/null +++ b/lib/instances_lib.py @@ -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() diff --git a/lib/sso_lib.py b/lib/sso_lib.py new file mode 100644 index 0000000..6b55598 --- /dev/null +++ b/lib/sso_lib.py @@ -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}" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f3458d8 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +flask>=3.0,<4 +requests>=2.31,<3 +werkzeug>=3.0,<4 +python-dotenv>=1.0,<2 diff --git a/static/hub.css b/static/hub.css new file mode 100644 index 0000000..bc9c862 --- /dev/null +++ b/static/hub.css @@ -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; } +} diff --git a/static/hub.js b/static/hub.js new file mode 100644 index 0000000..9cf99cb --- /dev/null +++ b/static/hub.js @@ -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 + ? '在线' + : '离线'; + const mode = inst.mode || "—"; + const err = row.error ? '
错误: ' + escapeHtml(row.error) + "
" : ""; + let rows = ""; + (brief || []).slice(0, 6).forEach(function (p) { + rows += + "" + + escapeHtml(p.inst_id || "") + + "" + + escapeHtml(String(p.sheets != null ? p.sheets : "")) + + "" + + escapeHtml(fmtPnl(p.pnl_usdt)) + + ""; + }); + if (!rows) rows = '暂无持仓'; + return ( + '
' + + "

" + + escapeHtml(row.name || "") + + "

" + + "
" + + status + + " · " + + escapeHtml(mode) + + " · 浮盈 " + + escapeHtml(fmtPnl(pnl)) + + "
" + + '
拉取: ' + + escapeHtml(row.pulled_at || "") + + "
" + + err + + "" + + rows + + "
合约张盈亏
" + + '

内嵌打开 ' + + '

' + + "
" + ); + } + + function escapeHtml(s) { + return String(s) + .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 = '

暂无启用实例,请先到「实例」添加.

'; + } 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); diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..28f8a32 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,29 @@ + + + + + + {% block title %}期权中控{% endblock %} + + + + {% if session.get('logged_in') %} +
+
crypto_okx_hub
+ +
+ {% endif %} +
+ {% with messages = get_flashed_messages() %} + {% if messages %}
{{ messages[0] }}
{% endif %} + {% endwith %} + {% block body %}{% endblock %} +
+ {% block scripts %}{% endblock %} + + diff --git a/templates/dashboard.html b/templates/dashboard.html new file mode 100644 index 0000000..5e6a809 --- /dev/null +++ b/templates/dashboard.html @@ -0,0 +1,14 @@ +{% extends "base.html" %} +{% block title %}总览 · 期权中控{% endblock %} +{% block body %} +
+

多机持仓总览

+ +
+

加载中…

+
+{% endblock %} +{% block scripts %} + + +{% endblock %} diff --git a/templates/desk.html b/templates/desk.html new file mode 100644 index 0000000..485c63e --- /dev/null +++ b/templates/desk.html @@ -0,0 +1,31 @@ +{% extends "base.html" %} +{% block title %}工作台 · 期权中控{% endblock %} +{% block body %} +
+ +
+ {% if current and open_url %} +
+ {{ current.name }} + {{ current.base_url }} + 新标签打开 + 回总览 +
+ + {% else %} +
请从左侧选择客户端,或到「实例」添加公网 crypto_okx.
+ {% endif %} +
+
+{% endblock %} diff --git a/templates/instances.html b/templates/instances.html new file mode 100644 index 0000000..6c5e89b --- /dev/null +++ b/templates/instances.html @@ -0,0 +1,54 @@ +{% extends "base.html" %} +{% block title %}实例 · 期权中控{% endblock %} +{% block body %} +

客户端实例

+ +
+

添加实例

+
+ + + + + + + + +
+
+ +
+

已登记 ({{ instances|length }})

+ {% if not instances %} +

暂无实例.先在 crypto_okx 配置 HUB_BRIDGE_TOKEN 并公网可访问.

+ {% endif %} + {% for inst in instances %} +
+ + {{ inst.name }} + {{ inst.base_url }} + {% if inst.enabled %}启用{% else %}停用{% endif %} + +
+ + + + + + + + + +
+
+ + + +
+

+ 内嵌打开 +

+
+ {% endfor %} +
+{% endblock %} diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..d80f787 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,13 @@ +{% extends "base.html" %} +{% block title %}登录 · 期权中控{% endblock %} +{% block body %} +
+

期权中控登录

+

关联客户端仓库 crypto_okx · 本仓不下单

+
+ + + +
+
+{% endblock %} diff --git a/设计文档.md b/设计文档.md index 7708ee5..c49b4d9 100644 --- a/设计文档.md +++ b/设计文档.md @@ -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 | diff --git a/部署文档.md b/部署文档.md index 44717f6..0ef1acd 100644 --- a/部署文档.md +++ b/部署文档.md @@ -5,7 +5,7 @@ 配套说明:**[使用说明.md](./使用说明.md)** · **[设计文档.md](./设计文档.md)** 关联客户端仓库:[crypto_okx](https://git.bz121.com/dekun/crypto_okx.git) -> 当前仓库处于 **设计定稿 / 代码待实现**。下文为第一期部署约定;可执行安装脚本随实现一并补齐。 +> 第一期骨架已可用:登录、登记实例、总览拉快照、工作台内嵌/新标签(SSO)。 ---