diff --git a/manual_trading_hub/.env.example b/manual_trading_hub/.env.example index cfe90a4..4e7519d 100644 --- a/manual_trading_hub/.env.example +++ b/manual_trading_hub/.env.example @@ -20,6 +20,15 @@ HUB_DISABLED_IDS=1 # true=允许 RFC1918 私网访问中控页面;false=仅 127.0.0.1 HUB_TRUST_LAN=true +# 中控 Web 登录密码(非空即启用;反代到公网时务必设置) +# HUB_PASSWORD=your-strong-password-here +# 会话签名密钥(建议单独随机串;未设则回退为 HUB_PASSWORD) +# HUB_SESSION_SECRET=another-long-random-string +# HTTPS 反代时设为 true,Cookie 仅通过加密连接传输 +# HUB_COOKIE_SECURE=true +# 登录保持天数(默认 7) +# HUB_SESSION_DAYS=7 + # 浏览器打开的复盘/实例链接:把 127.0.0.1 换成 Ubuntu 内网 IP 或域名(中控本机调 API 仍用 127.0.0.1) # 例:用手机/另一台电脑访问中控时必填,否则「交易复盘」会指向你自己电脑的 localhost # HUB_PUBLIC_ORIGIN=http://192.168.1.100 diff --git a/manual_trading_hub/hub.py b/manual_trading_hub/hub.py index 3a0b9d8..82e33ff 100644 --- a/manual_trading_hub/hub.py +++ b/manual_trading_hub/hub.py @@ -20,6 +20,16 @@ from settings_store import ( load_settings, save_settings, ) +from hub_web_auth import ( + SESSION_COOKIE, + SESSION_MAX_AGE_SEC, + cookie_secure, + create_session_token, + is_public_path, + password_required, + validate_session_token, + verify_password, +) from url_public import browser_url, default_review_url, public_origin HUB_HOST = os.getenv("HUB_HOST", "0.0.0.0") @@ -99,6 +109,24 @@ async def local_only(request: Request, call_next): return await call_next(request) +@app.middleware("http") +async def hub_password_gate(request: Request, call_next): + if not password_required(): + return await call_next(request) + path = request.url.path + if is_public_path(path, request.method): + return await call_next(request) + token = request.cookies.get(SESSION_COOKIE) + if validate_session_token(token): + return await call_next(request) + if path.startswith("/api/"): + return JSONResponse({"detail": "未登录", "login_required": True}, status_code=401) + from fastapi.responses import RedirectResponse + + nxt = path if path.startswith("/") else "/monitor" + return RedirectResponse(f"/login?next={nxt}", status_code=302) + + def _shell_page(): index = STATIC_DIR / "index.html" if not index.is_file(): @@ -106,6 +134,56 @@ def _shell_page(): return FileResponse(index) +def _login_page(): + login = STATIC_DIR / "login.html" + if not login.is_file(): + return JSONResponse({"detail": "missing static/login.html"}, status_code=500) + return FileResponse(login) + + +class LoginBody(BaseModel): + password: str = "" + + +@app.get("/api/auth/status") +def api_auth_status(request: Request): + required = password_required() + logged_in = not required or validate_session_token(request.cookies.get(SESSION_COOKIE)) + return {"required": required, "logged_in": logged_in} + + +@app.post("/api/auth/login") +def api_auth_login(body: LoginBody): + if not password_required(): + return {"ok": True, "auth_disabled": True} + if not verify_password(body.password): + raise HTTPException(status_code=401, detail="密码错误") + token = create_session_token() + resp = JSONResponse({"ok": True}) + resp.set_cookie( + SESSION_COOKIE, + token, + httponly=True, + samesite="lax", + path="/", + max_age=SESSION_MAX_AGE_SEC, + secure=cookie_secure(), + ) + return resp + + +@app.post("/api/auth/logout") +def api_auth_logout(): + resp = JSONResponse({"ok": True}) + resp.delete_cookie(SESSION_COOKIE, path="/") + return resp + + +@app.get("/login") +def login_page(): + return _login_page() + + @app.get("/") def root_redirect(): from fastapi.responses import RedirectResponse @@ -155,6 +233,7 @@ def api_settings_meta(): if not po else "复盘/展示链接已替换为对外地址" ), + "password_required": password_required(), } diff --git a/manual_trading_hub/hub_web_auth.py b/manual_trading_hub/hub_web_auth.py new file mode 100644 index 0000000..3721582 --- /dev/null +++ b/manual_trading_hub/hub_web_auth.py @@ -0,0 +1,78 @@ +"""中控 Web 登录:HUB_PASSWORD 非空时启用会话 Cookie。""" + +from __future__ import annotations + +import base64 +import hashlib +import hmac +import json +import os +import time +from secrets import compare_digest + +SESSION_COOKIE = "hub_sess" +SESSION_MAX_AGE_SEC = max(3600, int(os.getenv("HUB_SESSION_DAYS", "7")) * 86400) + + +def password_required() -> bool: + return bool((os.getenv("HUB_PASSWORD") or "").strip()) + + +def verify_password(password: str) -> bool: + expected = (os.getenv("HUB_PASSWORD") or "").strip() + if not expected: + return True + return compare_digest(expected, (password or "").strip()) + + +def _secret() -> bytes: + raw = (os.getenv("HUB_SESSION_SECRET") or os.getenv("HUB_PASSWORD") or "").strip() + if not raw: + return b"hub-dev-insecure" + return raw.encode("utf-8") + + +def _b64url_encode(data: bytes) -> str: + return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=") + + +def _b64url_decode(text: str) -> bytes: + pad = "=" * (-len(text) % 4) + return base64.urlsafe_b64decode(text + pad) + + +def create_session_token() -> str: + payload = {"exp": int(time.time()) + SESSION_MAX_AGE_SEC, "v": 1} + body = _b64url_encode(json.dumps(payload, separators=(",", ":")).encode("utf-8")) + sig = hmac.new(_secret(), body.encode("ascii"), hashlib.sha256).hexdigest() + return f"{body}.{sig}" + + +def validate_session_token(token: str | None) -> bool: + if not token or "." not in token: + return False + body, sig = token.rsplit(".", 1) + expected = hmac.new(_secret(), body.encode("ascii"), hashlib.sha256).hexdigest() + if not compare_digest(expected, sig): + return False + try: + payload = json.loads(_b64url_decode(body)) + except Exception: + return False + exp = int(payload.get("exp") or 0) + return exp > int(time.time()) + + +def cookie_secure() -> bool: + return (os.getenv("HUB_COOKIE_SECURE") or "").strip().lower() in ("1", "true", "yes", "on") + + +def is_public_path(path: str, method: str) -> bool: + p = (path or "").split("?")[0].rstrip("/") or "/" + if p.startswith("/assets"): + return True + if p in ("/login", "/api/auth/login", "/api/auth/status"): + return True + if p == "/api/auth/logout" and method.upper() == "POST": + return True + return False diff --git a/manual_trading_hub/static/app.css b/manual_trading_hub/static/app.css index a42d549..c0e2605 100644 --- a/manual_trading_hub/static/app.css +++ b/manual_trading_hub/static/app.css @@ -1,21 +1,23 @@ :root { - --bg: #0c0e12; - --bg-elevated: #12161d; - --panel: #181d26; - --panel-hover: #1e2430; - --text: #e6edf3; - --muted: #8b949e; - --border: #30363d; - --border-soft: #21262d; - --green: #3fb950; - --red: #f85149; - --accent: #539bf5; - --accent-dim: #1f3a5f; + --bg: #050810; + --bg-elevated: #0a1018; + --panel: rgba(12, 20, 32, 0.82); + --panel-hover: rgba(18, 28, 44, 0.9); + --text: #e8f4ff; + --muted: #6b8aa8; + --border: rgba(0, 212, 255, 0.22); + --border-soft: rgba(0, 212, 255, 0.1); + --green: #00ff9d; + --red: #ff4d6d; + --accent: #00d4ff; + --accent-2: #7b61ff; + --accent-dim: rgba(0, 212, 255, 0.12); + --glow: 0 0 24px rgba(0, 212, 255, 0.15); --radius: 10px; - --shadow: 0 4px 24px rgba(0, 0, 0, 0.35); - --font: "Segoe UI", ui-sans-serif, system-ui, -apple-system, sans-serif; - --mono: ui-monospace, "Cascadia Mono", Consolas, monospace; - /* 内容区最大宽度:比 1080p 下常见的 1280 略宽,带鱼屏两侧留白不拉伸 */ + --shadow: 0 8px 32px rgba(0, 0, 0, 0.45); + --font: "JetBrains Mono", ui-monospace, Consolas, monospace; + --display: "Orbitron", var(--font); + --mono: var(--font); --layout-max: 1520px; } @@ -28,8 +30,8 @@ body { background: var(--bg); color: var(--text); margin: 0; - font-size: 14px; - line-height: 1.5; + font-size: 13px; + line-height: 1.55; min-height: 100vh; } @@ -39,10 +41,41 @@ a { } a:hover { text-decoration: underline; + text-shadow: 0 0 12px rgba(0, 212, 255, 0.4); +} + +.app-bg, +.login-bg { + position: fixed; + inset: 0; + z-index: 0; + pointer-events: none; + background: + linear-gradient(rgba(0, 212, 255, 0.03) 1px, transparent 1px), + linear-gradient(90deg, rgba(0, 212, 255, 0.03) 1px, transparent 1px), + radial-gradient(ellipse 80% 50% at 50% -20%, rgba(0, 212, 255, 0.12), transparent), + radial-gradient(ellipse 60% 40% at 100% 100%, rgba(123, 97, 255, 0.08), transparent); + background-size: 48px 48px, 48px 48px, auto, auto; +} + +.app-bg::after, +.login-bg::after { + content: ""; + position: absolute; + inset: 0; + background: repeating-linear-gradient( + 0deg, + transparent, + transparent 2px, + rgba(0, 0, 0, 0.03) 2px, + rgba(0, 0, 0, 0.03) 4px + ); + opacity: 0.4; } -/* —— 顶栏 —— */ .app-shell { + position: relative; + z-index: 1; width: 100%; max-width: var(--layout-max); margin-left: auto; @@ -55,41 +88,97 @@ a:hover { align-items: center; justify-content: space-between; gap: 16px; - padding: 16px 0; + padding: 18px 0; border-bottom: 1px solid var(--border-soft); margin-bottom: 8px; flex-wrap: wrap; } .brand { + display: flex; + align-items: center; + gap: 12px; +} + +.brand-mark { + width: 12px; + height: 12px; + border-radius: 50%; + background: var(--accent); + box-shadow: 0 0 12px var(--accent), 0 0 24px rgba(0, 212, 255, 0.5); + animation: pulse-dot 2s ease-in-out infinite; +} + +@keyframes pulse-dot { + 0%, + 100% { + opacity: 1; + transform: scale(1); + } + 50% { + opacity: 0.7; + transform: scale(0.92); + } +} + +.brand-title { + font-family: var(--display); font-size: 15px; font-weight: 600; - letter-spacing: 0.02em; + letter-spacing: 0.08em; color: var(--text); } -.brand span { + +.brand-sub { + font-size: 10px; color: var(--muted); - font-weight: 400; - margin-left: 8px; + letter-spacing: 0.14em; + margin-top: 2px; +} + +.header-right { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} + +.sys-pill { + font-size: 10px; + letter-spacing: 0.12em; + padding: 5px 10px; + border-radius: 999px; + border: 1px solid var(--border); + color: var(--accent); + background: var(--accent-dim); + font-family: var(--display); +} + +.sys-pill.warn { + color: var(--red); + border-color: rgba(255, 77, 109, 0.4); + background: rgba(255, 77, 109, 0.1); } .top-nav { display: flex; - gap: 6px; - background: var(--bg-elevated); + gap: 4px; + background: rgba(0, 0, 0, 0.35); padding: 4px; border-radius: var(--radius); border: 1px solid var(--border-soft); + backdrop-filter: blur(8px); } .top-nav a { - padding: 8px 18px; + padding: 8px 16px; border-radius: 7px; text-decoration: none; color: var(--muted); - font-size: 13px; + font-size: 12px; font-weight: 500; - transition: background 0.15s, color 0.15s; + letter-spacing: 0.04em; + transition: background 0.15s, color 0.15s, box-shadow 0.15s; } .top-nav a:hover { @@ -99,45 +188,72 @@ a:hover { } .top-nav a.active { - background: var(--panel); - color: var(--text); - box-shadow: var(--shadow); + background: linear-gradient(135deg, rgba(0, 212, 255, 0.2), rgba(123, 97, 255, 0.15)); + color: var(--accent); border: 1px solid var(--border); + box-shadow: var(--glow); +} + +button.ghost { + background: transparent; + border: 1px solid var(--border-soft); + color: var(--muted); + font-size: 11px; + padding: 7px 12px; +} + +button.ghost:hover:not(:disabled) { + color: var(--text); + border-color: var(--border); } -/* —— 页面 —— */ .page.hidden { display: none; } .page-head { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 16px; - margin: 20px 0 16px; - flex-wrap: wrap; + margin: 24px 0 16px; } .page-head h1 { - margin: 0; - font-size: 22px; + margin: 0 0 6px; + font-family: var(--display); + font-size: 20px; font-weight: 600; - letter-spacing: -0.02em; + letter-spacing: 0.06em; + display: flex; + align-items: center; + gap: 10px; +} + +.head-tag { + font-size: 11px; + padding: 3px 8px; + border-radius: 4px; + background: var(--accent-dim); + border: 1px solid var(--border); + color: var(--accent); +} + +.page-desc { + margin: 0; + font-size: 12px; + color: var(--muted); } .hint-box { margin-bottom: 16px; border: 1px solid var(--border-soft); border-radius: var(--radius); - background: var(--bg-elevated); + background: var(--panel); + backdrop-filter: blur(10px); overflow: hidden; } .hint-box summary { padding: 10px 14px; cursor: pointer; - font-size: 13px; + font-size: 12px; color: var(--muted); user-select: none; list-style: none; @@ -155,18 +271,19 @@ a:hover { .hint-box .hint-body { padding: 0 14px 12px; - font-size: 12px; + font-size: 11px; color: var(--muted); - line-height: 1.6; + line-height: 1.65; border-top: 1px solid var(--border-soft); } .hint-box .hint-body code { font-family: var(--mono); - font-size: 11px; - background: var(--panel); + font-size: 10px; + background: rgba(0, 212, 255, 0.08); padding: 1px 5px; border-radius: 4px; - color: #b8c4ff; + color: var(--accent); + border: 1px solid var(--border-soft); } .toolbar { @@ -179,6 +296,8 @@ a:hover { border: 1px solid var(--border); border-radius: var(--radius); margin-bottom: 16px; + backdrop-filter: blur(10px); + box-shadow: var(--glow); } .toolbar-spacer { @@ -187,45 +306,49 @@ a:hover { } .toolbar-meta { - font-size: 12px; + font-size: 11px; color: var(--muted); font-family: var(--mono); } -/* —— 按钮 —— */ button, .btn { - background: var(--bg-elevated); + background: rgba(0, 0, 0, 0.4); color: var(--text); border: 1px solid var(--border); border-radius: 8px; padding: 8px 16px; cursor: pointer; - font-size: 13px; + font-size: 12px; + font-family: var(--font); font-weight: 500; - transition: border-color 0.15s, background 0.15s; + letter-spacing: 0.03em; + transition: border-color 0.15s, background 0.15s, box-shadow 0.15s; } button:hover:not(:disabled) { border-color: var(--accent); background: var(--panel-hover); + box-shadow: 0 0 16px rgba(0, 212, 255, 0.12); } button.primary { - background: var(--accent-dim); + background: linear-gradient(135deg, rgba(0, 212, 255, 0.25), rgba(123, 97, 255, 0.2)); border-color: var(--accent); color: #fff; + text-shadow: 0 0 20px rgba(0, 212, 255, 0.5); } button.danger { - border-color: rgba(248, 81, 73, 0.5); + border-color: rgba(255, 77, 109, 0.5); color: var(--red); - background: rgba(248, 81, 73, 0.08); + background: rgba(255, 77, 109, 0.08); } button.danger:hover:not(:disabled) { - background: rgba(248, 81, 73, 0.15); + background: rgba(255, 77, 109, 0.15); border-color: var(--red); + box-shadow: 0 0 16px rgba(255, 77, 109, 0.2); } button:disabled { @@ -235,37 +358,66 @@ button:disabled { .btn-link { background: transparent; - border: none; + border: 1px solid var(--border-soft); color: var(--accent); - padding: 6px 10px; - font-size: 12px; + padding: 5px 10px; + font-size: 11px; + border-radius: 6px; } .btn-link:hover { background: var(--accent-dim); text-decoration: none; + box-shadow: var(--glow); } .chk-label { display: inline-flex; align-items: center; gap: 6px; - font-size: 13px; + font-size: 12px; color: var(--muted); cursor: pointer; } -/* —— 卡片 —— */ .card { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; - box-shadow: var(--shadow); - transition: border-color 0.2s; + backdrop-filter: blur(12px); + transition: border-color 0.2s, box-shadow 0.2s; + position: relative; +} + +.card::before { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 2px; + background: linear-gradient(90deg, transparent, var(--accent), transparent); + opacity: 0.5; +} + +.card.card-online { + border-color: rgba(0, 255, 157, 0.35); +} +.card.card-online::before { + background: linear-gradient(90deg, transparent, var(--green), transparent); + opacity: 0.8; +} + +.card.card-offline { + border-color: rgba(255, 77, 109, 0.3); +} +.card.card-offline::before { + background: linear-gradient(90deg, transparent, var(--red), transparent); } .card:hover { - border-color: #3d444d; + border-color: rgba(0, 212, 255, 0.45); + box-shadow: var(--glow); } .card-head { @@ -275,17 +427,39 @@ button:disabled { justify-content: space-between; align-items: flex-start; gap: 12px; - background: linear-gradient(180deg, rgba(255, 255, 255, 0.03) 0%, transparent 100%); +} + +.card-title-row { + display: flex; + align-items: center; + gap: 8px; +} + +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} +.status-dot.ok { + background: var(--green); + box-shadow: 0 0 8px var(--green); +} +.status-dot.bad { + background: var(--red); + box-shadow: 0 0 8px var(--red); } .card-title { - font-size: 15px; + font-family: var(--display); + font-size: 13px; font-weight: 600; + letter-spacing: 0.05em; margin: 0 0 4px; } .card-sub { - font-size: 11px; + font-size: 10px; color: var(--muted); font-family: var(--mono); word-break: break-all; @@ -302,7 +476,6 @@ button:disabled { padding: 14px 16px; } -/* 固定内容宽度内三列,卡片不被拉宽 */ .grid-monitor { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); @@ -321,7 +494,6 @@ button:disabled { } } -/* 监控统计 */ .stat-row { display: grid; grid-template-columns: 1fr 1fr; @@ -330,32 +502,33 @@ button:disabled { } .stat-box { - background: var(--bg-elevated); + background: rgba(0, 0, 0, 0.35); border: 1px solid var(--border-soft); border-radius: 8px; padding: 10px 12px; } .stat-label { - font-size: 11px; + font-size: 10px; color: var(--muted); text-transform: uppercase; - letter-spacing: 0.04em; + letter-spacing: 0.08em; margin-bottom: 4px; } .stat-value { - font-size: 18px; + font-size: 17px; font-weight: 600; font-variant-numeric: tabular-nums; + color: var(--text); } .section-title { - font-size: 11px; + font-size: 10px; font-weight: 600; - color: #b8c4ff; + color: var(--accent); text-transform: uppercase; - letter-spacing: 0.06em; + letter-spacing: 0.1em; margin: 14px 0 8px; padding-bottom: 6px; border-bottom: 1px solid var(--border-soft); @@ -368,16 +541,16 @@ button:disabled { .data-table { width: 100%; border-collapse: collapse; - font-size: 12px; + font-size: 11px; } .data-table th { color: var(--muted); font-weight: 500; - font-size: 11px; + font-size: 10px; padding: 6px 8px; text-align: left; - border-bottom: 1px solid var(--border); + border-bottom: 1px solid var(--border-soft); } .data-table td { @@ -391,7 +564,7 @@ button:disabled { } .list-line { - font-size: 12px; + font-size: 11px; color: var(--muted); padding: 6px 0; border-bottom: 1px dashed var(--border-soft); @@ -402,41 +575,45 @@ button:disabled { } .empty-hint { - font-size: 12px; + font-size: 11px; color: var(--muted); padding: 8px 0; } .pnl-pos { color: var(--green); + text-shadow: 0 0 12px rgba(0, 255, 157, 0.3); } .pnl-neg { color: var(--red); } .err { color: var(--red); - font-size: 13px; + font-size: 12px; } .badge { - font-size: 10px; + font-size: 9px; padding: 2px 8px; border-radius: 999px; background: var(--accent-dim); - color: #8fc8ff; - border: 1px solid rgba(83, 155, 245, 0.35); + color: var(--accent); + border: 1px solid var(--border); white-space: nowrap; + letter-spacing: 0.06em; } .settings-meta-line { - font-size: 12px; + font-size: 11px; color: var(--muted); padding: 10px 14px; - background: var(--bg-elevated); + background: var(--panel); border-left: 3px solid var(--accent); border-radius: 0 var(--radius) var(--radius) 0; margin-bottom: 16px; line-height: 1.55; + border: 1px solid var(--border-soft); + border-left-width: 3px; } .field { @@ -445,10 +622,13 @@ button:disabled { gap: 5px; } -.field label { - font-size: 11px; +.field label, +.field > span { + font-size: 10px; color: var(--muted); font-weight: 500; + letter-spacing: 0.06em; + text-transform: uppercase; } .field-wide { @@ -459,12 +639,13 @@ button:disabled { .field select, .form-row input, .form-row select { - background: var(--bg); + background: rgba(0, 0, 0, 0.45); border: 1px solid var(--border); color: var(--text); border-radius: 8px; padding: 9px 11px; - font-size: 13px; + font-size: 12px; + font-family: var(--mono); width: 100%; } @@ -472,7 +653,7 @@ button:disabled { .field select:focus { outline: none; border-color: var(--accent); - box-shadow: 0 0 0 2px rgba(83, 155, 245, 0.2); + box-shadow: 0 0 0 2px rgba(0, 212, 255, 0.2), var(--glow); } .field-check { @@ -483,24 +664,18 @@ button:disabled { } .field-check label { - font-size: 13px; + font-size: 12px; color: var(--text); cursor: pointer; + text-transform: none; } -.form-actions { - grid-column: 1 / -1; - display: flex; - justify-content: flex-end; - padding-top: 4px; -} - -/* —— 系统设置 —— */ .settings-card { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; + backdrop-filter: blur(10px); } .settings-card-head { @@ -514,8 +689,9 @@ button:disabled { .settings-card-head .ex-name { flex: 1; min-width: 160px; - font-size: 15px; + font-size: 14px; font-weight: 600; + font-family: var(--display); background: transparent; border: none; border-bottom: 1px dashed var(--border); @@ -530,13 +706,12 @@ button:disabled { } .settings-grid .field input { - font-family: var(--mono); - font-size: 12px; + font-size: 11px; } .cap-chips { display: flex; - gap: 12px; + gap: 10px; flex-wrap: wrap; padding: 8px 0; } @@ -545,11 +720,11 @@ button:disabled { display: inline-flex; align-items: center; gap: 6px; - font-size: 13px; + font-size: 11px; color: var(--text); cursor: pointer; padding: 6px 12px; - background: var(--bg-elevated); + background: rgba(0, 0, 0, 0.35); border-radius: 999px; border: 1px solid var(--border-soft); } @@ -573,31 +748,100 @@ button:disabled { right: 20px; max-width: min(420px, 92vw); background: var(--panel); - border: 1px solid var(--border); + border: 1px solid var(--accent); padding: 12px 16px; border-radius: var(--radius); display: none; z-index: 50; white-space: pre-wrap; - font-size: 13px; - box-shadow: var(--shadow); + font-size: 12px; + box-shadow: var(--glow); + backdrop-filter: blur(12px); } #toast.show { display: block; } +/* —— 登录页 —— */ +body.login-page { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + padding: 24px; +} + +.login-panel { + position: relative; + z-index: 1; + width: 100%; + max-width: 400px; + padding: 28px 26px; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 12px; + backdrop-filter: blur(16px); + box-shadow: var(--shadow), var(--glow); +} + +.login-brand { + display: flex; + align-items: center; + gap: 14px; + margin-bottom: 24px; +} + +.login-title { + font-family: var(--display); + font-size: 16px; + font-weight: 600; + letter-spacing: 0.08em; +} + +.login-sub { + font-size: 10px; + color: var(--muted); + letter-spacing: 0.16em; + margin-top: 4px; +} + +.login-form .field { + margin-bottom: 16px; +} + +.login-submit { + width: 100%; + padding: 12px; +} + +.login-err { + color: var(--red); + font-size: 12px; + margin: 10px 0 0; +} + +.login-foot { + margin: 20px 0 0; + font-size: 10px; + color: var(--muted); + line-height: 1.5; +} +.login-foot code { + color: var(--accent); + font-size: 10px; +} + @media (max-width: 720px) { .app-shell { padding: 0 12px 32px; } - .grid-monitor { - grid-template-columns: 1fr; - } + .grid-monitor, .settings-grid-wrap { grid-template-columns: 1fr; } - .form-grid { - grid-template-columns: 1fr; + .header-right { + width: 100%; + justify-content: space-between; } } diff --git a/manual_trading_hub/static/app.js b/manual_trading_hub/static/app.js index f4ec628..c439b06 100644 --- a/manual_trading_hub/static/app.js +++ b/manual_trading_hub/static/app.js @@ -2,6 +2,34 @@ const toast = document.getElementById("toast"); let settingsCache = null; let monitorTimer = null; + let authState = { required: false, logged_in: true }; + + async function apiFetch(url, opts) { + const r = await fetch(url, opts); + if (r.status === 401) { + const next = encodeURIComponent(location.pathname + location.search); + location.href = "/login?next=" + next; + throw new Error("未登录"); + } + return r; + } + + async function initAuth() { + try { + const r = await fetch("/api/auth/status"); + authState = await r.json(); + const btn = document.getElementById("btn-logout"); + if (btn) btn.style.display = authState.required ? "" : "none"; + if (authState.required && !authState.logged_in) { + location.href = + "/login?next=" + encodeURIComponent(location.pathname + location.search); + return false; + } + return true; + } catch (_) { + return true; + } + } function showToast(msg, isErr) { toast.textContent = msg; @@ -63,7 +91,7 @@ } async function loadSettings() { - const r = await fetch("/api/settings"); + const r = await apiFetch("/api/settings"); settingsCache = await r.json(); return settingsCache; } @@ -75,11 +103,18 @@ async function loadMonitorBoard() { const box = document.getElementById("monitor-grid"); try { - const r = await fetch("/api/monitor/board"); + const r = await apiFetch("/api/monitor/board"); const data = await r.json(); + const rows = data.rows || []; + const online = rows.filter((x) => x.http_ok && (x.agent || {}).ok !== false).length; + const pill = document.getElementById("sys-status"); + if (pill) { + pill.textContent = rows.length ? `LINK ${online}/${rows.length}` : "NO DATA"; + pill.classList.toggle("warn", rows.length && online < rows.length); + } document.getElementById("monitor-updated").textContent = - "更新于 " + (data.updated_at || "").replace("T", " "); - const parts = (data.rows || []).map(renderMonitorCard); + "UPD " + (data.updated_at || "").replace("T", " "); + const parts = rows.map(renderMonitorCard); box.innerHTML = parts.join("") || '
实时聚合持仓、关键位与趋势计划