first commit

This commit is contained in:
2025-09-18 23:34:55 +08:00
commit 1f669143cc
84 changed files with 96383 additions and 0 deletions

151
src/App.tsx Normal file
View File

@@ -0,0 +1,151 @@
import React, { useEffect, useState } from 'react'
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'
import { useAppStore } from './store'
import { ToastProvider } from './contexts/ToastContext'
import AuthService from './services/authService'
// 懒加载页面组件
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 ClearRecords = React.lazy(() => import('./pages/ClearRecords'))
const ConfettiDemo = React.lazy(() => import('./pages/ConfettiDemo'))
const CrudTest = React.lazy(() => import('./pages/CrudTest'))
const NetworkTest = React.lazy(() => import('./pages/NetworkTest'))
const SimpleNetworkTest = React.lazy(() => import('./pages/SimpleNetworkTest'))
function App() {
const { isAuthenticated, initializeApp, checkAuthStatus } = useAppStore()
const [isInitializing, setIsInitializing] = useState(true)
useEffect(() => {
const initApp = async () => {
try {
// 首先检查认证状态
checkAuthStatus()
// 然后初始化应用
await initializeApp()
} catch (error) {
console.error('应用初始化失败:', error)
} finally {
setIsInitializing(false)
}
}
initApp()
}, [initializeApp, checkAuthStatus])
// 监听页面可见性变化,恢复时检查认证状态
useEffect(() => {
const handleVisibilityChange = () => {
if (!document.hidden) {
checkAuthStatus()
}
}
document.addEventListener('visibilitychange', handleVisibilityChange)
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange)
}
}, [checkAuthStatus])
// 应用卸载时清理资源
useEffect(() => {
return () => {
const { cleanup } = useAppStore.getState()
cleanup()
}
}, [])
// 应用初始化中的加载状态
if (isInitializing) {
return (
<div className="min-h-screen bg-gradient-to-br from-purple-900 via-blue-900 to-indigo-900 flex items-center justify-center">
<div className="text-center">
<div className="w-16 h-16 border-4 border-white/30 border-t-white rounded-full animate-spin mx-auto mb-4"></div>
<p className="text-white text-lg">...</p>
</div>
</div>
)
}
return (
<ToastProvider>
<Router>
<div className="min-h-screen bg-gradient-to-br from-purple-900 via-blue-900 to-indigo-900">
{/* 背景装饰 */}
<div className="fixed inset-0 overflow-hidden pointer-events-none">
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-purple-500/10 rounded-full blur-3xl" />
<div className="absolute top-3/4 right-1/4 w-96 h-96 bg-blue-500/10 rounded-full blur-3xl" />
<div className="absolute bottom-1/4 left-1/2 w-96 h-96 bg-indigo-500/10 rounded-full blur-3xl" />
</div>
{/* 路由配置 */}
<React.Suspense fallback={
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<div className="w-16 h-16 border-4 border-white/30 border-t-white rounded-full animate-spin mx-auto mb-4"></div>
<p className="text-white text-lg">...</p>
</div>
</div>
}>
<Routes>
<Route
path="/login"
element={isAuthenticated ? <Navigate to="/" replace /> : <Login />}
/>
<Route
path="/"
element={isAuthenticated ? <Lottery /> : <Navigate to="/login" replace />}
/>
<Route
path="/home"
element={isAuthenticated ? <Home /> : <Navigate to="/login" replace />}
/>
<Route
path="/admin"
element={isAuthenticated ? <Admin /> : <Navigate to="/login" replace />}
/>
<Route
path="/records"
element={isAuthenticated ? <Records /> : <Navigate to="/login" replace />}
/>
<Route
path="/clear-records"
element={isAuthenticated ? <ClearRecords /> : <Navigate to="/login" replace />}
/>
<Route
path="/confetti-demo"
element={isAuthenticated ? <ConfettiDemo /> : <Navigate to="/login" replace />}
/>
<Route
path="/crud-test"
element={<CrudTest />}
/>
<Route
path="/network-test"
element={<NetworkTest />}
/>
<Route
path="/simple-test"
element={<SimpleNetworkTest />}
/>
<Route
path="*"
element={<Navigate to={isAuthenticated ? "/" : "/login"} replace />}
/>
</Routes>
</React.Suspense>
</div>
</Router>
</ToastProvider>
)
}
export default App

1
src/assets/react.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,151 @@
import React, { useEffect, useState } from 'react';
import { motion } from 'framer-motion';
interface ConfettiPiece {
id: number;
x: number;
y: number;
color: string;
size: number;
rotation: number;
delay: number;
}
interface ConfettiEffectProps {
isActive: boolean;
duration?: number;
}
const ConfettiEffect: React.FC<ConfettiEffectProps> = ({
isActive,
duration = 3000
}) => {
const [confetti, setConfetti] = useState<ConfettiPiece[]>([]);
const [showEffect, setShowEffect] = useState(false);
const colors = [
'#FFD700', // 金色
'#FF6B6B', // 红色
'#4ECDC4', // 青色
'#45B7D1', // 蓝色
'#96CEB4', // 绿色
'#FFEAA7', // 黄色
'#DDA0DD', // 紫色
'#FFA07A', // 橙色
];
useEffect(() => {
if (isActive) {
setShowEffect(true);
// 生成撒花粒子
const pieces: ConfettiPiece[] = [];
for (let i = 0; i < 50; i++) {
pieces.push({
id: i,
x: Math.random() * window.innerWidth,
y: -20,
color: colors[Math.floor(Math.random() * colors.length)],
size: Math.random() * 8 + 4,
rotation: Math.random() * 360,
delay: Math.random() * 1000,
});
}
setConfetti(pieces);
// 设置效果持续时间
const timer = setTimeout(() => {
setShowEffect(false);
setConfetti([]);
}, duration);
return () => clearTimeout(timer);
}
}, [isActive, duration]);
if (!showEffect) {
return null;
}
return (
<div className="fixed inset-0 pointer-events-none z-50 overflow-hidden">
{confetti.map((piece) => (
<motion.div
key={piece.id}
className="absolute"
style={{
left: piece.x,
top: piece.y,
width: piece.size,
height: piece.size,
backgroundColor: piece.color,
borderRadius: Math.random() > 0.5 ? '50%' : '0%',
}}
initial={{
y: -20,
rotate: piece.rotation,
opacity: 1,
}}
animate={{
y: window.innerHeight + 100,
rotate: piece.rotation + 720,
opacity: 0,
x: piece.x + (Math.random() - 0.5) * 200,
}}
transition={{
duration: 3,
delay: piece.delay / 1000,
ease: 'easeOut',
}}
/>
))}
{/* 额外的星星特效 */}
{Array.from({ length: 20 }).map((_, i) => (
<motion.div
key={`star-${i}`}
className="absolute text-yellow-400 text-2xl"
style={{
left: Math.random() * window.innerWidth,
top: Math.random() * 100,
}}
initial={{
scale: 0,
rotate: 0,
opacity: 0,
}}
animate={{
scale: [0, 1.5, 0],
rotate: 360,
opacity: [0, 1, 0],
}}
transition={{
duration: 2,
delay: Math.random() * 1000 / 1000,
repeat: 1,
}}
>
</motion.div>
))}
{/* 烟花爆炸效果 */}
<motion.div
className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2"
initial={{ scale: 0, opacity: 0 }}
animate={{
scale: [0, 2, 0],
opacity: [0, 1, 0],
}}
transition={{
duration: 1.5,
times: [0, 0.3, 1],
}}
>
<div className="text-6xl">🎉</div>
</motion.div>
</div>
);
};
export default React.memo(ConfettiEffect);

View File

@@ -0,0 +1,217 @@
import React, { useState, useEffect } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { Trophy, Sparkles } from 'lucide-react'
import { Prize } from '../store'
interface DrawAnimationProps {
prizes: Prize[]
duration: number
}
const DrawAnimation: React.FC<DrawAnimationProps> = ({ prizes, duration }) => {
const [currentPrizeIndex, setCurrentPrizeIndex] = useState(0)
const [isRolling, setIsRolling] = useState(true)
const [showSparkles, setShowSparkles] = useState(false)
useEffect(() => {
if (prizes.length === 0) return
// 滚动动画
const rollInterval = setInterval(() => {
setCurrentPrizeIndex(prev => (prev + 1) % prizes.length)
}, 100) // 每100ms切换一次
// 停止滚动并显示结果
const stopTimer = setTimeout(() => {
clearInterval(rollInterval)
setIsRolling(false)
setShowSparkles(true)
}, duration - 500) // 提前500ms停止滚动
return () => {
clearInterval(rollInterval)
clearTimeout(stopTimer)
}
}, [prizes, duration])
if (prizes.length === 0) return null
const currentPrize = prizes[currentPrizeIndex]
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm"
>
{/* 背景粒子效果 */}
<div className="absolute inset-0 overflow-hidden">
{Array.from({ length: 20 }).map((_, i) => (
<motion.div
key={i}
className="absolute w-2 h-2 bg-yellow-400 rounded-full"
initial={{
x: Math.random() * window.innerWidth,
y: Math.random() * window.innerHeight,
scale: 0
}}
animate={{
scale: [0, 1, 0],
rotate: 360,
x: Math.random() * window.innerWidth,
y: Math.random() * window.innerHeight
}}
transition={{
duration: 2,
repeat: Infinity,
delay: Math.random() * 2
}}
/>
))}
</div>
{/* 主要动画容器 */}
<motion.div
initial={{ scale: 0, rotate: -180 }}
animate={{ scale: 1, rotate: 0 }}
exit={{ scale: 0, rotate: 180 }}
transition={{ type: "spring", stiffness: 200, damping: 20 }}
className="relative z-10 bg-gradient-to-br from-purple-600 via-blue-600 to-indigo-600 rounded-3xl p-12 shadow-2xl border-4 border-white/20 max-w-2xl w-full mx-4"
>
{/* 装饰性光环 */}
<div className="absolute -inset-4 bg-gradient-to-r from-yellow-400 via-pink-500 to-purple-500 rounded-3xl blur-xl opacity-30 animate-pulse"></div>
{/* 标题 */}
<div className="text-center mb-8 relative z-10">
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 2, repeat: Infinity, ease: "linear" }}
className="w-20 h-20 bg-gradient-to-r from-yellow-400 to-orange-500 rounded-full flex items-center justify-center mx-auto mb-4"
>
<Trophy className="w-10 h-10 text-white" />
</motion.div>
<h2 className="text-4xl font-bold text-white mb-2">
{isRolling ? '正在抽奖...' : '恭喜中奖!'}
</h2>
{isRolling && (
<p className="text-white/80 text-lg"></p>
)}
</div>
{/* 奖品展示区 */}
<div className="relative">
{/* 滚动框架 */}
<div className="bg-white/10 backdrop-blur-lg rounded-2xl p-8 border-2 border-white/20 relative overflow-hidden">
{/* 滚动指示器 */}
{isRolling && (
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-transparent via-yellow-400 to-transparent animate-pulse"></div>
)}
{/* 奖品信息 */}
<AnimatePresence mode="wait">
<motion.div
key={currentPrizeIndex}
initial={{ y: 50, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: -50, opacity: 0 }}
transition={{ duration: 0.1 }}
className="text-center"
>
{/* 奖品颜色指示器 */}
<div
className="w-24 h-24 rounded-full mx-auto mb-6 flex items-center justify-center shadow-lg bg-gradient-to-br from-purple-500 to-blue-500"
>
<Trophy className="w-12 h-12 text-white" />
</div>
{/* 奖品名称 */}
<h3 className="text-3xl font-bold text-white mb-2">
{currentPrize.name}
</h3>
{/* 剩余数量 */}
<div className="flex items-center justify-center space-x-4 text-white/60">
<span>: {currentPrize.remainingQuantity} </span>
<span></span>
<span>: {currentPrize.level}</span>
</div>
</motion.div>
</AnimatePresence>
{/* 滚动指示器底部 */}
{isRolling && (
<div className="absolute bottom-0 left-0 right-0 h-1 bg-gradient-to-r from-transparent via-yellow-400 to-transparent animate-pulse"></div>
)}
</div>
{/* 火花效果 */}
<AnimatePresence>
{showSparkles && (
<>
{Array.from({ length: 8 }).map((_, i) => (
<motion.div
key={i}
initial={{ scale: 0, rotate: 0 }}
animate={{
scale: [0, 1, 0],
rotate: 360,
x: [0, (Math.random() - 0.5) * 200],
y: [0, (Math.random() - 0.5) * 200]
}}
transition={{
duration: 1.5,
delay: i * 0.1,
ease: "easeOut"
}}
className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2"
>
<Sparkles className="w-8 h-8 text-yellow-400" />
</motion.div>
))}
</>
)}
</AnimatePresence>
</div>
{/* 进度条 */}
<div className="mt-8 relative z-10">
<div className="bg-white/20 rounded-full h-2 overflow-hidden">
<motion.div
className="bg-gradient-to-r from-yellow-400 to-orange-500 h-full rounded-full"
initial={{ width: '0%' }}
animate={{ width: '100%' }}
transition={{ duration: duration / 1000, ease: "linear" }}
/>
</div>
<div className="flex justify-between items-center mt-2 text-white/60 text-sm">
<span></span>
<span>{Math.round(duration / 1000)}</span>
</div>
</div>
{/* 装饰性元素 */}
<div className="absolute top-4 right-4">
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 3, repeat: Infinity, ease: "linear" }}
className="w-8 h-8 border-2 border-yellow-400 border-t-transparent rounded-full"
/>
</div>
<div className="absolute bottom-4 left-4">
<motion.div
animate={{ scale: [1, 1.2, 1] }}
transition={{ duration: 1, repeat: Infinity }}
className="w-6 h-6 bg-yellow-400 rounded-full opacity-60"
/>
</div>
</motion.div>
</motion.div>
)
}
export default React.memo(DrawAnimation)

45
src/components/Empty.tsx Normal file
View File

@@ -0,0 +1,45 @@
import React from 'react';
import { FileX, Package } from 'lucide-react';
import { cn } from '@/lib/utils';
interface EmptyProps {
icon?: React.ReactNode;
title?: string;
description?: string;
action?: React.ReactNode;
className?: string;
}
// Empty component with better UX
export default function Empty({
icon,
title = '暂无数据',
description = '当前没有可显示的内容',
action,
className
}: EmptyProps) {
const defaultIcon = icon || <Package className="w-12 h-12 text-gray-300" />;
return (
<div className={cn(
'flex flex-col items-center justify-center py-12 px-4 text-center',
'min-h-[200px]',
className
)}>
<div className="mb-4">
{defaultIcon}
</div>
<h3 className="text-lg font-medium text-gray-900 mb-2">
{title}
</h3>
<p className="text-sm text-gray-500 mb-6 max-w-sm">
{description}
</p>
{action && (
<div className="mt-4">
{action}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,223 @@
import React from 'react';
import { motion } from 'framer-motion';
import { AlertTriangle, RefreshCw, Wifi, WifiOff, CheckCircle, XCircle } from 'lucide-react';
// 增强的加载状态组件
interface EnhancedLoadingProps {
message?: string;
size?: 'sm' | 'md' | 'lg';
showProgress?: boolean;
progress?: number;
}
export const EnhancedLoading: React.FC<EnhancedLoadingProps> = ({
message = '加载中...',
size = 'md',
showProgress = false,
progress = 0
}) => {
const sizeClasses = {
sm: 'w-6 h-6',
md: 'w-8 h-8',
lg: 'w-12 h-12'
};
return (
<div className="flex flex-col items-center justify-center p-8">
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 1, repeat: Infinity, ease: 'linear' }}
className={`${sizeClasses[size]} text-blue-500 mb-4`}
>
<RefreshCw className="w-full h-full" />
</motion.div>
<p className="text-gray-600 text-center mb-4">{message}</p>
{showProgress && (
<div className="w-64 bg-gray-200 rounded-full h-2 mb-2">
<motion.div
className="bg-blue-500 h-2 rounded-full"
initial={{ width: 0 }}
animate={{ width: `${progress}%` }}
transition={{ duration: 0.3 }}
/>
</div>
)}
</div>
);
};
// 增强的错误状态组件
interface EnhancedErrorProps {
title?: string;
message?: string;
onRetry?: () => void;
retryText?: string;
showDetails?: boolean;
details?: string;
}
export const EnhancedError: React.FC<EnhancedErrorProps> = ({
title = '出现错误',
message = '请稍后重试',
onRetry,
retryText = '重试',
showDetails = false,
details
}) => {
return (
<div className="flex flex-col items-center justify-center p-8 text-center">
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mb-4">
<XCircle className="w-8 h-8 text-red-500" />
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">{title}</h3>
<p className="text-gray-600 mb-6 max-w-md">{message}</p>
{onRetry && (
<button
onClick={onRetry}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<RefreshCw className="w-4 h-4" />
{retryText}
</button>
)}
{showDetails && details && (
<details className="mt-4 text-left">
<summary className="cursor-pointer text-sm text-gray-500 hover:text-gray-700">
</summary>
<pre className="mt-2 p-3 bg-gray-100 rounded text-xs text-gray-700 overflow-auto max-w-md">
{details}
</pre>
</details>
)}
</div>
);
};
// 网络状态指示器
interface NetworkIndicatorProps {
isOnline: boolean;
className?: string;
}
export const NetworkIndicator: React.FC<NetworkIndicatorProps> = ({
isOnline,
className = ''
}) => {
return (
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
className={`flex items-center gap-2 px-3 py-1 rounded-full text-sm font-medium ${
isOnline
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
} ${className}`}
>
{isOnline ? (
<Wifi className="w-4 h-4" />
) : (
<WifiOff className="w-4 h-4" />
)}
{isOnline ? '在线' : '离线'}
</motion.div>
);
};
// 成功状态组件
interface SuccessStateProps {
title?: string;
message?: string;
onContinue?: () => void;
continueText?: string;
}
export const SuccessState: React.FC<SuccessStateProps> = ({
title = '操作成功',
message = '操作已完成',
onContinue,
continueText = '继续'
}) => {
return (
<motion.div
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
className="flex flex-col items-center justify-center p-8 text-center"
>
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ delay: 0.2, type: 'spring', stiffness: 200 }}
className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mb-4"
>
<CheckCircle className="w-8 h-8 text-green-500" />
</motion.div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">{title}</h3>
<p className="text-gray-600 mb-6 max-w-md">{message}</p>
{onContinue && (
<button
onClick={onContinue}
className="px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
>
{continueText}
</button>
)}
</motion.div>
);
};
// 警告状态组件
interface WarningStateProps {
title?: string;
message?: string;
onConfirm?: () => void;
onCancel?: () => void;
confirmText?: string;
cancelText?: string;
}
export const WarningState: React.FC<WarningStateProps> = ({
title = '请确认',
message = '此操作需要确认',
onConfirm,
onCancel,
confirmText = '确认',
cancelText = '取消'
}) => {
return (
<div className="flex flex-col items-center justify-center p-8 text-center">
<div className="w-16 h-16 bg-yellow-100 rounded-full flex items-center justify-center mb-4">
<AlertTriangle className="w-8 h-8 text-yellow-500" />
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">{title}</h3>
<p className="text-gray-600 mb-6 max-w-md">{message}</p>
<div className="flex gap-3">
{onCancel && (
<button
onClick={onCancel}
className="px-4 py-2 bg-gray-300 text-gray-700 rounded-lg hover:bg-gray-400 transition-colors"
>
{cancelText}
</button>
)}
{onConfirm && (
<button
onClick={onConfirm}
className="px-4 py-2 bg-yellow-600 text-white rounded-lg hover:bg-yellow-700 transition-colors"
>
{confirmText}
</button>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,49 @@
import React, { Component, ErrorInfo, ReactNode } from 'react';
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error?: Error;
}
class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('ErrorBoundary caught an error:', error, errorInfo);
}
render() {
if (this.state.hasError) {
return this.props.fallback || (
<div className="flex items-center justify-center min-h-screen bg-gradient-to-br from-purple-900 via-blue-900 to-indigo-900">
<div className="text-center p-8 bg-black/20 backdrop-blur-sm rounded-lg border border-white/10">
<div className="text-red-400 text-xl mb-4"> </div>
<div className="text-white/60 mb-4"></div>
<button
onClick={() => window.location.reload()}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors"
>
</button>
</div>
</div>
);
}
return this.props.children;
}
}
export default ErrorBoundary;

View File

@@ -0,0 +1,63 @@
import React from 'react';
import { Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
interface LoadingSpinnerProps {
size?: 'sm' | 'md' | 'lg';
text?: string;
className?: string;
fullScreen?: boolean;
}
const sizeClasses = {
sm: 'w-4 h-4',
md: 'w-6 h-6',
lg: 'w-8 h-8'
};
export default function LoadingSpinner({
size = 'md',
text,
className,
fullScreen = false
}: LoadingSpinnerProps) {
const content = (
<div className={cn(
'flex flex-col items-center justify-center gap-3',
fullScreen ? 'fixed inset-0 bg-white/80 backdrop-blur-sm z-50' : 'py-8',
className
)}>
<Loader2 className={cn(
'animate-spin text-blue-600',
sizeClasses[size]
)} />
{text && (
<p className="text-sm text-gray-600 font-medium">
{text}
</p>
)}
</div>
);
return content;
}
// 简化的内联加载器
export function InlineLoader({ className }: { className?: string }) {
return (
<Loader2 className={cn(
'w-4 h-4 animate-spin text-gray-400',
className
)} />
);
}
// 按钮加载状态
export function ButtonLoader({ className }: { className?: string }) {
return (
<Loader2 className={cn(
'w-4 h-4 animate-spin',
className
)} />
);
}

View File

@@ -0,0 +1,330 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { X } from 'lucide-react';
import { DrawResult, useAppStore } from '../store';
import ConfettiEffect from './ConfettiEffect';
interface LotteryAnimationProps {
isVisible: boolean;
onComplete: () => void;
onCountdownComplete?: () => void;
result?: DrawResult;
}
const LotteryAnimation: React.FC<LotteryAnimationProps> = ({
isVisible,
onComplete,
onCountdownComplete,
result
}) => {
const [animationPhase, setAnimationPhase] = useState<'countdown' | 'loading' | 'result' | 'hidden'>('hidden');
const [showConfetti, setShowConfetti] = useState(false);
const [forceRender, setForceRender] = useState(0);
const [countdownNumber, setCountdownNumber] = useState(3);
const [scrollingIndex, setScrollingIndex] = useState(0);
const [isCountdownRunning, setIsCountdownRunning] = useState(false); // 添加倒计时执行标志
// 调试日志:状态变化追踪
useEffect(() => {
console.log('🎲📊 状态更新 - animationPhase:', animationPhase, 'countdownNumber:', countdownNumber, 'isVisible:', isVisible);
}, [animationPhase, countdownNumber, isVisible]);
const scrollTimerRef = useRef<NodeJS.Timeout | null>(null);
const onCountdownCompleteRef = useRef(onCountdownComplete);
const onCompleteRef = useRef(onComplete);
const animationPhaseRef = useRef(animationPhase);
const resultRef = useRef(result);
// 从store获取奖项数据
const { prizes } = useAppStore();
// 倒数数字列表
const countdownNumbers = [3, 2, 1];
// 更新ref引用
useEffect(() => {
onCountdownCompleteRef.current = onCountdownComplete;
onCompleteRef.current = onComplete;
animationPhaseRef.current = animationPhase;
resultRef.current = result;
});
// 清理定时器函数
const clearTimer = useCallback(() => {
if (scrollTimerRef.current) {
clearTimeout(scrollTimerRef.current);
scrollTimerRef.current = null;
}
}, []);
// 开始倒计时效果 - 使用更稳定的实现
const startCountdownEffect = useCallback(() => {
// 防止重复执行倒计时
if (isCountdownRunning) {
console.log('🎲⚠️ 倒计时正在执行中,跳过重复调用');
return;
}
console.log('🎲🎲🎲 ===== 开始倒计时流程 =====');
console.log('🎲 启动倒计时效果');
setIsCountdownRunning(true); // 设置执行标志
setAnimationPhase('countdown');
setCountdownNumber(3);
console.log('🚀 开始倒计时动画,初始数字: 3');
window.postMessage({ type: 'COUNTDOWN_START', number: 3 }, '*');
// 清理之前的定时器
console.log('🎲 清理之前的定时器');
clearTimer();
// 使用简单的定时器序列,避免递归和闭包问题
console.log('🎲🎯 显示数字 3');
// 1秒后显示2
scrollTimerRef.current = setTimeout(() => {
console.log('🎲🎯 显示数字 2');
setCountdownNumber(2);
console.log('🔢 倒计时: 2');
window.postMessage({ type: 'COUNTDOWN_NUMBER', number: 2 }, '*');
// 再1秒后显示1
scrollTimerRef.current = setTimeout(() => {
console.log('🎲🎯 显示数字 1');
setCountdownNumber(1);
console.log('🔢 倒计时: 1');
window.postMessage({ type: 'COUNTDOWN_NUMBER', number: 1 }, '*');
// 再1秒后触发抽奖
scrollTimerRef.current = setTimeout(() => {
console.log('🎲🚀 倒计时完成,触发抽奖回调');
// 倒计时完成切换到loading状态
console.log('🎯 倒计时321完成切换到loading状态');
setAnimationPhase('loading');
setCountdownNumber(null);
setIsCountdownRunning(false); // 重置执行标志
// 触发倒计时完成回调
if (onCountdownCompleteRef.current) {
console.log('🎯 触发倒计时完成回调');
onCountdownCompleteRef.current();
}
console.log('🎲🎲🎲 ===== 倒计时流程完成 =====');
// 5秒后如果还没有结果则隐藏动画框
setTimeout(() => {
// 使用ref获取最新状态检查当前状态如果还在loading且没有结果则隐藏
console.log('🎲🔍 检查当前状态 - phase:', animationPhaseRef.current, 'hasResult:', !!resultRef.current);
if (animationPhaseRef.current === 'loading' && !resultRef.current) {
console.log('🚫 超时隐藏动画框 - 倒计时完成后无结果');
window.postMessage({ type: 'ANIMATION_HIDDEN', reason: 'timeout' }, '*');
setAnimationPhase('hidden');
if (onCompleteRef.current) {
onCompleteRef.current();
}
} else {
console.log('🎲✅ 状态已变化或已有结果,无需隐藏');
}
}, 5000); // 5秒后检查
}, 1000);
}, 1000);
}, 1000);
}, [clearTimer, isCountdownRunning]); // 添加isCountdownRunning依赖
// 重置动画状态
const resetAnimation = useCallback(() => {
console.log('🎲 重置动画状态');
clearTimer(); // 确保清理定时器
setAnimationPhase('hidden');
setShowConfetti(false);
setCountdownNumber(3);
setScrollingIndex(0);
setIsCountdownRunning(false); // 重置执行标志
setForceRender(prev => prev + 1);
}, [clearTimer]);
// 主要状态管理useEffect - 只监听isVisible的变化
useEffect(() => {
console.log('🎲🔄 LotteryAnimation状态变化:', { isVisible, result: !!result, animationPhase });
if (isVisible) {
// 只有在隐藏状态时才启动倒计时,避免重复触发
if (animationPhase === 'hidden') {
console.log('🎲🚀 准备启动倒计时');
startCountdownEffect();
}
} else if (!isVisible && animationPhase !== 'hidden') {
console.log('🎲🔒 隐藏动画,重置状态');
resetAnimation();
}
}, [isVisible]); // 修复只监听isVisible不监听animationPhase避免重复触发
// 组件卸载时清理定时器
useEffect(() => {
return () => {
clearTimer();
};
}, [clearTimer]);
// 监听结果变化 - 当有结果时切换到结果显示阶段
useEffect(() => {
if (result && (animationPhase === 'loading' || animationPhase === 'countdown')) {
// 如果正在倒数,让倒数完成后再显示结果
if (animationPhase === 'countdown') {
return;
}
console.log('🎲📋 收到抽奖结果,切换到结果显示阶段:', result);
// 直接显示结果
setAnimationPhase('result');
// 只有1等奖才显示撒花效果
const shouldShowConfetti = result.success && result.prize && result.prize.level === 1;
setShowConfetti(shouldShowConfetti);
// 3秒后隐藏撒花效果
if (shouldShowConfetti) {
const confettiTimer = setTimeout(() => {
setShowConfetti(false);
}, 3000);
return () => clearTimeout(confettiTimer);
}
}
// 添加超时机制如果在loading状态超过10秒没有结果自动隐藏
if (animationPhase === 'loading' && !result) {
console.log('🎲⏰ 设置loading状态超时检查');
const timeoutTimer = setTimeout(() => {
console.log('🎲⏰ Loading状态超时自动隐藏动画框');
setAnimationPhase('hidden');
if (onCompleteRef.current) {
onCompleteRef.current();
}
}, 10000); // 10秒超时
return () => clearTimeout(timeoutTimer);
}
}, [result, animationPhase]);
const handleClose = useCallback(() => {
clearTimer();
setAnimationPhase('hidden');
setShowConfetti(false);
setCountdownNumber(3);
setScrollingIndex(0);
setIsCountdownRunning(false); // 重置执行标志
setForceRender(prev => prev + 1);
if (onCompleteRef.current) {
onCompleteRef.current();
}
}, [clearTimer]);
if (!isVisible && animationPhase === 'hidden') {
console.log('🚫 LotteryAnimation组件已隐藏 - 倒计时修复成功!');
return null;
}
// 渲染不同阶段的内容
const renderContent = () => {
switch (animationPhase) {
case 'countdown':
return (
<div className="text-center">
<div className="mb-8">
<div className="text-4xl text-white mb-6 font-bold"></div>
<div className="bg-gradient-to-r from-red-500 to-pink-500 rounded-full w-40 h-40 mx-auto flex items-center justify-center shadow-2xl">
<div className="text-8xl font-bold text-white animate-bounce" key={`countdown-${countdownNumber}`}>
{countdownNumber}
</div>
</div>
<div className="text-lg text-gray-300 mt-6">
...
</div>
</div>
</div>
);
case 'loading':
return (
<div className="text-center">
<div className="animate-spin rounded-full h-24 w-24 border-b-4 border-yellow-400 mx-auto mb-6"></div>
<div className="text-3xl text-white mb-4 font-bold">
...
</div>
<div className="text-lg text-gray-300">
</div>
</div>
);
case 'result':
if (!result) {
return (
<div className="text-center">
<div className="text-3xl text-white mb-6 font-bold">
</div>
<button
onClick={onComplete}
className="px-8 py-3 bg-blue-500 text-white text-xl rounded-lg hover:bg-blue-600 font-bold"
>
</button>
</div>
);
}
return (
<div className="text-center">
{result.success && result.prize ? (
<>
<div className="text-8xl font-bold text-yellow-400 mb-6 animate-bounce">🎉 🎉</div>
<div className="text-6xl text-white mb-4 font-bold">
{result.prize.name}
</div>
<div className="text-4xl text-gray-300 mb-6">
{result.message}
</div>
</>
) : (
<>
<div className="text-4xl font-bold text-gray-400 mb-6"></div>
<div className="text-2xl text-gray-300 mb-6">
{result.message || '感谢您的参与,下次再来!'}
</div>
</>
)}
<button
onClick={onComplete}
className="px-8 py-3 bg-blue-500 text-white text-xl rounded-lg hover:bg-blue-600 transition-colors font-bold"
></button>
</div>
);
default:
return null;
}
};
return (
<div className="fixed inset-0 bg-black bg-opacity-80 flex items-center justify-center z-50">
<div className="w-4/5 h-1/2 flex items-center justify-center p-8">
{renderContent()}
</div>
{/* 撒花特效 */}
<ConfettiEffect
isActive={showConfetti}
duration={4000}
/>
</div>
);
};
export default LotteryAnimation;

View File

@@ -0,0 +1,68 @@
import React, { useState, useEffect } from 'react';
import { Wifi, WifiOff } from 'lucide-react';
import { NetworkService } from '../services/networkService';
interface NetworkStatusProps {
className?: string;
}
const NetworkStatus: React.FC<NetworkStatusProps> = ({ className = '' }) => {
const [networkStatus, setNetworkStatus] = useState({
isOnline: true,
lastCheck: new Date()
});
const networkService = NetworkService.getInstance();
useEffect(() => {
// 初始化网络状态
setNetworkStatus(networkService.getStatus());
// 订阅网络状态变化
const unsubscribe = networkService.subscribe((status) => {
setNetworkStatus(status);
});
return () => {
unsubscribe();
};
}, []);
const getStatusColor = () => {
return networkStatus.isOnline ? 'text-green-500' : 'text-orange-500';
};
const getStatusText = () => {
return networkStatus.isOnline ? '在线' : '离线';
};
const getStatusIcon = () => {
return networkStatus.isOnline ? <Wifi className="w-4 h-4" /> : <WifiOff className="w-4 h-4" />;
};
return (
<div className={`flex items-center space-x-2 ${className}`}>
{/* 状态指示器 */}
<div className={`flex items-center space-x-1 ${getStatusColor()}`}>
{getStatusIcon()}
<span className="text-sm font-medium">{getStatusText()}</span>
</div>
{/* 详细信息提示 */}
<div className="relative group">
<div className="w-2 h-2 bg-blue-400 rounded-full cursor-help"></div>
<div className="absolute bottom-full right-0 mb-2 w-48 p-3 bg-gray-800 text-white text-xs rounded-lg shadow-lg opacity-0 group-hover:opacity-100 transition-opacity z-50">
<div className="space-y-1">
<div>: {networkStatus.isOnline ? '在线' : '离线'}</div>
<div>: {networkStatus.lastCheck ? networkStatus.lastCheck.toLocaleTimeString() : '未知'}</div>
</div>
<div className="absolute top-full right-4 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-gray-800"></div>
</div>
</div>
</div>
);
};
export default NetworkStatus;

View File

@@ -0,0 +1,158 @@
import React from 'react'
import { motion } from 'framer-motion'
import { Delete, RotateCcw, Check } from 'lucide-react'
interface NumberKeyboardProps {
onNumberClick: (number: string) => void
onDeleteClick: () => void
onClearClick: () => void
onConfirmClick: () => void
disabled?: boolean
canConfirm?: boolean
studentIdLength?: number
}
const NumberKeyboard: React.FC<NumberKeyboardProps> = ({
onNumberClick,
onDeleteClick,
onClearClick,
onConfirmClick,
disabled = false,
canConfirm = false,
studentIdLength = 0
}) => {
// 数字键盘布局
const keyboardLayout = [
['1', '2', '3'],
['4', '5', '6'],
['7', '8', '9'],
['clear', '0', 'delete']
]
// 按钮点击动画变体
const buttonVariants = {
initial: { scale: 1 },
tap: { scale: 0.95 },
hover: { scale: 1.05 }
}
// 渲染按钮内容
const renderButtonContent = (key: string) => {
switch (key) {
case 'delete':
return <Delete className="w-8 h-8" />
case 'clear':
return <RotateCcw className="w-8 h-8" />
default:
return <span className="text-3xl font-bold">{key}</span>
}
}
// 检查数字键是否应该被禁用
const isNumberKeyDisabled = (key: string) => {
// 如果是数字键且学号已达到12位则禁用
return /^[0-9]$/.test(key) && studentIdLength >= 12
}
// 获取按钮样式
const getButtonStyle = (key: string) => {
const baseStyle = "w-[140px] h-[140px] rounded-2xl font-bold transition-all duration-200 flex items-center justify-center shadow-lg"
if (disabled) {
return `${baseStyle} bg-gray-500/20 text-gray-400 cursor-not-allowed`
}
// 检查数字键是否被禁用
if (isNumberKeyDisabled(key)) {
return `${baseStyle} bg-gray-500/20 text-gray-400 cursor-not-allowed`
}
switch (key) {
case 'delete':
return `${baseStyle} bg-red-500/20 hover:bg-red-500/30 text-red-400 hover:text-red-300 border border-red-500/30`
case 'clear':
return `${baseStyle} bg-yellow-500/20 hover:bg-yellow-500/30 text-yellow-400 hover:text-yellow-300 border border-yellow-500/30`
default:
return `${baseStyle} bg-blue-500/20 hover:bg-blue-500/30 text-white border border-blue-500/30 hover:border-blue-400`
}
}
// 处理按钮点击
const handleButtonClick = (key: string) => {
if (disabled) return
// 如果是数字键且被禁用,则不处理点击
if (isNumberKeyDisabled(key)) return
switch (key) {
case 'delete':
onDeleteClick()
break
case 'clear':
onClearClick()
break
default:
onNumberClick(key)
break
}
}
return (
<div className="flex flex-col items-center space-y-4">
{/* 数字键盘网格 */}
<div className="grid grid-cols-3 gap-[10px]">
{keyboardLayout.map((row, rowIndex) =>
row.map((key, keyIndex) => (
<motion.button
key={`${rowIndex}-${keyIndex}`}
variants={buttonVariants}
initial="initial"
whileHover={disabled || isNumberKeyDisabled(key) ? "initial" : "hover"}
whileTap={disabled || isNumberKeyDisabled(key) ? "initial" : "tap"}
onClick={() => handleButtonClick(key)}
className={getButtonStyle(key)}
disabled={disabled || isNumberKeyDisabled(key)}
>
{renderButtonContent(key)}
</motion.button>
))
)}
</div>
{/* 确认按钮 */}
<motion.button
variants={buttonVariants}
initial="initial"
whileHover={(!disabled && canConfirm) ? "hover" : "initial"}
whileTap={(!disabled && canConfirm) ? "tap" : "initial"}
onClick={onConfirmClick}
disabled={disabled || !canConfirm}
className={`w-full max-w-[450px] h-[80px] rounded-2xl font-bold text-xl transition-all duration-200 flex items-center justify-center space-x-3 shadow-lg ${
disabled || !canConfirm
? 'bg-gray-500/20 text-gray-400 cursor-not-allowed border border-gray-500/30'
: 'bg-gradient-to-r from-green-500 to-emerald-500 hover:from-green-600 hover:to-emerald-600 text-white shadow-green-500/25 hover:shadow-green-500/40'
}`}
>
<Check className="w-6 h-6" />
<span></span>
</motion.button>
{/* 键盘说明 */}
<div className="text-center text-white/50 text-sm space-y-1">
<p></p>
<div className="flex items-center justify-center space-x-4">
<div className="flex items-center space-x-1">
<RotateCcw className="w-4 h-4" />
<span></span>
</div>
<div className="flex items-center space-x-1">
<Delete className="w-4 h-4" />
<span></span>
</div>
</div>
</div>
</div>
)
}
export default React.memo(NumberKeyboard)

View File

@@ -0,0 +1,661 @@
import React, { useEffect, useState, useRef, useCallback } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { Trophy, Gift, Award, Star, Clock, User } from 'lucide-react'
import { Prize } from '../store'
import { useAppStore } from '../store'
import ApiService from '../services/apiService'
import EventService, { EVENT_TYPES } from '../services/eventService'
interface PrizeDisplayProps {
prizes: Prize[]
}
// 中奖记录接口
interface DrawRecord {
id: string
student_id: string
prize_id: string
prize_name: string
draw_time: string
}
const PrizeDisplay: React.FC<PrizeDisplayProps> = ({ prizes: propsPrizes }) => {
const { loadPrizes, prizes: storePrizes } = useAppStore();
const [drawRecords, setDrawRecords] = useState<DrawRecord[]>([]);
const [isLoadingRecords, setIsLoadingRecords] = useState(false);
const [isAutoRefreshing, setIsAutoRefreshing] = useState(true);
const [lastRefreshTime, setLastRefreshTime] = useState<Date | null>(null);
const [refreshError, setRefreshError] = useState<string | null>(null);
const [retryCount, setRetryCount] = useState(0);
const [isPageVisible, setIsPageVisible] = useState(true);
const [refreshInterval, setRefreshInterval] = useState(5000); // 动态刷新间隔
const refreshIntervalRef = useRef<NodeJS.Timeout | null>(null);
const retryTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const isComponentMountedRef = useRef(true);
const lastRequestTimeRef = useRef<number>(0);
const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// 使用store中的prizes数据如果没有则使用props传入的数据
const prizes = storePrizes && storePrizes.length > 0 ? storePrizes : propsPrizes;
// 调试日志已移除以优化控制台输出
// 检测网络连接状态
const checkNetworkStatus = useCallback(() => {
return navigator.onLine;
}, []);
// 获取错误类型和用户友好的错误消息
const getErrorInfo = useCallback((error: any) => {
if (!checkNetworkStatus()) {
return {
type: 'network',
message: '网络连接已断开,请检查网络设置',
canRetry: true
};
}
if (error instanceof TypeError && error.message.includes('fetch')) {
return {
type: 'connection',
message: '无法连接到服务器,请稍后重试',
canRetry: true
};
}
if (error instanceof Error) {
if (error.message.includes('404')) {
return {
type: 'not_found',
message: 'API接口不存在请联系管理员',
canRetry: false
};
}
if (error.message.includes('500')) {
return {
type: 'server_error',
message: '服务器内部错误,请稍后重试',
canRetry: true
};
}
if (error.message.includes('timeout')) {
return {
type: 'timeout',
message: '请求超时,请检查网络连接',
canRetry: true
};
}
}
return {
type: 'unknown',
message: error instanceof Error ? error.message : '未知错误',
canRetry: true
};
}, [checkNetworkStatus]);
// 加载中奖记录(带错误处理和重试机制)
const loadDrawRecords = useCallback(async (isBackground = false) => {
if (!isComponentMountedRef.current) return;
try {
if (!isBackground) {
setIsLoadingRecords(true);
}
setRefreshError(null);
setRetryCount(0); // 重置重试计数
const apiService = ApiService.getInstance();
const records = await apiService.getAllRecords();
if (isComponentMountedRef.current) {
setDrawRecords(records || []);
setLastRefreshTime(new Date());
console.log('✅ 中奖记录加载成功,共', records?.length || 0, '条记录');
}
} catch (error) {
console.error('❌ 中奖记录加载失败:', error);
if (isComponentMountedRef.current) {
const errorInfo = getErrorInfo(error);
setRefreshError(errorInfo.message);
// 增加重试计数
setRetryCount(prev => prev + 1);
// 只有在可以重试且是后台刷新且重试次数不超过3次时才进行自动重试
if (errorInfo.canRetry && isBackground && isAutoRefreshing && retryCount < 3) {
const retryDelay = Math.min(3000 * Math.pow(2, retryCount), 30000); // 指数退避最大30秒
console.log(`🔄 将在 ${retryDelay/1000} 秒后重试加载中奖记录 (第${retryCount + 1}次重试)`);
retryTimeoutRef.current = setTimeout(() => {
loadDrawRecords(true);
}, retryDelay);
}
}
} finally {
if (isComponentMountedRef.current && !isBackground) {
setIsLoadingRecords(false);
}
}
}, [isAutoRefreshing, getErrorInfo, retryCount]);
// 防抖加载函数
const debouncedLoadRecords = useCallback((isBackground = false) => {
const now = Date.now();
const timeSinceLastRequest = now - lastRequestTimeRef.current;
// 清除之前的防抖定时器
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current);
debounceTimeoutRef.current = null;
}
// 防止过于频繁的请求最小间隔2秒
if (timeSinceLastRequest < 2000) {
debounceTimeoutRef.current = setTimeout(() => {
if (isComponentMountedRef.current) {
lastRequestTimeRef.current = Date.now();
loadDrawRecords(isBackground);
}
}, 2000 - timeSinceLastRequest);
return;
}
lastRequestTimeRef.current = now;
loadDrawRecords(isBackground);
}, [loadDrawRecords]);
// 动态调整刷新间隔
const adjustRefreshInterval = useCallback(() => {
if (!isPageVisible) {
// 页面不可见时降低刷新频率到30秒
setRefreshInterval(30000);
} else if (refreshError) {
// 有错误时增加刷新间隔到10秒
setRefreshInterval(10000);
} else {
// 正常情况下5秒刷新
setRefreshInterval(5000);
}
}, [isPageVisible, refreshError]);
// 启动自动刷新定时器
const startAutoRefresh = useCallback(() => {
if (refreshIntervalRef.current) {
clearInterval(refreshIntervalRef.current);
}
if (isAutoRefreshing && isPageVisible) {
refreshIntervalRef.current = setInterval(() => {
debouncedLoadRecords(true); // 后台刷新
}, refreshInterval);
// 自动刷新已启动
}
}, [isAutoRefreshing, isPageVisible, refreshInterval, debouncedLoadRecords]);
// 停止自动刷新
const stopAutoRefresh = useCallback(() => {
if (refreshIntervalRef.current) {
clearInterval(refreshIntervalRef.current);
refreshIntervalRef.current = null;
// 自动刷新已停止
}
if (retryTimeoutRef.current) {
clearTimeout(retryTimeoutRef.current);
retryTimeoutRef.current = null;
}
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current);
debounceTimeoutRef.current = null;
}
}, []);
// 页面可见性监听
useEffect(() => {
const handleVisibilityChange = () => {
const isVisible = !document.hidden;
const wasVisible = isPageVisible;
setIsPageVisible(isVisible);
if (isVisible && !wasVisible) {
// 只有从不可见变为可见时才刷新数据,避免重复请求
console.log('📱 页面变为可见,刷新数据');
// 延迟一下再刷新,避免与其他请求冲突
setTimeout(() => {
if (isComponentMountedRef.current) {
debouncedLoadRecords(true);
}
}, 500);
} else if (!isVisible) {
console.log('📱 页面变为不可见');
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [debouncedLoadRecords, isPageVisible]);
// 动态调整刷新间隔
useEffect(() => {
adjustRefreshInterval();
}, [adjustRefreshInterval]);
// 合并初始化和自动刷新逻辑减少useEffect数量
useEffect(() => {
const initializeData = async () => {
try {
console.log('🎯 PrizeDisplay: 开始加载奖项数据');
await loadPrizes();
console.log('✅ PrizeDisplay: 奖项数据加载成功');
} catch (error) {
console.error('❌ PrizeDisplay: 奖项数据加载失败:', error);
const errorInfo = getErrorInfo(error);
setRefreshError(errorInfo.message);
}
// 延迟加载中奖记录,避免并发请求
setTimeout(() => {
if (isComponentMountedRef.current) {
loadDrawRecords();
}
}, 100);
// 延迟启动自动刷新
setTimeout(() => {
if (isComponentMountedRef.current && refreshInterval > 0) {
startAutoRefresh();
}
}, 1000);
};
initializeData();
// 组件卸载时清理
return () => {
isComponentMountedRef.current = false;
stopAutoRefresh();
};
}, []);
// 监听刷新间隔变化,单独处理
useEffect(() => {
if (refreshInterval > 0 && !isAutoRefreshing && isComponentMountedRef.current) {
startAutoRefresh();
}
}, [refreshInterval, startAutoRefresh, isAutoRefreshing]);
// WebSocket监听中奖记录更新
useEffect(() => {
const eventService = EventService.getInstance();
// 监听新增中奖记录
const handleNewRecord = (event: CustomEvent) => {
const { record } = event.detail;
if (record && isComponentMountedRef.current) {
setDrawRecords(prev => [record, ...prev]);
setLastRefreshTime(new Date());
setRefreshError(null);
// 收到新中奖记录
}
};
// 监听中奖记录更新
const handleRecordsUpdated = (event: CustomEvent) => {
const records = event.detail;
if (Array.isArray(records) && isComponentMountedRef.current) {
setDrawRecords(records);
setLastRefreshTime(new Date());
setRefreshError(null);
// 中奖记录已更新
}
};
// 监听数据清空
const handleDataClear = () => {
if (isComponentMountedRef.current) {
setDrawRecords([]);
setLastRefreshTime(new Date());
setRefreshError(null);
// 中奖记录已清空
}
};
// 监听奖项更新事件
const handlePrizesUpdated = (event: CustomEvent) => {
console.log('PrizeDisplay: 收到奖项更新事件', event.detail);
if (isComponentMountedRef.current) {
// 重新加载奖项数据
loadPrizes().catch(error => {
console.error('PrizeDisplay: 奖项更新后重新加载失败:', error);
});
}
};
eventService.subscribe('new_record', handleNewRecord);
eventService.subscribe('records_updated', handleRecordsUpdated);
eventService.subscribe('data_cleared', handleDataClear);
eventService.subscribe('system_reset', handleDataClear);
eventService.subscribe('force_reset', handleDataClear);
eventService.subscribe('prizes_updated', handlePrizesUpdated);
return () => {
eventService.unsubscribe('new_record', handleNewRecord);
eventService.unsubscribe('records_updated', handleRecordsUpdated);
eventService.unsubscribe('data_cleared', handleDataClear);
eventService.unsubscribe('system_reset', handleDataClear);
eventService.unsubscribe('force_reset', handleDataClear);
eventService.unsubscribe('prizes_updated', handlePrizesUpdated);
};
}, []);
// 过滤活跃奖项
const activePrizes = prizes.filter(prize => prize.is_active);
// 获取奖项图标
const getPrizeIcon = (level: number) => {
switch (level) {
case 1:
return <Trophy className="w-8 h-8" />
case 2:
return <Award className="w-8 h-8" />
case 3:
return <Star className="w-8 h-8" />
default:
return <Gift className="w-8 h-8" />
}
}
// 获取奖项等级文字
const getLevelText = (level: number) => {
const levelMap: { [key: number]: string } = {
1: '特等奖',
2: '一等奖',
3: '二等奖',
4: '三等奖',
5: '参与奖'
}
return levelMap[level] || `${level}等奖`
}
// 计算剩余比例
const getRemainingRatio = (prize: Prize) => {
return prize.totalQuantity > 0 ? (prize.remainingQuantity / prize.totalQuantity) * 100 : 0
}
// 获取状态颜色
const getStatusColor = (ratio: number) => {
if (ratio > 50) return 'text-green-400'
if (ratio > 20) return 'text-yellow-400'
if (ratio > 0) return 'text-orange-400'
return 'text-red-400'
}
// 根据奖项等级获取字体大小
const getFontSizeClass = (prizeName: string) => {
if (prizeName.includes('一等奖') || prizeName.includes('特等奖')) {
return 'text-lg' // 一等奖最大
} else if (prizeName.includes('二等奖')) {
return 'text-base' // 二等奖次之
} else if (prizeName.includes('三等奖')) {
return 'text-sm' // 三等奖再次
} else if (prizeName.includes('参与') || prizeName.includes('重在参与')) {
return 'text-xs' // 重在参与最小
}
return 'text-sm' // 默认大小
}
// 格式化学号显示每4位一个空格
const formatStudentId = (studentId: string) => {
return studentId.replace(/(\d{4})(?=\d)/g, '$1 ');
};
// 格式化时间显示
const formatTime = (timeString: string) => {
const date = new Date(timeString);
return date.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
};
// 判断是否为一等奖
const isFirstPrize = (prizeName: string) => {
return prizeName.includes('一等奖') || prizeName.includes('特等奖');
};
// 如果没有奖项数据,显示加载状态或错误信息
if (!prizes || prizes.length === 0) {
console.log('PrizeDisplay 渲染 - 没有奖项数据,显示加载状态');
return (
<div className="bg-white/10 backdrop-blur-md rounded-2xl p-4 h-full flex flex-col">
<h2 className="text-xl font-bold text-white mb-4 text-center">
</h2>
<div className="flex-1 flex items-center justify-center">
<div className="text-center text-white/60">
{refreshError ? (
<div className="text-center">
<div className="w-16 h-16 bg-red-500/20 rounded-full mx-auto mb-4 flex items-center justify-center">
<Gift className="w-8 h-8 text-red-400" />
</div>
<div className="text-lg mb-2 text-red-300"></div>
<div className="text-sm mb-4 text-red-200 max-w-xs mx-auto">
{refreshError}
</div>
<button
onClick={() => {
setRefreshError(null);
loadPrizes();
}}
className="px-4 py-2 bg-red-500/80 text-white rounded-lg hover:bg-red-500 transition-colors"
>
</button>
</div>
) : (
<div className="animate-pulse">
<div className="w-16 h-16 bg-white/20 rounded-full mx-auto mb-4 flex items-center justify-center">
<Gift className="w-8 h-8 text-white/40" />
</div>
<div className="text-lg mb-2">...</div>
<button
onClick={() => loadPrizes()}
className="mt-4 px-4 py-2 bg-blue-500/80 text-white rounded-lg hover:bg-blue-500 transition-colors"
>
</button>
</div>
)}
</div>
</div>
</div>
);
}
// 如果没有活跃奖项,显示提示信息
if (activePrizes.length === 0) {
console.log('PrizeDisplay 渲染 - 没有活跃奖项,显示提示信息');
return (
<div className="bg-white/10 backdrop-blur-md rounded-2xl p-4 h-full flex flex-col">
<h2 className="text-xl font-bold text-white mb-4 text-center">
</h2>
<div className="flex-1 flex items-center justify-center">
<div className="text-center text-white/60">
<div className="text-lg mb-2"></div>
<div className="text-sm"></div>
<button
onClick={() => loadPrizes()}
className="mt-4 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
>
</button>
</div>
</div>
</div>
);
}
return (
<div className="bg-white/10 backdrop-blur-md rounded-2xl p-4 h-full flex flex-col">
<h2 className="text-xl font-bold text-white mb-4 text-center">
</h2>
<div className="flex-1 space-y-3 overflow-y-auto scrollbar-hide">
{prizes.filter(prize => prize.is_active).map((prize) => (
<div
key={prize.id}
className="bg-white/5 rounded-xl p-3 border border-white/20"
>
<div className="flex items-center justify-between mb-2">
<h3 className={`${getFontSizeClass(prize.name)} font-semibold text-white truncate`}>
{prize.name}
</h3>
<span className="text-xs text-white/60">
{(prize.probability * 1000).toFixed(1)}
</span>
</div>
<div className="flex items-center justify-between text-xs text-white/80 mb-2">
<span>: {prize.remainingQuantity}</span>
<span>: {prize.totalQuantity}</span>
</div>
<div className="bg-white/10 rounded-full h-1.5">
<div
className="bg-gradient-to-r from-blue-500 to-purple-600 h-1.5 rounded-full transition-all duration-300"
style={{
width: `${(prize.remainingQuantity / prize.totalQuantity) * 100}%`
}}
/>
</div>
</div>
))}
</div>
{/* 中奖记录显示区域 */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.3 }}
className="mt-8 flex-1 flex flex-col"
>
<div className="bg-white/10 backdrop-blur-sm rounded-2xl p-6 border border-white/20 flex-1 flex flex-col">
<div className="flex items-center gap-3 mb-4">
<Trophy className="w-6 h-6 text-yellow-400" />
<h3 className="text-xl font-bold text-white"></h3>
<div className="flex-1 h-px bg-gradient-to-r from-yellow-400/50 to-transparent"></div>
</div>
{lastRefreshTime && (
<div className="mb-3 text-xs text-white/40 text-center">
: {lastRefreshTime.toLocaleTimeString()}
</div>
)}
{isLoadingRecords ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-yellow-400"></div>
<span className="ml-3 text-white/70">...</span>
</div>
) : drawRecords.length === 0 ? (
<div className="text-center py-8 text-white/50">
<Award className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p></p>
</div>
) : (
<div className={`flex-1 overflow-y-auto overflow-x-hidden space-y-1 scrollbar-thin scrollbar-thumb-white/20 scrollbar-track-transparent transition-opacity duration-500 ${isLoadingRecords ? 'opacity-50' : 'opacity-100'}`}>
{drawRecords.slice(0, 4).map((record, index) => {
const isFirst = isFirstPrize(record.prize_name);
return (
<motion.div
key={record.id}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.5, delay: index * 0.1 }}
className={`
relative p-2 rounded-lg border transition-all duration-500 hover:shadow-md transform hover:scale-[1.02]
${isFirst
? 'bg-gradient-to-r from-yellow-500/20 to-orange-500/20 border-yellow-400/50 shadow-lg shadow-yellow-400/20'
: 'bg-white/5 border-white/10 hover:bg-white/10'
}
`}
style={{
animationDelay: `${index * 100}ms`,
animationFillMode: 'both'
}}
>
{/* 一等奖发光效果 */}
{isFirst && (
<>
<div className="absolute inset-0 rounded-xl bg-gradient-to-r from-yellow-400/10 to-orange-400/10 animate-pulse"></div>
<div className="absolute -inset-1 rounded-xl bg-gradient-to-r from-yellow-400/20 to-orange-400/20 blur-sm animate-pulse"></div>
<div className="absolute top-2 right-2">
<Star className="w-5 h-5 text-yellow-400 animate-pulse" />
</div>
</>
)}
<div className="relative z-10 flex flex-wrap items-center gap-2 sm:gap-3">
<div className="flex items-center gap-2">
<User className="w-4 h-4 text-blue-400" />
<span className={`font-mono ${isFirst ? 'text-yellow-100 font-bold' : 'text-white'}`}>
{formatStudentId(record.student_id)}
</span>
</div>
<div className="flex items-center gap-2">
{isFirst ? (
<Trophy className="w-4 h-4 text-yellow-400" />
) : record.prize_name.includes('二等奖') ? (
<Award className="w-4 h-4 text-gray-300" />
) : record.prize_name.includes('三等奖') ? (
<Gift className="w-4 h-4 text-orange-400" />
) : (
<Star className="w-4 h-4 text-blue-400" />
)}
<span className={`font-semibold ${
isFirst
? 'text-yellow-100 text-lg'
: record.prize_name.includes('二等奖')
? 'text-gray-100'
: record.prize_name.includes('三等奖')
? 'text-orange-100'
: 'text-blue-100'
}`}>
{record.prize_name}
</span>
</div>
<div className="flex items-center gap-2 text-sm text-white/70 ml-auto">
<Clock className="w-4 h-4" />
<span>{formatTime(record.draw_time)}</span>
</div>
</div>
{/* 一等奖额外的装饰效果 */}
{isFirst && (
<div className="absolute bottom-0 left-0 right-0 h-1 bg-gradient-to-r from-yellow-400 to-orange-400 rounded-b-xl animate-pulse"></div>
)}
</motion.div>
);
})}
</div>
)}
</div>
</motion.div>
</div>
);
};
export default React.memo(PrizeDisplay);

View File

@@ -0,0 +1,323 @@
import React, { useState, useEffect } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { Trophy, RefreshCw, Clock, User, Gift, Sparkles } from 'lucide-react'
import { DrawResult } from '../store'
import NetworkStatus from './NetworkStatus'
import ApiService from '../services/apiService'
interface ResultDisplayProps {
drawResult: DrawResult | null
onRestart: () => void
isDrawing: boolean
showCountdown?: boolean
onCountdownComplete?: () => void
}
const ResultDisplay: React.FC<ResultDisplayProps> = ({
drawResult,
onRestart,
isDrawing,
showCountdown = false,
onCountdownComplete
}) => {
// 移除调试日志以优化控制台输出
const [maxDrawTimes, setMaxDrawTimes] = useState<number>(3) // 默认3次
// 倒计时相关状态已移除由浮窗LotteryAnimation组件处理
// 获取系统配置中的抽奖次数上限
useEffect(() => {
const loadMaxDrawTimes = async () => {
try {
const apiService = ApiService.getInstance();
const config = await apiService.getSystemConfig();
if (config && config.max_draw_times) {
setMaxDrawTimes(config.max_draw_times);
}
} catch (error) {
console.error('获取抽奖次数上限失败:', error);
}
};
loadMaxDrawTimes();
}, []);
// Props变化监听
useEffect(() => {
// 移除调试日志以优化控制台输出
}, [isDrawing, drawResult])
const [isCountdownActive, setIsCountdownActive] = useState(false)
// Props变化追踪
useEffect(() => {
// 移除调试日志以优化控制台输出
}, [drawResult, isDrawing, showCountdown]);
// 倒计时逻辑已完全移除由浮窗LotteryAnimation组件处理
// 不再在ResultDisplay中显示倒计时避免与浮窗倒计时重复显示数字3
// 倒计时状态重置逻辑已移除
// 格式化时间
const formatTime = (timestamp?: number) => {
if (!timestamp || typeof timestamp !== 'number') {
return new Date().toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
}
const date = new Date(timestamp)
if (isNaN(date.getTime())) {
return new Date().toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
}
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
}
// 格式化学号显示
const formatStudentId = (studentId?: string) => {
if (!studentId || typeof studentId !== 'string') return '未知学号'
if (studentId.length !== 12) return studentId
return `${studentId.slice(0, 4)}-${studentId.slice(4, 8)}-${studentId.slice(8)}`
}
return (
<div className="bg-white/10 backdrop-blur-md rounded-2xl p-4 h-full flex flex-col">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-xl font-bold text-white"></h2>
<NetworkStatus className="bg-black/20 backdrop-blur-sm rounded-lg px-3 py-2" />
</div>
<div className="flex-1 flex flex-col justify-center">
<AnimatePresence mode="wait">
{/* 倒计时状态已移除 - 由浮窗LotteryAnimation组件处理 */}
{/* 在浮窗倒计时期间,此处不显示任何倒计时内容,避免重复显示 */}
{/* 抽奖中状态 */}
{isDrawing && !isCountdownActive && (
<motion.div
key="drawing"
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
className="text-center space-y-6"
>
{/* 旋转圆环 */}
<div className="relative">
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 2, repeat: Infinity, ease: "linear" }}
className="w-20 h-20 border-4 border-blue-500/30 border-t-blue-500 rounded-full mx-auto"
/>
<div className="absolute inset-0 flex items-center justify-center">
<Trophy className="w-8 h-8 text-blue-400" />
</div>
</div>
<div>
<h3 className="text-2xl font-bold text-white mb-2"></h3>
<p className="text-white/70">...</p>
</div>
{/* 动态点 */}
<div className="flex justify-center space-x-2">
{Array.from({ length: 3 }).map((_, i) => (
<motion.div
key={i}
className="w-3 h-3 bg-blue-400 rounded-full"
animate={{ scale: [1, 1.5, 1], opacity: [0.5, 1, 0.5] }}
transition={{
duration: 1.5,
repeat: Infinity,
delay: i * 0.2
}}
/>
))}
</div>
</motion.div>
)}
{/* 有结果状态 */}
{!isDrawing && drawResult && (
<motion.div
key="result"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
className="space-y-6"
>
{/* 恭喜标题 */}
<div className="text-center">
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: "spring", stiffness: 200, delay: 0.2 }}
className="relative inline-block"
>
<div className="text-6xl mb-2">🎉</div>
{/* 闪烁效果 */}
<motion.div
animate={{ scale: [1.5, 2, 1.5], opacity: [0.5, 1, 0.5] }}
transition={{ duration: 2, repeat: Infinity }}
className="absolute -inset-4 bg-yellow-400/20 rounded-full blur-xl"
/>
</motion.div>
<h3 className="text-2xl font-bold text-white mb-1"></h3>
<p className="text-white/70">Congratulations!</p>
</div>
{/* 奖品信息卡片 */}
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.4 }}
className="bg-gradient-to-br from-yellow-500/20 to-orange-500/20 rounded-xl p-6 border border-yellow-500/30 relative overflow-hidden"
>
{/* 背景装饰 */}
<div className="absolute top-0 right-0 w-20 h-20 bg-yellow-400/10 rounded-full -translate-y-10 translate-x-10" />
<div className="absolute bottom-0 left-0 w-16 h-16 bg-orange-400/10 rounded-full translate-y-8 -translate-x-8" />
<div className="relative z-10 text-center space-y-4">
{/* 奖品名称 */}
<div>
<div className="text-3xl font-bold text-yellow-400 mb-1">
{drawResult.prize?.name || drawResult.prize?.prize_name || '未知奖品'}
</div>
<div className="text-white/60 text-sm"></div>
</div>
{/* 分隔线 */}
<div className="flex items-center space-x-4">
<div className="flex-1 h-px bg-white/20" />
<Sparkles className="w-5 h-5 text-yellow-400" />
<div className="flex-1 h-px bg-white/20" />
</div>
{/* 详细信息 */}
<div className="space-y-3 text-sm">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2 text-white/70">
<User className="w-4 h-4" />
<span></span>
</div>
<div className="text-white font-mono">
{formatStudentId(drawResult.studentId)}
</div>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2 text-white/70">
<Clock className="w-4 h-4" />
<span></span>
</div>
<div className="text-white text-xs">
{formatTime(drawResult.timestamp || Date.now())}
</div>
</div>
</div>
</div>
</motion.div>
{/* 重新开始按钮 */}
<motion.button
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.6 }}
onClick={onRestart}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
className="w-full py-3 bg-gradient-to-r from-blue-500 to-purple-600 hover:from-blue-600 hover:to-purple-700 text-white font-semibold rounded-lg shadow-lg hover:shadow-xl transition-all duration-200 flex items-center justify-center space-x-2"
>
<RefreshCw className="w-5 h-5" />
<span></span>
</motion.button>
</motion.div>
)}
{/* 等待状态 */}
{!isDrawing && !drawResult && (
<motion.div
key="waiting"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="text-center space-y-6"
>
{/* 等待图标 */}
<div className="relative">
<div className="w-20 h-20 bg-white/5 rounded-full flex items-center justify-center mx-auto border-2 border-white/10">
<Trophy className="w-10 h-10 text-white/30" />
</div>
{/* 呼吸效果 */}
<motion.div
animate={{ scale: [1, 1.1, 1], opacity: [0.3, 0.6, 0.3] }}
transition={{ duration: 3, repeat: Infinity }}
className="absolute inset-0 bg-white/5 rounded-full"
/>
</div>
<div>
<h3 className="text-xl font-bold text-white/70 mb-2"></h3>
<p className="text-white/50 text-sm leading-relaxed">
12<br />
</p>
<p className="text-white/40 text-xs mt-3">
{maxDrawTimes}
</p>
</div>
{/* 装饰性元素 */}
<div className="flex justify-center space-x-4">
{Array.from({ length: 5 }).map((_, i) => (
<motion.div
key={i}
className="w-2 h-2 bg-white/20 rounded-full"
animate={{
scale: [1, 1.5, 1],
opacity: [0.3, 0.8, 0.3]
}}
transition={{
duration: 2,
repeat: Infinity,
delay: i * 0.3
}}
/>
))}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
{/* 底部提示 */}
{!isDrawing && !drawResult && (
<div className="mt-6 pt-4 border-t border-white/10">
<div className="text-center text-white/40 text-xs space-y-1">
<p>🎯 </p>
<p>🏆 </p>
</div>
</div>
)}
</div>
)
}
export default ResultDisplay

View File

@@ -0,0 +1,317 @@
import React, { useState, useCallback, useEffect } from 'react'
import { motion } from 'framer-motion'
import { Play, RotateCcw, Delete, AlertCircle } from 'lucide-react'
import { useAppStore } from '../store'
import LotteryService from '../services/lotteryService'
import type { DrawResult } from '../types'
interface ScreenKeyboardProps {
onStudentIdChange: (studentId: string) => void
onDrawStart?: (result: DrawResult) => void
onCountdownStart?: () => Promise<boolean>
onClearResult?: () => void
}
const ScreenKeyboard: React.FC<ScreenKeyboardProps> = ({ onStudentIdChange, onDrawStart, onCountdownStart, onClearResult }) => {
const [studentId, setStudentId] = useState('');
const [studentDrawCount, setStudentDrawCount] = useState(0);
const [maxDrawTimes, setMaxDrawTimes] = useState(1);
const [isCheckingLimit, setIsCheckingLimit] = useState(false);
const { isDrawing, drawResult, systemConfig } = useAppStore();
const lotteryService = LotteryService.getInstance();
// 当抽奖结果出现时,清空输入框但不重置抽奖次数状态
useEffect(() => {
if (drawResult) {
setStudentId('');
onStudentIdChange('');
// 抽奖完成后重置防抖状态,允许下次抽奖
setIsDebouncing(false);
console.log('🎯 抽奖结果出现,重置防抖状态');
// 不重置抽奖次数状态让下次输入时重新从API获取最新数据
// setStudentDrawCount(0); // 移除这行,避免状态不一致
}
}, [drawResult, onStudentIdChange]);
// 监听系统配置变化
useEffect(() => {
if (systemConfig) {
setMaxDrawTimes(systemConfig.max_draw_times || 1);
}
}, [systemConfig]);
// 监听学号变化,检查抽奖次数
useEffect(() => {
const checkStudentDrawCount = async () => {
if (studentId.length === 12 && /^\d{12}$/.test(studentId)) {
setIsCheckingLimit(true);
try {
// 直接获取学生的实际抽奖次数而不是依赖canStudentDraw的验证结果
const apiService = (lotteryService as any).api;
const student = await apiService.getStudent(studentId);
const currentDrawCount = student ? student.draw_count : 0;
setStudentDrawCount(currentDrawCount);
console.log('🔍 检查抽奖次数:', {
studentId,
currentDrawCount,
maxDrawTimes,
canDraw: currentDrawCount < maxDrawTimes
});
} catch (error) {
console.error('检查抽奖次数失败:', error);
setStudentDrawCount(0);
} finally {
setIsCheckingLimit(false);
}
} else {
// 只有当学号不完整时才重置抽奖次数
if (studentId.length === 0) {
setStudentDrawCount(0);
}
}
};
checkStudentDrawCount();
}, [studentId, maxDrawTimes, lotteryService]);
const handleKeyPress = (key: string) => {
// 点击数字时清空抽奖结果
if (onClearResult) {
onClearResult();
}
// 如果学号已达到12位禁用数字键输入
if (studentId.length >= 12) {
return;
}
if (studentId.length < 12) {
const newStudentId = studentId + key;
setStudentId(newStudentId);
onStudentIdChange(newStudentId);
// 实时更新store中的currentStudentId
useAppStore.getState().setStudentId(newStudentId);
}
};
const handleDelete = () => {
const newStudentId = studentId.slice(0, -1);
setStudentId(newStudentId);
onStudentIdChange(newStudentId);
// 实时更新store中的currentStudentId
useAppStore.getState().setStudentId(newStudentId);
};
const handleClear = () => {
setStudentId('');
onStudentIdChange('');
// 实时更新store中的currentStudentId
useAppStore.getState().setStudentId('');
};
// 防抖机制,防止重复点击
const [isDebouncing, setIsDebouncing] = useState(false);
const handleDraw = useCallback(async () => {
console.log('🎯 ScreenKeyboard: handleDraw被调用', {
isDebouncing,
studentIdLength: studentId.length,
isDrawing,
isCheckingLimit,
studentDrawCount,
maxDrawTimes,
onDrawStart: !!onDrawStart,
onCountdownStart: !!onCountdownStart
});
// 强化防重复点击检查
if (isDebouncing || studentId.length !== 12 || isDrawing || isCheckingLimit) {
console.log('❌ 抽奖条件不满足:', { isDebouncing, studentIdLength: studentId.length, isDrawing, isCheckingLimit });
return;
}
// 检查抽奖次数限制
if (studentDrawCount >= maxDrawTimes) {
console.log('❌ 已达到抽奖上限:', { studentDrawCount, maxDrawTimes });
return;
}
console.log('✅ ScreenKeyboard: 开始抽奖流程', { studentId, studentDrawCount, maxDrawTimes });
// 验证学号格式
if (!/^\d{12}$/.test(studentId)) {
console.log('❌ 学号格式不正确');
return;
}
try {
// 立即设置防抖状态,防止重复点击
console.log('🔒 设置防抖状态');
setIsDebouncing(true);
// 触发抽奖开始事件
if (onDrawStart) {
console.log('📢 触发onDrawStart事件');
onDrawStart({
success: true,
message: '',
student_id: studentId,
draw_time: new Date().toISOString()
});
} else {
console.log('⚠️ onDrawStart回调不存在');
}
// 启动倒计时
if (onCountdownStart) {
console.log('⏰ 调用onCountdownStart');
const result = await onCountdownStart();
console.log('⏰ onCountdownStart返回结果:', result);
// 只有在倒计时成功启动后才重置防抖状态
if (result) {
// 延长防抖时间到5秒确保整个抽奖流程完成
setTimeout(() => {
console.log('🔓 重置防抖状态');
setIsDebouncing(false);
}, 5000);
} else {
// 如果倒计时启动失败,立即重置防抖状态
console.log('🔓 倒计时启动失败,立即重置防抖状态');
setIsDebouncing(false);
}
} else {
console.log('⚠️ onCountdownStart回调不存在');
setIsDebouncing(false);
}
} catch (error) {
console.error('❌ ScreenKeyboard: 抽奖启动失败:', error);
console.error('❌ 错误堆栈:', error.stack);
setIsDebouncing(false);
// 可以在这里添加用户友好的错误提示
// 例如通过toast显示错误信息
}
}, [studentId, isDrawing, isDebouncing, isCheckingLimit, studentDrawCount, maxDrawTimes, onDrawStart, onCountdownStart]);
const keys = [
['1', '2', '3'],
['4', '5', '6'],
['7', '8', '9'],
['clear', '0', 'delete']
];
return (
<div className="flex flex-col items-center space-y-6">
{/* 学号显示区域 */}
<div className="bg-white/10 backdrop-blur-md rounded-2xl p-6 w-full max-w-md">
<div className="bg-black/20 rounded-xl p-4 mb-4">
<div className="text-center">
<div className="text-4xl font-mono text-white mb-2 leading-tight">
{(() => {
const paddedId = studentId.padEnd(12, '_');
const groups = [
paddedId.slice(0, 4),
paddedId.slice(4, 8),
paddedId.slice(8, 12)
];
return groups.map((group, groupIndex) => (
<span key={groupIndex} className="inline-block">
{group.split('').map((char, charIndex) => (
<span key={charIndex} className={char === '_' ? 'text-white/30' : 'text-white'}>
{char}
</span>
))}
{groupIndex < 2 && <span className="text-white/50 mx-1"> </span>}
</span>
));
})()}
</div>
<div className="text-sm text-white/60">
{studentId.length}/12
</div>
</div>
</div>
{/* 抽奖次数显示已移除 */}
{/* 抽奖按钮 */}
<motion.button
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
onClick={handleDraw}
disabled={studentId.length !== 12 || isDrawing || isCheckingLimit || studentDrawCount >= maxDrawTimes || isDebouncing}
className="w-full py-6 bg-gradient-to-r from-green-500 to-blue-600 hover:from-green-600 hover:to-blue-700 disabled:from-gray-500 disabled:to-gray-600 disabled:cursor-not-allowed text-white font-bold text-xl rounded-xl shadow-lg transition-all duration-200 flex items-center justify-center space-x-2"
>
<Play className="w-6 h-6" />
<span>{isDrawing ? '抽奖中...' :
isCheckingLimit ? '检查中...' :
isDebouncing ? '请稍候...' :
studentDrawCount >= maxDrawTimes ? '已达上限' :
'开始抽奖'}</span>
</motion.button>
</div>
{/* 屏幕键盘 - 140px*140px */}
<div className="bg-white/10 backdrop-blur-md rounded-2xl p-6">
<div className="grid grid-cols-3 gap-[10px]" style={{ width: '450px', height: '600px' }}>
{keys.flat().map((key, index) => {
let content;
// 检查数字键是否应该被禁用
const isNumberKeyDisabled = /^[0-9]$/.test(key) && studentId.length >= 12;
const className = `
rounded-lg font-bold text-sm transition-all duration-200 flex items-center justify-center
${isDrawing || isNumberKeyDisabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}
${key === 'clear' || key === 'delete'
? 'bg-red-500/20 hover:bg-red-500/30 text-red-300 border border-red-500/30'
: isNumberKeyDisabled
? 'bg-gray-500/20 text-gray-400 border border-gray-500/30'
: 'bg-white/10 hover:bg-white/20 text-white border border-white/20'
}
${typeof key === 'number' ? '' : 'text-lg'}
`;
const numberStyle = /^[0-9]$/.test(key) ? { fontSize: '112px', lineHeight: '140px' } : {};
if (key === 'clear') {
content = <RotateCcw className="w-8 h-8" />;
} else if (key === 'delete') {
content = <Delete className="w-8 h-8" />;
} else {
content = key;
}
return (
<motion.button
key={index}
whileHover={isDrawing || isNumberKeyDisabled ? {} : { scale: 1.05 }}
whileTap={isDrawing || isNumberKeyDisabled ? {} : { scale: 0.95 }}
className={className}
style={{ width: '140px', height: '140px', ...numberStyle }}
disabled={isDrawing || isNumberKeyDisabled}
onClick={() => {
if (key === 'clear') {
handleClear();
} else if (key === 'delete') {
handleDelete();
} else if (!isNumberKeyDisabled) {
handleKeyPress(key);
}
}}
>
{content}
</motion.button>
);
})}
</div>
</div>
</div>
);
}
export default React.memo(ScreenKeyboard)

120
src/components/Toast.tsx Normal file
View File

@@ -0,0 +1,120 @@
import React, { useState, useEffect } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { AlertCircle, CheckCircle, Info, X } from 'lucide-react'
export interface ToastProps {
id: string
type: 'success' | 'error' | 'warning' | 'info'
title: string
message?: string
duration?: number
onClose: (id: string) => void
}
const Toast: React.FC<ToastProps> = ({
id,
type,
title,
message,
duration = 3000,
onClose
}) => {
const [isVisible, setIsVisible] = useState(true)
// 调试输出
console.log('Toast props:', { id, type, title, message, duration })
useEffect(() => {
const timer = setTimeout(() => {
setIsVisible(false)
setTimeout(() => onClose(id), 300) // 等待动画完成
}, duration)
return () => clearTimeout(timer)
}, [duration, id, onClose])
const handleClose = () => {
setIsVisible(false)
setTimeout(() => onClose(id), 300)
}
const getIcon = () => {
switch (type) {
case 'success':
return <CheckCircle className="w-5 h-5 text-green-400" />
case 'error':
return <AlertCircle className="w-5 h-5 text-red-400" />
case 'warning':
return <AlertCircle className="w-5 h-5 text-yellow-400" />
case 'info':
default:
return <Info className="w-5 h-5 text-blue-400" />
}
}
const getColorClasses = () => {
switch (type) {
case 'success':
return 'bg-gradient-to-br from-green-500/25 to-green-600/30 border-green-400/50 text-green-50 shadow-green-500/20'
case 'error':
return 'bg-gradient-to-br from-red-500/25 to-red-600/30 border-red-400/50 text-red-50 shadow-red-500/20'
case 'warning':
return 'bg-gradient-to-br from-yellow-500/25 to-yellow-600/30 border-yellow-400/50 text-yellow-50 shadow-yellow-500/20'
case 'info':
default:
return 'bg-gradient-to-br from-blue-500/25 to-blue-600/30 border-blue-400/50 text-blue-50 shadow-blue-500/20'
}
}
return (
<AnimatePresence>
{isVisible && (
<motion.div
initial={{ opacity: 0, y: -50, scale: 0.9 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -50, scale: 0.9 }}
transition={{ type: "spring", stiffness: 300, damping: 30 }}
className={`
relative p-5 rounded-2xl border-2 backdrop-blur-md
shadow-2xl shadow-black/30 drop-shadow-lg
${getColorClasses()}
min-w-[320px] max-w-[420px]
before:content-[''] before:absolute before:bottom-[-8px] before:left-6
before:w-4 before:h-4 before:rotate-45 before:border-r-2 before:border-b-2
before:border-inherit before:bg-inherit before:backdrop-blur-md
`}
>
<div className="flex items-start space-x-3">
<div className="flex-shrink-0 mt-0.5">
{getIcon()}
</div>
<div className="flex-1 min-w-0">
<h4 className="font-semibold text-sm">{title}</h4>
{message && message.trim() && (
<p className="mt-1 text-sm opacity-90">{message}</p>
)}
</div>
<button
onClick={handleClose}
className="flex-shrink-0 ml-2 p-1 rounded-md hover:bg-white/10 transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
{/* 进度条 */}
<motion.div
initial={{ width: '100%' }}
animate={{ width: '0%' }}
transition={{ duration: duration / 1000, ease: 'linear' }}
className="absolute bottom-0 left-0 h-1 bg-white/30 rounded-b-lg"
/>
</motion.div>
)}
</AnimatePresence>
)
}
export default Toast

View File

@@ -0,0 +1,31 @@
import React from 'react'
import { createPortal } from 'react-dom'
import Toast, { ToastProps } from './Toast'
export interface ToastItem extends Omit<ToastProps, 'onClose'> {
id: string
}
interface ToastContainerProps {
toasts: ToastItem[]
onRemoveToast: (id: string) => void
}
const ToastContainer: React.FC<ToastContainerProps> = ({ toasts, onRemoveToast }) => {
const portalRoot = document.getElementById('toast-root') || document.body
return createPortal(
<div className="fixed top-4 right-4 z-[9999] space-y-2">
{toasts.map((toast) => (
<Toast
key={toast.id}
{...toast}
onClose={onRemoveToast}
/>
))}
</div>,
portalRoot
)
}
export default ToastContainer

View File

@@ -0,0 +1,62 @@
import React, { createContext, useContext, ReactNode } from 'react'
import { useToast } from '../hooks/useToast'
import ToastContainer from '../components/ToastContainer'
interface ToastContextType {
showToast: (options: {
type?: 'success' | 'error' | 'warning' | 'info'
title: string
message?: string
duration?: number
}) => string
showSuccess: (title: string, message?: string, duration?: number) => string
showError: (title: string, message?: string, duration?: number) => string
showWarning: (title: string, message?: string, duration?: number) => string
showInfo: (title: string, message?: string, duration?: number) => string
removeToast: (id: string) => void
clearAllToasts: () => void
}
const ToastContext = createContext<ToastContextType | undefined>(undefined)
export const useToastContext = () => {
const context = useContext(ToastContext)
if (!context) {
throw new Error('useToastContext must be used within a ToastProvider')
}
return context
}
interface ToastProviderProps {
children: ReactNode
}
export const ToastProvider: React.FC<ToastProviderProps> = ({ children }) => {
const {
toasts,
showToast,
removeToast,
clearAllToasts,
showSuccess,
showError,
showWarning,
showInfo
} = useToast()
const contextValue: ToastContextType = {
showToast,
showSuccess,
showError,
showWarning,
showInfo,
removeToast,
clearAllToasts
}
return (
<ToastContext.Provider value={contextValue}>
{children}
<ToastContainer toasts={toasts} onRemoveToast={removeToast} />
</ToastContext.Provider>
)
}

435
src/database/database.ts Normal file
View File

@@ -0,0 +1,435 @@
// 数据库服务 - 直接调用服务器API
// 移除所有本地存储,统一使用服务器数据库
import type { LotteryRecord } from '../types';
// 数据库接口定义
export interface SystemConfig {
id: string;
admin_password: string;
login_password: string;
max_draw_times: number;
background_config: string;
hide_positions: string;
created_at: string;
updated_at: string;
}
export interface PrizeConfig {
id: string;
prize_name: string;
prize_level: number;
total_quantity: number;
remaining_quantity: number;
probability: number;
is_active: boolean;
created_at: string;
}
export interface Student {
student_id: string;
draw_count: number;
first_draw_at?: string;
last_draw_at?: string;
}
class DatabaseService {
private static instance: DatabaseService;
private baseUrl: string = 'http://localhost:3001/api';
private constructor() {
// 直接使用服务器API无需初始化本地数据库
}
public static getInstance(): DatabaseService {
if (!DatabaseService.instance) {
DatabaseService.instance = new DatabaseService();
}
return DatabaseService.instance;
}
// HTTP请求辅助方法
private async request(endpoint: string, options: RequestInit = {}): Promise<any> {
const url = `${this.baseUrl}${endpoint}`;
const response = await fetch(url, {
headers: {
'Content-Type': 'application/json',
...options.headers,
},
...options,
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
}
private generateId(): string {
return Math.random().toString(36).substr(2, 9) + Date.now().toString(36);
}
// 系统配置相关方法
public async getSystemConfig(): Promise<SystemConfig | null> {
try {
return await this.request('/system/config');
} catch (error) {
console.error('获取系统配置失败:', error);
return null;
}
}
public async updateSystemConfig(config: Partial<SystemConfig>): Promise<boolean> {
try {
await this.request('/system/config', {
method: 'PUT',
body: JSON.stringify(config),
});
return true;
} catch (error) {
console.error('更新系统配置失败:', error);
return false;
}
}
public async changeAdminPassword(newPassword: string): Promise<boolean> {
try {
await this.request('/system/admin-password', {
method: 'PUT',
body: JSON.stringify({ password: newPassword }),
});
return true;
} catch (error) {
console.error('修改管理员密码失败:', error);
return false;
}
}
public async verifyAdminPassword(password: string): Promise<boolean> {
try {
const result = await this.request('/system/verify-admin', {
method: 'POST',
body: JSON.stringify({ password }),
});
return result.valid;
} catch (error) {
console.error('验证管理员密码失败:', error);
return false;
}
}
// 更新管理员密码
public async updateAdminPassword(newPassword: string): Promise<boolean> {
try {
return await this.changeAdminPassword(newPassword);
} catch (error) {
console.error('Update admin password failed:', error);
return false;
}
}
// 验证当前管理员密码
public async validateCurrentAdminPassword(password: string): Promise<boolean> {
try {
return await this.verifyAdminPassword(password);
} catch (error) {
console.error('Validate admin password failed:', error);
return false;
}
}
// 重置管理员密码为默认值
public async resetAdminPasswordToDefault(): Promise<boolean> {
try {
console.log('重置管理员密码为默认值: 123456');
return await this.changeAdminPassword('123456');
} catch (error) {
console.error('Reset admin password failed:', error);
return false;
}
}
// 更新首页登录密码
public async updateLoginPassword(newPassword: string): Promise<boolean> {
try {
console.log('更新首页登录密码');
return await this.updateSystemConfig({ login_password: newPassword });
} catch (error) {
console.error('Update login password failed:', error);
return false;
}
}
// 验证首页登录密码
public async validateLoginPassword(password: string): Promise<boolean> {
try {
const config = await this.getSystemConfig();
return config ? config.login_password === password : false;
} catch (error) {
console.error('Validate login password failed:', error);
return false;
}
}
// 重置首页登录密码为默认值
public async resetLoginPasswordToDefault(): Promise<boolean> {
try {
console.log('重置首页登录密码为默认值: 123456');
return await this.updateSystemConfig({ login_password: '123456' });
} catch (error) {
console.error('Reset login password failed:', error);
return false;
}
}
// 奖项配置相关操作
public async getAllPrizes(): Promise<PrizeConfig[]> {
try {
return await this.request('/prizes');
} catch (error) {
console.error('获取奖项列表失败:', error);
return [];
}
}
// 添加奖项
public async addPrize(prize: Omit<PrizeConfig, 'id' | 'remaining_quantity' | 'created_at'>): Promise<boolean> {
try {
await this.request('/prizes', {
method: 'POST',
body: JSON.stringify(prize),
});
return true;
} catch (error) {
console.error('添加奖项失败:', error);
return false;
}
}
// 更新奖项
public async updatePrize(id: string, prize: Partial<Omit<PrizeConfig, 'id' | 'created_at'>>): Promise<boolean> {
try {
await this.request(`/prizes/${id}`, {
method: 'PUT',
body: JSON.stringify(prize),
});
return true;
} catch (error) {
console.error('更新奖项失败:', error);
return false;
}
}
// 删除奖项
public async deletePrize(id: string): Promise<boolean> {
try {
await this.request(`/prizes/${id}`, {
method: 'DELETE',
});
return true;
} catch (error) {
console.error('删除奖项失败:', error);
return false;
}
}
// 获取所有抽奖记录
public async getAllDrawRecords(): Promise<LotteryRecord[]> {
try {
return await this.request('/records');
} catch (error) {
console.error('获取抽奖记录失败:', error);
return [];
}
}
public async updatePrizeQuantity(prizeId: string, remainingQuantity: number): Promise<boolean> {
try {
await this.request(`/prizes/${prizeId}/quantity`, {
method: 'PUT',
body: JSON.stringify({ remaining_quantity: remainingQuantity }),
});
return true;
} catch (error) {
console.error('Update prize quantity failed:', error);
return false;
}
}
// 学生相关操作
public async getStudent(studentId: string): Promise<Student | null> {
try {
return await this.request(`/students/${studentId}`);
} catch (error) {
console.error('Get student failed:', error);
return null;
}
}
public async createOrUpdateStudent(studentId: string): Promise<Student> {
try {
return await this.request('/students', {
method: 'POST',
body: JSON.stringify({ student_id: studentId }),
});
} catch (error) {
console.error('Create or update student failed:', error);
throw error;
}
}
// 抽奖记录相关操作
public async createLotteryRecord(studentId: string, prizeId: string, prizeName: string, prizeLevel: number): Promise<LotteryRecord> {
try {
const record = {
student_id: studentId,
prize_id: prizeId,
prize_name: prizeName,
prize_level: prizeLevel
};
return await this.request('/records', {
method: 'POST',
body: JSON.stringify(record),
});
} catch (error) {
console.error('Create lottery record failed:', error);
throw error;
}
}
public async createDrawRecord(record: Omit<LotteryRecord, 'id' | 'draw_time' | 'is_synced' | 'cache_data'>): Promise<LotteryRecord> {
try {
return await this.request('/records', {
method: 'POST',
body: JSON.stringify(record),
});
} catch (error) {
console.error('创建抽奖记录失败:', error);
throw error;
}
}
public async getLotteryRecords(limit?: number): Promise<LotteryRecord[]> {
try {
const params = limit ? `?limit=${limit}` : '';
return await this.request(`/records${params}`);
} catch (error) {
console.error('Get lottery records failed:', error);
return [];
}
}
public async getStudentRecords(studentId: string): Promise<LotteryRecord[]> {
try {
return await this.request(`/students/${studentId}/records`);
} catch (error) {
console.error('Get student records failed:', error);
return [];
}
}
// 清空所有抽奖记录
public async clearAllLotteryRecords(): Promise<boolean> {
try {
await this.request('/records', {
method: 'DELETE',
});
return true;
} catch (error) {
console.error('Clear lottery records failed:', error);
return false;
}
}
public async clearAllDrawRecords(): Promise<boolean> {
try {
return await this.clearAllLotteryRecords();
} catch (error) {
console.error('清空抽奖记录失败:', error);
return false;
}
}
// 一键重置系统:清空记录、重置奖项数量和重新计算概率
public async resetSystemCompletely(): Promise<boolean> {
try {
await this.request('/system/reset', {
method: 'POST',
});
return true;
} catch (error) {
console.error('一键重置系统失败:', error);
return false;
}
}
public async resetSystem(): Promise<boolean> {
try {
return await this.resetSystemCompletely();
} catch (error) {
console.error('系统重置失败:', error);
return false;
}
}
// 强制重置数据库
public async forceResetDatabase(): Promise<boolean> {
try {
console.log('DatabaseService: 开始强制重置数据库...');
return await this.resetSystemCompletely();
} catch (error) {
console.error('强制重置数据库失败:', error);
return false;
}
}
// 模拟抽奖(用于测试)
public async simulateLottery(studentId: string, prizeId: string): Promise<LotteryRecord | null> {
try {
return await this.request('/lottery/simulate', {
method: 'POST',
body: JSON.stringify({ studentId, prizeId }),
});
} catch (error) {
console.error('模拟抽奖失败:', error);
return null;
}
}
// 抽奖逻辑现在由服务器处理
// 客户端不再需要本地抽奖逻辑
// 保存数据库(用于数据持久化)
public async saveDatabase(): Promise<boolean> {
try {
await this.request('/system/save', {
method: 'POST',
});
return true;
} catch (error) {
console.error('保存数据库失败:', error);
return false;
}
}
// 获取数据库统计信息
public async getDatabaseStats(): Promise<any> {
try {
return await this.request('/system/stats');
} catch (error) {
console.error('获取数据库统计信息失败:', error);
return null;
}
}
// 关闭数据库连接
public close(): void {
// 服务器端数据库连接由服务器管理,客户端无需关闭
console.log('使用服务器数据库,无需关闭连接');
}
}
export default DatabaseService;

66
src/database/init.sql Normal file
View File

@@ -0,0 +1,66 @@
-- 抽奖系统数据库初始化脚本
-- 创建系统配置表
CREATE TABLE system_config (
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
admin_password TEXT NOT NULL DEFAULT 'admin123',
login_password TEXT NOT NULL DEFAULT 'admin123',
max_draw_times INTEGER DEFAULT 1,
background_config TEXT DEFAULT '{}',
hide_positions TEXT DEFAULT '3,4,5,6',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- 创建奖项配置表
CREATE TABLE prize_config (
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
prize_name TEXT NOT NULL,
prize_level INTEGER NOT NULL CHECK (prize_level IN (1,2,3,4)),
total_quantity INTEGER NOT NULL DEFAULT 0,
remaining_quantity INTEGER NOT NULL DEFAULT 0,
probability REAL DEFAULT 0.0000,
is_active INTEGER DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- 创建学生表
CREATE TABLE students (
student_id TEXT PRIMARY KEY,
draw_count INTEGER DEFAULT 0,
first_draw_at DATETIME,
last_draw_at DATETIME
);
-- 创建抽奖记录表
CREATE TABLE lottery_records (
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
student_id TEXT NOT NULL,
prize_id TEXT REFERENCES prize_config(id),
prize_name TEXT NOT NULL,
prize_level INTEGER NOT NULL,
draw_time DATETIME DEFAULT CURRENT_TIMESTAMP,
is_synced INTEGER DEFAULT 0,
cache_data TEXT DEFAULT '{}'
);
-- 创建索引
CREATE INDEX idx_lottery_records_student_id ON lottery_records(student_id);
CREATE INDEX idx_lottery_records_draw_time ON lottery_records(draw_time DESC);
CREATE INDEX idx_lottery_records_prize_level ON lottery_records(prize_level);
CREATE INDEX idx_students_draw_count ON students(draw_count);
-- 创建触发器用于更新时间戳
CREATE TRIGGER update_system_config_timestamp
AFTER UPDATE ON system_config
BEGIN
UPDATE system_config SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
END;
-- 初始化数据
INSERT INTO system_config (admin_password, login_password, max_draw_times) VALUES ('admin123', 'admin123', 1);
INSERT INTO prize_config (prize_name, prize_level, total_quantity, remaining_quantity, probability) VALUES
('一等奖-iPad', 1, 1, 1, 0.0010),
('二等奖-蓝牙耳机', 2, 5, 5, 0.0050),
('三等奖-保温杯', 3, 20, 20, 0.0200),
('谢谢参与', 4, 9974, 9974, 0.9740);

29
src/hooks/useTheme.ts Normal file
View File

@@ -0,0 +1,29 @@
import { useState, useEffect } from 'react';
type Theme = 'light' | 'dark';
export function useTheme() {
const [theme, setTheme] = useState<Theme>(() => {
const savedTheme = localStorage.getItem('theme') as Theme;
if (savedTheme) {
return savedTheme;
}
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
});
useEffect(() => {
document.documentElement.classList.remove('light', 'dark');
document.documentElement.classList.add(theme);
localStorage.setItem('theme', theme);
}, [theme]);
const toggleTheme = () => {
setTheme(prevTheme => prevTheme === 'light' ? 'dark' : 'light');
};
return {
theme,
toggleTheme,
isDark: theme === 'dark'
};
}

64
src/hooks/useToast.ts Normal file
View File

@@ -0,0 +1,64 @@
import { useState, useCallback } from 'react'
import { ToastItem } from '../components/ToastContainer'
export interface ShowToastOptions {
type?: 'success' | 'error' | 'warning' | 'info'
title: string
message?: string
duration?: number
}
export const useToast = () => {
const [toasts, setToasts] = useState<ToastItem[]>([])
const showToast = useCallback((options: ShowToastOptions) => {
const id = Date.now().toString() + Math.random().toString(36).substr(2, 9)
const newToast: ToastItem = {
id,
type: options.type || 'info',
title: options.title,
message: options.message,
duration: options.duration || 3000
}
setToasts(prev => [...prev, newToast])
return id
}, [])
const removeToast = useCallback((id: string) => {
setToasts(prev => prev.filter(toast => toast.id !== id))
}, [])
const clearAllToasts = useCallback(() => {
setToasts([])
}, [])
// 便捷方法
const showSuccess = useCallback((title: string, message?: string, duration?: number) => {
return showToast({ type: 'success', title, message, duration })
}, [showToast])
const showError = useCallback((title: string, message?: string, duration?: number) => {
return showToast({ type: 'error', title, message, duration })
}, [showToast])
const showWarning = useCallback((title: string, message?: string, duration?: number) => {
console.log('showWarning调用:', { title, message, duration })
return showToast({ type: 'warning', title, message, duration })
}, [showToast])
const showInfo = useCallback((title: string, message?: string, duration?: number) => {
return showToast({ type: 'info', title, message, duration })
}, [showToast])
return {
toasts,
showToast,
removeToast,
clearAllToasts,
showSuccess,
showError,
showWarning,
showInfo
}
}

92
src/index.css Normal file
View File

@@ -0,0 +1,92 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
line-height: 1.5;
font-weight: 400;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* 深色模式全局样式 */
html {
background-color: #111827;
color: #f9fafb;
}
body {
@apply bg-gray-900 text-gray-100 transition-colors duration-300;
}
/* 深色模式下的滚动条样式 */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
@apply bg-gray-800;
}
::-webkit-scrollbar-thumb {
@apply bg-gray-600 rounded-md;
}
::-webkit-scrollbar-thumb:hover {
@apply bg-gray-500;
}
/* 淡入上升动画 */
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fadeInUp {
animation: fadeInUp 0.6s ease-out;
}
/* 平滑过渡效果 */
.smooth-refresh {
transition: opacity 0.3s ease-in-out, transform 0.3s ease-in-out;
}
.smooth-refresh.loading {
opacity: 0.7;
transform: scale(0.98);
}
/* 隐藏滚动条但保持滚动功能 */
.scrollbar-hide {
-ms-overflow-style: none; /* Internet Explorer 10+ */
scrollbar-width: none; /* Firefox */
}
.scrollbar-hide::-webkit-scrollbar {
display: none; /* Safari and Chrome */
}
/* 隐藏所有闪烁光标 */
* {
caret-color: transparent !important;
}
input, textarea, [contenteditable] {
caret-color: transparent !important;
cursor: default !important;
}
input:focus, textarea:focus, [contenteditable]:focus {
caret-color: transparent !important;
outline: none;
}

6
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

14
src/main.tsx Normal file
View File

@@ -0,0 +1,14 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
import ErrorBoundary from './components/ErrorBoundary'
import './index.css'
createRoot(document.getElementById('root')!).render(
<ErrorBoundary>
{/* 暂时禁用StrictMode以排查倒计时问题 */}
{/* <StrictMode> */}
<App />
{/* </StrictMode> */}
</ErrorBoundary>
)

1399
src/pages/Admin.tsx Normal file

File diff suppressed because it is too large Load Diff

184
src/pages/ClearRecords.tsx Normal file
View File

@@ -0,0 +1,184 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { ArrowLeft, Trash2, RefreshCw, CheckCircle } from 'lucide-react';
import ApiService from "../services/apiService";
import { useToast } from '../hooks/useToast';
const ClearRecords: React.FC = () => {
const navigate = useNavigate();
const { showSuccess, showError, showWarning } = useToast();
const [isClearing, setIsClearing] = useState(false);
const [clearResult, setClearResult] = useState<string | null>(null);
const [currentData, setCurrentData] = useState<any>(null);
const api = ApiService.getInstance();
const checkCurrentData = async () => {
try {
const records = await api.getLotteryRecords();
const prizes = await api.getAllPrizes();
const students = JSON.parse(localStorage.getItem('lottery_students') || '[]');
setCurrentData({
recordsCount: records.length,
studentsCount: students.length,
prizes: prizes.map(p => ({
name: p.prize_name,
total: p.total_quantity,
remaining: p.remaining_quantity
}))
});
} catch (error) {
console.error('ClearRecords页面 - 获取当前数据失败:', error);
showError('获取数据失败,请检查网络连接');
setCurrentData({
recordsCount: 0,
studentsCount: 0,
prizes: []
});
}
};
const handleClearRecords = async () => {
if (!window.confirm('确定要清空所有抽奖记录吗?此操作不可撤销!')) {
return;
}
setIsClearing(true);
setClearResult(null);
try {
const success = await api.clearAllLotteryRecords();
if (success) {
setClearResult('✅ 抽奖记录清空成功!');
// 重新检查数据
setTimeout(async () => {
await checkCurrentData();
}, 500);
} else {
setClearResult('❌ 清空操作失败,请重试');
}
} catch (error) {
setClearResult(`❌ 清空操作出错: ${error}`);
} finally {
setIsClearing(false);
}
};
React.useEffect(() => {
checkCurrentData();
}, []);
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 p-4">
<div className="max-w-4xl mx-auto">
{/* 头部 */}
<div className="bg-white rounded-lg shadow-lg p-6 mb-6">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<button
onClick={() => navigate('/admin')}
className="flex items-center space-x-2 text-gray-600 hover:text-gray-800 transition-colors"
>
<ArrowLeft className="w-5 h-5" />
<span></span>
</button>
<h1 className="text-2xl font-bold text-gray-800"></h1>
</div>
</div>
</div>
{/* 当前数据状态 */}
<div className="bg-white rounded-lg shadow-lg p-6 mb-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-gray-800"></h2>
<button
onClick={checkCurrentData}
className="flex items-center space-x-2 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"
>
<RefreshCw className="w-4 h-4" />
<span></span>
</button>
</div>
{currentData && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="bg-blue-50 p-4 rounded-lg">
<h3 className="font-semibold text-blue-800 mb-2"></h3>
<p className="text-2xl font-bold text-blue-600">{currentData.recordsCount}</p>
<p className="text-sm text-blue-600"></p>
</div>
<div className="bg-green-50 p-4 rounded-lg">
<h3 className="font-semibold text-green-800 mb-2"></h3>
<p className="text-2xl font-bold text-green-600">{currentData.studentsCount}</p>
<p className="text-sm text-green-600"></p>
</div>
<div className="bg-purple-50 p-4 rounded-lg">
<h3 className="font-semibold text-purple-800 mb-2"></h3>
<div className="space-y-1">
{currentData.prizes.map((prize: any, index: number) => (
<div key={index} className="text-sm">
<span className="text-purple-700">{prize.name}:</span>
<span className="text-purple-600 ml-1">
{prize.remaining}/{prize.total}
</span>
</div>
))}
</div>
</div>
</div>
)}
</div>
{/* 清空操作 */}
<div className="bg-white rounded-lg shadow-lg p-6">
<h2 className="text-xl font-semibold text-gray-800 mb-4"></h2>
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
<h3 className="font-semibold text-red-800 mb-2"> </h3>
<p className="text-red-700 mb-2"></p>
<ul className="list-disc list-inside text-red-700 space-y-1">
<li></li>
<li></li>
<li></li>
</ul>
<p className="text-red-800 font-semibold mt-2"></p>
</div>
<div className="flex items-center space-x-4">
<button
onClick={handleClearRecords}
disabled={isClearing}
className="flex items-center space-x-2 px-6 py-3 bg-red-500 text-white rounded-lg hover:bg-red-600 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
>
{isClearing ? (
<RefreshCw className="w-5 h-5 animate-spin" />
) : (
<Trash2 className="w-5 h-5" />
)}
<span>{isClearing ? '清空中...' : '清空所有记录'}</span>
</button>
</div>
{clearResult && (
<div className={`mt-4 p-4 rounded-lg ${
clearResult.includes('✅')
? 'bg-green-50 border border-green-200 text-green-800'
: 'bg-red-50 border border-red-200 text-red-800'
}`}>
<div className="flex items-center space-x-2">
<CheckCircle className="w-5 h-5" />
<span>{clearResult}</span>
</div>
</div>
)}
</div>
</div>
</div>
);
};
export default ClearRecords;

161
src/pages/ConfettiDemo.tsx Normal file
View File

@@ -0,0 +1,161 @@
import React, { useState } from 'react';
import { motion } from 'framer-motion';
import { Sparkles, Trophy, ArrowLeft } from 'lucide-react';
import { Link } from 'react-router-dom';
import ConfettiEffect from '../components/ConfettiEffect';
import LotteryAnimation from '../components/LotteryAnimation';
import { DrawResult } from '../store';
const ConfettiDemo: React.FC = () => {
const [showConfetti, setShowConfetti] = useState(false);
const [showLotteryAnimation, setShowLotteryAnimation] = useState(false);
const [demoResult, setDemoResult] = useState<DrawResult | undefined>();
// 模拟1等奖结果
const mockFirstPrizeResult: DrawResult = {
success: true,
message: '恭喜中奖!',
studentId: '2021001',
formattedStudentId: '2021001',
prize: {
id: '1',
name: '一等奖',
level: 1
},
timestamp: Date.now()
};
// 触发单独的撒花特效
const triggerConfetti = () => {
setShowConfetti(true);
setTimeout(() => {
setShowConfetti(false);
}, 4000);
};
// 触发完整的1等奖抽奖动画
const triggerFullAnimation = () => {
setDemoResult(mockFirstPrizeResult);
setShowLotteryAnimation(true);
};
const handleAnimationComplete = () => {
setShowLotteryAnimation(false);
setDemoResult(undefined);
};
const handleCountdownComplete = () => {
// 模拟抽奖完成后设置结果
setTimeout(() => {
setDemoResult(mockFirstPrizeResult);
}, 1000);
};
return (
<div className="min-h-screen bg-gradient-to-br from-purple-900 via-blue-900 to-indigo-900 p-4">
<div className="max-w-4xl mx-auto">
{/* 头部导航 */}
<div className="flex items-center justify-between mb-8">
<Link
to="/"
className="flex items-center space-x-2 text-white hover:text-yellow-400 transition-colors"
>
<ArrowLeft size={24} />
<span></span>
</Link>
<h1 className="text-3xl font-bold text-white flex items-center space-x-2">
<Sparkles className="text-yellow-400" />
<span></span>
</h1>
</div>
{/* 演示区域 */}
<div className="bg-white/10 backdrop-blur-sm rounded-2xl p-8 mb-8">
<div className="text-center">
<div className="mb-8">
<Trophy className="w-24 h-24 text-yellow-400 mx-auto mb-4" />
<h2 className="text-2xl font-bold text-white mb-2"></h2>
<p className="text-gray-300"></p>
</div>
{/* 演示按钮 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* 单独撒花特效 */}
<motion.button
onClick={triggerConfetti}
className="bg-gradient-to-r from-yellow-400 to-orange-500 text-black font-bold py-4 px-8 rounded-xl shadow-lg hover:shadow-xl transition-all duration-300"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<div className="flex items-center justify-center space-x-2">
<Sparkles size={24} />
<span></span>
</div>
<div className="text-sm mt-1 opacity-80">
</div>
</motion.button>
{/* 完整抽奖动画 */}
<motion.button
onClick={triggerFullAnimation}
className="bg-gradient-to-r from-purple-500 to-pink-500 text-white font-bold py-4 px-8 rounded-xl shadow-lg hover:shadow-xl transition-all duration-300"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<div className="flex items-center justify-center space-x-2">
<Trophy size={24} />
<span></span>
</div>
<div className="text-sm mt-1 opacity-80">
</div>
</motion.button>
</div>
</div>
</div>
{/* 说明文档 */}
<div className="bg-white/5 backdrop-blur-sm rounded-2xl p-6">
<h3 className="text-xl font-bold text-white mb-4"></h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 text-gray-300">
<div>
<h4 className="font-semibold text-yellow-400 mb-2"></h4>
<ul className="space-y-1 text-sm">
<li> 50</li>
<li> 20</li>
<li> </li>
<li> 4</li>
</ul>
</div>
<div>
<h4 className="font-semibold text-yellow-400 mb-2"></h4>
<ul className="space-y-1 text-sm">
<li> 1</li>
<li> level === 1</li>
<li> </li>
<li> </li>
</ul>
</div>
</div>
</div>
</div>
{/* 撒花特效组件 */}
<ConfettiEffect
isActive={showConfetti}
duration={4000}
/>
{/* 完整抽奖动画 */}
<LotteryAnimation
isVisible={showLotteryAnimation}
onComplete={handleAnimationComplete}
onCountdownComplete={handleCountdownComplete}
result={demoResult}
/>
</div>
);
};
export default ConfettiDemo;

284
src/pages/CrudTest.tsx Normal file
View File

@@ -0,0 +1,284 @@
import React, { useState, useEffect } from 'react';
import DatabaseService from '../database/database';
import { Prize, Student, DrawRecord, SystemConfig } from '../types';
import { PrizeConfig } from '../database/database';
interface TestResult {
operation: string;
success: boolean;
message: string;
data?: any;
}
const CrudTest: React.FC = () => {
const [testResults, setTestResults] = useState<TestResult[]>([]);
const [isRunning, setIsRunning] = useState(false);
const [dbService, setDbService] = useState<DatabaseService | null>(null);
useEffect(() => {
const initDb = async () => {
try {
const service = DatabaseService.getInstance();
// 使用公共方法ensureInitialized替代私有方法initDatabase
// 不再需要初始化本地数据库
setDbService(service);
addResult('数据库初始化', true, '数据库服务初始化成功');
} catch (error) {
addResult('数据库初始化', false, `初始化失败: ${error}`);
}
};
initDb();
}, []);
const addResult = (operation: string, success: boolean, message: string, data?: any) => {
setTestResults(prev => [...prev, { operation, success, message, data }]);
};
const clearResults = () => {
setTestResults([]);
};
const runAllTests = async () => {
if (!dbService) {
addResult('测试准备', false, '数据库服务未初始化');
return;
}
setIsRunning(true);
clearResults();
try {
// 测试系统配置CRUD
await testSystemConfig();
// 测试奖项CRUD
await testPrizes();
// 测试学生CRUD
await testStudents();
// 测试抽奖记录CRUD
await testDrawRecords();
// 测试密码管理
await testPasswordManagement();
addResult('所有测试', true, '所有CRUD接口测试完成');
} catch (error) {
addResult('测试执行', false, `测试过程中发生错误: ${error}`);
} finally {
setIsRunning(false);
}
};
const testSystemConfig = async () => {
try {
// 测试获取系统配置
const config = await dbService!.getSystemConfig();
addResult('获取系统配置', true, '成功获取系统配置', config);
// 测试更新系统配置
const newConfig: Partial<SystemConfig> = {
max_draw_times: 3,
admin_password: 'test123',
login_password: 'draw123'
};
const updateResult = await dbService!.updateSystemConfig(newConfig);
addResult('更新系统配置', updateResult, updateResult ? '系统配置更新成功' : '系统配置更新失败');
// 验证更新后的配置
const updatedConfig = await dbService!.getSystemConfig();
addResult('验证配置更新', true, '配置更新验证完成', updatedConfig);
} catch (error) {
addResult('系统配置测试', false, `系统配置测试失败: ${error}`);
}
};
const testPrizes = async () => {
try {
// 测试获取所有奖项
const prizes = await dbService!.getAllPrizes();
addResult('获取所有奖项', true, `成功获取${prizes?.length || 0}个奖项`, prizes);
// 测试添加奖项
const newPrize = {
prize_name: '测试奖品',
prize_level: 1,
probability: 0.1,
total_quantity: 10,
is_active: true
};
const addPrizeResult = await dbService!.addPrize(newPrize);
addResult('添加奖项', addPrizeResult, addPrizeResult ? '奖项添加成功' : '奖项添加失败', newPrize);
if (addPrizeResult) {
// 获取添加的奖项以获取ID
const allPrizes = await dbService!.getAllPrizes();
const addedPrize = allPrizes.find(p => p.prize_name === '测试奖品');
if (addedPrize) {
// 测试更新奖项
const updatedPrize = { prize_name: '更新后的测试奖品', remaining_quantity: 8 };
const updateResult = await dbService!.updatePrize(addedPrize.id, updatedPrize);
addResult('更新奖项', updateResult, updateResult ? '奖项更新成功' : '奖项更新失败', updatedPrize);
// 测试删除奖项
const deleteResult = await dbService!.deletePrize(addedPrize.id);
addResult('删除奖项', deleteResult, deleteResult ? '奖项删除成功' : '奖项删除失败');
}
}
} catch (error) {
addResult('奖项测试', false, `奖项测试失败: ${error}`);
}
};
const testStudents = async () => {
try {
const testStudentId = 'TEST001';
// 测试获取学生
const student = await dbService!.getStudent(testStudentId);
addResult('获取学生', true, student ? '学生已存在' : '学生不存在', student);
// 测试创建/更新学生
const createResult = await dbService!.createOrUpdateStudent(testStudentId);
addResult('创建/更新学生', !!createResult, createResult ? '学生操作成功' : '学生操作失败', createResult);
// 验证学生创建
const createdStudent = await dbService!.getStudent(testStudentId);
addResult('验证学生创建', !!createdStudent, createdStudent ? '学生创建验证成功' : '学生创建验证失败', createdStudent);
} catch (error) {
addResult('学生测试', false, `学生测试失败: ${error}`);
}
};
const testDrawRecords = async () => {
try {
// 测试获取所有抽奖记录
const records = await dbService!.getAllDrawRecords();
addResult('获取抽奖记录', true, `成功获取${records?.length || 0}条抽奖记录`, records);
// 测试创建抽奖记录
const newRecord = {
student_id: 'TEST001',
prize_id: 'test-prize',
prize_name: '测试奖品',
prize_level: 1
};
const createResult = await dbService!.createDrawRecord(newRecord);
addResult('创建抽奖记录', !!createResult, createResult ? '抽奖记录创建成功' : '抽奖记录创建失败', createResult);
// 测试清空抽奖记录
const clearResult = await dbService!.clearAllDrawRecords();
addResult('清空抽奖记录', clearResult, clearResult ? '抽奖记录清空成功' : '抽奖记录清空失败');
} catch (error) {
addResult('抽奖记录测试', false, `抽奖记录测试失败: ${error}`);
}
};
const testPasswordManagement = async () => {
try {
// 测试管理员密码验证
const adminVerify = await dbService!.verifyAdminPassword('123456');
addResult('验证管理员密码', true, adminVerify ? '管理员密码验证成功' : '管理员密码验证失败');
// 测试登录密码验证
const loginVerify = await dbService!.validateLoginPassword('123456');
addResult('验证登录密码', true, loginVerify ? '登录密码验证成功' : '登录密码验证失败');
// 测试修改管理员密码
const changeAdminResult = await dbService!.changeAdminPassword('newadmin123');
addResult('修改管理员密码', changeAdminResult, changeAdminResult ? '管理员密码修改成功' : '管理员密码修改失败');
// 恢复原密码
if (changeAdminResult) {
await dbService!.changeAdminPassword('123456');
addResult('恢复管理员密码', true, '管理员密码已恢复');
}
} catch (error) {
addResult('密码管理测试', false, `密码管理测试失败: ${error}`);
}
};
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 p-6">
<div className="max-w-6xl mx-auto">
<div className="bg-white rounded-lg shadow-lg p-6">
<h1 className="text-3xl font-bold text-gray-800 mb-6">CRUD接口兼容性测试</h1>
<div className="mb-6">
<button
onClick={runAllTests}
disabled={isRunning || !dbService}
className="bg-blue-500 hover:bg-blue-600 disabled:bg-gray-400 text-white px-6 py-2 rounded-lg font-medium transition-colors"
>
{isRunning ? '测试进行中...' : '运行所有测试'}
</button>
<button
onClick={clearResults}
disabled={isRunning}
className="ml-4 bg-gray-500 hover:bg-gray-600 disabled:bg-gray-400 text-white px-6 py-2 rounded-lg font-medium transition-colors"
>
</button>
</div>
<div className="space-y-4">
<h2 className="text-xl font-semibold text-gray-700"></h2>
{testResults.length === 0 ? (
<p className="text-gray-500"></p>
) : (
<div className="space-y-2">
{testResults.map((result, index) => (
<div
key={index}
className={`p-4 rounded-lg border-l-4 ${
result.success
? 'bg-green-50 border-green-400 text-green-800'
: 'bg-red-50 border-red-400 text-red-800'
}`}
>
<div className="flex items-center justify-between">
<div>
<span className="font-medium">{result.operation}</span>
<span className={`ml-2 px-2 py-1 rounded text-xs ${
result.success ? 'bg-green-200 text-green-800' : 'bg-red-200 text-red-800'
}`}>
{result.success ? '成功' : '失败'}
</span>
</div>
</div>
<p className="mt-1 text-sm">{result.message}</p>
{result.data && (
<details className="mt-2">
<summary className="cursor-pointer text-sm font-medium"></summary>
<pre className="mt-1 p-2 bg-gray-100 rounded text-xs overflow-auto">
{JSON.stringify(result.data, null, 2)}
</pre>
</details>
)}
</div>
))}
</div>
)}
</div>
<div className="mt-8 p-4 bg-blue-50 rounded-lg">
<h3 className="font-semibold text-blue-800 mb-2"></h3>
<ul className="text-sm text-blue-700 space-y-1">
<li> </li>
<li> </li>
<li> </li>
<li> </li>
<li> </li>
</ul>
</div>
</div>
</div>
</div>
);
};
export default CrudTest;

443
src/pages/Home.tsx Normal file
View File

@@ -0,0 +1,443 @@
import React, { useEffect, useState } from 'react';
import { motion } from 'framer-motion';
import { Trophy, Award, Star, Gift, RefreshCw, Clock, Sparkles, Wifi, WifiOff } from 'lucide-react';
import { Link } from 'react-router-dom';
import { useAppStore } from '../store';
import { Prize } from '../store';
import EventService, { EVENT_TYPES } from '../services/eventService';
import DatabaseService, { SystemConfig } from '../database/database';
import { websocketService } from '../services/websocketService';
import LoadingSpinner from '../components/LoadingSpinner';
import Empty from '../components/Empty';
interface ProbabilityInfo {
prize: Prize;
dynamicProbability: number;
remainingRatio: number;
adjustedForDrawLimit: boolean;
}
export default function Home() {
const { prizes, loadPrizes } = useAppStore();
const [probabilityData, setProbabilityData] = useState<ProbabilityInfo[]>([]);
const [lastUpdateTime, setLastUpdateTime] = useState<Date>(new Date());
const [isRefreshing, setIsRefreshing] = useState(false);
const [systemConfig, setSystemConfig] = useState<SystemConfig | null>(null);
const [isWebSocketConnected, setIsWebSocketConnected] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// 计算动态概率 - 结合抽奖次数上限和剩余数量
const calculateDynamicProbabilities = (prizeList: Prize[]): ProbabilityInfo[] => {
// 只显示启用且有剩余数量的奖项
const activePrizes = prizeList.filter(p => p.is_active && p.remainingQuantity > 0);
if (activePrizes.length === 0) {
return [];
}
const maxDrawTimes = systemConfig?.max_draw_times || 1;
// 计算总权重与抽奖服务的weightedRandomDraw方法一致
const totalWeight = activePrizes.reduce((sum, prize) => sum + prize.probability, 0);
return activePrizes.map(prize => {
// 基础概率计算
let dynamicProbability = totalWeight > 0 ? (prize.probability / totalWeight) : 0;
let adjustedForDrawLimit = false;
// 结合抽奖次数上限调整概率
if (maxDrawTimes > 0) {
// 计算剩余奖品与最大抽奖次数的比例
const remainingDrawOpportunities = prize.remainingQuantity;
const maxPossibleDraws = maxDrawTimes;
// 如果剩余奖品数量远小于可能的抽奖总次数,降低显示概率
if (remainingDrawOpportunities < maxPossibleDraws * 0.1) {
dynamicProbability *= (remainingDrawOpportunities / (maxPossibleDraws * 0.1));
adjustedForDrawLimit = true;
}
// 如果接近抽奖次数上限,进一步调整概率显示
if (remainingDrawOpportunities <= maxPossibleDraws * 0.05) {
dynamicProbability *= 0.5; // 进一步降低显示概率
adjustedForDrawLimit = true;
}
}
const remainingRatio = prize.totalQuantity > 0 ? (prize.remainingQuantity / prize.totalQuantity) * 100 : 0;
return {
prize,
dynamicProbability,
remainingRatio,
adjustedForDrawLimit
};
}).sort((a, b) => a.prize.level - b.prize.level);
};
// 获取奖项图标
const getPrizeIcon = (level: number) => {
switch (level) {
case 1:
return <Trophy className="w-6 h-6" />;
case 2:
return <Award className="w-6 h-6" />;
case 3:
return <Star className="w-6 h-6" />;
default:
return <Gift className="w-6 h-6" />;
}
};
// 获取奖项等级文字
const getLevelText = (level: number) => {
const levelMap: { [key: number]: string } = {
1: '特等奖',
2: '一等奖',
3: '二等奖',
4: '三等奖',
5: '参与奖'
};
return levelMap[level] || `${level}等奖`;
};
// 获取概率颜色
const getProbabilityColor = (probability: number) => {
if (probability >= 0.3) return 'text-green-400';
if (probability >= 0.15) return 'text-yellow-400';
if (probability >= 0.05) return 'text-orange-400';
return 'text-red-400';
};
// 加载系统配置
const loadSystemConfig = async () => {
try {
const db = DatabaseService.getInstance();
const config = await db.getSystemConfig();
setSystemConfig(config);
} catch (error) {
console.error('加载系统配置失败:', error);
}
};
// 手动刷新概率
const handleRefresh = async () => {
setIsRefreshing(true);
try {
await loadPrizes();
await loadSystemConfig();
setLastUpdateTime(new Date());
console.log('🔄 手动刷新完成,当前概率数据:', probabilityData);
} catch (error) {
console.error('刷新失败:', error);
} finally {
setIsRefreshing(false);
}
};
// 调试信息
useEffect(() => {
console.log('🏠 首页概率数据更新:', probabilityData);
console.log('📊 最后更新时间:', lastUpdateTime);
}, [probabilityData, lastUpdateTime]);
// 初始化和监听数据变化
useEffect(() => {
const initializeData = async () => {
try {
setIsLoading(true);
setError(null);
await Promise.all([
loadPrizes(),
loadSystemConfig()
]);
} catch (err) {
console.error('初始化数据失败:', err);
setError('加载数据失败,请刷新页面重试');
} finally {
setIsLoading(false);
}
};
initializeData();
// 监听事件服务的奖品更新事件,实现智能无感刷新
const eventService = EventService.getInstance();
const unsubscribeEvent = eventService.subscribe(EVENT_TYPES.PRIZES_UPDATED, () => {
console.log('WebSocket事件奖品数据已更新');
setLastUpdateTime(new Date());
});
return () => {
unsubscribeEvent();
};
}, []); // 空依赖数组,避免重复执行
// 使用useMemo优化概率计算避免不必要的重新计算
const memoizedProbabilityData = React.useMemo(() => {
if (!prizes || prizes.length === 0) return [];
return calculateDynamicProbabilities(prizes);
}, [prizes, systemConfig?.max_draw_times]);
// 只在计算结果真正变化时更新状态
useEffect(() => {
const hasChanges = JSON.stringify(memoizedProbabilityData) !== JSON.stringify(probabilityData);
if (hasChanges) {
console.log('检测到概率数据变化,更新显示');
setProbabilityData(memoizedProbabilityData);
setLastUpdateTime(new Date());
}
}, [memoizedProbabilityData, probabilityData]);
// WebSocket连接状态监控
useEffect(() => {
const checkConnection = () => {
setIsWebSocketConnected(websocketService.isConnected());
};
// 初始检查
checkConnection();
// 定期检查连接状态
const interval = setInterval(checkConnection, 5000);
// 监听WebSocket事件
const eventService = EventService.getInstance();
const unsubscribePrizes = eventService.subscribe(EVENT_TYPES.PRIZES_UPDATED, () => {
setLastUpdateTime(new Date());
console.log('🔄 WebSocket实时更新: 奖项数据已更新');
});
const unsubscribeRecords = eventService.subscribe(EVENT_TYPES.RECORDS_UPDATED, () => {
setLastUpdateTime(new Date());
console.log('🔄 WebSocket实时更新: 抽奖记录已更新');
});
const unsubscribeSystemConfig = eventService.subscribe(EVENT_TYPES.SYSTEM_CONFIG_UPDATED, () => {
setLastUpdateTime(new Date());
console.log('🔄 WebSocket实时更新: 系统配置已更新');
});
const unsubscribeDataClear = eventService.subscribe(EVENT_TYPES.DATA_CLEARED, () => {
setLastUpdateTime(new Date());
console.log('🔄 WebSocket实时更新: 数据已清空');
});
const unsubscribeSystemReset = eventService.subscribe(EVENT_TYPES.SYSTEM_RESET, () => {
setLastUpdateTime(new Date());
console.log('🔄 WebSocket实时更新: 系统已重置');
});
const unsubscribeForceReset = eventService.subscribe(EVENT_TYPES.FORCE_RESET, () => {
setLastUpdateTime(new Date());
console.log('🔄 WebSocket实时更新: 强制重置完成');
});
return () => {
clearInterval(interval);
unsubscribePrizes();
unsubscribeRecords();
unsubscribeSystemConfig();
unsubscribeDataClear();
unsubscribeSystemReset();
unsubscribeForceReset();
};
}, []);
// 加载状态
if (isLoading) {
return (
<div className="min-h-screen bg-gradient-to-br from-gray-900 to-gray-800 p-6">
<div className="max-w-6xl mx-auto">
<LoadingSpinner size="lg" text="正在加载数据..." fullScreen />
</div>
</div>
);
}
// 错误状态
if (error) {
return (
<div className="min-h-screen bg-gradient-to-br from-gray-900 to-gray-800 p-6">
<div className="max-w-6xl mx-auto">
<Empty
title="加载失败"
description={error}
action={
<button
onClick={() => window.location.reload()}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors"
>
</button>
}
/>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gradient-to-br from-gray-900 to-gray-800 p-6">
<div className="max-w-6xl mx-auto">
{/* 标题区域 */}
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className="text-center mb-8"
>
<h1 className="text-4xl font-bold text-white mb-4">
-
</h1>
<div className="flex items-center justify-center gap-4 text-gray-300 mb-3">
<div className="flex items-center gap-2">
{isWebSocketConnected ? (
<Wifi className="w-4 h-4 text-green-400" />
) : (
<WifiOff className="w-4 h-4 text-red-400" />
)}
<span className={isWebSocketConnected ? 'text-green-400' : 'text-red-400'}>
{isWebSocketConnected ? '实时连接' : '连接断开'}
</span>
</div>
<span>: {lastUpdateTime.toLocaleTimeString()}</span>
<button
onClick={handleRefresh}
disabled={isRefreshing}
className="flex items-center gap-2 px-3 py-1 bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors disabled:opacity-50"
>
<RefreshCw className={`w-4 h-4 ${isRefreshing ? 'animate-spin' : ''}`} />
{isRefreshing ? '刷新中...' : '手动刷新'}
</button>
<Link
to="/confetti-demo"
className="flex items-center gap-2 px-3 py-1 bg-gradient-to-r from-yellow-500 to-orange-500 hover:from-yellow-600 hover:to-orange-600 text-white rounded-lg transition-all duration-300 transform hover:scale-105"
>
<Sparkles className="w-4 h-4" />
</Link>
</div>
{systemConfig && (
<div className="flex items-center justify-center gap-2 text-yellow-400 text-sm">
<Clock className="w-4 h-4" />
<span> {systemConfig.max_draw_times} </span>
</div>
)}
</motion.div>
{/* 概率展示区域 */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{probabilityData.map((item, index) => (
<motion.div
key={item.prize.id}
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: index * 0.1 }}
className="bg-white/10 backdrop-blur-md rounded-2xl p-6 border border-white/20 hover:border-white/40 transition-all duration-300"
>
{/* 奖项标题 */}
<div className="flex items-center gap-3 mb-4">
<div className="text-yellow-400">
{getPrizeIcon(item.prize.level)}
</div>
<div>
<h3 className="text-lg font-semibold text-white">
{getLevelText(item.prize.level)}
</h3>
<p className="text-sm text-gray-300">
{item.prize.name}
</p>
</div>
</div>
{/* 概率信息 */}
<div className="space-y-3">
{/* 动态概率 */}
<div className="flex justify-between items-center">
<span className="text-gray-300">:</span>
<div className="flex items-center gap-2">
<span className={`font-bold text-lg ${getProbabilityColor(item.dynamicProbability)}`}>
{(item.dynamicProbability * 100).toFixed(2)}%
</span>
{item.adjustedForDrawLimit && (
<span className="text-xs bg-orange-500 text-white px-2 py-1 rounded-full">
</span>
)}
</div>
</div>
{/* 原始概率 */}
<div className="flex justify-between items-center">
<span className="text-gray-400 text-sm">:</span>
<span className="text-gray-400 text-sm">
{(item.prize.probability * 1000).toFixed(1)}
</span>
</div>
{/* 剩余数量 */}
<div className="flex justify-between items-center">
<span className="text-gray-300">:</span>
<span className="text-white font-semibold">
{item.prize.remainingQuantity} / {item.prize.totalQuantity}
</span>
</div>
{/* 剩余比例进度条 */}
<div className="mt-3">
<div className="flex justify-between text-xs text-gray-400 mb-1">
<span></span>
<span>{item.remainingRatio.toFixed(1)}%</span>
</div>
<div className="bg-gray-700 rounded-full h-2">
<div
className="bg-gradient-to-r from-blue-500 to-purple-600 h-2 rounded-full transition-all duration-500"
style={{ width: `${item.remainingRatio}%` }}
/>
</div>
</div>
</div>
</motion.div>
))}
</div>
{/* 无奖项提示 */}
{probabilityData.length === 0 && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="py-12"
>
<Empty
title="暂无奖项"
description="当前没有可抽取的奖项,请联系管理员添加奖项"
action={
<button
onClick={handleRefresh}
disabled={isRefreshing}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors disabled:opacity-50"
>
<RefreshCw className={`w-4 h-4 ${isRefreshing ? 'animate-spin' : ''}`} />
{isRefreshing ? '刷新中...' : '刷新数据'}
</button>
}
/>
</motion.div>
)}
{/* 说明文字 */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.5 }}
className="mt-8 text-center text-gray-400 text-sm"
>
<p>* </p>
<p>* WebSocket实时推送数据更新</p>
<p>* </p>
</motion.div>
</div>
</div>
);
}

156
src/pages/Login.tsx Normal file
View File

@@ -0,0 +1,156 @@
import React, { useState } from 'react'
import { motion } from 'framer-motion'
import { Lock, Eye, EyeOff } from 'lucide-react'
import { useAppStore } from '../store'
import LotteryService from '../services/lotteryService'
import { AuthService } from '../services/authService'
const Login: React.FC = () => {
const [password, setPassword] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const { login } = useAppStore()
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
setError('')
try {
const lotteryService = LotteryService.getInstance()
const isValid = await lotteryService.validateLoginPassword(password)
if (isValid) {
// 使用AuthService进行登录生成并保存JWT token
const authService = AuthService.getInstance()
const success = authService.login(password)
if (success) {
// 更新应用状态
login(password)
console.log('登录成功JWT token已保存')
} else {
setError('登录失败,请稍后重试')
}
} else {
setError('首页登录密码错误,请重新输入')
}
} catch (err) {
console.error('Login error:', err)
setError('登录失败,请稍后重试')
} finally {
setLoading(false)
}
}
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleSubmit(e as React.FormEvent)
}
}
return (
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-gray-800 to-black flex items-center justify-center p-4">
{/* 背景装饰 */}
<div className="absolute inset-0 overflow-hidden">
<div className="absolute -top-40 -right-40 w-80 h-80 bg-blue-600 rounded-full mix-blend-multiply filter blur-xl opacity-30 animate-blob"></div>
<div className="absolute -bottom-40 -left-40 w-80 h-80 bg-purple-600 rounded-full mix-blend-multiply filter blur-xl opacity-30 animate-blob animation-delay-2000"></div>
<div className="absolute top-40 left-40 w-80 h-80 bg-indigo-600 rounded-full mix-blend-multiply filter blur-xl opacity-30 animate-blob animation-delay-4000"></div>
</div>
{/* 登录卡片 */}
<motion.div
initial={{ opacity: 0, y: 20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ duration: 0.5 }}
className="relative z-10 w-full max-w-md"
>
<div className="bg-gray-800/80 backdrop-blur-lg rounded-2xl shadow-2xl border border-gray-700/50 p-8">
{/* 标题 */}
<div className="text-center mb-8">
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ delay: 0.2, type: "spring", stiffness: 200 }}
className="w-16 h-16 bg-gradient-to-r from-blue-600 to-indigo-600 rounded-full flex items-center justify-center mx-auto mb-4"
>
<Lock className="w-8 h-8 text-white" />
</motion.div>
<h1 className="text-3xl font-bold text-gray-100 mb-2"> </h1>
</div>
{/* 登录表单 */}
<form onSubmit={handleSubmit} className="space-y-6">
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => {
setPassword(e.target.value)
setError('')
}}
onKeyPress={handleKeyPress}
placeholder="请输入密码"
className="w-full px-4 py-3 pr-12 bg-gray-700/50 border border-gray-600 rounded-lg text-gray-100 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-200"
disabled={loading}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-200 transition-colors"
disabled={loading}
>
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
</div>
{/* 错误提示 */}
{error && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="text-red-300 text-sm text-center bg-red-900/30 border border-red-700/50 rounded-lg p-3"
>
{error}
</motion.div>
)}
{/* 登录按钮 */}
<motion.button
type="submit"
disabled={loading}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
className="w-full py-3 bg-gradient-to-r from-blue-600 to-indigo-600 text-white font-semibold rounded-lg shadow-lg hover:shadow-xl transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center space-x-2"
>
{loading ? (
<>
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin"></div>
<span>...</span>
</>
) : (
<span></span>
)}
</motion.button>
</form>
{/* 提示信息 */}
<div className="mt-6 text-center">
</div>
</div>
</motion.div>
{/* 底部版权信息 */}
<div className="absolute bottom-4 left-1/2 transform -translate-x-1/2">
<p className="text-gray-500 text-sm">
© 2025 - -
</p>
</div>
</div>
)
}
export default Login

364
src/pages/Lottery.tsx Normal file
View File

@@ -0,0 +1,364 @@
import React, { useState, useRef, useCallback, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { Settings, FileText, LogOut, X } from 'lucide-react'
import PrizeDisplay from '../components/PrizeDisplay'
import ResultDisplay from '../components/ResultDisplay'
import ScreenKeyboard from '../components/ScreenKeyboard'
import LotteryAnimation from '../components/LotteryAnimation'
import ErrorBoundary from '../components/ErrorBoundary'
import { useAppStore } from '../store'
import { useToastContext } from '../contexts/ToastContext'
import LotteryService from '../services/lotteryService'
import EventService, { EVENT_TYPES } from '../services/eventService'
const Lottery: React.FC = () => {
const navigate = useNavigate()
const { logout, prizes, drawResult, isDrawing, resetDraw, startDraw, currentStudentId, setStudentId } = useAppStore()
const { showWarning } = useToastContext()
const [showCountdown, setShowCountdown] = useState(false)
const [showAnimation, setShowAnimation] = useState(false)
const [errorMessage, setErrorMessage] = useState('')
const [showPasswordModal, setShowPasswordModal] = useState(false)
const [password, setPassword] = useState('')
const [passwordError, setPasswordError] = useState('')
// 验证抽奖次数并开始倒计时
const handleDrawStart = async () => {
const lotteryService = LotteryService.getInstance()
const validation = await lotteryService.canStudentDraw(currentStudentId)
console.log('=== 抽奖验证调试 ===')
console.log('当前学号:', currentStudentId)
console.log('验证结果:', validation)
console.log('是否可以抽奖:', validation.canDraw)
console.log('提示消息:', validation.message)
if (!validation.canDraw) {
// 显示气泡提示
console.log('显示Toast提示:', validation.message)
// 根据不同的错误类型使用不同的标题
const title = validation.message.includes('格式不正确') ? '学号格式错误' :
validation.message.includes('抽奖上限') ? '抽奖次数限制' :
validation.message.includes('系统配置') ? '系统错误' : '抽奖验证'
showWarning(title, validation.message, 5000)
return false
}
console.log('🎲 验证通过,准备开始抽奖,学号:', currentStudentId)
console.log('🎯 抽奖前奖品状态:', prizes.map(p => ({ name: p.name, remaining: p.remainingQuantity })))
setErrorMessage('')
// 不在这里设置showAnimation应该在倒计时完成后设置
return true
}
// 防止重复调用的引用
const isStartingDrawRef = useRef(false)
const isCountdownCompleteRef = useRef(false)
// 倒计时完成处理
const handleCountdownComplete = useCallback(async () => {
console.log('⏰ ===== 倒计时完成处理开始 =====');
console.log('⏰ 当前时间戳:', Date.now());
console.log('⏰ 当前状态 - isDrawing:', isDrawing);
console.log('⏰ 当前学号:', currentStudentId);
console.log('⏰ 当前drawResult:', drawResult);
console.log('⏰ startDraw函数类型:', typeof startDraw);
// 防止重复调用倒计时完成处理
if (isCountdownCompleteRef.current) {
console.log('⏰ 倒计时完成处理正在进行中,跳过重复调用');
return;
}
isCountdownCompleteRef.current = true;
console.log('⏰ 倒计时完成,开始执行抽奖逻辑');
if (!currentStudentId) {
console.error('⏰ 学号为空,无法进行抽奖');
isCountdownCompleteRef.current = false;
return;
}
// 防止重复调用
if (isDrawing) {
console.log('⏰ 抽奖正在进行中,跳过重复调用');
isCountdownCompleteRef.current = false;
return;
}
console.log('⏰ 准备调用startDraw参数检查:');
console.log('⏰ - 学号:', currentStudentId);
console.log('⏰ - 学号类型:', typeof currentStudentId);
console.log('⏰ - 学号长度:', currentStudentId.length);
try {
console.log('⏰ 开始调用startDraw函数...');
const result = await startDraw(currentStudentId);
console.log('⏰ startDraw调用完成返回结果:', result);
console.log('⏰ 结果类型:', typeof result);
console.log('⏰ 结果详情:', JSON.stringify(result, null, 2));
console.log('⏰ 抽奖完成后的状态 - isDrawing:', isDrawing, 'drawResult:', drawResult);
} catch (error) {
console.error('⏰ 抽奖过程中发生错误:', error);
console.error('⏰ 错误堆栈:', error.stack);
} finally {
// 重置防重复调用标志
isCountdownCompleteRef.current = false;
}
console.log('⏰ ===== 倒计时完成处理结束 =====');
}, [currentStudentId, startDraw, isDrawing, drawResult]) // 修复移除showCountdown依赖避免重复触发
// 处理学号输入变化
const handleStudentIdChange = (studentId: string) => {
console.log('学号输入变化:', studentId)
// 学号变化时的处理逻辑,如果需要的话
}
// 处理倒计时开始
const handleCountdownStart = async () => {
console.log('🎯 倒计时开始')
console.log('当前状态 - showCountdown:', showCountdown, 'isDrawing:', isDrawing)
try {
const canStart = await handleDrawStart()
console.log('🎯 handleDrawStart返回结果:', canStart)
if (canStart) {
console.log('✅ 验证通过只设置showAnimation为true不设置showCountdown')
// 修复只设置showAnimation不设置showCountdown避免重复倒计时
setShowAnimation(true) // 显示抽奖动画,动画内部会处理倒计时
console.log('✅ 状态更新后 - showAnimation: true')
return true
} else {
console.log('❌ 验证失败,无法开始倒计时')
return false
}
} catch (error) {
console.error('❌ handleCountdownStart出错:', error)
return false
}
}
// 处理清空结果
const handleClearResult = () => {
console.log('清空抽奖结果')
resetDraw()
}
// 处理密码提交
const handlePasswordSubmit = async () => {
console.log('验证后台管理密码:', password);
try {
const lotteryService = LotteryService.getInstance();
const isValid = await lotteryService.validateAdminPassword(password);
if (isValid) {
setShowPasswordModal(false)
setPassword('')
setPasswordError('')
navigate('/admin')
} else {
setPasswordError('后台管理密码错误,请重试!')
setPassword('')
}
} catch (error) {
setPasswordError('验证失败,请重试!')
setPassword('')
}
}
// 合并抽奖结果监听和事件订阅减少useEffect数量
React.useEffect(() => {
// 监听抽奖结果变化,向测试页面发送结果
if (drawResult) {
try {
window.parent.postMessage({
type: 'LOTTERY_RESULT',
result: drawResult
}, '*');
} catch (error) {
// 静默处理错误
}
}
// 设置事件监听
const eventService = EventService.getInstance();
// 监听奖品数据更新
const unsubscribePrizes = eventService.subscribe(EVENT_TYPES.PRIZES_UPDATED, () => {
// 奖品数据通过store自动更新无需手动处理
});
// 监听抽奖记录更新
const unsubscribeRecords = eventService.subscribe(EVENT_TYPES.RECORDS_UPDATED, () => {
// 记录更新可能影响用户抽奖次数限制,但这里主要用于日志记录
});
return () => {
unsubscribePrizes();
unsubscribeRecords();
};
}, [drawResult]);
return (
<div className="min-h-screen bg-gradient-to-br from-gray-900 to-gray-800 relative">
{/* 顶部导航栏 */}
<div className="absolute top-4 right-4 flex gap-2 z-10">
<button
onClick={() => setShowPasswordModal(true)}
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"
>
<Settings size={18} />
</button>
<button
onClick={() => navigate('/records')}
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} />
</button>
<button
onClick={() => {
logout()
navigate('/login')
}}
className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg shadow-md hover:shadow-lg hover:bg-red-700 transition-all duration-200"
>
<LogOut size={18} />
退
</button>
</div>
{/* 主要内容区域 */}
<div className="flex h-screen">
{/* 左侧区域 - 奖项展示 */}
<div className="w-[22.22%] p-4">
<PrizeDisplay prizes={prizes || []} />
</div>
{/* 中间结果展示 - 增大到45% */}
<div className="w-[45%] h-full p-4">
<ErrorBoundary>
<ResultDisplay
drawResult={drawResult}
onRestart={resetDraw}
isDrawing={isDrawing}
showCountdown={showCountdown}
onCountdownComplete={handleCountdownComplete}
/>
</ErrorBoundary>
</div>
{/* 右侧抽奖区域 - 缩小到32.78% */}
<div className="w-[32.78%] h-full p-4">
<div className="bg-gray-800/50 backdrop-blur-md rounded-2xl p-8 h-full flex flex-col border border-gray-700/50">
<div className="flex-1 flex flex-col justify-center items-center">
<ScreenKeyboard
onStudentIdChange={handleStudentIdChange}
onDrawStart={handleDrawStart}
onCountdownStart={handleCountdownStart}
onClearResult={handleClearResult}
/>
{/* 错误提示 */}
{errorMessage && (
<div className="mt-4 p-4 bg-red-500/20 border border-red-500/30 rounded-lg">
<p className="text-red-300 text-center font-medium">{errorMessage}</p>
</div>
)}
</div>
</div>
</div>
</div>
{/* 抽奖动画浮窗 */}
<LotteryAnimation
isVisible={showAnimation}
onComplete={() => {
console.log('🎲 LotteryAnimation onComplete 被调用,隐藏动画框');
setShowAnimation(false);
// 抽奖完成后,触发奖项数据更新
console.log('🎲 抽奖完成,触发奖项数据更新');
const eventService = EventService.getInstance();
eventService.emit(EVENT_TYPES.PRIZES_UPDATED, {
updatedPrizes: null, // 触发重新加载
timestamp: Date.now()
});
}}
onCountdownComplete={handleCountdownComplete}
result={drawResult}
/>
{/* 密码输入弹窗 */}
{showPasswordModal && (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50">
<div className="bg-gray-800 rounded-2xl p-6 w-96 border border-gray-700 shadow-2xl">
<div className="flex items-center justify-between mb-4">
<h3 className="text-xl font-semibold text-white"></h3>
<button
onClick={() => {
setShowPasswordModal(false)
setPassword('')
setPasswordError('')
}}
className="text-gray-400 hover:text-white transition-colors"
>
<X size={20} />
</button>
</div>
<div className="mb-4">
<label className="block text-gray-300 text-sm font-medium mb-2">
</label>
<input
type="password"
value={password}
onChange={(e) => {
setPassword(e.target.value)
setPasswordError('')
}}
onKeyPress={(e) => {
if (e.key === 'Enter') {
handlePasswordSubmit()
}
}}
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="输入密码"
autoFocus
/>
{passwordError && (
<p className="text-red-400 text-sm mt-2">{passwordError}</p>
)}
</div>
<div className="flex gap-3">
<button
onClick={() => {
setShowPasswordModal(false)
setPassword('')
setPasswordError('')
}}
className="flex-1 px-4 py-2 bg-gray-600 text-gray-200 rounded-lg hover:bg-gray-500 transition-colors"
>
</button>
<button
onClick={handlePasswordSubmit}
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
></button>
</div>
</div>
</div>
)}
</div>
)
}
export default Lottery

120
src/pages/NetworkTest.tsx Normal file
View File

@@ -0,0 +1,120 @@
import React, { useEffect, useState } from 'react';
import { useAppStore } from '../store';
import PrizeDisplay from '../components/PrizeDisplay';
const NetworkTest: React.FC = () => {
const { prizes, loadPrizes } = useAppStore();
const [networkInfo, setNetworkInfo] = useState({
url: '',
hostname: '',
protocol: '',
port: '',
userAgent: ''
});
useEffect(() => {
// 获取网络信息
setNetworkInfo({
url: window.location.href,
hostname: window.location.hostname,
protocol: window.location.protocol,
port: window.location.port,
userAgent: navigator.userAgent
});
// 加载奖项数据
console.log('NetworkTest - 开始加载奖项数据');
loadPrizes();
}, [loadPrizes]);
return (
<div className="min-h-screen bg-gradient-to-br from-purple-900 via-blue-900 to-indigo-900 p-4">
<div className="max-w-6xl mx-auto">
<h1 className="text-3xl font-bold text-white text-center mb-8">
</h1>
{/* 网络信息显示 */}
<div className="bg-white/10 backdrop-blur-md rounded-2xl p-6 mb-8">
<h2 className="text-xl font-bold text-white mb-4"></h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
<div className="text-white/80">
<strong>URL:</strong> {networkInfo.url}
</div>
<div className="text-white/80">
<strong>:</strong> {networkInfo.hostname}
</div>
<div className="text-white/80">
<strong>:</strong> {networkInfo.protocol}
</div>
<div className="text-white/80">
<strong>:</strong> {networkInfo.port || '默认'}
</div>
<div className="text-white/80 col-span-full">
<strong>:</strong> {networkInfo.userAgent}
</div>
</div>
</div>
{/* 奖项数据状态 */}
<div className="bg-white/10 backdrop-blur-md rounded-2xl p-6 mb-8">
<h2 className="text-xl font-bold text-white mb-4"></h2>
<div className="text-white/80">
<p><strong>:</strong> {prizes.length}</p>
<p><strong>:</strong> {prizes.filter(p => p.is_active).length}</p>
<p><strong>:</strong> {prizes.length > 0 ? '✅ 已加载' : '❌ 未加载或为空'}</p>
</div>
{/* 详细奖项信息 */}
{prizes.length > 0 && (
<div className="mt-4">
<h3 className="text-lg font-semibold text-white mb-2">:</h3>
<div className="space-y-2 max-h-40 overflow-y-auto">
{prizes.map((prize, index) => (
<div key={prize.id} className="text-xs text-white/70 bg-white/5 p-2 rounded">
<span className="font-medium">{index + 1}. {prize.name}</span>
<span className="ml-2">({prize.is_active ? '活跃' : '非活跃'})</span>
<span className="ml-2">: {prize.remainingQuantity}/{prize.totalQuantity}</span>
</div>
))}
</div>
</div>
)}
</div>
{/* PrizeDisplay组件测试 */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
<div>
<h2 className="text-xl font-bold text-white mb-4">PrizeDisplay </h2>
<PrizeDisplay prizes={prizes} />
</div>
<div className="bg-white/10 backdrop-blur-md rounded-2xl p-6">
<h2 className="text-xl font-bold text-white mb-4"></h2>
<div className="text-sm text-white/80 space-y-2">
<p></p>
<p>:</p>
<ul className="list-disc list-inside ml-4 space-y-1">
<li>PrizeDisplay - </li>
<li>Store loadPrizes - </li>
<li>SharedDatabase getAllPrizes - </li>
</ul>
<div className="mt-4 p-3 bg-white/5 rounded">
<p className="font-medium">:</p>
<ol className="list-decimal list-inside ml-4 space-y-1 mt-2">
<li>在localhost:5173访问此页面</li>
<li>在192.168.1.42:5173访问此页面</li>
<li></li>
<li>PrizeDisplay组件是否正常显示</li>
</ol>
</div>
</div>
</div>
</div>
</div>
</div>
);
};
export default NetworkTest;

307
src/pages/Records.tsx Normal file
View File

@@ -0,0 +1,307 @@
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

View File

@@ -0,0 +1,105 @@
import React, { useEffect, useState } from 'react';
import { useAppStore } from '../store';
import PrizeDisplay from '../components/PrizeDisplay';
const SimpleNetworkTest: React.FC = () => {
const { prizes, loadPrizes } = useAppStore();
const [networkInfo, setNetworkInfo] = useState({
url: '',
host: '',
userAgent: '',
online: navigator.onLine
});
useEffect(() => {
// 获取网络信息
setNetworkInfo({
url: window.location.href,
host: window.location.host,
userAgent: navigator.userAgent,
online: navigator.onLine
});
// 强制输出调试信息到控制台
console.log('=== 网络测试页面 ===');
console.log('当前URL:', window.location.href);
console.log('当前主机:', window.location.host);
console.log('网络状态:', navigator.onLine ? '在线' : '离线');
console.log('用户代理:', navigator.userAgent);
// 加载奖项数据
console.log('开始加载奖项数据...');
loadPrizes().then(() => {
console.log('奖项数据加载完成');
}).catch(error => {
console.error('奖项数据加载失败:', error);
});
}, [loadPrizes]);
useEffect(() => {
console.log('奖项数据更新:', prizes);
console.log('活跃奖项数量:', prizes.filter(p => p.is_active).length);
}, [prizes]);
return (
<div className="min-h-screen bg-gradient-to-br from-purple-900 via-blue-900 to-indigo-900 p-4">
<div className="max-w-4xl mx-auto">
<h1 className="text-3xl font-bold text-white text-center mb-8">
</h1>
{/* 网络信息显示 */}
<div className="bg-white/10 backdrop-blur-md rounded-2xl p-6 mb-6">
<h2 className="text-xl font-bold text-white mb-4"></h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-white">
<div>
<strong>URL:</strong>
<div className="text-sm text-white/80 break-all">{networkInfo.url}</div>
</div>
<div>
<strong>:</strong>
<div className="text-sm text-white/80">{networkInfo.host}</div>
</div>
<div>
<strong>:</strong>
<div className={`text-sm ${networkInfo.online ? 'text-green-400' : 'text-red-400'}`}>
{networkInfo.online ? '在线' : '离线'}
</div>
</div>
<div>
<strong>:</strong>
<div className="text-sm text-white/80">
: {prizes.length}, : {prizes.filter(p => p.is_active).length}
</div>
</div>
</div>
</div>
{/* PrizeDisplay 组件测试 */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div>
<h2 className="text-xl font-bold text-white mb-4">PrizeDisplay </h2>
<PrizeDisplay prizes={prizes} />
</div>
<div className="bg-white/10 backdrop-blur-md rounded-2xl p-6">
<h2 className="text-xl font-bold text-white mb-4"></h2>
<div className="text-white text-sm space-y-2">
<div></div>
<div></div>
<div></div>
<div className="mt-4 p-3 bg-white/5 rounded">
<strong>:</strong>
<pre className="text-xs mt-2 overflow-auto max-h-40">
{JSON.stringify(prizes, null, 2)}
</pre>
</div>
</div>
</div>
</div>
</div>
</div>
);
};
export default SimpleNetworkTest;

453
src/services/apiService.ts Normal file
View File

@@ -0,0 +1,453 @@
// API服务 - 统一的后端数据接口
// 替换IndexedDB使用后端SQLite数据库确保数据一致性
import type { SystemConfig, PrizeConfig, Student } from '../database/database';
import type { LotteryRecord } from '../types';
// 动态获取API基础URL支持不同访问方式
const getApiBaseUrl = () => {
if (process.env.NODE_ENV === 'production') {
return '';
}
// 开发环境下使用相对路径通过Vite代理转发到后端
const apiUrl = '';
console.log('API Base URL:', apiUrl || 'relative path (via proxy)');
return apiUrl;
};
const API_BASE_URL = getApiBaseUrl();
class ApiService {
private static instance: ApiService;
private constructor() {
// 单例模式,构造函数私有
}
public static getInstance(): ApiService {
if (!ApiService.instance) {
ApiService.instance = new ApiService();
}
return ApiService.instance;
}
private activeRequests = new Map<string, AbortController>();
private async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
const url = `/api${endpoint}`;
console.log(`🌐 API请求: ${url}`);
// 只在请求方法不同时才取消之前的请求避免取消相同的GET请求
const requestKey = `${endpoint}_${options.method || 'GET'}`;
if (this.activeRequests.has(requestKey)) {
const prevController = this.activeRequests.get(requestKey);
if (prevController && (options.method && options.method !== 'GET')) {
prevController.abort();
console.log('🔄 取消之前的请求:', requestKey);
}
}
// 创建新的 AbortController
const controller = new AbortController();
this.activeRequests.set(requestKey, controller);
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10秒超时
const config: RequestInit = {
headers: {
'Content-Type': 'application/json',
...options.headers,
},
signal: controller.signal,
...options,
};
try {
// 检查网络连接状态
if (!navigator.onLine) {
clearTimeout(timeoutId);
this.activeRequests.delete(endpoint);
throw new Error('网络连接已断开,请检查网络设置');
}
const response = await fetch(url, config);
clearTimeout(timeoutId);
this.activeRequests.delete(requestKey);
if (!response.ok) {
let errorMessage = `HTTP ${response.status}`;
switch (response.status) {
case 404:
errorMessage = 'API接口不存在请检查后端服务器配置';
break;
case 500:
errorMessage = '服务器内部错误,请稍后重试';
break;
case 503:
errorMessage = '服务器暂时不可用,请稍后重试';
break;
case 0:
errorMessage = '无法连接到服务器,请检查后端服务器是否运行';
break;
default:
errorMessage = `请求失败 (${response.status}): ${response.statusText}`;
}
throw new Error(errorMessage);
}
const data = await response.json();
if (data && data.error) {
throw new Error(data.error);
}
console.log(`✅ API请求成功: ${endpoint}`);
return data;
} catch (error) {
clearTimeout(timeoutId);
this.activeRequests.delete(requestKey);
console.error(`❌ API请求失败 ${endpoint}:`, error);
// 处理取消的请求(不抛出错误)
if (error instanceof Error && error.name === 'AbortError') {
console.log('🚫 API请求已取消:', endpoint);
throw new Error('请求已取消');
}
// 处理网络错误
if (error instanceof TypeError && (error.message.includes('fetch') || error.message.includes('Failed to fetch'))) {
throw new Error('无法连接到服务器,请确认后端服务器正在运行');
}
// 处理ERR_NETWORK错误
if (error instanceof Error && error.message.includes('ERR_NETWORK')) {
throw new Error('网络连接错误,请检查网络设置');
}
// 处理超时错误
if (error instanceof Error && error.name === 'TimeoutError') {
throw new Error('请求超时,请检查网络连接或稍后重试');
}
// 处理CORS错误
if (error instanceof TypeError && error.message.includes('CORS')) {
throw new Error('跨域请求被阻止请检查服务器CORS配置');
}
// 确保错误对象有正确的结构
if (error instanceof Error) {
throw error;
} else {
throw new Error(String(error));
}
}
}
// 系统配置相关方法
public async getSystemConfig(): Promise<SystemConfig | null> {
const response = await this.request<{ success: boolean; data: SystemConfig | null }>('/system-config');
return response.data;
}
public async updateSystemConfig(config: Partial<SystemConfig>): Promise<boolean> {
const result = await this.request<{ success: boolean }>('/system-config', {
method: 'PUT',
body: JSON.stringify(config),
});
return result.success;
}
public async verifyAdminPassword(password: string): Promise<boolean> {
try {
const config = await this.getSystemConfig();
return config ? config.admin_password === password : false;
} catch (error) {
console.error('验证管理员密码失败:', error);
return false;
}
}
public async changeAdminPassword(newPassword: string): Promise<boolean> {
return this.updateSystemConfig({ admin_password: newPassword });
}
public async validateLoginPassword(password: string): Promise<boolean> {
try {
const config = await this.getSystemConfig();
return config ? config.login_password === password : false;
} catch (error) {
console.error('验证登录密码失败:', error);
return false;
}
}
public async updateLoginPassword(newPassword: string): Promise<boolean> {
return this.updateSystemConfig({ login_password: newPassword });
}
public async validateCurrentAdminPassword(password: string): Promise<boolean> {
try {
const config = await this.getSystemConfig();
return config ? config.admin_password === password : false;
} catch (error) {
console.error('验证管理员密码失败:', error);
return false;
}
}
public async updateAdminPassword(newPassword: string): Promise<boolean> {
return this.updateSystemConfig({ admin_password: newPassword });
}
// 奖项相关方法
public async getAllPrizes(): Promise<PrizeConfig[]> {
const response = await this.request<{ success: boolean; data: PrizeConfig[] }>('/prizes');
return response.data || [];
}
public async addPrize(prize: Omit<PrizeConfig, 'id' | 'created_at'>): Promise<boolean> {
const result = await this.request<{ success: boolean }>('/prizes', {
method: 'POST',
body: JSON.stringify(prize),
});
return result.success;
}
public async updatePrize(id: string, prize: Partial<PrizeConfig>): Promise<boolean> {
const result = await this.request<{ success: boolean }>(`/prizes/${id}`, {
method: 'PUT',
body: JSON.stringify(prize),
});
return result.success;
}
public async deletePrize(id: string): Promise<boolean> {
const result = await this.request<{ success: boolean }>(`/prizes/${id}`, {
method: 'DELETE',
});
return result.success;
}
// 学生相关方法
public async getStudent(studentId: string): Promise<Student | null> {
return this.request<Student | null>(`/students/${studentId}`);
}
public async updateStudentDrawCount(studentId: string, drawCount: number): Promise<boolean> {
const result = await this.request<{ success: boolean }>(`/students/${studentId}`, {
method: 'PUT',
body: JSON.stringify({ draw_count: drawCount }),
});
return result.success;
}
public async createOrUpdateStudent(studentId: string): Promise<boolean> {
// 通过更新抽奖次数来创建或更新学生记录
const student = await this.getStudent(studentId);
const currentDrawCount = student ? student.draw_count + 1 : 1;
return this.updateStudentDrawCount(studentId, currentDrawCount);
}
public async getStudentRecords(studentId: string): Promise<LotteryRecord[]> {
// 从抽奖记录中筛选特定学生的记录
const allRecords = await this.getAllRecords();
return allRecords.filter(record => record.student_id === studentId);
}
public async updatePrizeQuantity(prizeId: string, quantity: number): Promise<boolean> {
return this.updatePrize(prizeId, { remaining_quantity: quantity });
}
public async createLotteryRecord(studentId: string, prizeId: string, prizeName: string, prizeLevel: number): Promise<LotteryRecord> {
const record = {
student_id: studentId,
prize_id: prizeId,
prize_name: prizeName,
prize_level: prizeLevel,
is_synced: 1,
cache_data: null
};
const result = await this.request<LotteryRecord>('/records', {
method: 'POST',
body: JSON.stringify(record),
});
return result;
}
// 抽奖记录相关方法
public async getAllRecords(): Promise<LotteryRecord[]> {
const response = await this.request<{ success: boolean; data: LotteryRecord[] }>('/records');
return response.data || [];
}
public async getLotteryRecords(limit?: number): Promise<LotteryRecord[]> {
const params = limit ? `?limit=${limit}` : '';
return this.request<LotteryRecord[]>(`/records${params}`);
}
public async addRecord(record: Omit<LotteryRecord, 'id' | 'draw_time'>): Promise<boolean> {
const result = await this.request<{ success: boolean }>('/records', {
method: 'POST',
body: JSON.stringify(record),
});
return result.success;
}
public async clearAllRecords(): Promise<boolean> {
const result = await this.request<{ success: boolean }>('/records', {
method: 'DELETE',
});
return result.success;
}
public async clearAllLotteryRecords(): Promise<boolean> {
return this.clearAllRecords();
}
// 系统重置
public async resetSystem(): Promise<boolean> {
const result = await this.request<{ success: boolean }>('/reset', {
method: 'POST',
});
return result.success;
}
public async resetSystemCompletely(): Promise<boolean> {
return this.resetSystem();
}
// 密码重置方法
public async resetAdminPasswordToDefault(): Promise<boolean> {
return this.updateSystemConfig({ admin_password: '123456' });
}
public async resetLoginPasswordToDefault(): Promise<boolean> {
return this.updateSystemConfig({ login_password: '123456' });
}
// 数据库重置
public async forceResetDatabase(): Promise<boolean> {
const result = await this.request<{ success: boolean }>('/force-reset', {
method: 'POST',
});
return result.success;
}
// 模拟抽奖
public async simulateLottery(times: number): Promise<any> {
const result = await this.request<any>('/simulate', {
method: 'POST',
body: JSON.stringify({ times }),
});
return result;
}
// 抽奖逻辑
public async drawPrize(studentId: string): Promise<{ prize: PrizeConfig; record: LotteryRecord } | null> {
try {
// 获取学生信息
const student = await this.getStudent(studentId);
const config = await this.getSystemConfig();
if (!config) {
throw new Error('系统配置未找到');
}
// 检查抽奖次数限制
const currentDrawCount = student ? student.draw_count : 0;
if (currentDrawCount >= config.max_draw_times) {
throw new Error('已达到最大抽奖次数');
}
// 获取可用奖项
const prizes = await this.getAllPrizes();
const availablePrizes = prizes.filter(p => p.is_active && p.remaining_quantity > 0);
if (availablePrizes.length === 0) {
throw new Error('没有可用的奖项');
}
// 抽奖算法
const random = Math.random();
let cumulativeProbability = 0;
let selectedPrize: PrizeConfig | null = null;
for (const prize of availablePrizes) {
cumulativeProbability += prize.probability;
if (random <= cumulativeProbability) {
selectedPrize = prize;
break;
}
}
// 如果没有选中任何奖项,选择最后一个(通常是谢谢参与)
if (!selectedPrize) {
selectedPrize = availablePrizes[availablePrizes.length - 1];
}
// 更新奖项剩余数量
await this.updatePrize(selectedPrize.id, {
remaining_quantity: selectedPrize.remaining_quantity - 1
});
// 更新学生抽奖次数
await this.updateStudentDrawCount(studentId, currentDrawCount + 1);
// 创建抽奖记录
const record: Omit<LotteryRecord, 'id' | 'draw_time'> = {
student_id: studentId,
prize_id: selectedPrize.id,
prize_name: selectedPrize.prize_name,
prize_level: selectedPrize.prize_level,
is_synced: 1,
cache_data: null
};
await this.addRecord(record);
// 返回完整的记录对象
const fullRecord: LotteryRecord = {
...record,
id: this.generateId(),
draw_time: new Date().toISOString()
};
return {
prize: {
...selectedPrize,
remaining_quantity: selectedPrize.remaining_quantity - 1
},
record: fullRecord
};
} catch (error) {
console.error('抽奖失败:', error);
throw error;
}
}
private generateId(): string {
return Math.random().toString(36).substr(2, 9) + Date.now().toString(36);
}
// 清理方法,防止内存泄漏
public cleanup(): void {
// 取消所有活跃的请求
this.activeRequests.forEach((controller, requestKey) => {
controller.abort();
console.log('🧹 清理请求:', requestKey);
});
this.activeRequests.clear();
}
// 获取活跃请求数量(用于调试)
public getActiveRequestsCount(): number {
return this.activeRequests.size;
}
}
export default ApiService;
export { PrizeConfig, SystemConfig, LotteryRecord, Student };

268
src/services/authService.ts Normal file
View File

@@ -0,0 +1,268 @@
import { jwtDecode } from 'jwt-decode';
// JWT payload 接口
interface JWTPayload {
userId: string;
exp: number;
iat: number;
}
// 认证服务类
class AuthService {
private static instance: AuthService;
private readonly TOKEN_KEY = 'lottery_auth_token';
private readonly SECRET_KEY = 'lottery_secret_2025'; // 在生产环境中应该使用环境变量
private constructor() {}
public static getInstance(): AuthService {
if (!AuthService.instance) {
AuthService.instance = new AuthService();
}
return AuthService.instance;
}
// 简单的JWT生成客户端实现仅用于演示
private generateToken(userId: string): string {
const header = {
alg: 'HS256',
typ: 'JWT'
};
const payload = {
userId,
exp: Math.floor(Date.now() / 1000) + (24 * 60 * 60), // 24小时过期
iat: Math.floor(Date.now() / 1000)
};
// 简化的JWT实现生产环境应使用专业库
const encodedHeader = btoa(JSON.stringify(header));
const encodedPayload = btoa(JSON.stringify(payload));
const signature = btoa(`${encodedHeader}.${encodedPayload}.${this.SECRET_KEY}`);
return `${encodedHeader}.${encodedPayload}.${signature}`;
}
// 验证token
private verifyToken(token: string): JWTPayload | null {
try {
const parts = token.split('.');
if (parts.length !== 3) {
return null;
}
const payload = JSON.parse(atob(parts[1]));
// 检查过期时间
if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) {
console.log('Token已过期');
return null;
}
return payload as JWTPayload;
} catch (error) {
console.error('Token验证失败:', error);
return null;
}
}
// 登录并生成token
public async login(password: string): Promise<{ success: boolean; token?: string }> {
try {
// 调用API服务验证密码
const apiServiceModule = await import('./apiService');
const ApiService = apiServiceModule.default;
const apiService = ApiService.getInstance();
const isValid = await apiService.validateLoginPassword(password);
if (isValid) {
const userId = 'admin';
const token = this.generateToken(userId);
// 存储token到localStorage
localStorage.setItem(this.TOKEN_KEY, token);
console.log('登录成功token已生成并存储');
return { success: true, token };
}
console.log('登录失败:密码不正确');
return { success: false };
} catch (error) {
console.error('登录验证过程中发生错误:', error);
return { success: false };
}
}
// 检查是否已登录
public isAuthenticated(): boolean {
const token = this.getToken();
if (!token) {
return false;
}
const payload = this.verifyToken(token);
return payload !== null;
}
// 获取存储的token
public getToken(): string | null {
return localStorage.getItem(this.TOKEN_KEY);
}
// 获取用户信息
public getUserInfo(): { userId: string } | null {
const token = this.getToken();
if (!token) {
return null;
}
const payload = this.verifyToken(token);
if (!payload) {
return null;
}
return { userId: payload.userId };
}
// 登出
public logout(): void {
localStorage.removeItem(this.TOKEN_KEY);
console.log('已登出token已清除');
}
// 刷新token延长有效期
public refreshToken(): boolean {
const currentToken = this.getToken();
if (!currentToken) {
return false;
}
const payload = this.verifyToken(currentToken);
if (!payload) {
return false;
}
// 如果token还有效生成新的token
const newToken = this.generateToken(payload.userId);
localStorage.setItem(this.TOKEN_KEY, newToken);
console.log('Token已刷新');
return true;
}
// 检查token是否即将过期1小时内
public isTokenExpiringSoon(): boolean {
const token = this.getToken();
if (!token) {
return false;
}
const payload = this.verifyToken(token);
if (!payload) {
return true; // token无效视为即将过期
}
const oneHourFromNow = Math.floor(Date.now() / 1000) + (60 * 60);
return payload.exp < oneHourFromNow;
}
// 自动刷新token如果即将过期
public autoRefreshToken(): void {
if (this.isTokenExpiringSoon() && this.isAuthenticated()) {
this.refreshToken();
}
}
// 处理token过期的回调
private onTokenExpired?: () => void;
private tokenMonitorInterval?: NodeJS.Timeout;
// 设置token过期回调
public setTokenExpiredCallback(callback: () => void): void {
this.onTokenExpired = callback;
}
// 检查token并处理过期
public checkTokenAndHandleExpiry(): boolean {
const token = this.getToken();
if (!token) {
this.handleTokenExpired();
return false;
}
const payload = this.verifyToken(token);
if (!payload) {
this.handleTokenExpired();
return false;
}
// 如果token即将过期尝试刷新
if (this.isTokenExpiringSoon()) {
const refreshed = this.refreshToken();
if (!refreshed) {
this.handleTokenExpired();
return false;
}
}
return true;
}
// 处理token过期
private handleTokenExpired(): void {
console.log('Token已过期清除认证状态');
this.logout();
if (this.onTokenExpired) {
this.onTokenExpired();
}
}
// 启动token监控
public startTokenMonitoring(): void {
// 清理之前的定时器
this.stopTokenMonitoring();
// 每分钟检查一次token状态
this.tokenMonitorInterval = setInterval(() => {
if (this.getToken()) {
this.checkTokenAndHandleExpiry();
}
}, 60 * 1000); // 1分钟
}
// 停止token监控
public stopTokenMonitoring(): void {
if (this.tokenMonitorInterval) {
clearInterval(this.tokenMonitorInterval);
this.tokenMonitorInterval = undefined;
}
}
// 获取token剩余有效时间
public getTokenRemainingTime(): number {
const token = this.getToken();
if (!token) {
return 0;
}
const payload = this.verifyToken(token);
if (!payload) {
return 0;
}
const now = Math.floor(Date.now() / 1000);
return Math.max(0, payload.exp - now);
}
// 清理方法,防止内存泄漏
public cleanup(): void {
this.stopTokenMonitoring();
this.onTokenExpired = undefined;
console.log('🧹 AuthService 已清理');
}
}
export { AuthService };
export default AuthService;

View File

@@ -0,0 +1,105 @@
// 事件服务 - 用于全站无感刷新
class EventService {
private static instance: EventService;
private eventTarget: EventTarget;
private listeners: Map<string, Set<EventListener>>;
private constructor() {
this.eventTarget = new EventTarget();
this.listeners = new Map();
}
public static getInstance(): EventService {
if (!EventService.instance) {
EventService.instance = new EventService();
}
return EventService.instance;
}
// 订阅事件
public subscribe(eventType: string, callback: EventListener): () => void {
if (!this.listeners.has(eventType)) {
this.listeners.set(eventType, new Set());
}
this.listeners.get(eventType)!.add(callback);
this.eventTarget.addEventListener(eventType, callback);
// 返回取消订阅函数
return () => {
this.unsubscribe(eventType, callback);
};
}
// 取消订阅
public unsubscribe(eventType: string, callback: EventListener): void {
const listeners = this.listeners.get(eventType);
if (listeners) {
listeners.delete(callback);
if (listeners.size === 0) {
this.listeners.delete(eventType);
}
}
this.eventTarget.removeEventListener(eventType, callback);
}
// 发布事件
public emit(eventType: string, data?: unknown): void {
const event = new CustomEvent(eventType, { detail: data });
this.eventTarget.dispatchEvent(event);
}
// 清理所有监听器
public cleanup(): void {
for (const [eventType, listeners] of this.listeners) {
for (const listener of listeners) {
this.eventTarget.removeEventListener(eventType, listener);
}
}
this.listeners.clear();
}
}
// 事件类型常量
export const EVENT_TYPES = {
PRIZE_DRAWN: 'prize_drawn', // 奖品被抽中
PRIZES_UPDATED: 'prizes_updated', // 奖品数据更新
RECORDS_UPDATED: 'records_updated', // 抽奖记录更新
PROBABILITY_CHANGED: 'probability_changed', // 概率发生变化
STUDENT_DRAWN: 'student_drawn', // 学生完成抽奖
STUDENT_UPDATED: 'student_updated', // 学生数据更新
SYSTEM_CONFIG_UPDATED: 'system_config_updated', // 系统配置更新
DATA_CLEARED: 'data_cleared', // 数据清空
SYSTEM_RESET: 'system_reset', // 系统重置
FORCE_RESET: 'force_reset' // 强制重置
} as const;
export type EventType = typeof EVENT_TYPES[keyof typeof EVENT_TYPES];
// 事件数据接口
export interface PrizeDrawnEventData {
studentId: string;
prizeId: string;
prizeName: string;
prizeLevel: number;
remainingQuantity: number;
timestamp: number;
}
export interface PrizesUpdatedEventData {
updatedPrizes: Array<{
id: string;
remainingQuantity: number;
totalQuantity: number;
}>;
timestamp: number;
}
export interface ProbabilityChangedEventData {
prizeId: string;
oldProbability: number;
newProbability: number;
timestamp: number;
}
export default EventService;

View File

@@ -0,0 +1,279 @@
import ApiService from '../services/apiService';
import type { PrizeConfig } from '../database/database';
import EventService, { EVENT_TYPES, type PrizeDrawnEventData } from './eventService';
import type { LotteryRecord } from '../types';
export interface DrawResult {
success: boolean;
prize?: {
id: string;
name: string;
level: number;
};
message: string;
remaining?: number;
}
export interface LotteryStats {
totalDraws: number;
prizeDistribution: Record<number, number>;
remainingPrizes: PrizeConfig[];
}
class LotteryService {
private api: ApiService;
private static instance: LotteryService;
private constructor() {
this.api = ApiService.getInstance();
}
public static getInstance(): LotteryService {
if (!LotteryService.instance) {
LotteryService.instance = new LotteryService();
}
return LotteryService.instance;
}
// 验证学号格式
public validateStudentId(studentId: string): boolean {
// 12位数字学号验证
const regex = /^\d{12}$/;
return regex.test(studentId);
}
// 检查学生是否可以抽奖
public async canStudentDraw(studentId: string): Promise<{ canDraw: boolean; message: string }> {
console.log('=== canStudentDraw 验证开始 ===');
console.log('输入学号:', studentId);
console.log('学号长度:', studentId.length);
console.log('学号类型:', typeof studentId);
// 验证学号格式
const isValidFormat = this.validateStudentId(studentId);
console.log('学号格式验证结果:', isValidFormat);
if (!isValidFormat) {
console.log('学号格式验证失败,返回格式错误提示');
return { canDraw: false, message: '学号格式不正确请输入12位数字学号' };
}
const systemConfig = await this.api.getSystemConfig();
console.log('系统配置:', systemConfig);
if (!systemConfig) {
console.log('系统配置获取失败');
return { canDraw: false, message: '系统配置错误,请联系管理员' };
}
const student = await this.api.getStudent(studentId);
console.log('学生信息:', student);
console.log('最大抽奖次数:', systemConfig.max_draw_times);
if (student && student.draw_count >= systemConfig.max_draw_times) {
console.log('抽奖次数验证失败,当前次数:', student.draw_count, '最大次数:', systemConfig.max_draw_times);
return { canDraw: false, message: `已达到抽奖上限!每人最多只能抽奖${systemConfig.max_draw_times}` };
}
console.log('所有验证通过,可以开始抽奖');
return { canDraw: true, message: '验证通过,可以开始抽奖' };
}
// 执行抽奖
public async drawLottery(studentId: string): Promise<DrawResult> {
// 验证是否可以抽奖
const validation = await this.canStudentDraw(studentId);
if (!validation.canDraw) {
return {
success: false,
message: validation.message
};
}
try {
// 获取所有奖项,只考虑启用且有剩余数量的奖项
const prizes = await this.api.getAllPrizes();
const availablePrizes = prizes.filter(p => p.is_active && p.remaining_quantity > 0);
if (availablePrizes.length === 0) {
return {
success: false,
message: '暂无可抽取的奖品'
};
}
// 执行加权随机抽奖
const selectedPrize = this.weightedRandomDraw(availablePrizes);
if (!selectedPrize) {
return {
success: false,
message: '抽奖失败,请重试'
};
}
// 更新数据库 - 确保原子性操作,防止重复计数
const newRemainingQuantity = selectedPrize.remaining_quantity - 1;
// 使用 await 确保所有数据库操作按顺序执行
// 先更新奖品数量
await this.api.updatePrizeQuantity(selectedPrize.id, newRemainingQuantity);
// 创建抽奖记录
await this.api.createLotteryRecord(
studentId,
selectedPrize.id,
selectedPrize.prize_name,
selectedPrize.prize_level
);
// 最后更新学生抽奖次数(只在成功抽奖后更新)
await this.api.createOrUpdateStudent(studentId);
// 触发奖品抽中事件,用于全站无感刷新
const eventService = EventService.getInstance();
const eventData: PrizeDrawnEventData = {
studentId,
prizeId: selectedPrize.id,
prizeName: selectedPrize.prize_name,
prizeLevel: selectedPrize.prize_level,
remainingQuantity: newRemainingQuantity,
timestamp: Date.now()
};
eventService.emit(EVENT_TYPES.PRIZE_DRAWN, eventData);
eventService.emit(EVENT_TYPES.PRIZES_UPDATED, {
updatedPrizes: [{
id: selectedPrize.id,
remainingQuantity: newRemainingQuantity,
totalQuantity: selectedPrize.total_quantity
}],
timestamp: Date.now()
});
console.log('=== 构建抽奖返回结果 ===');
console.log('selectedPrize:', selectedPrize);
const result = {
success: true,
prize: {
id: selectedPrize.id,
name: selectedPrize.prize_name,
level: selectedPrize.prize_level
},
message: '恭喜中奖!',
remaining: newRemainingQuantity,
studentId: studentId,
timestamp: Date.now(),
formattedStudentId: this.formatStudentId(studentId)
};
console.log('返回结果:', JSON.stringify(result, null, 2));
return result;
} catch (error) {
console.error('Draw lottery error:', error);
return {
success: false,
message: '抽奖过程中发生错误,请重试'
};
}
}
// 加权随机抽奖算法
private weightedRandomDraw(prizes: PrizeConfig[]): PrizeConfig | null {
// 只考虑启用且有剩余数量的奖项
const enabledPrizes = prizes.filter(p => p.is_active && p.remaining_quantity > 0);
if (enabledPrizes.length === 0) {
return null;
}
// 计算启用奖项的总权重
const totalWeight = enabledPrizes.reduce((sum, prize) => sum + prize.probability, 0);
if (totalWeight === 0) {
return null;
}
// 生成随机数
const random = Math.random() * totalWeight;
// 根据权重选择奖品
let currentWeight = 0;
for (const prize of enabledPrizes) {
currentWeight += prize.probability;
if (random <= currentWeight) {
return prize;
}
}
// 兜底返回最后一个启用的奖品
return enabledPrizes[enabledPrizes.length - 1];
}
// 获取抽奖统计信息
public async getLotteryStats(): Promise<LotteryStats> {
const records = await this.api.getLotteryRecords(1000);
const prizes = await this.api.getAllPrizes();
const prizeDistribution: Record<number, number> = {};
records.forEach(record => {
prizeDistribution[record.prize_level] = (prizeDistribution[record.prize_level] || 0) + 1;
});
return {
totalDraws: records.length,
prizeDistribution,
remainingPrizes: prizes.filter(p => p.remaining_quantity > 0)
};
}
// 获取学生抽奖记录
public async getStudentHistory(studentId: string): Promise<LotteryRecord[]> {
return this.api.getStudentRecords(studentId);
}
// 验证首页登录密码
public async validateLoginPassword(password: string): Promise<boolean> {
const config = await this.api.getSystemConfig();
return config ? config.login_password === password : false;
}
// 验证后台管理密码
public async validateAdminPassword(password: string): Promise<boolean> {
const config = await this.api.getSystemConfig();
return config ? config.admin_password === password : false;
}
// 获取隐藏位置配置
public async getHidePositions(): Promise<number[]> {
const config = await this.api.getSystemConfig();
if (!config || !config.hide_positions) {
return [3, 4, 5, 6]; // 默认隐藏位置
}
try {
return config.hide_positions.split(',').map(pos => parseInt(pos.trim()));
} catch {
return [3, 4, 5, 6];
}
}
// 格式化学号显示
public formatStudentId(studentId: string): string {
// 不隐藏任何数字,每四位用空格分隔
return studentId.replace(/(\d{4})(?=\d)/g, '$1 ');
}
// 清理方法,防止内存泄漏
public cleanup(): void {
// 清理API服务
this.api.cleanup();
console.log('🧹 LotteryService 已清理');
}
}
export default LotteryService;

View File

@@ -0,0 +1,159 @@
export interface NetworkStatus {
isOnline: boolean;
lastOnlineTime?: Date;
lastOfflineTime?: Date;
lastCheck: Date;
}
export type NetworkStatusCallback = (status: NetworkStatus) => void;
export class NetworkService {
private static instance: NetworkService;
private status: NetworkStatus;
private callbacks: Set<NetworkStatusCallback> = new Set();
private checkInterval?: NodeJS.Timeout;
private constructor() {
const now = new Date();
this.status = {
isOnline: navigator.onLine,
lastOnlineTime: navigator.onLine ? now : undefined,
lastOfflineTime: !navigator.onLine ? now : undefined,
lastCheck: now
};
this.initializeListeners();
this.startPeriodicCheck();
}
public static getInstance(): NetworkService {
if (!NetworkService.instance) {
NetworkService.instance = new NetworkService();
}
return NetworkService.instance;
}
private initializeListeners(): void {
window.addEventListener('online', this.handleOnline.bind(this));
window.addEventListener('offline', this.handleOffline.bind(this));
}
private handleOnline(): void {
console.log('网络连接已恢复');
this.updateStatus(true);
}
private handleOffline(): void {
console.log('网络连接已断开');
this.updateStatus(false);
}
private updateStatus(isOnline: boolean): void {
const now = new Date();
this.status = {
...this.status,
isOnline,
lastOnlineTime: isOnline ? now : this.status.lastOnlineTime,
lastOfflineTime: !isOnline ? now : this.status.lastOfflineTime,
lastCheck: now
};
// 通知所有监听器
this.callbacks.forEach(callback => {
try {
callback(this.status);
} catch (error) {
console.error('网络状态回调执行错误:', error);
}
});
}
private startPeriodicCheck(): void {
// 每30秒检查一次网络连接
this.checkInterval = setInterval(() => {
this.checkNetworkConnectivity();
}, 30000);
}
private async checkNetworkConnectivity(): Promise<void> {
try {
// 使用浏览器的在线状态作为主要判断依据
const browserOnline = navigator.onLine;
if (!browserOnline) {
if (this.status.isOnline) {
console.log('浏览器检测到网络断开');
this.updateStatus(false);
}
return;
}
// 如果浏览器认为在线,直接信任浏览器的判断
// 避免额外的网络请求导致不必要的错误日志
if (browserOnline !== this.status.isOnline) {
this.updateStatus(browserOnline);
} else {
// 即使状态没变,也要更新检查时间
this.status.lastCheck = new Date();
}
} catch (error) {
// 检测过程出错,保持当前状态
console.warn('网络状态检测出错:', error);
}
}
public getStatus(): NetworkStatus {
return { ...this.status };
}
public isOnline(): boolean {
return this.status.isOnline;
}
public subscribe(callback: NetworkStatusCallback): () => void {
this.callbacks.add(callback);
// 立即调用一次回调,提供当前状态
callback(this.status);
// 返回取消订阅函数
return () => {
this.callbacks.delete(callback);
};
}
public async waitForOnline(timeout: number = 30000): Promise<boolean> {
if (this.status.isOnline) {
return true;
}
return new Promise((resolve) => {
const timeoutId = setTimeout(() => {
unsubscribe();
resolve(false);
}, timeout);
const unsubscribe = this.subscribe((status) => {
if (status.isOnline) {
clearTimeout(timeoutId);
unsubscribe();
resolve(true);
}
});
});
}
public destroy(): void {
window.removeEventListener('online', this.handleOnline.bind(this));
window.removeEventListener('offline', this.handleOffline.bind(this));
if (this.checkInterval) {
clearInterval(this.checkInterval);
}
this.callbacks.clear();
}
}
export const networkService = NetworkService.getInstance();
export default NetworkService;

View File

@@ -0,0 +1,393 @@
import { io, Socket } from 'socket.io-client';
import { useAppStore } from '../store';
import EventService, { EVENT_TYPES } from './eventService';
class WebSocketService {
private socket: Socket | null = null;
private reconnectAttempts = 0;
private maxReconnectAttempts = 5;
private reconnectDelay = 1000;
private isConnecting = false;
private updateDebounceTimer: NodeJS.Timeout | null = null;
private readonly debounceDelay = 300; // 防抖延迟300ms
private networkStatusTimer: NodeJS.Timeout | null = null;
private lastPingTime = 0;
private connectionQuality: 'good' | 'poor' | 'disconnected' = 'disconnected';
private eventService: EventService;
constructor() {
this.eventService = EventService.getInstance();
this.connect();
}
private connect() {
if (this.isConnecting || this.socket?.connected) {
return;
}
this.isConnecting = true;
// 获取当前主机地址和端口配置
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const host = window.location.hostname === 'localhost' ? 'localhost' : window.location.hostname;
const port = process.env.NODE_ENV === 'production' ? window.location.port || '80' : '3001';
const socketUrl = process.env.NODE_ENV === 'production'
? `${protocol}//${host}${port !== '80' && port !== '443' ? ':' + port : ''}`
: `${protocol}//${host}:3001`;
console.log('🔌 连接WebSocket服务器:', socketUrl, '(环境:', process.env.NODE_ENV, ')');
this.socket = io(socketUrl, {
transports: ['websocket', 'polling'],
timeout: 10000,
reconnection: true,
reconnectionAttempts: this.maxReconnectAttempts,
reconnectionDelay: this.reconnectDelay,
});
this.setupEventListeners();
}
private setupEventListeners() {
if (!this.socket) return;
// 连接成功
this.socket.on('connect', () => {
console.log('✅ WebSocket连接成功:', this.socket?.id);
this.isConnecting = false;
this.reconnectAttempts = 0;
this.connectionQuality = 'good';
// 开始网络状态监控
this.startNetworkMonitoring();
// 延迟请求最新数据,避免连接刚建立时的不稳定
setTimeout(() => {
this.requestDataUpdate();
}, 500);
});
// 连接失败
this.socket.on('connect_error', (error) => {
console.error('❌ WebSocket连接失败:', error.message || error);
this.isConnecting = false;
this.connectionQuality = 'disconnected';
// 根据错误类型决定是否重连
if (error.message?.includes('ECONNREFUSED') || error.message?.includes('timeout')) {
console.log('🔄 服务器连接被拒绝或超时,将尝试重连');
this.handleReconnect();
} else {
console.log('⚠️ 连接错误,暂停重连尝试');
}
});
// 断开连接
this.socket.on('disconnect', (reason) => {
console.log('🔌 WebSocket断开连接:', reason);
this.isConnecting = false;
this.connectionQuality = 'disconnected';
// 停止网络监控
this.stopNetworkMonitoring();
// 根据断开原因决定重连策略
if (reason === 'io server disconnect') {
// 服务器主动断开,需要重新连接
console.log('🔄 服务器主动断开,准备重连');
this.handleReconnect();
} else if (reason === 'transport close' || reason === 'transport error') {
// 传输层错误,尝试重连
console.log('🔄 传输层错误,准备重连');
this.handleReconnect();
} else {
console.log(' 客户端主动断开或其他原因,不自动重连');
}
});
// 监听奖项数据更新(带防抖)
this.socket.on('prizes_updated', (data) => {
// 确保传递正确的数据格式
const prizes = data.updatedPrizes || data;
this.debouncedUpdate('prizes', prizes);
});
// 监听单个奖项更新(优化版)
this.socket.on('prize_updated', (data) => {
// 发射奖项更新事件
this.eventService.emit(EVENT_TYPES.PRIZES_UPDATED, {
updatedPrizes: [data.prize],
timestamp: Date.now()
});
});
// 监听抽奖记录更新
this.socket.on('records_updated', (data) => {
// 发射记录更新事件
this.eventService.emit(EVENT_TYPES.RECORDS_UPDATED, {
records: data,
timestamp: Date.now()
});
});
// 监听新增抽奖记录(优化版)
this.socket.on('new_record', (data) => {
console.log('收到新增抽奖记录:', data);
// 发射新记录事件
this.eventService.emit(EVENT_TYPES.RECORDS_UPDATED, {
records: [data.record],
timestamp: Date.now()
});
// 触发记录列表刷新
this.requestDataUpdate();
});
// 监听系统配置更新
this.socket.on('system_config_updated', (config) => {
console.log('收到系统配置更新:', config);
// 发射系统配置更新事件
this.eventService.emit(EVENT_TYPES.SYSTEM_CONFIG_UPDATED, {
config: config,
timestamp: Date.now()
});
// 请求数据更新
this.requestDataUpdate();
});
// 监听学生数据更新
this.socket.on('student_updated', (data) => {
console.log('收到学生数据更新:', data);
// 发射学生数据更新事件
this.eventService.emit(EVENT_TYPES.STUDENT_UPDATED, {
student: data,
timestamp: Date.now()
});
});
// 监听数据清空事件
this.socket.on('data_cleared', (data) => {
console.log('收到数据清空事件:', data);
// 发射数据清空事件
this.eventService.emit(EVENT_TYPES.DATA_CLEARED, {
message: data.message || '数据已清空',
timestamp: Date.now()
});
// 请求数据更新
this.requestDataUpdate();
});
// 监听系统重置事件
this.socket.on('system_reset', (data) => {
console.log('收到系统重置事件:', data);
// 发射系统重置事件
this.eventService.emit(EVENT_TYPES.SYSTEM_RESET, {
message: data.message || '系统已重置',
timestamp: Date.now()
});
// 重新请求最新数据
this.requestDataUpdate();
});
// 监听强制重置事件
this.socket.on('force_reset', (data) => {
console.log('收到强制重置事件:', data);
// 发射强制重置事件
this.eventService.emit(EVENT_TYPES.FORCE_RESET, {
message: data.message || '系统已强制重置',
timestamp: Date.now()
});
// 重新请求所有数据
this.requestDataUpdate();
});
}
// 防抖更新数据
private debouncedUpdate(type: 'prizes' | 'records', data: any) {
// 清除之前的定时器
if (this.updateDebounceTimer) {
clearTimeout(this.updateDebounceTimer);
}
// 设置新的防抖定时器
this.updateDebounceTimer = setTimeout(() => {
console.log(`收到${type === 'prizes' ? '奖项' : '记录'}数据更新:`, data);
if (type === 'prizes') {
// 发射奖项更新事件
this.eventService.emit(EVENT_TYPES.PRIZES_UPDATED, {
updatedPrizes: data,
timestamp: Date.now()
});
} else {
// 发射记录更新事件
this.eventService.emit(EVENT_TYPES.RECORDS_UPDATED, {
records: data,
timestamp: Date.now()
});
}
this.updateDebounceTimer = null;
}, this.debounceDelay);
}
private handleReconnect() {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error('WebSocket重连次数已达上限');
return;
}
this.reconnectAttempts++;
const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1);
console.log(`WebSocket将在${delay}ms后尝试第${this.reconnectAttempts}次重连`);
setTimeout(() => {
if (!this.socket?.connected) {
this.connect();
}
}, delay);
}
// 开始网络状态监控
private startNetworkMonitoring() {
if (this.networkStatusTimer) {
clearInterval(this.networkStatusTimer);
}
this.networkStatusTimer = setInterval(() => {
if (this.socket?.connected) {
const now = Date.now();
this.lastPingTime = now;
// 发送ping测试连接质量
this.socket.emit('ping', now);
// 设置超时检测
setTimeout(() => {
const responseTime = Date.now() - this.lastPingTime;
if (responseTime > 3000) {
this.connectionQuality = 'poor';
console.warn('网络连接质量较差,响应时间:', responseTime + 'ms');
} else if (responseTime > 1000) {
this.connectionQuality = 'poor';
} else {
this.connectionQuality = 'good';
}
}, 1000);
} else {
this.connectionQuality = 'disconnected';
}
}, 10000); // 每10秒检测一次
// 监听pong响应
this.socket?.on('pong', (timestamp) => {
const responseTime = Date.now() - timestamp;
if (responseTime < 500) {
this.connectionQuality = 'good';
} else if (responseTime < 1500) {
this.connectionQuality = 'poor';
}
});
}
// 停止网络监控
private stopNetworkMonitoring() {
if (this.networkStatusTimer) {
clearInterval(this.networkStatusTimer);
this.networkStatusTimer = null;
}
}
// 获取连接质量
public getConnectionQuality(): 'good' | 'poor' | 'disconnected' {
return this.connectionQuality;
}
// 请求数据更新
public requestDataUpdate() {
if (this.socket?.connected) {
this.socket.emit('request_data_update');
}
}
// 检查连接状态
public isConnected(): boolean {
return this.socket?.connected || false;
}
// 手动重连
public reconnect() {
if (this.socket) {
this.socket.disconnect();
this.socket = null;
}
this.reconnectAttempts = 0;
this.connect();
}
// 断开连接
public disconnect() {
// 停止网络监控
this.stopNetworkMonitoring();
// 清理防抖定时器
if (this.updateDebounceTimer) {
clearTimeout(this.updateDebounceTimer);
this.updateDebounceTimer = null;
}
if (this.socket) {
this.socket.disconnect();
this.socket = null;
}
this.connectionQuality = 'disconnected';
}
// 清理所有资源
public cleanup() {
console.log('WebSocketService: 开始清理资源');
// 停止网络监控
this.stopNetworkMonitoring();
// 清理防抖定时器
if (this.updateDebounceTimer) {
clearTimeout(this.updateDebounceTimer);
this.updateDebounceTimer = null;
}
// 断开WebSocket连接
if (this.socket) {
this.socket.removeAllListeners();
this.socket.disconnect();
this.socket = null;
}
// 重置状态
this.reconnectAttempts = 0;
this.isConnecting = false;
this.connectionQuality = 'disconnected';
this.lastPingTime = 0;
console.log('WebSocketService: 资源清理完成');
}
}
// 创建单例实例
export const websocketService = new WebSocketService();
export default websocketService;
// 导出类型
export type { WebSocketService };

404
src/store/index.ts Normal file
View File

@@ -0,0 +1,404 @@
import { create } from 'zustand';
import LotteryService from '../services/lotteryService';
import ApiService from '../services/apiService';
import AuthService from '../services/authService';
import type { PrizeConfig, SystemConfig } from '../database/database';
import EventService, { EVENT_TYPES, type PrizesUpdatedEventData } from '../services/eventService';
// 定义概率信息接口
interface ProbabilityInfo {
prize: Prize;
dynamicProbability: number;
remainingRatio: number;
adjustedForDrawLimit?: boolean;
}
// 奖项配置接口
export interface Prize {
id: string;
name: string;
level: number;
totalQuantity: number;
remainingQuantity: number;
probability: number;
is_active: boolean;
}
// 抽奖结果接口
export interface DrawResult {
success: boolean;
prize?: {
id: string;
name: string;
level: number;
prize_name?: string; // 兼容数据库字段
};
message: string;
remaining?: number;
studentId?: string;
timestamp?: number;
formattedStudentId?: string;
}
// 应用状态接口
export interface AppState {
// 用户认证
isAuthenticated: boolean;
// 奖项配置
prizes: Prize[];
// 抽奖相关
currentStudentId: string;
drawResult: DrawResult | null;
isDrawing: boolean;
drawMessage: string;
// 系统配置
maxDrawTimes: number;
hidePositions: number[];
systemConfig: SystemConfig | null;
// 操作方法
login: (password: string) => Promise<boolean>;
logout: () => void;
setStudentId: (id: string) => void;
startDraw: (studentId: string) => Promise<DrawResult>;
resetDraw: () => void;
loadPrizes: () => Promise<void>;
initializeApp: () => Promise<void>;
loadSystemConfig: () => Promise<void>;
setSystemConfig: (config: SystemConfig | null) => void;
calculateDynamicProbabilities: (prizeList: Prize[]) => ProbabilityInfo[];
checkAuthStatus: () => void;
autoRefreshAuth: () => void;
getTokenRemainingTime: () => number;
cleanup: () => void;
}
// 转换数据库奖项到应用奖项格式
const convertPrizeConfig = (dbPrize: PrizeConfig): Prize => ({
id: dbPrize.id,
name: dbPrize.prize_name,
level: dbPrize.prize_level,
totalQuantity: dbPrize.total_quantity,
remainingQuantity: dbPrize.remaining_quantity,
probability: dbPrize.probability,
is_active: dbPrize.is_active
});
export const useAppStore = create<AppState>()(
(set, get) => {
const lotteryService = LotteryService.getInstance();
const authService = AuthService.getInstance();
// 初始化事件监听
const eventService = EventService.getInstance();
// 监听奖品更新事件,实现智能无感刷新
eventService.subscribe(EVENT_TYPES.PRIZES_UPDATED, ((event: CustomEvent<PrizesUpdatedEventData>) => {
const { updatedPrizes } = event.detail;
const currentPrizes = get().prizes;
// 将数据库格式转换为应用格式
const convertedPrizes = (updatedPrizes as unknown as PrizeConfig[]).map(convertPrizeConfig);
// 智能比较:检查是否有任何字段发生变化
const hasChanges = JSON.stringify(convertedPrizes) !== JSON.stringify(currentPrizes);
// 只有在数据真正变化时才更新状态,避免不必要的重新渲染
if (hasChanges) {
console.log('检测到奖品数据变化,更新状态');
set({ prizes: convertedPrizes });
}
}) as EventListener);
return {
// 初始状态 - 从AuthService检查登录状态
isAuthenticated: authService.isAuthenticated(),
prizes: [],
currentStudentId: '',
drawResult: null,
isDrawing: false,
drawMessage: '',
maxDrawTimes: 1,
hidePositions: [3, 4, 5, 6],
systemConfig: null,
// 初始化应用
initializeApp: async () => {
try {
// 检查登录状态
get().checkAuthStatus();
// 设置token过期回调
authService.setTokenExpiredCallback(() => {
console.log('Token过期自动登出');
set({ isAuthenticated: false });
});
// 启动token监控
authService.startTokenMonitoring();
// 加载系统配置
await get().loadSystemConfig();
await get().loadPrizes();
const hidePositions = await lotteryService.getHidePositions();
set({ hidePositions });
// 设置自动刷新token的定时器
setInterval(() => {
get().autoRefreshAuth();
}, 5 * 60 * 1000); // 每5分钟检查一次
} catch (error) {
console.error('Initialize app failed:', error);
}
},
// 登录
login: async (inputPassword: string) => {
try {
const result = await authService.login(inputPassword);
if (result.success) {
set({ isAuthenticated: true });
console.log('登录成功,状态已更新');
return true;
}
return false;
} catch (error) {
console.error('登录失败:', error);
return false;
}
},
// 登出方法
logout: () => {
authService.logout();
set({
isAuthenticated: false,
drawResult: null,
drawMessage: ''
});
console.log('已登出');
},
// 设置学号
setStudentId: (id: string) => {
set({ currentStudentId: id, drawMessage: '' });
},
// 加载奖项配置
loadPrizes: async () => {
try {
console.log('Store loadPrizes - 开始加载奖项数据');
console.log('Store loadPrizes - 当前URL:', window.location.href);
const apiService = ApiService.getInstance();
const dbPrizes = await apiService.getAllPrizes();
console.log('Store loadPrizes - 从数据库获取的原始奖项:', dbPrizes);
const prizes = (dbPrizes || []).map(convertPrizeConfig);
console.log('Store loadPrizes - 处理后的奖项数据:', prizes);
set({ prizes });
console.log('Store loadPrizes - 奖项数据已更新到状态');
// 注意不在这里触发PRIZES_UPDATED事件避免与WebSocket事件形成循环
// WebSocket事件应该是数据变化的唯一触发源
} catch (error) {
console.error('Store loadPrizes - 加载奖项失败:', error);
console.error('Store loadPrizes - 错误详情:', error.stack);
// 提供更详细的错误信息
if (error instanceof TypeError && error.message.includes('fetch')) {
console.error('Store loadPrizes - 网络连接错误,请检查后端服务是否正常运行');
} else if (error.message?.includes('Failed to fetch')) {
console.error('Store loadPrizes - API请求失败可能是网络问题或服务器未响应');
}
// 设置空数组避免组件渲染错误
set({ prizes: [] });
// 重新抛出错误,让调用方知道加载失败
throw error;
}
},
// 开始抽奖
startDraw: async (studentId: string) => {
console.log('🎲 Store: 开始抽奖');
console.log('Store: 当前状态 - isDrawing:', get().isDrawing, 'drawResult:', get().drawResult);
// 防止并发调用
if (get().isDrawing) {
console.log('Store: 抽奖正在进行中,拒绝重复调用');
return {
success: false,
message: '抽奖正在进行中,请稍候'
};
}
set({ isDrawing: true, drawResult: null });
console.log('Store: 状态更新 - isDrawing: true, drawResult: null');
try {
console.log('Store: 调用 lotteryService.drawLottery()');
const result = await lotteryService.drawLottery(studentId);
console.log('Store: 抽奖结果:', result);
set({
drawResult: result,
isDrawing: false
});
console.log('Store: 最终状态更新 - drawResult:', result, 'isDrawing: false');
return result;
} catch (error) {
console.error('Store: 抽奖失败:', error);
const errorResult = {
success: false,
message: '抽奖失败,请重试'
};
set({ drawResult: errorResult, isDrawing: false });
console.log('Store: 错误状态更新 - isDrawing: false');
return errorResult;
}
},
// 重置抽奖
resetDraw: () => {
set({
drawResult: null,
currentStudentId: '',
isDrawing: false,
drawMessage: ''
});
},
// 加载系统配置
loadSystemConfig: async () => {
try {
const apiService = ApiService.getInstance();
const config = await apiService.getSystemConfig();
set({
systemConfig: config,
maxDrawTimes: config?.max_draw_times || 1
});
console.log('系统配置已加载:', config);
console.log('最大抽奖次数已更新为:', config?.max_draw_times || 1);
} catch (error) {
console.error('Load system config failed:', error);
set({
systemConfig: null,
maxDrawTimes: 1
});
}
},
// 设置系统配置
setSystemConfig: (config: SystemConfig | null) => {
set({
systemConfig: config,
maxDrawTimes: config?.max_draw_times || 1
});
console.log('系统配置已更新:', config);
console.log('最大抽奖次数已同步更新为:', config?.max_draw_times || 1);
},
// 动态概率计算函数 - 结合抽奖次数上限和剩余数量
calculateDynamicProbabilities: (prizeList: Prize[]): ProbabilityInfo[] => {
// 只考虑启用且有剩余数量的奖项
const activePrizes = prizeList.filter(p => p.is_active && p.remainingQuantity > 0);
if (activePrizes.length === 0) {
return [];
}
const state = get();
const maxDrawTimes = state.systemConfig?.max_draw_times || 1;
// 计算启用奖项的总权重(只考虑启用的奖项)
const totalWeight = activePrizes.reduce((sum, prize) => sum + prize.probability, 0);
return activePrizes.map(prize => {
// 基础概率计算
let dynamicProbability = totalWeight > 0 ? (prize.probability / totalWeight) : 0;
let adjustedForDrawLimit = false;
// 结合抽奖次数上限调整概率
if (maxDrawTimes > 0) {
// 计算剩余奖品与最大抽奖次数的比例
const remainingDrawOpportunities = prize.remainingQuantity;
const maxPossibleDraws = maxDrawTimes;
// 如果剩余奖品数量远小于可能的抽奖总次数,降低显示概率
if (remainingDrawOpportunities < maxPossibleDraws * 0.1) {
dynamicProbability *= (remainingDrawOpportunities / (maxPossibleDraws * 0.1));
adjustedForDrawLimit = true;
}
// 如果接近抽奖次数上限,进一步调整概率显示
if (remainingDrawOpportunities <= maxPossibleDraws * 0.05) {
dynamicProbability *= 0.5; // 进一步降低显示概率
adjustedForDrawLimit = true;
}
}
const remainingRatio = prize.totalQuantity > 0 ? (prize.remainingQuantity / prize.totalQuantity) * 100 : 0;
return {
prize,
dynamicProbability,
remainingRatio,
adjustedForDrawLimit
};
}).sort((a, b) => a.prize.level - b.prize.level);
},
// 检查认证状态
checkAuthStatus: () => {
const isAuth = authService.checkTokenAndHandleExpiry();
const currentAuth = get().isAuthenticated;
if (isAuth !== currentAuth) {
set({ isAuthenticated: isAuth });
console.log('认证状态已更新:', isAuth);
}
},
// 自动刷新认证
autoRefreshAuth: () => {
if (authService.isAuthenticated()) {
authService.autoRefreshToken();
}
},
// 获取token剩余时间
getTokenRemainingTime: () => {
return authService.getTokenRemainingTime();
},
// 清理方法 - 防止内存泄漏
cleanup: () => {
console.log('Store cleanup - 开始清理资源');
// 清理认证服务
authService.cleanup();
// 清理抽奖服务
lotteryService.cleanup();
// 清理事件服务
eventService.cleanup();
// 清理WebSocket服务
const { websocketService } = require('../services/websocketService');
websocketService.cleanup();
console.log('Store cleanup - 资源清理完成');
}
};
}
);

61
src/types.ts Normal file
View File

@@ -0,0 +1,61 @@
// 类型定义文件
export interface DrawRecord {
id: string;
student_id: string;
prize_id: string;
prize_name: string;
prize_level: number;
draw_time: string;
is_winner: boolean;
is_synced: number;
cache_data: string;
}
export interface Prize {
id: string;
prize_name: string;
prize_level: number;
total_quantity: number;
remaining_quantity: number;
probability: number;
is_active: number;
created_at: string;
}
export interface SystemConfig {
id: string;
admin_password: string;
login_password: string;
max_draw_times: number;
background_config: string;
hide_positions: string;
created_at: string;
updated_at: string;
}
export interface Student {
student_id: string;
draw_count: number;
first_draw_at?: string;
last_draw_at?: string;
}
export interface LotteryRecord {
id: string;
student_id: string;
prize_id: string;
prize_name: string;
prize_level: number;
draw_time: string;
is_synced: number;
cache_data: string;
}
export interface DrawResult {
success: boolean;
prize?: Prize;
message: string;
student_id: string;
draw_time: string;
}

1
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />