首测后同步

This commit is contained in:
2025-09-27 01:35:17 +08:00
parent f0cf3447d2
commit bcf220f4ad
9 changed files with 5110 additions and 4487 deletions

BIN
20250927艺术节.db Normal file

Binary file not shown.

2627
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -29,6 +29,8 @@
"socket.io-client": "^4.8.1", "socket.io-client": "^4.8.1",
"sqlite3": "^5.1.7", "sqlite3": "^5.1.7",
"tailwind-merge": "^3.0.2", "tailwind-merge": "^3.0.2",
"vite-plugin-xi-plus-badge": "^1.1.0",
"xlsx": "^0.18.5",
"zustand": "^5.0.3" "zustand": "^5.0.3"
}, },
"devDependencies": { "devDependencies": {

6184
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@@ -9,7 +9,8 @@ const Login = React.lazy(() => import('./pages/Login'))
const Lottery = React.lazy(() => import('./pages/Lottery')) const Lottery = React.lazy(() => import('./pages/Lottery'))
const Home = React.lazy(() => import('./pages/Home')) const Home = React.lazy(() => import('./pages/Home'))
const Admin = React.lazy(() => import('./pages/Admin')) const Admin = React.lazy(() => import('./pages/Admin'))
const Records = React.lazy(() => import('./pages/Records'))
const WinnerDetails = React.lazy(() => import('./pages/WinnerDetails'))
const ClearRecords = React.lazy(() => import('./pages/ClearRecords')) const ClearRecords = React.lazy(() => import('./pages/ClearRecords'))
const ConfettiDemo = React.lazy(() => import('./pages/ConfettiDemo')) const ConfettiDemo = React.lazy(() => import('./pages/ConfettiDemo'))
const CrudTest = React.lazy(() => import('./pages/CrudTest')) const CrudTest = React.lazy(() => import('./pages/CrudTest'))
@@ -112,9 +113,10 @@ function App() {
path="/admin" path="/admin"
element={isAuthenticated ? <Admin /> : <Navigate to="/login" replace />} element={isAuthenticated ? <Admin /> : <Navigate to="/login" replace />}
/> />
<Route <Route
path="/records" path="/winners"
element={isAuthenticated ? <Records /> : <Navigate to="/login" replace />} element={<WinnerDetails />}
/> />
<Route <Route
path="/clear-records" path="/clear-records"

View File

@@ -216,7 +216,7 @@ const Lottery: React.FC = () => {
</button> </button>
<button <button
onClick={() => navigate('/records')} onClick={() => navigate('/winners')}
className="flex items-center gap-2 px-4 py-2 bg-gray-700 text-gray-200 rounded-lg shadow-md hover:shadow-lg hover:bg-gray-600 transition-all duration-200" className="flex items-center gap-2 px-4 py-2 bg-gray-700 text-gray-200 rounded-lg shadow-md hover:shadow-lg hover:bg-gray-600 transition-all duration-200"
> >
<FileText size={18} /> <FileText size={18} />

View File

@@ -1,307 +0,0 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react'
import { Search, Download, ArrowLeft } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import ApiService from '../services/apiService'
import EventService, { EVENT_TYPES } from '../services/eventService'
import type { PrizeConfig } from '../database/database'
import type { LotteryRecord } from '../types'
// 为了兼容现有代码创建DrawRecord类型别名
type DrawRecord = LotteryRecord & { is_winner: boolean }
// 页面组件属性接口 - 使用Record类型避免空接口警告
type RecordsPageProps = Record<string, never>
const Records: React.FC<RecordsPageProps> = () => {
const navigate = useNavigate()
const [records, setRecords] = useState<DrawRecord[]>([])
const [prizes, setPrizes] = useState<PrizeConfig[]>([])
const [loading, setLoading] = useState(true)
const [searchTerm, setSearchTerm] = useState('')
const [selectedPrize, setSelectedPrize] = useState<string>('')
const [dateRange, setDateRange] = useState({ start: '', end: '' })
const [stats, setStats] = useState({
totalDraws: 0,
totalWins: 0,
winRate: 0,
prizeStats: [] as { prize_name: string; count: number }[]
})
const loadData = useCallback(async () => {
setLoading(true)
try {
const api = ApiService.getInstance()
const [recordsData, prizesData] = await Promise.all([
api.getAllRecords(),
api.getAllPrizes()
])
// 转换记录数据格式
const formattedRecords: DrawRecord[] = recordsData.map(record => ({
...record,
is_winner: record.prize_id !== null
}))
setRecords(formattedRecords)
setPrizes(prizesData)
calculateStats(formattedRecords)
} catch (error) {
console.error('Records页面 - 加载数据失败:', error)
// 设置空数据以避免界面错误
setRecords([])
setPrizes([])
setStats({
totalDraws: 0,
totalWins: 0,
winRate: 0,
prizeStats: []
})
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
loadData()
// 监听记录更新事件,实现智能刷新
const eventService = EventService.getInstance();
const unsubscribeRecords = eventService.subscribe(EVENT_TYPES.RECORDS_UPDATED, () => {
console.log('Records页面 - 收到记录更新事件,重新加载数据');
loadData();
});
return () => {
unsubscribeRecords();
};
}, [loadData])
const calculateStats = (recordsData: DrawRecord[]) => {
const totalDraws = recordsData.length
const totalWins = recordsData.filter(r => r.is_winner).length
const winRate = totalDraws > 0 ? (totalWins / totalDraws) * 100 : 0
const prizeStats = recordsData
.filter(r => r.is_winner && r.prize_name)
.reduce((acc, record) => {
const existing = acc.find(p => p.prize_name === record.prize_name)
if (existing) {
existing.count++
} else {
acc.push({ prize_name: record.prize_name!, count: 1 })
}
return acc
}, [] as { prize_name: string; count: number }[])
setStats({ totalDraws, totalWins, winRate, prizeStats })
}
// 使用useMemo优化过滤逻辑避免重复计算
const filteredRecords = useMemo(() => {
return records.filter(record => {
const matchesSearch = searchTerm === '' ||
record.student_id.includes(searchTerm) ||
(record.prize_name && record.prize_name.toLowerCase().includes(searchTerm.toLowerCase()))
const matchesPrize = selectedPrize === '' || record.prize_name === selectedPrize
const matchesDate = (!dateRange.start || new Date(record.draw_time) >= new Date(dateRange.start)) &&
(!dateRange.end || new Date(record.draw_time) <= new Date(dateRange.end))
return matchesSearch && matchesPrize && matchesDate
})
}, [records, searchTerm, selectedPrize, dateRange])
const exportRecords = () => {
const csvContent = [
['学号', '抽奖时间', '是否中奖', '奖项名称'].join(','),
...filteredRecords.map(record => [
record.student_id,
new Date(record.draw_time).toLocaleString(),
record.is_winner ? '是' : '否',
record.prize_name || '未中奖'
].join(','))
].join('\n')
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
const link = document.createElement('a')
const url = URL.createObjectURL(blob)
link.href = url
link.download = `抽奖记录_${new Date().toISOString().split('T')[0]}.csv`
link.click()
// 清理URL对象防止内存泄漏
setTimeout(() => {
URL.revokeObjectURL(url)
}, 100)
}
if (loading) {
return (
<div className="min-h-screen bg-gradient-to-br from-gray-900 to-gray-800 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
<p className="text-gray-300">...</p>
</div>
</div>
)
}
return (
<div className="min-h-screen bg-gradient-to-br from-gray-900 to-gray-800 p-6">
<div className="max-w-7xl mx-auto">
{/* 头部 */}
<div className="bg-gray-800 rounded-lg shadow-md p-6 mb-6 border border-gray-700">
<div className="flex justify-between items-center mb-6">
<div className="flex items-center gap-4">
<button
onClick={() => navigate('/')}
className="flex items-center gap-2 px-3 py-2 text-gray-300 hover:text-gray-100 transition-colors rounded-lg hover:bg-gray-700"
>
<ArrowLeft size={20} />
</button>
<h1 className="text-3xl font-bold text-gray-100"></h1>
</div>
<button
onClick={exportRecords}
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
>
<Download size={20} />
</button>
</div>
{/* 统计信息 */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div className="bg-gray-700 p-4 rounded-lg border border-gray-600">
<h3 className="text-sm font-medium text-blue-400 mb-1"></h3>
<p className="text-2xl font-bold text-blue-300">{stats.totalDraws}</p>
</div>
<div className="bg-gray-700 p-4 rounded-lg border border-gray-600">
<h3 className="text-sm font-medium text-green-400 mb-1"></h3>
<p className="text-2xl font-bold text-green-300">{stats.totalWins}</p>
</div>
<div className="bg-gray-700 p-4 rounded-lg border border-gray-600">
<h3 className="text-sm font-medium text-purple-400 mb-1"></h3>
<p className="text-2xl font-bold text-purple-300">{stats.winRate.toFixed(1)}%</p>
</div>
<div className="bg-gray-700 p-4 rounded-lg border border-gray-600">
<h3 className="text-sm font-medium text-orange-400 mb-1"></h3>
<p className="text-2xl font-bold text-orange-300">{stats.prizeStats.length}</p>
</div>
</div>
{/* 筛选条件 */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={20} />
<input
type="text"
placeholder="搜索学号或奖项名称"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<select
value={selectedPrize}
onChange={(e) => setSelectedPrize(e.target.value)}
className="px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value=""></option>
{prizes.map(prize => (
<option key={prize.id} value={prize.prize_name}>{prize.prize_name}</option>
))}
</select>
<input
type="date"
value={dateRange.start}
onChange={(e) => setDateRange({ ...dateRange, start: e.target.value })}
className="px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
<input
type="date"
value={dateRange.end}
onChange={(e) => setDateRange({ ...dateRange, end: e.target.value })}
className="px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
{/* 记录列表 */}
<div className="bg-gray-800 rounded-lg shadow-md overflow-hidden border border-gray-700">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-600">
<thead className="bg-gray-700">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">
</th>
</tr>
</thead>
<tbody className="bg-gray-800 divide-y divide-gray-600">
{filteredRecords.length === 0 ? (
<tr>
<td colSpan={4} className="px-6 py-12 text-center text-gray-400">
</td>
</tr>
) : (
filteredRecords.map((record) => (
<tr key={record.id} className="hover:bg-gray-700">
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-100">
{record.student_id}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-300">
{new Date(record.draw_time).toLocaleString()}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-300">
<span className={`px-2 py-1 text-xs rounded-full ${
record.is_winner
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}>
{record.is_winner ? '中奖' : '未中奖'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-300">
{record.prize_name || '-'}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{/* 奖项统计 */}
{stats.prizeStats.length > 0 && (
<div className="bg-gray-800 rounded-lg shadow-md p-6 mt-6 border border-gray-700">
<h2 className="text-xl font-bold text-gray-100 mb-4"></h2>
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 gap-4">
{stats.prizeStats.map((stat, index) => (
<div key={index} className="bg-gray-700 p-4 rounded-lg border border-gray-600">
<h3 className="text-sm font-medium text-gray-300 mb-1">{stat.prize_name}</h3>
<p className="text-xl font-bold text-gray-100">{stat.count} </p>
</div>
))}
</div>
</div>
)}
</div>
</div>
)
}
export default Records

467
src/pages/WinnerDetails.tsx Normal file
View File

@@ -0,0 +1,467 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react'
import { Search, Trophy, ArrowLeft, Calendar, Award, ChevronLeft, ChevronRight, BarChart3, FileSpreadsheet } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import * as XLSX from 'xlsx'
import ApiService from '../services/apiService'
import EventService, { EVENT_TYPES } from '../services/eventService'
import type { PrizeConfig } from '../database/database'
import type { LotteryRecord } from '../types'
// 中奖记录类型
type WinnerRecord = LotteryRecord & { is_winner: true }
// 页面组件属性接口
type WinnerDetailsPageProps = Record<string, never>
const WinnerDetails: React.FC<WinnerDetailsPageProps> = () => {
const navigate = useNavigate()
const [winners, setWinners] = useState<WinnerRecord[]>([])
const [prizes, setPrizes] = useState<PrizeConfig[]>([])
const [loading, setLoading] = useState(true)
const [searchTerm, setSearchTerm] = useState('')
const [selectedPrize, setSelectedPrize] = useState<string>('')
const [dateRange, setDateRange] = useState({ start: '', end: '' })
const [stats, setStats] = useState({
totalWinners: 0,
totalPrizes: 0,
prizeStats: [] as { prizeName: string; winnerCount: number; totalQuantity: number; prizeLevel: number }[],
recentWinners: [] as WinnerRecord[]
})
const [currentPage, setCurrentPage] = useState(1)
const recordsPerPage = 30
const loadData = useCallback(async () => {
setLoading(true)
try {
const api = ApiService.getInstance()
const [recordsData, prizesData] = await Promise.all([
api.getAllRecords(),
api.getAllPrizes()
])
// 只保留中奖记录
const winnerRecords: WinnerRecord[] = recordsData
.filter(record => record.prize_id !== null && record.prize_name)
.map(record => ({ ...record, is_winner: true as const }))
setWinners(winnerRecords)
setPrizes(prizesData)
// 在prizes数据设置后计算统计信息
setTimeout(() => {
calculateStats(winnerRecords, prizesData)
}, 0)
} catch (error) {
console.error('WinnerDetails页面 - 加载数据失败:', error)
// 设置空数据以避免界面错误
setWinners([])
setPrizes([])
setStats({
totalWinners: 0,
totalPrizes: 0,
prizeStats: [],
recentWinners: []
})
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
loadData()
// 监听记录更新事件,实现智能刷新
const eventService = EventService.getInstance();
const unsubscribeRecords = eventService.subscribe(EVENT_TYPES.RECORDS_UPDATED, () => {
console.log('WinnerDetails页面 - 收到记录更新事件,重新加载数据');
loadData();
});
return () => {
unsubscribeRecords();
};
}, [loadData])
const calculateStats = (winnerRecords: WinnerRecord[], prizesData: PrizeConfig[] = prizes) => {
const totalWinners = winnerRecords.length
// 按奖项统计中奖数量
const winnerStats = winnerRecords
.reduce((acc, record) => {
const existing = acc.find(p => p.prizeName === record.prize_name)
if (existing) {
existing.winnerCount++
} else {
acc.push({
prizeName: record.prize_name!,
winnerCount: 1,
prizeLevel: record.prize_level || 0
})
}
return acc
}, [] as { prizeName: string; winnerCount: number; prizeLevel: number }[])
// 结合奖项配置信息,计算完整统计
const prizeStats = prizesData.map(prize => {
const winnerStat = winnerStats.find(w => w.prizeName === prize.prize_name)
return {
prizeName: prize.prize_name,
winnerCount: winnerStat ? winnerStat.winnerCount : 0,
totalQuantity: prize.total_quantity,
prizeLevel: prize.prize_level
}
}).sort((a, b) => a.prizeLevel - b.prizeLevel)
// 计算总奖品数
const totalPrizes = prizeStats.reduce((sum, stat) => sum + stat.totalQuantity, 0);
// 最近中奖记录最新10条
const recentWinners = [...winnerRecords]
.sort((a, b) => new Date(b.draw_time).getTime() - new Date(a.draw_time).getTime())
.slice(0, 10)
setStats({ totalWinners, totalPrizes, prizeStats, recentWinners })
}
// 使用useMemo优化过滤逻辑
const filteredWinners = useMemo(() => {
return winners.filter(record => {
const matchesSearch = searchTerm === '' ||
record.student_id.includes(searchTerm) ||
(record.prize_name && record.prize_name.toLowerCase().includes(searchTerm.toLowerCase()))
const matchesPrize = selectedPrize === '' || record.prize_name === selectedPrize
const matchesDate = (!dateRange.start || new Date(record.draw_time) >= new Date(dateRange.start)) &&
(!dateRange.end || new Date(record.draw_time) <= new Date(dateRange.end))
return matchesSearch && matchesPrize && matchesDate
})
}, [winners, searchTerm, selectedPrize, dateRange])
// 分页逻辑
const totalPages = Math.ceil(filteredWinners.length / recordsPerPage)
const startIndex = (currentPage - 1) * recordsPerPage
const endIndex = startIndex + recordsPerPage
const currentRecords = filteredWinners.slice(startIndex, endIndex)
// 重置页码当过滤条件改变时
useEffect(() => {
setCurrentPage(1)
}, [searchTerm, selectedPrize, dateRange])
// 导出Excel函数
const exportToExcel = () => {
try {
// 创建工作簿
const wb = XLSX.utils.book_new()
// 1. 统计概览工作表
const statsData = [
{ '统计项目': '总中奖人次', '数值': stats.totalWinners },
{ '统计项目': '奖项种类', '数值': stats.prizeStats.length },
{ '统计项目': '总奖品数', '数值': stats.totalPrizes }
]
const statsWs = XLSX.utils.json_to_sheet(statsData)
XLSX.utils.book_append_sheet(wb, statsWs, '统计概览')
// 2. 奖项详情工作表
const prizeDetailsData = stats.prizeStats.map(stat => ({
'奖项名称': stat.prizeName,
'中奖数量': stat.winnerCount,
'总数量': stat.totalQuantity,
'占比': `${((stat.winnerCount / stat.totalQuantity) * 100).toFixed(1)}%`
}))
const prizeDetailsWs = XLSX.utils.json_to_sheet(prizeDetailsData)
XLSX.utils.book_append_sheet(wb, prizeDetailsWs, '奖项详情')
// 3. 中奖记录工作表
const winnersData = filteredWinners.map((record, index) => ({
'序号': index + 1,
'学号': record.student_id,
'中奖时间': new Date(record.draw_time).toLocaleString('zh-CN'),
'奖项名称': record.prize_name || '未知奖项'
}))
const winnersWs = XLSX.utils.json_to_sheet(winnersData)
// 设置列宽
const colWidths = [
{ wch: 8 }, // 序号
{ wch: 15 }, // 学号
{ wch: 20 }, // 中奖时间
{ wch: 20 } // 奖项名称
]
winnersWs['!cols'] = colWidths
XLSX.utils.book_append_sheet(wb, winnersWs, '中奖记录')
// 导出文件
const fileName = `抽奖数据统计_${new Date().toISOString().split('T')[0]}.xlsx`
XLSX.writeFile(wb, fileName)
} catch (error) {
console.error('导出Excel失败:', error)
alert('导出失败,请重试')
}
}
if (loading) {
return (
<div className="min-h-screen bg-gradient-to-br from-gray-900 to-gray-800 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-yellow-600 mx-auto mb-4"></div>
<p className="text-gray-300">...</p>
</div>
</div>
)
}
return (
<div className="min-h-screen bg-gradient-to-br from-gray-900 to-gray-800 p-6">
<div className="max-w-7xl mx-auto">
{/* 头部 */}
<div className="bg-gray-800 rounded-lg shadow-md p-6 mb-6 border border-gray-700">
<div className="flex justify-between items-center mb-6">
<div className="flex items-center gap-4">
<button
onClick={() => navigate('/')}
className="flex items-center gap-2 px-3 py-2 text-gray-300 hover:text-gray-100 transition-colors rounded-lg hover:bg-gray-700"
>
<ArrowLeft size={20} />
</button>
<button
onClick={exportToExcel}
className="flex items-center gap-2 px-4 py-2 bg-green-600 hover:bg-green-700 text-white transition-colors rounded-lg font-medium"
>
<FileSpreadsheet size={20} />
Excel
</button>
<div className="flex items-center gap-3">
<Trophy className="text-yellow-500" size={32} />
<h1 className="text-3xl font-bold text-gray-100"></h1>
</div>
</div>
</div>
{/* 统计信息 */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<div className="bg-gradient-to-r from-yellow-600 to-yellow-500 p-4 rounded-lg text-white">
<div className="flex items-center gap-3">
<Trophy size={24} />
<div>
<h3 className="text-sm font-medium opacity-90 mb-1"></h3>
<p className="text-2xl font-bold">{stats.totalWinners}</p>
</div>
</div>
</div>
<div className="bg-gradient-to-r from-purple-600 to-purple-500 p-4 rounded-lg text-white">
<div className="flex items-center gap-3">
<Award size={24} />
<div>
<h3 className="text-sm font-medium opacity-90 mb-1"></h3>
<p className="text-2xl font-bold">{stats.prizeStats.length}</p>
</div>
</div>
</div>
<div className="bg-gradient-to-r from-green-600 to-green-500 p-4 rounded-lg text-white">
<div className="flex items-center gap-3">
<Award size={24} />
<div>
<h3 className="text-sm font-medium opacity-90 mb-1"></h3>
<p className="text-2xl font-bold">{stats.totalPrizes}</p>
</div>
</div>
</div>
</div>
{/* 奖项统计详情 */}
<div className="bg-gray-700 rounded-lg p-6 mb-6 border border-gray-600">
<div className="flex items-center gap-3 mb-6">
<BarChart3 className="text-blue-500" size={24} />
<h2 className="text-xl font-bold text-gray-100"></h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{stats.prizeStats.map((stat) => {
const percentage = stat.totalQuantity > 0 ? (stat.winnerCount / stat.totalQuantity * 100) : 0;
return (
<div key={stat.prizeName} className="bg-gray-600 rounded-lg p-4 border border-gray-500">
<div className="flex items-center justify-between mb-3">
<h3 className="text-lg font-semibold text-gray-100">{stat.prizeName}</h3>
<Award className="text-yellow-500" size={20} />
</div>
<div className="space-y-2 mb-4">
<div className="flex justify-between text-sm">
<span className="text-gray-300">:</span>
<span className="text-green-400 font-medium">{stat.winnerCount}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-300">:</span>
<span className="text-blue-400 font-medium">{stat.totalQuantity}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-300">:</span>
<span className="text-yellow-400 font-medium">{percentage.toFixed(1)}%</span>
</div>
</div>
{/* 进度条 */}
<div className="w-full bg-gray-500 rounded-full h-2">
<div
className="bg-gradient-to-r from-yellow-500 to-yellow-400 h-2 rounded-full transition-all duration-300"
style={{ width: `${Math.min(percentage, 100)}%` }}
></div>
</div>
<div className="text-xs text-gray-400 mt-1 text-center">
{percentage.toFixed(1)}%
</div>
</div>
);
})}
</div>
</div>
{/* 筛选条件 */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={20} />
<input
type="text"
placeholder="搜索学号或奖项名称"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-yellow-500 focus:border-yellow-500"
/>
</div>
<select
value={selectedPrize}
onChange={(e) => setSelectedPrize(e.target.value)}
className="px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 focus:outline-none focus:ring-2 focus:ring-yellow-500 focus:border-yellow-500"
>
<option value=""></option>
{prizes.map(prize => (
<option key={prize.id} value={prize.prize_name}>{prize.prize_name}</option>
))}
</select>
<input
type="date"
value={dateRange.start}
onChange={(e) => setDateRange({ ...dateRange, start: e.target.value })}
className="px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 focus:outline-none focus:ring-2 focus:ring-yellow-500 focus:border-yellow-500"
/>
<input
type="date"
value={dateRange.end}
onChange={(e) => setDateRange({ ...dateRange, end: e.target.value })}
className="px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 focus:outline-none focus:ring-2 focus:ring-yellow-500 focus:border-yellow-500"
/>
</div>
</div>
{/* 中奖记录列表 */}
<div className="bg-gray-800 rounded-lg shadow-md overflow-hidden border border-gray-700">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-600">
<thead className="bg-gray-700">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">
</th>
</tr>
</thead>
<tbody className="bg-gray-800 divide-y divide-gray-600">
{currentRecords.length === 0 ? (
<tr>
<td colSpan={3} className="px-6 py-12 text-center text-gray-400">
<Trophy className="mx-auto mb-4 text-gray-500" size={48} />
<p></p>
</td>
</tr>
) : (
currentRecords.map((record) => (
<tr key={record.id} className="hover:bg-gray-700 transition-colors">
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-100">
{record.student_id}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-300">
{new Date(record.draw_time).toLocaleString()}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-300">
<span className="inline-flex items-center gap-2 px-3 py-1 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
<Trophy size={14} />
{record.prize_name}
</span>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* 分页控件 */}
{totalPages > 1 && (
<div className="bg-gray-700 px-6 py-4 flex items-center justify-between border-t border-gray-600">
<div className="flex items-center gap-2 text-sm text-gray-300">
<span> {startIndex + 1} - {Math.min(endIndex, filteredWinners.length)} {filteredWinners.length} </span>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setCurrentPage(prev => Math.max(prev - 1, 1))}
disabled={currentPage === 1}
className="flex items-center gap-1 px-3 py-2 text-sm font-medium text-gray-300 bg-gray-600 rounded-lg hover:bg-gray-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<ChevronLeft size={16} />
</button>
<div className="flex items-center gap-1">
{Array.from({ length: Math.min(totalPages, 5) }, (_, i) => {
let pageNum;
if (totalPages <= 5) {
pageNum = i + 1;
} else if (currentPage <= 3) {
pageNum = i + 1;
} else if (currentPage >= totalPages - 2) {
pageNum = totalPages - 4 + i;
} else {
pageNum = currentPage - 2 + i;
}
return (
<button
key={pageNum}
onClick={() => setCurrentPage(pageNum)}
className={`px-3 py-2 text-sm font-medium rounded-lg transition-colors ${
currentPage === pageNum
? 'bg-yellow-600 text-white'
: 'text-gray-300 bg-gray-600 hover:bg-gray-500'
}`}
>
{pageNum}
</button>
);
})}
</div>
<button
onClick={() => setCurrentPage(prev => Math.min(prev + 1, totalPages))}
disabled={currentPage === totalPages}
className="flex items-center gap-1 px-3 py-2 text-sm font-medium text-gray-300 bg-gray-600 rounded-lg hover:bg-gray-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<ChevronRight size={16} />
</button>
</div>
</div>
)}
</div>
</div>
</div>
)
}
export default WinnerDetails