dvc890 commited on
Commit
a147d81
·
verified ·
1 Parent(s): facad23

Upload 44 files

Browse files
Files changed (7) hide show
  1. models.js +2 -1
  2. pages/GameMonster.tsx +239 -585
  3. pages/GameRandom.tsx +192 -299
  4. pages/GameZen.tsx +350 -550
  5. pages/Games.tsx +1 -1
  6. server.js +34 -794
  7. 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
- // Monster visual assets
8
- const MONSTER_TYPES = ['👾', '👹', '👺', '👻', '💀', '👽'];
 
 
9
 
10
  export const GameMonster: React.FC = () => {
11
- const currentUser = api.auth.getCurrentUser();
12
- const homeroomClass = currentUser?.homeroomClass;
13
-
14
- // React State (for UI rendering)
15
  const [isPlaying, setIsPlaying] = useState(false);
16
- const [gameState, setGameState] = useState<'IDLE' | 'PLAYING' | 'VICTORY' | 'DEFEAT'>('IDLE');
17
- const [timeLeft, setTimeLeft] = useState(300);
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 [excludedStudentIds, setExcludedStudentIds] = useState<Set<string>>(new Set());
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 [rewardConfig, setRewardConfig] = useState<{
36
- enabled: boolean;
37
- type: 'DRAW_COUNT' | 'ITEM' | 'ACHIEVEMENT';
38
- val: string;
39
- count: number;
40
- }>({ enabled: true, type: 'DRAW_COUNT', val: '早读奖励', count: 1 });
41
 
42
- const [isConfigOpen, setIsConfigOpen] = useState(true);
 
 
43
 
44
- // REFS
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 dataArrayRef = useRef<Uint8Array | null>(null);
58
- const sourceRef = useRef<MediaStreamAudioSourceNode | null>(null);
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
- // Sync Refs
84
- useEffect(() => { isPlayingRef.current = isPlaying; }, [isPlaying]);
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
- if (!homeroomClass) return;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
  try {
101
- const [ac, stus, savedConfig] = await Promise.all([
102
- api.achievements.getConfig(homeroomClass),
103
- api.students.getAll(),
104
- api.games.getMonsterConfig(homeroomClass)
105
- ]);
106
- setAchConfig(ac);
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
- if (savedConfig && savedConfig.duration) {
118
- setDuration(savedConfig.duration);
119
- setSensitivity(savedConfig.sensitivity);
120
- setDifficulty(savedConfig.difficulty);
121
- setUseKeyboardMode(savedConfig.useKeyboardMode);
122
- if (savedConfig.rewardConfig) setRewardConfig(savedConfig.rewardConfig);
123
- }
124
- } catch (e) { console.error(e); }
125
  };
126
 
127
- const startAudio = async () => {
128
- if (useKeyboardMode) return;
129
- try {
130
- if (audioContextRef.current && audioContextRef.current.state === 'running') return;
131
 
132
- const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
133
- // @ts-ignore
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
- audioContextRef.current = ctx;
142
- analyserRef.current = analyser;
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
- const stopAudio = () => {
153
- if (sourceRef.current) sourceRef.current.disconnect();
154
- if (audioContextRef.current) audioContextRef.current.close();
155
- audioContextRef.current = null;
156
- };
157
-
158
- const saveConfig = async () => {
159
- if (!homeroomClass) return;
160
- try {
161
- await api.games.saveMonsterConfig({
162
- className: homeroomClass,
163
- duration,
164
- sensitivity,
165
- difficulty,
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
- setTimeLeft(prev => {
259
- const next = prev - delta;
260
- if (next <= 0) {
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
- useEffect(() => {
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
- setGameState(result);
288
- if(reqRef.current) cancelAnimationFrame(reqRef.current);
289
- stopAudio();
290
  };
291
 
292
- const handleBatchGrant = async () => {
293
- const targets = students.filter(s => !excludedStudentIds.has(s._id || String(s.id)));
294
- if (!rewardConfig.enabled || targets.length === 0) return;
295
- if (!confirm(`确定为全班 ${targets.length} 名学生发放奖励吗?\n(已排除 ${excludedStudentIds.size} 名请假学生)`)) return;
 
 
 
296
 
 
 
 
297
  try {
298
- const promises = targets.map(s => {
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
- alert('奖励发放成功!');
316
- setIsConfigOpen(false);
317
- } catch (e) { alert('发放失败'); }
318
  };
319
 
320
- const containerStyle = isFullscreen ? {
321
- position: 'fixed' as 'fixed',
322
- top: 0,
323
- left: 0,
324
- width: '100vw',
325
- height: '100vh',
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
- {/* Volume Bar Top Right */}
356
- <div className="absolute top-4 right-4 z-50 w-48 pointer-events-none hidden md:block">
357
- <div className="bg-black/40 p-2 rounded-lg backdrop-blur-md border border-white/10">
358
- <div className="flex justify-between text-[10px] text-gray-400 mb-1">
359
- <span>{useKeyboardMode ? 'KBD' : 'MIC'}</span>
360
- <span>MAX</span>
361
- </div>
362
- <div className="w-full h-3 bg-gray-800 rounded-full overflow-hidden relative">
363
- <div className="absolute top-0 bottom-0 w-0.5 bg-white z-10" style={{left: `${sensitivity}%`}}></div>
364
- <div
365
- className={`h-full transition-all duration-75 ${currentVolume > sensitivity ? 'bg-green-500 shadow-[0_0_10px_#22c55e]' : 'bg-yellow-600'}`}
366
- style={{width: `${Math.min(currentVolume, 100)}%`}}
367
- ></div>
 
 
368
  </div>
369
- </div>
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 relative z-10 flex flex-col justify-end pb-24 pointer-events-auto" onClick={handleManualInput}>
401
- <div className="absolute bottom-0 left-0 w-[15%] h-[80%] bg-gradient-to-r from-blue-900/30 to-transparent flex flex-col justify-end items-center pb-20 border-r border-blue-500/30">
402
- <div className="text-6xl animate-bounce mb-4">🏰</div>
 
 
 
 
 
 
 
 
 
 
 
 
 
403
  </div>
404
 
405
- <div
406
- className="absolute bottom-20 transition-transform duration-75 ease-linear w-80 h-64 pointer-events-none"
407
- style={{
408
- left: `${Math.max(5, monsterPos)}%`,
409
- transform: `translateX(-50%) ${isHit ? 'scale(0.95)' : 'scale(1)'}`,
410
- filter: isHit ? 'drop-shadow(0 0 30px red) brightness(2) hue-rotate(-20deg)' : 'none'
411
- }}
412
- >
413
- <div className="absolute -top-10 left-10 right-10 h-2 bg-gray-800 rounded-full overflow-hidden border border-white/20">
414
- <div
415
- className="h-full bg-gradient-to-r from-red-600 to-red-400 transition-all duration-300 ease-linear"
416
- style={{width: `${healthPercent}%`}}
417
- ></div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
418
  </div>
 
 
419
 
420
- <div className="relative w-full h-full">
421
- {monsterArmy.map((m) => (
422
- <div
423
- key={m.id}
424
- className="absolute text-7xl filter drop-shadow-2xl transition-transform"
425
- style={{
426
- left: `${m.leftPercent}%`,
427
- bottom: `${m.bottomPercent}%`,
428
- transform: `translate(-50%, 0) scale(${m.scale})`,
429
- zIndex: m.zIndex,
430
- animation: `monsterWalk 1s infinite alternate ${m.id * 0.2}s`
431
- }}
432
- >
433
- {m.emoji}
434
- </div>
435
- ))}
436
- {isHit && <div className="absolute top-0 left-1/2 text-8xl animate-ping z-50">💥</div>}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
437
  </div>
438
  </div>
439
- </div>
440
 
441
- {/* Config Modal */}
442
- {isConfigOpen && (
443
- <div className="absolute inset-0 bg-black/80 z-[999999] flex items-center justify-center p-4 backdrop-blur-md">
444
- <div className="bg-slate-800 text-white rounded-2xl w-full max-w-lg max-h-[90vh] flex flex-col shadow-2xl border border-slate-700 animate-in zoom-in-95">
445
- <div className="p-5 border-b border-slate-700 flex justify-between items-center bg-slate-900/50">
446
- <h2 className="text-xl font-bold flex items-center"><Settings className="mr-2 text-blue-400"/> 游戏设置</h2>
 
447
  </div>
448
-
449
- <div className="p-6 space-y-5 overflow-y-auto flex-1 custom-scrollbar">
450
- <div className="space-y-4">
451
- <div>
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
- {/* Person Filter */}
486
- <div className="bg-slate-900 p-4 rounded-xl border border-slate-700">
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
- {/* Extended Reward Config */}
497
- <div className="bg-slate-900 p-4 rounded-xl border border-slate-700 space-y-3">
498
- <div className="flex items-center justify-between">
499
- <span className="text-sm font-bold flex items-center text-amber-400"><Award size={16} className="mr-2"/> 挑战成功奖励</span>
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
- {rewardConfig.enabled && (
507
- <div className="space-y-3 animate-in slide-in-from-top-2">
508
  <div className="flex gap-2">
509
- {[
510
- {id: 'DRAW_COUNT', label: '抽奖券', icon: <TicketIcon/>},
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
- <div className="flex gap-2">
525
- {rewardConfig.type === 'ACHIEVEMENT' ? (
526
- <select
527
- className="flex-1 bg-slate-800 border border-slate-600 rounded-lg p-2 text-xs text-white outline-none"
528
- value={rewardConfig.val}
529
- onChange={e=>setRewardConfig({...rewardConfig, val: e.target.value})}
530
- >
531
- <option value="">-- 选择成就 --</option>
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
- </div>
557
  </div>
558
  )}
559
  </div>
560
- </div>
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, GameSession, GameTeam, AchievementConfig } from '../types';
6
- import { Loader2, User, Users, Play, Pause, Gift, CheckCircle, XCircle, Award, Volume2, Settings, Maximize, Minimize, UserX } from 'lucide-react';
7
 
8
  export const GameRandom: React.FC = () => {
9
- const [loading, setLoading] = useState(true);
10
  const [students, setStudents] = useState<Student[]>([]);
11
- const [teams, setTeams] = useState<GameTeam[]>([]);
12
- const [achConfig, setAchConfig] = useState<AchievementConfig | null>(null);
 
 
 
13
 
14
- // Game State
15
- const [mode, setMode] = useState<'STUDENT' | 'TEAM'>('STUDENT');
16
- const [scopeTeamId, setScopeTeamId] = useState<string>('ALL'); // 'ALL' or specific team ID
17
- const [isRunning, setIsRunning] = useState(false);
18
- const [highlightIndex, setHighlightIndex] = useState<number | null>(null);
19
- const [selectedResult, setSelectedResult] = useState<Student | GameTeam | null>(null);
20
- const [showResultModal, setShowResultModal] = useState(false);
21
- const [isFullscreen, setIsFullscreen] = useState(false);
22
 
23
- // Filter State
24
- const [excludedStudentIds, setExcludedStudentIds] = useState<Set<string>>(new Set());
25
- const [isFilterOpen, setIsFilterOpen] = useState(false);
26
-
27
- // Reward State
28
  const [rewardType, setRewardType] = useState<'DRAW_COUNT' | 'ITEM' | 'ACHIEVEMENT'>('DRAW_COUNT');
29
- const [rewardId, setRewardId] = useState(''); // Achievement ID or Item Name
30
  const [rewardCount, setRewardCount] = useState(1);
31
- const [enableReward, setEnableReward] = useState(true);
32
-
33
- const timerRef = useRef<any>(null);
34
- const speedRef = useRef<number>(50);
35
 
 
36
  const currentUser = api.auth.getCurrentUser();
37
- const homeroomClass = currentUser?.homeroomClass;
38
 
39
  useEffect(() => {
40
  loadData();
41
- return () => stopAnimation();
42
  }, []);
43
 
44
  const loadData = async () => {
45
- if (!homeroomClass) return setLoading(false);
46
- try {
47
- const [allStus, session, ac] = await Promise.all([
48
- api.students.getAll(),
49
- api.games.getMountainSession(homeroomClass),
50
- api.achievements.getConfig(homeroomClass)
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 startRandom = () => {
85
- const list = getTargetList();
86
- if (list.length === 0) return alert('当前范围内没有可选对象');
87
-
88
- setIsRunning(true);
89
- setShowResultModal(false);
90
- setSelectedResult(null);
91
- speedRef.current = 50;
92
 
93
- const run = () => {
94
- setHighlightIndex(prev => {
95
- const next = (prev === null || prev >= list.length - 1) ? 0 : prev + 1;
96
- return next;
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
- run();
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 stopAnimation = () => {
124
- if (timerRef.current) clearTimeout(timerRef.current);
125
- setIsRunning(false);
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
- if (targets.length === 0) {
145
- alert('没有符合条件的学生可发放奖励');
146
- setShowResultModal(false);
147
- return;
 
 
 
 
 
 
 
 
 
 
148
  }
 
149
 
150
- const rewardName = rewardType === 'ACHIEVEMENT'
151
- ? achConfig?.achievements.find(a => a.id === rewardId)?.name
152
- : (rewardId || (rewardType === 'DRAW_COUNT' ? '抽奖券' : '奖励'));
153
-
154
  try {
155
- const promises = targets.map(s => {
156
- if (rewardType === 'ACHIEVEMENT' && rewardId) {
157
  return api.achievements.grant({
158
  studentId: s._id || String(s.id),
159
- achievementId: rewardId,
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(`已发放奖励给 ${targets.length} 名学生!`);
 
173
  } catch (e) {
174
- console.error(e);
175
  alert('发放失败');
176
  }
177
- setShowResultModal(false);
178
  };
179
 
180
- if (loading) return <div className="h-full flex items-center justify-center"><Loader2 className="animate-spin text-blue-600"/></div>;
181
-
182
- const targetList = getTargetList();
183
 
184
- const GameContent = (
185
- <div className={`${isFullscreen ? 'fixed inset-0 z-[9999] w-screen h-screen' : 'h-full w-full relative'} flex flex-col bg-slate-50 overflow-hidden transition-all duration-300`}>
186
- {/* Floating Fullscreen Toggle Button */}
187
- <button
188
- onClick={() => setIsFullscreen(!isFullscreen)}
189
- className={`absolute top-4 right-4 z-50 p-2 rounded-lg shadow-md transition-all flex items-center gap-2 ${
190
- isFullscreen
191
- ? 'bg-slate-800/80 text-white hover:bg-slate-700 backdrop-blur-md border border-white/20'
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
- <button onClick={()=>{setMode('TEAM'); setHighlightIndex(null);}} className={`px-4 py-2 rounded-md text-sm font-bold transition-all ${mode==='TEAM'?'bg-white shadow text-purple-600':'text-gray-500'}`}>
207
- <Users size={16} className="inline mr-1"/> 随机小组
208
- </button>
209
- </div>
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
- ) : rewardType === 'ITEM' ? (
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
- </div>
244
  </div>
245
- {/* Grid Area */}
246
- <div className="flex-1 overflow-y-auto p-6 custom-scrollbar bg-slate-50">
247
- <div className={`grid gap-4 transition-all pb-24 ${mode==='STUDENT' ? (isFullscreen ? 'grid-cols-6 sm:grid-cols-8 md:grid-cols-10 lg:grid-cols-12' : 'grid-cols-4 sm:grid-cols-6 md:grid-cols-8 lg:grid-cols-10') : 'grid-cols-2 md:grid-cols-3 lg:grid-cols-4'}`}>
248
- {targetList.map((item, idx) => {
249
- const isHighlighted = idx === highlightIndex;
250
- const isTeam = mode === 'TEAM';
251
- // @ts-ignore
252
- const name = isTeam ? item.name : item.name;
253
- // @ts-ignore
254
- const avatar = isTeam ? (item.avatar || '🚩') : (item.seatNo || 'User');
255
- // @ts-ignore
256
- const subText = isTeam ? `${item.members.length}` : item.studentNo;
257
- // @ts-ignore
258
- const color = isTeam ? (item.color || '#3b82f6') : '#3b82f6';
259
- return (
260
- <div key={idx} className={`relative aspect-square flex flex-col items-center justify-center rounded-2xl border-4 transition-all duration-100 ${
261
- isHighlighted
262
- ? 'bg-yellow-100 border-yellow-400 scale-110 shadow-2xl z-10'
263
- : 'bg-white border-gray-100 shadow-sm opacity-80'
264
- }`}>
265
- {isHighlighted && <div className="absolute inset-0 bg-yellow-400 opacity-20 animate-pulse rounded-xl"></div>}
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
- {/* Student Filter Modal */}
291
- {isFilterOpen && (
292
- <div className="absolute inset-0 bg-black/50 z-[1000000] flex items-center justify-center p-4">
293
- <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">
294
- <div className="p-4 border-b flex justify-between items-center">
295
- <h3 className="font-bold text-lg">排除请假/缺勤学生</h3>
296
- <div className="flex gap-2">
297
- <button onClick={() => setExcludedStudentIds(new Set())} className="text-xs text-blue-600 hover:underline">清空选择</button>
298
- <button onClick={() => setIsFilterOpen(false)} className="px-4 py-1 bg-blue-600 text-white rounded text-sm">确定</button>
299
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
300
  </div>
301
- <div className="flex-1 overflow-y-auto p-4 bg-gray-50">
302
- <div className="grid grid-cols-4 sm:grid-cols-5 md:grid-cols-6 gap-2">
303
- {students.map(s => {
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
- {/* Result Modal */}
329
- {showResultModal && selectedResult && (
330
- <div className="fixed inset-0 bg-black/60 z-[110] flex items-center justify-center p-4 backdrop-blur-sm animate-in fade-in">
331
- <div className="bg-white rounded-3xl p-8 w-full max-w-md text-center shadow-2xl relative animate-in zoom-in-95">
332
- <div className="absolute -top-12 left-1/2 -translate-x-1/2 w-24 h-24 bg-yellow-400 rounded-full flex items-center justify-center border-4 border-white shadow-lg">
333
- <span className="text-5xl">✨</span>
334
- </div>
335
-
336
- <h2 className="mt-10 text-gray-500 text-sm font-bold uppercase tracking-widest">选中对象</h2>
337
- <div className="text-5xl font-black text-gray-800 my-6">
338
- {/* @ts-ignore */}
339
- {selectedResult.name}
340
- </div>
341
- {/* @ts-ignore */}
342
- <div className="text-gray-400 mb-8">{mode==='STUDENT' ? `座号: ${selectedResult.seatNo || '-'}` : `${selectedResult.members.length} 位成员`}</div>
343
- <div className="grid grid-cols-2 gap-4 mb-4">
344
- <button onClick={() => { setEnableReward(true); handleGrantReward(); }} className="bg-green-500 hover:bg-green-600 text-white py-4 rounded-2xl font-bold text-lg flex flex-col items-center justify-center shadow-md transition-transform hover:-translate-y-1">
345
- <CheckCircle size={32} className="mb-1"/>
346
- 回答正确
347
- <span className="text-xs font-normal opacity-80 mt-1">发放奖励</span>
348
- </button>
349
- <button onClick={() => { setEnableReward(false); handleGrantReward(); }} className="bg-gray-100 hover:bg-gray-200 text-gray-600 py-4 rounded-2xl font-bold text-lg flex flex-col items-center justify-center shadow-sm transition-transform hover:-translate-y-1">
350
- <XCircle size={32} className="mb-1"/>
351
- 回答错误
352
- <span className="text-xs font-normal opacity-80 mt-1">无奖励</span>
353
- </button>
 
 
 
 
 
 
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 { AchievementConfig, Student } from '../types';
6
- import { Play, Pause, Settings, Maximize, Minimize, Gift, Trophy, Package, Moon, UserX, CheckCircle, Volume2 } from 'lucide-react';
7
 
8
  export const GameZen: React.FC = () => {
9
- const currentUser = api.auth.getCurrentUser();
10
- const homeroomClass = currentUser?.homeroomClass;
11
-
12
- // React State
13
  const [isPlaying, setIsPlaying] = useState(false);
14
- const [gameState, setGameState] = useState<'IDLE' | 'PLAYING' | 'FINISHED'>('IDLE');
15
- const [timeLeft, setTimeLeft] = useState(2400); // Seconds
16
- const [currentVolume, setCurrentVolume] = useState(0); // This is now the SMOOTHED volume
17
- const [isFullscreen, setIsFullscreen] = useState(false);
18
-
19
- // Game Logic State
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 [excludedStudentIds, setExcludedStudentIds] = useState<Set<string>>(new Set());
28
- const [isFilterOpen, setIsFilterOpen] = useState(false);
29
 
30
- const [durationMinutes, setDurationMinutes] = useState(40);
31
- const [threshold, setThreshold] = useState(30);
32
- const [passRate, setPassRate] = useState(90);
33
-
34
- const [rewardConfig, setRewardConfig] = useState<{
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 dataArrayRef = useRef<Uint8Array | null>(null);
50
- const sourceRef = useRef<MediaStreamAudioSourceNode | null>(null);
51
- const reqRef = useRef<number>(0);
52
-
53
- // Logic Refs for Smoothing
54
  const smoothedVolRef = useRef(0);
55
-
56
- // Sync Refs
57
- useEffect(() => { isPlayingRef.current = isPlaying; }, [isPlaying]);
58
- useEffect(() => { gameStateRef.current = gameState; }, [gameState]);
 
 
 
59
 
60
  useEffect(() => {
61
  loadData();
62
- return () => {
63
- stopAudio();
64
- if(reqRef.current) cancelAnimationFrame(reqRef.current);
65
- };
66
  }, []);
67
 
68
  const loadData = async () => {
69
- if (!homeroomClass) return;
70
- try {
71
- const [ac, stus, savedConfig] = await Promise.all([
72
- api.achievements.getConfig(homeroomClass),
73
- api.students.getAll(),
74
- api.games.getZenConfig(homeroomClass)
75
- ]);
76
- setAchConfig(ac);
77
- const classStudents = (stus as Student[]).filter((s: Student) => s.className === homeroomClass);
78
- // Sort by Seat No
79
- classStudents.sort((a, b) => {
80
- const seatA = parseInt(a.seatNo || '99999');
81
- const seatB = parseInt(b.seatNo || '99999');
82
- if (seatA !== seatB) return seatA - seatB;
83
- return a.name.localeCompare(b.name, 'zh-CN');
84
- });
85
- setStudents(classStudents);
86
-
87
- if (savedConfig && savedConfig.durationMinutes) {
88
- setDurationMinutes(savedConfig.durationMinutes);
89
- setThreshold(savedConfig.threshold);
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
- saveConfig();
139
- setIsConfigOpen(false);
140
- setGameState('PLAYING');
141
- setTimeLeft(durationMinutes * 60);
142
- setQuietSeconds(0);
143
- setTotalSeconds(0);
144
- setScore(100);
145
- smoothedVolRef.current = 0; // Reset smooth vol
146
-
147
- await startAudio();
148
- setIsPlaying(true);
149
-
150
- lastTimeRef.current = performance.now();
151
- if (reqRef.current) cancelAnimationFrame(reqRef.current);
152
- reqRef.current = requestAnimationFrame(loop);
153
- };
 
 
154
 
155
- const endGame = () => {
156
- setIsPlaying(false);
157
- setGameState('FINISHED');
158
- if(reqRef.current) cancelAnimationFrame(reqRef.current);
159
- stopAudio();
160
  };
161
 
162
- const loop = (time: number) => {
163
- reqRef.current = requestAnimationFrame(loop);
164
-
165
- if (!isPlayingRef.current || gameStateRef.current !== 'PLAYING') {
166
- lastTimeRef.current = time;
167
- return;
168
- }
169
-
170
- const delta = (time - lastTimeRef.current) / 1000;
171
- lastTimeRef.current = time;
172
-
173
- // Audio Processing
174
- if (analyserRef.current && dataArrayRef.current) {
175
- analyserRef.current.getByteFrequencyData(dataArrayRef.current as any);
176
- const avg = dataArrayRef.current.reduce((a,b)=>a+b) / dataArrayRef.current.length;
177
- const rawVol = Math.min(100, Math.floor(avg / 2));
178
-
179
- // --- SMOOTHING ALGORITHM (Fast Attack, Slow Decay) ---
180
- // If getting louder, react fast (0.2). If getting quieter, react slow (0.05).
181
- // This prevents flickering at the threshold.
182
- const smoothingFactor = rawVol > smoothedVolRef.current ? 0.2 : 0.05;
183
- smoothedVolRef.current = smoothedVolRef.current + (rawVol - smoothedVolRef.current) * smoothingFactor;
184
-
185
- // Ensure strictly non-negative
186
- if (smoothedVolRef.current < 0) smoothedVolRef.current = 0;
187
-
188
- const displayVol = Math.round(smoothedVolRef.current);
189
- setCurrentVolume(displayVol);
190
-
191
- // Game Logic uses SMOOTHED volume for stability
192
- if (delta > 0) {
193
- setTotalSeconds(prev => prev + delta);
194
- // Count as quiet only if below threshold (Levels 1 & 2)
195
- if (displayVol < threshold) {
196
- setQuietSeconds(prev => prev + delta);
197
- }
198
- }
199
- }
200
 
201
- setTimeLeft(prev => {
202
- const next = prev - delta;
203
- if (next <= 0) {
204
- endGame();
205
- return 0;
206
- }
207
- return next;
208
- });
209
  };
210
 
211
- // Score Calculation
212
- const calculatedScore = totalSeconds > 0 ? Math.round((quietSeconds / totalSeconds) * 100) : 100;
213
-
214
- // Determine Current State (4 Levels) based on SMOOTHED volume
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 currentState = getZenState();
223
-
224
- const handleBatchGrant = async () => {
225
- const targets = students.filter(s => !excludedStudentIds.has(s._id || String(s.id)));
226
- if (!rewardConfig.enabled || targets.length === 0) return;
227
- if (!confirm(`确定为 ${targets.length} 名学生发放奖励吗?\n(已排除 ${excludedStudentIds.size} 名请假学生)`)) return;
 
 
 
 
 
 
 
 
 
 
 
 
228
 
 
 
 
229
  try {
230
- const promises = targets.map(s => {
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' ? '禅奖励' : rewardConfig.val
 
243
  });
244
  }
245
  });
246
  await Promise.all(promises);
247
- alert('奖励发放成功!');
248
- setIsConfigOpen(true);
249
- setGameState('IDLE');
250
- } catch (e) { alert('发放失败'); }
251
  };
252
 
253
- const containerStyle = isFullscreen ? {
254
- position: 'fixed' as 'fixed',
255
- top: 0,
256
- left: 0,
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 currentVisual = visualParams[currentState];
291
-
292
- const GameContent = (
293
- <div
294
- className={`${isFullscreen ? '' : 'h-full w-full relative'} flex flex-col overflow-hidden select-none transition-all duration-1000 ease-in-out font-sans ${currentVisual.bg}`}
295
- style={containerStyle}
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
- {/* Student Filter Modal */}
528
- {isFilterOpen && (
529
- <div className="absolute inset-0 bg-black/80 z-[1000000] flex items-center justify-center p-4">
530
- <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">
531
- <div className="p-4 border-b flex justify-between items-center">
532
- <h3 className="font-bold text-lg">排除请假/缺勤学生</h3>
533
- <div className="flex gap-2">
534
- <button onClick={() => setExcludedStudentIds(new Set())} className="text-xs text-blue-600 hover:underline">清空选择</button>
535
- <button onClick={() => setIsFilterOpen(false)} className="px-4 py-1 bg-blue-600 text-white rounded text-sm">确定</button>
536
- </div>
537
- </div>
538
- <div className="flex-1 overflow-y-auto p-4 bg-gray-50">
539
- <div className="grid grid-cols-4 sm:grid-cols-5 md:grid-cols-6 gap-2">
540
- {students.map(s => {
541
- const isExcluded = excludedStudentIds.has(s._id || String(s.id));
542
- return (
543
- <div
544
- key={s._id}
545
- onClick={() => {
546
- const newSet = new Set(excludedStudentIds);
547
- if (isExcluded) newSet.delete(s._id || String(s.id));
548
- else newSet.add(s._id || String(s.id));
549
- setExcludedStudentIds(newSet);
550
- }}
551
- 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'}`}
552
- >
553
- <div className="font-bold">{s.name}</div>
554
- <div className="text-xs opacity-70">{s.seatNo}</div>
555
- {isExcluded && <div className="text-[10px] font-bold mt-1">已排除</div>}
556
- </div>
557
- )
558
- })}
559
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
560
  </div>
561
- </div>
562
- </div>
563
- )}
564
-
565
- {/* Result Modal */}
566
- {gameState === 'FINISHED' && !isConfigOpen && (
567
- <div className="absolute inset-0 bg-black/90 z-[110] flex items-center justify-center p-4 backdrop-blur-lg animate-in zoom-in">
568
- <div className="text-center w-full max-w-lg">
569
- <div className="text-9xl mb-6 animate-bounce drop-shadow-[0_0_25px_rgba(255,255,255,0.5)]">
570
- {calculatedScore >= passRate ? '🌸' : '🍂'}
 
 
 
 
 
 
 
 
 
 
571
  </div>
572
- <h2 className={`text-5xl font-black mb-2 tracking-tight ${calculatedScore >= passRate ? 'text-transparent bg-clip-text bg-gradient-to-b from-green-300 to-emerald-600' : 'text-gray-400'}`}>
573
- {calculatedScore >= passRate ? '修行圆满' : '心浮气躁'}
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
- </div>
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">目标: &gt;{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
- // ... imports
2
- const express = require('express');
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
- // ... constants
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
- if (matchedPrize) {
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
- await StudentRewardModel.create({ schoolId: req.headers['x-school-id'], studentId, studentName: (await Student.findById(studentId)).name, rewardType, name: finalName, count: finalCount, status: rewardType === 'DRAW_COUNT' ? 'REDEEMED' : 'PENDING', source: '教师发放' });
736
- res.json({});
737
- });
738
- app.put('/api/rewards/:id', async (req, res) => { await StudentRewardModel.findByIdAndUpdate(req.params.id, req.body); res.json({}); });
739
- app.delete('/api/rewards/:id', async (req, res) => {
740
- const reward = await StudentRewardModel.findById(req.params.id);
741
- if (!reward) return res.status(404).json({error: 'Not found'});
742
- if (reward.rewardType === 'DRAW_COUNT') {
743
- const student = await Student.findById(reward.studentId);
744
- if (student && student.drawAttempts < reward.count) return res.status(400).json({ error: 'FAILED_REVOKE', message: '修改失败,次数已被使用' });
745
- await Student.findByIdAndUpdate(reward.studentId, { $inc: { drawAttempts: -reward.count } });
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
- app.post('/api/rewards/:id/redeem', async (req, res) => { await StudentRewardModel.findByIdAndUpdate(req.params.id, {status:'REDEEMED'}); res.json({}); });
773
- app.get('/api/attendance', async (req, res) => {
774
- const { className, date, studentId } = req.query;
775
- const filter = getQueryFilter(req);
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
- app.post('/api/batch-delete', async (req, res) => {
810
- if(req.body.type==='student') await Student.deleteMany({_id:{$in:req.body.ids}});
811
- if(req.body.type==='score') await Score.deleteMany({_id:{$in:req.body.ids}});
812
- res.json({});
 
 
 
813
  });
814
 
815
- app.get('*', (req, res) => { res.sendFile(path.join(__dirname, 'dist', 'index.html')); });
816
- app.listen(PORT, () => console.log(`🚀 Server running on port ${PORT}`));
 
 
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
- const getBaseUrl = () => {
4
- let isProd = false;
5
- try {
6
- // @ts-ignore
7
- if (typeof import.meta !== 'undefined' && import.meta.env && import.meta.env.PROD) {
8
- isProd = true;
9
- }
10
- } catch (e) {}
11
-
12
- if (isProd || (typeof window !== 'undefined' && window.location.port === '7860')) {
13
- return '/api';
14
- }
15
- return 'http://localhost:7860/api';
16
- };
17
-
18
- const API_BASE_URL = getBaseUrl();
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
- // Inject User Role for backend logic (e.g., bypassing draw limits)
34
- if (currentUser?.role) {
35
- headers['x-user-role'] = currentUser.role;
36
- headers['x-user-username'] = currentUser.username;
37
  }
38
- }
39
 
40
- const res = await fetch(`${API_BASE_URL}${endpoint}`, { ...options, headers });
41
-
42
- if (!res.ok) {
43
- if (res.status === 401) throw new Error('AUTH_FAILED');
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
- schools: {
102
- getPublic: () => request('/public/schools'),
103
- getAll: () => request('/schools'),
104
- add: (data: School) => request('/schools', { method: 'POST', body: JSON.stringify(data) }),
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
- users: {
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
- students: {
123
- getAll: () => request('/students'),
124
- add: (data: any) => request('/students', { method: 'POST', body: JSON.stringify(data) }),
125
- update: (id: string, data: any) => request(`/students/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
126
- delete: (id: string | number) => request(`/students/${id}`, { method: 'DELETE' }),
127
- // NEW: Promote and Transfer
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
- attendance: {
177
- checkIn: (data: { studentId: string, date: string, status?: string }) => request('/attendance/check-in', { method: 'POST', body: JSON.stringify(data) }),
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
- batchDelete: (type: 'student' | 'score' | 'user', ids: string[]) => {
236
- return request('/batch-delete', { method: 'POST', body: JSON.stringify({ type, ids }) });
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
+ };