stud-manager / pages /GameRandom.tsx
dvc890's picture
Update pages/GameRandom.tsx
7911c0b verified
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;
};