435 lines
16 KiB
TypeScript
435 lines
16 KiB
TypeScript
'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>
|
||
)
|
||
}
|