Open trade detail in a modal dialog on row click.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-27 15:48:32 +08:00
parent 70a294c948
commit 1f6de95711
3 changed files with 263 additions and 77 deletions
+9
View File
@@ -5,6 +5,15 @@
---
## 2026-07-27 — 交易记录详情改为弹窗
### 变更
1. 点击成交组弹出对话框查看开平仓时间、持仓周期、成交明细与盈亏。
2. 支持点遮罩 / 关闭按钮 / Esc 关闭;手机端底部抽屉式布局。
---
## 2026-07-27 — 修复交易记录 500(记录未丢)
### 变更
+164 -77
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useId, useState } from "react";
import { apiFetch } from "../api/client";
import {
closeReasonZh,
@@ -82,11 +82,14 @@ function fmtHold(ms: number | null | undefined) {
}
export default function TradesPage() {
const titleId = useId();
const [groups, setGroups] = useState<Group[]>([]);
const [selected, setSelected] = useState<string | null>(null);
const [selectedGroup, setSelectedGroup] = useState<Group | null>(null);
const [fills, setFills] = useState<Fill[]>([]);
const [summary, setSummary] = useState<PnlSummary | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [detailErr, setDetailErr] = useState("");
const [err, setErr] = useState("");
useEffect(() => {
@@ -95,10 +98,36 @@ export default function TradesPage() {
.catch((e) => setErr(e instanceof Error ? e.message : String(e)));
}, []);
useEffect(() => {
if (!selected) return;
const onKey = (ev: KeyboardEvent) => {
if (ev.key === "Escape") closeDetail();
};
window.addEventListener("keydown", onKey);
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
window.removeEventListener("keydown", onKey);
document.body.style.overflow = prev;
};
}, [selected]);
function closeDetail() {
setSelected(null);
setSelectedGroup(null);
setFills([]);
setSummary(null);
setDetailErr("");
setDetailLoading(false);
}
async function openGroup(id: string) {
setSelected(id);
setSummary(null);
setSelectedGroup(null);
setFills([]);
setDetailErr("");
setDetailLoading(true);
try {
const r = await apiFetch<{
group: Group;
@@ -109,7 +138,9 @@ export default function TradesPage() {
setSummary(r.pnl_summary);
setSelectedGroup(r.group);
} catch (e) {
setErr(e instanceof Error ? e.message : String(e));
setDetailErr(e instanceof Error ? e.message : String(e));
} finally {
setDetailLoading(false);
}
}
@@ -117,7 +148,7 @@ export default function TradesPage() {
<div className="trades-page">
<h2 style={{ marginTop: 0 }}></h2>
<p className="trade-hold-note">
</p>
{err ? <div className="err">{err}</div> : null}
<div className="card trade-list" style={{ marginBottom: 12 }}>
@@ -160,83 +191,139 @@ export default function TradesPage() {
})
)}
</div>
{selected ? (
<div className="card trade-detail">
<h3 style={{ marginTop: 0 }}>{selected} </h3>
{selectedGroup ? (
<div className="trade-hold-summary">
<div className="kv">
<span></span>
<span className="mono">
{fmtTime(selectedGroup.hold_open_at_ms ?? selectedGroup.open_at_ms)}
</span>
</div>
<div className="kv">
<span></span>
<span className="mono">
{fmtTime(
selectedGroup.hold_close_at_ms ?? selectedGroup.close_at_ms,
)}
{selectedGroup.hold_basis === "perp" ? (
<span className="trade-hold-basis"></span>
<div
className="modal-backdrop"
role="presentation"
onClick={closeDetail}
>
<div
className="modal-dialog trade-detail-modal"
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
onClick={(ev) => ev.stopPropagation()}
>
<div className="modal-head">
<h3 id={titleId} className="modal-title mono">
{selected}
</h3>
<button
type="button"
className="btn ghost modal-close"
onClick={closeDetail}
aria-label="关闭"
>
</button>
</div>
<div className="modal-body">
{detailLoading ? (
<p className="meta"></p>
) : null}
{detailErr ? <div className="err">{detailErr}</div> : null}
{!detailLoading && !detailErr && selectedGroup ? (
<>
<div className="trade-hold-summary">
<div className="kv">
<span></span>
<span className="mono">
{statusZh(selectedGroup.status)}
{selectedGroup.close_reason
? ` · ${closeReasonZh(selectedGroup.close_reason)}`
: ""}
</span>
</div>
<div className="kv">
<span></span>
<span className="mono">
{positionSidesZh(
selectedGroup.perp_side,
selectedGroup.option_side,
)}
</span>
</div>
<div className="kv">
<span></span>
<span className="mono">
{fmtTime(
selectedGroup.hold_open_at_ms ??
selectedGroup.open_at_ms,
)}
</span>
</div>
<div className="kv">
<span></span>
<span className="mono">
{fmtTime(
selectedGroup.hold_close_at_ms ??
selectedGroup.close_at_ms,
)}
{selectedGroup.hold_basis === "perp" ? (
<span className="trade-hold-basis"></span>
) : null}
</span>
</div>
<div className="kv">
<span></span>
<span className="mono">
{fmtHold(selectedGroup.hold_ms)}
</span>
</div>
</div>
<p className="trade-detail-hint">
= +
</p>
{fills.map((f) => (
<div key={f.id} className="trade-fill kv">
<span className="mono">
{fillDescZh(f.leg, f.action, f.side)}
</span>
<span className="mono">
{f.fill_px.toFixed(4)} · {f.qty_eth} · {" "}
{f.fee.toFixed(4)}
</span>
</div>
))}
{summary ? (
<div className="trade-pnl-block">
<div className="kv">
<span></span>
<span className={`mono ${pnlClass(summary.option_pnl)}`}>
{fmt(summary.option_pnl)}
</span>
</div>
<div className="kv">
<span></span>
<span className={`mono ${pnlClass(summary.perp_pnl)}`}>
{fmt(summary.perp_pnl)}
</span>
</div>
<div className="kv">
<span></span>
<span className="mono">{fmt(summary.fees_total, 4)}</span>
</div>
<div className="kv">
<span>
<strong></strong>
</span>
<span className={`mono ${pnlClass(summary.net_pnl)}`}>
<strong>{fmt(summary.net_pnl)}</strong>
</span>
</div>
</div>
) : null}
</span>
</div>
<div className="kv">
<span></span>
<span className="mono">{fmtHold(selectedGroup.hold_ms)}</span>
</div>
</>
) : null}
</div>
) : null}
<p style={{ color: "var(--muted)", fontSize: 13, marginTop: 0 }}>
= +
</p>
{fills.map((f) => (
<div key={f.id} className="trade-fill kv">
<span className="mono">
{fillDescZh(f.leg, f.action, f.side)}
</span>
<span className="mono">
{f.fill_px.toFixed(4)} · {f.qty_eth} · {" "}
{f.fee.toFixed(4)}
</span>
</div>
))}
{summary ? (
<div
style={{
marginTop: 14,
paddingTop: 12,
borderTop: "1px solid var(--border, #333)",
}}
>
<div className="kv">
<span></span>
<span className={`mono ${pnlClass(summary.option_pnl)}`}>
{fmt(summary.option_pnl)}
</span>
</div>
<div className="kv">
<span></span>
<span className={`mono ${pnlClass(summary.perp_pnl)}`}>
{fmt(summary.perp_pnl)}
</span>
</div>
<div className="kv">
<span></span>
<span className="mono">{fmt(summary.fees_total, 4)}</span>
</div>
<div className="kv">
<span>
<strong></strong>
</span>
<span className={`mono ${pnlClass(summary.net_pnl)}`}>
<strong>{fmt(summary.net_pnl)}</strong>
</span>
</div>
</div>
) : null}
</div>
</div>
) : null}
</div>
+90
View File
@@ -584,6 +584,96 @@ input {
font-size: 12px;
}
.trade-detail-hint {
color: var(--muted);
font-size: 13px;
margin: 0 0 12px;
line-height: 1.45;
}
.trade-pnl-block {
margin-top: 14px;
padding-top: 12px;
border-top: 1px solid var(--line);
}
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 80;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
padding-bottom: calc(16px + env(safe-area-inset-bottom, 0px));
background: rgba(0, 0, 0, 0.62);
backdrop-filter: blur(4px);
}
.modal-dialog {
width: min(560px, 100%);
max-height: min(86vh, 820px);
display: flex;
flex-direction: column;
background: var(--bg-elev);
border: 1px solid var(--line);
border-radius: 12px;
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.45);
overflow: hidden;
}
.modal-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
border-bottom: 1px solid var(--line);
flex: 0 0 auto;
}
.modal-title {
margin: 0;
font-size: 16px;
font-weight: 700;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.modal-close {
flex: 0 0 auto;
min-height: 40px;
}
.modal-body {
padding: 14px 16px 18px;
overflow: auto;
-webkit-overflow-scrolling: touch;
}
@media (max-width: 900px) {
.modal-backdrop {
align-items: flex-end;
padding: 0;
}
.modal-dialog {
width: 100%;
max-height: min(88vh, 900px);
border-radius: 14px 14px 0 0;
}
.modal-head {
padding: 12px 14px;
}
.modal-body {
padding: 12px 14px calc(18px + env(safe-area-inset-bottom, 0px));
}
}
.trade-row-pnl {
display: flex;
flex-direction: column;