diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index 6a787dd..006032c 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -7,13 +7,30 @@ from app.core.auth import hash_password, verify_password, create_access_token from app.core.deps import get_current_user from app.db.database import get_db from app.db.models import ClassMembership, User -from app.schemas.auth import LoginRequest, RegisterRequest, ChangePasswordRequest +from app.schemas.auth import ( + ChangePasswordRequest, + InviteCodeClassPreview, + LoginRequest, + RegisterRequest, +) from app.schemas.user import TokenResponse, UserOut, build_user_out -from app.services.member_activation_service import validate_registration +from app.services.member_activation_service import get_class_by_invite_code, validate_registration router = APIRouter(prefix="/api/auth", tags=["auth"]) +@router.get("/invite-code/{invite_code}", response_model=InviteCodeClassPreview) +async def preview_invite_code_class(invite_code: str, db: AsyncSession = Depends(get_db)): + class_ = await get_class_by_invite_code(db, invite_code.strip().upper()) + if class_ is None: + raise HTTPException(status_code=404, detail="邀请码无效") + return InviteCodeClassPreview( + id=class_.id, + name=class_.name, + cohort_year=class_.cohort_year, + ) + + @router.post("/activate") async def activate_account(req: RegisterRequest, db: AsyncSession = Depends(get_db)): # 1. Check if email is already in use diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index 04d4c5f..df02d63 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -16,3 +16,9 @@ class RegisterRequest(BaseModel): class ChangePasswordRequest(BaseModel): old_password: str new_password: str + + +class InviteCodeClassPreview(BaseModel): + id: int + name: str + cohort_year: int diff --git a/backend/app/services/member_activation_service.py b/backend/app/services/member_activation_service.py index da9bfa5..155da88 100644 --- a/backend/app/services/member_activation_service.py +++ b/backend/app/services/member_activation_service.py @@ -173,6 +173,11 @@ async def validate_registration( return user, class_.id +async def get_class_by_invite_code(db: AsyncSession, invite_code: str) -> Class_ | None: + result = await db.execute(select(Class_).where(Class_.invite_code == invite_code)) + return result.scalar_one_or_none() + + async def delete_inactive_member(db: AsyncSession, class_id: int, user_id: int) -> bool: result = await db.execute( select(User) diff --git a/frontend/src/app/activate/page.tsx b/frontend/src/app/activate/page.tsx index 900dbdf..b456032 100644 --- a/frontend/src/app/activate/page.tsx +++ b/frontend/src/app/activate/page.tsx @@ -6,16 +6,25 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { getErrorMessage, postAPI } from "@/lib/api"; +import { fetchAPI, getErrorMessage, postAPI } from "@/lib/api"; import Link from "next/link"; import type { LoginResponse } from "@/lib/types"; +type InviteCodeClassPreview = { + id: number; + name: string; + cohort_year: number; +}; + export default function ActivatePage() { const [inviteCode, setInviteCode] = useState(""); const [studentId, setStudentId] = useState(""); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); + const [classPreview, setClassPreview] = useState(null); + const [classLookupLoading, setClassLookupLoading] = useState(false); + const [classLookupError, setClassLookupError] = useState(""); const [error, setError] = useState(""); const [loading, setLoading] = useState(false); const router = useRouter(); @@ -28,6 +37,32 @@ export default function ActivatePage() { } }, [searchParams]); + useEffect(() => { + const normalizedCode = inviteCode.trim().toUpperCase(); + if (!normalizedCode) { + setClassPreview(null); + setClassLookupError(""); + setClassLookupLoading(false); + return; + } + + const timer = window.setTimeout(async () => { + setClassLookupLoading(true); + setClassLookupError(""); + try { + const res = await fetchAPI(`/api/auth/invite-code/${encodeURIComponent(normalizedCode)}`); + setClassPreview(res); + } catch { + setClassPreview(null); + setClassLookupError("未找到对应班级,请确认邀请码是否正确"); + } finally { + setClassLookupLoading(false); + } + }, 300); + + return () => window.clearTimeout(timer); + }, [inviteCode]); + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(""); @@ -98,6 +133,17 @@ export default function ActivatePage() { onChange={(e) => setInviteCode(e.target.value.toUpperCase())} required /> + {classLookupLoading && ( +

正在识别班级...

+ )} + {!classLookupLoading && classPreview && ( +
+ 班级确认:{classPreview.name} +
+ )} + {!classLookupLoading && classLookupError && ( +

{classLookupError}

+ )}