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(记录未丢)
|
## 2026-07-27 — 修复交易记录 500(记录未丢)
|
||||||
|
|
||||||
### 变更
|
### 变更
|
||||||
|
|||||||
+107
-20
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useId, useState } from "react";
|
||||||
import { apiFetch } from "../api/client";
|
import { apiFetch } from "../api/client";
|
||||||
import {
|
import {
|
||||||
closeReasonZh,
|
closeReasonZh,
|
||||||
@@ -82,11 +82,14 @@ function fmtHold(ms: number | null | undefined) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function TradesPage() {
|
export default function TradesPage() {
|
||||||
|
const titleId = useId();
|
||||||
const [groups, setGroups] = useState<Group[]>([]);
|
const [groups, setGroups] = useState<Group[]>([]);
|
||||||
const [selected, setSelected] = useState<string | null>(null);
|
const [selected, setSelected] = useState<string | null>(null);
|
||||||
const [selectedGroup, setSelectedGroup] = useState<Group | null>(null);
|
const [selectedGroup, setSelectedGroup] = useState<Group | null>(null);
|
||||||
const [fills, setFills] = useState<Fill[]>([]);
|
const [fills, setFills] = useState<Fill[]>([]);
|
||||||
const [summary, setSummary] = useState<PnlSummary | null>(null);
|
const [summary, setSummary] = useState<PnlSummary | null>(null);
|
||||||
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
|
const [detailErr, setDetailErr] = useState("");
|
||||||
const [err, setErr] = useState("");
|
const [err, setErr] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -95,10 +98,36 @@ export default function TradesPage() {
|
|||||||
.catch((e) => setErr(e instanceof Error ? e.message : String(e)));
|
.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) {
|
async function openGroup(id: string) {
|
||||||
setSelected(id);
|
setSelected(id);
|
||||||
setSummary(null);
|
setSummary(null);
|
||||||
setSelectedGroup(null);
|
setSelectedGroup(null);
|
||||||
|
setFills([]);
|
||||||
|
setDetailErr("");
|
||||||
|
setDetailLoading(true);
|
||||||
try {
|
try {
|
||||||
const r = await apiFetch<{
|
const r = await apiFetch<{
|
||||||
group: Group;
|
group: Group;
|
||||||
@@ -109,7 +138,9 @@ export default function TradesPage() {
|
|||||||
setSummary(r.pnl_summary);
|
setSummary(r.pnl_summary);
|
||||||
setSelectedGroup(r.group);
|
setSelectedGroup(r.group);
|
||||||
} catch (e) {
|
} 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">
|
<div className="trades-page">
|
||||||
<h2 style={{ marginTop: 0 }}>交易记录</h2>
|
<h2 style={{ marginTop: 0 }}>交易记录</h2>
|
||||||
<p className="trade-hold-note">
|
<p className="trade-hold-note">
|
||||||
持仓周期按目标平仓计时;净盈利达标且只平永续时,以永续平仓时间为准(不含期权残留至到期)。
|
持仓周期按目标平仓计时;净盈利达标且只平永续时,以永续平仓时间为准(不含期权残留至到期)。点击一条可查看明细。
|
||||||
</p>
|
</p>
|
||||||
{err ? <div className="err">{err}</div> : null}
|
{err ? <div className="err">{err}</div> : null}
|
||||||
<div className="card trade-list" style={{ marginBottom: 12 }}>
|
<div className="card trade-list" style={{ marginBottom: 12 }}>
|
||||||
@@ -160,22 +191,76 @@ export default function TradesPage() {
|
|||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selected ? (
|
{selected ? (
|
||||||
<div className="card trade-detail">
|
<div
|
||||||
<h3 style={{ marginTop: 0 }}>{selected} 成交明细</h3>
|
className="modal-backdrop"
|
||||||
{selectedGroup ? (
|
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="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">
|
<div className="kv">
|
||||||
<span>开仓时间</span>
|
<span>开仓时间</span>
|
||||||
<span className="mono">
|
<span className="mono">
|
||||||
{fmtTime(selectedGroup.hold_open_at_ms ?? selectedGroup.open_at_ms)}
|
{fmtTime(
|
||||||
|
selectedGroup.hold_open_at_ms ??
|
||||||
|
selectedGroup.open_at_ms,
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="kv">
|
<div className="kv">
|
||||||
<span>平仓时间</span>
|
<span>平仓时间</span>
|
||||||
<span className="mono">
|
<span className="mono">
|
||||||
{fmtTime(
|
{fmtTime(
|
||||||
selectedGroup.hold_close_at_ms ?? selectedGroup.close_at_ms,
|
selectedGroup.hold_close_at_ms ??
|
||||||
|
selectedGroup.close_at_ms,
|
||||||
)}
|
)}
|
||||||
{selectedGroup.hold_basis === "perp" ? (
|
{selectedGroup.hold_basis === "perp" ? (
|
||||||
<span className="trade-hold-basis">(永续)</span>
|
<span className="trade-hold-basis">(永续)</span>
|
||||||
@@ -184,14 +269,17 @@ export default function TradesPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="kv">
|
<div className="kv">
|
||||||
<span>持仓周期</span>
|
<span>持仓周期</span>
|
||||||
<span className="mono">{fmtHold(selectedGroup.hold_ms)}</span>
|
<span className="mono">
|
||||||
|
{fmtHold(selectedGroup.hold_ms)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
|
||||||
<p style={{ color: "var(--muted)", fontSize: 13, marginTop: 0 }}>
|
<p className="trade-detail-hint">
|
||||||
成交价为成交均价(未预先扣费);手续费单独列出。净盈亏 = 期权盈亏 + 永续盈亏 −
|
成交价为成交均价(未预先扣费);手续费单独列出。净盈亏 = 期权盈亏 +
|
||||||
全部手续费。
|
永续盈亏 − 全部手续费。
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{fills.map((f) => (
|
{fills.map((f) => (
|
||||||
<div key={f.id} className="trade-fill kv">
|
<div key={f.id} className="trade-fill kv">
|
||||||
<span className="mono">
|
<span className="mono">
|
||||||
@@ -203,14 +291,9 @@ export default function TradesPage() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{summary ? (
|
{summary ? (
|
||||||
<div
|
<div className="trade-pnl-block">
|
||||||
style={{
|
|
||||||
marginTop: 14,
|
|
||||||
paddingTop: 12,
|
|
||||||
borderTop: "1px solid var(--border, #333)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="kv">
|
<div className="kv">
|
||||||
<span>期权盈亏</span>
|
<span>期权盈亏</span>
|
||||||
<span className={`mono ${pnlClass(summary.option_pnl)}`}>
|
<span className={`mono ${pnlClass(summary.option_pnl)}`}>
|
||||||
@@ -237,6 +320,10 @@ export default function TradesPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -584,6 +584,96 @@ input {
|
|||||||
font-size: 12px;
|
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 {
|
.trade-row-pnl {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
Reference in New Issue
Block a user