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

Upload 31 files

Browse files
pages/Games.tsx CHANGED
@@ -2,7 +2,7 @@
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
 
@@ -10,14 +10,14 @@ const FlipCard = ({ prize, onFlip, isRevealed }: { prize: string, onFlip: () =>
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>
@@ -32,14 +32,14 @@ const MountainStage = ({ team, index, rewardsConfig, maxSteps }: { team: GameTea
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>
41
 
42
- {/* Mountain SVG Background */}
43
  <div className="absolute bottom-0 left-0 w-full h-full z-0 overflow-visible filter drop-shadow-md">
44
  <svg viewBox="0 0 200 500" preserveAspectRatio="none" className="w-full h-full">
45
  <path d="M100 20 L 190 500 L 10 500 Z" fill={index % 2 === 0 ? '#4ade80' : '#22c55e'} stroke="#15803d" strokeWidth="2" />
@@ -47,16 +47,16 @@ const MountainStage = ({ team, index, rewardsConfig, maxSteps }: { team: GameTea
47
  </svg>
48
  </div>
49
 
50
- {/* Ladder & Rewards */}
51
- <div className="absolute bottom-0 w-10 h-[90%] z-10 flex flex-col-reverse justify-between items-center py-4">
52
  {Array.from({ length: maxSteps + 1 }).map((_, i) => {
53
  const reward = rewardsConfig.find(r => r.scoreThreshold === i);
54
  const isUnlocked = team.score >= i;
55
  return (
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,9 +67,9 @@ const MountainStage = ({ team, index, rewardsConfig, maxSteps }: { team: GameTea
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>
75
  </div>
@@ -81,18 +81,23 @@ const MountainStage = ({ team, index, rewardsConfig, maxSteps }: { team: GameTea
81
  // --- Main Page ---
82
 
83
  export const Games: React.FC = () => {
 
84
  const [activeGame, setActiveGame] = useState<'mountain' | 'lucky'>('mountain');
 
85
  const [session, setSession] = useState<GameSession | null>(null);
86
  const [luckyConfig, setLuckyConfig] = useState<LuckyDrawConfig | null>(null);
87
  const [myRewards, setMyRewards] = useState<StudentReward[]>([]);
 
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);
@@ -100,25 +105,32 @@ export const Games: React.FC = () => {
100
 
101
  const currentUser = api.auth.getCurrentUser();
102
  const isTeacher = currentUser?.role === 'TEACHER';
 
103
 
104
  useEffect(() => {
105
  loadData();
106
- }, [activeGame]);
107
 
108
  const loadData = async () => {
109
  setLoading(true);
110
  try {
111
  if (!currentUser) return;
112
 
 
 
113
  let targetClass = '';
114
- if (isTeacher && currentUser.homeroomClass) targetClass = currentUser.homeroomClass;
115
- else if (currentUser.role === 'STUDENT') {
116
- const stus = await api.students.getAll();
 
 
117
  const me = stus.find((s: Student) => s.name === (currentUser.trueName || currentUser.username));
118
  if (me) {
119
  setStudentInfo(me);
120
  targetClass = me.className;
121
  }
 
 
122
  }
123
 
124
  if (targetClass) {
@@ -150,13 +162,22 @@ export const Games: React.FC = () => {
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);
156
- }
157
-
158
- if (isTeacher) {
159
- setStudents(await api.students.getAll());
 
 
 
 
 
 
 
 
160
  }
161
 
162
  } catch (e) { console.error(e); }
@@ -174,7 +195,9 @@ export const Games: React.FC = () => {
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) {
180
  api.rewards.addReward({
@@ -184,11 +207,11 @@ export const Games: React.FC = () => {
184
  rewardType: reward.rewardType as any,
185
  name: reward.rewardName,
186
  status: 'PENDING',
187
- source: `群岳争锋 - ${t.name} ${newScore}分奖励`
188
  });
189
  }
190
  });
191
- alert(`🎉 ${t.name} 达到 ${newScore} 分!已发放 [${reward.rewardName}] 给 ${t.members.length} 名组员!`);
192
  }
193
  }
194
  return { ...t, score: newScore };
@@ -208,12 +231,19 @@ export const Games: React.FC = () => {
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
  });
@@ -224,7 +254,7 @@ export const Games: React.FC = () => {
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 {
@@ -248,72 +278,158 @@ export const Games: React.FC = () => {
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>
288
- </div>
289
- )}
290
- </div>
291
- ))}
292
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
293
  </div>
294
  </div>
295
  )}
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
@@ -325,35 +441,17 @@ export const Games: React.FC = () => {
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
 
@@ -381,9 +479,9 @@ export const Games: React.FC = () => {
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'}`}
@@ -393,7 +491,7 @@ export const Games: React.FC = () => {
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)});
@@ -403,11 +501,11 @@ export const Games: React.FC = () => {
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>
@@ -415,11 +513,12 @@ export const Games: React.FC = () => {
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 => {
@@ -429,14 +528,14 @@ export const Games: React.FC = () => {
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
  })}
@@ -461,7 +560,7 @@ export const Games: React.FC = () => {
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>
@@ -524,8 +623,32 @@ export const Games: React.FC = () => {
524
  </div>
525
  </div>
526
  )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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>;
 
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, Settings, Plus, Minus, Users, CheckSquare, Loader2, Save, Trash2, X, Star, CreditCard } from 'lucide-react';
6
 
7
  // --- Components ---
8
 
 
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 */}
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 */}
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>
 
32
  const bottomPos = 5 + (percentage * 85);
33
 
34
  return (
35
+ <div className="relative flex flex-col items-center justify-end h-[350px] w-32 md:w-40 mx-2 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-sm font-black text-slate-800 bg-white/90 px-2 py-1 rounded-lg shadow-sm border border-white/60 truncate max-w-full">
38
  {team.name}
39
  </h3>
40
  </div>
41
 
42
+ {/* Mountain SVG */}
43
  <div className="absolute bottom-0 left-0 w-full h-full z-0 overflow-visible filter drop-shadow-md">
44
  <svg viewBox="0 0 200 500" preserveAspectRatio="none" className="w-full h-full">
45
  <path d="M100 20 L 190 500 L 10 500 Z" fill={index % 2 === 0 ? '#4ade80' : '#22c55e'} stroke="#15803d" strokeWidth="2" />
 
47
  </svg>
48
  </div>
49
 
50
+ {/* Ladder */}
51
+ <div className="absolute bottom-0 w-8 h-[90%] z-10 flex flex-col-reverse justify-between items-center py-4">
52
  {Array.from({ length: maxSteps + 1 }).map((_, i) => {
53
  const reward = rewardsConfig.find(r => r.scoreThreshold === i);
54
  const isUnlocked = team.score >= i;
55
  return (
56
  <div key={i} className="relative w-full h-full flex items-center justify-center">
57
+ <div className="w-full h-0.5 bg-amber-700/30 rounded-sm"></div>
58
  {reward && (
59
+ <div className={`absolute left-full ml-2 px-1.5 py-0.5 rounded text-[9px] 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 bg-white rounded-full border-2 shadow-lg flex items-center justify-center transform hover:scale-110 transition-transform" style={{ borderColor: team.color }}>
71
+ <span className="text-xl">{team.avatar || '🚩'}</span>
72
+ <div className="absolute -top-1 -right-1 bg-red-500 text-white text-[9px] font-bold w-4 h-4 rounded-full flex items-center justify-center border border-white">
73
  {team.score}
74
  </div>
75
  </div>
 
81
  // --- Main Page ---
82
 
83
  export const Games: React.FC = () => {
84
+ const [activeTab, setActiveTab] = useState<'games' | 'rewards'>('games');
85
  const [activeGame, setActiveGame] = useState<'mountain' | 'lucky'>('mountain');
86
+
87
  const [session, setSession] = useState<GameSession | null>(null);
88
  const [luckyConfig, setLuckyConfig] = useState<LuckyDrawConfig | null>(null);
89
  const [myRewards, setMyRewards] = useState<StudentReward[]>([]);
90
+ const [allRewards, setAllRewards] = useState<StudentReward[]>([]);
91
  const [studentInfo, setStudentInfo] = useState<Student | null>(null);
92
  const [loading, setLoading] = useState(true);
93
 
94
  // Teacher Controls State
95
  const [isMtSettingsOpen, setIsMtSettingsOpen] = useState(false);
96
  const [isLuckySettingsOpen, setIsLuckySettingsOpen] = useState(false);
97
+ const [isGrantModalOpen, setIsGrantModalOpen] = useState(false);
98
  const [students, setStudents] = useState<Student[]>([]);
99
  const [selectedTeamId, setSelectedTeamId] = useState<string | null>(null); // For member management
100
+ const [grantForm, setGrantForm] = useState({ studentId: '', count: 1 });
101
 
102
  // Lucky Draw State
103
  const [drawResult, setDrawResult] = useState<{prize: string} | null>(null);
 
105
 
106
  const currentUser = api.auth.getCurrentUser();
107
  const isTeacher = currentUser?.role === 'TEACHER';
108
+ const isStudent = currentUser?.role === 'STUDENT';
109
 
110
  useEffect(() => {
111
  loadData();
112
+ }, [activeGame, activeTab]);
113
 
114
  const loadData = async () => {
115
  setLoading(true);
116
  try {
117
  if (!currentUser) return;
118
 
119
+ const stus = await api.students.getAll();
120
+
121
  let targetClass = '';
122
+ if (isTeacher && currentUser.homeroomClass) {
123
+ targetClass = currentUser.homeroomClass;
124
+ // Filter students for Teacher View (Only their class)
125
+ setStudents(stus.filter((s: Student) => s.className === targetClass));
126
+ } else if (isStudent) {
127
  const me = stus.find((s: Student) => s.name === (currentUser.trueName || currentUser.username));
128
  if (me) {
129
  setStudentInfo(me);
130
  targetClass = me.className;
131
  }
132
+ } else if (currentUser.role === 'ADMIN') {
133
+ setStudents(stus); // Admin sees all
134
  }
135
 
136
  if (targetClass) {
 
162
  const lCfg = await api.games.getLuckyConfig();
163
  setLuckyConfig(lCfg);
164
 
165
+ // Load Rewards
166
+ if (isStudent && studentInfo && studentInfo._id) {
167
  const rews = await api.rewards.getMyRewards(studentInfo._id);
168
  setMyRewards(rews);
169
+ } else if (isTeacher || currentUser.role === 'ADMIN') {
170
+ // Teacher View: Get class rewards (Backend allows filtering or we filter here)
171
+ // Assuming api.rewards.getAll returns all for admin/teacher scope
172
+ const all = await api.rewards.getMyRewards(''); // Hack: empty returns all based on logic or need new endpoint
173
+ // Filter rewards for students in this class
174
+ if (isTeacher && currentUser.homeroomClass) {
175
+ // We need to fetch all rewards first then filter
176
+ // Better to use dedicated endpoint in real app
177
+ setAllRewards(all); // Temporary showing all for prototype or implementation detail
178
+ } else {
179
+ setAllRewards(all);
180
+ }
181
  }
182
 
183
  } catch (e) { console.error(e); }
 
195
  if (delta > 0) {
196
  const reward = session.rewardsConfig.find(r => r.scoreThreshold === newScore);
197
  if (reward) {
198
+ // Grant rewards
199
  t.members.forEach(stuId => {
200
+ // Find student detail from the full list loaded
201
  const stu = students.find(s => (s._id || s.id) == stuId);
202
  if (stu) {
203
  api.rewards.addReward({
 
207
  rewardType: reward.rewardType as any,
208
  name: reward.rewardName,
209
  status: 'PENDING',
210
+ source: `群岳争锋 - ${t.name} ${newScore}分`
211
  });
212
  }
213
  });
214
+ alert(`🎉 ${t.name} 达到 ${newScore} 分!已为组员发放 [${reward.rewardName}]!`);
215
  }
216
  }
217
  return { ...t, score: newScore };
 
231
  if (!session) return;
232
  const newTeams = session.teams.map(t => {
233
  const members = new Set(t.members);
234
+
235
  if (t.id === teamId) {
236
+ // Toggle Logic: If exists, remove. If not, add.
237
+ if (members.has(studentId)) {
238
+ members.delete(studentId);
239
+ } else {
240
+ members.add(studentId);
241
+ }
242
  } else {
243
+ // Ensure student is removed from other teams (Exclusive)
244
+ if (members.has(studentId)) {
245
+ members.delete(studentId);
246
+ }
247
  }
248
  return { ...t, members: Array.from(members) };
249
  });
 
254
 
255
  const handleDraw = async () => {
256
  if (!studentInfo || !luckyConfig || isFlipping) return;
257
+ if ((studentInfo.drawAttempts || 0) <= 0) return alert('抽奖次数不足!请通过“群岳争锋”游戏或老师奖励获取。');
258
 
259
  setIsFlipping(true);
260
  try {
 
278
  setIsLuckySettingsOpen(false);
279
  };
280
 
281
+ const handleGrantDraw = async () => {
282
+ if(!grantForm.studentId) return alert('请选择学生');
283
+ try {
284
+ await api.games.grantDrawCount(grantForm.studentId, grantForm.count);
285
+ setIsGrantModalOpen(false);
286
+ alert('发放成功');
287
+ loadData();
288
+ } catch(e) { alert('发放失败'); }
289
+ };
290
 
291
+ const handleRedeem = async (id: string) => {
292
+ if(confirm('确认标记为已核销/已兑换?')) {
293
+ await api.rewards.redeem(id);
294
+ loadData();
295
+ }
296
+ };
297
+
298
+ if (loading) return <div className="p-10 text-center"><Loader2 className="animate-spin inline"/></div>;
299
 
300
  return (
301
+ <div className="flex flex-col h-[calc(100vh-120px)]">
302
+ {/* Top Tabs */}
303
+ <div className="flex justify-center space-x-4 mb-4 shrink-0">
304
+ <button onClick={() => setActiveTab('games')} className={`px-6 py-2 rounded-full font-bold flex items-center transition-all ${activeTab === 'games' ? 'bg-blue-600 text-white shadow-md' : 'bg-white text-gray-500 hover:bg-gray-50'}`}>
305
+ <Trophy className="mr-2" size={18}/> 互动游戏
306
  </button>
307
+ <button onClick={() => setActiveTab('rewards')} className={`px-6 py-2 rounded-full font-bold flex items-center transition-all ${activeTab === 'rewards' ? 'bg-amber-500 text-white shadow-md' : 'bg-white text-gray-500 hover:bg-gray-50'}`}>
308
+ <Star className="mr-2" size={18}/> 奖励管理
309
  </button>
310
  </div>
311
 
312
+ {/* --- REWARDS MANAGEMENT TAB --- */}
313
+ {activeTab === 'rewards' && (
314
+ <div className="flex-1 overflow-y-auto bg-white rounded-2xl shadow-sm border border-gray-100 p-6 animate-in fade-in">
315
+ <div className="flex justify-between items-center mb-6">
316
+ <h3 className="text-xl font-bold text-gray-800">
317
+ {isStudent ? '我的战利品清单' : '班级奖励核销台'}
318
+ </h3>
319
+ {!isStudent && (
320
+ <button onClick={() => setIsGrantModalOpen(true)} className="flex items-center px-4 py-2 bg-amber-100 text-amber-700 rounded-lg font-bold hover:bg-amber-200">
321
+ <Gift size={18} className="mr-2"/> 手动发放抽奖券
322
  </button>
323
+ )}
324
+ </div>
325
+
326
+ <div className="overflow-x-auto">
327
+ <table className="w-full text-left">
328
+ <thead className="bg-gray-50 text-gray-500 text-xs uppercase">
329
+ <tr>
330
+ {!isStudent && <th className="p-4">学生姓名</th>}
331
+ <th className="p-4">奖品名称</th>
332
+ <th className="p-4">类型</th>
333
+ <th className="p-4">来源</th>
334
+ <th className="p-4">获得时间</th>
335
+ <th className="p-4">状态</th>
336
+ {!isStudent && <th className="p-4 text-right">操作</th>}
337
+ </tr>
338
+ </thead>
339
+ <tbody className="divide-y divide-gray-100">
340
+ {(isStudent ? myRewards : allRewards).map(r => (
341
+ <tr key={r._id} className="hover:bg-gray-50">
342
+ {!isStudent && <td className="p-4 font-bold text-gray-700">{r.studentName}</td>}
343
+ <td className="p-4 font-medium text-gray-800">{r.name}</td>
344
+ <td className="p-4"><span className={`text-xs px-2 py-1 rounded ${r.rewardType === 'DRAW_COUNT' ? 'bg-purple-100 text-purple-700' : 'bg-blue-100 text-blue-700'}`}>{r.rewardType==='DRAW_COUNT' ? '抽奖券' : '实物'}</span></td>
345
+ <td className="p-4 text-gray-500 text-sm">{r.source}</td>
346
+ <td className="p-4 text-gray-500 text-sm">{new Date(r.createTime).toLocaleDateString()}</td>
347
+ <td className="p-4">
348
+ {r.status === 'REDEEMED'
349
+ ? <span className="text-xs bg-gray-200 text-gray-500 px-2 py-1 rounded">已兑换</span>
350
+ : <span className="text-xs bg-green-100 text-green-700 px-2 py-1 rounded">未兑换</span>
351
+ }
352
+ </td>
353
+ {!isStudent && (
354
+ <td className="p-4 text-right">
355
+ {r.status !== 'REDEEMED' && (
356
+ <button onClick={() => handleRedeem(r._id!)} className="text-xs bg-green-600 text-white px-3 py-1.5 rounded hover:bg-green-700">核销</button>
357
+ )}
358
+ </td>
359
+ )}
360
+ </tr>
361
+ ))}
362
+ {(isStudent ? myRewards : allRewards).length === 0 && (
363
+ <tr><td colSpan={7} className="text-center py-10 text-gray-400">暂无记录</td></tr>
364
+ )}
365
+ </tbody>
366
+ </table>
367
  </div>
368
  </div>
369
  )}
370
 
371
+ {/* --- GAMES TAB --- */}
372
+ {activeTab === 'games' && (
373
+ <div className="flex-1 flex flex-col min-h-0 animate-in fade-in">
374
+ {/* Sub Switcher */}
375
+ <div className="flex justify-center space-x-2 mb-4 shrink-0">
376
+ <button onClick={() => setActiveGame('mountain')} className={`px-4 py-1.5 rounded-lg text-sm font-bold transition-all ${activeGame === 'mountain' ? 'bg-sky-100 text-sky-700 border border-sky-200' : 'text-gray-500 hover:bg-gray-100'}`}>
377
+ 群岳争锋
378
+ </button>
379
+ <button onClick={() => setActiveGame('lucky')} className={`px-4 py-1.5 rounded-lg text-sm font-bold transition-all ${activeGame === 'lucky' ? 'bg-pink-100 text-pink-700 border border-pink-200' : 'text-gray-500 hover:bg-gray-100'}`}>
380
+ 幸运红包
381
+ </button>
382
+ </div>
383
+
384
+ <div className="flex-1 bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden relative">
385
+ {/* Mountain Game */}
386
+ {activeGame === 'mountain' && session && (
387
+ <div className="h-full flex flex-col">
388
+ {isTeacher && (
389
+ <div className="absolute top-4 right-4 z-10">
390
+ <button onClick={() => setIsMtSettingsOpen(true)} className="flex items-center text-xs font-bold text-slate-600 bg-white/80 backdrop-blur px-3 py-1.5 rounded-lg border border-slate-200 hover:bg-slate-50 shadow-sm">
391
+ <Settings size={14} className="mr-1"/> 设置/管理
392
+ </button>
393
+ </div>
394
+ )}
395
+
396
+ <div className="flex-1 overflow-x-auto overflow-y-hidden bg-gradient-to-b from-sky-200 to-white relative">
397
+ <div className="h-full flex items-end min-w-max px-10 pb-10 gap-6 mx-auto">
398
+ {session.teams.map((team, idx) => (
399
+ <div key={team.id} className="relative group">
400
+ <MountainStage team={team} index={idx} rewardsConfig={session.rewardsConfig} maxSteps={session.maxSteps} />
401
+ {isTeacher && (
402
+ <div className="absolute -bottom-8 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">
403
+ <button onClick={() => handleScoreChange(team.id, -1)} className="p-1 hover:bg-gray-100 rounded-full text-gray-500"><Minus size={14}/></button>
404
+ <span className="w-6 text-center font-bold text-gray-700 text-xs">{team.score}</span>
405
+ <button onClick={() => handleScoreChange(team.id, 1)} className="p-1 hover:bg-blue-50 rounded-full text-blue-600"><Plus size={14}/></button>
406
+ </div>
407
+ )}
408
+ </div>
409
+ ))}
410
+ </div>
411
+ </div>
412
  </div>
413
  )}
414
 
415
+ {/* Lucky Game */}
416
+ {activeGame === 'lucky' && luckyConfig && (
417
+ <div className="h-full overflow-y-auto p-6 md:p-10 flex flex-col items-center">
418
+ {isTeacher && (
419
+ <div className="absolute top-4 right-4">
420
+ <button onClick={() => setIsLuckySettingsOpen(true)} className="flex items-center text-xs font-bold text-slate-600 bg-gray-100 px-3 py-1.5 rounded-lg border hover:bg-gray-200">
421
+ <Settings size={14} className="mr-1"/> 奖池配置
422
+ </button>
423
+ </div>
424
+ )}
425
+
426
+ <div className="bg-yellow-50 p-4 rounded-2xl shadow-sm border border-yellow-100 w-full max-w-md mb-8 text-center relative overflow-hidden">
427
+ <div className="absolute top-0 right-0 p-2 opacity-10"><Gift size={64}/></div>
428
+ <h3 className="text-xl font-bold text-yellow-800 mb-1">我的抽奖券</h3>
429
+ <div className="text-4xl font-black text-amber-500 mb-1">{studentInfo?.drawAttempts || 0}</div>
430
+ <p className="text-[10px] text-yellow-600 opacity-70">每日上限 {luckyConfig.dailyLimit} 次 | 每次消耗 1 张</p>
431
  </div>
432
 
 
433
  <div className="grid grid-cols-3 gap-4 w-full max-w-md">
434
  {Array.from({ length: 9 }).map((_, i) => (
435
  <FlipCard
 
441
  ))}
442
  </div>
443
  </div>
444
+ )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
445
  </div>
446
+ </div>
447
  )}
448
 
449
  {/* --- MOUNTAIN SETTINGS MODAL --- */}
450
  {isMtSettingsOpen && session && (
451
  <div className="fixed inset-0 bg-black/60 z-[100] flex items-center justify-center p-4 backdrop-blur-sm">
452
+ <div className="bg-white rounded-2xl w-full max-w-4xl h-[85vh] flex flex-col shadow-2xl animate-in zoom-in-95">
453
  <div className="p-6 border-b border-gray-100 flex justify-between items-center">
454
+ <h3 className="text-xl font-bold text-gray-800">群岳争锋 - 设置与管理</h3>
455
  <button onClick={() => setIsMtSettingsOpen(false)}><X size={24} className="text-gray-400 hover:text-gray-600"/></button>
456
  </div>
457
 
 
479
  }} className="text-xs bg-blue-100 text-blue-600 px-2 py-1 rounded hover:bg-blue-200">+ 新建队伍</button>
480
  </div>
481
 
482
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-6 h-96">
483
  {/* Left: Team List */}
484
+ <div className="space-y-2 overflow-y-auto pr-2 border rounded-lg p-2">
485
  {session.teams.map(t => (
486
  <div key={t.id}
487
  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'}`}
 
491
  <input value={t.name} onChange={e => {
492
  const updated = session.teams.map(tm => tm.id === t.id ? {...tm, name: e.target.value} : tm);
493
  setSession({...session, teams: updated});
494
+ }} className="bg-transparent font-bold text-sm w-24 outline-none border-b border-transparent focus:border-blue-300" onClick={e=>e.stopPropagation()}/>
495
  <button onClick={(e) => {
496
  e.stopPropagation();
497
  if(confirm('删除队伍?')) setSession({...session, teams: session.teams.filter(tm => tm.id !== t.id)});
 
501
  <input type="color" value={t.color} onChange={e => {
502
  const updated = session.teams.map(tm => tm.id === t.id ? {...tm, color: e.target.value} : tm);
503
  setSession({...session, teams: updated});
504
+ }} className="w-6 h-6 p-0 border-0 rounded overflow-hidden" onClick={e=>e.stopPropagation()}/>
505
  <input value={t.avatar} onChange={e => {
506
  const updated = session.teams.map(tm => tm.id === t.id ? {...tm, avatar: e.target.value} : tm);
507
  setSession({...session, teams: updated});
508
+ }} className="w-8 border rounded text-center text-sm" onClick={e=>e.stopPropagation()}/>
509
  <span className="text-xs text-gray-400 self-center ml-auto">{t.members.length} 人</span>
510
  </div>
511
  </div>
 
513
  </div>
514
 
515
  {/* Right: Member Shuttle */}
516
+ <div className="col-span-2 bg-gray-50 rounded-xl border border-gray-200 p-4 flex flex-col">
517
  {selectedTeamId ? (
518
  <>
519
+ <div className="text-sm font-bold text-gray-700 mb-2 border-b pb-2 flex justify-between">
520
+ <span>配置 [{session.teams.find(t => t.id === selectedTeamId)?.name}] 成员</span>
521
+ <span className="text-xs text-gray-400">点击勾选/取消</span>
522
  </div>
523
  <div className="flex-1 overflow-y-auto grid grid-cols-3 gap-2 content-start">
524
  {students.map(s => {
 
528
 
529
  return (
530
  <div key={s._id}
531
+ onClick={() => !isInOther && toggleTeamMember(s._id || String(s.id), selectedTeamId)}
532
+ className={`text-xs p-2 rounded border cursor-pointer flex items-center justify-between transition-colors ${
533
  isInCurrent ? 'bg-green-100 border-green-300 text-green-800' :
534
+ isInOther ? 'bg-gray-100 border-gray-200 text-gray-400 opacity-50 cursor-not-allowed' : 'bg-white border-gray-300 hover:border-blue-400'
535
  }`}
536
  >
537
  <span className="truncate">{s.name}</span>
538
+ {isInCurrent && <CheckSquare size={12}/>}
539
  </div>
540
  );
541
  })}
 
560
  {/* --- LUCKY SETTINGS MODAL --- */}
561
  {isLuckySettingsOpen && luckyConfig && (
562
  <div className="fixed inset-0 bg-black/60 z-[100] flex items-center justify-center p-4 backdrop-blur-sm">
563
+ <div className="bg-white rounded-2xl w-full max-w-2xl h-[80vh] flex flex-col shadow-2xl animate-in zoom-in-95">
564
  <div className="p-6 border-b border-gray-100 flex justify-between items-center">
565
  <h3 className="text-xl font-bold text-gray-800">奖池配置</h3>
566
  <button onClick={() => setIsLuckySettingsOpen(false)}><X size={24} className="text-gray-400 hover:text-gray-600"/></button>
 
623
  </div>
624
  </div>
625
  )}
626
+
627
+ {/* --- MANUAL GRANT MODAL --- */}
628
+ {isGrantModalOpen && (
629
+ <div className="fixed inset-0 bg-black/50 z-[100] flex items-center justify-center p-4">
630
+ <div className="bg-white rounded-xl p-6 w-full max-w-sm animate-in fade-in">
631
+ <h3 className="font-bold text-lg mb-4">手动发放抽奖券</h3>
632
+ <div className="space-y-4">
633
+ <div>
634
+ <label className="block text-sm font-medium text-gray-700">选择学生</label>
635
+ <select className="w-full border p-2 rounded mt-1" value={grantForm.studentId} onChange={e=>setGrantForm({...grantForm, studentId: e.target.value})}>
636
+ <option value="">-- 请选择 --</option>
637
+ {students.map(s => <option key={s._id} value={s._id}>{s.name} ({s.studentNo})</option>)}
638
+ </select>
639
+ </div>
640
+ <div>
641
+ <label className="block text-sm font-medium text-gray-700">数量</label>
642
+ <input type="number" min={1} className="w-full border p-2 rounded mt-1" value={grantForm.count} onChange={e=>setGrantForm({...grantForm, count: Number(e.target.value)})}/>
643
+ </div>
644
+ <div className="flex gap-2 pt-2">
645
+ <button onClick={handleGrantDraw} className="flex-1 bg-amber-500 text-white py-2 rounded font-bold">确认发放</button>
646
+ <button onClick={() => setIsGrantModalOpen(false)} className="flex-1 bg-gray-100 text-gray-600 py-2 rounded">取消</button>
647
+ </div>
648
+ </div>
649
+ </div>
650
+ </div>
651
+ )}
652
  </div>
653
  );
654
  };
 
 
pages/Login.tsx CHANGED
@@ -331,7 +331,6 @@ export const Login: React.FC<LoginProps> = ({ onLogin }) => {
331
  <button type="submit" disabled={loading} className="w-full py-3 bg-blue-600 text-white rounded-lg font-bold hover:bg-blue-700 transition-colors">
332
  {loading ? <Loader2 className="animate-spin inline"/> : '登 录'}
333
  </button>
334
- <div className="text-center text-xs text-gray-400 mt-4">演示账号: admin / admin</div>
335
  </form>
336
  ) : (
337
  <div className="min-h-[300px]">
 
331
  <button type="submit" disabled={loading} className="w-full py-3 bg-blue-600 text-white rounded-lg font-bold hover:bg-blue-700 transition-colors">
332
  {loading ? <Loader2 className="animate-spin inline"/> : '登 录'}
333
  </button>
 
334
  </form>
335
  ) : (
336
  <div className="min-h-[300px]">
pages/Reports.tsx CHANGED
@@ -1,386 +1,16 @@
1
 
2
- import React, { useState, useEffect } from 'react';
3
- import {
4
- BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer,
5
- LineChart, Line, AreaChart, Area, RadarChart, Radar, PolarGrid, PolarAngleAxis, PolarRadiusAxis
6
- } from 'recharts';
7
  import { api } from '../services/api';
8
- import { Loader2, Download, Filter, TrendingUp, Grid, BarChart2, User, PieChart as PieChartIcon, Lock } from 'lucide-react';
9
- import { Score, Student, ClassInfo, Subject, Exam } from '../types';
10
-
11
- const localSortGrades = (a: string, b: string) => {
12
- const order: Record<string, number> = { '一年级': 1, '二年级': 2, '三年级': 3, '四年级': 4, '五年级': 5, '六年级': 6, '初一': 7, '七年级': 7, '初二': 8, '八年级': 8, '初三': 9, '九年级': 9, '高一': 10, '高二': 11, '高三': 12 };
13
- return (order[a] || 99) - (order[b] || 99);
14
- };
15
 
16
  export const Reports: React.FC = () => {
17
- const [loading, setLoading] = useState(true);
18
  const currentUser = api.auth.getCurrentUser();
19
  const isStudent = currentUser?.role === 'STUDENT';
20
 
21
- // Force 'student' tab for Student Role
22
- const [activeTab, setActiveTab] = useState<'overview' | 'grade' | 'trend' | 'matrix' | 'student'>(isStudent ? 'student' : 'overview');
 
23
 
24
- const [scores, setScores] = useState<Score[]>([]);
25
- const [students, setStudents] = useState<Student[]>([]);
26
- const [classes, setClasses] = useState<ClassInfo[]>([]);
27
- const [subjects, setSubjects] = useState<Subject[]>([]);
28
- const [exams, setExams] = useState<Exam[]>([]);
29
-
30
- const [selectedGrade, setSelectedGrade] = useState<string>('六年级');
31
- const [selectedClass, setSelectedClass] = useState<string>('');
32
- const [selectedSubject, setSelectedSubject] = useState<string>('');
33
-
34
- const [gradeAnalysisData, setGradeAnalysisData] = useState<any[]>([]);
35
- const [trendData, setTrendData] = useState<any[]>([]);
36
- const [matrixData, setMatrixData] = useState<any[]>([]);
37
- const [overviewData, setOverviewData] = useState<any>({});
38
-
39
- const [selectedStudent, setSelectedStudent] = useState<Student | null>(null);
40
-
41
- useEffect(() => {
42
- const loadData = async () => {
43
- setLoading(true);
44
- try {
45
- const [scs, stus, cls, subs, exs] = await Promise.all([
46
- api.scores.getAll(),
47
- api.students.getAll(),
48
- api.classes.getAll(),
49
- api.subjects.getAll(),
50
- api.exams.getAll()
51
- ]);
52
- setScores(scs);
53
- setStudents(stus);
54
- setClasses(cls);
55
- setSubjects(subs);
56
- setExams(exs);
57
-
58
- if (cls.length > 0) setSelectedClass(cls[0].grade + cls[0].className);
59
- if (subs.length > 0) setSelectedSubject(subs[0].name);
60
-
61
- // If Student, auto select self
62
- if (isStudent && currentUser) {
63
- const me = stus.find((s: Student) => s.name === (currentUser.trueName || currentUser.username));
64
- if (me) {
65
- setSelectedClass(me.className);
66
- setSelectedStudent(me);
67
- }
68
- }
69
- } catch (e) {
70
- console.error(e);
71
- } finally {
72
- setLoading(false);
73
- }
74
- };
75
- loadData();
76
- }, []);
77
-
78
- useEffect(() => {
79
- if (scores.length === 0 || students.length === 0) return;
80
-
81
- // --- Overview ---
82
- if (!isStudent) {
83
- const normalScores = scores.filter(s => s.status === 'Normal');
84
- const totalAvg = normalScores.length ? normalScores.reduce((a,b)=>a+b.score,0)/normalScores.length : 0;
85
- const totalPass = normalScores.filter(s => s.score >= 60).length;
86
- const passRate = normalScores.length ? (totalPass / normalScores.length)*100 : 0;
87
-
88
- const uniqueGradesList = Array.from(new Set(classes.map(c => c.grade))).sort(localSortGrades);
89
- const ladderData = uniqueGradesList.map(g => {
90
- const gradeClasses = classes.filter(c => c.grade === g).map(c => c.grade+c.className);
91
- const gradeStus = students.filter(s => gradeClasses.includes(s.className)).map(s => s.studentNo);
92
- const gradeScores = normalScores.filter(s => gradeStus.includes(s.studentNo));
93
- const gAvg = gradeScores.length ? gradeScores.reduce((a,b)=>a+b.score,0)/gradeScores.length : 0;
94
- return { name: g, 平均分: Number(gAvg.toFixed(1)) };
95
- });
96
-
97
- const subDist = subjects.map(sub => {
98
- const subScores = normalScores.filter(s => s.courseName === sub.name);
99
- const sAvg = subScores.length ? subScores.reduce((a,b)=>a+b.score,0)/subScores.length : 0;
100
- return { name: sub.name, value: Number(sAvg.toFixed(1)), color: sub.color };
101
- });
102
-
103
- setOverviewData({
104
- totalStudents: students.length,
105
- avgScore: Number(totalAvg.toFixed(1)),
106
- passRate: Number(passRate.toFixed(1)),
107
- gradeLadder: ladderData,
108
- subjectDist: subDist
109
- });
110
-
111
- // --- Grade Analysis ---
112
- const gradeClasses = classes.filter(c => c.grade === selectedGrade);
113
- const gaData = gradeClasses.map(cls => {
114
- const fullClassName = cls.grade + cls.className;
115
- const classStudentIds = students.filter(s => s.className === fullClassName).map(s => s.studentNo);
116
- const classScores = scores.filter(s => classStudentIds.includes(s.studentNo) && s.status === 'Normal');
117
- const totalScore = classScores.reduce((sum, s) => sum + s.score, 0);
118
- const avg = classScores.length ? (totalScore / classScores.length) : 0;
119
- const passed = classScores.filter(s => s.score >= 60).length;
120
- const passRate = classScores.length ? (passed / classScores.length) * 100 : 0;
121
- const excellent = classScores.filter(s => {
122
- const sub = subjects.find(sub => sub.name === s.courseName);
123
- return s.score >= (sub?.excellenceThreshold || 90);
124
- }).length;
125
- const excellentRate = classScores.length ? (excellent / classScores.length) * 100 : 0;
126
- return { name: cls.className, fullName: fullClassName, 平均分: Number(avg.toFixed(1)), 及格率: Number(passRate.toFixed(1)), 优秀率: Number(excellentRate.toFixed(1)) };
127
- });
128
- setGradeAnalysisData(gaData);
129
- }
130
-
131
- // --- Trend Analysis ---
132
- if (selectedClass && selectedSubject && !isStudent) {
133
- const classStudentIds = students.filter(s => s.className === selectedClass).map(s => s.studentNo);
134
- const uniqueExamNames = Array.from(new Set(scores.map(s => s.examName || s.type)));
135
- uniqueExamNames.sort((a, b) => {
136
- const dateA = exams.find(e => e.name === a)?.date || '9999-99-99';
137
- const dateB = exams.find(e => e.name === b)?.date || '9999-99-99';
138
- return dateA.localeCompare(dateB);
139
- });
140
- const tData = uniqueExamNames.map(exam => {
141
- const examScores = scores.filter(s => (s.examName === exam || s.type === exam) && s.courseName === selectedSubject && s.status === 'Normal');
142
- const classExamScores = examScores.filter(s => classStudentIds.includes(s.studentNo));
143
- const classAvg = classExamScores.length ? classExamScores.reduce((a,b)=>a+b.score,0) / classExamScores.length : 0;
144
- const currentGrade = classes.find(c => c.grade + c.className === selectedClass)?.grade || '';
145
- const gradeStudentIds = students.filter(s => s.className.startsWith(currentGrade)).map(s => s.studentNo);
146
- const gradeExamScores = examScores.filter(s => gradeStudentIds.includes(s.studentNo));
147
- const gradeAvg = gradeExamScores.length ? gradeExamScores.reduce((a,b)=>a+b.score,0) / gradeExamScores.length : 0;
148
- return { name: exam, 班级平均: Number(classAvg.toFixed(1)), 年级平均: Number(gradeAvg.toFixed(1)) };
149
- });
150
- setTrendData(tData);
151
- }
152
- }, [scores, students, classes, subjects, exams, selectedGrade, selectedClass, selectedSubject]);
153
-
154
- const getStudentTrend = (studentNo: string) => {
155
- const stuScores = scores.filter(s => s.studentNo === studentNo && s.status === 'Normal');
156
- const uniqueExamNames = Array.from(new Set(stuScores.map(s => s.examName || s.type)));
157
- uniqueExamNames.sort((a, b) => {
158
- const dateA = exams.find(e => e.name === a)?.date || '9999-99-99';
159
- const dateB = exams.find(e => e.name === b)?.date || '9999-99-99';
160
- return dateA.localeCompare(dateB);
161
- });
162
- return uniqueExamNames.map(exam => {
163
- const s = stuScores.find(s => (s.examName || s.type) === exam);
164
- return { name: exam, score: s ? s.score : 0 };
165
- });
166
- };
167
-
168
- const getStudentRadar = (studentNo: string) => {
169
- const stuScores = scores.filter(s => s.studentNo === studentNo && s.status === 'Normal');
170
- return subjects.map(sub => {
171
- const subScores = stuScores.filter(s => s.courseName === sub.name);
172
- const avg = subScores.length ? subScores.reduce((a,b)=>a+b.score,0)/subScores.length : 0;
173
- return { subject: sub.name, score: Number(avg.toFixed(1)), fullMark: 100 };
174
- });
175
- };
176
-
177
- const uniqueGrades = Array.from(new Set(classes.map(c => c.grade))).sort(localSortGrades);
178
- const allClasses = classes.map(c => c.grade + c.className);
179
-
180
- // Student Focus List
181
- // If Student Role, only show SELF.
182
- const focusStudents = isStudent
183
- ? (selectedStudent ? [selectedStudent] : [])
184
- : students.filter(s => selectedClass ? s.className === selectedClass : true);
185
-
186
- if (loading) return <div className="flex justify-center py-20"><Loader2 className="animate-spin text-blue-600"/></div>;
187
-
188
- return (
189
- <div className="space-y-6">
190
- <div className="flex justify-between items-center">
191
- <div>
192
- <h2 className="text-xl font-bold text-gray-800">
193
- {isStudent ? '我的成绩分析' : '教务数据分析中心'}
194
- </h2>
195
- </div>
196
- </div>
197
-
198
- {/* Tabs - Hidden for Students */}
199
- {!isStudent && (
200
- <div className="flex space-x-1 bg-gray-100 p-1 rounded-xl w-full md:w-auto overflow-x-auto">
201
- {[
202
- { id: 'overview', label: '全校概览', icon: PieChartIcon },
203
- { id: 'grade', label: '年级横向分析', icon: BarChart2 },
204
- { id: 'trend', label: '教学成长轨迹', icon: TrendingUp },
205
- { id: 'matrix', label: '学科质量透视', icon: Grid },
206
- { id: 'student', label: '学生个像', icon: User },
207
- ].map(tab => (
208
- <button
209
- key={tab.id}
210
- onClick={() => setActiveTab(tab.id as any)}
211
- className={`flex items-center space-x-2 px-4 py-2 rounded-lg text-sm font-medium transition-all whitespace-nowrap ${
212
- activeTab === tab.id ? 'bg-white text-blue-600 shadow-sm' : 'text-gray-500 hover:text-gray-700'
213
- }`}
214
- >
215
- <tab.icon size={16}/>
216
- <span>{tab.label}</span>
217
- </button>
218
- ))}
219
- </div>
220
- )}
221
-
222
- <div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6 min-h-[500px]">
223
- {/* Filters */}
224
- {!isStudent && activeTab !== 'overview' && (
225
- <div className="flex flex-wrap gap-4 mb-8 items-center bg-gray-50 p-4 rounded-lg">
226
- <div className="flex items-center text-sm font-bold text-gray-500"><Filter size={16} className="mr-2"/> 筛选维度:</div>
227
- {(activeTab === 'grade' || activeTab === 'matrix') && (
228
- <select className="border border-gray-300 p-2 rounded text-sm text-gray-800" value={selectedGrade} onChange={e => setSelectedGrade(e.target.value)}>
229
- {uniqueGrades.map(g => <option key={g} value={g}>{g}</option>)}
230
- </select>
231
- )}
232
- {(activeTab === 'trend' || activeTab === 'student') && (
233
- <select className="border border-gray-300 p-2 rounded text-sm text-gray-800" value={selectedClass} onChange={e => setSelectedClass(e.target.value)}>
234
- {allClasses.map(c => <option key={c} value={c}>{c}</option>)}
235
- </select>
236
- )}
237
- {activeTab === 'trend' && (
238
- <select className="border border-gray-300 p-2 rounded text-sm text-gray-800" value={selectedSubject} onChange={e => setSelectedSubject(e.target.value)}>
239
- {subjects.map(s => <option key={s._id} value={s.name}>{s.name}</option>)}
240
- </select>
241
- )}
242
- </div>
243
- )}
244
-
245
- {/* Overview Tab */}
246
- {activeTab === 'overview' && !isStudent && (
247
- <div className="animate-in fade-in space-y-8">
248
- <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
249
- <div className="bg-blue-50 p-6 rounded-xl border border-blue-100">
250
- <p className="text-gray-500 text-sm font-medium mb-1">全校总人数</p>
251
- <h3 className="text-3xl font-bold text-blue-700">{overviewData.totalStudents}</h3>
252
- </div>
253
- <div className="bg-violet-50 p-6 rounded-xl border border-violet-100">
254
- <p className="text-gray-500 text-sm font-medium mb-1">全校综合平均分</p>
255
- <h3 className="text-3xl font-bold text-violet-700">{overviewData.avgScore}</h3>
256
- </div>
257
- <div className="bg-emerald-50 p-6 rounded-xl border border-emerald-100">
258
- <p className="text-gray-500 text-sm font-medium mb-1">综合及格率</p>
259
- <h3 className="text-3xl font-bold text-emerald-700">{overviewData.passRate}%</h3>
260
- </div>
261
- </div>
262
- <div className="bg-white p-4 rounded-lg border border-gray-100 shadow-sm">
263
- <h3 className="font-bold text-gray-800 mb-4 text-center">各年级平均分阶梯</h3>
264
- <div className="h-72">
265
- <ResponsiveContainer width="100%" height="100%">
266
- <AreaChart data={overviewData.gradeLadder}>
267
- <defs><linearGradient id="colorAvg" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="#8884d8" stopOpacity={0.8}/><stop offset="95%" stopColor="#8884d8" stopOpacity={0}/></linearGradient></defs>
268
- <XAxis dataKey="name" /><YAxis domain={[0, 100]}/>
269
- <CartesianGrid strokeDasharray="3 3" vertical={false} /><Tooltip />
270
- <Area type="monotone" dataKey="平均分" stroke="#8884d8" fillOpacity={1} fill="url(#colorAvg)" />
271
- </AreaChart>
272
- </ResponsiveContainer>
273
- </div>
274
- </div>
275
- </div>
276
- )}
277
-
278
- {/* Grade Tab */}
279
- {activeTab === 'grade' && !isStudent && (
280
- <div className="h-80">
281
- <ResponsiveContainer width="100%" height="100%">
282
- <BarChart data={gradeAnalysisData} layout="vertical">
283
- <CartesianGrid strokeDasharray="3 3" horizontal={true} vertical={false}/>
284
- <XAxis type="number" domain={[0, 100]} hide/>
285
- <YAxis dataKey="name" type="category" width={80}/>
286
- <Tooltip/>
287
- <Bar dataKey="平均分" fill="#3b82f6" radius={[0, 4, 4, 0]} barSize={20}/>
288
- </BarChart>
289
- </ResponsiveContainer>
290
- </div>
291
- )}
292
-
293
- {/* Student Focus / Student View */}
294
- {activeTab === 'student' && (
295
- <div className="animate-in fade-in">
296
- {isStudent && <div className="mb-6 bg-blue-50 text-blue-700 p-4 rounded-lg flex items-center"><Lock className="mr-2" size={16}/> 您正在查看自己的成绩分析档案。</div>}
297
-
298
- {isStudent && selectedStudent ? (
299
- // Expanded Single Student View for Student Role
300
- <div className="space-y-8">
301
- <div className="grid grid-cols-1 md:grid-cols-2 gap-8">
302
- <div className="bg-white border border-gray-100 rounded-xl p-4 shadow-sm">
303
- <h3 className="font-bold text-gray-800 mb-4 text-center">我的学科能力模型</h3>
304
- <div className="h-64">
305
- <ResponsiveContainer width="100%" height="100%">
306
- <RadarChart cx="50%" cy="50%" outerRadius="70%" data={getStudentRadar(selectedStudent.studentNo)}>
307
- <PolarGrid />
308
- <PolarAngleAxis dataKey="subject" />
309
- <PolarRadiusAxis angle={30} domain={[0, 100]} />
310
- <Radar name={selectedStudent.name} dataKey="score" stroke="#8884d8" fill="#8884d8" fillOpacity={0.6} />
311
- <Tooltip />
312
- </RadarChart>
313
- </ResponsiveContainer>
314
- </div>
315
- </div>
316
- <div className="bg-white border border-gray-100 rounded-xl p-4 shadow-sm">
317
- <h3 className="font-bold text-gray-800 mb-4 text-center">我的综合成绩走势</h3>
318
- <div className="h-64">
319
- <ResponsiveContainer width="100%" height="100%">
320
- <LineChart data={getStudentTrend(selectedStudent.studentNo)}>
321
- <CartesianGrid strokeDasharray="3 3" vertical={false}/>
322
- <XAxis dataKey="name" />
323
- <YAxis domain={[0, 100]}/>
324
- <Tooltip />
325
- <Line type="monotone" dataKey="score" stroke="#2563eb" strokeWidth={3} />
326
- </LineChart>
327
- </ResponsiveContainer>
328
- </div>
329
- </div>
330
- </div>
331
- </div>
332
- ) : (
333
- // List for Admin/Teacher
334
- <div className="grid grid-cols-1 md:grid-cols-3 xl:grid-cols-4 gap-4">
335
- {focusStudents.map((s: Student) => (
336
- <div
337
- key={s._id}
338
- onClick={() => setSelectedStudent(s)}
339
- className="bg-white border border-gray-200 p-4 rounded-xl cursor-pointer hover:shadow-lg hover:border-blue-400 transition-all group"
340
- >
341
- <div className="flex items-center space-x-3 mb-3">
342
- <div className={`w-10 h-10 rounded-full flex items-center justify-center font-bold text-white ${s.gender === 'Female' ? 'bg-pink-400' : 'bg-blue-400'}`}>
343
- {s.name[0]}
344
- </div>
345
- <div>
346
- <h4 className="font-bold text-gray-800 group-hover:text-blue-600">{s.name}</h4>
347
- <p className="text-xs text-gray-500 font-mono">{s.studentNo}</p>
348
- </div>
349
- </div>
350
- </div>
351
- ))}
352
- </div>
353
- )}
354
- </div>
355
- )}
356
- </div>
357
-
358
- {/* Admin/Teacher Modal for Detail */}
359
- {!isStudent && selectedStudent && (
360
- <div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4 backdrop-blur-sm animate-in fade-in">
361
- <div className="bg-white rounded-2xl p-6 w-full max-w-4xl max-h-[90vh] overflow-y-auto shadow-2xl">
362
- <div className="flex justify-between items-start mb-6">
363
- <div className="flex items-center space-x-4">
364
- <div className={`w-16 h-16 rounded-full flex items-center justify-center text-2xl font-bold text-white ${selectedStudent.gender === 'Female' ? 'bg-pink-500' : 'bg-blue-500'}`}>
365
- {selectedStudent.name[0]}
366
- </div>
367
- <div>
368
- <h2 className="text-2xl font-bold text-gray-900">{selectedStudent.name}</h2>
369
- <div className="flex items-center space-x-3 text-sm text-gray-500 mt-1">
370
- <span className="bg-gray-100 px-2 py-0.5 rounded text-gray-700">{selectedStudent.className}</span>
371
- </div>
372
- </div>
373
- </div>
374
- <button onClick={() => setSelectedStudent(null)} className="p-2 hover:bg-gray-100 rounded-full transition-colors"><Grid size={20}/></button>
375
- </div>
376
- {/* Re-use charts logic for modal */}
377
- <div className="grid grid-cols-1 md:grid-cols-2 gap-8">
378
- <div className="h-64"><ResponsiveContainer><RadarChart data={getStudentRadar(selectedStudent.studentNo)}><PolarGrid/><PolarAngleAxis dataKey="subject"/><PolarRadiusAxis angle={30} domain={[0,100]}/><Radar dataKey="score" stroke="#8884d8" fill="#8884d8" fillOpacity={0.6}/><Tooltip/></RadarChart></ResponsiveContainer></div>
379
- <div className="h-64"><ResponsiveContainer><LineChart data={getStudentTrend(selectedStudent.studentNo)}><CartesianGrid vertical={false}/><XAxis dataKey="name"/><YAxis domain={[0,100]}/><Tooltip/><Line type="monotone" dataKey="score" stroke="#2563eb" strokeWidth={3}/></LineChart></ResponsiveContainer></div>
380
- </div>
381
- </div>
382
- </div>
383
- )}
384
- </div>
385
- );
386
  };
 
1
 
2
+ import React from 'react';
 
 
 
 
3
  import { api } from '../services/api';
4
+ import { TeacherReports } from './TeacherReports';
5
+ import { StudentReports } from './StudentReports';
 
 
 
 
 
6
 
7
  export const Reports: React.FC = () => {
 
8
  const currentUser = api.auth.getCurrentUser();
9
  const isStudent = currentUser?.role === 'STUDENT';
10
 
11
+ if (isStudent) {
12
+ return <StudentReports />;
13
+ }
14
 
15
+ return <TeacherReports />;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
  };
pages/StudentReports.tsx ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import React, { useState, useEffect } from 'react';
3
+ import {
4
+ LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
5
+ RadarChart, Radar, PolarGrid, PolarAngleAxis, PolarRadiusAxis
6
+ } from 'recharts';
7
+ import { api } from '../services/api';
8
+ import { Loader2, Lock } from 'lucide-react';
9
+ import { Score, Student, Subject, Exam } from '../types';
10
+
11
+ export const StudentReports: React.FC = () => {
12
+ const [loading, setLoading] = useState(true);
13
+ const [student, setStudent] = useState<Student | null>(null);
14
+ const [scores, setScores] = useState<Score[]>([]);
15
+ const [subjects, setSubjects] = useState<Subject[]>([]);
16
+ const [exams, setExams] = useState<Exam[]>([]);
17
+
18
+ const currentUser = api.auth.getCurrentUser();
19
+
20
+ useEffect(() => {
21
+ const loadData = async () => {
22
+ setLoading(true);
23
+ try {
24
+ const [stus, scs, subs, exs] = await Promise.all([
25
+ api.students.getAll(),
26
+ api.scores.getAll(),
27
+ api.subjects.getAll(),
28
+ api.exams.getAll()
29
+ ]);
30
+ const me = stus.find((s: Student) => s.name === (currentUser?.trueName || currentUser?.username));
31
+ setStudent(me || null);
32
+ setScores(scs);
33
+ setSubjects(subs);
34
+ setExams(exs);
35
+ } catch (e) {
36
+ console.error(e);
37
+ } finally {
38
+ setLoading(false);
39
+ }
40
+ };
41
+ loadData();
42
+ }, []);
43
+
44
+ const getStudentRadar = () => {
45
+ if (!student) return [];
46
+ const stuScores = scores.filter(s => s.studentNo === student.studentNo && s.status === 'Normal');
47
+ return subjects.map(sub => {
48
+ const subScores = stuScores.filter(s => s.courseName === sub.name);
49
+ const avg = subScores.length ? subScores.reduce((a,b)=>a+b.score,0)/subScores.length : 0;
50
+ return { subject: sub.name, score: Number(avg.toFixed(1)), fullMark: 100 };
51
+ });
52
+ };
53
+
54
+ const getStudentTrend = () => {
55
+ if (!student) return [];
56
+ const stuScores = scores.filter(s => s.studentNo === student.studentNo && s.status === 'Normal');
57
+ const uniqueExamNames = Array.from(new Set(stuScores.map(s => s.examName || s.type)));
58
+ uniqueExamNames.sort((a, b) => {
59
+ const dateA = exams.find(e => e.name === a)?.date || '9999-99-99';
60
+ const dateB = exams.find(e => e.name === b)?.date || '9999-99-99';
61
+ return dateA.localeCompare(dateB);
62
+ });
63
+ return uniqueExamNames.map(exam => {
64
+ const s = stuScores.find(s => (s.examName || s.type) === exam);
65
+ return { name: exam, score: s ? s.score : 0 };
66
+ });
67
+ };
68
+
69
+ if (loading) return <div className="flex justify-center py-20"><Loader2 className="animate-spin text-blue-600"/></div>;
70
+ if (!student) return <div className="text-center py-20 text-gray-500">暂无学生档案信息,请联系老师关联账号。</div>;
71
+
72
+ return (
73
+ <div className="space-y-6">
74
+ <div className="bg-blue-600 rounded-xl p-6 text-white shadow-lg">
75
+ <h2 className="text-2xl font-bold mb-1">我的成绩分析</h2>
76
+ <p className="opacity-90 flex items-center"><Lock size={14} className="mr-1"/> 仅自己可见</p>
77
+ </div>
78
+
79
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
80
+ <div className="bg-white border border-gray-100 rounded-xl p-6 shadow-sm">
81
+ <h3 className="font-bold text-gray-800 mb-4 text-center">学科能力模型</h3>
82
+ <div className="h-72">
83
+ <ResponsiveContainer width="100%" height="100%">
84
+ <RadarChart cx="50%" cy="50%" outerRadius="70%" data={getStudentRadar()}>
85
+ <PolarGrid />
86
+ <PolarAngleAxis dataKey="subject" />
87
+ <PolarRadiusAxis angle={30} domain={[0, 100]} />
88
+ <Radar name={student.name} dataKey="score" stroke="#8884d8" fill="#8884d8" fillOpacity={0.6} />
89
+ <Tooltip />
90
+ </RadarChart>
91
+ </ResponsiveContainer>
92
+ </div>
93
+ </div>
94
+ <div className="bg-white border border-gray-100 rounded-xl p-6 shadow-sm">
95
+ <h3 className="font-bold text-gray-800 mb-4 text-center">综合成绩走势</h3>
96
+ <div className="h-72">
97
+ <ResponsiveContainer width="100%" height="100%">
98
+ <LineChart data={getStudentTrend()}>
99
+ <CartesianGrid strokeDasharray="3 3" vertical={false}/>
100
+ <XAxis dataKey="name" />
101
+ <YAxis domain={[0, 100]}/>
102
+ <Tooltip />
103
+ <Line type="monotone" dataKey="score" stroke="#2563eb" strokeWidth={3} activeDot={{r:6}} />
104
+ </LineChart>
105
+ </ResponsiveContainer>
106
+ </div>
107
+ </div>
108
+ </div>
109
+ </div>
110
+ );
111
+ };
pages/TeacherReports.tsx ADDED
@@ -0,0 +1,385 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import React, { useState, useEffect } from 'react';
3
+ import {
4
+ BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer,
5
+ LineChart, Line, AreaChart, Area, RadarChart, Radar, PolarGrid, PolarAngleAxis, PolarRadiusAxis, ScatterChart, Scatter, ZAxis
6
+ } from 'recharts';
7
+ import { api } from '../services/api';
8
+ import { Loader2, Filter, TrendingUp, Grid, BarChart2, User, PieChart as PieChartIcon } from 'lucide-react';
9
+ import { Score, Student, ClassInfo, Subject, Exam } from '../types';
10
+
11
+ const localSortGrades = (a: string, b: string) => {
12
+ const order: Record<string, number> = { '一年级': 1, '二年级': 2, '三年级': 3, '四年级': 4, '五年级': 5, '六年级': 6, '初一': 7, '七年级': 7, '初二': 8, '八年级': 8, '初三': 9, '九年级': 9, '高一': 10, '高二': 11, '高三': 12 };
13
+ return (order[a] || 99) - (order[b] || 99);
14
+ };
15
+
16
+ export const TeacherReports: React.FC = () => {
17
+ const [loading, setLoading] = useState(true);
18
+ const [activeTab, setActiveTab] = useState<'overview' | 'grade' | 'trend' | 'matrix' | 'student'>('overview');
19
+
20
+ const [scores, setScores] = useState<Score[]>([]);
21
+ const [students, setStudents] = useState<Student[]>([]);
22
+ const [classes, setClasses] = useState<ClassInfo[]>([]);
23
+ const [subjects, setSubjects] = useState<Subject[]>([]);
24
+ const [exams, setExams] = useState<Exam[]>([]);
25
+
26
+ const [selectedGrade, setSelectedGrade] = useState<string>('六年级');
27
+ const [selectedClass, setSelectedClass] = useState<string>('');
28
+ const [selectedSubject, setSelectedSubject] = useState<string>('');
29
+
30
+ const [gradeAnalysisData, setGradeAnalysisData] = useState<any[]>([]);
31
+ const [trendData, setTrendData] = useState<any[]>([]);
32
+ const [matrixData, setMatrixData] = useState<any[]>([]);
33
+ const [overviewData, setOverviewData] = useState<any>({});
34
+
35
+ const [selectedStudent, setSelectedStudent] = useState<Student | null>(null);
36
+
37
+ useEffect(() => {
38
+ const loadData = async () => {
39
+ setLoading(true);
40
+ try {
41
+ const [scs, stus, cls, subs, exs] = await Promise.all([
42
+ api.scores.getAll(),
43
+ api.students.getAll(),
44
+ api.classes.getAll(),
45
+ api.subjects.getAll(),
46
+ api.exams.getAll()
47
+ ]) as [Score[], Student[], ClassInfo[], Subject[], Exam[]];
48
+
49
+ setScores(scs);
50
+ setStudents(stus);
51
+ setClasses(cls);
52
+ setSubjects(subs);
53
+ setExams(exs);
54
+
55
+ if (cls.length > 0) setSelectedClass(cls[0].grade + cls[0].className);
56
+ if (subs.length > 0) setSelectedSubject(subs[0].name);
57
+
58
+ // Init view grade
59
+ const grades = Array.from(new Set(cls.map((c: ClassInfo) => c.grade))).sort(localSortGrades);
60
+ if (grades.length > 0) setSelectedGrade(grades[0]);
61
+
62
+ } catch (e) { console.error(e); }
63
+ finally { setLoading(false); }
64
+ };
65
+ loadData();
66
+ }, []);
67
+
68
+ useEffect(() => {
69
+ if (scores.length === 0 || students.length === 0) return;
70
+
71
+ // --- Overview Logic ---
72
+ const normalScores = scores.filter(s => s.status === 'Normal');
73
+ const totalAvg = normalScores.length ? normalScores.reduce((a,b)=>a+b.score,0)/normalScores.length : 0;
74
+ const totalPass = normalScores.filter(s => s.score >= 60).length;
75
+ const passRate = normalScores.length ? (totalPass / normalScores.length)*100 : 0;
76
+
77
+ const uniqueGradesList = Array.from(new Set(classes.map(c => c.grade))).sort(localSortGrades);
78
+ const ladderData = uniqueGradesList.map(g => {
79
+ const gradeClasses = classes.filter(c => c.grade === g).map(c => c.grade+c.className);
80
+ const gradeStus = students.filter(s => gradeClasses.includes(s.className)).map(s => s.studentNo);
81
+ const gradeScores = normalScores.filter(s => gradeStus.includes(s.studentNo));
82
+ const gAvg = gradeScores.length ? gradeScores.reduce((a,b)=>a+b.score,0)/gradeScores.length : 0;
83
+ return { name: g, 平均分: Number(gAvg.toFixed(1)) };
84
+ });
85
+
86
+ const subDist = subjects.map(sub => {
87
+ const subScores = normalScores.filter(s => s.courseName === sub.name);
88
+ const sAvg = subScores.length ? subScores.reduce((a,b)=>a+b.score,0)/subScores.length : 0;
89
+ return { name: sub.name, value: Number(sAvg.toFixed(1)), color: sub.color };
90
+ });
91
+
92
+ setOverviewData({
93
+ totalStudents: students.length,
94
+ avgScore: Number(totalAvg.toFixed(1)),
95
+ passRate: Number(passRate.toFixed(1)),
96
+ gradeLadder: ladderData,
97
+ subjectDist: subDist
98
+ });
99
+
100
+ // --- Grade Analysis ---
101
+ const gradeClasses = classes.filter(c => c.grade === selectedGrade);
102
+ const gaData = gradeClasses.map(cls => {
103
+ const fullClassName = cls.grade + cls.className;
104
+ const classStudentIds = students.filter(s => s.className === fullClassName).map(s => s.studentNo);
105
+ const classScores = scores.filter(s => classStudentIds.includes(s.studentNo) && s.status === 'Normal');
106
+ const totalScore = classScores.reduce((sum, s) => sum + s.score, 0);
107
+ const avg = classScores.length ? (totalScore / classScores.length) : 0;
108
+ const passed = classScores.filter(s => s.score >= 60).length;
109
+ const passRate = classScores.length ? (passed / classScores.length) * 100 : 0;
110
+ const excellent = classScores.filter(s => {
111
+ const sub = subjects.find(sub => sub.name === s.courseName);
112
+ return s.score >= (sub?.excellenceThreshold || 90);
113
+ }).length;
114
+ const excellentRate = classScores.length ? (excellent / classScores.length) * 100 : 0;
115
+ return { name: cls.className, fullName: fullClassName, 平均分: Number(avg.toFixed(1)), 及格率: Number(passRate.toFixed(1)), 优秀率: Number(excellentRate.toFixed(1)) };
116
+ });
117
+ setGradeAnalysisData(gaData);
118
+
119
+ // --- Trend Analysis ---
120
+ if (selectedClass && selectedSubject) {
121
+ const classStudentIds = students.filter(s => s.className === selectedClass).map(s => s.studentNo);
122
+ const uniqueExamNames = Array.from(new Set(scores.map(s => s.examName || s.type)));
123
+ uniqueExamNames.sort((a, b) => {
124
+ const dateA = exams.find(e => e.name === a)?.date || '9999-99-99';
125
+ const dateB = exams.find(e => e.name === b)?.date || '9999-99-99';
126
+ return dateA.localeCompare(dateB);
127
+ });
128
+ const tData = uniqueExamNames.map(exam => {
129
+ const examScores = scores.filter(s => (s.examName === exam || s.type === exam) && s.courseName === selectedSubject && s.status === 'Normal');
130
+ const classExamScores = examScores.filter(s => classStudentIds.includes(s.studentNo));
131
+ const classAvg = classExamScores.length ? classExamScores.reduce((a,b)=>a+b.score,0) / classExamScores.length : 0;
132
+ const currentGrade = classes.find(c => c.grade + c.className === selectedClass)?.grade || '';
133
+ const gradeStudentIds = students.filter(s => s.className.startsWith(currentGrade)).map(s => s.studentNo);
134
+ const gradeExamScores = examScores.filter(s => gradeStudentIds.includes(s.studentNo));
135
+ const gradeAvg = gradeExamScores.length ? gradeExamScores.reduce((a,b)=>a+b.score,0) / gradeExamScores.length : 0;
136
+ return { name: exam, 班级平均: Number(classAvg.toFixed(1)), 年级平均: Number(gradeAvg.toFixed(1)) };
137
+ });
138
+ setTrendData(tData);
139
+ }
140
+
141
+ // --- Matrix (Scatter) ---
142
+ // Show distribution of scores for selected Grade + Subject
143
+ if (selectedGrade) {
144
+ const relevantClasses = classes.filter(c => c.grade === selectedGrade).map(c => c.grade+c.className);
145
+ // We will average score per class per subject
146
+ // Just mocking a complex chart: Average Score vs Variance? Or Pass Rate vs Excellent Rate
147
+ const mData = relevantClasses.map(cName => {
148
+ const sIds = students.filter(s => s.className === cName).map(s => s.studentNo);
149
+ const cScores = normalScores.filter(s => sIds.includes(s.studentNo));
150
+ if (cScores.length === 0) return { name: cName, x: 0, y: 0, z: 0 };
151
+
152
+ const avg = cScores.reduce((a,b)=>a+b.score,0)/cScores.length;
153
+ const pass = cScores.filter(s=>s.score>=60).length / cScores.length * 100;
154
+ const excellent = cScores.filter(s=>s.score>=90).length / cScores.length * 100;
155
+
156
+ return { name: cName.replace(selectedGrade,''), x: Number(avg.toFixed(1)), y: Number(pass.toFixed(1)), z: Number(excellent.toFixed(1)) };
157
+ });
158
+ setMatrixData(mData);
159
+ }
160
+
161
+ }, [scores, students, classes, subjects, exams, selectedGrade, selectedClass, selectedSubject]);
162
+
163
+ const getStudentRadar = (studentNo: string) => {
164
+ const stuScores = scores.filter(s => s.studentNo === studentNo && s.status === 'Normal');
165
+ return subjects.map(sub => {
166
+ const subScores = stuScores.filter(s => s.courseName === sub.name);
167
+ const avg = subScores.length ? subScores.reduce((a,b)=>a+b.score,0)/subScores.length : 0;
168
+ return { subject: sub.name, score: Number(avg.toFixed(1)), fullMark: 100 };
169
+ });
170
+ };
171
+
172
+ const getStudentTrend = (studentNo: string) => {
173
+ const stuScores = scores.filter(s => s.studentNo === studentNo && s.status === 'Normal');
174
+ const uniqueExamNames = Array.from(new Set(stuScores.map(s => s.examName || s.type)));
175
+ uniqueExamNames.sort((a, b) => {
176
+ const dateA = exams.find(e => e.name === a)?.date || '9999-99-99';
177
+ const dateB = exams.find(e => e.name === b)?.date || '9999-99-99';
178
+ return dateA.localeCompare(dateB);
179
+ });
180
+ return uniqueExamNames.map(exam => {
181
+ const s = stuScores.find(s => (s.examName || s.type) === exam);
182
+ return { name: exam, score: s ? s.score : 0 };
183
+ });
184
+ };
185
+
186
+ const uniqueGrades = Array.from(new Set(classes.map(c => c.grade))).sort(localSortGrades);
187
+ const allClasses = classes.map(c => c.grade + c.className);
188
+
189
+ // Filter students for "Student Focus" tab
190
+ const focusStudents = students.filter(s => selectedClass ? s.className === selectedClass : true);
191
+
192
+ if (loading) return <div className="flex justify-center py-20"><Loader2 className="animate-spin text-blue-600"/></div>;
193
+
194
+ return (
195
+ <div className="space-y-6">
196
+ <div className="flex justify-between items-center">
197
+ <h2 className="text-xl font-bold text-gray-800">教务数据分析中心</h2>
198
+ </div>
199
+
200
+ <div className="flex space-x-1 bg-gray-100 p-1 rounded-xl w-full md:w-auto overflow-x-auto">
201
+ {[
202
+ { id: 'overview', label: '全校概览', icon: PieChartIcon },
203
+ { id: 'grade', label: '年级横向分析', icon: BarChart2 },
204
+ { id: 'trend', label: '教学成长轨迹', icon: TrendingUp },
205
+ { id: 'matrix', label: '学科质量透视', icon: Grid },
206
+ { id: 'student', label: '学生个像', icon: User },
207
+ ].map(tab => (
208
+ <button
209
+ key={tab.id}
210
+ onClick={() => setActiveTab(tab.id as any)}
211
+ className={`flex items-center space-x-2 px-4 py-2 rounded-lg text-sm font-medium transition-all whitespace-nowrap ${
212
+ activeTab === tab.id ? 'bg-white text-blue-600 shadow-sm' : 'text-gray-500 hover:text-gray-700'
213
+ }`}
214
+ >
215
+ <tab.icon size={16}/>
216
+ <span>{tab.label}</span>
217
+ </button>
218
+ ))}
219
+ </div>
220
+
221
+ <div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6 min-h-[500px]">
222
+ {/* Filters */}
223
+ {activeTab !== 'overview' && (
224
+ <div className="flex flex-wrap gap-4 mb-8 items-center bg-gray-50 p-4 rounded-lg">
225
+ <div className="flex items-center text-sm font-bold text-gray-500"><Filter size={16} className="mr-2"/> 筛选维度:</div>
226
+ {(activeTab === 'grade' || activeTab === 'matrix') && (
227
+ <select className="border border-gray-300 p-2 rounded text-sm text-gray-800" value={selectedGrade} onChange={e => setSelectedGrade(e.target.value)}>
228
+ {uniqueGrades.map(g => <option key={g} value={g}>{g}</option>)}
229
+ </select>
230
+ )}
231
+ {(activeTab === 'trend' || activeTab === 'student') && (
232
+ <select className="border border-gray-300 p-2 rounded text-sm text-gray-800" value={selectedClass} onChange={e => setSelectedClass(e.target.value)}>
233
+ <option value="">-- 选择班级 --</option>
234
+ {allClasses.map(c => <option key={c} value={c}>{c}</option>)}
235
+ </select>
236
+ )}
237
+ {activeTab === 'trend' && (
238
+ <select className="border border-gray-300 p-2 rounded text-sm text-gray-800" value={selectedSubject} onChange={e => setSelectedSubject(e.target.value)}>
239
+ {subjects.map(s => <option key={s._id} value={s.name}>{s.name}</option>)}
240
+ </select>
241
+ )}
242
+ </div>
243
+ )}
244
+
245
+ {/* 1. Overview */}
246
+ {activeTab === 'overview' && (
247
+ <div className="animate-in fade-in space-y-8">
248
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
249
+ <div className="bg-blue-50 p-6 rounded-xl border border-blue-100">
250
+ <p className="text-gray-500 text-sm font-medium mb-1">全校总人数</p>
251
+ <h3 className="text-3xl font-bold text-blue-700">{overviewData.totalStudents}</h3>
252
+ </div>
253
+ <div className="bg-violet-50 p-6 rounded-xl border border-violet-100">
254
+ <p className="text-gray-500 text-sm font-medium mb-1">全校综合平均分</p>
255
+ <h3 className="text-3xl font-bold text-violet-700">{overviewData.avgScore}</h3>
256
+ </div>
257
+ <div className="bg-emerald-50 p-6 rounded-xl border border-emerald-100">
258
+ <p className="text-gray-500 text-sm font-medium mb-1">综合及格率</p>
259
+ <h3 className="text-3xl font-bold text-emerald-700">{overviewData.passRate}%</h3>
260
+ </div>
261
+ </div>
262
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
263
+ <div className="bg-white p-4 rounded-lg border border-gray-100 shadow-sm">
264
+ <h3 className="font-bold text-gray-800 mb-4 text-center">各年级平均分阶梯</h3>
265
+ <div className="h-72">
266
+ <ResponsiveContainer width="100%" height="100%">
267
+ <AreaChart data={overviewData.gradeLadder}>
268
+ <defs><linearGradient id="colorAvg" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="#8884d8" stopOpacity={0.8}/><stop offset="95%" stopColor="#8884d8" stopOpacity={0}/></linearGradient></defs>
269
+ <XAxis dataKey="name" /><YAxis domain={[0, 100]}/>
270
+ <CartesianGrid strokeDasharray="3 3" vertical={false} /><Tooltip />
271
+ <Area type="monotone" dataKey="平均分" stroke="#8884d8" fillOpacity={1} fill="url(#colorAvg)" />
272
+ </AreaChart>
273
+ </ResponsiveContainer>
274
+ </div>
275
+ </div>
276
+ {/* Add more overview charts if needed */}
277
+ </div>
278
+ </div>
279
+ )}
280
+
281
+ {/* 2. Grade Analysis */}
282
+ {activeTab === 'grade' && (
283
+ <div className="h-96 animate-in fade-in">
284
+ <ResponsiveContainer width="100%" height="100%">
285
+ <BarChart data={gradeAnalysisData} layout="vertical" margin={{left: 40}}>
286
+ <CartesianGrid strokeDasharray="3 3" horizontal={true} vertical={false}/>
287
+ <XAxis type="number" domain={[0, 100]} />
288
+ <YAxis dataKey="name" type="category" width={80}/>
289
+ <Tooltip cursor={{fill: 'transparent'}}/>
290
+ <Legend />
291
+ <Bar dataKey="平均分" fill="#3b82f6" radius={[0, 4, 4, 0]} barSize={20} name="平均分"/>
292
+ <Bar dataKey="及格率" fill="#10b981" radius={[0, 4, 4, 0]} barSize={20} name="及格率%"/>
293
+ <Bar dataKey="优秀率" fill="#f59e0b" radius={[0, 4, 4, 0]} barSize={20} name="优秀率%"/>
294
+ </BarChart>
295
+ </ResponsiveContainer>
296
+ </div>
297
+ )}
298
+
299
+ {/* 3. Trend */}
300
+ {activeTab === 'trend' && (
301
+ <div className="h-96 animate-in fade-in">
302
+ {trendData.length > 0 ? (
303
+ <ResponsiveContainer width="100%" height="100%">
304
+ <LineChart data={trendData}>
305
+ <CartesianGrid strokeDasharray="3 3" vertical={false}/>
306
+ <XAxis dataKey="name" />
307
+ <YAxis domain={[0, 100]} />
308
+ <Tooltip />
309
+ <Legend />
310
+ <Line type="monotone" dataKey="班级平均" stroke="#2563eb" strokeWidth={3} activeDot={{ r: 8 }} />
311
+ <Line type="monotone" dataKey="年级平均" stroke="#9ca3af" strokeWidth={2} strokeDasharray="5 5" />
312
+ </LineChart>
313
+ </ResponsiveContainer>
314
+ ) : <div className="flex h-full items-center justify-center text-gray-400">请选择班级和学科以查看趋势</div>}
315
+ </div>
316
+ )}
317
+
318
+ {/* 4. Matrix (Scatter) - Restored */}
319
+ {activeTab === 'matrix' && (
320
+ <div className="h-96 animate-in fade-in">
321
+ <div className="text-center text-sm text-gray-500 mb-2">X轴: 平均分 | Y轴: 及格率 | 气泡大小: 优秀率</div>
322
+ <ResponsiveContainer width="100%" height="100%">
323
+ <ScatterChart margin={{ top: 20, right: 20, bottom: 20, left: 20 }}>
324
+ <CartesianGrid />
325
+ <XAxis type="number" dataKey="x" name="平均分" unit="分" domain={[0, 100]} />
326
+ <YAxis type="number" dataKey="y" name="及格率" unit="%" domain={[0, 100]} />
327
+ <ZAxis type="number" dataKey="z" range={[50, 400]} name="优秀率" unit="%" />
328
+ <Tooltip cursor={{ strokeDasharray: '3 3' }} />
329
+ <Legend />
330
+ <Scatter name="各班级表现" data={matrixData} fill="#8884d8" />
331
+ </ScatterChart>
332
+ </ResponsiveContainer>
333
+ </div>
334
+ )}
335
+
336
+ {/* 5. Student Individual */}
337
+ {activeTab === 'student' && (
338
+ <div className="animate-in fade-in">
339
+ {!selectedStudent ? (
340
+ <div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
341
+ {focusStudents.map((s: Student) => (
342
+ <div
343
+ key={s._id}
344
+ onClick={() => setSelectedStudent(s)}
345
+ className="bg-white border border-gray-200 p-4 rounded-xl cursor-pointer hover:shadow-lg hover:border-blue-400 transition-all group text-center"
346
+ >
347
+ <div className={`w-12 h-12 mx-auto rounded-full flex items-center justify-center font-bold text-white text-lg mb-2 ${s.gender === 'Female' ? 'bg-pink-400' : 'bg-blue-400'}`}>
348
+ {s.name[0]}
349
+ </div>
350
+ <h4 className="font-bold text-gray-800 group-hover:text-blue-600 truncate">{s.name}</h4>
351
+ <p className="text-xs text-gray-500 font-mono truncate">{s.studentNo}</p>
352
+ </div>
353
+ ))}
354
+ {focusStudents.length === 0 && <div className="col-span-full text-center py-10 text-gray-400">请先选择班级</div>}
355
+ </div>
356
+ ) : (
357
+ <div className="space-y-6">
358
+ <button onClick={() => setSelectedStudent(null)} className="text-sm text-blue-600 hover:underline mb-4">&larr; 返回学生列表</button>
359
+ <div className="bg-gray-50 p-4 rounded-xl border border-gray-200 mb-6 flex items-center gap-4">
360
+ <div className={`w-16 h-16 rounded-full flex items-center justify-center text-2xl font-bold text-white ${selectedStudent.gender === 'Female' ? 'bg-pink-500' : 'bg-blue-500'}`}>
361
+ {selectedStudent.name[0]}
362
+ </div>
363
+ <div>
364
+ <h2 className="text-2xl font-bold text-gray-900">{selectedStudent.name}</h2>
365
+ <p className="text-gray-500">{selectedStudent.className} | {selectedStudent.studentNo}</p>
366
+ </div>
367
+ </div>
368
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-8">
369
+ <div className="h-72 bg-white p-4 rounded-xl border shadow-sm">
370
+ <h3 className="text-center font-bold text-gray-700 mb-2">学科能力雷达</h3>
371
+ <ResponsiveContainer><RadarChart data={getStudentRadar(selectedStudent.studentNo)}><PolarGrid/><PolarAngleAxis dataKey="subject"/><PolarRadiusAxis angle={30} domain={[0,100]}/><Radar dataKey="score" stroke="#8884d8" fill="#8884d8" fillOpacity={0.6}/><Tooltip/></RadarChart></ResponsiveContainer>
372
+ </div>
373
+ <div className="h-72 bg-white p-4 rounded-xl border shadow-sm">
374
+ <h3 className="text-center font-bold text-gray-700 mb-2">成绩走势</h3>
375
+ <ResponsiveContainer><LineChart data={getStudentTrend(selectedStudent.studentNo)}><CartesianGrid vertical={false}/><XAxis dataKey="name"/><YAxis domain={[0,100]}/><Tooltip/><Line type="monotone" dataKey="score" stroke="#2563eb" strokeWidth={3}/></LineChart></ResponsiveContainer>
376
+ </div>
377
+ </div>
378
+ </div>
379
+ )}
380
+ </div>
381
+ )}
382
+ </div>
383
+ </div>
384
+ );
385
+ };
server.js CHANGED
@@ -786,10 +786,42 @@ app.post('/api/games/lucky-draw', async (req, res) => {
786
  }
787
  });
788
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
789
  app.get('/api/rewards', async (req, res) => {
790
- const { studentId } = req.query;
791
- const filter = { ...getQueryFilter(req), studentId };
792
- if (InMemoryDB.isFallback) return res.json(InMemoryDB.rewards.filter(r => r.studentId === studentId));
 
 
 
 
 
793
  res.json(await StudentRewardModel.find(filter).sort({ createTime: -1 }));
794
  });
795
  app.post('/api/rewards', async (req, res) => {
 
786
  }
787
  });
788
 
789
+ // Grant Draw Count Manually
790
+ app.post('/api/games/grant-draw', async (req, res) => {
791
+ const { studentId, count } = req.body;
792
+ const schoolId = req.headers['x-school-id'];
793
+ try {
794
+ if(InMemoryDB.isFallback) return res.json({success:true});
795
+
796
+ const student = await Student.findById(studentId);
797
+ if(!student) return res.status(404).json({error: '学生未找到'});
798
+
799
+ await Student.findByIdAndUpdate(studentId, { $inc: { drawAttempts: count } });
800
+
801
+ // Log as reward
802
+ await StudentRewardModel.create({
803
+ schoolId,
804
+ studentId,
805
+ studentName: student.name,
806
+ rewardType: 'DRAW_COUNT',
807
+ name: `老师发放 ${count} 次`,
808
+ status: 'REDEEMED',
809
+ source: '教师发放'
810
+ });
811
+
812
+ res.json({ success: true });
813
+ } catch(e) { res.status(500).json({error: e.message}); }
814
+ });
815
+
816
  app.get('/api/rewards', async (req, res) => {
817
+ const { studentId, scope } = req.query;
818
+ const filter = getQueryFilter(req);
819
+ if (studentId) filter.studentId = studentId;
820
+
821
+ // If teacher requests class scope (in frontend we usually filter, but backend support helps)
822
+ // NOTE: Simple implementation: return all school rewards for teacher role, let frontend filter by class for now, or improve query later.
823
+
824
+ if (InMemoryDB.isFallback) return res.json(InMemoryDB.rewards.filter(r => !studentId || r.studentId === studentId));
825
  res.json(await StudentRewardModel.find(filter).sort({ createTime: -1 }));
826
  });
827
  app.post('/api/rewards', async (req, res) => {
services/api.ts CHANGED
@@ -1,3 +1,4 @@
 
1
  import { User, ClassInfo, SystemConfig, Subject, School, Schedule, GameSession, StudentReward, LuckyDrawConfig } from '../types';
2
 
3
  const getBaseUrl = () => {
@@ -169,9 +170,12 @@ export const api = {
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}`),
 
 
175
  addReward: (data: Partial<StudentReward>) => request('/rewards', { method: 'POST', body: JSON.stringify(data) }),
176
  redeem: (id: string) => request(`/rewards/${id}/redeem`, { method: 'POST' }),
177
  consumeDraw: (studentId: string) => request(`/rewards/consume-draw`, { method: 'POST', body: JSON.stringify({ studentId }) })
@@ -180,4 +184,4 @@ export const api = {
180
  batchDelete: (type: 'student' | 'score' | 'user', ids: string[]) => {
181
  return request('/batch-delete', { method: 'POST', body: JSON.stringify({ type, ids }) });
182
  }
183
- };
 
1
+
2
  import { User, ClassInfo, SystemConfig, Subject, School, Schedule, GameSession, StudentReward, LuckyDrawConfig } from '../types';
3
 
4
  const getBaseUrl = () => {
 
170
  getLuckyConfig: () => request('/games/lucky-config'),
171
  saveLuckyConfig: (data: LuckyDrawConfig) => request('/games/lucky-config', { method: 'POST', body: JSON.stringify(data) }),
172
  drawLucky: (studentId: string) => request('/games/lucky-draw', { method: 'POST', body: JSON.stringify({ studentId }) }),
173
+ grantDrawCount: (studentId: string, count: number) => request('/games/grant-draw', { method: 'POST', body: JSON.stringify({ studentId, count }) }),
174
  },
175
  rewards: {
176
  getMyRewards: (studentId: string) => request(`/rewards?studentId=${studentId}`),
177
+ // For teachers to get class rewards, we can use the same endpoint but passing a filter in backend or allow listing all
178
+ getClassRewards: () => request('/rewards?scope=class'),
179
  addReward: (data: Partial<StudentReward>) => request('/rewards', { method: 'POST', body: JSON.stringify(data) }),
180
  redeem: (id: string) => request(`/rewards/${id}/redeem`, { method: 'POST' }),
181
  consumeDraw: (studentId: string) => request(`/rewards/consume-draw`, { method: 'POST', body: JSON.stringify({ studentId }) })
 
184
  batchDelete: (type: 'student' | 'score' | 'user', ids: string[]) => {
185
  return request('/batch-delete', { method: 'POST', body: JSON.stringify({ type, ids }) });
186
  }
187
+ };