gongxiangp2/components/RankingPanel.tsx
2026-08-19 12:08:38 +08:00

435 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client'
import { useState, useEffect, useRef } from 'react'
interface RankingUser {
username: string
count: number
}
interface RankingData {
contributors: RankingUser[]
downloads: RankingUser[]
likes: RankingUser[]
}
interface Position {
x: number
y: number
}
type RankingPanelProps = {
appearance?: 'dark' | 'light'
safeLeft?: number
dockRight?: boolean
}
export default function RankingPanel({ appearance = 'dark', safeLeft = 0, dockRight = false }: RankingPanelProps) {
const [isVisible, setIsVisible] = useState(!dockRight)
const [currentTab, setCurrentTab] = useState<'contributors' | 'downloads' | 'likes'>('contributors')
const [rankings, setRankings] = useState<RankingData>({
contributors: [],
downloads: [],
likes: []
})
const [loading, setLoading] = useState(true)
const [position, setPosition] = useState<Position>(() => ({
x: 32,
y: typeof window === 'undefined' ? 200 : window.innerHeight / 2 - 200,
}))
const [isDragging, setIsDragging] = useState(false)
const [dragOffset, setDragOffset] = useState<Position>({ x: 0, y: 0 })
const panelRef = useRef<HTMLDivElement>(null)
const buttonRef = useRef<HTMLButtonElement>(null)
const [buttonPosition, setButtonPosition] = useState<Position>(() => ({
x: 32,
y: typeof window === 'undefined' ? 200 : window.innerHeight / 2 - 24,
}))
const [isButtonDragging, setIsButtonDragging] = useState(false)
const [buttonDragOffset, setButtonDragOffset] = useState<Position>({ x: 0, y: 0 })
const clickStartRef = useRef<{ x: number; y: number; time: number } | null>(null)
const isLight = appearance === 'light'
// 从localStorage恢复状态
useEffect(() => {
if (dockRight) {
setIsVisible(false)
return
}
try {
const saved = localStorage.getItem('ranking_panel_state')
if (saved) {
const state = JSON.parse(saved)
// 默认显示为迷你按钮(isVisible默认为false)
setIsVisible(state.isVisible ?? false)
if (state.position) {
setPosition(state.position)
}
if (state.buttonPosition) {
setButtonPosition(state.buttonPosition)
}
} else {
// 首次访问,默认为迷你按钮
setIsVisible(false)
}
} catch (error) {
console.error('恢复榜单面板状态失败:', error)
setIsVisible(false)
}
}, [dockRight])
useEffect(() => {
const minimumX = window.innerWidth >= 1024 ? safeLeft : 0
setPosition((current) => current.x < minimumX ? { ...current, x: minimumX + 16 } : current)
setButtonPosition((current) => current.x < minimumX ? { ...current, x: minimumX + 16 } : current)
}, [safeLeft])
// 保存状态到localStorage
const saveState = () => {
try {
localStorage.setItem('ranking_panel_state', JSON.stringify({
isVisible,
position,
buttonPosition
}))
} catch (error) {
console.error('保存榜单面板状态失败:', error)
}
}
// 状态变化时保存
useEffect(() => {
saveState()
}, [isVisible, position, buttonPosition])
// 自动轮播
useEffect(() => {
const tabs: ('contributors' | 'downloads' | 'likes')[] = ['contributors', 'downloads', 'likes']
let currentIndex = 0
const interval = setInterval(() => {
currentIndex = (currentIndex + 1) % tabs.length
setCurrentTab(tabs[currentIndex])
}, 5000)
return () => clearInterval(interval)
}, [])
// 获取排行榜数据
useEffect(() => {
const fetchRankings = async () => {
try {
const response = await fetch('/api/rankings')
if (response.ok) {
const data = await response.json()
setRankings(data)
}
} catch (error) {
console.error('获取排行榜失败:', error)
} finally {
setLoading(false)
}
}
fetchRankings()
const interval = setInterval(fetchRankings, 30000)
return () => clearInterval(interval)
}, [])
// 拖拽处理
const handleMouseDown = (e: React.MouseEvent) => {
if (panelRef.current) {
setIsDragging(true)
const rect = panelRef.current.getBoundingClientRect()
setDragOffset({
x: e.clientX - rect.left,
y: e.clientY - rect.top
})
}
}
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (isDragging) {
const newX = e.clientX - dragOffset.x
const newY = e.clientY - dragOffset.y
// 边界约束
const maxX = window.innerWidth - 150
const maxY = window.innerHeight - 400
setPosition({
x: Math.max(window.innerWidth >= 1024 ? safeLeft : 0, Math.min(newX, maxX)),
y: Math.max(0, Math.min(newY, maxY))
})
}
}
const handleMouseUp = () => {
setIsDragging(false)
}
if (isDragging) {
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
}
return () => {
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
}, [isDragging, dragOffset, safeLeft])
// 缩略按钮拖动处理
const handleButtonMouseDown = (e: React.MouseEvent) => {
if (buttonRef.current) {
const rect = buttonRef.current.getBoundingClientRect()
setButtonDragOffset({
x: e.clientX - rect.left,
y: e.clientY - rect.top
})
setIsButtonDragging(true)
// 记录点击开始位置和时间
clickStartRef.current = {
x: e.clientX,
y: e.clientY,
time: Date.now()
}
}
}
useEffect(() => {
const handleButtonMouseMove = (e: MouseEvent) => {
if (isButtonDragging) {
const newX = e.clientX - buttonDragOffset.x
const newY = e.clientY - buttonDragOffset.y
// 边界约束
const maxX = window.innerWidth - 48
const maxY = window.innerHeight - 48
setButtonPosition({
x: Math.max(window.innerWidth >= 1024 ? safeLeft : 0, Math.min(newX, maxX)),
y: Math.max(0, Math.min(newY, maxY))
})
}
}
const handleButtonMouseUp = () => {
setIsButtonDragging(false)
}
if (isButtonDragging) {
document.addEventListener('mousemove', handleButtonMouseMove)
document.addEventListener('mouseup', handleButtonMouseUp)
}
return () => {
document.removeEventListener('mousemove', handleButtonMouseMove)
document.removeEventListener('mouseup', handleButtonMouseUp)
}
}, [isButtonDragging, buttonDragOffset, safeLeft])
const handleClose = () => {
setIsVisible(false)
}
const handleOpen = () => {
setIsVisible(true)
// 打开时使用按钮的当前位置
setPosition(buttonPosition)
}
const getMedalIcon = (index: number) => {
switch (index) {
case 0:
return (
<svg className="w-5 h-5 text-yellow-400" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
)
case 1:
return (
<svg className="w-5 h-5 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
)
case 2:
return (
<svg className="w-5 h-5 text-orange-600" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
)
default:
return null
}
}
const getTabTitle = () => {
switch (currentTab) {
case 'contributors':
return '🏆 贡献排行榜'
case 'downloads':
return '📥 下载排行榜'
case 'likes':
return '❤️ 点赞排行榜'
}
}
const getCurrentData = () => {
switch (currentTab) {
case 'contributors':
return rankings.contributors
case 'downloads':
return rankings.downloads
case 'likes':
return rankings.likes
}
}
// 迷你按钮
if (!isVisible) {
return (
<button
ref={buttonRef}
onMouseDown={handleButtonMouseDown}
onClick={(e) => {
// 检查是否是拖动操作(移动超过5px或时间超过200ms)
if (clickStartRef.current) {
const deltaX = Math.abs(e.clientX - clickStartRef.current.x)
const deltaY = Math.abs(e.clientY - clickStartRef.current.y)
const deltaTime = Date.now() - clickStartRef.current.time
// 如果移动距离小于5px且时间小于200ms,认为是点击
if (deltaX < 5 && deltaY < 5 && deltaTime < 200 && !isButtonDragging) {
handleOpen()
}
clickStartRef.current = null
}
}}
className={`fixed z-40 flex h-12 w-12 items-center justify-center rounded-full transition-transform hover:scale-110 ${dockRight ? 'bottom-24 right-6' : ''} ${isLight ? 'bg-white shadow-[0_10px_28px_rgba(67,93,126,0.16)]' : 'border border-gray-700 bg-gray-900 shadow-2xl dark:border-gray-800 dark:bg-gray-950'}`}
style={dockRight ? undefined : { left: `${buttonPosition.x}px`, top: `${buttonPosition.y}px`, cursor: isButtonDragging ? 'grabbing' : 'grab' }}
title="打开榜单面板(可拖动)"
aria-label="打开榜单面板"
>
<svg className={`pointer-events-none h-6 w-6 ${isLight ? 'text-[#247fd3]' : 'text-yellow-400'}`} fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
</button>
)
}
return (
<div
ref={panelRef}
className={`fixed z-40 h-[400px] w-[150px] overflow-hidden rounded-2xl ${dockRight ? 'right-6 top-24' : ''} ${isLight ? 'bg-white text-[#101828] shadow-[0_14px_38px_rgba(67,93,126,0.16)]' : 'border border-gray-700 bg-gray-900 shadow-2xl backdrop-blur-sm dark:border-gray-800 dark:bg-gray-950'}`}
style={dockRight ? undefined : { left: `${position.x}px`, top: `${position.y}px`, cursor: isDragging ? 'grabbing' : 'grab' }}
>
{/* 拖拽区域 */}
<div
onMouseDown={dockRight ? undefined : handleMouseDown}
className={`${dockRight ? '' : 'cursor-grab active:cursor-grabbing'} border-b px-3 py-3 text-center ${isLight ? 'border-[#e2e9f2] bg-[#f1f6fc]' : 'border-gray-700 bg-gradient-to-r from-gray-800 to-gray-900'}`}
>
<h3 className={`text-sm font-bold leading-tight ${isLight ? 'text-[#1b2738]' : 'text-white'}`}>
{getTabTitle()}
</h3>
</div>
{/* 关闭按钮 */}
<button
onClick={handleClose}
className={`absolute right-1.5 top-1.5 z-10 flex h-4 w-4 items-center justify-center rounded-full transition-colors ${isLight ? 'bg-white hover:bg-[#e5eef8]' : 'bg-gray-800 hover:bg-gray-700'}`}
aria-label="关闭"
>
<svg className={`h-2 w-2 ${isLight ? 'text-[#66758b]' : 'text-gray-400 hover:text-white'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
{/* 指示器 */}
<div className={`flex justify-center gap-1 border-b py-2 ${isLight ? 'border-[#e2e9f2] bg-[#f8fafd]' : 'border-gray-700 bg-gray-800'}`}>
<button
onClick={() => setCurrentTab('contributors')}
aria-label="查看贡献榜"
aria-pressed={currentTab === 'contributors'}
className={`w-1.5 h-1.5 rounded-full transition-all ${
currentTab === 'contributors' ? `${isLight ? 'bg-[#247fd3]' : 'bg-white'} w-4` : `${isLight ? 'bg-[#c7d8eb]' : 'bg-gray-600'}`
}`}
/>
<button
onClick={() => setCurrentTab('downloads')}
aria-label="查看下载榜"
aria-pressed={currentTab === 'downloads'}
className={`w-1.5 h-1.5 rounded-full transition-all ${
currentTab === 'downloads' ? `${isLight ? 'bg-[#247fd3]' : 'bg-white'} w-4` : `${isLight ? 'bg-[#c7d8eb]' : 'bg-gray-600'}`
}`}
/>
<button
onClick={() => setCurrentTab('likes')}
aria-label="查看点赞榜"
aria-pressed={currentTab === 'likes'}
className={`w-1.5 h-1.5 rounded-full transition-all ${
currentTab === 'likes' ? `${isLight ? 'bg-[#247fd3]' : 'bg-white'} w-4` : `${isLight ? 'bg-[#c7d8eb]' : 'bg-gray-600'}`
}`}
/>
</div>
{/* 排行榜内容 */}
<div className="p-3 overflow-y-auto" style={{ height: 'calc(100% - 80px)' }}>
{loading ? (
<div className="flex items-center justify-center h-full">
<div className={`text-xs ${isLight ? 'text-[#738198]' : 'text-gray-400'}`}>加载中...</div>
</div>
) : getCurrentData().length === 0 ? (
<div className="flex items-center justify-center h-full">
<div className={`text-center text-xs ${isLight ? 'text-[#738198]' : 'text-gray-500'}`}>暂无数据</div>
</div>
) : (
<div className="space-y-2">
{getCurrentData().map((user, index) => (
<div
key={index}
className={`flex items-center gap-2 p-2 rounded-lg transition-all ${
index < 3
? isLight
? 'bg-amber-50'
: 'bg-gradient-to-r from-yellow-900/30 to-orange-900/30 border border-yellow-600/30'
: isLight
? 'bg-[#f7f9fc]'
: 'bg-gray-800/50 border border-gray-700'
}`}
>
<div className="flex-shrink-0 w-6 flex items-center justify-center">
{index < 3 ? (
getMedalIcon(index)
) : (
<span className="text-xs font-medium text-gray-400">
{index + 1}
</span>
)}
</div>
<div className="flex-1 min-w-0">
<div
className={`text-xs font-medium truncate ${
isLight ? 'text-[#1b2738]' : index < 3 ? 'text-yellow-400' : 'text-gray-200'
}`}
>
{user.username}
</div>
</div>
<div className="flex-shrink-0">
<span className={`text-xs font-bold ${
isLight ? 'text-[#176fc1]' : index < 3 ? 'text-yellow-400' : 'text-gray-400'
}`}>
{user.count}
</span>
</div>
</div>
))}
</div>
)}
</div>
</div>
)
}