Spaces:
Sleeping
Sleeping
Upload 44 files
Browse files- models.js +2 -1
- pages/GameMonster.tsx +239 -585
- pages/GameRandom.tsx +192 -299
- pages/GameZen.tsx +350 -550
- pages/Games.tsx +1 -1
- server.js +34 -794
- services/api.ts +176 -227
models.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
|
|
| 1 |
const mongoose = require('mongoose');
|
| 2 |
|
| 3 |
const SchoolSchema = new mongoose.Schema({ name: String, code: String });
|
|
@@ -143,4 +144,4 @@ module.exports = {
|
|
| 143 |
School, User, Student, Course, Score, ClassModel, SubjectModel, ExamModel, ScheduleModel,
|
| 144 |
ConfigModel, NotificationModel, GameSessionModel, StudentRewardModel, LuckyDrawConfigModel, GameMonsterConfigModel, GameZenConfigModel,
|
| 145 |
AchievementConfigModel, StudentAchievementModel, AttendanceModel, LeaveRequestModel
|
| 146 |
-
};
|
|
|
|
| 1 |
+
|
| 2 |
const mongoose = require('mongoose');
|
| 3 |
|
| 4 |
const SchoolSchema = new mongoose.Schema({ name: String, code: String });
|
|
|
|
| 144 |
School, User, Student, Course, Score, ClassModel, SubjectModel, ExamModel, ScheduleModel,
|
| 145 |
ConfigModel, NotificationModel, GameSessionModel, StudentRewardModel, LuckyDrawConfigModel, GameMonsterConfigModel, GameZenConfigModel,
|
| 146 |
AchievementConfigModel, StudentAchievementModel, AttendanceModel, LeaveRequestModel
|
| 147 |
+
};
|
pages/GameMonster.tsx
CHANGED
|
@@ -1,301 +1,137 @@
|
|
| 1 |
-
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
| 2 |
-
import { createPortal } from 'react-dom';
|
| 3 |
-
import { api } from '../services/api';
|
| 4 |
-
import { AchievementConfig, Student } from '../types';
|
| 5 |
-
import { Play, Pause, Settings, Maximize, Minimize, Gift, Trophy, Package, Volume2, Keyboard, Award, RotateCcw, UserX } from 'lucide-react';
|
| 6 |
|
| 7 |
-
|
| 8 |
-
|
|
|
|
|
|
|
| 9 |
|
| 10 |
export const GameMonster: React.FC = () => {
|
| 11 |
-
const
|
| 12 |
-
const homeroomClass = currentUser?.homeroomClass;
|
| 13 |
-
|
| 14 |
-
// React State (for UI rendering)
|
| 15 |
const [isPlaying, setIsPlaying] = useState(false);
|
| 16 |
-
const [
|
| 17 |
-
const [
|
| 18 |
-
const [monsterPos, setMonsterPos] = useState(100);
|
| 19 |
-
const [currentVolume, setCurrentVolume] = useState(0);
|
| 20 |
-
const [attackCount, setAttackCount] = useState(0);
|
| 21 |
-
const [isFullscreen, setIsFullscreen] = useState(false);
|
| 22 |
-
const [isHit, setIsHit] = useState(false);
|
| 23 |
-
|
| 24 |
-
// Config State
|
| 25 |
-
const [achConfig, setAchConfig] = useState<AchievementConfig | null>(null);
|
| 26 |
const [students, setStudents] = useState<Student[]>([]);
|
| 27 |
-
const [
|
| 28 |
-
const [isFilterOpen, setIsFilterOpen] = useState(false);
|
| 29 |
-
|
| 30 |
-
const [duration, setDuration] = useState(300);
|
| 31 |
-
const [sensitivity, setSensitivity] = useState(40);
|
| 32 |
-
const [difficulty, setDifficulty] = useState(5);
|
| 33 |
-
const [useKeyboardMode, setUseKeyboardMode] = useState(false);
|
| 34 |
|
| 35 |
-
const [
|
| 36 |
-
|
| 37 |
-
type: 'DRAW_COUNT' | 'ITEM' | 'ACHIEVEMENT';
|
| 38 |
-
val: string;
|
| 39 |
-
count: number;
|
| 40 |
-
}>({ enabled: true, type: 'DRAW_COUNT', val: '早读奖励', count: 1 });
|
| 41 |
|
| 42 |
-
|
|
|
|
|
|
|
| 43 |
|
| 44 |
-
//
|
| 45 |
-
const isPlayingRef = useRef(false);
|
| 46 |
-
const gameStateRef = useRef('IDLE');
|
| 47 |
-
const lastTimeRef = useRef(0);
|
| 48 |
-
|
| 49 |
-
// Logic Refs
|
| 50 |
-
const volumeRef = useRef(0);
|
| 51 |
-
const keyboardModeRef = useRef(false);
|
| 52 |
-
const sensitivityRef = useRef(40);
|
| 53 |
-
const difficultyRef = useRef(5);
|
| 54 |
-
|
| 55 |
const audioContextRef = useRef<AudioContext | null>(null);
|
| 56 |
const analyserRef = useRef<AnalyserNode | null>(null);
|
| 57 |
-
const
|
| 58 |
-
const
|
| 59 |
-
const reqRef = useRef<number>(0);
|
| 60 |
-
const lastAttackTime = useRef(0);
|
| 61 |
-
|
| 62 |
-
// Monster Army Layout
|
| 63 |
-
const monsterArmy = useMemo(() => {
|
| 64 |
-
const formations = [
|
| 65 |
-
{ emoji: MONSTER_TYPES[0], x: 0, y: 5, s: 1.2, z: 20 },
|
| 66 |
-
{ emoji: MONSTER_TYPES[1], x: -30, y: 15, s: 0.9, z: 15 },
|
| 67 |
-
{ emoji: MONSTER_TYPES[2], x: 30, y: 15, s: 0.9, z: 15 },
|
| 68 |
-
{ emoji: MONSTER_TYPES[3], x: -15, y: 25, s: 0.8, z: 10 },
|
| 69 |
-
{ emoji: MONSTER_TYPES[4], x: 15, y: 25, s: 0.8, z: 10 },
|
| 70 |
-
{ emoji: MONSTER_TYPES[5], x: 0, y: 35, s: 0.7, z: 5 },
|
| 71 |
-
];
|
| 72 |
-
|
| 73 |
-
return formations.map((pos, i) => ({
|
| 74 |
-
id: i,
|
| 75 |
-
emoji: pos.emoji,
|
| 76 |
-
leftPercent: 50 + pos.x,
|
| 77 |
-
bottomPercent: pos.y,
|
| 78 |
-
scale: pos.s,
|
| 79 |
-
zIndex: pos.z
|
| 80 |
-
}));
|
| 81 |
-
}, []);
|
| 82 |
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
useEffect(() => { gameStateRef.current = gameState; }, [gameState]);
|
| 86 |
-
useEffect(() => { keyboardModeRef.current = useKeyboardMode; }, [useKeyboardMode]);
|
| 87 |
-
useEffect(() => { sensitivityRef.current = sensitivity; }, [sensitivity]);
|
| 88 |
-
useEffect(() => { difficultyRef.current = difficulty; }, [difficulty]);
|
| 89 |
|
| 90 |
useEffect(() => {
|
| 91 |
loadData();
|
| 92 |
-
return () =>
|
| 93 |
-
stopAudio();
|
| 94 |
-
if(reqRef.current) cancelAnimationFrame(reqRef.current);
|
| 95 |
-
};
|
| 96 |
}, []);
|
| 97 |
|
| 98 |
const loadData = async () => {
|
| 99 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
try {
|
| 101 |
-
const
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
const classStudents = (stus as Student[]).filter((s: Student) => s.className === homeroomClass);
|
| 108 |
-
// Sort by Seat No
|
| 109 |
-
classStudents.sort((a, b) => {
|
| 110 |
-
const seatA = parseInt(a.seatNo || '99999');
|
| 111 |
-
const seatB = parseInt(b.seatNo || '99999');
|
| 112 |
-
if (seatA !== seatB) return seatA - seatB;
|
| 113 |
-
return a.name.localeCompare(b.name, 'zh-CN');
|
| 114 |
-
});
|
| 115 |
-
setStudents(classStudents);
|
| 116 |
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
}
|
| 125 |
};
|
| 126 |
|
| 127 |
-
const
|
| 128 |
-
|
| 129 |
-
try {
|
| 130 |
-
if (audioContextRef.current && audioContextRef.current.state === 'running') return;
|
| 131 |
|
| 132 |
-
const
|
| 133 |
-
|
| 134 |
-
const AudioContext = window.AudioContext || window.webkitAudioContext;
|
| 135 |
-
const ctx = new AudioContext();
|
| 136 |
-
const analyser = ctx.createAnalyser();
|
| 137 |
-
analyser.fftSize = 256;
|
| 138 |
-
const source = ctx.createMediaStreamSource(stream);
|
| 139 |
-
source.connect(analyser);
|
| 140 |
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
sourceRef.current = source;
|
| 144 |
-
dataArrayRef.current = new Uint8Array(analyser.frequencyBinCount);
|
| 145 |
-
} catch (e) {
|
| 146 |
-
console.error("Mic error", e);
|
| 147 |
-
alert('无法访问麦克风,切换至键盘模式');
|
| 148 |
-
setUseKeyboardMode(true);
|
| 149 |
-
}
|
| 150 |
-
};
|
| 151 |
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
useKeyboardMode,
|
| 167 |
-
rewardConfig
|
| 168 |
});
|
| 169 |
-
} catch (e) { console.error('Auto-save config failed', e); }
|
| 170 |
-
};
|
| 171 |
-
|
| 172 |
-
const startGame = async () => {
|
| 173 |
-
saveConfig();
|
| 174 |
-
setIsConfigOpen(false);
|
| 175 |
-
setGameState('PLAYING');
|
| 176 |
-
setMonsterPos(100);
|
| 177 |
-
setTimeLeft(duration);
|
| 178 |
-
setAttackCount(0);
|
| 179 |
-
setIsHit(false);
|
| 180 |
-
volumeRef.current = 0;
|
| 181 |
-
|
| 182 |
-
await startAudio();
|
| 183 |
-
setIsPlaying(true);
|
| 184 |
-
|
| 185 |
-
lastTimeRef.current = performance.now();
|
| 186 |
-
if (reqRef.current) cancelAnimationFrame(reqRef.current);
|
| 187 |
-
reqRef.current = requestAnimationFrame(loop);
|
| 188 |
-
};
|
| 189 |
-
|
| 190 |
-
const togglePause = () => {
|
| 191 |
-
if (gameState === 'IDLE') {
|
| 192 |
-
startGame();
|
| 193 |
-
return;
|
| 194 |
}
|
| 195 |
-
if (gameState !== 'PLAYING') return;
|
| 196 |
-
setIsPlaying(!isPlaying);
|
| 197 |
-
};
|
| 198 |
-
|
| 199 |
-
// --- GAME LOOP ---
|
| 200 |
-
const loop = (time: number) => {
|
| 201 |
-
reqRef.current = requestAnimationFrame(loop);
|
| 202 |
-
|
| 203 |
-
if (!isPlayingRef.current || gameStateRef.current !== 'PLAYING') {
|
| 204 |
-
lastTimeRef.current = time;
|
| 205 |
-
return;
|
| 206 |
-
}
|
| 207 |
-
|
| 208 |
-
const delta = (time - lastTimeRef.current) / 1000;
|
| 209 |
-
lastTimeRef.current = time;
|
| 210 |
-
|
| 211 |
-
let currentVol = 0;
|
| 212 |
-
|
| 213 |
-
if (keyboardModeRef.current) {
|
| 214 |
-
volumeRef.current = Math.max(0, volumeRef.current - (100 * delta));
|
| 215 |
-
currentVol = volumeRef.current;
|
| 216 |
-
} else {
|
| 217 |
-
if (analyserRef.current && dataArrayRef.current) {
|
| 218 |
-
analyserRef.current.getByteFrequencyData(dataArrayRef.current as any);
|
| 219 |
-
const avg = dataArrayRef.current.reduce((a,b)=>a+b) / dataArrayRef.current.length;
|
| 220 |
-
|
| 221 |
-
// ADJUSTMENT: Moderate Gain Factor
|
| 222 |
-
// Raw 0-255 -> 0-127 mapped to 0-100 approx.
|
| 223 |
-
// Avg 40 (Noise) -> 20.
|
| 224 |
-
// Avg 100 (Talk) -> 50.
|
| 225 |
-
// Avg 180 (Loud) -> 90.
|
| 226 |
-
// This provides a much better dynamic range than *2.5 (too loud) or /2.5 (too quiet).
|
| 227 |
-
currentVol = Math.min(100, Math.floor(avg / 2));
|
| 228 |
-
|
| 229 |
-
volumeRef.current = currentVol;
|
| 230 |
-
}
|
| 231 |
-
}
|
| 232 |
-
|
| 233 |
-
setCurrentVolume(currentVol);
|
| 234 |
-
|
| 235 |
-
setMonsterPos(prev => {
|
| 236 |
-
let next = prev;
|
| 237 |
-
next -= (difficultyRef.current * 2 * delta);
|
| 238 |
-
|
| 239 |
-
if (currentVol > sensitivityRef.current) {
|
| 240 |
-
const power = (currentVol - sensitivityRef.current) * 0.8 * delta;
|
| 241 |
-
next += power;
|
| 242 |
-
|
| 243 |
-
if (time - lastAttackTime.current > 150) {
|
| 244 |
-
lastAttackTime.current = time;
|
| 245 |
-
setAttackCount(c => c + 1);
|
| 246 |
-
setIsHit(true);
|
| 247 |
-
setTimeout(() => setIsHit(false), 100);
|
| 248 |
-
}
|
| 249 |
-
}
|
| 250 |
-
|
| 251 |
-
if (next <= 0) {
|
| 252 |
-
endGame('DEFEAT');
|
| 253 |
-
return 0;
|
| 254 |
-
}
|
| 255 |
-
return Math.min(100, next);
|
| 256 |
-
});
|
| 257 |
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
endGame('VICTORY');
|
| 262 |
-
return 0;
|
| 263 |
-
}
|
| 264 |
-
return next;
|
| 265 |
-
});
|
| 266 |
-
};
|
| 267 |
-
|
| 268 |
-
const handleManualInput = () => {
|
| 269 |
-
if (!useKeyboardMode || !isPlaying || gameState !== 'PLAYING') return;
|
| 270 |
-
volumeRef.current = Math.min(volumeRef.current + 40, 100);
|
| 271 |
-
setCurrentVolume(volumeRef.current);
|
| 272 |
};
|
| 273 |
|
| 274 |
-
|
| 275 |
-
const handler = (e: KeyboardEvent) => {
|
| 276 |
-
if (e.code === 'Space') {
|
| 277 |
-
e.preventDefault();
|
| 278 |
-
handleManualInput();
|
| 279 |
-
}
|
| 280 |
-
};
|
| 281 |
-
window.addEventListener('keydown', handler);
|
| 282 |
-
return () => window.removeEventListener('keydown', handler);
|
| 283 |
-
}, [useKeyboardMode, isPlaying, gameState]);
|
| 284 |
-
|
| 285 |
-
const endGame = (result: 'VICTORY' | 'DEFEAT') => {
|
| 286 |
setIsPlaying(false);
|
| 287 |
-
|
| 288 |
-
if(
|
| 289 |
-
stopAudio();
|
| 290 |
};
|
| 291 |
|
| 292 |
-
const
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
if (
|
|
|
|
|
|
|
|
|
|
| 296 |
|
|
|
|
|
|
|
|
|
|
| 297 |
try {
|
| 298 |
-
const promises =
|
| 299 |
if (rewardConfig.type === 'ACHIEVEMENT') {
|
| 300 |
return api.achievements.grant({
|
| 301 |
studentId: s._id || String(s.id),
|
|
@@ -307,359 +143,177 @@ export const GameMonster: React.FC = () => {
|
|
| 307 |
studentId: s._id || String(s.id),
|
| 308 |
count: rewardConfig.count,
|
| 309 |
rewardType: rewardConfig.type,
|
| 310 |
-
name: rewardConfig.type === 'DRAW_COUNT' ? '早读抽奖券' : rewardConfig.val
|
|
|
|
| 311 |
});
|
| 312 |
}
|
| 313 |
});
|
| 314 |
await Promise.all(promises);
|
| 315 |
-
|
| 316 |
-
setIsConfigOpen(false);
|
| 317 |
-
} catch (e) { alert('发放失败'); }
|
| 318 |
};
|
| 319 |
|
| 320 |
-
const
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
zIndex: 999999,
|
| 327 |
-
backgroundColor: '#0f172a',
|
| 328 |
-
} : {};
|
| 329 |
-
|
| 330 |
-
const healthPercent = duration > 0 ? Math.max(0, Math.min(100, (timeLeft / duration) * 100)) : 100;
|
| 331 |
-
|
| 332 |
-
const GameContent = (
|
| 333 |
-
<div
|
| 334 |
-
className={`${isFullscreen ? '' : 'h-full w-full relative'} flex flex-col bg-slate-900 overflow-hidden select-none transition-all duration-300`}
|
| 335 |
-
style={containerStyle}
|
| 336 |
-
>
|
| 337 |
-
{/* Background */}
|
| 338 |
-
<div className="absolute inset-0 bg-[url('https://images.unsplash.com/photo-1519074069444-1ba4fff66d16?q=80&w=2544&auto=format&fit=crop')] bg-cover bg-center opacity-40 pointer-events-none"></div>
|
| 339 |
-
<div className="absolute inset-0 bg-gradient-to-t from-black via-transparent to-black opacity-80 pointer-events-none"></div>
|
| 340 |
-
|
| 341 |
-
{/* HUD Top Left */}
|
| 342 |
-
<div className="absolute top-4 left-4 z-50 flex gap-3 pointer-events-none">
|
| 343 |
-
<div className="bg-black/60 border border-yellow-500/30 rounded-xl p-3 min-w-[100px] text-center backdrop-blur-md">
|
| 344 |
-
<span className="text-[10px] text-yellow-500 font-bold uppercase block">倒计时</span>
|
| 345 |
-
<span className={`text-3xl font-mono font-black ${timeLeft < 30 ? 'text-red-500 animate-pulse' : 'text-white'}`}>
|
| 346 |
-
{Math.floor(timeLeft / 60)}:{String(Math.floor(timeLeft % 60)).padStart(2, '0')}
|
| 347 |
-
</span>
|
| 348 |
-
</div>
|
| 349 |
-
<div className="bg-black/60 border border-red-500/30 rounded-xl p-3 min-w-[100px] text-center backdrop-blur-md">
|
| 350 |
-
<span className="text-[10px] text-red-400 font-bold uppercase block">连击数</span>
|
| 351 |
-
<span className="text-3xl font-mono font-black text-white">{attackCount}</span>
|
| 352 |
-
</div>
|
| 353 |
-
</div>
|
| 354 |
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
<
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
|
|
|
|
|
|
| 368 |
</div>
|
| 369 |
-
|
| 370 |
-
</div>
|
| 371 |
-
|
| 372 |
-
{/* Main Controls */}
|
| 373 |
-
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 z-50 flex gap-4 items-center bg-black/50 p-2 rounded-2xl backdrop-blur-md border border-white/10 shadow-2xl">
|
| 374 |
-
<button
|
| 375 |
-
onClick={() => setIsConfigOpen(true)}
|
| 376 |
-
className="p-3 bg-gray-700/50 hover:bg-gray-600 text-white rounded-xl transition-all border border-transparent hover:border-gray-400"
|
| 377 |
-
title="设置"
|
| 378 |
-
>
|
| 379 |
-
<Settings size={20}/>
|
| 380 |
-
</button>
|
| 381 |
-
|
| 382 |
-
<button
|
| 383 |
-
onClick={togglePause}
|
| 384 |
-
className={`p-4 rounded-xl shadow-lg transition-all transform active:scale-95 flex items-center justify-center w-16 h-16 border-2 ${isPlaying ? 'bg-yellow-500 border-yellow-300 text-black hover:bg-yellow-400' : 'bg-green-600 border-green-400 text-white hover:bg-green-500 animate-pulse'}`}
|
| 385 |
-
title={isPlaying ? "暂停" : "开始"}
|
| 386 |
-
>
|
| 387 |
-
{isPlaying ? <Pause fill="currentColor" size={28}/> : <Play fill="currentColor" size={28}/>}
|
| 388 |
-
</button>
|
| 389 |
-
|
| 390 |
-
<button
|
| 391 |
-
onClick={() => setIsFullscreen(!isFullscreen)}
|
| 392 |
-
className={`p-3 rounded-xl transition-all border ${isFullscreen ? 'bg-blue-600 text-white border-blue-400' : 'bg-gray-700/50 text-white hover:bg-gray-600 border-transparent hover:border-gray-400'}`}
|
| 393 |
-
title="全屏切换"
|
| 394 |
-
>
|
| 395 |
-
{isFullscreen ? <Minimize size={20}/> : <Maximize size={20}/>}
|
| 396 |
-
</button>
|
| 397 |
</div>
|
| 398 |
|
| 399 |
{/* Game Area */}
|
| 400 |
-
<div className="flex-1
|
| 401 |
-
|
| 402 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 403 |
</div>
|
| 404 |
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 418 |
</div>
|
|
|
|
|
|
|
| 419 |
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 437 |
</div>
|
| 438 |
</div>
|
| 439 |
-
|
| 440 |
|
| 441 |
-
{/*
|
| 442 |
-
{
|
| 443 |
-
<div className="
|
| 444 |
-
<div className="bg-
|
| 445 |
-
<div className="
|
| 446 |
-
<
|
|
|
|
| 447 |
</div>
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
<
|
| 452 |
-
<label className="text-xs text-gray-400 font-bold uppercase block mb-1">早读时长 (秒)</label>
|
| 453 |
-
<input type="number" className="w-full bg-slate-900 border border-slate-600 rounded p-3 text-white focus:border-blue-500 outline-none" value={duration} onChange={e=>setDuration(Number(e.target.value))}/>
|
| 454 |
-
</div>
|
| 455 |
-
|
| 456 |
-
<div>
|
| 457 |
-
<label className="text-xs text-gray-400 font-bold uppercase block mb-1">怪兽前进速度 (难度: {difficulty})</label>
|
| 458 |
-
<input type="range" min="1" max="20" className="w-full h-2 bg-slate-700 rounded-lg appearance-none cursor-pointer" value={difficulty} onChange={e=>setDifficulty(Number(e.target.value))}/>
|
| 459 |
-
<div className="flex justify-between text-xs text-gray-500 mt-1"><span>1 (龟速)</span><span>20 (极速)</span></div>
|
| 460 |
-
</div>
|
| 461 |
-
|
| 462 |
-
<div>
|
| 463 |
-
<label className="text-xs text-gray-400 font-bold uppercase block mb-1">声控灵敏度阈值: {sensitivity}</label>
|
| 464 |
-
<input type="range" min="5" max="95" className="w-full h-2 bg-slate-700 rounded-lg appearance-none cursor-pointer" value={sensitivity} onChange={e=>setSensitivity(Number(e.target.value))}/>
|
| 465 |
-
<div className="flex justify-between text-xs text-gray-500 mt-1"><span>5 (极易触发/小声)</span><span>95 (极难触发/吼叫)</span></div>
|
| 466 |
-
</div>
|
| 467 |
-
|
| 468 |
-
<div className="flex items-center justify-between bg-slate-900 p-3 rounded-lg border border-slate-700">
|
| 469 |
-
<span className="text-sm font-bold flex items-center text-white"><Maximize size={16} className="mr-2"/> 全屏模式</span>
|
| 470 |
-
<label className="relative inline-flex items-center cursor-pointer">
|
| 471 |
-
<input type="checkbox" checked={isFullscreen} onChange={e=>setIsFullscreen(e.target.checked)} className="sr-only peer"/>
|
| 472 |
-
<div className="w-9 h-5 bg-slate-700 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-blue-600"></div>
|
| 473 |
-
</label>
|
| 474 |
-
</div>
|
| 475 |
-
|
| 476 |
-
<label className="flex items-center gap-3 bg-slate-900 p-3 rounded-lg border border-slate-700 cursor-pointer hover:bg-slate-700/50 transition-colors">
|
| 477 |
-
<input type="checkbox" checked={useKeyboardMode} onChange={e=>setUseKeyboardMode(e.target.checked)} className="w-5 h-5 rounded bg-slate-800 border-slate-600 text-blue-500 focus:ring-blue-500"/>
|
| 478 |
-
<div>
|
| 479 |
-
<span className="text-sm font-bold flex items-center text-white"><Keyboard size={16} className="mr-2"/> 键盘/点击辅助模式</span>
|
| 480 |
-
<p className="text-[10px] text-gray-400 mt-0.5">无麦克风或IE浏览器推荐开启 (空格键攻击)</p>
|
| 481 |
-
</div>
|
| 482 |
-
</label>
|
| 483 |
</div>
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
<div className="flex justify-between items-center mb-2">
|
| 488 |
-
<h4 className="text-xs text-gray-400 font-bold uppercase">参与人员</h4>
|
| 489 |
-
<button onClick={() => setIsFilterOpen(true)} className="text-xs text-blue-400 hover:text-blue-300 flex items-center">
|
| 490 |
-
<UserX size={14} className="mr-1"/> 排除请假学生 ({excludedStudentIds.size})
|
| 491 |
-
</button>
|
| 492 |
-
</div>
|
| 493 |
-
<p className="text-xs text-gray-500">共 {students.length - excludedStudentIds.size} 人参与奖励结算</p>
|
| 494 |
</div>
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
<
|
| 500 |
-
<label className="relative inline-flex items-center cursor-pointer">
|
| 501 |
-
<input type="checkbox" checked={rewardConfig.enabled} onChange={e=>setRewardConfig({...rewardConfig, enabled: e.target.checked})} className="sr-only peer"/>
|
| 502 |
-
<div className="w-9 h-5 bg-slate-700 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-amber-500"></div>
|
| 503 |
-
</label>
|
| 504 |
</div>
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
<div className="space-y-3 animate-in slide-in-from-top-2">
|
| 508 |
<div className="flex gap-2">
|
| 509 |
-
{
|
| 510 |
-
|
| 511 |
-
{id: 'ITEM', label: '实物', icon: <Package size={14}/>},
|
| 512 |
-
{id: 'ACHIEVEMENT', label: '成就', icon: <Trophy size={14}/>}
|
| 513 |
-
].map(t => (
|
| 514 |
-
<button
|
| 515 |
-
key={t.id}
|
| 516 |
-
onClick={() => setRewardConfig({...rewardConfig, type: t.id as any})}
|
| 517 |
-
className={`flex-1 py-2 text-xs rounded-lg border transition-all flex items-center justify-center gap-1 ${rewardConfig.type === t.id ? 'bg-amber-500/20 border-amber-500 text-amber-400' : 'bg-slate-800 border-slate-600 text-gray-400 hover:bg-slate-700'}`}
|
| 518 |
-
>
|
| 519 |
-
{t.icon} {t.label}
|
| 520 |
-
</button>
|
| 521 |
-
))}
|
| 522 |
</div>
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
<
|
| 527 |
-
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
>
|
| 531 |
-
|
| 532 |
-
{achConfig?.achievements.map(a => <option key={a.id} value={a.id}>{a.icon} {a.name}</option>)}
|
| 533 |
-
</select>
|
| 534 |
-
) : rewardConfig.type === 'ITEM' ? (
|
| 535 |
-
<input
|
| 536 |
-
className="flex-1 bg-slate-800 border border-slate-600 rounded-lg p-2 text-xs text-white outline-none placeholder-gray-500"
|
| 537 |
-
placeholder="奖品名称"
|
| 538 |
-
value={rewardConfig.val}
|
| 539 |
-
onChange={e=>setRewardConfig({...rewardConfig, val: e.target.value})}
|
| 540 |
-
/>
|
| 541 |
-
) : (
|
| 542 |
-
<div className="flex-1 bg-slate-800 border border-slate-600 rounded-lg p-2 text-xs text-gray-400">
|
| 543 |
-
自动发放系统抽奖券
|
| 544 |
-
</div>
|
| 545 |
-
)}
|
| 546 |
-
<div className="flex items-center bg-slate-800 border border-slate-600 rounded-lg px-2">
|
| 547 |
-
<span className="text-xs text-gray-500 mr-2">x</span>
|
| 548 |
-
<input
|
| 549 |
-
type="number"
|
| 550 |
-
min={1}
|
| 551 |
-
className="bg-transparent w-8 text-center text-white text-xs outline-none"
|
| 552 |
-
value={rewardConfig.count}
|
| 553 |
-
onChange={e=>setRewardConfig({...rewardConfig, count: Number(e.target.value)})}
|
| 554 |
-
/>
|
| 555 |
</div>
|
| 556 |
-
|
| 557 |
</div>
|
| 558 |
)}
|
| 559 |
</div>
|
| 560 |
-
|
| 561 |
-
|
| 562 |
-
<div className="p-5 border-t border-slate-700 bg-slate-900/50 shrink-0">
|
| 563 |
-
<button onClick={startGame} className="w-full py-3 bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl font-bold text-lg shadow-lg shadow-blue-900/50 transition-all transform hover:scale-[1.02] flex items-center justify-center">
|
| 564 |
-
<Play size={20} className="mr-2" fill="white"/> 开始挑战
|
| 565 |
-
</button>
|
| 566 |
</div>
|
| 567 |
</div>
|
| 568 |
</div>
|
| 569 |
)}
|
| 570 |
-
|
| 571 |
-
{/* Student Filter Modal */}
|
| 572 |
-
{isFilterOpen && (
|
| 573 |
-
<div className="absolute inset-0 bg-black/50 z-[1000000] flex items-center justify-center p-4">
|
| 574 |
-
<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">
|
| 575 |
-
<div className="p-4 border-b flex justify-between items-center">
|
| 576 |
-
<h3 className="font-bold text-lg">排除请假/缺勤学生</h3>
|
| 577 |
-
<div className="flex gap-2">
|
| 578 |
-
<button onClick={() => setExcludedStudentIds(new Set())} className="text-xs text-blue-600 hover:underline">清空选择</button>
|
| 579 |
-
<button onClick={() => setIsFilterOpen(false)} className="px-4 py-1 bg-blue-600 text-white rounded text-sm">确定</button>
|
| 580 |
-
</div>
|
| 581 |
-
</div>
|
| 582 |
-
<div className="flex-1 overflow-y-auto p-4 bg-gray-50">
|
| 583 |
-
<div className="grid grid-cols-4 sm:grid-cols-5 md:grid-cols-6 gap-2">
|
| 584 |
-
{students.map(s => {
|
| 585 |
-
const isExcluded = excludedStudentIds.has(s._id || String(s.id));
|
| 586 |
-
return (
|
| 587 |
-
<div
|
| 588 |
-
key={s._id}
|
| 589 |
-
onClick={() => {
|
| 590 |
-
const newSet = new Set(excludedStudentIds);
|
| 591 |
-
if (isExcluded) newSet.delete(s._id || String(s.id));
|
| 592 |
-
else newSet.add(s._id || String(s.id));
|
| 593 |
-
setExcludedStudentIds(newSet);
|
| 594 |
-
}}
|
| 595 |
-
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'}`}
|
| 596 |
-
>
|
| 597 |
-
<div className="font-bold">{s.name}</div>
|
| 598 |
-
<div className="text-xs opacity-70">{s.seatNo}</div>
|
| 599 |
-
{isExcluded && <div className="text-[10px] font-bold mt-1">已排除</div>}
|
| 600 |
-
</div>
|
| 601 |
-
)
|
| 602 |
-
})}
|
| 603 |
-
</div>
|
| 604 |
-
</div>
|
| 605 |
-
</div>
|
| 606 |
-
</div>
|
| 607 |
-
)}
|
| 608 |
-
|
| 609 |
-
{/* Result Modal */}
|
| 610 |
-
{gameState !== 'IDLE' && gameState !== 'PLAYING' && !isConfigOpen && (
|
| 611 |
-
<div className="absolute inset-0 bg-black/90 z-[110] flex items-center justify-center p-4 backdrop-blur-lg animate-in zoom-in">
|
| 612 |
-
<div className="text-center w-full max-w-lg">
|
| 613 |
-
<div className="text-9xl mb-6 animate-bounce drop-shadow-[0_0_25px_rgba(255,255,255,0.5)]">
|
| 614 |
-
{gameState === 'VICTORY' ? '🏆' : '💀'}
|
| 615 |
-
</div>
|
| 616 |
-
<h2 className={`text-6xl font-black mb-2 tracking-tight ${gameState==='VICTORY' ? 'text-transparent bg-clip-text bg-gradient-to-b from-yellow-300 to-yellow-600' : 'text-gray-400'}`}>
|
| 617 |
-
{gameState === 'VICTORY' ? '守卫成功!' : '防线沦陷...'}
|
| 618 |
-
</h2>
|
| 619 |
-
|
| 620 |
-
{gameState === 'VICTORY' && rewardConfig.enabled && (
|
| 621 |
-
<div className="bg-white/10 p-6 rounded-2xl mb-10 border border-white/10 backdrop-blur-sm mt-8">
|
| 622 |
-
<p className="text-yellow-200 font-bold mb-2 flex items-center justify-center"><Gift size={20} className="mr-2"/> 奖励已解锁</p>
|
| 623 |
-
<p className="text-sm text-gray-300 mb-4">
|
| 624 |
-
为 <span className="text-white font-bold">{students.length - excludedStudentIds.size}</span> 名学生发放:
|
| 625 |
-
<span className="text-white font-bold mx-1 border-b border-white/30">
|
| 626 |
-
{rewardConfig.type==='ACHIEVEMENT' ? '成就奖状' : rewardConfig.type==='ITEM' ? rewardConfig.val : '抽奖券'}
|
| 627 |
-
</span>
|
| 628 |
-
x{rewardConfig.count}
|
| 629 |
-
</p>
|
| 630 |
-
<button onClick={handleBatchGrant} className="bg-amber-500 hover:bg-amber-600 text-white px-6 py-2 rounded-lg font-bold text-sm shadow-lg transition-colors">
|
| 631 |
-
立即发放
|
| 632 |
-
</button>
|
| 633 |
-
</div>
|
| 634 |
-
)}
|
| 635 |
-
|
| 636 |
-
<div className="flex gap-4 justify-center mt-8">
|
| 637 |
-
<button onClick={() => setIsConfigOpen(true)} className="px-8 py-3 bg-white/10 hover:bg-white/20 text-white rounded-full font-bold flex items-center transition-colors border border-white/10">
|
| 638 |
-
<Settings size={18} className="mr-2"/> 调整设置
|
| 639 |
-
</button>
|
| 640 |
-
<button onClick={startGame} className="px-10 py-3 bg-blue-600 hover:bg-blue-500 text-white rounded-full font-bold flex items-center shadow-lg shadow-blue-500/50 transition-all hover:scale-105">
|
| 641 |
-
<RotateCcw size={18} className="mr-2"/> 再来一次
|
| 642 |
-
</button>
|
| 643 |
-
</div>
|
| 644 |
-
</div>
|
| 645 |
-
</div>
|
| 646 |
-
)}
|
| 647 |
-
|
| 648 |
-
<style>{`
|
| 649 |
-
@keyframes monsterWalk {
|
| 650 |
-
0% { transform: scale(0.8) rotate(-5deg); }
|
| 651 |
-
100% { transform: scale(0.8) rotate(5deg); }
|
| 652 |
-
}
|
| 653 |
-
`}</style>
|
| 654 |
</div>
|
| 655 |
);
|
| 656 |
-
|
| 657 |
-
if (isFullscreen) {
|
| 658 |
-
return createPortal(GameContent, document.body);
|
| 659 |
-
}
|
| 660 |
-
return GameContent;
|
| 661 |
};
|
| 662 |
-
|
| 663 |
-
const TicketIcon = () => (
|
| 664 |
-
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M2 9a3 3 0 0 1 0 6v2a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-2a3 3 0 0 1 0-6V7a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2Z"/><path d="M13 5v2"/><path d="M13 17v2"/><path d="M13 11v2"/></svg>
|
| 665 |
-
);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
|
| 2 |
+
import React, { useState, useEffect, useRef } from 'react';
|
| 3 |
+
import { api } from '../services/api';
|
| 4 |
+
import { Student, GameMonsterConfig, AchievementItem } from '../types';
|
| 5 |
+
import { Mic, Volume2, Shield, Sword, Heart, Settings, X, Gift, PlayCircle, PauseCircle, Filter, CheckCircle, Loader2 } from 'lucide-react';
|
| 6 |
|
| 7 |
export const GameMonster: React.FC = () => {
|
| 8 |
+
const [config, setConfig] = useState<GameMonsterConfig | null>(null);
|
|
|
|
|
|
|
|
|
|
| 9 |
const [isPlaying, setIsPlaying] = useState(false);
|
| 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 |
+
const [gameResult, setGameResult] = useState<'WIN' | 'LOSE' | null>(null);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
+
// Exclusion
|
| 19 |
+
const [excludedIds, setExcludedIds] = useState<Set<string>>(new Set());
|
| 20 |
+
const [showFilterModal, setShowFilterModal] = useState(false);
|
| 21 |
|
| 22 |
+
// Audio Refs
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
const audioContextRef = useRef<AudioContext | null>(null);
|
| 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 |
+
if (isTeacher && currentUser.homeroomClass) {
|
| 38 |
+
try {
|
| 39 |
+
// Load students
|
| 40 |
+
const all = await api.students.getAll();
|
| 41 |
+
setStudents(all.filter((s: Student) => s.className === currentUser.homeroomClass));
|
| 42 |
+
|
| 43 |
+
// Load achievements
|
| 44 |
+
const ac = await api.achievements.getConfig(currentUser.homeroomClass);
|
| 45 |
+
if (ac) setAchievements(ac.achievements);
|
| 46 |
+
|
| 47 |
+
// Load Config
|
| 48 |
+
let cfg = await api.games.getMonsterConfig(currentUser.homeroomClass);
|
| 49 |
+
if (!cfg) {
|
| 50 |
+
cfg = {
|
| 51 |
+
schoolId: currentUser.schoolId || '',
|
| 52 |
+
className: currentUser.homeroomClass,
|
| 53 |
+
duration: 300,
|
| 54 |
+
sensitivity: 50,
|
| 55 |
+
difficulty: 5,
|
| 56 |
+
useKeyboardMode: false,
|
| 57 |
+
rewardConfig: { enabled: true, type: 'DRAW_COUNT', val: '1', count: 1 }
|
| 58 |
+
};
|
| 59 |
+
}
|
| 60 |
+
setConfig(cfg);
|
| 61 |
+
} catch(e) { console.error(e); }
|
| 62 |
+
}
|
| 63 |
+
};
|
| 64 |
+
|
| 65 |
+
const startGame = async () => {
|
| 66 |
+
if (!config) return;
|
| 67 |
try {
|
| 68 |
+
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
| 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 processAudio = () => {
|
| 86 |
+
if (!analyserRef.current || !config) return;
|
|
|
|
|
|
|
| 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 endGame = (result: 'WIN' | 'LOSE') => {
|
| 123 |
+
stopGame();
|
| 124 |
+
setGameResult(result);
|
| 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 = activeStudents.map(s => {
|
| 135 |
if (rewardConfig.type === 'ACHIEVEMENT') {
|
| 136 |
return api.achievements.grant({
|
| 137 |
studentId: s._id || String(s.id),
|
|
|
|
| 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 |
+
} catch (e) { console.error(e); }
|
|
|
|
|
|
|
| 153 |
};
|
| 154 |
|
| 155 |
+
const saveSettings = async () => {
|
| 156 |
+
if (config) {
|
| 157 |
+
await api.games.saveMonsterConfig(config);
|
| 158 |
+
setIsSettingsOpen(false);
|
| 159 |
+
}
|
| 160 |
+
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
|
| 162 |
+
if (!config) return <div className="p-10 text-center"><Loader2 className="animate-spin inline mr-2"/> 加载配置中...</div>;
|
| 163 |
+
|
| 164 |
+
return (
|
| 165 |
+
<div className="h-full flex flex-col relative bg-slate-900 overflow-hidden text-white">
|
| 166 |
+
{/* Header */}
|
| 167 |
+
<div className="p-4 flex justify-between items-center z-10">
|
| 168 |
+
<h3 className="font-bold text-lg flex items-center"><Mic className="mr-2 text-purple-400"/> 早读战怪兽</h3>
|
| 169 |
+
{isTeacher && (
|
| 170 |
+
<div className="flex gap-2">
|
| 171 |
+
<button onClick={()=>setShowFilterModal(true)} className="bg-white/10 px-3 py-2 rounded text-sm flex items-center hover:bg-white/20">
|
| 172 |
+
<Filter size={16} className="mr-1"/> 人员 ({students.length - excludedIds.size})
|
| 173 |
+
</button>
|
| 174 |
+
<button onClick={()=>setIsSettingsOpen(true)} className="bg-white/10 p-2 rounded hover:bg-white/20">
|
| 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-all duration-100 relative ${isPlaying && volume > 50 ? 'animate-pulse scale-105' : ''}`}>
|
| 185 |
+
<div className="text-[150px] md:text-[200px] leading-none filter drop-shadow-[0_0_30px_rgba(168,85,247,0.5)]">
|
| 186 |
+
{gameResult === 'WIN' ? '💀' : '👾'}
|
| 187 |
+
</div>
|
| 188 |
+
{/* HP Bar */}
|
| 189 |
+
<div className="w-64 h-6 bg-gray-700 rounded-full mt-8 overflow-hidden border-2 border-gray-600 relative">
|
| 190 |
+
<div
|
| 191 |
+
className="h-full bg-gradient-to-r from-red-600 to-purple-600 transition-all duration-200"
|
| 192 |
+
style={{ width: `${monsterHp}%` }}
|
| 193 |
+
></div>
|
| 194 |
+
<span className="absolute inset-0 flex items-center justify-center text-xs font-bold drop-shadow">
|
| 195 |
+
BOSS HP: {Math.ceil(monsterHp)}%
|
| 196 |
+
</span>
|
| 197 |
+
</div>
|
| 198 |
</div>
|
| 199 |
|
| 200 |
+
{/* Volume Meter */}
|
| 201 |
+
<div className="mt-10 flex items-center gap-4">
|
| 202 |
+
<Volume2 size={24} className={volume > 30 ? 'text-green-400' : 'text-gray-500'}/>
|
| 203 |
+
<div className="w-48 h-2 bg-gray-700 rounded-full overflow-hidden">
|
| 204 |
+
<div
|
| 205 |
+
className="h-full bg-green-400 transition-all duration-75"
|
| 206 |
+
style={{ width: `${(volume/255)*100}%` }}
|
| 207 |
+
></div>
|
| 208 |
+
</div>
|
| 209 |
+
<span className="font-mono text-sm">{Math.round(volume)}</span>
|
| 210 |
+
</div>
|
| 211 |
+
|
| 212 |
+
<p className="mt-4 text-gray-400 text-sm">
|
| 213 |
+
{isPlaying ? '大声朗读来攻击怪兽!' : '点击开始,全班齐读'}
|
| 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 |
+
{/* Filter Modal */}
|
| 233 |
+
{showFilterModal && (
|
| 234 |
+
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 text-gray-800">
|
| 235 |
+
<div className="bg-white rounded-xl p-6 w-full max-w-lg h-[80vh] flex flex-col animate-in zoom-in-95">
|
| 236 |
+
<div className="flex justify-between items-center mb-4">
|
| 237 |
+
<h3 className="font-bold text-lg">参与人员筛选 (不参与奖励)</h3>
|
| 238 |
+
<button onClick={()=>setShowFilterModal(false)}><X size={20}/></button>
|
| 239 |
+
</div>
|
| 240 |
+
<div className="flex-1 overflow-y-auto grid grid-cols-3 gap-2 custom-scrollbar">
|
| 241 |
+
{students.map(s => {
|
| 242 |
+
const isExcluded = excludedIds.has(s._id || String(s.id));
|
| 243 |
+
return (
|
| 244 |
+
<div
|
| 245 |
+
key={s._id}
|
| 246 |
+
onClick={() => {
|
| 247 |
+
const newSet = new Set(excludedIds);
|
| 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/80 z-50 flex items-center justify-center p-4 text-gray-800">
|
| 271 |
+
<div className="bg-white rounded-xl p-6 w-full max-w-md animate-in zoom-in-95">
|
| 272 |
+
<div className="flex justify-between items-center mb-6">
|
| 273 |
+
<h3 className="font-bold text-lg">游戏设置</h3>
|
| 274 |
+
<button onClick={()=>setIsSettingsOpen(false)}><X/></button>
|
| 275 |
</div>
|
| 276 |
+
<div className="space-y-4">
|
| 277 |
+
<div>
|
| 278 |
+
<label className="text-xs font-bold text-gray-500 uppercase block mb-1">灵敏度 (更容易造成伤害)</label>
|
| 279 |
+
<input type="range" min="1" max="100" className="w-full" value={config.sensitivity} onChange={e=>setConfig({...config, sensitivity: Number(e.target.value)})}/>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 280 |
</div>
|
| 281 |
+
<div>
|
| 282 |
+
<label className="text-xs font-bold text-gray-500 uppercase block mb-1">怪兽血量 (难度)</label>
|
| 283 |
+
<input type="range" min="1" max="20" className="w-full" value={config.difficulty} onChange={e=>setConfig({...config, difficulty: Number(e.target.value)})}/>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 284 |
</div>
|
| 285 |
+
|
| 286 |
+
<div className="bg-gray-50 p-4 rounded-lg border border-gray-200">
|
| 287 |
+
<div className="flex items-center justify-between mb-2">
|
| 288 |
+
<label className="font-bold text-sm">胜利奖励</label>
|
| 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 |
<div className="flex gap-2">
|
| 294 |
+
<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>
|
| 295 |
+
<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>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 296 |
</div>
|
| 297 |
+
{config.rewardConfig.type === 'ACHIEVEMENT' ? (
|
| 298 |
+
<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}})}>
|
| 299 |
+
<option value="">选择成就</option>
|
| 300 |
+
{achievements.map(a=><option key={a.id} value={a.id}>{a.name}</option>)}
|
| 301 |
+
</select>
|
| 302 |
+
) : (
|
| 303 |
+
<div className="flex items-center gap-2">
|
| 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 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 317 |
</div>
|
| 318 |
);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 319 |
};
|
|
|
|
|
|
|
|
|
|
|
|
pages/GameRandom.tsx
CHANGED
|
@@ -1,162 +1,99 @@
|
|
| 1 |
|
| 2 |
import React, { useState, useEffect, useRef } from 'react';
|
| 3 |
-
import { createPortal } from 'react-dom';
|
| 4 |
import { api } from '../services/api';
|
| 5 |
-
import { Student
|
| 6 |
-
import {
|
| 7 |
|
| 8 |
export const GameRandom: React.FC = () => {
|
| 9 |
-
const [loading, setLoading] = useState(true);
|
| 10 |
const [students, setStudents] = useState<Student[]>([]);
|
| 11 |
-
const [
|
| 12 |
-
const [
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
-
//
|
| 15 |
-
const [
|
| 16 |
-
const [
|
| 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 |
-
//
|
| 24 |
-
const [excludedStudentIds, setExcludedStudentIds] = useState<Set<string>>(new Set());
|
| 25 |
-
const [isFilterOpen, setIsFilterOpen] = useState(false);
|
| 26 |
-
|
| 27 |
-
// Reward State
|
| 28 |
const [rewardType, setRewardType] = useState<'DRAW_COUNT' | 'ITEM' | 'ACHIEVEMENT'>('DRAW_COUNT');
|
| 29 |
-
const [
|
| 30 |
const [rewardCount, setRewardCount] = useState(1);
|
| 31 |
-
const [
|
| 32 |
-
|
| 33 |
-
const timerRef = useRef<any>(null);
|
| 34 |
-
const speedRef = useRef<number>(50);
|
| 35 |
|
|
|
|
| 36 |
const currentUser = api.auth.getCurrentUser();
|
| 37 |
-
const
|
| 38 |
|
| 39 |
useEffect(() => {
|
| 40 |
loadData();
|
| 41 |
-
return () =>
|
| 42 |
}, []);
|
| 43 |
|
| 44 |
const loadData = async () => {
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
const classStudents = allStus.filter((s: Student) => s.className === homeroomClass);
|
| 54 |
-
// Sort by Seat No
|
| 55 |
-
classStudents.sort((a: Student, b: Student) => {
|
| 56 |
-
const seatA = parseInt(a.seatNo || '99999');
|
| 57 |
-
const seatB = parseInt(b.seatNo || '99999');
|
| 58 |
-
if (seatA !== seatB) return seatA - seatB;
|
| 59 |
-
return a.name.localeCompare(b.name, 'zh-CN');
|
| 60 |
-
});
|
| 61 |
-
|
| 62 |
-
setStudents(classStudents);
|
| 63 |
-
if (session) setTeams(session.teams || []);
|
| 64 |
-
setAchConfig(ac);
|
| 65 |
-
} catch (e) { console.error(e); }
|
| 66 |
-
finally { setLoading(false); }
|
| 67 |
-
};
|
| 68 |
-
|
| 69 |
-
const getTargetList = () => {
|
| 70 |
-
if (mode === 'TEAM') return teams;
|
| 71 |
-
|
| 72 |
-
let baseList = students;
|
| 73 |
-
if (scopeTeamId !== 'ALL') {
|
| 74 |
-
const team = teams.find(t => t.id === scopeTeamId);
|
| 75 |
-
if (team) {
|
| 76 |
-
baseList = students.filter(s => team.members.includes(s._id || String(s.id)));
|
| 77 |
}
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
// Filter out excluded students
|
| 81 |
-
return baseList.filter(s => !excludedStudentIds.has(s._id || String(s.id)));
|
| 82 |
};
|
| 83 |
|
| 84 |
-
const
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
speedRef.current = 50;
|
| 92 |
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
});
|
| 98 |
-
|
| 99 |
-
// Slow down logic
|
| 100 |
-
speedRef.current = Math.min(speedRef.current * 1.1, 600);
|
| 101 |
-
|
| 102 |
-
if (speedRef.current < 500) {
|
| 103 |
-
timerRef.current = setTimeout(run, speedRef.current);
|
| 104 |
-
} else {
|
| 105 |
-
// Stop
|
| 106 |
-
stopAnimation();
|
| 107 |
-
setTimeout(() => finalizeSelection(list), 500);
|
| 108 |
-
}
|
| 109 |
};
|
| 110 |
-
|
| 111 |
-
};
|
| 112 |
-
|
| 113 |
-
const finalizeSelection = (list: any[]) => {
|
| 114 |
-
setHighlightIndex(prev => {
|
| 115 |
-
if (prev === null) return 0;
|
| 116 |
-
const result = list[prev];
|
| 117 |
-
setSelectedResult(result);
|
| 118 |
-
setShowResultModal(true);
|
| 119 |
-
return prev;
|
| 120 |
-
});
|
| 121 |
};
|
| 122 |
|
| 123 |
-
const
|
| 124 |
-
if (timerRef.current)
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
const handleGrantReward = async () => {
|
| 129 |
-
if (!selectedResult || !enableReward) {
|
| 130 |
-
setShowResultModal(false);
|
| 131 |
-
return;
|
| 132 |
-
}
|
| 133 |
-
|
| 134 |
-
let targets: Student[] = [];
|
| 135 |
-
if (mode === 'STUDENT') {
|
| 136 |
-
targets = [selectedResult as Student];
|
| 137 |
-
} else {
|
| 138 |
-
// Team mode: find all students in team
|
| 139 |
-
const team = selectedResult as GameTeam;
|
| 140 |
-
// IMPORTANT: Exclude absent students from team reward
|
| 141 |
-
targets = students.filter(s => team.members.includes(s._id || String(s.id)) && !excludedStudentIds.has(s._id || String(s.id)));
|
| 142 |
}
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
}
|
|
|
|
| 149 |
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
try {
|
| 155 |
-
const promises =
|
| 156 |
-
if (rewardType === 'ACHIEVEMENT'
|
| 157 |
return api.achievements.grant({
|
| 158 |
studentId: s._id || String(s.id),
|
| 159 |
-
achievementId:
|
| 160 |
semester: '当前学期' // Ideally fetch from config
|
| 161 |
});
|
| 162 |
} else {
|
|
@@ -164,203 +101,159 @@ export const GameRandom: React.FC = () => {
|
|
| 164 |
studentId: s._id || String(s.id),
|
| 165 |
count: rewardCount,
|
| 166 |
rewardType,
|
| 167 |
-
name: rewardName
|
|
|
|
| 168 |
});
|
| 169 |
}
|
| 170 |
});
|
| 171 |
await Promise.all(promises);
|
| 172 |
-
alert(`已发放奖励给 ${
|
|
|
|
| 173 |
} catch (e) {
|
| 174 |
-
console.error(e);
|
| 175 |
alert('发放失败');
|
| 176 |
}
|
| 177 |
-
setShowResultModal(false);
|
| 178 |
};
|
| 179 |
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
const targetList = getTargetList();
|
| 183 |
|
| 184 |
-
|
| 185 |
-
<div className=
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
: 'bg-white text-gray-600 hover:bg-gray-100 border border-gray-200'
|
| 193 |
-
}`}
|
| 194 |
-
title={isFullscreen ? "退出全屏" : "全屏显示"}
|
| 195 |
-
>
|
| 196 |
-
{isFullscreen ? <Minimize size={20}/> : <Maximize size={20}/>}
|
| 197 |
-
<span className="text-xs font-bold hidden sm:inline">{isFullscreen ? '退出全屏' : '全屏模式'}</span>
|
| 198 |
-
</button>
|
| 199 |
-
{/* Header Config */}
|
| 200 |
-
<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">
|
| 201 |
-
<div className="flex flex-wrap gap-4 items-center">
|
| 202 |
-
<div className="flex gap-2 bg-gray-100 p-1 rounded-lg">
|
| 203 |
-
<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'}`}>
|
| 204 |
-
<User size={16} className="inline mr-1"/> 点名学生
|
| 205 |
</button>
|
| 206 |
-
<
|
| 207 |
-
<
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
{mode === 'STUDENT' && (
|
| 211 |
-
<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)}>
|
| 212 |
-
<option value="ALL">全班范围</option>
|
| 213 |
-
{teams.map(t => <option key={t.id} value={t.id}>{t.name} (组)</option>)}
|
| 214 |
-
</select>
|
| 215 |
-
)}
|
| 216 |
-
|
| 217 |
-
<button
|
| 218 |
-
onClick={() => setIsFilterOpen(true)}
|
| 219 |
-
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'}`}
|
| 220 |
-
>
|
| 221 |
-
<UserX size={16}/> 排除 ({excludedStudentIds.size})
|
| 222 |
-
</button>
|
| 223 |
-
|
| 224 |
-
<div className="flex items-center gap-2 bg-amber-50 px-3 py-2 rounded-lg border border-amber-100">
|
| 225 |
-
<Gift size={16} className="text-amber-500"/>
|
| 226 |
-
<label className="text-sm font-bold text-gray-700">奖励:</label>
|
| 227 |
-
<select className="text-sm border-gray-300 rounded bg-white outline-none" value={rewardType} onChange={e=>setRewardType(e.target.value as any)}>
|
| 228 |
-
<option value="DRAW_COUNT">抽奖券</option>
|
| 229 |
-
<option value="ITEM">实物</option>
|
| 230 |
-
<option value="ACHIEVEMENT">成就</option>
|
| 231 |
-
</select>
|
| 232 |
-
{rewardType === 'ACHIEVEMENT' ? (
|
| 233 |
-
<select className="text-sm border-gray-300 rounded w-32 bg-white outline-none" value={rewardId} onChange={e=>setRewardId(e.target.value)}>
|
| 234 |
-
<option value="">选择成就</option>
|
| 235 |
-
{achConfig?.achievements.map(a => <option key={a.id} value={a.id}>{a.icon} {a.name}</option>)}
|
| 236 |
</select>
|
| 237 |
-
|
| 238 |
-
<input className="text-sm border-gray-300 rounded w-24 p-1 outline-none" placeholder="奖品名" value={rewardId} onChange={e=>setRewardId(e.target.value)}/>
|
| 239 |
-
) : null}
|
| 240 |
-
<span className="text-xs text-gray-400">x</span>
|
| 241 |
-
<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))}/>
|
| 242 |
</div>
|
| 243 |
-
|
| 244 |
</div>
|
| 245 |
-
|
| 246 |
-
<div className="flex-1
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
<div className={`mb-2 font-bold ${isFullscreen ? 'text-5xl' : 'text-3xl'}`} style={{color: isTeam ? color : '#64748b'}}>
|
| 267 |
-
{mode === 'STUDENT' ? (
|
| 268 |
-
<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'}`}>
|
| 269 |
-
{name[0]}
|
| 270 |
-
</div>
|
| 271 |
-
) : avatar}
|
| 272 |
-
</div>
|
| 273 |
-
<div className={`font-bold text-gray-800 text-center truncate w-full px-2 ${isFullscreen ? 'text-2xl' : 'text-base'}`}>{name}</div>
|
| 274 |
-
<div className="text-xs text-gray-400">{subText}</div>
|
| 275 |
-
</div>
|
| 276 |
-
);
|
| 277 |
-
})}
|
| 278 |
-
</div>
|
| 279 |
-
</div>
|
| 280 |
-
{/* Start Button */}
|
| 281 |
-
<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)]">
|
| 282 |
-
<button
|
| 283 |
-
onClick={isRunning ? stopAnimation : startRandom}
|
| 284 |
-
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'}`}
|
| 285 |
-
>
|
| 286 |
-
{isRunning ? <><Pause fill="white"/> 停止</> : <><Play fill="white"/> 开始随机</>}
|
| 287 |
-
</button>
|
| 288 |
</div>
|
| 289 |
|
| 290 |
-
{
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
<
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 300 |
</div>
|
| 301 |
-
<div className="
|
| 302 |
-
<
|
| 303 |
-
|
| 304 |
-
const isExcluded = excludedStudentIds.has(s._id || String(s.id));
|
| 305 |
-
return (
|
| 306 |
-
<div
|
| 307 |
-
key={s._id}
|
| 308 |
-
onClick={() => {
|
| 309 |
-
const newSet = new Set(excludedStudentIds);
|
| 310 |
-
if (isExcluded) newSet.delete(s._id || String(s.id));
|
| 311 |
-
else newSet.add(s._id || String(s.id));
|
| 312 |
-
setExcludedStudentIds(newSet);
|
| 313 |
-
}}
|
| 314 |
-
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'}`}
|
| 315 |
-
>
|
| 316 |
-
<div className="font-bold">{s.name}</div>
|
| 317 |
-
<div className="text-xs opacity-70">{s.seatNo}</div>
|
| 318 |
-
{isExcluded && <div className="text-[10px] font-bold mt-1">已排除</div>}
|
| 319 |
-
</div>
|
| 320 |
-
)
|
| 321 |
-
})}
|
| 322 |
-
</div>
|
| 323 |
</div>
|
| 324 |
</div>
|
| 325 |
</div>
|
| 326 |
)}
|
| 327 |
|
| 328 |
-
{/*
|
| 329 |
-
{
|
| 330 |
-
<div className="fixed inset-0 bg-black/
|
| 331 |
-
<div className="bg-white rounded-
|
| 332 |
-
<
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 354 |
</div>
|
| 355 |
-
<button onClick={() => setShowResultModal(false)} className="text-gray-400 hover:text-gray-600 text-sm underline">跳过 / 关闭</button>
|
| 356 |
</div>
|
| 357 |
</div>
|
| 358 |
)}
|
| 359 |
</div>
|
| 360 |
);
|
| 361 |
-
|
| 362 |
-
if (isFullscreen) {
|
| 363 |
-
return createPortal(GameContent, document.body);
|
| 364 |
-
}
|
| 365 |
-
return GameContent;
|
| 366 |
};
|
|
|
|
| 1 |
|
| 2 |
import React, { useState, useEffect, useRef } from 'react';
|
|
|
|
| 3 |
import { api } from '../services/api';
|
| 4 |
+
import { Student } from '../types';
|
| 5 |
+
import { Zap, Play, Square, Gift, Users, Check, X, RotateCcw, Filter, CheckCircle } from 'lucide-react';
|
| 6 |
|
| 7 |
export const GameRandom: React.FC = () => {
|
|
|
|
| 8 |
const [students, setStudents] = useState<Student[]>([]);
|
| 9 |
+
const [selectedStudents, setSelectedStudents] = useState<Student[]>([]);
|
| 10 |
+
const [isRolling, setIsRolling] = useState(false);
|
| 11 |
+
const [displayIndex, setDisplayIndex] = useState(0);
|
| 12 |
+
const [rollCount, setRollCount] = useState(1);
|
| 13 |
+
const [showRewardModal, setShowRewardModal] = useState(false);
|
| 14 |
|
| 15 |
+
// Exclusion Logic
|
| 16 |
+
const [excludedIds, setExcludedIds] = useState<Set<string>>(new Set());
|
| 17 |
+
const [showFilterModal, setShowFilterModal] = useState(false);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
+
// Reward Form
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
const [rewardType, setRewardType] = useState<'DRAW_COUNT' | 'ITEM' | 'ACHIEVEMENT'>('DRAW_COUNT');
|
| 21 |
+
const [rewardName, setRewardName] = useState('随机奖励');
|
| 22 |
const [rewardCount, setRewardCount] = useState(1);
|
| 23 |
+
const [achieveId, setAchieveId] = useState('');
|
| 24 |
+
const [achievements, setAchievements] = useState<any[]>([]);
|
|
|
|
|
|
|
| 25 |
|
| 26 |
+
const timerRef = useRef<number | null>(null);
|
| 27 |
const currentUser = api.auth.getCurrentUser();
|
| 28 |
+
const isTeacher = currentUser?.role === 'TEACHER';
|
| 29 |
|
| 30 |
useEffect(() => {
|
| 31 |
loadData();
|
| 32 |
+
return () => stopRolling();
|
| 33 |
}, []);
|
| 34 |
|
| 35 |
const loadData = async () => {
|
| 36 |
+
try {
|
| 37 |
+
const all = await api.students.getAll();
|
| 38 |
+
let list = all;
|
| 39 |
+
if (isTeacher && currentUser.homeroomClass) {
|
| 40 |
+
list = all.filter((s: Student) => s.className === currentUser.homeroomClass);
|
| 41 |
+
// Load achievements for reward dropdown
|
| 42 |
+
const ac = await api.achievements.getConfig(currentUser.homeroomClass);
|
| 43 |
+
if (ac) setAchievements(ac.achievements);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
}
|
| 45 |
+
setStudents(list);
|
| 46 |
+
} catch(e) { console.error(e); }
|
|
|
|
|
|
|
| 47 |
};
|
| 48 |
|
| 49 |
+
const getActiveStudents = () => students.filter(s => !excludedIds.has(s._id || String(s.id)));
|
| 50 |
+
|
| 51 |
+
const startRolling = () => {
|
| 52 |
+
const active = getActiveStudents();
|
| 53 |
+
if (active.length === 0) return alert('没有参与的学生');
|
| 54 |
+
setIsRolling(true);
|
| 55 |
+
setSelectedStudents([]);
|
|
|
|
| 56 |
|
| 57 |
+
let speed = 50;
|
| 58 |
+
const animate = () => {
|
| 59 |
+
setDisplayIndex(Math.floor(Math.random() * active.length));
|
| 60 |
+
timerRef.current = window.setTimeout(animate, speed);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
};
|
| 62 |
+
animate();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
};
|
| 64 |
|
| 65 |
+
const stopRolling = () => {
|
| 66 |
+
if (timerRef.current) {
|
| 67 |
+
clearTimeout(timerRef.current);
|
| 68 |
+
timerRef.current = null;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
while(results.length < Math.min(rollCount, active.length)) {
|
| 78 |
+
const idx = Math.floor(Math.random() * active.length);
|
| 79 |
+
if (!picked.has(idx)) {
|
| 80 |
+
picked.add(idx);
|
| 81 |
+
results.push(active[idx]);
|
| 82 |
+
}
|
| 83 |
+
}
|
| 84 |
+
setSelectedStudents(results);
|
| 85 |
}
|
| 86 |
+
};
|
| 87 |
|
| 88 |
+
const handleGrantReward = async () => {
|
| 89 |
+
if (selectedStudents.length === 0) return;
|
| 90 |
+
|
|
|
|
| 91 |
try {
|
| 92 |
+
const promises = selectedStudents.map(s => {
|
| 93 |
+
if (rewardType === 'ACHIEVEMENT') {
|
| 94 |
return api.achievements.grant({
|
| 95 |
studentId: s._id || String(s.id),
|
| 96 |
+
achievementId: achieveId,
|
| 97 |
semester: '当前学期' // Ideally fetch from config
|
| 98 |
});
|
| 99 |
} else {
|
|
|
|
| 101 |
studentId: s._id || String(s.id),
|
| 102 |
count: rewardCount,
|
| 103 |
rewardType,
|
| 104 |
+
name: rewardName,
|
| 105 |
+
source: '随机点名'
|
| 106 |
});
|
| 107 |
}
|
| 108 |
});
|
| 109 |
await Promise.all(promises);
|
| 110 |
+
alert(`已发放奖励给 ${selectedStudents.length} 名学生!`);
|
| 111 |
+
setShowRewardModal(false);
|
| 112 |
} catch (e) {
|
|
|
|
| 113 |
alert('发放失败');
|
| 114 |
}
|
|
|
|
| 115 |
};
|
| 116 |
|
| 117 |
+
const activePool = getActiveStudents();
|
|
|
|
|
|
|
| 118 |
|
| 119 |
+
return (
|
| 120 |
+
<div className="h-full flex flex-col relative overflow-hidden bg-gradient-to-br from-yellow-50 to-orange-50">
|
| 121 |
+
<div className="p-6 border-b border-yellow-100 flex justify-between items-center bg-white/50 backdrop-blur-sm">
|
| 122 |
+
<h3 className="text-xl font-bold text-gray-800 flex items-center"><Zap className="text-yellow-500 mr-2" fill="currentColor"/> 随机点名</h3>
|
| 123 |
+
{isTeacher && (
|
| 124 |
+
<div className="flex items-center gap-2">
|
| 125 |
+
<button onClick={()=>setShowFilterModal(true)} className="px-3 py-1.5 bg-white border border-gray-200 rounded-lg text-sm text-gray-600 flex items-center hover:bg-gray-50">
|
| 126 |
+
<Filter size={16} className="mr-1"/> 人员 ({activePool.length}/{students.length})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
</div>
|
| 137 |
+
|
| 138 |
+
<div className="flex-1 flex flex-col items-center justify-center p-8">
|
| 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 |
+
{isTeacher && (
|
| 161 |
+
<div className="p-8 flex justify-center gap-4">
|
| 162 |
+
{!isRolling ? (
|
| 163 |
+
<>
|
| 164 |
+
<button onClick={startRolling} disabled={activePool.length===0} className="bg-blue-600 text-white px-8 py-3 rounded-full font-bold text-lg shadow-lg hover:bg-blue-700 hover:scale-105 transition-all flex items-center disabled:opacity-50">
|
| 165 |
+
<Play className="mr-2" fill="currentColor"/> 开始点名
|
| 166 |
+
</button>
|
| 167 |
+
{selectedStudents.length > 0 && (
|
| 168 |
+
<button onClick={() => setShowRewardModal(true)} className="bg-amber-500 text-white px-8 py-3 rounded-full font-bold text-lg shadow-lg hover:bg-amber-600 hover:scale-105 transition-all flex items-center">
|
| 169 |
+
<Gift className="mr-2"/> 发放奖励
|
| 170 |
+
</button>
|
| 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 |
+
{showFilterModal && (
|
| 183 |
+
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
| 184 |
+
<div className="bg-white rounded-xl p-6 w-full max-w-lg h-[80vh] flex flex-col animate-in zoom-in-95">
|
| 185 |
+
<div className="flex justify-between items-center mb-4">
|
| 186 |
+
<h3 className="font-bold text-lg">参与人员筛选</h3>
|
| 187 |
+
<button onClick={()=>setShowFilterModal(false)}><X size={20}/></button>
|
| 188 |
+
</div>
|
| 189 |
+
<div className="bg-gray-50 p-2 rounded mb-2 text-xs text-gray-500">
|
| 190 |
+
取消勾选的学生将不会被点到。适用于请假或缺勤的学生。
|
| 191 |
+
</div>
|
| 192 |
+
<div className="flex-1 overflow-y-auto grid grid-cols-3 gap-2 custom-scrollbar">
|
| 193 |
+
{students.map(s => {
|
| 194 |
+
const isExcluded = excludedIds.has(s._id || String(s.id));
|
| 195 |
+
return (
|
| 196 |
+
<div
|
| 197 |
+
key={s._id}
|
| 198 |
+
onClick={() => {
|
| 199 |
+
const newSet = new Set(excludedIds);
|
| 200 |
+
const sid = s._id || String(s.id);
|
| 201 |
+
if (newSet.has(sid)) newSet.delete(sid);
|
| 202 |
+
else newSet.add(sid);
|
| 203 |
+
setExcludedIds(newSet);
|
| 204 |
+
}}
|
| 205 |
+
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'}`}
|
| 206 |
+
>
|
| 207 |
+
<span>{s.seatNo ? s.seatNo+'.':''}{s.name}</span>
|
| 208 |
+
{!isExcluded && <CheckCircle size={14}/>}
|
| 209 |
+
</div>
|
| 210 |
+
);
|
| 211 |
+
})}
|
| 212 |
</div>
|
| 213 |
+
<div className="pt-4 border-t mt-2 flex justify-between items-center">
|
| 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 |
+
{/* Reward Modal */}
|
| 222 |
+
{showRewardModal && (
|
| 223 |
+
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
| 224 |
+
<div className="bg-white rounded-xl p-6 w-full max-w-sm animate-in zoom-in-95">
|
| 225 |
+
<h3 className="font-bold text-lg mb-4 text-gray-800">给幸运儿发放奖励</h3>
|
| 226 |
+
<div className="space-y-4">
|
| 227 |
+
<div>
|
| 228 |
+
<label className="text-xs font-bold text-gray-500 uppercase block mb-1">奖励类型</label>
|
| 229 |
+
<div className="flex bg-gray-100 p-1 rounded-lg">
|
| 230 |
+
<button onClick={()=>setRewardType('DRAW_COUNT')} className={`flex-1 py-1.5 rounded text-xs font-bold ${rewardType==='DRAW_COUNT'?'bg-white shadow text-blue-600':'text-gray-500'}`}>抽奖券</button>
|
| 231 |
+
<button onClick={()=>setRewardType('ITEM')} className={`flex-1 py-1.5 rounded text-xs font-bold ${rewardType==='ITEM'?'bg-white shadow text-blue-600':'text-gray-500'}`}>实物</button>
|
| 232 |
+
<button onClick={()=>setRewardType('ACHIEVEMENT')} className={`flex-1 py-1.5 rounded text-xs font-bold ${rewardType==='ACHIEVEMENT'?'bg-white shadow text-blue-600':'text-gray-500'}`}>成就</button>
|
| 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 |
};
|
pages/GameZen.tsx
CHANGED
|
@@ -1,233 +1,166 @@
|
|
| 1 |
|
| 2 |
import React, { useState, useEffect, useRef } from 'react';
|
| 3 |
-
import { createPortal } from 'react-dom';
|
| 4 |
import { api } from '../services/api';
|
| 5 |
-
import {
|
| 6 |
-
import {
|
| 7 |
|
| 8 |
export const GameZen: React.FC = () => {
|
| 9 |
-
const
|
| 10 |
-
const homeroomClass = currentUser?.homeroomClass;
|
| 11 |
-
|
| 12 |
-
// React State
|
| 13 |
const [isPlaying, setIsPlaying] = useState(false);
|
| 14 |
-
const [
|
| 15 |
-
const [
|
| 16 |
-
const [
|
| 17 |
-
const [
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
const [score, setScore] = useState(100);
|
| 21 |
-
const [quietSeconds, setQuietSeconds] = useState(0);
|
| 22 |
-
const [totalSeconds, setTotalSeconds] = useState(0);
|
| 23 |
-
|
| 24 |
-
// Config State
|
| 25 |
-
const [achConfig, setAchConfig] = useState<AchievementConfig | null>(null);
|
| 26 |
const [students, setStudents] = useState<Student[]>([]);
|
| 27 |
-
const [
|
| 28 |
-
const [
|
| 29 |
|
| 30 |
-
|
| 31 |
-
const [
|
| 32 |
-
const [
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
enabled: boolean;
|
| 36 |
-
type: 'DRAW_COUNT' | 'ITEM' | 'ACHIEVEMENT';
|
| 37 |
-
val: string;
|
| 38 |
-
count: number;
|
| 39 |
-
}>({ enabled: true, type: 'DRAW_COUNT', val: '自习奖励', count: 1 });
|
| 40 |
-
|
| 41 |
-
const [isConfigOpen, setIsConfigOpen] = useState(true);
|
| 42 |
-
|
| 43 |
-
// REFS
|
| 44 |
-
const isPlayingRef = useRef(false);
|
| 45 |
-
const gameStateRef = useRef('IDLE');
|
| 46 |
-
const lastTimeRef = useRef(0);
|
| 47 |
const audioContextRef = useRef<AudioContext | null>(null);
|
| 48 |
const analyserRef = useRef<AnalyserNode | null>(null);
|
| 49 |
-
const
|
| 50 |
-
const
|
| 51 |
-
const reqRef = useRef<number>(0);
|
| 52 |
-
|
| 53 |
-
// Logic Refs for Smoothing
|
| 54 |
const smoothedVolRef = useRef(0);
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
|
|
|
|
|
|
|
|
|
| 59 |
|
| 60 |
useEffect(() => {
|
| 61 |
loadData();
|
| 62 |
-
return () =>
|
| 63 |
-
stopAudio();
|
| 64 |
-
if(reqRef.current) cancelAnimationFrame(reqRef.current);
|
| 65 |
-
};
|
| 66 |
}, []);
|
| 67 |
|
| 68 |
const loadData = async () => {
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
setPassRate(savedConfig.passRate);
|
| 91 |
-
if (savedConfig.rewardConfig) setRewardConfig(savedConfig.rewardConfig);
|
| 92 |
-
}
|
| 93 |
-
} catch (e) { console.error(e); }
|
| 94 |
-
};
|
| 95 |
-
|
| 96 |
-
const startAudio = async () => {
|
| 97 |
-
try {
|
| 98 |
-
if (audioContextRef.current && audioContextRef.current.state === 'running') return;
|
| 99 |
-
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
| 100 |
-
// @ts-ignore
|
| 101 |
-
const AudioContext = window.AudioContext || window.webkitAudioContext;
|
| 102 |
-
const ctx = new AudioContext();
|
| 103 |
-
const analyser = ctx.createAnalyser();
|
| 104 |
-
analyser.fftSize = 256;
|
| 105 |
-
const source = ctx.createMediaStreamSource(stream);
|
| 106 |
-
source.connect(analyser);
|
| 107 |
-
|
| 108 |
-
audioContextRef.current = ctx;
|
| 109 |
-
analyserRef.current = analyser;
|
| 110 |
-
sourceRef.current = source;
|
| 111 |
-
dataArrayRef.current = new Uint8Array(analyser.frequencyBinCount);
|
| 112 |
-
} catch (e) {
|
| 113 |
-
console.error("Mic error", e);
|
| 114 |
-
alert('无法访问麦克风,请检查权限');
|
| 115 |
}
|
| 116 |
};
|
| 117 |
|
| 118 |
-
const stopAudio = () => {
|
| 119 |
-
if (sourceRef.current) sourceRef.current.disconnect();
|
| 120 |
-
if (audioContextRef.current) audioContextRef.current.close();
|
| 121 |
-
audioContextRef.current = null;
|
| 122 |
-
};
|
| 123 |
-
|
| 124 |
-
const saveConfig = async () => {
|
| 125 |
-
if (!homeroomClass) return;
|
| 126 |
-
try {
|
| 127 |
-
await api.games.saveZenConfig({
|
| 128 |
-
className: homeroomClass,
|
| 129 |
-
durationMinutes,
|
| 130 |
-
threshold,
|
| 131 |
-
passRate,
|
| 132 |
-
rewardConfig
|
| 133 |
-
});
|
| 134 |
-
} catch (e) { console.error('Auto-save config failed', e); }
|
| 135 |
-
};
|
| 136 |
-
|
| 137 |
const startGame = async () => {
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
|
|
|
|
|
|
| 154 |
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
setGameState('FINISHED');
|
| 158 |
-
if(reqRef.current) cancelAnimationFrame(reqRef.current);
|
| 159 |
-
stopAudio();
|
| 160 |
};
|
| 161 |
|
| 162 |
-
const
|
| 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 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
|
| 201 |
-
|
| 202 |
-
const next = prev - delta;
|
| 203 |
-
if (next <= 0) {
|
| 204 |
-
endGame();
|
| 205 |
-
return 0;
|
| 206 |
-
}
|
| 207 |
-
return next;
|
| 208 |
-
});
|
| 209 |
};
|
| 210 |
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
const getZenState = () => {
|
| 216 |
-
if (currentVolume < threshold * 0.5) return 'DEEP';
|
| 217 |
-
if (currentVolume < threshold) return 'FOCUSED';
|
| 218 |
-
if (currentVolume < threshold * 1.5) return 'RESTLESS';
|
| 219 |
-
return 'CHAOS';
|
| 220 |
};
|
| 221 |
|
| 222 |
-
const
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
const
|
| 226 |
-
if (
|
| 227 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 228 |
|
|
|
|
|
|
|
|
|
|
| 229 |
try {
|
| 230 |
-
const promises =
|
| 231 |
if (rewardConfig.type === 'ACHIEVEMENT') {
|
| 232 |
return api.achievements.grant({
|
| 233 |
studentId: s._id || String(s.id),
|
|
@@ -239,372 +172,239 @@ export const GameZen: React.FC = () => {
|
|
| 239 |
studentId: s._id || String(s.id),
|
| 240 |
count: rewardConfig.count,
|
| 241 |
rewardType: rewardConfig.type,
|
| 242 |
-
name: rewardConfig.type === 'DRAW_COUNT' ? '禅
|
|
|
|
| 243 |
});
|
| 244 |
}
|
| 245 |
});
|
| 246 |
await Promise.all(promises);
|
| 247 |
-
|
| 248 |
-
setIsConfigOpen(true);
|
| 249 |
-
setGameState('IDLE');
|
| 250 |
-
} catch (e) { alert('发放失败'); }
|
| 251 |
};
|
| 252 |
|
| 253 |
-
const
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
width: '100vw',
|
| 258 |
-
height: '100vh',
|
| 259 |
-
zIndex: 999999,
|
| 260 |
-
} : {};
|
| 261 |
-
|
| 262 |
-
// Visual Parameters Mapping
|
| 263 |
-
const visualParams = {
|
| 264 |
-
DEEP: {
|
| 265 |
-
bg: 'bg-gradient-to-b from-teal-900 to-teal-800',
|
| 266 |
-
monk: '🧘', monkY: -50, monkScale: 1.2,
|
| 267 |
-
text: '🌿 极静 · 入定', badge: 'bg-teal-500/20 text-teal-200 border-teal-500/50',
|
| 268 |
-
auraColor: 'bg-emerald-300', auraOpacity: 0.6, auraScale: 1.5
|
| 269 |
-
},
|
| 270 |
-
FOCUSED: {
|
| 271 |
-
bg: 'bg-gradient-to-b from-emerald-800 to-emerald-600',
|
| 272 |
-
monk: '🧘', monkY: 0, monkScale: 1.0,
|
| 273 |
-
text: '🍃 专注 · 宁静', badge: 'bg-emerald-500/20 text-emerald-100 border-emerald-500/50',
|
| 274 |
-
auraColor: 'bg-emerald-200', auraOpacity: 0.3, auraScale: 1.1
|
| 275 |
-
},
|
| 276 |
-
RESTLESS: {
|
| 277 |
-
bg: 'bg-gradient-to-b from-amber-800 to-orange-700',
|
| 278 |
-
monk: '😰', monkY: 10, monkScale: 0.95,
|
| 279 |
-
text: '🍂 浮躁 · 波动', badge: 'bg-amber-500/20 text-amber-100 border-amber-500/50',
|
| 280 |
-
auraColor: 'bg-orange-400', auraOpacity: 0.2, auraScale: 0.9
|
| 281 |
-
},
|
| 282 |
-
CHAOS: {
|
| 283 |
-
bg: 'bg-gradient-to-b from-red-900 to-red-700',
|
| 284 |
-
monk: '😖', monkY: 20, monkScale: 0.9,
|
| 285 |
-
text: '🔥 喧哗 · 破功', badge: 'bg-red-500/20 text-red-100 border-red-500/50',
|
| 286 |
-
auraColor: 'bg-red-500', auraOpacity: 0.1, auraScale: 0.8
|
| 287 |
}
|
| 288 |
};
|
| 289 |
|
| 290 |
-
const
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
>
|
| 297 |
-
{/* Background Overlay */}
|
| 298 |
-
<div className="absolute inset-0 bg-black/10 pointer-events-none"></div>
|
| 299 |
-
<div className="absolute inset-0 flex items-center justify-center pointer-events-none opacity-20">
|
| 300 |
-
<div className={`w-[800px] h-[800px] border-[50px] rounded-full border-white transition-all duration-1000 ${currentState === 'DEEP' ? 'scale-110 opacity-50' : 'scale-90 opacity-20'}`}></div>
|
| 301 |
-
</div>
|
| 302 |
-
|
| 303 |
-
{/* HUD */}
|
| 304 |
-
<div className="absolute top-4 left-4 z-50 flex gap-3">
|
| 305 |
-
<div className="bg-black/30 border border-white/20 rounded-xl p-3 min-w-[120px] text-center backdrop-blur-md">
|
| 306 |
-
<span className="text-[10px] text-gray-300 font-bold uppercase block">倒计时</span>
|
| 307 |
-
<span className="text-3xl font-mono font-black text-white">
|
| 308 |
-
{Math.floor(timeLeft / 60)}:{String(Math.floor(timeLeft % 60)).padStart(2, '0')}
|
| 309 |
-
</span>
|
| 310 |
-
</div>
|
| 311 |
-
<div className="bg-black/30 border border-white/20 rounded-xl p-3 min-w-[120px] text-center backdrop-blur-md">
|
| 312 |
-
<span className="text-[10px] text-gray-300 font-bold uppercase block">专注评分</span>
|
| 313 |
-
<span className={`text-3xl font-mono font-black ${calculatedScore >= passRate ? 'text-green-400' : 'text-red-400'}`}>{calculatedScore}</span>
|
| 314 |
-
</div>
|
| 315 |
-
</div>
|
| 316 |
-
|
| 317 |
-
{/* Status Indicator */}
|
| 318 |
-
<div className="absolute top-4 right-4 z-50">
|
| 319 |
-
<div className={`px-6 py-3 rounded-full font-bold text-lg shadow-lg backdrop-blur-md transition-all duration-500 border ${currentVisual.badge}`}>
|
| 320 |
-
{currentVisual.text}
|
| 321 |
-
</div>
|
| 322 |
-
</div>
|
| 323 |
-
|
| 324 |
-
{/* Main Controls */}
|
| 325 |
-
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 z-50 flex gap-4 items-center bg-black/40 p-2 rounded-2xl backdrop-blur-md border border-white/10 shadow-2xl">
|
| 326 |
-
<button
|
| 327 |
-
onClick={() => setIsConfigOpen(true)}
|
| 328 |
-
className="p-3 bg-white/10 hover:bg-white/20 text-white rounded-xl transition-all"
|
| 329 |
-
title="设置"
|
| 330 |
-
>
|
| 331 |
-
<Settings size={20}/>
|
| 332 |
-
</button>
|
| 333 |
-
|
| 334 |
-
{gameState === 'PLAYING' ? (
|
| 335 |
-
<button
|
| 336 |
-
onClick={endGame}
|
| 337 |
-
className="px-6 py-3 rounded-xl shadow-lg bg-red-600 hover:bg-red-500 text-white font-bold transition-all"
|
| 338 |
-
>
|
| 339 |
-
提前结束
|
| 340 |
-
</button>
|
| 341 |
-
) : (
|
| 342 |
-
<button
|
| 343 |
-
onClick={startGame}
|
| 344 |
-
className="p-4 rounded-xl shadow-lg bg-green-600 hover:bg-green-500 text-white transition-all w-16 h-16 flex items-center justify-center animate-pulse"
|
| 345 |
-
>
|
| 346 |
-
<Play fill="currentColor" size={28}/>
|
| 347 |
-
</button>
|
| 348 |
-
)}
|
| 349 |
-
|
| 350 |
-
<button
|
| 351 |
-
onClick={() => setIsFullscreen(!isFullscreen)}
|
| 352 |
-
className="p-3 bg-white/10 hover:bg-white/20 text-white rounded-xl transition-all"
|
| 353 |
-
title="全屏"
|
| 354 |
-
>
|
| 355 |
-
{isFullscreen ? <Minimize size={20}/> : <Maximize size={20}/>}
|
| 356 |
-
</button>
|
| 357 |
-
</div>
|
| 358 |
-
|
| 359 |
-
{/* Visuals */}
|
| 360 |
-
<div className="flex-1 relative z-10 flex flex-col items-center justify-center pb-20">
|
| 361 |
-
{/* Aura */}
|
| 362 |
-
<div
|
| 363 |
-
className={`absolute w-64 h-64 rounded-full blur-[80px] transition-all duration-1000 ${currentVisual.auraColor}`}
|
| 364 |
-
style={{ opacity: currentVisual.auraOpacity, transform: `scale(${currentVisual.auraScale})` }}
|
| 365 |
-
></div>
|
| 366 |
-
|
| 367 |
-
{/* Monk/Visual */}
|
| 368 |
-
<div
|
| 369 |
-
className={`text-[150px] transition-all duration-1000 ease-in-out relative z-20 drop-shadow-2xl ${currentState === 'CHAOS' ? 'animate-bounce' : currentState === 'RESTLESS' ? 'animate-pulse' : ''}`}
|
| 370 |
-
style={{ transform: `translateY(${currentVisual.monkY}px) scale(${currentVisual.monkScale})` }}
|
| 371 |
-
>
|
| 372 |
-
{currentVisual.monk}
|
| 373 |
-
</div>
|
| 374 |
-
|
| 375 |
-
{/* Levitation Base (Shadow) */}
|
| 376 |
-
<div className={`w-40 h-10 bg-black/30 rounded-[100%] blur-md transition-all duration-1000 ${currentState === 'DEEP' ? 'scale-50 opacity-40 translate-y-10' : 'scale-100 opacity-80'}`}></div>
|
| 377 |
-
</div>
|
| 378 |
-
|
| 379 |
-
{/* Config Modal */}
|
| 380 |
-
{isConfigOpen && (
|
| 381 |
-
<div className="absolute inset-0 bg-black/80 z-[999999] flex items-center justify-center p-4 backdrop-blur-md">
|
| 382 |
-
<div className="bg-slate-800 text-white rounded-2xl w-full max-w-2xl max-h-[90vh] flex flex-col shadow-2xl border border-slate-700 animate-in zoom-in-95">
|
| 383 |
-
<div className="p-5 border-b border-slate-700 flex justify-between items-center bg-slate-900/50">
|
| 384 |
-
<h2 className="text-xl font-bold flex items-center"><Moon className="mr-2 text-teal-400"/> 禅道修行设置</h2>
|
| 385 |
-
</div>
|
| 386 |
-
|
| 387 |
-
<div className="p-6 space-y-6 overflow-y-auto flex-1 custom-scrollbar">
|
| 388 |
-
<div className="grid grid-cols-2 gap-6">
|
| 389 |
-
<div className="col-span-2 bg-slate-900 p-4 rounded-xl border border-slate-700">
|
| 390 |
-
<h4 className="text-xs text-teal-400 font-bold uppercase mb-4">麦克风校准 (Calibration)</h4>
|
| 391 |
-
<div className="flex items-center gap-4">
|
| 392 |
-
<div className="flex-1">
|
| 393 |
-
<div className="flex justify-between text-xs text-gray-400 mb-1">
|
| 394 |
-
<span>当前环境噪音: {currentVolume}</span>
|
| 395 |
-
<span>阈值设定: {threshold}</span>
|
| 396 |
-
</div>
|
| 397 |
-
<div className="w-full h-6 bg-gray-800 rounded-full overflow-hidden relative border border-gray-600">
|
| 398 |
-
{/* Threshold Marker */}
|
| 399 |
-
<div className="absolute top-0 bottom-0 w-0.5 bg-yellow-500 z-10 shadow-[0_0_5px_yellow]" style={{left: `${threshold}%`}}></div>
|
| 400 |
-
|
| 401 |
-
{/* Current Vol Bar */}
|
| 402 |
-
<div
|
| 403 |
-
className={`h-full transition-all duration-75 ${
|
| 404 |
-
currentVolume < threshold * 0.5 ? 'bg-teal-500' :
|
| 405 |
-
currentVolume < threshold ? 'bg-emerald-500' :
|
| 406 |
-
currentVolume < threshold * 1.5 ? 'bg-orange-500' : 'bg-red-600'
|
| 407 |
-
}`}
|
| 408 |
-
style={{width: `${Math.min(currentVolume, 100)}%`}}
|
| 409 |
-
></div>
|
| 410 |
-
</div>
|
| 411 |
-
<div className="mt-2 flex justify-between text-[10px] text-gray-500 px-1">
|
| 412 |
-
<span>静</span>
|
| 413 |
-
<span>↑ 阈值</span>
|
| 414 |
-
<span>闹</span>
|
| 415 |
-
</div>
|
| 416 |
-
<div className="mt-1">
|
| 417 |
-
<input type="range" min="1" max="100" className="w-full h-2 bg-slate-700 rounded-lg appearance-none cursor-pointer" value={threshold} onChange={e=>setThreshold(Number(e.target.value))}/>
|
| 418 |
-
</div>
|
| 419 |
-
</div>
|
| 420 |
-
<button
|
| 421 |
-
onClick={() => { startAudio(); setThreshold(currentVolume + 10); }}
|
| 422 |
-
className="px-4 py-2 bg-slate-700 hover:bg-slate-600 rounded-lg text-xs font-bold whitespace-nowrap border border-slate-600"
|
| 423 |
-
>
|
| 424 |
-
<Volume2 size={14} className="inline mr-1"/> 自动设定
|
| 425 |
-
</button>
|
| 426 |
-
</div>
|
| 427 |
-
<p className="text-[10px] text-gray-500 mt-2">* 绿色区域为安静(计分),橙色/红色区域为喧哗(不计分)。请在安静环境下点击自动设定。</p>
|
| 428 |
-
</div>
|
| 429 |
-
|
| 430 |
-
<div>
|
| 431 |
-
<label className="text-xs text-gray-400 font-bold uppercase block mb-1">修行时长 (分钟)</label>
|
| 432 |
-
<input type="number" className="w-full bg-slate-900 border border-slate-600 rounded p-3 text-white focus:border-teal-500 outline-none" value={durationMinutes} onChange={e=>setDurationMinutes(Number(e.target.value))}/>
|
| 433 |
-
</div>
|
| 434 |
-
|
| 435 |
-
<div>
|
| 436 |
-
<label className="text-xs text-gray-400 font-bold uppercase block mb-1">及格评分 (%)</label>
|
| 437 |
-
<input type="number" min="1" max="100" className="w-full bg-slate-900 border border-slate-600 rounded p-3 text-white focus:border-teal-500 outline-none" value={passRate} onChange={e=>setPassRate(Number(e.target.value))}/>
|
| 438 |
-
</div>
|
| 439 |
-
</div>
|
| 440 |
-
|
| 441 |
-
{/* Person Filter */}
|
| 442 |
-
<div className="bg-slate-900 p-4 rounded-xl border border-slate-700">
|
| 443 |
-
<div className="flex justify-between items-center mb-2">
|
| 444 |
-
<h4 className="text-xs text-gray-400 font-bold uppercase">参与人员</h4>
|
| 445 |
-
<button onClick={() => setIsFilterOpen(true)} className="text-xs text-blue-400 hover:text-blue-300 flex items-center">
|
| 446 |
-
<UserX size={14} className="mr-1"/> 排除请假学生 ({excludedStudentIds.size})
|
| 447 |
-
</button>
|
| 448 |
-
</div>
|
| 449 |
-
<p className="text-xs text-gray-500">共 {students.length - excludedStudentIds.size} 人参与奖励结算</p>
|
| 450 |
-
</div>
|
| 451 |
-
|
| 452 |
-
{/* Reward Config */}
|
| 453 |
-
<div className="bg-slate-900 p-4 rounded-xl border border-slate-700 space-y-3">
|
| 454 |
-
<div className="flex items-center justify-between">
|
| 455 |
-
<span className="text-sm font-bold flex items-center text-amber-400"><Gift size={16} className="mr-2"/> 达成奖励</span>
|
| 456 |
-
<label className="relative inline-flex items-center cursor-pointer">
|
| 457 |
-
<input type="checkbox" checked={rewardConfig.enabled} onChange={e=>setRewardConfig({...rewardConfig, enabled: e.target.checked})} className="sr-only peer"/>
|
| 458 |
-
<div className="w-9 h-5 bg-slate-700 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-amber-500"></div>
|
| 459 |
-
</label>
|
| 460 |
-
</div>
|
| 461 |
-
|
| 462 |
-
{rewardConfig.enabled && (
|
| 463 |
-
<div className="space-y-3 animate-in slide-in-from-top-2">
|
| 464 |
-
<div className="flex gap-2">
|
| 465 |
-
{[
|
| 466 |
-
{id: 'DRAW_COUNT', label: '抽奖券', icon: <Gift size={14}/>},
|
| 467 |
-
{id: 'ITEM', label: '实物', icon: <Package size={14}/>},
|
| 468 |
-
{id: 'ACHIEVEMENT', label: '成就', icon: <Trophy size={14}/>}
|
| 469 |
-
].map(t => (
|
| 470 |
-
<button
|
| 471 |
-
key={t.id}
|
| 472 |
-
onClick={() => setRewardConfig({...rewardConfig, type: t.id as any})}
|
| 473 |
-
className={`flex-1 py-2 text-xs rounded-lg border transition-all flex items-center justify-center gap-1 ${rewardConfig.type === t.id ? 'bg-amber-500/20 border-amber-500 text-amber-400' : 'bg-slate-800 border-slate-600 text-gray-400 hover:bg-slate-700'}`}
|
| 474 |
-
>
|
| 475 |
-
{t.icon} {t.label}
|
| 476 |
-
</button>
|
| 477 |
-
))}
|
| 478 |
-
</div>
|
| 479 |
-
|
| 480 |
-
<div className="flex gap-2">
|
| 481 |
-
{rewardConfig.type === 'ACHIEVEMENT' ? (
|
| 482 |
-
<select
|
| 483 |
-
className="flex-1 bg-slate-800 border border-slate-600 rounded-lg p-2 text-xs text-white outline-none"
|
| 484 |
-
value={rewardConfig.val}
|
| 485 |
-
onChange={e=>setRewardConfig({...rewardConfig, val: e.target.value})}
|
| 486 |
-
>
|
| 487 |
-
<option value="">-- 选择成就 --</option>
|
| 488 |
-
{achConfig?.achievements.map(a => <option key={a.id} value={a.id}>{a.icon} {a.name}</option>)}
|
| 489 |
-
</select>
|
| 490 |
-
) : rewardConfig.type === 'ITEM' ? (
|
| 491 |
-
<input
|
| 492 |
-
className="flex-1 bg-slate-800 border border-slate-600 rounded-lg p-2 text-xs text-white outline-none placeholder-gray-500"
|
| 493 |
-
placeholder="奖品名称"
|
| 494 |
-
value={rewardConfig.val}
|
| 495 |
-
onChange={e=>setRewardConfig({...rewardConfig, val: e.target.value})}
|
| 496 |
-
/>
|
| 497 |
-
) : (
|
| 498 |
-
<div className="flex-1 bg-slate-800 border border-slate-600 rounded-lg p-2 text-xs text-gray-400">
|
| 499 |
-
自动发放系统抽奖券
|
| 500 |
-
</div>
|
| 501 |
-
)}
|
| 502 |
-
<div className="flex items-center bg-slate-800 border border-slate-600 rounded-lg px-2">
|
| 503 |
-
<span className="text-xs text-gray-500 mr-2">x</span>
|
| 504 |
-
<input
|
| 505 |
-
type="number"
|
| 506 |
-
min={1}
|
| 507 |
-
className="bg-transparent w-8 text-center text-white text-xs outline-none"
|
| 508 |
-
value={rewardConfig.count}
|
| 509 |
-
onChange={e=>setRewardConfig({...rewardConfig, count: Number(e.target.value)})}
|
| 510 |
-
/>
|
| 511 |
-
</div>
|
| 512 |
-
</div>
|
| 513 |
-
</div>
|
| 514 |
-
)}
|
| 515 |
-
</div>
|
| 516 |
-
</div>
|
| 517 |
-
|
| 518 |
-
<div className="p-5 border-t border-slate-700 bg-slate-900/50 shrink-0">
|
| 519 |
-
<button onClick={startGame} className="w-full py-3 bg-gradient-to-r from-teal-600 to-emerald-600 hover:from-teal-500 hover:to-emerald-500 rounded-xl font-bold text-lg shadow-lg shadow-teal-900/50 transition-all transform hover:scale-[1.02] flex items-center justify-center">
|
| 520 |
-
<Play size={20} className="mr-2" fill="white"/> 开始修行
|
| 521 |
-
</button>
|
| 522 |
-
</div>
|
| 523 |
-
</div>
|
| 524 |
-
</div>
|
| 525 |
-
)}
|
| 526 |
|
| 527 |
-
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
|
| 536 |
-
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
|
| 553 |
-
|
| 554 |
-
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
|
| 558 |
-
|
| 559 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 560 |
</div>
|
| 561 |
-
|
| 562 |
-
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
|
| 566 |
-
|
| 567 |
-
|
| 568 |
-
|
| 569 |
-
|
| 570 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 571 |
</div>
|
| 572 |
-
<
|
| 573 |
-
|
| 574 |
-
</h2>
|
| 575 |
-
<p className="text-xl text-gray-300 mb-8">
|
| 576 |
-
最终评分: <span className="font-mono font-bold text-white text-3xl">{calculatedScore}</span>
|
| 577 |
-
</p>
|
| 578 |
-
|
| 579 |
-
{calculatedScore >= passRate && rewardConfig.enabled && (
|
| 580 |
-
<div className="bg-white/10 p-6 rounded-2xl mb-10 border border-white/10 backdrop-blur-sm">
|
| 581 |
-
<p className="text-teal-200 font-bold mb-2 flex items-center justify-center"><Gift size={20} className="mr-2"/> 奖励已解锁</p>
|
| 582 |
-
<p className="text-sm text-gray-300 mb-4">
|
| 583 |
-
为 <span className="font-bold text-white">{students.length - excludedStudentIds.size}</span> 名学生发放:
|
| 584 |
-
<span className="text-white font-bold mx-1 border-b border-white/30">
|
| 585 |
-
{rewardConfig.type==='ACHIEVEMENT' ? '成就奖状' : rewardConfig.type==='ITEM' ? rewardConfig.val : '抽奖券'}
|
| 586 |
-
</span>
|
| 587 |
-
x{rewardConfig.count}
|
| 588 |
-
</p>
|
| 589 |
-
<button onClick={handleBatchGrant} className="bg-amber-500 hover:bg-amber-600 text-white px-6 py-2 rounded-lg font-bold text-sm shadow-lg transition-colors">
|
| 590 |
-
立即发放
|
| 591 |
-
</button>
|
| 592 |
-
</div>
|
| 593 |
-
)}
|
| 594 |
-
|
| 595 |
-
<div className="flex gap-4 justify-center mt-8">
|
| 596 |
-
<button onClick={() => setIsConfigOpen(true)} className="px-8 py-3 bg-white/10 hover:bg-white/20 text-white rounded-full font-bold flex items-center transition-colors border border-white/10">
|
| 597 |
-
<Settings size={18} className="mr-2"/> 再来一次
|
| 598 |
-
</button>
|
| 599 |
</div>
|
| 600 |
</div>
|
| 601 |
</div>
|
| 602 |
)}
|
| 603 |
-
|
| 604 |
);
|
| 605 |
-
|
| 606 |
-
if (isFullscreen) {
|
| 607 |
-
return createPortal(GameContent, document.body);
|
| 608 |
-
}
|
| 609 |
-
return GameContent;
|
| 610 |
};
|
|
|
|
| 1 |
|
| 2 |
import React, { useState, useEffect, useRef } from 'react';
|
|
|
|
| 3 |
import { api } from '../services/api';
|
| 4 |
+
import { Student, AchievementItem } from '../types';
|
| 5 |
+
import { Moon, Settings, X, Play, Volume2, CheckCircle, Filter, Trophy, Zap, CloudRain, Wind } from 'lucide-react';
|
| 6 |
|
| 7 |
export const GameZen: React.FC = () => {
|
| 8 |
+
const [config, setConfig] = useState<any>(null);
|
|
|
|
|
|
|
|
|
|
| 9 |
const [isPlaying, setIsPlaying] = useState(false);
|
| 10 |
+
const [volume, setVolume] = useState(0); // Smoothed Volume
|
| 11 |
+
const [gameTime, setGameTime] = useState(0); // Ms elapsed
|
| 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 |
const [students, setStudents] = useState<Student[]>([]);
|
| 17 |
+
const [achievements, setAchievements] = useState<AchievementItem[]>([]);
|
| 18 |
+
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
| 19 |
|
| 20 |
+
// Exclusion
|
| 21 |
+
const [excludedIds, setExcludedIds] = useState<Set<string>>(new Set());
|
| 22 |
+
const [showFilterModal, setShowFilterModal] = useState(false);
|
| 23 |
+
|
| 24 |
+
// Audio & State Refs
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
const audioContextRef = useRef<AudioContext | null>(null);
|
| 26 |
const analyserRef = useRef<AnalyserNode | null>(null);
|
| 27 |
+
const microphoneRef = useRef<MediaStreamAudioSourceNode | null>(null);
|
| 28 |
+
const animationFrameRef = useRef<number | null>(null);
|
|
|
|
|
|
|
|
|
|
| 29 |
const smoothedVolRef = useRef(0);
|
| 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 () => stopGame();
|
|
|
|
|
|
|
|
|
|
| 41 |
}, []);
|
| 42 |
|
| 43 |
const loadData = async () => {
|
| 44 |
+
if (isTeacher && currentUser.homeroomClass) {
|
| 45 |
+
try {
|
| 46 |
+
const all = await api.students.getAll();
|
| 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 startGame = async () => {
|
| 69 |
+
if (!config) return;
|
| 70 |
+
try {
|
| 71 |
+
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
| 72 |
+
audioContextRef.current = new (window.AudioContext || (window as any).webkitAudioContext)();
|
| 73 |
+
analyserRef.current = audioContextRef.current.createAnalyser();
|
| 74 |
+
microphoneRef.current = audioContextRef.current.createMediaStreamSource(stream);
|
| 75 |
+
microphoneRef.current.connect(analyserRef.current);
|
| 76 |
+
analyserRef.current.fftSize = 256;
|
| 77 |
+
|
| 78 |
+
setIsPlaying(true);
|
| 79 |
+
setGameResult(null);
|
| 80 |
+
setGameTime(0);
|
| 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 processAudio = () => {
|
| 93 |
+
if (!analyserRef.current || !config) return;
|
| 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 stopGame = () => {
|
| 135 |
+
setIsPlaying(false);
|
| 136 |
+
if (audioContextRef.current) audioContextRef.current.close();
|
| 137 |
+
if (animationFrameRef.current) cancelAnimationFrame(animationFrameRef.current);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
};
|
| 139 |
|
| 140 |
+
const endGame = () => {
|
| 141 |
+
stopGame();
|
| 142 |
+
// Calculate Score
|
| 143 |
+
const totalTime = Math.min(gameTime, config.durationMinutes * 60 * 1000);
|
| 144 |
+
if (totalTime <= 0) {
|
| 145 |
+
setGameResult(null);
|
| 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 grantBatchReward = async (rewardConfig: any) => {
|
| 160 |
+
const activeStudents = students.filter(s => !excludedIds.has(s._id || String(s.id)));
|
| 161 |
+
if (!activeStudents.length) return;
|
| 162 |
try {
|
| 163 |
+
const promises = activeStudents.map(s => {
|
| 164 |
if (rewardConfig.type === 'ACHIEVEMENT') {
|
| 165 |
return api.achievements.grant({
|
| 166 |
studentId: s._id || String(s.id),
|
|
|
|
| 172 |
studentId: s._id || String(s.id),
|
| 173 |
count: rewardConfig.count,
|
| 174 |
rewardType: rewardConfig.type,
|
| 175 |
+
name: rewardConfig.type === 'DRAW_COUNT' ? '禅修奖励' : rewardConfig.val,
|
| 176 |
+
source: '禅道修行' // Updated Source
|
| 177 |
});
|
| 178 |
}
|
| 179 |
});
|
| 180 |
await Promise.all(promises);
|
| 181 |
+
} catch (e) { console.error(e); }
|
|
|
|
|
|
|
|
|
|
| 182 |
};
|
| 183 |
|
| 184 |
+
const saveSettings = async () => {
|
| 185 |
+
if (config) {
|
| 186 |
+
await api.games.saveZenConfig(config);
|
| 187 |
+
setIsSettingsOpen(false);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 188 |
}
|
| 189 |
};
|
| 190 |
|
| 191 |
+
const formatTime = (ms: number) => {
|
| 192 |
+
const sec = Math.floor(ms / 1000);
|
| 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 |
+
<div className={`h-full flex flex-col relative overflow-hidden transition-all duration-1000 bg-gradient-to-b ${currentState.color}`}>
|
| 213 |
+
{/* Header */}
|
| 214 |
+
<div className="p-4 flex justify-between items-center z-10 text-white/80">
|
| 215 |
+
<h3 className="font-bold text-lg flex items-center gap-2">
|
| 216 |
+
<span>禅道修行</span>
|
| 217 |
+
{isPlaying && <span className="text-xs border px-2 py-0.5 rounded-full bg-black/20">目标: >{config.passRate}%</span>}
|
| 218 |
+
</h3>
|
| 219 |
+
{isTeacher && (
|
| 220 |
+
<div className="flex gap-2">
|
| 221 |
+
<button onClick={()=>setShowFilterModal(true)} className="bg-white/10 px-3 py-2 rounded text-sm flex items-center hover:bg-white/20">
|
| 222 |
+
<Filter size={16} className="mr-1"/> 人员 ({students.length - excludedIds.size})
|
| 223 |
+
</button>
|
| 224 |
+
<button onClick={()=>setIsSettingsOpen(true)} className="bg-white/10 p-2 rounded hover:bg-white/20"><Settings size={20}/></button>
|
| 225 |
+
</div>
|
| 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 |
+
{/* Result Overlay */}
|
| 282 |
+
{gameResult && (
|
| 283 |
+
<div className="absolute inset-0 bg-black/80 flex flex-col items-center justify-center z-50 animate-in zoom-in backdrop-blur-md">
|
| 284 |
+
{gameResult === 'SUCCESS' ? (
|
| 285 |
+
<>
|
| 286 |
+
<Trophy size={80} className="text-yellow-400 mb-4 drop-shadow-[0_0_20px_rgba(250,204,21,0.5)]"/>
|
| 287 |
+
<h2 className="text-4xl font-black text-white mb-2">修行圆满</h2>
|
| 288 |
+
<p className="text-emerald-300 text-lg mb-6">最终评分: {currentScore} (通过)</p>
|
| 289 |
+
{config.rewardConfig.enabled && <div className="text-sm bg-white/10 px-4 py-2 rounded-full text-white/80">已自动发放奖励</div>}
|
| 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 |
+
{/* Filter Modal */}
|
| 374 |
+
{showFilterModal && (
|
| 375 |
+
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 text-gray-800">
|
| 376 |
+
<div className="bg-white rounded-xl p-6 w-full max-w-lg h-[80vh] flex flex-col animate-in zoom-in-95">
|
| 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 |
+
<div className="flex-1 overflow-y-auto grid grid-cols-3 gap-2 custom-scrollbar">
|
| 382 |
+
{students.map(s => {
|
| 383 |
+
const isExcluded = excludedIds.has(s._id || String(s.id));
|
| 384 |
+
return (
|
| 385 |
+
<div
|
| 386 |
+
key={s._id}
|
| 387 |
+
onClick={() => {
|
| 388 |
+
const newSet = new Set(excludedIds);
|
| 389 |
+
const sid = s._id || String(s.id);
|
| 390 |
+
if (newSet.has(sid)) newSet.delete(sid);
|
| 391 |
+
else newSet.add(sid);
|
| 392 |
+
setExcludedIds(newSet);
|
| 393 |
+
}}
|
| 394 |
+
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'}`}
|
| 395 |
+
>
|
| 396 |
+
<span>{s.seatNo ? s.seatNo+'.':''}{s.name}</span>
|
| 397 |
+
{!isExcluded && <CheckCircle size={14}/>}
|
| 398 |
+
</div>
|
| 399 |
+
);
|
| 400 |
+
})}
|
| 401 |
</div>
|
| 402 |
+
<div className="pt-4 border-t mt-2">
|
| 403 |
+
<button onClick={()=>setShowFilterModal(false)} className="w-full bg-blue-600 text-white py-2 rounded-lg font-bold">确定</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 404 |
</div>
|
| 405 |
</div>
|
| 406 |
</div>
|
| 407 |
)}
|
| 408 |
+
</div>
|
| 409 |
);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 410 |
};
|
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,725 +1,14 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
const mongoose = require('mongoose');
|
| 4 |
-
const cors = require('cors');
|
| 5 |
-
const bodyParser = require('body-parser');
|
| 6 |
-
const path = require('path');
|
| 7 |
-
const compression = require('compression');
|
| 8 |
const {
|
| 9 |
School, User, Student, Course, Score, ClassModel, SubjectModel, ExamModel, ScheduleModel,
|
| 10 |
ConfigModel, NotificationModel, GameSessionModel, StudentRewardModel, LuckyDrawConfigModel, GameMonsterConfigModel, GameZenConfigModel,
|
| 11 |
-
AchievementConfigModel, StudentAchievementModel, AttendanceModel, LeaveRequestModel
|
| 12 |
} = require('./models');
|
| 13 |
|
| 14 |
-
// ...
|
| 15 |
-
const PORT = 7860;
|
| 16 |
-
const MONGO_URI = 'mongodb+srv://dv890a:db8822723@chatpro.gw3v0v7.mongodb.net/chatpro?retryWrites=true&w=majority&appName=chatpro&authSource=admin';
|
| 17 |
-
|
| 18 |
-
const app = express();
|
| 19 |
-
|
| 20 |
-
// PERFORMANCE 1: Enable Gzip Compression
|
| 21 |
-
app.use(compression());
|
| 22 |
-
|
| 23 |
-
app.use(cors());
|
| 24 |
-
app.use(bodyParser.json({ limit: '10mb' }));
|
| 25 |
-
|
| 26 |
-
// PERFORMANCE 2: Smart Caching Strategy
|
| 27 |
-
app.use(express.static(path.join(__dirname, 'dist'), {
|
| 28 |
-
setHeaders: (res, filePath) => {
|
| 29 |
-
if (filePath.endsWith('.html')) {
|
| 30 |
-
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
| 31 |
-
} else {
|
| 32 |
-
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
| 33 |
-
}
|
| 34 |
-
}
|
| 35 |
-
}));
|
| 36 |
-
|
| 37 |
-
const InMemoryDB = {
|
| 38 |
-
schools: [],
|
| 39 |
-
users: [],
|
| 40 |
-
// ... other mock data if needed, but we rely on Mongo mostly now
|
| 41 |
-
isFallback: false
|
| 42 |
-
};
|
| 43 |
-
|
| 44 |
-
const connectDB = async () => {
|
| 45 |
-
try {
|
| 46 |
-
await mongoose.connect(MONGO_URI, { serverSelectionTimeoutMS: 30000 });
|
| 47 |
-
console.log('✅ MongoDB 连接成功 (Real Data)');
|
| 48 |
-
} catch (err) {
|
| 49 |
-
console.error('❌ MongoDB 连接失败:', err.message);
|
| 50 |
-
console.warn('⚠️ 启动内存数据库模式 (Limited functionality)');
|
| 51 |
-
InMemoryDB.isFallback = true;
|
| 52 |
-
}
|
| 53 |
-
};
|
| 54 |
-
connectDB();
|
| 55 |
-
|
| 56 |
-
// ... Helpers ...
|
| 57 |
-
const getQueryFilter = (req) => {
|
| 58 |
-
const s = req.headers['x-school-id'];
|
| 59 |
-
const role = req.headers['x-user-role'];
|
| 60 |
-
|
| 61 |
-
if (role === 'PRINCIPAL') {
|
| 62 |
-
if (!s) return { _id: null };
|
| 63 |
-
return { schoolId: s };
|
| 64 |
-
}
|
| 65 |
-
|
| 66 |
-
if (!s) return {};
|
| 67 |
-
|
| 68 |
-
return {
|
| 69 |
-
$or: [
|
| 70 |
-
{ schoolId: s },
|
| 71 |
-
{ schoolId: { $exists: false } },
|
| 72 |
-
{ schoolId: null }
|
| 73 |
-
]
|
| 74 |
-
};
|
| 75 |
-
};
|
| 76 |
-
const injectSchoolId = (req, b) => ({ ...b, schoolId: req.headers['x-school-id'] });
|
| 77 |
-
|
| 78 |
-
const getAutoSemester = () => {
|
| 79 |
-
const now = new Date();
|
| 80 |
-
const month = now.getMonth() + 1; // 1-12
|
| 81 |
-
const year = now.getFullYear();
|
| 82 |
-
if (month >= 8 || month === 1) {
|
| 83 |
-
const startYear = month === 1 ? year - 1 : year;
|
| 84 |
-
return `${startYear}-${startYear + 1}学年 第一学期`;
|
| 85 |
-
} else {
|
| 86 |
-
const startYear = year - 1;
|
| 87 |
-
return `${startYear}-${startYear + 1}学年 第二学期`;
|
| 88 |
-
}
|
| 89 |
-
};
|
| 90 |
-
|
| 91 |
-
const generateStudentNo = async () => {
|
| 92 |
-
const year = new Date().getFullYear();
|
| 93 |
-
const random = Math.floor(100000 + Math.random() * 900000);
|
| 94 |
-
return `${year}${random}`;
|
| 95 |
-
};
|
| 96 |
-
|
| 97 |
-
// ... ROUTES ...
|
| 98 |
-
app.get('/api/auth/me', async (req, res) => {
|
| 99 |
-
const username = req.headers['x-user-username'];
|
| 100 |
-
if (!username) return res.status(401).json({ error: 'Unauthorized' });
|
| 101 |
-
const user = await User.findOne({ username });
|
| 102 |
-
if (!user) return res.status(404).json({ error: 'User not found' });
|
| 103 |
-
res.json(user);
|
| 104 |
-
});
|
| 105 |
-
|
| 106 |
-
app.post('/api/auth/update-profile', async (req, res) => {
|
| 107 |
-
const { userId, trueName, phone, avatar, currentPassword, newPassword } = req.body;
|
| 108 |
-
|
| 109 |
-
try {
|
| 110 |
-
const user = await User.findById(userId);
|
| 111 |
-
if (!user) return res.status(404).json({ error: 'User not found' });
|
| 112 |
-
|
| 113 |
-
// If changing password, verify old one
|
| 114 |
-
if (newPassword) {
|
| 115 |
-
if (user.password !== currentPassword) {
|
| 116 |
-
return res.status(401).json({ error: 'INVALID_PASSWORD', message: '旧密码错误' });
|
| 117 |
-
}
|
| 118 |
-
user.password = newPassword;
|
| 119 |
-
}
|
| 120 |
-
|
| 121 |
-
if (trueName) user.trueName = trueName;
|
| 122 |
-
if (phone) user.phone = phone;
|
| 123 |
-
if (avatar) user.avatar = avatar;
|
| 124 |
-
|
| 125 |
-
await user.save();
|
| 126 |
-
|
| 127 |
-
// If user is a student, sync profile data to Student collection
|
| 128 |
-
if (user.role === 'STUDENT') {
|
| 129 |
-
await Student.findOneAndUpdate(
|
| 130 |
-
{ studentNo: user.studentNo },
|
| 131 |
-
{ name: user.trueName || user.username, phone: user.phone }
|
| 132 |
-
);
|
| 133 |
-
}
|
| 134 |
-
// If user is a teacher, sync name to Class collection
|
| 135 |
-
if (user.role === 'TEACHER' && user.homeroomClass) {
|
| 136 |
-
const cls = await ClassModel.findOne({ schoolId: user.schoolId, $expr: { $eq: [{ $concat: ["$grade", "$className"] }, user.homeroomClass] } });
|
| 137 |
-
if (cls) {
|
| 138 |
-
cls.teacherName = user.trueName || user.username;
|
| 139 |
-
await cls.save();
|
| 140 |
-
}
|
| 141 |
-
}
|
| 142 |
-
|
| 143 |
-
res.json({ success: true, user });
|
| 144 |
-
} catch (e) {
|
| 145 |
-
console.error(e);
|
| 146 |
-
res.status(500).json({ error: e.message });
|
| 147 |
-
}
|
| 148 |
-
});
|
| 149 |
-
|
| 150 |
-
app.post('/api/auth/register', async (req, res) => {
|
| 151 |
-
const { role, username, password, schoolId, trueName, seatNo } = req.body;
|
| 152 |
-
const className = req.body.className || req.body.homeroomClass;
|
| 153 |
-
|
| 154 |
-
try {
|
| 155 |
-
if (role === 'STUDENT') {
|
| 156 |
-
if (!trueName || !className) {
|
| 157 |
-
return res.status(400).json({ error: 'MISSING_FIELDS', message: '姓名和班级不能为空' });
|
| 158 |
-
}
|
| 159 |
-
|
| 160 |
-
const cleanName = trueName.trim();
|
| 161 |
-
const cleanClass = className.trim();
|
| 162 |
-
|
| 163 |
-
const existingProfile = await Student.findOne({
|
| 164 |
-
schoolId,
|
| 165 |
-
name: { $regex: new RegExp(`^${cleanName}$`, 'i') },
|
| 166 |
-
className: cleanClass
|
| 167 |
-
});
|
| 168 |
-
|
| 169 |
-
let finalUsername = '';
|
| 170 |
-
|
| 171 |
-
if (existingProfile) {
|
| 172 |
-
if (existingProfile.studentNo && existingProfile.studentNo.length > 5) {
|
| 173 |
-
finalUsername = existingProfile.studentNo;
|
| 174 |
-
} else {
|
| 175 |
-
finalUsername = await generateStudentNo();
|
| 176 |
-
existingProfile.studentNo = finalUsername;
|
| 177 |
-
await existingProfile.save();
|
| 178 |
-
}
|
| 179 |
-
|
| 180 |
-
const userExists = await User.findOne({ username: finalUsername, schoolId });
|
| 181 |
-
if (userExists) {
|
| 182 |
-
if (userExists.status === 'active') {
|
| 183 |
-
return res.status(409).json({ error: 'ACCOUNT_EXISTS', message: '该学生账号已存在且激活,请直接登录。' });
|
| 184 |
-
}
|
| 185 |
-
if (userExists.status === 'pending') {
|
| 186 |
-
return res.status(409).json({ error: 'ACCOUNT_PENDING', message: '该学生的注册申请正在审核中,请耐心等待。' });
|
| 187 |
-
}
|
| 188 |
-
}
|
| 189 |
-
} else {
|
| 190 |
-
finalUsername = await generateStudentNo();
|
| 191 |
-
}
|
| 192 |
-
|
| 193 |
-
await User.create({
|
| 194 |
-
username: finalUsername,
|
| 195 |
-
password,
|
| 196 |
-
role: 'STUDENT',
|
| 197 |
-
trueName: cleanName,
|
| 198 |
-
schoolId,
|
| 199 |
-
status: 'pending',
|
| 200 |
-
homeroomClass: cleanClass,
|
| 201 |
-
studentNo: finalUsername,
|
| 202 |
-
seatNo: seatNo || '',
|
| 203 |
-
parentName: req.body.parentName,
|
| 204 |
-
parentPhone: req.body.parentPhone,
|
| 205 |
-
address: req.body.address,
|
| 206 |
-
idCard: req.body.idCard,
|
| 207 |
-
gender: req.body.gender || 'Male',
|
| 208 |
-
createTime: new Date()
|
| 209 |
-
});
|
| 210 |
-
|
| 211 |
-
return res.json({ username: finalUsername });
|
| 212 |
-
}
|
| 213 |
-
|
| 214 |
-
const existing = await User.findOne({ username });
|
| 215 |
-
if (existing) return res.status(409).json({ error: 'USERNAME_EXISTS', message: '用户名已存在' });
|
| 216 |
-
|
| 217 |
-
await User.create({...req.body, status: 'pending', createTime: new Date()});
|
| 218 |
-
res.json({ username });
|
| 219 |
-
|
| 220 |
-
} catch(e) {
|
| 221 |
-
console.error(e);
|
| 222 |
-
res.status(500).json({ error: e.message });
|
| 223 |
-
}
|
| 224 |
-
});
|
| 225 |
-
|
| 226 |
-
app.get('/api/users', async (req, res) => {
|
| 227 |
-
const filter = getQueryFilter(req);
|
| 228 |
-
const requesterRole = req.headers['x-user-role'];
|
| 229 |
-
if (requesterRole === 'PRINCIPAL') filter.role = { $ne: 'ADMIN' };
|
| 230 |
-
if (req.query.role) filter.role = req.query.role;
|
| 231 |
-
res.json(await User.find(filter).sort({ createTime: -1 }));
|
| 232 |
-
});
|
| 233 |
-
|
| 234 |
-
app.put('/api/users/:id', async (req, res) => {
|
| 235 |
-
const userId = req.params.id;
|
| 236 |
-
const updates = req.body;
|
| 237 |
-
const requesterRole = req.headers['x-user-role'];
|
| 238 |
-
|
| 239 |
-
try {
|
| 240 |
-
const user = await User.findById(userId);
|
| 241 |
-
if (!user) return res.status(404).json({ error: 'User not found' });
|
| 242 |
-
|
| 243 |
-
if (requesterRole === 'PRINCIPAL') {
|
| 244 |
-
if (user.schoolId !== req.headers['x-school-id']) return res.status(403).json({ error: 'Permission denied' });
|
| 245 |
-
if (user.role === 'ADMIN' || updates.role === 'ADMIN') return res.status(403).json({ error: 'Cannot modify Admin users' });
|
| 246 |
-
}
|
| 247 |
-
|
| 248 |
-
if (user.status !== 'active' && updates.status === 'active') {
|
| 249 |
-
if (user.role === 'TEACHER' && user.homeroomClass) {
|
| 250 |
-
await ClassModel.updateOne(
|
| 251 |
-
{ schoolId: user.schoolId, $expr: { $eq: [{ $concat: ["$grade", "$className"] }, user.homeroomClass] } },
|
| 252 |
-
{ teacherName: user.trueName || user.username }
|
| 253 |
-
);
|
| 254 |
-
}
|
| 255 |
-
if (user.role === 'STUDENT') {
|
| 256 |
-
const profileData = {
|
| 257 |
-
schoolId: user.schoolId,
|
| 258 |
-
studentNo: user.studentNo,
|
| 259 |
-
seatNo: user.seatNo,
|
| 260 |
-
name: user.trueName,
|
| 261 |
-
className: user.homeroomClass,
|
| 262 |
-
gender: user.gender || 'Male',
|
| 263 |
-
parentName: user.parentName,
|
| 264 |
-
parentPhone: user.parentPhone,
|
| 265 |
-
address: user.address,
|
| 266 |
-
idCard: user.idCard,
|
| 267 |
-
status: 'Enrolled',
|
| 268 |
-
birthday: '2015-01-01'
|
| 269 |
-
};
|
| 270 |
-
await Student.findOneAndUpdate(
|
| 271 |
-
{ studentNo: user.studentNo, schoolId: user.schoolId },
|
| 272 |
-
{ $set: profileData },
|
| 273 |
-
{ upsert: true, new: true }
|
| 274 |
-
);
|
| 275 |
-
}
|
| 276 |
-
}
|
| 277 |
-
await User.findByIdAndUpdate(userId, updates);
|
| 278 |
-
res.json({});
|
| 279 |
-
} catch (e) {
|
| 280 |
-
console.error(e);
|
| 281 |
-
res.status(500).json({ error: e.message });
|
| 282 |
-
}
|
| 283 |
-
});
|
| 284 |
-
|
| 285 |
-
app.post('/api/users/class-application', async (req, res) => {
|
| 286 |
-
const { userId, type, targetClass, action } = req.body;
|
| 287 |
-
const userRole = req.headers['x-user-role'];
|
| 288 |
-
const schoolId = req.headers['x-school-id'];
|
| 289 |
-
|
| 290 |
-
if (action === 'APPLY') {
|
| 291 |
-
try {
|
| 292 |
-
const user = await User.findById(userId);
|
| 293 |
-
if(!user) return res.status(404).json({error:'User not found'});
|
| 294 |
-
await User.findByIdAndUpdate(userId, {
|
| 295 |
-
classApplication: { type: type, targetClass: targetClass || '', status: 'PENDING' }
|
| 296 |
-
});
|
| 297 |
-
const typeText = type === 'CLAIM' ? '申请班主任' : '申请卸任';
|
| 298 |
-
await NotificationModel.create({
|
| 299 |
-
schoolId, targetUserId: userId, title: '申请已提交', content: `您已成功提交 ${typeText} (${type === 'CLAIM' ? targetClass : user.homeroomClass}) 的申请,等待管理员审核。`, type: 'info'
|
| 300 |
-
});
|
| 301 |
-
await NotificationModel.create({ schoolId, targetRole: 'ADMIN', title: '新的班主任任免申请', content: `${user.trueName || user.username} 申请 ${typeText},请及时处理。`, type: 'warning' });
|
| 302 |
-
await NotificationModel.create({ schoolId, targetRole: 'PRINCIPAL', title: '新的班主任任免申请', content: `${user.trueName || user.username} 申请 ${typeText},请及时处理。`, type: 'warning' });
|
| 303 |
-
return res.json({ success: true });
|
| 304 |
-
} catch (e) {
|
| 305 |
-
console.error(e);
|
| 306 |
-
return res.status(500).json({ error: e.message });
|
| 307 |
-
}
|
| 308 |
-
}
|
| 309 |
-
|
| 310 |
-
if (userRole === 'ADMIN' || userRole === 'PRINCIPAL') {
|
| 311 |
-
const user = await User.findById(userId);
|
| 312 |
-
if (!user || !user.classApplication) return res.status(404).json({ error: 'Application not found' });
|
| 313 |
-
const appType = user.classApplication.type;
|
| 314 |
-
const appTarget = user.classApplication.targetClass;
|
| 315 |
-
|
| 316 |
-
if (action === 'APPROVE') {
|
| 317 |
-
const updates = { classApplication: null };
|
| 318 |
-
if (appType === 'CLAIM') {
|
| 319 |
-
updates.homeroomClass = appTarget;
|
| 320 |
-
const classes = await ClassModel.find({ schoolId });
|
| 321 |
-
const matchedClass = classes.find(c => (c.grade + c.className) === appTarget);
|
| 322 |
-
if (matchedClass) {
|
| 323 |
-
if (matchedClass.teacherName) await User.updateOne({ trueName: matchedClass.teacherName, schoolId }, { homeroomClass: '' });
|
| 324 |
-
await ClassModel.findByIdAndUpdate(matchedClass._id, { teacherName: user.trueName || user.username });
|
| 325 |
-
}
|
| 326 |
-
} else if (appType === 'RESIGN') {
|
| 327 |
-
updates.homeroomClass = '';
|
| 328 |
-
if (user.homeroomClass) {
|
| 329 |
-
const classes = await ClassModel.find({ schoolId });
|
| 330 |
-
const matchedClass = classes.find(c => (c.grade + c.className) === user.homeroomClass);
|
| 331 |
-
if (matchedClass) await ClassModel.findByIdAndUpdate(matchedClass._id, { teacherName: '' });
|
| 332 |
-
}
|
| 333 |
-
}
|
| 334 |
-
await User.findByIdAndUpdate(userId, updates);
|
| 335 |
-
await NotificationModel.create({ schoolId, targetUserId: userId, title: '申请已通过', content: `管理员已同意您的${appType === 'CLAIM' ? '任教' : '卸任'}申请。`, type: 'success' });
|
| 336 |
-
} else {
|
| 337 |
-
await User.findByIdAndUpdate(userId, { 'classApplication.status': 'REJECTED' });
|
| 338 |
-
await NotificationModel.create({ schoolId, targetUserId: userId, title: '申请被拒绝', content: `管理员拒绝了您的${appType === 'CLAIM' ? '任教' : '卸任'}申请。`, type: 'error' });
|
| 339 |
-
}
|
| 340 |
-
return res.json({ success: true });
|
| 341 |
-
}
|
| 342 |
-
res.status(403).json({ error: 'Permission denied' });
|
| 343 |
-
});
|
| 344 |
-
|
| 345 |
-
app.post('/api/students/promote', async (req, res) => {
|
| 346 |
-
const { teacherFollows } = req.body;
|
| 347 |
-
const sId = req.headers['x-school-id'];
|
| 348 |
-
const role = req.headers['x-user-role'];
|
| 349 |
-
if (role !== 'ADMIN' && role !== 'PRINCIPAL') return res.status(403).json({ error: 'Permission denied' });
|
| 350 |
-
|
| 351 |
-
const GRADE_MAP = {
|
| 352 |
-
'一年级': '二年级', '二年级': '三年级', '三年级': '四年级', '四年级': '五年级', '五年级': '六年级', '六年级': '毕业',
|
| 353 |
-
'初一': '初二', '七年级': '八年级', '初二': '初三', '八年级': '九年级', '初三': '毕业', '九年级': '毕业',
|
| 354 |
-
'高一': '高二', '高二': '高三', '高三': '毕业'
|
| 355 |
-
};
|
| 356 |
-
|
| 357 |
-
const classes = await ClassModel.find(getQueryFilter(req));
|
| 358 |
-
let promotedCount = 0;
|
| 359 |
-
|
| 360 |
-
for (const cls of classes) {
|
| 361 |
-
const currentGrade = cls.grade;
|
| 362 |
-
const nextGrade = GRADE_MAP[currentGrade] || currentGrade;
|
| 363 |
-
const suffix = cls.className;
|
| 364 |
-
|
| 365 |
-
if (nextGrade === '毕业') {
|
| 366 |
-
const oldFullClass = cls.grade + cls.className;
|
| 367 |
-
await Student.updateMany({ className: oldFullClass, ...getQueryFilter(req) }, { status: 'Graduated', className: '已毕业' });
|
| 368 |
-
if (teacherFollows && cls.teacherName) {
|
| 369 |
-
await User.updateOne({ trueName: cls.teacherName, schoolId: sId }, { homeroomClass: '' });
|
| 370 |
-
await ClassModel.findByIdAndUpdate(cls._id, { teacherName: '' });
|
| 371 |
-
}
|
| 372 |
-
} else {
|
| 373 |
-
const oldFullClass = cls.grade + cls.className;
|
| 374 |
-
const newFullClass = nextGrade + suffix;
|
| 375 |
-
await ClassModel.findOneAndUpdate({ grade: nextGrade, className: suffix, schoolId: sId }, { schoolId: sId, grade: nextGrade, className: suffix, teacherName: teacherFollows ? cls.teacherName : undefined }, { upsert: true });
|
| 376 |
-
const result = await Student.updateMany({ className: oldFullClass, status: 'Enrolled', ...getQueryFilter(req) }, { className: newFullClass });
|
| 377 |
-
promotedCount += result.modifiedCount;
|
| 378 |
-
|
| 379 |
-
if (teacherFollows && cls.teacherName) {
|
| 380 |
-
await User.updateOne({ trueName: cls.teacherName, schoolId: sId, homeroomClass: oldFullClass }, { homeroomClass: newFullClass });
|
| 381 |
-
await ClassModel.findByIdAndUpdate(cls._id, { teacherName: '' });
|
| 382 |
-
// Migrate other configs (omitted for brevity)
|
| 383 |
-
}
|
| 384 |
-
}
|
| 385 |
-
}
|
| 386 |
-
res.json({ success: true, count: promotedCount });
|
| 387 |
-
});
|
| 388 |
-
|
| 389 |
-
app.post('/api/students/transfer', async (req, res) => {
|
| 390 |
-
const { studentId, targetClass } = req.body;
|
| 391 |
-
const student = await Student.findById(studentId);
|
| 392 |
-
if (!student) return res.status(404).json({ error: 'Student not found' });
|
| 393 |
-
student.className = targetClass;
|
| 394 |
-
await student.save();
|
| 395 |
-
res.json({ success: true });
|
| 396 |
-
});
|
| 397 |
-
|
| 398 |
-
app.get('/api/achievements/config', async (req, res) => {
|
| 399 |
-
const { className } = req.query;
|
| 400 |
-
if (!className) return res.status(400).json({ error: 'Class name required' });
|
| 401 |
-
const config = await AchievementConfigModel.findOne({ ...getQueryFilter(req), className });
|
| 402 |
-
res.json(config || { className, achievements: [], exchangeRules: [] });
|
| 403 |
-
});
|
| 404 |
-
app.post('/api/achievements/config', async (req, res) => {
|
| 405 |
-
const { className } = req.body;
|
| 406 |
-
const sId = req.headers['x-school-id'];
|
| 407 |
-
await AchievementConfigModel.findOneAndUpdate({ className, schoolId: sId }, injectSchoolId(req, req.body), { upsert: true });
|
| 408 |
-
res.json({ success: true });
|
| 409 |
-
});
|
| 410 |
-
app.get('/api/achievements/student', async (req, res) => {
|
| 411 |
-
const { studentId, semester } = req.query;
|
| 412 |
-
const filter = getQueryFilter(req);
|
| 413 |
-
if (studentId) filter.studentId = studentId;
|
| 414 |
-
if (semester && semester !== '全部时间' && semester !== 'All') filter.semester = semester;
|
| 415 |
-
const list = await StudentAchievementModel.find(filter).sort({ createTime: -1 });
|
| 416 |
-
res.json(list);
|
| 417 |
-
});
|
| 418 |
-
app.post('/api/achievements/grant', async (req, res) => {
|
| 419 |
-
const { studentId, achievementId, semester } = req.body;
|
| 420 |
-
const sId = req.headers['x-school-id'];
|
| 421 |
-
const student = await Student.findById(studentId);
|
| 422 |
-
const config = await AchievementConfigModel.findOne({ className: student.className, ...getQueryFilter(req) });
|
| 423 |
-
const ach = config?.achievements.find(a => a.id === achievementId);
|
| 424 |
-
if (!ach) return res.status(404).json({ error: 'Achievement not found' });
|
| 425 |
-
await StudentAchievementModel.create({ schoolId: sId, studentId, studentName: student.name, achievementId: ach.id, achievementName: ach.name, achievementIcon: ach.icon, semester: semester || '当前学期' });
|
| 426 |
-
await Student.findByIdAndUpdate(studentId, { $inc: { flowerBalance: ach.points } });
|
| 427 |
-
res.json({ success: true });
|
| 428 |
-
});
|
| 429 |
-
app.post('/api/achievements/exchange', async (req, res) => {
|
| 430 |
-
const { studentId, ruleId } = req.body;
|
| 431 |
-
const sId = req.headers['x-school-id'];
|
| 432 |
-
const student = await Student.findById(studentId);
|
| 433 |
-
const config = await AchievementConfigModel.findOne({ className: student.className, ...getQueryFilter(req) });
|
| 434 |
-
const rule = config?.exchangeRules.find(r => r.id === ruleId);
|
| 435 |
-
if (!rule) return res.status(404).json({ error: 'Exchange rule not found' });
|
| 436 |
-
if ((student.flowerBalance || 0) < rule.cost) return res.status(400).json({ error: 'INSUFFICIENT_FLOWERS', message: '小红花余额不足' });
|
| 437 |
-
await Student.findByIdAndUpdate(studentId, { $inc: { flowerBalance: -rule.cost } });
|
| 438 |
-
let status = 'PENDING';
|
| 439 |
-
if (rule.rewardType === 'DRAW_COUNT') {
|
| 440 |
-
status = 'REDEEMED';
|
| 441 |
-
await Student.findByIdAndUpdate(studentId, { $inc: { drawAttempts: rule.rewardValue } });
|
| 442 |
-
}
|
| 443 |
-
await StudentRewardModel.create({ schoolId: sId, studentId, studentName: student.name, rewardType: rule.rewardType, name: rule.rewardName, count: rule.rewardValue, status: status, source: `积分兑换` });
|
| 444 |
-
res.json({ success: true });
|
| 445 |
-
});
|
| 446 |
-
|
| 447 |
-
app.get('/api/games/lucky-config', async (req, res) => {
|
| 448 |
-
const filter = getQueryFilter(req);
|
| 449 |
-
if (req.query.className) filter.className = req.query.className;
|
| 450 |
-
const config = await LuckyDrawConfigModel.findOne(filter);
|
| 451 |
-
res.json(config || { prizes: [], dailyLimit: 3, cardCount: 9, defaultPrize: '再接再厉' });
|
| 452 |
-
});
|
| 453 |
-
app.post('/api/games/lucky-config', async (req, res) => {
|
| 454 |
-
const data = injectSchoolId(req, req.body);
|
| 455 |
-
await LuckyDrawConfigModel.findOneAndUpdate({ className: data.className, ...getQueryFilter(req) }, data, { upsert: true });
|
| 456 |
-
res.json({ success: true });
|
| 457 |
-
});
|
| 458 |
-
app.post('/api/games/lucky-draw', async (req, res) => {
|
| 459 |
-
const { studentId } = req.body;
|
| 460 |
-
const schoolId = req.headers['x-school-id'];
|
| 461 |
-
const userRole = req.headers['x-user-role'];
|
| 462 |
-
try {
|
| 463 |
-
const student = await Student.findById(studentId);
|
| 464 |
-
if (!student) return res.status(404).json({ error: 'Student not found' });
|
| 465 |
-
const config = await LuckyDrawConfigModel.findOne({ className: student.className, ...getQueryFilter(req) });
|
| 466 |
-
const prizes = config?.prizes || [];
|
| 467 |
-
const defaultPrize = config?.defaultPrize || '再接再厉';
|
| 468 |
-
const dailyLimit = config?.dailyLimit || 3;
|
| 469 |
-
const consolationWeight = config?.consolationWeight || 0;
|
| 470 |
-
|
| 471 |
-
const availablePrizes = prizes.filter(p => (p.count === undefined || p.count > 0));
|
| 472 |
-
if (availablePrizes.length === 0 && consolationWeight === 0) return res.status(400).json({ error: 'POOL_EMPTY', message: '奖品库存不足' });
|
| 473 |
-
|
| 474 |
-
if (userRole === 'STUDENT') {
|
| 475 |
-
if (student.drawAttempts <= 0) return res.status(403).json({ error: '次数不足', message: '您的抽奖次数已用完' });
|
| 476 |
-
const today = new Date().toISOString().split('T')[0];
|
| 477 |
-
let dailyLog = student.dailyDrawLog || { date: today, count: 0 };
|
| 478 |
-
if (dailyLog.date !== today) dailyLog = { date: today, count: 0 };
|
| 479 |
-
if (dailyLog.count >= dailyLimit) return res.status(403).json({ error: 'DAILY_LIMIT_REACHED', message: `今日抽奖次数已达上限 (${dailyLimit}次)` });
|
| 480 |
-
dailyLog.count += 1;
|
| 481 |
-
student.drawAttempts -= 1;
|
| 482 |
-
student.dailyDrawLog = dailyLog;
|
| 483 |
-
await student.save();
|
| 484 |
-
} else {
|
| 485 |
-
if (student.drawAttempts <= 0) return res.status(403).json({ error: '次数不足', message: '该学生抽奖次数已用完' });
|
| 486 |
-
student.drawAttempts -= 1;
|
| 487 |
-
await student.save();
|
| 488 |
-
}
|
| 489 |
-
|
| 490 |
-
let totalWeight = consolationWeight;
|
| 491 |
-
availablePrizes.forEach(p => totalWeight += (p.probability || 0));
|
| 492 |
-
let random = Math.random() * totalWeight;
|
| 493 |
-
let selectedPrize = defaultPrize;
|
| 494 |
-
let rewardType = 'CONSOLATION';
|
| 495 |
-
let matchedPrize = null;
|
| 496 |
-
|
| 497 |
-
for (const p of availablePrizes) {
|
| 498 |
-
random -= (p.probability || 0);
|
| 499 |
-
if (random <= 0) { matchedPrize = p; break; }
|
| 500 |
-
}
|
| 501 |
|
| 502 |
-
|
| 503 |
-
selectedPrize = matchedPrize.name;
|
| 504 |
-
rewardType = 'ITEM';
|
| 505 |
-
if (config._id) await LuckyDrawConfigModel.updateOne({ _id: config._id, "prizes.id": matchedPrize.id }, { $inc: { "prizes.$.count": -1 } });
|
| 506 |
-
}
|
| 507 |
-
await StudentRewardModel.create({ schoolId, studentId, studentName: student.name, rewardType, name: selectedPrize, count: 1, status: 'PENDING', source: '幸运大抽奖' });
|
| 508 |
-
res.json({ prize: selectedPrize, rewardType });
|
| 509 |
-
} catch (e) { res.status(500).json({ error: e.message }); }
|
| 510 |
-
});
|
| 511 |
-
|
| 512 |
-
// --- GAME CONFIG ROUTES ---
|
| 513 |
-
app.get('/api/games/monster-config', async (req, res) => {
|
| 514 |
-
const filter = getQueryFilter(req);
|
| 515 |
-
if (req.query.className) filter.className = req.query.className;
|
| 516 |
-
const config = await GameMonsterConfigModel.findOne(filter);
|
| 517 |
-
res.json(config || {});
|
| 518 |
-
});
|
| 519 |
-
app.post('/api/games/monster-config', async (req, res) => {
|
| 520 |
-
const data = injectSchoolId(req, req.body);
|
| 521 |
-
await GameMonsterConfigModel.findOneAndUpdate({ className: data.className, ...getQueryFilter(req) }, data, { upsert: true });
|
| 522 |
-
res.json({ success: true });
|
| 523 |
-
});
|
| 524 |
-
|
| 525 |
-
app.get('/api/games/zen-config', async (req, res) => {
|
| 526 |
-
const filter = getQueryFilter(req);
|
| 527 |
-
if (req.query.className) filter.className = req.query.className;
|
| 528 |
-
const config = await GameZenConfigModel.findOne(filter);
|
| 529 |
-
res.json(config || {});
|
| 530 |
-
});
|
| 531 |
-
app.post('/api/games/zen-config', async (req, res) => {
|
| 532 |
-
const data = injectSchoolId(req, req.body);
|
| 533 |
-
await GameZenConfigModel.findOneAndUpdate({ className: data.className, ...getQueryFilter(req) }, data, { upsert: true });
|
| 534 |
-
res.json({ success: true });
|
| 535 |
-
});
|
| 536 |
-
|
| 537 |
-
app.get('/api/notifications', async (req, res) => {
|
| 538 |
-
const { role, userId } = req.query;
|
| 539 |
-
const query = { $and: [ getQueryFilter(req), { $or: [ { targetRole: null, targetUserId: null }, { targetRole: role }, { targetUserId: userId } ] } ] };
|
| 540 |
-
res.json(await NotificationModel.find(query).sort({ createTime: -1 }).limit(20));
|
| 541 |
-
});
|
| 542 |
-
app.get('/api/public/schools', async (req, res) => { res.json(await School.find({}, 'name code _id')); });
|
| 543 |
-
app.get('/api/public/config', async (req, res) => {
|
| 544 |
-
const currentSem = getAutoSemester();
|
| 545 |
-
let config = await ConfigModel.findOne({ key: 'main' });
|
| 546 |
-
if (config) {
|
| 547 |
-
let semesters = config.semesters || [];
|
| 548 |
-
if (!semesters.includes(currentSem)) {
|
| 549 |
-
semesters.unshift(currentSem);
|
| 550 |
-
config.semesters = semesters;
|
| 551 |
-
config.semester = currentSem;
|
| 552 |
-
await ConfigModel.updateOne({ key: 'main' }, { semesters, semester: currentSem });
|
| 553 |
-
}
|
| 554 |
-
} else {
|
| 555 |
-
config = { key: 'main', allowRegister: true, semester: currentSem, semesters: [currentSem] };
|
| 556 |
-
}
|
| 557 |
-
res.json(config);
|
| 558 |
-
});
|
| 559 |
-
app.get('/api/public/meta', async (req, res) => { res.json({ classes: await ClassModel.find({ schoolId: req.query.schoolId }), subjects: await SubjectModel.find({ schoolId: req.query.schoolId }) }); });
|
| 560 |
-
app.post('/api/auth/login', async (req, res) => {
|
| 561 |
-
const { username, password } = req.body;
|
| 562 |
-
const user = await User.findOne({ username, password });
|
| 563 |
-
if (!user) return res.status(401).json({ message: 'Error' });
|
| 564 |
-
if (user.status !== 'active') return res.status(403).json({ error: 'PENDING_APPROVAL' });
|
| 565 |
-
res.json(user);
|
| 566 |
-
});
|
| 567 |
-
app.get('/api/schools', async (req, res) => { res.json(await School.find()); });
|
| 568 |
-
app.post('/api/schools', async (req, res) => { res.json(await School.create(req.body)); });
|
| 569 |
-
app.put('/api/schools/:id', async (req, res) => { await School.findByIdAndUpdate(req.params.id, req.body); res.json({}); });
|
| 570 |
-
app.delete('/api/schools/:id', async (req, res) => {
|
| 571 |
-
const schoolId = req.params.id;
|
| 572 |
-
try {
|
| 573 |
-
await School.findByIdAndDelete(schoolId);
|
| 574 |
-
await User.deleteMany({ schoolId });
|
| 575 |
-
await Student.deleteMany({ schoolId });
|
| 576 |
-
await ClassModel.deleteMany({ schoolId });
|
| 577 |
-
await SubjectModel.deleteMany({ schoolId });
|
| 578 |
-
await Course.deleteMany({ schoolId });
|
| 579 |
-
await Score.deleteMany({ schoolId });
|
| 580 |
-
await ExamModel.deleteMany({ schoolId });
|
| 581 |
-
await ScheduleModel.deleteMany({ schoolId });
|
| 582 |
-
await NotificationModel.deleteMany({ schoolId });
|
| 583 |
-
await AttendanceModel.deleteMany({ schoolId });
|
| 584 |
-
await LeaveRequestModel.deleteMany({ schoolId });
|
| 585 |
-
await GameSessionModel.deleteMany({ schoolId });
|
| 586 |
-
await StudentRewardModel.deleteMany({ schoolId });
|
| 587 |
-
await LuckyDrawConfigModel.deleteMany({ schoolId });
|
| 588 |
-
await GameMonsterConfigModel.deleteMany({ schoolId });
|
| 589 |
-
await GameZenConfigModel.deleteMany({ schoolId });
|
| 590 |
-
await AchievementConfigModel.deleteMany({ schoolId });
|
| 591 |
-
await StudentAchievementModel.deleteMany({ schoolId });
|
| 592 |
-
res.json({ success: true });
|
| 593 |
-
} catch (e) { res.status(500).json({ error: e.message }); }
|
| 594 |
-
});
|
| 595 |
-
app.delete('/api/users/:id', async (req, res) => {
|
| 596 |
-
const requesterRole = req.headers['x-user-role'];
|
| 597 |
-
if (requesterRole === 'PRINCIPAL') {
|
| 598 |
-
const user = await User.findById(req.params.id);
|
| 599 |
-
if (!user || user.schoolId !== req.headers['x-school-id']) return res.status(403).json({error: 'Permission denied'});
|
| 600 |
-
if (user.role === 'ADMIN') return res.status(403).json({error: 'Cannot delete admin'});
|
| 601 |
-
}
|
| 602 |
-
await User.findByIdAndDelete(req.params.id);
|
| 603 |
-
res.json({});
|
| 604 |
-
});
|
| 605 |
-
app.get('/api/students', async (req, res) => { res.json(await Student.find(getQueryFilter(req))); });
|
| 606 |
-
app.post('/api/students', async (req, res) => {
|
| 607 |
-
const data = injectSchoolId(req, req.body);
|
| 608 |
-
if (data.studentNo === '') delete data.studentNo;
|
| 609 |
-
try {
|
| 610 |
-
const existing = await Student.findOne({ schoolId: data.schoolId, name: data.name, className: data.className });
|
| 611 |
-
if (existing) {
|
| 612 |
-
Object.assign(existing, data);
|
| 613 |
-
if (!existing.studentNo) { existing.studentNo = await generateStudentNo(); }
|
| 614 |
-
await existing.save();
|
| 615 |
-
} else {
|
| 616 |
-
if (!data.studentNo) { data.studentNo = await generateStudentNo(); }
|
| 617 |
-
await Student.create(data);
|
| 618 |
-
}
|
| 619 |
-
res.json({ success: true });
|
| 620 |
-
} catch (e) {
|
| 621 |
-
if (e.code === 11000) return res.status(409).json({ error: 'DUPLICATE', message: '保存失败:存在重复的系统ID' });
|
| 622 |
-
res.status(500).json({ error: e.message });
|
| 623 |
-
}
|
| 624 |
-
});
|
| 625 |
-
app.put('/api/students/:id', async (req, res) => { await Student.findByIdAndUpdate(req.params.id, req.body); res.json({}); });
|
| 626 |
-
app.delete('/api/students/:id', async (req, res) => { await Student.findByIdAndDelete(req.params.id); res.json({}); });
|
| 627 |
-
app.get('/api/classes', async (req, res) => {
|
| 628 |
-
const filter = getQueryFilter(req);
|
| 629 |
-
const cls = await ClassModel.find(filter);
|
| 630 |
-
const resData = await Promise.all(cls.map(async c => {
|
| 631 |
-
const count = await Student.countDocuments({ className: c.grade + c.className, status: 'Enrolled', ...filter });
|
| 632 |
-
return { ...c.toObject(), studentCount: count };
|
| 633 |
-
}));
|
| 634 |
-
res.json(resData);
|
| 635 |
-
});
|
| 636 |
-
app.post('/api/classes', async (req, res) => {
|
| 637 |
-
const data = injectSchoolId(req, req.body);
|
| 638 |
-
await ClassModel.create(data);
|
| 639 |
-
if (data.teacherName) await User.updateOne({ trueName: data.teacherName, schoolId: data.schoolId }, { homeroomClass: data.grade + data.className });
|
| 640 |
-
res.json({});
|
| 641 |
-
});
|
| 642 |
-
app.put('/api/classes/:id', async (req, res) => {
|
| 643 |
-
const classId = req.params.id;
|
| 644 |
-
const { grade, className, teacherName } = req.body;
|
| 645 |
-
const sId = req.headers['x-school-id'];
|
| 646 |
-
const oldClass = await ClassModel.findById(classId);
|
| 647 |
-
if (!oldClass) return res.status(404).json({ error: 'Class not found' });
|
| 648 |
-
const newFullClass = grade + className;
|
| 649 |
-
const oldFullClass = oldClass.grade + oldClass.className;
|
| 650 |
-
if (oldClass.teacherName && (oldClass.teacherName !== teacherName || oldFullClass !== newFullClass)) {
|
| 651 |
-
await User.updateOne({ trueName: oldClass.teacherName, schoolId: sId, homeroomClass: oldFullClass }, { homeroomClass: '' });
|
| 652 |
-
}
|
| 653 |
-
if (teacherName) await User.updateOne({ trueName: teacherName, schoolId: sId }, { homeroomClass: newFullClass });
|
| 654 |
-
await ClassModel.findByIdAndUpdate(classId, { grade, className, teacherName });
|
| 655 |
-
if (oldFullClass !== newFullClass) await Student.updateMany({ className: oldFullClass, schoolId: sId }, { className: newFullClass });
|
| 656 |
-
res.json({ success: true });
|
| 657 |
-
});
|
| 658 |
-
app.delete('/api/classes/:id', async (req, res) => {
|
| 659 |
-
const cls = await ClassModel.findById(req.params.id);
|
| 660 |
-
if (cls && cls.teacherName) await User.updateOne({ trueName: cls.teacherName, schoolId: cls.schoolId, homeroomClass: cls.grade + cls.className }, { homeroomClass: '' });
|
| 661 |
-
await ClassModel.findByIdAndDelete(req.params.id);
|
| 662 |
-
res.json({});
|
| 663 |
-
});
|
| 664 |
-
app.get('/api/subjects', async (req, res) => { res.json(await SubjectModel.find(getQueryFilter(req))); });
|
| 665 |
-
app.post('/api/subjects', async (req, res) => { await SubjectModel.create(injectSchoolId(req, req.body)); res.json({}); });
|
| 666 |
-
app.put('/api/subjects/:id', async (req, res) => { await SubjectModel.findByIdAndUpdate(req.params.id, req.body); res.json({}); });
|
| 667 |
-
app.delete('/api/subjects/:id', async (req, res) => { await SubjectModel.findByIdAndDelete(req.params.id); res.json({}); });
|
| 668 |
-
app.get('/api/courses', async (req, res) => { res.json(await Course.find(getQueryFilter(req))); });
|
| 669 |
-
app.post('/api/courses', async (req, res) => { await Course.create(injectSchoolId(req, req.body)); res.json({}); });
|
| 670 |
-
app.put('/api/courses/:id', async (req, res) => { await Course.findByIdAndUpdate(req.params.id, req.body); res.json({}); });
|
| 671 |
-
app.delete('/api/courses/:id', async (req, res) => { await Course.findByIdAndDelete(req.params.id); res.json({}); });
|
| 672 |
-
app.get('/api/scores', async (req, res) => { res.json(await Score.find(getQueryFilter(req))); });
|
| 673 |
-
app.post('/api/scores', async (req, res) => { await Score.create(injectSchoolId(req, req.body)); res.json({}); });
|
| 674 |
-
app.put('/api/scores/:id', async (req, res) => { await Score.findByIdAndUpdate(req.params.id, req.body); res.json({}); });
|
| 675 |
-
app.delete('/api/scores/:id', async (req, res) => { await Score.findByIdAndDelete(req.params.id); res.json({}); });
|
| 676 |
-
app.get('/api/exams', async (req, res) => { res.json(await ExamModel.find(getQueryFilter(req))); });
|
| 677 |
-
app.post('/api/exams', async (req, res) => { await ExamModel.findOneAndUpdate({name:req.body.name}, injectSchoolId(req, req.body), {upsert:true}); res.json({}); });
|
| 678 |
-
app.get('/api/schedules', async (req, res) => {
|
| 679 |
-
const query = { ...getQueryFilter(req), ...req.query };
|
| 680 |
-
if (query.grade) { query.className = { $regex: '^' + query.grade }; delete query.grade; }
|
| 681 |
-
res.json(await ScheduleModel.find(query));
|
| 682 |
-
});
|
| 683 |
-
app.post('/api/schedules', async (req, res) => {
|
| 684 |
-
const filter = { className:req.body.className, dayOfWeek:req.body.dayOfWeek, period:req.body.period };
|
| 685 |
-
const sId = req.headers['x-school-id'];
|
| 686 |
-
if(sId) filter.schoolId = sId;
|
| 687 |
-
await ScheduleModel.findOneAndUpdate(filter, injectSchoolId(req, req.body), {upsert:true});
|
| 688 |
-
res.json({});
|
| 689 |
-
});
|
| 690 |
-
app.delete('/api/schedules', async (req, res) => { await ScheduleModel.deleteOne({...getQueryFilter(req), ...req.query}); res.json({}); });
|
| 691 |
-
app.get('/api/stats', async (req, res) => {
|
| 692 |
-
const filter = getQueryFilter(req);
|
| 693 |
-
const studentCount = await Student.countDocuments(filter);
|
| 694 |
-
const courseCount = await Course.countDocuments(filter);
|
| 695 |
-
const scores = await Score.find({...filter, status: 'Normal'});
|
| 696 |
-
let avgScore = 0; let excellentRate = '0%';
|
| 697 |
-
if (scores.length > 0) {
|
| 698 |
-
const total = scores.reduce((sum, s) => sum + s.score, 0);
|
| 699 |
-
avgScore = parseFloat((total / scores.length).toFixed(1));
|
| 700 |
-
const excellent = scores.filter(s => s.score >= 90).length;
|
| 701 |
-
excellentRate = Math.round((excellent / scores.length) * 100) + '%';
|
| 702 |
-
}
|
| 703 |
-
res.json({ studentCount, courseCount, avgScore, excellentRate });
|
| 704 |
-
});
|
| 705 |
-
app.get('/api/config', async (req, res) => {
|
| 706 |
-
const currentSem = getAutoSemester();
|
| 707 |
-
let config = await ConfigModel.findOne({key:'main'});
|
| 708 |
-
if (config) {
|
| 709 |
-
let semesters = config.semesters || [];
|
| 710 |
-
if (!semesters.includes(currentSem)) {
|
| 711 |
-
semesters.unshift(currentSem);
|
| 712 |
-
config.semesters = semesters;
|
| 713 |
-
config.semester = currentSem;
|
| 714 |
-
await ConfigModel.updateOne({ key: 'main' }, { semesters, semester: currentSem });
|
| 715 |
-
}
|
| 716 |
-
} else {
|
| 717 |
-
config = { key: 'main', allowRegister: true, semester: currentSem, semesters: [currentSem] };
|
| 718 |
-
}
|
| 719 |
-
res.json(config);
|
| 720 |
-
});
|
| 721 |
-
app.post('/api/config', async (req, res) => { await ConfigModel.findOneAndUpdate({key:'main'}, req.body, {upsert:true}); res.json({}); });
|
| 722 |
-
app.get('/api/games/mountain', async (req, res) => { res.json(await GameSessionModel.findOne({...getQueryFilter(req), className: req.query.className})); });
|
| 723 |
app.post('/api/games/mountain', async (req, res) => {
|
| 724 |
const filter = { className: req.body.className };
|
| 725 |
const sId = req.headers['x-school-id'];
|
|
@@ -727,90 +16,41 @@ app.post('/api/games/mountain', async (req, res) => {
|
|
| 727 |
await GameSessionModel.findOneAndUpdate(filter, injectSchoolId(req, req.body), {upsert:true});
|
| 728 |
res.json({});
|
| 729 |
});
|
|
|
|
| 730 |
app.post('/api/games/grant-reward', async (req, res) => {
|
| 731 |
-
const { studentId, count, rewardType, name } = req.body;
|
| 732 |
const finalCount = count || 1;
|
| 733 |
const finalName = name || (rewardType === 'DRAW_COUNT' ? '抽奖券' : '奖品');
|
|
|
|
|
|
|
| 734 |
if (rewardType === 'DRAW_COUNT') await Student.findByIdAndUpdate(studentId, { $inc: { drawAttempts: finalCount } });
|
| 735 |
-
|
| 736 |
-
|
| 737 |
-
|
| 738 |
-
|
| 739 |
-
|
| 740 |
-
|
| 741 |
-
|
| 742 |
-
|
| 743 |
-
|
| 744 |
-
|
| 745 |
-
|
| 746 |
-
}
|
| 747 |
-
await StudentRewardModel.findByIdAndDelete(req.params.id);
|
| 748 |
-
res.json({});
|
| 749 |
-
});
|
| 750 |
-
app.get('/api/rewards', async (req, res) => {
|
| 751 |
-
const filter = getQueryFilter(req);
|
| 752 |
-
if(req.query.studentId) filter.studentId = req.query.studentId;
|
| 753 |
-
if (req.query.className) {
|
| 754 |
-
const classStudents = await Student.find({ className: req.query.className, ...getQueryFilter(req) }, '_id');
|
| 755 |
-
filter.studentId = { $in: classStudents.map(s => s._id.toString()) };
|
| 756 |
-
}
|
| 757 |
-
if (req.query.excludeType) filter.rewardType = { $ne: req.query.excludeType };
|
| 758 |
-
const page = parseInt(req.query.page) || 1;
|
| 759 |
-
const limit = parseInt(req.query.limit) || 20;
|
| 760 |
-
const skip = (page - 1) * limit;
|
| 761 |
-
const total = await StudentRewardModel.countDocuments(filter);
|
| 762 |
-
const list = await StudentRewardModel.find(filter).sort({createTime:-1}).skip(skip).limit(limit);
|
| 763 |
-
res.json({ list, total });
|
| 764 |
-
});
|
| 765 |
-
app.post('/api/rewards', async (req, res) => {
|
| 766 |
-
const data = injectSchoolId(req, req.body);
|
| 767 |
-
if (!data.count) data.count = 1;
|
| 768 |
-
if(data.rewardType==='DRAW_COUNT') { data.status='REDEEMED'; await Student.findByIdAndUpdate(data.studentId, {$inc:{drawAttempts:data.count}}); }
|
| 769 |
-
await StudentRewardModel.create(data);
|
| 770 |
res.json({});
|
| 771 |
});
|
| 772 |
-
|
| 773 |
-
app.get('/api/
|
| 774 |
-
const
|
| 775 |
-
|
| 776 |
-
if(className) filter.className = className;
|
| 777 |
-
if(date) filter.date = date;
|
| 778 |
-
if(studentId) filter.studentId = studentId;
|
| 779 |
-
res.json(await AttendanceModel.find(filter));
|
| 780 |
-
});
|
| 781 |
-
app.post('/api/attendance/check-in', async (req, res) => {
|
| 782 |
-
const { studentId, date, status } = req.body;
|
| 783 |
-
const exists = await AttendanceModel.findOne({ studentId, date });
|
| 784 |
-
if (exists) return res.status(400).json({ error: 'ALREADY_CHECKED_IN', message: '今日已打卡' });
|
| 785 |
-
const student = await Student.findById(studentId);
|
| 786 |
-
await AttendanceModel.create({ schoolId: req.headers['x-school-id'], studentId, studentName: student.name, className: student.className, date, status: status || 'Present', checkInTime: new Date() });
|
| 787 |
-
res.json({ success: true });
|
| 788 |
-
});
|
| 789 |
-
app.post('/api/attendance/batch', async (req, res) => {
|
| 790 |
-
const { className, date, status } = req.body;
|
| 791 |
-
const students = await Student.find({ className, ...getQueryFilter(req) });
|
| 792 |
-
const ops = students.map(s => ({ updateOne: { filter: { studentId: s._id, date }, update: { $setOnInsert: { schoolId: req.headers['x-school-id'], studentId: s._id, studentName: s.name, className: s.className, date, status: status || 'Present', checkInTime: new Date() } }, upsert: true } }));
|
| 793 |
-
if (ops.length > 0) await AttendanceModel.bulkWrite(ops);
|
| 794 |
-
res.json({ success: true, count: ops.length });
|
| 795 |
-
});
|
| 796 |
-
app.put('/api/attendance/update', async (req, res) => {
|
| 797 |
-
const { studentId, date, status } = req.body;
|
| 798 |
-
const student = await Student.findById(studentId);
|
| 799 |
-
await AttendanceModel.findOneAndUpdate({ studentId, date }, { schoolId: req.headers['x-school-id'], studentId, studentName: student.name, className: student.className, date, status, checkInTime: new Date() }, { upsert: true });
|
| 800 |
-
res.json({ success: true });
|
| 801 |
-
});
|
| 802 |
-
app.post('/api/leave', async (req, res) => {
|
| 803 |
-
await LeaveRequestModel.create(injectSchoolId(req, req.body));
|
| 804 |
-
const { studentId, startDate } = req.body;
|
| 805 |
-
const student = await Student.findById(studentId);
|
| 806 |
-
if (student) await AttendanceModel.findOneAndUpdate({ studentId, date: startDate }, { schoolId: req.headers['x-school-id'], studentId, studentName: student.name, className: student.className, date: startDate, status: 'Leave', checkInTime: new Date() }, { upsert: true });
|
| 807 |
-
res.json({ success: true });
|
| 808 |
});
|
| 809 |
-
|
| 810 |
-
|
| 811 |
-
|
| 812 |
-
|
|
|
|
|
|
|
|
|
|
| 813 |
});
|
| 814 |
|
| 815 |
-
|
| 816 |
-
app.
|
|
|
|
|
|
| 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'];
|
|
|
|
| 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,
|
| 31 |
+
studentName: (await Student.findById(studentId)).name,
|
| 32 |
+
rewardType,
|
| 33 |
+
name: finalName,
|
| 34 |
+
count: finalCount,
|
| 35 |
+
status: rewardType === 'DRAW_COUNT' ? 'REDEEMED' : 'PENDING',
|
| 36 |
+
source: finalSource
|
| 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 ...
|
services/api.ts
CHANGED
|
@@ -1,238 +1,187 @@
|
|
| 1 |
-
import { User, ClassInfo, SystemConfig, Subject, School, Schedule, GameSession, StudentReward, LuckyDrawConfig, Attendance, LeaveRequest, AchievementConfig } from '../types';
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
async function request(endpoint: string, options: RequestInit = {}) {
|
| 21 |
-
const headers: any = { 'Content-Type': 'application/json', ...options.headers };
|
| 22 |
-
|
| 23 |
-
if (typeof window !== 'undefined') {
|
| 24 |
-
const currentUser = JSON.parse(localStorage.getItem('user') || 'null');
|
| 25 |
-
const selectedSchoolId = localStorage.getItem('admin_view_school_id');
|
| 26 |
-
|
| 27 |
-
if (currentUser?.role === 'ADMIN' && selectedSchoolId) {
|
| 28 |
-
headers['x-school-id'] = selectedSchoolId;
|
| 29 |
-
} else if (currentUser?.schoolId) {
|
| 30 |
-
headers['x-school-id'] = currentUser.schoolId;
|
| 31 |
-
}
|
| 32 |
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
headers['x-user-role'] = currentUser.role;
|
| 36 |
-
headers['x-user-username'] = currentUser.username;
|
| 37 |
}
|
| 38 |
-
}
|
| 39 |
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
const errorData = await res.json().catch(() => ({}));
|
| 45 |
-
const errorMessage = errorData.error || errorData.message || `Server Error: ${res.status}`;
|
| 46 |
-
if (errorData.error === 'PENDING_APPROVAL') throw new Error('PENDING_APPROVAL');
|
| 47 |
-
if (errorData.error === 'BANNED') throw new Error('BANNED');
|
| 48 |
-
if (errorData.error === 'CONFLICT') throw new Error(errorData.message);
|
| 49 |
-
if (errorData.error === 'INVALID_PASSWORD') throw new Error('INVALID_PASSWORD');
|
| 50 |
-
throw new Error(errorMessage);
|
| 51 |
-
}
|
| 52 |
-
return res.json();
|
| 53 |
-
}
|
| 54 |
-
|
| 55 |
-
export const api = {
|
| 56 |
-
init: () => console.log('🔗 API:', API_BASE_URL),
|
| 57 |
-
|
| 58 |
-
auth: {
|
| 59 |
-
login: async (username: string, password: string): Promise<User> => {
|
| 60 |
-
const user = await request('/auth/login', { method: 'POST', body: JSON.stringify({ username, password }) });
|
| 61 |
-
if (typeof window !== 'undefined') {
|
| 62 |
-
localStorage.setItem('user', JSON.stringify(user));
|
| 63 |
-
localStorage.removeItem('admin_view_school_id');
|
| 64 |
-
}
|
| 65 |
-
return user;
|
| 66 |
-
},
|
| 67 |
-
refreshSession: async (): Promise<User | null> => {
|
| 68 |
-
try {
|
| 69 |
-
const user = await request('/auth/me');
|
| 70 |
-
if (typeof window !== 'undefined' && user) {
|
| 71 |
-
localStorage.setItem('user', JSON.stringify(user));
|
| 72 |
-
}
|
| 73 |
-
return user;
|
| 74 |
-
} catch (e) { return null; }
|
| 75 |
-
},
|
| 76 |
-
register: async (data: any): Promise<User> => {
|
| 77 |
-
return await request('/auth/register', { method: 'POST', body: JSON.stringify(data) });
|
| 78 |
-
},
|
| 79 |
-
updateProfile: async (data: any): Promise<any> => {
|
| 80 |
-
return await request('/auth/update-profile', { method: 'POST', body: JSON.stringify(data) });
|
| 81 |
-
},
|
| 82 |
-
logout: () => {
|
| 83 |
-
if (typeof window !== 'undefined') {
|
| 84 |
-
localStorage.removeItem('user');
|
| 85 |
-
localStorage.removeItem('admin_view_school_id');
|
| 86 |
-
}
|
| 87 |
-
},
|
| 88 |
-
getCurrentUser: (): User | null => {
|
| 89 |
-
if (typeof window !== 'undefined') {
|
| 90 |
-
try {
|
| 91 |
-
const stored = localStorage.getItem('user');
|
| 92 |
-
if (stored) return JSON.parse(stored);
|
| 93 |
-
} catch (e) {
|
| 94 |
-
localStorage.removeItem('user');
|
| 95 |
-
}
|
| 96 |
-
}
|
| 97 |
-
return null;
|
| 98 |
}
|
| 99 |
-
},
|
| 100 |
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
update: (id: string, data: Partial<School>) => request(`/schools/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
| 106 |
-
delete: (id: string) => request(`/schools/${id}`, { method: 'DELETE' }) // NEW
|
| 107 |
-
},
|
| 108 |
|
| 109 |
-
|
| 110 |
-
getAll: (options?: { global?: boolean; role?: string }) => {
|
| 111 |
-
const params = new URLSearchParams();
|
| 112 |
-
if (options?.global) params.append('global', 'true');
|
| 113 |
-
if (options?.role) params.append('role', options.role);
|
| 114 |
-
return request(`/users?${params.toString()}`);
|
| 115 |
-
},
|
| 116 |
-
update: (id: string, data: Partial<User>) => request(`/users/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
| 117 |
-
delete: (id: string) => request(`/users/${id}`, { method: 'DELETE' }),
|
| 118 |
-
applyClass: (data: { userId: string, type: 'CLAIM'|'RESIGN', targetClass?: string, action: 'APPLY'|'APPROVE'|'REJECT' }) =>
|
| 119 |
-
request('/users/class-application', { method: 'POST', body: JSON.stringify(data) }),
|
| 120 |
-
},
|
| 121 |
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
promote: (data: { teacherFollows: boolean }) => request('/students/promote', { method: 'POST', body: JSON.stringify(data) }),
|
| 129 |
-
transfer: (data: { studentId: string, targetClass: string }) => request('/students/transfer', { method: 'POST', body: JSON.stringify(data) })
|
| 130 |
-
},
|
| 131 |
-
|
| 132 |
-
classes: {
|
| 133 |
-
getAll: () => request('/classes'),
|
| 134 |
-
add: (data: ClassInfo) => request('/classes', { method: 'POST', body: JSON.stringify(data) }),
|
| 135 |
-
delete: (id: string | number) => request(`/classes/${id}`, { method: 'DELETE' })
|
| 136 |
-
},
|
| 137 |
-
|
| 138 |
-
subjects: {
|
| 139 |
-
getAll: () => request('/subjects'),
|
| 140 |
-
add: (data: Subject) => request('/subjects', { method: 'POST', body: JSON.stringify(data) }),
|
| 141 |
-
update: (id: string | number, data: Partial<Subject>) => request(`/subjects/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
| 142 |
-
delete: (id: string | number) => request(`/subjects/${id}`, { method: 'DELETE' })
|
| 143 |
-
},
|
| 144 |
-
|
| 145 |
-
exams: {
|
| 146 |
-
getAll: () => request('/exams'),
|
| 147 |
-
save: (data: any) => request('/exams', { method: 'POST', body: JSON.stringify(data) })
|
| 148 |
-
},
|
| 149 |
-
|
| 150 |
-
courses: {
|
| 151 |
-
getAll: () => request('/courses'),
|
| 152 |
-
add: (data: any) => request('/courses', { method: 'POST', body: JSON.stringify(data) }),
|
| 153 |
-
update: (id: string | number, data: any) => request(`/courses/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
| 154 |
-
delete: (id: string | number) => request(`/courses/${id}`, { method: 'DELETE' })
|
| 155 |
-
},
|
| 156 |
-
|
| 157 |
-
scores: {
|
| 158 |
-
getAll: () => request('/scores'),
|
| 159 |
-
add: (data: any) => request('/scores', { method: 'POST', body: JSON.stringify(data) }),
|
| 160 |
-
update: (id: string | number, data: any) => request(`/scores/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
| 161 |
-
delete: (id: string | number) => request(`/scores/${id}`, { method: 'DELETE' })
|
| 162 |
-
},
|
| 163 |
-
|
| 164 |
-
schedules: {
|
| 165 |
-
get: (params: { className?: string; teacherName?: string; grade?: string }) => {
|
| 166 |
-
const qs = new URLSearchParams(params as any).toString();
|
| 167 |
-
return request(`/schedules?${qs}`);
|
| 168 |
-
},
|
| 169 |
-
save: (data: Schedule) => request('/schedules', { method: 'POST', body: JSON.stringify(data) }),
|
| 170 |
-
delete: (params: { className: string; dayOfWeek: number; period: number }) => {
|
| 171 |
-
const qs = new URLSearchParams(params as any).toString();
|
| 172 |
-
return request(`/schedules?${qs}`, { method: 'DELETE' });
|
| 173 |
}
|
| 174 |
-
},
|
| 175 |
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
get: (params: { className?: string, date?: string, studentId?: string }) => {
|
| 179 |
-
const qs = new URLSearchParams(params as any).toString();
|
| 180 |
-
return request(`/attendance?${qs}`);
|
| 181 |
-
},
|
| 182 |
-
batch: (data: { className: string, date: string, status?: string }) => request('/attendance/batch', { method: 'POST', body: JSON.stringify(data) }),
|
| 183 |
-
update: (data: { studentId: string, date: string, status: string }) => request('/attendance/update', { method: 'PUT', body: JSON.stringify(data) }),
|
| 184 |
-
applyLeave: (data: { studentId: string, studentName: string, className: string, reason: string, startDate: string, endDate: string }) => request('/leave', { method: 'POST', body: JSON.stringify(data) }),
|
| 185 |
-
},
|
| 186 |
-
|
| 187 |
-
stats: {
|
| 188 |
-
getSummary: () => request('/stats')
|
| 189 |
-
},
|
| 190 |
-
|
| 191 |
-
config: {
|
| 192 |
-
get: () => request('/config'),
|
| 193 |
-
getPublic: () => request('/public/config'),
|
| 194 |
-
save: (data: SystemConfig) => request('/config', { method: 'POST', body: JSON.stringify(data) })
|
| 195 |
-
},
|
| 196 |
-
|
| 197 |
-
notifications: {
|
| 198 |
-
getAll: (userId: string, role: string) => request(`/notifications?userId=${userId}&role=${role}`),
|
| 199 |
-
},
|
| 200 |
-
|
| 201 |
-
games: {
|
| 202 |
-
getMountainSession: (className: string) => request(`/games/mountain?className=${className}`),
|
| 203 |
-
saveMountainSession: (data: GameSession) => request('/games/mountain', { method: 'POST', body: JSON.stringify(data) }),
|
| 204 |
-
getLuckyConfig: (className?: string) => request(`/games/lucky-config${className ? `?className=${className}` : ''}`),
|
| 205 |
-
saveLuckyConfig: (data: LuckyDrawConfig) => request('/games/lucky-config', { method: 'POST', body: JSON.stringify(data) }),
|
| 206 |
-
drawLucky: (studentId: string) => request('/games/lucky-draw', { method: 'POST', body: JSON.stringify({ studentId }) }),
|
| 207 |
-
grantReward: (data: { studentId: string, count: number, rewardType: string, name?: string }) => request('/games/grant-reward', { method: 'POST', body: JSON.stringify(data) }),
|
| 208 |
-
getMonsterConfig: (className: string) => request(`/games/monster-config?className=${className}`),
|
| 209 |
-
saveMonsterConfig: (data: any) => request('/games/monster-config', { method: 'POST', body: JSON.stringify(data) }),
|
| 210 |
-
getZenConfig: (className: string) => request(`/games/zen-config?className=${className}`),
|
| 211 |
-
saveZenConfig: (data: any) => request('/games/zen-config', { method: 'POST', body: JSON.stringify(data) }),
|
| 212 |
-
},
|
| 213 |
-
|
| 214 |
-
achievements: {
|
| 215 |
-
getConfig: (className: string) => request(`/achievements/config?className=${className}`),
|
| 216 |
-
saveConfig: (data: AchievementConfig) => request('/achievements/config', { method: 'POST', body: JSON.stringify(data) }),
|
| 217 |
-
getStudentAchievements: (studentId: string, semester?: string) => request(`/achievements/student?studentId=${studentId}${semester ? `&semester=${semester}` : ''}`),
|
| 218 |
-
grant: (data: { studentId: string, achievementId: string, semester: string }) => request('/achievements/grant', { method: 'POST', body: JSON.stringify(data) }),
|
| 219 |
-
exchange: (data: { studentId: string, ruleId: string }) => request('/achievements/exchange', { method: 'POST', body: JSON.stringify(data) }),
|
| 220 |
-
},
|
| 221 |
-
|
| 222 |
-
rewards: {
|
| 223 |
-
getMyRewards: (studentId: string, page = 1, limit = 20) => request(`/rewards?studentId=${studentId}&page=${page}&limit=${limit}&excludeType=CONSOLATION`),
|
| 224 |
-
getClassRewards: (page = 1, limit = 20, className?: string) => {
|
| 225 |
-
let qs = `scope=class&page=${page}&limit=${limit}&excludeType=CONSOLATION`;
|
| 226 |
-
if (className) qs += `&className=${className}`;
|
| 227 |
-
return request(`/rewards?${qs}`);
|
| 228 |
-
},
|
| 229 |
-
addReward: (data: Partial<StudentReward>) => request('/rewards', { method: 'POST', body: JSON.stringify(data) }),
|
| 230 |
-
update: (id: string, data: Partial<StudentReward>) => request(`/rewards/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
| 231 |
-
delete: (id: string) => request(`/rewards/${id}`, { method: 'DELETE' }),
|
| 232 |
-
redeem: (id: string) => request(`/rewards/${id}/redeem`, { method: 'POST' }),
|
| 233 |
-
},
|
| 234 |
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
// Simple request helper
|
| 12 |
+
const request = async (endpoint: string, options: RequestInit = {}) => {
|
| 13 |
+
const token = localStorage.getItem('token');
|
| 14 |
+
const headers: any = {
|
| 15 |
+
'Content-Type': 'application/json',
|
| 16 |
+
...options.headers,
|
| 17 |
+
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
+
if (token) {
|
| 20 |
+
headers['Authorization'] = `Bearer ${token}`;
|
|
|
|
|
|
|
| 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 |
+
if (!response.ok) {
|
| 37 |
+
if (response.status === 401) {
|
| 38 |
+
localStorage.removeItem('token');
|
| 39 |
+
// Optional: Redirect to login
|
| 40 |
+
}
|
| 41 |
+
throw new Error(data.message || '请求失败');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
}
|
|
|
|
| 43 |
|
| 44 |
+
return data;
|
| 45 |
+
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
|
| 47 |
+
export const api = {
|
| 48 |
+
init: () => {
|
| 49 |
+
// Any init logic
|
| 50 |
+
},
|
| 51 |
+
auth: {
|
| 52 |
+
login: (username: string, password: string) =>
|
| 53 |
+
request('/auth/login', { method: 'POST', body: JSON.stringify({ username, password }) }),
|
| 54 |
+
register: (data: any) =>
|
| 55 |
+
request('/auth/register', { method: 'POST', body: JSON.stringify(data) }),
|
| 56 |
+
getCurrentUser: (): User | null => {
|
| 57 |
+
const u = localStorage.getItem('user');
|
| 58 |
+
return u ? JSON.parse(u) : null;
|
| 59 |
+
},
|
| 60 |
+
refreshSession: async () => {
|
| 61 |
+
try {
|
| 62 |
+
const user = await request('/auth/me');
|
| 63 |
+
localStorage.setItem('user', JSON.stringify(user));
|
| 64 |
+
return user;
|
| 65 |
+
} catch (e) { return null; }
|
| 66 |
+
},
|
| 67 |
+
updateProfile: (data: any) => request('/auth/profile', { method: 'PUT', body: JSON.stringify(data) }),
|
| 68 |
+
logout: () => {
|
| 69 |
+
localStorage.removeItem('token');
|
| 70 |
+
localStorage.removeItem('user');
|
| 71 |
+
window.location.reload();
|
| 72 |
+
}
|
| 73 |
+
},
|
| 74 |
+
stats: {
|
| 75 |
+
getSummary: () => request('/stats/summary'),
|
| 76 |
+
},
|
| 77 |
+
students: {
|
| 78 |
+
getAll: () => request('/students'),
|
| 79 |
+
add: (data: any) => request('/students', { method: 'POST', body: JSON.stringify(data) }),
|
| 80 |
+
update: (id: string, data: any) => request(`/students/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
| 81 |
+
delete: (id: string) => request(`/students/${id}`, { method: 'DELETE' }),
|
| 82 |
+
transfer: (data: { studentId: string, targetClass: string }) => request('/students/transfer', { method: 'POST', body: JSON.stringify(data) }),
|
| 83 |
+
promote: (data: { teacherFollows: boolean }) => request('/students/promote', { method: 'POST', body: JSON.stringify(data) }),
|
| 84 |
+
},
|
| 85 |
+
classes: {
|
| 86 |
+
getAll: () => request('/classes'),
|
| 87 |
+
add: (data: any) => request('/classes', { method: 'POST', body: JSON.stringify(data) }),
|
| 88 |
+
delete: (id: string | number) => request(`/classes/${id}`, { method: 'DELETE' }),
|
| 89 |
+
},
|
| 90 |
+
schools: {
|
| 91 |
+
getAll: () => request('/schools'),
|
| 92 |
+
getPublic: () => request('/public/schools'),
|
| 93 |
+
add: (data: any) => request('/schools', { method: 'POST', body: JSON.stringify(data) }),
|
| 94 |
+
update: (id: string, data: any) => request(`/schools/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
| 95 |
+
delete: (id: string) => request(`/schools/${id}`, { method: 'DELETE' }),
|
| 96 |
+
},
|
| 97 |
+
users: {
|
| 98 |
+
getAll: (params?: any) => {
|
| 99 |
+
const qs = params ? '?' + new URLSearchParams(params).toString() : '';
|
| 100 |
+
return request(`/users${qs}`);
|
| 101 |
+
},
|
| 102 |
+
update: (id: string, data: any) => request(`/users/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
| 103 |
+
delete: (id: string) => request(`/users/${id}`, { method: 'DELETE' }),
|
| 104 |
+
applyClass: (data: any) => request('/users/apply-class', { method: 'POST', body: JSON.stringify(data) }),
|
| 105 |
+
},
|
| 106 |
+
subjects: {
|
| 107 |
+
getAll: () => request('/subjects'),
|
| 108 |
+
add: (data: any) => request('/subjects', { method: 'POST', body: JSON.stringify(data) }),
|
| 109 |
+
update: (id: string, data: any) => request(`/subjects/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
| 110 |
+
delete: (id: string) => request(`/subjects/${id}`, { method: 'DELETE' }),
|
| 111 |
+
},
|
| 112 |
+
courses: {
|
| 113 |
+
getAll: () => request('/courses'),
|
| 114 |
+
add: (data: any) => request('/courses', { method: 'POST', body: JSON.stringify(data) }),
|
| 115 |
+
update: (id: string, data: any) => request(`/courses/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
| 116 |
+
delete: (id: string) => request(`/courses/${id}`, { method: 'DELETE' }),
|
| 117 |
+
},
|
| 118 |
+
scores: {
|
| 119 |
+
getAll: () => request('/scores'),
|
| 120 |
+
add: (data: any) => request('/scores', { method: 'POST', body: JSON.stringify(data) }),
|
| 121 |
+
update: (id: string, data: any) => request(`/scores/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
| 122 |
+
delete: (id: string) => request(`/scores/${id}`, { method: 'DELETE' }),
|
| 123 |
+
},
|
| 124 |
+
exams: {
|
| 125 |
+
getAll: () => request('/exams'),
|
| 126 |
+
save: (data: any) => request('/exams', { method: 'POST', body: JSON.stringify(data) }),
|
| 127 |
+
},
|
| 128 |
+
schedules: {
|
| 129 |
+
get: (params?: any) => {
|
| 130 |
+
const qs = params ? '?' + new URLSearchParams(params).toString() : '';
|
| 131 |
+
return request(`/schedules${qs}`);
|
| 132 |
+
},
|
| 133 |
+
save: (data: any) => request('/schedules', { method: 'POST', body: JSON.stringify(data) }),
|
| 134 |
+
delete: (data: any) => request('/schedules', { method: 'DELETE', body: JSON.stringify(data) }),
|
| 135 |
+
},
|
| 136 |
+
config: {
|
| 137 |
+
get: () => request('/config'),
|
| 138 |
+
getPublic: () => request('/public/config'),
|
| 139 |
+
save: (data: any) => request('/config', { method: 'POST', body: JSON.stringify(data) }),
|
| 140 |
+
},
|
| 141 |
+
notifications: {
|
| 142 |
+
getAll: (userId: string, role: string) => request(`/notifications?userId=${userId}&role=${role}`),
|
| 143 |
+
},
|
| 144 |
+
games: {
|
| 145 |
+
getMountainSession: (className: string) => request(`/games/mountain?className=${className}`),
|
| 146 |
+
saveMountainSession: (data: GameSession) => request('/games/mountain', { method: 'POST', body: JSON.stringify(data) }),
|
| 147 |
+
getLuckyConfig: (className?: string) => request(`/games/lucky-config${className ? `?className=${className}` : ''}`),
|
| 148 |
+
saveLuckyConfig: (data: LuckyDrawConfig) => request('/games/lucky-config', { method: 'POST', body: JSON.stringify(data) }),
|
| 149 |
+
drawLucky: (studentId: string) => request('/games/lucky-draw', { method: 'POST', body: JSON.stringify({ studentId }) }),
|
| 150 |
+
|
| 151 |
+
// Updated grantReward with 'source'
|
| 152 |
+
grantReward: (data: { studentId: string, count: number, rewardType: string, name?: string, source?: string }) => request('/games/grant-reward', { method: 'POST', body: JSON.stringify(data) }),
|
| 153 |
+
|
| 154 |
+
getMonsterConfig: (className: string) => request(`/games/monster-config?className=${className}`),
|
| 155 |
+
saveMonsterConfig: (data: any) => request('/games/monster-config', { method: 'POST', body: JSON.stringify(data) }),
|
| 156 |
+
|
| 157 |
+
// Added Zen Mode
|
| 158 |
+
getZenConfig: (className: string) => request(`/games/zen-config?className=${className}`),
|
| 159 |
+
saveZenConfig: (data: any) => request('/games/zen-config', { method: 'POST', body: JSON.stringify(data) }),
|
| 160 |
+
},
|
| 161 |
+
rewards: {
|
| 162 |
+
getClassRewards: (page: number, size: number, className?: string) => request(`/rewards?page=${page}&size=${size}${className ? `&className=${className}` : ''}`),
|
| 163 |
+
getMyRewards: (studentId: string, page: number, size: number) => request(`/rewards/my?studentId=${studentId}&page=${page}&size=${size}`),
|
| 164 |
+
redeem: (id: string) => request(`/rewards/${id}/redeem`, { method: 'POST' }),
|
| 165 |
+
delete: (id: string) => request(`/rewards/${id}`, { method: 'DELETE' }),
|
| 166 |
+
update: (id: string, data: any) => request(`/rewards/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
| 167 |
+
addReward: (data: any) => request('/rewards/add', { method: 'POST', body: JSON.stringify(data) }),
|
| 168 |
+
},
|
| 169 |
+
attendance: {
|
| 170 |
+
get: (params: any) => {
|
| 171 |
+
const qs = new URLSearchParams(params).toString();
|
| 172 |
+
return request(`/attendance?${qs}`);
|
| 173 |
+
},
|
| 174 |
+
checkIn: (data: any) => request('/attendance/checkin', { method: 'POST', body: JSON.stringify(data) }),
|
| 175 |
+
update: (data: any) => request('/attendance/update', { method: 'POST', body: JSON.stringify(data) }),
|
| 176 |
+
batch: (data: any) => request('/attendance/batch', { method: 'POST', body: JSON.stringify(data) }),
|
| 177 |
+
applyLeave: (data: any) => request('/attendance/leave', { method: 'POST', body: JSON.stringify(data) }),
|
| 178 |
+
},
|
| 179 |
+
achievements: {
|
| 180 |
+
getConfig: (className: string) => request(`/achievements/config?className=${className}`),
|
| 181 |
+
saveConfig: (data: AchievementConfig) => request('/achievements/config', { method: 'POST', body: JSON.stringify(data) }),
|
| 182 |
+
getStudentAchievements: (studentId: string, semester?: string) => request(`/achievements/student/${studentId}${semester ? `?semester=${semester}` : ''}`),
|
| 183 |
+
grant: (data: { studentId: string, achievementId: string, semester: string }) => request('/achievements/grant', { method: 'POST', body: JSON.stringify(data) }),
|
| 184 |
+
exchange: (data: { studentId: string, ruleId: string }) => request('/achievements/exchange', { method: 'POST', body: JSON.stringify(data) }),
|
| 185 |
+
},
|
| 186 |
+
batchDelete: (type: string, ids: string[]) => request('/batch-delete', { method: 'POST', body: JSON.stringify({ type, ids }) })
|
| 187 |
+
};
|