flnxi-Wu/components/RankingPanel.tsx
2025-11-22 17:56:24 +08:00

347 lines
12 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.

/**
* RankingPanel 组件
*
* 排行榜面板组件
* 显示贡献者、下载、点赞排行榜
* 支持自动轮播、拖拽、折叠等功能
* 使用三层架构样式系统,支持深浅色主题
*
* @created 2025-01-XX
* @component
*/
'use client'
import { useState, useEffect, useRef } from 'react'
import { useThemeStyles, cn } from '@/lib/ui-styles'
interface RankingUser {
username: string
count: number
}
interface RankingData {
contributors: RankingUser[]
downloads: RankingUser[]
likes: RankingUser[]
}
export default function RankingPanel() {
const styles = useThemeStyles()
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({ x: 32, y: 0 }) // 默认位置 left-8 = 32px
const [isDragging, setIsDragging] = useState(false)
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 })
const panelRef = useRef<HTMLDivElement>(null)
// 自动轮播 - 滚动间隔调慢到10秒
useEffect(() => {
const tabs: ('contributors' | 'downloads' | 'likes')[] = ['contributors', 'downloads', 'likes']
let currentIndex = 0
const interval = setInterval(() => {
currentIndex = (currentIndex + 1) % tabs.length
setCurrentTab(tabs[currentIndex])
}, 10000) // 每10秒切换一次(原来5秒,现在更慢)
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()
// 每30秒更新一次数据
const interval = setInterval(fetchRankings, 30000)
return () => clearInterval(interval)
}, [])
const handleClose = () => {
setIsVisible(false)
// 保存关闭状态到localStorage
localStorage.setItem('rankingPanelClosed', 'true')
}
const handleOpen = () => {
setIsVisible(true)
localStorage.removeItem('rankingPanelClosed')
}
// 检查localStorage中的关闭状态
useEffect(() => {
const closed = localStorage.getItem('rankingPanelClosed')
if (closed === 'true') {
setIsVisible(false)
}
}, [])
// 拖动处理
const handleMouseDown = (e: React.MouseEvent) => {
if (panelRef.current) {
const rect = panelRef.current.getBoundingClientRect()
setDragOffset({
x: e.clientX - rect.left,
y: e.clientY - rect.top
})
setIsDragging(true)
}
}
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (isDragging) {
const newX = e.clientX - dragOffset.x
const newY = e.clientY - dragOffset.y
// 限制在视口内
const maxX = window.innerWidth - (panelRef.current?.offsetWidth || 150)
const maxY = window.innerHeight - (panelRef.current?.offsetHeight || 400)
const newPosition = {
x: Math.max(0, Math.min(newX, maxX)),
y: Math.max(0, Math.min(newY, maxY))
}
setPosition(newPosition)
// 保存位置到localStorage
localStorage.setItem('rankingPanelPosition', JSON.stringify(newPosition))
}
}
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, position])
// 从localStorage恢复位置
useEffect(() => {
const savedPosition = localStorage.getItem('rankingPanelPosition')
if (savedPosition) {
try {
const pos = JSON.parse(savedPosition)
setPosition(pos)
} catch (e) {
// 忽略解析错误
}
}
}, [])
if (!isVisible) {
// 显示一个小的重新打开按钮
return (
<button
onClick={handleOpen}
className={cn(
'fixed left-8 top-1/2 -translate-y-1/2 z-40 w-12 h-12 rounded-lg shadow-lg border flex items-center justify-center transition-colors',
'bg-slate-900 dark:bg-slate-950',
'border-slate-700 dark:border-slate-800',
'hover:bg-slate-800'
)}
title="显示排行榜"
>
<svg className="w-6 h-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
</button>
)
}
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
}
}
return (
<div
ref={panelRef}
className={cn(
'fixed z-40 w-[150px] h-[400px] rounded-lg shadow-2xl border overflow-hidden backdrop-blur-sm',
'bg-slate-900 dark:bg-slate-950',
'border-slate-700 dark:border-slate-800'
)}
style={{
left: `${position.x}px`,
top: position.y === 0 ? '50%' : `${position.y}px`,
transform: position.y === 0 ? 'translateY(-50%)' : 'none',
cursor: isDragging ? 'grabbing' : 'default'
}}
>
{/* 拖动区域和关闭按钮 */}
<div
className="bg-gradient-to-r from-gray-800 to-gray-900 px-3 py-3 text-center border-b border-gray-700 relative cursor-move select-none"
onMouseDown={handleMouseDown}
>
{/* 关闭按钮 */}
<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="关闭"
onMouseDown={(e) => e.stopPropagation()} // 阻止拖动
>
<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>
{/* 标题 */}
<h3 className="text-sm font-bold text-white leading-tight">
{getTabTitle()}
</h3>
</div>
{/* 指示器 */}
<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>
)
}