Spaces:
Running
Running
Upload 44 files
Browse files- models.js +1 -2
- pages/GameMonster.tsx +204 -239
- pages/GameRandom.tsx +354 -185
- pages/GameZen.tsx +219 -353
- pages/Games.tsx +1 -1
- server.js +2 -41
- services/api.ts +165 -178
models.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
| 1 |
-
|
| 2 |
const mongoose = require('mongoose');
|
| 3 |
|
| 4 |
const SchoolSchema = new mongoose.Schema({ name: String, code: String });
|
|
@@ -144,4 +143,4 @@ module.exports = {
|
|
| 144 |
School, User, Student, Course, Score, ClassModel, SubjectModel, ExamModel, ScheduleModel,
|
| 145 |
ConfigModel, NotificationModel, GameSessionModel, StudentRewardModel, LuckyDrawConfigModel, GameMonsterConfigModel, GameZenConfigModel,
|
| 146 |
AchievementConfigModel, StudentAchievementModel, AttendanceModel, LeaveRequestModel
|
| 147 |
-
};
|
|
|
|
|
|
|
| 1 |
const mongoose = require('mongoose');
|
| 2 |
|
| 3 |
const SchoolSchema = new mongoose.Schema({ name: String, code: String });
|
|
|
|
| 143 |
School, User, Student, Course, Score, ClassModel, SubjectModel, ExamModel, ScheduleModel,
|
| 144 |
ConfigModel, NotificationModel, GameSessionModel, StudentRewardModel, LuckyDrawConfigModel, GameMonsterConfigModel, GameZenConfigModel,
|
| 145 |
AchievementConfigModel, StudentAchievementModel, AttendanceModel, LeaveRequestModel
|
| 146 |
+
};
|
pages/GameMonster.tsx
CHANGED
|
@@ -1,316 +1,281 @@
|
|
| 1 |
-
|
| 2 |
-
import React, { useState, useEffect, useRef } from 'react';
|
| 3 |
import { api } from '../services/api';
|
| 4 |
-
import { Student, GameMonsterConfig,
|
| 5 |
-
import {
|
| 6 |
|
| 7 |
export const GameMonster: React.FC = () => {
|
| 8 |
const [config, setConfig] = useState<GameMonsterConfig | null>(null);
|
| 9 |
-
const [
|
| 10 |
-
const [volume, setVolume] = useState(0);
|
| 11 |
-
const [monsterHp, setMonsterHp] = useState(100);
|
| 12 |
-
const [students, setStudents] = useState<Student[]>([]);
|
| 13 |
-
const [achievements, setAchievements] = useState<AchievementItem[]>([]);
|
| 14 |
-
|
| 15 |
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
const [
|
| 20 |
-
const [
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
const
|
| 24 |
-
const analyserRef = useRef<AnalyserNode | null>(null);
|
| 25 |
-
const microphoneRef = useRef<MediaStreamAudioSourceNode | null>(null);
|
| 26 |
-
const animationFrameRef = useRef<number | null>(null);
|
| 27 |
|
| 28 |
const currentUser = api.auth.getCurrentUser();
|
| 29 |
const isTeacher = currentUser?.role === 'TEACHER';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
useEffect(() => {
|
| 32 |
loadData();
|
| 33 |
-
return () => stopGame();
|
| 34 |
}, []);
|
| 35 |
|
| 36 |
const loadData = async () => {
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
};
|
| 64 |
|
| 65 |
-
const
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
audioContextRef.current = new (window.AudioContext || (window as any).webkitAudioContext)();
|
| 70 |
-
analyserRef.current = audioContextRef.current.createAnalyser();
|
| 71 |
-
microphoneRef.current = audioContextRef.current.createMediaStreamSource(stream);
|
| 72 |
-
microphoneRef.current.connect(analyserRef.current);
|
| 73 |
-
analyserRef.current.fftSize = 256;
|
| 74 |
-
|
| 75 |
-
setIsPlaying(true);
|
| 76 |
-
setMonsterHp(100);
|
| 77 |
-
setGameResult(null);
|
| 78 |
-
|
| 79 |
-
processAudio();
|
| 80 |
-
} catch (e) {
|
| 81 |
-
alert('无法访问麦克风,请检查权限');
|
| 82 |
-
}
|
| 83 |
};
|
| 84 |
|
| 85 |
-
const
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
const dataArray = new Uint8Array(analyserRef.current.frequencyBinCount);
|
| 89 |
-
analyserRef.current.getByteFrequencyData(dataArray);
|
| 90 |
-
|
| 91 |
-
const avgVolume = dataArray.reduce((a,b) => a+b) / dataArray.length;
|
| 92 |
-
setVolume(avgVolume); // 0-255
|
| 93 |
-
|
| 94 |
-
// Damage Logic
|
| 95 |
-
// Threshold based on sensitivity (Inverted: higher sensitivity = lower threshold)
|
| 96 |
-
const threshold = 100 - (config.sensitivity || 50);
|
| 97 |
-
|
| 98 |
-
if (avgVolume > threshold) {
|
| 99 |
-
// Damage formula
|
| 100 |
-
const damage = (avgVolume - threshold) / (config.difficulty * 10);
|
| 101 |
-
setMonsterHp(prev => {
|
| 102 |
-
const next = prev - damage;
|
| 103 |
-
if (next <= 0) {
|
| 104 |
-
endGame('WIN');
|
| 105 |
-
return 0;
|
| 106 |
-
}
|
| 107 |
-
return next;
|
| 108 |
-
});
|
| 109 |
-
}
|
| 110 |
-
|
| 111 |
-
if (isPlaying && monsterHp > 0) {
|
| 112 |
-
animationFrameRef.current = requestAnimationFrame(processAudio);
|
| 113 |
-
}
|
| 114 |
-
};
|
| 115 |
-
|
| 116 |
-
const stopGame = () => {
|
| 117 |
-
setIsPlaying(false);
|
| 118 |
-
if (audioContextRef.current) audioContextRef.current.close();
|
| 119 |
-
if (animationFrameRef.current) cancelAnimationFrame(animationFrameRef.current);
|
| 120 |
};
|
| 121 |
|
| 122 |
-
const
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
if (result === 'WIN' && config?.rewardConfig?.enabled) {
|
| 126 |
-
grantBatchReward(config.rewardConfig);
|
| 127 |
-
}
|
| 128 |
-
};
|
| 129 |
|
| 130 |
-
const grantBatchReward = async (rewardConfig: any) => {
|
| 131 |
-
const activeStudents = students.filter(s => !excludedIds.has(s._id || String(s.id)));
|
| 132 |
-
if (!activeStudents.length) return;
|
| 133 |
try {
|
| 134 |
-
const promises =
|
| 135 |
-
if (rewardConfig.type === 'ACHIEVEMENT') {
|
| 136 |
return api.achievements.grant({
|
| 137 |
studentId: s._id || String(s.id),
|
| 138 |
-
achievementId: rewardConfig.val,
|
| 139 |
semester: '当前学期'
|
| 140 |
});
|
| 141 |
} else {
|
| 142 |
return api.games.grantReward({
|
| 143 |
studentId: s._id || String(s.id),
|
| 144 |
-
count: rewardConfig.count,
|
| 145 |
-
rewardType: rewardConfig.type,
|
| 146 |
-
name: rewardConfig.type === 'DRAW_COUNT' ? '早读抽奖券' : rewardConfig.val,
|
| 147 |
source: '早读战怪兽'
|
| 148 |
});
|
| 149 |
}
|
| 150 |
});
|
| 151 |
await Promise.all(promises);
|
| 152 |
-
|
|
|
|
|
|
|
| 153 |
};
|
| 154 |
|
| 155 |
-
const
|
| 156 |
-
if (config)
|
| 157 |
-
|
| 158 |
-
setIsSettingsOpen(false);
|
| 159 |
-
}
|
| 160 |
};
|
| 161 |
|
| 162 |
-
if (
|
|
|
|
| 163 |
|
| 164 |
return (
|
| 165 |
-
<div className="h-full flex flex-col
|
| 166 |
-
{/*
|
| 167 |
-
|
| 168 |
-
<
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
<Settings size={20}/>
|
| 176 |
-
</button>
|
| 177 |
-
</div>
|
| 178 |
-
)}
|
| 179 |
-
</div>
|
| 180 |
|
| 181 |
-
{/* Game Area */}
|
| 182 |
<div className="flex-1 flex flex-col items-center justify-center relative">
|
|
|
|
|
|
|
|
|
|
| 183 |
{/* Monster */}
|
| 184 |
-
<div className={`transition-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
className=
|
| 192 |
-
style={{ width: `${
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
</div>
|
| 199 |
|
| 200 |
-
{/*
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
</p>
|
| 215 |
-
|
| 216 |
-
{!isPlaying && !gameResult && (
|
| 217 |
-
<button onClick={startGame} className="mt-8 bg-purple-600 hover:bg-purple-500 text-white px-8 py-3 rounded-full font-bold text-lg flex items-center shadow-lg hover:scale-105 transition-all">
|
| 218 |
-
<PlayCircle className="mr-2"/> 开始挑战
|
| 219 |
-
</button>
|
| 220 |
-
)}
|
| 221 |
-
|
| 222 |
-
{gameResult === 'WIN' && (
|
| 223 |
-
<div className="absolute inset-0 bg-black/80 flex flex-col items-center justify-center z-20 animate-in zoom-in">
|
| 224 |
-
<div className="text-6xl mb-4">🎉</div>
|
| 225 |
-
<h2 className="text-4xl font-black text-yellow-400 mb-4">挑战成功!</h2>
|
| 226 |
-
<p className="text-gray-300 mb-8">怪兽已被击败,全班奖励已发放!</p>
|
| 227 |
-
<button onClick={() => { setGameResult(null); setMonsterHp(100); }} className="bg-white text-purple-900 px-6 py-2 rounded-full font-bold">再来一次</button>
|
| 228 |
</div>
|
| 229 |
)}
|
| 230 |
</div>
|
| 231 |
|
| 232 |
-
{/*
|
| 233 |
-
|
| 234 |
-
<div className="
|
| 235 |
-
<div className=
|
| 236 |
-
<
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
<div className="flex
|
| 241 |
-
{
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
<
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
const sid = s._id || String(s.id);
|
| 249 |
-
if (newSet.has(sid)) newSet.delete(sid);
|
| 250 |
-
else newSet.add(sid);
|
| 251 |
-
setExcludedIds(newSet);
|
| 252 |
-
}}
|
| 253 |
-
className={`p-2 rounded border cursor-pointer flex items-center justify-between text-sm select-none ${!isExcluded ? 'bg-blue-50 border-blue-300 text-blue-800' : 'bg-gray-100 border-gray-200 text-gray-400'}`}
|
| 254 |
-
>
|
| 255 |
-
<span>{s.seatNo ? s.seatNo+'.':''}{s.name}</span>
|
| 256 |
-
{!isExcluded && <CheckCircle size={14}/>}
|
| 257 |
-
</div>
|
| 258 |
-
);
|
| 259 |
-
})}
|
| 260 |
-
</div>
|
| 261 |
-
<div className="pt-4 border-t mt-2">
|
| 262 |
-
<button onClick={()=>setShowFilterModal(false)} className="w-full bg-blue-600 text-white py-2 rounded-lg font-bold">确定</button>
|
| 263 |
</div>
|
| 264 |
</div>
|
| 265 |
</div>
|
| 266 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 267 |
|
| 268 |
{/* Settings Modal */}
|
| 269 |
{isSettingsOpen && (
|
| 270 |
-
<div className="fixed inset-0 bg-black/
|
| 271 |
-
<div className="bg-white rounded-xl
|
| 272 |
<div className="flex justify-between items-center mb-6">
|
| 273 |
-
<h3 className="font-bold
|
| 274 |
-
<button onClick={()=>setIsSettingsOpen(false)}><X/></button>
|
| 275 |
</div>
|
| 276 |
-
|
|
|
|
| 277 |
<div>
|
| 278 |
-
<label className="text-
|
| 279 |
-
<input type="range" min="
|
|
|
|
| 280 |
</div>
|
| 281 |
<div>
|
| 282 |
-
<label className="text-
|
| 283 |
-
<input type="
|
| 284 |
</div>
|
| 285 |
|
| 286 |
-
<div className="
|
| 287 |
-
<
|
| 288 |
-
|
| 289 |
-
<input type="checkbox" checked={config.rewardConfig.enabled} onChange={e=>setConfig({...config, rewardConfig: {...config.rewardConfig, enabled: e.target.checked}})}/>
|
|
|
|
| 290 |
</div>
|
| 291 |
{config.rewardConfig.enabled && (
|
| 292 |
-
<div className="space-y-2">
|
| 293 |
-
<
|
| 294 |
-
<
|
| 295 |
-
<
|
| 296 |
-
|
|
|
|
|
|
|
| 297 |
{config.rewardConfig.type === 'ACHIEVEMENT' ? (
|
| 298 |
-
<select className="w-full border rounded p-
|
| 299 |
<option value="">选择成就</option>
|
| 300 |
-
{achievements.map(a
|
| 301 |
</select>
|
| 302 |
) : (
|
| 303 |
-
<
|
| 304 |
-
<span className="text-sm">数量:</span>
|
| 305 |
-
<input type="number" min="1" className="border rounded p-1 w-20" value={config.rewardConfig.count} onChange={e=>setConfig({...config, rewardConfig: {...config.rewardConfig, count: Number(e.target.value)}})}/>
|
| 306 |
-
</div>
|
| 307 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 308 |
</div>
|
| 309 |
)}
|
| 310 |
</div>
|
| 311 |
-
|
| 312 |
-
<button onClick={saveSettings} className="w-full bg-purple-600 text-white py-2 rounded-lg font-bold hover:bg-purple-700">保存设置</button>
|
| 313 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 314 |
</div>
|
| 315 |
</div>
|
| 316 |
)}
|
|
|
|
| 1 |
+
import React, { useState, useEffect } from 'react';
|
|
|
|
| 2 |
import { api } from '../services/api';
|
| 3 |
+
import { Student, GameMonsterConfig, AchievementConfig } from '../types';
|
| 4 |
+
import { Sword, Settings, Loader2, Save, X, Mic, Volume2, Trophy } from 'lucide-react';
|
| 5 |
|
| 6 |
export const GameMonster: React.FC = () => {
|
| 7 |
const [config, setConfig] = useState<GameMonsterConfig | null>(null);
|
| 8 |
+
const [loading, setLoading] = useState(true);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
| 10 |
+
|
| 11 |
+
// Game State
|
| 12 |
+
const [hp, setHp] = useState(100);
|
| 13 |
+
const [monsterState, setMonsterState] = useState<'IDLE' | 'HIT' | 'DEAD'>('IDLE');
|
| 14 |
+
const [volume, setVolume] = useState(0); // Simulated volume 0-100
|
| 15 |
+
const [isFighting, setIsFighting] = useState(false);
|
| 16 |
+
const [students, setStudents] = useState<Student[]>([]);
|
| 17 |
+
const [achConfig, setAchConfig] = useState<AchievementConfig | null>(null);
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
const currentUser = api.auth.getCurrentUser();
|
| 20 |
const isTeacher = currentUser?.role === 'TEACHER';
|
| 21 |
+
const homeroomClass = isTeacher ? currentUser?.homeroomClass : (currentUser?.role === 'STUDENT' ? currentUser?.class : '');
|
| 22 |
+
|
| 23 |
+
// Simulation Interval for "Microphone" volume
|
| 24 |
+
useEffect(() => {
|
| 25 |
+
let interval: any;
|
| 26 |
+
if (isFighting && monsterState !== 'DEAD') {
|
| 27 |
+
interval = setInterval(() => {
|
| 28 |
+
// Simulate volume fluctuation or use real Web Audio API if browser allows without gesture
|
| 29 |
+
const simulatedVol = Math.random() * 100;
|
| 30 |
+
setVolume(simulatedVol);
|
| 31 |
+
|
| 32 |
+
// Damage logic based on sensitivity
|
| 33 |
+
if (config && simulatedVol > (100 - config.sensitivity)) {
|
| 34 |
+
setHp(prev => {
|
| 35 |
+
const damage = config.difficulty > 0 ? (simulatedVol / 10) / config.difficulty : 1;
|
| 36 |
+
const newHp = Math.max(0, prev - damage);
|
| 37 |
+
if (newHp === 0) setMonsterState('DEAD');
|
| 38 |
+
else setMonsterState('HIT');
|
| 39 |
+
return newHp;
|
| 40 |
+
});
|
| 41 |
+
setTimeout(() => setMonsterState(prev => prev === 'DEAD' ? 'DEAD' : 'IDLE'), 200);
|
| 42 |
+
}
|
| 43 |
+
}, 500);
|
| 44 |
+
}
|
| 45 |
+
return () => clearInterval(interval);
|
| 46 |
+
}, [isFighting, config, monsterState]);
|
| 47 |
|
| 48 |
useEffect(() => {
|
| 49 |
loadData();
|
|
|
|
| 50 |
}, []);
|
| 51 |
|
| 52 |
const loadData = async () => {
|
| 53 |
+
if (!homeroomClass) {
|
| 54 |
+
setLoading(false);
|
| 55 |
+
return;
|
| 56 |
+
}
|
| 57 |
+
try {
|
| 58 |
+
const [cfg, stus, ac] = await Promise.all([
|
| 59 |
+
api.games.getMonsterConfig(homeroomClass),
|
| 60 |
+
api.students.getAll(),
|
| 61 |
+
api.achievements.getConfig(homeroomClass)
|
| 62 |
+
]);
|
| 63 |
+
|
| 64 |
+
const classStudents = stus.filter((s: Student) => s.className === homeroomClass);
|
| 65 |
+
setStudents(classStudents);
|
| 66 |
+
setAchConfig(ac);
|
| 67 |
|
| 68 |
+
if (cfg && cfg.className) {
|
| 69 |
+
setConfig(cfg);
|
| 70 |
+
} else {
|
| 71 |
+
// Default Config
|
| 72 |
+
setConfig({
|
| 73 |
+
schoolId: currentUser?.schoolId || '',
|
| 74 |
+
className: homeroomClass,
|
| 75 |
+
duration: 300,
|
| 76 |
+
sensitivity: 50,
|
| 77 |
+
difficulty: 5,
|
| 78 |
+
useKeyboardMode: false,
|
| 79 |
+
rewardConfig: {
|
| 80 |
+
enabled: true,
|
| 81 |
+
type: 'DRAW_COUNT',
|
| 82 |
+
val: '1',
|
| 83 |
+
count: 1
|
| 84 |
+
}
|
| 85 |
+
});
|
| 86 |
+
}
|
| 87 |
+
} catch (e) { console.error(e); }
|
| 88 |
+
finally { setLoading(false); }
|
| 89 |
};
|
| 90 |
|
| 91 |
+
const handleStart = () => {
|
| 92 |
+
setHp(100);
|
| 93 |
+
setMonsterState('IDLE');
|
| 94 |
+
setIsFighting(true);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
};
|
| 96 |
|
| 97 |
+
const handleStop = () => {
|
| 98 |
+
setIsFighting(false);
|
| 99 |
+
setVolume(0);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
};
|
| 101 |
|
| 102 |
+
const handleGrantReward = async () => {
|
| 103 |
+
if (!config?.rewardConfig.enabled || students.length === 0) return;
|
| 104 |
+
if (!confirm('怪兽已被击败!确认给全班同学发放奖励吗?')) return;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
|
|
|
|
|
|
|
|
|
|
| 106 |
try {
|
| 107 |
+
const promises = students.map(s => {
|
| 108 |
+
if (config.rewardConfig.type === 'ACHIEVEMENT' && config.rewardConfig.val) {
|
| 109 |
return api.achievements.grant({
|
| 110 |
studentId: s._id || String(s.id),
|
| 111 |
+
achievementId: config.rewardConfig.val,
|
| 112 |
semester: '当前学期'
|
| 113 |
});
|
| 114 |
} else {
|
| 115 |
return api.games.grantReward({
|
| 116 |
studentId: s._id || String(s.id),
|
| 117 |
+
count: config.rewardConfig.count || 1,
|
| 118 |
+
rewardType: config.rewardConfig.type,
|
| 119 |
+
name: config.rewardConfig.type === 'DRAW_COUNT' ? '早读抽奖券' : config.rewardConfig.val,
|
| 120 |
source: '早读战怪兽'
|
| 121 |
});
|
| 122 |
}
|
| 123 |
});
|
| 124 |
await Promise.all(promises);
|
| 125 |
+
alert('奖励发放成功!');
|
| 126 |
+
handleStart(); // Reset
|
| 127 |
+
} catch (e) { alert('发放失败'); }
|
| 128 |
};
|
| 129 |
|
| 130 |
+
const saveConfig = async () => {
|
| 131 |
+
if (config) await api.games.saveMonsterConfig(config);
|
| 132 |
+
setIsSettingsOpen(false);
|
|
|
|
|
|
|
| 133 |
};
|
| 134 |
|
| 135 |
+
if (loading) return <div className="h-full flex items-center justify-center"><Loader2 className="animate-spin text-blue-600"/></div>;
|
| 136 |
+
if (!config) return <div className="h-full flex items-center justify-center text-gray-400">无法加载配置 (仅限班级成员)</div>;
|
| 137 |
|
| 138 |
return (
|
| 139 |
+
<div className="h-full flex flex-col bg-slate-900 text-white overflow-hidden relative">
|
| 140 |
+
{/* Settings Button */}
|
| 141 |
+
{isTeacher && (
|
| 142 |
+
<button
|
| 143 |
+
onClick={() => setIsSettingsOpen(true)}
|
| 144 |
+
className="absolute top-4 right-4 z-20 p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors"
|
| 145 |
+
>
|
| 146 |
+
<Settings size={20} className="text-white"/>
|
| 147 |
+
</button>
|
| 148 |
+
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
|
| 150 |
+
{/* Main Game Area */}
|
| 151 |
<div className="flex-1 flex flex-col items-center justify-center relative">
|
| 152 |
+
{/* Background Effect */}
|
| 153 |
+
<div className={`absolute inset-0 transition-opacity duration-100 ${monsterState === 'HIT' ? 'bg-red-500/20' : 'bg-transparent'}`}></div>
|
| 154 |
+
|
| 155 |
{/* Monster */}
|
| 156 |
+
<div className={`relative transition-transform duration-100 ${monsterState === 'HIT' ? 'scale-95 translate-x-2' : 'scale-100'}`}>
|
| 157 |
+
<div className={`text-9xl filter drop-shadow-[0_0_20px_rgba(255,255,255,0.5)] transition-all duration-500 ${monsterState === 'DEAD' ? 'opacity-0 scale-0 rotate-180' : 'opacity-100'}`}>
|
| 158 |
+
👾
|
| 159 |
+
</div>
|
| 160 |
+
{/* HP Bar */}
|
| 161 |
+
<div className="absolute -bottom-12 left-1/2 -translate-x-1/2 w-64 h-6 bg-slate-700 rounded-full overflow-hidden border-2 border-slate-500">
|
| 162 |
+
<div
|
| 163 |
+
className={`h-full transition-all duration-200 ${hp > 50 ? 'bg-green-500' : hp > 20 ? 'bg-yellow-500' : 'bg-red-500'}`}
|
| 164 |
+
style={{ width: `${hp}%` }}
|
| 165 |
+
></div>
|
| 166 |
+
</div>
|
| 167 |
+
<div className="absolute -bottom-20 left-1/2 -translate-x-1/2 text-2xl font-black font-mono">
|
| 168 |
+
HP: {Math.ceil(hp)}%
|
| 169 |
+
</div>
|
| 170 |
</div>
|
| 171 |
|
| 172 |
+
{/* Victory Screen */}
|
| 173 |
+
{monsterState === 'DEAD' && (
|
| 174 |
+
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black/60 z-10 animate-in zoom-in">
|
| 175 |
+
<Trophy size={80} className="text-yellow-400 mb-4"/>
|
| 176 |
+
<h2 className="text-4xl font-bold mb-6 text-yellow-100">怪兽已被击败!</h2>
|
| 177 |
+
{isTeacher && (
|
| 178 |
+
<button
|
| 179 |
+
onClick={handleGrantReward}
|
| 180 |
+
className="bg-yellow-500 hover:bg-yellow-600 text-black px-8 py-3 rounded-full font-bold text-lg shadow-lg flex items-center gap-2 transform hover:scale-105 transition-all"
|
| 181 |
+
>
|
| 182 |
+
<Trophy size={20}/> 发放全班奖励
|
| 183 |
+
</button>
|
| 184 |
+
)}
|
| 185 |
+
<button onClick={handleStart} className="mt-4 text-slate-400 hover:text-white underline">再来一局</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
</div>
|
| 187 |
)}
|
| 188 |
</div>
|
| 189 |
|
| 190 |
+
{/* Controls */}
|
| 191 |
+
<div className="h-32 bg-slate-800 border-t border-slate-700 flex items-center justify-between px-8">
|
| 192 |
+
<div className="flex items-center gap-4">
|
| 193 |
+
<div className={`p-4 rounded-full ${isFighting ? 'bg-red-500 animate-pulse' : 'bg-slate-600'}`}>
|
| 194 |
+
<Mic size={24}/>
|
| 195 |
+
</div>
|
| 196 |
+
<div>
|
| 197 |
+
<div className="text-xs text-slate-400 uppercase font-bold mb-1">当前音量能量</div>
|
| 198 |
+
<div className="flex items-end gap-1 h-10">
|
| 199 |
+
{Array.from({length: 10}).map((_, i) => (
|
| 200 |
+
<div
|
| 201 |
+
key={i}
|
| 202 |
+
className={`w-3 rounded-t-sm transition-all duration-100 ${i < (volume/10) ? 'bg-green-400' : 'bg-slate-600'}`}
|
| 203 |
+
style={{ height: `${i < (volume/10) ? (Math.random()*50 + 50) : 20}%` }}
|
| 204 |
+
></div>
|
| 205 |
+
))}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
</div>
|
| 207 |
</div>
|
| 208 |
</div>
|
| 209 |
+
|
| 210 |
+
<div className="flex items-center gap-4">
|
| 211 |
+
{!isFighting && monsterState !== 'DEAD' && (
|
| 212 |
+
<button onClick={handleStart} className="bg-blue-600 hover:bg-blue-700 text-white px-8 py-3 rounded-xl font-bold flex items-center gap-2 text-lg shadow-lg shadow-blue-900/50">
|
| 213 |
+
<Sword size={20}/> 开始战斗
|
| 214 |
+
</button>
|
| 215 |
+
)}
|
| 216 |
+
{isFighting && (
|
| 217 |
+
<button onClick={handleStop} className="bg-slate-700 hover:bg-slate-600 text-white px-6 py-3 rounded-xl font-bold">
|
| 218 |
+
暂停
|
| 219 |
+
</button>
|
| 220 |
+
)}
|
| 221 |
+
</div>
|
| 222 |
+
</div>
|
| 223 |
|
| 224 |
{/* Settings Modal */}
|
| 225 |
{isSettingsOpen && (
|
| 226 |
+
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 text-slate-800">
|
| 227 |
+
<div className="bg-white rounded-xl w-full max-w-md p-6 shadow-2xl animate-in zoom-in-95">
|
| 228 |
<div className="flex justify-between items-center mb-6">
|
| 229 |
+
<h3 className="text-xl font-bold">游戏设置</h3>
|
| 230 |
+
<button onClick={() => setIsSettingsOpen(false)}><X size={24} className="text-gray-400 hover:text-gray-600"/></button>
|
| 231 |
</div>
|
| 232 |
+
|
| 233 |
+
<div className="space-y-4 mb-6">
|
| 234 |
<div>
|
| 235 |
+
<label className="block text-sm font-bold text-gray-700 mb-1">声音灵敏度 (0-100)</label>
|
| 236 |
+
<input type="range" min="0" max="90" className="w-full" value={config.sensitivity} onChange={e => setConfig({...config, sensitivity: Number(e.target.value)})}/>
|
| 237 |
+
<div className="text-xs text-right text-gray-500">{config.sensitivity} (数值越大越容易触发)</div>
|
| 238 |
</div>
|
| 239 |
<div>
|
| 240 |
+
<label className="block text-sm font-bold text-gray-700 mb-1">怪兽防御力 (难度)</label>
|
| 241 |
+
<input type="number" min="1" max="20" className="w-full border rounded p-2" value={config.difficulty} onChange={e => setConfig({...config, difficulty: Number(e.target.value)})}/>
|
| 242 |
</div>
|
| 243 |
|
| 244 |
+
<div className="border-t pt-4">
|
| 245 |
+
<label className="block text-sm font-bold text-gray-700 mb-2">通关奖励设置</label>
|
| 246 |
+
<div className="flex items-center gap-2 mb-2">
|
| 247 |
+
<input type="checkbox" checked={config.rewardConfig.enabled} onChange={e => setConfig({...config, rewardConfig: {...config.rewardConfig, enabled: e.target.checked}})}/>
|
| 248 |
+
<span className="text-sm">启用自动奖励</span>
|
| 249 |
</div>
|
| 250 |
{config.rewardConfig.enabled && (
|
| 251 |
+
<div className="bg-gray-50 p-3 rounded-lg space-y-2 border">
|
| 252 |
+
<select className="w-full border rounded p-2 text-sm" value={config.rewardConfig.type} onChange={e => setConfig({...config, rewardConfig: {...config.rewardConfig, type: e.target.value as any}})}>
|
| 253 |
+
<option value="DRAW_COUNT">🎲 抽奖券</option>
|
| 254 |
+
<option value="ITEM">🎁 物品</option>
|
| 255 |
+
<option value="ACHIEVEMENT">🏆 成就</option>
|
| 256 |
+
</select>
|
| 257 |
+
|
| 258 |
{config.rewardConfig.type === 'ACHIEVEMENT' ? (
|
| 259 |
+
<select className="w-full border rounded p-2 text-sm" value={config.rewardConfig.val} onChange={e => setConfig({...config, rewardConfig: {...config.rewardConfig, val: e.target.value}})}>
|
| 260 |
<option value="">选择成就</option>
|
| 261 |
+
{achConfig?.achievements.map(a => <option key={a.id} value={a.id}>{a.icon} {a.name}</option>)}
|
| 262 |
</select>
|
| 263 |
) : (
|
| 264 |
+
<input className="w-full border rounded p-2 text-sm" placeholder={config.rewardConfig.type === 'ITEM' ? '物品名称' : '备注'} value={config.rewardConfig.val} onChange={e => setConfig({...config, rewardConfig: {...config.rewardConfig, val: e.target.value}})}/>
|
|
|
|
|
|
|
|
|
|
| 265 |
)}
|
| 266 |
+
|
| 267 |
+
<div className="flex items-center gap-2">
|
| 268 |
+
<span className="text-sm">数量:</span>
|
| 269 |
+
<input type="number" min="1" className="border rounded p-1 w-16 text-sm" value={config.rewardConfig.count} onChange={e => setConfig({...config, rewardConfig: {...config.rewardConfig, count: Number(e.target.value)}})}/>
|
| 270 |
+
</div>
|
| 271 |
</div>
|
| 272 |
)}
|
| 273 |
</div>
|
|
|
|
|
|
|
| 274 |
</div>
|
| 275 |
+
|
| 276 |
+
<button onClick={saveConfig} className="w-full bg-blue-600 text-white py-2 rounded-lg font-bold hover:bg-blue-700 flex items-center justify-center gap-2">
|
| 277 |
+
<Save size={18}/> 保存配置
|
| 278 |
+
</button>
|
| 279 |
</div>
|
| 280 |
</div>
|
| 281 |
)}
|
pages/GameRandom.tsx
CHANGED
|
@@ -1,99 +1,202 @@
|
|
| 1 |
|
| 2 |
import React, { useState, useEffect, useRef } from 'react';
|
|
|
|
| 3 |
import { api } from '../services/api';
|
| 4 |
-
import { Student } from '../types';
|
| 5 |
-
import {
|
| 6 |
|
| 7 |
export const GameRandom: React.FC = () => {
|
|
|
|
| 8 |
const [students, setStudents] = useState<Student[]>([]);
|
| 9 |
-
const [
|
| 10 |
-
const [
|
| 11 |
-
const [displayIndex, setDisplayIndex] = useState(0);
|
| 12 |
-
const [rollCount, setRollCount] = useState(1);
|
| 13 |
-
const [showRewardModal, setShowRewardModal] = useState(false);
|
| 14 |
|
| 15 |
-
//
|
| 16 |
-
const [
|
| 17 |
-
const [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
const [rewardType, setRewardType] = useState<'DRAW_COUNT' | 'ITEM' | 'ACHIEVEMENT'>('DRAW_COUNT');
|
| 21 |
-
const [
|
| 22 |
const [rewardCount, setRewardCount] = useState(1);
|
| 23 |
-
const [
|
| 24 |
-
|
|
|
|
|
|
|
| 25 |
|
| 26 |
-
const timerRef = useRef<number | null>(null);
|
| 27 |
const currentUser = api.auth.getCurrentUser();
|
| 28 |
-
const
|
| 29 |
|
| 30 |
useEffect(() => {
|
| 31 |
loadData();
|
| 32 |
-
return () =>
|
| 33 |
}, []);
|
| 34 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
const loadData = async () => {
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
};
|
| 48 |
|
| 49 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
};
|
| 62 |
-
|
| 63 |
};
|
| 64 |
|
| 65 |
-
const
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
if (isRolling) {
|
| 71 |
-
setIsRolling(false);
|
| 72 |
-
const active = getActiveStudents();
|
| 73 |
-
// Pick unique randoms
|
| 74 |
-
const picked = new Set<number>();
|
| 75 |
-
const results: Student[] = [];
|
| 76 |
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
results.push(active[idx]);
|
| 82 |
-
}
|
| 83 |
}
|
| 84 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
}
|
| 86 |
};
|
| 87 |
|
| 88 |
const handleGrantReward = async () => {
|
| 89 |
-
if (
|
| 90 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
try {
|
| 92 |
-
const promises =
|
| 93 |
-
if (rewardType === 'ACHIEVEMENT') {
|
| 94 |
return api.achievements.grant({
|
| 95 |
studentId: s._id || String(s.id),
|
| 96 |
-
achievementId:
|
| 97 |
semester: '当前学期' // Ideally fetch from config
|
| 98 |
});
|
| 99 |
} else {
|
|
@@ -107,153 +210,219 @@ export const GameRandom: React.FC = () => {
|
|
| 107 |
}
|
| 108 |
});
|
| 109 |
await Promise.all(promises);
|
| 110 |
-
alert(`已发放奖励给 ${
|
| 111 |
-
setShowRewardModal(false);
|
| 112 |
} catch (e) {
|
|
|
|
| 113 |
alert('发放失败');
|
| 114 |
}
|
|
|
|
| 115 |
};
|
| 116 |
|
| 117 |
-
|
|
|
|
|
|
|
| 118 |
|
| 119 |
-
|
| 120 |
-
<div className=
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
{
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
</button>
|
| 128 |
-
<div className="flex items-center gap-2 border-l pl-3 ml-1 border-gray-200">
|
| 129 |
-
<span className="text-sm font-bold text-gray-500">抽取:</span>
|
| 130 |
-
<select className="border rounded-lg p-1 text-sm font-bold bg-white outline-none focus:ring-2 focus:ring-yellow-400" value={rollCount} onChange={e=>setRollCount(Number(e.target.value))}>
|
| 131 |
-
{[1,2,3,4,5,10].map(n=><option key={n} value={n}>{n}人</option>)}
|
| 132 |
-
</select>
|
| 133 |
-
</div>
|
| 134 |
</div>
|
| 135 |
-
|
| 136 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
|
| 138 |
-
|
| 139 |
-
{isRolling ? (
|
| 140 |
-
<div className="text-9xl font-black text-transparent bg-clip-text bg-gradient-to-r from-yellow-500 to-red-500 animate-pulse transition-all duration-75">
|
| 141 |
-
{activePool[displayIndex]?.name || '???'}
|
| 142 |
-
</div>
|
| 143 |
-
) : selectedStudents.length > 0 ? (
|
| 144 |
-
<div className="flex flex-wrap gap-6 justify-center max-w-4xl animate-in zoom-in duration-300">
|
| 145 |
-
{selectedStudents.map(s => (
|
| 146 |
-
<div key={s._id} className="bg-white p-6 rounded-2xl shadow-xl border-4 border-yellow-400 transform hover:scale-105 transition-transform flex flex-col items-center">
|
| 147 |
-
<div className="text-4xl font-bold text-gray-800 mb-2">{s.name}</div>
|
| 148 |
-
<div className="text-gray-400 text-sm font-mono">{s.seatNo ? `${s.seatNo}号` : ''}</div>
|
| 149 |
-
</div>
|
| 150 |
-
))}
|
| 151 |
-
</div>
|
| 152 |
-
) : (
|
| 153 |
-
<div className="text-center text-gray-400">
|
| 154 |
-
<Users size={64} className="mx-auto mb-4 opacity-20"/>
|
| 155 |
-
<p className="text-xl">准备好开始了吗?</p>
|
| 156 |
-
</div>
|
| 157 |
-
)}
|
| 158 |
-
</div>
|
| 159 |
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
<button onClick={stopRolling} className="bg-red-500 text-white px-10 py-3 rounded-full font-bold text-lg shadow-lg hover:bg-red-600 hover:scale-105 transition-all flex items-center">
|
| 175 |
-
<Square className="mr-2" fill="currentColor"/> 停止
|
| 176 |
</button>
|
| 177 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
</div>
|
| 179 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
|
| 181 |
-
{/* Filter Modal */}
|
| 182 |
-
{
|
| 183 |
-
<div className="
|
| 184 |
-
<div className="bg-white rounded-xl
|
| 185 |
-
<div className="flex justify-between items-center
|
| 186 |
-
<h3 className="font-bold text-lg"
|
| 187 |
-
<
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
</div>
|
| 192 |
-
<div className="flex-1 overflow-y-auto
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
<span className="text-sm text-gray-500">已排除 {excludedIds.size} 人</span>
|
| 215 |
-
<button onClick={()=>setShowFilterModal(false)} className="bg-blue-600 text-white px-6 py-2 rounded-lg text-sm font-bold">确定</button>
|
| 216 |
</div>
|
| 217 |
</div>
|
| 218 |
</div>
|
| 219 |
)}
|
| 220 |
|
| 221 |
-
{/*
|
| 222 |
-
{
|
| 223 |
-
<div className="fixed inset-0 bg-black/
|
| 224 |
-
<div className="bg-white rounded-
|
| 225 |
-
<
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
</div>
|
| 234 |
-
</div>
|
| 235 |
-
|
| 236 |
-
{rewardType === 'ACHIEVEMENT' ? (
|
| 237 |
-
<select className="w-full border p-2 rounded" value={achieveId} onChange={e=>setAchieveId(e.target.value)}>
|
| 238 |
-
<option value="">-- 选择成就 --</option>
|
| 239 |
-
{achievements.map(a => <option key={a.id} value={a.id}>{a.icon} {a.name}</option>)}
|
| 240 |
-
</select>
|
| 241 |
-
) : (
|
| 242 |
-
<input className="w-full border p-2 rounded" placeholder="奖励名称" value={rewardName} onChange={e=>setRewardName(e.target.value)}/>
|
| 243 |
-
)}
|
| 244 |
-
|
| 245 |
-
{rewardType !== 'ACHIEVEMENT' && (
|
| 246 |
-
<input type="number" className="w-full border p-2 rounded" placeholder="数量" min={1} value={rewardCount} onChange={e=>setRewardCount(Number(e.target.value))}/>
|
| 247 |
-
)}
|
| 248 |
-
|
| 249 |
-
<div className="flex gap-2">
|
| 250 |
-
<button onClick={handleGrantReward} className="flex-1 bg-amber-500 text-white py-2 rounded font-bold hover:bg-amber-600">确认发放</button>
|
| 251 |
-
<button onClick={()=>setShowRewardModal(false)} className="flex-1 bg-gray-100 py-2 rounded text-gray-600">取消</button>
|
| 252 |
-
</div>
|
| 253 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 254 |
</div>
|
| 255 |
</div>
|
| 256 |
)}
|
| 257 |
</div>
|
| 258 |
);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 259 |
};
|
|
|
|
| 1 |
|
| 2 |
import React, { useState, useEffect, useRef } from 'react';
|
| 3 |
+
import { createPortal } from 'react-dom';
|
| 4 |
import { api } from '../services/api';
|
| 5 |
+
import { Student, GameSession, GameTeam, AchievementConfig } from '../types';
|
| 6 |
+
import { Loader2, User, Users, Play, Pause, Gift, CheckCircle, XCircle, Award, Volume2, Settings, Maximize, Minimize, UserX, RotateCcw, Repeat } from 'lucide-react';
|
| 7 |
|
| 8 |
export const GameRandom: React.FC = () => {
|
| 9 |
+
const [loading, setLoading] = useState(true);
|
| 10 |
const [students, setStudents] = useState<Student[]>([]);
|
| 11 |
+
const [teams, setTeams] = useState<GameTeam[]>([]);
|
| 12 |
+
const [achConfig, setAchConfig] = useState<AchievementConfig | null>(null);
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
+
// Game State
|
| 15 |
+
const [mode, setMode] = useState<'STUDENT' | 'TEAM'>('STUDENT');
|
| 16 |
+
const [scopeTeamId, setScopeTeamId] = useState<string>('ALL'); // 'ALL' or specific team ID
|
| 17 |
+
const [isRunning, setIsRunning] = useState(false);
|
| 18 |
+
const [highlightIndex, setHighlightIndex] = useState<number | null>(null);
|
| 19 |
+
const [selectedResult, setSelectedResult] = useState<Student | GameTeam | null>(null);
|
| 20 |
+
const [showResultModal, setShowResultModal] = useState(false);
|
| 21 |
+
const [isFullscreen, setIsFullscreen] = useState(false);
|
| 22 |
|
| 23 |
+
// Filter State
|
| 24 |
+
const [excludedStudentIds, setExcludedStudentIds] = useState<Set<string>>(new Set());
|
| 25 |
+
const [isFilterOpen, setIsFilterOpen] = useState(false);
|
| 26 |
+
|
| 27 |
+
// Avoid Repetition State
|
| 28 |
+
const [avoidRepeat, setAvoidRepeat] = useState(false);
|
| 29 |
+
const [pickedIds, setPickedIds] = useState<Set<string>>(new Set());
|
| 30 |
+
|
| 31 |
+
// Reward State
|
| 32 |
const [rewardType, setRewardType] = useState<'DRAW_COUNT' | 'ITEM' | 'ACHIEVEMENT'>('DRAW_COUNT');
|
| 33 |
+
const [rewardId, setRewardId] = useState(''); // Achievement ID or Item Name
|
| 34 |
const [rewardCount, setRewardCount] = useState(1);
|
| 35 |
+
const [enableReward, setEnableReward] = useState(true);
|
| 36 |
+
|
| 37 |
+
const timerRef = useRef<any>(null);
|
| 38 |
+
const speedRef = useRef<number>(50);
|
| 39 |
|
|
|
|
| 40 |
const currentUser = api.auth.getCurrentUser();
|
| 41 |
+
const homeroomClass = currentUser?.homeroomClass;
|
| 42 |
|
| 43 |
useEffect(() => {
|
| 44 |
loadData();
|
| 45 |
+
return () => stopAnimation();
|
| 46 |
}, []);
|
| 47 |
|
| 48 |
+
// Clear picked history when switching modes
|
| 49 |
+
useEffect(() => {
|
| 50 |
+
setPickedIds(new Set());
|
| 51 |
+
setHighlightIndex(null);
|
| 52 |
+
}, [mode]);
|
| 53 |
+
|
| 54 |
const loadData = async () => {
|
| 55 |
+
if (!homeroomClass) return setLoading(false);
|
| 56 |
+
try {
|
| 57 |
+
const [allStus, session, ac] = await Promise.all([
|
| 58 |
+
api.students.getAll(),
|
| 59 |
+
api.games.getMountainSession(homeroomClass),
|
| 60 |
+
api.achievements.getConfig(homeroomClass)
|
| 61 |
+
]);
|
| 62 |
+
|
| 63 |
+
const classStudents = allStus.filter((s: Student) => s.className === homeroomClass);
|
| 64 |
+
// Sort by Seat No
|
| 65 |
+
classStudents.sort((a: Student, b: Student) => {
|
| 66 |
+
const seatA = parseInt(a.seatNo || '99999');
|
| 67 |
+
const seatB = parseInt(b.seatNo || '99999');
|
| 68 |
+
if (seatA !== seatB) return seatA - seatB;
|
| 69 |
+
return a.name.localeCompare(b.name, 'zh-CN');
|
| 70 |
+
});
|
| 71 |
+
|
| 72 |
+
setStudents(classStudents);
|
| 73 |
+
if (session) setTeams(session.teams || []);
|
| 74 |
+
setAchConfig(ac);
|
| 75 |
+
} catch (e) { console.error(e); }
|
| 76 |
+
finally { setLoading(false); }
|
| 77 |
};
|
| 78 |
|
| 79 |
+
const getTargetList = () => {
|
| 80 |
+
if (mode === 'TEAM') {
|
| 81 |
+
let tList = teams;
|
| 82 |
+
if (avoidRepeat) {
|
| 83 |
+
tList = tList.filter(t => !pickedIds.has(t.id));
|
| 84 |
+
}
|
| 85 |
+
return tList;
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
let baseList = students;
|
| 89 |
+
if (scopeTeamId !== 'ALL') {
|
| 90 |
+
const team = teams.find(t => t.id === scopeTeamId);
|
| 91 |
+
if (team) {
|
| 92 |
+
baseList = students.filter(s => team.members.includes(s._id || String(s.id)));
|
| 93 |
+
}
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
// Filter out excluded students (Attendance)
|
| 97 |
+
let list = baseList.filter(s => !excludedStudentIds.has(s._id || String(s.id)));
|
| 98 |
+
|
| 99 |
+
// Filter out picked students (Avoid Repetition)
|
| 100 |
+
if (avoidRepeat) {
|
| 101 |
+
list = list.filter(s => !pickedIds.has(s._id || String(s.id)));
|
| 102 |
+
}
|
| 103 |
|
| 104 |
+
return list;
|
| 105 |
+
};
|
| 106 |
+
|
| 107 |
+
const startRandom = () => {
|
| 108 |
+
const list = getTargetList();
|
| 109 |
+
if (list.length === 0) {
|
| 110 |
+
if (avoidRepeat && pickedIds.size > 0) return alert('所有对象都已点过名,请点击“重置”按钮重新开始。');
|
| 111 |
+
return alert('当前范围内没有可选对象');
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
setIsRunning(true);
|
| 115 |
+
setShowResultModal(false);
|
| 116 |
+
setSelectedResult(null);
|
| 117 |
+
speedRef.current = 50;
|
| 118 |
|
| 119 |
+
const run = () => {
|
| 120 |
+
setHighlightIndex(prev => {
|
| 121 |
+
const next = (prev === null || prev >= list.length - 1) ? 0 : prev + 1;
|
| 122 |
+
return next;
|
| 123 |
+
});
|
| 124 |
+
|
| 125 |
+
// Slow down logic
|
| 126 |
+
speedRef.current = Math.min(speedRef.current * 1.1, 600);
|
| 127 |
+
|
| 128 |
+
if (speedRef.current < 500) {
|
| 129 |
+
timerRef.current = setTimeout(run, speedRef.current);
|
| 130 |
+
} else {
|
| 131 |
+
// Stop
|
| 132 |
+
stopAnimation();
|
| 133 |
+
// IMPORTANT: The highlightIndex corresponds to the *filtered* list, not the original data
|
| 134 |
+
setTimeout(() => finalizeSelection(list), 500);
|
| 135 |
+
}
|
| 136 |
};
|
| 137 |
+
run();
|
| 138 |
};
|
| 139 |
|
| 140 |
+
const finalizeSelection = (list: any[]) => {
|
| 141 |
+
setHighlightIndex(prev => {
|
| 142 |
+
if (prev === null) return 0;
|
| 143 |
+
const result = list[prev];
|
| 144 |
+
setSelectedResult(result);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
|
| 146 |
+
// Mark as picked
|
| 147 |
+
if (avoidRepeat) {
|
| 148 |
+
const id = mode === 'TEAM' ? (result as GameTeam).id : (result as Student)._id || String((result as Student).id);
|
| 149 |
+
setPickedIds(prevSet => new Set(prevSet).add(id));
|
|
|
|
|
|
|
| 150 |
}
|
| 151 |
+
|
| 152 |
+
setShowResultModal(true);
|
| 153 |
+
return prev;
|
| 154 |
+
});
|
| 155 |
+
};
|
| 156 |
+
|
| 157 |
+
const stopAnimation = () => {
|
| 158 |
+
if (timerRef.current) clearTimeout(timerRef.current);
|
| 159 |
+
setIsRunning(false);
|
| 160 |
+
};
|
| 161 |
+
|
| 162 |
+
const resetPicked = () => {
|
| 163 |
+
if (confirm('确定要清空已点名记录,重新开始吗?')) {
|
| 164 |
+
setPickedIds(new Set());
|
| 165 |
}
|
| 166 |
};
|
| 167 |
|
| 168 |
const handleGrantReward = async () => {
|
| 169 |
+
if (!selectedResult || !enableReward) {
|
| 170 |
+
setShowResultModal(false);
|
| 171 |
+
return;
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
let targets: Student[] = [];
|
| 175 |
+
if (mode === 'STUDENT') {
|
| 176 |
+
targets = [selectedResult as Student];
|
| 177 |
+
} else {
|
| 178 |
+
// Team mode: find all students in team
|
| 179 |
+
const team = selectedResult as GameTeam;
|
| 180 |
+
// IMPORTANT: Exclude absent students from team reward
|
| 181 |
+
targets = students.filter(s => team.members.includes(s._id || String(s.id)) && !excludedStudentIds.has(s._id || String(s.id)));
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
if (targets.length === 0) {
|
| 185 |
+
alert('没有符合条件的学生可发放奖励');
|
| 186 |
+
setShowResultModal(false);
|
| 187 |
+
return;
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
const rewardName = rewardType === 'ACHIEVEMENT'
|
| 191 |
+
? achConfig?.achievements.find(a => a.id === rewardId)?.name
|
| 192 |
+
: (rewardId || (rewardType === 'DRAW_COUNT' ? '抽奖券' : '奖励'));
|
| 193 |
+
|
| 194 |
try {
|
| 195 |
+
const promises = targets.map(s => {
|
| 196 |
+
if (rewardType === 'ACHIEVEMENT' && rewardId) {
|
| 197 |
return api.achievements.grant({
|
| 198 |
studentId: s._id || String(s.id),
|
| 199 |
+
achievementId: rewardId,
|
| 200 |
semester: '当前学期' // Ideally fetch from config
|
| 201 |
});
|
| 202 |
} else {
|
|
|
|
| 210 |
}
|
| 211 |
});
|
| 212 |
await Promise.all(promises);
|
| 213 |
+
alert(`已发放奖励给 ${targets.length} 名学生!`);
|
|
|
|
| 214 |
} catch (e) {
|
| 215 |
+
console.error(e);
|
| 216 |
alert('发放失败');
|
| 217 |
}
|
| 218 |
+
setShowResultModal(false);
|
| 219 |
};
|
| 220 |
|
| 221 |
+
if (loading) return <div className="h-full flex items-center justify-center"><Loader2 className="animate-spin text-blue-600"/></div>;
|
| 222 |
+
|
| 223 |
+
const targetList = getTargetList();
|
| 224 |
|
| 225 |
+
const GameContent = (
|
| 226 |
+
<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`}>
|
| 227 |
+
{/* Floating Fullscreen Toggle Button */}
|
| 228 |
+
<button
|
| 229 |
+
onClick={() => setIsFullscreen(!isFullscreen)}
|
| 230 |
+
className={`absolute top-4 right-4 z-50 p-2 rounded-lg shadow-md transition-all flex items-center gap-2 ${
|
| 231 |
+
isFullscreen
|
| 232 |
+
? 'bg-slate-800/80 text-white hover:bg-slate-700 backdrop-blur-md border border-white/20'
|
| 233 |
+
: 'bg-white text-gray-600 hover:bg-gray-100 border border-gray-200'
|
| 234 |
+
}`}
|
| 235 |
+
title={isFullscreen ? "退出全屏" : "全屏显示"}
|
| 236 |
+
>
|
| 237 |
+
{isFullscreen ? <Minimize size={20}/> : <Maximize size={20}/>}
|
| 238 |
+
<span className="text-xs font-bold hidden sm:inline">{isFullscreen ? '退出全屏' : '全屏模式'}</span>
|
| 239 |
+
</button>
|
| 240 |
+
{/* Header Config */}
|
| 241 |
+
<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">
|
| 242 |
+
<div className="flex flex-wrap gap-4 items-center">
|
| 243 |
+
<div className="flex gap-2 bg-gray-100 p-1 rounded-lg">
|
| 244 |
+
<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'}`}>
|
| 245 |
+
<User size={16} className="inline mr-1"/> 点名学生
|
| 246 |
+
</button>
|
| 247 |
+
<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'}`}>
|
| 248 |
+
<Users size={16} className="inline mr-1"/> 随机小组
|
| 249 |
</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 250 |
</div>
|
| 251 |
+
{mode === 'STUDENT' && (
|
| 252 |
+
<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)}>
|
| 253 |
+
<option value="ALL">全班范围</option>
|
| 254 |
+
{teams.map(t => <option key={t.id} value={t.id}>{t.name} (组)</option>)}
|
| 255 |
+
</select>
|
| 256 |
+
)}
|
| 257 |
+
|
| 258 |
+
<button
|
| 259 |
+
onClick={() => setIsFilterOpen(true)}
|
| 260 |
+
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'}`}
|
| 261 |
+
>
|
| 262 |
+
<UserX size={16}/> 排除 ({excludedStudentIds.size})
|
| 263 |
+
</button>
|
| 264 |
|
| 265 |
+
<div className="h-8 w-px bg-gray-200 mx-2"></div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 266 |
|
| 267 |
+
<button
|
| 268 |
+
onClick={() => setAvoidRepeat(!avoidRepeat)}
|
| 269 |
+
className={`flex items-center gap-1 px-3 py-2 rounded-lg text-sm border font-medium transition-colors ${avoidRepeat ? 'bg-indigo-50 text-indigo-600 border-indigo-200' : 'bg-white text-gray-500 border-gray-200 hover:bg-gray-50'}`}
|
| 270 |
+
>
|
| 271 |
+
<Repeat size={16} className={avoidRepeat ? '' : 'text-gray-400'}/> {avoidRepeat ? '不重复点名' : '允许重复'}
|
| 272 |
+
</button>
|
| 273 |
+
|
| 274 |
+
{avoidRepeat && pickedIds.size > 0 && (
|
| 275 |
+
<button
|
| 276 |
+
onClick={resetPicked}
|
| 277 |
+
className="flex items-center gap-1 px-3 py-2 rounded-lg text-sm font-medium text-gray-500 hover:text-red-500 hover:bg-red-50 transition-colors"
|
| 278 |
+
title="清空已点名记录"
|
| 279 |
+
>
|
| 280 |
+
<RotateCcw size={14}/> 重置 ({pickedIds.size})
|
|
|
|
|
|
|
| 281 |
</button>
|
| 282 |
)}
|
| 283 |
+
|
| 284 |
+
<div className="h-8 w-px bg-gray-200 mx-2"></div>
|
| 285 |
+
|
| 286 |
+
<div className="flex items-center gap-2 bg-amber-50 px-3 py-2 rounded-lg border border-amber-100">
|
| 287 |
+
<Gift size={16} className="text-amber-500"/>
|
| 288 |
+
<label className="text-sm font-bold text-gray-700">奖励:</label>
|
| 289 |
+
<select className="text-sm border-gray-300 rounded bg-white outline-none" value={rewardType} onChange={e=>setRewardType(e.target.value as any)}>
|
| 290 |
+
<option value="DRAW_COUNT">抽奖券</option>
|
| 291 |
+
<option value="ITEM">实物</option>
|
| 292 |
+
<option value="ACHIEVEMENT">成就</option>
|
| 293 |
+
</select>
|
| 294 |
+
{rewardType === 'ACHIEVEMENT' ? (
|
| 295 |
+
<select className="text-sm border-gray-300 rounded w-32 bg-white outline-none" value={rewardId} onChange={e=>setRewardId(e.target.value)}>
|
| 296 |
+
<option value="">选择成就</option>
|
| 297 |
+
{achConfig?.achievements.map(a => <option key={a.id} value={a.id}>{a.icon} {a.name}</option>)}
|
| 298 |
+
</select>
|
| 299 |
+
) : rewardType === 'ITEM' ? (
|
| 300 |
+
<input className="text-sm border-gray-300 rounded w-24 p-1 outline-none" placeholder="奖品名" value={rewardId} onChange={e=>setRewardId(e.target.value)}/>
|
| 301 |
+
) : null}
|
| 302 |
+
<span className="text-xs text-gray-400">x</span>
|
| 303 |
+
<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))}/>
|
| 304 |
+
</div>
|
| 305 |
</div>
|
| 306 |
+
</div>
|
| 307 |
+
{/* Grid Area */}
|
| 308 |
+
<div className="flex-1 overflow-y-auto p-6 custom-scrollbar bg-slate-50">
|
| 309 |
+
<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'}`}>
|
| 310 |
+
{targetList.map((item, idx) => {
|
| 311 |
+
const isHighlighted = idx === highlightIndex;
|
| 312 |
+
const isTeam = mode === 'TEAM';
|
| 313 |
+
// @ts-ignore
|
| 314 |
+
const name = isTeam ? item.name : item.name;
|
| 315 |
+
// @ts-ignore
|
| 316 |
+
const avatar = isTeam ? (item.avatar || '🚩') : (item.seatNo || 'User');
|
| 317 |
+
// @ts-ignore
|
| 318 |
+
const subText = isTeam ? `${item.members.length}人` : item.studentNo;
|
| 319 |
+
// @ts-ignore
|
| 320 |
+
const color = isTeam ? (item.color || '#3b82f6') : '#3b82f6';
|
| 321 |
+
return (
|
| 322 |
+
<div key={idx} className={`relative aspect-square flex flex-col items-center justify-center rounded-2xl border-4 transition-all duration-100 ${
|
| 323 |
+
isHighlighted
|
| 324 |
+
? 'bg-yellow-100 border-yellow-400 scale-110 shadow-2xl z-10'
|
| 325 |
+
: 'bg-white border-gray-100 shadow-sm opacity-80'
|
| 326 |
+
}`}>
|
| 327 |
+
{isHighlighted && <div className="absolute inset-0 bg-yellow-400 opacity-20 animate-pulse rounded-xl"></div>}
|
| 328 |
+
<div className={`mb-2 font-bold ${isFullscreen ? 'text-5xl' : 'text-3xl'}`} style={{color: isTeam ? color : '#64748b'}}>
|
| 329 |
+
{mode === 'STUDENT' ? (
|
| 330 |
+
<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'}`}>
|
| 331 |
+
{name[0]}
|
| 332 |
+
</div>
|
| 333 |
+
) : avatar}
|
| 334 |
+
</div>
|
| 335 |
+
<div className={`font-bold text-gray-800 text-center truncate w-full px-2 ${isFullscreen ? 'text-2xl' : 'text-base'}`}>{name}</div>
|
| 336 |
+
<div className="text-xs text-gray-400">{subText}</div>
|
| 337 |
+
</div>
|
| 338 |
+
);
|
| 339 |
+
})}
|
| 340 |
+
</div>
|
| 341 |
+
</div>
|
| 342 |
+
{/* Start Button */}
|
| 343 |
+
<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)]">
|
| 344 |
+
<button
|
| 345 |
+
onClick={isRunning ? stopAnimation : startRandom}
|
| 346 |
+
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'}`}
|
| 347 |
+
>
|
| 348 |
+
{isRunning ? <><Pause fill="white"/> 停止</> : <><Play fill="white"/> 开始随机</>}
|
| 349 |
+
</button>
|
| 350 |
+
</div>
|
| 351 |
|
| 352 |
+
{/* Student Filter Modal */}
|
| 353 |
+
{isFilterOpen && (
|
| 354 |
+
<div className="absolute inset-0 bg-black/50 z-[1000000] flex items-center justify-center p-4">
|
| 355 |
+
<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">
|
| 356 |
+
<div className="p-4 border-b flex justify-between items-center">
|
| 357 |
+
<h3 className="font-bold text-lg">排除请假/缺勤学生</h3>
|
| 358 |
+
<div className="flex gap-2">
|
| 359 |
+
<button onClick={() => setExcludedStudentIds(new Set())} className="text-xs text-blue-600 hover:underline">清空选择</button>
|
| 360 |
+
<button onClick={() => setIsFilterOpen(false)} className="px-4 py-1 bg-blue-600 text-white rounded text-sm">确定</button>
|
| 361 |
+
</div>
|
| 362 |
</div>
|
| 363 |
+
<div className="flex-1 overflow-y-auto p-4 bg-gray-50">
|
| 364 |
+
<div className="grid grid-cols-4 sm:grid-cols-5 md:grid-cols-6 gap-2">
|
| 365 |
+
{students.map(s => {
|
| 366 |
+
const isExcluded = excludedStudentIds.has(s._id || String(s.id));
|
| 367 |
+
return (
|
| 368 |
+
<div
|
| 369 |
+
key={s._id}
|
| 370 |
+
onClick={() => {
|
| 371 |
+
const newSet = new Set(excludedStudentIds);
|
| 372 |
+
if (isExcluded) newSet.delete(s._id || String(s.id));
|
| 373 |
+
else newSet.add(s._id || String(s.id));
|
| 374 |
+
setExcludedStudentIds(newSet);
|
| 375 |
+
}}
|
| 376 |
+
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'}`}
|
| 377 |
+
>
|
| 378 |
+
<div className="font-bold">{s.name}</div>
|
| 379 |
+
<div className="text-xs opacity-70">{s.seatNo}</div>
|
| 380 |
+
{isExcluded && <div className="text-[10px] font-bold mt-1">已排除</div>}
|
| 381 |
+
</div>
|
| 382 |
+
)
|
| 383 |
+
})}
|
| 384 |
+
</div>
|
|
|
|
|
|
|
| 385 |
</div>
|
| 386 |
</div>
|
| 387 |
</div>
|
| 388 |
)}
|
| 389 |
|
| 390 |
+
{/* Result Modal */}
|
| 391 |
+
{showResultModal && selectedResult && (
|
| 392 |
+
<div className="fixed inset-0 bg-black/60 z-[110] flex items-center justify-center p-4 backdrop-blur-sm animate-in fade-in">
|
| 393 |
+
<div className="bg-white rounded-3xl p-8 w-full max-w-md text-center shadow-2xl relative animate-in zoom-in-95">
|
| 394 |
+
<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">
|
| 395 |
+
<span className="text-5xl">✨</span>
|
| 396 |
+
</div>
|
| 397 |
+
|
| 398 |
+
<h2 className="mt-10 text-gray-500 text-sm font-bold uppercase tracking-widest">选中对象</h2>
|
| 399 |
+
<div className="text-5xl font-black text-gray-800 my-6">
|
| 400 |
+
{/* @ts-ignore */}
|
| 401 |
+
{selectedResult.name}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 402 |
</div>
|
| 403 |
+
{/* @ts-ignore */}
|
| 404 |
+
<div className="text-gray-400 mb-8">{mode==='STUDENT' ? `座号: ${selectedResult.seatNo || '-'}` : `${selectedResult.members.length} 位成员`}</div>
|
| 405 |
+
<div className="grid grid-cols-2 gap-4 mb-4">
|
| 406 |
+
<button onClick={() => { setEnableReward(true); handleGrantReward(); }} 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">
|
| 407 |
+
<CheckCircle size={32} className="mb-1"/>
|
| 408 |
+
回答正确
|
| 409 |
+
<span className="text-xs font-normal opacity-80 mt-1">发放奖励</span>
|
| 410 |
+
</button>
|
| 411 |
+
<button onClick={() => { setEnableReward(false); handleGrantReward(); }} 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">
|
| 412 |
+
<XCircle size={32} className="mb-1"/>
|
| 413 |
+
回答错误
|
| 414 |
+
<span className="text-xs font-normal opacity-80 mt-1">无奖励</span>
|
| 415 |
+
</button>
|
| 416 |
+
</div>
|
| 417 |
+
<button onClick={() => setShowResultModal(false)} className="text-gray-400 hover:text-gray-600 text-sm underline">跳过 / 关闭</button>
|
| 418 |
</div>
|
| 419 |
</div>
|
| 420 |
)}
|
| 421 |
</div>
|
| 422 |
);
|
| 423 |
+
|
| 424 |
+
if (isFullscreen) {
|
| 425 |
+
return createPortal(GameContent, document.body);
|
| 426 |
+
}
|
| 427 |
+
return GameContent;
|
| 428 |
};
|
pages/GameZen.tsx
CHANGED
|
@@ -1,410 +1,276 @@
|
|
| 1 |
-
|
| 2 |
import React, { useState, useEffect, useRef } from 'react';
|
| 3 |
import { api } from '../services/api';
|
| 4 |
-
import { Student,
|
| 5 |
-
import { Moon,
|
| 6 |
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
const [quietTime, setQuietTime] = useState(0); // Ms in quiet state
|
| 13 |
-
const [startTime, setStartTime] = useState(0);
|
| 14 |
-
const [gameResult, setGameResult] = useState<'SUCCESS' | 'FAIL' | null>(null);
|
| 15 |
|
| 16 |
-
|
| 17 |
-
const [
|
|
|
|
| 18 |
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
const [excludedIds, setExcludedIds] = useState<Set<string>>(new Set());
|
| 22 |
-
const [showFilterModal, setShowFilterModal] = useState(false);
|
| 23 |
|
| 24 |
-
//
|
| 25 |
-
const
|
| 26 |
-
const
|
| 27 |
-
const
|
| 28 |
-
|
| 29 |
-
const
|
| 30 |
-
const lastTimeRef = useRef(0);
|
| 31 |
|
| 32 |
const currentUser = api.auth.getCurrentUser();
|
| 33 |
const isTeacher = currentUser?.role === 'TEACHER';
|
| 34 |
-
|
| 35 |
-
// 4 States: 0=Deep(Quiet), 1=Focused, 2=Restless, 3=Chaos
|
| 36 |
-
const [zenState, setZenState] = useState(0);
|
| 37 |
|
| 38 |
useEffect(() => {
|
| 39 |
loadData();
|
| 40 |
-
return () =>
|
| 41 |
}, []);
|
| 42 |
|
| 43 |
const loadData = async () => {
|
| 44 |
-
if (
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
setStudents(all.filter((s: Student) => s.className === currentUser.homeroomClass));
|
| 48 |
-
|
| 49 |
-
const ac = await api.achievements.getConfig(currentUser.homeroomClass);
|
| 50 |
-
if (ac) setAchievements(ac.achievements);
|
| 51 |
-
|
| 52 |
-
let cfg = await api.games.getZenConfig(currentUser.homeroomClass);
|
| 53 |
-
if (!cfg) {
|
| 54 |
-
cfg = {
|
| 55 |
-
schoolId: currentUser.schoolId || '',
|
| 56 |
-
className: currentUser.homeroomClass,
|
| 57 |
-
durationMinutes: 40,
|
| 58 |
-
threshold: 30,
|
| 59 |
-
passRate: 90,
|
| 60 |
-
rewardConfig: { enabled: true, type: 'DRAW_COUNT', val: '1', count: 1 }
|
| 61 |
-
};
|
| 62 |
-
}
|
| 63 |
-
setConfig(cfg);
|
| 64 |
-
} catch(e) { console.error(e); }
|
| 65 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
};
|
| 67 |
|
| 68 |
-
const
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
setQuietTime(0);
|
| 82 |
-
smoothedVolRef.current = 0;
|
| 83 |
-
setZenState(0);
|
| 84 |
-
|
| 85 |
-
setStartTime(Date.now());
|
| 86 |
-
lastTimeRef.current = Date.now();
|
| 87 |
-
|
| 88 |
-
processAudio();
|
| 89 |
-
} catch (e) { alert('无法访问麦克风'); }
|
| 90 |
};
|
| 91 |
|
| 92 |
-
const
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
const now = Date.now();
|
| 96 |
-
const delta = now - lastTimeRef.current;
|
| 97 |
-
lastTimeRef.current = now;
|
| 98 |
-
|
| 99 |
-
// 1. Get Volume
|
| 100 |
-
const dataArray = new Uint8Array(analyserRef.current.frequencyBinCount);
|
| 101 |
-
analyserRef.current.getByteFrequencyData(dataArray);
|
| 102 |
-
const rawAvg = dataArray.reduce((a,b)=>a+b)/dataArray.length;
|
| 103 |
-
|
| 104 |
-
// 2. Smooth Volume (Fast Attack, Slow Decay)
|
| 105 |
-
// Attack: 0.2 (react fast to noise), Decay: 0.05 (calm down slowly)
|
| 106 |
-
const smoothingFactor = rawAvg > smoothedVolRef.current ? 0.2 : 0.05;
|
| 107 |
-
smoothedVolRef.current = smoothedVolRef.current * (1 - smoothingFactor) + rawAvg * smoothingFactor;
|
| 108 |
-
setVolume(smoothedVolRef.current);
|
| 109 |
-
|
| 110 |
-
// 3. Determine State
|
| 111 |
-
const threshold = config.threshold;
|
| 112 |
-
let currentState = 0;
|
| 113 |
-
if (smoothedVolRef.current < threshold * 0.5) currentState = 0; // Deep
|
| 114 |
-
else if (smoothedVolRef.current < threshold) currentState = 1; // Focused
|
| 115 |
-
else if (smoothedVolRef.current < threshold * 1.5) currentState = 2; // Restless
|
| 116 |
-
else currentState = 3; // Chaos
|
| 117 |
-
setZenState(currentState);
|
| 118 |
-
|
| 119 |
-
// 4. Update Time
|
| 120 |
-
if (gameTime < config.durationMinutes * 60 * 1000) {
|
| 121 |
-
setGameTime(prev => prev + delta);
|
| 122 |
-
// Only count Quiet(0) and Focused(1) as valid time
|
| 123 |
-
if (currentState <= 1) {
|
| 124 |
-
setQuietTime(prev => prev + delta);
|
| 125 |
-
}
|
| 126 |
-
} else {
|
| 127 |
-
endGame(); // Time's up
|
| 128 |
-
return;
|
| 129 |
-
}
|
| 130 |
-
|
| 131 |
-
if (isPlaying) animationFrameRef.current = requestAnimationFrame(processAudio);
|
| 132 |
};
|
| 133 |
|
| 134 |
-
const
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
};
|
| 139 |
|
| 140 |
-
const
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
return;
|
| 147 |
-
}
|
| 148 |
-
|
| 149 |
-
const score = (quietTime / totalTime) * 100;
|
| 150 |
-
const passed = score >= config.passRate;
|
| 151 |
-
|
| 152 |
-
setGameResult(passed ? 'SUCCESS' : 'FAIL');
|
| 153 |
-
|
| 154 |
-
if (passed && config.rewardConfig.enabled) {
|
| 155 |
-
grantBatchReward(config.rewardConfig);
|
| 156 |
-
}
|
| 157 |
};
|
| 158 |
|
| 159 |
-
const
|
| 160 |
-
|
| 161 |
-
if (!
|
|
|
|
| 162 |
try {
|
| 163 |
-
const promises =
|
| 164 |
-
if (rewardConfig.type === 'ACHIEVEMENT') {
|
| 165 |
return api.achievements.grant({
|
| 166 |
studentId: s._id || String(s.id),
|
| 167 |
-
achievementId: rewardConfig.val,
|
| 168 |
semester: '当前学期'
|
| 169 |
});
|
| 170 |
} else {
|
| 171 |
return api.games.grantReward({
|
| 172 |
studentId: s._id || String(s.id),
|
| 173 |
-
count: rewardConfig.count,
|
| 174 |
-
rewardType: rewardConfig.type,
|
| 175 |
-
name: rewardConfig.type === 'DRAW_COUNT' ? '
|
| 176 |
-
source: '禅道修行'
|
| 177 |
});
|
| 178 |
}
|
| 179 |
});
|
| 180 |
await Promise.all(promises);
|
| 181 |
-
|
|
|
|
|
|
|
| 182 |
};
|
| 183 |
|
| 184 |
-
const
|
| 185 |
if (config) {
|
| 186 |
await api.games.saveZenConfig(config);
|
| 187 |
-
|
| 188 |
}
|
|
|
|
| 189 |
};
|
| 190 |
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
const m = Math.floor(sec / 60);
|
| 194 |
-
const s = sec % 60;
|
| 195 |
-
return `${m.toString().padStart(2,'0')}:${s.toString().padStart(2,'0')}`;
|
| 196 |
-
};
|
| 197 |
-
|
| 198 |
-
// State Styles Map
|
| 199 |
-
const stateConfig = [
|
| 200 |
-
{ color: 'from-emerald-900 to-teal-800', textColor: 'text-emerald-100', label: '极静', icon: <Moon size={48} className="text-emerald-200 drop-shadow-[0_0_15px_rgba(52,211,153,0.8)]"/>, msg: '心如止水' },
|
| 201 |
-
{ color: 'from-green-800 to-emerald-700', textColor: 'text-green-100', label: '专注', icon: <Wind size={48} className="text-green-200"/>, msg: '渐入佳境' },
|
| 202 |
-
{ color: 'from-amber-700 to-orange-800', textColor: 'text-orange-100', label: '浮躁', icon: <CloudRain size={48} className="text-orange-200 animate-bounce"/>, msg: '有些嘈杂...' },
|
| 203 |
-
{ color: 'from-red-900 to-rose-800', textColor: 'text-red-100', label: '喧哗', icon: <Zap size={48} className="text-red-200 animate-pulse"/>, msg: '太吵了!!!' },
|
| 204 |
-
];
|
| 205 |
-
|
| 206 |
-
const currentState = stateConfig[zenState];
|
| 207 |
-
const currentScore = gameTime > 0 ? (quietTime / gameTime * 100).toFixed(1) : '100.0';
|
| 208 |
-
|
| 209 |
-
if (!config) return <div className="p-10 text-center text-white">加载配置...</div>;
|
| 210 |
|
| 211 |
return (
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
)}
|
| 227 |
-
</div>
|
| 228 |
-
|
| 229 |
-
<div className="flex-1 flex flex-col items-center justify-center relative">
|
| 230 |
-
{/* Monk / Centerpiece */}
|
| 231 |
-
<div className={`relative transition-all duration-500 flex flex-col items-center ${zenState >= 2 ? 'scale-110' : 'scale-100'}`}>
|
| 232 |
-
{/* Aura */}
|
| 233 |
-
{zenState === 0 && <div className="absolute inset-0 bg-emerald-400/20 blur-3xl rounded-full animate-pulse scale-150"></div>}
|
| 234 |
-
{zenState === 3 && <div className="absolute inset-0 bg-red-500/20 blur-2xl rounded-full animate-ping"></div>}
|
| 235 |
-
|
| 236 |
-
<div className="mb-6 transform transition-all duration-300">
|
| 237 |
-
{currentState.icon}
|
| 238 |
-
</div>
|
| 239 |
-
|
| 240 |
-
<div className={`text-4xl font-bold mb-2 ${currentState.textColor} drop-shadow-md`}>{currentState.label}</div>
|
| 241 |
-
<div className="text-sm text-white/60 mb-8">{currentState.msg}</div>
|
| 242 |
-
|
| 243 |
-
{/* Main Timer */}
|
| 244 |
-
<div className="text-6xl font-mono font-bold text-white tracking-widest drop-shadow-lg">
|
| 245 |
-
{formatTime(Math.max(0, config.durationMinutes * 60 * 1000 - gameTime))}
|
| 246 |
-
</div>
|
| 247 |
-
</div>
|
| 248 |
-
|
| 249 |
-
{/* Stats Bar */}
|
| 250 |
-
<div className="mt-12 flex gap-8 items-center bg-black/20 p-4 rounded-2xl backdrop-blur-md border border-white/10">
|
| 251 |
-
<div className="text-center">
|
| 252 |
-
<div className="text-xs text-white/50 mb-1">当前音量</div>
|
| 253 |
-
<div className="flex items-center gap-2">
|
| 254 |
-
<div className="w-24 h-2 bg-white/20 rounded-full overflow-hidden">
|
| 255 |
-
<div className={`h-full transition-all duration-100 ${zenState>=2?'bg-red-400':'bg-emerald-400'}`} style={{width: `${(volume/255)*100}%`}}></div>
|
| 256 |
-
</div>
|
| 257 |
-
<span className="text-sm font-mono text-white w-8">{Math.round(volume)}</span>
|
| 258 |
-
</div>
|
| 259 |
-
</div>
|
| 260 |
-
<div className="w-px h-8 bg-white/10"></div>
|
| 261 |
-
<div className="text-center">
|
| 262 |
-
<div className="text-xs text-white/50 mb-1">专注评分</div>
|
| 263 |
-
<div className={`text-xl font-bold font-mono ${Number(currentScore)>=config.passRate ? 'text-green-300' : 'text-red-300'}`}>
|
| 264 |
-
{currentScore}
|
| 265 |
-
</div>
|
| 266 |
-
</div>
|
| 267 |
-
</div>
|
| 268 |
-
|
| 269 |
-
{!isPlaying && !gameResult && (
|
| 270 |
-
<button onClick={startGame} className="mt-12 bg-white/10 hover:bg-white/20 text-white border border-white/40 px-10 py-4 rounded-full font-bold text-lg flex items-center shadow-lg transition-all hover:scale-105 backdrop-blur-sm">
|
| 271 |
-
<Play className="mr-2" fill="currentColor"/> 开始修习
|
| 272 |
-
</button>
|
| 273 |
-
)}
|
| 274 |
-
|
| 275 |
-
{isPlaying && (
|
| 276 |
-
<button onClick={endGame} className="mt-12 text-white/50 hover:text-white text-sm border-b border-transparent hover:border-white transition-colors">
|
| 277 |
-
提前结束
|
| 278 |
-
</button>
|
| 279 |
-
)}
|
| 280 |
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
<>
|
| 293 |
-
<X size={80} className="text-red-400 mb-4"/>
|
| 294 |
-
<h2 className="text-4xl font-black text-white mb-2">修行失败</h2>
|
| 295 |
-
<p className="text-red-300 text-lg mb-6">最终评分: {currentScore} (未达 {config.passRate})</p>
|
| 296 |
-
<p className="text-white/50 text-sm">心浮气躁,难成大器。下次再来。</p>
|
| 297 |
-
</>
|
| 298 |
-
)}
|
| 299 |
-
<button onClick={() => { setGameResult(null); setGameTime(0); setQuietTime(0); }} className="mt-8 bg-white text-slate-900 px-8 py-3 rounded-full font-bold hover:scale-105 transition-transform">返回</button>
|
| 300 |
-
</div>
|
| 301 |
-
)}
|
| 302 |
-
</div>
|
| 303 |
-
|
| 304 |
-
{/* Settings Modal */}
|
| 305 |
-
{isSettingsOpen && (
|
| 306 |
-
<div className="fixed inset-0 bg-black/80 z-50 flex items-center justify-center p-4 text-gray-800">
|
| 307 |
-
<div className="bg-white rounded-xl p-6 w-full max-w-md animate-in zoom-in-95">
|
| 308 |
-
<div className="flex justify-between items-center mb-6">
|
| 309 |
-
<h3 className="font-bold text-lg">修行设置</h3>
|
| 310 |
-
<button onClick={()=>setIsSettingsOpen(false)}><X/></button>
|
| 311 |
-
</div>
|
| 312 |
-
<div className="space-y-4">
|
| 313 |
-
<div>
|
| 314 |
-
<label className="text-xs font-bold text-gray-500 uppercase block mb-1">测试当前环境音量</label>
|
| 315 |
-
<div className="flex items-center gap-2 bg-gray-100 p-2 rounded">
|
| 316 |
-
<Volume2 size={16}/>
|
| 317 |
-
<div className="flex-1 h-2 bg-gray-300 rounded overflow-hidden">
|
| 318 |
-
<div className="bg-blue-500 h-full transition-all" style={{width: `${(volume/255)*100}%`}}></div>
|
| 319 |
-
</div>
|
| 320 |
-
<span className="text-xs font-mono">{Math.round(volume)}</span>
|
| 321 |
-
<button onClick={()=>startGame().then(()=>setTimeout(stopGame, 5000))} className="text-xs bg-blue-100 text-blue-600 px-2 py-1 rounded">测试5秒</button>
|
| 322 |
-
</div>
|
| 323 |
-
</div>
|
| 324 |
-
<div className="grid grid-cols-2 gap-4">
|
| 325 |
-
<div>
|
| 326 |
-
<label className="text-xs font-bold text-gray-500 uppercase block mb-1">时长 (分钟)</label>
|
| 327 |
-
<input type="number" className="w-full border rounded p-2" value={config.durationMinutes} onChange={e=>setConfig({...config, durationMinutes: Number(e.target.value)})}/>
|
| 328 |
-
</div>
|
| 329 |
-
<div>
|
| 330 |
-
<label className="text-xs font-bold text-gray-500 uppercase block mb-1">及格分 (%)</label>
|
| 331 |
-
<input type="number" className="w-full border rounded p-2" value={config.passRate} onChange={e=>setConfig({...config, passRate: Number(e.target.value)})}/>
|
| 332 |
-
</div>
|
| 333 |
-
</div>
|
| 334 |
-
<div>
|
| 335 |
-
<label className="text-xs font-bold text-gray-500 uppercase block mb-1">安静阈值 (建议设为环境音+10)</label>
|
| 336 |
-
<div className="flex items-center gap-2">
|
| 337 |
-
<input type="range" min="1" max="100" className="flex-1" value={config.threshold} onChange={e=>setConfig({...config, threshold: Number(e.target.value)})}/>
|
| 338 |
-
<span className="w-8 text-center font-bold text-sm">{config.threshold}</span>
|
| 339 |
-
</div>
|
| 340 |
-
<button onClick={()=>setConfig({...config, threshold: Math.round(volume)+10})} className="text-xs text-blue-600 underline mt-1">设为当前环境音+10</button>
|
| 341 |
-
</div>
|
| 342 |
-
<div className="bg-gray-50 p-4 rounded-lg border border-gray-200">
|
| 343 |
-
<div className="flex items-center justify-between mb-2">
|
| 344 |
-
<label className="font-bold text-sm">完成奖励</label>
|
| 345 |
-
<input type="checkbox" checked={config.rewardConfig.enabled} onChange={e=>setConfig({...config, rewardConfig: {...config.rewardConfig, enabled: e.target.checked}})}/>
|
| 346 |
-
</div>
|
| 347 |
-
{config.rewardConfig.enabled && (
|
| 348 |
-
<div className="space-y-2">
|
| 349 |
-
<div className="flex gap-2">
|
| 350 |
-
<button onClick={()=>setConfig({...config, rewardConfig:{...config.rewardConfig, type:'DRAW_COUNT'}})} className={`flex-1 text-xs py-1.5 rounded border ${config.rewardConfig.type==='DRAW_COUNT'?'bg-blue-50 border-blue-500 text-blue-600':'bg-white'}`}>抽奖券</button>
|
| 351 |
-
<button onClick={()=>setConfig({...config, rewardConfig:{...config.rewardConfig, type:'ACHIEVEMENT'}})} className={`flex-1 text-xs py-1.5 rounded border ${config.rewardConfig.type==='ACHIEVEMENT'?'bg-blue-50 border-blue-500 text-blue-600':'bg-white'}`}>成就</button>
|
| 352 |
-
</div>
|
| 353 |
-
{config.rewardConfig.type === 'ACHIEVEMENT' ? (
|
| 354 |
-
<select className="w-full border rounded p-1.5 text-sm" value={config.rewardConfig.val} onChange={e=>setConfig({...config, rewardConfig: {...config.rewardConfig, val: e.target.value}})}>
|
| 355 |
-
<option value="">选择成就</option>
|
| 356 |
-
{achievements.map(a=><option key={a.id} value={a.id}>{a.name}</option>)}
|
| 357 |
-
</select>
|
| 358 |
-
) : (
|
| 359 |
-
<div className="flex items-center gap-2">
|
| 360 |
-
<span className="text-sm">数量:</span>
|
| 361 |
-
<input type="number" min="1" className="border rounded p-1 w-20" value={config.rewardConfig.count} onChange={e=>setConfig({...config, rewardConfig: {...config.rewardConfig, count: Number(e.target.value)}})}/>
|
| 362 |
-
</div>
|
| 363 |
-
)}
|
| 364 |
-
</div>
|
| 365 |
-
)}
|
| 366 |
-
</div>
|
| 367 |
-
<button onClick={saveSettings} className="w-full bg-emerald-600 text-white py-2 rounded-lg font-bold hover:bg-emerald-700">保存设置</button>
|
| 368 |
-
</div>
|
| 369 |
-
</div>
|
| 370 |
-
</div>
|
| 371 |
-
)}
|
| 372 |
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
<div className="flex justify-between items-center mb-4">
|
| 378 |
-
<h3 className="font-bold text-lg">参与人员筛选 (不参与奖励)</h3>
|
| 379 |
-
<button onClick={()=>setShowFilterModal(false)}><X size={20}/></button>
|
| 380 |
</div>
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 401 |
</div>
|
| 402 |
-
|
| 403 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 404 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 405 |
</div>
|
| 406 |
</div>
|
| 407 |
)}
|
| 408 |
-
|
| 409 |
);
|
| 410 |
};
|
|
|
|
|
|
|
| 1 |
import React, { useState, useEffect, useRef } from 'react';
|
| 2 |
import { api } from '../services/api';
|
| 3 |
+
import { Student, GameZenConfig, AchievementConfig } from '../types';
|
| 4 |
+
import { Moon, Play, Square, Settings, Save, X, Loader2, Clock, CheckCircle } from 'lucide-react';
|
| 5 |
|
| 6 |
+
const formatTime = (seconds: number) => {
|
| 7 |
+
const mins = Math.floor(seconds / 60);
|
| 8 |
+
const secs = seconds % 60;
|
| 9 |
+
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
| 10 |
+
};
|
|
|
|
|
|
|
|
|
|
| 11 |
|
| 12 |
+
export const GameZen: React.FC = () => {
|
| 13 |
+
const [config, setConfig] = useState<GameZenConfig | null>(null);
|
| 14 |
+
const [loading, setLoading] = useState(true);
|
| 15 |
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
| 16 |
+
const [students, setStudents] = useState<Student[]>([]);
|
| 17 |
+
const [achConfig, setAchConfig] = useState<AchievementConfig | null>(null);
|
|
|
|
|
|
|
| 18 |
|
| 19 |
+
// Game State
|
| 20 |
+
const [timeLeft, setTimeLeft] = useState(0);
|
| 21 |
+
const [isActive, setIsActive] = useState(false);
|
| 22 |
+
const [isFinished, setIsFinished] = useState(false);
|
| 23 |
+
|
| 24 |
+
const timerRef = useRef<any>(null);
|
|
|
|
| 25 |
|
| 26 |
const currentUser = api.auth.getCurrentUser();
|
| 27 |
const isTeacher = currentUser?.role === 'TEACHER';
|
| 28 |
+
const homeroomClass = isTeacher ? currentUser?.homeroomClass : (currentUser?.role === 'STUDENT' ? currentUser?.class : '');
|
|
|
|
|
|
|
| 29 |
|
| 30 |
useEffect(() => {
|
| 31 |
loadData();
|
| 32 |
+
return () => clearInterval(timerRef.current);
|
| 33 |
}, []);
|
| 34 |
|
| 35 |
const loadData = async () => {
|
| 36 |
+
if (!homeroomClass) {
|
| 37 |
+
setLoading(false);
|
| 38 |
+
return;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
}
|
| 40 |
+
try {
|
| 41 |
+
const [cfg, stus, ac] = await Promise.all([
|
| 42 |
+
api.games.getZenConfig(homeroomClass),
|
| 43 |
+
api.students.getAll(),
|
| 44 |
+
api.achievements.getConfig(homeroomClass)
|
| 45 |
+
]);
|
| 46 |
+
|
| 47 |
+
const classStudents = stus.filter((s: Student) => s.className === homeroomClass);
|
| 48 |
+
setStudents(classStudents);
|
| 49 |
+
setAchConfig(ac);
|
| 50 |
+
|
| 51 |
+
if (cfg && cfg.className) {
|
| 52 |
+
setConfig(cfg);
|
| 53 |
+
setTimeLeft(cfg.durationMinutes * 60);
|
| 54 |
+
} else {
|
| 55 |
+
// Default Config
|
| 56 |
+
const defaultCfg = {
|
| 57 |
+
schoolId: currentUser?.schoolId || '',
|
| 58 |
+
className: homeroomClass,
|
| 59 |
+
durationMinutes: 40,
|
| 60 |
+
threshold: 30, // unused in simple mode
|
| 61 |
+
passRate: 90, // unused
|
| 62 |
+
rewardConfig: {
|
| 63 |
+
enabled: true,
|
| 64 |
+
type: 'DRAW_COUNT' as any,
|
| 65 |
+
val: '1',
|
| 66 |
+
count: 1
|
| 67 |
+
}
|
| 68 |
+
};
|
| 69 |
+
setConfig(defaultCfg);
|
| 70 |
+
setTimeLeft(40 * 60);
|
| 71 |
+
}
|
| 72 |
+
} catch (e) { console.error(e); }
|
| 73 |
+
finally { setLoading(false); }
|
| 74 |
};
|
| 75 |
|
| 76 |
+
const startTimer = () => {
|
| 77 |
+
setIsActive(true);
|
| 78 |
+
setIsFinished(false);
|
| 79 |
+
timerRef.current = setInterval(() => {
|
| 80 |
+
setTimeLeft(prev => {
|
| 81 |
+
if (prev <= 1) {
|
| 82 |
+
clearInterval(timerRef.current);
|
| 83 |
+
finishSession();
|
| 84 |
+
return 0;
|
| 85 |
+
}
|
| 86 |
+
return prev - 1;
|
| 87 |
+
});
|
| 88 |
+
}, 1000);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
};
|
| 90 |
|
| 91 |
+
const stopTimer = () => {
|
| 92 |
+
clearInterval(timerRef.current);
|
| 93 |
+
setIsActive(false);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
};
|
| 95 |
|
| 96 |
+
const resetTimer = () => {
|
| 97 |
+
stopTimer();
|
| 98 |
+
setTimeLeft((config?.durationMinutes || 40) * 60);
|
| 99 |
+
setIsFinished(false);
|
| 100 |
};
|
| 101 |
|
| 102 |
+
const finishSession = () => {
|
| 103 |
+
setIsActive(false);
|
| 104 |
+
setIsFinished(true);
|
| 105 |
+
// Play sound
|
| 106 |
+
const audio = new Audio('https://assets.mixkit.co/active_storage/sfx/2869/2869-preview.mp3');
|
| 107 |
+
audio.play().catch(() => {});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
};
|
| 109 |
|
| 110 |
+
const handleGrantReward = async () => {
|
| 111 |
+
if (!config?.rewardConfig.enabled || students.length === 0) return;
|
| 112 |
+
if (!confirm('修行结束!确认给全班同学发放奖励吗?')) return;
|
| 113 |
+
|
| 114 |
try {
|
| 115 |
+
const promises = students.map(s => {
|
| 116 |
+
if (config.rewardConfig.type === 'ACHIEVEMENT' && config.rewardConfig.val) {
|
| 117 |
return api.achievements.grant({
|
| 118 |
studentId: s._id || String(s.id),
|
| 119 |
+
achievementId: config.rewardConfig.val,
|
| 120 |
semester: '当前学期'
|
| 121 |
});
|
| 122 |
} else {
|
| 123 |
return api.games.grantReward({
|
| 124 |
studentId: s._id || String(s.id),
|
| 125 |
+
count: config.rewardConfig.count || 1,
|
| 126 |
+
rewardType: config.rewardConfig.type,
|
| 127 |
+
name: config.rewardConfig.type === 'DRAW_COUNT' ? '禅道修行奖励' : config.rewardConfig.val,
|
| 128 |
+
source: '禅道修行'
|
| 129 |
});
|
| 130 |
}
|
| 131 |
});
|
| 132 |
await Promise.all(promises);
|
| 133 |
+
alert('奖励发放成功!');
|
| 134 |
+
resetTimer();
|
| 135 |
+
} catch (e) { alert('发放失败'); }
|
| 136 |
};
|
| 137 |
|
| 138 |
+
const saveConfig = async () => {
|
| 139 |
if (config) {
|
| 140 |
await api.games.saveZenConfig(config);
|
| 141 |
+
setTimeLeft(config.durationMinutes * 60);
|
| 142 |
}
|
| 143 |
+
setIsSettingsOpen(false);
|
| 144 |
};
|
| 145 |
|
| 146 |
+
if (loading) return <div className="h-full flex items-center justify-center"><Loader2 className="animate-spin text-teal-600"/></div>;
|
| 147 |
+
if (!config) return <div className="h-full flex items-center justify-center text-gray-400">无法加载配置</div>;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
|
| 149 |
return (
|
| 150 |
+
<div className="h-full flex flex-col bg-teal-900 text-teal-50 overflow-hidden relative">
|
| 151 |
+
{/* Decorative Elements */}
|
| 152 |
+
<div className="absolute top-10 left-10 text-teal-800 text-9xl opacity-20 select-none animate-pulse duration-[10s]">🌑</div>
|
| 153 |
+
<div className="absolute bottom-10 right-10 text-teal-800 text-9xl opacity-20 select-none animate-bounce duration-[20s]">🎋</div>
|
| 154 |
+
|
| 155 |
+
{/* Settings Button */}
|
| 156 |
+
{isTeacher && (
|
| 157 |
+
<button
|
| 158 |
+
onClick={() => setIsSettingsOpen(true)}
|
| 159 |
+
className="absolute top-4 right-4 z-20 p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors"
|
| 160 |
+
>
|
| 161 |
+
<Settings size={20} className="text-white"/>
|
| 162 |
+
</button>
|
| 163 |
+
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
|
| 165 |
+
<div className="flex-1 flex flex-col items-center justify-center relative z-10">
|
| 166 |
+
<h2 className="text-teal-200 font-medium tracking-[0.5em] mb-8 text-xl">禅 · 道 · 修 · 行</h2>
|
| 167 |
+
|
| 168 |
+
<div className={`relative w-80 h-80 rounded-full border-4 flex items-center justify-center transition-all duration-1000 ${isActive ? 'border-teal-400 shadow-[0_0_50px_rgba(45,212,191,0.3)]' : 'border-teal-700'}`}>
|
| 169 |
+
{isActive && (
|
| 170 |
+
<div className="absolute inset-0 rounded-full border-4 border-t-transparent border-teal-300 animate-spin duration-[10s]"></div>
|
| 171 |
+
)}
|
| 172 |
+
<div className="text-6xl font-thin font-mono tracking-wider">
|
| 173 |
+
{formatTime(timeLeft)}
|
| 174 |
+
</div>
|
| 175 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
|
| 177 |
+
{isFinished && (
|
| 178 |
+
<div className="mt-8 flex flex-col items-center animate-in fade-in slide-in-from-bottom-4">
|
| 179 |
+
<div className="bg-teal-500/20 text-teal-200 px-6 py-2 rounded-full border border-teal-500/50 mb-4 flex items-center gap-2">
|
| 180 |
+
<CheckCircle size={18}/> 修行圆满完成
|
|
|
|
|
|
|
|
|
|
| 181 |
</div>
|
| 182 |
+
{isTeacher && (
|
| 183 |
+
<button
|
| 184 |
+
onClick={handleGrantReward}
|
| 185 |
+
className="bg-amber-500 hover:bg-amber-600 text-white px-8 py-3 rounded-full font-bold shadow-lg transform hover:scale-105 transition-all"
|
| 186 |
+
>
|
| 187 |
+
发放全班奖励
|
| 188 |
+
</button>
|
| 189 |
+
)}
|
| 190 |
+
</div>
|
| 191 |
+
)}
|
| 192 |
+
</div>
|
| 193 |
+
|
| 194 |
+
{/* Controls */}
|
| 195 |
+
<div className="h-24 bg-teal-950/50 border-t border-teal-800/50 flex items-center justify-center gap-8 relative z-10">
|
| 196 |
+
{!isActive && !isFinished && (
|
| 197 |
+
<button onClick={startTimer} className="bg-teal-600 hover:bg-teal-500 text-white w-14 h-14 rounded-full flex items-center justify-center shadow-lg transition-transform hover:scale-110">
|
| 198 |
+
<Play size={24} className="ml-1"/>
|
| 199 |
+
</button>
|
| 200 |
+
)}
|
| 201 |
+
{isActive && (
|
| 202 |
+
<button onClick={stopTimer} className="bg-amber-600 hover:bg-amber-500 text-white w-14 h-14 rounded-full flex items-center justify-center shadow-lg transition-transform hover:scale-110">
|
| 203 |
+
<Square size={20} fill="white"/>
|
| 204 |
+
</button>
|
| 205 |
+
)}
|
| 206 |
+
<button onClick={resetTimer} className="text-teal-400 hover:text-white p-2 rounded-full hover:bg-white/5 transition-colors absolute right-8">
|
| 207 |
+
重置
|
| 208 |
+
</button>
|
| 209 |
+
</div>
|
| 210 |
+
|
| 211 |
+
{/* Settings Modal */}
|
| 212 |
+
{isSettingsOpen && (
|
| 213 |
+
<div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4 text-slate-800">
|
| 214 |
+
<div className="bg-white rounded-xl w-full max-w-md p-6 shadow-2xl animate-in zoom-in-95">
|
| 215 |
+
<div className="flex justify-between items-center mb-6">
|
| 216 |
+
<h3 className="text-xl font-bold">修行设置</h3>
|
| 217 |
+
<button onClick={() => setIsSettingsOpen(false)}><X size={24} className="text-gray-400 hover:text-gray-600"/></button>
|
| 218 |
</div>
|
| 219 |
+
|
| 220 |
+
<div className="space-y-6 mb-6">
|
| 221 |
+
<div>
|
| 222 |
+
<label className="block text-sm font-bold text-gray-700 mb-2 flex items-center"><Clock size={16} className="mr-2"/> 修行时长 (分钟)</label>
|
| 223 |
+
<div className="grid grid-cols-4 gap-2">
|
| 224 |
+
{[10, 20, 30, 40, 45, 60, 90].map(m => (
|
| 225 |
+
<button
|
| 226 |
+
key={m}
|
| 227 |
+
onClick={() => setConfig({...config, durationMinutes: m})}
|
| 228 |
+
className={`py-2 rounded border text-sm font-medium transition-colors ${config.durationMinutes === m ? 'bg-teal-600 text-white border-teal-600' : 'bg-white text-gray-600 hover:border-teal-400'}`}
|
| 229 |
+
>
|
| 230 |
+
{m}
|
| 231 |
+
</button>
|
| 232 |
+
))}
|
| 233 |
+
</div>
|
| 234 |
+
</div>
|
| 235 |
+
|
| 236 |
+
<div className="border-t pt-4">
|
| 237 |
+
<label className="block text-sm font-bold text-gray-700 mb-2">完成奖励</label>
|
| 238 |
+
<div className="flex items-center gap-2 mb-2">
|
| 239 |
+
<input type="checkbox" checked={config.rewardConfig.enabled} onChange={e => setConfig({...config, rewardConfig: {...config.rewardConfig, enabled: e.target.checked}})}/>
|
| 240 |
+
<span className="text-sm">启用自动奖励</span>
|
| 241 |
+
</div>
|
| 242 |
+
{config.rewardConfig.enabled && (
|
| 243 |
+
<div className="bg-gray-50 p-3 rounded-lg space-y-2 border">
|
| 244 |
+
<select className="w-full border rounded p-2 text-sm" value={config.rewardConfig.type} onChange={e => setConfig({...config, rewardConfig: {...config.rewardConfig, type: e.target.value as any}})}>
|
| 245 |
+
<option value="DRAW_COUNT">🎲 抽奖券</option>
|
| 246 |
+
<option value="ITEM">🎁 物品</option>
|
| 247 |
+
<option value="ACHIEVEMENT">🏆 成就</option>
|
| 248 |
+
</select>
|
| 249 |
+
|
| 250 |
+
{config.rewardConfig.type === 'ACHIEVEMENT' ? (
|
| 251 |
+
<select className="w-full border rounded p-2 text-sm" value={config.rewardConfig.val} onChange={e => setConfig({...config, rewardConfig: {...config.rewardConfig, val: e.target.value}})}>
|
| 252 |
+
<option value="">选择成就</option>
|
| 253 |
+
{achConfig?.achievements.map(a => <option key={a.id} value={a.id}>{a.icon} {a.name}</option>)}
|
| 254 |
+
</select>
|
| 255 |
+
) : (
|
| 256 |
+
<input className="w-full border rounded p-2 text-sm" placeholder={config.rewardConfig.type === 'ITEM' ? '物品名称' : '备注'} value={config.rewardConfig.val} onChange={e => setConfig({...config, rewardConfig: {...config.rewardConfig, val: e.target.value}})}/>
|
| 257 |
+
)}
|
| 258 |
+
|
| 259 |
+
<div className="flex items-center gap-2">
|
| 260 |
+
<span className="text-sm">数量:</span>
|
| 261 |
+
<input type="number" min="1" className="border rounded p-1 w-16 text-sm" value={config.rewardConfig.count} onChange={e => setConfig({...config, rewardConfig: {...config.rewardConfig, count: Number(e.target.value)}})}/>
|
| 262 |
+
</div>
|
| 263 |
+
</div>
|
| 264 |
+
)}
|
| 265 |
+
</div>
|
| 266 |
</div>
|
| 267 |
+
|
| 268 |
+
<button onClick={saveConfig} className="w-full bg-teal-600 text-white py-2 rounded-lg font-bold hover:bg-teal-700 flex items-center justify-center gap-2">
|
| 269 |
+
<Save size={18}/> 保存并重置
|
| 270 |
+
</button>
|
| 271 |
</div>
|
| 272 |
</div>
|
| 273 |
)}
|
| 274 |
+
</div>
|
| 275 |
);
|
| 276 |
};
|
pages/Games.tsx
CHANGED
|
@@ -75,4 +75,4 @@ export const Games: React.FC = () => {
|
|
| 75 |
</div>
|
| 76 |
</div>
|
| 77 |
);
|
| 78 |
-
};
|
|
|
|
| 75 |
</div>
|
| 76 |
</div>
|
| 77 |
);
|
| 78 |
+
};
|
server.js
CHANGED
|
@@ -1,30 +1,8 @@
|
|
| 1 |
-
|
| 2 |
-
// ... existing imports ...
|
| 3 |
-
const {
|
| 4 |
-
School, User, Student, Course, Score, ClassModel, SubjectModel, ExamModel, ScheduleModel,
|
| 5 |
-
ConfigModel, NotificationModel, GameSessionModel, StudentRewardModel, LuckyDrawConfigModel, GameMonsterConfigModel, GameZenConfigModel,
|
| 6 |
-
AchievementConfigModel, StudentAchievementModel, AttendanceModel, LeaveRequestModel
|
| 7 |
-
} = require('./models');
|
| 8 |
-
|
| 9 |
-
// ... existing setup ...
|
| 10 |
-
|
| 11 |
-
// --- Game Routes ---
|
| 12 |
-
app.post('/api/games/mountain', async (req, res) => {
|
| 13 |
-
const filter = { className: req.body.className };
|
| 14 |
-
const sId = req.headers['x-school-id'];
|
| 15 |
-
if(sId) filter.schoolId = sId;
|
| 16 |
-
await GameSessionModel.findOneAndUpdate(filter, injectSchoolId(req, req.body), {upsert:true});
|
| 17 |
-
res.json({});
|
| 18 |
-
});
|
| 19 |
-
|
| 20 |
app.post('/api/games/grant-reward', async (req, res) => {
|
| 21 |
const { studentId, count, rewardType, name, source } = req.body;
|
| 22 |
const finalCount = count || 1;
|
| 23 |
const finalName = name || (rewardType === 'DRAW_COUNT' ? '抽奖券' : '奖品');
|
| 24 |
-
const finalSource = source || '教师发放'; // Use provided source or default
|
| 25 |
-
|
| 26 |
if (rewardType === 'DRAW_COUNT') await Student.findByIdAndUpdate(studentId, { $inc: { drawAttempts: finalCount } });
|
| 27 |
-
|
| 28 |
await StudentRewardModel.create({
|
| 29 |
schoolId: req.headers['x-school-id'],
|
| 30 |
studentId,
|
|
@@ -33,24 +11,7 @@ app.post('/api/games/grant-reward', async (req, res) => {
|
|
| 33 |
name: finalName,
|
| 34 |
count: finalCount,
|
| 35 |
status: rewardType === 'DRAW_COUNT' ? 'REDEEMED' : 'PENDING',
|
| 36 |
-
source:
|
| 37 |
});
|
| 38 |
res.json({});
|
| 39 |
-
});
|
| 40 |
-
|
| 41 |
-
app.get('/api/games/zen-config', async (req, res) => {
|
| 42 |
-
const data = await GameZenConfigModel.findOne({ className: req.query.className });
|
| 43 |
-
res.json(data);
|
| 44 |
-
});
|
| 45 |
-
|
| 46 |
-
app.post('/api/games/zen-config', async (req, res) => {
|
| 47 |
-
const filter = { className: req.body.className };
|
| 48 |
-
const sId = req.headers['x-school-id'];
|
| 49 |
-
if(sId) filter.schoolId = sId;
|
| 50 |
-
await GameZenConfigModel.findOneAndUpdate(filter, injectSchoolId(req, req.body), { upsert: true });
|
| 51 |
-
res.json({});
|
| 52 |
-
});
|
| 53 |
-
|
| 54 |
-
// ... Keep existing routes ...
|
| 55 |
-
app.put('/api/rewards/:id', async (req, res) => { await StudentRewardModel.findByIdAndUpdate(req.params.id, req.body); res.json({}); });
|
| 56 |
-
// ... existing code ...
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
app.post('/api/games/grant-reward', async (req, res) => {
|
| 2 |
const { studentId, count, rewardType, name, source } = req.body;
|
| 3 |
const finalCount = count || 1;
|
| 4 |
const finalName = name || (rewardType === 'DRAW_COUNT' ? '抽奖券' : '奖品');
|
|
|
|
|
|
|
| 5 |
if (rewardType === 'DRAW_COUNT') await Student.findByIdAndUpdate(studentId, { $inc: { drawAttempts: finalCount } });
|
|
|
|
| 6 |
await StudentRewardModel.create({
|
| 7 |
schoolId: req.headers['x-school-id'],
|
| 8 |
studentId,
|
|
|
|
| 11 |
name: finalName,
|
| 12 |
count: finalCount,
|
| 13 |
status: rewardType === 'DRAW_COUNT' ? 'REDEEMED' : 'PENDING',
|
| 14 |
+
source: source || '教师发放'
|
| 15 |
});
|
| 16 |
res.json({});
|
| 17 |
+
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
services/api.ts
CHANGED
|
@@ -1,187 +1,174 @@
|
|
| 1 |
-
|
| 2 |
-
import {
|
| 3 |
-
User, UserRole, School, ClassInfo, Subject, Score, Student,
|
| 4 |
-
SystemConfig, Notification, GameSession, GameRewardConfig,
|
| 5 |
-
LuckyDrawConfig, AchievementConfig, StudentReward, Attendance,
|
| 6 |
-
GameMonsterConfig
|
| 7 |
-
} from '../types';
|
| 8 |
|
| 9 |
const API_BASE = '/api';
|
| 10 |
|
| 11 |
-
|
| 12 |
-
const
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
}
|
| 22 |
-
|
| 23 |
-
// Pass selected school ID header for Admin view
|
| 24 |
-
const viewSchoolId = localStorage.getItem('admin_view_school_id');
|
| 25 |
-
if (viewSchoolId) {
|
| 26 |
-
headers['x-school-id'] = viewSchoolId;
|
| 27 |
-
}
|
| 28 |
-
|
| 29 |
-
const response = await fetch(`${API_BASE}${endpoint}`, {
|
| 30 |
-
...options,
|
| 31 |
-
headers
|
| 32 |
-
});
|
| 33 |
-
|
| 34 |
-
const data = await response.json();
|
| 35 |
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
throw new Error(data.message || '请求失败');
|
| 42 |
-
}
|
| 43 |
|
| 44 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
};
|
| 46 |
|
| 47 |
export const api = {
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
},
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
}
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
},
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
},
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
},
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 187 |
};
|
|
|
|
| 1 |
+
import { User, UserRole, Student, ClassInfo, School, Subject, Course, Score, Schedule, SystemConfig, Notification, ApiResponse, GameSession, LuckyDrawConfig, StudentReward, GameMonsterConfig, GameZenConfig, AchievementConfig, StudentAchievement, Attendance } from '../types';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
const API_BASE = '/api';
|
| 4 |
|
| 5 |
+
const getHeaders = () => {
|
| 6 |
+
const token = localStorage.getItem('token');
|
| 7 |
+
const schoolId = localStorage.getItem('admin_view_school_id') || localStorage.getItem('user_school_id');
|
| 8 |
+
const headers: HeadersInit = {
|
| 9 |
+
'Content-Type': 'application/json',
|
| 10 |
+
};
|
| 11 |
+
if (token) headers['Authorization'] = `Bearer ${token}`;
|
| 12 |
+
if (schoolId) headers['x-school-id'] = schoolId;
|
| 13 |
+
return headers;
|
| 14 |
+
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
+
const request = async <T>(endpoint: string, options: RequestInit = {}): Promise<T> => {
|
| 17 |
+
const res = await fetch(`${API_BASE}${endpoint}`, {
|
| 18 |
+
...options,
|
| 19 |
+
headers: { ...getHeaders(), ...options.headers },
|
| 20 |
+
});
|
|
|
|
|
|
|
| 21 |
|
| 22 |
+
const data = await res.json();
|
| 23 |
+
if (!res.ok) {
|
| 24 |
+
throw new Error(data.message || 'API request failed');
|
| 25 |
+
}
|
| 26 |
+
return data as T;
|
| 27 |
};
|
| 28 |
|
| 29 |
export const api = {
|
| 30 |
+
init: () => {
|
| 31 |
+
// Any initialization logic if needed
|
| 32 |
+
},
|
| 33 |
+
auth: {
|
| 34 |
+
login: async (username: string, password: string): Promise<User> => {
|
| 35 |
+
const user = await request<User>('/auth/login', {
|
| 36 |
+
method: 'POST',
|
| 37 |
+
body: JSON.stringify({ username, password })
|
| 38 |
+
});
|
| 39 |
+
localStorage.setItem('user', JSON.stringify(user));
|
| 40 |
+
if(user.schoolId) localStorage.setItem('user_school_id', user.schoolId);
|
| 41 |
+
return user;
|
| 42 |
+
},
|
| 43 |
+
register: (data: any) => request<any>('/auth/register', { method: 'POST', body: JSON.stringify(data) }),
|
| 44 |
+
logout: () => {
|
| 45 |
+
localStorage.removeItem('user');
|
| 46 |
+
localStorage.removeItem('user_school_id');
|
| 47 |
+
localStorage.removeItem('admin_view_school_id');
|
| 48 |
+
// Clear token if token based auth is fully implemented in future
|
| 49 |
+
},
|
| 50 |
+
getCurrentUser: (): User | null => {
|
| 51 |
+
const u = localStorage.getItem('user');
|
| 52 |
+
return u ? JSON.parse(u) : null;
|
| 53 |
+
},
|
| 54 |
+
refreshSession: () => request<User>('/auth/session'),
|
| 55 |
+
updateProfile: (data: any) => request('/auth/profile', { method: 'PUT', body: JSON.stringify(data) }),
|
| 56 |
+
},
|
| 57 |
+
users: {
|
| 58 |
+
getAll: (params: any = {}) => {
|
| 59 |
+
const search = new URLSearchParams(params);
|
| 60 |
+
return request<User[]>(`/users?${search.toString()}`);
|
| 61 |
+
},
|
| 62 |
+
update: (id: string, data: Partial<User>) => request(`/users/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
| 63 |
+
delete: (id: string) => request(`/users/${id}`, { method: 'DELETE' }),
|
| 64 |
+
applyClass: (data: any) => request('/users/apply-class', { method: 'POST', body: JSON.stringify(data) }),
|
| 65 |
+
},
|
| 66 |
+
students: {
|
| 67 |
+
getAll: () => request<Student[]>('/students'),
|
| 68 |
+
add: (data: Partial<Student>) => request('/students', { method: 'POST', body: JSON.stringify(data) }),
|
| 69 |
+
update: (id: string, data: Partial<Student>) => request(`/students/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
| 70 |
+
delete: (id: string) => request(`/students/${id}`, { method: 'DELETE' }),
|
| 71 |
+
transfer: (data: {studentId: string, targetClass: string}) => request('/students/transfer', { method: 'POST', body: JSON.stringify(data) }),
|
| 72 |
+
promote: (data: { teacherFollows: boolean }) => request<{count: number}>('/students/promote', { method: 'POST', body: JSON.stringify(data) }),
|
| 73 |
+
},
|
| 74 |
+
classes: {
|
| 75 |
+
getAll: () => request<ClassInfo[]>('/classes'),
|
| 76 |
+
add: (data: Partial<ClassInfo>) => request('/classes', { method: 'POST', body: JSON.stringify(data) }),
|
| 77 |
+
update: (id: string, data: Partial<ClassInfo>) => request(`/classes/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
| 78 |
+
delete: (id: string | number) => request(`/classes/${id}`, { method: 'DELETE' }),
|
| 79 |
+
},
|
| 80 |
+
schools: {
|
| 81 |
+
getAll: () => request<School[]>('/schools'), // Admin only
|
| 82 |
+
getPublic: () => request<School[]>('/public/schools'), // For registration
|
| 83 |
+
add: (data: Partial<School>) => request('/schools', { method: 'POST', body: JSON.stringify(data) }),
|
| 84 |
+
update: (id: string, data: Partial<School>) => request(`/schools/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
| 85 |
+
delete: (id: string) => request(`/schools/${id}`, { method: 'DELETE' }),
|
| 86 |
+
},
|
| 87 |
+
subjects: {
|
| 88 |
+
getAll: () => request<Subject[]>('/subjects'),
|
| 89 |
+
add: (data: Partial<Subject>) => request('/subjects', { method: 'POST', body: JSON.stringify(data) }),
|
| 90 |
+
update: (id: string, data: Partial<Subject>) => request(`/subjects/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
| 91 |
+
delete: (id: string) => request(`/subjects/${id}`, { method: 'DELETE' }),
|
| 92 |
+
},
|
| 93 |
+
courses: {
|
| 94 |
+
getAll: () => request<Course[]>('/courses'),
|
| 95 |
+
add: (data: Partial<Course>) => request('/courses', { method: 'POST', body: JSON.stringify(data) }),
|
| 96 |
+
update: (id: string, data: Partial<Course>) => request(`/courses/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
| 97 |
+
delete: (id: string) => request(`/courses/${id}`, { method: 'DELETE' }),
|
| 98 |
+
},
|
| 99 |
+
scores: {
|
| 100 |
+
getAll: () => request<Score[]>('/scores'),
|
| 101 |
+
add: (data: Partial<Score>) => request('/scores', { method: 'POST', body: JSON.stringify(data) }),
|
| 102 |
+
update: (id: string, data: Partial<Score>) => request(`/scores/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
| 103 |
+
delete: (id: string) => request(`/scores/${id}`, { method: 'DELETE' }),
|
| 104 |
+
},
|
| 105 |
+
exams: {
|
| 106 |
+
getAll: () => request<any[]>('/exams'),
|
| 107 |
+
save: (data: any) => request('/exams', { method: 'POST', body: JSON.stringify(data) }),
|
| 108 |
+
},
|
| 109 |
+
schedules: {
|
| 110 |
+
get: (params: any = {}) => {
|
| 111 |
+
const search = new URLSearchParams(params);
|
| 112 |
+
return request<Schedule[]>(`/schedules?${search.toString()}`);
|
| 113 |
+
},
|
| 114 |
+
save: (data: Partial<Schedule>) => request('/schedules', { method: 'POST', body: JSON.stringify(data) }),
|
| 115 |
+
delete: (data: Partial<Schedule>) => request('/schedules', { method: 'DELETE', body: JSON.stringify(data) }),
|
| 116 |
+
},
|
| 117 |
+
config: {
|
| 118 |
+
get: () => request<SystemConfig>('/config'),
|
| 119 |
+
getPublic: () => request<SystemConfig>('/public/config'),
|
| 120 |
+
save: (data: SystemConfig) => request('/config', { method: 'POST', body: JSON.stringify(data) }),
|
| 121 |
+
},
|
| 122 |
+
stats: {
|
| 123 |
+
getSummary: () => request<any>('/stats/summary'),
|
| 124 |
+
},
|
| 125 |
+
notifications: {
|
| 126 |
+
getAll: (userId: string, role: string) => request<Notification[]>(`/notifications?userId=${userId}&role=${role}`),
|
| 127 |
+
},
|
| 128 |
+
batchDelete: (type: string, ids: string[]) => request('/batch-delete', { method: 'POST', body: JSON.stringify({ type, ids }) }),
|
| 129 |
+
|
| 130 |
+
games: {
|
| 131 |
+
// Mountain
|
| 132 |
+
getMountainSession: (className: string) => request<GameSession | null>(`/games/mountain-session?className=${className}`),
|
| 133 |
+
saveMountainSession: (session: GameSession) => request('/games/mountain-session', { method: 'POST', body: JSON.stringify(session) }),
|
| 134 |
+
// Lucky Draw
|
| 135 |
+
getLuckyConfig: (className: string) => request<LuckyDrawConfig>(`/games/lucky-config?className=${className}`),
|
| 136 |
+
saveLuckyConfig: (config: LuckyDrawConfig) => request('/games/lucky-config', { method: 'POST', body: JSON.stringify(config) }),
|
| 137 |
+
drawLucky: (studentId: string) => request<{prize: string, rewardType: string}>('/games/lucky-draw', { method: 'POST', body: JSON.stringify({ studentId }) }),
|
| 138 |
+
// Common
|
| 139 |
+
grantReward: (data: { studentId: string, count: number, rewardType: string, name?: string, source?: string }) => request('/games/grant-reward', { method: 'POST', body: JSON.stringify(data) }),
|
| 140 |
+
|
| 141 |
+
// Monster
|
| 142 |
+
getMonsterConfig: (className: string) => request<GameMonsterConfig>(`/games/monster-config?className=${className}`),
|
| 143 |
+
saveMonsterConfig: (config: GameMonsterConfig) => request('/games/monster-config', { method: 'POST', body: JSON.stringify(config) }),
|
| 144 |
+
|
| 145 |
+
// Zen
|
| 146 |
+
getZenConfig: (className: string) => request<GameZenConfig>(`/games/zen-config?className=${className}`),
|
| 147 |
+
saveZenConfig: (config: GameZenConfig) => request('/games/zen-config', { method: 'POST', body: JSON.stringify(config) }),
|
| 148 |
+
},
|
| 149 |
+
achievements: {
|
| 150 |
+
getConfig: (className: string) => request<AchievementConfig | null>(`/achievements/config?className=${className}`),
|
| 151 |
+
saveConfig: (config: AchievementConfig) => request('/achievements/config', { method: 'POST', body: JSON.stringify(config) }),
|
| 152 |
+
getStudentAchievements: (studentId: string, semester?: string) => request<StudentAchievement[]>(`/achievements/student/${studentId}?semester=${semester||''}`),
|
| 153 |
+
grant: (data: { studentId: string, achievementId: string, semester: string }) => request('/achievements/grant', { method: 'POST', body: JSON.stringify(data) }),
|
| 154 |
+
exchange: (data: { studentId: string, ruleId: string }) => request('/achievements/exchange', { method: 'POST', body: JSON.stringify(data) }),
|
| 155 |
+
},
|
| 156 |
+
rewards: {
|
| 157 |
+
getMyRewards: (studentId: string, page: number, pageSize: number) => request<any>(`/rewards/my?studentId=${studentId}&page=${page}&pageSize=${pageSize}`),
|
| 158 |
+
getClassRewards: (page: number, pageSize: number, className?: string) => request<any>(`/rewards/class?page=${page}&pageSize=${pageSize}&className=${className||''}`),
|
| 159 |
+
addReward: (data: Partial<StudentReward>) => request('/rewards', { method: 'POST', body: JSON.stringify(data) }),
|
| 160 |
+
redeem: (id: string) => request(`/rewards/${id}/redeem`, { method: 'POST' }),
|
| 161 |
+
update: (id: string, data: Partial<StudentReward>) => request(`/rewards/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
| 162 |
+
delete: (id: string) => request(`/rewards/${id}`, { method: 'DELETE' }),
|
| 163 |
+
},
|
| 164 |
+
attendance: {
|
| 165 |
+
get: (params: any) => {
|
| 166 |
+
const s = new URLSearchParams(params);
|
| 167 |
+
return request<Attendance[]>(`/attendance?${s.toString()}`);
|
| 168 |
+
},
|
| 169 |
+
checkIn: (data: {studentId: string, date: string}) => request('/attendance/checkin', { method: 'POST', body: JSON.stringify(data) }),
|
| 170 |
+
update: (data: {studentId: string, date: string, status: string}) => request('/attendance/update', { method: 'POST', body: JSON.stringify(data) }),
|
| 171 |
+
batch: (data: {className: string, date: string}) => request('/attendance/batch', { method: 'POST', body: JSON.stringify(data) }),
|
| 172 |
+
applyLeave: (data: any) => request('/attendance/leave', { method: 'POST', body: JSON.stringify(data) }),
|
| 173 |
+
}
|
| 174 |
};
|