dvc890 commited on
Commit
648396d
·
verified ·
1 Parent(s): 666c740

Upload 44 files

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