dvc890 commited on
Commit
1a8352d
·
verified ·
1 Parent(s): 945b7cf

Upload 34 files

Browse files
Files changed (5) hide show
  1. pages/Dashboard.tsx +7 -7
  2. pages/GameRewards.tsx +167 -51
  3. server.js +69 -12
  4. services/api.ts +3 -3
  5. types.ts +2 -1
pages/Dashboard.tsx CHANGED
@@ -173,10 +173,10 @@ export const Dashboard: React.FC<DashboardProps> = ({ onNavigate }) => {
173
  : classList.map(c => c.grade + c.className).sort(sortClasses);
174
 
175
  const cards = [
176
- { label: '在校学生', value: stats.studentCount, icon: Users, color: 'bg-blue-500', trend: '+12%' },
177
- { label: '开设课程', value: stats.courseCount, icon: BookOpen, color: 'bg-emerald-500', trend: '+4' },
178
- { label: '平均成绩', value: stats.avgScore, icon: GraduationCap, color: 'bg-violet-500', trend: '+2.5' },
179
- { label: '优秀率', value: stats.excellentRate, icon: TrendingUp, color: 'bg-orange-500', trend: '-1%' },
180
  ];
181
 
182
  return (
@@ -202,9 +202,9 @@ export const Dashboard: React.FC<DashboardProps> = ({ onNavigate }) => {
202
  <div className="relative z-10">
203
  <p className="text-sm font-medium text-gray-500 mb-1">{card.label}</p>
204
  <h3 className="text-3xl font-bold text-gray-800">{card.value}</h3>
205
- <div className={`flex items-center mt-2 text-xs font-medium ${card.trend.startsWith('+') ? 'text-green-600' : 'text-red-500'}`}>
206
- <span className={`px-1.5 py-0.5 rounded ${card.trend.startsWith('+') ? 'bg-green-100' : 'bg-red-100'}`}>{card.trend}</span>
207
- <span className="ml-2 text-gray-400">较上月</span>
208
  </div>
209
  </div>
210
  <div className={`p-3 rounded-xl ${card.color} bg-opacity-10 text-white shadow-sm group-hover:scale-110 transition-transform`}>
 
173
  : classList.map(c => c.grade + c.className).sort(sortClasses);
174
 
175
  const cards = [
176
+ { label: '在校学生', value: stats.studentCount, icon: Users, color: 'bg-blue-500', trend: '实时' },
177
+ { label: '开设课程', value: stats.courseCount, icon: BookOpen, color: 'bg-emerald-500', trend: '实时' },
178
+ { label: '平均成绩', value: stats.avgScore, icon: GraduationCap, color: 'bg-violet-500', trend: '全校' },
179
+ { label: '优秀率', value: stats.excellentRate, icon: TrendingUp, color: 'bg-orange-500', trend: '>=90分' },
180
  ];
181
 
182
  return (
 
202
  <div className="relative z-10">
203
  <p className="text-sm font-medium text-gray-500 mb-1">{card.label}</p>
204
  <h3 className="text-3xl font-bold text-gray-800">{card.value}</h3>
205
+ <div className={`flex items-center mt-2 text-xs font-medium text-gray-400`}>
206
+ <span className={`px-1.5 py-0.5 rounded bg-gray-100 text-gray-600 mr-2`}>{card.trend}</span>
207
+ 统计数据
208
  </div>
209
  </div>
210
  <div className={`p-3 rounded-xl ${card.color} bg-opacity-10 text-white shadow-sm group-hover:scale-110 transition-transform`}>
pages/GameRewards.tsx CHANGED
@@ -2,7 +2,7 @@
2
  import React, { useState, useEffect } from 'react';
3
  import { api } from '../services/api';
4
  import { StudentReward, Student } from '../types';
5
- import { Gift, Loader2, Search } from 'lucide-react';
6
 
7
  export const GameRewards: React.FC = () => {
8
  const [myRewards, setMyRewards] = useState<StudentReward[]>([]);
@@ -12,18 +12,30 @@ export const GameRewards: React.FC = () => {
12
  // Grant Modal
13
  const [isGrantModalOpen, setIsGrantModalOpen] = useState(false);
14
  const [students, setStudents] = useState<Student[]>([]);
15
- const [grantForm, setGrantForm] = useState({ studentId: '', count: 1 });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
 
17
  const currentUser = api.auth.getCurrentUser();
18
  const isStudent = currentUser?.role === 'STUDENT';
19
  const isTeacher = currentUser?.role === 'TEACHER';
20
- const isAdmin = currentUser?.role === 'ADMIN';
21
 
22
  const loadData = async () => {
23
  setLoading(true);
24
  try {
25
  if (isStudent) {
26
- // Need to find my student ID first
27
  const stus = await api.students.getAll();
28
  const me = stus.find((s: Student) => s.name === (currentUser.trueName || currentUser.username));
29
  if (me) {
@@ -41,12 +53,13 @@ export const GameRewards: React.FC = () => {
41
  let filteredStudents = allStus;
42
 
43
  if (isTeacher && currentUser.homeroomClass) {
44
- // Filter rewards by student names belonging to class (Backend link is better, but frontend filtering works for mock)
45
- // Or better: filter students first, then filter rewards by student IDs
46
  filteredStudents = allStus.filter((s: Student) => s.className === currentUser.homeroomClass);
47
  const studentIds = filteredStudents.map((s: Student) => s._id || String(s.id));
48
  filteredRewards = allRews.filter((r: StudentReward) => studentIds.includes(r.studentId));
49
  }
 
 
 
50
 
51
  setAllRewards(filteredRewards);
52
  setStudents(filteredStudents);
@@ -57,12 +70,16 @@ export const GameRewards: React.FC = () => {
57
 
58
  useEffect(() => { loadData(); }, []);
59
 
60
- const handleGrantDraw = async () => {
61
  if(!grantForm.studentId) return alert('请选择学生');
 
 
62
  try {
63
- await api.games.grantDrawCount(grantForm.studentId, grantForm.count);
64
  setIsGrantModalOpen(false);
65
  alert('发放成功');
 
 
66
  loadData();
67
  } catch(e) { alert('发放失败'); }
68
  };
@@ -74,19 +91,74 @@ export const GameRewards: React.FC = () => {
74
  }
75
  };
76
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77
  if (loading) return <div className="h-full flex items-center justify-center"><Loader2 className="animate-spin text-blue-600"/></div>;
78
 
79
  return (
80
  <div className="flex-1 flex flex-col min-h-0 bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden">
81
- <div className="p-6 border-b border-gray-100 flex justify-between items-center shrink-0">
82
  <h3 className="text-xl font-bold text-gray-800">
83
  {isStudent ? '我的战利品清单' : '班级奖励核销台'}
84
  </h3>
85
- {!isStudent && (
86
- <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 text-sm">
87
- <Gift size={16} className="mr-2"/> 手动发放抽奖券
88
- </button>
89
- )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90
  </div>
91
 
92
  <div className="flex-1 overflow-auto p-0">
@@ -94,7 +166,7 @@ export const GameRewards: React.FC = () => {
94
  <thead className="bg-gray-50 text-gray-500 text-xs uppercase sticky top-0 z-10 shadow-sm">
95
  <tr>
96
  {!isStudent && <th className="p-4 font-semibold">学生姓名</th>}
97
- <th className="p-4 font-semibold">奖品名称</th>
98
  <th className="p-4 font-semibold">类型</th>
99
  <th className="p-4 font-semibold">来源</th>
100
  <th className="p-4 font-semibold">获得时间</th>
@@ -103,38 +175,66 @@ export const GameRewards: React.FC = () => {
103
  </tr>
104
  </thead>
105
  <tbody className="divide-y divide-gray-100 text-sm">
106
- {(isStudent ? myRewards : allRewards).map(r => (
107
- <tr key={r._id} className="hover:bg-blue-50/30 transition-colors">
108
- {!isStudent && <td className="p-4 font-bold text-gray-700">{r.studentName}</td>}
109
- <td className="p-4 font-medium text-gray-900">{r.name}</td>
110
- <td className="p-4">
111
- <span className={`text-xs px-2 py-1 rounded border ${r.rewardType === 'DRAW_COUNT' ? 'bg-purple-50 text-purple-700 border-purple-100' : 'bg-blue-50 text-blue-700 border-blue-100'}`}>
112
- {r.rewardType==='DRAW_COUNT' ? '抽奖券' : '实物'}
113
- </span>
114
- </td>
115
- <td className="p-4 text-gray-500">{r.source}</td>
116
- <td className="p-4 text-gray-500">{new Date(r.createTime).toLocaleDateString()} {new Date(r.createTime).toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'})}</td>
117
- <td className="p-4">
118
- {r.rewardType === 'DRAW_COUNT' ? (
119
- <span className="text-xs bg-purple-100 text-purple-700 px-2 py-1 rounded font-medium">系统已发放</span>
120
- ) : (
121
- r.status === 'REDEEMED'
122
- ? <span className="text-xs bg-gray-100 text-gray-500 px-2 py-1 rounded border border-gray-200">已兑换</span>
123
- : <span className="text-xs bg-green-100 text-green-700 px-2 py-1 rounded border border-green-200 animate-pulse">未兑换</span>
124
- )}
125
- </td>
126
- {!isStudent && (
127
- <td className="p-4 text-right">
128
- {r.status !== 'REDEEMED' && r.rewardType !== 'DRAW_COUNT' && (
129
- <button onClick={() => handleRedeem(r._id!)} className="text-xs bg-green-600 text-white px-3 py-1.5 rounded hover:bg-green-700 shadow-sm transition-colors">
130
- 核销
131
- </button>
132
  )}
133
  </td>
134
- )}
135
- </tr>
136
- ))}
137
- {(isStudent ? myRewards : allRewards).length === 0 && (
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
  <tr><td colSpan={7} className="text-center py-10 text-gray-400">暂无记录</td></tr>
139
  )}
140
  </tbody>
@@ -145,22 +245,38 @@ export const GameRewards: React.FC = () => {
145
  {isGrantModalOpen && (
146
  <div className="fixed inset-0 bg-black/50 z-[100] flex items-center justify-center p-4">
147
  <div className="bg-white rounded-xl p-6 w-full max-w-sm animate-in fade-in zoom-in-95 shadow-2xl">
148
- <h3 className="font-bold text-lg mb-4 text-gray-800">手动发放</h3>
149
  <div className="space-y-4">
150
  <div>
151
- <label className="block text-sm font-medium text-gray-700 mb-1">选择学生</label>
152
- <select className="w-full border border-gray-300 p-2 rounded-lg bg-gray-50 focus:bg-white transition-colors outline-none focus:ring-2 focus:ring-blue-500" value={grantForm.studentId} onChange={e=>setGrantForm({...grantForm, studentId: e.target.value})}>
153
  <option value="">-- 请选择 --</option>
154
  {students.map(s => <option key={s._id} value={s._id || String(s.id)}>{s.name} ({s.studentNo})</option>)}
155
  </select>
156
  </div>
157
  <div>
158
- <label className="block text-sm font-medium text-gray-700 mb-1">数量</label>
159
- <input type="number" min={1} className="w-full border border-gray-300 p-2 rounded-lg outline-none focus:ring-2 focus:ring-blue-500" value={grantForm.count} onChange={e=>setGrantForm({...grantForm, count: Number(e.target.value)})}/>
 
 
 
160
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
161
  <div className="flex gap-2 pt-2">
162
  <button onClick={() => setIsGrantModalOpen(false)} className="flex-1 bg-gray-100 text-gray-700 py-2 rounded-lg hover:bg-gray-200 transition-colors">取消</button>
163
- <button onClick={handleGrantDraw} className="flex-1 bg-amber-500 text-white py-2 rounded-lg font-bold hover:bg-amber-600 shadow-md transition-colors">确认发放</button>
164
  </div>
165
  </div>
166
  </div>
 
2
  import React, { useState, useEffect } from 'react';
3
  import { api } from '../services/api';
4
  import { StudentReward, Student } from '../types';
5
+ import { Gift, Loader2, Search, Filter, Trash2, Edit, Save, X } from 'lucide-react';
6
 
7
  export const GameRewards: React.FC = () => {
8
  const [myRewards, setMyRewards] = useState<StudentReward[]>([]);
 
12
  // Grant Modal
13
  const [isGrantModalOpen, setIsGrantModalOpen] = useState(false);
14
  const [students, setStudents] = useState<Student[]>([]);
15
+ const [grantForm, setGrantForm] = useState({
16
+ studentId: '',
17
+ count: 1,
18
+ rewardType: 'DRAW_COUNT' as 'DRAW_COUNT' | 'ITEM',
19
+ name: ''
20
+ });
21
+
22
+ // Filters
23
+ const [filterType, setFilterType] = useState('ALL'); // ALL, ITEM, DRAW_COUNT
24
+ const [filterStatus, setFilterStatus] = useState('ALL'); // ALL, PENDING, REDEEMED
25
+ const [searchText, setSearchText] = useState('');
26
+
27
+ // Edit State
28
+ const [editingId, setEditingId] = useState<string | null>(null);
29
+ const [editForm, setEditForm] = useState({ name: '', count: 1 });
30
 
31
  const currentUser = api.auth.getCurrentUser();
32
  const isStudent = currentUser?.role === 'STUDENT';
33
  const isTeacher = currentUser?.role === 'TEACHER';
 
34
 
35
  const loadData = async () => {
36
  setLoading(true);
37
  try {
38
  if (isStudent) {
 
39
  const stus = await api.students.getAll();
40
  const me = stus.find((s: Student) => s.name === (currentUser.trueName || currentUser.username));
41
  if (me) {
 
53
  let filteredStudents = allStus;
54
 
55
  if (isTeacher && currentUser.homeroomClass) {
 
 
56
  filteredStudents = allStus.filter((s: Student) => s.className === currentUser.homeroomClass);
57
  const studentIds = filteredStudents.map((s: Student) => s._id || String(s.id));
58
  filteredRewards = allRews.filter((r: StudentReward) => studentIds.includes(r.studentId));
59
  }
60
+
61
+ // Hide Consolation prizes from teacher view to keep it clean
62
+ filteredRewards = filteredRewards.filter((r: StudentReward) => r.rewardType !== 'CONSOLATION');
63
 
64
  setAllRewards(filteredRewards);
65
  setStudents(filteredStudents);
 
70
 
71
  useEffect(() => { loadData(); }, []);
72
 
73
+ const handleGrant = async () => {
74
  if(!grantForm.studentId) return alert('请选择学生');
75
+ if(grantForm.rewardType === 'ITEM' && !grantForm.name) return alert('请输入奖品名称');
76
+
77
  try {
78
+ await api.games.grantReward(grantForm);
79
  setIsGrantModalOpen(false);
80
  alert('发放成功');
81
+ // Reset form defaults
82
+ setGrantForm({ studentId: '', count: 1, rewardType: 'DRAW_COUNT', name: '' });
83
  loadData();
84
  } catch(e) { alert('发放失败'); }
85
  };
 
91
  }
92
  };
93
 
94
+ const handleDelete = async (r: StudentReward) => {
95
+ if (!confirm(`确定要撤回这条奖励吗?\n如果学生已经使用了抽奖券,撤回将失败。`)) return;
96
+ try {
97
+ await api.rewards.delete(r._id!);
98
+ loadData();
99
+ } catch (e: any) {
100
+ alert(e.message || '撤回失败,可能学生已使用部分次数');
101
+ }
102
+ };
103
+
104
+ const handleUpdate = async (id: string) => {
105
+ await api.rewards.update(id, editForm);
106
+ setEditingId(null);
107
+ loadData();
108
+ };
109
+
110
+ const startEdit = (r: StudentReward) => {
111
+ setEditingId(r._id!);
112
+ setEditForm({ name: r.name, count: r.count || 1 });
113
+ };
114
+
115
+ // Filter Logic
116
+ const displayRewards = (isStudent ? myRewards : allRewards).filter(r => {
117
+ if (filterType !== 'ALL' && r.rewardType !== filterType) return false;
118
+ if (filterStatus !== 'ALL' && r.status !== filterStatus) return false;
119
+ if (searchText) {
120
+ const lower = searchText.toLowerCase();
121
+ return r.studentName.toLowerCase().includes(lower) || r.name.toLowerCase().includes(lower);
122
+ }
123
+ return true;
124
+ });
125
+
126
  if (loading) return <div className="h-full flex items-center justify-center"><Loader2 className="animate-spin text-blue-600"/></div>;
127
 
128
  return (
129
  <div className="flex-1 flex flex-col min-h-0 bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden">
130
+ <div className="p-6 border-b border-gray-100 flex flex-col md:flex-row justify-between items-start md:items-center gap-4 shrink-0">
131
  <h3 className="text-xl font-bold text-gray-800">
132
  {isStudent ? '我的战利品清单' : '班级奖励核销台'}
133
  </h3>
134
+
135
+ <div className="flex flex-wrap items-center gap-3 w-full md:w-auto">
136
+ {/* Filters */}
137
+ <div className="flex items-center bg-gray-100 rounded-lg p-1">
138
+ <select className="bg-transparent text-xs p-1.5 rounded outline-none text-gray-600" value={filterType} onChange={e=>setFilterType(e.target.value)}>
139
+ <option value="ALL">全部类型</option>
140
+ <option value="ITEM">实物</option>
141
+ <option value="DRAW_COUNT">抽奖券</option>
142
+ </select>
143
+ </div>
144
+ <div className="flex items-center bg-gray-100 rounded-lg p-1">
145
+ <select className="bg-transparent text-xs p-1.5 rounded outline-none text-gray-600" value={filterStatus} onChange={e=>setFilterStatus(e.target.value)}>
146
+ <option value="ALL">全部状态</option>
147
+ <option value="PENDING">未核销</option>
148
+ <option value="REDEEMED">已核销</option>
149
+ </select>
150
+ </div>
151
+ <div className="relative">
152
+ <input className="pl-8 pr-3 py-1.5 text-xs border rounded-lg bg-gray-50 focus:bg-white transition-colors outline-none w-32" placeholder="搜索..." value={searchText} onChange={e=>setSearchText(e.target.value)}/>
153
+ <Search className="absolute left-2.5 top-2 text-gray-400" size={12}/>
154
+ </div>
155
+
156
+ {!isStudent && (
157
+ <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 text-sm ml-auto md:ml-0">
158
+ <Gift size={16} className="mr-2"/> 发放奖励
159
+ </button>
160
+ )}
161
+ </div>
162
  </div>
163
 
164
  <div className="flex-1 overflow-auto p-0">
 
166
  <thead className="bg-gray-50 text-gray-500 text-xs uppercase sticky top-0 z-10 shadow-sm">
167
  <tr>
168
  {!isStudent && <th className="p-4 font-semibold">学生姓名</th>}
169
+ <th className="p-4 font-semibold">奖品内容</th>
170
  <th className="p-4 font-semibold">类型</th>
171
  <th className="p-4 font-semibold">来源</th>
172
  <th className="p-4 font-semibold">获得时间</th>
 
175
  </tr>
176
  </thead>
177
  <tbody className="divide-y divide-gray-100 text-sm">
178
+ {displayRewards.map(r => {
179
+ const isEditing = editingId === r._id;
180
+ return (
181
+ <tr key={r._id} className="hover:bg-blue-50/30 transition-colors">
182
+ {!isStudent && <td className="p-4 font-bold text-gray-700">{r.studentName}</td>}
183
+ <td className="p-4 font-medium text-gray-900">
184
+ {isEditing ? (
185
+ <div className="flex gap-1 items-center">
186
+ <input className="border rounded px-1 py-0.5 text-xs w-24" value={editForm.name} onChange={e=>setEditForm({...editForm, name:e.target.value})} placeholder="名称"/>
187
+ <span className="text-xs text-gray-400">x</span>
188
+ <input type="number" min={1} className="border rounded px-1 py-0.5 text-xs w-12" value={editForm.count} onChange={e=>setEditForm({...editForm, count:Number(e.target.value)})}/>
189
+ </div>
190
+ ) : (
191
+ <span>{r.name} <span className="text-gray-400 text-xs ml-1">x{r.count || 1}</span></span>
 
 
 
 
 
 
 
 
 
 
 
 
192
  )}
193
  </td>
194
+ <td className="p-4">
195
+ <span className={`text-xs px-2 py-1 rounded border ${
196
+ r.rewardType === 'DRAW_COUNT' ? 'bg-purple-50 text-purple-700 border-purple-100' :
197
+ r.rewardType === 'CONSOLATION' ? 'bg-gray-50 text-gray-500 border-gray-100' :
198
+ 'bg-blue-50 text-blue-700 border-blue-100'
199
+ }`}>
200
+ {r.rewardType==='DRAW_COUNT' ? '抽奖券' : r.rewardType==='CONSOLATION' ? '安慰奖' : '实物'}
201
+ </span>
202
+ </td>
203
+ <td className="p-4 text-gray-500 text-xs">{r.source}</td>
204
+ <td className="p-4 text-gray-500 text-xs">{new Date(r.createTime).toLocaleDateString()}</td>
205
+ <td className="p-4">
206
+ {r.rewardType === 'DRAW_COUNT' || r.rewardType === 'CONSOLATION' ? (
207
+ <span className="text-xs bg-gray-100 text-gray-500 px-2 py-1 rounded">无需核销</span>
208
+ ) : (
209
+ r.status === 'REDEEMED'
210
+ ? <span className="text-xs bg-green-100 text-green-700 px-2 py-1 rounded border border-green-200">已兑换</span>
211
+ : <span className="text-xs bg-amber-100 text-amber-700 px-2 py-1 rounded border border-amber-200 animate-pulse">待核销</span>
212
+ )}
213
+ </td>
214
+ {!isStudent && (
215
+ <td className="p-4 text-right flex justify-end gap-2">
216
+ {isEditing ? (
217
+ <>
218
+ <button onClick={()=>handleUpdate(r._id!)} className="text-green-600 hover:bg-green-50 p-1 rounded"><Save size={16}/></button>
219
+ <button onClick={()=>setEditingId(null)} className="text-gray-400 hover:bg-gray-50 p-1 rounded"><X size={16}/></button>
220
+ </>
221
+ ) : (
222
+ <>
223
+ {r.status !== 'REDEEMED' && r.rewardType === 'ITEM' && (
224
+ <button onClick={() => handleRedeem(r._id!)} className="text-xs bg-green-600 text-white px-3 py-1 rounded hover:bg-green-700 shadow-sm transition-colors mr-2">
225
+ 核销
226
+ </button>
227
+ )}
228
+ <button onClick={()=>startEdit(r)} className="text-blue-400 hover:text-blue-600 p-1" title="编辑"><Edit size={16}/></button>
229
+ <button onClick={()=>handleDelete(r)} className="text-gray-400 hover:text-red-500 p-1" title="撤回"><Trash2 size={16}/></button>
230
+ </>
231
+ )}
232
+ </td>
233
+ )}
234
+ </tr>
235
+ );
236
+ })}
237
+ {displayRewards.length === 0 && (
238
  <tr><td colSpan={7} className="text-center py-10 text-gray-400">暂无记录</td></tr>
239
  )}
240
  </tbody>
 
245
  {isGrantModalOpen && (
246
  <div className="fixed inset-0 bg-black/50 z-[100] flex items-center justify-center p-4">
247
  <div className="bg-white rounded-xl p-6 w-full max-w-sm animate-in fade-in zoom-in-95 shadow-2xl">
248
+ <h3 className="font-bold text-lg mb-4 text-gray-800">发放奖</h3>
249
  <div className="space-y-4">
250
  <div>
251
+ <label className="block text-xs font-bold text-gray-500 uppercase mb-1">选择学生</label>
252
+ <select className="w-full border border-gray-300 p-2 rounded-lg bg-gray-50 focus:bg-white text-sm" value={grantForm.studentId} onChange={e=>setGrantForm({...grantForm, studentId: e.target.value})}>
253
  <option value="">-- 请选择 --</option>
254
  {students.map(s => <option key={s._id} value={s._id || String(s.id)}>{s.name} ({s.studentNo})</option>)}
255
  </select>
256
  </div>
257
  <div>
258
+ <label className="block text-xs font-bold text-gray-500 uppercase mb-1">奖励类型</label>
259
+ <div className="flex gap-2">
260
+ <button onClick={()=>setGrantForm({...grantForm, rewardType: 'DRAW_COUNT'})} className={`flex-1 py-2 text-sm rounded border ${grantForm.rewardType==='DRAW_COUNT' ? 'bg-purple-50 border-purple-500 text-purple-700 font-bold' : 'border-gray-200 text-gray-600'}`}>抽奖券</button>
261
+ <button onClick={()=>setGrantForm({...grantForm, rewardType: 'ITEM'})} className={`flex-1 py-2 text-sm rounded border ${grantForm.rewardType==='ITEM' ? 'bg-blue-50 border-blue-500 text-blue-700 font-bold' : 'border-gray-200 text-gray-600'}`}>实物奖品</button>
262
+ </div>
263
  </div>
264
+
265
+ {grantForm.rewardType === 'ITEM' && (
266
+ <div>
267
+ <label className="block text-xs font-bold text-gray-500 uppercase mb-1">奖品名称</label>
268
+ <input className="w-full border border-gray-300 p-2 rounded-lg text-sm" placeholder="如: 笔记本、铅笔" value={grantForm.name} onChange={e=>setGrantForm({...grantForm, name: e.target.value})}/>
269
+ </div>
270
+ )}
271
+
272
+ <div>
273
+ <label className="block text-xs font-bold text-gray-500 uppercase mb-1">发放数量</label>
274
+ <input type="number" min={1} className="w-full border border-gray-300 p-2 rounded-lg outline-none" value={grantForm.count} onChange={e=>setGrantForm({...grantForm, count: Number(e.target.value)})}/>
275
+ </div>
276
+
277
  <div className="flex gap-2 pt-2">
278
  <button onClick={() => setIsGrantModalOpen(false)} className="flex-1 bg-gray-100 text-gray-700 py-2 rounded-lg hover:bg-gray-200 transition-colors">取消</button>
279
+ <button onClick={handleGrant} className="flex-1 bg-amber-500 text-white py-2 rounded-lg font-bold hover:bg-amber-600 shadow-md transition-colors">确认发放</button>
280
  </div>
281
  </div>
282
  </div>
server.js CHANGED
@@ -80,7 +80,7 @@ const NotificationModel = mongoose.model('Notification', NotificationSchema);
80
  // Game Schemas
81
  const GameSessionSchema = new mongoose.Schema({ schoolId: String, className: String, isEnabled: Boolean, maxSteps: Number, teams: [{ id: String, name: String, score: Number, avatar: String, color: String, members: [String] }], rewardsConfig: [{ scoreThreshold: Number, rewardType: String, rewardName: String, rewardValue: Number }] });
82
  const GameSessionModel = mongoose.model('GameSession', GameSessionSchema);
83
- const StudentRewardSchema = new mongoose.Schema({ schoolId: String, studentId: String, studentName: String, rewardType: String, name: String, status: String, source: String, createTime: { type: Date, default: Date.now } });
84
  const StudentRewardModel = mongoose.model('StudentReward', StudentRewardSchema);
85
  const LuckyDrawConfigSchema = new mongoose.Schema({ schoolId: String, prizes: [{ id: String, name: String, probability: Number, count: Number, icon: String }], dailyLimit: Number, cardCount: Number, defaultPrize: String });
86
  const LuckyDrawConfigModel = mongoose.model('LuckyDrawConfig', LuckyDrawConfigSchema);
@@ -145,6 +145,7 @@ app.post('/api/games/lucky-draw', async (req, res) => {
145
 
146
  // 4. Weighted Random Logic
147
  let selectedPrize = defaultPrize;
 
148
  const random = Math.random() * 100;
149
  let currentWeight = 0;
150
  let matchedPrize = null;
@@ -159,10 +160,9 @@ app.post('/api/games/lucky-draw', async (req, res) => {
159
 
160
  if (matchedPrize) {
161
  selectedPrize = matchedPrize.name;
 
162
  // Only decrease count if it's not infinite (undefined)
163
  if (matchedPrize.count !== undefined && matchedPrize.count > 0) {
164
- // Need to update the specific array element.
165
- // If config has no ID, we might have trouble updating. Assuming config has _id.
166
  if (config._id) {
167
  await LuckyDrawConfigModel.updateOne(
168
  { _id: config._id, "prizes.id": matchedPrize.id },
@@ -176,12 +176,14 @@ app.post('/api/games/lucky-draw', async (req, res) => {
176
  await Student.findByIdAndUpdate(studentId, { $inc: { drawAttempts: -1 } });
177
 
178
  // 6. Record Reward
 
179
  await StudentRewardModel.create({
180
  schoolId,
181
  studentId,
182
  studentName: student.name,
183
- rewardType: 'ITEM',
184
  name: selectedPrize,
 
185
  status: 'PENDING',
186
  source: '幸运大抽奖'
187
  });
@@ -275,7 +277,25 @@ app.post('/api/schedules', async (req, res) => {
275
  });
276
  app.delete('/api/schedules', async (req, res) => { await ScheduleModel.deleteOne({...getQueryFilter(req), ...req.query}); res.json({}); });
277
 
278
- app.get('/api/stats', async (req, res) => { res.json({studentCount: await Student.countDocuments(getQueryFilter(req))}); });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
279
 
280
  app.get('/api/config', async (req, res) => { res.json(await ConfigModel.findOne({key:'main'})); });
281
  app.post('/api/config', async (req, res) => { await ConfigModel.findOneAndUpdate({key:'main'}, req.body, {upsert:true}); res.json({}); });
@@ -290,21 +310,55 @@ app.post('/api/games/mountain', async (req, res) => {
290
  res.json({});
291
  });
292
 
293
- app.post('/api/games/grant-draw', async (req, res) => {
294
- const { studentId, count } = req.body;
295
- await Student.findByIdAndUpdate(studentId, { $inc: { drawAttempts: count } });
 
 
 
 
 
 
 
296
  await StudentRewardModel.create({
297
  schoolId: req.headers['x-school-id'],
298
  studentId,
299
  studentName: (await Student.findById(studentId)).name,
300
- rewardType: 'DRAW_COUNT',
301
- name: '抽奖券',
302
- status: 'REDEEMED',
 
303
  source: '教师发放'
304
  });
305
  res.json({});
306
  });
307
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
308
  app.get('/api/rewards', async (req, res) => {
309
  const filter = getQueryFilter(req);
310
  if(req.query.studentId) filter.studentId = req.query.studentId;
@@ -312,9 +366,12 @@ app.get('/api/rewards', async (req, res) => {
312
  });
313
  app.post('/api/rewards', async (req, res) => {
314
  const data = injectSchoolId(req, req.body);
 
 
 
315
  if(data.rewardType==='DRAW_COUNT') {
316
  data.status='REDEEMED';
317
- await Student.findByIdAndUpdate(data.studentId, {$inc:{drawAttempts:1}});
318
  }
319
  await StudentRewardModel.create(data);
320
  res.json({});
 
80
  // Game Schemas
81
  const GameSessionSchema = new mongoose.Schema({ schoolId: String, className: String, isEnabled: Boolean, maxSteps: Number, teams: [{ id: String, name: String, score: Number, avatar: String, color: String, members: [String] }], rewardsConfig: [{ scoreThreshold: Number, rewardType: String, rewardName: String, rewardValue: Number }] });
82
  const GameSessionModel = mongoose.model('GameSession', GameSessionSchema);
83
+ const StudentRewardSchema = new mongoose.Schema({ schoolId: String, studentId: String, studentName: String, rewardType: String, name: String, count: { type: Number, default: 1 }, status: String, source: String, createTime: { type: Date, default: Date.now } });
84
  const StudentRewardModel = mongoose.model('StudentReward', StudentRewardSchema);
85
  const LuckyDrawConfigSchema = new mongoose.Schema({ schoolId: String, prizes: [{ id: String, name: String, probability: Number, count: Number, icon: String }], dailyLimit: Number, cardCount: Number, defaultPrize: String });
86
  const LuckyDrawConfigModel = mongoose.model('LuckyDrawConfig', LuckyDrawConfigSchema);
 
145
 
146
  // 4. Weighted Random Logic
147
  let selectedPrize = defaultPrize;
148
+ let rewardType = 'CONSOLATION'; // Default to consolation
149
  const random = Math.random() * 100;
150
  let currentWeight = 0;
151
  let matchedPrize = null;
 
160
 
161
  if (matchedPrize) {
162
  selectedPrize = matchedPrize.name;
163
+ rewardType = 'ITEM'; // It's a real prize
164
  // Only decrease count if it's not infinite (undefined)
165
  if (matchedPrize.count !== undefined && matchedPrize.count > 0) {
 
 
166
  if (config._id) {
167
  await LuckyDrawConfigModel.updateOne(
168
  { _id: config._id, "prizes.id": matchedPrize.id },
 
176
  await Student.findByIdAndUpdate(studentId, { $inc: { drawAttempts: -1 } });
177
 
178
  // 6. Record Reward
179
+ // Note: Consolation prizes are recorded but handled differently in UI
180
  await StudentRewardModel.create({
181
  schoolId,
182
  studentId,
183
  studentName: student.name,
184
+ rewardType,
185
  name: selectedPrize,
186
+ count: 1,
187
  status: 'PENDING',
188
  source: '幸运大抽奖'
189
  });
 
277
  });
278
  app.delete('/api/schedules', async (req, res) => { await ScheduleModel.deleteOne({...getQueryFilter(req), ...req.query}); res.json({}); });
279
 
280
+ // REAL STATS API (No more mock)
281
+ app.get('/api/stats', async (req, res) => {
282
+ const filter = getQueryFilter(req);
283
+ const studentCount = await Student.countDocuments(filter);
284
+ const courseCount = await Course.countDocuments(filter);
285
+
286
+ const scores = await Score.find({...filter, status: 'Normal'});
287
+ let avgScore = 0;
288
+ let excellentRate = '0%';
289
+
290
+ if (scores.length > 0) {
291
+ const total = scores.reduce((sum, s) => sum + s.score, 0);
292
+ avgScore = parseFloat((total / scores.length).toFixed(1));
293
+ const excellent = scores.filter(s => s.score >= 90).length;
294
+ excellentRate = Math.round((excellent / scores.length) * 100) + '%';
295
+ }
296
+
297
+ res.json({ studentCount, courseCount, avgScore, excellentRate });
298
+ });
299
 
300
  app.get('/api/config', async (req, res) => { res.json(await ConfigModel.findOne({key:'main'})); });
301
  app.post('/api/config', async (req, res) => { await ConfigModel.findOneAndUpdate({key:'main'}, req.body, {upsert:true}); res.json({}); });
 
310
  res.json({});
311
  });
312
 
313
+ // Grant Reward (Flexible)
314
+ app.post('/api/games/grant-reward', async (req, res) => {
315
+ const { studentId, count, rewardType, name } = req.body;
316
+ const finalCount = count || 1;
317
+ const finalName = name || (rewardType === 'DRAW_COUNT' ? '抽奖券' : '奖品');
318
+
319
+ if (rewardType === 'DRAW_COUNT') {
320
+ await Student.findByIdAndUpdate(studentId, { $inc: { drawAttempts: finalCount } });
321
+ }
322
+
323
  await StudentRewardModel.create({
324
  schoolId: req.headers['x-school-id'],
325
  studentId,
326
  studentName: (await Student.findById(studentId)).name,
327
+ rewardType,
328
+ name: finalName,
329
+ count: finalCount,
330
+ status: rewardType === 'DRAW_COUNT' ? 'REDEEMED' : 'PENDING',
331
  source: '教师发放'
332
  });
333
  res.json({});
334
  });
335
 
336
+ // Update Reward
337
+ app.put('/api/rewards/:id', async (req, res) => {
338
+ await StudentRewardModel.findByIdAndUpdate(req.params.id, req.body);
339
+ res.json({});
340
+ });
341
+
342
+ // Delete Reward (Smart Revoke)
343
+ app.delete('/api/rewards/:id', async (req, res) => {
344
+ const reward = await StudentRewardModel.findById(req.params.id);
345
+ if (!reward) return res.status(404).json({error: 'Not found'});
346
+
347
+ // If we revoke draw counts, ensure student has enough
348
+ if (reward.rewardType === 'DRAW_COUNT') {
349
+ const student = await Student.findById(reward.studentId);
350
+ if (!student) return res.status(404).json({error: 'Student missing'});
351
+
352
+ if (student.drawAttempts < reward.count) {
353
+ return res.status(400).json({ error: 'FAILED_REVOKE', message: '修改失败,次数已被使用' });
354
+ }
355
+ await Student.findByIdAndUpdate(reward.studentId, { $inc: { drawAttempts: -reward.count } });
356
+ }
357
+
358
+ await StudentRewardModel.findByIdAndDelete(req.params.id);
359
+ res.json({});
360
+ });
361
+
362
  app.get('/api/rewards', async (req, res) => {
363
  const filter = getQueryFilter(req);
364
  if(req.query.studentId) filter.studentId = req.query.studentId;
 
366
  });
367
  app.post('/api/rewards', async (req, res) => {
368
  const data = injectSchoolId(req, req.body);
369
+ // Ensure count default
370
+ if (!data.count) data.count = 1;
371
+
372
  if(data.rewardType==='DRAW_COUNT') {
373
  data.status='REDEEMED';
374
+ await Student.findByIdAndUpdate(data.studentId, {$inc:{drawAttempts:data.count}});
375
  }
376
  await StudentRewardModel.create(data);
377
  res.json({});
services/api.ts CHANGED
@@ -170,15 +170,15 @@ export const api = {
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 }) })
182
  },
183
 
184
  batchDelete: (type: 'student' | 'score' | 'user', ids: string[]) => {
 
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
+ grantReward: (data: { studentId: string, count: number, rewardType: string, name?: string }) => request('/games/grant-reward', { method: 'POST', body: JSON.stringify(data) }),
174
  },
175
  rewards: {
176
  getMyRewards: (studentId: string) => request(`/rewards?studentId=${studentId}`),
 
177
  getClassRewards: () => request('/rewards?scope=class'),
178
  addReward: (data: Partial<StudentReward>) => request('/rewards', { method: 'POST', body: JSON.stringify(data) }),
179
+ update: (id: string, data: Partial<StudentReward>) => request(`/rewards/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
180
+ delete: (id: string) => request(`/rewards/${id}`, { method: 'DELETE' }),
181
  redeem: (id: string) => request(`/rewards/${id}/redeem`, { method: 'POST' }),
 
182
  },
183
 
184
  batchDelete: (type: 'student' | 'score' | 'user', ids: string[]) => {
types.ts CHANGED
@@ -194,8 +194,9 @@ export interface StudentReward {
194
  schoolId?: string;
195
  studentId: string; // Link to Student
196
  studentName: string;
197
- rewardType: 'ITEM' | 'DRAW_COUNT';
198
  name: string;
 
199
  status: 'PENDING' | 'REDEEMED';
200
  source: string; // "Mountain Game"
201
  createTime: string;
 
194
  schoolId?: string;
195
  studentId: string; // Link to Student
196
  studentName: string;
197
+ rewardType: 'ITEM' | 'DRAW_COUNT' | 'CONSOLATION'; // Updated
198
  name: string;
199
+ count?: number; // Quantity
200
  status: 'PENDING' | 'REDEEMED';
201
  source: string; // "Mountain Game"
202
  createTime: string;