From 69cd7af8d9800f00fa3468544bde89f852b635df Mon Sep 17 00:00:00 2001 From: shy3130 Date: Sun, 28 Jun 2026 20:02:00 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E8=AE=BF=E9=97=AE?= =?UTF-8?q?=E5=AF=86=E7=A0=81=E8=AE=A4=E8=AF=81(=E9=A6=96=E6=AC=A1?= =?UTF-8?q?=E6=9C=AC=E6=9C=BA=E8=AE=BE=E5=AF=86=E7=A0=81=20+=20=E5=85=AC?= =?UTF-8?q?=E7=BD=91=E9=98=B2=E6=8A=A4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端: PBKDF2 密码哈希 + 会话 token(auth.json 存储, 零数据库) - 中间件: 未设密码时本机可设/公网拒绝(防裸奔+防抢占), 已设密码后 401 拦截 - 5个 API: status/setup/login/logout/change-password, 含本机限制+登录限流 - 前端: 登录页/设密码页 + 全局 401/403 拦截跳转 - 安全: HttpOnly cookie + 5次失败锁5分钟 + 改密码清会话 --- backend/app/api/auth.py | 213 +++++++++++++++++++++++++++++++++++ backend/app/main.py | 53 ++++++++- backend/app/services/auth.py | 173 ++++++++++++++++++++++++++++ backend/uv.lock | 2 +- frontend/src/lib/api.ts | 24 +++- frontend/src/main.tsx | 30 ++++- frontend/src/pages/Auth.tsx | 182 ++++++++++++++++++++++++++++++ frontend/src/router.tsx | 2 + 8 files changed, 673 insertions(+), 6 deletions(-) create mode 100644 backend/app/api/auth.py create mode 100644 backend/app/services/auth.py create mode 100644 frontend/src/pages/Auth.tsx diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py new file mode 100644 index 0000000..05e7771 --- /dev/null +++ b/backend/app/api/auth.py @@ -0,0 +1,213 @@ +"""访问认证 API。 + +端点: + GET /api/auth/status — 是否已设密码、当前会话是否有效 + POST /api/auth/setup — 首次设置密码(仅限本机/内网, 防公网抢占) + POST /api/auth/login — 登录(密码 → 会话 token, 含限流) + POST /api/auth/logout — 注销当前会话 + POST /api/auth/change-password — 改密码(需已登录) + +安全: + - setup 端点只接受本机/内网请求(request.client.host), 公网请求 403。 + 否则黑客可比用户更早扫到域名, 抢先设密码, 反客为主。 + - login 限流: 同一来源 IP 连续失败 5 次, 锁 5 分钟(内存计数)。 + - 会话 token 通过 HttpOnly cookie 下发, 前端无需手动管理。 +""" +from __future__ import annotations + +import logging +import time +from collections import defaultdict +from threading import Lock + +from fastapi import APIRouter, HTTPException, Request, Response +from pydantic import BaseModel, Field + +from app.services import auth + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/auth", tags=["auth"]) + +COOKIE_NAME = "tf_session" +_COOKIE_MAX_AGE = 30 * 24 * 3600 # 与 SESSION_TTL 一致 + +# 限流: { ip: (fail_count, lock_until_ts) } +_fail_counter: dict[str, tuple[int, float]] = defaultdict(lambda: (0, 0.0)) +_fail_lock = Lock() +_MAX_FAILS = 5 +_LOCK_SECONDS = 300 + + +def _is_local_network(host: str | None) -> bool: + """是否本机或内网请求。 + + 反向代理(Nginx)场景下 request.client.host 是代理本身(127.0.0.1), + 需信任 X-Forwarded-For 的最左(原始客户端)。本项目部署若经反代, + 请在反代配置正确的 X-Forwarded-For(标准做法)。 + """ + if not host: + return False + if host in ("127.0.0.1", "::1", "localhost"): + return True + # 内网网段: 10.x / 172.16-31.x / 192.168.x + if host.startswith("10.") or host.startswith("192.168."): + return True + if host.startswith("172."): + try: + second = int(host.split(".")[1]) + if 16 <= second <= 31: + return True + except (IndexError, ValueError): + pass + return False + + +def _client_ip(request: Request) -> str: + """取真实客户端 IP(信任反代 X-Forwarded-For)。""" + xff = request.headers.get("x-forwarded-for") + if xff: + return xff.split(",")[0].strip() + return request.client.host if request.client else "unknown" + + +def _check_login_rate_limit(ip: str) -> None: + """登录失败限流检查, 触发则抛 429。""" + with _fail_lock: + count, until = _fail_counter.get(ip, (0, 0.0)) + now = time.time() + if until > now: + wait = int(until - now) + raise HTTPException( + status_code=429, + detail=f"登录失败次数过多, 请 {wait} 秒后重试", + ) + + +def _record_login_fail(ip: str) -> None: + """记录一次登录失败, 达阈值则锁定。""" + with _fail_lock: + count, until = _fail_counter.get(ip, (0, 0.0)) + count += 1 + if count >= _MAX_FAILS: + until = time.time() + _LOCK_SECONDS + logger.warning("auth login locked for %s after %d fails", ip, count) + _fail_counter[ip] = (count, until) + + +def _clear_login_fails(ip: str) -> None: + """登录成功后清除该 IP 的失败计数。""" + with _fail_lock: + _fail_counter.pop(ip, None) + + +# ================================================================ +# 端点 +# ================================================================ + +class PasswordIn(BaseModel): + password: str = Field(min_length=6, max_length=128) + + +class LoginIn(BaseModel): + password: str = Field(min_length=1, max_length=128) + + +class ChangePasswordIn(BaseModel): + old_password: str = Field(min_length=1, max_length=128) + new_password: str = Field(min_length=6, max_length=128) + + +@router.get("/status") +def auth_status(request: Request) -> dict: + """认证状态: 是否已设密码 + 当前请求是否已登录。""" + token = request.cookies.get(COOKIE_NAME) + return { + "configured": auth.is_configured(), + "authenticated": bool(token and auth.is_valid_session(token)), + } + + +@router.post("/setup") +def setup_password(req: PasswordIn, request: Request) -> dict: + """首次设置访问密码。仅限本机/内网请求(防公网抢占)。 + + 若已设置过密码, 返回 409(改密码走 /change-password)。 + """ + # 关键: 限制只有服务器主人(本机/内网)能设密码 + client_ip = _client_ip(request) + if not _is_local_network(client_ip): + logger.warning("setup rejected from non-local ip: %s", client_ip) + raise HTTPException( + status_code=403, + detail="首次设置密码仅允许本机或内网访问,请通过 SSH/本地浏览器操作", + ) + + if auth.is_configured(): + raise HTTPException(status_code=409, detail="密码已设置,如需修改请登录后使用改密码功能") + + auth.set_password(req.password) + logger.info("access password set up from %s", client_ip) + return {"ok": True, "configured": True} + + +@router.post("/login") +def login(req: LoginIn, request: Request, response: Response) -> dict: + """登录: 密码 → 会话 token(写 HttpOnly cookie)。含失败限流。""" + ip = _client_ip(request) + _check_login_rate_limit(ip) + + if not auth.is_configured(): + raise HTTPException(status_code=409, detail="尚未设置访问密码") + + token = auth.verify_and_create_session(req.password) + if not token: + _record_login_fail(ip) + raise HTTPException(status_code=401, detail="密码错误") + + _clear_login_fails(ip) + # HttpOnly: 防 XSS 窃取; SameSite=Lax: 防 CSRF; Path=/: 全站生效 + response.set_cookie( + key=COOKIE_NAME, + value=token, + max_age=_COOKIE_MAX_AGE, + httponly=True, + samesite="lax", + path="/", + secure=False, # 自托管可能无 HTTPS, 不强制 secure(建议反代加 HTTPS) + ) + return {"ok": True, "authenticated": True} + + +@router.post("/logout") +def logout(request: Request, response: Response) -> dict: + """注销当前会话。""" + token = request.cookies.get(COOKIE_NAME) + if token: + auth.revoke_session(token) + response.delete_cookie(key=COOKIE_NAME, path="/") + return {"ok": True} + + +@router.post("/change-password") +def change_password(req: ChangePasswordIn, request: Request) -> dict: + """修改密码: 需验证旧密码, 成功后所有会话失效(含当前, 需重新登录)。""" + token = request.cookies.get(COOKIE_NAME) + if not (token and auth.is_valid_session(token)): + raise HTTPException(status_code=401, detail="请先登录") + + if not auth.is_configured(): + raise HTTPException(status_code=409, detail="尚未设置访问密码") + + # 验证旧密码 + new_token = auth.verify_and_create_session(req.old_password) + if not new_token: + ip = _client_ip(request) + _record_login_fail(ip) + raise HTTPException(status_code=401, detail="旧密码错误") + # 临时 token 用完即弃 + auth.revoke_session(new_token) + + # 改密码(set_password 会清空所有会话) + auth.set_password(req.new_password) + return {"ok": True, "message": "密码已修改, 请重新登录"} diff --git a/backend/app/main.py b/backend/app/main.py index a59f94d..4b15d2e 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -5,13 +5,13 @@ import logging from contextlib import asynccontextmanager from pathlib import Path -from fastapi import FastAPI +from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import FileResponse +from fastapi.responses import FileResponse, JSONResponse from fastapi.staticfiles import StaticFiles from app import __version__ -from app.api import analysis, backtest, data, ext_data, financials, indices, intraday, kline, market_recap, monitor_rules, alerts, overview, pipeline, screener, settings as settings_api, signals, stock_analysis, strategy, watchlist +from app.api import analysis, auth as auth_api, backtest, data, ext_data, financials, indices, intraday, kline, market_recap, monitor_rules, alerts, overview, pipeline, screener, settings as settings_api, signals, stock_analysis, strategy, watchlist from app.api.routes import router as core_router from app.config import settings from app.jobs import daily_pipeline @@ -186,8 +186,55 @@ app.add_middleware( allow_headers=["*"], ) + +# ================================================================ +# 访问认证中间件 +# ================================================================ +# 拦截所有 /api/ 请求, 三种状态: +# 1. 未设密码 + 本机/内网 → 放行(让本机用户访问面板 + 调 /api/auth/setup 设密码) +# 2. 未设密码 + 公网 → 拒绝(403, 防裸奔也防抢占; 引导本机设密码) +# 3. 已设密码 → 检查 session, 无效则 401(前端跳登录) +# 白名单: /api/auth/* (设密码/登录本身)、/health 等探活。 +_AUTH_WHITELIST_PREFIX = ("/api/auth/",) +_AUTH_WHITELIST_EXACT = ("/health", "/api/health", "/openapi.json", "/docs", "/redoc") + + +@app.middleware("http") +async def auth_middleware(request: Request, call_next): + path = request.url.path + # 仅 /api/ 走认证; 静态资源(前端页面/assets)放行, 由前端处理跳转 + if not path.startswith("/api/"): + return await call_next(request) + # 白名单放行(设密码/登录/探活本身不拦) + if path.startswith(_AUTH_WHITELIST_PREFIX) or path in _AUTH_WHITELIST_EXACT: + return await call_next(request) + + from app.services import auth as auth_service + # 情况 1+2: 未设密码 + if not auth_service.is_configured(): + # 本机/内网 → 放行(服务器主人可访问, 并去 /login 设密码) + if auth_api._is_local_network(auth_api._client_ip(request)): + return await call_next(request) + # 公网 → 拒绝。不裸奔, 也不给公网设密码的机会(防抢占) + return JSONResponse( + status_code=403, + content={ + "detail": "面板尚未初始化访问密码,请通过 SSH/本机浏览器访问以设置密码", + "code": "NOT_INITIALIZED", + }, + ) + + # 情况 3: 已设密码, 检查会话 + token = request.cookies.get(auth_api.COOKIE_NAME) + if token and auth_service.is_valid_session(token): + return await call_next(request) + # 未登录: 401(前端跳登录页) + return JSONResponse(status_code=401, content={"detail": "未登录或会话已过期"}) + + # 路由 app.include_router(core_router) +app.include_router(auth_api.router) app.include_router(kline.router) app.include_router(watchlist.router) app.include_router(screener.router) diff --git a/backend/app/services/auth.py b/backend/app/services/auth.py new file mode 100644 index 0000000..e5aadd7 --- /dev/null +++ b/backend/app/services/auth.py @@ -0,0 +1,173 @@ +"""访问密码认证 — 单用户, 自托管场景。 + +设计: + - 密码用 PBKDF2-HMAC-SHA256 哈希(标准库 hashlib, 无新依赖), 加随机 salt。 + 即使 auth.json 泄露, 也无法逆向出明文密码。 + - 会话用随机 token(token_urlsafe), 内存 + 文件双存(支持多进程/重启不丢失)。 + - 存储: data/user_data/auth.json (chmod 0600), 仿 secrets_store 模式。 + +安全要点: + - 设密码接口必须限制本机/内网(见 auth router), 防黑客抢占域名抢先设密码。 + - 登录限流: 错5次锁5分钟(见 auth router 内存计数)。 + - 单密码, 不做多用户(避免重构全项目数据层)。 +""" +from __future__ import annotations + +import hashlib +import json +import logging +import os +import secrets as _secrets +import threading +import time +from pathlib import Path + +logger = logging.getLogger(__name__) + +# PBKDF2 参数(NIST 推荐, 单次校验 ~100ms, 兼顾安全与响应) +_PBKDF2_ITER = 200_000 +_SALT_LEN = 16 +_TOKEN_BYTES = 32 + +# 会话有效期: 30 天(自托管单用户, 长一点减少重登频率) +SESSION_TTL = 30 * 24 * 3600 + +_lock = threading.Lock() +# 内存中的有效会话: { token: expire_ts }。进程重启后从磁盘恢复。 +_sessions: dict[str, float] = {} + + +def _path() -> Path: + from app.config import settings + p = settings.data_dir / "user_data" / "auth.json" + p.parent.mkdir(parents=True, exist_ok=True) + return p + + +def _load() -> dict: + p = _path() + if p.exists(): + try: + return json.loads(p.read_text(encoding="utf-8")) + except Exception as e: # noqa: BLE001 + logger.warning("auth.json malformed: %s", e) + return {} + + +def _save(data: dict) -> None: + p = _path() + p.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8") + try: + os.chmod(p, 0o600) + except OSError: + pass + + +def _hash_password(password: str, salt: bytes | None = None) -> tuple[str, str]: + """返回 (salt_hex, hash_hex)。salt 为 None 时生成新 salt。""" + if salt is None: + salt = os.urandom(_SALT_LEN) + dk = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, _PBKDF2_ITER) + return salt.hex(), dk.hex() + + +def _verify_password(password: str, salt_hex: str, hash_hex: str) -> bool: + """恒定时间比较, 防时序攻击。""" + try: + salt = bytes.fromhex(salt_hex) + expected = bytes.fromhex(hash_hex) + except ValueError: + return False + actual = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, _PBKDF2_ITER) + return _secrets.compare_digest(actual, expected) + + +# ================================================================ +# 密码管理 +# ================================================================ + +def is_configured() -> bool: + """是否已设置访问密码。""" + d = _load() + return bool(d.get("password_hash")) + + +def set_password(password: str) -> None: + """设置/修改访问密码。清空所有现有会话(强制重新登录)。""" + if len(password) < 6: + raise ValueError("密码至少 6 位") + salt_hex, hash_hex = _hash_password(password) + with _lock: + _sessions.clear() # 改密码 = 旧会话全部失效 + _save({ + "password_hash": hash_hex, + "password_salt": salt_hex, + "updated_at": int(time.time()), + "sessions": {}, # 清空持久化会话 + }) + logger.info("access password set") + + +def verify_and_create_session(password: str) -> str | None: + """验证密码, 成功则创建会话并返回 token, 失败返回 None。""" + d = _load() + if not d.get("password_hash"): + return None + if not _verify_password(password, d.get("password_salt", ""), d["password_hash"]): + return None + token = _secrets.token_urlsafe(_TOKEN_BYTES) + expire = time.time() + SESSION_TTL + with _lock: + _sessions[token] = expire + _persist_sessions_locked() + return token + + +def revoke_session(token: str) -> None: + """注销会话(登出)。""" + with _lock: + _sessions.pop(token, None) + _persist_sessions_locked() + + +def is_valid_session(token: str) -> bool: + """检查会话是否有效(存在且未过期)。过期则清理。""" + if not token: + return False + with _lock: + expire = _sessions.get(token) + if expire is None: + return False + if time.time() > expire: + _sessions.pop(token, None) + _persist_sessions_locked() + return False + return True + + +def _persist_sessions_locked() -> None: + """把当前内存会话写回 auth.json(需持锁调用)。""" + d = _load() + d["sessions"] = {t: exp for t, exp in _sessions.items()} + _save(d) + + +def _restore_sessions() -> None: + """启动时从 auth.json 恢复未过期会话(支持进程重启不丢登录态)。""" + with _lock: + d = _load() + now = time.time() + saved = d.get("sessions") or {} + for token, expire in saved.items(): + if isinstance(expire, (int, float)) and expire > now: + _sessions[token] = expire + if len(_sessions) != len(saved): + # 有过期会话被清理, 落盘一次 + _persist_sessions_locked() + + +# 模块加载时恢复会话 +try: + _restore_sessions() +except Exception as e: # noqa: BLE001 + logger.warning("restore sessions failed: %s", e) diff --git a/backend/uv.lock b/backend/uv.lock index 1d7f05b..233de5f 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -2491,7 +2491,7 @@ all = [ [[package]] name = "tickflow-stock-panel-backend" -version = "0.1.62" +version = "0.1.63" source = { editable = "." } dependencies = [ { name = "apscheduler" }, diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 44ae6a5..d0feaf7 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -16,7 +16,8 @@ async function request(path: string, init?: RequestInit): Promise { let detail = '' try { const j = JSON.parse(await res.text()); detail = j.detail ?? j.message ?? '' } catch { /* ignore */ } const msg = detail || `${res.status} ${res.statusText}` - toast(msg, 'error') + // 401 (未登录/会话过期) 不弹 toast — 由全局认证拦截器统一跳登录页, 避免刷屏 + if (res.status !== 401) toast(msg, 'error') throw new Error(msg) } return res.json() as Promise @@ -704,6 +705,27 @@ export interface StrategyAlertEvent { export const api = { health: () => request<{ status: string; version: string; mode: string }>('/health'), + // ===== Auth (访问认证) ===== + authStatus: () => + request<{ configured: boolean; authenticated: boolean }>('/api/auth/status'), + authSetup: (password: string) => + request<{ ok: boolean }>('/api/auth/setup', { + method: 'POST', + body: JSON.stringify({ password }), + }), + authLogin: (password: string) => + request<{ ok: boolean }>('/api/auth/login', { + method: 'POST', + body: JSON.stringify({ password }), + }), + authLogout: () => + request<{ ok: boolean }>('/api/auth/logout', { method: 'POST' }), + authChangePassword: (oldPassword: string, newPassword: string) => + request<{ ok: boolean }>('/api/auth/change-password', { + method: 'POST', + body: JSON.stringify({ old_password: oldPassword, new_password: newPassword }), + }), + settings: () => request('/api/settings'), saveTickflowKey: (api_key: string) => request('/api/settings/tickflow-key', { diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 52ddceb..4bd0e4b 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,16 +1,44 @@ import React from 'react' import ReactDOM from 'react-dom/client' import { RouterProvider } from 'react-router-dom' -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { QueryClient, QueryClientProvider, QueryCache } from '@tanstack/react-query' import { router } from './router' import './index.css' +// 全局认证拦截: 任何 query/mutation 收到 401 (未登录/会话过期) → 跳登录页。 +// api.ts 的 request() 已对 401 静默 (不弹 toast), 这里统一负责跳转。 +// 排除 /login 自身的请求, 避免登录页请求失败又跳登录形成死循环。 +const _redirectToLogin = (() => { + let redirecting = false + return (err: unknown) => { + if (redirecting) return + if (!(err instanceof Error)) return + const msg = err.message || '' + // 401 (未登录/会话过期) → 跳登录页 + // 403 未初始化 (面板未设密码, 公网访问) → 也跳登录页(显示设密码提示) + const is401 = msg.includes('未登录') || msg.includes('会话已过期') || msg.includes('401') + const isNotInit = msg.includes('尚未初始化访问密码') || msg.includes('NOT_INITIALIZED') + if (!is401 && !isNotInit) return + // 已在登录页则不跳(避免死循环) + if (window.location.pathname === '/login') return + redirecting = true + const redirect = encodeURIComponent(window.location.pathname + window.location.search) + window.location.href = `/login?redirect=${redirect}` + } +})() + const queryClient = new QueryClient({ + queryCache: new QueryCache({ + onError: (err) => _redirectToLogin(err), + }), defaultOptions: { queries: { staleTime: 5_000, // 5s 内复用,与 §4.2 Repository 不变量一致 refetchOnWindowFocus: false, }, + mutations: { + onError: (err) => _redirectToLogin(err), + }, }, }) diff --git a/frontend/src/pages/Auth.tsx b/frontend/src/pages/Auth.tsx new file mode 100644 index 0000000..01ce3c8 --- /dev/null +++ b/frontend/src/pages/Auth.tsx @@ -0,0 +1,182 @@ +/** + * 访问认证页 — 复用同一组件处理「首次设密码」和「登录」两种状态。 + * + * 根据后端 /api/auth/status 的 configured 字段决定显示: + * - configured=false → 显示「设置访问密码」(首次) + * - configured=true → 显示「登录」 + * + * 安全: + * - 设密码接口后端限本机/内网; 公网用户设密码会被 403 拒绝, 页面据此提示。 + * - 登录失败由后端限流(5次锁5分钟), 429 时前端显示等待提示。 + */ +import { useEffect, useState, type FormEvent } from 'react' +import { useNavigate } from 'react-router-dom' +import { useMutation } from '@tanstack/react-query' +import { motion } from 'framer-motion' +import { Eye, EyeOff, Loader2, Lock, ShieldCheck, ShieldAlert, Sparkles } from 'lucide-react' +import { api } from '@/lib/api' +import { Logo } from '@/components/Logo' +import { cn } from '@/lib/cn' + +export function Auth() { + const navigate = useNavigate() + const [password, setPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') // 仅设密码时用 + const [showPwd, setShowPwd] = useState(false) + const [localError, setLocalError] = useState('') + + // 取认证状态(是否已设密码) + const [status, setStatus] = useState<{ configured: boolean } | null>(null) + useEffect(() => { + api.authStatus().then(s => { + setStatus(s) + // 已登录的话直接进面板(避免登录页死循环) + if (s.authenticated) navigate('/', { replace: true }) + }).catch(() => setStatus({ configured: false })) + }, [navigate]) + + const isSetup = !status?.configured // configured=false → 设密码模式 + + // 登录 / 设密码 共用一个 mutation(按 isSetup 调不同接口) + const submitMut = useMutation({ + mutationFn: async () => { + if (isSetup) { + return api.authSetup(password) + } + return api.authLogin(password) + }, + onSuccess: () => { + // 成功: 跳回原页面(或首页) + const redirect = new URLSearchParams(window.location.search).get('redirect') || '/' + navigate(redirect, { replace: true }) + }, + onError: (err: any) => { + const msg = err?.message || (isSetup ? '设置失败' : '登录失败') + // 设密码/登录失败必须显示: 401(密码错)/403(公网设密码被拒)/429(限流) 都要提示 + setLocalError(msg) + }, + }) + + const handleSubmit = (e: FormEvent) => { + e.preventDefault() + setLocalError('') + if (isSetup) { + if (password.length < 6) { setLocalError('密码至少 6 位'); return } + if (password !== confirmPassword) { setLocalError('两次密码不一致'); return } + } + submitMut.mutate() + } + + if (!status) { + return ( +
+ +
+ ) + } + + return ( +
+ {/* 背景辉光(与 Onboarding 风格一致) */} +
+ + + {/* Logo */} +
+ +

TickFlow 股票面板

+
+ +
+ {/* 标题区: 图标 + 文案随模式切换 */} +
+
+ {isSetup ? : } +
+
+
+ {isSetup ? '设置访问密码' : '登录访问'} +
+
+ {isSetup ? '首次使用, 请为面板设置访问密码' : '请输入访问密码以继续'} +
+
+
+ +
+ {/* 密码输入 */} +
+ setPassword(e.target.value)} + placeholder="访问密码" + autoFocus + className="h-10 w-full rounded-btn border border-border bg-base px-3 pr-9 text-sm text-foreground outline-none transition-colors focus:border-accent/50" + /> + +
+ + {/* 确认密码(仅设密码模式) */} + {isSetup && ( + setConfirmPassword(e.target.value)} + placeholder="再次输入密码" + className="h-10 w-full rounded-btn border border-border bg-base px-3 text-sm text-foreground outline-none transition-colors focus:border-accent/50" + /> + )} + + {/* 错误提示 */} + {(localError || submitMut.error) && ( +
+ + {localError} +
+ )} + + +
+ + {/* 提示: 设密码模式告知本机限制 */} + {isSetup && ( +

+ 出于安全考虑, 首次设置密码需在服务器本机或内网访问时操作。公网环境下仅可登录。 +

+ )} +
+ +
+ + 自托管量化工作台 · 数据完全掌握在自己手里 +
+
+
+ ) +} diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index 0e3a2cb..a717724 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -5,6 +5,7 @@ import { Screener } from './pages/Screener' import { Backtest } from './pages/Backtest' import { Financials } from './pages/Financials' import { Onboarding } from './pages/Onboarding' +import { Auth } from './pages/Auth' import { Data } from './pages/Data' import { Monitor } from './pages/Monitor' import { Trading } from './pages/Trading' @@ -51,6 +52,7 @@ function OnboardingGuard({ children }: { children: React.ReactNode }) { export const router = createBrowserRouter([ { path: '/onboarding', element: }, + { path: '/login', element: }, { path: '/', element: (