Restructure Plan UI for semi-auto tabs and T-quote ladder.

Hide amplitude card when filter off; compact semi form with rules; monitor tab keeps positions and market.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-08-08 14:01:12 +08:00
parent 457838ef64
commit d98bf46549
4 changed files with 632 additions and 189 deletions
+387 -188
View File
@@ -122,6 +122,23 @@ export default function PlanPage() {
const [semiPerpU, setSemiPerpU] = useState(1);
const [semiOptU, setSemiOptU] = useState(4);
const [semiDirty, setSemiDirty] = useState(false);
const [planTab, setPlanTab] = useState<"semi" | "monitor">("semi");
const [ladder, setLadder] = useState<{
ok?: boolean;
detail?: string;
index_px?: number | null;
expiry_ymd?: string;
atm_strike?: number;
rows?: Array<{
strike: number;
offset: number;
tag: string;
call_ask: number | null;
call_lev: number | null;
put_ask: number | null;
put_lev: number | null;
}>;
} | null>(null);
const semiParamsBody = () => ({
semi_view_side: semiView,
@@ -149,12 +166,26 @@ export default function PlanPage() {
setSemiExitU(Number(p.semi_perp_exit_unit ?? 5));
setSemiMinH(Number(p.semi_min_option_hours ?? 30));
setSemiMinLev(Number(p.semi_min_option_leverage ?? 200));
const m = p.semi_moneyness;
setSemiMny(m === "itm" || m === "atm" || m === "otm" ? m : "otm");
const mny = p.semi_moneyness;
setSemiMny(mny === "itm" || mny === "atm" || mny === "otm" ? mny : "otm");
setSemiOtmOff(Number(p.semi_otm_max_offset ?? 25));
setSemiPerpU(Number(p.semi_perp_unit ?? 1));
setSemiOptU(Number(p.semi_option_unit ?? 4));
}
if (
p.semi_auto_enabled &&
String(p.hedge_mode || "") !== "option_option"
) {
try {
setLadder(
await apiFetch<NonNullable<typeof ladder>>(
"/api/market/option-ladder?wings=4",
),
);
} catch {
/* ignore ladder errors */
}
}
setErr("");
} catch (e) {
setErr(e instanceof Error ? e.message : String(e));
@@ -276,6 +307,8 @@ export default function PlanPage() {
snap?.hedge_mode === "option_option" ||
pos?.hedge_mode === "option_option" ||
!!pos?.option2_inst_id;
const semiOn = !!plan?.semi_auto_enabled && !isOo;
const showAmpCard = plan?.oo_amplitude_filter_enabled === true;
const exitMode = plan?.exit_mode ?? "fixed_usdt";
const riskBased =
plan?.risk_based === true || plan?.sizing_mode === "risk_based";
@@ -573,209 +606,351 @@ export default function PlanPage() {
{busy ? <span className="meta">{busy}</span> : null}
</div>
{plan?.semi_auto_enabled && !isOo ? (
<section className="card plan-semi-card" aria-label="半自动本单">
<h3 className="plan-panel-title"> · </h3>
<p className="meta" style={{ marginTop: 0 }}>
// 180200±&gt;0×k
</p>
<div className="settings-fields" style={{ marginTop: 8 }}>
<div className="field">
<label htmlFor="semiView"></label>
<select
id="semiView"
className="mono"
disabled={open || !!busy || !!plan.semi_armed}
value={semiView}
onChange={(e) => {
setSemiView(e.target.value === "short" ? "short" : "long");
setSemiDirty(true);
}}
>
<option value="long"> · Call + </option>
<option value="short"> · Put + </option>
</select>
{semiOn ? (
<div className="tabs plan-main-tabs" role="tablist" aria-label="计划页">
<button
type="button"
role="tab"
className={planTab === "semi" ? "tab active" : "tab"}
aria-selected={planTab === "semi"}
onClick={() => setPlanTab("semi")}
>
</button>
<button
type="button"
role="tab"
className={planTab === "monitor" ? "tab active" : "tab"}
aria-selected={planTab === "monitor"}
onClick={() => setPlanTab("monitor")}
>
</button>
</div>
) : null}
{semiOn && planTab === "semi" ? (
<div className="plan-semi-tab">
<section className="card plan-semi-card plan-semi-compact" aria-label="半自动本单">
<div className="plan-semi-head">
<h3 className="plan-panel-title"> · </h3>
<span className="mono meta">
{plan.semi_armed ? "已授权" : "未授权"}
{" · "}
{semiMny === "itm" ? "实值" : semiMny === "atm" ? "平值" : "虚值"}
{" · "}
{fmt(semiPerpU, 0)}:{fmt(semiOptU, 0)}
</span>
</div>
<div className="field">
<label htmlFor="semiMny"></label>
<select
id="semiMny"
className="mono"
disabled={open || !!busy || !!plan.semi_armed}
value={semiMny}
onChange={(e) => {
const v = e.target.value;
setSemiMny(
v === "itm" || v === "atm" || v === "otm" ? v : "otm",
);
setSemiDirty(true);
}}
>
<option value="itm"></option>
<option value="atm"></option>
<option value="otm"></option>
</select>
</div>
{semiMny === "otm" ? (
<details className="plan-semi-rules">
<summary></summary>
<ul>
<li>永续:期权配比与出场 //</li>
<li></li>
<li> 180 200/</li>
<li> ± &gt; 0 </li>
<li> × k </li>
<li> k</li>
</ul>
</details>
<div className="settings-fields plan-semi-fields">
<div className="field">
<label htmlFor="semiOtmOff"></label>
<label htmlFor="semiView"></label>
<select
id="semiView"
className="mono"
disabled={open || !!busy || !!plan.semi_armed}
value={semiView}
onChange={(e) => {
setSemiView(e.target.value === "short" ? "short" : "long");
setSemiDirty(true);
}}
>
<option value="long"> · Call+</option>
<option value="short"> · Put+</option>
</select>
</div>
<div className="field">
<label htmlFor="semiMny"></label>
<select
id="semiMny"
className="mono"
disabled={open || !!busy || !!plan.semi_armed}
value={semiMny}
onChange={(e) => {
const v = e.target.value;
setSemiMny(
v === "itm" || v === "atm" || v === "otm" ? v : "otm",
);
setSemiDirty(true);
}}
>
<option value="itm"></option>
<option value="atm"></option>
<option value="otm"></option>
</select>
</div>
{semiMny === "otm" ? (
<div className="field">
<label htmlFor="semiOtmOff"></label>
<input
id="semiOtmOff"
className="mono"
type="number"
step="1"
min="1"
disabled={open || !!busy || !!plan.semi_armed}
value={semiOtmOff}
onChange={(e) => {
setSemiOtmOff(Number(e.target.value));
setSemiDirty(true);
}}
/>
</div>
) : null}
<div className="field">
<label htmlFor="semiPerpU"></label>
<input
id="semiOtmOff"
id="semiPerpU"
className="mono"
type="number"
step="0.1"
min="0.01"
disabled={open || !!busy || !!plan.semi_armed}
value={semiPerpU}
onChange={(e) => {
setSemiPerpU(Number(e.target.value));
setSemiDirty(true);
}}
/>
</div>
<div className="field">
<label htmlFor="semiOptU"></label>
<input
id="semiOptU"
className="mono"
type="number"
step="0.1"
min="0.01"
disabled={open || !!busy || !!plan.semi_armed}
value={semiOptU}
onChange={(e) => {
setSemiOptU(Number(e.target.value));
setSemiDirty(true);
}}
/>
</div>
<div className="field">
<label htmlFor="semiMove"></label>
<input
id="semiMove"
className="mono"
type="number"
step="1"
min="1"
disabled={open || !!busy || !!plan.semi_armed}
value={semiOtmOff}
value={semiMove}
onChange={(e) => {
setSemiOtmOff(Number(e.target.value));
setSemiMove(Number(e.target.value));
setSemiDirty(true);
}}
/>
</div>
<div className="field">
<label htmlFor="semiExitU"></label>
<input
id="semiExitU"
className="mono"
type="number"
step="0.1"
min="0.1"
disabled={open || !!busy || !!plan.semi_armed}
value={semiExitU}
onChange={(e) => {
setSemiExitU(Number(e.target.value));
setSemiDirty(true);
}}
/>
</div>
<div className="field">
<label htmlFor="semiMinH"> h</label>
<input
id="semiMinH"
className="mono"
type="number"
step="1"
min="1"
disabled={open || !!busy || !!plan.semi_armed}
value={semiMinH}
onChange={(e) => {
setSemiMinH(Number(e.target.value));
setSemiDirty(true);
}}
/>
</div>
<div className="field">
<label htmlFor="semiMinLev"></label>
<input
id="semiMinLev"
className="mono"
type="number"
step="1"
min="1"
disabled={open || !!busy || !!plan.semi_armed}
value={semiMinLev}
onChange={(e) => {
setSemiMinLev(Number(e.target.value));
setSemiDirty(true);
}}
/>
</div>
) : null}
<div className="field">
<label htmlFor="semiPerpU"></label>
<input
id="semiPerpU"
className="mono"
type="number"
step="0.1"
min="0.01"
disabled={open || !!busy || !!plan.semi_armed}
value={semiPerpU}
onChange={(e) => {
setSemiPerpU(Number(e.target.value));
setSemiDirty(true);
}}
/>
</div>
<div className="field">
<label htmlFor="semiOptU"></label>
<input
id="semiOptU"
className="mono"
type="number"
step="0.1"
min="0.01"
disabled={open || !!busy || !!plan.semi_armed}
value={semiOptU}
onChange={(e) => {
setSemiOptU(Number(e.target.value));
setSemiDirty(true);
}}
/>
<div className="mono meta plan-semi-summary">
{(() => {
const idx = snap?.index_px;
if (idx == null || !Number.isFinite(Number(idx))) {
return `净利目标≈${fmt(plan.semi_net_exit_target ?? semiExitU, 2)}U`;
}
const n = Number(idx);
const tgt =
semiView === "long"
? n + Number(semiMove)
: n - Number(semiMove);
return `${fmtExPx("index", n)}${fmtExPx("index", tgt)} · 净利≈${fmt(plan.semi_net_exit_target ?? semiExitU, 2)}U`;
})()}
</div>
<div className="field">
<label htmlFor="semiMove"> · </label>
<input
id="semiMove"
className="mono"
type="number"
step="1"
min="1"
<div className="plan-semi-btns">
<button
className="btn ghost"
type="button"
disabled={open || !!busy || !semiDirty}
onClick={() => void saveSemiParams()}
>
</button>
<button
className="btn"
type="button"
disabled={open || !!busy || !!plan.semi_armed}
value={semiMove}
onChange={(e) => {
setSemiMove(Number(e.target.value));
setSemiDirty(true);
}}
/>
onClick={() => void armSemi(true)}
>
</button>
<button
className="btn ghost"
type="button"
disabled={open || !!busy || !plan.semi_armed}
onClick={() => void armSemi(false)}
>
</button>
</div>
<div className="field">
<label htmlFor="semiExitU">U×k</label>
<input
id="semiExitU"
className="mono"
type="number"
step="0.1"
min="0.1"
disabled={open || !!busy || !!plan.semi_armed}
value={semiExitU}
onChange={(e) => {
setSemiExitU(Number(e.target.value));
setSemiDirty(true);
}}
/>
</section>
<section className="card plan-t-quote" aria-label="T型报价">
<div className="plan-semi-head">
<h3 className="plan-panel-title">T · / / </h3>
<span className="mono meta">
{ladder?.expiry_ymd
? `到期 ${ladder.expiry_ymd}`
: "—"}
{ladder?.atm_strike != null
? ` · ATM ${Math.round(ladder.atm_strike)}`
: ""}
{ladder?.index_px != null
? ` · 指数 ${fmtExPx("index", ladder.index_px)}`
: ""}
</span>
</div>
<div className="field">
<label htmlFor="semiMinH"> · h</label>
<input
id="semiMinH"
className="mono"
type="number"
step="1"
min="1"
disabled={open || !!busy || !!plan.semi_armed}
value={semiMinH}
onChange={(e) => {
setSemiMinH(Number(e.target.value));
setSemiDirty(true);
}}
/>
</div>
<div className="field">
<label htmlFor="semiMinLev"> · </label>
<input
id="semiMinLev"
className="mono"
type="number"
step="1"
min="1"
disabled={open || !!busy || !!plan.semi_armed}
value={semiMinLev}
onChange={(e) => {
setSemiMinLev(Number(e.target.value));
setSemiDirty(true);
}}
/>
</div>
</div>
<div className="mono meta" style={{ marginTop: 8 }}>
{(() => {
const idx = snap?.index_px;
if (idx == null || !Number.isFinite(Number(idx))) {
return `目标净利≈${fmt(plan.semi_net_exit_target ?? semiExitU, 2)}U · 指数 —`;
}
const n = Number(idx);
const tgt =
semiView === "long" ? n + Number(semiMove) : n - Number(semiMove);
const mnyZh =
semiMny === "itm" ? "实值" : semiMny === "atm" ? "平值" : "虚值";
return `指数 ${fmtExPx("index", n)} → 到点 ${fmtExPx("index", tgt)} · ${mnyZh}${
semiMny === "otm" ? `${fmt(semiOtmOff, 0)}` : ""
} · 配比 ${fmt(semiPerpU, 2)}:${fmt(semiOptU, 2)} · 净利目标≈${fmt(plan.semi_net_exit_target ?? semiExitU, 2)}U · ${plan.semi_armed ? "已授权盯开" : "未授权"}`;
})()}
</div>
<div className="plan-actions" style={{ marginTop: 10, paddingTop: 0 }}>
<button
className="btn ghost"
type="button"
disabled={open || !!busy || !semiDirty}
onClick={() => void saveSemiParams()}
>
</button>
<button
className="btn"
type="button"
disabled={open || !!busy || !!plan.semi_armed}
onClick={() => void armSemi(true)}
>
</button>
<button
className="btn ghost"
type="button"
disabled={open || !!busy || !plan.semi_armed}
onClick={() => void armSemi(false)}
>
</button>
</div>
</section>
{!ladder?.ok ? (
<p className="meta">{ladder?.detail || "暂无报价链"}</p>
) : (
<div className="plan-t-wrap">
<table className="plan-t-table">
<thead>
<tr>
<th>Call卖一</th>
<th>Call杠杆</th>
<th></th>
<th></th>
<th>Put杠杆</th>
<th>Put卖一</th>
</tr>
</thead>
<tbody>
{[...(ladder.rows || [])]
.slice()
.reverse()
.map((r) => {
const tagZh =
r.tag === "atm"
? "平值"
: r.tag === "otm_call"
? "Call虚/Put实"
: "Put虚/Call实";
const hi =
(semiMny === "otm" &&
((semiView === "long" && r.tag === "otm_call") ||
(semiView === "short" && r.tag === "otm_put")) &&
Math.abs(r.offset) <= Number(semiOtmOff) + 1e-9) ||
(semiMny === "atm" && r.tag === "atm") ||
(semiMny === "itm" &&
((semiView === "long" &&
(r.tag === "otm_put" || r.tag === "atm")) ||
(semiView === "short" &&
(r.tag === "otm_call" || r.tag === "atm"))));
return (
<tr
key={r.strike}
className={
r.tag === "atm"
? "plan-t-atm"
: hi
? "plan-t-pick"
: undefined
}
>
<td className="mono">
{r.call_ask != null
? fmtExPx("option", r.call_ask)
: "—"}
</td>
<td className="mono">
{r.call_lev != null
? `${r.call_lev.toFixed(0)}x`
: "—"}
</td>
<td className="mono plan-t-k">
{Math.round(r.strike)}
<span className="meta">
{" "}
({r.offset >= 0 ? "+" : ""}
{fmt(r.offset, 0)})
</span>
</td>
<td>{tagZh}</td>
<td className="mono">
{r.put_lev != null
? `${r.put_lev.toFixed(0)}x`
: "—"}
</td>
<td className="mono">
{r.put_ask != null
? fmtExPx("option", r.put_ask)
: "—"}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</section>
</div>
) : null}
{!semiOn || planTab === "monitor" ? (
<div className="plan-shell">
<div className="plan-board">
<div className="card plan-strategy">
@@ -798,7 +973,18 @@ export default function PlanPage() {
<div className="kv">
<span></span>
<span className="mono">
{riskBased ? (
{semiOn ? (
<span className="status-running">
·{" "}
{semiMny === "itm"
? "实值"
: semiMny === "atm"
? "平值"
: "虚值"}{" "}
· {fmt(semiPerpU, 0)}:{fmt(semiOptU, 0)}
{plan?.semi_armed ? " · 已授权" : " · 未授权"}
</span>
) : riskBased ? (
<span className="status-running">{sizingModeLabel}</span>
) : (
sizingModeLabel
@@ -808,7 +994,17 @@ export default function PlanPage() {
<div className="kv">
<span></span>
<span className="mono">
{isOo ? (
{semiOn ? (
<>
{fmt(semiMinH, 0)}h · {fmt(semiMinLev, 0)}x
{semiMny === "otm"
? ` · 虚值≤${fmt(semiOtmOff, 0)}`
: ""}
{" · "}
±{fmt(semiMove, 0)}&gt;0 /
{fmt(semiExitU, 1)}×k
</>
) : isOo ? (
ooSelectLabel
) : (
<>
@@ -1326,7 +1522,8 @@ export default function PlanPage() {
</div>
<div className="plan-market">
<div className="card">
{showAmpCard ? (
<div className="card plan-card-compact">
<h3 className="plan-panel-title"> / </h3>
<div className="kv">
<span></span>
@@ -1375,6 +1572,7 @@ export default function PlanPage() {
</span>
</div>
</div>
) : null}
{!isOo ? (
<div className="card">
<h3 className="plan-panel-title"></h3>
@@ -1617,6 +1815,7 @@ export default function PlanPage() {
</div>
) : null}
</div>
) : null}
</div>
);
}
+138
View File
@@ -468,6 +468,144 @@ input {
color: #5ec8ff;
}
.plan-main-tabs {
margin-top: 4px;
margin-bottom: 10px;
}
.plan-semi-tab {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 12px;
}
.plan-semi-compact {
padding: 10px 12px;
}
.plan-semi-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
margin-bottom: 6px;
}
.plan-semi-head .plan-panel-title {
margin: 0;
font-size: 13px;
}
.plan-semi-rules {
margin: 0 0 8px;
font-size: 12px;
color: var(--muted);
}
.plan-semi-rules summary {
cursor: pointer;
color: #5ec8ff;
user-select: none;
}
.plan-semi-rules ul {
margin: 6px 0 0;
padding-left: 1.2em;
line-height: 1.45;
}
.plan-semi-fields {
margin-top: 0 !important;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)) !important;
gap: 8px !important;
}
.plan-semi-fields .field label {
font-size: 11px;
}
.plan-semi-fields .field input,
.plan-semi-fields .field select {
padding: 6px 8px;
font-size: 12px;
min-height: 32px;
}
.plan-semi-summary {
margin-top: 6px !important;
font-size: 12px;
}
.plan-semi-btns {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
.plan-semi-btns .btn {
min-height: 34px;
padding: 6px 12px;
font-size: 13px;
}
.plan-t-quote {
padding: 10px 12px;
}
.plan-t-wrap {
overflow-x: auto;
margin-top: 4px;
}
.plan-t-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.plan-t-table th,
.plan-t-table td {
padding: 4px 6px;
border-bottom: 1px solid var(--line);
text-align: right;
white-space: nowrap;
}
.plan-t-table th:nth-child(3),
.plan-t-table td:nth-child(3),
.plan-t-table th:nth-child(4),
.plan-t-table td:nth-child(4) {
text-align: center;
}
.plan-t-table th {
color: var(--muted);
font-weight: 600;
}
.plan-t-atm {
background: rgba(94, 200, 255, 0.08);
}
.plan-t-pick {
background: rgba(14, 203, 129, 0.08);
}
.plan-t-k {
font-weight: 700;
}
.plan-card-compact {
padding: 10px 12px;
}
.plan-card-compact .plan-panel-title {
margin-bottom: 6px;
font-size: 13px;
}
@media (max-width: 900px) {
.app-container {
padding: 0 12px calc(72px + env(safe-area-inset-bottom, 0px));