flnxi-Wu/components/RankingPanel.tsx
WuYongJie d1c6efc79d
Some checks failed
CI Check / check (push) Has been cancelled
feat: NAS 部署与 Harbor 推送流程
2025-12-03 14:39:06 +08:00

498 lines
18 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, useMemo } from 'react'
import { useThemeStyles, cn } from '@/lib/ui-styles'
interface RankingUser {
username: string
count: number
}
interface RankingData {
contributors: RankingUser[]
downloads: RankingUser[]
likes: RankingUser[]
}
type PanelTab = 'contributors' | 'downloads' | 'likes'
const PANEL_DIMENSIONS = { width: 150, height: 400 }
const BUTTON_SIZE = 48
const clampToViewport = (
position: { x: number; y: number },
size: { width: number; height: number }
) => {
if (typeof window === 'undefined') return position
const maxX = Math.max(0, window.innerWidth - size.width)
const maxY = Math.max(0, window.innerHeight - size.height)
return {
x: Math.min(Math.max(0, position.x), maxX),
y: Math.min(Math.max(0, position.y), maxY),
}
}
const TAB_META: Record<
PanelTab,
{ label: string; accent: string }
> = {
contributors: {
label: '贡献榜',
accent: 'text-amber-600',
},
downloads: {
label: '下载榜',
accent: 'text-sky-600',
},
likes: {
label: '点赞榜',
accent: 'text-rose-600',
},
}
export default function RankingPanel() {
const styles = useThemeStyles()
const [isVisible, setIsVisible] = useState(true)
const [currentTab, setCurrentTab] = useState<PanelTab>('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 [panelCentered, setPanelCentered] = useState(true)
const [isDragging, setIsDragging] = useState(false)
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 })
const panelRef = useRef<HTMLDivElement>(null)
const buttonRef = useRef<HTMLButtonElement>(null)
const [buttonPosition, setButtonPosition] = useState({ x: 32, y: 0 }) // 缩略按钮位置
const [buttonCentered, setButtonCentered] = useState(true)
const [isButtonDragging, setIsButtonDragging] = useState(false)
const [buttonDragOffset, setButtonDragOffset] = useState({ x: 0, y: 0 })
// 自动轮播 - 滚动间隔调慢到10秒
useEffect(() => {
const tabs: PanelTab[] = ['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({
contributors: (data.contributors || []).filter((item: RankingUser) => item.count > 0),
downloads: (data.downloads || []).filter((item: RankingUser) => item.count > 0),
likes: (data.likes || []).filter((item: RankingUser) => item.count > 0),
})
}
} catch (error) {
console.error('获取排行榜失败:', error)
} finally {
setLoading(false)
}
}
fetchRankings()
// 每30秒更新一次数据
const interval = setInterval(fetchRankings, 30000)
return () => clearInterval(interval)
}, [])
const clampPanelPosition = (value: { x: number; y: number }) => {
const width = panelRef.current?.offsetWidth || PANEL_DIMENSIONS.width
const height = panelRef.current?.offsetHeight || PANEL_DIMENSIONS.height
return clampToViewport(value, { width, height })
}
const clampButtonPosition = (value: { x: number; y: number }) => {
const width = buttonRef.current?.offsetWidth || BUTTON_SIZE
const height = buttonRef.current?.offsetHeight || BUTTON_SIZE
return clampToViewport(value, { width, height })
}
const handleClose = () => {
setIsVisible(false)
// 保存关闭状态到localStorage
localStorage.setItem('rankingPanelClosed', 'true')
// 保存当前面板位置作为缩略按钮的初始位置
const clamped = clampPanelPosition(position)
setButtonPosition(clamped)
setButtonCentered(false)
localStorage.setItem('rankingPanelButtonPosition', JSON.stringify(clamped))
}
const handleOpen = () => {
setIsVisible(true)
localStorage.removeItem('rankingPanelClosed')
setPosition((prev) => {
const fallback = buttonCentered
? { x: buttonPosition.x, y: 0 }
: buttonPosition.x === 0 && buttonPosition.y === 0
? prev
: buttonPosition
const nextPosition = clampPanelPosition(fallback)
setPanelCentered(buttonCentered && nextPosition.y === 0)
localStorage.setItem('rankingPanelPosition', JSON.stringify(nextPosition))
return nextPosition
})
}
// 检查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 size = {
width: panelRef.current?.offsetWidth || PANEL_DIMENSIONS.width,
height: panelRef.current?.offsetHeight || PANEL_DIMENSIONS.height,
}
const newPosition = clampToViewport({ x: newX, y: newY }, size)
setPanelCentered(false)
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(clampPanelPosition(pos))
setPanelCentered(false)
} catch (e) {
// 忽略解析错误
}
}
// 恢复缩略按钮位置
const savedButtonPosition = localStorage.getItem('rankingPanelButtonPosition')
if (savedButtonPosition) {
try {
const pos = JSON.parse(savedButtonPosition)
setButtonPosition(clampButtonPosition(pos))
setButtonCentered(false)
} catch (e) {
// 忽略解析错误
}
}
}, [])
useEffect(() => {
const handleResize = () => {
setPosition((prev) => clampPanelPosition(prev))
setButtonPosition((prev) => clampButtonPosition(prev))
}
window.addEventListener('resize', handleResize)
return () => window.removeEventListener('resize', handleResize)
}, [])
// 缩略按钮拖动处理
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)
}
}
useEffect(() => {
const handleButtonMouseMove = (e: MouseEvent) => {
if (isButtonDragging && buttonRef.current) {
const newX = e.clientX - buttonDragOffset.x
const newY = e.clientY - buttonDragOffset.y
const size = {
width: buttonRef.current.offsetWidth || BUTTON_SIZE,
height: buttonRef.current.offsetHeight || BUTTON_SIZE,
}
const newPosition = clampToViewport({ x: newX, y: newY }, size)
setButtonCentered(false)
setButtonPosition(newPosition)
// 保存位置到localStorage
localStorage.setItem('rankingPanelButtonPosition', JSON.stringify(newPosition))
}
}
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 currentTabMeta = useMemo(() => TAB_META[currentTab], [currentTab])
// 必须在组件顶层调用 hooks
const clickStartRef = useRef<{ x: number; y: number; time: number } | null>(null)
if (!isVisible) {
// 显示一个小的重新打开按钮(可拖动)
return (
<button
ref={buttonRef}
onMouseDown={(e) => {
// 记录点击开始位置和时间
clickStartRef.current = {
x: e.clientX,
y: e.clientY,
time: Date.now()
}
handleButtonMouseDown(e)
}}
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={cn(
'fixed z-40 w-12 h-12 rounded-xl shadow-lg border flex items-center justify-center transition-colors cursor-move backdrop-blur',
'bg-white/90 dark:bg-slate-900/90',
'border-slate-200 dark:border-slate-700',
'hover:bg-white dark:hover:bg-slate-900',
isButtonDragging && 'cursor-grabbing'
)}
style={{
left: `${buttonPosition.x}px`,
top: buttonCentered ? '50%' : `${buttonPosition.y}px`,
transform: buttonCentered ? 'translateY(-50%)' : 'none',
}}
title="显示排行榜(可拖动)"
>
<svg className="w-6 h-6 text-slate-500 dark:text-slate-400 pointer-events-none" 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-slate-400 dark:text-slate-500" 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 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-2xl shadow-2xl border overflow-hidden backdrop-blur-xl',
'bg-white/90 dark:bg-slate-950',
'border-slate-200/80 dark:border-slate-800'
)}
style={{
left: `${position.x}px`,
top: panelCentered ? '50%' : `${position.y}px`,
transform: panelCentered ? 'translateY(-50%)' : 'none',
cursor: isDragging ? 'grabbing' : 'default'
}}
>
{/* 拖动区域和关闭按钮 */}
<div
className="bg-white/80 dark:bg-slate-900/80 px-3 py-3 text-center border-b border-slate-200/80 dark:border-slate-800 relative cursor-move select-none"
onMouseDown={handleMouseDown}
>
{/* 关闭按钮 */}
<button
onClick={handleClose}
className="absolute top-1.5 right-1.5 w-6 h-6 flex items-center justify-center bg-white/80 dark:bg-slate-900/80 border border-white/50 dark:border-slate-700 rounded-full transition-colors z-10 hover:bg-white dark:hover:bg-slate-800"
aria-label="关闭"
onMouseDown={(e) => e.stopPropagation()}
>
<svg className="w-4 h-4 text-slate-500 dark:text-slate-400" 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-slate-900 dark:text-white leading-tight">
{currentTabMeta.label}
</h3>
</div>
{/* 指示器 */}
<div className="flex items-center gap-1 px-3 py-2 bg-white/70 dark:bg-slate-900/80 border-b border-slate-200/60 dark:border-slate-800/70">
{(Object.keys(TAB_META) as PanelTab[]).map((tabKey) => {
const isActive = tabKey === currentTab
return (
<button
key={tabKey}
onClick={() => setCurrentTab(tabKey)}
className={cn(
'flex-1 h-1.5 rounded-full transition-all',
isActive
? 'bg-slate-400 dark:bg-slate-200'
: 'bg-slate-200/70 dark:bg-slate-700/70 hover:bg-slate-300/80 dark:hover:bg-slate-600/80'
)}
>
<span className="sr-only">{TAB_META[tabKey].label}</span>
</button>
)
})}
</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-slate-500 dark:text-slate-400">加载中...</div>
</div>
) : getCurrentData().length === 0 ? (
<div className="flex items-center justify-center h-full">
<div className="text-xs text-slate-500 dark:text-slate-400 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 border ${
index < 3
? 'bg-yellow-50 text-slate-900 dark:bg-slate-900/70 dark:text-white border-yellow-100 dark:border-yellow-500/20'
: 'bg-slate-100 text-slate-700 dark:bg-slate-900/40 dark:text-slate-200 border-slate-200 dark:border-slate-800'
}`}
>
{/* 排名/奖牌 */}
<div className="flex-shrink-0 w-6 flex items-center justify-center">
{index < 3 ? (
getMedalIcon(index)
) : (
<span className="text-xs font-medium text-slate-400 dark:text-slate-500">
{index + 1}
</span>
)}
</div>
{/* 用户名 */}
<div className="flex-1 min-w-0">
<div className="text-xs font-medium truncate">
{user.username}
</div>
</div>
{/* 数量 */}
<div className="flex-shrink-0">
<span className="text-xs font-bold">
{user.count}
</span>
</div>
</div>
))}
</div>
)}
</div>
</div>
)
}