Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 81 additions & 16 deletions frontend/components/auth/login-page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client"

import { useEffect, useState } from "react"
import { useCallback, useEffect, useState } from "react"
import { motion, AnimatePresence } from "motion/react"
import { useRouter, useSearchParams } from "next/navigation"
import { toast } from "sonner"
Expand All @@ -12,6 +12,7 @@ import { Check } from "lucide-react"

import { AuroraBackground } from "@/components/ui/aurora-background"
import services from "@/lib/services"
import type { ApiResponse } from "@/lib/services/core/types"


/**
Expand All @@ -34,6 +35,7 @@ export function LoginPage() {
const code = searchParams.get('code')
return !!(state && code)
})
const [isCheckingSession, setIsCheckingSession] = useState(() => !searchParams.get('state') || !searchParams.get('code'))

const [loginSuccess, setLoginSuccess] = useState(false)
const [needsPayKeySetup, setNeedsPayKeySetup] = useState(false)
Expand All @@ -47,6 +49,18 @@ export function LoginPage() {
const isConfirmValid = confirmPayKey.length === 6 && /^\d{6}$/.test(confirmPayKey)
const passwordsMatch = payKey === confirmPayKey

const resolveRedirectTarget = useCallback(() => {
const callbackUrl = searchParams.get('callbackUrl')
const storedRedirect = sessionStorage.getItem('redirect_after_login')
const target = callbackUrl || storedRedirect || '/home'

if (storedRedirect) {
sessionStorage.removeItem('redirect_after_login')
}

return target
}, [searchParams])

/* 安全密码输入 */
const handlePayKeyChange = (value: string) => {
const numericValue = value.replace(/\D/g, '')
Expand All @@ -59,6 +73,56 @@ export function LoginPage() {
setConfirmPayKey(numericValue)
}

/* 登录页兜底:已登录用户直接跳转 */
useEffect(() => {
const state = searchParams.get('state')
const code = searchParams.get('code')

if (state && code) {
setIsCheckingSession(false)
return
}

let cancelled = false

const checkExistingSession = async () => {
setIsCheckingSession(true)

try {
const response = await fetch('/api/v1/oauth/user-info', {
credentials: 'include',
cache: 'no-store',
})

if (cancelled) return

if (response.ok) {
await response.json() as ApiResponse
router.replace(resolveRedirectTarget())
return
}

if (response.status !== 401) {
console.error('Session probe failed:', response.status)
}
} catch (error) {
if (!cancelled) {
console.error('Session probe error:', error)
}
} finally {
if (!cancelled) {
setIsCheckingSession(false)
}
}
}

checkExistingSession()

return () => {
cancelled = true
}
}, [router, searchParams, resolveRedirectTarget])

/* 回调逻辑 */
useEffect(() => {
const handleOAuthCallback = async () => {
Expand All @@ -78,13 +142,8 @@ export function LoginPage() {
setLoginSuccess(true)
toast.success("登录成功")

const callbackUrl = searchParams.get('callbackUrl') || sessionStorage.getItem('redirect_after_login') || '/home'
if (sessionStorage.getItem('redirect_after_login')) {
sessionStorage.removeItem('redirect_after_login')
}

setTimeout(() => {
router.replace(callbackUrl)
router.replace(resolveRedirectTarget())
}, 1500)
}
} catch (error) {
Expand All @@ -96,7 +155,7 @@ export function LoginPage() {
}
}
handleOAuthCallback()
}, [searchParams, router])
}, [searchParams, router, resolveRedirectTarget])

/* 安全密码设置 */
const handlePayKeySubmit = async (e: React.FormEvent) => {
Expand Down Expand Up @@ -131,11 +190,7 @@ export function LoginPage() {
setConfirmPayKey("")
setSetupStep('password')
setTimeout(() => {
const callbackUrl = searchParams.get('callbackUrl') || sessionStorage.getItem('redirect_after_login') || '/home'
if (sessionStorage.getItem('redirect_after_login')) {
sessionStorage.removeItem('redirect_after_login')
}
router.replace(callbackUrl)
router.replace(resolveRedirectTarget())
}, 1500)
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "设置安全密码失败"
Expand Down Expand Up @@ -266,15 +321,25 @@ export function LoginPage() {
</div>

<AnimatePresence mode="wait">
{isProcessingCallback ? (
{isProcessingCallback || isCheckingSession ? (
<motion.div
key="processing"
key={isProcessingCallback ? "processing" : "session-check"}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="w-full"
>
{needsPayKeySetup ? (
{isCheckingSession ? (
<div className="flex flex-col items-center justify-center space-y-4 py-2">
<div className="relative">
<Spinner className="w-8 h-8 text-blue-600" />
</div>
<div className="text-center space-y-2">
<h3 className="font-semibold tracking-tight text-foreground">正在检查登录状态</h3>
<p className="text-xs text-muted-foreground">请稍候,我们正在确认当前会话...</p>
</div>
</div>
) : needsPayKeySetup ? (
renderPayKeySetup("oauth-pay-key-setup")
) : loginSuccess ? (
<div className="flex flex-col items-center justify-center space-y-4 py-2">
Expand Down
48 changes: 24 additions & 24 deletions frontend/components/common/leaderboard/leaderboard-main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,10 @@ import { useUser } from "@/contexts/user-context"
import { useLeaderboard } from "@/hooks/use-leaderboard"
import { LeaderboardPodium } from "@/components/common/leaderboard/leaderboard-podium"
import { LeaderboardTable } from "@/components/common/leaderboard/leaderboard-table"
import { LoadingState } from "@/components/layout/loading"
import { cn } from "@/lib/utils"

/**
* 排行榜主组件
*
* 负责组装排行榜的各个子组件,包括领奖台、用户排名卡片和排名列表
*/
export function LeaderboardMain() {
const { user } = useUser()
Expand All @@ -34,6 +31,7 @@ export function LeaderboardMain() {
loadNextPage,
refresh,
} = useLeaderboard()
const isInitialLoading = loading && items.length === 0

const currentUserEntry = React.useMemo(() => {
if (!myRank?.user) return undefined
Expand All @@ -51,21 +49,17 @@ export function LeaderboardMain() {
}
}, [items, myRank, user])

if (loading && items.length === 0) {
return <LoadingState title="加载中" description="正在获取排行榜数据..." />
}

return (
<div className="py-6 space-y-6">
<div className="flex flex-col gap-4 border-b border-dashed border-border/80 pb-5 md:flex-row md:items-end md:justify-between">
<div className="py-6 space-y-10">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold tracking-tight">全局排行榜</h1>

<div className="flex items-center gap-2 self-start md:self-auto">
<div className="flex items-center gap-2">
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="h-8 border-dashed gap-1.5 shadow-none">
<Button variant="ghost" size="sm" className="h-8 gap-1.5 text-muted-foreground">
<CircleHelp className="size-4" />
规则说明
规则
</Button>
</DialogTrigger>
<DialogContent>
Expand All @@ -79,24 +73,30 @@ export function LeaderboardMain() {
</DialogContent>
</Dialog>

<Button variant="outline" size="icon" className="size-8 border-dashed shadow-none" onClick={refresh} aria-label="刷新排行榜">
<Button variant="ghost" size="icon" className="size-8 text-muted-foreground" onClick={refresh} aria-label="刷新排行榜">
<RefreshCw className={cn("size-4", (loading || loadingMore || myRankLoading) && "animate-spin")} />
</Button>
</div>
</div>

<LeaderboardPodium items={items.slice(0, 3)} loading={loading} />

<LeaderboardTable
items={items}
loading={loading || loadingMore || myRankLoading}
currentUserId={myRank?.user.user_id}
currentUserEntry={currentUserEntry}
currentUserRank={myRank?.user.rank}
onLoadMore={loadNextPage}
hasMore={hasMore}
startRank={1}
<LeaderboardPodium
items={items.slice(0, 3)}
loading={isInitialLoading}
/>

<section>
<h2 className="font-semibold mb-4">完整榜单</h2>
<LeaderboardTable
items={items}
loading={isInitialLoading}
currentUserId={myRank?.user.user_id}
currentUserEntry={currentUserEntry}
currentUserRank={myRank?.user.rank}
onLoadMore={loadNextPage}
hasMore={hasMore}
startRank={1}
/>
</section>
</div>
)
}
Loading
Loading