feat: add system settings page for admin username and password

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-08-02 08:56:54 +08:00
parent 4743f3efdd
commit b5cba83df4
20 changed files with 647 additions and 117 deletions
+116 -9
View File
@@ -39,6 +39,15 @@
.chart-svg { width: 100%; height: auto; display: block; }
.hidden { display: none; }
pre { background: var(--panel); border: 1px solid #243041; border-radius: 10px; padding: 1rem; overflow: auto; font-size: 0.78rem; color: #b7c5d4; }
.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.75rem; }
.field { display: block; margin-bottom: 1rem; }
.field-input { width: 100%; margin-top: 0.4rem; padding: 0.55rem 0.65rem; border-radius: 6px; border: 1px solid #243041; background: #0c1117; color: var(--text); }
.btn-primary { margin-top: 0.5rem; padding: 0.55rem 1.2rem; border-radius: 6px; border: 0; background: var(--accent); color: #fff; cursor: pointer; }
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.form-err { color: var(--bad); font-size: 0.85rem; }
.form-ok { color: var(--ok); font-size: 0.85rem; }
</style>
</head>
<body>
@@ -49,15 +58,23 @@
<div class="nav">
<a id="nav-dash" class="active" data-page="dash">总览</a>
<a id="nav-ops" data-page="ops">作战地图</a>
<a id="nav-settings" data-page="settings">系统设置</a>
<a id="nav-logout" class="hidden">退出</a>
</div>
<main>
<section id="page-login" class="hidden">
<div class="tile" style="max-width:360px">
<div class="label">管理员密码</div>
<input id="loginPass" type="password" style="width:100%;margin-top:0.5rem;padding:0.55rem;border-radius:6px;border:1px solid #243041;background:#0c1117;color:#e8eef5" />
<p id="loginErr" style="color:var(--bad);font-size:0.85rem"></p>
<button id="loginBtn" type="button" style="margin-top:0.5rem;padding:0.5rem 1rem;border:0;border-radius:6px;background:var(--accent);color:#fff;cursor:pointer">登录</button>
<div class="center-page">
<div class="tile settings-card">
<div class="settings-title">登录</div>
<label class="field"><span class="label">用户名</span>
<input id="loginUser" class="field-input" value="admin" autocomplete="username" />
</label>
<label class="field"><span class="label">密码</span>
<input id="loginPass" type="password" class="field-input" autocomplete="current-password" />
</label>
<p id="loginErr" class="form-err"></p>
<button id="loginBtn" type="button" class="btn-primary">登录</button>
</div>
</div>
</section>
<section id="page-dash">
@@ -105,10 +122,33 @@
</div>
<p id="moveHint" style="color:var(--muted);font-size:0.85rem;margin-top:0.75rem"></p>
</section>
<section id="page-settings" class="hidden">
<div class="center-page">
<form id="settingsForm" class="tile settings-card">
<div class="settings-title">系统设置</div>
<p id="settingsAuthHint" class="hint" style="margin-bottom:1rem"></p>
<label class="field"><span class="label">用户名</span>
<input id="setUsername" class="field-input" autocomplete="username" />
</label>
<label class="field"><span class="label">当前密码</span>
<input id="setCurPass" type="password" class="field-input" autocomplete="current-password" />
</label>
<label class="field"><span class="label">新密码(留空则不修改)</span>
<input id="setNewPass" type="password" class="field-input" autocomplete="new-password" />
</label>
<label class="field"><span class="label">确认新密码</span>
<input id="setNewPass2" type="password" class="field-input" autocomplete="new-password" />
</label>
<p id="settingsErr" class="form-err"></p>
<p id="settingsOk" class="form-ok"></p>
<button type="submit" class="btn-primary" id="settingsSaveBtn">保存</button>
</form>
</div>
</section>
</main>
<script>
const TOKEN_KEY = "mi_token";
const state = { page: "dash", range: "day", side: "both", moveMode: "abs", lastMov: null, authRequired: false };
const state = { page: "dash", range: "day", side: "both", moveMode: "abs", lastMov: null, authRequired: false, loadedUsername: "admin" };
function getToken() { try { return localStorage.getItem(TOKEN_KEY); } catch { return null; } }
function setToken(t) { try { if (t) localStorage.setItem(TOKEN_KEY, t); else localStorage.removeItem(TOKEN_KEY); } catch {} }
function authHeaders() {
@@ -135,6 +175,7 @@
document.getElementById("page-login").classList.remove("hidden");
document.getElementById("page-dash").classList.add("hidden");
document.getElementById("page-ops").classList.add("hidden");
document.getElementById("page-settings").classList.add("hidden");
document.getElementById("nav-logout").classList.add("hidden");
}
function hideLogin() {
@@ -151,13 +192,19 @@
hideLogin();
document.getElementById("page-dash").classList.toggle("hidden", p !== "dash");
document.getElementById("page-ops").classList.toggle("hidden", p !== "ops");
document.getElementById("page-settings").classList.toggle("hidden", p !== "settings");
document.getElementById("nav-dash").classList.toggle("active", p === "dash");
document.getElementById("nav-ops").classList.toggle("active", p === "ops");
location.hash = p === "ops" ? "ops-map" : "";
document.getElementById("nav-settings").classList.toggle("active", p === "settings");
if (p === "ops") location.hash = "ops-map";
else if (p === "settings") location.hash = "settings";
else location.hash = "";
if (p === "ops") loadOps();
if (p === "dash") refreshDash();
if (p === "settings") loadSettings();
}
if (location.pathname === "/ops-map" || location.hash === "#ops-map") state.page = "ops";
else if (location.pathname === "/settings" || location.hash === "#settings") state.page = "settings";
document.querySelectorAll(".nav a[data-page]").forEach(a => a.addEventListener("click", () => showPage(a.dataset.page)));
document.getElementById("nav-logout").addEventListener("click", async () => {
setToken(null);
@@ -165,15 +212,16 @@
showLogin();
});
document.getElementById("loginBtn").addEventListener("click", async () => {
const username = document.getElementById("loginUser").value.trim();
const password = document.getElementById("loginPass").value;
document.getElementById("loginErr").textContent = "";
try {
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);
showPage(state.page || "dash");
@@ -182,6 +230,65 @@
}
});
async function loadSettings() {
document.getElementById("settingsErr").textContent = "";
document.getElementById("settingsOk").textContent = "";
try {
const d = await apiFetch("/api/settings/account").then(r => {
if (!r.ok) throw new Error("settings " + r.status);
return r.json();
});
document.getElementById("setUsername").value = d.username || "admin";
state.loadedUsername = d.username || "admin";
const hint = document.getElementById("settingsAuthHint");
const disabled = !d.auth_required;
hint.textContent = disabled ? "当前鉴权已关闭(AUTH_SECRET=disabled),请在 .env 中修改。" : "";
["setUsername","setCurPass","setNewPass","setNewPass2","settingsSaveBtn"].forEach(id => {
document.getElementById(id).disabled = disabled;
});
} catch (e) {
document.getElementById("settingsErr").textContent = String(e);
}
}
document.getElementById("settingsForm").addEventListener("submit", async (e) => {
e.preventDefault();
document.getElementById("settingsErr").textContent = "";
document.getElementById("settingsOk").textContent = "";
const cur = document.getElementById("setCurPass").value;
const nu = document.getElementById("setUsername").value.trim();
const np = document.getElementById("setNewPass").value;
const np2 = document.getElementById("setNewPass2").value;
if (np && np !== np2) {
document.getElementById("settingsErr").textContent = "两次输入的新密码不一致";
return;
}
const body = { current_password: cur };
if (nu !== state.loadedUsername) body.new_username = nu;
if (np) body.new_password = np;
try {
const r = await apiFetch("/api/settings/account", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const d = await r.json();
if (!r.ok) throw new Error(d.detail || "save failed");
if (d.relogin_required) {
setToken(null);
document.getElementById("settingsOk").textContent = d.message || "已保存,请重新登录";
setTimeout(showLogin, 800);
return;
}
document.getElementById("settingsOk").textContent = d.message || "已保存";
document.getElementById("setCurPass").value = "";
document.getElementById("setNewPass").value = "";
document.getElementById("setNewPass2").value = "";
} catch (err) {
document.getElementById("settingsErr").textContent = String(err.message || err);
}
});
document.querySelectorAll("#rangeSeg button").forEach(b => b.addEventListener("click", () => {
state.range = b.dataset.range;
document.querySelectorAll("#rangeSeg button").forEach(x => x.classList.toggle("active", x === b));
+35 -4
View File
@@ -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" });
+31
View File
@@ -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>
);
}
+30 -35
View File
@@ -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>
+2
View File
@@ -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>
+2 -15
View File
@@ -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
View File
@@ -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">
+155
View File
@@ -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
View File
@@ -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);