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

Upload 44 files

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