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 MI_PORT=5170
TZ=Asia/Shanghai TZ=Asia/Shanghai
AUTH_SECRET=change-me AUTH_SECRET=change-me
ADMIN_USERNAME=admin
ADMIN_PASSWORD=admin123 ADMIN_PASSWORD=admin123
ENV_FILE=/app/.env
# ---- 采集(OKX 只读;公开行情可留空 Key)---- # ---- 采集(OKX 只读;公开行情可留空 Key)----
OKX_API_KEY= OKX_API_KEY=
+18 -12
View File
@@ -1,4 +1,4 @@
"""简单 Token 鉴权(对齐策略仓:密码换 HMAC token)。""" """简单 Token 鉴权(用户名 + 密码 → HMAC token)。"""
from __future__ import annotations from __future__ import annotations
@@ -21,24 +21,35 @@ def auth_disabled() -> bool:
return (s.auth_secret or "").strip().lower() in ("", "disabled", "off", "none") 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( return hmac.new(
secret.encode("utf-8"), secret.encode("utf-8"),
password.encode("utf-8"), payload.encode("utf-8"),
hashlib.sha256, hashlib.sha256,
).hexdigest() ).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: def expected_token() -> str:
s = get_settings() 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() s = get_settings()
if not secrets.compare_digest(password, s.admin_password): if not verify_credentials(username, password):
return None return None
return _token_for_password(password, s.auth_secret) return _token_for_credentials(username, password, s.auth_secret)
def _extract_token( def _extract_token(
@@ -53,7 +64,6 @@ def _extract_token(
return authorization[7:].strip() return authorization[7:].strip()
if x_mi_token: if x_mi_token:
return x_mi_token.strip() return x_mi_token.strip()
# 查询参数兜底(方便内网脚本;生产建议只用 Header)
q = request.query_params.get("token") q = request.query_params.get("token")
if q: if q:
return q.strip() return q.strip()
@@ -69,10 +79,6 @@ def require_auth(
x_mi_token: Annotated[str | None, Header(alias="X-MI-Token")] = None, x_mi_token: Annotated[str | None, Header(alias="X-MI-Token")] = None,
creds: Annotated[HTTPAuthorizationCredentials | None, Depends(_bearer)] = None, creds: Annotated[HTTPAuthorizationCredentials | None, Depends(_bearer)] = None,
) -> None: ) -> None:
"""
AUTH_SECRET=disabled 时跳过。
否则需要 Bearer / X-MI-Token / Cookie / ?token=。
"""
if auth_disabled(): if auth_disabled():
return return
token = _extract_token(request, authorization, x_mi_token, creds) 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.responses import FileResponse, HTMLResponse, Response
from fastapi.staticfiles import StaticFiles 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( app = FastAPI(
title="比特骆驼行情采集分析", title="比特骆驼行情采集分析",
@@ -31,6 +31,7 @@ app.include_router(meta.router, prefix="/api")
app.include_router(samples.router, prefix="/api") app.include_router(samples.router, prefix="/api")
app.include_router(stats.router, prefix="/api") app.include_router(stats.router, prefix="/api")
app.include_router(notify.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" _WEB_DIST = Path(__file__).resolve().parents[2] / "web" / "dist"
@@ -64,6 +65,11 @@ def ops_map_page() -> Response:
return _index_response() return _index_response()
@app.get("/settings")
def settings_page() -> Response:
return _index_response()
if _WEB_DIST.is_dir(): if _WEB_DIST.is_dir():
assets = _WEB_DIST / "assets" assets = _WEB_DIST / "assets"
if assets.is_dir(): if assets.is_dir():
+17 -7
View File
@@ -2,35 +2,40 @@
from __future__ import annotations from __future__ import annotations
from fastapi import APIRouter, Response from fastapi import APIRouter, HTTPException, Response, status
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from apps.api.auth import COOKIE_NAME, auth_disabled, issue_token from apps.api.auth import COOKIE_NAME, auth_disabled, issue_token
from packages.config import get_settings
router = APIRouter(prefix="/auth", tags=["auth"]) router = APIRouter(prefix="/auth", tags=["auth"])
class LoginBody(BaseModel): class LoginBody(BaseModel):
username: str = Field(default="admin", min_length=1, max_length=64)
password: str = Field(min_length=1, max_length=256) password: str = Field(min_length=1, max_length=256)
@router.get("/status") @router.get("/status")
def auth_status() -> dict: def auth_status() -> dict:
s = get_settings()
return { return {
"auth_required": not auth_disabled(), "auth_required": not auth_disabled(),
"product": "比特骆驼行情采集分析", "product": "比特骆驼行情采集分析",
"username": s.admin_username,
} }
@router.post("/login") @router.post("/login")
def login(body: LoginBody, response: Response) -> dict: def login(body: LoginBody, response: Response) -> dict:
if auth_disabled(): if auth_disabled():
return {"ok": True, "auth_required": False, "token": None} return {"ok": True, "auth_required": False, "token": None, "username": body.username}
token = issue_token(body.password) token = issue_token(body.username.strip(), body.password)
if not token: if not token:
from fastapi import HTTPException, status raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid password") detail="invalid username or password",
)
response.set_cookie( response.set_cookie(
key=COOKIE_NAME, key=COOKIE_NAME,
value=token, value=token,
@@ -39,7 +44,12 @@ def login(body: LoginBody, response: Response) -> dict:
max_age=7 * 24 * 3600, max_age=7 * 24 * 3600,
path="/", path="/",
) )
return {"ok": True, "auth_required": True, "token": token} return {
"ok": True,
"auth_required": True,
"token": token,
"username": body.username.strip(),
}
@router.post("/logout") @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}" "SAMPLE_INTERVAL_SEC" "采样间隔秒" "30"
ensure_env_key "${envf}" "MIN_OPTION_LEVERAGE" "杠杆达标线" "100" ensure_env_key "${envf}" "MIN_OPTION_LEVERAGE" "杠杆达标线" "100"
ensure_env_key "${envf}" "AUTH_SECRET" "鉴权密钥(disabled 关闭)" "change-me" 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}" "ADMIN_PASSWORD" "管理员密码" "admin123"
ensure_env_key "${envf}" "OKX_API_KEY" "OKX API Key(可空)" "" ensure_env_key "${envf}" "OKX_API_KEY" "OKX API Key(可空)" ""
ensure_env_key "${envf}" "OKX_API_SECRET" "OKX API Secret(可空)" "" ensure_env_key "${envf}" "OKX_API_SECRET" "OKX API Secret(可空)" ""
+2
View File
@@ -23,10 +23,12 @@ services:
environment: environment:
TZ: Asia/Shanghai TZ: Asia/Shanghai
MI_DB_PATH: /app/data/market_intel.db MI_DB_PATH: /app/data/market_intel.db
ENV_FILE: /app/.env
ports: ports:
- "${MI_PORT:-5170}:5170" - "${MI_PORT:-5170}:5170"
volumes: volumes:
- mi_data:/app/data - mi_data:/app/data
- ./.env:/app/.env
depends_on: depends_on:
- collector - collector
restart: unless-stopped 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") mi_port: int = Field(default=5170, alias="MI_PORT")
tz: str = Field(default="Asia/Shanghai", alias="TZ") tz: str = Field(default="Asia/Shanghai", alias="TZ")
auth_secret: str = Field(default="change-me", alias="AUTH_SECRET") 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") admin_password: str = Field(default="admin123", alias="ADMIN_PASSWORD")
env_file: str = Field(default=".env", alias="ENV_FILE")
# OKX # OKX
okx_api_key: str = Field(default="", alias="OKX_API_KEY") okx_api_key: str = Field(default="", alias="OKX_API_KEY")
@@ -56,6 +58,15 @@ class Settings(BaseSettings):
def db_path(self) -> Path: def db_path(self) -> Path:
return Path(self.mi_db_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 @lru_cache
def get_settings() -> Settings: def get_settings() -> Settings:
+8 -3
View File
@@ -24,12 +24,17 @@ def _restore(old: dict):
def test_issue_token_ok(): def test_issue_token_ok():
from apps.api.auth import expected_token, issue_token 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: try:
tok = issue_token("pass123") tok = issue_token("admin", "pass123")
assert tok is not None assert tok is not None
assert tok == expected_token() 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: finally:
_restore(old) _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; } .chart-svg { width: 100%; height: auto; display: block; }
.hidden { display: none; } .hidden { display: none; }
pre { background: var(--panel); border: 1px solid #243041; border-radius: 10px; padding: 1rem; overflow: auto; font-size: 0.78rem; color: #b7c5d4; } 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> </style>
</head> </head>
<body> <body>
@@ -49,15 +58,23 @@
<div class="nav"> <div class="nav">
<a id="nav-dash" class="active" data-page="dash">总览</a> <a id="nav-dash" class="active" data-page="dash">总览</a>
<a id="nav-ops" data-page="ops">作战地图</a> <a id="nav-ops" data-page="ops">作战地图</a>
<a id="nav-settings" data-page="settings">系统设置</a>
<a id="nav-logout" class="hidden">退出</a> <a id="nav-logout" class="hidden">退出</a>
</div> </div>
<main> <main>
<section id="page-login" class="hidden"> <section id="page-login" class="hidden">
<div class="tile" style="max-width:360px"> <div class="center-page">
<div class="label">管理员密码</div> <div class="tile settings-card">
<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" /> <div class="settings-title">登录</div>
<p id="loginErr" style="color:var(--bad);font-size:0.85rem"></p> <label class="field"><span class="label">用户名</span>
<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> <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> </div>
</section> </section>
<section id="page-dash"> <section id="page-dash">
@@ -105,10 +122,33 @@
</div> </div>
<p id="moveHint" style="color:var(--muted);font-size:0.85rem;margin-top:0.75rem"></p> <p id="moveHint" style="color:var(--muted);font-size:0.85rem;margin-top:0.75rem"></p>
</section> </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> </main>
<script> <script>
const TOKEN_KEY = "mi_token"; 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 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 setToken(t) { try { if (t) localStorage.setItem(TOKEN_KEY, t); else localStorage.removeItem(TOKEN_KEY); } catch {} }
function authHeaders() { function authHeaders() {
@@ -135,6 +175,7 @@
document.getElementById("page-login").classList.remove("hidden"); document.getElementById("page-login").classList.remove("hidden");
document.getElementById("page-dash").classList.add("hidden"); document.getElementById("page-dash").classList.add("hidden");
document.getElementById("page-ops").classList.add("hidden"); document.getElementById("page-ops").classList.add("hidden");
document.getElementById("page-settings").classList.add("hidden");
document.getElementById("nav-logout").classList.add("hidden"); document.getElementById("nav-logout").classList.add("hidden");
} }
function hideLogin() { function hideLogin() {
@@ -151,13 +192,19 @@
hideLogin(); hideLogin();
document.getElementById("page-dash").classList.toggle("hidden", p !== "dash"); document.getElementById("page-dash").classList.toggle("hidden", p !== "dash");
document.getElementById("page-ops").classList.toggle("hidden", p !== "ops"); 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-dash").classList.toggle("active", p === "dash");
document.getElementById("nav-ops").classList.toggle("active", p === "ops"); 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 === "ops") loadOps();
if (p === "dash") refreshDash(); if (p === "dash") refreshDash();
if (p === "settings") loadSettings();
} }
if (location.pathname === "/ops-map" || location.hash === "#ops-map") state.page = "ops"; 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.querySelectorAll(".nav a[data-page]").forEach(a => a.addEventListener("click", () => showPage(a.dataset.page)));
document.getElementById("nav-logout").addEventListener("click", async () => { document.getElementById("nav-logout").addEventListener("click", async () => {
setToken(null); setToken(null);
@@ -165,15 +212,16 @@
showLogin(); showLogin();
}); });
document.getElementById("loginBtn").addEventListener("click", async () => { document.getElementById("loginBtn").addEventListener("click", async () => {
const username = document.getElementById("loginUser").value.trim();
const password = document.getElementById("loginPass").value; const password = document.getElementById("loginPass").value;
document.getElementById("loginErr").textContent = ""; document.getElementById("loginErr").textContent = "";
try { try {
const r = await fetch("/api/auth/login", { const r = await fetch("/api/auth/login", {
method: "POST", credentials: "include", method: "POST", credentials: "include",
headers: { "Content-Type": "application/json" }, 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(); const body = await r.json();
if (body.token) setToken(body.token); if (body.token) setToken(body.token);
showPage(state.page || "dash"); 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", () => { document.querySelectorAll("#rangeSeg button").forEach(b => b.addEventListener("click", () => {
state.range = b.dataset.range; state.range = b.dataset.range;
document.querySelectorAll("#rangeSeg button").forEach(x => x.classList.toggle("active", x === b)); 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; return r;
} }
export type AuthStatus = { auth_required: boolean }; export type AuthStatus = { auth_required: boolean; username?: string };
export async function fetchAuthStatus(): Promise<AuthStatus> { export async function fetchAuthStatus(): Promise<AuthStatus> {
const r = await fetch("/api/auth/status", { credentials: "include" }); const r = await fetch("/api/auth/status", { credentials: "include" });
@@ -42,19 +42,50 @@ export async function fetchAuthStatus(): Promise<AuthStatus> {
return r.json(); 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", { const r = await fetch("/api/auth/login", {
method: "POST", method: "POST",
credentials: "include", credentials: "include",
headers: { "Content-Type": "application/json" }, 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(); const body = await r.json();
if (body.token) setToken(body.token); if (body.token) setToken(body.token);
return body; 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> { export async function logout(): Promise<void> {
setToken(null); setToken(null);
await fetch("/api/auth/logout", { method: "POST", credentials: "include" }); 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>
);
}
+25 -30
View File
@@ -6,6 +6,7 @@ type Props = {
}; };
export default function LoginGate({ onOk }: Props) { export default function LoginGate({ onOk }: Props) {
const [username, setUsername] = useState("admin");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [err, setErr] = useState<string | null>(null); const [err, setErr] = useState<string | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@@ -15,7 +16,7 @@ export default function LoginGate({ onOk }: Props) {
setBusy(true); setBusy(true);
setErr(null); setErr(null);
try { try {
await login(password); await login(username.trim(), password);
onOk(); onOk();
} catch (ex) { } catch (ex) {
setErr(String(ex)); setErr(String(ex));
@@ -25,40 +26,34 @@ export default function LoginGate({ onOk }: Props) {
}; };
return ( return (
<div className="layout"> <div className="center-page">
<div className="brand"></div> <form className="tile settings-card" onSubmit={submit}>
<div className="sub"></div> <div className="settings-title"></div>
<form className="tile" onSubmit={submit} style={{ maxWidth: 360 }}> <div className="sub" style={{ marginBottom: "1rem" }}>
<div className="label"></div>
</div>
<label className="field">
<span className="label"></span>
<input <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" type="password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
autoFocus autoComplete="current-password"
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>} </label>
<button {err && <p className="form-err">{err}</p>}
type="submit" <button className="btn-primary" type="submit" disabled={busy || !password || !username}>
disabled={busy || !password}
style={{
marginTop: "0.85rem",
padding: "0.5rem 1rem",
borderRadius: 6,
border: 0,
background: "#3d8fd1",
color: "#fff",
cursor: "pointer",
}}
>
{busy ? "登录中…" : "登录"} {busy ? "登录中…" : "登录"}
</button> </button>
</form> </form>
+2
View File
@@ -3,6 +3,7 @@ import ReactDOM from "react-dom/client";
import { BrowserRouter, Route, Routes } from "react-router-dom"; import { BrowserRouter, Route, Routes } from "react-router-dom";
import Dashboard from "./pages/Dashboard"; import Dashboard from "./pages/Dashboard";
import OpsMap from "./pages/OpsMap"; import OpsMap from "./pages/OpsMap";
import Settings from "./pages/Settings";
import "./styles.css"; import "./styles.css";
ReactDOM.createRoot(document.getElementById("root")!).render( ReactDOM.createRoot(document.getElementById("root")!).render(
@@ -11,6 +12,7 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
<Routes> <Routes>
<Route path="/" element={<Dashboard />} /> <Route path="/" element={<Dashboard />} />
<Route path="/ops-map" element={<OpsMap />} /> <Route path="/ops-map" element={<OpsMap />} />
<Route path="/settings" element={<Settings />} />
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>
</React.StrictMode> </React.StrictMode>
+2 -15
View File
@@ -1,13 +1,12 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { import {
fetchAuthStatus, fetchAuthStatus,
fetchHealth, fetchHealth,
fetchLatest, fetchLatest,
Health, Health,
Latest, Latest,
logout,
} from "../api/client"; } from "../api/client";
import AppNav from "../components/AppNav";
import LoginGate from "../components/LoginGate"; import LoginGate from "../components/LoginGate";
function fmt(n?: number | null, d = 1) { function fmt(n?: number | null, d = 1) {
@@ -94,19 +93,7 @@ export default function Dashboard() {
<div className="layout"> <div className="layout">
<div className="brand"></div> <div className="brand"></div>
<div className="sub"> · = ÷ · Asia/Shanghai</div> <div className="sub"> · = ÷ · Asia/Shanghai</div>
<div className="nav"> <AppNav />
<Link to="/"></Link>
<Link to="/ops-map"></Link>
<a
href="#logout"
onClick={(e) => {
e.preventDefault();
logout().finally(() => setNeedLogin(true));
}}
>
退
</a>
</div>
{err && <p style={{ color: "#e85d5d" }}>{err}</p>} {err && <p style={{ color: "#e85d5d" }}>{err}</p>}
<div className="row"> <div className="row">
<div className="tile"> <div className="tile">
+11 -29
View File
@@ -1,13 +1,12 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { import {
fetchAuthStatus, fetchAccountSettings,
fetchOpsMap, fetchOpsMap,
LeverageStats, LeverageStats,
logout,
MovePointsStats, MovePointsStats,
OpsMap, OpsMap,
} from "../api/client"; } from "../api/client";
import AppNav from "../components/AppNav";
import LeverageChart from "../components/LeverageChart"; import LeverageChart from "../components/LeverageChart";
import LoginGate from "../components/LoginGate"; import LoginGate from "../components/LoginGate";
import MovePointsChart from "../components/MovePointsChart"; import MovePointsChart from "../components/MovePointsChart";
@@ -35,22 +34,17 @@ export default function OpsMapPage() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
useEffect(() => { useEffect(() => {
fetchAuthStatus() fetchAccountSettings()
.then(async (st) => { .then(() => {
if (!st.auth_required) {
setNeedLogin(false); setNeedLogin(false);
setReady(true); setReady(true);
return;
}
try {
await fetchOpsMap({ range: "day", bucket_minutes: 60 });
setNeedLogin(false);
} catch {
setNeedLogin(true);
}
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(() => { useEffect(() => {
@@ -96,19 +90,7 @@ export default function OpsMapPage() {
<div className="layout"> <div className="layout">
<div className="brand"></div> <div className="brand"></div>
<div className="sub"> · × </div> <div className="sub"> · × </div>
<div className="nav"> <AppNav />
<Link to="/"></Link>
<Link to="/ops-map"></Link>
<a
href="#logout"
onClick={(e) => {
e.preventDefault();
logout().finally(() => setNeedLogin(true));
}}
>
退
</a>
</div>
<div className="toolbar"> <div className="toolbar">
<div className="seg"> <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; } .layout { max-width: 980px; margin: 0 auto; padding: 1.5rem; }
.brand { font-size: 1.35rem; font-weight: 700; } .brand { font-size: 1.35rem; font-weight: 700; }
.sub { color: var(--muted); margin: 0.35rem 0 1.25rem; } .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; } .row { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 1rem; }
.tile { .tile {
background: var(--panel); background: var(--panel);