dvc890 commited on
Commit
7ac1491
·
verified ·
1 Parent(s): 236e355

Upload 44 files

Browse files
Files changed (7) hide show
  1. models.js +16 -1
  2. pages/GameMonster.tsx +66 -6
  3. pages/GameRandom.tsx +72 -7
  4. pages/GameZen.tsx +549 -0
  5. pages/Games.tsx +9 -4
  6. server.js +17 -4
  7. services/api.ts +2 -0
models.js CHANGED
@@ -112,6 +112,21 @@ const GameMonsterConfigSchema = new mongoose.Schema({
112
  });
113
  const GameMonsterConfigModel = mongoose.model('GameMonsterConfig', GameMonsterConfigSchema);
114
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
  const AchievementConfigSchema = new mongoose.Schema({ schoolId: String, className: String, achievements: [{ id: String, name: String, icon: String, points: Number, description: String }], exchangeRules: [{ id: String, cost: Number, rewardType: String, rewardName: String, rewardValue: Number }] });
116
  const AchievementConfigModel = mongoose.model('AchievementConfig', AchievementConfigSchema);
117
 
@@ -126,6 +141,6 @@ const LeaveRequestModel = mongoose.model('LeaveRequest', LeaveRequestSchema);
126
 
127
  module.exports = {
128
  School, User, Student, Course, Score, ClassModel, SubjectModel, ExamModel, ScheduleModel,
129
- ConfigModel, NotificationModel, GameSessionModel, StudentRewardModel, LuckyDrawConfigModel, GameMonsterConfigModel,
130
  AchievementConfigModel, StudentAchievementModel, AttendanceModel, LeaveRequestModel
131
  };
 
112
  });
113
  const GameMonsterConfigModel = mongoose.model('GameMonsterConfig', GameMonsterConfigSchema);
114
 
115
+ const GameZenConfigSchema = new mongoose.Schema({
116
+ schoolId: String,
117
+ className: String,
118
+ durationMinutes: { type: Number, default: 40 },
119
+ threshold: { type: Number, default: 30 },
120
+ passRate: { type: Number, default: 90 },
121
+ rewardConfig: {
122
+ enabled: Boolean,
123
+ type: { type: String },
124
+ val: String,
125
+ count: Number
126
+ }
127
+ });
128
+ const GameZenConfigModel = mongoose.model('GameZenConfig', GameZenConfigSchema);
129
+
130
  const AchievementConfigSchema = new mongoose.Schema({ schoolId: String, className: String, achievements: [{ id: String, name: String, icon: String, points: Number, description: String }], exchangeRules: [{ id: String, cost: Number, rewardType: String, rewardName: String, rewardValue: Number }] });
131
  const AchievementConfigModel = mongoose.model('AchievementConfig', AchievementConfigSchema);
132
 
 
141
 
142
  module.exports = {
143
  School, User, Student, Course, Score, ClassModel, SubjectModel, ExamModel, ScheduleModel,
144
+ ConfigModel, NotificationModel, GameSessionModel, StudentRewardModel, LuckyDrawConfigModel, GameMonsterConfigModel, GameZenConfigModel,
145
  AchievementConfigModel, StudentAchievementModel, AttendanceModel, LeaveRequestModel
146
  };
pages/GameMonster.tsx CHANGED
@@ -2,7 +2,7 @@ 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 } from 'lucide-react';
6
 
7
  // Monster visual assets
8
  const MONSTER_TYPES = ['👾', '👹', '👺', '👻', '💀', '👽'];
@@ -24,6 +24,8 @@ export const GameMonster: React.FC = () => {
24
  // Config State
25
  const [achConfig, setAchConfig] = useState<AchievementConfig | null>(null);
26
  const [students, setStudents] = useState<Student[]>([]);
 
 
27
 
28
  const [duration, setDuration] = useState(300);
29
  const [sensitivity, setSensitivity] = useState(40);
@@ -102,7 +104,15 @@ export const GameMonster: React.FC = () => {
102
  api.games.getMonsterConfig(homeroomClass)
103
  ]);
104
  setAchConfig(ac);
105
- setStudents((stus as Student[]).filter((s: Student) => s.className === homeroomClass));
 
 
 
 
 
 
 
 
106
 
107
  if (savedConfig && savedConfig.duration) {
108
  setDuration(savedConfig.duration);
@@ -280,11 +290,12 @@ export const GameMonster: React.FC = () => {
280
  };
281
 
282
  const handleBatchGrant = async () => {
283
- if (!rewardConfig.enabled || students.length === 0) return;
284
- if (!confirm(`确定为全班 ${students.length} 名学生发放奖励吗?`)) return;
 
285
 
286
  try {
287
- const promises = students.map(s => {
288
  if (rewardConfig.type === 'ACHIEVEMENT') {
289
  return api.achievements.grant({
290
  studentId: s._id || String(s.id),
@@ -471,6 +482,17 @@ export const GameMonster: React.FC = () => {
471
  </label>
472
  </div>
473
 
 
 
 
 
 
 
 
 
 
 
 
474
  {/* Extended Reward Config */}
475
  <div className="bg-slate-900 p-4 rounded-xl border border-slate-700 space-y-3">
476
  <div className="flex items-center justify-between">
@@ -546,6 +568,44 @@ export const GameMonster: React.FC = () => {
546
  </div>
547
  )}
548
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
549
  {/* Result Modal */}
550
  {gameState !== 'IDLE' && gameState !== 'PLAYING' && !isConfigOpen && (
551
  <div className="absolute inset-0 bg-black/90 z-[110] flex items-center justify-center p-4 backdrop-blur-lg animate-in zoom-in">
@@ -561,7 +621,7 @@ export const GameMonster: React.FC = () => {
561
  <div className="bg-white/10 p-6 rounded-2xl mb-10 border border-white/10 backdrop-blur-sm mt-8">
562
  <p className="text-yellow-200 font-bold mb-2 flex items-center justify-center"><Gift size={20} className="mr-2"/> 奖励已解锁</p>
563
  <p className="text-sm text-gray-300 mb-4">
564
- 全班每人可获得
565
  <span className="text-white font-bold mx-1 border-b border-white/30">
566
  {rewardConfig.type==='ACHIEVEMENT' ? '成就奖状' : rewardConfig.type==='ITEM' ? rewardConfig.val : '抽奖券'}
567
  </span>
 
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 = ['👾', '👹', '👺', '👻', '💀', '👽'];
 
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);
 
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);
 
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),
 
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">
 
568
  </div>
569
  )}
570
 
571
+ {/* Student Filter Modal */}
572
+ {isFilterOpen && (
573
+ <div className="absolute inset-0 bg-black/50 z-[1000000] flex items-center justify-center p-4">
574
+ <div className="bg-white rounded-xl w-full max-w-3xl h-[80vh] flex flex-col shadow-2xl animate-in zoom-in-95 text-gray-800">
575
+ <div className="p-4 border-b flex justify-between items-center">
576
+ <h3 className="font-bold text-lg">排除请假/缺勤学生</h3>
577
+ <div className="flex gap-2">
578
+ <button onClick={() => setExcludedStudentIds(new Set())} className="text-xs text-blue-600 hover:underline">清空选择</button>
579
+ <button onClick={() => setIsFilterOpen(false)} className="px-4 py-1 bg-blue-600 text-white rounded text-sm">确定</button>
580
+ </div>
581
+ </div>
582
+ <div className="flex-1 overflow-y-auto p-4 bg-gray-50">
583
+ <div className="grid grid-cols-4 sm:grid-cols-5 md:grid-cols-6 gap-2">
584
+ {students.map(s => {
585
+ const isExcluded = excludedStudentIds.has(s._id || String(s.id));
586
+ return (
587
+ <div
588
+ key={s._id}
589
+ onClick={() => {
590
+ const newSet = new Set(excludedStudentIds);
591
+ if (isExcluded) newSet.delete(s._id || String(s.id));
592
+ else newSet.add(s._id || String(s.id));
593
+ setExcludedStudentIds(newSet);
594
+ }}
595
+ className={`p-2 rounded border cursor-pointer text-center text-sm transition-all select-none ${isExcluded ? 'bg-red-50 border-red-300 text-red-500 opacity-60' : 'bg-white border-gray-200 hover:border-blue-300'}`}
596
+ >
597
+ <div className="font-bold">{s.name}</div>
598
+ <div className="text-xs opacity-70">{s.seatNo}</div>
599
+ {isExcluded && <div className="text-[10px] font-bold mt-1">已排除</div>}
600
+ </div>
601
+ )
602
+ })}
603
+ </div>
604
+ </div>
605
+ </div>
606
+ </div>
607
+ )}
608
+
609
  {/* Result Modal */}
610
  {gameState !== 'IDLE' && gameState !== 'PLAYING' && !isConfigOpen && (
611
  <div className="absolute inset-0 bg-black/90 z-[110] flex items-center justify-center p-4 backdrop-blur-lg animate-in zoom-in">
 
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>
pages/GameRandom.tsx CHANGED
@@ -1,8 +1,9 @@
 
1
  import React, { useState, useEffect, useRef } from 'react';
2
  import { createPortal } from 'react-dom';
3
  import { api } from '../services/api';
4
  import { Student, GameSession, GameTeam, AchievementConfig } from '../types';
5
- import { Loader2, User, Users, Play, Pause, Gift, CheckCircle, XCircle, Award, Volume2, Settings, Maximize, Minimize } from 'lucide-react';
6
 
7
  export const GameRandom: React.FC = () => {
8
  const [loading, setLoading] = useState(true);
@@ -19,6 +20,10 @@ export const GameRandom: React.FC = () => {
19
  const [showResultModal, setShowResultModal] = useState(false);
20
  const [isFullscreen, setIsFullscreen] = useState(false);
21
 
 
 
 
 
22
  // Reward State
23
  const [rewardType, setRewardType] = useState<'DRAW_COUNT' | 'ITEM' | 'ACHIEVEMENT'>('DRAW_COUNT');
24
  const [rewardId, setRewardId] = useState(''); // Achievement ID or Item Name
@@ -63,10 +68,17 @@ export const GameRandom: React.FC = () => {
63
 
64
  const getTargetList = () => {
65
  if (mode === 'TEAM') return teams;
66
- if (scopeTeamId === 'ALL') return students;
67
- const team = teams.find(t => t.id === scopeTeamId);
68
- if (!team) return students;
69
- return students.filter(s => team.members.includes(s._id || String(s.id)));
 
 
 
 
 
 
 
70
  };
71
 
72
  const startRandom = () => {
@@ -125,7 +137,14 @@ export const GameRandom: React.FC = () => {
125
  } else {
126
  // Team mode: find all students in team
127
  const team = selectedResult as GameTeam;
128
- targets = students.filter(s => team.members.includes(s._id || String(s.id)));
 
 
 
 
 
 
 
129
  }
130
 
131
  const rewardName = rewardType === 'ACHIEVEMENT'
@@ -194,6 +213,14 @@ export const GameRandom: React.FC = () => {
194
  {teams.map(t => <option key={t.id} value={t.id}>{t.name} (组)</option>)}
195
  </select>
196
  )}
 
 
 
 
 
 
 
 
197
  <div className="flex items-center gap-2 bg-amber-50 px-3 py-2 rounded-lg border border-amber-100">
198
  <Gift size={16} className="text-amber-500"/>
199
  <label className="text-sm font-bold text-gray-700">奖励:</label>
@@ -260,6 +287,44 @@ export const GameRandom: React.FC = () => {
260
  </button>
261
  </div>
262
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
263
  {/* Result Modal */}
264
  {showResultModal && selectedResult && (
265
  <div className="fixed inset-0 bg-black/60 z-[110] flex items-center justify-center p-4 backdrop-blur-sm animate-in fade-in">
@@ -298,4 +363,4 @@ export const GameRandom: React.FC = () => {
298
  return createPortal(GameContent, document.body);
299
  }
300
  return GameContent;
301
- };
 
1
+
2
  import React, { useState, useEffect, useRef } from 'react';
3
  import { createPortal } from 'react-dom';
4
  import { api } from '../services/api';
5
  import { Student, GameSession, GameTeam, AchievementConfig } from '../types';
6
+ import { Loader2, User, Users, Play, Pause, Gift, CheckCircle, XCircle, Award, Volume2, Settings, Maximize, Minimize, UserX } from 'lucide-react';
7
 
8
  export const GameRandom: React.FC = () => {
9
  const [loading, setLoading] = useState(true);
 
20
  const [showResultModal, setShowResultModal] = useState(false);
21
  const [isFullscreen, setIsFullscreen] = useState(false);
22
 
23
+ // Filter State
24
+ const [excludedStudentIds, setExcludedStudentIds] = useState<Set<string>>(new Set());
25
+ const [isFilterOpen, setIsFilterOpen] = useState(false);
26
+
27
  // Reward State
28
  const [rewardType, setRewardType] = useState<'DRAW_COUNT' | 'ITEM' | 'ACHIEVEMENT'>('DRAW_COUNT');
29
  const [rewardId, setRewardId] = useState(''); // Achievement ID or Item Name
 
68
 
69
  const getTargetList = () => {
70
  if (mode === 'TEAM') return teams;
71
+
72
+ let baseList = students;
73
+ if (scopeTeamId !== 'ALL') {
74
+ const team = teams.find(t => t.id === scopeTeamId);
75
+ if (team) {
76
+ baseList = students.filter(s => team.members.includes(s._id || String(s.id)));
77
+ }
78
+ }
79
+
80
+ // Filter out excluded students
81
+ return baseList.filter(s => !excludedStudentIds.has(s._id || String(s.id)));
82
  };
83
 
84
  const startRandom = () => {
 
137
  } else {
138
  // Team mode: find all students in team
139
  const team = selectedResult as GameTeam;
140
+ // IMPORTANT: Exclude absent students from team reward
141
+ targets = students.filter(s => team.members.includes(s._id || String(s.id)) && !excludedStudentIds.has(s._id || String(s.id)));
142
+ }
143
+
144
+ if (targets.length === 0) {
145
+ alert('没有符合条件的学生可发放奖励');
146
+ setShowResultModal(false);
147
+ return;
148
  }
149
 
150
  const rewardName = rewardType === 'ACHIEVEMENT'
 
213
  {teams.map(t => <option key={t.id} value={t.id}>{t.name} (组)</option>)}
214
  </select>
215
  )}
216
+
217
+ <button
218
+ onClick={() => setIsFilterOpen(true)}
219
+ className={`flex items-center gap-1 px-3 py-2 rounded-lg text-sm border font-medium ${excludedStudentIds.size > 0 ? 'bg-red-50 text-red-600 border-red-200' : 'bg-gray-50 text-gray-600 border-gray-200'}`}
220
+ >
221
+ <UserX size={16}/> 排除 ({excludedStudentIds.size})
222
+ </button>
223
+
224
  <div className="flex items-center gap-2 bg-amber-50 px-3 py-2 rounded-lg border border-amber-100">
225
  <Gift size={16} className="text-amber-500"/>
226
  <label className="text-sm font-bold text-gray-700">奖励:</label>
 
287
  </button>
288
  </div>
289
 
290
+ {/* Student Filter Modal */}
291
+ {isFilterOpen && (
292
+ <div className="absolute inset-0 bg-black/50 z-[1000000] flex items-center justify-center p-4">
293
+ <div className="bg-white rounded-xl w-full max-w-3xl h-[80vh] flex flex-col shadow-2xl animate-in zoom-in-95 text-gray-800">
294
+ <div className="p-4 border-b flex justify-between items-center">
295
+ <h3 className="font-bold text-lg">排除请假/缺勤学生</h3>
296
+ <div className="flex gap-2">
297
+ <button onClick={() => setExcludedStudentIds(new Set())} className="text-xs text-blue-600 hover:underline">清空选择</button>
298
+ <button onClick={() => setIsFilterOpen(false)} className="px-4 py-1 bg-blue-600 text-white rounded text-sm">确定</button>
299
+ </div>
300
+ </div>
301
+ <div className="flex-1 overflow-y-auto p-4 bg-gray-50">
302
+ <div className="grid grid-cols-4 sm:grid-cols-5 md:grid-cols-6 gap-2">
303
+ {students.map(s => {
304
+ const isExcluded = excludedStudentIds.has(s._id || String(s.id));
305
+ return (
306
+ <div
307
+ key={s._id}
308
+ onClick={() => {
309
+ const newSet = new Set(excludedStudentIds);
310
+ if (isExcluded) newSet.delete(s._id || String(s.id));
311
+ else newSet.add(s._id || String(s.id));
312
+ setExcludedStudentIds(newSet);
313
+ }}
314
+ className={`p-2 rounded border cursor-pointer text-center text-sm transition-all select-none ${isExcluded ? 'bg-red-50 border-red-300 text-red-500 opacity-60' : 'bg-white border-gray-200 hover:border-blue-300'}`}
315
+ >
316
+ <div className="font-bold">{s.name}</div>
317
+ <div className="text-xs opacity-70">{s.seatNo}</div>
318
+ {isExcluded && <div className="text-[10px] font-bold mt-1">已排除</div>}
319
+ </div>
320
+ )
321
+ })}
322
+ </div>
323
+ </div>
324
+ </div>
325
+ </div>
326
+ )}
327
+
328
  {/* Result Modal */}
329
  {showResultModal && selectedResult && (
330
  <div className="fixed inset-0 bg-black/60 z-[110] flex items-center justify-center p-4 backdrop-blur-sm animate-in fade-in">
 
363
  return createPortal(GameContent, document.body);
364
  }
365
  return GameContent;
366
+ };
pages/GameZen.tsx ADDED
@@ -0,0 +1,549 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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);
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
+ // Sync Refs
54
+ useEffect(() => { isPlayingRef.current = isPlaying; }, [isPlaying]);
55
+ useEffect(() => { gameStateRef.current = gameState; }, [gameState]);
56
+
57
+ useEffect(() => {
58
+ loadData();
59
+ return () => {
60
+ stopAudio();
61
+ if(reqRef.current) cancelAnimationFrame(reqRef.current);
62
+ };
63
+ }, []);
64
+
65
+ const loadData = async () => {
66
+ if (!homeroomClass) return;
67
+ try {
68
+ const [ac, stus, savedConfig] = await Promise.all([
69
+ api.achievements.getConfig(homeroomClass),
70
+ api.students.getAll(),
71
+ api.games.getZenConfig(homeroomClass)
72
+ ]);
73
+ setAchConfig(ac);
74
+ const classStudents = (stus as Student[]).filter((s: Student) => s.className === homeroomClass);
75
+ // Sort by Seat No
76
+ classStudents.sort((a, b) => {
77
+ const seatA = parseInt(a.seatNo || '99999');
78
+ const seatB = parseInt(b.seatNo || '99999');
79
+ if (seatA !== seatB) return seatA - seatB;
80
+ return a.name.localeCompare(b.name, 'zh-CN');
81
+ });
82
+ setStudents(classStudents);
83
+
84
+ if (savedConfig && savedConfig.durationMinutes) {
85
+ setDurationMinutes(savedConfig.durationMinutes);
86
+ setThreshold(savedConfig.threshold);
87
+ setPassRate(savedConfig.passRate);
88
+ if (savedConfig.rewardConfig) setRewardConfig(savedConfig.rewardConfig);
89
+ }
90
+ } catch (e) { console.error(e); }
91
+ };
92
+
93
+ const startAudio = async () => {
94
+ try {
95
+ if (audioContextRef.current && audioContextRef.current.state === 'running') return;
96
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
97
+ // @ts-ignore
98
+ const AudioContext = window.AudioContext || window.webkitAudioContext;
99
+ const ctx = new AudioContext();
100
+ const analyser = ctx.createAnalyser();
101
+ analyser.fftSize = 256;
102
+ const source = ctx.createMediaStreamSource(stream);
103
+ source.connect(analyser);
104
+
105
+ audioContextRef.current = ctx;
106
+ analyserRef.current = analyser;
107
+ sourceRef.current = source;
108
+ dataArrayRef.current = new Uint8Array(analyser.frequencyBinCount);
109
+ } catch (e) {
110
+ console.error("Mic error", e);
111
+ alert('无法访问麦克风,请检查权限');
112
+ }
113
+ };
114
+
115
+ const stopAudio = () => {
116
+ if (sourceRef.current) sourceRef.current.disconnect();
117
+ if (audioContextRef.current) audioContextRef.current.close();
118
+ audioContextRef.current = null;
119
+ };
120
+
121
+ const saveConfig = async () => {
122
+ if (!homeroomClass) return;
123
+ try {
124
+ await api.games.saveZenConfig({
125
+ className: homeroomClass,
126
+ durationMinutes,
127
+ threshold,
128
+ passRate,
129
+ rewardConfig
130
+ });
131
+ } catch (e) { console.error('Auto-save config failed', e); }
132
+ };
133
+
134
+ const startGame = async () => {
135
+ saveConfig();
136
+ setIsConfigOpen(false);
137
+ setGameState('PLAYING');
138
+ setTimeLeft(durationMinutes * 60);
139
+ setQuietSeconds(0);
140
+ setTotalSeconds(0);
141
+ setScore(100);
142
+
143
+ await startAudio();
144
+ setIsPlaying(true);
145
+
146
+ lastTimeRef.current = performance.now();
147
+ if (reqRef.current) cancelAnimationFrame(reqRef.current);
148
+ reqRef.current = requestAnimationFrame(loop);
149
+ };
150
+
151
+ const endGame = () => {
152
+ setIsPlaying(false);
153
+ setGameState('FINISHED');
154
+ if(reqRef.current) cancelAnimationFrame(reqRef.current);
155
+ stopAudio();
156
+ };
157
+
158
+ const loop = (time: number) => {
159
+ reqRef.current = requestAnimationFrame(loop);
160
+
161
+ if (!isPlayingRef.current || gameStateRef.current !== 'PLAYING') {
162
+ lastTimeRef.current = time;
163
+ return;
164
+ }
165
+
166
+ const delta = (time - lastTimeRef.current) / 1000;
167
+ lastTimeRef.current = time;
168
+
169
+ // Audio Processing
170
+ if (analyserRef.current && dataArrayRef.current) {
171
+ analyserRef.current.getByteFrequencyData(dataArrayRef.current as any);
172
+ const avg = dataArrayRef.current.reduce((a,b)=>a+b) / dataArrayRef.current.length;
173
+ const vol = Math.min(100, Math.floor(avg / 2));
174
+ setCurrentVolume(vol);
175
+
176
+ // Game Logic
177
+ if (delta > 0) {
178
+ setTotalSeconds(prev => prev + delta);
179
+ if (vol < threshold) {
180
+ setQuietSeconds(prev => prev + delta);
181
+ }
182
+ }
183
+ }
184
+
185
+ setTimeLeft(prev => {
186
+ const next = prev - delta;
187
+ if (next <= 0) {
188
+ endGame();
189
+ return 0;
190
+ }
191
+ return next;
192
+ });
193
+ };
194
+
195
+ // Score Calculation
196
+ const calculatedScore = totalSeconds > 0 ? Math.round((quietSeconds / totalSeconds) * 100) : 100;
197
+ const isQuiet = currentVolume < threshold;
198
+
199
+ const handleBatchGrant = async () => {
200
+ const targets = students.filter(s => !excludedStudentIds.has(s._id || String(s.id)));
201
+ if (!rewardConfig.enabled || targets.length === 0) return;
202
+ if (!confirm(`确定为 ${targets.length} 名学生发放奖励吗?\n(已排除 ${excludedStudentIds.size} 名请假学生)`)) return;
203
+
204
+ try {
205
+ const promises = targets.map(s => {
206
+ if (rewardConfig.type === 'ACHIEVEMENT') {
207
+ return api.achievements.grant({
208
+ studentId: s._id || String(s.id),
209
+ achievementId: rewardConfig.val,
210
+ semester: '当前学期'
211
+ });
212
+ } else {
213
+ return api.games.grantReward({
214
+ studentId: s._id || String(s.id),
215
+ count: rewardConfig.count,
216
+ rewardType: rewardConfig.type,
217
+ name: rewardConfig.type === 'DRAW_COUNT' ? '禅道修行奖励' : rewardConfig.val
218
+ });
219
+ }
220
+ });
221
+ await Promise.all(promises);
222
+ alert('奖励发放成功!');
223
+ setIsConfigOpen(true);
224
+ setGameState('IDLE');
225
+ } catch (e) { alert('发放失败'); }
226
+ };
227
+
228
+ const containerStyle = isFullscreen ? {
229
+ position: 'fixed' as 'fixed',
230
+ top: 0,
231
+ left: 0,
232
+ width: '100vw',
233
+ height: '100vh',
234
+ zIndex: 999999,
235
+ backgroundColor: '#0f766e',
236
+ } : {};
237
+
238
+ // Zen Visuals
239
+ const monkY = isQuiet ? -20 : 0;
240
+ const monkScale = isQuiet ? 1.1 : 1;
241
+ const glowOpacity = isQuiet ? 0.6 : 0;
242
+
243
+ const GameContent = (
244
+ <div
245
+ className={`${isFullscreen ? '' : 'h-full w-full relative'} flex flex-col bg-teal-800 overflow-hidden select-none transition-all duration-300 font-sans`}
246
+ style={containerStyle}
247
+ >
248
+ {/* Background */}
249
+ <div className="absolute inset-0 bg-gradient-to-b from-teal-900 to-teal-700 opacity-90 pointer-events-none"></div>
250
+ <div className="absolute inset-0 flex items-center justify-center pointer-events-none opacity-10">
251
+ <div className={`w-[800px] h-[800px] border-[50px] rounded-full border-white transition-all duration-1000 ${isQuiet ? 'scale-100' : 'scale-90 border-red-500'}`}></div>
252
+ </div>
253
+
254
+ {/* HUD */}
255
+ <div className="absolute top-4 left-4 z-50 flex gap-3">
256
+ <div className="bg-black/30 border border-teal-300/30 rounded-xl p-3 min-w-[120px] text-center backdrop-blur-md">
257
+ <span className="text-[10px] text-teal-200 font-bold uppercase block">倒计时</span>
258
+ <span className="text-3xl font-mono font-black text-white">
259
+ {Math.floor(timeLeft / 60)}:{String(Math.floor(timeLeft % 60)).padStart(2, '0')}
260
+ </span>
261
+ </div>
262
+ <div className="bg-black/30 border border-teal-300/30 rounded-xl p-3 min-w-[120px] text-center backdrop-blur-md">
263
+ <span className="text-[10px] text-teal-200 font-bold uppercase block">专注评分</span>
264
+ <span className={`text-3xl font-mono font-black ${calculatedScore >= passRate ? 'text-green-400' : 'text-red-400'}`}>{calculatedScore}</span>
265
+ </div>
266
+ </div>
267
+
268
+ {/* Status Indicator */}
269
+ <div className="absolute top-4 right-4 z-50">
270
+ <div className={`px-4 py-2 rounded-full font-bold shadow-lg backdrop-blur-md transition-colors ${isQuiet ? 'bg-green-500/20 text-green-300 border border-green-500/50' : 'bg-red-500/20 text-red-300 border border-red-500/50'}`}>
271
+ {isQuiet ? '🌿 极静 · 专注' : '⚠️ 喧哗 · 浮躁'}
272
+ </div>
273
+ </div>
274
+
275
+ {/* Main Controls */}
276
+ <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">
277
+ <button
278
+ onClick={() => setIsConfigOpen(true)}
279
+ className="p-3 bg-white/10 hover:bg-white/20 text-white rounded-xl transition-all"
280
+ title="设置"
281
+ >
282
+ <Settings size={20}/>
283
+ </button>
284
+
285
+ {gameState === 'PLAYING' ? (
286
+ <button
287
+ onClick={endGame}
288
+ className="px-6 py-3 rounded-xl shadow-lg bg-red-600 hover:bg-red-500 text-white font-bold transition-all"
289
+ >
290
+ 提前结束
291
+ </button>
292
+ ) : (
293
+ <button
294
+ onClick={startGame}
295
+ 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"
296
+ >
297
+ <Play fill="currentColor" size={28}/>
298
+ </button>
299
+ )}
300
+
301
+ <button
302
+ onClick={() => setIsFullscreen(!isFullscreen)}
303
+ className="p-3 bg-white/10 hover:bg-white/20 text-white rounded-xl transition-all"
304
+ title="全屏"
305
+ >
306
+ {isFullscreen ? <Minimize size={20}/> : <Maximize size={20}/>}
307
+ </button>
308
+ </div>
309
+
310
+ {/* Visuals */}
311
+ <div className="flex-1 relative z-10 flex flex-col items-center justify-center pb-20">
312
+ {/* Aura */}
313
+ <div
314
+ className="absolute w-64 h-64 bg-yellow-200 rounded-full blur-[80px] transition-all duration-1000"
315
+ style={{ opacity: glowOpacity, transform: `scale(${isQuiet ? 1.5 : 0.5})` }}
316
+ ></div>
317
+
318
+ {/* Monk/Visual */}
319
+ <div
320
+ className="text-[150px] transition-all duration-1000 ease-in-out relative z-20 drop-shadow-2xl"
321
+ style={{ transform: `translateY(${monkY}px) scale(${monkScale})` }}
322
+ >
323
+ {isQuiet ? '🧘' : '😖'}
324
+ </div>
325
+
326
+ {/* Levitation Base */}
327
+ <div className={`w-40 h-10 bg-black/20 rounded-[100%] blur-md transition-all duration-1000 ${isQuiet ? 'scale-75 opacity-50' : 'scale-100 opacity-80'}`}></div>
328
+ </div>
329
+
330
+ {/* Config Modal */}
331
+ {isConfigOpen && (
332
+ <div className="absolute inset-0 bg-black/80 z-[999999] flex items-center justify-center p-4 backdrop-blur-md">
333
+ <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">
334
+ <div className="p-5 border-b border-slate-700 flex justify-between items-center bg-slate-900/50">
335
+ <h2 className="text-xl font-bold flex items-center"><Moon className="mr-2 text-teal-400"/> 禅道修行设置</h2>
336
+ </div>
337
+
338
+ <div className="p-6 space-y-6 overflow-y-auto flex-1 custom-scrollbar">
339
+ <div className="grid grid-cols-2 gap-6">
340
+ <div className="col-span-2 bg-slate-900 p-4 rounded-xl border border-slate-700">
341
+ <h4 className="text-xs text-teal-400 font-bold uppercase mb-4">麦克风校准 (Calibration)</h4>
342
+ <div className="flex items-center gap-4">
343
+ <div className="flex-1">
344
+ <div className="flex justify-between text-xs text-gray-400 mb-1">
345
+ <span>当前环境噪音: {currentVolume}</span>
346
+ <span>阈值设定: {threshold}</span>
347
+ </div>
348
+ <div className="w-full h-6 bg-gray-800 rounded-full overflow-hidden relative border border-gray-600">
349
+ <div className="absolute top-0 bottom-0 w-0.5 bg-yellow-500 z-10 shadow-[0_0_5px_yellow]" style={{left: `${threshold}%`}}></div>
350
+ <div
351
+ className={`h-full transition-all duration-75 ${currentVolume < threshold ? 'bg-green-500' : 'bg-red-500'}`}
352
+ style={{width: `${Math.min(currentVolume, 100)}%`}}
353
+ ></div>
354
+ </div>
355
+ <div className="mt-2">
356
+ <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))}/>
357
+ </div>
358
+ </div>
359
+ <button
360
+ onClick={() => { startAudio(); setThreshold(currentVolume + 5); }}
361
+ className="px-4 py-2 bg-slate-700 hover:bg-slate-600 rounded-lg text-xs font-bold whitespace-nowrap border border-slate-600"
362
+ >
363
+ <Volume2 size={14} className="inline mr-1"/> 自动设定
364
+ </button>
365
+ </div>
366
+ <p className="text-[10px] text-gray-500 mt-2">* 点击“自动设定”前请让教室保持您期望的安静状态,系统将自动设置合适的阈值。</p>
367
+ </div>
368
+
369
+ <div>
370
+ <label className="text-xs text-gray-400 font-bold uppercase block mb-1">修行时长 (分钟)</label>
371
+ <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))}/>
372
+ </div>
373
+
374
+ <div>
375
+ <label className="text-xs text-gray-400 font-bold uppercase block mb-1">及格评分 (%)</label>
376
+ <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))}/>
377
+ </div>
378
+ </div>
379
+
380
+ {/* Person Filter */}
381
+ <div className="bg-slate-900 p-4 rounded-xl border border-slate-700">
382
+ <div className="flex justify-between items-center mb-2">
383
+ <h4 className="text-xs text-gray-400 font-bold uppercase">参与人员</h4>
384
+ <button onClick={() => setIsFilterOpen(true)} className="text-xs text-blue-400 hover:text-blue-300 flex items-center">
385
+ <UserX size={14} className="mr-1"/> 排除请假学生 ({excludedStudentIds.size})
386
+ </button>
387
+ </div>
388
+ <p className="text-xs text-gray-500">共 {students.length - excludedStudentIds.size} 人参与奖励结算</p>
389
+ </div>
390
+
391
+ {/* Reward Config */}
392
+ <div className="bg-slate-900 p-4 rounded-xl border border-slate-700 space-y-3">
393
+ <div className="flex items-center justify-between">
394
+ <span className="text-sm font-bold flex items-center text-amber-400"><Gift size={16} className="mr-2"/> 达成奖励</span>
395
+ <label className="relative inline-flex items-center cursor-pointer">
396
+ <input type="checkbox" checked={rewardConfig.enabled} onChange={e=>setRewardConfig({...rewardConfig, enabled: e.target.checked})} className="sr-only peer"/>
397
+ <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>
398
+ </label>
399
+ </div>
400
+
401
+ {rewardConfig.enabled && (
402
+ <div className="space-y-3 animate-in slide-in-from-top-2">
403
+ <div className="flex gap-2">
404
+ {[
405
+ {id: 'DRAW_COUNT', label: '抽奖券', icon: <Gift size={14}/>},
406
+ {id: 'ITEM', label: '实物', icon: <Package size={14}/>},
407
+ {id: 'ACHIEVEMENT', label: '成就', icon: <Trophy size={14}/>}
408
+ ].map(t => (
409
+ <button
410
+ key={t.id}
411
+ onClick={() => setRewardConfig({...rewardConfig, type: t.id as any})}
412
+ 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'}`}
413
+ >
414
+ {t.icon} {t.label}
415
+ </button>
416
+ ))}
417
+ </div>
418
+
419
+ <div className="flex gap-2">
420
+ {rewardConfig.type === 'ACHIEVEMENT' ? (
421
+ <select
422
+ className="flex-1 bg-slate-800 border border-slate-600 rounded-lg p-2 text-xs text-white outline-none"
423
+ value={rewardConfig.val}
424
+ onChange={e=>setRewardConfig({...rewardConfig, val: e.target.value})}
425
+ >
426
+ <option value="">-- 选择成就 --</option>
427
+ {achConfig?.achievements.map(a => <option key={a.id} value={a.id}>{a.icon} {a.name}</option>)}
428
+ </select>
429
+ ) : rewardConfig.type === 'ITEM' ? (
430
+ <input
431
+ className="flex-1 bg-slate-800 border border-slate-600 rounded-lg p-2 text-xs text-white outline-none placeholder-gray-500"
432
+ placeholder="奖品名称"
433
+ value={rewardConfig.val}
434
+ onChange={e=>setRewardConfig({...rewardConfig, val: e.target.value})}
435
+ />
436
+ ) : (
437
+ <div className="flex-1 bg-slate-800 border border-slate-600 rounded-lg p-2 text-xs text-gray-400">
438
+ 自动发放系统抽奖券
439
+ </div>
440
+ )}
441
+ <div className="flex items-center bg-slate-800 border border-slate-600 rounded-lg px-2">
442
+ <span className="text-xs text-gray-500 mr-2">x</span>
443
+ <input
444
+ type="number"
445
+ min={1}
446
+ className="bg-transparent w-8 text-center text-white text-xs outline-none"
447
+ value={rewardConfig.count}
448
+ onChange={e=>setRewardConfig({...rewardConfig, count: Number(e.target.value)})}
449
+ />
450
+ </div>
451
+ </div>
452
+ </div>
453
+ )}
454
+ </div>
455
+ </div>
456
+
457
+ <div className="p-5 border-t border-slate-700 bg-slate-900/50 shrink-0">
458
+ <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">
459
+ <Play size={20} className="mr-2" fill="white"/> 开始修行
460
+ </button>
461
+ </div>
462
+ </div>
463
+ </div>
464
+ )}
465
+
466
+ {/* Student Filter Modal */}
467
+ {isFilterOpen && (
468
+ <div className="absolute inset-0 bg-black/80 z-[1000000] flex items-center justify-center p-4">
469
+ <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">
470
+ <div className="p-4 border-b flex justify-between items-center">
471
+ <h3 className="font-bold text-lg">排除请假/缺勤学生</h3>
472
+ <div className="flex gap-2">
473
+ <button onClick={() => setExcludedStudentIds(new Set())} className="text-xs text-blue-600 hover:underline">清空选择</button>
474
+ <button onClick={() => setIsFilterOpen(false)} className="px-4 py-1 bg-blue-600 text-white rounded text-sm">确定</button>
475
+ </div>
476
+ </div>
477
+ <div className="flex-1 overflow-y-auto p-4 bg-gray-50">
478
+ <div className="grid grid-cols-4 sm:grid-cols-5 md:grid-cols-6 gap-2">
479
+ {students.map(s => {
480
+ const isExcluded = excludedStudentIds.has(s._id || String(s.id));
481
+ return (
482
+ <div
483
+ key={s._id}
484
+ onClick={() => {
485
+ const newSet = new Set(excludedStudentIds);
486
+ if (isExcluded) newSet.delete(s._id || String(s.id));
487
+ else newSet.add(s._id || String(s.id));
488
+ setExcludedStudentIds(newSet);
489
+ }}
490
+ className={`p-2 rounded border cursor-pointer text-center text-sm transition-all select-none ${isExcluded ? 'bg-red-50 border-red-300 text-red-500 opacity-60' : 'bg-white border-gray-200 hover:border-blue-300'}`}
491
+ >
492
+ <div className="font-bold">{s.name}</div>
493
+ <div className="text-xs opacity-70">{s.seatNo}</div>
494
+ {isExcluded && <div className="text-[10px] font-bold mt-1">已排除</div>}
495
+ </div>
496
+ )
497
+ })}
498
+ </div>
499
+ </div>
500
+ </div>
501
+ </div>
502
+ )}
503
+
504
+ {/* Result Modal */}
505
+ {gameState === 'FINISHED' && !isConfigOpen && (
506
+ <div className="absolute inset-0 bg-black/90 z-[110] flex items-center justify-center p-4 backdrop-blur-lg animate-in zoom-in">
507
+ <div className="text-center w-full max-w-lg">
508
+ <div className="text-9xl mb-6 animate-bounce drop-shadow-[0_0_25px_rgba(255,255,255,0.5)]">
509
+ {calculatedScore >= passRate ? '🌸' : '🍂'}
510
+ </div>
511
+ <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'}`}>
512
+ {calculatedScore >= passRate ? '修行圆满' : '心浮气躁'}
513
+ </h2>
514
+ <p className="text-xl text-gray-300 mb-8">
515
+ 最终评分: <span className="font-mono font-bold text-white text-3xl">{calculatedScore}</span>
516
+ </p>
517
+
518
+ {calculatedScore >= passRate && rewardConfig.enabled && (
519
+ <div className="bg-white/10 p-6 rounded-2xl mb-10 border border-white/10 backdrop-blur-sm">
520
+ <p className="text-teal-200 font-bold mb-2 flex items-center justify-center"><Gift size={20} className="mr-2"/> 奖励已解锁</p>
521
+ <p className="text-sm text-gray-300 mb-4">
522
+ 为 <span className="font-bold text-white">{students.length - excludedStudentIds.size}</span> 名学生发放:
523
+ <span className="text-white font-bold mx-1 border-b border-white/30">
524
+ {rewardConfig.type==='ACHIEVEMENT' ? '成就奖状' : rewardConfig.type==='ITEM' ? rewardConfig.val : '抽奖券'}
525
+ </span>
526
+ x{rewardConfig.count}
527
+ </p>
528
+ <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">
529
+ 立即发放
530
+ </button>
531
+ </div>
532
+ )}
533
+
534
+ <div className="flex gap-4 justify-center mt-8">
535
+ <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">
536
+ <Settings size={18} className="mr-2"/> 再来一次
537
+ </button>
538
+ </div>
539
+ </div>
540
+ </div>
541
+ )}
542
+ </div>
543
+ );
544
+
545
+ if (isFullscreen) {
546
+ return createPortal(GameContent, document.body);
547
+ }
548
+ return GameContent;
549
+ };
pages/Games.tsx CHANGED
@@ -1,10 +1,11 @@
1
 
2
  import React, { useState } from 'react';
3
- import { Trophy, Star, Award, Zap, Mic } from 'lucide-react';
4
  import { GameMountain } from './GameMountain';
5
  import { GameLucky } from './GameLucky';
6
  import { GameRandom } from './GameRandom';
7
  import { GameMonster } from './GameMonster';
 
8
  import { GameRewards } from './GameRewards';
9
  import { AchievementTeacher } from './AchievementTeacher';
10
  import { AchievementStudent } from './AchievementStudent';
@@ -12,7 +13,7 @@ import { api } from '../services/api';
12
 
13
  export const Games: React.FC = () => {
14
  const [activeTab, setActiveTab] = useState<'games' | 'achievements' | 'rewards'>('games');
15
- const [activeGame, setActiveGame] = useState<'mountain' | 'lucky' | 'random' | 'monster'>('mountain');
16
 
17
  const currentUser = api.auth.getCurrentUser();
18
  const isStudent = currentUser?.role === 'STUDENT';
@@ -50,12 +51,16 @@ export const Games: React.FC = () => {
50
  <button onClick={() => setActiveGame('monster')} className={`px-4 py-1.5 rounded-xl text-sm font-bold transition-all whitespace-nowrap ${activeGame === 'monster' ? 'bg-purple-100 text-purple-700 border-2 border-purple-200 shadow-sm' : 'text-gray-500 hover:bg-white hover:shadow-sm border-2 border-transparent'}`}>
51
  <Mic size={14} className="inline mr-1"/> 早读战怪兽
52
  </button>
 
 
 
53
  </div>
54
 
55
  <div className="flex-1 bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden relative">
56
  {activeGame === 'mountain' ? <GameMountain /> :
57
  activeGame === 'lucky' ? <GameLucky /> :
58
- activeGame === 'random' ? <GameRandom /> : <GameMonster />}
 
59
  </div>
60
  </div>
61
  )}
@@ -70,4 +75,4 @@ export const Games: React.FC = () => {
70
  </div>
71
  </div>
72
  );
73
- };
 
1
 
2
  import React, { useState } from 'react';
3
+ import { Trophy, Star, Award, Zap, Mic, Moon } from 'lucide-react';
4
  import { GameMountain } from './GameMountain';
5
  import { GameLucky } from './GameLucky';
6
  import { GameRandom } from './GameRandom';
7
  import { GameMonster } from './GameMonster';
8
+ import { GameZen } from './GameZen';
9
  import { GameRewards } from './GameRewards';
10
  import { AchievementTeacher } from './AchievementTeacher';
11
  import { AchievementStudent } from './AchievementStudent';
 
13
 
14
  export const Games: React.FC = () => {
15
  const [activeTab, setActiveTab] = useState<'games' | 'achievements' | 'rewards'>('games');
16
+ const [activeGame, setActiveGame] = useState<'mountain' | 'lucky' | 'random' | 'monster' | 'zen'>('mountain');
17
 
18
  const currentUser = api.auth.getCurrentUser();
19
  const isStudent = currentUser?.role === 'STUDENT';
 
51
  <button onClick={() => setActiveGame('monster')} className={`px-4 py-1.5 rounded-xl text-sm font-bold transition-all whitespace-nowrap ${activeGame === 'monster' ? 'bg-purple-100 text-purple-700 border-2 border-purple-200 shadow-sm' : 'text-gray-500 hover:bg-white hover:shadow-sm border-2 border-transparent'}`}>
52
  <Mic size={14} className="inline mr-1"/> 早读战怪兽
53
  </button>
54
+ <button onClick={() => setActiveGame('zen')} className={`px-4 py-1.5 rounded-xl text-sm font-bold transition-all whitespace-nowrap ${activeGame === 'zen' ? 'bg-teal-100 text-teal-700 border-2 border-teal-200 shadow-sm' : 'text-gray-500 hover:bg-white hover:shadow-sm border-2 border-transparent'}`}>
55
+ <Moon size={14} className="inline mr-1"/> 禅道修行
56
+ </button>
57
  </div>
58
 
59
  <div className="flex-1 bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden relative">
60
  {activeGame === 'mountain' ? <GameMountain /> :
61
  activeGame === 'lucky' ? <GameLucky /> :
62
+ activeGame === 'random' ? <GameRandom /> :
63
+ activeGame === 'zen' ? <GameZen /> : <GameMonster />}
64
  </div>
65
  </div>
66
  )}
 
75
  </div>
76
  </div>
77
  );
78
+ };
server.js CHANGED
@@ -7,7 +7,7 @@ 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,
11
  AchievementConfigModel, StudentAchievementModel, AttendanceModel, LeaveRequestModel
12
  } = require('./models');
13
 
@@ -509,7 +509,7 @@ app.post('/api/games/lucky-draw', async (req, res) => {
509
  } catch (e) { res.status(500).json({ error: e.message }); }
510
  });
511
 
512
- // --- NEW MONSTER 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;
@@ -522,6 +522,18 @@ app.post('/api/games/monster-config', async (req, res) => {
522
  res.json({ success: true });
523
  });
524
 
 
 
 
 
 
 
 
 
 
 
 
 
525
  app.get('/api/notifications', async (req, res) => {
526
  const { role, userId } = req.query;
527
  const query = { $and: [ getQueryFilter(req), { $or: [ { targetRole: null, targetUserId: null }, { targetRole: role }, { targetUserId: userId } ] } ] };
@@ -573,7 +585,8 @@ app.delete('/api/schools/:id', async (req, res) => {
573
  await GameSessionModel.deleteMany({ schoolId });
574
  await StudentRewardModel.deleteMany({ schoolId });
575
  await LuckyDrawConfigModel.deleteMany({ schoolId });
576
- await GameMonsterConfigModel.deleteMany({ schoolId }); // Clean up monster config
 
577
  await AchievementConfigModel.deleteMany({ schoolId });
578
  await StudentAchievementModel.deleteMany({ schoolId });
579
  res.json({ success: true });
@@ -800,4 +813,4 @@ app.post('/api/batch-delete', async (req, res) => {
800
  });
801
 
802
  app.get('*', (req, res) => { res.sendFile(path.join(__dirname, 'dist', 'index.html')); });
803
- app.listen(PORT, () => console.log(`🚀 Server running on port ${PORT}`));
 
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
 
 
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;
 
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 } ] } ] };
 
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 });
 
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
@@ -207,6 +207,8 @@ export const api = {
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
  },
211
 
212
  achievements: {
 
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: {