gongxiangp2/components/RankingPanel.tsx
chenpei 6c6c282203 feat: 全站视觉统一白皮书语言 + 个人档案改造 + 顶栏平台化
视觉统一(Zen Minimal × Future Organic 白皮书语言,全站浅色单主题):
- 登录页:品牌→由壹点·品牌中心,白纸底+发丝线+柔光光晕,黑按钮
- 作品过稿:深灰头→白皮书,返回链接→返回创意资产库
- 绩效考核:桌面+移动双头部换白皮书,标题改绩效考核,返回改链接
- 组织架构:BoardShell+页面 76 处调色板全量替换(#eef1f4→#fafafa 等)
- admin 后台:MaterialsHeader(面包屑治理与设置),Tab 栏墨色高亮
- 深色主题废弃:globals.css 移除媒体查询,tailwind darkMode:'class' 不挂 .dark
- 设计文档同步更新:AGENTS.md UI 基线/DESIGN.md/PRODUCT.md/README.md/design-qa.md

个人档案(原个人中心):
- API 合并权威人员档案(getEmployeeByUserId → 花名/工号/部门/职位/M-P等级/主管/状态)
- 页面白皮书化:权威档案卡(只读 HR 字段)+ 账号区(头像上传)+ 素材/收藏

顶栏平台化:
- MaterialsHeader 双模式:首页(五板块导航+全套工具+菜单)/板块(面包屑)
- 菜单重排:我的档案/绩效考核/作品过稿看板‖更新记录/提交反馈‖治理与设置‖退出
- 菜单头身份行:花名+职位·部门(权威档案)
- 首页排序:01 创意资产库(全员)→ 02 组织管理(管理员)→ 03-05 规划中
- 素材库改名创意资产库,全站同步(索引/导航/卡片/按钮/页脚)
- 排行榜换白皮书(白底发丝线+琥珀前三名)

构建修复:
- tsconfig 排除 output/(本地审计临时文件不再卡类型检查)
- 存储边界门禁豁免 output/(与 .dockerignore 对齐)
2026-08-15 14:16:20 +08:00

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 flex h-12 w-12 items-center justify-center rounded-full border border-[#e7e8ea] bg-white shadow-[0_12px_32px_-12px_rgba(26,28,29,0.28)] transition-transform hover:scale-110"
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 h-[400px] w-[150px] overflow-hidden rounded-xl border border-[#e7e8ea] bg-white shadow-[0_24px_60px_-24px_rgba(26,28,29,0.3)]"
style={{ left: `${position.x}px`, top: `${position.y}px`, cursor: isDragging ? 'grabbing' : 'grab' }}
>
{/* 拖拽区域 */}
<div
onMouseDown={handleMouseDown}
className="cursor-grab border-b border-[#e7e8ea] bg-[#fafafa] px-3 py-3 text-center active:cursor-grabbing"
>
<h3 className="text-sm font-semibold leading-tight text-[#1a1c1d]">
{getTabTitle()}
</h3>
</div>
{/* 关闭按钮 */}
<button
onClick={handleClose}
className="absolute top-1.5 right-1.5 z-10 flex h-4 w-4 items-center justify-center rounded-full border border-[#e7e8ea] bg-white transition-colors hover:bg-[#e7e8ea]"
aria-label="关闭"
>
<svg className="h-2 w-2 text-[#6b7075]" 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 border-[#e7e8ea] bg-[#fafafa] py-2">
<button
onClick={() => setCurrentTab('contributors')}
className={`h-1.5 w-1.5 rounded-full transition-all ${
currentTab === 'contributors' ? 'w-4 bg-[#1a1c1d]' : 'bg-[#d4d6da]'
}`}
/>
<button
onClick={() => setCurrentTab('downloads')}
className={`h-1.5 w-1.5 rounded-full transition-all ${
currentTab === 'downloads' ? 'w-4 bg-[#1a1c1d]' : 'bg-[#d4d6da]'
}`}
/>
<button
onClick={() => setCurrentTab('likes')}
className={`h-1.5 w-1.5 rounded-full transition-all ${
currentTab === 'likes' ? 'w-4 bg-[#1a1c1d]' : 'bg-[#d4d6da]'
}`}
/>
</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-[#86898e]">加载中...</div>
</div>
) : getCurrentData().length === 0 ? (
<div className="flex items-center justify-center h-full">
<div className="text-xs text-center text-[#86898e]">暂无数据</div>
</div>
) : (
<div className="space-y-2">
{getCurrentData().map((user, index) => (
<div
key={index}
className={`flex items-center gap-2 rounded-lg border p-2 transition-all ${
index < 3
? 'border-[#f0dfb8] bg-[#fdf6e9]'
: 'border-[#e7e8ea] bg-[#fafafa]'
}`}
>
<div className="flex-shrink-0 w-6 flex items-center justify-center">
{index < 3 ? (
getMedalIcon(index)
) : (
<span className="text-xs font-medium text-[#86898e]">
{index + 1}
</span>
)}
</div>
<div className="flex-1 min-w-0">
<div
className={`text-xs font-medium truncate ${
index < 3 ? 'text-[#a0671c]' : 'text-[#1a1c1d]'
}`}
>
{user.username}
</div>
</div>
<div className="flex-shrink-0">
<span className={`text-xs font-bold ${
index < 3 ? 'text-[#a0671c]' : 'text-[#6b7075]'
}`}>
{user.count}
</span>
</div>
</div>
))}
</div>
)}
</div>
</div>
)
}