Control settings tabs, hide default creds hint after change, LAN passwordless login.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -7,4 +7,6 @@ CONTROL_AUTH_TOKEN_VERSION=1
|
|||||||
CONTROL_TOKEN_TTL_SEC=604800
|
CONTROL_TOKEN_TTL_SEC=604800
|
||||||
CONTROL_POLL_INTERVAL_SEC=8
|
CONTROL_POLL_INTERVAL_SEC=8
|
||||||
CONTROL_HTTP_TIMEOUT_SEC=12
|
CONTROL_HTTP_TIMEOUT_SEC=12
|
||||||
|
# 局域网免登录:1=开启(仅私网 IP),0=关闭
|
||||||
|
CONTROL_LAN_AUTH_BYPASS=0
|
||||||
# CONTROL_DB_PATH=/opt/eth_hedge_sim/control/data/control.db
|
# CONTROL_DB_PATH=/opt/eth_hedge_sim/control/data/control.db
|
||||||
|
|||||||
@@ -3,12 +3,13 @@ from __future__ import annotations
|
|||||||
import hmac
|
import hmac
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from ..auth import issue_token, require_control_user
|
from ..auth import issue_token, require_control_user
|
||||||
from ..config import ControlSettings, get_control_settings
|
from ..config import ControlSettings, get_control_settings
|
||||||
from ..envfile import update_control_credentials
|
from ..envfile import update_control_credentials, upsert_env_control
|
||||||
|
from ..lan import client_ip, is_lan_ip
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||||
|
|
||||||
@@ -24,6 +25,46 @@ class ChangeCredentialsBody(BaseModel):
|
|||||||
new_password: str = Field(min_length=6, max_length=128)
|
new_password: str = Field(min_length=6, max_length=128)
|
||||||
|
|
||||||
|
|
||||||
|
class LanBypassBody(BaseModel):
|
||||||
|
enabled: bool
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/login-meta")
|
||||||
|
async def login_meta(
|
||||||
|
request: Request,
|
||||||
|
settings: Annotated[ControlSettings, Depends(get_control_settings)],
|
||||||
|
) -> dict:
|
||||||
|
ip = client_ip(request)
|
||||||
|
lan = is_lan_ip(ip)
|
||||||
|
return {
|
||||||
|
"show_default_hint": settings.is_default_credentials,
|
||||||
|
"lan_bypass_enabled": settings.lan_auth_bypass,
|
||||||
|
"lan_client": lan,
|
||||||
|
"lan_login_available": bool(settings.lan_auth_bypass and lan),
|
||||||
|
"client_ip": ip or None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/lan-login")
|
||||||
|
async def lan_login(
|
||||||
|
request: Request,
|
||||||
|
settings: Annotated[ControlSettings, Depends(get_control_settings)],
|
||||||
|
) -> dict:
|
||||||
|
if not settings.lan_auth_bypass:
|
||||||
|
raise HTTPException(status_code=403, detail="未开启局域网免登录")
|
||||||
|
ip = client_ip(request)
|
||||||
|
if not is_lan_ip(ip):
|
||||||
|
raise HTTPException(status_code=403, detail="仅局域网地址可免登录")
|
||||||
|
user = settings.control_auth_username
|
||||||
|
token, ttl = issue_token(user, settings)
|
||||||
|
return {
|
||||||
|
"token": token,
|
||||||
|
"username": user,
|
||||||
|
"expires_in": ttl,
|
||||||
|
"via": "lan",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login")
|
@router.post("/login")
|
||||||
async def login(
|
async def login(
|
||||||
body: LoginBody,
|
body: LoginBody,
|
||||||
@@ -51,6 +92,26 @@ async def me(
|
|||||||
return {
|
return {
|
||||||
"username": username,
|
"username": username,
|
||||||
"poll_interval_sec": settings.control_poll_interval_sec,
|
"poll_interval_sec": settings.control_poll_interval_sec,
|
||||||
|
"show_default_hint": settings.is_default_credentials,
|
||||||
|
"lan_bypass_enabled": settings.lan_auth_bypass,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/lan-bypass")
|
||||||
|
async def put_lan_bypass(
|
||||||
|
body: LanBypassBody,
|
||||||
|
_user: Annotated[str, Depends(require_control_user)],
|
||||||
|
) -> dict:
|
||||||
|
upsert_env_control(
|
||||||
|
"CONTROL_LAN_AUTH_BYPASS",
|
||||||
|
"1" if body.enabled else "0",
|
||||||
|
overwrite=True,
|
||||||
|
)
|
||||||
|
get_control_settings.cache_clear()
|
||||||
|
settings = get_control_settings()
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"lan_bypass_enabled": settings.lan_auth_bypass,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -78,4 +139,5 @@ async def change_credentials(
|
|||||||
"token": token,
|
"token": token,
|
||||||
"username": body.new_username.strip(),
|
"username": body.new_username.strip(),
|
||||||
"expires_in": ttl,
|
"expires_in": ttl,
|
||||||
|
"show_default_hint": settings2.is_default_credentials,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ class ControlSettings(BaseSettings):
|
|||||||
control_poll_interval_sec: int = 8
|
control_poll_interval_sec: int = 8
|
||||||
control_http_timeout_sec: float = 12.0
|
control_http_timeout_sec: float = 12.0
|
||||||
control_port: int = 5160
|
control_port: int = 5160
|
||||||
|
# "1"/"0":局域网客户端免密登录
|
||||||
|
control_lan_auth_bypass: str = "0"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def db_path(self) -> Path:
|
def db_path(self) -> Path:
|
||||||
@@ -40,6 +42,22 @@ class ControlSettings(BaseSettings):
|
|||||||
return Path(self.control_db_path)
|
return Path(self.control_db_path)
|
||||||
return _control_root() / "data" / "control.db"
|
return _control_root() / "data" / "control.db"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def lan_auth_bypass(self) -> bool:
|
||||||
|
return self.control_lan_auth_bypass.strip().lower() in (
|
||||||
|
"1",
|
||||||
|
"true",
|
||||||
|
"yes",
|
||||||
|
"on",
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_default_credentials(self) -> bool:
|
||||||
|
return (
|
||||||
|
self.control_auth_username.strip() == "admin"
|
||||||
|
and self.control_auth_password == "admin123"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@lru_cache
|
@lru_cache
|
||||||
def get_control_settings() -> ControlSettings:
|
def get_control_settings() -> ControlSettings:
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ _DEPLOY_DEFAULTS: dict[str, str] = {
|
|||||||
"CONTROL_POLL_INTERVAL_SEC": "8",
|
"CONTROL_POLL_INTERVAL_SEC": "8",
|
||||||
"CONTROL_HTTP_TIMEOUT_SEC": "12",
|
"CONTROL_HTTP_TIMEOUT_SEC": "12",
|
||||||
"CONTROL_AUTH_TOKEN_VERSION": "1",
|
"CONTROL_AUTH_TOKEN_VERSION": "1",
|
||||||
|
"CONTROL_LAN_AUTH_BYPASS": "0",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""客户端 IP / 局域网判断。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ipaddress
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
|
||||||
|
|
||||||
|
def client_ip(request: Request) -> str:
|
||||||
|
xff = request.headers.get("x-forwarded-for") or ""
|
||||||
|
if xff.strip():
|
||||||
|
return xff.split(",")[0].strip()
|
||||||
|
if request.client and request.client.host:
|
||||||
|
return request.client.host
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def is_lan_ip(ip: str) -> bool:
|
||||||
|
raw = (ip or "").strip()
|
||||||
|
if not raw:
|
||||||
|
return False
|
||||||
|
if raw in ("localhost", "::1"):
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
addr = ipaddress.ip_address(raw.split("%")[0])
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
return bool(addr.is_loopback or addr.is_private)
|
||||||
@@ -38,8 +38,14 @@ export async function apiFetch<T>(
|
|||||||
data = { detail: text };
|
data = { detail: text };
|
||||||
}
|
}
|
||||||
if (res.status === 401) {
|
if (res.status === 401) {
|
||||||
// 仅中控自身鉴权失败才清会话;勿把策略机错误当成掉登录
|
// 登录页公开接口失败不要清会话文案;仅受保护接口掉登录
|
||||||
clearSession();
|
const publicAuth =
|
||||||
|
path.startsWith("/api/auth/login") ||
|
||||||
|
path.startsWith("/api/auth/login-meta") ||
|
||||||
|
path.startsWith("/api/auth/lan-login");
|
||||||
|
if (!publicAuth) {
|
||||||
|
clearSession();
|
||||||
|
}
|
||||||
const detail =
|
const detail =
|
||||||
typeof data === "object" && data && "detail" in data
|
typeof data === "object" && data && "detail" in data
|
||||||
? String((data as { detail: unknown }).detail)
|
? String((data as { detail: unknown }).detail)
|
||||||
|
|||||||
@@ -1,13 +1,52 @@
|
|||||||
import { FormEvent, useState } from "react";
|
import { FormEvent, useEffect, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { login } from "../api";
|
import { apiFetch, getToken, login, setSession } from "../api";
|
||||||
|
|
||||||
|
type LoginMeta = {
|
||||||
|
show_default_hint: boolean;
|
||||||
|
lan_login_available: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const nav = useNavigate();
|
const nav = useNavigate();
|
||||||
const [username, setUsername] = useState("admin");
|
const [username, setUsername] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [err, setErr] = useState("");
|
const [err, setErr] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [showHint, setShowHint] = useState(false);
|
||||||
|
const [lanTrying, setLanTrying] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (getToken()) {
|
||||||
|
nav("/monitor", { replace: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const meta = await apiFetch<LoginMeta>("/api/auth/login-meta");
|
||||||
|
if (cancelled) return;
|
||||||
|
setShowHint(!!meta.show_default_hint);
|
||||||
|
if (meta.lan_login_available) {
|
||||||
|
const res = await apiFetch<{ token: string; username: string }>(
|
||||||
|
"/api/auth/lan-login",
|
||||||
|
{ method: "POST", body: "{}" },
|
||||||
|
);
|
||||||
|
if (cancelled) return;
|
||||||
|
setSession(res.token, res.username);
|
||||||
|
nav("/monitor", { replace: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* 免登失败则走普通登录 */
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLanTrying(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [nav]);
|
||||||
|
|
||||||
async function onSubmit(e: FormEvent) {
|
async function onSubmit(e: FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -23,15 +62,43 @@ export default function LoginPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (lanTrying) {
|
||||||
|
return (
|
||||||
|
<div className="login-wrap">
|
||||||
|
<div className="login-box">
|
||||||
|
<h1>比特骆驼中控</h1>
|
||||||
|
<p className="meta">检测局域网免登录…</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="login-wrap">
|
<div className="login-wrap">
|
||||||
<form className="login-box" onSubmit={onSubmit}>
|
<form
|
||||||
|
className="login-box"
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
autoComplete="off"
|
||||||
|
data-lpignore="true"
|
||||||
|
>
|
||||||
<h1>比特骆驼中控</h1>
|
<h1>比特骆驼中控</h1>
|
||||||
<p className="meta">本地运维面板 · 默认 admin / admin123</p>
|
<p className="meta">
|
||||||
|
本地运维面板
|
||||||
|
{showHint ? " · 默认 admin / admin123" : ""}
|
||||||
|
</p>
|
||||||
{err ? <div className="err">{err}</div> : null}
|
{err ? <div className="err">{err}</div> : null}
|
||||||
<label>
|
<label>
|
||||||
用户名
|
用户名
|
||||||
<input value={username} onChange={(e) => setUsername(e.target.value)} required />
|
<input
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
autoComplete="off"
|
||||||
|
autoCorrect="off"
|
||||||
|
autoCapitalize="off"
|
||||||
|
spellCheck={false}
|
||||||
|
name="control-login-user"
|
||||||
|
required
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
密码
|
密码
|
||||||
@@ -39,6 +106,8 @@ export default function LoginPage() {
|
|||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
autoComplete="new-password"
|
||||||
|
name="control-login-pass"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { FormEvent, useEffect, useState } from "react";
|
import { FormEvent, useEffect, useState } from "react";
|
||||||
import { apiFetch, getUsername, setSession, type NodeCard } from "../api";
|
import { apiFetch, setSession, type NodeCard } from "../api";
|
||||||
|
|
||||||
|
type Tab = "account" | "add" | "list";
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
|
const [tab, setTab] = useState<Tab>("account");
|
||||||
const [nodes, setNodes] = useState<NodeCard[]>([]);
|
const [nodes, setNodes] = useState<NodeCard[]>([]);
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [baseUrl, setBaseUrl] = useState("https://");
|
const [baseUrl, setBaseUrl] = useState("https://");
|
||||||
@@ -11,19 +14,33 @@ export default function SettingsPage() {
|
|||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
|
||||||
const [newUsername, setNewUsername] = useState(getUsername() || "admin");
|
const [newUsername, setNewUsername] = useState("");
|
||||||
const [currentPassword, setCurrentPassword] = useState("");
|
const [currentPassword, setCurrentPassword] = useState("");
|
||||||
const [newPassword, setNewPassword] = useState("");
|
const [newPassword, setNewPassword] = useState("");
|
||||||
const [confirmPassword, setConfirmPassword] = useState("");
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
const [credBusy, setCredBusy] = useState(false);
|
const [credBusy, setCredBusy] = useState(false);
|
||||||
|
const [showHint, setShowHint] = useState(false);
|
||||||
|
const [lanBypass, setLanBypass] = useState(false);
|
||||||
|
const [lanBusy, setLanBusy] = useState(false);
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
const r = await apiFetch<{ nodes: NodeCard[] }>("/api/nodes/");
|
const r = await apiFetch<{ nodes: NodeCard[] }>("/api/nodes/");
|
||||||
setNodes(r.nodes || []);
|
setNodes(r.nodes || []);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadMe() {
|
||||||
|
const m = await apiFetch<{
|
||||||
|
show_default_hint?: boolean;
|
||||||
|
lan_bypass_enabled?: boolean;
|
||||||
|
}>("/api/auth/me");
|
||||||
|
setShowHint(!!m.show_default_hint);
|
||||||
|
setLanBypass(!!m.lan_bypass_enabled);
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load().catch((ex) => setErr(ex instanceof Error ? ex.message : String(ex)));
|
Promise.all([load(), loadMe()]).catch((ex) =>
|
||||||
|
setErr(ex instanceof Error ? ex.message : String(ex)),
|
||||||
|
);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function onAdd(e: FormEvent) {
|
async function onAdd(e: FormEvent) {
|
||||||
@@ -38,6 +55,7 @@ export default function SettingsPage() {
|
|||||||
setName("");
|
setName("");
|
||||||
setOk("已添加策略机");
|
setOk("已添加策略机");
|
||||||
await load();
|
await load();
|
||||||
|
setTab("list");
|
||||||
} catch (ex) {
|
} catch (ex) {
|
||||||
setErr(ex instanceof Error ? ex.message : String(ex));
|
setErr(ex instanceof Error ? ex.message : String(ex));
|
||||||
}
|
}
|
||||||
@@ -53,21 +71,24 @@ export default function SettingsPage() {
|
|||||||
}
|
}
|
||||||
setCredBusy(true);
|
setCredBusy(true);
|
||||||
try {
|
try {
|
||||||
const r = await apiFetch<{ token: string; username: string }>(
|
const r = await apiFetch<{
|
||||||
"/api/auth/change-credentials",
|
token: string;
|
||||||
{
|
username: string;
|
||||||
method: "POST",
|
show_default_hint?: boolean;
|
||||||
body: JSON.stringify({
|
}>("/api/auth/change-credentials", {
|
||||||
current_password: currentPassword,
|
method: "POST",
|
||||||
new_username: newUsername.trim(),
|
body: JSON.stringify({
|
||||||
new_password: newPassword,
|
current_password: currentPassword,
|
||||||
}),
|
new_username: newUsername.trim(),
|
||||||
},
|
new_password: newPassword,
|
||||||
);
|
}),
|
||||||
|
});
|
||||||
setSession(r.token, r.username);
|
setSession(r.token, r.username);
|
||||||
setCurrentPassword("");
|
setCurrentPassword("");
|
||||||
setNewPassword("");
|
setNewPassword("");
|
||||||
setConfirmPassword("");
|
setConfirmPassword("");
|
||||||
|
setNewUsername("");
|
||||||
|
setShowHint(!!r.show_default_hint);
|
||||||
setOk("中控账号已更新");
|
setOk("中控账号已更新");
|
||||||
} catch (ex) {
|
} catch (ex) {
|
||||||
setErr(ex instanceof Error ? ex.message : String(ex));
|
setErr(ex instanceof Error ? ex.message : String(ex));
|
||||||
@@ -76,6 +97,31 @@ export default function SettingsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function onToggleLan(next: boolean) {
|
||||||
|
setErr("");
|
||||||
|
setOk("");
|
||||||
|
setLanBusy(true);
|
||||||
|
try {
|
||||||
|
const r = await apiFetch<{ lan_bypass_enabled: boolean }>(
|
||||||
|
"/api/auth/lan-bypass",
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ enabled: next }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
setLanBypass(!!r.lan_bypass_enabled);
|
||||||
|
setOk(
|
||||||
|
next
|
||||||
|
? "已开启局域网免登录(仅私网 IP 生效)"
|
||||||
|
: "已关闭局域网免登录",
|
||||||
|
);
|
||||||
|
} catch (ex) {
|
||||||
|
setErr(ex instanceof Error ? ex.message : String(ex));
|
||||||
|
} finally {
|
||||||
|
setLanBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function genToken(id: number) {
|
async function genToken(id: number) {
|
||||||
setErr("");
|
setErr("");
|
||||||
setOk("");
|
setOk("");
|
||||||
@@ -97,6 +143,7 @@ export default function SettingsPage() {
|
|||||||
setErr("");
|
setErr("");
|
||||||
try {
|
try {
|
||||||
await apiFetch(`/api/nodes/${id}`, { method: "DELETE" });
|
await apiFetch(`/api/nodes/${id}`, { method: "DELETE" });
|
||||||
|
setOk("已删除");
|
||||||
await load();
|
await load();
|
||||||
} catch (ex) {
|
} catch (ex) {
|
||||||
setErr(ex instanceof Error ? ex.message : String(ex));
|
setErr(ex instanceof Error ? ex.message : String(ex));
|
||||||
@@ -109,133 +156,198 @@ export default function SettingsPage() {
|
|||||||
<p className="meta">
|
<p className="meta">
|
||||||
中控不访问交易所。生成 Token 后登录策略机,在「登录账户」中保存同一 Token。
|
中控不访问交易所。生成 Token 后登录策略机,在「登录账户」中保存同一 Token。
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<div className="settings-tabs" role="tablist">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={tab === "account" ? "tab active" : "tab"}
|
||||||
|
onClick={() => setTab("account")}
|
||||||
|
>
|
||||||
|
中控登录账户
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={tab === "add" ? "tab active" : "tab"}
|
||||||
|
onClick={() => setTab("add")}
|
||||||
|
>
|
||||||
|
添加策略机
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={tab === "list" ? "tab active" : "tab"}
|
||||||
|
onClick={() => setTab("list")}
|
||||||
|
>
|
||||||
|
策略机列表
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{err ? <div className="err">{err}</div> : null}
|
{err ? <div className="err">{err}</div> : null}
|
||||||
{ok ? <div className="ok">{ok}</div> : null}
|
{ok ? <div className="ok">{ok}</div> : null}
|
||||||
|
|
||||||
<form className="add-form" onSubmit={onSaveCreds}>
|
{tab === "account" ? (
|
||||||
<h3>中控登录账号</h3>
|
<form className="add-form" onSubmit={onSaveCreds} autoComplete="off">
|
||||||
<p className="meta">默认 admin / admin123,建议首次登录后修改。</p>
|
<h3>中控登录账号</h3>
|
||||||
<label>
|
{showHint ? (
|
||||||
新用户名
|
<p className="meta">当前仍为默认账号,建议修改用户名与密码。</p>
|
||||||
<input
|
) : (
|
||||||
value={newUsername}
|
<p className="meta">修改后旧会话将失效,需重新登录(局域网免登除外)。</p>
|
||||||
onChange={(e) => setNewUsername(e.target.value)}
|
)}
|
||||||
required
|
<label className="switch-row">
|
||||||
/>
|
<span>
|
||||||
</label>
|
本地局域网免登录
|
||||||
<label>
|
<span className="meta switch-hint">
|
||||||
当前密码
|
开启后,从 192.168/10/172.16 等私网访问可自动进入,无需密码
|
||||||
<input
|
</span>
|
||||||
type="password"
|
</span>
|
||||||
value={currentPassword}
|
<button
|
||||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
type="button"
|
||||||
required
|
className={`switch ${lanBypass ? "on" : ""}`}
|
||||||
/>
|
disabled={lanBusy}
|
||||||
</label>
|
aria-pressed={lanBypass}
|
||||||
<label>
|
onClick={() => void onToggleLan(!lanBypass)}
|
||||||
新密码
|
>
|
||||||
<input
|
<span className="switch-knob" />
|
||||||
type="password"
|
</button>
|
||||||
value={newPassword}
|
</label>
|
||||||
onChange={(e) => setNewPassword(e.target.value)}
|
<label>
|
||||||
minLength={6}
|
新用户名
|
||||||
required
|
<input
|
||||||
/>
|
value={newUsername}
|
||||||
</label>
|
onChange={(e) => setNewUsername(e.target.value)}
|
||||||
<label>
|
autoComplete="off"
|
||||||
确认新密码
|
name="control-new-user"
|
||||||
<input
|
placeholder="输入新用户名"
|
||||||
type="password"
|
required
|
||||||
value={confirmPassword}
|
/>
|
||||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
</label>
|
||||||
minLength={6}
|
<label>
|
||||||
required
|
当前密码
|
||||||
/>
|
<input
|
||||||
</label>
|
type="password"
|
||||||
<button className="btn" type="submit" disabled={credBusy}>
|
value={currentPassword}
|
||||||
{credBusy ? "保存中…" : "保存账号"}
|
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||||
</button>
|
autoComplete="new-password"
|
||||||
</form>
|
name="control-cur-pass"
|
||||||
|
required
|
||||||
{lastToken ? (
|
/>
|
||||||
<div className="token-box">
|
</label>
|
||||||
<div>
|
<label>
|
||||||
节点 #{lastToken.id} 新 Token(只显示一次,请复制):
|
新密码
|
||||||
</div>
|
<input
|
||||||
<code className="mono">{lastToken.token}</code>
|
type="password"
|
||||||
<button
|
value={newPassword}
|
||||||
type="button"
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
className="btn ghost"
|
autoComplete="new-password"
|
||||||
onClick={() => {
|
name="control-new-pass"
|
||||||
void navigator.clipboard.writeText(lastToken.token);
|
minLength={6}
|
||||||
setOk("已复制到剪贴板");
|
required
|
||||||
}}
|
/>
|
||||||
>
|
</label>
|
||||||
复制
|
<label>
|
||||||
|
确认新密码
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
autoComplete="new-password"
|
||||||
|
name="control-confirm-pass"
|
||||||
|
minLength={6}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button className="btn" type="submit" disabled={credBusy}>
|
||||||
|
{credBusy ? "保存中…" : "保存账号"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</form>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<form className="add-form" onSubmit={onAdd}>
|
{tab === "add" ? (
|
||||||
<h3>添加策略机</h3>
|
<form className="add-form" onSubmit={onAdd}>
|
||||||
<label>
|
<h3>添加策略机</h3>
|
||||||
名称
|
<label>
|
||||||
<input
|
名称
|
||||||
value={name}
|
<input
|
||||||
onChange={(e) => setName(e.target.value)}
|
value={name}
|
||||||
placeholder="例如 云机-A"
|
onChange={(e) => setName(e.target.value)}
|
||||||
required
|
placeholder="例如 云机-A"
|
||||||
/>
|
required
|
||||||
</label>
|
/>
|
||||||
<label>
|
</label>
|
||||||
公网 Base URL
|
<label>
|
||||||
<input
|
公网 Base URL
|
||||||
value={baseUrl}
|
<input
|
||||||
onChange={(e) => setBaseUrl(e.target.value)}
|
value={baseUrl}
|
||||||
placeholder="https://dc.hyf2.cc"
|
onChange={(e) => setBaseUrl(e.target.value)}
|
||||||
required
|
placeholder="https://dc.hyf2.cc"
|
||||||
/>
|
required
|
||||||
</label>
|
/>
|
||||||
<button className="btn" type="submit">
|
</label>
|
||||||
添加
|
<button className="btn" type="submit">
|
||||||
</button>
|
添加
|
||||||
</form>
|
</button>
|
||||||
|
</form>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="table-wrap">
|
{tab === "list" ? (
|
||||||
<table>
|
<div>
|
||||||
<thead>
|
{lastToken ? (
|
||||||
<tr>
|
<div className="token-box">
|
||||||
<th>ID</th>
|
<div>节点 #{lastToken.id} 新 Token(只显示一次,请复制):</div>
|
||||||
<th>名称</th>
|
<code className="mono">{lastToken.token}</code>
|
||||||
<th>URL</th>
|
<button
|
||||||
<th>Token</th>
|
type="button"
|
||||||
<th>操作</th>
|
className="btn ghost"
|
||||||
</tr>
|
onClick={() => {
|
||||||
</thead>
|
void navigator.clipboard.writeText(lastToken.token);
|
||||||
<tbody>
|
setOk("已复制到剪贴板");
|
||||||
{nodes.map((n) => (
|
}}
|
||||||
<tr key={n.id}>
|
>
|
||||||
<td>{n.id}</td>
|
复制
|
||||||
<td>{n.name}</td>
|
</button>
|
||||||
<td className="mono">{n.base_url}</td>
|
</div>
|
||||||
<td>{n.token_configured ? "已生成" : "无"}</td>
|
) : null}
|
||||||
<td className="row-actions">
|
<div className="table-wrap">
|
||||||
<button type="button" className="btn" onClick={() => void genToken(n.id)}>
|
<table>
|
||||||
生成 Token
|
<thead>
|
||||||
</button>
|
<tr>
|
||||||
<button
|
<th>ID</th>
|
||||||
type="button"
|
<th>名称</th>
|
||||||
className="btn ghost"
|
<th>URL</th>
|
||||||
onClick={() => void remove(n.id)}
|
<th>Token</th>
|
||||||
>
|
<th>操作</th>
|
||||||
删除
|
</tr>
|
||||||
</button>
|
</thead>
|
||||||
</td>
|
<tbody>
|
||||||
</tr>
|
{nodes.map((n) => (
|
||||||
))}
|
<tr key={n.id}>
|
||||||
</tbody>
|
<td>{n.id}</td>
|
||||||
</table>
|
<td>{n.name}</td>
|
||||||
</div>
|
<td className="mono">{n.base_url}</td>
|
||||||
|
<td>{n.token_configured ? "已生成" : "无"}</td>
|
||||||
|
<td className="row-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn"
|
||||||
|
onClick={() => void genToken(n.id)}
|
||||||
|
>
|
||||||
|
生成 Token
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn ghost"
|
||||||
|
onClick={() => void remove(n.id)}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{!nodes.length ? <p className="meta">暂无策略机,请到「添加策略机」。</p> : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -317,6 +317,78 @@ td {
|
|||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-tabs {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 12px 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-tabs .tab {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-tabs .tab.active,
|
||||||
|
.settings-tabs .tab:hover {
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--panel);
|
||||||
|
border-color: #3d5a73;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-row {
|
||||||
|
display: flex !important;
|
||||||
|
flex-direction: row !important;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 10px 0;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-hint {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch {
|
||||||
|
position: relative;
|
||||||
|
width: 48px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 0;
|
||||||
|
background: #3a4654;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch.on {
|
||||||
|
background: var(--ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-knob {
|
||||||
|
position: absolute;
|
||||||
|
top: 3px;
|
||||||
|
left: 3px;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #fff;
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch.on .switch-knob {
|
||||||
|
transform: translateX(20px);
|
||||||
|
}
|
||||||
|
|
||||||
.modal-backdrop {
|
.modal-backdrop {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user