first commit
This commit is contained in:
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;
|
||||
Reference in New Issue
Block a user