first commit
This commit is contained in:
151
src/App.tsx
Normal file
151
src/App.tsx
Normal 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
1
src/assets/react.svg
Normal 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 |
151
src/components/ConfettiEffect.tsx
Normal file
151
src/components/ConfettiEffect.tsx
Normal 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);
|
||||
217
src/components/DrawAnimation.tsx
Normal file
217
src/components/DrawAnimation.tsx
Normal 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
45
src/components/Empty.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
223
src/components/EnhancedUX.tsx
Normal file
223
src/components/EnhancedUX.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
49
src/components/ErrorBoundary.tsx
Normal file
49
src/components/ErrorBoundary.tsx
Normal 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;
|
||||
63
src/components/LoadingSpinner.tsx
Normal file
63
src/components/LoadingSpinner.tsx
Normal 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
|
||||
)} />
|
||||
);
|
||||
}
|
||||
330
src/components/LotteryAnimation.tsx
Normal file
330
src/components/LotteryAnimation.tsx
Normal 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;
|
||||
68
src/components/NetworkStatus.tsx
Normal file
68
src/components/NetworkStatus.tsx
Normal 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;
|
||||
158
src/components/NumberKeyboard.tsx
Normal file
158
src/components/NumberKeyboard.tsx
Normal 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)
|
||||
661
src/components/PrizeDisplay.tsx
Normal file
661
src/components/PrizeDisplay.tsx
Normal 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);
|
||||
323
src/components/ResultDisplay.tsx
Normal file
323
src/components/ResultDisplay.tsx
Normal 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
|
||||
317
src/components/ScreenKeyboard.tsx
Normal file
317
src/components/ScreenKeyboard.tsx
Normal 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
120
src/components/Toast.tsx
Normal 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
|
||||
31
src/components/ToastContainer.tsx
Normal file
31
src/components/ToastContainer.tsx
Normal 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
|
||||
62
src/contexts/ToastContext.tsx
Normal file
62
src/contexts/ToastContext.tsx
Normal 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
435
src/database/database.ts
Normal 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
66
src/database/init.sql
Normal 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
29
src/hooks/useTheme.ts
Normal 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
64
src/hooks/useToast.ts
Normal 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
92
src/index.css
Normal 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
6
src/lib/utils.ts
Normal 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
14
src/main.tsx
Normal 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
1399
src/pages/Admin.tsx
Normal file
File diff suppressed because it is too large
Load Diff
184
src/pages/ClearRecords.tsx
Normal file
184
src/pages/ClearRecords.tsx
Normal 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
161
src/pages/ConfettiDemo.tsx
Normal 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
284
src/pages/CrudTest.tsx
Normal 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
443
src/pages/Home.tsx
Normal 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
156
src/pages/Login.tsx
Normal 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
364
src/pages/Lottery.tsx
Normal 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
120
src/pages/NetworkTest.tsx
Normal 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
307
src/pages/Records.tsx
Normal 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
|
||||
105
src/pages/SimpleNetworkTest.tsx
Normal file
105
src/pages/SimpleNetworkTest.tsx
Normal 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
453
src/services/apiService.ts
Normal 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
268
src/services/authService.ts
Normal 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;
|
||||
105
src/services/eventService.ts
Normal file
105
src/services/eventService.ts
Normal 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;
|
||||
279
src/services/lotteryService.ts
Normal file
279
src/services/lotteryService.ts
Normal 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;
|
||||
159
src/services/networkService.ts
Normal file
159
src/services/networkService.ts
Normal 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;
|
||||
393
src/services/websocketService.ts
Normal file
393
src/services/websocketService.ts
Normal 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
404
src/store/index.ts
Normal 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
61
src/types.ts
Normal 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
1
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user