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
+2
View File
@@ -5,7 +5,9 @@
MI_PORT=5170
TZ=Asia/Shanghai
AUTH_SECRET=change-me
ADMIN_USERNAME=admin
ADMIN_PASSWORD=admin123
ENV_FILE=/app/.env
# ---- 采集(OKX 只读;公开行情可留空 Key)----
OKX_API_KEY=
+18 -12
View File
@@ -1,4 +1,4 @@
"""简单 Token 鉴权(对齐策略仓:密码换 HMAC token)。"""
"""简单 Token 鉴权(用户名 + 密码 → HMAC token)。"""
from __future__ import annotations
@@ -21,24 +21,35 @@ def auth_disabled() -> bool:
return (s.auth_secret or "").strip().lower() in ("", "disabled", "off", "none")
def _token_for_password(password: str, secret: str) -> str:
def _token_for_credentials(username: str, password: str, secret: str) -> str:
payload = f"{username}:{password}"
return hmac.new(
secret.encode("utf-8"),
password.encode("utf-8"),
payload.encode("utf-8"),
hashlib.sha256,
).hexdigest()
def verify_credentials(username: str, password: str) -> bool:
s = get_settings()
u = (username or "").strip()
if not u:
return False
return secrets.compare_digest(u, s.admin_username) and secrets.compare_digest(
password, s.admin_password
)
def expected_token() -> str:
s = get_settings()
return _token_for_password(s.admin_password, s.auth_secret)
return _token_for_credentials(s.admin_username, s.admin_password, s.auth_secret)
def issue_token(password: str) -> str | None:
def issue_token(username: str, password: str) -> str | None:
s = get_settings()
if not secrets.compare_digest(password, s.admin_password):
if not verify_credentials(username, password):
return None
return _token_for_password(password, s.auth_secret)
return _token_for_credentials(username, password, s.auth_secret)
def _extract_token(
@@ -53,7 +64,6 @@ def _extract_token(
return authorization[7:].strip()
if x_mi_token:
return x_mi_token.strip()
# 查询参数兜底(方便内网脚本;生产建议只用 Header)
q = request.query_params.get("token")
if q:
return q.strip()
@@ -69,10 +79,6 @@ def require_auth(
x_mi_token: Annotated[str | None, Header(alias="X-MI-Token")] = None,
creds: Annotated[HTTPAuthorizationCredentials | None, Depends(_bearer)] = None,
) -> None:
"""
AUTH_SECRET=disabled 时跳过。
否则需要 Bearer / X-MI-Token / Cookie / ?token=。
"""
if auth_disabled():
return
token = _extract_token(request, authorization, x_mi_token, creds)
+7 -1
View File
@@ -9,7 +9,7 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, HTMLResponse, Response
from fastapi.staticfiles import StaticFiles
from apps.api.routes import auth, health, meta, notify, samples, stats
from apps.api.routes import auth, health, meta, notify, samples, settings, stats
app = FastAPI(
title="比特骆驼行情采集分析",
@@ -31,6 +31,7 @@ app.include_router(meta.router, prefix="/api")
app.include_router(samples.router, prefix="/api")
app.include_router(stats.router, prefix="/api")
app.include_router(notify.router, prefix="/api")
app.include_router(settings.router, prefix="/api")
_WEB_DIST = Path(__file__).resolve().parents[2] / "web" / "dist"
@@ -64,6 +65,11 @@ def ops_map_page() -> Response:
return _index_response()
@app.get("/settings")
def settings_page() -> Response:
return _index_response()
if _WEB_DIST.is_dir():
assets = _WEB_DIST / "assets"
if assets.is_dir():
+17 -7
View File
@@ -2,35 +2,40 @@
from __future__ import annotations
from fastapi import APIRouter, Response
from fastapi import APIRouter, HTTPException, Response, status
from pydantic import BaseModel, Field
from apps.api.auth import COOKIE_NAME, auth_disabled, issue_token
from packages.config import get_settings
router = APIRouter(prefix="/auth", tags=["auth"])
class LoginBody(BaseModel):
username: str = Field(default="admin", min_length=1, max_length=64)
password: str = Field(min_length=1, max_length=256)
@router.get("/status")
def auth_status() -> dict:
s = get_settings()
return {
"auth_required": not auth_disabled(),
"product": "比特骆驼行情采集分析",
"username": s.admin_username,
}
@router.post("/login")
def login(body: LoginBody, response: Response) -> dict:
if auth_disabled():
return {"ok": True, "auth_required": False, "token": None}
token = issue_token(body.password)
return {"ok": True, "auth_required": False, "token": None, "username": body.username}
token = issue_token(body.username.strip(), body.password)
if not token:
from fastapi import HTTPException, status
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid password")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="invalid username or password",
)
response.set_cookie(
key=COOKIE_NAME,
value=token,
@@ -39,7 +44,12 @@ def login(body: LoginBody, response: Response) -> dict:
max_age=7 * 24 * 3600,
path="/",
)
return {"ok": True, "auth_required": True, "token": token}
return {
"ok": True,
"auth_required": True,
"token": token,
"username": body.username.strip(),
}
@router.post("/logout")
+83
View File
@@ -0,0 +1,83 @@
"""系统设置:管理员账号。"""
from __future__ import annotations
import re
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from apps.api.auth import auth_disabled, require_auth, verify_credentials
from packages.config import get_settings, reload_settings
from packages.config.env_file import update_env_file
router = APIRouter(
prefix="/settings",
tags=["settings"],
dependencies=[Depends(require_auth)],
)
_USERNAME_RE = re.compile(r"^[a-zA-Z0-9_\-]{2,32}$")
class UpdateAccountBody(BaseModel):
current_password: str = Field(min_length=1, max_length=256)
new_username: str | None = Field(default=None, max_length=32)
new_password: str | None = Field(default=None, max_length=256)
@router.get("/account")
def get_account() -> dict:
s = get_settings()
return {
"username": s.admin_username,
"auth_required": not auth_disabled(),
"env_file": str(s.env_file_path),
}
@router.put("/account")
def update_account(body: UpdateAccountBody) -> dict:
s = get_settings()
if auth_disabled():
raise HTTPException(status_code=400, detail="auth disabled; edit .env manually")
if not verify_credentials(s.admin_username, body.current_password):
raise HTTPException(status_code=401, detail="current password incorrect")
if body.new_password is not None and len(body.new_password) < 6:
raise HTTPException(status_code=400, detail="password must be at least 6 characters")
new_user = (body.new_username or s.admin_username).strip()
new_pass = body.new_password or s.admin_password
if body.new_username is not None and not _USERNAME_RE.fullmatch(new_user):
raise HTTPException(
status_code=400,
detail="username: 2-32 chars, letters/digits/_/- only",
)
if new_user == s.admin_username and new_pass == s.admin_password:
return {"ok": True, "changed": False, "message": "no changes", "username": new_user}
updates = {
"ADMIN_USERNAME": new_user,
"ADMIN_PASSWORD": new_pass,
}
path = s.env_file_path
try:
update_env_file(path, updates)
except OSError as e:
raise HTTPException(
status_code=500,
detail=f"failed to write {path}: {e}",
) from e
reload_settings()
return {
"ok": True,
"changed": True,
"username": new_user,
"relogin_required": True,
"message": "账号已更新,请重新登录",
}
+1
View File
@@ -221,6 +221,7 @@ ensure_dotenv() {
ensure_env_key "${envf}" "SAMPLE_INTERVAL_SEC" "采样间隔秒" "30"
ensure_env_key "${envf}" "MIN_OPTION_LEVERAGE" "杠杆达标线" "100"
ensure_env_key "${envf}" "AUTH_SECRET" "鉴权密钥(disabled 关闭)" "change-me"
ensure_env_key "${envf}" "ADMIN_USERNAME" "管理员用户名" "admin"
ensure_env_key "${envf}" "ADMIN_PASSWORD" "管理员密码" "admin123"
ensure_env_key "${envf}" "OKX_API_KEY" "OKX API Key(可空)" ""
ensure_env_key "${envf}" "OKX_API_SECRET" "OKX API Secret(可空)" ""
+2
View File
@@ -23,10 +23,12 @@ services:
environment:
TZ: Asia/Shanghai
MI_DB_PATH: /app/data/market_intel.db
ENV_FILE: /app/.env
ports:
- "${MI_PORT:-5170}:5170"
volumes:
- mi_data:/app/data
- ./.env:/app/.env
depends_on:
- collector
restart: unless-stopped
+60
View File
@@ -0,0 +1,60 @@
"""读写 .env(保留其它键;不记录密钥到日志)。"""
from __future__ import annotations
import os
import re
from pathlib import Path
_KEY_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
def read_env_value(path: Path, key: str) -> str | None:
if not path.is_file():
return None
for line in path.read_text(encoding="utf-8").splitlines():
s = line.strip()
if not s or s.startswith("#") or "=" not in s:
continue
k, v = s.split("=", 1)
if k.strip() == key:
return v.strip()
return None
def update_env_file(path: Path, updates: dict[str, str]) -> None:
"""更新或追加键值;已有键覆盖。"""
for key in updates:
if not _KEY_RE.fullmatch(key):
raise ValueError(f"invalid env key: {key!r}")
lines: list[str] = []
if path.is_file():
lines = path.read_text(encoding="utf-8").splitlines()
seen: set[str] = set()
out: list[str] = []
for line in lines:
raw = line
s = line.strip()
if s and not s.startswith("#") and "=" in s:
k, _ = s.split("=", 1)
key = k.strip()
if key in updates:
out.append(f"{key}={updates[key]}")
seen.add(key)
continue
out.append(raw)
for key, val in updates.items():
if key not in seen:
out.append(f"{key}={val}")
path.parent.mkdir(parents=True, exist_ok=True)
text = "\n".join(out)
if text and not text.endswith("\n"):
text += "\n"
path.write_text(text, encoding="utf-8")
for key, val in updates.items():
os.environ[key] = val
+11
View File
@@ -20,7 +20,9 @@ class Settings(BaseSettings):
mi_port: int = Field(default=5170, alias="MI_PORT")
tz: str = Field(default="Asia/Shanghai", alias="TZ")
auth_secret: str = Field(default="change-me", alias="AUTH_SECRET")
admin_username: str = Field(default="admin", alias="ADMIN_USERNAME")
admin_password: str = Field(default="admin123", alias="ADMIN_PASSWORD")
env_file: str = Field(default=".env", alias="ENV_FILE")
# OKX
okx_api_key: str = Field(default="", alias="OKX_API_KEY")
@@ -56,6 +58,15 @@ class Settings(BaseSettings):
def db_path(self) -> Path:
return Path(self.mi_db_path)
@property
def env_file_path(self) -> Path:
return Path(self.env_file)
def reload_settings() -> Settings:
get_settings.cache_clear()
return get_settings()
@lru_cache
def get_settings() -> Settings:
+8 -3
View File
@@ -24,12 +24,17 @@ def _restore(old: dict):
def test_issue_token_ok():
from apps.api.auth import expected_token, issue_token
old = _with_env(AUTH_SECRET="unit-secret", ADMIN_PASSWORD="pass123")
old = _with_env(
AUTH_SECRET="unit-secret",
ADMIN_USERNAME="admin",
ADMIN_PASSWORD="pass123",
)
try:
tok = issue_token("pass123")
tok = issue_token("admin", "pass123")
assert tok is not None
assert tok == expected_token()
assert issue_token("wrong") is None
assert issue_token("admin", "wrong") is None
assert issue_token("wrong", "pass123") is None
finally:
_restore(old)
+15
View File
@@ -0,0 +1,15 @@
from pathlib import Path
from packages.config.env_file import read_env_value, update_env_file
def test_update_env_file(tmp_path: Path):
envf = tmp_path / ".env"
envf.write_text("FOO=bar\n# comment\nBAZ=old\n", encoding="utf-8")
update_env_file(envf, {"BAZ": "new", "ADMIN_USERNAME": "alice"})
text = envf.read_text(encoding="utf-8")
assert "FOO=bar" in text
assert "BAZ=new" in text
assert "ADMIN_USERNAME=alice" in text
assert read_env_value(envf, "BAZ") == "new"
assert read_env_value(envf, "ADMIN_USERNAME") == "alice"
+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);