This commit is contained in:
dekun
2026-05-24 08:27:40 +08:00
parent 88f4166bb7
commit 695a785832
5 changed files with 644 additions and 123 deletions
+1 -1
View File
@@ -43,7 +43,7 @@ HUB_BRIDGE_TOKEN = (os.getenv("HUB_BRIDGE_TOKEN") or os.getenv("CONTROL_TOKEN")
_trust_raw = (os.getenv("HUB_TRUST_LAN", "true") or "").strip().lower()
HUB_TRUST_LAN = _trust_raw not in ("0", "false", "no", "off")
DIR = Path(__file__).resolve().parent
HUB_BUILD = "20260525-orders-ui"
HUB_BUILD = "20260525-expand-ui"
def _is_local(host: str | None) -> bool:
+284
View File
@@ -493,6 +493,290 @@ button:disabled {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.grid-monitor.grid-monitor-expanded {
grid-template-columns: minmax(0, 1fr);
max-width: 720px;
margin: 0 auto;
}
.card.card-expanded {
grid-column: 1 / -1;
}
.card-expand-hit {
cursor: pointer;
}
.card-expand-hint {
margin-top: 12px;
padding: 8px 10px;
font-size: 11px;
color: var(--muted);
text-align: center;
border: 1px dashed var(--border-soft);
border-radius: 8px;
background: rgba(0, 212, 255, 0.03);
}
.compact-pos-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.compact-pos-line {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
font-size: 12px;
padding: 6px 8px;
background: rgba(0, 0, 0, 0.25);
border-radius: 6px;
border: 1px solid var(--border-soft);
}
.hub-pos-list {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 14px;
}
/* 对齐实盘「实时持仓」pos-card */
.hub-pos-card.pos-card {
background: rgba(10, 16, 28, 0.95);
border: 1px solid var(--border-soft);
border-radius: 10px;
padding: 12px 14px;
}
.hub-pos-card .pos-card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 10px;
}
.hub-pos-card .pos-card-symbol {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
min-width: 0;
}
.hub-pos-card .pos-card-symbol strong {
font-size: 14px;
color: var(--text);
font-weight: 600;
}
.hub-pos-card .pos-side-badge {
padding: 3px 8px;
border-radius: 6px;
font-size: 11px;
font-weight: 500;
}
.hub-pos-card .pos-side-long {
background: rgba(37, 58, 110, 0.9);
color: #6eb5ff;
}
.hub-pos-card .pos-side-short {
background: rgba(74, 34, 48, 0.9);
color: #ff8a8a;
}
.hub-pos-card .pos-head-actions {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.hub-pos-card .pos-entrust-btn {
padding: 6px 12px;
background: rgba(42, 74, 122, 0.9);
color: #8fc8ff;
border: 1px solid rgba(0, 212, 255, 0.25);
border-radius: 8px;
font-size: 12px;
cursor: pointer;
white-space: nowrap;
}
.hub-pos-card .pos-close-btn {
padding: 6px 14px;
background: rgba(196, 84, 84, 0.95);
color: #fff;
border: none;
border-radius: 8px;
font-size: 12px;
cursor: pointer;
white-space: nowrap;
}
.hub-pos-card .pos-meta {
font-size: 11px;
color: var(--muted);
line-height: 1.45;
margin-bottom: 12px;
display: flex;
flex-wrap: wrap;
gap: 4px 0;
}
.hub-pos-card .pos-meta-item:not(:last-child)::after {
content: "|";
margin: 0 8px;
color: var(--border-soft);
}
.hub-pos-card .pos-meta-on {
color: #6eb5ff;
}
.hub-pos-card .pos-meta-off {
color: var(--muted);
}
.hub-pos-card .pos-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px 14px;
margin-bottom: 12px;
}
.hub-pos-card .pos-cell {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.hub-pos-card .pos-label {
font-size: 10px;
color: var(--muted);
letter-spacing: 0.04em;
}
.hub-pos-card .pos-value {
font-size: 13px;
color: var(--text);
font-weight: 500;
}
.hub-pos-card .pos-footer {
display: flex;
flex-wrap: wrap;
gap: 12px 16px;
font-size: 11px;
color: var(--muted);
margin-bottom: 4px;
}
.hub-pos-card .pos-ex-orders {
margin-top: 10px;
padding-top: 10px;
border-top: 1px dashed var(--border-soft);
}
.hub-pos-card .pos-ex-orders-title {
font-size: 11px;
color: var(--muted);
margin-bottom: 6px;
}
.hub-pos-card .pos-ex-order-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
font-size: 12px;
margin-top: 5px;
}
.hub-pos-card .pos-ex-order-main {
flex: 1;
min-width: 0;
}
.hub-pos-card .pos-ex-cancel-btn {
padding: 3px 10px;
background: rgba(58, 48, 72, 0.9);
color: #d4b8ff;
border: 1px solid rgba(123, 97, 255, 0.35);
border-radius: 6px;
font-size: 11px;
cursor: pointer;
flex-shrink: 0;
}
.hub-pos-card .pos-orders-collapse {
margin-top: 10px;
}
.hub-section-card {
margin-top: 14px;
padding: 12px 14px;
background: rgba(0, 0, 0, 0.22);
border: 1px solid var(--border-soft);
border-radius: 10px;
}
.hub-section-head {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--accent);
margin-bottom: 10px;
}
.hub-section-body {
display: flex;
flex-direction: column;
gap: 8px;
}
.hub-mini-card {
padding: 10px 12px;
background: rgba(0, 0, 0, 0.3);
border: 1px solid var(--border-soft);
border-radius: 8px;
}
.hub-mini-title {
font-size: 12px;
font-weight: 600;
color: var(--text);
margin-bottom: 4px;
}
.hub-mini-line {
font-size: 11px;
color: var(--muted);
line-height: 1.45;
}
.pos-empty {
padding: 18px;
text-align: center;
color: var(--muted);
font-size: 12px;
border: 1px dashed var(--border-soft);
border-radius: 10px;
}
@media (max-width: 520px) {
.hub-pos-card .pos-grid {
grid-template-columns: repeat(2, 1fr);
}
}
.settings-grid-wrap {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
+354 -119
View File
@@ -4,6 +4,8 @@
let monitorTimer = null;
let authState = { required: false, logged_in: true };
let tpslPending = null;
let lastMonitorRows = [];
let expandedExchangeId = sessionStorage.getItem("hub_expanded_ex") || "";
async function apiFetch(url, opts) {
const r = await fetch(url, opts);
@@ -113,62 +115,166 @@
gridEl.style.gridTemplateColumns = `repeat(${cols}, minmax(0, 1fr))`;
}
function normSym(s) {
return String(s || "")
.toUpperCase()
.replace(/:USDT$/i, "")
.replace(/\/USDT:USDT$/i, "")
.replace(/\/USDT$/i, "");
}
function symbolsMatchHub(a, b) {
const x = normSym(a);
const y = normSym(b);
if (!x || !y) return false;
return x === y;
}
function ordersCollapseKey(exchangeId, symbol) {
const sym = normSym(symbol) || "unknown";
return `hub_orders_${exchangeId}_${sym}`;
}
function isOrdersCollapseOpen(exchangeId, symbol) {
return localStorage.getItem(ordersCollapseKey(exchangeId, symbol)) === "1";
}
function findMonitorOrder(orders, symbol, side) {
const want = (side || "").toLowerCase();
for (const o of orders || []) {
const sym = o.exchange_symbol || o.symbol || "";
if (!symbolsMatchHub(sym, symbol)) continue;
const d = (o.direction || "").toLowerCase();
if (!d || d === want) return o;
}
return null;
}
function calcRrRatio(side, entry, sl, tp) {
const e = Number(entry);
const s = Number(sl);
const t = Number(tp);
if (![e, s, t].every((n) => Number.isFinite(n) && n > 0)) return null;
if ((side || "long").toLowerCase() === "short") {
const risk = s - e;
const reward = e - t;
if (risk <= 0 || reward <= 0) return null;
return reward / risk;
}
const risk = e - s;
const reward = t - e;
if (risk <= 0 || reward <= 0) return null;
return reward / risk;
}
async function loadMonitorBoard() {
const box = document.getElementById("monitor-grid");
try {
const r = await apiFetch("/api/monitor/board");
const data = await r.json();
const rows = data.rows || [];
const online = rows.filter((x) => x.http_ok && (x.agent || {}).ok !== false).length;
lastMonitorRows = data.rows || [];
const online = lastMonitorRows.filter(
(x) => x.http_ok && (x.agent || {}).ok !== false
).length;
const pill = document.getElementById("sys-status");
if (pill) {
pill.textContent = rows.length ? `LINK ${online}/${rows.length}` : "NO DATA";
pill.classList.toggle("warn", rows.length && online < rows.length);
pill.textContent = lastMonitorRows.length
? `LINK ${online}/${lastMonitorRows.length}`
: "NO DATA";
pill.classList.toggle("warn", lastMonitorRows.length && online < lastMonitorRows.length);
}
document.getElementById("monitor-updated").textContent =
"UPD " + (data.updated_at || "").replace("T", " ");
const parts = rows.map(renderMonitorCard);
box.innerHTML = parts.join("") || '<div class="err">无已启用账户</div>';
syncMonitorGridColumns(box, rows.length);
box.querySelectorAll(".btn-close-ex").forEach((btn) => {
btn.onclick = () => closeOne(btn.dataset.id);
});
box.querySelectorAll(".btn-close-pos").forEach((btn) => {
btn.onclick = () =>
closeOnePosition(btn.dataset.exId, btn.dataset.symbol, btn.dataset.side);
});
box.querySelectorAll(".btn-cancel-order").forEach((btn) => {
btn.onclick = () =>
cancelOneOrder(
btn.dataset.exId,
btn.dataset.symbol,
btn.dataset.orderId,
btn.dataset.channel
);
});
box.querySelectorAll(".btn-cancel-cond-all").forEach((btn) => {
btn.onclick = (ev) => {
ev.preventDefault();
ev.stopPropagation();
cancelSymbolOrders(btn.dataset.exId, btn.dataset.symbol, "conditional");
};
});
box.querySelectorAll(".btn-place-tpsl").forEach((btn) => {
btn.onclick = () =>
openTpslModal(
btn.dataset.exId,
btn.dataset.symbol,
btn.dataset.side,
btn.dataset.contracts,
btn.dataset.sl || "",
btn.dataset.tp || ""
);
});
renderMonitorGrid(lastMonitorRows);
} catch (e) {
box.innerHTML = `<div class="err">${esc(e)}</div>`;
}
}
function renderMonitorGrid(rows) {
const box = document.getElementById("monitor-grid");
if (!box) return;
if (expandedExchangeId && !rows.some((r) => String(r.id) === String(expandedExchangeId))) {
expandedExchangeId = "";
sessionStorage.removeItem("hub_expanded_ex");
}
const visible = expandedExchangeId
? rows.filter((r) => String(r.id) === String(expandedExchangeId))
: rows;
box.classList.toggle("grid-monitor-expanded", !!expandedExchangeId);
const parts = visible.map((r) => renderMonitorCard(r, !!expandedExchangeId));
box.innerHTML = parts.join("") || '<div class="err">无已启用账户</div>';
if (!expandedExchangeId) syncMonitorGridColumns(box, rows.length);
bindMonitorInteractions(box);
}
function bindMonitorInteractions(box) {
box.querySelectorAll(".btn-close-ex").forEach((btn) => {
btn.onclick = () => closeOne(btn.dataset.id);
});
box.querySelectorAll(".btn-close-pos").forEach((btn) => {
btn.onclick = (ev) => {
ev.stopPropagation();
closeOnePosition(btn.dataset.exId, btn.dataset.symbol, btn.dataset.side);
};
});
box.querySelectorAll(".btn-cancel-order").forEach((btn) => {
btn.onclick = (ev) => {
ev.stopPropagation();
cancelOneOrder(
btn.dataset.exId,
btn.dataset.symbol,
btn.dataset.orderId,
btn.dataset.channel
);
};
});
box.querySelectorAll(".btn-cancel-cond-all").forEach((btn) => {
btn.onclick = (ev) => {
ev.preventDefault();
ev.stopPropagation();
cancelSymbolOrders(btn.dataset.exId, btn.dataset.symbol, "conditional");
};
});
box.querySelectorAll(".btn-place-tpsl").forEach((btn) => {
btn.onclick = (ev) => {
ev.stopPropagation();
openTpslModal(
btn.dataset.exId,
btn.dataset.symbol,
btn.dataset.side,
btn.dataset.contracts,
btn.dataset.sl || "",
btn.dataset.tp || ""
);
};
});
box.querySelectorAll(".btn-expand-back").forEach((btn) => {
btn.onclick = (ev) => {
ev.stopPropagation();
expandedExchangeId = "";
sessionStorage.removeItem("hub_expanded_ex");
renderMonitorGrid(lastMonitorRows);
};
});
box.querySelectorAll(".card-expand-hit").forEach((hit) => {
hit.onclick = (ev) => {
if (ev.target.closest("a, button, input, summary, .pos-orders-collapse")) return;
const id = hit.closest(".card")?.dataset.exId;
if (!id || expandedExchangeId) return;
expandedExchangeId = id;
sessionStorage.setItem("hub_expanded_ex", id);
renderMonitorGrid(lastMonitorRows);
};
});
box.querySelectorAll("details.pos-orders-collapse[data-collapse-key]").forEach((el) => {
el.addEventListener("toggle", () => {
const k = el.dataset.collapseKey;
if (k) localStorage.setItem(k, el.open ? "1" : "0");
});
});
}
function renderOrderRows(exchangeId, symbol, orders, kind) {
if (!orders || !orders.length) {
const hint =
@@ -209,38 +315,18 @@
return { sl: triggers[0], tp: triggers[triggers.length - 1] };
}
function renderPositionBlock(exchangeId, x) {
const symAttr = esc(x.symbol || "").replace(/"/g, "&quot;");
const sideAttr = esc((x.side || "").toLowerCase()).replace(/"/g, "&quot;");
const contractsAttr = esc(String(x.contracts != null ? x.contracts : "")).replace(/"/g, "&quot;");
const cond = Array.isArray(x.conditional_orders) ? x.conditional_orders : [];
const reg = Array.isArray(x.regular_orders) ? x.regular_orders : [];
const guess = guessTpslFromCondOrders(x.side, cond);
const slAttr = esc(String(guess.sl)).replace(/"/g, "&quot;");
const tpAttr = esc(String(guess.tp)).replace(/"/g, "&quot;");
function renderOrdersCollapse(exchangeId, symbol, cond, reg) {
const symAttr = esc(symbol || "").replace(/"/g, "&quot;");
const orderTotal = cond.length + reg.length;
const collapseKey = ordersCollapseKey(exchangeId, symbol);
const openAttr = isOrdersCollapseOpen(exchangeId, symbol) ? " open" : "";
const condAllBtn =
cond.length > 0
? `<button type="button" class="btn-cancel-cond-all btn-sm ghost" data-ex-id="${esc(exchangeId)}" data-symbol="${symAttr}">撤销条件单</button>`
: "";
const condBody = renderOrderRows(exchangeId, x.symbol, cond, "conditional");
const regBody = renderOrderRows(exchangeId, x.symbol, reg, "limit");
return `<div class="pos-block">
<table class="data-table"><thead><tr><th>合约</th><th>方向</th><th>张数</th><th>浮盈</th><th>操作</th></tr></thead><tbody>
<tr>
<td>${esc(x.symbol)}</td>
<td>${esc(x.side)}</td>
<td>${fmt(x.contracts, 4)}</td>
<td class="${pnlCls(x.unrealized_pnl)}">${fmt(x.unrealized_pnl, 4)}</td>
<td class="td-actions">
<div class="pos-action-group">
<button type="button" class="btn-place-tpsl btn-sm ghost" data-ex-id="${esc(exchangeId)}" data-symbol="${symAttr}" data-side="${sideAttr}" data-contracts="${contractsAttr}" data-sl="${slAttr}" data-tp="${tpAttr}">委托</button>
<button type="button" class="btn-close-pos btn-sm danger" data-ex-id="${esc(exchangeId)}" data-symbol="${symAttr}" data-side="${sideAttr}">平仓</button>
</div>
</td>
</tr>
</tbody></table>
<details class="pos-orders-collapse">
const condBody = renderOrderRows(exchangeId, symbol, cond, "conditional");
const regBody = renderOrderRows(exchangeId, symbol, reg, "limit");
return `<details class="pos-orders-collapse"${openAttr} data-collapse-key="${esc(collapseKey)}">
<summary class="pos-orders-collapse-summary">
<span class="pos-orders-collapse-label">委托单 <em>${orderTotal}</em></span>
<span class="pos-orders-collapse-meta">条件 ${cond.length} · 普通 ${reg.length}</span>
@@ -256,10 +342,198 @@
${regBody}
</div>
</div>
</details>
</details>`;
}
function renderExTpslRows(exchangeId, symbol, cond) {
const symAttr = esc(symbol || "").replace(/"/g, "&quot;");
const sl = cond.find((o) => (o.label || "").includes("止损"));
const tp = cond.find((o) => (o.label || "").includes("止盈"));
function row(label, o) {
if (!o) {
return `<div class="pos-ex-order-row"><span class="pos-ex-order-main">${label}:—</span></div>`;
}
const oid = esc(o.id || "").replace(/"/g, "&quot;");
const ch = esc(o.channel || "regular").replace(/"/g, "&quot;");
const trig = o.trigger_price != null ? fmt(o.trigger_price, 4) : "—";
return `<div class="pos-ex-order-row">
<span class="pos-ex-order-main">${label}:触发 ${trig} · 数量 ${fmt(o.amount, 4)}</span>
<button type="button" class="pos-ex-cancel-btn btn-cancel-order" data-ex-id="${esc(exchangeId)}" data-symbol="${symAttr}" data-order-id="${oid}" data-channel="${ch}">撤单</button>
</div>`;
}
return row("止损", sl) + row("止盈", tp);
}
function renderLivePositionCard(exchangeId, pos, monitorOrder) {
const symbol = pos.symbol || "";
const side = (pos.side || "long").toLowerCase();
const sideCn = side === "long" ? "做多" : "做空";
const sideCls = side === "long" ? "pos-side-long" : "pos-side-short";
const mo = monitorOrder || {};
const cond = Array.isArray(pos.conditional_orders) ? pos.conditional_orders : [];
const reg = Array.isArray(pos.regular_orders) ? pos.regular_orders : [];
const guess = guessTpslFromCondOrders(side, cond);
const symAttr = esc(symbol).replace(/"/g, "&quot;");
const sideAttr = esc(side).replace(/"/g, "&quot;");
const contractsAttr = esc(String(pos.contracts != null ? pos.contracts : "")).replace(/"/g, "&quot;");
const slAttr = esc(String(mo.stop_loss != null ? mo.stop_loss : guess.sl)).replace(/"/g, "&quot;");
const tpAttr = esc(String(mo.take_profit != null ? mo.take_profit : guess.tp)).replace(/"/g, "&quot;");
const entry = pos.entry_price != null ? pos.entry_price : mo.trigger_price;
const sl = mo.stop_loss != null ? mo.stop_loss : guess.sl;
const tp = mo.take_profit != null ? mo.take_profit : guess.tp;
const rr = calcRrRatio(side, entry, sl, tp);
const upnl = pos.unrealized_pnl;
let pnlText = fmt(upnl, 2) + "U";
if (pos.notional_usdt && upnl != null && Math.abs(Number(pos.notional_usdt)) > 1e-8) {
const pct = (Number(upnl) / Math.abs(Number(pos.notional_usdt))) * 100;
pnlText += ` (${pct >= 0 ? "" : ""}${pct.toFixed(2)}%)`;
}
const meta = [];
if (mo.monitor_type || mo.key_signal_type) {
meta.push(
`来源: ${esc(mo.monitor_type || "下单监控")}${mo.key_signal_type ? " · " + esc(mo.key_signal_type) : ""}`
);
} else {
meta.push("来源: 交易所持仓");
}
if (mo.trade_style) meta.push(`风格: ${esc(mo.trade_style)}`);
else meta.push("风格: —");
if (mo.risk_percent != null) {
meta.push(`风险: ${esc(mo.risk_percent)}%`);
}
const beOn = mo.breakeven_enabled === 1 || mo.breakeven_enabled === true;
meta.push(
`<span class="${beOn ? "pos-meta-on" : "pos-meta-off"}">移动保本:${beOn ? "开" : "关"}</span>`
);
return `<div class="pos-card hub-pos-card">
<div class="pos-card-head">
<div class="pos-card-symbol">
<strong>${esc(symbol)}</strong>
<span class="pos-side-badge ${sideCls}">${sideCn}</span>
</div>
<div class="pos-head-actions">
<button type="button" class="pos-entrust-btn btn-place-tpsl" data-ex-id="${esc(exchangeId)}" data-symbol="${symAttr}" data-side="${sideAttr}" data-contracts="${contractsAttr}" data-sl="${slAttr}" data-tp="${tpAttr}">委托</button>
<button type="button" class="pos-close-btn btn-close-pos" data-ex-id="${esc(exchangeId)}" data-symbol="${symAttr}" data-side="${sideAttr}">平仓</button>
</div>
</div>
<div class="pos-meta">${meta.map((m) => `<span class="pos-meta-item">${m}</span>`).join("")}</div>
<div class="pos-grid">
<div class="pos-cell"><span class="pos-label">成交价</span><span class="pos-value">${entry != null ? fmt(entry, 4) : "—"}</span></div>
<div class="pos-cell"><span class="pos-label">止损</span><span class="pos-value">${sl != null && sl !== "" ? fmt(sl, 4) : "—"}</span></div>
<div class="pos-cell"><span class="pos-label">止盈</span><span class="pos-value">${tp != null && tp !== "" ? fmt(tp, 4) : "—"}</span></div>
<div class="pos-cell"><span class="pos-label">盈亏比</span><span class="pos-value">${rr != null ? fmt(rr, 2) + ":1" : "-:1"}</span></div>
<div class="pos-cell"><span class="pos-label">张数</span><span class="pos-value">${fmt(pos.contracts, 4)}</span></div>
<div class="pos-cell"><span class="pos-label">浮盈亏</span><span class="pos-value ${pnlCls(upnl)}">${pnlText}</span></div>
</div>
<div class="pos-footer">
<span>杠杆: ${mo.leverage != null ? esc(mo.leverage) + "x" : "—"}</span>
<span>计划基数: ${mo.margin_capital != null ? fmt(mo.margin_capital, 2) + "U" : "—"}</span>
<span>仓位占比: ${mo.position_ratio != null ? esc(mo.position_ratio) + "%" : "—"}</span>
</div>
<div class="pos-ex-orders">
<div class="pos-ex-orders-title">交易所止盈止损</div>
${renderExTpslRows(exchangeId, symbol, cond)}
</div>
${renderOrdersCollapse(exchangeId, symbol, cond, reg)}
</div>`;
}
function renderHubSectionCard(title, bodyHtml, emptyHint) {
const inner = bodyHtml || `<div class="pos-empty">${esc(emptyHint || "暂无")}</div>`;
return `<div class="hub-section-card">
<div class="hub-section-head">${esc(title)}</div>
<div class="hub-section-body">${inner}</div>
</div>`;
}
function renderKeySection(keys, kmap) {
if (!keys.length) return "";
return keys
.map((k) => {
const kp = kmap[k.id] || kmap[String(k.id)] || {};
const mt = k.monitor_type || k.type || "";
return `<div class="hub-mini-card">
<div class="hub-mini-title">${esc(k.symbol)} · ${esc(mt)}</div>
<div class="hub-mini-line">上沿 ${esc(k.upper)} / 下沿 ${esc(k.lower)}</div>
<div class="hub-mini-line">${esc(kp.gate_summary || kp.price_display || kp.price || "—")}</div>
</div>`;
})
.join("");
}
function renderStrategySection(orders, trends) {
const parts = [];
(orders || []).forEach((o) => {
parts.push(`<div class="hub-mini-card">
<div class="hub-mini-title">下单监控 #${esc(o.id)} · ${esc(o.symbol || o.exchange_symbol)}</div>
<div class="hub-mini-line">${esc(o.direction)} · 触发 ${fmt(o.trigger_price, 4)} · SL ${fmt(o.stop_loss, 4)} · TP ${fmt(o.take_profit, 4)}</div>
</div>`);
});
(trends || []).forEach((t) => {
parts.push(`<div class="hub-mini-card">
<div class="hub-mini-title">趋势计划 #${esc(t.id)} · ${esc(t.symbol)}</div>
<div class="hub-mini-line">${esc(t.direction)} · SL ${fmt(t.stop_loss, 4)} · TP ${fmt(t.take_profit, 4)}</div>
</div>`;
});
return parts.join("");
}
function renderCompactBody(row, ag, pos, hm, flaskOk, keys, orders, trends, kmap) {
let inner = `<div class="stat-row">
<div class="stat-box"><div class="stat-label">余额</div><div class="stat-value">${fmt(ag.balance_usdt, 2)} <small style="font-size:12px;color:var(--muted)">U</small></div></div>
<div class="stat-box"><div class="stat-label">浮盈合计</div><div class="stat-value ${pnlCls(ag.total_unrealized_pnl)}">${fmt(ag.total_unrealized_pnl, 4)}</div></div>
</div>`;
inner += `<div class="section-title">持仓 · ${pos.length}</div>`;
if (pos.length) {
inner += '<div class="compact-pos-list">';
pos.forEach((p) => {
inner += `<div class="compact-pos-line"><span>${esc(p.symbol)} · ${esc(p.side)}</span><span class="${pnlCls(p.unrealized_pnl)}">${fmt(p.unrealized_pnl, 4)}</span></div>`;
});
inner += "</div>";
} else {
inner += '<div class="empty-hint">无持仓</div>';
}
const keyN = (row.capabilities || []).includes("key") ? keys.length : 0;
const stratN = orders.length + ((row.capabilities || []).includes("trend") ? trends.length : 0);
inner += `<div class="card-expand-hint">点击卡片展开 · 持仓详情 / 关键位 ${keyN} / 策略 ${stratN}</div>`;
return inner;
}
function renderExpandedBody(row, ag, pos, hm, flaskOk, keys, orders, trends, kmap) {
let inner = `<div class="stat-row">
<div class="stat-box"><div class="stat-label">余额</div><div class="stat-value">${fmt(ag.balance_usdt, 2)} <small style="font-size:12px;color:var(--muted)">U</small></div></div>
<div class="stat-box"><div class="stat-label">浮盈合计</div><div class="stat-value ${pnlCls(ag.total_unrealized_pnl)}">${fmt(ag.total_unrealized_pnl, 4)}</div></div>
</div>`;
inner += '<div class="hub-pos-list">';
if (pos.length) {
pos.forEach((p) => {
const mo = findMonitorOrder(orders, p.symbol, p.side);
inner += renderLivePositionCard(row.id, p, mo);
});
} else {
inner += '<div class="pos-empty">暂无持仓</div>';
}
inner += "</div>";
if ((row.capabilities || []).includes("key")) {
if (!flaskOk) {
const fe = row.flask_error || hm.msg || hm.error || "策略 Flask 未连通";
inner += renderHubSectionCard("关键位", `<div class="err">${esc(fe)}</div>`, "");
} else {
inner += renderHubSectionCard("关键位", renderKeySection(keys, kmap), "当前无关键位记录");
}
}
const showStrategy =
orders.length || ((row.capabilities || []).includes("trend") && trends.length);
if (showStrategy) {
inner += renderHubSectionCard(
"策略",
renderStrategySection(orders, (row.capabilities || []).includes("trend") ? trends : []),
"当前无策略记录"
);
}
return inner;
}
function openTpslModal(exchangeId, symbol, side, contracts, slHint, tpHint) {
tpslPending = {
exchangeId,
@@ -398,7 +672,7 @@
}
}
function renderMonitorCard(row) {
function renderMonitorCard(row, expanded) {
const ag = row.agent || {};
const pos = Array.isArray(ag.positions) ? ag.positions : [];
const hm = row.hub_monitor || {};
@@ -418,54 +692,10 @@
} else if (!agOk) {
inner = `<div class="err">${esc(agErr || "子代理返回失败")}</div>`;
inner += `<div class="empty-hint">请检查 PM2 子代理与 <code>${esc(row.agent_url || "")}/status</code></div>`;
} else if (expanded) {
inner = renderExpandedBody(row, ag, pos, hm, flaskOk, keys, orders, trends, kmap);
} else {
inner = `<div class="stat-row">
<div class="stat-box"><div class="stat-label">余额</div><div class="stat-value">${fmt(ag.balance_usdt, 2)} <small style="font-size:12px;color:var(--muted)">U</small></div></div>
<div class="stat-box"><div class="stat-label">浮盈合计</div><div class="stat-value ${pnlCls(ag.total_unrealized_pnl)}">${fmt(ag.total_unrealized_pnl, 4)}</div></div>
</div>`;
inner += `<div class="section-title">交易所持仓</div>`;
if (pos.length) {
inner += pos.map((x) => renderPositionBlock(row.id, x)).join("");
} else {
inner += `<div class="empty-hint">无持仓</div>`;
}
if (orders.length) {
inner += `<div class="section-title">机器人单 · ${orders.length}</div>`;
orders.forEach((o) => {
inner += `<div class="list-line">${esc(o.symbol)} · ${esc(o.direction)} · 触发 ${o.trigger_price}</div>`;
});
}
if ((row.capabilities || []).includes("key")) {
inner += `<div class="section-title">关键位</div>`;
if (!flaskOk) {
const fe = row.flask_error || hm.msg || hm.error || "";
const short =
fe ||
(hm.status === 404
? "HTTP 404:请重启各 crypto_* Flask"
: "策略 Flask 未连通");
inner += `<div class="err">${esc(short)}</div>`;
} else if (!keys.length) {
inner += `<div class="empty-hint">当前无记录</div>`;
} else {
keys.slice(0, 8).forEach((k) => {
const kp = kmap[k.id] || kmap[String(k.id)] || {};
const mt = k.monitor_type || k.type || "";
let line = `${esc(k.symbol)} · ${esc(mt)} · ${k.upper} / ${k.lower}`;
if (kp.price_display != null || kp.price != null) {
line += ` · ${esc(kp.price_display != null ? kp.price_display : kp.price)}`;
}
line += ` · ${esc(kp.gate_summary || "-")}`;
inner += `<div class="list-line">${line}</div>`;
});
}
}
if ((row.capabilities || []).includes("trend") && trends.length) {
inner += `<div class="section-title">趋势计划 · ${trends.length}</div>`;
trends.forEach((t) => {
inner += `<div class="list-line">#${t.id} ${esc(t.symbol)} ${t.direction} · SL ${t.stop_loss} · TP ${t.take_profit}</div>`;
});
}
inner = renderCompactBody(row, ag, pos, hm, flaskOk, keys, orders, trends, kmap);
}
const online = row.http_ok && agOk;
const cardCls = online ? "card-online" : "card-offline";
@@ -477,7 +707,11 @@
const openFlask = flaskOpen
? `<a class="btn-link" href="${esc(flaskOpen)}" target="_blank" rel="noopener">实例</a>`
: "";
return `<div class="card ${cardCls}">
const backBtn = expanded
? `<button type="button" class="ghost btn-expand-back">返回</button>`
: "";
const expandHit = !expanded && online ? " card-expand-hit" : "";
return `<div class="card ${cardCls}${expanded ? " card-expanded" : ""}" data-ex-id="${esc(row.id)}">
<div class="card-head">
<div>
<div class="card-title-row">
@@ -487,12 +721,13 @@
<div class="card-sub">${esc(flaskOpen || "")}</div>
</div>
<div class="card-actions">
${backBtn}
${openFlask}
${review}
<button type="button" class="danger btn-close-ex" data-id="${esc(row.id)}">全平</button>
</div>
</div>
<div class="card-body">${inner}</div>
<div class="card-body${expandHit}">${inner}</div>
</div>`;
}
+2 -2
View File
@@ -7,7 +7,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/assets/app.css?v=20260525-orders-ui" />
<link rel="stylesheet" href="/assets/app.css?v=20260525-expand-ui" />
</head>
<body>
<div class="app-bg" aria-hidden="true"></div>
@@ -101,6 +101,6 @@
</div>
<div id="toast"></div>
<script src="/assets/app.js?v=20260525-orders-ui"></script>
<script src="/assets/app.js?v=20260525-expand-ui"></script>
</body>
</html>
+3 -1
View File
@@ -176,7 +176,9 @@ curl -s http://127.0.0.1:5100/api/ping
| 功能 | 说明 |
|------|------|
| **2×2 卡片** | 仅显示「已启用」账户;每卡含子代理持仓、浮盈、余额 |
| **2×2 卡片** | 仅显示「已启用」账户;**点击卡片**可放大,放大后每仓一张「实盘」风格持仓卡 |
| **放大视图** | 持仓详情(与实例「实时持仓」布局一致)、**关键位**与**策略**各为独立卡片;顶栏「返回」回到网格 |
| **委托单折叠** | 展开/收起状态保存在浏览器本地,**5 秒自动刷新不会重置**(便于填写委托) |
| **条件单 / 委托** | 每个持仓下方展示交易所 **条件单**(默认折叠)与 **普通委托**;数据来自子代理实时拉取(币安含 Algo 通道) |
| **撤单** | 条件单区内单笔「撤单」或「撤销全部」;经中控 `POST /api/orders/{id}/cancel``cancel-symbol` |
| **挂止盈止损** | 持仓行 **「委托」**:弹窗填止损/止盈价 → **先撤该合约全部条件单,再挂新 TP/SL**(币安 / OKX / Gate / Gate趋势 四所统一,逻辑与各实例 `.env` 参数一致) |