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() {
+ {/* 登录方式切换 */}
+
+
+
+
+
{/* 登录表单 */}