mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-10 17:26:38 +08:00
压缩历史至 95081aff
This commit is contained in:
@@ -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>
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user