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