diff --git a/.env.example b/.env.example
index cf36e57..a0e5ee7 100644
--- a/.env.example
+++ b/.env.example
@@ -110,4 +110,16 @@ SMTP_USE_TLS=true
# 用于生成邮件中的链接
# 生产环境示例:https://tradingagents.com
# 开发环境示例:http://localhost:3000
-APP_BASE_URL=http://localhost:3000
\ No newline at end of file
+APP_BASE_URL=http://localhost:3000
+
+# ============================================
+# Cloudflare Turnstile 人机验证(登录/注册/邮箱验证码下发)
+# ============================================
+# 默认使用 Cloudflare 官方测试密钥(始终通过),仅供联调;
+# 生产环境请前往 https://dash.cloudflare.com/?to=/:/turnstile 创建真实站点并覆盖以下两项。
+# 测试 sitekey(始终通过):1x00000000000000000000AA
+# 测试 secret key(始终通过):1x0000000000000000000000000000000AA
+TURNSTILE_SITE_KEY=1x00000000000000000000AA
+TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
+# 前端读取的 sitekey(Next.js 仅注入 NEXT_PUBLIC_ 前缀变量)
+NEXT_PUBLIC_TURNSTILE_SITE_KEY=1x00000000000000000000AA
\ No newline at end of file
diff --git a/web/backend/auth_routes.py b/web/backend/auth_routes.py
index e6fe59b..663155f 100644
--- a/web/backend/auth_routes.py
+++ b/web/backend/auth_routes.py
@@ -9,7 +9,7 @@
from sqlalchemy.ext.asyncio import AsyncSession
from web.backend.database import get_db
from web.backend.schemas import (
- UserCreate, UserLogin, AuthResponse, User as UserSchema, Token, CaptchaResponse,
+ UserCreate, UserLogin, AuthResponse, User as UserSchema, Token,
EmailCodeSendRequest, EmailCodeSendResponse, EmailCodeLoginRequest, PasswordSetRequest
)
from web.backend.auth import (
@@ -29,12 +29,9 @@
from typing import Deque, Dict
import time
-_CAPTCHA_REQUESTS: Dict[str, Deque[float]] = defaultdict(deque) # 每IP的验证码请求时间戳
_FAILED_ATTEMPTS: Dict[str, Deque[float]] = defaultdict(deque) # 每IP的失败时间戳
_EMAIL_CODE_REQUESTS: Dict[str, Deque[float]] = defaultdict(deque) # 每邮箱的验证码请求时间戳
-CAPTCHA_RATE_LIMIT = 20 # 每分钟最多获取20次验证码/每IP
-CAPTCHA_RATE_WINDOW = 60 # 秒
FAIL_WINDOW = 600 # 10分钟
FAIL_LIMIT = 5 # 10分钟内最多失败5次
EMAIL_CODE_RATE_LIMIT = 1 # 每邮箱每60秒最多请求1次
@@ -45,16 +42,6 @@ def _prune(dq: Deque[float], window: int):
while dq and now - dq[0] > window:
dq.popleft()
-def _check_rate(ip: str) -> bool:
- dq = _CAPTCHA_REQUESTS[ip]
- _prune(dq, CAPTCHA_RATE_WINDOW)
- return len(dq) < CAPTCHA_RATE_LIMIT
-
-def _note_captcha_request(ip: str):
- dq = _CAPTCHA_REQUESTS[ip]
- _prune(dq, CAPTCHA_RATE_WINDOW)
- dq.append(time.time())
-
def _note_fail(ip: str):
dq = _FAILED_ATTEMPTS[ip]
_prune(dq, FAIL_WINDOW)
@@ -75,21 +62,15 @@ def _note_email_code_request(email: str):
_prune(dq, EMAIL_CODE_RATE_WINDOW)
dq.append(time.time())
-@router.post("/captcha/new", response_model=CaptchaResponse)
-async def new_captcha(request: Request):
+@router.get("/turnstile/sitekey")
+async def get_turnstile_sitekey():
"""
- Create new captcha challenge and return seed and id (frontend draws image via Canvas using seed)
+ Return the Cloudflare Turnstile site key (public, safe to expose).
+ Frontend uses it to render the human-verification widget.
+ Defaults to Cloudflare's always-pass test key for debugging.
"""
- client_ip = request.client.host if request.client else "unknown"
- if not _check_rate(client_ip):
- raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="验证码请求过于频繁,请稍后再试")
- try:
- from web.backend.captcha import create_captcha
- cid, seed = create_captcha()
- _note_captcha_request(client_ip)
- return {"captcha_id": cid, "seed": seed}
- except Exception as e:
- raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"生成验证码失败: {str(e)}")
+ from web.backend.turnstile import TURNSTILE_SITE_KEY
+ return {"sitekey": TURNSTILE_SITE_KEY}
# Security scheme
security = HTTPBearer()
@@ -130,16 +111,16 @@ async def register(user_data: UserCreate, db: AsyncSession = Depends(get_db), re
Register a new user (requires captcha and email verification code)
"""
try:
- # 验证服务端验证码(防止绕过前端)
- from web.backend.captcha import verify_captcha
+ # 校验 Cloudflare Turnstile 人机验证 token
+ from web.backend.turnstile import verify_turnstile_token
from web.backend.services.verification_code_service import get_verification_code_service
client_ip = request.client.host if request and request.client else "unknown"
if _too_many_fails(client_ip):
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="尝试次数过多,请稍后再试")
- if not user_data.captcha_id or not user_data.captcha_answer or not verify_captcha(user_data.captcha_id, user_data.captcha_answer):
+ if not await verify_turnstile_token(user_data.turnstile_token, client_ip):
_note_fail(client_ip)
- raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="图形验证码无效或已过期")
+ raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="人机验证失败,请刷新后重试")
# Verify email code
if not user_data.email_code:
@@ -199,14 +180,14 @@ async def login(user_data: UserLogin, db: AsyncSession = Depends(get_db), reques
"""
Login user and return access token (requires captcha)
"""
- # 验证服务端验证码(防止绕过前端)
- from web.backend.captcha import verify_captcha
+ # 校验 Cloudflare Turnstile 人机验证 token
+ from web.backend.turnstile import verify_turnstile_token
client_ip = request.client.host if request and request.client else "unknown"
if _too_many_fails(client_ip):
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="尝试次数过多,请稍后再试")
- if not user_data.captcha_id or not user_data.captcha_answer or not verify_captcha(user_data.captcha_id, user_data.captcha_answer):
+ if not await verify_turnstile_token(user_data.turnstile_token, client_ip):
_note_fail(client_ip)
- raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="验证码无效或已过期")
+ raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="人机验证失败,请刷新后重试")
# Authenticate user
user = await authenticate_user(db, user_data.username, user_data.password)
@@ -314,9 +295,9 @@ async def send_email_code(
):
"""
Send verification code to user's email
- Requires CAPTCHA validation and rate limiting
+ Requires Cloudflare Turnstile validation and rate limiting
"""
- from web.backend.captcha import verify_captcha
+ from web.backend.turnstile import verify_turnstile_token
from web.backend.services.verification_code_service import get_verification_code_service
from sqlalchemy import select
@@ -329,12 +310,12 @@ async def send_email_code(
detail="尝试次数过多,请稍后再试"
)
- # Validate CAPTCHA
- if not verify_captcha(request_data.captcha_id, request_data.captcha_answer):
+ # Validate Turnstile token
+ if not await verify_turnstile_token(request_data.turnstile_token, client_ip):
_note_fail(client_ip)
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
- detail="验证码无效或已过期"
+ detail="人机验证失败,请刷新后重试"
)
# Check email rate limiting
@@ -386,10 +367,10 @@ async def send_email_code_for_register(
):
"""
Send verification code for registration
- Requires CAPTCHA validation and rate limiting
+ Requires Cloudflare Turnstile validation and rate limiting
Checks that email is NOT already registered
"""
- from web.backend.captcha import verify_captcha
+ from web.backend.turnstile import verify_turnstile_token
from web.backend.services.verification_code_service import VerificationCodeService
from sqlalchemy import select
@@ -402,12 +383,12 @@ async def send_email_code_for_register(
detail="尝试次数过多,请稍后再试"
)
- # Validate CAPTCHA
- if not verify_captcha(request_data.captcha_id, request_data.captcha_answer):
+ # Validate Turnstile token
+ if not await verify_turnstile_token(request_data.turnstile_token, client_ip):
_note_fail(client_ip)
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
- detail="验证码无效或已过期"
+ detail="人机验证失败,请刷新后重试"
)
# Check email rate limiting
@@ -489,9 +470,9 @@ async def login_with_email_code(
):
"""
Login user with email and verification code
- Requires CAPTCHA validation
+ Requires Cloudflare Turnstile validation
"""
- from web.backend.captcha import verify_captcha
+ from web.backend.turnstile import verify_turnstile_token
from web.backend.services.verification_code_service import get_verification_code_service
from sqlalchemy import select
@@ -504,12 +485,12 @@ async def login_with_email_code(
detail="尝试次数过多,请稍后再试"
)
- # Validate CAPTCHA
- if not verify_captcha(request_data.captcha_id, request_data.captcha_answer):
+ # Validate Turnstile token
+ if not await verify_turnstile_token(request_data.turnstile_token, client_ip):
_note_fail(client_ip)
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
- detail="验证码无效或已过期"
+ detail="人机验证失败,请刷新后重试"
)
# Verify email code
diff --git a/web/backend/schemas.py b/web/backend/schemas.py
index 44ff7ec..f1ea8a3 100644
--- a/web/backend/schemas.py
+++ b/web/backend/schemas.py
@@ -15,9 +15,8 @@ class UserBase(BaseModel):
class UserCreate(UserBase):
password: Optional[str] = None # Password is now optional
- # 服务端验证码(防绕过前端)
- captcha_id: Optional[str] = None
- captcha_answer: Optional[str] = None
+ # Cloudflare Turnstile 人机验证 token(前端 widget 回传)
+ turnstile_token: Optional[str] = None
# 邮箱验证码
email_code: Optional[str] = None
@@ -55,9 +54,8 @@ def validate_password(cls, v):
class UserLogin(BaseModel):
username: str
password: str
- # 服务端验证码(防绕过前端)
- captcha_id: Optional[str] = None
- captcha_answer: Optional[str] = None
+ # Cloudflare Turnstile 人机验证 token(前端 widget 回传)
+ turnstile_token: Optional[str] = None
class User(UserBase):
id: int
@@ -72,7 +70,7 @@ class Config:
class UserInDB(User):
hashed_password: str
-# Captcha
+# Captcha(已废弃:图形验证码已替换为 Cloudflare Turnstile,保留仅为向后兼容引用)
class CaptchaResponse(BaseModel):
captcha_id: str
seed: str
@@ -81,8 +79,7 @@ class CaptchaResponse(BaseModel):
class EmailCodeSendRequest(BaseModel):
"""Request schema for sending verification code"""
email: EmailStr
- captcha_id: str
- captcha_answer: str
+ turnstile_token: Optional[str] = None
class EmailCodeSendResponse(BaseModel):
"""Response schema for send verification code"""
@@ -93,8 +90,7 @@ class EmailCodeLoginRequest(BaseModel):
"""Request schema for email code login"""
email: EmailStr
code: str
- captcha_id: str
- captcha_answer: str
+ turnstile_token: Optional[str] = None
@validator('code')
def validate_code(cls, v):
diff --git a/web/backend/turnstile.py b/web/backend/turnstile.py
new file mode 100644
index 0000000..ebdf2f0
--- /dev/null
+++ b/web/backend/turnstile.py
@@ -0,0 +1,48 @@
+"""Cloudflare Turnstile 人机验证集成。
+
+后端用 siteverify 校验前端下发的 turnstile_token。
+默认使用 Cloudflare 官方测试密钥(始终通过),方便联调;
+生产环境请通过环境变量 TURNSTILE_SECRET_KEY 覆盖为真实密钥。
+"""
+import os
+from typing import Optional
+
+import httpx
+
+# Cloudflare 官方测试密钥(始终通过 / always passes)
+# 详见 https://developers.cloudflare.com/turnstile/troubleshooting/testing/
+_TEST_SECRET = "1x0000000000000000000000000000000AA"
+_TEST_SITE_KEY = "1x00000000000000000000AA"
+
+TURNSTILE_SECRET_KEY = os.getenv("TURNSTILE_SECRET_KEY", _TEST_SECRET)
+TURNSTILE_SITE_KEY = os.getenv("TURNSTILE_SITE_KEY", _TEST_SITE_KEY)
+
+_SITEVERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify"
+
+
+async def verify_turnstile_token(token: Optional[str], remote_ip: Optional[str] = None) -> bool:
+ """校验 Turnstile token。
+
+ - token 为空或 None:直接判失败。
+ - 调用 Cloudflare siteverify;测试密钥始终返回 success=true。
+ - 任何网络/解析异常都判失败(fail-closed),不抛异常以免打断登录流程。
+ """
+ if not token:
+ return False
+
+ # 测试密钥短路:Cloudflare 不会真正校验测试 token,直接放行以方便联调。
+ if TURNSTILE_SECRET_KEY == _TEST_SECRET:
+ return True
+
+ try:
+ async with httpx.AsyncClient(timeout=8.0) as client:
+ data = {"secret": TURNSTILE_SECRET_KEY, "response": token}
+ if remote_ip:
+ data["remoteip"] = remote_ip
+ resp = await client.post(_SITEVERIFY_URL, data=data)
+ resp.raise_for_status()
+ payload = resp.json()
+ return bool(payload.get("success", False))
+ except Exception:
+ # fail-closed:异常时拒绝,避免放行机器人
+ return False
diff --git a/web/frontend/public/lib/font-awesome/css/icons.subset.css b/web/frontend/public/lib/font-awesome/css/icons.subset.css
index 7977c18..b29a1d1 100644
--- a/web/frontend/public/lib/font-awesome/css/icons.subset.css
+++ b/web/frontend/public/lib/font-awesome/css/icons.subset.css
@@ -73,6 +73,9 @@
.fa-calendar-alt:before,.fa-calendar-days:before{content:"\f073"}
.fa-comments:before{content:"\f086"}
.fa-user-check:before{content:"\f4fc"}
+.fa-circle-notch:before{content:"\f1ce"}
+.fa-gavel:before,.fa-legal:before{content:"\f0e3"}
+.fa-compass:before{content:"\f14e"}
.fa-bars:before,.fa-navicon:before{content:"\f0c9"}
.fa-lightbulb:before{content:"\f0eb"}
.fa-circle-exclamation:before,.fa-exclamation-circle:before{content:"\f06a"}
@@ -91,7 +94,7 @@
.fa-server:before{content:"\f233"}
.fa-right-to-bracket:before,.fa-sign-in-alt:before{content:"\f2f6"}
.fa-crown:before{content:"\f521"}
-.fa-user-edit:before,.fa-user-pen:before{content:"\f4ff"}
+.fa-folder-open:before{content:"\f07c"}
.fa-bar-chart:before,.fa-chart-bar:before{content:"\f080"}
.fa-image:before{content:"\f03e"}
.fa-circle-play:before,.fa-play-circle:before{content:"\f144"}
@@ -101,12 +104,14 @@
.fa-arrows-rotate:before,.fa-refresh:before,.fa-sync:before{content:"\f021"}
.fa-shield-alt:before,.fa-shield-halved:before{content:"\f3ed"}
.fa-layer-group:before{content:"\f5fd"}
+.fa-newspaper:before{content:"\f1ea"}
.fa-question:before{content:"\3f"}
+.fa-magnifying-glass-chart:before{content:"\e522"}
.fa-chart-line:before,.fa-line-chart:before{content:"\f201"}
-.fa-arrow-right:before{content:"\f061"}
.fa-circle-pause:before,.fa-pause-circle:before{content:"\f28b"}
.fa-cube:before{content:"\f1b2"}
.fa-circle:before{content:"\f111"}
+.fa-users-rays:before{content:"\e593"}
.fa-terminal:before{content:"\f120"}
.fa-eye:before{content:"\f06e"}
.fa-pen:before{content:"\f304"}
@@ -130,27 +135,34 @@
.fa-drivers-license:before,.fa-id-card:before{content:"\f2c2"}
.fa-home-alt:before,.fa-home-lg-alt:before,.fa-home:before,.fa-house:before{content:"\f015"}
.fa-calendar-week:before{content:"\f784"}
+.fa-redo-alt:before,.fa-rotate-forward:before,.fa-rotate-right:before{content:"\f2f9"}
.fa-stop:before{content:"\f04d"}
.fa-bolt:before,.fa-zap:before{content:"\f0e7"}
.fa-bug:before{content:"\f188"}
.fa-arrow-down:before{content:"\f063"}
.fa-play:before{content:"\f04b"}
.fa-magnifying-glass:before,.fa-search:before{content:"\f002"}
+.fa-receipt:before{content:"\f543"}
.fa-chevron-down:before{content:"\f078"}
.fa-arrow-up:before{content:"\f062"}
.fa-list-check:before,.fa-tasks:before{content:"\f0ae"}
.fa-circle-user:before,.fa-user-circle:before{content:"\f2bd"}
.fa-add:before,.fa-plus:before{content:"\2b"}
.fa-close:before,.fa-multiply:before,.fa-remove:before,.fa-times:before,.fa-xmark:before{content:"\f00d"}
+.fa-rocket:before{content:"\f135"}
+.fa-arrow-trend-up:before{content:"\e098"}
.fa-chevron-left:before{content:"\f053"}
.fa-chevron-right:before{content:"\f054"}
.fa-spinner:before{content:"\f110"}
.fa-robot:before{content:"\f544"}
-.fa-building:before{content:"\f1ad"}
.fa-clock-rotate-left:before,.fa-history:before{content:"\f1da"}
.fa-arrow-right-from-file:before,.fa-file-export:before{content:"\f56e"}
+.fa-shield-blank:before,.fa-shield:before{content:"\f132"}
.fa-calendar:before{content:"\f133"}
.fa-circle-plus:before,.fa-plus-circle:before{content:"\f055"}
+.fa-arrow-trend-down:before{content:"\e097"}
+.fa-balance-scale:before,.fa-scale-balanced:before{content:"\f24e"}
+.fa-table-list:before,.fa-th-list:before{content:"\f00b"}
.fa-user-plus:before{content:"\f234"}
.fa-check:before{content:"\f00c"}
.fa-exclamation-triangle:before,.fa-triangle-exclamation:before,.fa-warning:before{content:"\f071"}
diff --git a/web/frontend/src/app/admin/llm-config/page.tsx b/web/frontend/src/app/admin/llm-config/page.tsx
index 10306f9..ffe0839 100644
--- a/web/frontend/src/app/admin/llm-config/page.tsx
+++ b/web/frontend/src/app/admin/llm-config/page.tsx
@@ -6,8 +6,7 @@ import { useRouter } from 'next/navigation';
import { useAuth } from '@/lib/auth';
import { buildApiUrl } from '@/utils/api';
import { useToast, Toast } from '@/components/ui/Toast';
-import { AppNavbar } from '@/components/common/AppNavbar';
-import { Footer } from '@/components/common/Footer';
+import { SiteLayout } from '@/components/site/SiteLayout';
import { ProviderList } from '@/components/admin/llm-config/ProviderList';
import { ModelList } from '@/components/admin/llm-config/ModelList';
import { ProviderForm } from '@/components/admin/llm-config/ProviderForm';
@@ -16,7 +15,7 @@ import { ConfirmDialog } from '@/components/admin/llm-config/ConfirmDialog';
import { RouteDataState } from '@/components/ui/RouteDataState';
export default function LLMConfigPage() {
- const { user, logout, isLoading: authLoading } = useAuth();
+ const { user, isLoading: authLoading } = useAuth();
const router = useRouter();
const { toast, showToast, hideToast } = useToast();
const queryClient = useQueryClient();
@@ -176,17 +175,18 @@ export default function LLMConfigPage() {
}
return (
-
-
-
-
+
{/* 页面标题 */}
-
-
-
- LLM 配置管理
-
-
管理 LLM 供应商和模型配置
+
+
+
管理控制台
+
+
+ LLM 配置管理
+
+
管理 LLM 供应商和模型配置
+
+
管理员
{/* 标签页 */}
@@ -273,9 +273,6 @@ export default function LLMConfigPage() {
)}
-
-
-
{/* 供应商表单模态框 */}
{showProviderForm && (
@@ -328,6 +325,6 @@ export default function LLMConfigPage() {
onConfirm={confirmDialog.onConfirm}
onCancel={() => setConfirmDialog({ ...confirmDialog, isOpen: false })}
/>
-
+
);
}
diff --git a/web/frontend/src/app/admin/system-default-provider/page.tsx b/web/frontend/src/app/admin/system-default-provider/page.tsx
index 02ba3cd..bff33d6 100644
--- a/web/frontend/src/app/admin/system-default-provider/page.tsx
+++ b/web/frontend/src/app/admin/system-default-provider/page.tsx
@@ -4,13 +4,12 @@ import React from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/lib/auth';
-import { AppNavbar } from '@/components/common/AppNavbar';
-import { Footer } from '@/components/common/Footer';
+import { SiteLayout } from '@/components/site/SiteLayout';
import { SystemDefaultForm } from '@/components/admin/system-default-provider/SystemDefaultForm';
import { PageLoading } from '@/components/ui/PageLoading';
export default function SystemDefaultProviderPage() {
- const { user, logout, isLoading: authLoading } = useAuth();
+ const { user, isLoading: authLoading } = useAuth();
const router = useRouter();
// 权限检查:仅管理员可访问,普通用户无写权限,重定向回首页
@@ -25,25 +24,23 @@ export default function SystemDefaultProviderPage() {
}
return (
-
-
-
-
- {/* 页面标题 */}
-
-
-
+
+ {/* 页面标题 */}
+
+
+
管理控制台
+
+
系统默认 Provider
-
-
+
+
指定一个系统默认 AI provider,供未配置个人 provider 的用户使用。其 API Key 由后端保存并脱敏,不会以明文暴露。
-
-
+
管理员
-
-
+
+
);
}
diff --git a/web/frontend/src/app/admin/users/page.tsx b/web/frontend/src/app/admin/users/page.tsx
index 2c06ec6..c399061 100644
--- a/web/frontend/src/app/admin/users/page.tsx
+++ b/web/frontend/src/app/admin/users/page.tsx
@@ -7,8 +7,7 @@ import { useAuth } from '@/lib/auth';
import { buildApiUrl } from '@/utils/api';
import { useToast, Toast } from '@/components/ui/Toast';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
-import { AppNavbar } from '@/components/common/AppNavbar';
-import { Footer } from '@/components/common/Footer';
+import { SiteLayout } from '@/components/site/SiteLayout';
import { ResponsiveUserCard } from '@/components/admin/ResponsiveUserCard';
import { useIsMobile } from '@/hooks/useMediaQuery';
import { RouteDataState } from '@/components/ui/RouteDataState';
@@ -54,7 +53,7 @@ interface SystemStats {
}
export default function UserManagementPage() {
- const { user, logout, isLoading: authLoading } = useAuth();
+ const { user, isLoading: authLoading } = useAuth();
const router = useRouter();
const { toast, showToast, hideToast } = useToast();
const [page, setPage] = useState(1);
@@ -290,14 +289,9 @@ export default function UserManagementPage() {
}
return (
-
- {/* 顶部导航栏 */}
-
-
- {/* 主要内容 */}
-
- {/* 页面标题 */}
-
+
+ {/* 页面标题 */}
+
用户管理
@@ -508,9 +502,6 @@ export default function UserManagementPage() {
)}
-
-
-
{/* Toast组件 */}
-
+
);
}
diff --git a/web/frontend/src/app/leaderboard/page.tsx b/web/frontend/src/app/leaderboard/page.tsx
index ba3eca2..6ab3b94 100644
--- a/web/frontend/src/app/leaderboard/page.tsx
+++ b/web/frontend/src/app/leaderboard/page.tsx
@@ -4,7 +4,7 @@ import { useQuery } from '@tanstack/react-query';
import { useState } from 'react';
import { SiteLayout } from '@/components/site/SiteLayout';
import { reportAPI } from '@/lib/api/reports';
-import { ResearchCard } from '@/app/page';
+import { ResearchCard } from '@/components/site/ResearchCard';
import { SearchBar } from '@/components/site/SearchBar';
export default function LeaderboardPage() {
diff --git a/web/frontend/src/app/me/billing/page.tsx b/web/frontend/src/app/me/billing/page.tsx
index 13aeb89..b738196 100644
--- a/web/frontend/src/app/me/billing/page.tsx
+++ b/web/frontend/src/app/me/billing/page.tsx
@@ -1,7 +1,7 @@
'use client';
-import { SiteLayout } from '@/components/site/SiteLayout';
-import { MeNav } from '@/app/me/page';
+import { AccountLayout } from '@/components/site/AccountLayout';
+import Link from 'next/link';
// 示例数据:订阅计费表尚未在后端落地,先用示例展示形态。
const SAMPLE_LEDGER = [
@@ -12,16 +12,16 @@ const SAMPLE_LEDGER = [
export default function BillingPage() {
return (
-
-
-
-
+
购买次数
+ }
+ >
+
当前可用次数(示例)
@@ -30,7 +30,7 @@ export default function BillingPage() {
示例数据
-
+
@@ -55,7 +55,6 @@ export default function BillingPage() {
计费明细为示例数据;后端订阅配额表(SubscriptionProduct / UserQuota / QuotaLedger)落地后将对接真实记录。
-
+
);
}
-
diff --git a/web/frontend/src/app/me/page.tsx b/web/frontend/src/app/me/page.tsx
index 250c240..a2cc38e 100644
--- a/web/frontend/src/app/me/page.tsx
+++ b/web/frontend/src/app/me/page.tsx
@@ -2,9 +2,9 @@
import { useQuery } from '@tanstack/react-query';
import Link from 'next/link';
-import { SiteLayout } from '@/components/site/SiteLayout';
+import { AccountLayout } from '@/components/site/AccountLayout';
import { reportAPI } from '@/lib/api/reports';
-import { ResearchCard } from '@/app/page';
+import { ResearchCard } from '@/components/site/ResearchCard';
import { useAuth } from '@/lib/auth';
export default function MyAnalysesPage() {
@@ -17,28 +17,33 @@ export default function MyAnalysesPage() {
if (!isLoading && !user) {
return (
-
-
+
+
-
+
);
}
const reports = data?.data ?? [];
return (
-
-
-
-
我的分析
-
你发起的多智能体研究报告,可在此管理与公开。
-
-
-
-
+ 发起研究
+ }
+ >
{reports.length === 0 ? (
@@ -50,30 +55,6 @@ export default function MyAnalysesPage() {
{reports.map((r) => )}
)}
-
- );
-}
-
-export function MeNav({ active }: { active: 'me' | 'billing' | 'preferences' }) {
- const items = [
- { k: 'me', href: '/me', label: '我的分析' },
- { k: 'billing', href: '/me/billing', label: '订阅明细' },
- { k: 'preferences', href: '/me/preferences', label: '账户偏好' },
- ];
- return (
-
+
);
}
-
diff --git a/web/frontend/src/app/me/preferences/page.tsx b/web/frontend/src/app/me/preferences/page.tsx
index 58a045d..45eb0bd 100644
--- a/web/frontend/src/app/me/preferences/page.tsx
+++ b/web/frontend/src/app/me/preferences/page.tsx
@@ -1,9 +1,9 @@
'use client';
-import { SiteLayout } from '@/components/site/SiteLayout';
-import { MeNav } from '@/app/me/page';
+import { AccountLayout } from '@/components/site/AccountLayout';
import { useAuth } from '@/lib/auth';
import { useState } from 'react';
+import Link from 'next/link';
export default function PreferencesPage() {
const { user } = useAuth();
@@ -11,16 +11,13 @@ export default function PreferencesPage() {
const [emailNotify, setEmailNotify] = useState(true);
return (
-
-
-
-
账户偏好
-
分析结果的默认公开与通知设置。
-
-
-
-
-
+
+
{user?.email ?? '—'}
@@ -33,11 +30,11 @@ export default function PreferencesPage() {
-
- 前往设置 →
+
+ 前往设置 →
-
+
);
}
@@ -69,4 +66,3 @@ function Toggle({ on, onChange }: { on: boolean; onChange: (v: boolean) => void
);
}
-
diff --git a/web/frontend/src/app/page.tsx b/web/frontend/src/app/page.tsx
index ba93947..4f71146 100644
--- a/web/frontend/src/app/page.tsx
+++ b/web/frontend/src/app/page.tsx
@@ -4,9 +4,9 @@ import Link from 'next/link';
import { useEffect, useState } from 'react';
import { SearchBar } from '@/components/site/SearchBar';
import { SiteLayout } from '@/components/site/SiteLayout';
+import { ResearchCard } from '@/components/site/ResearchCard';
import { reportAPI } from '@/lib/api/reports';
import type { ReportPreview } from '@/types/report';
-import { VERDICT_PILL } from '@/types/report';
const FEATURES = [
{ icon: 'fa-users-gear', title: '多智能体协作', desc: '市场 / 舆情 / 新闻 / 基本面分析师 + 多空辩论 + 风险裁决,结构化产出。' },
@@ -84,33 +84,3 @@ export default function HomePage() {
);
}
-export function ResearchCard({ report }: { report: ReportPreview }) {
- const decision = report.role_chain?.decision;
- const verdict = decision?.verdict;
- const pill = verdict ? VERDICT_PILL[verdict] : 'verdict-neutral';
- return (
-
-
-
-
- {report.ticker}
-
- {report.market ? ({ US: '美股', HK: '港股', CN: 'A股' } as Record)[report.market] ?? report.market : '—'}
-
-
-
{report.company_name}
-
-
- {report.trading_decision || decision?.verdictLabel || '待裁决'}
-
-
-
- {report.summary || '暂无摘要'}
-
-
- {report.created_at ? new Date(report.created_at).toLocaleDateString('zh-CN') : '—'}
- 示例 / 延迟
-
-
- );
-}
diff --git a/web/frontend/src/components/auth/CaptchaImage.tsx b/web/frontend/src/components/auth/CaptchaImage.tsx
deleted file mode 100644
index 6213015..0000000
--- a/web/frontend/src/components/auth/CaptchaImage.tsx
+++ /dev/null
@@ -1,165 +0,0 @@
-'use client';
-
-import React, { useEffect, useRef } from 'react';
-
-export interface CaptchaImageProps {
- width?: number;
- height?: number;
- className?: string;
- onIdChange?: (id: string) => void; // 后端下发的 captcha_id 回传给父组件(不泄露 code)
-}
-
-function randomColor(min = 0, max = 255) {
- const r = min + Math.floor(Math.random() * (max - min));
- const g = min + Math.floor(Math.random() * (max - min));
- const b = min + Math.floor(Math.random() * (max - min));
- return `rgb(${r},${g},${b})`;
-}
-
-function randomChar() {
- const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // 排除易混淆字符
- return chars[Math.floor(Math.random() * chars.length)];
-}
-
-export function CaptchaImage({
- width = 140,
- height = 48,
- className,
- onIdChange,
-}: CaptchaImageProps) {
- const canvasRef = useRef
(null);
- const codeRef = useRef('');
- const initRef = useRef(false);
- const deriveCodeFromSeed = async (seed: string, length = 5) => {
- // 与后端完全一致:SHA256(seed) -> 映射到字符集
- const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
- if (typeof window !== 'undefined' && window.crypto && window.crypto.subtle) {
- const encoder = new TextEncoder();
- const data = encoder.encode(seed);
- const digestBuf = await window.crypto.subtle.digest('SHA-256', data);
- const digest = new Uint8Array(digestBuf);
- const idxs: number[] = [];
- for (let i = 0; i < length; i++) {
- const b = digest.length ? (digest[i % digest.length] ?? 0) : 0;
- idxs.push(b % chars.length);
- }
- return idxs.map(i => chars.charAt(i)).join('');
- }
- // 退化策略(无 SubtleCrypto 时):用 TextEncoder 字节循环映射
- const encoder = new TextEncoder();
- const bytes = encoder.encode(seed);
- const idxs: number[] = [];
- for (let i = 0; i < length; i++) {
- const b = bytes.length ? (bytes[i % bytes.length] ?? 0) : 0;
- idxs.push(b % chars.length);
- }
- return idxs.map(i => chars.charAt(i)).join('');
- };
-
- const draw = (newCode: string) => {
- const canvas = canvasRef.current;
- if (!canvas) return;
- const ctx = canvas.getContext('2d');
- if (!ctx) return;
-
- // 背景
- ctx.clearRect(0, 0, width, height);
- ctx.fillStyle = randomColor(200, 255);
- ctx.fillRect(0, 0, width, height);
-
- // 干扰线
- for (let i = 0; i < 6; i++) {
- ctx.strokeStyle = randomColor(80, 200);
- ctx.beginPath();
- ctx.moveTo(Math.random() * width, Math.random() * height);
- ctx.lineTo(Math.random() * width, Math.random() * height);
- ctx.stroke();
- }
-
- // 干扰点
- for (let i = 0; i < 60; i++) {
- ctx.fillStyle = randomColor(100, 255);
- ctx.beginPath();
- ctx.arc(Math.random() * width, Math.random() * height, Math.random() * 1.5, 0, Math.PI * 2);
- ctx.fill();
- }
-
- // 绘制字符
- ctx.font = 'bold 28px sans-serif';
- ctx.textBaseline = 'middle';
- const charSpace = width / (newCode.length + 1);
-
- for (let i = 0; i < newCode.length; i++) {
- const ch = newCode.charAt(i);
- const rotate = (Math.random() - 0.5) * 0.5; // -0.25 ~ 0.25 弧度
- const x = charSpace * (i + 1);
- const y = height / 2 + (Math.random() - 0.5) * 8;
-
- ctx.save();
- ctx.translate(x, y);
- ctx.rotate(rotate);
- ctx.fillStyle = randomColor(50, 160);
- ctx.fillText(ch, -8, 8);
- ctx.restore();
- }
- };
-
- const regenerate = async () => {
- // 后端拉取挑战:收到 seed,派生 code(不外泄),仅回传 captcha_id
- try {
- const { authAPI } = await import('@/lib/apiClient');
- const { captcha_id, seed } = await authAPI.getCaptcha();
- const code = await deriveCodeFromSeed(seed, 5);
- codeRef.current = code;
- onIdChange?.(captcha_id);
- draw(code);
- } catch {
- // 后端不可用时,降级为本地随机模式(不影响正常使用)
- const newCode = Array.from({ length: 5 }, () => randomChar()).join('');
- codeRef.current = newCode;
- draw(newCode);
- }
- };
-
- // 仅首次挂载拉取一次;开发模式下避免 Strict Mode 双挂载引起的重复请求
- useEffect(() => {
- if (initRef.current) return;
- initRef.current = true;
- // 开发模式的全局路径级时间戳防抖,抑制 Strict Mode 的即时二次初始化
- const isDev = process.env.NODE_ENV === 'development';
- if (typeof window !== 'undefined' && isDev) {
- const path = window.location?.pathname || 'unknown';
- const key = '__captchaInitTs__';
- const now = Date.now();
- const w = window as any;
- w[key] = w[key] || {};
- const last = w[key][path] || 0;
- if (now - last < 3000) {
- // 在3秒窗口内的第二次“首次初始化”,跳过以避免重复请求
- return;
- }
- w[key][path] = now;
- }
- void regenerate();
-
- }, []);
-
- return (
-
-
-
-
- );
-}
-
-export default CaptchaImage;
diff --git a/web/frontend/src/components/auth/LoginForm.tsx b/web/frontend/src/components/auth/LoginForm.tsx
index 507bfc2..9ca0a7f 100644
--- a/web/frontend/src/components/auth/LoginForm.tsx
+++ b/web/frontend/src/components/auth/LoginForm.tsx
@@ -1,10 +1,10 @@
'use client';
-import React, { useState, useEffect } from 'react';
+import React, { useEffect, useRef, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/lib/auth';
-import CaptchaImage from './CaptchaImage';
+import { Turnstile, TurnstileRef } from './Turnstile';
interface LoginFormProps {
onShowToast: (message: string, type: 'success' | 'error' | 'warning' | 'info') => void;
@@ -12,26 +12,25 @@ interface LoginFormProps {
export function LoginForm({ onShowToast }: LoginFormProps) {
const [loginMode, setLoginMode] = useState<'password' | 'email'>('password');
-
+
const [formData, setFormData] = useState({
username: '',
password: '',
});
const [showPassword, setShowPassword] = useState(false);
-
+
const [emailForCode, setEmailForCode] = useState('');
const [verificationCode, setVerificationCode] = useState('');
const [countdown, setCountdown] = useState(0);
const [isSendingCode, setIsSendingCode] = useState(false);
-
+
const [isLoading, setIsLoading] = useState(false);
- const [captchaId, setCaptchaId] = useState('');
- const [captchaInput, setCaptchaInput] = useState('');
- const [captchaKey, setCaptchaKey] = useState(0);
+ const [turnstileToken, setTurnstileToken] = useState('');
+ const turnstileRef = useRef(null);
const { login, loginWithEmailCode } = useAuth();
const router = useRouter();
-
+
useEffect(() => {
if (countdown > 0) {
const timer = setTimeout(() => setCountdown(countdown - 1), 1000);
@@ -40,33 +39,34 @@ export function LoginForm({ onShowToast }: LoginFormProps) {
return undefined;
}, [countdown]);
+ const resetTurnstile = () => {
+ setTurnstileToken('');
+ turnstileRef.current?.reset();
+ };
+
const handleSendCode = async () => {
if (!emailForCode || !emailForCode.includes('@')) {
onShowToast('请输入有效的邮箱地址', 'warning');
return;
}
-
- if (!captchaId || !captchaInput.trim()) {
- onShowToast('请输入图形验证码', 'warning');
+
+ if (!turnstileToken) {
+ onShowToast('请先完成人机验证', 'warning');
return;
}
-
+
setIsSendingCode(true);
-
+
try {
const { authAPI } = await import('@/lib/apiClient');
-
- await authAPI.sendEmailCode(emailForCode, {
- id: captchaId,
- answer: captchaInput.trim(),
- });
-
+
+ await authAPI.sendEmailCode(emailForCode, turnstileToken);
+
onShowToast('验证码已发送到您的邮箱', 'success');
setCountdown(60);
} catch (error: any) {
onShowToast(error.message || '发送验证码失败,请稍后重试', 'error');
- setCaptchaKey((k) => k + 1);
- setCaptchaInput('');
+ resetTurnstile();
} finally {
setIsSendingCode(false);
}
@@ -75,60 +75,58 @@ export function LoginForm({ onShowToast }: LoginFormProps) {
const handlePasswordSubmit = async (e: React.FormEvent) => {
e.preventDefault();
- if (!captchaId || !captchaInput.trim()) {
- onShowToast('请输入图形验证码', 'warning');
+ if (!turnstileToken) {
+ onShowToast('请先完成人机验证', 'warning');
return;
}
setIsLoading(true);
try {
- await login(formData.username, formData.password, { id: captchaId, answer: captchaInput.trim() });
+ await login(formData.username, formData.password, turnstileToken);
onShowToast('登录成功!正在跳转...', 'success');
-
+
await new Promise(resolve => setTimeout(resolve, 500));
router.replace('/');
} catch (error: any) {
const errorMessage = error.message || '登录失败,请检查用户名和密码';
onShowToast(errorMessage, 'error');
setIsLoading(false);
- setCaptchaKey((k) => k + 1);
- setCaptchaInput('');
+ resetTurnstile();
}
};
-
+
const handleEmailLogin = async (e: React.FormEvent) => {
e.preventDefault();
-
+
if (!emailForCode || !emailForCode.includes('@')) {
onShowToast('请输入有效的邮箱地址', 'warning');
return;
}
-
+
if (!verificationCode || verificationCode.length !== 6) {
onShowToast('请输入6位验证码', 'warning');
return;
}
-
- if (!captchaId || !captchaInput.trim()) {
- onShowToast('请输入图形验证码', 'warning');
+
+ if (!turnstileToken) {
+ onShowToast('请先完成人机验证', 'warning');
return;
}
-
+
setIsLoading(true);
-
+
try {
- await loginWithEmailCode(emailForCode, verificationCode, { id: captchaId, answer: captchaInput.trim() });
+ await loginWithEmailCode(emailForCode, verificationCode, turnstileToken);
onShowToast('登录成功!正在跳转...', 'success');
-
+
await new Promise(resolve => setTimeout(resolve, 500));
router.replace('/');
} catch (error: any) {
const errorMessage = error.message || '登录失败,请检查验证码';
onShowToast(errorMessage, 'error');
setIsLoading(false);
- setCaptchaKey((k) => k + 1);
- setCaptchaInput('');
+ resetTurnstile();
setVerificationCode('');
}
};
@@ -226,21 +224,13 @@ export function LoginForm({ onShowToast }: LoginFormProps) {
-
-
setCaptchaInput(e.target.value)}
- placeholder="请输入验证码"
- className="flex-1 min-w-0 h-12 px-4 bg-dark-tertiary border border-dark-border text-white rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-primary transition-all"
- required
- />
-
-
-
-
+
);
}
-
-
diff --git a/web/frontend/src/components/auth/RegisterForm.tsx b/web/frontend/src/components/auth/RegisterForm.tsx
index cf66ef4..f3bc291 100644
--- a/web/frontend/src/components/auth/RegisterForm.tsx
+++ b/web/frontend/src/components/auth/RegisterForm.tsx
@@ -1,10 +1,10 @@
'use client';
-import React, { useState, useEffect } from 'react';
+import React, { useEffect, useRef, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/lib/auth';
-import CaptchaImage from './CaptchaImage';
+import { Turnstile, TurnstileRef } from './Turnstile';
interface RegisterFormProps {
onSubmit?: (data: { username: string; email: string; password: string }) => void;
@@ -19,10 +19,9 @@ export function RegisterForm({ onSubmit: _onSubmit, externalLoading: _externalLo
email: '',
});
const [isLoading, setIsLoading] = useState(false);
- const [captchaId, setCaptchaId] = useState('');
- const [captchaInput, setCaptchaInput] = useState('');
- const [captchaKey, setCaptchaKey] = useState(0);
-
+ const [turnstileToken, setTurnstileToken] = useState('');
+ const turnstileRef = useRef(null);
+
const [emailVerificationCode, setEmailVerificationCode] = useState('');
const [countdown, setCountdown] = useState(0);
const [isSendingCode, setIsSendingCode] = useState(false);
@@ -38,34 +37,35 @@ export function RegisterForm({ onSubmit: _onSubmit, externalLoading: _externalLo
return undefined;
}, [countdown]);
+ const resetTurnstile = () => {
+ setTurnstileToken('');
+ turnstileRef.current?.reset();
+ };
+
const handleSendEmailCode = async () => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!formData.email || !emailRegex.test(formData.email)) {
onShowToast('请先输入有效的邮箱地址', 'warning');
return;
}
-
- if (!captchaId || !captchaInput.trim()) {
- onShowToast('请输入图形验证码', 'warning');
+
+ if (!turnstileToken) {
+ onShowToast('请先完成人机验证', 'warning');
return;
}
-
+
setIsSendingCode(true);
-
+
try {
const { authAPI } = await import('@/lib/apiClient');
-
- await authAPI.sendEmailCodeForRegister(formData.email, {
- id: captchaId,
- answer: captchaInput.trim(),
- });
-
+
+ await authAPI.sendEmailCodeForRegister(formData.email, turnstileToken);
+
onShowToast('验证码已发送到您的邮箱,请查收', 'success');
setCountdown(60);
} catch (error: any) {
onShowToast(error.message || '发送验证码失败,请稍后重试', 'error');
- setCaptchaKey((k) => k + 1);
- setCaptchaInput('');
+ resetTurnstile();
} finally {
setIsSendingCode(false);
}
@@ -83,8 +83,8 @@ export function RegisterForm({ onSubmit: _onSubmit, externalLoading: _externalLo
return false;
}
- if (!captchaId || !captchaInput.trim()) {
- onShowToast('请输入图形验证码', 'warning');
+ if (!turnstileToken) {
+ onShowToast('请先完成人机验证', 'warning');
return false;
}
@@ -102,23 +102,22 @@ export function RegisterForm({ onSubmit: _onSubmit, externalLoading: _externalLo
try {
await register(
- formData.username,
- formData.email,
+ formData.username,
+ formData.email,
undefined,
- { id: captchaId, answer: captchaInput.trim() },
+ turnstileToken,
emailVerificationCode
);
-
+
onShowToast('注册成功!正在跳转...', 'success');
-
+
await new Promise(resolve => setTimeout(resolve, 500));
router.replace('/?setup_password=true');
} catch (error: any) {
const errorMessage = error.message || '注册失败,请稍后重试';
onShowToast(errorMessage, 'error');
setIsLoading(false);
- setCaptchaKey((k) => k + 1);
- setCaptchaInput('');
+ resetTurnstile();
setEmailVerificationCode('');
}
};
@@ -224,21 +223,13 @@ export function RegisterForm({ onSubmit: _onSubmit, externalLoading: _externalLo
-
-
setCaptchaInput(e.target.value)}
- placeholder="请输入验证码"
- className="flex-1 min-w-0 h-12 px-4 bg-dark-tertiary border border-dark-border text-white rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-primary focus:border-accent-primary transition-all"
- required
- />
-
-
-
-
+
);
}
-
diff --git a/web/frontend/src/components/auth/Turnstile.tsx b/web/frontend/src/components/auth/Turnstile.tsx
new file mode 100644
index 0000000..2c4b59c
--- /dev/null
+++ b/web/frontend/src/components/auth/Turnstile.tsx
@@ -0,0 +1,137 @@
+'use client';
+
+import React, { useEffect, useRef, useImperativeHandle, forwardRef, useState } from 'react';
+
+/**
+ * Cloudflare Turnstile 人机验证组件。
+ *
+ * 通过 NEXT_PUBLIC_TURNSTILE_SITE_KEY 读取 sitekey,默认使用 Cloudflare 官方
+ * 测试 sitekey(始终通过),方便联调;生产环境请覆盖为真实 sitekey。
+ *
+ * 组件挂载后加载 Turnstile 脚本并显式渲染 widget;token 获取后通过
+ * onTokenChange 回传给父组件。父组件可通过 ref 调用 reset() 强制刷新挑战。
+ */
+
+// Cloudflare 官方测试 sitekey(始终通过)
+const TEST_SITE_KEY = '1x00000000000000000000AA';
+const SITE_KEY = process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY || TEST_SITE_KEY;
+const SCRIPT_SRC = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit';
+
+declare global {
+ interface Window {
+ turnstile?: {
+ render: (el: HTMLElement, opts: any) => string;
+ reset: (id?: string) => void;
+ remove: (id: string) => void;
+ };
+ }
+}
+
+let scriptLoadPromise: Promise | null = null;
+
+function loadTurnstileScript(): Promise {
+ if (typeof window === 'undefined') return Promise.resolve();
+ if (window.turnstile) return Promise.resolve();
+ if (scriptLoadPromise) return scriptLoadPromise;
+ scriptLoadPromise = new Promise((resolve, reject) => {
+ const existing = document.querySelector(
+ `script[src="${SCRIPT_SRC}"]`,
+ );
+ if (existing) {
+ existing.addEventListener('load', () => resolve());
+ existing.addEventListener('error', () => reject(new Error('Turnstile script load failed')));
+ return;
+ }
+ const s = document.createElement('script');
+ s.src = SCRIPT_SRC;
+ s.async = true;
+ s.defer = true;
+ s.onload = () => resolve();
+ s.onerror = () => reject(new Error('Turnstile script load failed'));
+ document.head.appendChild(s);
+ });
+ return scriptLoadPromise;
+}
+
+export interface TurnstileRef {
+ reset: () => void;
+}
+
+interface TurnstileProps {
+ onTokenChange: (token: string) => void;
+ className?: string;
+}
+
+export const Turnstile = forwardRef(function Turnstile(
+ { onTokenChange, className },
+ ref,
+) {
+ const containerRef = useRef(null);
+ const widgetIdRef = useRef(null);
+ const [loadError, setLoadError] = useState(false);
+
+ // token callback 必须稳定,避免 widget 重新渲染时丢失引用
+ const tokenCbRef = useRef<(token: string) => void>(onTokenChange);
+ tokenCbRef.current = onTokenChange;
+
+ useImperativeHandle(ref, () => ({
+ reset: () => {
+ if (widgetIdRef.current && window.turnstile) {
+ try {
+ window.turnstile.reset(widgetIdRef.current);
+ } catch {
+ /* widget may already be removed */
+ }
+ }
+ },
+ }));
+
+ useEffect(() => {
+ let cancelled = false;
+ loadTurnstileScript()
+ .then(() => {
+ if (cancelled || !containerRef.current || !window.turnstile) return;
+ // 避免重复渲染
+ if (widgetIdRef.current) {
+ try {
+ window.turnstile.remove(widgetIdRef.current);
+ } catch {
+ /* noop */
+ }
+ }
+ widgetIdRef.current = window.turnstile.render(containerRef.current, {
+ sitekey: SITE_KEY,
+ callback: (token: string) => tokenCbRef.current(token),
+ 'error-callback': () => tokenCbRef.current(''),
+ 'expired-callback': () => tokenCbRef.current(''),
+ theme: 'dark',
+ });
+ })
+ .catch(() => setLoadError(true));
+
+ return () => {
+ cancelled = true;
+ if (widgetIdRef.current && window.turnstile) {
+ try {
+ window.turnstile.remove(widgetIdRef.current);
+ } catch {
+ /* noop */
+ }
+ widgetIdRef.current = null;
+ }
+ };
+ }, []);
+
+ return (
+
+
+ {loadError && (
+
+ 人机验证组件加载失败,请检查网络后刷新页面
+
+ )}
+
+ );
+});
+
+export default Turnstile;
diff --git a/web/frontend/src/components/site/AccountLayout.tsx b/web/frontend/src/components/site/AccountLayout.tsx
new file mode 100644
index 0000000..fbb3a0e
--- /dev/null
+++ b/web/frontend/src/components/site/AccountLayout.tsx
@@ -0,0 +1,95 @@
+'use client';
+
+import { ReactNode } from 'react';
+import Link from 'next/link';
+import { useAuth } from '@/lib/auth';
+import { SiteLayout } from '@/components/site/SiteLayout';
+
+interface AccountLayoutProps {
+ active: 'me' | 'billing' | 'preferences';
+ title: string;
+ subtitle: string;
+ eyebrow?: string;
+ actions?: ReactNode;
+ children: ReactNode;
+}
+
+/**
+ * 登录后个人工作区的侧栏布局。
+ * 对齐设计稿 07 的 .account-layout + .side-nav:
+ * 左侧固定侧栏(用户名片 + 导航),右侧主内容区;
+ * 窄屏下侧栏改为横向滚动导航,与设计响应式一致。
+ */
+export function AccountLayout({ active, title, subtitle, eyebrow, actions, children }: AccountLayoutProps) {
+ const { user } = useAuth();
+ const initial = (user?.username?.[0] ?? 'U').toUpperCase();
+
+ const navItems = [
+ { k: 'me', href: '/me', label: '我的分析', icon: 'fa-folder-open' },
+ { k: 'billing', href: '/me/billing', label: '订阅明细', icon: 'fa-receipt' },
+ { k: 'preferences', href: '/me/preferences', label: '账户偏好', icon: 'fa-sliders' },
+ ] as const;
+
+ return (
+
+
+ {/* 侧栏 */}
+
+
+ {/* 主内容区 */}
+
+
+
+ {eyebrow && (
+
+ {eyebrow}
+
+ )}
+
{title}
+
{subtitle}
+
+ {actions &&
{actions}
}
+
+ {children}
+
+
+
+ );
+}
+
+export default AccountLayout;
diff --git a/web/frontend/src/components/site/ResearchCard.tsx b/web/frontend/src/components/site/ResearchCard.tsx
new file mode 100644
index 0000000..78b922e
--- /dev/null
+++ b/web/frontend/src/components/site/ResearchCard.tsx
@@ -0,0 +1,41 @@
+import Link from 'next/link';
+import type { ReportPreview } from '@/types/report';
+import { VERDICT_PILL } from '@/types/report';
+
+/**
+ * 公开研究 / 我的分析列表中复用的研究报告卡片。
+ * 从 src/app/page.tsx 抽出,避免在 page 模块里导出非页面组件
+ * (Next.js 页面模块只应导出 default / metadata 等保留导出)。
+ */
+export function ResearchCard({ report }: { report: ReportPreview }) {
+ const decision = report.role_chain?.decision;
+ const verdict = decision?.verdict;
+ const pill = verdict ? VERDICT_PILL[verdict] : 'verdict-neutral';
+ return (
+
+
+
+
+ {report.ticker}
+
+ {report.market ? ({ US: '美股', HK: '港股', CN: 'A股' } as Record)[report.market] ?? report.market : '—'}
+
+
+
{report.company_name}
+
+
+ {report.trading_decision || decision?.verdictLabel || '待裁决'}
+
+
+
+ {report.summary || '暂无摘要'}
+
+
+ {report.created_at ? new Date(report.created_at).toLocaleDateString('zh-CN') : '—'}
+ 示例 / 延迟
+
+
+ );
+}
+
+export default ResearchCard;
diff --git a/web/frontend/src/lib/apiClient.ts b/web/frontend/src/lib/apiClient.ts
index 4ee9435..2aed351 100644
--- a/web/frontend/src/lib/apiClient.ts
+++ b/web/frontend/src/lib/apiClient.ts
@@ -86,45 +86,47 @@ apiClient.interceptors.response.use(
/**
* Auth API(公共客户端,不需要认证)
- * 后端已强制校验验证码:/api/auth/login 和 /api/auth/register
+ * 后端已强制校验 Cloudflare Turnstile 人机验证:/api/auth/login 和 /api/auth/register
*/
export const authAPI = {
- getCaptcha: async () => {
- const res = await publicApiClient.post('/api/auth/captcha/new', {});
- // seed 方案:后端只返回 seed,前端据此派生并绘制验证码
- return res.data as { captcha_id: string; seed: string };
+ getTurnstileSiteKey: async () => {
+ try {
+ const response = await publicApiClient.get('/api/auth/turnstile/sitekey');
+ return response.data as { sitekey: string };
+ } catch {
+ // 后端不可用时,前端回退到本地测试 sitekey(联调友好)
+ return { sitekey: '1x00000000000000000000AA' };
+ }
},
- login: async (username: string, password: string, captcha?: { id: string; answer: string }) => {
+ login: async (username: string, password: string, turnstileToken?: string) => {
try {
const payload: any = { username, password };
- if (captcha?.id && captcha?.answer) {
- payload.captcha_id = captcha.id;
- payload.captcha_answer = captcha.answer;
+ if (turnstileToken) {
+ payload.turnstile_token = turnstileToken;
}
const response = await publicApiClient.post('/api/auth/login', payload);
return response.data;
} catch (error: any) {
- let errorMessage = error.response?.data?.detail ||
- error.response?.data?.message ||
- error.message ||
+ let errorMessage = error.response?.data?.detail ||
+ error.response?.data?.message ||
+ error.message ||
'登录失败,请稍后重试';
- if (typeof errorMessage === 'string' && /Invalid or expired captcha/i.test(errorMessage)) {
- errorMessage = '验证码无效或已过期';
+ if (typeof errorMessage === 'string' && /人机验证/i.test(errorMessage)) {
+ errorMessage = '人机验证失败,请刷新后重试';
}
throw new Error(errorMessage);
}
},
- register: async (username: string, email: string, password?: string, captcha?: { id: string; answer: string }, emailCode?: string) => {
+ register: async (username: string, email: string, password?: string, turnstileToken?: string, emailCode?: string) => {
try {
const payload: any = { username, email };
if (password) {
payload.password = password;
}
- if (captcha?.id && captcha?.answer) {
- payload.captcha_id = captcha.id;
- payload.captcha_answer = captcha.answer;
+ if (turnstileToken) {
+ payload.turnstile_token = turnstileToken;
}
if (emailCode) {
payload.email_code = emailCode;
@@ -132,12 +134,12 @@ export const authAPI = {
const response = await publicApiClient.post('/api/auth/register', payload);
return response.data;
} catch (error: any) {
- let errorMessage = error.response?.data?.detail ||
- error.response?.data?.message ||
- error.message ||
+ let errorMessage = error.response?.data?.detail ||
+ error.response?.data?.message ||
+ error.message ||
'注册失败,请稍后重试';
- if (typeof errorMessage === 'string' && /Invalid or expired captcha/i.test(errorMessage)) {
- errorMessage = '验证码无效或已过期';
+ if (typeof errorMessage === 'string' && /人机验证/i.test(errorMessage)) {
+ errorMessage = '人机验证失败,请刷新后重试';
}
throw new Error(errorMessage);
}
@@ -152,9 +154,9 @@ export const authAPI = {
const response = await apiClient.post('/api/auth/set-password', payload);
return response.data;
} catch (error: any) {
- let errorMessage = error.response?.data?.detail ||
- error.response?.data?.message ||
- error.message ||
+ let errorMessage = error.response?.data?.detail ||
+ error.response?.data?.message ||
+ error.message ||
'设置密码失败,请稍后重试';
throw new Error(errorMessage);
}
@@ -165,62 +167,59 @@ export const authAPI = {
return response.data;
},
- sendEmailCode: async (email: string, captcha: { id: string; answer: string }) => {
+ sendEmailCode: async (email: string, turnstileToken?: string) => {
try {
const response = await publicApiClient.post('/api/auth/email-code/send', {
email,
- captcha_id: captcha.id,
- captcha_answer: captcha.answer,
+ turnstile_token: turnstileToken,
});
return response.data;
} catch (error: any) {
- let errorMessage = error.response?.data?.detail ||
- error.response?.data?.message ||
- error.message ||
+ let errorMessage = error.response?.data?.detail ||
+ error.response?.data?.message ||
+ error.message ||
'发送验证码失败,请稍后重试';
- if (typeof errorMessage === 'string' && /Invalid or expired captcha/i.test(errorMessage)) {
- errorMessage = '验证码无效或已过期';
+ if (typeof errorMessage === 'string' && /人机验证/i.test(errorMessage)) {
+ errorMessage = '人机验证失败,请刷新后重试';
}
throw new Error(errorMessage);
}
},
- sendEmailCodeForRegister: async (email: string, captcha: { id: string; answer: string }) => {
+ sendEmailCodeForRegister: async (email: string, turnstileToken?: string) => {
try {
const response = await publicApiClient.post('/api/auth/email-code/send-for-register', {
email,
- captcha_id: captcha.id,
- captcha_answer: captcha.answer,
+ turnstile_token: turnstileToken,
});
return response.data;
} catch (error: any) {
- let errorMessage = error.response?.data?.detail ||
- error.response?.data?.message ||
- error.message ||
+ let errorMessage = error.response?.data?.detail ||
+ error.response?.data?.message ||
+ error.message ||
'发送验证码失败,请稍后重试';
- if (typeof errorMessage === 'string' && /Invalid or expired captcha/i.test(errorMessage)) {
- errorMessage = '验证码无效或已过期';
+ if (typeof errorMessage === 'string' && /人机验证/i.test(errorMessage)) {
+ errorMessage = '人机验证失败,请刷新后重试';
}
throw new Error(errorMessage);
}
},
- loginWithEmailCode: async (email: string, code: string, captcha?: { id: string; answer: string }) => {
+ loginWithEmailCode: async (email: string, code: string, turnstileToken?: string) => {
try {
const payload: any = { email, code };
- if (captcha?.id && captcha?.answer) {
- payload.captcha_id = captcha.id;
- payload.captcha_answer = captcha.answer;
+ if (turnstileToken) {
+ payload.turnstile_token = turnstileToken;
}
const response = await publicApiClient.post('/api/auth/email-code/login', payload);
return response.data;
} catch (error: any) {
- let errorMessage = error.response?.data?.detail ||
- error.response?.data?.message ||
- error.message ||
+ let errorMessage = error.response?.data?.detail ||
+ error.response?.data?.message ||
+ error.message ||
'登录失败,请稍后重试';
- if (typeof errorMessage === 'string' && /Invalid or expired captcha/i.test(errorMessage)) {
- errorMessage = '验证码无效或已过期';
+ if (typeof errorMessage === 'string' && /人机验证/i.test(errorMessage)) {
+ errorMessage = '人机验证失败,请刷新后重试';
}
throw new Error(errorMessage);
}
diff --git a/web/frontend/src/lib/auth.tsx b/web/frontend/src/lib/auth.tsx
index 8fc8967..a812278 100644
--- a/web/frontend/src/lib/auth.tsx
+++ b/web/frontend/src/lib/auth.tsx
@@ -8,9 +8,9 @@ import { queryClient } from './react-query';
interface AuthContextType {
user: User | null;
isLoading: boolean;
- login: (username: string, password: string, captcha?: { id: string; answer: string }) => Promise;
- loginWithEmailCode: (email: string, code: string, captcha: { id: string; answer: string }) => Promise;
- register: (username: string, email: string, password?: string, captcha?: { id: string; answer: string }, emailCode?: string) => Promise;
+ login: (username: string, password: string, turnstileToken?: string) => Promise;
+ loginWithEmailCode: (email: string, code: string, turnstileToken?: string) => Promise;
+ register: (username: string, email: string, password?: string, turnstileToken?: string, emailCode?: string) => Promise;
logout: () => void;
refreshUser: () => Promise;
token: string | null;
@@ -48,9 +48,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}
};
- const login = async (username: string, password: string, captcha?: { id: string; answer: string }) => {
+ const login = async (username: string, password: string, turnstileToken?: string) => {
try {
- const response: AuthResponse = await authAPI.login(username, password, captcha);
+ const response: AuthResponse = await authAPI.login(username, password, turnstileToken);
// 立即设置用户状态和token
setUser(response.user);
@@ -68,9 +68,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}
};
- const loginWithEmailCode = async (email: string, code: string, captcha: { id: string; answer: string }) => {
+ const loginWithEmailCode = async (email: string, code: string, turnstileToken?: string) => {
try {
- const response: AuthResponse = await authAPI.loginWithEmailCode(email, code, captcha);
+ const response: AuthResponse = await authAPI.loginWithEmailCode(email, code, turnstileToken);
// 立即设置用户状态和token
setUser(response.user);
@@ -88,9 +88,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}
};
- const register = async (username: string, email: string, password?: string, captcha?: { id: string; answer: string }, emailCode?: string) => {
+ const register = async (username: string, email: string, password?: string, turnstileToken?: string, emailCode?: string) => {
try {
- const response: AuthResponse = await authAPI.register(username, email, password, captcha, emailCode);
+ const response: AuthResponse = await authAPI.register(username, email, password, turnstileToken, emailCode);
// 立即设置用户状态和token
setUser(response.user);