Hide manual trade by default, block open while running, auto-refresh auth token.
Also fix flat-side reconcile to check both long and short residuals; document in 更新说明. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+104
-3
@@ -1,5 +1,6 @@
|
||||
const TOKEN_KEY = "eth_hedge_token";
|
||||
const USER_KEY = "eth_hedge_user";
|
||||
const TOKEN_EXP_KEY = "eth_hedge_token_exp";
|
||||
|
||||
export function getApiBase(): string {
|
||||
return window.location.origin;
|
||||
@@ -9,14 +10,21 @@ export function getToken(): string | null {
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function setSession(token: string, username: string) {
|
||||
export function setSession(token: string, username: string, expiresInSec?: number) {
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
localStorage.setItem(USER_KEY, username);
|
||||
if (expiresInSec != null && expiresInSec > 0) {
|
||||
localStorage.setItem(
|
||||
TOKEN_EXP_KEY,
|
||||
String(Date.now() + expiresInSec * 1000),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function clearSession() {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(USER_KEY);
|
||||
localStorage.removeItem(TOKEN_EXP_KEY);
|
||||
localStorage.removeItem("eth_hedge_api_base");
|
||||
}
|
||||
|
||||
@@ -24,10 +32,97 @@ export function getUsername(): string | null {
|
||||
return localStorage.getItem(USER_KEY);
|
||||
}
|
||||
|
||||
function tokenExpiresAtMs(): number | null {
|
||||
const raw = localStorage.getItem(TOKEN_EXP_KEY);
|
||||
if (!raw) return null;
|
||||
const n = Number(raw);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
/** 距过期不足该毫秒则主动换发(默认 12h)。 */
|
||||
const REFRESH_BEFORE_MS = 12 * 60 * 60 * 1000;
|
||||
|
||||
let _refreshInFlight: Promise<boolean> | null = null;
|
||||
|
||||
/**
|
||||
* 用当前 Bearer 换发新 HMAC token(非 Flask session)。
|
||||
* 登录态下由定时器/页面可见时静默调用。
|
||||
*/
|
||||
export async function refreshAuthToken(): Promise<boolean> {
|
||||
const token = getToken();
|
||||
if (!token) return false;
|
||||
if (_refreshInFlight) return _refreshInFlight;
|
||||
_refreshInFlight = (async () => {
|
||||
try {
|
||||
const res = await fetch(`${getApiBase()}/api/auth/refresh`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: "{}",
|
||||
});
|
||||
if (!res.ok) return false;
|
||||
const data = (await res.json()) as LoginResult;
|
||||
if (!data?.token) return false;
|
||||
setSession(data.token, data.username || getUsername() || "", data.expires_in);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
_refreshInFlight = null;
|
||||
}
|
||||
})();
|
||||
return _refreshInFlight;
|
||||
}
|
||||
|
||||
let _lastRefreshAttemptMs = 0;
|
||||
|
||||
/** 临近过期则刷新;返回是否仍持有有效 token。 */
|
||||
export async function ensureFreshToken(): Promise<boolean> {
|
||||
if (!getToken()) return false;
|
||||
const exp = tokenExpiresAtMs();
|
||||
const need =
|
||||
exp == null || exp - Date.now() < REFRESH_BEFORE_MS;
|
||||
if (!need) return true;
|
||||
const now = Date.now();
|
||||
// 无过期戳的旧会话:最多每 60s 尝试一次,避免轮询打爆 /refresh
|
||||
if (exp == null && now - _lastRefreshAttemptMs < 60_000) {
|
||||
return true;
|
||||
}
|
||||
_lastRefreshAttemptMs = now;
|
||||
const ok = await refreshAuthToken();
|
||||
if (!ok && exp != null && exp <= now) {
|
||||
clearSession();
|
||||
return false;
|
||||
}
|
||||
return !!getToken();
|
||||
}
|
||||
|
||||
/** 在已登录壳内启动:定时换发 + 页面回到前台时检查。 */
|
||||
export function startAuthTokenAutoRefresh(): () => void {
|
||||
const tick = () => {
|
||||
void ensureFreshToken();
|
||||
};
|
||||
tick();
|
||||
const id = window.setInterval(tick, 30 * 60 * 1000);
|
||||
const onVis = () => {
|
||||
if (document.visibilityState === "visible") tick();
|
||||
};
|
||||
document.addEventListener("visibilitychange", onVis);
|
||||
return () => {
|
||||
window.clearInterval(id);
|
||||
document.removeEventListener("visibilitychange", onVis);
|
||||
};
|
||||
}
|
||||
|
||||
export async function apiFetch<T>(
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
): Promise<T> {
|
||||
if (!path.startsWith("/api/auth/login")) {
|
||||
await ensureFreshToken();
|
||||
}
|
||||
const base = getApiBase();
|
||||
const headers = new Headers(options.headers || {});
|
||||
if (!headers.has("Content-Type") && options.body) {
|
||||
@@ -67,10 +162,12 @@ export type LoginResult = {
|
||||
};
|
||||
|
||||
export async function login(username: string, password: string) {
|
||||
return apiFetch<LoginResult>("/api/auth/login", {
|
||||
const res = await apiFetch<LoginResult>("/api/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
setSession(res.token, res.username, res.expires_in);
|
||||
return res;
|
||||
}
|
||||
|
||||
export async function changeCredentials(input: {
|
||||
@@ -78,10 +175,12 @@ export async function changeCredentials(input: {
|
||||
new_username: string;
|
||||
new_password: string;
|
||||
}) {
|
||||
return apiFetch<LoginResult>("/api/auth/change-credentials", {
|
||||
const res = await apiFetch<LoginResult>("/api/auth/change-credentials", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
setSession(res.token, res.username, res.expires_in);
|
||||
return res;
|
||||
}
|
||||
|
||||
export type MarketSnapshot = {
|
||||
@@ -135,6 +234,7 @@ export type PlanState = {
|
||||
max_atm_open_offset?: number;
|
||||
can_open: boolean;
|
||||
last_error: string | null;
|
||||
show_manual_trade_buttons?: boolean;
|
||||
position: {
|
||||
has_position: boolean;
|
||||
group_id?: string;
|
||||
@@ -217,6 +317,7 @@ export type StrategySettings = {
|
||||
close_bid_mark_max_pct: number;
|
||||
perp_qty_eth: number;
|
||||
option_qty_eth: number;
|
||||
show_manual_trade_buttons?: boolean;
|
||||
exchange: "okx" | "binance";
|
||||
perp_inst_id?: string;
|
||||
option_inst_family?: string;
|
||||
|
||||
Reference in New Issue
Block a user