import { FormEvent, ReactNode, useEffect, useState } from "react"; import { changeCredentials, getUsername, setSession, apiFetch, StrategySettings, RuntimeSettings, BackupStatus, downloadBackup, uploadRestoreBackup, } from "../api/client"; type Tab = "strategy" | "runtime" | "account" | "backup"; type StratSub = | "position" | "select" | "exit" | "pace"; function RulesFold({ open, onToggle, children, }: { open: boolean; onToggle: () => void; children: ReactNode; }) { return (
{open ?
{children}
: null}
); } export default function SettingsPage() { const [tab, setTab] = useState("strategy"); const [stratSub, setStratSub] = useState("position"); const [stratRulesOpen, setStratRulesOpen] = useState(false); const [runtimeRulesOpen, setRuntimeRulesOpen] = useState(false); const [newUsername, setNewUsername] = useState(getUsername() || "admin"); const [currentPassword, setCurrentPassword] = useState(""); const [newPassword, setNewPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); const [err, setErr] = useState(""); const [ok, setOk] = useState(""); const [loading, setLoading] = useState(false); const [fee, setFee] = useState(0.0005); const [exitMode, setExitMode] = useState<"fixed_usdt" | "premium_multiple">( "fixed_usdt", ); const [netTarget, setNetTarget] = useState(15); const [premMult, setPremMult] = useState(1); const [rest, setRest] = useState(300); const [orderInterval, setOrderInterval] = useState(1); const [skipWeekends, setSkipWeekends] = useState(true); const [leverage, setLeverage] = useState(3); const [minHours, setMinHours] = useState(12); const [minOptLev, setMinOptLev] = useState(100); const [atmOffOn, setAtmOffOn] = useState(false); const [maxAtmOff, setMaxAtmOff] = useState(3); const [closeDevPct, setCloseDevPct] = useState(30); const [perpQty, setPerpQty] = useState(1); const [optQty, setOptQty] = useState(2); const [showManualTrade, setShowManualTrade] = useState(false); const [initialEquity, setInitialEquity] = useState(10000); const [exchange, setExchange] = useState<"okx" | "binance">("okx"); const [stratOk, setStratOk] = useState(""); const [runtime, setRuntime] = useState(null); const [mode, setMode] = useState<"SIM" | "LIVE">("SIM"); const [okxKey, setOkxKey] = useState(""); const [okxSecret, setOkxSecret] = useState(""); const [okxPass, setOkxPass] = useState(""); const [bnKey, setBnKey] = useState(""); const [bnSecret, setBnSecret] = useState(""); const [runtimeOk, setRuntimeOk] = useState(""); const [backup, setBackup] = useState(null); const [bakAuto, setBakAuto] = useState(true); const [bakInterval, setBakInterval] = useState(24); const [bakKeep, setBakKeep] = useState(14); const [bakOk, setBakOk] = useState(""); const [bakBusy, setBakBusy] = useState(""); const [restoreFile, setRestoreFile] = useState(null); const [restorePhrase, setRestorePhrase] = useState(""); function loadRuntime() { apiFetch("/api/settings/runtime") .then((r) => { setRuntime(r); setMode(r.mode === "LIVE" ? "LIVE" : "SIM"); }) .catch(() => undefined); } function loadBackup() { apiFetch("/api/backup/status") .then((s) => { setBackup(s); setBakAuto(s.auto_enabled !== false); setBakInterval(s.interval_hours ?? 24); setBakKeep(s.keep_count ?? 14); }) .catch(() => undefined); } useEffect(() => { apiFetch("/api/settings/strategy") .then((s) => { setFee(s.fee_rate); setExitMode(s.exit_mode === "premium_multiple" ? "premium_multiple" : "fixed_usdt"); setNetTarget(s.net_profit_target ?? 15); setPremMult(s.premium_exit_multiple ?? 1); setRest(s.rest_seconds); setOrderInterval(s.live_order_interval_sec ?? 1); setSkipWeekends(s.skip_weekends !== false); setLeverage(s.leverage ?? 3); setMinHours(s.min_option_hours ?? 12); setMinOptLev(s.min_option_leverage ?? 100); setAtmOffOn(s.atm_open_offset_enabled === true); setMaxAtmOff(s.max_atm_open_offset ?? 3); setCloseDevPct(s.close_bid_mark_max_pct ?? 30); setPerpQty(s.perp_qty_eth ?? 1); setOptQty(s.option_qty_eth ?? 2); setShowManualTrade(s.show_manual_trade_buttons === true); setInitialEquity(s.initial_equity ?? 10000); setExchange(s.exchange === "binance" ? "binance" : "okx"); }) .catch(() => undefined); loadRuntime(); }, []); async function onSaveBackupSettings(e: FormEvent) { e.preventDefault(); setErr(""); setBakOk(""); try { const s = await apiFetch("/api/backup/settings", { method: "PUT", body: JSON.stringify({ auto_enabled: bakAuto, interval_hours: bakInterval, keep_count: bakKeep, }), }); setBackup(s); setBakOk("备份设置已保存"); } catch (ex) { setErr(ex instanceof Error ? ex.message : String(ex)); } } async function onBackupNow() { setErr(""); setBakOk(""); setBakBusy("备份中"); try { const r = await apiFetch<{ name: string; backup_dir: string }>( "/api/backup/now", { method: "POST", body: "{}" }, ); setBakOk(`已备份 ${r.name} → ${r.backup_dir}`); loadBackup(); } catch (ex) { setErr(ex instanceof Error ? ex.message : String(ex)); } finally { setBakBusy(""); } } async function onDownload(name: string) { setErr(""); setBakBusy("下载中"); try { await downloadBackup(name); setBakOk(`已下载 ${name}`); } catch (ex) { setErr(ex instanceof Error ? ex.message : String(ex)); } finally { setBakBusy(""); } } async function onRestore() { setErr(""); setBakOk(""); if (!restoreFile) { setErr("请先选择备份 zip"); return; } if (restorePhrase.trim() !== "RESTORE") { setErr("请输入确认串 RESTORE(大写)"); return; } if ( !window.confirm( "将用备份覆盖本机数据库与 .env,服务会自动重启。新服务器迁移请确认包来自可信来源。继续?", ) ) { return; } setBakBusy("恢复中"); try { const r = await uploadRestoreBackup(restoreFile, restorePhrase.trim()); setBakOk(r.detail || "恢复成功,请等待重启后刷新"); window.setTimeout(() => { window.location.reload(); }, 2500); } catch (ex) { setErr(ex instanceof Error ? ex.message : String(ex)); } finally { setBakBusy(""); } } async function onSaveCreds(e: FormEvent) { e.preventDefault(); setErr(""); setOk(""); if (newPassword !== confirmPassword) { setErr("两次输入的新密码不一致"); return; } if (newPassword.length < 4) { setErr("新密码至少 4 位"); return; } setLoading(true); try { const res = await changeCredentials({ current_password: currentPassword, new_username: newUsername.trim(), new_password: newPassword, }); setSession(res.token, res.username, res.expires_in); setCurrentPassword(""); setNewPassword(""); setConfirmPassword(""); setOk("用户名/密码已更新"); } catch (ex) { setErr(ex instanceof Error ? ex.message : String(ex)); } finally { setLoading(false); } } async function onSaveStrategy(e: FormEvent) { e.preventDefault(); setStratOk(""); setErr(""); const isLive = (runtime?.mode ?? mode) === "LIVE"; try { const body: Record = { fee_rate: fee, exit_mode: exitMode, net_profit_target: netTarget, premium_exit_multiple: premMult, rest_seconds: rest, live_order_interval_sec: orderInterval, skip_weekends: skipWeekends, leverage, min_option_hours: minHours, min_option_leverage: minOptLev, atm_open_offset_enabled: atmOffOn, max_atm_open_offset: maxAtmOff, close_bid_mark_max_pct: closeDevPct, perp_qty_eth: perpQty, option_qty_eth: optQty, show_manual_trade_buttons: showManualTrade, exchange, }; // LIVE 不改模拟资金,避免误重置本地账本 if (!isLive) { body.initial_equity = initialEquity; } await apiFetch("/api/settings/strategy", { method: "PUT", body: JSON.stringify(body), }); setStratOk( isLive ? "策略参数已保存(实盘模式不改模拟资金)" : "策略参数已保存(切换交易所/改模拟资金需无持仓;切换后会重连行情)", ); } catch (ex) { setErr(ex instanceof Error ? ex.message : String(ex)); } } async function onSaveRuntime(e: FormEvent) { e.preventDefault(); setErr(""); setRuntimeOk(""); const goingLive = mode === "LIVE" && runtime?.mode !== "LIVE"; if (goingLive) { const typed = window.prompt('切换到 LIVE 实盘:请输入 LIVE 确认(将真实下单)'); if (typed !== "LIVE") { setErr("已取消:须输入 LIVE 才能切换到实盘"); return; } } setLoading(true); try { const body: Record = { mode, confirm_live: goingLive, confirm_live_phrase: goingLive ? "LIVE" : undefined, }; if (okxKey.trim()) body.okx_api_key = okxKey.trim(); if (okxSecret.trim()) body.okx_api_secret = okxSecret.trim(); if (okxPass.trim()) body.okx_api_passphrase = okxPass.trim(); if (bnKey.trim()) body.binance_api_key = bnKey.trim(); if (bnSecret.trim()) body.binance_api_secret = bnSecret.trim(); const r = await apiFetch("/api/settings/runtime", { method: "PUT", body: JSON.stringify(body), }); setRuntime(r); setMode(r.mode === "LIVE" ? "LIVE" : "SIM"); setOkxKey(""); setOkxSecret(""); setOkxPass(""); setBnKey(""); setBnSecret(""); setRuntimeOk( r.mode === "LIVE" ? r.live_ready ? "已切换 LIVE,密钥已写入 .env" : `已切 LIVE,但未就绪:${r.live_ready_reason}` : "已切换 SIM,配置已写入 .env", ); } catch (ex) { setErr(ex instanceof Error ? ex.message : String(ex)); } finally { setLoading(false); } } return (

系统设置

{tab === "strategy" ? (
{stratOk ?
{stratOk}
: null} {err && tab === "strategy" ?
{err}
: null}
{( [ ["position", "仓位"], ["select", "选约"], ["exit", "出场"], ["pace", "节奏"], ] as const ).map(([id, label]) => ( ))}
{stratSub === "position" ? (

交易所与仓位

{(runtime?.mode ?? mode) !== "LIVE" ? (
setInitialEquity(Number(e.target.value)) } />
) : null}
setLeverage(Number(e.target.value))} />
setPerpQty(Number(e.target.value))} />
setOptQty(Number(e.target.value))} />
) : null} {stratSub === "select" ? (

选约条件

setMinHours(Number(e.target.value))} />
setMinOptLev(Number(e.target.value))} />
setMaxAtmOff(Number(e.target.value))} />
) : null} {stratSub === "exit" ? (

出场与平仓

{exitMode === "fixed_usdt" ? (
setNetTarget(Number(e.target.value))} />
) : (
setPremMult(Number(e.target.value))} />
)}
setCloseDevPct(Number(e.target.value))} />
) : null} {stratSub === "pace" ? (

节奏与费用

setRest(Number(e.target.value))} />
setOrderInterval(Number(e.target.value)) } />
setFee(Number(e.target.value))} />
) : null} setStratRulesOpen((v) => !v)} >
  • {(runtime?.mode ?? mode) === "LIVE" ? "当前 LIVE:仓位/选约/出场影响真下单;模拟资金已隐藏。" : "当前 SIM:本地撮合;可改模拟资金与交易所行情源。"}
  • {stratSub === "position" ? ( <>
  • 交易所:有持仓时不可切换。切换后套用该所合约并重连行情。
  • {(runtime?.mode ?? mode) !== "LIVE" ? (
  • 模拟资金:仅 SIM 显示。保存后重置权益(须无持仓)。默认 10000。
  • ) : null} ) : null} {stratSub === "select" ? ( <>
  • 开仓 ATM 偏差限制:关闭时不限制 |ATM − 现价|;开启后超过下方点数则不开仓。
  • 开仓 ATM 最大偏差:仅开关开启时生效。币安行权价较粗时可能长时间无开仓机会。
  • ) : null} {stratSub === "exit" ? (
  • 平仓买一/标记最大偏差:主要用于 SIM 流动性闸门;LIVE 以交易所能否成交为准。
  • ) : null} {stratSub === "pace" ? ( <>
  • 实盘下单最小间隔:LIVE 私有下单/查单间隔,默认 1s。范围 0.2–30。
  • 显示手动开仓:默认关闭。开启后计划页才出现「手动开一组 / 手动全平」。策略自动运行中禁止手动开仓(须先暂停);紧急全平始终可用。
  • ) : null}
) : null} {tab === "runtime" ? (
{runtimeOk ?
{runtimeOk}
: null} {err && tab === "runtime" ?
{err}
: null}

模式

OKX API

setOkxKey(e.target.value)} />
setOkxSecret(e.target.value)} />
setOkxPass(e.target.value)} />

币安 API

setBnKey(e.target.value)} />
setBnSecret(e.target.value)} />
setRuntimeRulesOpen((v) => !v)} >
  • SIM = 本地撮合;LIVE = 当前所选交易所真下单(OKX / 币安均已接入)。密钥只写入服务器{" "} .env ,接口不回传明文。OKX 期权常需账户有 USDC;币安多为 USDT。
  • 当前:{runtime?.mode || "—"} · 交易所{" "} {runtime?.exchange || "—"} ·{" "} {runtime?.mode === "LIVE" ? runtime.live_ready ? "LIVE 就绪" : `未就绪(${runtime.live_ready_reason})` : "SIM"} 。有持仓时不可切换模式。
  • 密钥留空保存 = 不覆盖已有配置。交易所选币安且 LIVE 时用币安密钥真下单(无 Passphrase)。
) : null} {tab === "account" ? (
{err ?
{err}
: null} {ok ?
{ok}
: null}
setNewUsername(e.target.value)} required />
setCurrentPassword(e.target.value)} required />
setNewPassword(e.target.value)} required />
setConfirmPassword(e.target.value)} required />
) : null} {tab === "backup" ? (
{bakOk ?
{bakOk}
: null} {err && tab === "backup" ?
{err}
: null} {bakBusy ?
{bakBusy}…
: null}

自动备份

备份内容:SQLite 数据库 +{" "} .env(含登录与交易所密钥)。服务器目录: {" "} {backup?.backup_dir || "/root/eth_hedge_backups"}

setBakInterval(Number(e.target.value))} />
setBakKeep(Number(e.target.value))} />

服务器备份列表

{backup?.items && backup.items.length > 0 ? (
{backup.items.map((it) => (
{it.name} {" "} · {(it.size_bytes / 1024).toFixed(1)} KB ·{" "} {new Date(it.mtime_ms).toLocaleString()}
))}
) : (

暂无备份,可点「立即备份」。

)}

上传恢复(新服务器迁移)

在新机器部署后,上传旧机下载的 zip,将覆盖数据库与{" "} .env 。须无持仓;确认串填写{" "} RESTORE 。恢复后服务自动重启。

setRestoreFile(e.target.files?.[0] ?? null) } />
setRestorePhrase(e.target.value)} />
) : null}
); }