gongxiangp2/components/RankingPanel.tsx

401 lines
14 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
}
export default function RankingPanel() {
const [isVisible, setIsVisible] = useState(true)
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: 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: 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)
// 从localStorage恢复状态
useEffect(() => {
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)
}
}, [])
// 保存状态到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(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])
// 缩略按钮拖动处理
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(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])
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 w-12 h-12 bg-gray-900 dark:bg-gray-950 rounded-full shadow-2xl border border-gray-700 dark:border-gray-800 flex items-center justify-center hover:scale-110 transition-transform"
style={{ left: `${buttonPosition.x}px`, top: `${buttonPosition.y}px`, cursor: isButtonDragging ? 'grabbing' : 'grab' }}
title="打开榜单面板(可拖动)"
>
<svg className="w-6 h-6 text-yellow-400 pointer-events-none" 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 w-[150px] h-[400px] bg-gray-900 dark:bg-gray-950 rounded-lg shadow-2xl border border-gray-700 dark:border-gray-800 overflow-hidden backdrop-blur-sm"
style={{ left: `${position.x}px`, top: `${position.y}px`, cursor: isDragging ? 'grabbing' : 'grab' }}
>
{/* 拖拽区域 */}
<div
onMouseDown={handleMouseDown}
className="bg-gradient-to-r from-gray-800 to-gray-900 px-3 py-3 text-center border-b border-gray-700 cursor-grab active:cursor-grabbing"
>
<h3 className="text-sm font-bold text-white leading-tight">
{getTabTitle()}
</h3>
</div>
{/* 关闭按钮 */}
<button
onClick={handleClose}
className="absolute top-1.5 right-1.5 w-3 h-3 flex items-center justify-center bg-gray-800 hover:bg-gray-700 rounded-full transition-colors z-10"
aria-label="关闭"
>
<svg className="w-2 h-2 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 py-2 bg-gray-800 border-b border-gray-700">
<button
onClick={() => setCurrentTab('contributors')}
className={`w-1.5 h-1.5 rounded-full transition-all ${
currentTab === 'contributors' ? 'bg-white w-4' : 'bg-gray-600'
}`}
/>
<button
onClick={() => setCurrentTab('downloads')}
className={`w-1.5 h-1.5 rounded-full transition-all ${
currentTab === 'downloads' ? 'bg-white w-4' : 'bg-gray-600'
}`}
/>
<button
onClick={() => setCurrentTab('likes')}
className={`w-1.5 h-1.5 rounded-full transition-all ${
currentTab === 'likes' ? 'bg-white w-4' : '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 text-gray-400">加载中...</div>
</div>
) : getCurrentData().length === 0 ? (
<div className="flex items-center justify-center h-full">
<div className="text-xs text-gray-500 text-center">暂无数据</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
? 'bg-gradient-to-r from-yellow-900/30 to-orange-900/30 border border-yellow-600/30'
: '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 ${
index < 3 ? 'text-yellow-400' : 'text-gray-200'
}`}
>
{user.username}
</div>
</div>
<div className="flex-shrink-0">
<span className={`text-xs font-bold ${
index < 3 ? 'text-yellow-400' : 'text-gray-400'
}`}>
{user.count}
</span>
</div>
</div>
))}
</div>
)}
</div>
</div>
)
}