first commit

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

1399
src/pages/Admin.tsx Normal file

File diff suppressed because it is too large Load Diff

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

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