This commit is contained in:
dekun
2026-05-24 08:41:31 +08:00
parent eebb658dc5
commit 8b0607d83f
6 changed files with 293 additions and 98 deletions
+17 -2
View File
@@ -167,17 +167,32 @@ def register_hub_routes(app):
"SELECT * FROM trend_pullback_plans WHERE status='active' ORDER BY id DESC" "SELECT * FROM trend_pullback_plans WHERE status='active' ORDER BY id DESC"
).fetchall(): ).fetchall():
trends.append(_row_to_dict(row)) trends.append(_row_to_dict(row))
rolls = []
try:
for row in conn.execute(
"SELECT * FROM roll_groups WHERE status='active' ORDER BY id DESC"
).fetchall():
rolls.append(_row_to_dict(row))
except Exception:
pass
conn.close() conn.close()
enrich = c.get("enrich_monitor") enrich = c.get("enrich_monitor")
if callable(enrich): if callable(enrich):
try: try:
payload = enrich(keys=keys, orders=orders, trends=trends) payload = enrich(keys=keys, orders=orders, trends=trends, rolls=rolls)
if isinstance(payload, dict): if isinstance(payload, dict):
return jsonify({"ok": True, **payload}) return jsonify({"ok": True, **payload})
except Exception as e: except Exception as e:
return jsonify({"ok": False, "msg": str(e)}), 500 return jsonify({"ok": False, "msg": str(e)}), 500
return jsonify( return jsonify(
{"ok": True, "keys": keys, "orders": orders, "trends": trends, "key_prices": []} {
"ok": True,
"keys": keys,
"orders": orders,
"trends": trends,
"rolls": rolls,
"key_prices": [],
}
) )
@app.route("/api/hub/add_order", methods=["POST"]) @app.route("/api/hub/add_order", methods=["POST"])
+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() _trust_raw = (os.getenv("HUB_TRUST_LAN", "true") or "").strip().lower()
HUB_TRUST_LAN = _trust_raw not in ("0", "false", "no", "off") HUB_TRUST_LAN = _trust_raw not in ("0", "false", "no", "off")
DIR = Path(__file__).resolve().parent DIR = Path(__file__).resolve().parent
HUB_BUILD = "20260525-expand-fix" HUB_BUILD = "20260525-fullscreen"
def _is_local(host: str | None) -> bool: def _is_local(host: str | None) -> bool:
+51 -7
View File
@@ -493,18 +493,62 @@ button:disabled {
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
} }
.grid-monitor.grid-monitor-expanded { .card-expand-zone {
grid-template-columns: minmax(0, 1fr); cursor: pointer;
max-width: 720px; }
.card-expand-zone:hover .card-title {
color: var(--accent);
}
body.hub-fullscreen-open {
overflow: hidden;
}
.exchange-fullscreen {
position: fixed;
inset: 0;
z-index: 150;
background: rgba(2, 6, 12, 0.92);
backdrop-filter: blur(6px);
overflow: auto;
padding: 16px 20px 24px;
}
.exchange-fullscreen-panel {
max-width: 820px;
margin: 0 auto; margin: 0 auto;
} }
.card.card-expanded { .fs-head {
grid-column: 1 / -1; display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid var(--border-soft);
} }
.card-expand-hit { .fs-title {
cursor: pointer; margin: 0;
font-family: var(--display);
font-size: 18px;
letter-spacing: 0.04em;
}
.fs-sub {
font-size: 11px;
color: var(--muted);
margin-top: 4px;
word-break: break-all;
}
.fs-head-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-end;
} }
.card-expand-hint { .card-expand-hint {
+213 -83
View File
@@ -191,21 +191,59 @@
} }
} }
function closeExchangeFullscreen() {
expandedExchangeId = "";
sessionStorage.removeItem("hub_expanded_ex");
const fs = document.getElementById("exchange-fullscreen");
if (fs) {
fs.classList.add("hidden");
fs.setAttribute("aria-hidden", "true");
}
document.body.classList.remove("hub-fullscreen-open");
}
function openExchangeFullscreen(exId) {
expandedExchangeId = String(exId);
sessionStorage.setItem("hub_expanded_ex", expandedExchangeId);
renderMonitorGrid(lastMonitorRows);
}
function renderMonitorGrid(rows) { function renderMonitorGrid(rows) {
const box = document.getElementById("monitor-grid"); const box = document.getElementById("monitor-grid");
const fs = document.getElementById("exchange-fullscreen");
const fsInner = document.getElementById("exchange-fullscreen-inner");
if (!box) return; if (!box) return;
if (expandedExchangeId && !rows.some((r) => String(r.id) === String(expandedExchangeId))) { if (expandedExchangeId && !rows.some((r) => String(r.id) === String(expandedExchangeId))) {
expandedExchangeId = ""; closeExchangeFullscreen();
sessionStorage.removeItem("hub_expanded_ex");
} }
const visible = expandedExchangeId box.innerHTML =
? rows.filter((r) => String(r.id) === String(expandedExchangeId)) rows.map((r) => renderMonitorCard(r)).join("") || '<div class="err">无已启用账户</div>';
: rows; syncMonitorGridColumns(box, rows.length);
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); bindMonitorInteractions(box);
if (expandedExchangeId && fs && fsInner) {
const row = rows.find((r) => String(r.id) === String(expandedExchangeId));
if (row) {
fsInner.innerHTML = renderFullscreenExchange(row);
fs.classList.remove("hidden");
fs.setAttribute("aria-hidden", "false");
document.body.classList.add("hub-fullscreen-open");
bindMonitorInteractions(fsInner);
fsInner.querySelectorAll(".btn-expand-back").forEach((btn) => {
btn.onclick = (ev) => {
ev.stopPropagation();
closeExchangeFullscreen();
renderMonitorGrid(lastMonitorRows);
};
});
} else {
closeExchangeFullscreen();
}
} else if (fs) {
fs.classList.add("hidden");
fs.setAttribute("aria-hidden", "true");
document.body.classList.remove("hub-fullscreen-open");
}
} }
function bindMonitorInteractions(box) { function bindMonitorInteractions(box) {
@@ -249,22 +287,11 @@
); );
}; };
}); });
box.querySelectorAll(".btn-expand-back").forEach((btn) => { box.querySelectorAll(".card-expand-zone").forEach((zone) => {
btn.onclick = (ev) => { zone.onclick = (ev) => {
ev.stopPropagation(); if (ev.target.closest("a, button, input, summary, details, .card-actions")) return;
expandedExchangeId = ""; const id = zone.closest(".card")?.dataset.exId;
sessionStorage.removeItem("hub_expanded_ex"); if (id) openExchangeFullscreen(id);
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) => { box.querySelectorAll("details.pos-orders-collapse[data-collapse-key]").forEach((el) => {
@@ -461,77 +488,180 @@
.join(""); .join("");
} }
function renderStrategySection(orders, trends) { function renderOrderMonitorSection(orders) {
const parts = []; if (!orders || !orders.length) return "";
(orders || []).forEach((o) => { return orders
parts.push(`<div class="hub-mini-card"> .map(
<div class="hub-mini-title">下单监控 #${esc(o.id)} · ${esc(o.symbol || o.exchange_symbol)}</div> (o) => `<div class="hub-mini-card">
<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 class="hub-mini-title">#${esc(o.id)} · ${esc(o.symbol || o.exchange_symbol)} · ${esc(o.direction)}</div>
</div>`); <div class="hub-mini-line">触发 ${fmt(o.trigger_price, 4)} · SL ${fmt(o.stop_loss, 4)} · TP ${fmt(o.take_profit, 4)} · ${esc(o.trade_style || o.monitor_type || "下单监控")}</div>
}); </div>`
(trends || []).forEach((t) => { )
parts.push(`<div class="hub-mini-card"> .join("");
<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) { function renderTrendSection(trends) {
if (!trends || !trends.length) return "";
return trends
.map(
(t) => `<div class="hub-mini-card">
<div class="hub-mini-title">#${esc(t.id)} · ${esc(t.symbol)} · ${esc(t.direction)}</div>
<div class="hub-mini-line">SL ${fmt(t.stop_loss, 4)} · TP ${fmt(t.take_profit, 4)} · 状态 ${esc(t.status || "active")}</div>
</div>`
)
.join("");
}
function renderRollSection(rolls) {
if (!rolls || !rolls.length) return "";
return rolls
.map(
(g) => `<div class="hub-mini-card">
<div class="hub-mini-title">组 #${esc(g.id)} · 监控单 #${esc(g.order_monitor_id || "—")}</div>
<div class="hub-mini-line">腿数 ${esc(g.leg_count != null ? g.leg_count : "—")} · 止损 ${fmt(g.current_stop_loss, 4)} · ${esc(g.status || "active")}</div>
</div>`
)
.join("");
}
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;");
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>
${renderOrdersCollapse(exchangeId, x.symbol, cond, reg)}
</div>`;
}
function renderGridBody(row, ag, pos, hm, flaskOk, keys, orders, trends, rolls, kmap) {
let inner = `<div class="stat-row"> 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">${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 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>`; </div>`;
inner += `<div class="section-title">持仓 · ${pos.length}</div>`; inner += `<div class="section-title">交易所持仓</div>`;
if (pos.length) { if (pos.length) {
inner += '<div class="compact-pos-list">'; inner += pos.map((p) => renderPositionBlock(row.id, p)).join("");
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 { } else {
inner += '<div class="empty-hint">无持仓</div>'; inner += '<div class="empty-hint">无持仓</div>';
} }
const keyN = (row.capabilities || []).includes("key") ? keys.length : 0; if (orders.length) {
const stratN = orders.length + ((row.capabilities || []).includes("trend") ? trends.length : 0); inner += `<div class="section-title">下单监控 · ${orders.length}</div>`;
inner += `<div class="card-expand-hint">点击卡片展开 · 持仓详情 / 关键位 ${keyN} / 策略 ${stratN}</div>`; orders.forEach((o) => {
inner += `<div class="list-line">${esc(o.symbol || o.exchange_symbol)} · ${esc(o.direction)} · 触发 ${fmt(o.trigger_price, 4)}</div>`;
});
}
if ((row.capabilities || []).includes("key")) {
inner += `<div class="section-title">关键位</div>`;
if (!flaskOk) {
const fe = row.flask_error || hm.msg || hm.error || "策略 Flask 未连通";
inner += `<div class="err">${esc(fe)}</div>`;
} else if (!keys.length) {
inner += '<div class="empty-hint">当前无记录</div>';
} else {
keys.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>`;
});
}
if (rolls.length) {
inner += `<div class="section-title">顺势加仓 · ${rolls.length}</div>`;
rolls.forEach((g) => {
inner += `<div class="list-line">组 #${g.id} · 监控 #${g.order_monitor_id || "—"} · ${g.leg_count != null ? g.leg_count : "—"} 腿</div>`;
});
}
inner += `<div class="card-expand-hint">点击标题栏放大全屏 · 查看持仓卡片 / 关键位 / 策略详情</div>`;
return inner; return inner;
} }
function renderExpandedBody(row, ag, pos, hm, flaskOk, keys, orders, trends, kmap) { function renderFullscreenExchange(row) {
let inner = `<div class="stat-row"> const ag = row.agent || {};
<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> const pos = Array.isArray(ag.positions) ? ag.positions : [];
const hm = row.hub_monitor || {};
const flaskOk = row.flask_ok !== false && hm.ok !== false;
const keys = flaskOk ? hm.keys || [] : [];
const orders = flaskOk ? hm.orders || [] : [];
const trends = flaskOk ? hm.trends || [] : [];
const rolls = flaskOk ? hm.rolls || [] : [];
const kmap = {};
(row.key_prices || []).forEach((k) => {
kmap[k.id] = k;
});
const flaskOpen = row.flask_url_browser || row.flask_url;
const strategyUrl = flaskOpen ? esc(flaskOpen.replace(/\/$/, "") + "/strategy") : "";
let html = `<div class="fs-head">
<div>
<h2 class="fs-title">${esc(row.name)}</h2>
<div class="fs-sub">${esc(flaskOpen || "")}</div>
</div>
<div class="fs-head-actions">
<button type="button" class="ghost btn-expand-back">返回监控</button>
${flaskOpen ? `<a class="btn-link" href="${esc(flaskOpen)}" target="_blank" rel="noopener">打开实例</a>` : ""}
${strategyUrl ? `<a class="btn-link" href="${strategyUrl}" target="_blank" rel="noopener">策略交易</a>` : ""}
<button type="button" class="danger btn-close-ex" data-id="${esc(row.id)}">全平</button>
</div>
</div>`;
if (!row.http_ok || ag.ok === false) {
html += `<div class="err">${esc(row.error || ag.error || "子代理不可用")}</div>`;
return html;
}
html += `<div class="stat-row">
<div class="stat-box"><div class="stat-label">余额</div><div class="stat-value">${fmt(ag.balance_usdt, 2)} U</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 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>`; </div>`;
inner += '<div class="hub-pos-list">'; html += '<div class="section-title">持仓(每币种一卡)</div><div class="hub-pos-list">';
if (pos.length) { if (pos.length) {
pos.forEach((p) => { pos.forEach((p) => {
const mo = findMonitorOrder(orders, p.symbol, p.side); html += renderLivePositionCard(row.id, p, findMonitorOrder(orders, p.symbol, p.side));
inner += renderLivePositionCard(row.id, p, mo);
}); });
} else { } else {
inner += '<div class="pos-empty">暂无持仓</div>'; html += '<div class="pos-empty">暂无持仓</div>';
} }
inner += "</div>"; html += "</div>";
if ((row.capabilities || []).includes("key")) { if ((row.capabilities || []).includes("key")) {
if (!flaskOk) { if (!flaskOk) {
const fe = row.flask_error || hm.msg || hm.error || "策略 Flask 未连通"; html += renderHubSectionCard("关键位", `<div class="err">${esc(row.flask_error || hm.error || "Flask 未连通")}</div>`, "");
inner += renderHubSectionCard("关键位", `<div class="err">${esc(fe)}</div>`, "");
} else { } else {
inner += renderHubSectionCard("关键位", renderKeySection(keys, kmap), "当前无关键位记录"); html += renderHubSectionCard("关键位", renderKeySection(keys, kmap), "当前无关键位记录");
} }
} }
const showStrategy = html += renderHubSectionCard("下单监控", renderOrderMonitorSection(orders), "暂无运行中的下单监控");
orders.length || ((row.capabilities || []).includes("trend") && trends.length); if ((row.capabilities || []).includes("trend")) {
if (showStrategy) { html += renderHubSectionCard("趋势回调", renderTrendSection(trends), "暂无运行中的趋势回调计划");
inner += renderHubSectionCard(
"策略",
renderStrategySection(orders, (row.capabilities || []).includes("trend") ? trends : []),
"当前无策略记录"
);
} }
return inner; html += renderHubSectionCard("顺势加仓", renderRollSection(rolls), "暂无运行中的顺势加仓组");
return html;
} }
function openTpslModal(exchangeId, symbol, side, contracts, slHint, tpHint) { function openTpslModal(exchangeId, symbol, side, contracts, slHint, tpHint) {
@@ -627,7 +757,13 @@
if (cancel) cancel.onclick = closeTpslModal; if (cancel) cancel.onclick = closeTpslModal;
if (submit) submit.onclick = () => submitTpslModal(); if (submit) submit.onclick = () => submitTpslModal();
document.addEventListener("keydown", (ev) => { document.addEventListener("keydown", (ev) => {
if (ev.key === "Escape") closeTpslModal(); if (ev.key === "Escape") {
closeTpslModal();
if (expandedExchangeId) {
closeExchangeFullscreen();
renderMonitorGrid(lastMonitorRows);
}
}
}); });
} }
@@ -672,7 +808,7 @@
} }
} }
function renderMonitorCard(row, expanded) { function renderMonitorCard(row) {
const ag = row.agent || {}; const ag = row.agent || {};
const pos = Array.isArray(ag.positions) ? ag.positions : []; const pos = Array.isArray(ag.positions) ? ag.positions : [];
const hm = row.hub_monitor || {}; const hm = row.hub_monitor || {};
@@ -680,6 +816,7 @@
const keys = flaskOk ? hm.keys || [] : []; const keys = flaskOk ? hm.keys || [] : [];
const orders = flaskOk ? hm.orders || [] : []; const orders = flaskOk ? hm.orders || [] : [];
const trends = flaskOk ? hm.trends || [] : []; const trends = flaskOk ? hm.trends || [] : [];
const rolls = flaskOk ? hm.rolls || [] : [];
const kmap = {}; const kmap = {};
(row.key_prices || []).forEach((k) => { (row.key_prices || []).forEach((k) => {
kmap[k.id] = k; kmap[k.id] = k;
@@ -692,10 +829,8 @@
} else if (!agOk) { } else if (!agOk) {
inner = `<div class="err">${esc(agErr || "子代理返回失败")}</div>`; inner = `<div class="err">${esc(agErr || "子代理返回失败")}</div>`;
inner += `<div class="empty-hint">请检查 PM2 子代理与 <code>${esc(row.agent_url || "")}/status</code></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 { } else {
inner = renderCompactBody(row, ag, pos, hm, flaskOk, keys, orders, trends, kmap); inner = renderGridBody(row, ag, pos, hm, flaskOk, keys, orders, trends, rolls, kmap);
} }
const online = row.http_ok && agOk; const online = row.http_ok && agOk;
const cardCls = online ? "card-online" : "card-offline"; const cardCls = online ? "card-online" : "card-offline";
@@ -707,12 +842,8 @@
const openFlask = flaskOpen const openFlask = flaskOpen
? `<a class="btn-link" href="${esc(flaskOpen)}" target="_blank" rel="noopener">实例</a>` ? `<a class="btn-link" href="${esc(flaskOpen)}" target="_blank" rel="noopener">实例</a>`
: ""; : "";
const backBtn = expanded return `<div class="card ${cardCls}" data-ex-id="${esc(row.id)}">
? `<button type="button" class="ghost btn-expand-back">返回</button>` <div class="card-head card-expand-zone" title="点击放大全屏">
: "";
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>
<div class="card-title-row"> <div class="card-title-row">
<span class="status-dot ${dotCls}" title="${online ? "在线" : "离线"}"></span> <span class="status-dot ${dotCls}" title="${online ? "在线" : "离线"}"></span>
@@ -721,13 +852,12 @@
<div class="card-sub">${esc(flaskOpen || "")}</div> <div class="card-sub">${esc(flaskOpen || "")}</div>
</div> </div>
<div class="card-actions"> <div class="card-actions">
${backBtn}
${openFlask} ${openFlask}
${review} ${review}
<button type="button" class="danger btn-close-ex" data-id="${esc(row.id)}">全平</button> <button type="button" class="danger btn-close-ex" data-id="${esc(row.id)}">全平</button>
</div> </div>
</div> </div>
<div class="card-body${expandHit}">${inner}</div> <div class="card-body">${inner}</div>
</div>`; </div>`;
} }
+8 -2
View File
@@ -7,7 +7,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <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 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-expand-fix" /> <link rel="stylesheet" href="/assets/app.css?v=20260525-fullscreen" />
</head> </head>
<body> <body>
<div class="app-bg" aria-hidden="true"></div> <div class="app-bg" aria-hidden="true"></div>
@@ -56,6 +56,12 @@
<div id="monitor-grid" class="grid-monitor"></div> <div id="monitor-grid" class="grid-monitor"></div>
</div> </div>
<div id="exchange-fullscreen" class="exchange-fullscreen hidden" aria-hidden="true">
<div class="exchange-fullscreen-panel">
<div id="exchange-fullscreen-inner"></div>
</div>
</div>
<div id="page-settings" class="page hidden"> <div id="page-settings" class="page hidden">
<div class="page-head"> <div class="page-head">
<h1><span class="head-tag">CFG</span> 系统设置</h1> <h1><span class="head-tag">CFG</span> 系统设置</h1>
@@ -101,6 +107,6 @@
</div> </div>
<div id="toast"></div> <div id="toast"></div>
<script src="/assets/app.js?v=20260525-expand-fix"></script> <script src="/assets/app.js?v=20260525-fullscreen"></script>
</body> </body>
</html> </html>
+3 -3
View File
@@ -176,9 +176,9 @@ curl -s http://127.0.0.1:5100/api/ping
| 功能 | 说明 | | 功能 | 说明 |
|------|------| |------|------|
| **2×2 卡片** | 仅显示「已启用」账户;**点击卡片**可放大,放大后每仓一张「实盘」风格持仓卡 | | **2×2 主界面** | 四所信息**完整展示**:余额、持仓表、委托/平仓、折叠委托单、下单监控、关键位、趋势/加仓摘要 |
| **放大视图** | 持仓详情(与实例「实时持仓」布局一致)、**关键位**与**策略**各为独立卡片;顶栏「返回」回到网格 | | **全屏放大** | **点击卡片标题栏**(非按钮区)→ 该所**全屏**:每币种一张实盘风格持仓卡;独立卡片:**关键位**、**下单监控**、**趋势回调**、**顺势加仓** |
| **委托单折叠** | 展开/收起状态保存在浏览器本地,**5 秒自动刷新不重置**(便于填写委托) | | **委托单折叠** | 仅「委托单」区块默认折叠;展开状态存浏览器本地,**5 秒刷新不重置** |
| **条件单 / 委托** | 每个持仓下方展示交易所 **条件单**(默认折叠)与 **普通委托**;数据来自子代理实时拉取(币安含 Algo 通道) | | **条件单 / 委托** | 每个持仓下方展示交易所 **条件单**(默认折叠)与 **普通委托**;数据来自子代理实时拉取(币安含 Algo 通道) |
| **撤单** | 条件单区内单笔「撤单」或「撤销全部」;经中控 `POST /api/orders/{id}/cancel``cancel-symbol` | | **撤单** | 条件单区内单笔「撤单」或「撤销全部」;经中控 `POST /api/orders/{id}/cancel``cancel-symbol` |
| **挂止盈止损** | 持仓行 **「委托」**:弹窗填止损/止盈价 → **先撤该合约全部条件单,再挂新 TP/SL**(币安 / OKX / Gate / Gate趋势 四所统一,逻辑与各实例 `.env` 参数一致) | | **挂止盈止损** | 持仓行 **「委托」**:弹窗填止损/止盈价 → **先撤该合约全部条件单,再挂新 TP/SL**(币安 / OKX / Gate / Gate趋势 四所统一,逻辑与各实例 `.env` 参数一致) |