diff --git a/backend/app/sim/matcher.py b/backend/app/sim/matcher.py index 3650293..e7b2f06 100644 --- a/backend/app/sim/matcher.py +++ b/backend/app/sim/matcher.py @@ -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, diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 2208680..d97861d 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -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; diff --git a/frontend/src/pages/Plan.tsx b/frontend/src/pages/Plan.tsx index d48eb39..07a114d 100644 --- a/frontend/src/pages/Plan.tsx +++ b/frontend/src/pages/Plan.tsx @@ -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 = { idle: "空闲", wait_signal: "等待信号", @@ -29,6 +51,7 @@ export default function PlanPage() { const [plan, setPlan] = useState(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 (
@@ -290,6 +324,14 @@ export default function PlanPage() {
+
+ 到期倒计时 + + {countdown} + +
开仓均价 {fmt(pos?.option_entry_px)} diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 7ea763a..b744712 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -112,7 +112,7 @@ export default function SettingsPage() { } return ( -
+

系统设置

-
- - setPerpQty(Number(e.target.value))} - /> -
-
- - setOptQty(Number(e.target.value))} - /> -
-
- - setRest(Number(e.target.value))} - /> -
-
- - setFee(Number(e.target.value))} - /> -
-
) : ( -
+
{err ?
{err}
: null} - {ok ?
{ok}
: null} -
-
- - setNewUsername(e.target.value)} - required - /> + {ok ?
{ok}
: null} + +
+
+ + setNewUsername(e.target.value)} + required + /> +
+
+ + setCurrentPassword(e.target.value)} + required + /> +
+
+ + setNewPassword(e.target.value)} + required + /> +
+
+ + setConfirmPassword(e.target.value)} + required + /> +
-
- - setCurrentPassword(e.target.value)} - required - /> +
+
-
- - setNewPassword(e.target.value)} - required - /> -
-
- - setConfirmPassword(e.target.value)} - required - /> -
-
)} diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index 500b648..0348b37 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -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;