feat条件单候选展示交易户资金,并支持打开实例划转。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-10-13 14:07:20 +08:00
parent f2605b1e8f
commit 88337ecdaf
8 changed files with 183 additions and 8 deletions
+3 -1
View File
@@ -586,13 +586,15 @@ def api_open_url(iid: int):
inst = get_instance(iid) inst = get_instance(iid)
if not inst: if not inst:
return jsonify({"ok": False, "msg": "不存在"}), 404 return jsonify({"ok": False, "msg": "不存在"}), 404
tab = (request.args.get("tab") or "").strip() or None
url = build_embed_open_url( url = build_embed_open_url(
inst["base_url"], inst["base_url"],
bridge_token=inst["bridge_token"], bridge_token=inst["bridge_token"],
embed_path=inst.get("embed_path") or "/embed", embed_path=inst.get("embed_path") or "/embed",
ttl_sec=3600, ttl_sec=3600,
tab=tab,
) )
return jsonify({"ok": True, "url": url, "base_url": inst["base_url"]}) return jsonify({"ok": True, "url": url, "base_url": inst["base_url"], "tab": tab})
@app.route("/api/settings/update/hub/version", methods=["GET"]) @app.route("/api/settings/update/hub/version", methods=["GET"])
+25
View File
@@ -33,6 +33,15 @@ def _sse_heartbeat_sec() -> float:
return 25.0 return 25.0
def _safe_float(v: Any) -> float | None:
if v is None or v == "":
return None
try:
return float(v)
except (TypeError, ValueError):
return None
def _candidates_from_snaps(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: def _candidates_from_snaps(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = [] out: list[dict[str, Any]] = []
for row in rows or []: for row in rows or []:
@@ -45,6 +54,20 @@ def _candidates_from_snaps(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
mode = str(inst.get("mode") or "").strip().lower() mode = str(inst.get("mode") or "").strip().lower()
if mode not in ("sim", "live"): if mode not in ("sim", "live"):
mode = "" mode = ""
funds = snap.get("funds") if isinstance(snap.get("funds"), dict) else {}
# 开仓吃交易户;优先 avail,回退总额
trading_usdt = _safe_float(funds.get("trading_usdt_avail"))
if trading_usdt is None:
trading_usdt = _safe_float(funds.get("trading_usdt"))
if trading_usdt is None:
trading_usdt = _safe_float(snap.get("trading_usdt"))
funding_usdt = _safe_float(funds.get("funding_usdt"))
if funding_usdt is None:
funding_usdt = _safe_float(snap.get("funding_usdt"))
if trading_usdt is not None:
trading_usdt = round(float(trading_usdt), 4)
if funding_usdt is not None:
funding_usdt = round(float(funding_usdt), 4)
out.append( out.append(
{ {
"id": row.get("id"), "id": row.get("id"),
@@ -55,6 +78,8 @@ def _candidates_from_snaps(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
"selectable": selectable, "selectable": selectable,
"mode": mode, "mode": mode,
"error": row.get("error") or "", "error": row.get("error") or "",
"trading_usdt": trading_usdt,
"funding_usdt": funding_usdt,
} }
) )
return out return out
+7
View File
@@ -24,11 +24,18 @@ def build_embed_open_url(
bridge_token: str, bridge_token: str,
embed_path: str = "/embed", embed_path: str = "/embed",
ttl_sec: int = 120, ttl_sec: int = 120,
tab: str | None = None,
) -> str: ) -> str:
root = (base_url or "").rstrip("/") root = (base_url or "").rstrip("/")
path = embed_path or "/embed" path = embed_path or "/embed"
if not path.startswith("/"): if not path.startswith("/"):
path = "/" + path path = "/" + path
tab_s = "".join(c for c in str(tab or "").strip().lower() if c.isalnum() or c == "_")[
:32
]
if tab_s:
joiner = "&" if "?" in path else "?"
path = f"{path}{joiner}tab={tab_s}"
token = make_sso_token(bridge_token, ttl_sec=ttl_sec) token = make_sso_token(bridge_token, ttl_sec=ttl_sec)
q = urlencode({"token": token, "next": path}) q = urlencode({"token": token, "next": path})
return f"{root}/hub_sso?{q}" return f"{root}/hub_sso?{q}"
+56 -1
View File
@@ -202,6 +202,36 @@
return m === "sim" || m === "live" ? m : ""; return m === "sim" || m === "live" ? m : "";
} }
function fmtTradingU(v) {
if (v == null || v === "") return "—";
const n = Number(v);
if (Number.isNaN(n)) return "—";
return n.toFixed(2) + "U";
}
function openInstanceTab(instanceId, tab) {
if (!instanceId) return;
const q = tab ? "?tab=" + encodeURIComponent(tab) : "";
fetch("/api/instances/" + encodeURIComponent(instanceId) + "/open_url" + q)
.then(function (r) {
return r.json();
})
.then(function (d) {
const url =
d && d.url
? d.url
: "/desk?id=" + encodeURIComponent(instanceId);
window.open(url, "_blank", "noopener,noreferrer");
})
.catch(function () {
window.open(
"/desk?id=" + encodeURIComponent(instanceId),
"_blank",
"noopener,noreferrer"
);
});
}
function renderCandidateCard(it, checkedIds) { function renderCandidateCard(it, checkedIds) {
const disabled = !it.selectable; const disabled = !it.selectable;
const tip = disabled const tip = disabled
@@ -215,7 +245,14 @@
: "可选"; : "可选";
const id = String(it.id); const id = String(it.id);
const checked = !disabled && checkedIds.indexOf(id) >= 0; const checked = !disabled && checkedIds.indexOf(id) >= 0;
const fundsTip =
it.funding_usdt != null
? "资金户 " + fmtTradingU(it.funding_usdt)
: "";
return ( return (
'<div class="cond-inst-row' +
(disabled ? " is-off" : "") +
'">' +
'<label class="cond-inst' + '<label class="cond-inst' +
(disabled ? " is-off" : "") + (disabled ? " is-off" : "") +
'" title="' + '" title="' +
@@ -232,7 +269,17 @@
"</span>" + "</span>" +
'<span class="cond-inst-tag">' + '<span class="cond-inst-tag">' +
escapeHtml(tip) + escapeHtml(tip) +
"</span></label>" "</span>" +
'<span class="cond-inst-funds" title="' +
escapeHtml(fundsTip || "交易账户可用") +
'">交易 ' +
escapeHtml(fmtTradingU(it.trading_usdt)) +
"</span>" +
"</label>" +
'<button type="button" class="btn btn-sm cond-inst-open" data-inst-id="' +
encodeURIComponent(id) +
'" title="新标签打开期权实例(可划转)">打开</button>' +
"</div>"
); );
} }
@@ -287,6 +334,14 @@
renderCandidateGroup("sim", sim, checkedIds) + renderCandidateGroup("sim", sim, checkedIds) +
renderCandidateGroup("live", live, checkedIds) + renderCandidateGroup("live", live, checkedIds) +
renderCandidateGroup("", other, checkedIds); renderCandidateGroup("", other, checkedIds);
box.querySelectorAll(".cond-inst-open").forEach(function (btn) {
btn.addEventListener("click", function (ev) {
ev.preventDefault();
ev.stopPropagation();
const iid = decodeURIComponent(btn.getAttribute("data-inst-id") || "");
openInstanceTab(iid, "options");
});
});
} }
function renderDesktopRows(list) { function renderDesktopRows(list) {
+24 -4
View File
@@ -1524,14 +1524,22 @@ input.readonly {
.cond-inst-group-label { margin-bottom: 8px; } .cond-inst-group-label { margin-bottom: 8px; }
.cond-inst-grid { .cond-inst-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 10px; gap: 10px;
} }
.cond-inst-row {
display: flex;
align-items: stretch;
gap: 8px;
}
.cond-inst-row.is-off { opacity: 0.5; }
.cond-inst { .cond-inst {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 8px; gap: 8px;
flex: 1;
min-width: 0;
padding: 10px 12px; padding: 10px 12px;
border-radius: 10px; border-radius: 10px;
border: 1px solid var(--line); border: 1px solid var(--line);
@@ -1539,9 +1547,21 @@ input.readonly {
cursor: pointer; cursor: pointer;
} }
.cond-inst input { width: auto; margin: 0; } .cond-inst input { width: auto; margin: 0; }
.cond-inst-name { flex: 1; font-weight: 600; } .cond-inst-name { flex: 1; font-weight: 600; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cond-inst-tag { color: var(--muted); font-size: 0.75rem; } .cond-inst-tag { color: var(--muted); font-size: 0.75rem; flex-shrink: 0; }
.cond-inst.is-off { opacity: 0.5; cursor: not-allowed; } .cond-inst-funds {
color: var(--accent-2, #38bdf8);
font-size: 0.78rem;
font-weight: 600;
white-space: nowrap;
flex-shrink: 0;
}
.cond-inst-open {
flex-shrink: 0;
align-self: center;
white-space: nowrap;
}
.cond-inst.is-off { cursor: not-allowed; }
.cond-actions { .cond-actions {
display: flex; display: flex;
align-items: center; align-items: center;
+1 -1
View File
@@ -11,7 +11,7 @@
<link rel="icon" href="{{ url_for('static', filename='icons/favicon.ico') }}?v=2" sizes="any"> <link rel="icon" href="{{ url_for('static', filename='icons/favicon.ico') }}?v=2" sizes="any">
<link rel="apple-touch-icon" href="{{ url_for('static', filename='icons/apple-touch-icon.png') }}?v=2"> <link rel="apple-touch-icon" href="{{ url_for('static', filename='icons/apple-touch-icon.png') }}?v=2">
<link rel="manifest" href="/manifest.webmanifest"> <link rel="manifest" href="/manifest.webmanifest">
<link rel="stylesheet" href="{{ url_for('static', filename='hub.css') }}?v=46"> <link rel="stylesheet" href="{{ url_for('static', filename='hub.css') }}?v=47">
<script> <script>
(function () { (function () {
try { try {
+2 -1
View File
@@ -68,6 +68,7 @@
<div class="cond-section"> <div class="cond-section">
<div class="cond-section-title">客户端</div> <div class="cond-section-title">客户端</div>
<p class="muted" style="margin:0 0 8px;font-size:.76rem">展示交易账户可用;右侧「打开」新标签进期权实例划转,划完后回此页刷新即可。</p>
<div id="cf-instances"></div> <div id="cf-instances"></div>
</div> </div>
@@ -94,6 +95,6 @@
</div> </div>
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script src="{{ url_for('static', filename='cond_orders.js') }}?v=7"></script> <script src="{{ url_for('static', filename='cond_orders.js') }}?v=8"></script>
<script>HubCondOrders.boot();</script> <script>HubCondOrders.boot();</script>
{% endblock %} {% endblock %}
+65
View File
@@ -0,0 +1,65 @@
"""条件单候选:带出交易户资金字段."""
from __future__ import annotations
import unittest
class TestCondCandidatesFunds(unittest.TestCase):
def test_candidates_include_trading_usdt(self):
from lib.cond_orders_cache_lib import _candidates_from_snaps
rows = [
{
"id": 1,
"name": "okx-a",
"online": True,
"error": "",
"snapshot": {
"positions_brief": [],
"cond_order_locked": False,
"instance": {"mode": "live"},
"funds": {"trading_usdt": 123.456, "funding_usdt": 50.0},
},
},
{
"id": 2,
"name": "okx-b",
"online": False,
"error": "timeout",
"snapshot": {
"positions_brief": [{"inst_id": "x"}],
"cond_order_locked": False,
"instance": {"mode": "sim"},
"funds": {},
},
},
]
out = _candidates_from_snaps(rows)
self.assertEqual(len(out), 2)
self.assertTrue(out[0]["selectable"])
self.assertAlmostEqual(out[0]["trading_usdt"], 123.456, places=3)
self.assertAlmostEqual(out[0]["funding_usdt"], 50.0, places=3)
self.assertFalse(out[1]["selectable"])
self.assertIsNone(out[1]["trading_usdt"])
def test_open_url_tab_appended(self):
from lib.sso_lib import build_embed_open_url
url = build_embed_open_url(
"https://okx.example.com",
bridge_token="tok",
embed_path="/embed",
tab="options",
)
self.assertIn("hub_sso?", url)
self.assertIn("next=", url)
# next 已 urlencode,解码后应含 tab=options
from urllib.parse import parse_qs, unquote, urlparse
q = parse_qs(urlparse(url).query)
next_path = unquote(q.get("next", [""])[0])
self.assertIn("tab=options", next_path)
if __name__ == "__main__":
unittest.main()