Open trade detail in a modal dialog on row click.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -5,6 +5,15 @@
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-27 — 交易记录详情改为弹窗
|
||||
|
||||
### 变更
|
||||
|
||||
1. 点击成交组弹出对话框查看开平仓时间、持仓周期、成交明细与盈亏。
|
||||
2. 支持点遮罩 / 关闭按钮 / Esc 关闭;手机端底部抽屉式布局。
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-27 — 修复交易记录 500(记录未丢)
|
||||
|
||||
### 变更
|
||||
|
||||
+164
-77
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user