feat条件单页本页划入交易户,避免跳转丢失新建表单。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-10-13 14:30:36 +08:00
parent 88337ecdaf
commit 1c3ea77db8
8 changed files with 235 additions and 16 deletions
+37
View File
@@ -597,6 +597,43 @@ def api_open_url(iid: int):
return jsonify({"ok": True, "url": url, "base_url": inst["base_url"], "tab": tab})
@app.route("/api/instances/<int:iid>/transfer", methods=["POST"])
@login_required
def api_instance_transfer(iid: int):
"""条件单页本页划转:中控发指令,实例执行资金户↔交易户."""
from lib.bridge_client_lib import post_instance_transfer
inst = get_instance(iid)
if not inst:
return jsonify({"ok": False, "msg": "不存在"}), 404
data = request.get_json(silent=True) or {}
try:
amount = float(data.get("amount"))
except (TypeError, ValueError):
return jsonify({"ok": False, "msg": "数量无效"}), 400
if amount <= 0:
return jsonify({"ok": False, "msg": "数量须大于 0"}), 400
from_acct = (data.get("from") or "funding").strip() or "funding"
to_acct = (data.get("to") or "trading").strip() or "trading"
ccy = (data.get("ccy") or "USDT").strip().upper() or "USDT"
out = post_instance_transfer(
inst,
amount=amount,
from_acct=from_acct,
to_acct=to_acct,
ccy=ccy,
)
# 成功后轻量刷新总览/条件单缓存,供下次读到新余额;不整页重载
if out.get("ok"):
try:
overview_store.request_refresh()
cond_orders_store.request_refresh()
except Exception:
pass
code = 200 if out.get("ok") else 400
return jsonify(out), code
@app.route("/api/settings/update/hub/version", methods=["GET"])
@login_required
def api_hub_self_version():
+55
View File
@@ -325,6 +325,61 @@ def pull_all_stats(window: str = "7d") -> list[dict[str, Any]]:
return results
def post_instance_transfer(
inst: dict[str, Any],
*,
amount: float,
from_acct: str = "funding",
to_acct: str = "trading",
ccy: str = "USDT",
) -> dict[str, Any]:
"""中控指令:实例账户内划转."""
base = (inst.get("base_url") or "").rstrip("/")
token = (inst.get("bridge_token") or "").strip()
out: dict[str, Any] = {
"ok": False,
"msg": "",
"instance_id": inst.get("id"),
"instance_name": inst.get("name"),
}
if not base or not token:
out["msg"] = "缺少 URL 或 Token"
return out
url = f"{base}/api/hub/options/transfer"
try:
r = requests.post(
url,
headers=_headers(token),
json={
"amount": float(amount),
"from": from_acct,
"to": to_acct,
"ccy": ccy,
},
timeout=max(_timeout(), 30.0),
)
data, err = _response_json(r)
if data is None:
out["msg"] = err or f"HTTP {r.status_code}"
return out
if not isinstance(data, dict):
out["msg"] = f"HTTP {r.status_code}"
return out
out.update(data)
out["instance_id"] = inst.get("id")
out["instance_name"] = inst.get("name")
if r.status_code >= 400 and not out.get("ok"):
out["ok"] = False
out.setdefault("msg", err or f"HTTP {r.status_code}")
return out
except requests.Timeout:
out["msg"] = "划转超时"
return out
except Exception as e:
out["msg"] = str(e) or "划转失败"
return out
def fetch_one_pnl_curve(inst: dict[str, Any], *, range_key: str = "7d") -> dict[str, Any]:
"""拉取单机累计已实现盈亏曲线."""
base = (inst.get("base_url") or "").rstrip("/")
+6
View File
@@ -68,6 +68,10 @@ def _candidates_from_snaps(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
trading_usdt = round(float(trading_usdt), 4)
if funding_usdt is not None:
funding_usdt = round(float(funding_usdt), 4)
transfer_blocked = bool(snap.get("transfer_blocked")) or has_pos
transfer_block_msg = str(snap.get("transfer_block_msg") or "").strip()
if has_pos and not transfer_block_msg:
transfer_block_msg = "期权持仓中禁止划转"
out.append(
{
"id": row.get("id"),
@@ -80,6 +84,8 @@ def _candidates_from_snaps(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
"error": row.get("error") or "",
"trading_usdt": trading_usdt,
"funding_usdt": funding_usdt,
"transfer_blocked": transfer_blocked,
"transfer_block_msg": transfer_block_msg,
}
)
return out
+123 -12
View File
@@ -202,6 +202,8 @@
return m === "sim" || m === "live" ? m : "";
}
var candidatesById = {};
function fmtTradingU(v) {
if (v == null || v === "") return "—";
const n = Number(v);
@@ -232,6 +234,97 @@
});
}
function patchCandidateFunds(instanceId, tradingUsdt, fundingUsdt) {
const id = String(instanceId);
const it = candidatesById[id];
if (it) {
if (tradingUsdt != null) it.trading_usdt = tradingUsdt;
if (fundingUsdt != null) it.funding_usdt = fundingUsdt;
}
const row = document.querySelector(
'.cond-inst-row[data-inst-id="' + id.replace(/"/g, "") + '"]'
);
if (!row) return;
const tradeEl = row.querySelector(".cond-inst-funds--trading");
const fundEl = row.querySelector(".cond-inst-funds--funding");
if (tradeEl) tradeEl.textContent = "交易 " + fmtTradingU(tradingUsdt);
if (fundEl) fundEl.textContent = "资金 " + fmtTradingU(fundingUsdt);
}
function transferFundingToTrading(instanceId) {
const id = String(instanceId || "");
const it = candidatesById[id];
if (!it) return;
if (!it.online) {
window.alert("实例离线,无法划转");
return;
}
if (it.transfer_blocked) {
window.alert(it.transfer_block_msg || "当前不可划转");
return;
}
const fund = Number(it.funding_usdt);
const defAmt =
!Number.isNaN(fund) && fund > 0 ? String(Math.round(fund * 10000) / 10000) : "";
const raw = window.prompt("资金户 → 交易户(USDT)。本页划转,条件单表单不会丢失。", defAmt);
if (raw == null) return;
const amount = Number(String(raw).trim());
if (!(amount > 0)) {
window.alert("请输入大于 0 的数量");
return;
}
const btn = document.querySelector(
'.cond-inst-xfer[data-inst-id="' + id.replace(/"/g, "") + '"]'
);
if (btn) {
btn.disabled = true;
btn.textContent = "划转中…";
}
fetch("/api/instances/" + encodeURIComponent(id) + "/transfer", {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({
amount: amount,
from: "funding",
to: "trading",
ccy: "USDT",
}),
})
.then(function (r) {
return r.json().then(function (d) {
return { http: r.status, d: d };
});
})
.then(function (pack) {
const d = pack.d || {};
if (!d.ok) {
window.alert(d.msg || "划转失败");
return;
}
patchCandidateFunds(id, d.trading_usdt, d.funding_usdt);
const st = document.getElementById("cf-status");
if (st) {
st.textContent =
(it.name || id) +
" 已划入交易 " +
fmtTradingU(amount) +
"(交易 " +
fmtTradingU(d.trading_usdt) +
")";
}
})
.catch(function () {
window.alert("划转请求失败");
})
.finally(function () {
if (btn) {
btn.disabled = !!(it.transfer_blocked || !it.online);
btn.textContent = "划入交易";
}
});
}
function renderCandidateCard(it, checkedIds) {
const disabled = !it.selectable;
const tip = disabled
@@ -245,13 +338,15 @@
: "可选";
const id = String(it.id);
const checked = !disabled && checkedIds.indexOf(id) >= 0;
const fundsTip =
it.funding_usdt != null
? "资金户 " + fmtTradingU(it.funding_usdt)
: "";
const xferBlocked = !!(it.transfer_blocked || !it.online);
const xferTitle = xferBlocked
? it.transfer_block_msg || (!it.online ? "离线" : "不可划转")
: "资金户→交易户,本页完成不丢表单";
return (
'<div class="cond-inst-row' +
(disabled ? " is-off" : "") +
'" data-inst-id="' +
escapeHtml(id) +
'">' +
'<label class="cond-inst' +
(disabled ? " is-off" : "") +
@@ -270,15 +365,23 @@
'<span class="cond-inst-tag">' +
escapeHtml(tip) +
"</span>" +
'<span class="cond-inst-funds" title="' +
escapeHtml(fundsTip || "交易账户可用") +
'">交易 ' +
'<span class="cond-inst-funds cond-inst-funds--trading" title="交易账户可用">交易 ' +
escapeHtml(fmtTradingU(it.trading_usdt)) +
"</span>" +
'<span class="cond-inst-funds cond-inst-funds--funding muted" title="资金账户可用">资金 ' +
escapeHtml(fmtTradingU(it.funding_usdt)) +
"</span>" +
"</label>" +
'<button type="button" class="btn btn-sm cond-inst-open" data-inst-id="' +
encodeURIComponent(id) +
'" title="新标签打开期权实例(可划转)">打开</button>' +
'<button type="button" class="btn btn-sm cond-inst-xfer" data-inst-id="' +
escapeHtml(id) +
'" title="' +
escapeHtml(xferTitle) +
'"' +
(xferBlocked ? " disabled" : "") +
">划入交易</button>" +
'<button type="button" class="btn btn-secondary btn-sm cond-inst-open" data-inst-id="' +
escapeHtml(id) +
'" title="新标签打开实例">打开</button>' +
"</div>"
);
}
@@ -324,8 +427,10 @@
const sim = [];
const live = [];
const other = [];
candidatesById = {};
list.forEach(function (it) {
const m = instanceMode(it);
candidatesById[String(it.id)] = it;
if (m === "sim") sim.push(it);
else if (m === "live") live.push(it);
else other.push(it);
@@ -334,12 +439,18 @@
renderCandidateGroup("sim", sim, checkedIds) +
renderCandidateGroup("live", live, checkedIds) +
renderCandidateGroup("", other, checkedIds);
box.querySelectorAll(".cond-inst-xfer").forEach(function (btn) {
btn.addEventListener("click", function (ev) {
ev.preventDefault();
ev.stopPropagation();
transferFundingToTrading(btn.getAttribute("data-inst-id") || "");
});
});
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");
openInstanceTab(btn.getAttribute("data-inst-id") || "", "options");
});
});
}
+6 -1
View File
@@ -1524,7 +1524,7 @@ input.readonly {
.cond-inst-group-label { margin-bottom: 8px; }
.cond-inst-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
gap: 10px;
}
.cond-inst-row {
@@ -1556,6 +1556,11 @@ input.readonly {
white-space: nowrap;
flex-shrink: 0;
}
.cond-inst-funds--funding {
color: var(--muted);
font-weight: 500;
}
.cond-inst-xfer,
.cond-inst-open {
flex-shrink: 0;
align-self: 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="apple-touch-icon" href="{{ url_for('static', filename='icons/apple-touch-icon.png') }}?v=2">
<link rel="manifest" href="/manifest.webmanifest">
<link rel="stylesheet" href="{{ url_for('static', filename='hub.css') }}?v=47">
<link rel="stylesheet" href="{{ url_for('static', filename='hub.css') }}?v=48">
<script>
(function () {
try {
+2 -2
View File
@@ -68,7 +68,7 @@
<div class="cond-section">
<div class="cond-section-title">客户端</div>
<p class="muted" style="margin:0 0 8px;font-size:.76rem">展示交易账户可用;右侧「打开」新标签进期权实例划转,划完后回此页刷新即可。</p>
<p class="muted" style="margin:0 0 8px;font-size:.76rem">展示交易/资金余额;「划入交易」在本页完成(资金→交易),表单不丢。「打开」仅备用进实例。</p>
<div id="cf-instances"></div>
</div>
@@ -95,6 +95,6 @@
</div>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='cond_orders.js') }}?v=8"></script>
<script src="{{ url_for('static', filename='cond_orders.js') }}?v=9"></script>
<script>HubCondOrders.boot();</script>
{% endblock %}
+5
View File
@@ -19,6 +19,7 @@ class TestCondCandidatesFunds(unittest.TestCase):
"cond_order_locked": False,
"instance": {"mode": "live"},
"funds": {"trading_usdt": 123.456, "funding_usdt": 50.0},
"transfer_blocked": False,
},
},
{
@@ -31,6 +32,8 @@ class TestCondCandidatesFunds(unittest.TestCase):
"cond_order_locked": False,
"instance": {"mode": "sim"},
"funds": {},
"transfer_blocked": True,
"transfer_block_msg": "期权持仓中禁止划转",
},
},
]
@@ -39,8 +42,10 @@ class TestCondCandidatesFunds(unittest.TestCase):
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[0]["transfer_blocked"])
self.assertFalse(out[1]["selectable"])
self.assertIsNone(out[1]["trading_usdt"])
self.assertTrue(out[1]["transfer_blocked"])
def test_open_url_tab_appended(self):
from lib.sso_lib import build_embed_open_url