This commit is contained in:
aaron 2026-04-27 22:36:48 +08:00
parent bc16d2f074
commit 82b786f513
8 changed files with 600 additions and 42 deletions

View File

@ -0,0 +1,59 @@
"""add email verification codes table
Revision ID: 20260427_add_email_codes
Revises: 20260427_create_memberships
Create Date: 2026-04-27 22:20:00
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "20260427_add_email_codes"
down_revision = "20260427_create_memberships"
branch_labels = None
depends_on = None
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if "email_verification_codes" not in set(inspector.get_table_names()):
op.create_table(
"email_verification_codes",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("email", sa.String(length=255), nullable=False),
sa.Column("purpose", sa.String(length=50), nullable=False),
sa.Column("code_hash", sa.String(length=64), nullable=False),
sa.Column("expires_at", sa.DateTime(), nullable=False),
sa.Column("consumed_at", sa.DateTime(), nullable=True),
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"ix_email_verification_codes_email",
"email_verification_codes",
["email"],
)
op.create_index(
"ix_email_verification_codes_purpose",
"email_verification_codes",
["purpose"],
)
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
tables = set(inspector.get_table_names())
if "email_verification_codes" in tables:
indexes = {index["name"] for index in inspector.get_indexes("email_verification_codes")}
if "ix_email_verification_codes_purpose" in indexes:
op.drop_index("ix_email_verification_codes_purpose", table_name="email_verification_codes")
if "ix_email_verification_codes_email" in indexes:
op.drop_index("ix_email_verification_codes_email", table_name="email_verification_codes")
op.drop_table("email_verification_codes")

View File

@ -12,8 +12,18 @@ from app.schemas.auth import (
InviteCodeClassPreview, InviteCodeClassPreview,
LoginRequest, LoginRequest,
RegisterRequest, RegisterRequest,
SendEmailCodeRequest,
) )
from app.schemas.user import TokenResponse, UserOut, build_user_out from app.schemas.user import TokenResponse, UserOut, build_user_out
from app.services.email_service import (
send_account_activated_email,
send_email_verification_code_email,
)
from app.services.email_verification_service import (
ACTIVATION_EMAIL_PURPOSE,
issue_email_verification_code,
verify_email_code,
)
from app.services.member_activation_service import get_class_by_invite_code, validate_registration from app.services.member_activation_service import get_class_by_invite_code, validate_registration
router = APIRouter(prefix="/api/auth", tags=["auth"]) router = APIRouter(prefix="/api/auth", tags=["auth"])
@ -31,6 +41,30 @@ async def preview_invite_code_class(invite_code: str, db: AsyncSession = Depends
) )
@router.post("/email-verification-code")
async def send_activation_email_code(
req: SendEmailCodeRequest,
db: AsyncSession = Depends(get_db),
):
existing = await db.execute(select(User).where(User.email == req.email))
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="该邮箱已注册")
activation_target = await validate_registration(db, req.invite_code, req.student_id)
if activation_target is None:
raise HTTPException(status_code=400, detail="邀请码或学号无效,或账号已激活")
code = await issue_email_verification_code(
db,
email=req.email,
purpose=ACTIVATION_EMAIL_PURPOSE,
)
sent = await send_email_verification_code_email(req.email, code)
if sent is False:
raise HTTPException(status_code=500, detail="邮件发送失败,请检查邮件服务配置")
return {"message": "验证码已发送,请查收邮箱"}
@router.post("/activate") @router.post("/activate")
async def activate_account(req: RegisterRequest, db: AsyncSession = Depends(get_db)): async def activate_account(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
# 1. Check if email is already in use # 1. Check if email is already in use
@ -43,6 +77,15 @@ async def activate_account(req: RegisterRequest, db: AsyncSession = Depends(get_
if activation_target is None: if activation_target is None:
raise HTTPException(status_code=400, detail="邀请码或学号无效,或账号已激活") raise HTTPException(status_code=400, detail="邀请码或学号无效,或账号已激活")
is_code_valid = await verify_email_code(
db,
email=req.email,
code=req.email_code,
purpose=ACTIVATION_EMAIL_PURPOSE,
)
if not is_code_valid:
raise HTTPException(status_code=400, detail="邮箱验证码无效或已过期")
user, class_id = activation_target user, class_id = activation_target
user.email = req.email user.email = req.email
user.password_hash = hash_password(req.password) user.password_hash = hash_password(req.password)
@ -61,6 +104,7 @@ async def activate_account(req: RegisterRequest, db: AsyncSession = Depends(get_
# 3. Issue token — activation and login in one step # 3. Issue token — activation and login in one step
token = create_access_token({"sub": str(user.id), "role": user.role}) token = create_access_token({"sub": str(user.id), "role": user.role})
await send_account_activated_email(req.email)
return { return {
"message": "账号激活成功", "message": "账号激活成功",
"token": token, "token": token,

View File

@ -177,6 +177,21 @@ class ClassMembership(Base):
) )
class EmailVerificationCode(Base):
__tablename__ = "email_verification_codes"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
email: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
purpose: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
code_hash: Mapped[str] = mapped_column(String(64), nullable=False)
expires_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
consumed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now(), onupdate=func.now()
)
class Timeline(Base): class Timeline(Base):
__tablename__ = "timelines" __tablename__ = "timelines"

View File

@ -10,9 +10,16 @@ class RegisterRequest(BaseModel):
invite_code: str invite_code: str
student_id: str student_id: str
email: EmailStr email: EmailStr
email_code: str
password: str password: str
class SendEmailCodeRequest(BaseModel):
invite_code: str
student_id: str
email: EmailStr
class ChangePasswordRequest(BaseModel): class ChangePasswordRequest(BaseModel):
old_password: str old_password: str
new_password: str new_password: str

View File

@ -9,6 +9,137 @@ from app.config import settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def build_email_shell(
*,
eyebrow: str,
title: str,
body_html: str,
action_label: str | None = None,
action_url: str | None = None,
summary_html: str | None = None,
footer_note: str = "此邮件由系统自动发送,请勿直接回复。",
) -> str:
action_html = ""
if action_label and action_url:
action_html = f"""
<div style="margin-top:28px;">
<a
href="{action_url}"
style="
display:inline-block;
padding:12px 22px;
border-radius:999px;
background:linear-gradient(135deg,#6c1a25 0%,#915435 62%,#d9b68d 130%);
color:#fffaf3;
text-decoration:none;
font-size:14px;
font-weight:600;
letter-spacing:0.01em;
box-shadow:0 14px 32px -18px rgba(83,25,24,0.55);
"
>
{action_label}
</a>
</div>
"""
summary_block = ""
if summary_html:
summary_block = f"""
<div
style="
margin:18px 0 0;
padding:14px 16px;
border:1px solid #eadcc8;
border-radius:18px;
background:#fff4e3;
font-size:13px;
line-height:1.8;
color:#84553c;
"
>
{summary_html}
</div>
"""
return f"""
<!doctype html>
<html lang="zh-CN">
<body style="margin:0;padding:0;background:#f7efe2;">
<div style="margin:0;padding:32px 16px;background:linear-gradient(180deg,#f7efe2 0%,#f4ebdf 38%,#f9f5ee 100%);">
<div style="max-width:640px;margin:0 auto;">
<div
style="
overflow:hidden;
border:1px solid #e7d3ba;
border-radius:28px;
background:#fffaf3;
box-shadow:0 28px 80px -44px rgba(83,25,24,0.34);
"
>
<div
style="
padding:32px 36px 28px;
background:
radial-gradient(circle at top, rgba(115,25,37,0.18), transparent 58%),
linear-gradient(135deg, rgba(108,26,37,0.98), rgba(145,84,53,0.92) 58%, rgba(233,206,160,0.88) 140%);
color:#fff7ee;
"
>
<div
style="
display:inline-block;
padding:7px 12px;
border:1px solid rgba(255,245,231,0.28);
border-radius:999px;
background:rgba(255,250,242,0.08);
font-size:11px;
letter-spacing:0.24em;
text-transform:uppercase;
"
>
{eyebrow}
</div>
<h1 style="margin:18px 0 0;font-size:30px;line-height:1.22;font-weight:700;">
香港大学中国商业学院
</h1>
<p style="margin:8px 0 0;font-size:14px;line-height:1.7;color:rgba(255,247,238,0.86);">
HKU ICB · 班级信息管理平台
</p>
</div>
<div style="padding:32px 36px 14px;">
<h2 style="margin:0 0 14px;font-size:24px;line-height:1.35;color:#4e1d1a;">
{title}
</h2>
<div style="font-size:15px;line-height:1.9;color:#775a4a;">
{body_html}
</div>
{summary_block}
{action_html}
</div>
<div style="padding:18px 36px 30px;">
<div
style="
border-top:1px solid #eadcc8;
padding-top:16px;
font-size:12px;
line-height:1.8;
color:#9a7b68;
"
>
{footer_note}
</div>
</div>
</div>
</div>
</div>
</body>
</html>
"""
async def send_email(to: str, subject: str, html_body: str) -> bool: async def send_email(to: str, subject: str, html_body: str) -> bool:
"""Send HTML email via SMTP. Returns True on success.""" """Send HTML email via SMTP. Returns True on success."""
if not settings.smtp_host: if not settings.smtp_host:
@ -36,13 +167,35 @@ async def send_email(to: str, subject: str, html_body: str) -> bool:
return False return False
async def send_account_activated_email(member_email: str): async def send_account_activated_email(member_email: str) -> bool:
html = """ login_url = f"{settings.frontend_url.rstrip('/')}/login"
<h2>Account Activated</h2> html = build_email_shell(
<p>Your HKU ICB account has been activated successfully.</p> eyebrow="Account Ready",
<p>You can now log in to the platform.</p> title="账号已激活,可以开始使用",
""" body_html="""
await send_email(member_email, "HKU ICB: Account Activated", html) <p style="margin:0 0 12px;">你的 HKU ICB 班级账号已经成功激活</p>
<p style="margin:0;">现在可以使用注册邮箱登录平台进入班级空间查看公告排期资源与成员信息</p>
""",
action_label="立即登录",
action_url=login_url,
)
return await send_email(member_email, "HKU ICB账号激活成功", html)
async def send_email_verification_code_email(member_email: str, code: str) -> bool:
html = build_email_shell(
eyebrow="Email Verification",
title="请验证你的邮箱地址",
body_html="""
<p style="margin:0 0 12px;">你正在激活 HKU ICB 班级账号请使用下方验证码完成邮箱验证</p>
<p style="margin:0;">验证码 10 分钟内有效如果不是你本人操作可以忽略此邮件</p>
""",
summary_html=f"""
<div style="font-size:12px;letter-spacing:0.18em;text-transform:uppercase;color:#9a7b68;">邮箱验证码</div>
<div style="margin-top:8px;font-size:32px;line-height:1.2;font-weight:700;letter-spacing:0.32em;color:#4e1d1a;">{code}</div>
""",
)
return await send_email(member_email, "HKU ICB邮箱验证码", html)
async def send_class_notification_email( async def send_class_notification_email(
@ -51,31 +204,19 @@ async def send_class_notification_email(
title: str, title: str,
body: str, body: str,
action_url: str | None = None, action_url: str | None = None,
eyebrow: str = "Class Update",
action_label: str | None = "查看详情",
summary_html: str | None = None,
): ):
"""Send a styled notification email to class members.""" """Send a styled notification email to class members."""
action_html = "" normalized_body = body.replace("\n", "<br />")
if action_url: html = build_email_shell(
action_html = f""" eyebrow=eyebrow,
<div style="margin-top: 20px;"> title=title,
<a href="{action_url}" style="display:inline-block;padding:10px 24px;background:#111827;color:#fff;border-radius:6px;text-decoration:none;font-size:14px;"> body_html=f"<div>{normalized_body}</div>",
查看详情 action_label=action_label if action_url else None,
</a> action_url=action_url,
</div> summary_html=summary_html,
""" )
html = f"""
<div style="max-width:600px;margin:0 auto;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;color:#1f2937;">
<div style="padding:24px 0;border-bottom:2px solid #111827;">
<h1 style="margin:0;font-size:20px;color:#111827;">HKU ICB</h1>
</div>
<div style="padding:24px 0;">
<h2 style="margin:0 0 12px;font-size:18px;">{title}</h2>
<div style="font-size:14px;line-height:1.6;color:#4b5563;">{body}</div>
{action_html}
</div>
<div style="padding:16px 0;border-top:1px solid #e5e7eb;font-size:12px;color:#9ca3af;">
此邮件由系统自动发送请勿直接回复
</div>
</div>
"""
for email in emails: for email in emails:
await send_email(email, subject, html) await send_email(email, subject, html)

View File

@ -0,0 +1,107 @@
import hashlib
import secrets
from datetime import datetime, timedelta, timezone
from fastapi import HTTPException
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import EmailVerificationCode
ACTIVATION_EMAIL_PURPOSE = "account_activation"
EMAIL_CODE_TTL_MINUTES = 10
EMAIL_CODE_RESEND_SECONDS = 60
def _hash_code(code: str) -> str:
return hashlib.sha256(code.encode("utf-8")).hexdigest()
def generate_email_code() -> str:
return f"{secrets.randbelow(1000000):06d}"
async def issue_email_verification_code(
db: AsyncSession,
*,
email: str,
purpose: str = ACTIVATION_EMAIL_PURPOSE,
) -> str:
normalized_email = email.strip().lower()
now = datetime.now(timezone.utc)
latest_result = await db.execute(
select(EmailVerificationCode)
.where(
EmailVerificationCode.email == normalized_email,
EmailVerificationCode.purpose == purpose,
EmailVerificationCode.consumed_at.is_(None),
)
.order_by(EmailVerificationCode.created_at.desc())
.limit(1)
)
latest = latest_result.scalar_one_or_none()
if latest and latest.created_at and (now - latest.created_at.replace(tzinfo=timezone.utc)).total_seconds() < EMAIL_CODE_RESEND_SECONDS:
raise HTTPException(
status_code=429,
detail=f"验证码发送过于频繁,请在 {EMAIL_CODE_RESEND_SECONDS} 秒后重试",
)
await db.execute(
update(EmailVerificationCode)
.where(
EmailVerificationCode.email == normalized_email,
EmailVerificationCode.purpose == purpose,
EmailVerificationCode.consumed_at.is_(None),
)
.values(consumed_at=now)
)
code = generate_email_code()
db.add(
EmailVerificationCode(
email=normalized_email,
purpose=purpose,
code_hash=_hash_code(code),
expires_at=now + timedelta(minutes=EMAIL_CODE_TTL_MINUTES),
)
)
await db.commit()
return code
async def verify_email_code(
db: AsyncSession,
*,
email: str,
code: str,
purpose: str = ACTIVATION_EMAIL_PURPOSE,
) -> bool:
normalized_email = email.strip().lower()
normalized_code = code.strip()
if not normalized_code:
return False
now = datetime.now(timezone.utc)
result = await db.execute(
select(EmailVerificationCode)
.where(
EmailVerificationCode.email == normalized_email,
EmailVerificationCode.purpose == purpose,
EmailVerificationCode.consumed_at.is_(None),
)
.order_by(EmailVerificationCode.created_at.desc())
.limit(1)
)
record = result.scalar_one_or_none()
if record is None:
return False
expires_at = record.expires_at.replace(tzinfo=timezone.utc)
if expires_at < now or record.code_hash != _hash_code(normalized_code):
return False
record.consumed_at = now
await db.commit()
return True

View File

@ -10,6 +10,60 @@ from app.services.email_service import send_class_notification_email
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
EMAIL_VISUALS: dict[str, dict[str, str | None]] = {
"announcement": {
"eyebrow": "Announcement",
"action_label": "查看公告",
"summary_prefix": "公告摘要",
},
"timeline": {
"eyebrow": "Class Feed",
"action_label": "查看动态",
"summary_prefix": "动态摘要",
},
"vote": {
"eyebrow": "Voting Open",
"action_label": "立即投票",
"summary_prefix": "投票说明",
},
"schedule": {
"eyebrow": "Schedule Update",
"action_label": "查看排期",
"summary_prefix": "时间信息",
},
"assignment": {
"eyebrow": "Assignment Posted",
"action_label": "查看作业",
"summary_prefix": "作业说明",
},
"resource": {
"eyebrow": "Resource Added",
"action_label": "查看资源",
"summary_prefix": "资源说明",
},
"fund": {
"eyebrow": "Fund Update",
"action_label": "查看班费",
"summary_prefix": "账务说明",
},
}
def _build_email_meta(type_: str, email_body: str | None) -> tuple[str, str | None, str | None]:
visual = EMAIL_VISUALS.get(type_, {})
eyebrow = str(visual.get("eyebrow") or "Class Update")
action_label = visual.get("action_label")
summary_prefix = visual.get("summary_prefix")
if not email_body:
return eyebrow, action_label, None
summary_html = email_body
if summary_prefix:
summary_html = f"<strong>{summary_prefix}</strong>{email_body}"
return eyebrow, action_label, summary_html
async def create_notification( async def create_notification(
db: AsyncSession, db: AsyncSession,
user_id: int, user_id: int,
@ -72,15 +126,43 @@ async def create_notifications_for_class(
if email_subject and emails: if email_subject and emails:
from app.config import settings from app.config import settings
action_url = f"{settings.frontend_url}{email_action_path}" if email_action_path else None action_url = f"{settings.frontend_url}{email_action_path}" if email_action_path else None
asyncio.create_task(_safe_send_emails(emails, email_subject, title, email_body or content or "", action_url)) eyebrow, action_label, summary_html = _build_email_meta(type, email_body or content or "")
asyncio.create_task(
_safe_send_emails(
emails,
email_subject,
title,
email_body or content or "",
action_url,
eyebrow,
action_label,
summary_html,
)
)
async def _safe_send_emails( async def _safe_send_emails(
emails: list[str], subject: str, title: str, body: str, action_url: str | None emails: list[str],
subject: str,
title: str,
body: str,
action_url: str | None,
eyebrow: str,
action_label: str | None,
summary_html: str | None,
): ):
"""Fire-and-forget email sending with error logging.""" """Fire-and-forget email sending with error logging."""
try: try:
await send_class_notification_email(emails, subject, title, body, action_url) await send_class_notification_email(
emails,
subject,
title,
body,
action_url,
eyebrow=eyebrow,
action_label=action_label,
summary_html=summary_html,
)
except Exception as e: except Exception as e:
logger.error(f"Failed to send class notification emails: {e}") logger.error(f"Failed to send class notification emails: {e}")

View File

@ -20,16 +20,37 @@ export default function ActivatePage() {
const [inviteCode, setInviteCode] = useState(""); const [inviteCode, setInviteCode] = useState("");
const [studentId, setStudentId] = useState(""); const [studentId, setStudentId] = useState("");
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [emailCode, setEmailCode] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState("");
const [classPreview, setClassPreview] = useState<InviteCodeClassPreview | null>(null); const [classPreview, setClassPreview] = useState<InviteCodeClassPreview | null>(null);
const [classLookupLoading, setClassLookupLoading] = useState(false); const [classLookupLoading, setClassLookupLoading] = useState(false);
const [classLookupError, setClassLookupError] = useState(""); const [classLookupError, setClassLookupError] = useState("");
const [sendingEmailCode, setSendingEmailCode] = useState(false);
const [emailCodeCooldown, setEmailCodeCooldown] = useState(0);
const [emailCodeSentTo, setEmailCodeSentTo] = useState("");
const [emailCodeNotice, setEmailCodeNotice] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
useEffect(() => {
if (emailCodeCooldown <= 0) {
return;
}
const timer = window.setInterval(() => {
setEmailCodeCooldown((current) => {
if (current <= 1) {
window.clearInterval(timer);
return 0;
}
return current - 1;
});
}, 1000);
return () => window.clearInterval(timer);
}, [emailCodeCooldown]);
useEffect(() => { useEffect(() => {
const code = searchParams.get("code")?.trim(); const code = searchParams.get("code")?.trim();
if (code) { if (code) {
@ -63,6 +84,52 @@ export default function ActivatePage() {
return () => window.clearTimeout(timer); return () => window.clearTimeout(timer);
}, [inviteCode]); }, [inviteCode]);
useEffect(() => {
if (!emailCodeSentTo) {
return;
}
if (email.trim().toLowerCase() !== emailCodeSentTo) {
setEmailCode("");
setEmailCodeNotice("");
}
}, [email, emailCodeSentTo]);
const isEmailValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim());
const handleSendEmailCode = async () => {
setError("");
setEmailCodeNotice("");
if (!inviteCode.trim()) {
setError("请先输入班级邀请码");
return;
}
if (!studentId.trim()) {
setError("请先输入学号");
return;
}
if (!isEmailValid) {
setError("请输入正确的邮箱地址");
return;
}
setSendingEmailCode(true);
try {
await postAPI<{ message: string }>("/api/auth/email-verification-code", {
invite_code: inviteCode.trim().toUpperCase(),
student_id: studentId.trim(),
email: email.trim().toLowerCase(),
});
setEmailCodeSentTo(email.trim().toLowerCase());
setEmailCodeCooldown(60);
setEmailCodeNotice("验证码已发送,请前往邮箱查收。");
} catch (err: unknown) {
setError(getErrorMessage(err, "验证码发送失败"));
} finally {
setSendingEmailCode(false);
}
};
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setError(""); setError("");
@ -75,13 +142,22 @@ export default function ActivatePage() {
setError("密码至少8位"); setError("密码至少8位");
return; return;
} }
if (!emailCode.trim()) {
setError("请输入邮箱验证码");
return;
}
if (emailCodeSentTo !== email.trim().toLowerCase()) {
setError("邮箱已变更,请重新获取验证码");
return;
}
setLoading(true); setLoading(true);
try { try {
const res = await postAPI<LoginResponse>("/api/auth/activate", { const res = await postAPI<LoginResponse>("/api/auth/activate", {
invite_code: inviteCode, invite_code: inviteCode.trim().toUpperCase(),
student_id: studentId, student_id: studentId.trim(),
email, email: email.trim().toLowerCase(),
email_code: emailCode.trim(),
password, password,
}); });
@ -157,12 +233,39 @@ export default function ActivatePage() {
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="email"></Label> <Label htmlFor="email"></Label>
<div className="flex flex-col gap-2 sm:flex-row">
<Input
id="email"
type="email"
placeholder="your@email.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
<Button
type="button"
variant="outline"
className="shrink-0 sm:min-w-[132px]"
disabled={sendingEmailCode || emailCodeCooldown > 0}
onClick={handleSendEmailCode}
>
{sendingEmailCode ? "发送中..." : emailCodeCooldown > 0 ? `${emailCodeCooldown}s 后重发` : "发送验证码"}
</Button>
</div>
<p className="text-xs text-[#9a7b68]">
10
</p>
{emailCodeNotice && (
<p className="text-xs text-emerald-700">{emailCodeNotice}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="emailCode"></Label>
<Input <Input
id="email" id="emailCode"
type="email" placeholder="请输入 6 位验证码"
placeholder="your@email.com" value={emailCode}
value={email} onChange={(e) => setEmailCode(e.target.value.replace(/\D/g, "").slice(0, 6))}
onChange={(e) => setEmail(e.target.value)}
required required
/> />
</div> </div>