Add OKX-style funds bar with USDT/USDC convert and transfer.

SIM uses multi-wallet balances; LIVE hits OKX asset/account APIs and spot USDC-USDT swap. Funds strip hides sim labeling.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-29 17:06:13 +08:00
parent 1c75db4a19
commit 5c3bd4b654
13 changed files with 1020 additions and 6 deletions
+2
View File
@@ -6,6 +6,7 @@ import {
getUsername,
startAuthTokenAutoRefresh,
} from "./api/client";
import FundsBar from "./components/FundsBar";
import LoginPage from "./pages/Login";
import PlanPage from "./pages/Plan";
import TradesPage from "./pages/Trades";
@@ -63,6 +64,7 @@ function Shell({ children }: { children: ReactNode }) {
</button>
</div>
</header>
<FundsBar />
<main className="page">{children}</main>
<nav className="bottom-nav" aria-label="手机导航">
{NAV.map((item) => (
+104
View File
@@ -0,0 +1,104 @@
import { useCallback, useEffect, useState } from "react";
import { apiFetch } from "../api/client";
export type FundsSummary = {
ok: boolean;
exchange: string;
trading_day: string;
total_trades: number;
win_rate: number;
profit_loss_ratio: number | null;
total_funds: number | null;
funding_usdt: number | null;
trading_usdt: number | null;
options_funding_label: string;
options_trading_label: string;
realtime_pnl: number | null;
usdc_usdt_rate?: number;
detail?: string;
};
function fmtU(n: number | null | undefined) {
if (n == null || Number.isNaN(n)) return "—";
return `${n.toFixed(2)}U`;
}
function pnlClass(n: number | null | undefined) {
if (n == null || Number.isNaN(n) || n === 0) return "";
return n > 0 ? "pos-pnl-profit" : "pos-pnl-loss";
}
export default function FundsBar() {
const [s, setS] = useState<FundsSummary | null>(null);
const load = useCallback(() => {
apiFetch<FundsSummary>("/api/funds/summary")
.then(setS)
.catch(() => {
/* 顶栏静默失败,避免刷屏 */
});
}, []);
useEffect(() => {
load();
const t = window.setInterval(load, 5000);
return () => window.clearInterval(t);
}, [load]);
if (!s?.ok) return null;
return (
<div className="funds-bar" aria-label="资金与统计">
<div className="funds-item funds-item--primary">
<div className="funds-label"></div>
<div className="funds-value">{s.exchange || "—"}</div>
</div>
<div className="funds-item">
<div className="funds-label"></div>
<div className="funds-value mono">{s.trading_day}</div>
</div>
<div className="funds-item funds-item--primary">
<div className="funds-label"></div>
<div className="funds-value mono">{s.total_trades}</div>
</div>
<div className="funds-item">
<div className="funds-label"></div>
<div className="funds-value mono">
{(s.win_rate * 100).toFixed(0)}%
</div>
</div>
<div className="funds-item" title="平均盈利 ÷ 平均亏损">
<div className="funds-label"></div>
<div className="funds-value mono">
{s.profit_loss_ratio != null ? s.profit_loss_ratio.toFixed(2) : "—"}
</div>
</div>
<div className="funds-item">
<div className="funds-label"></div>
<div className="funds-value mono">{fmtU(s.total_funds)}</div>
</div>
<div className="funds-item">
<div className="funds-label"></div>
<div className="funds-value mono">{fmtU(s.funding_usdt)}</div>
</div>
<div className="funds-item">
<div className="funds-label"></div>
<div className="funds-value mono">{fmtU(s.trading_usdt)}</div>
</div>
<div className="funds-item">
<div className="funds-label"></div>
<div className="funds-value mono">{s.options_funding_label}</div>
</div>
<div className="funds-item">
<div className="funds-label"></div>
<div className="funds-value mono">{s.options_trading_label}</div>
</div>
<div className="funds-item funds-item--pnl">
<div className="funds-label"></div>
<div className={`funds-value mono ${pnlClass(s.realtime_pnl)}`}>
{s.realtime_pnl == null ? "—" : fmtU(s.realtime_pnl)}
</div>
</div>
</div>
);
}
+1 -2
View File
@@ -168,8 +168,7 @@ export default function PlanPage() {
<div className="plan-desktop-head">
<h2 style={{ marginTop: 0 }}></h2>
<p style={{ color: "var(--muted)", marginTop: -8 }}>
{plan?.mode === "LIVE" ? "LIVE 实盘下单" : "SIM 本地撮合"} · {" "}
{(snap?.exchange || "okx").toUpperCase()}
{(snap?.exchange || "okx").toUpperCase()}
{snap?.perp_inst_id ? ` · ${snap.perp_inst_id}` : ""} ·
(/) ·
{plan?.mode === "LIVE" && plan.live_ready === false
+190 -1
View File
@@ -12,7 +12,7 @@ import {
uploadRestoreBackup,
} from "../api/client";
type Tab = "strategy" | "runtime" | "account" | "backup";
type Tab = "strategy" | "runtime" | "funds" | "account" | "backup";
type StratSub =
| "position"
| "select"
@@ -104,6 +104,18 @@ export default function SettingsPage() {
const [restoreFile, setRestoreFile] = useState<File | null>(null);
const [restorePhrase, setRestorePhrase] = useState("");
const [fundsOk, setFundsOk] = useState("");
const [fundsErr, setFundsErr] = useState("");
const [convertDir, setConvertDir] = useState<"usdt_to_usdc" | "usdc_to_usdt">(
"usdt_to_usdc",
);
const [convertAmt, setConvertAmt] = useState(100);
const [xferCcy, setXferCcy] = useState<"USDT" | "USDC">("USDC");
const [xferAmt, setXferAmt] = useState(50);
const [xferFrom, setXferFrom] = useState("funding");
const [xferTo, setXferTo] = useState("trading");
const [usdcRate, setUsdcRate] = useState<number | null>(null);
function loadRuntime() {
apiFetch<RuntimeSettings>("/api/settings/runtime")
.then((r) => {
@@ -426,6 +438,20 @@ export default function SettingsPage() {
>
</button>
<button
type="button"
className={tab === "funds" ? "tab active" : "tab"}
onClick={() => {
setTab("funds");
setFundsErr("");
setFundsOk("");
apiFetch<{ usdc_usdt_rate?: number }>("/api/funds/summary")
.then((r) => setUsdcRate(r.usdc_usdt_rate ?? null))
.catch(() => undefined);
}}
>
</button>
<button
type="button"
className={tab === "account" ? "tab active" : "tab"}
@@ -1031,6 +1057,169 @@ export default function SettingsPage() {
</div>
) : null}
{tab === "funds" ? (
<div className="card settings-card">
{fundsErr ? <div className="err">{fundsErr}</div> : null}
{fundsOk ? <div className="settings-ok">{fundsOk}</div> : null}
<section className="settings-section">
<h3>USDT USDC</h3>
<p className="meta" style={{ marginTop: 0 }}>
OKX USDC-USDT{" "}
{usdcRate != null ? `1 USDC ≈ ${usdcRate.toFixed(4)} USDT` : "—"}
USDT USDC
</p>
<div className="settings-fields">
<div className="field">
<label htmlFor="convertDir"></label>
<select
id="convertDir"
value={convertDir}
onChange={(e) =>
setConvertDir(
e.target.value === "usdc_to_usdt"
? "usdc_to_usdt"
: "usdt_to_usdc",
)
}
>
<option value="usdt_to_usdc">USDT USDC</option>
<option value="usdc_to_usdt">USDC USDT</option>
</select>
</div>
<div className="field">
<label htmlFor="convertAmt"></label>
<input
id="convertAmt"
type="number"
min={0}
step="0.01"
value={convertAmt}
onChange={(e) => setConvertAmt(Number(e.target.value))}
/>
</div>
</div>
<div className="settings-actions">
<button
className="btn"
type="button"
disabled={loading}
onClick={async () => {
setFundsErr("");
setFundsOk("");
setLoading(true);
try {
await apiFetch("/api/funds/convert", {
method: "POST",
body: JSON.stringify({
direction: convertDir,
amount: convertAmt,
}),
});
setFundsOk("兑换成功,顶部资金条将刷新");
} catch (e) {
setFundsErr(e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
}
}}
>
</button>
</div>
</section>
<section className="settings-section">
<h3></h3>
<p className="meta" style={{ marginTop: 0 }}>
USDT/USDC options_funding /
options_trading
</p>
<div className="settings-fields">
<div className="field">
<label htmlFor="xferCcy"></label>
<select
id="xferCcy"
value={xferCcy}
onChange={(e) =>
setXferCcy(e.target.value === "USDT" ? "USDT" : "USDC")
}
>
<option value="USDC">USDC</option>
<option value="USDT">USDT</option>
</select>
</div>
<div className="field">
<label htmlFor="xferFrom"></label>
<select
id="xferFrom"
value={xferFrom}
onChange={(e) => setXferFrom(e.target.value)}
>
<option value="funding"></option>
<option value="trading"></option>
<option value="options_funding"></option>
<option value="options_trading"></option>
</select>
</div>
<div className="field">
<label htmlFor="xferTo"></label>
<select
id="xferTo"
value={xferTo}
onChange={(e) => setXferTo(e.target.value)}
>
<option value="funding"></option>
<option value="trading"></option>
<option value="options_funding"></option>
<option value="options_trading"></option>
</select>
</div>
<div className="field">
<label htmlFor="xferAmt"></label>
<input
id="xferAmt"
type="number"
min={0}
step="0.01"
value={xferAmt}
onChange={(e) => setXferAmt(Number(e.target.value))}
/>
</div>
</div>
<div className="settings-actions">
<button
className="btn"
type="button"
disabled={loading}
onClick={async () => {
setFundsErr("");
setFundsOk("");
setLoading(true);
try {
await apiFetch("/api/funds/transfer", {
method: "POST",
body: JSON.stringify({
ccy: xferCcy,
amount: xferAmt,
from_account: xferFrom,
to_account: xferTo,
}),
});
setFundsOk("划转成功,顶部资金条将刷新");
} catch (e) {
setFundsErr(e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
}
}}
>
</button>
</div>
</section>
</div>
) : null}
{tab === "account" ? (
<div className="card settings-card settings-card-narrow">
{err ? <div className="err">{err}</div> : null}
+46 -1
View File
@@ -80,7 +80,52 @@ input {
.app-shell {
min-height: 100vh;
display: grid;
grid-template-rows: auto 1fr;
grid-template-rows: auto auto 1fr;
}
.funds-bar {
display: flex;
flex-wrap: wrap;
gap: 2px 0;
padding: 8px 12px;
border-bottom: 1px solid var(--line);
background: rgba(18, 22, 28, 0.96);
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.funds-item {
flex: 1 1 auto;
min-width: 88px;
padding: 4px 10px;
border-right: 1px solid var(--line);
}
.funds-item:last-child {
border-right: none;
}
.funds-item--primary .funds-value {
color: var(--accent);
}
.funds-label {
font-size: 11px;
color: var(--muted);
line-height: 1.2;
margin-bottom: 2px;
white-space: nowrap;
}
.funds-value {
font-size: 13px;
font-weight: 600;
line-height: 1.25;
white-space: nowrap;
}
.funds-item--pnl .funds-value {
font-variant-numeric: tabular-nums;
}
.topnav {