Files
eth_hedge_sim/frontend/src/pages/Settings.tsx
T
dekun 56a4007130 Add WeCom markdown alerts and keep target exits while strategy is paused.
Notify open/close/start/pause/fault with SIM vs LIVE venue labels; configure webhook in Settings.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 23:19:39 +08:00

1247 lines
44 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (
<div className={`settings-rules ${open ? "open" : ""}`}>
<button
type="button"
className="settings-rules-toggle"
aria-expanded={open}
onClick={onToggle}
>
<span></span>
<span className="settings-rules-chevron" aria-hidden>
{open ? "▾" : "▸"}
</span>
</button>
{open ? <div className="settings-rules-body">{children}</div> : null}
</div>
);
}
export default function SettingsPage() {
const [tab, setTab] = useState<Tab>("strategy");
const [stratSub, setStratSub] = useState<StratSub>("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<RuntimeSettings | null>(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<NotifySettings | null>(null);
const [wecomOk, setWecomOk] = useState("");
const [backup, setBackup] = useState<BackupStatus | null>(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<File | null>(null);
const [restorePhrase, setRestorePhrase] = useState("");
function loadRuntime() {
apiFetch<RuntimeSettings>("/api/settings/runtime")
.then((r) => {
setRuntime(r);
setMode(r.mode === "LIVE" ? "LIVE" : "SIM");
})
.catch(() => undefined);
apiFetch<NotifySettings>("/api/settings/notify")
.then((n) => {
setWecomMeta(n);
setWecomEnabled(n.enabled === true);
})
.catch(() => undefined);
}
function loadBackup() {
apiFetch<BackupStatus>("/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<StrategySettings>("/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<BackupStatus>("/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<string, unknown> = {
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<string, unknown> = {
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<RuntimeSettings>("/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<string, unknown> = { enabled: wecomEnabled };
if (wecomWebhook.trim()) body.webhook_url = wecomWebhook.trim();
const n = await apiFetch<NotifySettings>("/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<NotifySettings & { detail?: string }>(
"/api/settings/notify/test",
{ method: "POST", body: "{}" },
);
setWecomMeta(n);
setWecomOk(n.detail || "测试消息已发送,请查看企业微信群");
} catch (ex) {
setErr(ex instanceof Error ? ex.message : String(ex));
}
}
return (
<div className="settings-page">
<h2 style={{ marginTop: 0 }}></h2>
<div className="tabs">
<button
type="button"
className={tab === "strategy" ? "tab active" : "tab"}
onClick={() => {
setTab("strategy");
loadRuntime();
}}
>
</button>
<button
type="button"
className={tab === "runtime" ? "tab active" : "tab"}
onClick={() => {
setTab("runtime");
loadRuntime();
}}
>
</button>
<button
type="button"
className={tab === "account" ? "tab active" : "tab"}
onClick={() => setTab("account")}
>
</button>
<button
type="button"
className={tab === "backup" ? "tab active" : "tab"}
onClick={() => {
setTab("backup");
loadBackup();
}}
>
</button>
</div>
{tab === "strategy" ? (
<div className="card settings-card">
{stratOk ? <div className="settings-ok">{stratOk}</div> : null}
{err && tab === "strategy" ? <div className="err">{err}</div> : null}
<div className="settings-subtabs" role="tablist" aria-label="策略子分类">
{(
[
["position", "仓位"],
["select", "选约"],
["exit", "出场"],
["pace", "节奏"],
] as const
).map(([id, label]) => (
<button
key={id}
type="button"
role="tab"
aria-selected={stratSub === id}
className={
stratSub === id ? "settings-subtab active" : "settings-subtab"
}
onClick={() => setStratSub(id)}
>
{label}
</button>
))}
</div>
<form onSubmit={onSaveStrategy}>
{stratSub === "position" ? (
<section className="settings-section">
<h3></h3>
<div className="settings-fields">
<div className="field">
<label htmlFor="exch"></label>
<select
id="exch"
className="mono"
value={exchange}
onChange={(e) =>
setExchange(
e.target.value === "binance" ? "binance" : "okx",
)
}
>
<option value="okx">OKXUSDC </option>
<option value="binance">
USDT +
</option>
</select>
</div>
{(runtime?.mode ?? mode) !== "LIVE" ? (
<div className="field">
<label htmlFor="equity">USDT</label>
<input
id="equity"
className="mono"
type="number"
step="100"
min="1000"
value={initialEquity}
onChange={(e) =>
setInitialEquity(Number(e.target.value))
}
/>
</div>
) : null}
<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>
) : null}
{stratSub === "select" ? (
<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 className="field">
<label htmlFor="atmoffOn"> ATM </label>
<select
id="atmoffOn"
className="mono"
value={atmOffOn ? "1" : "0"}
onChange={(e) => setAtmOffOn(e.target.value === "1")}
>
<option value="0"></option>
<option value="1"></option>
</select>
</div>
<div className="field">
<label htmlFor="atmoff"> ATM </label>
<input
id="atmoff"
className="mono"
type="number"
step="0.5"
min="0"
disabled={!atmOffOn}
value={maxAtmOff}
onChange={(e) => setMaxAtmOff(Number(e.target.value))}
/>
</div>
</div>
</section>
) : null}
{stratSub === "exit" ? (
<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>
</section>
) : null}
{stratSub === "pace" ? (
<section className="settings-section">
<h3></h3>
<div className="settings-fields">
<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="orderInterval">
</label>
<input
id="orderInterval"
className="mono"
type="number"
step="0.1"
min="0.2"
max="30"
value={orderInterval}
onChange={(e) =>
setOrderInterval(Number(e.target.value))
}
/>
</div>
<div className="field">
<label htmlFor="skipWe"></label>
<select
id="skipWe"
className="mono"
value={skipWeekends ? "1" : "0"}
onChange={(e) => setSkipWeekends(e.target.value === "1")}
>
<option value="1"></option>
<option value="0"></option>
</select>
</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 className="field">
<label htmlFor="showManual"></label>
<select
id="showManual"
className="mono"
value={showManualTrade ? "1" : "0"}
onChange={(e) =>
setShowManualTrade(e.target.value === "1")
}
>
<option value="0"></option>
<option value="1"> / </option>
</select>
</div>
</div>
</section>
) : null}
<RulesFold
open={stratRulesOpen}
onToggle={() => setStratRulesOpen((v) => !v)}
>
<ul className="settings-rules-list">
<li>
{(runtime?.mode ?? mode) === "LIVE"
? "当前 LIVE:仓位/选约/出场影响真下单;模拟资金已隐藏。"
: "当前 SIM:本地撮合;可改模拟资金与交易所行情源。"}
</li>
{stratSub === "position" ? (
<>
<li></li>
{(runtime?.mode ?? mode) !== "LIVE" ? (
<li>
SIM
10000
</li>
) : null}
</>
) : null}
{stratSub === "select" ? (
<>
<li>
ATM |ATM
|
</li>
<li>
ATM
</li>
</>
) : null}
{stratSub === "exit" ? (
<li>
/ SIM
LIVE
</li>
) : null}
{stratSub === "pace" ? (
<>
<li>
LIVE / 1s
0.230
</li>
<li>
/
</li>
</>
) : null}
</ul>
</RulesFold>
<div className="settings-actions">
<button className="btn" type="submit">
</button>
</div>
</form>
</div>
) : null}
{tab === "runtime" ? (
<div className="card settings-card">
{runtimeOk ? <div className="settings-ok">{runtimeOk}</div> : null}
{err && tab === "runtime" ? <div className="err">{err}</div> : null}
<form onSubmit={onSaveRuntime}>
<section className="settings-section">
<h3></h3>
<div className="settings-fields">
<div className="field">
<label htmlFor="mode"></label>
<select
id="mode"
className="mono"
value={mode}
onChange={(e) =>
setMode(e.target.value === "LIVE" ? "LIVE" : "SIM")
}
>
<option value="SIM">SIM </option>
<option value="LIVE">LIVE </option>
</select>
</div>
</div>
</section>
<section className="settings-section">
<h3>OKX API</h3>
<div className="settings-fields">
<div className="field">
<label htmlFor="okxKey">API Key</label>
<input
id="okxKey"
className="mono"
type="password"
autoComplete="off"
placeholder={
runtime?.okx_api_key_masked
? `已配置 ${runtime.okx_api_key_masked}`
: "未配置"
}
value={okxKey}
onChange={(e) => setOkxKey(e.target.value)}
/>
</div>
<div className="field">
<label htmlFor="okxSecret">Secret</label>
<input
id="okxSecret"
className="mono"
type="password"
autoComplete="off"
placeholder={
runtime?.okx_api_secret_masked
? `已配置 ${runtime.okx_api_secret_masked}`
: "未配置"
}
value={okxSecret}
onChange={(e) => setOkxSecret(e.target.value)}
/>
</div>
<div className="field">
<label htmlFor="okxPass">Passphrase</label>
<input
id="okxPass"
className="mono"
type="password"
autoComplete="off"
placeholder={
runtime?.okx_api_passphrase_masked
? `已配置 ${runtime.okx_api_passphrase_masked}`
: "未配置"
}
value={okxPass}
onChange={(e) => setOkxPass(e.target.value)}
/>
</div>
</div>
</section>
<section className="settings-section">
<h3> API</h3>
<div className="settings-fields">
<div className="field">
<label htmlFor="bnKey">API Key</label>
<input
id="bnKey"
className="mono"
type="password"
autoComplete="off"
placeholder={
runtime?.binance_api_key_masked
? `已配置 ${runtime.binance_api_key_masked}`
: "未配置"
}
value={bnKey}
onChange={(e) => setBnKey(e.target.value)}
/>
</div>
<div className="field">
<label htmlFor="bnSecret">Secret</label>
<input
id="bnSecret"
className="mono"
type="password"
autoComplete="off"
placeholder={
runtime?.binance_api_secret_masked
? `已配置 ${runtime.binance_api_secret_masked}`
: "未配置"
}
value={bnSecret}
onChange={(e) => setBnSecret(e.target.value)}
/>
</div>
</div>
</section>
<section className="settings-section">
<h3></h3>
<p style={{ color: "var(--muted)", marginTop: 0 }}>
Webhook MarkdownOPEN/CLOSE/START/PAUSE/FAULT
<span className="mono">
{" "}
{wecomMeta?.venue_label ||
(runtime?.mode === "LIVE"
? `实盘·${(runtime.exchange || "okx").toUpperCase()}`
: "模拟盘")}
</span>
////·
</p>
<div className="settings-fields">
<div className="field">
<label htmlFor="wecomEn"></label>
<select
id="wecomEn"
className="mono"
value={wecomEnabled ? "1" : "0"}
onChange={(e) => setWecomEnabled(e.target.value === "1")}
>
<option value="0"></option>
<option value="1"></option>
</select>
</div>
<div className="field">
<label htmlFor="wecomUrl">Webhook URL</label>
<input
id="wecomUrl"
className="mono"
type="password"
autoComplete="off"
placeholder={
wecomMeta?.webhook_configured
? `已配置 ${wecomMeta.webhook_url_masked || "********"}`
: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=..."
}
value={wecomWebhook}
onChange={(e) => setWecomWebhook(e.target.value)}
/>
</div>
</div>
{wecomOk ? <div className="settings-ok">{wecomOk}</div> : null}
<div className="settings-actions" style={{ gap: 8 }}>
<button className="btn ghost" type="button" onClick={() => void onSaveWecom()}>
</button>
<button
className="btn ghost"
type="button"
onClick={() => void onTestWecom()}
>
</button>
</div>
</section>
<RulesFold
open={runtimeRulesOpen}
onToggle={() => setRuntimeRulesOpen((v) => !v)}
>
<ul className="settings-rules-list">
<li>
SIM = LIVE =
OKX / {" "}
<span className="mono">.env</span>
OKX USDC USDT
</li>
<li>
{runtime?.mode || "—"} · {" "}
{runtime?.exchange || "—"} ·{" "}
{runtime?.mode === "LIVE"
? runtime.live_ready
? "LIVE 就绪"
: `未就绪(${runtime.live_ready_reason})`
: "SIM"}
</li>
<li>
= LIVE
Passphrase
</li>
<li>
Webhook
URL 5
</li>
</ul>
</RulesFold>
<div className="settings-actions">
<button className="btn" type="submit" disabled={loading}>
{loading ? "保存中…" : "保存模式与密钥"}
</button>
</div>
</form>
</div>
) : null}
{tab === "account" ? (
<div className="card settings-card settings-card-narrow">
{err ? <div className="err">{err}</div> : null}
{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="settings-actions">
<button className="btn" type="submit" disabled={loading}>
{loading ? "保存中…" : "保存账号"}
</button>
</div>
</form>
</div>
) : null}
{tab === "backup" ? (
<div className="card settings-card">
{bakOk ? <div className="settings-ok">{bakOk}</div> : null}
{err && tab === "backup" ? <div className="err">{err}</div> : null}
{bakBusy ? <div className="meta">{bakBusy}</div> : null}
<form onSubmit={onSaveBackupSettings}>
<section className="settings-section">
<h3></h3>
<p style={{ color: "var(--muted)", marginTop: 0 }}>
SQLite +{" "}
<span className="mono">.env</span>
<span className="mono">
{" "}
{backup?.backup_dir || "/root/eth_hedge_backups"}
</span>
</p>
<div className="settings-fields">
<div className="field">
<label htmlFor="bakAuto"></label>
<select
id="bakAuto"
className="mono"
value={bakAuto ? "1" : "0"}
onChange={(e) => setBakAuto(e.target.value === "1")}
>
<option value="1"></option>
<option value="0"></option>
</select>
</div>
<div className="field">
<label htmlFor="bakInt"></label>
<input
id="bakInt"
className="mono"
type="number"
min={1}
max={168}
value={bakInterval}
onChange={(e) => setBakInterval(Number(e.target.value))}
/>
</div>
<div className="field">
<label htmlFor="bakKeep"></label>
<input
id="bakKeep"
className="mono"
type="number"
min={1}
max={90}
value={bakKeep}
onChange={(e) => setBakKeep(Number(e.target.value))}
/>
</div>
</div>
<div className="settings-actions" style={{ gap: 8 }}>
<button className="btn" type="submit">
</button>
<button
className="btn ghost"
type="button"
disabled={!!bakBusy}
onClick={() => void onBackupNow()}
>
</button>
</div>
</section>
</form>
<section className="settings-section">
<h3></h3>
{backup?.items && backup.items.length > 0 ? (
<div className="settings-fields">
{backup.items.map((it) => (
<div
key={it.name}
className="field"
style={{
display: "flex",
flexWrap: "wrap",
gap: 8,
alignItems: "center",
}}
>
<span className="mono" style={{ flex: "1 1 220px" }}>
{it.name}
<span style={{ color: "var(--muted)" }}>
{" "}
· {(it.size_bytes / 1024).toFixed(1)} KB ·{" "}
{new Date(it.mtime_ms).toLocaleString()}
</span>
</span>
<button
className="btn ghost"
type="button"
disabled={!!bakBusy}
onClick={() => void onDownload(it.name)}
>
</button>
</div>
))}
</div>
) : (
<p style={{ color: "var(--muted)" }}></p>
)}
</section>
<section className="settings-section">
<h3></h3>
<p style={{ color: "var(--muted)", marginTop: 0 }}>
zip{" "}
<span className="mono">.env</span>
{" "}
<span className="mono">RESTORE</span>
</p>
<div className="settings-fields">
<div className="field">
<label htmlFor="bakFile"> zip</label>
<input
id="bakFile"
type="file"
accept=".zip,application/zip"
onChange={(e) =>
setRestoreFile(e.target.files?.[0] ?? null)
}
/>
</div>
<div className="field">
<label htmlFor="bakPhrase"></label>
<input
id="bakPhrase"
className="mono"
value={restorePhrase}
placeholder="RESTORE"
onChange={(e) => setRestorePhrase(e.target.value)}
/>
</div>
</div>
<div className="settings-actions">
<button
className="btn danger"
type="button"
disabled={!!bakBusy}
onClick={() => void onRestore()}
>
</button>
</div>
</section>
</div>
) : null}
</div>
);
}