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:
@@ -89,6 +89,22 @@ async def me(
|
||||
}
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=LoginResponse)
|
||||
async def refresh_token(
|
||||
username: Annotated[str, Depends(require_user)],
|
||||
settings: Annotated[Settings, Depends(get_settings)],
|
||||
) -> LoginResponse:
|
||||
"""用仍有效的 Bearer 换发新 HMAC token(自动轮换,无需重登)。"""
|
||||
token, ttl = issue_token(username, settings)
|
||||
return LoginResponse(
|
||||
token=token,
|
||||
username=username,
|
||||
expires_in=ttl,
|
||||
env_name=settings.env_name,
|
||||
mode=settings.mode,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/change-credentials", response_model=LoginResponse)
|
||||
async def change_credentials(
|
||||
body: ChangeCredentialsRequest,
|
||||
|
||||
@@ -44,6 +44,7 @@ KEYS = (
|
||||
"close_bid_mark_max_pct",
|
||||
"perp_qty_eth",
|
||||
"option_qty_eth",
|
||||
"show_manual_trade_buttons",
|
||||
)
|
||||
|
||||
|
||||
@@ -65,6 +66,7 @@ class StrategySettingsBody(BaseModel):
|
||||
close_bid_mark_max_pct: float | None = Field(default=None, ge=1, le=100)
|
||||
perp_qty_eth: float | None = Field(default=None, ge=0.01, le=100)
|
||||
option_qty_eth: float | None = Field(default=None, ge=0.01, le=100)
|
||||
show_manual_trade_buttons: bool | None = None
|
||||
exchange: str | None = Field(default=None, pattern="^(okx|binance|bn)$")
|
||||
|
||||
|
||||
@@ -139,6 +141,9 @@ def _read_settings() -> dict:
|
||||
"option_qty_eth": float(
|
||||
db.get_setting("option_qty_eth", str(s.option_qty_eth)) or s.option_qty_eth
|
||||
),
|
||||
"show_manual_trade_buttons": _as_bool(
|
||||
db.get_setting("show_manual_trade_buttons", "0"), False
|
||||
),
|
||||
"exchange": rt.exchange,
|
||||
"perp_inst_id": rt.perp_inst_id,
|
||||
"option_inst_family": rt.option_inst_family,
|
||||
|
||||
@@ -42,6 +42,19 @@ async def sim_open_group(
|
||||
ok, reason = live_ready()
|
||||
if not get_settings().is_sim and not ok:
|
||||
raise HTTPException(status_code=400, detail=reason)
|
||||
from ..strategy import get_engine
|
||||
|
||||
st = get_engine().state()
|
||||
if st.get("running"):
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="策略自动运行中,禁止手动开仓;请先暂停",
|
||||
)
|
||||
if not Ledger().get_setting_bool("show_manual_trade_buttons", False):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="未开启「显示手动开仓」;请在策略设置中开启后再用",
|
||||
)
|
||||
ex = get_executor()
|
||||
if ex.has_open_position():
|
||||
raise HTTPException(status_code=409, detail="有未平仓,禁止开下一组")
|
||||
@@ -115,6 +128,11 @@ async def sim_open_group(
|
||||
|
||||
@router.post("/close-group")
|
||||
async def sim_close_group(_user: Annotated[str, Depends(require_user)]) -> dict:
|
||||
if not Ledger().get_setting_bool("show_manual_trade_buttons", False):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="未开启「显示手动开仓」;请在策略设置中开启后再用",
|
||||
)
|
||||
r = get_executor().close_group(reason="manual")
|
||||
if not r.ok and not r.liquidity_wait:
|
||||
raise HTTPException(status_code=400, detail=r.detail)
|
||||
|
||||
@@ -76,12 +76,19 @@ def assert_safe_to_open_live(executor) -> tuple[bool, str]:
|
||||
return False, "无法核对交易所持仓"
|
||||
|
||||
perp_inst = resolve_perp_inst_id(executor.db)
|
||||
perp_side = str(pos.get("perp_side") or "long")
|
||||
ex_sz = exchange_perp_abs_size(client, ex_name or "", perp_inst, perp_side)
|
||||
if ex_sz is None:
|
||||
return False, "无法核对交易所持仓"
|
||||
# flat/opening:两侧都查,避免只查默认 long 漏掉 short 残留
|
||||
if st in ("flat", "", "opening"):
|
||||
sides = ("long", "short")
|
||||
else:
|
||||
sides = (str(pos.get("perp_side") or "long"),)
|
||||
total = 0.0
|
||||
for side in sides:
|
||||
ex_sz = exchange_perp_abs_size(client, ex_name or "", perp_inst, side)
|
||||
if ex_sz is None:
|
||||
return False, "无法核对交易所持仓"
|
||||
total += float(ex_sz)
|
||||
|
||||
if ex_sz > _PERP_EPS and st in ("flat", "", "opening"):
|
||||
if total > _PERP_EPS and st in ("flat", "", "opening"):
|
||||
return (
|
||||
False,
|
||||
"交易所有永续仓但本地无持仓,禁止新开,请人工核对",
|
||||
|
||||
@@ -128,6 +128,9 @@ class StrategyEngine:
|
||||
"sim": s.is_sim,
|
||||
"live_ready": (live_ready()[0] if not s.is_sim else True),
|
||||
"live_ready_reason": (live_ready()[1] if not s.is_sim else "sim"),
|
||||
"show_manual_trade_buttons": self.ledger.get_setting_bool(
|
||||
"show_manual_trade_buttons", False
|
||||
),
|
||||
}
|
||||
|
||||
def _set_state(self, **kwargs: Any) -> None:
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.api.auth import issue_token, verify_token
|
||||
from app.config import get_settings
|
||||
|
||||
|
||||
def test_issue_and_verify_token_roundtrip():
|
||||
s = get_settings()
|
||||
token, ttl = issue_token("admin", s)
|
||||
assert ttl == s.auth_token_ttl_sec
|
||||
assert verify_token(token, s) == "admin"
|
||||
|
||||
|
||||
def test_refresh_mints_another_valid_token():
|
||||
s = get_settings()
|
||||
t1, _ = issue_token("admin", s)
|
||||
t2, ttl = issue_token("admin", s)
|
||||
assert ttl > 0
|
||||
assert verify_token(t1, s) == "admin"
|
||||
assert verify_token(t2, s) == "admin"
|
||||
@@ -5,6 +5,38 @@
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-26 — LIVE 对账 / 手动开仓 UI / Token 自动换发
|
||||
|
||||
### 变更
|
||||
|
||||
1. **开仓幂等与对账**:`opening` 占槽;LIVE 开仓前核对交易所永续(双侧);启动写 mismatch 日志。
|
||||
2. **平仓张数**:OKX/币安平永续优先读交易所持仓,失败才回退本地账本。
|
||||
3. **杠杆**:LIVE 开永续前 `set_leverage`(失败仅告警)。
|
||||
4. **自动运行禁手动开仓**:`running=1` 时 `/api/sim/open-group` 返回 409;计划页按钮禁用。
|
||||
5. **手动开仓显示**:策略设置「节奏」→「显示手动开仓」,**默认不显示**;关闭时 API 亦拒绝手动开/手动全平。紧急全平始终可用且为**红色**。
|
||||
6. **启动态**:策略运行中主按钮文案为「启动中」,绿色样式。
|
||||
7. **Token 自动换发**:HMAC Bearer(非 Flask)。新增 `POST /api/auth/refresh`;前端登录后定时/切回前台/临近过期静默换发。默认口令未改。
|
||||
|
||||
### 审计(本包改后)
|
||||
|
||||
| 项 | 状态 |
|
||||
|----|------|
|
||||
| 交易所↔本地对账 / 开仓幂等 | **已修** |
|
||||
| 平仓张数读交易所 | **已修** |
|
||||
| set_leverage | **已修**(失败告警) |
|
||||
| 自动运行仍可手动开仓 | **已修**(API + UI) |
|
||||
| 手动开/全平默认露出 | **已修**(默认隐藏 + 设置开关) |
|
||||
| 紧急全平样式 / 启动中文案 | **已修** |
|
||||
| Token 长期不轮换 | **已修**(refresh + 前端自动换发) |
|
||||
| 对账只查单侧漏 short | **已修**(flat 时 long+short) |
|
||||
| 放弃期权残留 / 模式切换仅本地 | **未做**(非本包;紧急全平/人工核对) |
|
||||
|
||||
### 测试
|
||||
|
||||
- `pytest`:52 passed
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-26 — 审计修复包(安全 / 下单 / 平仓)
|
||||
|
||||
### 变更
|
||||
|
||||
+11
-2
@@ -1,6 +1,11 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { type ReactNode, useEffect } from "react";
|
||||
import { NavLink, Navigate, Route, Routes } from "react-router-dom";
|
||||
import { clearSession, getToken, getUsername } from "./api/client";
|
||||
import {
|
||||
clearSession,
|
||||
getToken,
|
||||
getUsername,
|
||||
startAuthTokenAutoRefresh,
|
||||
} from "./api/client";
|
||||
import LoginPage from "./pages/Login";
|
||||
import PlanPage from "./pages/Plan";
|
||||
import TradesPage from "./pages/Trades";
|
||||
@@ -58,6 +63,10 @@ function Shell({ children }: { children: ReactNode }) {
|
||||
}
|
||||
|
||||
function RequireAuth({ children }: { children: ReactNode }) {
|
||||
useEffect(() => {
|
||||
if (!getToken()) return;
|
||||
return startAuthTokenAutoRefresh();
|
||||
}, []);
|
||||
if (!getToken()) return <Navigate to="/login" replace />;
|
||||
return <Shell>{children}</Shell>;
|
||||
}
|
||||
|
||||
+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;
|
||||
|
||||
@@ -15,7 +15,7 @@ export default function LoginPage() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await login(username.trim(), password);
|
||||
setSession(res.token, res.username);
|
||||
setSession(res.token, res.username, res.expires_in);
|
||||
nav("/plan", { replace: true });
|
||||
} catch (ex) {
|
||||
setErr(ex instanceof Error ? ex.message : String(ex));
|
||||
|
||||
+35
-22
@@ -144,11 +144,11 @@ export default function PlanPage() {
|
||||
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, marginBottom: 12 }}>
|
||||
<button
|
||||
className="btn"
|
||||
className={plan?.running ? "btn btn-running" : "btn"}
|
||||
type="button"
|
||||
disabled={
|
||||
!!busy ||
|
||||
plan?.running ||
|
||||
!!plan?.running ||
|
||||
(plan?.mode === "LIVE" && plan.live_ready === false)
|
||||
}
|
||||
title={
|
||||
@@ -158,7 +158,7 @@ export default function PlanPage() {
|
||||
}
|
||||
onClick={() => act("/api/plan/start", "start")}
|
||||
>
|
||||
启动策略
|
||||
{plan?.running ? "启动中" : "启动策略"}
|
||||
</button>
|
||||
<button
|
||||
className="btn ghost"
|
||||
@@ -168,26 +168,39 @@ export default function PlanPage() {
|
||||
>
|
||||
暂停
|
||||
</button>
|
||||
{plan?.show_manual_trade_buttons ? (
|
||||
<>
|
||||
<button
|
||||
className="btn ghost"
|
||||
type="button"
|
||||
disabled={
|
||||
!!busy ||
|
||||
!!plan?.running ||
|
||||
(plan?.mode === "LIVE" && plan.live_ready === false)
|
||||
}
|
||||
title={
|
||||
plan?.running
|
||||
? "策略自动运行中,禁止手动开仓"
|
||||
: plan?.mode === "LIVE" && plan.live_ready === false
|
||||
? plan.live_ready_reason || "LIVE 未就绪"
|
||||
: undefined
|
||||
}
|
||||
onClick={() => act("/api/sim/open-group", "open")}
|
||||
>
|
||||
手动开一组
|
||||
</button>
|
||||
<button
|
||||
className="btn ghost"
|
||||
type="button"
|
||||
disabled={!!busy}
|
||||
onClick={() => act("/api/sim/close-group", "close")}
|
||||
>
|
||||
手动全平
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
<button
|
||||
className="btn ghost"
|
||||
type="button"
|
||||
disabled={
|
||||
!!busy || (plan?.mode === "LIVE" && plan.live_ready === false)
|
||||
}
|
||||
onClick={() => act("/api/sim/open-group", "open")}
|
||||
>
|
||||
手动开一组
|
||||
</button>
|
||||
<button
|
||||
className="btn ghost"
|
||||
type="button"
|
||||
disabled={!!busy}
|
||||
onClick={() => act("/api/sim/close-group", "close")}
|
||||
>
|
||||
手动全平
|
||||
</button>
|
||||
<button
|
||||
className="btn ghost"
|
||||
className="btn danger"
|
||||
type="button"
|
||||
disabled={!!busy}
|
||||
onClick={() => act("/api/plan/emergency-close", "emg")}
|
||||
|
||||
@@ -73,6 +73,7 @@ export default function SettingsPage() {
|
||||
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("");
|
||||
@@ -113,6 +114,7 @@ export default function SettingsPage() {
|
||||
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");
|
||||
})
|
||||
@@ -139,7 +141,7 @@ export default function SettingsPage() {
|
||||
new_username: newUsername.trim(),
|
||||
new_password: newPassword,
|
||||
});
|
||||
setSession(res.token, res.username);
|
||||
setSession(res.token, res.username, res.expires_in);
|
||||
setCurrentPassword("");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
@@ -173,6 +175,7 @@ export default function SettingsPage() {
|
||||
close_bid_mark_max_pct: closeDevPct,
|
||||
perp_qty_eth: perpQty,
|
||||
option_qty_eth: optQty,
|
||||
show_manual_trade_buttons: showManualTrade,
|
||||
exchange,
|
||||
};
|
||||
// LIVE 不改模拟资金,避免误重置本地账本
|
||||
@@ -564,6 +567,20 @@ export default function SettingsPage() {
|
||||
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}
|
||||
@@ -607,10 +624,16 @@ export default function SettingsPage() {
|
||||
</li>
|
||||
) : null}
|
||||
{stratSub === "pace" ? (
|
||||
<li>
|
||||
实盘下单最小间隔:LIVE 私有下单/查单间隔,默认 1s。范围
|
||||
0.2–30。
|
||||
</li>
|
||||
<>
|
||||
<li>
|
||||
实盘下单最小间隔:LIVE 私有下单/查单间隔,默认 1s。范围
|
||||
0.2–30。
|
||||
</li>
|
||||
<li>
|
||||
显示手动开仓:默认关闭。开启后计划页才出现「手动开一组 /
|
||||
手动全平」。策略自动运行中禁止手动开仓(须先暂停);紧急全平始终可用。
|
||||
</li>
|
||||
</>
|
||||
) : null}
|
||||
</ul>
|
||||
</RulesFold>
|
||||
|
||||
@@ -613,6 +613,25 @@ input {
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.btn.danger {
|
||||
background: transparent;
|
||||
color: var(--danger);
|
||||
border: 1px solid var(--danger);
|
||||
}
|
||||
|
||||
.btn.danger:hover:not(:disabled) {
|
||||
background: rgba(246, 70, 93, 0.12);
|
||||
}
|
||||
|
||||
.btn.btn-running,
|
||||
.btn.btn-running:disabled {
|
||||
background: rgba(14, 203, 129, 0.16);
|
||||
color: #0ecb81;
|
||||
border: 1px solid #0ecb81;
|
||||
opacity: 1;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.btn.block {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user