dvc890 commited on
Commit
cd3f315
·
verified ·
1 Parent(s): 8113b31

Upload 29 files

Browse files
Files changed (5) hide show
  1. index.css +27 -0
  2. pages/Games.tsx +319 -123
  3. server.js +78 -3
  4. services/api.ts +2 -3
  5. types.ts +10 -2
index.css CHANGED
@@ -12,3 +12,30 @@ body {
12
  -moz-osx-font-smoothing: grayscale;
13
  background-color: #f9fafb;
14
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  -moz-osx-font-smoothing: grayscale;
13
  background-color: #f9fafb;
14
  }
15
+
16
+ /* Custom Scrollbar */
17
+ .custom-scrollbar::-webkit-scrollbar {
18
+ width: 6px;
19
+ height: 6px;
20
+ }
21
+ .custom-scrollbar::-webkit-scrollbar-track {
22
+ background: transparent;
23
+ }
24
+ .custom-scrollbar::-webkit-scrollbar-thumb {
25
+ background-color: rgba(156, 163, 175, 0.5);
26
+ border-radius: 3px;
27
+ }
28
+
29
+ /* Card Flip Animation */
30
+ .perspective-1000 {
31
+ perspective: 1000px;
32
+ }
33
+ .transform-style-3d {
34
+ transform-style: preserve-3d;
35
+ }
36
+ .backface-hidden {
37
+ backface-visibility: hidden;
38
+ }
39
+ .rotate-y-180 {
40
+ transform: rotateY(180deg);
41
+ }
pages/Games.tsx CHANGED
@@ -1,19 +1,40 @@
1
 
2
  import React, { useState, useEffect } from 'react';
3
  import { api } from '../services/api';
4
- import { GameSession, GameTeam, Student, StudentReward, LuckyDrawConfig } from '../types';
5
- import { Trophy, Gift, Lock, Settings, Plus, Minus, Users, RefreshCw, Star, ArrowRight, Loader2 } from 'lucide-react';
6
 
7
- // --- Mountain Game Sub-Components ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
 
9
  const MountainStage = ({ team, index, rewardsConfig, maxSteps }: { team: GameTeam, index: number, rewardsConfig: any[], maxSteps: number }) => {
10
  const percentage = Math.min(Math.max(team.score, 0), maxSteps) / maxSteps;
11
  const bottomPos = 5 + (percentage * 85);
12
 
13
  return (
14
- <div className="relative flex flex-col items-center justify-end h-[400px] w-48 mx-4 flex-shrink-0 select-none group">
15
- <div className="absolute -top-12 text-center w-[140%] z-20">
16
- <h3 className="text-lg font-black text-slate-800 bg-white/90 px-3 py-1 rounded-xl shadow-sm border border-white/60">
17
  {team.name}
18
  </h3>
19
  </div>
@@ -35,7 +56,7 @@ const MountainStage = ({ team, index, rewardsConfig, maxSteps }: { team: GameTea
35
  <div key={i} className="relative w-full h-full flex items-center justify-center">
36
  <div className="w-full h-1 bg-amber-700/50 rounded-sm"></div>
37
  {reward && (
38
- <div className={`absolute left-full ml-2 px-2 py-1 rounded text-[10px] font-bold whitespace-nowrap border ${isUnlocked ? 'bg-yellow-100 border-yellow-300 text-yellow-700' : 'bg-gray-100 border-gray-200 text-gray-400'}`}>
39
  {reward.rewardType === 'DRAW_COUNT' ? '🎲' : '🎁'} {reward.rewardName}
40
  </div>
41
  )}
@@ -46,8 +67,8 @@ const MountainStage = ({ team, index, rewardsConfig, maxSteps }: { team: GameTea
46
 
47
  {/* Climber */}
48
  <div className="absolute z-30 transition-all duration-700 ease-out flex flex-col items-center" style={{ bottom: `${bottomPos}%` }}>
49
- <div className="w-12 h-12 bg-white rounded-full border-4 shadow-lg flex items-center justify-center transform hover:scale-110 transition-transform" style={{ borderColor: team.color }}>
50
- <span className="text-2xl">{team.avatar || '🚩'}</span>
51
  <div className="absolute -top-2 -right-2 bg-red-500 text-white text-[10px] font-bold w-5 h-5 rounded-full flex items-center justify-center border border-white">
52
  {team.score}
53
  </div>
@@ -57,38 +78,6 @@ const MountainStage = ({ team, index, rewardsConfig, maxSteps }: { team: GameTea
57
  );
58
  };
59
 
60
- // --- Lucky Wheel Sub-Components ---
61
-
62
- const LuckyGrid = ({ prizes, onDraw, remaining, isSpinning }: { prizes: string[], onDraw: () => void, remaining: number, isSpinning: boolean }) => {
63
- return (
64
- <div className="relative w-full max-w-md mx-auto aspect-square bg-red-600 rounded-3xl p-4 shadow-2xl border-4 border-yellow-400">
65
- <div className="grid grid-cols-3 gap-2 h-full">
66
- {/* 8 items around, center is button */}
67
- {[0, 1, 2, 7, -1, 3, 6, 5, 4].map((idx, pos) => {
68
- if (idx === -1) {
69
- return (
70
- <button
71
- key="btn"
72
- onClick={onDraw}
73
- disabled={isSpinning || remaining <= 0}
74
- className="bg-yellow-400 hover:bg-yellow-300 active:scale-95 disabled:opacity-50 disabled:scale-100 transition-all rounded-xl flex flex-col items-center justify-center shadow-inner border-b-4 border-yellow-600"
75
- >
76
- <span className="text-2xl font-black text-red-700">抽奖</span>
77
- <span className="text-xs font-bold text-red-800">剩 {remaining} 次</span>
78
- </button>
79
- );
80
- }
81
- return (
82
- <div key={pos} className="bg-red-50 rounded-xl flex items-center justify-center text-center p-1 shadow-sm border border-red-100">
83
- <span className="text-sm font-bold text-red-800 break-words w-full">{prizes[idx] || '谢谢参与'}</span>
84
- </div>
85
- );
86
- })}
87
- </div>
88
- </div>
89
- );
90
- };
91
-
92
  // --- Main Page ---
93
 
94
  export const Games: React.FC = () => {
@@ -99,9 +88,15 @@ export const Games: React.FC = () => {
99
  const [studentInfo, setStudentInfo] = useState<Student | null>(null);
100
  const [loading, setLoading] = useState(true);
101
 
102
- // Teacher Controls
103
- const [isSettingsOpen, setIsSettingsOpen] = useState(false);
 
104
  const [students, setStudents] = useState<Student[]>([]);
 
 
 
 
 
105
 
106
  const currentUser = api.auth.getCurrentUser();
107
  const isTeacher = currentUser?.role === 'TEACHER';
@@ -115,7 +110,6 @@ export const Games: React.FC = () => {
115
  try {
116
  if (!currentUser) return;
117
 
118
- // Load context based on user
119
  let targetClass = '';
120
  if (isTeacher && currentUser.homeroomClass) targetClass = currentUser.homeroomClass;
121
  else if (currentUser.role === 'STUDENT') {
@@ -132,7 +126,7 @@ export const Games: React.FC = () => {
132
  const sess = await api.games.getMountainSession(targetClass);
133
  if (sess) setSession(sess);
134
  else if (isTeacher && currentUser?.schoolId) {
135
- // Init default session for teacher
136
  const newSess: GameSession = {
137
  schoolId: currentUser.schoolId,
138
  className: targetClass,
@@ -140,11 +134,11 @@ export const Games: React.FC = () => {
140
  isEnabled: true,
141
  maxSteps: 10,
142
  teams: [
143
- { id: '1', name: '探索队', score: 0, avatar: '🚀', color: '#ef4444', members: [] },
144
- { id: '2', name: '雄鹰队', score: 0, avatar: '🦅', color: '#3b82f6', members: [] }
145
  ],
146
  rewardsConfig: [
147
- { scoreThreshold: 5, rewardType: 'DRAW_COUNT', rewardName: '抽奖券', rewardValue: 1 },
148
  { scoreThreshold: 10, rewardType: 'ITEM', rewardName: '神秘大奖', rewardValue: 1 }
149
  ]
150
  };
@@ -156,7 +150,6 @@ export const Games: React.FC = () => {
156
  const lCfg = await api.games.getLuckyConfig();
157
  setLuckyConfig(lCfg);
158
 
159
- // Load Rewards if Student
160
  if (currentUser.role === 'STUDENT' && studentInfo && studentInfo._id) {
161
  const rews = await api.rewards.getMyRewards(studentInfo._id);
162
  setMyRewards(rews);
@@ -170,17 +163,17 @@ export const Games: React.FC = () => {
170
  finally { setLoading(false); }
171
  };
172
 
 
 
173
  const handleScoreChange = async (teamId: string, delta: number) => {
174
  if (!session || !isTeacher) return;
175
  const newTeams = session.teams.map(t => {
176
  if (t.id !== teamId) return t;
177
  const newScore = Math.max(0, Math.min(session.maxSteps, t.score + delta));
178
 
179
- // Check for Reward Trigger (Scaling up)
180
  if (delta > 0) {
181
  const reward = session.rewardsConfig.find(r => r.scoreThreshold === newScore);
182
  if (reward) {
183
- // Distribute Reward to all members
184
  t.members.forEach(stuId => {
185
  const stu = students.find(s => (s._id || s.id) == stuId);
186
  if (stu) {
@@ -206,81 +199,89 @@ export const Games: React.FC = () => {
206
  await api.games.saveMountainSession(newSession);
207
  };
208
 
209
- // Lucky Draw Logic
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
210
  const handleDraw = async () => {
211
- if (!studentInfo || !luckyConfig) return;
212
  if ((studentInfo.drawAttempts || 0) <= 0) return alert('抽奖次数不足!请通过“群岳争锋”游戏获取。');
213
 
214
- // 1. Consume Attempt
215
- await api.rewards.consumeDraw(studentInfo._id!);
216
- setStudentInfo({ ...studentInfo, drawAttempts: (studentInfo.drawAttempts || 0) - 1 });
217
-
218
- // 2. Random Logic
219
- const rand = Math.random();
220
- const prizeIndex = Math.floor(rand * luckyConfig.prizes.length);
221
- const prize = luckyConfig.prizes[prizeIndex] || luckyConfig.defaultPrize;
222
-
223
- // 3. Record Prize
224
- await api.rewards.addReward({
225
- schoolId: luckyConfig.schoolId,
226
- studentId: studentInfo._id!,
227
- studentName: studentInfo.name,
228
- rewardType: 'ITEM',
229
- name: prize,
230
- status: 'PENDING',
231
- source: '幸运大抽奖'
232
- });
233
 
234
- alert(`🎁 恭喜你抽中了:${prize}!请联系老师兑换。`);
235
- loadData(); // Reload rewards
 
236
  };
237
 
238
  if (loading) return <div className="p-10 text-center"><Loader2 className="animate-spin inline"/></div>;
239
 
240
- if (!isTeacher && !session?.isEnabled) {
241
- return <div className="p-10 text-center text-gray-400">老师尚未开启互动教学功能</div>;
242
- }
243
 
244
  return (
245
  <div className="space-y-6">
246
  {/* Game Switcher */}
247
  <div className="flex justify-center space-x-4 mb-6">
248
- <button
249
- onClick={() => setActiveGame('mountain')}
250
- className={`px-6 py-3 rounded-2xl font-bold flex items-center transition-all ${activeGame === 'mountain' ? 'bg-gradient-to-r from-green-500 to-emerald-600 text-white shadow-lg scale-105' : 'bg-white text-gray-500 hover:bg-gray-50'}`}
251
- >
252
  <Trophy className="mr-2"/> 群岳争锋
253
  </button>
254
- <button
255
- onClick={() => setActiveGame('lucky')}
256
- className={`px-6 py-3 rounded-2xl font-bold flex items-center transition-all ${activeGame === 'lucky' ? 'bg-gradient-to-r from-red-500 to-pink-600 text-white shadow-lg scale-105' : 'bg-white text-gray-500 hover:bg-gray-50'}`}
257
- >
258
  <Gift className="mr-2"/> 幸运红包
259
  </button>
260
  </div>
261
 
262
  {/* --- MOUNTAIN GAME --- */}
263
  {activeGame === 'mountain' && session && (
264
- <div className="animate-in fade-in">
265
  {isTeacher && (
266
  <div className="flex justify-end mb-4">
267
- <button onClick={() => setIsSettingsOpen(true)} className="flex items-center text-sm text-gray-600 bg-white px-3 py-1.5 rounded-lg border hover:bg-gray-50"><Settings size={16} className="mr-1"/> 游戏设置</button>
 
 
268
  </div>
269
  )}
270
 
271
  <div className="bg-gradient-to-b from-sky-200 to-white rounded-3xl p-8 overflow-x-auto min-h-[500px] border border-sky-100 shadow-inner relative">
272
- <div className="flex items-end min-w-max mx-auto justify-center gap-8 pb-10">
273
  {session.teams.map((team, idx) => (
274
  <div key={team.id} className="relative group">
275
- <MountainStage
276
- team={team}
277
- index={idx}
278
- rewardsConfig={session.rewardsConfig}
279
- maxSteps={session.maxSteps}
280
- />
281
- {/* Teacher Controls */}
282
  {isTeacher && (
283
- <div className="absolute -bottom-12 left-1/2 -translate-x-1/2 flex items-center bg-white rounded-full shadow-lg p-1 border border-gray-100 opacity-0 group-hover:opacity-100 transition-opacity">
284
  <button onClick={() => handleScoreChange(team.id, -1)} className="p-2 hover:bg-gray-100 rounded-full text-gray-500"><Minus size={16}/></button>
285
  <span className="w-8 text-center font-bold text-gray-700">{team.score}</span>
286
  <button onClick={() => handleScoreChange(team.id, 1)} className="p-2 hover:bg-blue-50 rounded-full text-blue-600"><Plus size={16}/></button>
@@ -295,37 +296,230 @@ export const Games: React.FC = () => {
295
 
296
  {/* --- LUCKY DRAW GAME --- */}
297
  {activeGame === 'lucky' && luckyConfig && (
298
- <div className="animate-in fade-in grid grid-cols-1 md:grid-cols-2 gap-8">
299
- <div className="flex flex-col items-center justify-center">
300
- <div className="bg-white p-6 rounded-2xl shadow-sm border w-full max-w-md mb-6 text-center">
301
- <h3 className="text-xl font-bold text-gray-800 mb-2">我的抽奖券</h3>
302
- <div className="text-4xl font-black text-amber-500 mb-2">{studentInfo?.drawAttempts || 0}</div>
303
- <p className="text-xs text-gray-400">每日上限 {luckyConfig.dailyLimit} 次</p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
304
  </div>
305
- <LuckyGrid
306
- prizes={luckyConfig.prizes}
307
- onDraw={handleDraw}
308
- remaining={studentInfo?.drawAttempts || 0}
309
- isSpinning={false}
310
- />
311
  </div>
312
-
313
- {/* Rewards List */}
314
- <div className="bg-white rounded-2xl shadow-sm border border-gray-100 p-6">
315
- <h3 className="font-bold text-gray-800 mb-4 flex items-center"><Star className="text-yellow-400 mr-2"/> 我的战利品</h3>
316
- <div className="space-y-3 max-h-[500px] overflow-y-auto">
317
- {myRewards.length > 0 ? myRewards.map(r => (
318
- <div key={r._id} className="flex justify-between items-center p-3 bg-gray-50 rounded-lg border border-gray-100">
319
- <div>
320
- <div className="font-bold text-gray-800">{r.name}</div>
321
- <div className="text-xs text-gray-400">{r.source}</div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
322
  </div>
323
- {r.status === 'REDEEMED'
324
- ? <span className="text-xs bg-gray-200 text-gray-500 px-2 py-1 rounded">已兑换</span>
325
- : <span className="text-xs bg-green-100 text-green-700 px-2 py-1 rounded cursor-pointer">未兑换</span>
326
- }
327
  </div>
328
- )) : <div className="text-center text-gray-400 py-10">暂无奖品,去登山吧!</div>}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
329
  </div>
330
  </div>
331
  </div>
@@ -333,3 +527,5 @@ export const Games: React.FC = () => {
333
  </div>
334
  );
335
  };
 
 
 
1
 
2
  import React, { useState, useEffect } from 'react';
3
  import { api } from '../services/api';
4
+ import { GameSession, GameTeam, Student, StudentReward, LuckyDrawConfig, LuckyPrize } from '../types';
5
+ import { Trophy, Gift, Lock, Settings, Plus, Minus, Users, RefreshCw, Star, ArrowRight, Loader2, Save, Trash2, X, AlertCircle } from 'lucide-react';
6
 
7
+ // --- Components ---
8
+
9
+ const FlipCard = ({ prize, onFlip, isRevealed }: { prize: string, onFlip: () => void, isRevealed: boolean }) => {
10
+ return (
11
+ <div className="relative w-full aspect-[3/4] cursor-pointer perspective-1000 group" onClick={!isRevealed ? onFlip : undefined}>
12
+ <div className={`relative w-full h-full text-center transition-transform duration-700 transform-style-3d shadow-xl rounded-xl ${isRevealed ? 'rotate-y-180' : 'group-hover:-translate-y-2'}`}>
13
+ {/* Front (Red Packet) */}
14
+ <div className="absolute w-full h-full backface-hidden bg-gradient-to-br from-red-500 to-red-700 rounded-xl flex flex-col items-center justify-center border-4 border-yellow-300 shadow-inner">
15
+ <div className="w-12 h-12 bg-yellow-200 rounded-full flex items-center justify-center mb-2 shadow-md border-2 border-yellow-400">
16
+ <span className="text-xl">🧧</span>
17
+ </div>
18
+ <span className="text-yellow-100 font-bold text-lg tracking-widest">開</span>
19
+ </div>
20
+ {/* Back (Prize) */}
21
+ <div className="absolute w-full h-full backface-hidden bg-white rounded-xl flex flex-col items-center justify-center border-2 border-red-200 rotate-y-180 shadow-inner p-2">
22
+ <span className="text-3xl mb-2">🎁</span>
23
+ <span className="text-red-600 font-bold text-sm break-words leading-tight">{prize}</span>
24
+ </div>
25
+ </div>
26
+ </div>
27
+ );
28
+ };
29
 
30
  const MountainStage = ({ team, index, rewardsConfig, maxSteps }: { team: GameTeam, index: number, rewardsConfig: any[], maxSteps: number }) => {
31
  const percentage = Math.min(Math.max(team.score, 0), maxSteps) / maxSteps;
32
  const bottomPos = 5 + (percentage * 85);
33
 
34
  return (
35
+ <div className="relative flex flex-col items-center justify-end h-[400px] w-32 md:w-48 mx-2 md:mx-4 flex-shrink-0 select-none group">
36
+ <div className="absolute -top-12 text-center w-[140%] z-20 transition-transform hover:-translate-y-1">
37
+ <h3 className="text-base md:text-lg font-black text-slate-800 bg-white/90 px-3 py-1 rounded-xl shadow-sm border border-white/60 truncate max-w-full">
38
  {team.name}
39
  </h3>
40
  </div>
 
56
  <div key={i} className="relative w-full h-full flex items-center justify-center">
57
  <div className="w-full h-1 bg-amber-700/50 rounded-sm"></div>
58
  {reward && (
59
+ <div className={`absolute left-full ml-2 px-2 py-1 rounded text-[10px] font-bold whitespace-nowrap border z-20 ${isUnlocked ? 'bg-yellow-100 border-yellow-300 text-yellow-700' : 'bg-gray-100 border-gray-200 text-gray-400'}`}>
60
  {reward.rewardType === 'DRAW_COUNT' ? '🎲' : '🎁'} {reward.rewardName}
61
  </div>
62
  )}
 
67
 
68
  {/* Climber */}
69
  <div className="absolute z-30 transition-all duration-700 ease-out flex flex-col items-center" style={{ bottom: `${bottomPos}%` }}>
70
+ <div className="w-10 h-10 md:w-12 md:h-12 bg-white rounded-full border-4 shadow-lg flex items-center justify-center transform hover:scale-110 transition-transform" style={{ borderColor: team.color }}>
71
+ <span className="text-xl md:text-2xl">{team.avatar || '🚩'}</span>
72
  <div className="absolute -top-2 -right-2 bg-red-500 text-white text-[10px] font-bold w-5 h-5 rounded-full flex items-center justify-center border border-white">
73
  {team.score}
74
  </div>
 
78
  );
79
  };
80
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
81
  // --- Main Page ---
82
 
83
  export const Games: React.FC = () => {
 
88
  const [studentInfo, setStudentInfo] = useState<Student | null>(null);
89
  const [loading, setLoading] = useState(true);
90
 
91
+ // Teacher Controls State
92
+ const [isMtSettingsOpen, setIsMtSettingsOpen] = useState(false);
93
+ const [isLuckySettingsOpen, setIsLuckySettingsOpen] = useState(false);
94
  const [students, setStudents] = useState<Student[]>([]);
95
+ const [selectedTeamId, setSelectedTeamId] = useState<string | null>(null); // For member management
96
+
97
+ // Lucky Draw State
98
+ const [drawResult, setDrawResult] = useState<{prize: string} | null>(null);
99
+ const [isFlipping, setIsFlipping] = useState(false);
100
 
101
  const currentUser = api.auth.getCurrentUser();
102
  const isTeacher = currentUser?.role === 'TEACHER';
 
110
  try {
111
  if (!currentUser) return;
112
 
 
113
  let targetClass = '';
114
  if (isTeacher && currentUser.homeroomClass) targetClass = currentUser.homeroomClass;
115
  else if (currentUser.role === 'STUDENT') {
 
126
  const sess = await api.games.getMountainSession(targetClass);
127
  if (sess) setSession(sess);
128
  else if (isTeacher && currentUser?.schoolId) {
129
+ // Init default session
130
  const newSess: GameSession = {
131
  schoolId: currentUser.schoolId,
132
  className: targetClass,
 
134
  isEnabled: true,
135
  maxSteps: 10,
136
  teams: [
137
+ { id: '1', name: '红队', score: 0, avatar: '🚀', color: '#ef4444', members: [] },
138
+ { id: '2', name: '蓝队', score: 0, avatar: '🦅', color: '#3b82f6', members: [] }
139
  ],
140
  rewardsConfig: [
141
+ { scoreThreshold: 5, rewardType: 'DRAW_COUNT', rewardName: '抽奖券x1', rewardValue: 1 },
142
  { scoreThreshold: 10, rewardType: 'ITEM', rewardName: '神秘大奖', rewardValue: 1 }
143
  ]
144
  };
 
150
  const lCfg = await api.games.getLuckyConfig();
151
  setLuckyConfig(lCfg);
152
 
 
153
  if (currentUser.role === 'STUDENT' && studentInfo && studentInfo._id) {
154
  const rews = await api.rewards.getMyRewards(studentInfo._id);
155
  setMyRewards(rews);
 
163
  finally { setLoading(false); }
164
  };
165
 
166
+ // --- Mountain Logic ---
167
+
168
  const handleScoreChange = async (teamId: string, delta: number) => {
169
  if (!session || !isTeacher) return;
170
  const newTeams = session.teams.map(t => {
171
  if (t.id !== teamId) return t;
172
  const newScore = Math.max(0, Math.min(session.maxSteps, t.score + delta));
173
 
 
174
  if (delta > 0) {
175
  const reward = session.rewardsConfig.find(r => r.scoreThreshold === newScore);
176
  if (reward) {
 
177
  t.members.forEach(stuId => {
178
  const stu = students.find(s => (s._id || s.id) == stuId);
179
  if (stu) {
 
199
  await api.games.saveMountainSession(newSession);
200
  };
201
 
202
+ const saveMountainSettings = async () => {
203
+ if (session) await api.games.saveMountainSession(session);
204
+ setIsMtSettingsOpen(false);
205
+ };
206
+
207
+ const toggleTeamMember = (studentId: string, teamId: string) => {
208
+ if (!session) return;
209
+ const newTeams = session.teams.map(t => {
210
+ const members = new Set(t.members);
211
+ if (t.id === teamId) {
212
+ // Add to target team
213
+ members.add(studentId);
214
+ } else {
215
+ // Remove from other teams (exclusive membership)
216
+ members.delete(studentId);
217
+ }
218
+ return { ...t, members: Array.from(members) };
219
+ });
220
+ setSession({ ...session, teams: newTeams });
221
+ };
222
+
223
+ // --- Lucky Draw Logic ---
224
+
225
  const handleDraw = async () => {
226
+ if (!studentInfo || !luckyConfig || isFlipping) return;
227
  if ((studentInfo.drawAttempts || 0) <= 0) return alert('抽奖次数不足!请通过“群岳争锋”游戏获取。');
228
 
229
+ setIsFlipping(true);
230
+ try {
231
+ const res = await api.games.drawLucky(studentInfo._id!);
232
+ setDrawResult(res);
233
+ setStudentInfo({ ...studentInfo, drawAttempts: (studentInfo.drawAttempts || 0) - 1 });
234
+ setTimeout(() => {
235
+ alert(`🎁 恭喜!你抽中了:${res.prize}`);
236
+ setIsFlipping(false);
237
+ setDrawResult(null);
238
+ loadData(); // Reload rewards list
239
+ }, 1500);
240
+ } catch (e) {
241
+ alert('抽奖失败,请稍后重试');
242
+ setIsFlipping(false);
243
+ }
244
+ };
 
 
 
245
 
246
+ const saveLuckySettings = async () => {
247
+ if (luckyConfig) await api.games.saveLuckyConfig(luckyConfig);
248
+ setIsLuckySettingsOpen(false);
249
  };
250
 
251
  if (loading) return <div className="p-10 text-center"><Loader2 className="animate-spin inline"/></div>;
252
 
253
+ if (!isTeacher && !session?.isEnabled) return <div className="p-10 text-center text-gray-400">老师尚未开启互动教学功能</div>;
 
 
254
 
255
  return (
256
  <div className="space-y-6">
257
  {/* Game Switcher */}
258
  <div className="flex justify-center space-x-4 mb-6">
259
+ <button onClick={() => setActiveGame('mountain')} className={`px-6 py-3 rounded-2xl font-bold flex items-center transition-all ${activeGame === 'mountain' ? 'bg-gradient-to-r from-green-500 to-emerald-600 text-white shadow-lg scale-105' : 'bg-white text-gray-500 hover:bg-gray-50'}`}>
 
 
 
260
  <Trophy className="mr-2"/> 群岳争锋
261
  </button>
262
+ <button onClick={() => setActiveGame('lucky')} className={`px-6 py-3 rounded-2xl font-bold flex items-center transition-all ${activeGame === 'lucky' ? 'bg-gradient-to-r from-red-500 to-pink-600 text-white shadow-lg scale-105' : 'bg-white text-gray-500 hover:bg-gray-50'}`}>
 
 
 
263
  <Gift className="mr-2"/> 幸运红包
264
  </button>
265
  </div>
266
 
267
  {/* --- MOUNTAIN GAME --- */}
268
  {activeGame === 'mountain' && session && (
269
+ <div className="animate-in fade-in relative">
270
  {isTeacher && (
271
  <div className="flex justify-end mb-4">
272
+ <button onClick={() => setIsMtSettingsOpen(true)} className="flex items-center text-sm font-bold text-blue-600 bg-blue-50 px-4 py-2 rounded-lg border border-blue-200 hover:bg-blue-100 shadow-sm z-10">
273
+ <Settings size={16} className="mr-2"/> 游戏设置 & 成员管理
274
+ </button>
275
  </div>
276
  )}
277
 
278
  <div className="bg-gradient-to-b from-sky-200 to-white rounded-3xl p-8 overflow-x-auto min-h-[500px] border border-sky-100 shadow-inner relative">
279
+ <div className="flex items-end min-w-max mx-auto justify-center gap-4 pb-10">
280
  {session.teams.map((team, idx) => (
281
  <div key={team.id} className="relative group">
282
+ <MountainStage team={team} index={idx} rewardsConfig={session.rewardsConfig} maxSteps={session.maxSteps} />
 
 
 
 
 
 
283
  {isTeacher && (
284
+ <div className="absolute -bottom-12 left-1/2 -translate-x-1/2 flex items-center bg-white rounded-full shadow-lg p-1 border border-gray-100 opacity-0 group-hover:opacity-100 transition-opacity z-20">
285
  <button onClick={() => handleScoreChange(team.id, -1)} className="p-2 hover:bg-gray-100 rounded-full text-gray-500"><Minus size={16}/></button>
286
  <span className="w-8 text-center font-bold text-gray-700">{team.score}</span>
287
  <button onClick={() => handleScoreChange(team.id, 1)} className="p-2 hover:bg-blue-50 rounded-full text-blue-600"><Plus size={16}/></button>
 
296
 
297
  {/* --- LUCKY DRAW GAME --- */}
298
  {activeGame === 'lucky' && luckyConfig && (
299
+ <div className="animate-in fade-in">
300
+ {isTeacher && (
301
+ <div className="flex justify-end mb-4">
302
+ <button onClick={() => setIsLuckySettingsOpen(true)} className="flex items-center text-sm font-bold text-blue-600 bg-blue-50 px-4 py-2 rounded-lg border border-blue-200 hover:bg-blue-100 shadow-sm">
303
+ <Settings size={16} className="mr-2"/> 奖池配置
304
+ </button>
305
+ </div>
306
+ )}
307
+
308
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-8">
309
+ <div className="flex flex-col items-center">
310
+ <div className="bg-white p-4 rounded-2xl shadow-sm border w-full max-w-md mb-6 text-center">
311
+ <h3 className="text-xl font-bold text-gray-800 mb-2">我的抽奖券</h3>
312
+ <div className="text-4xl font-black text-amber-500 mb-2">{studentInfo?.drawAttempts || 0}</div>
313
+ <p className="text-xs text-gray-400">每日上限 {luckyConfig.dailyLimit} 次 | 每次消耗 1 券</p>
314
+ </div>
315
+
316
+ {/* Grid Layout for Cards */}
317
+ <div className="grid grid-cols-3 gap-4 w-full max-w-md">
318
+ {Array.from({ length: 9 }).map((_, i) => (
319
+ <FlipCard
320
+ key={i}
321
+ prize={drawResult ? drawResult.prize : '???'}
322
+ onFlip={handleDraw}
323
+ isRevealed={!!drawResult}
324
+ />
325
+ ))}
326
+ </div>
327
+ </div>
328
+
329
+ {/* Rewards List */}
330
+ <div className="bg-white rounded-2xl shadow-sm border border-gray-100 p-6 h-fit">
331
+ <h3 className="font-bold text-gray-800 mb-4 flex items-center"><Star className="text-yellow-400 mr-2"/> 我的战利品</h3>
332
+ <div className="space-y-3 max-h-[500px] overflow-y-auto custom-scrollbar pr-2">
333
+ {myRewards.length > 0 ? myRewards.map(r => (
334
+ <div key={r._id} className="flex justify-between items-center p-3 bg-gray-50 rounded-lg border border-gray-100">
335
+ <div>
336
+ <div className="font-bold text-gray-800 text-sm">{r.name}</div>
337
+ <div className="text-xs text-gray-400">{new Date(r.createTime).toLocaleDateString()} · {r.source}</div>
338
+ </div>
339
+ {r.status === 'REDEEMED'
340
+ ? <span className="text-xs bg-gray-200 text-gray-500 px-2 py-1 rounded">已兑换</span>
341
+ : <span className="text-xs bg-green-100 text-green-700 px-2 py-1 rounded cursor-pointer border border-green-200">未兑换</span>
342
+ }
343
+ </div>
344
+ )) : <div className="text-center text-gray-400 py-10">暂无奖品,去登山吧!</div>}
345
+ </div>
346
  </div>
 
 
 
 
 
 
347
  </div>
348
+ </div>
349
+ )}
350
+
351
+ {/* --- MOUNTAIN SETTINGS MODAL --- */}
352
+ {isMtSettingsOpen && session && (
353
+ <div className="fixed inset-0 bg-black/60 z-[100] flex items-center justify-center p-4 backdrop-blur-sm">
354
+ <div className="bg-white rounded-2xl w-full max-w-4xl h-[85vh] flex flex-col shadow-2xl">
355
+ <div className="p-6 border-b border-gray-100 flex justify-between items-center">
356
+ <h3 className="text-xl font-bold text-gray-800">群岳争锋 - 游戏设置</h3>
357
+ <button onClick={() => setIsMtSettingsOpen(false)}><X size={24} className="text-gray-400 hover:text-gray-600"/></button>
358
+ </div>
359
+
360
+ <div className="flex-1 overflow-y-auto p-6 space-y-8">
361
+ {/* Basic Settings */}
362
+ <section>
363
+ <h4 className="font-bold text-gray-700 mb-3 flex items-center"><Settings size={16} className="mr-2"/> 基础参数</h4>
364
+ <div className="flex gap-4 items-center bg-gray-50 p-4 rounded-xl border border-gray-200">
365
+ <label className="text-sm font-medium">山峰总高度 (步数):</label>
366
+ <input type="number" className="border rounded px-2 py-1 w-20 text-center" value={session.maxSteps} onChange={e => setSession({...session, maxSteps: Number(e.target.value)})} min={5} max={50}/>
367
+ <div className="w-px h-6 bg-gray-300 mx-2"></div>
368
+ <label className="flex items-center text-sm cursor-pointer">
369
+ <input type="checkbox" checked={session.isEnabled} onChange={e => setSession({...session, isEnabled: e.target.checked})} className="mr-2"/> 启用游戏
370
+ </label>
371
+ </div>
372
+ </section>
373
+
374
+ {/* Team & Member Management */}
375
+ <section>
376
+ <div className="flex justify-between items-center mb-3">
377
+ <h4 className="font-bold text-gray-700 flex items-center"><Users size={16} className="mr-2"/> 队伍与成员管理</h4>
378
+ <button onClick={() => {
379
+ const newTeam: GameTeam = { id: Date.now().toString(), name: '新队伍', color: '#6366f1', avatar: '🚩', score: 0, members: [] };
380
+ setSession({ ...session, teams: [...session.teams, newTeam] });
381
+ }} className="text-xs bg-blue-100 text-blue-600 px-2 py-1 rounded hover:bg-blue-200">+ 新建队伍</button>
382
+ </div>
383
+
384
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
385
+ {/* Left: Team List */}
386
+ <div className="space-y-2 max-h-64 overflow-y-auto pr-2">
387
+ {session.teams.map(t => (
388
+ <div key={t.id}
389
+ className={`p-3 rounded-lg border cursor-pointer transition-all ${selectedTeamId === t.id ? 'border-blue-500 bg-blue-50 shadow-sm' : 'border-gray-200 hover:bg-gray-50'}`}
390
+ onClick={() => setSelectedTeamId(t.id)}
391
+ >
392
+ <div className="flex justify-between items-center mb-2">
393
+ <input value={t.name} onChange={e => {
394
+ const updated = session.teams.map(tm => tm.id === t.id ? {...tm, name: e.target.value} : tm);
395
+ setSession({...session, teams: updated});
396
+ }} className="bg-transparent font-bold text-sm w-24 outline-none border-b border-transparent focus:border-blue-300" />
397
+ <button onClick={(e) => {
398
+ e.stopPropagation();
399
+ if(confirm('删除队伍?')) setSession({...session, teams: session.teams.filter(tm => tm.id !== t.id)});
400
+ }} className="text-gray-300 hover:text-red-500"><Trash2 size={14}/></button>
401
+ </div>
402
+ <div className="flex gap-2">
403
+ <input type="color" value={t.color} onChange={e => {
404
+ const updated = session.teams.map(tm => tm.id === t.id ? {...tm, color: e.target.value} : tm);
405
+ setSession({...session, teams: updated});
406
+ }} className="w-6 h-6 p-0 border-0 rounded overflow-hidden" />
407
+ <input value={t.avatar} onChange={e => {
408
+ const updated = session.teams.map(tm => tm.id === t.id ? {...tm, avatar: e.target.value} : tm);
409
+ setSession({...session, teams: updated});
410
+ }} className="w-8 border rounded text-center text-sm" />
411
+ <span className="text-xs text-gray-400 self-center ml-auto">{t.members.length} 人</span>
412
+ </div>
413
+ </div>
414
+ ))}
415
+ </div>
416
+
417
+ {/* Right: Member Shuttle */}
418
+ <div className="col-span-2 bg-gray-50 rounded-xl border border-gray-200 p-4 flex flex-col h-80">
419
+ {selectedTeamId ? (
420
+ <>
421
+ <div className="text-sm font-bold text-gray-700 mb-2 border-b pb-2">
422
+ 配置 [{session.teams.find(t => t.id === selectedTeamId)?.name}] 的成员
423
+ </div>
424
+ <div className="flex-1 overflow-y-auto grid grid-cols-3 gap-2 content-start">
425
+ {students.map(s => {
426
+ const currentTeamId = session.teams.find(t => t.members.includes(s._id || String(s.id)))?.id;
427
+ const isInCurrent = currentTeamId === selectedTeamId;
428
+ const isInOther = currentTeamId && !isInCurrent;
429
+
430
+ return (
431
+ <div key={s._id}
432
+ onClick={() => toggleTeamMember(s._id || String(s.id), selectedTeamId)}
433
+ className={`text-xs p-2 rounded border cursor-pointer flex items-center justify-between ${
434
+ isInCurrent ? 'bg-green-100 border-green-300 text-green-800' :
435
+ isInOther ? 'bg-gray-100 border-gray-200 text-gray-400 opacity-60' : 'bg-white border-gray-300 hover:border-blue-400'
436
+ }`}
437
+ >
438
+ <span className="truncate">{s.name}</span>
439
+ {isInCurrent && <CheckIcon />}
440
+ </div>
441
+ );
442
+ })}
443
+ </div>
444
+ </>
445
+ ) : (
446
+ <div className="flex items-center justify-center h-full text-gray-400">请先在左侧选择一个队伍</div>
447
+ )}
448
  </div>
 
 
 
 
449
  </div>
450
+ </section>
451
+ </div>
452
+
453
+ <div className="p-4 border-t border-gray-100 bg-gray-50 rounded-b-2xl flex justify-end gap-2">
454
+ <button onClick={() => setIsMtSettingsOpen(false)} className="px-4 py-2 text-gray-600 hover:bg-gray-200 rounded-lg">取消</button>
455
+ <button onClick={saveMountainSettings} className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-bold">保存设置</button>
456
+ </div>
457
+ </div>
458
+ </div>
459
+ )}
460
+
461
+ {/* --- LUCKY SETTINGS MODAL --- */}
462
+ {isLuckySettingsOpen && luckyConfig && (
463
+ <div className="fixed inset-0 bg-black/60 z-[100] flex items-center justify-center p-4 backdrop-blur-sm">
464
+ <div className="bg-white rounded-2xl w-full max-w-2xl h-[80vh] flex flex-col shadow-2xl">
465
+ <div className="p-6 border-b border-gray-100 flex justify-between items-center">
466
+ <h3 className="text-xl font-bold text-gray-800">奖池配置</h3>
467
+ <button onClick={() => setIsLuckySettingsOpen(false)}><X size={24} className="text-gray-400 hover:text-gray-600"/></button>
468
+ </div>
469
+
470
+ <div className="flex-1 overflow-y-auto p-6">
471
+ <div className="mb-6 flex gap-4 bg-yellow-50 p-4 rounded-xl border border-yellow-100">
472
+ <div>
473
+ <label className="text-xs font-bold text-yellow-700 uppercase block mb-1">每日抽奖上限</label>
474
+ <input type="number" className="border rounded px-2 py-1 w-20 text-center" value={luckyConfig.dailyLimit} onChange={e => setLuckyConfig({...luckyConfig, dailyLimit: Number(e.target.value)})}/>
475
+ </div>
476
+ <div>
477
+ <label className="text-xs font-bold text-yellow-700 uppercase block mb-1">默认安慰奖</label>
478
+ <input className="border rounded px-2 py-1" value={luckyConfig.defaultPrize} onChange={e => setLuckyConfig({...luckyConfig, defaultPrize: e.target.value})}/>
479
+ </div>
480
+ </div>
481
+
482
+ <table className="w-full text-sm text-left">
483
+ <thead className="bg-gray-100 text-gray-500 uppercase text-xs">
484
+ <tr>
485
+ <th className="p-3 rounded-tl-lg">奖品名称</th>
486
+ <th className="p-3">概率 (%)</th>
487
+ <th className="p-3">库存</th>
488
+ <th className="p-3 rounded-tr-lg text-right">操作</th>
489
+ </tr>
490
+ </thead>
491
+ <tbody className="divide-y divide-gray-100">
492
+ {luckyConfig.prizes.map((p, idx) => (
493
+ <tr key={idx} className="group hover:bg-gray-50">
494
+ <td className="p-2"><input value={p.name} onChange={e => {
495
+ const np = [...luckyConfig.prizes]; np[idx].name = e.target.value; setLuckyConfig({...luckyConfig, prizes: np});
496
+ }} className="w-full border p-1 rounded"/></td>
497
+ <td className="p-2"><input type="number" value={p.probability} onChange={e => {
498
+ const np = [...luckyConfig.prizes]; np[idx].probability = Number(e.target.value); setLuckyConfig({...luckyConfig, prizes: np});
499
+ }} className="w-16 border p-1 rounded text-center"/></td>
500
+ <td className="p-2"><input type="number" value={p.count} onChange={e => {
501
+ const np = [...luckyConfig.prizes]; np[idx].count = Number(e.target.value); setLuckyConfig({...luckyConfig, prizes: np});
502
+ }} className="w-16 border p-1 rounded text-center"/></td>
503
+ <td className="p-2 text-right">
504
+ <button onClick={() => {
505
+ const np = luckyConfig.prizes.filter((_, i) => i !== idx);
506
+ setLuckyConfig({...luckyConfig, prizes: np});
507
+ }} className="text-gray-300 hover:text-red-500"><Trash2 size={16}/></button>
508
+ </td>
509
+ </tr>
510
+ ))}
511
+ </tbody>
512
+ </table>
513
+
514
+ <button onClick={() => {
515
+ const newPrize: LuckyPrize = { id: Date.now().toString(), name: '新奖品', probability: 10, count: 10 };
516
+ setLuckyConfig({...luckyConfig, prizes: [...luckyConfig.prizes, newPrize]});
517
+ }} className="mt-4 w-full py-2 border-2 border-dashed border-gray-300 rounded-lg text-gray-500 hover:border-blue-400 hover:text-blue-500 transition-colors">+ 添加奖品</button>
518
+ </div>
519
+
520
+ <div className="p-4 border-t border-gray-100 bg-gray-50 rounded-b-2xl flex justify-end gap-2">
521
+ <button onClick={() => setIsLuckySettingsOpen(false)} className="px-4 py-2 text-gray-600 hover:bg-gray-200 rounded-lg">取消</button>
522
+ <button onClick={saveLuckySettings} className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-bold">保存配置</button>
523
  </div>
524
  </div>
525
  </div>
 
527
  </div>
528
  );
529
  };
530
+
531
+ const CheckIcon = () => <svg className="w-4 h-4 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /></svg>;
server.js CHANGED
@@ -229,11 +229,18 @@ const StudentRewardSchema = new mongoose.Schema({
229
  });
230
  const StudentRewardModel = mongoose.model('StudentReward', StudentRewardSchema);
231
 
 
232
  const LuckyDrawConfigSchema = new mongoose.Schema({
233
  schoolId: String,
234
- prizes: [String],
 
 
 
 
 
 
235
  dailyLimit: { type: Number, default: 3 },
236
- defaultPrize: { type: String, default: '谢谢参与' }
237
  });
238
  const LuckyDrawConfigModel = mongoose.model('LuckyDrawConfig', LuckyDrawConfigSchema);
239
 
@@ -702,7 +709,7 @@ app.post('/api/games/mountain', async (req, res) => {
702
  app.get('/api/games/lucky-config', async (req, res) => {
703
  const filter = getQueryFilter(req);
704
  if (InMemoryDB.isFallback) return res.json(InMemoryDB.luckyConfig);
705
- res.json(await LuckyDrawConfigModel.findOne(filter) || { prizes: ['免作业券', '文具套装'], dailyLimit: 3 });
706
  });
707
  app.post('/api/games/lucky-config', async (req, res) => {
708
  const data = injectSchoolId(req, req.body);
@@ -711,6 +718,74 @@ app.post('/api/games/lucky-config', async (req, res) => {
711
  res.json({ success: true });
712
  });
713
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
714
  app.get('/api/rewards', async (req, res) => {
715
  const { studentId } = req.query;
716
  const filter = { ...getQueryFilter(req), studentId };
 
229
  });
230
  const StudentRewardModel = mongoose.model('StudentReward', StudentRewardSchema);
231
 
232
+ // Updated Lucky Draw Config Schema to support probability
233
  const LuckyDrawConfigSchema = new mongoose.Schema({
234
  schoolId: String,
235
+ prizes: [{
236
+ id: String,
237
+ name: String,
238
+ probability: Number, // 0-100
239
+ count: Number, // Inventory
240
+ icon: String
241
+ }],
242
  dailyLimit: { type: Number, default: 3 },
243
+ defaultPrize: { type: String, default: '再接再厉' }
244
  });
245
  const LuckyDrawConfigModel = mongoose.model('LuckyDrawConfig', LuckyDrawConfigSchema);
246
 
 
709
  app.get('/api/games/lucky-config', async (req, res) => {
710
  const filter = getQueryFilter(req);
711
  if (InMemoryDB.isFallback) return res.json(InMemoryDB.luckyConfig);
712
+ res.json(await LuckyDrawConfigModel.findOne(filter) || { prizes: [], dailyLimit: 3 });
713
  });
714
  app.post('/api/games/lucky-config', async (req, res) => {
715
  const data = injectSchoolId(req, req.body);
 
718
  res.json({ success: true });
719
  });
720
 
721
+ // Secure Lucky Draw Endpoint
722
+ app.post('/api/games/lucky-draw', async (req, res) => {
723
+ const { studentId } = req.body;
724
+ const schoolId = req.headers['x-school-id'];
725
+
726
+ try {
727
+ if (InMemoryDB.isFallback) return res.json({ prize: '模拟奖品' });
728
+
729
+ // 1. Get Student & Check Attempts
730
+ const student = await Student.findById(studentId);
731
+ if (!student || student.drawAttempts <= 0) return res.status(403).json({ error: '次数不足' });
732
+
733
+ // 2. Get Config
734
+ const config = await LuckyDrawConfigModel.findOne({ schoolId });
735
+ const prizes = config?.prizes || [];
736
+ const defaultPrize = config?.defaultPrize || '再接再厉';
737
+
738
+ // 3. Weighted Random Logic
739
+ let selectedPrize = defaultPrize;
740
+
741
+ const availablePrizes = prizes.filter(p => (p.count === undefined || p.count > 0));
742
+ const totalWeight = availablePrizes.reduce((sum, p) => sum + (p.probability || 0), 0);
743
+ const random = Math.random() * Math.max(100, totalWeight); // Normalized to 100 or total
744
+
745
+ let currentWeight = 0;
746
+ let matchedPrize = null;
747
+
748
+ for (const p of availablePrizes) {
749
+ currentWeight += p.probability || 0;
750
+ if (random <= currentWeight) {
751
+ matchedPrize = p;
752
+ break;
753
+ }
754
+ }
755
+
756
+ if (matchedPrize) {
757
+ selectedPrize = matchedPrize.name;
758
+ // Deduct inventory
759
+ if (matchedPrize.count !== undefined && matchedPrize.count > 0) {
760
+ await LuckyDrawConfigModel.updateOne(
761
+ { schoolId, "prizes.id": matchedPrize.id },
762
+ { $inc: { "prizes.$.count": -1 } }
763
+ );
764
+ }
765
+ }
766
+
767
+ // 4. Consume Attempt
768
+ await Student.findByIdAndUpdate(studentId, { $inc: { drawAttempts: -1 } });
769
+
770
+ // 5. Record Reward
771
+ await StudentRewardModel.create({
772
+ schoolId,
773
+ studentId,
774
+ studentName: student.name,
775
+ rewardType: 'ITEM',
776
+ name: selectedPrize,
777
+ status: 'PENDING',
778
+ source: '幸运大抽奖'
779
+ });
780
+
781
+ res.json({ prize: selectedPrize });
782
+
783
+ } catch (e) {
784
+ console.error(e);
785
+ res.status(500).json({ error: e.message });
786
+ }
787
+ });
788
+
789
  app.get('/api/rewards', async (req, res) => {
790
  const { studentId } = req.query;
791
  const filter = { ...getQueryFilter(req), studentId };
services/api.ts CHANGED
@@ -1,5 +1,3 @@
1
-
2
- /// <reference types="vite/client" />
3
  import { User, ClassInfo, SystemConfig, Subject, School, Schedule, GameSession, StudentReward, LuckyDrawConfig } from '../types';
4
 
5
  const getBaseUrl = () => {
@@ -170,6 +168,7 @@ export const api = {
170
  saveMountainSession: (data: GameSession) => request('/games/mountain', { method: 'POST', body: JSON.stringify(data) }),
171
  getLuckyConfig: () => request('/games/lucky-config'),
172
  saveLuckyConfig: (data: LuckyDrawConfig) => request('/games/lucky-config', { method: 'POST', body: JSON.stringify(data) }),
 
173
  },
174
  rewards: {
175
  getMyRewards: (studentId: string) => request(`/rewards?studentId=${studentId}`),
@@ -181,4 +180,4 @@ export const api = {
181
  batchDelete: (type: 'student' | 'score' | 'user', ids: string[]) => {
182
  return request('/batch-delete', { method: 'POST', body: JSON.stringify({ type, ids }) });
183
  }
184
- };
 
 
 
1
  import { User, ClassInfo, SystemConfig, Subject, School, Schedule, GameSession, StudentReward, LuckyDrawConfig } from '../types';
2
 
3
  const getBaseUrl = () => {
 
168
  saveMountainSession: (data: GameSession) => request('/games/mountain', { method: 'POST', body: JSON.stringify(data) }),
169
  getLuckyConfig: () => request('/games/lucky-config'),
170
  saveLuckyConfig: (data: LuckyDrawConfig) => request('/games/lucky-config', { method: 'POST', body: JSON.stringify(data) }),
171
+ drawLucky: (studentId: string) => request('/games/lucky-draw', { method: 'POST', body: JSON.stringify({ studentId }) }),
172
  },
173
  rewards: {
174
  getMyRewards: (studentId: string) => request(`/rewards?studentId=${studentId}`),
 
180
  batchDelete: (type: 'student' | 'score' | 'user', ids: string[]) => {
181
  return request('/batch-delete', { method: 'POST', body: JSON.stringify({ type, ids }) });
182
  }
183
+ };
types.ts CHANGED
@@ -201,10 +201,18 @@ export interface StudentReward {
201
  createTime: string;
202
  }
203
 
 
 
 
 
 
 
 
 
204
  export interface LuckyDrawConfig {
205
  _id?: string;
206
  schoolId: string;
207
- prizes: string[]; // List of prize names
208
  dailyLimit: number;
209
- defaultPrize: string; // "Thank you"
210
  }
 
201
  createTime: string;
202
  }
203
 
204
+ export interface LuckyPrize {
205
+ id: string;
206
+ name: string;
207
+ probability: number; // 0-100
208
+ count: number; // Inventory
209
+ icon?: string;
210
+ }
211
+
212
  export interface LuckyDrawConfig {
213
  _id?: string;
214
  schoolId: string;
215
+ prizes: LuckyPrize[];
216
  dailyLimit: number;
217
+ defaultPrize: string; // "再接再厉"
218
  }