diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index 717b310..b976ed5 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -13,6 +13,7 @@ from app.schemas.auth import ( LoginResponse, RefreshTokenRequest, RefreshTokenResponse, + SendEmailCodeRequest, UserResponse, ) from app.services.auth import ( @@ -27,11 +28,65 @@ from app.services.auth import ( decode_token, get_user_organization_info, ) +from app.services.verification import generate_code, verify_code +from app.services.email import send_verification_email from app.services.audit import log_action router = APIRouter(prefix="/auth", tags=["认证"]) +@router.post("/send-code") +async def send_email_code( + request: SendEmailCodeRequest, + req: Request, + db: AsyncSession = Depends(get_db), +): + """ + 发送邮箱验证码 + + - purpose=register: 注册用,邮箱不能已被注册 + - purpose=login: 登录用,邮箱必须已注册 + - purpose=reset_password: 重置密码用,邮箱必须已注册 + - 60秒内不可重复发送 + """ + email = request.email + purpose = request.purpose + + # 根据用途检查邮箱状态 + existing = await get_user_by_email(db, email) + + if purpose == "register": + if existing: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="该邮箱已被注册", + ) + elif purpose in ("login", "reset_password"): + if not existing: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="该邮箱未注册", + ) + + # 生成验证码 + code, error = generate_code(email, purpose) + if error: + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail=error, + ) + + # 发送邮件 + sent = send_verification_email(email, code, purpose) + if not sent: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="验证码发送失败,请稍后重试", + ) + + return {"message": "验证码已发送", "expires_in": 300} + + @router.post("/register", response_model=LoginResponse, status_code=status.HTTP_201_CREATED) async def register( request: RegisterRequest, @@ -41,24 +96,24 @@ async def register( """ 用户注册 - - 支持邮箱或手机号注册(至少提供一个) + - 需要先调用 /auth/send-code 获取邮箱验证码 + - 验证码正确后完成注册 - 注册后自动登录,返回 Token """ - # 验证至少提供邮箱或手机号 - if not request.email and not request.phone: + # 验证邮箱验证码 + if not verify_code(request.email, request.email_code, "register"): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="请提供邮箱或手机号", + detail="验证码错误或已过期", ) # 检查邮箱是否已存在 - if request.email: - existing = await get_user_by_email(db, request.email) - if existing: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="该邮箱已被注册", - ) + existing = await get_user_by_email(db, request.email) + if existing: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="该邮箱已被注册", + ) # 检查手机号是否已存在 if request.phone: @@ -69,7 +124,7 @@ async def register( detail="该手机号已被注册", ) - # 创建用户 + # 创建用户(邮箱已验证) user = await create_user( db=db, email=request.email, @@ -77,6 +132,7 @@ async def register( password=request.password, name=request.name, role=request.role, + is_verified=True, ) # 生成 Token @@ -122,8 +178,8 @@ async def login( """ 用户登录 - - 支持邮箱+密码 或 手机号+密码 登录 - - 返回 accessToken 和 refreshToken + - 支持邮箱+密码登录 + - 支持邮箱+验证码登录(需先调用 /auth/send-code) """ # 验证请求参数 if not request.email and not request.phone: @@ -132,19 +188,35 @@ async def login( detail="请提供邮箱或手机号", ) - if not request.password: + if not request.password and not request.email_code: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="请提供密码", + detail="请提供密码或验证码", ) - # 验证用户 - user = await authenticate_user( - db=db, - email=request.email, - phone=request.phone, - password=request.password, - ) + user = None + + # 验证码登录 + if request.email_code and request.email: + if not verify_code(request.email, request.email_code, "login"): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="验证码错误或已过期", + ) + user = await get_user_by_email(db, request.email) + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="用户不存在", + ) + else: + # 密码登录 + user = await authenticate_user( + db=db, + email=request.email, + phone=request.phone, + password=request.password, + ) if not user: raise HTTPException( diff --git a/backend/app/config.py b/backend/app/config.py index a9dd1e9..97de14b 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -39,6 +39,18 @@ class Settings(BaseSettings): OSS_BUCKET_NAME: str = "miaosi-files" OSS_BUCKET_DOMAIN: str = "" # 公开访问域名,如 https://miaosi-files.oss-cn-hangzhou.aliyuncs.com + # 邮件 SMTP + SMTP_HOST: str = "" + SMTP_PORT: int = 465 + SMTP_USER: str = "" + SMTP_PASSWORD: str = "" + SMTP_FROM_NAME: str = "秒思智能审核平台" + SMTP_USE_SSL: bool = True + + # 验证码 + VERIFICATION_CODE_EXPIRE_MINUTES: int = 5 + VERIFICATION_CODE_LENGTH: int = 6 + # 加密密钥 ENCRYPTION_KEY: str = "" diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index 56abbca..a8eb979 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -8,13 +8,28 @@ from app.models.user import UserRole # ===== 请求 ===== +class SendEmailCodeRequest(BaseModel): + """发送邮箱验证码请求""" + email: EmailStr + purpose: str = Field("register", pattern=r"^(register|login|reset_password)$") + + class Config: + json_schema_extra = { + "example": { + "email": "user@example.com", + "purpose": "register" + } + } + + class RegisterRequest(BaseModel): """注册请求""" - email: Optional[EmailStr] = None + email: EmailStr phone: Optional[str] = Field(None, pattern=r"^1[3-9]\d{9}$") password: str = Field(..., min_length=6, max_length=128) name: str = Field(..., min_length=1, max_length=100) role: UserRole + email_code: str = Field(..., min_length=4, max_length=8, description="邮箱验证码") class Config: json_schema_extra = { @@ -22,17 +37,18 @@ class RegisterRequest(BaseModel): "email": "user@example.com", "password": "password123", "name": "张三", - "role": "creator" + "role": "creator", + "email_code": "123456" } } class LoginRequest(BaseModel): - """登录请求(邮箱或手机号)""" + """登录请求(支持邮箱+密码 或 邮箱+验证码)""" email: Optional[EmailStr] = None phone: Optional[str] = None password: Optional[str] = None - sms_code: Optional[str] = None # 短信验证码 + email_code: Optional[str] = None # 邮箱验证码 class Config: json_schema_extra = { diff --git a/backend/app/services/auth.py b/backend/app/services/auth.py index 2a7ecd5..e4c6bc2 100644 --- a/backend/app/services/auth.py +++ b/backend/app/services/auth.py @@ -100,6 +100,7 @@ async def create_user( password: str, name: str, role: UserRole, + is_verified: bool = False, ) -> User: """创建用户""" user_id = generate_id("U") @@ -112,7 +113,7 @@ async def create_user( name=name, role=role, is_active=True, - is_verified=False, + is_verified=is_verified, ) db.add(user) diff --git a/backend/app/services/email.py b/backend/app/services/email.py new file mode 100644 index 0000000..18114ba --- /dev/null +++ b/backend/app/services/email.py @@ -0,0 +1,105 @@ +""" +邮件发送服务 + +开发环境:将验证码输出到控制台(不实际发送)。 +生产环境:通过 SMTP 发送邮件。 +""" +import smtplib +import logging +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart + +from app.config import settings + +logger = logging.getLogger(__name__) + + +def _build_verification_email(to_email: str, code: str, purpose: str) -> MIMEMultipart: + """构建验证码邮件""" + purpose_text = { + "register": "注册账号", + "login": "登录", + "reset_password": "重置密码", + }.get(purpose, "操作") + + subject = f"【{settings.APP_NAME}】{purpose_text}验证码" + html = f""" +
+
+

{settings.APP_NAME}

+
+
+

您好,

+

+ 您正在{purpose_text},验证码为: +

+
+ {code} +
+

+ 验证码 {settings.VERIFICATION_CODE_EXPIRE_MINUTES} 分钟内有效,请勿泄露给他人。 +

+

+ 如非本人操作,请忽略此邮件。 +

+
+
+ """ + + msg = MIMEMultipart("alternative") + msg["Subject"] = subject + msg["From"] = f"{settings.SMTP_FROM_NAME} <{settings.SMTP_USER}>" + msg["To"] = to_email + msg.attach(MIMEText(html, "html", "utf-8")) + return msg + + +def send_verification_email(to_email: str, code: str, purpose: str = "register") -> bool: + """ + 发送验证码邮件。 + + 开发环境下仅打印到控制台,不实际发送。 + 返回 True 表示成功。 + """ + purpose_text = { + "register": "注册", + "login": "登录", + "reset_password": "重置密码", + }.get(purpose, "操作") + + # 开发环境:仅打印到控制台 + if settings.ENVIRONMENT == "development" or not settings.SMTP_HOST: + logger.info( + "\n" + "============================================\n" + " 邮箱验证码 (开发模式 - 未实际发送)\n" + " 收件人: %s\n" + " 用途: %s\n" + " 验证码: %s\n" + " 有效期: %d 分钟\n" + "============================================", + to_email, purpose_text, code, + settings.VERIFICATION_CODE_EXPIRE_MINUTES, + ) + return True + + # 生产环境:通过 SMTP 发送 + try: + msg = _build_verification_email(to_email, code, purpose) + + if settings.SMTP_USE_SSL: + server = smtplib.SMTP_SSL(settings.SMTP_HOST, settings.SMTP_PORT) + else: + server = smtplib.SMTP(settings.SMTP_HOST, settings.SMTP_PORT) + server.starttls() + + server.login(settings.SMTP_USER, settings.SMTP_PASSWORD) + server.sendmail(settings.SMTP_USER, [to_email], msg.as_string()) + server.quit() + + logger.info("验证码邮件已发送: %s (%s)", to_email, purpose_text) + return True + + except Exception: + logger.exception("发送验证码邮件失败: %s", to_email) + return False diff --git a/backend/app/services/verification.py b/backend/app/services/verification.py new file mode 100644 index 0000000..03685dc --- /dev/null +++ b/backend/app/services/verification.py @@ -0,0 +1,96 @@ +""" +验证码服务 + +使用内存存储验证码,支持 TTL 自动过期。 +生产环境建议替换为 Redis 存储。 +""" +import secrets +import time +import logging +from typing import Optional + +from app.config import settings + +logger = logging.getLogger(__name__) + +# 内存存储: { "email:purpose" -> (code, expire_timestamp) } +_code_store: dict[str, tuple[str, float]] = {} + +# 发送频率限制: { "email:purpose" -> last_send_timestamp } +_rate_limit: dict[str, float] = {} + +# 最小发送间隔(秒) +SEND_INTERVAL = 60 + + +def _cleanup_expired() -> None: + """清理过期的验证码""" + now = time.time() + expired_keys = [k for k, (_, exp) in _code_store.items() if now > exp] + for k in expired_keys: + del _code_store[k] + + +def generate_code(email: str, purpose: str = "register") -> tuple[str, Optional[str]]: + """ + 生成验证码并存储。 + + 返回 (code, error)。 + error 为 None 表示成功,否则返回错误信息。 + """ + _cleanup_expired() + + key = f"{email}:{purpose}" + + # 检查发送频率 + now = time.time() + last_sent = _rate_limit.get(key, 0) + if now - last_sent < SEND_INTERVAL: + remaining = int(SEND_INTERVAL - (now - last_sent)) + return "", f"发送过于频繁,请 {remaining} 秒后重试" + + # 生成验证码 + code = "".join(str(secrets.randbelow(10)) for _ in range(settings.VERIFICATION_CODE_LENGTH)) + + # 存储(带 TTL) + expire_at = now + settings.VERIFICATION_CODE_EXPIRE_MINUTES * 60 + _code_store[key] = (code, expire_at) + _rate_limit[key] = now + + logger.info("验证码已生成: email=%s, purpose=%s", email, purpose) + return code, None + + +def verify_code(email: str, code: str, purpose: str = "register") -> bool: + """ + 验证验证码是否正确。 + + 验证成功后自动删除验证码(一次性使用)。 + """ + _cleanup_expired() + + key = f"{email}:{purpose}" + stored = _code_store.get(key) + + if not stored: + return False + + stored_code, expire_at = stored + + # 已过期 + if time.time() > expire_at: + del _code_store[key] + return False + + # 验证码匹配 + if stored_code == code: + del _code_store[key] # 一次性使用 + return True + + return False + + +def clear_all() -> None: + """清除所有验证码(用于测试)""" + _code_store.clear() + _rate_limit.clear() diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 38919c5..05f93d9 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -21,6 +21,8 @@ from app.services.health import ( get_health_checker, ) from app.middleware.rate_limit import RateLimitMiddleware +from app.services import verification as verification_module +from app.api import auth as auth_api_module @pytest.fixture(scope="session") @@ -32,6 +34,17 @@ def event_loop(): loop.close() +@pytest.fixture(autouse=True) +def _bypass_verification(monkeypatch): + """测试环境中跳过验证码验证,所有验证码校验直接通过""" + _always_true = lambda email, code, purpose="register": True + monkeypatch.setattr(verification_module, "verify_code", _always_true) + monkeypatch.setattr(auth_api_module, "verify_code", _always_true) + verification_module.clear_all() + yield + verification_module.clear_all() + + @pytest.fixture(autouse=True) def _clear_rate_limiter(): """清除限流中间件的请求记录,防止测试间互相影响""" diff --git a/backend/tests/test_auth_api.py b/backend/tests/test_auth_api.py index 5c33959..584086d 100644 --- a/backend/tests/test_auth_api.py +++ b/backend/tests/test_auth_api.py @@ -49,12 +49,12 @@ async def register_user( ) -> dict: """注册用户并返回响应对象""" payload = { + "email": email, "password": password, "name": name, "role": role, + "email_code": "000000", } - if email is not None: - payload["email"] = email if phone is not None: payload["phone"] = phone response = await client.post("/api/v1/auth/register", json=payload) @@ -113,25 +113,10 @@ class TestRegister: assert user["email"] == "user@example.com" assert user["name"] == "测试用户" assert user["role"] == "brand" - assert user["is_verified"] is False + assert user["is_verified"] is True @pytest.mark.asyncio - async def test_register_with_phone_success(self, client: AsyncClient): - """通过手机号注册成功""" - resp = await register_user(client, email=None, phone="13800138000", role="creator") - assert resp.status_code == 201 - - data = resp.json() - assert "access_token" in data - assert "refresh_token" in data - - user = data["user"] - assert user["phone"] == "13800138000" - assert user["email"] is None - assert user["role"] == "creator" - - @pytest.mark.asyncio - async def test_register_with_both_email_and_phone(self, client: AsyncClient): + async def test_register_with_email_and_phone(self, client: AsyncClient): """同时提供邮箱和手机号注册成功""" resp = await register_user( client, @@ -147,14 +132,17 @@ class TestRegister: assert user["role"] == "agency" @pytest.mark.asyncio - async def test_register_missing_email_and_phone_returns_400(self, client: AsyncClient): - """不提供邮箱和手机号时返回 400""" - resp = await register_user(client, email=None, phone=None) - assert resp.status_code == 400 - - data = resp.json() - assert "detail" in data - assert "邮箱" in data["detail"] or "手机号" in data["detail"] + async def test_register_missing_email_returns_422(self, client: AsyncClient): + """不提供邮箱时返回 422(邮箱为必填字段)""" + payload = { + "phone": "13800138000", + "password": "Test1234!", + "name": "测试用户", + "role": "brand", + "email_code": "000000", + } + resp = await client.post("/api/v1/auth/register", json=payload) + assert resp.status_code == 422 @pytest.mark.asyncio async def test_register_duplicate_email_returns_400(self, client: AsyncClient): @@ -174,11 +162,11 @@ class TestRegister: async def test_register_duplicate_phone_returns_400(self, client: AsyncClient): """重复手机号注册返回 400""" # 第一次注册 - resp1 = await register_user(client, email=None, phone="13800000001") + resp1 = await register_user(client, email="phone1@example.com", phone="13800000001") assert resp1.status_code == 201 # 第二次用相同手机号注册 - resp2 = await register_user(client, email=None, phone="13800000001", name="另一个用户") + resp2 = await register_user(client, email="phone2@example.com", phone="13800000001", name="另一个用户") assert resp2.status_code == 400 data = resp2.json() @@ -238,7 +226,7 @@ class TestRegister: @pytest.mark.asyncio async def test_register_invalid_phone_format_returns_422(self, client: AsyncClient): """无效的手机号格式返回 422 (不匹配 ^1[3-9]\\d{9}$)""" - resp = await register_user(client, email=None, phone="12345") + resp = await register_user(client, email="badphone@example.com", phone="12345") assert resp.status_code == 422 @pytest.mark.asyncio @@ -341,9 +329,9 @@ class TestLogin: @pytest.mark.asyncio async def test_login_with_phone_success(self, client: AsyncClient): """通过手机号+密码登录成功""" - # 先注册 - await register_user(client, email=None, phone="13800138001", password="Test1234!") - # 再登录 + # 先注册(带邮箱+手机号) + await register_user(client, email="phonelogin@example.com", phone="13800138001", password="Test1234!") + # 用手机号登录 resp = await login_user(client, email=None, phone="13800138001", password="Test1234!") assert resp.status_code == 200 @@ -378,14 +366,14 @@ class TestLogin: assert "邮箱" in data["detail"] or "手机号" in data["detail"] @pytest.mark.asyncio - async def test_login_missing_password_returns_400(self, client: AsyncClient): - """不提供密码登录时返回 400""" + async def test_login_missing_password_and_code_returns_400(self, client: AsyncClient): + """不提供密码和验证码登录时返回 400""" payload = {"email": "test@example.com"} resp = await client.post("/api/v1/auth/login", json=payload) assert resp.status_code == 400 data = resp.json() - assert "密码" in data["detail"] + assert "密码" in data["detail"] or "验证码" in data["detail"] @pytest.mark.asyncio async def test_login_disabled_user_returns_403(self, client: AsyncClient): @@ -804,7 +792,7 @@ class TestAuthEndToEnd: """多用户注册不会互相影响""" resp1 = await register_user(client, email="user1@example.com", name="用户一", role="brand") resp2 = await register_user(client, email="user2@example.com", name="用户二", role="agency") - resp3 = await register_user(client, email=None, phone="13700137001", name="用户三", role="creator") + resp3 = await register_user(client, email="user3@example.com", phone="13700137001", name="用户三", role="creator") assert resp1.status_code == 201 assert resp2.status_code == 201 diff --git a/backend/tests/test_briefs_api.py b/backend/tests/test_briefs_api.py index 7fcf5ad..8addd4d 100644 --- a/backend/tests/test_briefs_api.py +++ b/backend/tests/test_briefs_api.py @@ -64,6 +64,7 @@ async def _register(client: AsyncClient, role: str, name: str | None = None): "password": "test123456", "name": name or f"Test {role.title()}", "role": role, + "email_code": "000000", }) assert resp.status_code == 201, f"Registration failed for {role}: {resp.text}" data = resp.json() diff --git a/backend/tests/test_dashboard_api.py b/backend/tests/test_dashboard_api.py index 420150b..ae919cc 100644 --- a/backend/tests/test_dashboard_api.py +++ b/backend/tests/test_dashboard_api.py @@ -58,6 +58,7 @@ async def _register(client: AsyncClient, role: str, name: str | None = None): "password": "test123456", "name": name or f"Test {role.title()}", "role": role, + "email_code": "000000", }) assert resp.status_code == 201, f"Registration failed for {role}: {resp.text}" data = resp.json() diff --git a/backend/tests/test_export_api.py b/backend/tests/test_export_api.py index 5a41867..27ec908 100644 --- a/backend/tests/test_export_api.py +++ b/backend/tests/test_export_api.py @@ -58,6 +58,7 @@ async def _register(client: AsyncClient, role: str, name: str | None = None): "password": "test123456", "name": name or f"Test {role.title()}", "role": role, + "email_code": "000000", }) assert resp.status_code == 201, f"Registration failed for {role}: {resp.text}" data = resp.json() diff --git a/backend/tests/test_organizations_api.py b/backend/tests/test_organizations_api.py index 0a0fe96..630be08 100644 --- a/backend/tests/test_organizations_api.py +++ b/backend/tests/test_organizations_api.py @@ -71,6 +71,7 @@ async def _register(client: AsyncClient, role: str, name: str | None = None): "password": "test123456", "name": name or f"Test {role.title()}", "role": role, + "email_code": "000000", }) assert resp.status_code == 201, f"Registration failed for {role}: {resp.text}" data = resp.json() diff --git a/backend/tests/test_projects_api.py b/backend/tests/test_projects_api.py index 7f1092f..c54ada6 100644 --- a/backend/tests/test_projects_api.py +++ b/backend/tests/test_projects_api.py @@ -72,6 +72,7 @@ async def _register(client: AsyncClient, role: str, name: str | None = None): "password": "test123456", "name": name or f"Test {role.title()}", "role": role, + "email_code": "000000", }) assert resp.status_code == 201, f"Registration failed for {role}: {resp.text}" data = resp.json() diff --git a/backend/tests/test_tasks_api.py b/backend/tests/test_tasks_api.py index 4d2f6d6..df1ab83 100644 --- a/backend/tests/test_tasks_api.py +++ b/backend/tests/test_tasks_api.py @@ -73,6 +73,7 @@ async def _register(client: AsyncClient, role: str, name: str | None = None): "password": "test123456", "name": name or f"Test {role.title()}", "role": role, + "email_code": "000000", }) assert resp.status_code == 201, f"Registration failed for {role}: {resp.text}" data = resp.json() diff --git a/frontend/app/login/page.tsx b/frontend/app/login/page.tsx index 981a02f..0913c24 100644 --- a/frontend/app/login/page.tsx +++ b/frontend/app/login/page.tsx @@ -1,10 +1,14 @@ 'use client' -import { useState, useEffect, Suspense } from 'react' +import { useState, useEffect, useCallback, Suspense } from 'react' import { useRouter, useSearchParams } from 'next/navigation' import { useAuth } from '@/contexts/AuthContext' -import { ShieldCheck, AlertCircle, ArrowLeft, Mail, Lock } from 'lucide-react' +import { ShieldCheck, AlertCircle, ArrowLeft, Mail, Lock, KeyRound } from 'lucide-react' import Link from 'next/link' +import { api } from '@/lib/api' +import { USE_MOCK } from '@/contexts/AuthContext' + +type LoginMode = 'password' | 'code' function LoginForm() { const router = useRouter() @@ -12,13 +16,24 @@ function LoginForm() { const { login } = useAuth() const [email, setEmail] = useState('') const [password, setPassword] = useState('') + const [emailCode, setEmailCode] = useState('') + const [loginMode, setLoginMode] = useState('password') const [error, setError] = useState('') const [isLoading, setIsLoading] = useState(false) const [autoLoginAttempted, setAutoLoginAttempted] = useState(false) + const [codeSending, setCodeSending] = useState(false) + const [countdown, setCountdown] = useState(0) // 如果 URL 有 role 参数,自动触发 demo 登录 const roleFromUrl = searchParams.get('role') as 'creator' | 'agency' | 'brand' | null + // 倒计时 + useEffect(() => { + if (countdown <= 0) return + const timer = setTimeout(() => setCountdown(countdown - 1), 1000) + return () => clearTimeout(timer) + }, [countdown]) + const handleDemoLogin = async (role: 'creator' | 'agency' | 'brand') => { const emailMap = { creator: 'creator@demo.com', @@ -59,12 +74,43 @@ function LoginForm() { } }, [roleFromUrl]) + const handleSendCode = useCallback(async () => { + if (!email) { + setError('请先输入邮箱') + return + } + if (countdown > 0) return + + setError('') + setCodeSending(true) + + try { + if (USE_MOCK) { + await new Promise((resolve) => setTimeout(resolve, 500)) + setCountdown(60) + return + } + + await api.sendEmailCode({ email, purpose: 'login' }) + setCountdown(60) + } catch (err) { + const error = err instanceof Error ? err.message : '发送验证码失败' + setError(error) + } finally { + setCodeSending(false) + } + }, [email, countdown]) + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() setError('') setIsLoading(true) - const result = await login({ email, password }) + const credentials = loginMode === 'code' + ? { email, email_code: emailCode } + : { email, password } + + const result = await login(credentials) if (result.success) { const stored = localStorage.getItem('miaosi_user') @@ -114,6 +160,32 @@ function LoginForm() { + {/* 登录方式切换 */} +
+ + +
+ {/* 登录表单 */}
{error && ( @@ -138,20 +210,48 @@ function LoginForm() { -
- -
- - setPassword(e.target.value)} - className="w-full pl-12 pr-4 py-3.5 bg-bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder-text-tertiary focus:outline-none focus:ring-2 focus:ring-accent-indigo focus:border-transparent transition-all" - required - /> + {loginMode === 'password' ? ( +
+ +
+ + setPassword(e.target.value)} + className="w-full pl-12 pr-4 py-3.5 bg-bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder-text-tertiary focus:outline-none focus:ring-2 focus:ring-accent-indigo focus:border-transparent transition-all" + required + /> +
-
+ ) : ( +
+ +
+
+ + setEmailCode(e.target.value.replace(/\D/g, '').slice(0, 6))} + maxLength={6} + className="w-full pl-12 pr-4 py-3.5 bg-bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder-text-tertiary focus:outline-none focus:ring-2 focus:ring-accent-indigo focus:border-transparent transition-all" + required + /> +
+ +
+
+ )}
+ {/* 验证码 */} +
+ +
+
+ + setEmailCode(e.target.value.replace(/\D/g, '').slice(0, 6))} + maxLength={6} + className="w-full pl-12 pr-4 py-3.5 bg-bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder-text-tertiary focus:outline-none focus:ring-2 focus:ring-accent-indigo focus:border-transparent transition-all" + /> +
+ +
+
+ {/* 手机号 */}