Spaces:
Sleeping
Sleeping
| import React, { useState, useEffect, useRef } from 'react'; | |
| import { createPortal } from 'react-dom'; | |
| import { api } from '../services/api'; | |
| import { Student, GameSession, GameTeam, AchievementConfig } from '../types'; | |
| import { Loader2, User, Users, Play, Pause, Gift, CheckCircle, XCircle, Award, Volume2, Settings, Maximize, Minimize, UserX, RotateCcw, Repeat } from 'lucide-react'; | |
| import { Emoji } from '../components/Emoji'; | |
| export const GameRandom: React.FC<{className?: string}> = ({ className }) => { | |
| const [loading, setLoading] = useState(true); | |
| const [students, setStudents] = useState<Student[]>([]); | |
| const [teams, setTeams] = useState<GameTeam[]>([]); | |
| const [achConfig, setAchConfig] = useState<AchievementConfig | null>(null); | |
| // Game State | |
| const [mode, setMode] = useState<'STUDENT' | 'TEAM'>('STUDENT'); | |
| const [scopeTeamId, setScopeTeamId] = useState<string>('ALL'); // 'ALL' or specific team ID | |
| const [isRunning, setIsRunning] = useState(false); | |
| const [highlightIndex, setHighlightIndex] = useState<number | null>(null); | |
| const [selectedResult, setSelectedResult] = useState<Student | GameTeam | null>(null); | |
| const [showResultModal, setShowResultModal] = useState(false); | |
| const [isFullscreen, setIsFullscreen] = useState(false); | |
| // Avoidance / No Repeat State | |
| const [avoidRepeat, setAvoidRepeat] = useState(false); | |
| const [pickedIds, setPickedIds] = useState<Set<string>>(new Set()); | |
| // Filter State | |
| const [excludedStudentIds, setExcludedStudentIds] = useState<Set<string>>(new Set()); | |
| const [isFilterOpen, setIsFilterOpen] = useState(false); | |
| // Reward State | |
| const [rewardType, setRewardType] = useState<'DRAW_COUNT' | 'ITEM' | 'ACHIEVEMENT'>('DRAW_COUNT'); | |
| const [rewardId, setRewardId] = useState(''); // Achievement ID or Item Name | |
| const [rewardCount, setRewardCount] = useState(1); | |
| const timerRef = useRef<any>(null); | |
| const speedRef = useRef<number>(50); | |
| const currentUser = api.auth.getCurrentUser(); | |
| const homeroomClass = className || currentUser?.homeroomClass; | |
| useEffect(() => { | |
| loadData(); | |
| return () => stopAnimation(); | |
| }, [homeroomClass]); | |
| // Clear picked IDs when mode changes to avoid ID confusion | |
| useEffect(() => { | |
| setPickedIds(new Set()); | |
| }, [mode, scopeTeamId]); | |
| const loadData = async () => { | |
| if (!homeroomClass) { | |
| setLoading(false); | |
| return; | |
| } | |
| setLoading(true); | |
| // 1. Load Students (Critical) | |
| try { | |
| const allStus = await api.students.getAll(); | |
| const classStudents = allStus.filter((s: Student) => s.className === homeroomClass); | |
| // Sort by Seat No | |
| classStudents.sort((a: Student, b: Student) => { | |
| const seatA = parseInt(a.seatNo || '99999'); | |
| const seatB = parseInt(b.seatNo || '99999'); | |
| if (seatA !== seatB) return seatA - seatB; | |
| return a.name.localeCompare(b.name, 'zh-CN'); | |
| }); | |
| setStudents(classStudents); | |
| // 2. Load Teams (Optional - Fallback to auto-gen if missing) | |
| try { | |
| const session = await api.games.getMountainSession(homeroomClass); | |
| if (session && session.teams && session.teams.length > 0) { | |
| setTeams(session.teams); | |
| } else { | |
| // Generate temporary teams if none exist | |
| generateTempTeams(classStudents); | |
| } | |
| } catch (e) { | |
| console.warn('Failed to load teams, generating defaults', e); | |
| generateTempTeams(classStudents); | |
| } | |
| // 3. Load Achievements (Optional) | |
| try { | |
| const ac = await api.achievements.getConfig(homeroomClass); | |
| setAchConfig(ac); | |
| } catch (e) { console.warn('Failed to load achievements', e); } | |
| } catch (e) { | |
| console.error('Failed to load student list', e); | |
| } finally { | |
| setLoading(false); | |
| } | |
| }; | |
| const generateTempTeams = (stus: Student[]) => { | |
| // Simple logic: 6 students per team | |
| const teamSize = 6; | |
| const teamCount = Math.ceil(stus.length / teamSize); | |
| const tempTeams: GameTeam[] = []; | |
| const avatars = ['🐯','🦁','🐺','🐻','🐨','🐼','🐸','🐙']; | |
| const colors = ['#ef4444', '#f97316', '#eab308', '#84cc16', '#22c55e', '#3b82f6', '#8b5cf6', '#ec4899']; | |
| for (let i = 0; i < teamCount; i++) { | |
| const members = stus.slice(i * teamSize, (i + 1) * teamSize).map(s => s._id || String(s.id)); | |
| tempTeams.push({ | |
| id: `temp-${i}`, | |
| name: `${i+1}组`, | |
| score: 0, | |
| avatar: avatars[i % avatars.length], | |
| color: colors[i % colors.length], | |
| members | |
| }); | |
| } | |
| setTeams(tempTeams); | |
| }; | |
| const getTargetList = () => { | |
| let baseList: any[] = []; | |
| if (mode === 'TEAM') { | |
| baseList = teams; | |
| } else { | |
| baseList = students; | |
| if (scopeTeamId !== 'ALL') { | |
| const team = teams.find(t => t.id === scopeTeamId); | |
| if (team) { | |
| baseList = students.filter(s => team.members.includes(s._id || String(s.id))); | |
| } | |
| } | |
| // Filter out excluded (absent) students | |
| baseList = baseList.filter(s => !excludedStudentIds.has(s._id || String(s.id))); | |
| } | |
| // Filter out previously picked if mode is on | |
| if (avoidRepeat) { | |
| baseList = baseList.filter(item => !pickedIds.has(mode === 'TEAM' ? item.id : (item._id || String(item.id)))); | |
| } | |
| return baseList; | |
| }; | |
| const startRandom = () => { | |
| const list = getTargetList(); | |
| if (list.length === 0) { | |
| if (avoidRepeat && pickedIds.size > 0) { | |
| if (confirm('本轮所有候选对象已全部点完!是否重置记录并重新开始?')) { | |
| setPickedIds(new Set()); | |
| } | |
| return; | |
| } | |
| return alert('当前范围内没有可选对象'); | |
| } | |
| setIsRunning(true); | |
| setShowResultModal(false); | |
| setSelectedResult(null); | |
| speedRef.current = 50; | |
| const run = () => { | |
| setHighlightIndex(prev => { | |
| let next = Math.floor(Math.random() * list.length); | |
| // 视觉优化:避免连续两次选中同一个学生,增加跳跃感 | |
| if (list.length > 1 && prev !== null) { | |
| while (next === prev) { | |
| next = Math.floor(Math.random() * list.length); | |
| } | |
| } | |
| return next; | |
| }); | |
| // Slow down logic | |
| speedRef.current = Math.min(speedRef.current * 1.1, 600); | |
| if (speedRef.current < 500) { | |
| timerRef.current = setTimeout(run, speedRef.current); | |
| } else { | |
| // Stop | |
| stopAnimation(); | |
| setTimeout(() => finalizeSelection(list), 500); | |
| } | |
| }; | |
| run(); | |
| }; | |
| const finalizeSelection = (list: any[]) => { | |
| setHighlightIndex(prev => { | |
| if (prev === null) return 0; | |
| const result = list[prev]; | |
| setSelectedResult(result); | |
| setShowResultModal(true); | |
| // Add to picked history | |
| if (avoidRepeat) { | |
| const id = mode === 'TEAM' ? result.id : (result._id || String(result.id)); | |
| setPickedIds(prev => new Set(prev).add(id)); | |
| } | |
| return prev; | |
| }); | |
| }; | |
| const stopAnimation = () => { | |
| if (timerRef.current) clearTimeout(timerRef.current); | |
| setIsRunning(false); | |
| }; | |
| const handleGrantReward = async (shouldReward: boolean) => { | |
| if (!selectedResult || !shouldReward) { | |
| setShowResultModal(false); | |
| return; | |
| } | |
| let targets: Student[] = []; | |
| if (mode === 'STUDENT') { | |
| targets = [selectedResult as Student]; | |
| } else { | |
| // Team mode: find all students in team | |
| const team = selectedResult as GameTeam; | |
| // IMPORTANT: Exclude absent students from team reward | |
| targets = students.filter(s => team.members.includes(s._id || String(s.id)) && !excludedStudentIds.has(s._id || String(s.id))); | |
| } | |
| if (targets.length === 0) { | |
| alert('没有符合条件的学生可发放奖励'); | |
| setShowResultModal(false); | |
| return; | |
| } | |
| const rewardName = rewardType === 'ACHIEVEMENT' | |
| ? achConfig?.achievements.find(a => a.id === rewardId)?.name | |
| : (rewardId || (rewardType === 'DRAW_COUNT' ? '抽奖券' : '奖励')); | |
| try { | |
| const promises = targets.map(s => { | |
| if (rewardType === 'ACHIEVEMENT' && rewardId) { | |
| return api.achievements.grant({ | |
| studentId: s._id || String(s.id), | |
| achievementId: rewardId, | |
| semester: '当前学期' // Ideally fetch from config | |
| }); | |
| } else { | |
| return api.games.grantReward({ | |
| studentId: s._id || String(s.id), | |
| count: rewardCount, | |
| rewardType, | |
| name: rewardName | |
| }); | |
| } | |
| }); | |
| await Promise.all(promises); | |
| } catch (e) { | |
| console.error(e); | |
| alert('发放失败'); | |
| } | |
| setShowResultModal(false); | |
| }; | |
| if (loading) return <div className="h-full flex items-center justify-center"><Loader2 className="animate-spin text-blue-600"/></div>; | |
| const targetList = getTargetList(); | |
| const GameContent = ( | |
| <div className={`${isFullscreen ? 'fixed inset-0 z-[9999] w-screen h-screen' : 'h-full w-full relative'} flex flex-col bg-slate-50 overflow-hidden transition-all duration-300`}> | |
| {/* Floating Fullscreen Toggle Button */} | |
| <button | |
| onClick={() => setIsFullscreen(!isFullscreen)} | |
| className={`absolute top-4 right-4 z-50 p-2 rounded-lg shadow-md transition-all flex items-center gap-2 ${ | |
| isFullscreen | |
| ? 'bg-slate-800/80 text-white hover:bg-slate-700 backdrop-blur-md border border-white/20' | |
| : 'bg-white text-gray-600 hover:bg-gray-100 border border-gray-200' | |
| }`} | |
| title={isFullscreen ? "退出全屏" : "全屏显示"} | |
| > | |
| {isFullscreen ? <Minimize size={20}/> : <Maximize size={20}/>} | |
| <span className="text-xs font-bold hidden sm:inline">{isFullscreen ? '退出全屏' : '全屏模式'}</span> | |
| </button> | |
| {/* Header Config */} | |
| <div className="bg-white p-4 shadow-sm border-b border-gray-200 z-10 flex flex-wrap gap-4 items-center justify-between shrink-0 pr-36"> | |
| <div className="flex flex-wrap gap-4 items-center"> | |
| <div className="flex gap-2 bg-gray-100 p-1 rounded-lg"> | |
| <button onClick={()=>{setMode('STUDENT'); setHighlightIndex(null);}} className={`px-4 py-2 rounded-md text-sm font-bold transition-all ${mode==='STUDENT'?'bg-white shadow text-blue-600':'text-gray-500'}`}> | |
| <User size={16} className="inline mr-1"/> 点名学生 | |
| </button> | |
| <button onClick={()=>{setMode('TEAM'); setHighlightIndex(null);}} className={`px-4 py-2 rounded-md text-sm font-bold transition-all ${mode==='TEAM'?'bg-white shadow text-purple-600':'text-gray-500'}`}> | |
| <Users size={16} className="inline mr-1"/> 随机小组 | |
| </button> | |
| </div> | |
| {mode === 'STUDENT' && ( | |
| <select className="border border-gray-300 rounded-lg px-3 py-2 text-sm bg-white outline-none focus:ring-2 focus:ring-blue-500" value={scopeTeamId} onChange={e=>setScopeTeamId(e.target.value)}> | |
| <option value="ALL">全班范围</option> | |
| {teams.map(t => <option key={t.id} value={t.id}>{t.name} (组)</option>)} | |
| </select> | |
| )} | |
| {/* Avoid Repeat Logic */} | |
| <div className={`flex items-center gap-2 px-3 py-2 rounded-lg border transition-colors ${avoidRepeat ? 'bg-indigo-50 border-indigo-200' : 'bg-gray-50 border-gray-200'}`}> | |
| <div className="flex items-center gap-2" title="开启后,已点过的学生/小组不会再次被选中"> | |
| <input type="checkbox" id="avoidRepeat" checked={avoidRepeat} onChange={e => setAvoidRepeat(e.target.checked)} className="w-4 h-4 text-indigo-600 rounded focus:ring-indigo-500 cursor-pointer"/> | |
| <label htmlFor="avoidRepeat" className={`text-sm font-bold cursor-pointer select-none ${avoidRepeat ? 'text-indigo-700' : 'text-gray-600'}`}>不重复</label> | |
| </div> | |
| {avoidRepeat && pickedIds.size > 0 && ( | |
| <> | |
| <div className="w-px h-4 bg-indigo-200 mx-1"></div> | |
| <span className="text-xs text-indigo-500 font-mono">已点{pickedIds.size}</span> | |
| <button onClick={() => setPickedIds(new Set())} className="text-indigo-400 hover:text-indigo-700 p-1 rounded-full hover:bg-indigo-100 transition-colors" title="重置记录"> | |
| <RotateCcw size={14}/> | |
| </button> | |
| </> | |
| )} | |
| </div> | |
| <button | |
| onClick={() => setIsFilterOpen(true)} | |
| className={`flex items-center gap-1 px-3 py-2 rounded-lg text-sm border font-medium ${excludedStudentIds.size > 0 ? 'bg-red-50 text-red-600 border-red-200' : 'bg-gray-50 text-gray-600 border-gray-200'}`} | |
| > | |
| <UserX size={16}/> 排除 ({excludedStudentIds.size}) | |
| </button> | |
| <div className="flex items-center gap-2 bg-amber-50 px-3 py-2 rounded-lg border border-amber-100"> | |
| <Gift size={16} className="text-amber-500"/> | |
| <label className="text-sm font-bold text-gray-700">奖励:</label> | |
| <select className="text-sm border-gray-300 rounded bg-white outline-none" value={rewardType} onChange={e=>setRewardType(e.target.value as any)}> | |
| <option value="DRAW_COUNT">抽奖券</option> | |
| <option value="ITEM">实物</option> | |
| <option value="ACHIEVEMENT">成就</option> | |
| </select> | |
| {rewardType === 'ACHIEVEMENT' ? ( | |
| <select className="text-sm border-gray-300 rounded w-32 bg-white outline-none" value={rewardId} onChange={e=>setRewardId(e.target.value)}> | |
| <option value="">选择成就</option> | |
| {achConfig?.achievements.map(a => <option key={a.id} value={a.id}>{a.icon} {a.name}</option>)} | |
| </select> | |
| ) : rewardType === 'ITEM' ? ( | |
| <input className="text-sm border-gray-300 rounded w-24 p-1 outline-none" placeholder="奖品名" value={rewardId} onChange={e=>setRewardId(e.target.value)}/> | |
| ) : null} | |
| <span className="text-xs text-gray-400">x</span> | |
| <input type="number" min={1} className="w-12 text-sm border-gray-300 rounded p-1 text-center outline-none" value={rewardCount} onChange={e=>setRewardCount(Number(e.target.value))}/> | |
| </div> | |
| </div> | |
| </div> | |
| {/* Grid Area */} | |
| <div className="flex-1 overflow-y-auto p-6 custom-scrollbar bg-slate-50"> | |
| {targetList.length === 0 ? ( | |
| <div className="h-full flex flex-col items-center justify-center text-gray-400"> | |
| <Users size={64} className="mb-4 opacity-20"/> | |
| <p className="text-lg font-bold">暂无数据</p> | |
| <p className="text-sm">该班级下没有找到{mode === 'STUDENT' ? '学生' : '小组'}数据</p> | |
| </div> | |
| ) : ( | |
| <div className={`grid gap-4 transition-all pb-24 ${mode==='STUDENT' ? (isFullscreen ? 'grid-cols-6 sm:grid-cols-8 md:grid-cols-10 lg:grid-cols-12' : 'grid-cols-4 sm:grid-cols-6 md:grid-cols-8 lg:grid-cols-10') : 'grid-cols-2 md:grid-cols-3 lg:grid-cols-4'}`}> | |
| {targetList.map((item, idx) => { | |
| const isHighlighted = idx === highlightIndex; | |
| const isTeam = mode === 'TEAM'; | |
| // @ts-ignore | |
| const name = isTeam ? item.name : item.name; | |
| // @ts-ignore | |
| const avatar = isTeam ? (item.avatar || '🚩') : (item.seatNo || 'User'); | |
| // @ts-ignore | |
| const subText = isTeam ? `${item.members.length}人` : item.studentNo; | |
| // @ts-ignore | |
| const color = isTeam ? (item.color || '#3b82f6') : '#3b82f6'; | |
| return ( | |
| <div key={idx} className={`relative aspect-square flex flex-col items-center justify-center rounded-2xl border-4 transition-all duration-100 ${ | |
| isHighlighted | |
| ? 'bg-yellow-100 border-yellow-400 scale-110 shadow-2xl z-10' | |
| : 'bg-white border-gray-100 shadow-sm opacity-80' | |
| }`}> | |
| {isHighlighted && <div className="absolute inset-0 bg-yellow-400 opacity-20 animate-pulse rounded-xl"></div>} | |
| <div className={`mb-2 font-bold ${isFullscreen ? 'text-5xl' : 'text-3xl'}`} style={{color: isTeam ? color : '#64748b'}}> | |
| {mode === 'STUDENT' ? ( | |
| <div className={`rounded-full bg-blue-100 flex items-center justify-center text-blue-600 ${isFullscreen ? 'w-24 h-24 text-4xl' : 'w-12 h-12 text-lg'}`}> | |
| {name[0]} | |
| </div> | |
| ) : <Emoji symbol={avatar} />} | |
| </div> | |
| <div className={`font-bold text-gray-800 text-center truncate w-full px-2 ${isFullscreen ? 'text-2xl' : 'text-base'}`}>{name}</div> | |
| <div className="text-xs text-gray-400">{subText}</div> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| )} | |
| </div> | |
| {/* Start Button */} | |
| <div className="absolute bottom-0 left-0 right-0 p-6 flex justify-center bg-white/90 backdrop-blur-sm border-t border-gray-200 z-10 shadow-[0_-4px_10px_-1px_rgba(0,0,0,0.1)]"> | |
| <button | |
| onClick={isRunning ? stopAnimation : startRandom} | |
| disabled={targetList.length === 0} | |
| className={`px-12 py-4 rounded-full text-xl font-black text-white shadow-lg transition-all transform active:scale-95 flex items-center gap-3 ${isRunning ? 'bg-red-500 hover:bg-red-600' : 'bg-blue-600 hover:bg-blue-700 disabled:bg-gray-300 disabled:cursor-not-allowed'}`} | |
| > | |
| {isRunning ? <><Pause fill="white"/> 停止</> : <><Play fill="white"/> 开始随机</>} | |
| </button> | |
| </div> | |
| {/* Student Filter Modal */} | |
| {isFilterOpen && ( | |
| <div className="absolute inset-0 bg-black/50 z-[1000000] flex items-center justify-center p-4"> | |
| <div className="bg-white rounded-xl w-full max-w-3xl h-[80vh] flex flex-col shadow-2xl animate-in zoom-in-95 text-gray-800"> | |
| <div className="p-4 border-b flex justify-between items-center"> | |
| <h3 className="font-bold text-lg">排除请假/缺勤学生</h3> | |
| <div className="flex gap-2"> | |
| <button onClick={() => setExcludedStudentIds(new Set())} className="text-xs text-blue-600 hover:underline">清空选择</button> | |
| <button onClick={() => setIsFilterOpen(false)} className="px-4 py-1 bg-blue-600 text-white rounded text-sm">确定</button> | |
| </div> | |
| </div> | |
| <div className="flex-1 overflow-y-auto p-4 bg-gray-50"> | |
| {students.length === 0 ? ( | |
| <div className="text-center text-gray-400 py-10">暂无学生数据</div> | |
| ) : ( | |
| <div className="grid grid-cols-4 sm:grid-cols-5 md:grid-cols-6 gap-2"> | |
| {students.map(s => { | |
| const isExcluded = excludedStudentIds.has(s._id || String(s.id)); | |
| return ( | |
| <div | |
| key={s._id} | |
| onClick={() => { | |
| const newSet = new Set(excludedStudentIds); | |
| if (isExcluded) newSet.delete(s._id || String(s.id)); | |
| else newSet.add(s._id || String(s.id)); | |
| setExcludedStudentIds(newSet); | |
| }} | |
| className={`p-2 rounded border cursor-pointer text-center text-sm transition-all select-none ${isExcluded ? 'bg-red-50 border-red-300 text-red-500 opacity-60' : 'bg-white border-gray-200 hover:border-blue-300'}`} | |
| > | |
| <div className="font-bold">{s.name}</div> | |
| <div className="text-xs opacity-70">{s.seatNo}</div> | |
| {isExcluded && <div className="text-[10px] font-bold mt-1">已排除</div>} | |
| </div> | |
| ) | |
| })} | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| {/* Result Modal */} | |
| {showResultModal && selectedResult && ( | |
| <div className="fixed inset-0 bg-black/60 z-[110] flex items-center justify-center p-4 backdrop-blur-sm animate-in fade-in"> | |
| <div className="bg-white rounded-3xl p-8 w-full max-w-md text-center shadow-2xl relative animate-in zoom-in-95"> | |
| <div className="absolute -top-12 left-1/2 -translate-x-1/2 w-24 h-24 bg-yellow-400 rounded-full flex items-center justify-center border-4 border-white shadow-lg"> | |
| <span className="text-5xl"><Emoji symbol="✨"/></span> | |
| </div> | |
| <h2 className="mt-10 text-gray-500 text-sm font-bold uppercase tracking-widest">选中对象</h2> | |
| <div className="text-5xl font-black text-gray-800 my-6"> | |
| {/* @ts-ignore */} | |
| {selectedResult.name} | |
| </div> | |
| {/* @ts-ignore */} | |
| <div className="text-gray-400 mb-8">{mode==='STUDENT' ? `座号: ${selectedResult.seatNo || '-'}` : `${selectedResult.members.length} 位成员`}</div> | |
| <div className="grid grid-cols-2 gap-4 mb-4"> | |
| <button onClick={() => handleGrantReward(true)} className="bg-green-500 hover:bg-green-600 text-white py-4 rounded-2xl font-bold text-lg flex flex-col items-center justify-center shadow-md transition-transform hover:-translate-y-1"> | |
| <CheckCircle size={32} className="mb-1"/> | |
| 回答正确 | |
| <span className="text-xs font-normal opacity-80 mt-1">发放奖励</span> | |
| </button> | |
| <button onClick={() => handleGrantReward(false)} className="bg-gray-100 hover:bg-gray-200 text-gray-600 py-4 rounded-2xl font-bold text-lg flex flex-col items-center justify-center shadow-sm transition-transform hover:-translate-y-1"> | |
| <XCircle size={32} className="mb-1"/> | |
| 回答错误 | |
| <span className="text-xs font-normal opacity-80 mt-1">无奖励</span> | |
| </button> | |
| </div> | |
| <button onClick={() => setShowResultModal(false)} className="text-gray-400 hover:text-gray-600 text-sm underline">跳过 / 关闭</button> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| if (isFullscreen) { | |
| return createPortal(GameContent, document.body); | |
| } | |
| return GameContent; | |
| }; | |