feat: 新增访问密码认证(首次本机设密码 + 公网防护)

- 后端: PBKDF2 密码哈希 + 会话 token(auth.json 存储, 零数据库)
- 中间件: 未设密码时本机可设/公网拒绝(防裸奔+防抢占), 已设密码后 401 拦截
- 5个 API: status/setup/login/logout/change-password, 含本机限制+登录限流
- 前端: 登录页/设密码页 + 全局 401/403 拦截跳转
- 安全: HttpOnly cookie + 5次失败锁5分钟 + 改密码清会话
This commit is contained in:
shy3130
2026-06-28 20:02:00 +08:00
parent 5585f445fc
commit 69cd7af8d9
8 changed files with 673 additions and 6 deletions
+213
View File
@@ -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": "密码已修改, 请重新登录"}
+50 -3
View File
@@ -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)
+173
View File
@@ -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)
+1 -1
View File
@@ -2491,7 +2491,7 @@ all = [
[[package]]
name = "tickflow-stock-panel-backend"
version = "0.1.62"
version = "0.1.63"
source = { editable = "." }
dependencies = [
{ name = "apscheduler" },
+23 -1
View File
@@ -16,7 +16,8 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
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<T>
@@ -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<SettingsState>('/api/settings'),
saveTickflowKey: (api_key: string) =>
request<SaveTickflowKeyResult>('/api/settings/tickflow-key', {
+29 -1
View File
@@ -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),
},
},
})
+182
View File
@@ -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 (
<div className="flex min-h-screen items-center justify-center bg-base">
<Loader2 className="h-6 w-6 animate-spin text-muted" />
</div>
)
}
return (
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-base px-4">
{/* 背景辉光(与 Onboarding 风格一致) */}
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_30%_20%,rgba(139,92,246,0.15),transparent_40%),radial-gradient(circle_at_70%_80%,rgba(59,130,246,0.12),transparent_40%)]" />
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, ease: [0.16, 1, 0.3, 1] }}
className="relative w-full max-w-sm"
>
{/* Logo */}
<div className="mb-6 flex flex-col items-center gap-2">
<Logo className="h-10 w-10" />
<h1 className="text-lg font-semibold text-foreground">TickFlow </h1>
</div>
<div className="rounded-card border border-border bg-surface/90 p-6 shadow-2xl backdrop-blur">
{/* 标题区: 图标 + 文案随模式切换 */}
<div className="mb-5 flex items-center gap-2.5">
<div className={cn(
'grid h-9 w-9 place-items-center rounded-lg',
isSetup ? 'bg-accent/15 text-accent' : 'bg-purple-500/15 text-purple-400',
)}>
{isSetup ? <ShieldCheck className="h-5 w-5" /> : <Lock className="h-5 w-5" />}
</div>
<div>
<div className="text-sm font-medium text-foreground">
{isSetup ? '设置访问密码' : '登录访问'}
</div>
<div className="text-[11px] text-muted">
{isSetup ? '首次使用, 请为面板设置访问密码' : '请输入访问密码以继续'}
</div>
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-3">
{/* 密码输入 */}
<div className="relative">
<input
type={showPwd ? 'text' : 'password'}
value={password}
onChange={e => 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"
/>
<button
type="button"
onClick={() => setShowPwd(s => !s)}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-muted hover:text-foreground"
tabIndex={-1}
>
{showPwd ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
{/* 确认密码(仅设密码模式) */}
{isSetup && (
<input
type={showPwd ? 'text' : 'password'}
value={confirmPassword}
onChange={e => 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) && (
<div className="flex items-start gap-1.5 rounded-btn bg-danger/10 px-3 py-2 text-[11px] text-danger">
<ShieldAlert className="mt-px h-3.5 w-3.5 shrink-0" />
<span>{localError}</span>
</div>
)}
<button
type="submit"
disabled={submitMut.isPending || !password}
className="inline-flex h-10 w-full items-center justify-center gap-1.5 rounded-btn bg-accent text-sm font-medium text-white transition-colors hover:bg-accent/90 disabled:opacity-50"
>
{submitMut.isPending ? (
<><Loader2 className="h-4 w-4 animate-spin" /></>
) : (
<>{isSetup ? '设置并进入' : '登录'}</>
)}
</button>
</form>
{/* 提示: 设密码模式告知本机限制 */}
{isSetup && (
<p className="mt-3 text-[10px] leading-relaxed text-muted/70">
, 访
</p>
)}
</div>
<div className="mt-4 flex items-center justify-center gap-1.5 text-[10px] text-muted/60">
<Sparkles className="h-3 w-3" />
·
</div>
</motion.div>
</div>
)
}
+2
View File
@@ -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: <Onboarding /> },
{ path: '/login', element: <Auth /> },
{
path: '/',
element: (