Control settings tabs, hide default creds hint after change, LAN passwordless login.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,13 +1,52 @@
|
||||
import { FormEvent, useState } from "react";
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
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() {
|
||||
const nav = useNavigate();
|
||||
const [username, setUsername] = useState("admin");
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [err, setErr] = useState("");
|
||||
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) {
|
||||
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 (
|
||||
<div className="login-wrap">
|
||||
<form className="login-box" onSubmit={onSubmit}>
|
||||
<form
|
||||
className="login-box"
|
||||
onSubmit={onSubmit}
|
||||
autoComplete="off"
|
||||
data-lpignore="true"
|
||||
>
|
||||
<h1>比特骆驼中控</h1>
|
||||
<p className="meta">本地运维面板 · 默认 admin / admin123</p>
|
||||
<p className="meta">
|
||||
本地运维面板
|
||||
{showHint ? " · 默认 admin / admin123" : ""}
|
||||
</p>
|
||||
{err ? <div className="err">{err}</div> : null}
|
||||
<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>
|
||||
密码
|
||||
@@ -39,6 +106,8 @@ export default function LoginPage() {
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
name="control-login-pass"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
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() {
|
||||
const [tab, setTab] = useState<Tab>("account");
|
||||
const [nodes, setNodes] = useState<NodeCard[]>([]);
|
||||
const [name, setName] = useState("");
|
||||
const [baseUrl, setBaseUrl] = useState("https://");
|
||||
@@ -11,19 +14,33 @@ export default function SettingsPage() {
|
||||
null,
|
||||
);
|
||||
|
||||
const [newUsername, setNewUsername] = useState(getUsername() || "admin");
|
||||
const [newUsername, setNewUsername] = useState("");
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [credBusy, setCredBusy] = useState(false);
|
||||
const [showHint, setShowHint] = useState(false);
|
||||
const [lanBypass, setLanBypass] = useState(false);
|
||||
const [lanBusy, setLanBusy] = useState(false);
|
||||
|
||||
async function load() {
|
||||
const r = await apiFetch<{ nodes: NodeCard[] }>("/api/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(() => {
|
||||
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) {
|
||||
@@ -38,6 +55,7 @@ export default function SettingsPage() {
|
||||
setName("");
|
||||
setOk("已添加策略机");
|
||||
await load();
|
||||
setTab("list");
|
||||
} catch (ex) {
|
||||
setErr(ex instanceof Error ? ex.message : String(ex));
|
||||
}
|
||||
@@ -53,21 +71,24 @@ export default function SettingsPage() {
|
||||
}
|
||||
setCredBusy(true);
|
||||
try {
|
||||
const r = await apiFetch<{ token: string; username: string }>(
|
||||
"/api/auth/change-credentials",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
current_password: currentPassword,
|
||||
new_username: newUsername.trim(),
|
||||
new_password: newPassword,
|
||||
}),
|
||||
},
|
||||
);
|
||||
const r = await apiFetch<{
|
||||
token: string;
|
||||
username: string;
|
||||
show_default_hint?: boolean;
|
||||
}>("/api/auth/change-credentials", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
current_password: currentPassword,
|
||||
new_username: newUsername.trim(),
|
||||
new_password: newPassword,
|
||||
}),
|
||||
});
|
||||
setSession(r.token, r.username);
|
||||
setCurrentPassword("");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
setNewUsername("");
|
||||
setShowHint(!!r.show_default_hint);
|
||||
setOk("中控账号已更新");
|
||||
} catch (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) {
|
||||
setErr("");
|
||||
setOk("");
|
||||
@@ -97,6 +143,7 @@ export default function SettingsPage() {
|
||||
setErr("");
|
||||
try {
|
||||
await apiFetch(`/api/nodes/${id}`, { method: "DELETE" });
|
||||
setOk("已删除");
|
||||
await load();
|
||||
} catch (ex) {
|
||||
setErr(ex instanceof Error ? ex.message : String(ex));
|
||||
@@ -109,133 +156,198 @@ export default function SettingsPage() {
|
||||
<p className="meta">
|
||||
中控不访问交易所。生成 Token 后登录策略机,在「登录账户」中保存同一 Token。
|
||||
</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}
|
||||
{ok ? <div className="ok">{ok}</div> : null}
|
||||
|
||||
<form className="add-form" onSubmit={onSaveCreds}>
|
||||
<h3>中控登录账号</h3>
|
||||
<p className="meta">默认 admin / admin123,建议首次登录后修改。</p>
|
||||
<label>
|
||||
新用户名
|
||||
<input
|
||||
value={newUsername}
|
||||
onChange={(e) => setNewUsername(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
当前密码
|
||||
<input
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
新密码
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
minLength={6}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
确认新密码
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
minLength={6}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<button className="btn" type="submit" disabled={credBusy}>
|
||||
{credBusy ? "保存中…" : "保存账号"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{lastToken ? (
|
||||
<div className="token-box">
|
||||
<div>
|
||||
节点 #{lastToken.id} 新 Token(只显示一次,请复制):
|
||||
</div>
|
||||
<code className="mono">{lastToken.token}</code>
|
||||
<button
|
||||
type="button"
|
||||
className="btn ghost"
|
||||
onClick={() => {
|
||||
void navigator.clipboard.writeText(lastToken.token);
|
||||
setOk("已复制到剪贴板");
|
||||
}}
|
||||
>
|
||||
复制
|
||||
{tab === "account" ? (
|
||||
<form className="add-form" onSubmit={onSaveCreds} autoComplete="off">
|
||||
<h3>中控登录账号</h3>
|
||||
{showHint ? (
|
||||
<p className="meta">当前仍为默认账号,建议修改用户名与密码。</p>
|
||||
) : (
|
||||
<p className="meta">修改后旧会话将失效,需重新登录(局域网免登除外)。</p>
|
||||
)}
|
||||
<label className="switch-row">
|
||||
<span>
|
||||
本地局域网免登录
|
||||
<span className="meta switch-hint">
|
||||
开启后,从 192.168/10/172.16 等私网访问可自动进入,无需密码
|
||||
</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={`switch ${lanBypass ? "on" : ""}`}
|
||||
disabled={lanBusy}
|
||||
aria-pressed={lanBypass}
|
||||
onClick={() => void onToggleLan(!lanBypass)}
|
||||
>
|
||||
<span className="switch-knob" />
|
||||
</button>
|
||||
</label>
|
||||
<label>
|
||||
新用户名
|
||||
<input
|
||||
value={newUsername}
|
||||
onChange={(e) => setNewUsername(e.target.value)}
|
||||
autoComplete="off"
|
||||
name="control-new-user"
|
||||
placeholder="输入新用户名"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
当前密码
|
||||
<input
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
name="control-cur-pass"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
新密码
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
name="control-new-pass"
|
||||
minLength={6}
|
||||
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>
|
||||
</div>
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
<form className="add-form" onSubmit={onAdd}>
|
||||
<h3>添加策略机</h3>
|
||||
<label>
|
||||
名称
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="例如 云机-A"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
公网 Base URL
|
||||
<input
|
||||
value={baseUrl}
|
||||
onChange={(e) => setBaseUrl(e.target.value)}
|
||||
placeholder="https://dc.hyf2.cc"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<button className="btn" type="submit">
|
||||
添加
|
||||
</button>
|
||||
</form>
|
||||
{tab === "add" ? (
|
||||
<form className="add-form" onSubmit={onAdd}>
|
||||
<h3>添加策略机</h3>
|
||||
<label>
|
||||
名称
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="例如 云机-A"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
公网 Base URL
|
||||
<input
|
||||
value={baseUrl}
|
||||
onChange={(e) => setBaseUrl(e.target.value)}
|
||||
placeholder="https://dc.hyf2.cc"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<button className="btn" type="submit">
|
||||
添加
|
||||
</button>
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
<div className="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>名称</th>
|
||||
<th>URL</th>
|
||||
<th>Token</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{nodes.map((n) => (
|
||||
<tr key={n.id}>
|
||||
<td>{n.id}</td>
|
||||
<td>{n.name}</td>
|
||||
<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>
|
||||
{tab === "list" ? (
|
||||
<div>
|
||||
{lastToken ? (
|
||||
<div className="token-box">
|
||||
<div>节点 #{lastToken.id} 新 Token(只显示一次,请复制):</div>
|
||||
<code className="mono">{lastToken.token}</code>
|
||||
<button
|
||||
type="button"
|
||||
className="btn ghost"
|
||||
onClick={() => {
|
||||
void navigator.clipboard.writeText(lastToken.token);
|
||||
setOk("已复制到剪贴板");
|
||||
}}
|
||||
>
|
||||
复制
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>名称</th>
|
||||
<th>URL</th>
|
||||
<th>Token</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{nodes.map((n) => (
|
||||
<tr key={n.id}>
|
||||
<td>{n.id}</td>
|
||||
<td>{n.name}</td>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user