import { FormEvent, ReactNode, useEffect, useState } from "react";
import {
changeCredentials,
getUsername,
setSession,
apiFetch,
StrategySettings,
RuntimeSettings,
NotifySettings,
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 [wecomEnabled, setWecomEnabled] = useState(false);
const [wecomWebhook, setWecomWebhook] = useState("");
const [wecomMeta, setWecomMeta] = useState(null);
const [wecomOk, setWecomOk] = 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);
apiFetch("/api/settings/notify")
.then((n) => {
setWecomMeta(n);
setWecomEnabled(n.enabled === true);
})
.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);
}
}
async function onSaveWecom() {
setErr("");
setWecomOk("");
try {
const body: Record = { enabled: wecomEnabled };
if (wecomWebhook.trim()) body.webhook_url = wecomWebhook.trim();
const n = await apiFetch("/api/settings/notify", {
method: "PUT",
body: JSON.stringify(body),
});
setWecomMeta(n);
setWecomEnabled(n.enabled === true);
setWecomWebhook("");
setWecomOk(
n.enabled
? n.webhook_configured
? "企业微信通知已保存并开启"
: "已开启,但尚未配置 Webhook"
: "企业微信通知已关闭",
);
} catch (ex) {
setErr(ex instanceof Error ? ex.message : String(ex));
}
}
async function onTestWecom() {
setErr("");
setWecomOk("");
try {
const n = await apiFetch(
"/api/settings/notify/test",
{ method: "POST", body: "{}" },
);
setWecomMeta(n);
setWecomOk(n.detail || "测试消息已发送,请查看企业微信群");
} catch (ex) {
setErr(ex instanceof Error ? ex.message : String(ex));
}
}
return (
系统设置
{tab === "strategy" ? (
{stratOk ?
{stratOk}
: null}
{err && tab === "strategy" ?
{err}
: null}
{(
[
["position", "仓位"],
["select", "选约"],
["exit", "出场"],
["pace", "节奏"],
] as const
).map(([id, label]) => (
))}
) : null}
{tab === "runtime" ? (
{runtimeOk ?
{runtimeOk}
: null}
{err && tab === "runtime" ?
{err}
: null}
) : null}
{tab === "account" ? (
{err ?
{err}
: null}
{ok ?
{ok}
: null}
) : null}
{tab === "backup" ? (
{bakOk ?
{bakOk}
: null}
{err && tab === "backup" ?
{err}
: null}
{bakBusy ?
{bakBusy}…
: null}
服务器备份列表
{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
。恢复后服务自动重启。
) : null}
);
}