Show option expiry countdown and widen settings for ultrawide/tablet.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-25 09:24:11 +08:00
parent 37894e6f0f
commit 77ae6a44b9
5 changed files with 406 additions and 190 deletions
+9
View File
@@ -507,6 +507,14 @@ class Matcher:
)
strike = float(g["strike"]) if g and g["strike"] is not None else None
expiry_ymd = str(g["expiry_ymd"]) if g and g["expiry_ymd"] else None
expiry_ms = None
if expiry_ymd and len(expiry_ymd) == 6:
try:
from ..exchange.okx.parse import expiry_ms_from_ymd
expiry_ms = expiry_ms_from_ymd(expiry_ymd)
except Exception:
expiry_ms = None
perp_inst_id = (
str(g["perp_inst_id"])
if g and g["perp_inst_id"]
@@ -532,6 +540,7 @@ class Matcher:
"option_mark_px": float(opt_mark) if opt_mark is not None else None,
"strike": strike,
"expiry_ymd": expiry_ymd,
"expiry_ms": expiry_ms,
"perp_upl": perp_upl,
"option_upl": option_upl,
"est_close_fees": est_close_fees,
+1
View File
@@ -149,6 +149,7 @@ export type PlanState = {
option_mark_px?: number | null;
strike?: number | null;
expiry_ymd?: string | null;
expiry_ms?: number | null;
perp_upl?: number;
option_upl?: number;
est_close_fees?: number;
+42
View File
@@ -11,6 +11,28 @@ function pnlClass(n: number | null | undefined) {
return n > 0 ? "pos-pnl-profit" : "pos-pnl-loss";
}
/** OKX 期权到期:YYMMDD 当日 08:00 UTC(上海 16:00 */
function expiryMsFromYmd(ymd: string | null | undefined): number | null {
if (!ymd || ymd.length !== 6) return null;
const yy = 2000 + Number(ymd.slice(0, 2));
const mm = Number(ymd.slice(2, 4));
const dd = Number(ymd.slice(4, 6));
if (![yy, mm, dd].every((x) => Number.isFinite(x))) return null;
return Date.UTC(yy, mm - 1, dd, 8, 0, 0);
}
function formatCountdown(msLeft: number): string {
if (msLeft <= 0) return "已到期";
const s = Math.floor(msLeft / 1000);
const d = Math.floor(s / 86400);
const h = Math.floor((s % 86400) / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
if (d > 0) return `${d}${h}${String(m).padStart(2, "0")}`;
if (h > 0) return `${h}${String(m).padStart(2, "0")}${String(sec).padStart(2, "0")}`;
return `${m}${String(sec).padStart(2, "0")}`;
}
const PHASE_ZH: Record<string, string> = {
idle: "空闲",
wait_signal: "等待信号",
@@ -29,6 +51,7 @@ export default function PlanPage() {
const [plan, setPlan] = useState<PlanState | null>(null);
const [err, setErr] = useState("");
const [busy, setBusy] = useState("");
const [nowMs, setNowMs] = useState(() => Date.now());
async function refresh() {
try {
@@ -50,6 +73,11 @@ export default function PlanPage() {
return () => window.clearInterval(t);
}, []);
useEffect(() => {
const t = window.setInterval(() => setNowMs(Date.now()), 1000);
return () => window.clearInterval(t);
}, []);
async function act(path: string, label: string) {
setBusy(label);
setErr("");
@@ -84,6 +112,12 @@ export default function PlanPage() {
? `权利金×${fmt(plan?.premium_exit_multiple ?? 1, 2)}`
: `固定 ${fmt(plan?.net_profit_target ?? 15)} U`;
const phaseLabel = PHASE_ZH[plan?.phase || ""] || plan?.phase || "—";
const expiryMs =
pos?.expiry_ms ?? expiryMsFromYmd(pos?.expiry_ymd) ?? null;
const countdown =
open && expiryMs != null ? formatCountdown(expiryMs - nowMs) : "—";
const countdownUrgent =
open && expiryMs != null && expiryMs - nowMs > 0 && expiryMs - nowMs < 6 * 3600_000;
return (
<div>
@@ -290,6 +324,14 @@ export default function PlanPage() {
</span>
</div>
<div className="pos-grid">
<div className="pos-cell pos-cell-wide">
<span className="pos-label"></span>
<span
className={`pos-value mono ${countdownUrgent ? "pos-countdown-urgent" : ""}`}
>
{countdown}
</span>
</div>
<div className="pos-cell">
<span className="pos-label"></span>
<span className="pos-value mono">{fmt(pos?.option_entry_px)}</span>
+209 -188
View File
@@ -112,7 +112,7 @@ export default function SettingsPage() {
}
return (
<div style={{ maxWidth: 560 }}>
<div className="settings-page">
<h2 style={{ marginTop: 0 }}></h2>
<div className="tabs">
<button
@@ -132,207 +132,228 @@ export default function SettingsPage() {
</div>
{tab === "strategy" ? (
<div className="card">
<p style={{ color: "var(--muted)", marginTop: 0 }}>
<div className="card settings-card">
<p className="settings-lead">
</p>
{stratOk ? <div style={{ color: "var(--up)", marginBottom: 12 }}>{stratOk}</div> : null}
{stratOk ? <div className="settings-ok">{stratOk}</div> : null}
{err && tab === "strategy" ? <div className="err">{err}</div> : null}
<form onSubmit={onSaveStrategy}>
<h3 style={{ margin: "0 0 10px", fontSize: 14, color: "var(--muted)" }}></h3>
<div className="field">
<label htmlFor="lev"></label>
<input
id="lev"
className="mono"
type="number"
step="1"
min="1"
value={leverage}
onChange={(e) => setLeverage(Number(e.target.value))}
/>
</div>
<section className="settings-section">
<h3></h3>
<div className="settings-fields">
<div className="field">
<label htmlFor="lev"></label>
<input
id="lev"
className="mono"
type="number"
step="1"
min="1"
value={leverage}
onChange={(e) => setLeverage(Number(e.target.value))}
/>
</div>
<div className="field">
<label htmlFor="perp"> ETH </label>
<input
id="perp"
className="mono"
type="number"
step="0.01"
min="0.01"
value={perpQty}
onChange={(e) => setPerpQty(Number(e.target.value))}
/>
</div>
<div className="field">
<label htmlFor="opt"> ETH </label>
<input
id="opt"
className="mono"
type="number"
step="0.01"
min="0.01"
value={optQty}
onChange={(e) => setOptQty(Number(e.target.value))}
/>
</div>
</div>
</section>
<h3 style={{ margin: "18px 0 10px", fontSize: 14, color: "var(--muted)" }}></h3>
<div className="field">
<label htmlFor="hours"></label>
<input
id="hours"
className="mono"
type="number"
step="1"
min="1"
value={minHours}
onChange={(e) => setMinHours(Number(e.target.value))}
/>
</div>
<div className="field">
<label htmlFor="olev">÷</label>
<input
id="olev"
className="mono"
type="number"
step="1"
min="1"
value={minOptLev}
onChange={(e) => setMinOptLev(Number(e.target.value))}
/>
</div>
<div className="field">
<label htmlFor="exitMode"></label>
<select
id="exitMode"
className="mono"
value={exitMode}
onChange={(e) =>
setExitMode(
e.target.value === "premium_multiple"
? "premium_multiple"
: "fixed_usdt",
)
}
>
<option value="fixed_usdt">USDT</option>
<option value="premium_multiple"></option>
</select>
</div>
{exitMode === "fixed_usdt" ? (
<div className="field">
<label htmlFor="netTarget">USDT</label>
<input
id="netTarget"
className="mono"
type="number"
step="0.1"
min="0.1"
value={netTarget}
onChange={(e) => setNetTarget(Number(e.target.value))}
/>
<section className="settings-section">
<h3></h3>
<div className="settings-fields">
<div className="field">
<label htmlFor="hours"></label>
<input
id="hours"
className="mono"
type="number"
step="1"
min="1"
value={minHours}
onChange={(e) => setMinHours(Number(e.target.value))}
/>
</div>
<div className="field">
<label htmlFor="olev">÷</label>
<input
id="olev"
className="mono"
type="number"
step="1"
min="1"
value={minOptLev}
onChange={(e) => setMinOptLev(Number(e.target.value))}
/>
</div>
</div>
) : (
<div className="field">
<label htmlFor="premMult">1 = </label>
<input
id="premMult"
className="mono"
type="number"
step="0.1"
min="0.1"
value={premMult}
onChange={(e) => setPremMult(Number(e.target.value))}
/>
</section>
<section className="settings-section">
<h3></h3>
<div className="settings-fields">
<div className="field">
<label htmlFor="exitMode"></label>
<select
id="exitMode"
className="mono"
value={exitMode}
onChange={(e) =>
setExitMode(
e.target.value === "premium_multiple"
? "premium_multiple"
: "fixed_usdt",
)
}
>
<option value="fixed_usdt">USDT</option>
<option value="premium_multiple"></option>
</select>
</div>
{exitMode === "fixed_usdt" ? (
<div className="field">
<label htmlFor="netTarget">USDT</label>
<input
id="netTarget"
className="mono"
type="number"
step="0.1"
min="0.1"
value={netTarget}
onChange={(e) => setNetTarget(Number(e.target.value))}
/>
</div>
) : (
<div className="field">
<label htmlFor="premMult">1 = </label>
<input
id="premMult"
className="mono"
type="number"
step="0.1"
min="0.1"
value={premMult}
onChange={(e) => setPremMult(Number(e.target.value))}
/>
</div>
)}
<div className="field">
<label htmlFor="closeDev">/%</label>
<input
id="closeDev"
className="mono"
type="number"
step="1"
min="1"
value={closeDevPct}
onChange={(e) => setCloseDevPct(Number(e.target.value))}
/>
</div>
<div className="field">
<label htmlFor="rest"></label>
<input
id="rest"
className="mono"
type="number"
step="1"
value={rest}
onChange={(e) => setRest(Number(e.target.value))}
/>
</div>
<div className="field">
<label htmlFor="fee"></label>
<input
id="fee"
className="mono"
type="number"
step="0.0001"
value={fee}
onChange={(e) => setFee(Number(e.target.value))}
/>
</div>
</div>
)}
<div className="field">
<label htmlFor="closeDev">/%</label>
<input
id="closeDev"
className="mono"
type="number"
step="1"
min="1"
value={closeDevPct}
onChange={(e) => setCloseDevPct(Number(e.target.value))}
/>
</section>
<div className="settings-actions">
<button className="btn" type="submit">
</button>
</div>
<div className="field">
<label htmlFor="perp"> ETH </label>
<input
id="perp"
className="mono"
type="number"
step="0.01"
min="0.01"
value={perpQty}
onChange={(e) => setPerpQty(Number(e.target.value))}
/>
</div>
<div className="field">
<label htmlFor="opt"> ETH </label>
<input
id="opt"
className="mono"
type="number"
step="0.01"
min="0.01"
value={optQty}
onChange={(e) => setOptQty(Number(e.target.value))}
/>
</div>
<div className="field">
<label htmlFor="rest"></label>
<input
id="rest"
className="mono"
type="number"
step="1"
value={rest}
onChange={(e) => setRest(Number(e.target.value))}
/>
</div>
<div className="field">
<label htmlFor="fee"></label>
<input
id="fee"
className="mono"
type="number"
step="0.0001"
value={fee}
onChange={(e) => setFee(Number(e.target.value))}
/>
</div>
<button className="btn" type="submit">
</button>
</form>
</div>
) : (
<div className="card">
<div className="card settings-card settings-card-narrow">
{err ? <div className="err">{err}</div> : null}
{ok ? <div style={{ color: "var(--up)", marginBottom: 12 }}>{ok}</div> : null}
<form onSubmit={onSaveCreds}>
<div className="field">
<label htmlFor="user"></label>
<input
id="user"
value={newUsername}
onChange={(e) => setNewUsername(e.target.value)}
required
/>
{ok ? <div className="settings-ok">{ok}</div> : null}
<form onSubmit={onSaveCreds} className="settings-account-form">
<div className="settings-fields">
<div className="field">
<label htmlFor="user"></label>
<input
id="user"
value={newUsername}
onChange={(e) => setNewUsername(e.target.value)}
required
/>
</div>
<div className="field">
<label htmlFor="cur"></label>
<input
id="cur"
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
required
/>
</div>
<div className="field">
<label htmlFor="np"></label>
<input
id="np"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
/>
</div>
<div className="field">
<label htmlFor="cp"></label>
<input
id="cp"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
/>
</div>
</div>
<div className="field">
<label htmlFor="cur"></label>
<input
id="cur"
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
required
/>
<div className="settings-actions">
<button className="btn" type="submit" disabled={loading}>
{loading ? "保存中…" : "保存账号"}
</button>
</div>
<div className="field">
<label htmlFor="np"></label>
<input
id="np"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
/>
</div>
<div className="field">
<label htmlFor="cp"></label>
<input
id="cp"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
/>
</div>
<button className="btn" type="submit" disabled={loading}>
{loading ? "保存中…" : "保存账号"}
</button>
</form>
</div>
)}
+145 -2
View File
@@ -145,12 +145,49 @@ input {
}
.page {
padding: 16px;
max-width: 1200px;
padding: 16px 20px 28px;
max-width: min(1680px, 100%);
margin: 0 auto;
width: 100%;
}
@media (min-width: 1800px) {
.page {
max-width: 1760px;
padding-left: 28px;
padding-right: 28px;
}
}
@media (max-width: 900px) {
.page {
padding: 12px 14px 24px;
}
.topnav {
grid-template-columns: 1fr;
gap: 10px;
}
.topnav-side.left,
.topnav-side.right {
justify-content: space-between;
}
.topnav-center {
justify-content: flex-start;
flex-wrap: wrap;
gap: 2px;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.topnav a {
padding: 8px 10px;
font-size: 13px;
}
}
.card {
background: var(--bg-panel);
border: 1px solid var(--line);
@@ -233,6 +270,8 @@ input {
border-radius: 8px;
padding: 10px 12px;
outline: none;
width: 100%;
min-height: 42px;
}
.field input:focus,
@@ -240,6 +279,96 @@ input {
border-color: rgba(240, 185, 11, 0.55);
}
/* Settings: ultrawide + tablet */
.settings-page {
width: 100%;
max-width: 1200px;
}
.settings-card {
width: 100%;
}
.settings-card-narrow {
max-width: 640px;
}
.settings-lead {
color: var(--muted);
margin: 0 0 16px;
line-height: 1.5;
font-size: 13px;
}
.settings-ok {
color: var(--up);
margin-bottom: 12px;
}
.settings-section {
margin-bottom: 8px;
padding-bottom: 8px;
border-bottom: 1px solid var(--line);
}
.settings-section:last-of-type {
border-bottom: 0;
}
.settings-section h3 {
margin: 0 0 12px;
font-size: 13px;
font-weight: 600;
color: var(--muted);
letter-spacing: 0.04em;
text-transform: none;
}
.settings-fields {
display: grid;
grid-template-columns: 1fr;
gap: 0 18px;
}
.settings-fields .field {
margin-bottom: 12px;
}
.settings-actions {
margin-top: 8px;
display: flex;
flex-wrap: wrap;
gap: 10px;
}
@media (min-width: 700px) {
.settings-fields {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (min-width: 1100px) {
.settings-page {
max-width: 1320px;
}
.settings-fields {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 699px) {
.settings-fields .field {
margin-bottom: 14px;
}
.field input,
.field select {
min-height: 44px;
font-size: 16px; /* 避免 iOS 自动缩放 */
}
}
.btn {
border: 0;
border-radius: 8px;
@@ -385,12 +514,26 @@ input {
gap: 12px 14px;
}
.pos-cell-wide {
grid-column: 1 / -1;
}
.pos-countdown-urgent {
color: #ffb020 !important;
}
@media (max-width: 520px) {
.pos-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (min-width: 1400px) {
.pos-embed-grid {
gap: 14px;
}
}
.pos-cell {
display: flex;
flex-direction: column;