Split OKX monitor into perp and options cards.

Align four exchange cards in a 2x2 grid so 1080p layout stays balanced.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-16 13:47:21 +08:00
parent bde8c517e0
commit 018b4d5601
4 changed files with 98 additions and 52 deletions
+26
View File
@@ -4,6 +4,32 @@
--- ---
## 2026-07-16 · OKX 拆成永续/期权双卡(2×2 对齐)
### 修改原因
OKX 单卡内嵌永续+期权过高,右侧币安/Gate 两卡对不齐,1080p 观感不协调。按产品建议改为四卡对齐。
### 修改的地方
| 文件 | 改动摘要 |
|------|----------|
| `manual_trading_hub/static/app.js` | 期权分栏时 OKX 渲染为「·永续」「·期权」两张独立卡;标题与操作按钮按卡片分流 |
| `manual_trading_hub/static/app.css` | 左右列均 `1fr/1fr`,四卡 2×2 等高对齐;卡体内滚 |
| `manual_trading_hub/static/index.html` | 静态资源缓存 `20260716-hub-okx-2x2` |
### 达成的目标
监控区呈现:左上永续 / 左下期权 / 右上币安 / 右下 Gate,四卡对齐。
### 交付之后的验收
1. 桌面监控区可见 `OKX_趋势 · 永续``OKX_趋势 · 期权` 两张独立卡。
2. 四卡与右侧币安、Gate 同行等高,不再一大两小。
3. 点击任一张 OKX 卡标题仍可全屏;期权卡「全平」不重复出现。
---
## 2026-07-16 · 1080p 监控区无持仓空洞收紧 ## 2026-07-16 · 1080p 监控区无持仓空洞收紧
### 修改原因 ### 修改原因
+29 -35
View File
@@ -1275,31 +1275,25 @@ html[data-theme="light"] .host-metric-bar {
.monitor-split-right { .monitor-split-right {
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
}
.monitor-split-left > .card {
height: 100%;
}
.monitor-split-right {
display: grid; display: grid;
/* 无持仓时勿 1fr 均分拉高,按内容收紧;有仓时自然增高 */ grid-template-rows: minmax(0, 1fr) minmax(0, 1fr);
grid-template-rows: auto auto;
align-content: start;
gap: 16px; gap: 16px;
overflow: auto; align-content: stretch;
overflow: hidden;
} }
.monitor-split-left > .card,
.monitor-split-right > .card { .monitor-split-right > .card {
height: auto; height: 100%;
min-height: 0; min-height: 0;
overflow: hidden;
} }
.card-monitor-okx-split, .card-monitor-okx-split,
.card-monitor-split-side { .card-monitor-split-side {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: visible; overflow: hidden;
} }
.card-monitor-okx-split .card-body, .card-monitor-okx-split .card-body,
@@ -1307,7 +1301,7 @@ html[data-theme="light"] .host-metric-bar {
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: visible; overflow: auto;
min-height: 0; min-height: 0;
} }
@@ -1374,6 +1368,7 @@ html[data-theme="light"] .host-metric-bar {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.monitor-split-left,
.monitor-split-right { .monitor-split-right {
grid-template-rows: auto auto; grid-template-rows: auto auto;
} }
@@ -9898,15 +9893,18 @@ html[data-theme="light"] .hub-logs-card-hint {
height: auto; height: auto;
} }
body.hub-page-monitor:not(.hub-phone) .monitor-split-left,
body.hub-page-monitor:not(.hub-phone) .monitor-split-right { body.hub-page-monitor:not(.hub-phone) .monitor-split-right {
gap: 8px; gap: 8px;
grid-template-rows: auto auto; grid-template-rows: minmax(0, 1fr) minmax(0, 1fr);
align-content: start; align-content: stretch;
overflow: auto; overflow: hidden;
} }
body.hub-page-monitor:not(.hub-phone) .monitor-split-left > .card,
body.hub-page-monitor:not(.hub-phone) .monitor-split-right > .card { body.hub-page-monitor:not(.hub-phone) .monitor-split-right > .card {
height: auto; height: 100%;
min-height: 0;
} }
body.hub-page-monitor:not(.hub-phone) .card-monitor-okx-split, body.hub-page-monitor:not(.hub-phone) .card-monitor-okx-split,
@@ -9922,7 +9920,7 @@ html[data-theme="light"] .hub-logs-card-hint {
body.hub-page-monitor:not(.hub-phone) .card-monitor-okx-split .card-body, body.hub-page-monitor:not(.hub-phone) .card-monitor-okx-split .card-body,
body.hub-page-monitor:not(.hub-phone) .card-monitor-split-side .card-body { body.hub-page-monitor:not(.hub-phone) .card-monitor-split-side .card-body {
padding: 8px 10px; padding: 8px 10px;
overflow: hidden; overflow: auto;
} }
body.hub-page-monitor:not(.hub-phone) .hub-inner-cards { body.hub-page-monitor:not(.hub-phone) .hub-inner-cards {
@@ -10208,38 +10206,34 @@ body.hub-tablet.hub-page-monitor:not(.hub-phone) .grid-monitor-options-split .mo
align-items: stretch; align-items: stretch;
} }
body.hub-tablet.hub-page-monitor:not(.hub-phone) .monitor-split-left > .card { body.hub-tablet.hub-page-monitor:not(.hub-phone) .monitor-split-left > .card,
body.hub-tablet.hub-page-monitor:not(.hub-phone) .monitor-split-right > .card {
height: 100%; height: 100%;
min-height: 0; min-height: 0;
} }
body.hub-tablet.hub-page-monitor:not(.hub-phone) .monitor-split-right > .card { body.hub-tablet.hub-page-monitor:not(.hub-phone) .monitor-split-left,
height: auto;
min-height: 0;
}
body.hub-tablet.hub-page-monitor:not(.hub-phone) .monitor-split-right { body.hub-tablet.hub-page-monitor:not(.hub-phone) .monitor-split-right {
grid-template-rows: auto auto; grid-template-rows: minmax(0, 1fr) minmax(0, 1fr);
align-content: start; align-content: stretch;
gap: 6px; gap: 6px;
min-height: 0; min-height: 0;
overflow: auto; overflow: hidden;
} }
/* 竖屏(<960)叠成 OKX 上 + Gate/币安并排 */ /* 竖屏(<960)叠成 OKX 两卡在上 + Gate/币安并排 */
@media (max-width: 960px) { @media (max-width: 960px) {
body.hub-tablet.hub-page-monitor:not(.hub-phone) .monitor-split-body { body.hub-tablet.hub-page-monitor:not(.hub-phone) .monitor-split-body {
grid-template-rows: minmax(0, 1.35fr) minmax(0, 1fr); grid-template-rows: minmax(0, 1.35fr) minmax(0, 1fr);
} }
body.hub-tablet.hub-page-monitor:not(.hub-phone) .monitor-split-right { body.hub-tablet.hub-page-monitor:not(.hub-phone) .monitor-split-left {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) minmax(0, 1fr);
grid-template-rows: auto;
align-content: stretch;
} }
body.hub-tablet.hub-page-monitor:not(.hub-phone) .monitor-split-right > .card { body.hub-tablet.hub-page-monitor:not(.hub-phone) .monitor-split-right {
height: 100%; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
grid-template-rows: minmax(0, 1fr);
} }
} }
+37 -11
View File
@@ -2434,7 +2434,11 @@
if (optionsSplit) { if (optionsSplit) {
const okxRow = displayRows.find((r) => rowHasOptionsLayout(r)); const okxRow = displayRows.find((r) => rowHasOptionsLayout(r));
const otherRows = displayRows.filter((r) => !rowHasOptionsLayout(r)); const otherRows = displayRows.filter((r) => !rowHasOptionsLayout(r));
const leftHtml = okxRow ? renderMonitorCard(okxRow, { okxSplit: true }) : ""; /* OKX 拆成永续+期权两张独立卡,与右侧两所组成 2×2 对齐 */
const leftHtml = okxRow
? renderMonitorCard(okxRow, { okxPart: "perp", splitSide: true }) +
renderMonitorCard(okxRow, { okxPart: "options", splitSide: true })
: "";
const rightHtml = otherRows.map((r) => renderMonitorCard(r, { splitSide: true })).join(""); const rightHtml = otherRows.map((r) => renderMonitorCard(r, { splitSide: true })).join("");
cardsHtml = cardsHtml =
`<div class="monitor-split-body">` + `<div class="monitor-split-body">` +
@@ -3852,12 +3856,26 @@
return html; return html;
} }
function renderGridBody(row, ag, pos, hm, flaskOk, keys, orders, trends, rolls, kmap) { function renderGridBody(row, ag, pos, hm, flaskOk, keys, orders, trends, rolls, kmap, layoutOpts) {
const layout = layoutOpts || {};
const tickMap = buildPriceTickMap(row); const tickMap = buildPriceTickMap(row);
const intraday = isIntradayDisciplineRow(row); const intraday = isIntradayDisciplineRow(row);
let inner = ""; let inner = "";
if (layout.okxPart === "perp") {
inner += renderAccountStatRow(row, ag);
inner += renderPerpetualPositionsSection(row, ag, pos, orders, trends, tickMap, intraday);
inner += renderCardStrategyStats(row, hm, flaskOk);
inner += `<div class="card-expand-hint">点击标题栏进入全屏</div>`;
return inner;
}
if (layout.okxPart === "options") {
inner += renderOptionsSectionBody(row);
inner += `<div class="card-expand-hint">点击标题栏进入全屏 · 期权专页可下单</div>`;
return inner;
}
if (rowHasOptionsLayout(row)) { if (rowHasOptionsLayout(row)) {
if (monitorGridOptionsSplit) { if (monitorGridOptionsSplit) {
/* 兼容旧单卡内嵌;正常分栏已走 okxPart 双卡 */
inner += '<div class="hub-inner-cards">'; inner += '<div class="hub-inner-cards">';
inner += renderPerpetualInnerCard(row, ag, pos, orders, trends, tickMap, intraday); inner += renderPerpetualInnerCard(row, ag, pos, orders, trends, tickMap, intraday);
inner += renderOptionsInnerCard(row); inner += renderOptionsInnerCard(row);
@@ -4266,41 +4284,49 @@
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 { } else {
inner = renderGridBody(row, ag, pos, hm, flaskOk, keys, orders, trends, rolls, kmap); inner = renderGridBody(row, ag, pos, hm, flaskOk, keys, orders, trends, rolls, kmap, opts);
} }
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";
const dotCls = online ? "ok" : "bad"; const dotCls = online ? "ok" : "bad";
const flaskOpen = row.flask_url_browser || row.flask_url; const flaskOpen = row.flask_url_browser || row.flask_url;
const okxPart = opts.okxPart || "";
const isOkxOptionsCard = okxPart === "options";
const isOkxPerpCard = okxPart === "perp";
const titleSuffix = isOkxPerpCard ? " · 永续" : isOkxOptionsCard ? " · 期权" : "";
const openFlask = flaskOpen const openFlask = flaskOpen
? `<a class="btn-link btn-open-instance btn-open-trade" href="#" data-ex-id="${esc(row.id)}" data-next="/trade" data-new-tab="1">打开实例</a>` ? `<a class="btn-link btn-open-instance btn-open-trade" href="#" data-ex-id="${esc(row.id)}" data-next="/trade" data-new-tab="1">打开实例</a>`
: ""; : "";
const openTrade = flaskOpen const openTrade =
flaskOpen && !isOkxOptionsCard
? `<a class="btn-link btn-open-instance" href="#" data-ex-id="${esc(row.id)}" data-next="/trade">下单</a>` ? `<a class="btn-link btn-open-instance" href="#" data-ex-id="${esc(row.id)}" data-next="/trade">下单</a>`
: ""; : "";
const openKey = flaskOpen const openKey =
flaskOpen && !isOkxOptionsCard
? `<a class="btn-link btn-open-instance" href="#" data-ex-id="${esc(row.id)}" data-next="/key_monitor">监控位</a>` ? `<a class="btn-link btn-open-instance" href="#" data-ex-id="${esc(row.id)}" data-next="/key_monitor">监控位</a>`
: ""; : "";
const openReview = flaskOpen const openReview = flaskOpen
? `<a class="btn-link btn-open-instance" href="#" data-ex-id="${esc(row.id)}" data-next="/records">复盘</a>` ? `<a class="btn-link btn-open-instance" href="#" data-ex-id="${esc(row.id)}" data-next="/records">复盘</a>`
: ""; : "";
const openOptions = const openOptions =
flaskOpen && (row.capabilities || []).includes("options") flaskOpen && ((row.capabilities || []).includes("options") || isOkxOptionsCard)
? `<a class="btn-link btn-open-instance" href="#" data-ex-id="${esc(row.id)}" data-next="/options">期权</a>` ? `<a class="btn-link btn-open-instance" href="#" data-ex-id="${esc(row.id)}" data-next="/options">期权</a>`
: ""; : "";
const intraday = isIntradayDisciplineRow(row); const intraday = isIntradayDisciplineRow(row);
const fcHeadBadge = intraday ? forceCloseHeadBadgeHtml(row.force_close) : ""; const fcHeadBadge = intraday && !isOkxOptionsCard ? forceCloseHeadBadgeHtml(row.force_close) : "";
const showCloseAll = !intraday && !isOkxOptionsCard;
const layoutCls = opts.okxSplit const layoutCls = opts.okxSplit
? " card-monitor-okx-split" ? " card-monitor-okx-split"
: opts.splitSide : opts.splitSide || okxPart
? " card-monitor-split-side" ? " card-monitor-split-side"
: ""; : "";
return `<div class="card ${cardCls}${layoutCls}" data-ex-id="${esc(row.id)}"> const partAttr = okxPart ? ` data-okx-part="${esc(okxPart)}"` : "";
return `<div class="card ${cardCls}${layoutCls}" data-ex-id="${esc(row.id)}"${partAttr}>
<div class="card-head card-expand-zone" title="点击放大全屏"> <div class="card-head card-expand-zone" title="点击放大全屏">
<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>
<div class="card-title"><span>${esc(row.name)}</span>${fcHeadBadge}${formatRiskStatusBadge(hm.risk_status)}</div> <div class="card-title"><span>${esc(row.name)}${esc(titleSuffix)}</span>${fcHeadBadge}${formatRiskStatusBadge(hm.risk_status)}</div>
</div> </div>
<div class="card-sub">${esc(flaskOpen || "")}</div> <div class="card-sub">${esc(flaskOpen || "")}</div>
</div> </div>
@@ -4310,7 +4336,7 @@
${openKey} ${openKey}
${openOptions} ${openOptions}
${openReview} ${openReview}
${intraday ? "" : `<button type="button" class="danger btn-close-ex" data-id="${esc(row.id)}">全平</button>`} ${showCloseAll ? `<button type="button" class="danger btn-close-ex" data-id="${esc(row.id)}">全平</button>` : ""}
</div> </div>
</div> </div>
<div class="card-body">${inner}</div> <div class="card-body">${inner}</div>
+2 -2
View File
@@ -15,7 +15,7 @@
<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" media="print" onload="this.media='all'" /> <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" media="print" onload="this.media='all'" />
<noscript><link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" /></noscript> <noscript><link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" /></noscript>
<link rel="stylesheet" href="/assets/app.css?v=20260716-hub-1080-tight" /> <link rel="stylesheet" href="/assets/app.css?v=20260716-hub-okx-2x2" />
<link rel="stylesheet" href="/assets/trade_stats_calendar.css?v=4" /> <link rel="stylesheet" href="/assets/trade_stats_calendar.css?v=4" />
<link rel="stylesheet" href="/assets/account_risk_badge.css?v=4" /> <link rel="stylesheet" href="/assets/account_risk_badge.css?v=4" />
<script src="/assets/account_risk_badge.js?v=4"></script> <script src="/assets/account_risk_badge.js?v=4"></script>
@@ -1327,6 +1327,6 @@
<script src="/assets/options_expiry_countdown.js?v=1"></script> <script src="/assets/options_expiry_countdown.js?v=1"></script>
<script src="/assets/options_position_cards.js?v=1"></script> <script src="/assets/options_position_cards.js?v=1"></script>
<script src="/assets/backup.js?v=1"></script> <script src="/assets/backup.js?v=1"></script>
<script src="/assets/app.js?v=20260716-hub-desktop-clear"></script> <script src="/assets/app.js?v=20260716-hub-okx-2x2"></script>
</body> </body>
</html> </html>