压缩历史至 95081aff

This commit is contained in:
ryan
2026-06-08 20:34:27 +08:00
commit 8a782525de
435 changed files with 71146 additions and 0 deletions
@@ -0,0 +1,102 @@
"use client"
import * as React from "react"
import { CircleHelp, RefreshCw } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
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 { cn } from "@/lib/utils"
/**
* 排行榜主组件
*/
export function LeaderboardMain() {
const { user } = useUser()
const {
items,
myRank,
loading,
loadingMore,
myRankLoading,
hasMore,
loadNextPage,
refresh,
} = useLeaderboard()
const isInitialLoading = loading && items.length === 0
const currentUserEntry = React.useMemo(() => {
if (!myRank?.user) return undefined
const matchedEntry = items.find((item) => item.user_id === myRank.user.user_id)
if (matchedEntry) return matchedEntry
if (!user) return undefined
return {
user_id: myRank.user.user_id,
username: user.nickname || user.username,
avatar_url: user.avatar_url,
available_balance: myRank.user.available_balance,
}
}, [items, myRank, user])
return (
<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">
<Dialog>
<DialogTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 gap-1.5 text-muted-foreground">
<CircleHelp className="size-4" />
规则
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>排行榜如何运作</DialogTitle>
</DialogHeader>
<div className="space-y-3 text-sm text-muted-foreground">
<p>排行榜按可用积分实时排序,数据会定期刷新。</p>
<p>参与社区活动、进行积分流转,都会影响可用积分与当前排名。</p>
</div>
</DialogContent>
</Dialog>
<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={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>
)
}
@@ -0,0 +1,140 @@
"use client"
import * as React from "react"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Skeleton } from "@/components/ui/skeleton"
import { cn } from "@/lib/utils"
import type { LeaderboardEntry } from "@/lib/services/leaderboard"
interface LeaderboardPodiumProps {
items: LeaderboardEntry[]
loading?: boolean
}
type PodiumRank = 1 | 2 | 3
function formatBalance(value: string) {
return Number.parseFloat(value).toLocaleString("zh-CN", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})
}
const rankConfig = {
1: {
numColor: "from-amber-500/60 to-amber-500/8",
scoreColor: "text-amber-500",
avatarSize: "size-16 sm:size-[72px]",
numSize: "text-[72px] sm:text-[88px]",
nameSize: "text-base font-bold",
scoreSize: "text-lg font-black",
avatarNegMt: "-mt-6 sm:-mt-7",
order: "md:order-2",
shift: "",
},
2: {
numColor: "from-zinc-400/45 to-zinc-400/5",
scoreColor: "text-zinc-400",
avatarSize: "size-12 sm:size-[52px]",
numSize: "text-[52px] sm:text-[64px]",
nameSize: "text-sm font-semibold",
scoreSize: "text-sm font-bold",
avatarNegMt: "-mt-5",
order: "md:order-1",
shift: "md:translate-y-9",
},
3: {
numColor: "from-orange-500/55 to-orange-500/8",
scoreColor: "text-orange-500",
avatarSize: "size-12 sm:size-[52px]",
numSize: "text-[52px] sm:text-[64px]",
nameSize: "text-sm font-semibold",
scoreSize: "text-sm font-bold",
avatarNegMt: "-mt-5",
order: "md:order-3",
shift: "md:translate-y-14",
},
} as const
function PodiumSkeleton() {
return (
<div className="rounded-2xl bg-muted/25 px-6 pb-10 pt-6 sm:px-14">
<div className="grid grid-cols-3 items-end gap-4 sm:gap-12">
{([2, 1, 3] as PodiumRank[]).map((rank) => {
const cfg = rankConfig[rank]
return (
<div key={rank} className={cn("flex flex-col items-center gap-0", cfg.order)}>
<Skeleton className={cn("rounded-lg", rank === 1 ? "h-[100px] w-[76px]" : "h-[72px] w-[52px]")} />
<Skeleton className={cn("shrink-0 rounded-full", cfg.avatarSize, cfg.avatarNegMt)} />
<Skeleton className="mt-3 h-4 w-[70%]" />
<Skeleton className="mt-1.5 h-4 w-[45%]" />
</div>
)
})}
</div>
</div>
)
}
function PodiumColumn({ entry, rank }: { entry: LeaderboardEntry; rank: PodiumRank }) {
const cfg = rankConfig[rank]
return (
<article className={cn("flex min-w-0 flex-col items-center", cfg.order, cfg.shift)}>
<div
className={cn(
"select-none bg-clip-text text-transparent font-black leading-none tracking-[-0.05em] -mt-8",
cfg.numSize,
`bg-linear-to-b ${cfg.numColor}`,
)}
>
{String(rank).padStart(2, "0")}
</div>
<Avatar className={cn("shrink-0 rounded-full", cfg.avatarSize, cfg.avatarNegMt)}>
<AvatarImage src={entry.avatar_url} alt={entry.username} className="object-cover" />
<AvatarFallback className={cn("rounded-full font-bold", rank === 1 ? "text-sm" : "text-xs")}>
{entry.username.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className={cn("mt-2.5 w-full truncate text-center tracking-tight text-foreground", cfg.nameSize)}>
{entry.username}
</div>
<div className={cn("mt-0.5 mb-3 tabular-nums tracking-tight", cfg.scoreSize, cfg.scoreColor)}>
{formatBalance(entry.available_balance)}
</div>
</article>
)
}
export const LeaderboardPodium = React.memo(function LeaderboardPodium({
items,
loading,
}: LeaderboardPodiumProps) {
if (loading) return <PodiumSkeleton />
if (items.length === 0) {
return <div className="py-16 text-center text-muted-foreground">暂无排行数据</div>
}
const ordered = [
items[1] ? { entry: items[1], rank: 2 as PodiumRank } : null,
items[0] ? { entry: items[0], rank: 1 as PodiumRank } : null,
items[2] ? { entry: items[2], rank: 3 as PodiumRank } : null,
].filter(Boolean) as Array<{ entry: LeaderboardEntry; rank: PodiumRank }>
return (
<div className="rounded-2xl bg-muted/25 px-6 pb-10 pt-6 sm:px-14">
<div className="grid grid-cols-3 items-end gap-4 sm:gap-12">
{ordered.map(({ entry, rank }) => (
<PodiumColumn key={entry.user_id} entry={entry} rank={rank} />
))}
</div>
</div>
)
})
@@ -0,0 +1,182 @@
"use client"
import * as React from "react"
import { useVirtualizer } from "@tanstack/react-virtual"
import { Skeleton } from "@/components/ui/skeleton"
import { ScrollArea } from "@/components/ui/scroll-area"
import { RankRowItem } from "./rank-row-item"
import type { LeaderboardEntry } from "@/lib/services/leaderboard"
const leaderboardTableColumns = "grid-cols-[72px_minmax(0,1fr)_132px] sm:grid-cols-[88px_minmax(0,1fr)_152px]"
const leaderboardRowHeight = 60
interface LeaderboardTableProps {
items: LeaderboardEntry[]
loading?: boolean
currentUserId?: number
currentUserEntry?: LeaderboardEntry
currentUserRank?: number
onLoadMore?: () => void
hasMore?: boolean
startRank?: number
}
export const LeaderboardTable = React.memo(function LeaderboardTable({
items,
loading,
currentUserId,
currentUserEntry,
currentUserRank,
onLoadMore,
hasMore,
startRank = 1,
}: LeaderboardTableProps) {
const parentRef = React.useRef<HTMLDivElement>(null)
const maxBalance = React.useMemo(() => {
if (items.length === 0) return 1
return Math.max(...items.map(item => parseFloat(item.available_balance))) || 1
}, [items])
const computePercentage = React.useCallback(
(balance: number) => {
const denominator = Math.log(maxBalance + 1)
if (denominator <= 0) return 0
const safeBalance = Math.max(balance, 0)
return (Math.log(safeBalance + 1) / denominator) * 100
},
[maxBalance]
)
const rankedItems = React.useMemo(
() =>
items
.map((entry, index) => ({
entry,
rank: startRank + index,
percentage: computePercentage(parseFloat(entry.available_balance)),
}))
.filter(({ entry }) => entry.user_id !== currentUserEntry?.user_id),
[items, startRank, currentUserEntry?.user_id, computePercentage]
)
const virtualizer = useVirtualizer({
count: rankedItems.length,
getScrollElement: () => parentRef.current,
estimateSize: () => leaderboardRowHeight,
overscan: 6,
useFlushSync: false,
})
const virtualItems = virtualizer.getVirtualItems()
React.useEffect(() => {
const lastItem = virtualItems[virtualItems.length - 1]
if (!lastItem) return
if (lastItem.index >= rankedItems.length - 1 && hasMore && !loading && onLoadMore) {
onLoadMore()
}
}, [virtualItems, rankedItems.length, hasMore, loading, onLoadMore])
const currentUserPercentage = currentUserEntry
? computePercentage(parseFloat(currentUserEntry.available_balance))
: 0
if (loading && items.length === 0) {
return (
<div>
<div className={`grid ${leaderboardTableColumns} items-center px-3 py-2.5 text-[11px] font-medium uppercase tracking-wide text-muted-foreground`}>
<div>排名</div>
<div>用户</div>
<div className="text-right">可用积分</div>
</div>
<div className="mt-1 overflow-hidden rounded-xl bg-linear-to-b from-muted/30 to-transparent">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className={`grid h-[60px] ${leaderboardTableColumns} items-center gap-3 px-3`}>
<Skeleton className="h-4 w-8" />
<div className="flex items-center gap-3">
<Skeleton className="size-9 rounded-full" />
<Skeleton className="h-4 w-24" />
</div>
<Skeleton className="ml-auto h-5 w-16" />
</div>
))}
</div>
</div>
)
}
if (items.length === 0) {
return <div className="py-16 text-center text-muted-foreground">暂无排行数据</div>
}
return (
<div>
{/* 列头 */}
<div className={`grid ${leaderboardTableColumns} items-center px-3 py-2.5 text-[11px] font-medium uppercase tracking-wide text-muted-foreground`}>
<div>排名</div>
<div>用户</div>
<div className="text-right">可用积分</div>
</div>
{/* 当前用户置顶行 */}
{currentUserEntry && currentUserRank && (
<div className="mt-1 overflow-hidden rounded-xl bg-primary/[0.04]">
<RankRowItem
entry={currentUserEntry}
rank={currentUserRank}
percentage={currentUserPercentage}
isCurrentUser
pinned
metaLabel="我的位置"
/>
</div>
)}
{/* 榜单主体 — 从 muted 过渡到页面背景色 */}
<div className="relative mt-2">
<div className="overflow-hidden rounded-xl bg-linear-to-b from-muted/30 via-muted/10 to-transparent">
<ScrollArea className="h-[440px] sm:h-[480px]" viewportRef={parentRef}>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: "100%",
position: "relative",
}}
>
{virtualItems.map((virtualRow) => {
const { entry, rank, percentage } = rankedItems[virtualRow.index]
return (
<div
key={entry.user_id}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
<RankRowItem
entry={entry}
rank={rank}
percentage={percentage}
isCurrentUser={entry.user_id === currentUserId}
/>
</div>
)
})}
</div>
</ScrollArea>
</div>
</div>
{loading && items.length > 0 && (
<div className="py-3 text-center text-sm text-muted-foreground">
正在加载更多...
</div>
)}
</div>
)
})
@@ -0,0 +1,80 @@
"use client"
import * as React from "react"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { cn } from "@/lib/utils"
import type { LeaderboardEntry } from "@/lib/services/leaderboard"
const leaderboardRowColumns = "grid-cols-[72px_minmax(0,1fr)_112px] sm:grid-cols-[88px_minmax(0,1fr)_128px]"
interface RankRowItemProps {
entry: LeaderboardEntry
rank: number
percentage?: number
isCurrentUser?: boolean
metaLabel?: string
pinned?: boolean
}
export const RankRowItem = React.memo(function RankRowItem({
entry,
rank,
percentage = 0,
isCurrentUser,
metaLabel,
pinned = false,
}: RankRowItemProps) {
const rankClass =
rank === 1 ? "text-amber-500 font-black" :
rank === 2 ? "text-zinc-400 font-bold" :
rank === 3 ? "text-orange-500 font-bold" :
"text-muted-foreground"
return (
<div
className={cn(
"relative grid h-[60px] items-center px-3 transition-colors hover:bg-muted/30",
leaderboardRowColumns,
isCurrentUser && !pinned && "bg-primary/[0.03]",
pinned && "bg-transparent"
)}
>
{/* 进度条背景 */}
{percentage > 0 && (
<div
className="pointer-events-none absolute inset-y-0 left-0 transition-all duration-500"
style={{
width: `${Math.max(percentage, 4)}%`,
background: "linear-gradient(to right, oklch(58.5% 51% 277.117 / 0.15) 65%, transparent)",
}}
/>
)}
<div className={cn("relative truncate pr-2 text-sm tabular-nums", rankClass)}>
#{rank}
</div>
<div className="relative flex min-w-0 items-center gap-3">
<Avatar className="size-9 shrink-0">
<AvatarImage src={entry.avatar_url} alt={entry.username} />
<AvatarFallback className="text-xs font-semibold">
{entry.username.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="min-w-0">
<div className="truncate text-sm font-medium">{entry.username}</div>
{metaLabel && (
<div className={cn("text-[11px]", pinned ? "text-primary/70" : "text-muted-foreground")}>
{metaLabel}
</div>
)}
</div>
</div>
<div className="relative text-right text-sm font-semibold tabular-nums text-foreground sm:text-[15px]">
{parseFloat(entry.available_balance).toFixed(2)}
</div>
</div>
)
})
@@ -0,0 +1,68 @@
"use client"
import * as React from "react"
import { Skeleton } from "@/components/ui/skeleton"
import { cn } from "@/lib/utils"
import type { UserRankResponse } from "@/lib/services/leaderboard"
import { UserRound } from "lucide-react"
interface UserRankCardProps {
data: UserRankResponse | null
loading?: boolean
className?: string
}
export const UserRankCard = React.memo(function UserRankCard({
data,
loading,
className,
}: UserRankCardProps) {
if (loading) {
return (
<section className={cn("space-y-4", className)}>
<div className="flex items-center gap-2">
<UserRound className="size-4 text-muted-foreground" />
<h2 className="text-lg font-semibold">我的排名</h2>
</div>
<div className="space-y-4 px-1 py-1">
{Array.from({ length: 2 }).map((_, index) => (
<div key={index} className="space-y-2">
<Skeleton className="h-3 w-16" />
<Skeleton className="h-8 w-24" />
</div>
))}
</div>
</section>
)
}
return (
<section className={cn("space-y-4", className)}>
<div className="flex items-center gap-2">
<UserRound className="size-4 text-muted-foreground" />
<h2 className="text-lg font-semibold">我的排名</h2>
</div>
<div className="space-y-5 px-1 py-1">
{data ? (
<>
<div className="space-y-1">
<div className="text-[11px] font-medium text-muted-foreground">当前名次</div>
<div className="text-3xl font-semibold tabular-nums">#{data.user.rank.toLocaleString()}</div>
</div>
<div className="space-y-1">
<div className="text-[11px] font-medium text-muted-foreground">可用积分</div>
<div className="text-2xl font-semibold tabular-nums">{parseFloat(data.user.available_balance).toFixed(2)}</div>
</div>
</>
) : (
<div className="space-y-1">
<div className="text-[11px] font-medium text-muted-foreground">当前状态</div>
<div className="text-base font-medium">暂未进入榜单</div>
<div className="text-sm text-muted-foreground">继续积累可用积分后会出现在排行榜中。</div>
</div>
)}
</div>
</section>
)
})