1. 排行榜悬浮窗关闭后小按钮消失问题 - 修复面板居中模式关闭时按钮位置错误 - 保存并恢复按钮的居中状态 2. 首页tag按钮按素材数量排序 - 添加按素材数量降序排序逻辑 3. 上传弹窗输入框浅色模式文字不可见 - 添加 text-slate-900 确保浅色模式下文字可见 - 添加 placeholder 样式确保占位符正确显示
511 lines
18 KiB
TypeScript
511 lines
18 KiB
TypeScript
/**
|
||
* RankingPanel 组件
|
||
*
|
||
* 排行榜面板组件
|
||
* 显示贡献者、下载、点赞排行榜
|
||
* 支持自动轮播、拖拽、折叠等功能
|
||
* 使用三层架构样式系统,支持深浅色主题
|
||
*
|
||
* @created 2025-01-XX
|
||
* @component
|
||
*/
|
||
|
||
'use client'
|
||
|
||
import { useState, useEffect, useRef, useMemo } from 'react'
|
||
import Link from 'next/link'
|
||
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')
|
||
// 保存当前面板位置作为缩略按钮的初始位置
|
||
// 如果面板是居中模式,按钮也应该保持居中
|
||
if (panelCentered) {
|
||
setButtonPosition({ x: position.x, y: 0 })
|
||
setButtonCentered(true)
|
||
localStorage.setItem('rankingPanelButtonPosition', JSON.stringify({ x: position.x, y: 0, centered: true }))
|
||
} else {
|
||
const clamped = clampPanelPosition(position)
|
||
setButtonPosition(clamped)
|
||
setButtonCentered(false)
|
||
localStorage.setItem('rankingPanelButtonPosition', JSON.stringify({ ...clamped, centered: false }))
|
||
}
|
||
}
|
||
|
||
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({ x: pos.x, y: pos.y }))
|
||
// 恢复居中状态
|
||
setButtonCentered(pos.centered === true)
|
||
} 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">
|
||
<Link
|
||
href={`/author/${encodeURIComponent(user.username)}`}
|
||
className="text-xs font-medium truncate block hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
|
||
title={`查看 ${user.username} 的作品`}
|
||
>
|
||
{user.username}
|
||
</Link>
|
||
</div>
|
||
|
||
{/* 数量 */}
|
||
<div className="flex-shrink-0">
|
||
<span className="text-xs font-bold">
|
||
{user.count}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|