feat: add system settings page for admin username and password
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+35
-4
@@ -34,7 +34,7 @@ async function apiFetch(input: string, init?: RequestInit): Promise<Response> {
|
||||
return r;
|
||||
}
|
||||
|
||||
export type AuthStatus = { auth_required: boolean };
|
||||
export type AuthStatus = { auth_required: boolean; username?: string };
|
||||
|
||||
export async function fetchAuthStatus(): Promise<AuthStatus> {
|
||||
const r = await fetch("/api/auth/status", { credentials: "include" });
|
||||
@@ -42,19 +42,50 @@ export async function fetchAuthStatus(): Promise<AuthStatus> {
|
||||
return r.json();
|
||||
}
|
||||
|
||||
export async function login(password: string): Promise<{ ok: boolean; token?: string | null }> {
|
||||
export async function login(
|
||||
username: string,
|
||||
password: string
|
||||
): Promise<{ ok: boolean; token?: string | null }> {
|
||||
const r = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ password }),
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
if (!r.ok) throw new Error("密码错误");
|
||||
if (!r.ok) throw new Error("用户名或密码错误");
|
||||
const body = await r.json();
|
||||
if (body.token) setToken(body.token);
|
||||
return body;
|
||||
}
|
||||
|
||||
export type AccountSettings = {
|
||||
username: string;
|
||||
auth_required: boolean;
|
||||
};
|
||||
|
||||
export async function fetchAccountSettings(): Promise<AccountSettings> {
|
||||
const r = await apiFetch("/api/settings/account");
|
||||
if (!r.ok) throw new Error(r.status === 401 ? "unauthorized" : "settings failed");
|
||||
return r.json();
|
||||
}
|
||||
|
||||
export async function updateAccountSettings(body: {
|
||||
current_password: string;
|
||||
new_username?: string;
|
||||
new_password?: string;
|
||||
}): Promise<{ ok: boolean; message?: string; relogin_required?: boolean; username: string }> {
|
||||
const r = await apiFetch("/api/settings/account", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!r.ok) {
|
||||
const d = await r.json().catch(() => ({}));
|
||||
throw new Error(d.detail || "save failed");
|
||||
}
|
||||
return r.json();
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
setToken(null);
|
||||
await fetch("/api/auth/logout", { method: "POST", credentials: "include" });
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import { logout } from "../api/client";
|
||||
|
||||
export default function AppNav() {
|
||||
const loc = useLocation();
|
||||
const path = loc.pathname;
|
||||
|
||||
return (
|
||||
<div className="nav">
|
||||
<Link to="/" className={path === "/" ? "active" : ""}>
|
||||
总览
|
||||
</Link>
|
||||
<Link to="/ops-map" className={path === "/ops-map" ? "active" : ""}>
|
||||
作战地图
|
||||
</Link>
|
||||
<Link to="/settings" className={path === "/settings" ? "active" : ""}>
|
||||
系统设置
|
||||
</Link>
|
||||
<a
|
||||
href="#logout"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
logout();
|
||||
window.location.href = "/";
|
||||
}}
|
||||
>
|
||||
退出
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ type Props = {
|
||||
};
|
||||
|
||||
export default function LoginGate({ onOk }: Props) {
|
||||
const [username, setUsername] = useState("admin");
|
||||
const [password, setPassword] = useState("");
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -15,7 +16,7 @@ export default function LoginGate({ onOk }: Props) {
|
||||
setBusy(true);
|
||||
setErr(null);
|
||||
try {
|
||||
await login(password);
|
||||
await login(username.trim(), password);
|
||||
onOk();
|
||||
} catch (ex) {
|
||||
setErr(String(ex));
|
||||
@@ -25,40 +26,34 @@ export default function LoginGate({ onOk }: Props) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="layout">
|
||||
<div className="brand">比特骆驼行情采集分析</div>
|
||||
<div className="sub">需要登录后查看看板</div>
|
||||
<form className="tile" onSubmit={submit} style={{ maxWidth: 360 }}>
|
||||
<div className="label">管理员密码</div>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoFocus
|
||||
style={{
|
||||
width: "100%",
|
||||
marginTop: "0.5rem",
|
||||
padding: "0.55rem 0.65rem",
|
||||
borderRadius: 6,
|
||||
border: "1px solid #243041",
|
||||
background: "#0c1117",
|
||||
color: "#e8eef5",
|
||||
}}
|
||||
/>
|
||||
{err && <p style={{ color: "#e85d5d", fontSize: "0.85rem" }}>{err}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || !password}
|
||||
style={{
|
||||
marginTop: "0.85rem",
|
||||
padding: "0.5rem 1rem",
|
||||
borderRadius: 6,
|
||||
border: 0,
|
||||
background: "#3d8fd1",
|
||||
color: "#fff",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<div className="center-page">
|
||||
<form className="tile settings-card" onSubmit={submit}>
|
||||
<div className="settings-title">登录</div>
|
||||
<div className="sub" style={{ marginBottom: "1rem" }}>
|
||||
比特骆驼行情采集分析
|
||||
</div>
|
||||
<label className="field">
|
||||
<span className="label">用户名</span>
|
||||
<input
|
||||
className="field-input"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="label">密码</span>
|
||||
<input
|
||||
className="field-input"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</label>
|
||||
{err && <p className="form-err">{err}</p>}
|
||||
<button className="btn-primary" type="submit" disabled={busy || !password || !username}>
|
||||
{busy ? "登录中…" : "登录"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -3,6 +3,7 @@ import ReactDOM from "react-dom/client";
|
||||
import { BrowserRouter, Route, Routes } from "react-router-dom";
|
||||
import Dashboard from "./pages/Dashboard";
|
||||
import OpsMap from "./pages/OpsMap";
|
||||
import Settings from "./pages/Settings";
|
||||
import "./styles.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
@@ -11,6 +12,7 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/ops-map" element={<OpsMap />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
fetchAuthStatus,
|
||||
fetchHealth,
|
||||
fetchLatest,
|
||||
Health,
|
||||
Latest,
|
||||
logout,
|
||||
} from "../api/client";
|
||||
import AppNav from "../components/AppNav";
|
||||
import LoginGate from "../components/LoginGate";
|
||||
|
||||
function fmt(n?: number | null, d = 1) {
|
||||
@@ -94,19 +93,7 @@ export default function Dashboard() {
|
||||
<div className="layout">
|
||||
<div className="brand">比特骆驼行情采集分析</div>
|
||||
<div className="sub">只读采集 · 杠杆 = 指数 ÷ 卖一 · Asia/Shanghai</div>
|
||||
<div className="nav">
|
||||
<Link to="/">总览</Link>
|
||||
<Link to="/ops-map">作战地图</Link>
|
||||
<a
|
||||
href="#logout"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
logout().finally(() => setNeedLogin(true));
|
||||
}}
|
||||
>
|
||||
退出
|
||||
</a>
|
||||
</div>
|
||||
<AppNav />
|
||||
{err && <p style={{ color: "#e85d5d" }}>{err}</p>}
|
||||
<div className="row">
|
||||
<div className="tile">
|
||||
|
||||
+12
-30
@@ -1,13 +1,12 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
fetchAuthStatus,
|
||||
fetchAccountSettings,
|
||||
fetchOpsMap,
|
||||
LeverageStats,
|
||||
logout,
|
||||
MovePointsStats,
|
||||
OpsMap,
|
||||
} from "../api/client";
|
||||
import AppNav from "../components/AppNav";
|
||||
import LeverageChart from "../components/LeverageChart";
|
||||
import LoginGate from "../components/LoginGate";
|
||||
import MovePointsChart from "../components/MovePointsChart";
|
||||
@@ -35,22 +34,17 @@ export default function OpsMapPage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAuthStatus()
|
||||
.then(async (st) => {
|
||||
if (!st.auth_required) {
|
||||
setNeedLogin(false);
|
||||
setReady(true);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await fetchOpsMap({ range: "day", bucket_minutes: 60 });
|
||||
setNeedLogin(false);
|
||||
} catch {
|
||||
setNeedLogin(true);
|
||||
}
|
||||
fetchAccountSettings()
|
||||
.then(() => {
|
||||
setNeedLogin(false);
|
||||
setReady(true);
|
||||
})
|
||||
.catch((e) => setErr(String(e)));
|
||||
.catch((e) => {
|
||||
const msg = String(e);
|
||||
if (msg.includes("unauthorized")) setNeedLogin(true);
|
||||
else setErr(msg);
|
||||
setReady(true);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -96,19 +90,7 @@ export default function OpsMapPage() {
|
||||
<div className="layout">
|
||||
<div className="brand">比特骆驼行情采集分析</div>
|
||||
<div className="sub">作战地图 · 杠杆 × 时段 → 到期波动</div>
|
||||
<div className="nav">
|
||||
<Link to="/">总览</Link>
|
||||
<Link to="/ops-map">作战地图</Link>
|
||||
<a
|
||||
href="#logout"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
logout().finally(() => setNeedLogin(true));
|
||||
}}
|
||||
>
|
||||
退出
|
||||
</a>
|
||||
</div>
|
||||
<AppNav />
|
||||
|
||||
<div className="toolbar">
|
||||
<div className="seg">
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import {
|
||||
fetchAccountSettings,
|
||||
logout,
|
||||
updateAccountSettings,
|
||||
} from "../api/client";
|
||||
import AppNav from "../components/AppNav";
|
||||
import LoginGate from "../components/LoginGate";
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [needLogin, setNeedLogin] = useState(false);
|
||||
const [ready, setReady] = useState(false);
|
||||
const [username, setUsername] = useState("admin");
|
||||
const [newUsername, setNewUsername] = useState("");
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [authRequired, setAuthRequired] = useState(true);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAccountSettings()
|
||||
.then((d) => {
|
||||
setUsername(d.username);
|
||||
setNewUsername(d.username);
|
||||
setAuthRequired(d.auth_required);
|
||||
setNeedLogin(false);
|
||||
setReady(true);
|
||||
})
|
||||
.catch((e) => {
|
||||
const m = String(e);
|
||||
if (m.includes("unauthorized")) setNeedLogin(true);
|
||||
else setErr(m);
|
||||
setReady(true);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const submit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setErr(null);
|
||||
setMsg(null);
|
||||
if (newPassword && newPassword !== confirmPassword) {
|
||||
setErr("两次输入的新密码不一致");
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await updateAccountSettings({
|
||||
current_password: currentPassword,
|
||||
new_username: newUsername !== username ? newUsername : undefined,
|
||||
new_password: newPassword || undefined,
|
||||
});
|
||||
if (res.relogin_required) {
|
||||
await logout();
|
||||
setMsg(res.message || "已保存,请重新登录");
|
||||
setNeedLogin(true);
|
||||
return;
|
||||
}
|
||||
setMsg(res.message || "已保存");
|
||||
setUsername(res.username);
|
||||
setCurrentPassword("");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
} catch (ex) {
|
||||
setErr(String(ex));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!ready) {
|
||||
return (
|
||||
<div className="layout">
|
||||
<div className="sub">加载中…</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (needLogin) {
|
||||
return <LoginGate onOk={() => window.location.reload()} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="layout">
|
||||
<div className="brand">比特骆驼行情采集分析</div>
|
||||
<div className="sub">系统设置 · 管理员账号</div>
|
||||
<AppNav />
|
||||
|
||||
<div className="center-page">
|
||||
<form className="tile settings-card" onSubmit={submit}>
|
||||
<div className="settings-title">系统设置</div>
|
||||
{!authRequired && (
|
||||
<p className="settings-hint">当前鉴权已关闭(AUTH_SECRET=disabled),请在 .env 中修改。</p>
|
||||
)}
|
||||
|
||||
<label className="field">
|
||||
<span className="label">用户名</span>
|
||||
<input
|
||||
className="field-input"
|
||||
value={newUsername}
|
||||
onChange={(e) => setNewUsername(e.target.value)}
|
||||
autoComplete="username"
|
||||
disabled={!authRequired}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="field">
|
||||
<span className="label">当前密码</span>
|
||||
<input
|
||||
className="field-input"
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
disabled={!authRequired}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="field">
|
||||
<span className="label">新密码(留空则不修改)</span>
|
||||
<input
|
||||
className="field-input"
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
disabled={!authRequired}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="field">
|
||||
<span className="label">确认新密码</span>
|
||||
<input
|
||||
className="field-input"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
disabled={!authRequired}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{err && <p className="form-err">{err}</p>}
|
||||
{msg && <p className="form-ok">{msg}</p>}
|
||||
|
||||
<button className="btn-primary" type="submit" disabled={busy || !authRequired || !currentPassword}>
|
||||
{busy ? "保存中…" : "保存"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+40
-1
@@ -20,7 +20,46 @@ a { color: var(--accent); text-decoration: none; }
|
||||
.layout { max-width: 980px; margin: 0 auto; padding: 1.5rem; }
|
||||
.brand { font-size: 1.35rem; font-weight: 700; }
|
||||
.sub { color: var(--muted); margin: 0.35rem 0 1.25rem; }
|
||||
.nav { display: flex; gap: 1rem; margin-bottom: 1.25rem; }
|
||||
.nav { display: flex; gap: 1rem; margin-bottom: 1.25rem; flex-wrap: wrap; }
|
||||
.nav a.active { color: var(--accent); font-weight: 600; }
|
||||
|
||||
.center-page {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
min-height: 50vh;
|
||||
padding: 1.5rem 0 3rem;
|
||||
}
|
||||
.settings-card { width: 100%; max-width: 420px; }
|
||||
.settings-title { font-size: 1.15rem; font-weight: 650; margin-bottom: 0.25rem; }
|
||||
.settings-hint { color: var(--muted); font-size: 0.85rem; line-height: 1.5; margin: 0 0 1rem; }
|
||||
.field { display: block; margin-bottom: 1rem; }
|
||||
.field-input {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 0.4rem;
|
||||
padding: 0.55rem 0.65rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #243041;
|
||||
background: #0c1117;
|
||||
color: var(--text);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.field-input:disabled { opacity: 0.55; }
|
||||
.btn-primary {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.55rem 1.2rem;
|
||||
border-radius: 6px;
|
||||
border: 0;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.form-err { color: #e85d5d; font-size: 0.85rem; margin: 0.5rem 0 0; }
|
||||
.form-ok { color: var(--ok); font-size: 0.85rem; margin: 0.5rem 0 0; }
|
||||
|
||||
.row { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 1rem; }
|
||||
.tile {
|
||||
background: var(--panel);
|
||||
|
||||
Reference in New Issue
Block a user