首测后同步
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",
|
"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
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 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"
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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