首测后同步
This commit is contained in:
BIN
20250927艺术节.db
Normal file
BIN
20250927艺术节.db
Normal file
Binary file not shown.
2627
package-lock.json
generated
2627
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -29,6 +29,8 @@
|
||||
"socket.io-client": "^4.8.1",
|
||||
"sqlite3": "^5.1.7",
|
||||
"tailwind-merge": "^3.0.2",
|
||||
"vite-plugin-xi-plus-badge": "^1.1.0",
|
||||
"xlsx": "^0.18.5",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
6184
pnpm-lock.yaml
generated
6184
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -9,7 +9,8 @@ const Login = React.lazy(() => import('./pages/Login'))
|
||||
const Lottery = React.lazy(() => import('./pages/Lottery'))
|
||||
const Home = React.lazy(() => import('./pages/Home'))
|
||||
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 ConfettiDemo = React.lazy(() => import('./pages/ConfettiDemo'))
|
||||
const CrudTest = React.lazy(() => import('./pages/CrudTest'))
|
||||
@@ -112,9 +113,10 @@ function App() {
|
||||
path="/admin"
|
||||
element={isAuthenticated ? <Admin /> : <Navigate to="/login" replace />}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/records"
|
||||
element={isAuthenticated ? <Records /> : <Navigate to="/login" replace />}
|
||||
path="/winners"
|
||||
element={<WinnerDetails />}
|
||||
/>
|
||||
<Route
|
||||
path="/clear-records"
|
||||
|
||||
@@ -216,7 +216,7 @@ const Lottery: React.FC = () => {
|
||||
后台管理
|
||||
</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"
|
||||
>
|
||||
<FileText size={18} />
|
||||
|
||||
@@ -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
467
src/pages/WinnerDetails.tsx
Normal 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
|
||||
Reference in New Issue
Block a user