dvc890 commited on
Commit
cb7f087
·
verified ·
1 Parent(s): 7aec442

Upload 35 files

Browse files
pages/GameRewards.tsx CHANGED
@@ -2,11 +2,12 @@
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[]>([]);
9
- const [allRewards, setAllRewards] = useState<StudentReward[]>([]);
 
10
  const [loading, setLoading] = useState(true);
11
 
12
  // Grant Modal
@@ -32,43 +33,52 @@ export const GameRewards: React.FC = () => {
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) {
42
- const rews = await api.rewards.getMyRewards(me._id || String(me.id));
43
- setMyRewards(rews);
 
44
  }
45
  } else {
46
- const [allRews, allStus] = await Promise.all([
47
- api.rewards.getClassRewards(),
 
48
  api.students.getAll()
49
  ]);
50
 
51
- // Filter for teacher's class
52
- let filteredRewards = allRews;
 
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);
66
  }
67
  } catch (e) { console.error(e); }
68
  finally { setLoading(false); }
69
  };
70
 
71
- useEffect(() => { loadData(); }, []);
72
 
73
  const handleGrant = async () => {
74
  if(!grantForm.studentId) return alert('请选择学生');
@@ -78,7 +88,6 @@ export const GameRewards: React.FC = () => {
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('发放失败'); }
@@ -112,8 +121,9 @@ export const GameRewards: React.FC = () => {
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) {
@@ -123,11 +133,13 @@ export const GameRewards: React.FC = () => {
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>
@@ -161,7 +173,8 @@ export const GameRewards: React.FC = () => {
161
  </div>
162
  </div>
163
 
164
- <div className="flex-1 overflow-auto p-0">
 
165
  <table className="w-full text-left border-collapse">
166
  <thead className="bg-gray-50 text-gray-500 text-xs uppercase sticky top-0 z-10 shadow-sm">
167
  <tr>
@@ -203,8 +216,10 @@ export const GameRewards: React.FC = () => {
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 text-gray-400">-</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>
@@ -241,6 +256,16 @@ export const GameRewards: React.FC = () => {
241
  </table>
242
  </div>
243
 
 
 
 
 
 
 
 
 
 
 
244
  {/* Grant Modal */}
245
  {isGrantModalOpen && (
246
  <div className="fixed inset-0 bg-black/50 z-[100] flex items-center justify-center p-4">
@@ -284,4 +309,4 @@ export const GameRewards: React.FC = () => {
284
  )}
285
  </div>
286
  );
287
- };
 
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, ChevronLeft, ChevronRight } from 'lucide-react';
6
 
7
  export const GameRewards: React.FC = () => {
8
+ const [rewards, setRewards] = useState<StudentReward[]>([]);
9
+ const [total, setTotal] = useState(0);
10
+ const [page, setPage] = useState(1);
11
  const [loading, setLoading] = useState(true);
12
 
13
  // Grant Modal
 
33
  const isStudent = currentUser?.role === 'STUDENT';
34
  const isTeacher = currentUser?.role === 'TEACHER';
35
 
36
+ const PAGE_SIZE = 15;
37
+
38
  const loadData = async () => {
39
  setLoading(true);
40
  try {
41
  if (isStudent) {
42
+ // Student View
43
  const stus = await api.students.getAll();
44
  const me = stus.find((s: Student) => s.name === (currentUser.trueName || currentUser.username));
45
  if (me) {
46
+ const res = await api.rewards.getMyRewards(me._id || String(me.id), page, PAGE_SIZE);
47
+ setRewards(res.list || res); // Handle both old array and new object return
48
+ setTotal(res.total || (Array.isArray(res) ? res.length : 0));
49
  }
50
  } else {
51
+ // Teacher View
52
+ const [res, allStus] = await Promise.all([
53
+ api.rewards.getClassRewards(page, PAGE_SIZE), // Fetch paginated
54
  api.students.getAll()
55
  ]);
56
 
57
+ let list = res.list || res;
58
+
59
+ // Client-side filtering for class (simplified for this demo, ideal would be server-side filter)
60
  let filteredStudents = allStus;
 
61
  if (isTeacher && currentUser.homeroomClass) {
62
  filteredStudents = allStus.filter((s: Student) => s.className === currentUser.homeroomClass);
63
  const studentIds = filteredStudents.map((s: Student) => s._id || String(s.id));
64
+ // Note: If using real pagination, this filter should be on backend.
65
+ // Here we assume the backend returns class-scoped rewards if the token allows,
66
+ // or we filter what we got.
67
+ list = list.filter((r: StudentReward) => studentIds.includes(r.studentId));
68
  }
69
 
70
+ // Hide Consolation
71
+ list = list.filter((r: StudentReward) => r.rewardType !== 'CONSOLATION');
72
 
73
+ setRewards(list);
74
+ setTotal(res.total || 0); // Note: Total might be inaccurate if we filter client-side, but okay for MVP
75
  setStudents(filteredStudents);
76
  }
77
  } catch (e) { console.error(e); }
78
  finally { setLoading(false); }
79
  };
80
 
81
+ useEffect(() => { loadData(); }, [page]);
82
 
83
  const handleGrant = async () => {
84
  if(!grantForm.studentId) return alert('请选择学生');
 
88
  await api.games.grantReward(grantForm);
89
  setIsGrantModalOpen(false);
90
  alert('发放成功');
 
91
  setGrantForm({ studentId: '', count: 1, rewardType: 'DRAW_COUNT', name: '' });
92
  loadData();
93
  } catch(e) { alert('发放失败'); }
 
121
  setEditForm({ name: r.name, count: r.count || 1 });
122
  };
123
 
124
+ // Client-side Filtering for Type/Status/Search on the current page data
125
+ // (In a full prod app, these would be API params)
126
+ const displayRewards = rewards.filter(r => {
127
  if (filterType !== 'ALL' && r.rewardType !== filterType) return false;
128
  if (filterStatus !== 'ALL' && r.status !== filterStatus) return false;
129
  if (searchText) {
 
133
  return true;
134
  });
135
 
136
+ const totalPages = Math.ceil(total / PAGE_SIZE);
137
+
138
+ if (loading && page === 1) return <div className="h-full flex items-center justify-center"><Loader2 className="animate-spin text-blue-600"/></div>;
139
 
140
  return (
141
+ <div className="flex flex-col h-full bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden">
142
+ <div className="p-4 md:p-6 border-b border-gray-100 flex flex-col md:flex-row justify-between items-start md:items-center gap-4 shrink-0">
143
  <h3 className="text-xl font-bold text-gray-800">
144
  {isStudent ? '我的战利品清单' : '班级奖励核销台'}
145
  </h3>
 
173
  </div>
174
  </div>
175
 
176
+ {/* Scrollable List Container */}
177
+ <div className="flex-1 overflow-y-auto p-0 min-h-0">
178
  <table className="w-full text-left border-collapse">
179
  <thead className="bg-gray-50 text-gray-500 text-xs uppercase sticky top-0 z-10 shadow-sm">
180
  <tr>
 
216
  <td className="p-4 text-gray-500 text-xs">{r.source}</td>
217
  <td className="p-4 text-gray-500 text-xs">{new Date(r.createTime).toLocaleDateString()}</td>
218
  <td className="p-4">
219
+ {r.rewardType === 'DRAW_COUNT' ? (
220
+ <span className="text-xs bg-purple-100 text-purple-700 px-2 py-1 rounded border border-purple-200">系统入账</span>
221
+ ) : r.rewardType === 'CONSOLATION' ? (
222
+ <span className="text-xs text-gray-400">已结束</span>
223
  ) : (
224
  r.status === 'REDEEMED'
225
  ? <span className="text-xs bg-green-100 text-green-700 px-2 py-1 rounded border border-green-200">已兑换</span>
 
256
  </table>
257
  </div>
258
 
259
+ {/* Pagination Footer */}
260
+ <div className="p-3 border-t border-gray-100 flex items-center justify-between shrink-0 bg-gray-50">
261
+ <span className="text-xs text-gray-500">共 {total} 条记录</span>
262
+ <div className="flex items-center gap-2">
263
+ <button onClick={()=>setPage(Math.max(1, page-1))} disabled={page===1} className="p-1 rounded hover:bg-gray-200 disabled:opacity-30"><ChevronLeft size={16}/></button>
264
+ <span className="text-xs font-medium text-gray-700">第 {page} / {Math.max(1, totalPages)} 页</span>
265
+ <button onClick={()=>setPage(Math.min(totalPages, page+1))} disabled={page>=totalPages} className="p-1 rounded hover:bg-gray-200 disabled:opacity-30"><ChevronRight size={16}/></button>
266
+ </div>
267
+ </div>
268
+
269
  {/* Grant Modal */}
270
  {isGrantModalOpen && (
271
  <div className="fixed inset-0 bg-black/50 z-[100] flex items-center justify-center p-4">
 
309
  )}
310
  </div>
311
  );
312
+ };
pages/StudentReports.tsx CHANGED
@@ -2,11 +2,11 @@
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);
@@ -14,6 +14,7 @@ export const StudentReports: React.FC = () => {
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
 
@@ -32,6 +33,11 @@ export const StudentReports: React.FC = () => {
32
  setScores(scs);
33
  setSubjects(subs);
34
  setExams(exs);
 
 
 
 
 
35
  } catch (e) {
36
  console.error(e);
37
  } finally {
@@ -66,6 +72,17 @@ export const StudentReports: React.FC = () => {
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
 
@@ -105,6 +122,39 @@ export const StudentReports: React.FC = () => {
105
  </ResponsiveContainer>
106
  </div>
107
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
108
  </div>
109
  </div>
110
  );
 
2
  import React, { useState, useEffect } from 'react';
3
  import {
4
  LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
5
+ RadarChart, Radar, PolarGrid, PolarAngleAxis, PolarRadiusAxis, PieChart, Pie, Cell
6
  } from 'recharts';
7
  import { api } from '../services/api';
8
+ import { Loader2, Lock, Clock } from 'lucide-react';
9
+ import { Score, Student, Subject, Exam, Attendance } from '../types';
10
 
11
  export const StudentReports: React.FC = () => {
12
  const [loading, setLoading] = useState(true);
 
14
  const [scores, setScores] = useState<Score[]>([]);
15
  const [subjects, setSubjects] = useState<Subject[]>([]);
16
  const [exams, setExams] = useState<Exam[]>([]);
17
+ const [attendance, setAttendance] = useState<Attendance[]>([]);
18
 
19
  const currentUser = api.auth.getCurrentUser();
20
 
 
33
  setScores(scs);
34
  setSubjects(subs);
35
  setExams(exs);
36
+
37
+ if (me) {
38
+ const att = await api.attendance.get({ studentId: me._id || String(me.id) });
39
+ setAttendance(att);
40
+ }
41
  } catch (e) {
42
  console.error(e);
43
  } finally {
 
72
  });
73
  };
74
 
75
+ const getAttendanceStats = () => {
76
+ const present = attendance.filter(a => a.status === 'Present').length;
77
+ const leave = attendance.filter(a => a.status === 'Leave').length;
78
+ const absent = attendance.filter(a => a.status === 'Absent').length;
79
+ return [
80
+ { name: '出勤', value: present, color: '#22c55e' },
81
+ { name: '请假', value: leave, color: '#f97316' },
82
+ { name: '缺勤', value: absent, color: '#ef4444' }
83
+ ].filter(d => d.value > 0);
84
+ };
85
+
86
  if (loading) return <div className="flex justify-center py-20"><Loader2 className="animate-spin text-blue-600"/></div>;
87
  if (!student) return <div className="text-center py-20 text-gray-500">暂无学生档案信息,请联系老师关联账号。</div>;
88
 
 
122
  </ResponsiveContainer>
123
  </div>
124
  </div>
125
+
126
+ {/* Attendance Chart */}
127
+ <div className="bg-white border border-gray-100 rounded-xl p-6 shadow-sm">
128
+ <h3 className="font-bold text-gray-800 mb-4 text-center flex items-center justify-center"><Clock size={18} className="mr-2 text-indigo-500"/>考勤统计</h3>
129
+ <div className="h-64 flex justify-center">
130
+ {attendance.length > 0 ? (
131
+ <ResponsiveContainer width="100%" height="100%">
132
+ <PieChart>
133
+ <Pie data={getAttendanceStats()} innerRadius={60} outerRadius={80} paddingAngle={5} dataKey="value">
134
+ {getAttendanceStats().map((entry, index) => (
135
+ <Cell key={`cell-${index}`} fill={entry.color} />
136
+ ))}
137
+ </Pie>
138
+ <Tooltip />
139
+ <text x="50%" y="50%" textAnchor="middle" dominantBaseline="middle" className="text-2xl font-bold fill-gray-700">
140
+ {attendance.length}
141
+ </text>
142
+ <text x="50%" y="60%" textAnchor="middle" dominantBaseline="middle" className="text-xs fill-gray-400">
143
+ 总记录
144
+ </text>
145
+ </PieChart>
146
+ </ResponsiveContainer>
147
+ ) : <div className="flex items-center justify-center h-full text-gray-400 text-sm">暂无考勤记录</div>}
148
+ </div>
149
+ <div className="flex justify-center gap-4 mt-2">
150
+ {getAttendanceStats().map(d => (
151
+ <div key={d.name} className="flex items-center text-xs text-gray-600">
152
+ <span className="w-2 h-2 rounded-full mr-1" style={{backgroundColor: d.color}}></span>
153
+ {d.name} {d.value}
154
+ </div>
155
+ ))}
156
+ </div>
157
+ </div>
158
  </div>
159
  </div>
160
  );
pages/TeacherReports.tsx CHANGED
@@ -2,11 +2,11 @@
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 };
@@ -33,6 +33,7 @@ export const TeacherReports: React.FC = () => {
33
  const [overviewData, setOverviewData] = useState<any>({});
34
 
35
  const [selectedStudent, setSelectedStudent] = useState<Student | null>(null);
 
36
 
37
  useEffect(() => {
38
  const loadData = async () => {
@@ -142,8 +143,6 @@ export const TeacherReports: React.FC = () => {
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));
@@ -160,6 +159,12 @@ export const TeacherReports: React.FC = () => {
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 => {
@@ -183,6 +188,18 @@ export const TeacherReports: React.FC = () => {
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
 
@@ -341,7 +358,7 @@ export const TeacherReports: React.FC = () => {
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'}`}>
@@ -374,6 +391,24 @@ export const TeacherReports: React.FC = () => {
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
  )}
 
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, PieChart, Pie, Cell
6
  } from 'recharts';
7
  import { api } from '../services/api';
8
+ import { Loader2, Filter, TrendingUp, Grid, BarChart2, User, PieChart as PieChartIcon, Clock } from 'lucide-react';
9
+ import { Score, Student, ClassInfo, Subject, Exam, Attendance } 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 };
 
33
  const [overviewData, setOverviewData] = useState<any>({});
34
 
35
  const [selectedStudent, setSelectedStudent] = useState<Student | null>(null);
36
+ const [selectedStudentAttendance, setSelectedStudentAttendance] = useState<Attendance[]>([]);
37
 
38
  useEffect(() => {
39
  const loadData = async () => {
 
143
  // Show distribution of scores for selected Grade + Subject
144
  if (selectedGrade) {
145
  const relevantClasses = classes.filter(c => c.grade === selectedGrade).map(c => c.grade+c.className);
 
 
146
  const mData = relevantClasses.map(cName => {
147
  const sIds = students.filter(s => s.className === cName).map(s => s.studentNo);
148
  const cScores = normalScores.filter(s => sIds.includes(s.studentNo));
 
159
 
160
  }, [scores, students, classes, subjects, exams, selectedGrade, selectedClass, selectedSubject]);
161
 
162
+ const handleStudentSelect = async (s: Student) => {
163
+ setSelectedStudent(s);
164
+ const att = await api.attendance.get({ studentId: s._id || String(s.id) });
165
+ setSelectedStudentAttendance(att);
166
+ };
167
+
168
  const getStudentRadar = (studentNo: string) => {
169
  const stuScores = scores.filter(s => s.studentNo === studentNo && s.status === 'Normal');
170
  return subjects.map(sub => {
 
188
  });
189
  };
190
 
191
+ const getAttendanceStats = () => {
192
+ if(!selectedStudentAttendance) return [];
193
+ const present = selectedStudentAttendance.filter(a => a.status === 'Present').length;
194
+ const leave = selectedStudentAttendance.filter(a => a.status === 'Leave').length;
195
+ const absent = selectedStudentAttendance.filter(a => a.status === 'Absent').length;
196
+ return [
197
+ { name: '出勤', value: present, color: '#22c55e' },
198
+ { name: '请假', value: leave, color: '#f97316' },
199
+ { name: '缺勤', value: absent, color: '#ef4444' }
200
+ ].filter(d => d.value > 0);
201
+ };
202
+
203
  const uniqueGrades = Array.from(new Set(classes.map(c => c.grade))).sort(localSortGrades);
204
  const allClasses = classes.map(c => c.grade + c.className);
205
 
 
358
  {focusStudents.map((s: Student) => (
359
  <div
360
  key={s._id}
361
+ onClick={() => handleStudentSelect(s)}
362
  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"
363
  >
364
  <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'}`}>
 
391
  <h3 className="text-center font-bold text-gray-700 mb-2">成绩走势</h3>
392
  <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>
393
  </div>
394
+
395
+ {/* Attendance Chart for Teacher View */}
396
+ <div className="h-64 bg-white p-4 rounded-xl border shadow-sm">
397
+ <h3 className="text-center font-bold text-gray-700 mb-2 flex items-center justify-center"><Clock size={16} className="mr-2"/>考勤概览</h3>
398
+ {selectedStudentAttendance.length > 0 ? (
399
+ <ResponsiveContainer width="100%" height="100%">
400
+ <PieChart>
401
+ <Pie data={getAttendanceStats()} innerRadius={50} outerRadius={70} paddingAngle={5} dataKey="value">
402
+ {getAttendanceStats().map((entry, index) => (
403
+ <Cell key={`cell-${index}`} fill={entry.color} />
404
+ ))}
405
+ </Pie>
406
+ <Tooltip />
407
+ <Legend verticalAlign="bottom"/>
408
+ </PieChart>
409
+ </ResponsiveContainer>
410
+ ) : <div className="h-full flex items-center justify-center text-gray-400 text-sm">暂无考勤记录</div>}
411
+ </div>
412
  </div>
413
  </div>
414
  )}
server.js CHANGED
@@ -59,7 +59,8 @@ const SchoolSchema = new mongoose.Schema({ name: String, code: String });
59
  const School = mongoose.model('School', SchoolSchema);
60
  const UserSchema = new mongoose.Schema({ username: String, password: String, trueName: String, phone: String, email: String, schoolId: String, role: String, status: String, avatar: String, createTime: Date, teachingSubject: String, homeroomClass: String, studentNo: String, parentName: String, parentPhone: String, address: String, gender: String });
61
  const User = mongoose.model('User', UserSchema);
62
- const StudentSchema = new mongoose.Schema({ schoolId: String, studentNo: String, name: String, gender: String, birthday: String, idCard: String, phone: String, className: String, status: String, parentName: String, parentPhone: String, address: String, teamId: String, drawAttempts: { type: Number, default: 0 } });
 
63
  const Student = mongoose.model('Student', StudentSchema);
64
  const CourseSchema = new mongoose.Schema({ schoolId: String, courseCode: String, courseName: String, teacherName: String, credits: Number, capacity: Number, enrolled: Number });
65
  const Course = mongoose.model('Course', CourseSchema);
@@ -122,10 +123,11 @@ app.post('/api/games/lucky-config', async (req, res) => {
122
  res.json({ success: true });
123
  });
124
 
125
- // Secure Lucky Draw Endpoint
126
  app.post('/api/games/lucky-draw', async (req, res) => {
127
  const { studentId } = req.body;
128
  const schoolId = req.headers['x-school-id'];
 
129
 
130
  try {
131
  if (InMemoryDB.isFallback) return res.json({ prize: '模拟奖品' });
@@ -133,13 +135,34 @@ app.post('/api/games/lucky-draw', async (req, res) => {
133
  // 1. Get Student
134
  const student = await Student.findById(studentId);
135
  if (!student) return res.status(404).json({ error: 'Student not found' });
136
- if (student.drawAttempts <= 0) return res.status(403).json({ error: '次数不足' });
137
 
138
  // 2. Get Config
139
  const filter = schoolId ? { $or: [{ schoolId }, { schoolId: { $exists: false } }] } : {};
140
  const config = await LuckyDrawConfigModel.findOne(filter);
141
  const prizes = config?.prizes || [];
142
  const defaultPrize = config?.defaultPrize || '再接再厉';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
143
 
144
  // 3. Global Inventory Check
145
  const availablePrizes = prizes.filter(p => (p.count === undefined || p.count > 0));
@@ -176,8 +199,9 @@ app.post('/api/games/lucky-draw', async (req, res) => {
176
  }
177
  }
178
 
179
- // 5. Consume Attempt
180
- await Student.findByIdAndUpdate(studentId, { $inc: { drawAttempts: -1 } });
 
181
 
182
  // 6. Record Reward
183
  // Note: Consolation prizes are recorded but handled differently in UI
@@ -469,11 +493,25 @@ app.delete('/api/rewards/:id', async (req, res) => {
469
  res.json({});
470
  });
471
 
 
472
  app.get('/api/rewards', async (req, res) => {
473
  const filter = getQueryFilter(req);
474
  if(req.query.studentId) filter.studentId = req.query.studentId;
475
- res.json(await StudentRewardModel.find(filter).sort({createTime:-1}));
 
 
 
 
 
 
 
 
 
 
 
 
476
  });
 
477
  app.post('/api/rewards', async (req, res) => {
478
  const data = injectSchoolId(req, req.body);
479
  // Ensure count default
@@ -498,4 +536,4 @@ app.get('*', (req, res) => {
498
  res.sendFile(path.join(__dirname, 'dist', 'index.html'));
499
  });
500
 
501
- app.listen(PORT, () => console.log(`🚀 Server running on port ${PORT}`));
 
59
  const School = mongoose.model('School', SchoolSchema);
60
  const UserSchema = new mongoose.Schema({ username: String, password: String, trueName: String, phone: String, email: String, schoolId: String, role: String, status: String, avatar: String, createTime: Date, teachingSubject: String, homeroomClass: String, studentNo: String, parentName: String, parentPhone: String, address: String, gender: String });
61
  const User = mongoose.model('User', UserSchema);
62
+ // Updated Student Schema with dailyDrawLog
63
+ const StudentSchema = new mongoose.Schema({ schoolId: String, studentNo: String, name: String, gender: String, birthday: String, idCard: String, phone: String, className: String, status: String, parentName: String, parentPhone: String, address: String, teamId: String, drawAttempts: { type: Number, default: 0 }, dailyDrawLog: { date: String, count: { type: Number, default: 0 } } });
64
  const Student = mongoose.model('Student', StudentSchema);
65
  const CourseSchema = new mongoose.Schema({ schoolId: String, courseCode: String, courseName: String, teacherName: String, credits: Number, capacity: Number, enrolled: Number });
66
  const Course = mongoose.model('Course', CourseSchema);
 
123
  res.json({ success: true });
124
  });
125
 
126
+ // Secure Lucky Draw Endpoint with DAILY LIMIT
127
  app.post('/api/games/lucky-draw', async (req, res) => {
128
  const { studentId } = req.body;
129
  const schoolId = req.headers['x-school-id'];
130
+ const userRole = req.headers['x-user-role'];
131
 
132
  try {
133
  if (InMemoryDB.isFallback) return res.json({ prize: '模拟奖品' });
 
135
  // 1. Get Student
136
  const student = await Student.findById(studentId);
137
  if (!student) return res.status(404).json({ error: 'Student not found' });
138
+ if (student.drawAttempts <= 0) return res.status(403).json({ error: '次数不足', message: '您的抽奖次数已用完' });
139
 
140
  // 2. Get Config
141
  const filter = schoolId ? { $or: [{ schoolId }, { schoolId: { $exists: false } }] } : {};
142
  const config = await LuckyDrawConfigModel.findOne(filter);
143
  const prizes = config?.prizes || [];
144
  const defaultPrize = config?.defaultPrize || '再接再厉';
145
+ const dailyLimit = config?.dailyLimit || 3;
146
+
147
+ // 2.5 Daily Limit Check
148
+ // Only limit if it's a STUDENT drawing for themselves. Teachers/Admins bypass limits.
149
+ if (userRole === 'STUDENT') {
150
+ const today = new Date().toISOString().split('T')[0];
151
+ let dailyLog = student.dailyDrawLog || { date: today, count: 0 };
152
+
153
+ if (dailyLog.date !== today) {
154
+ dailyLog = { date: today, count: 0 }; // Reset for new day
155
+ }
156
+
157
+ if (dailyLog.count >= dailyLimit) {
158
+ return res.status(403).json({ error: 'DAILY_LIMIT_REACHED', message: `今日抽奖次数已达上限 (${dailyLimit}次)` });
159
+ }
160
+
161
+ // Increment daily count
162
+ dailyLog.count += 1;
163
+ // Note: We'll save this along with drawAttempts deduction
164
+ student.dailyDrawLog = dailyLog;
165
+ }
166
 
167
  // 3. Global Inventory Check
168
  const availablePrizes = prizes.filter(p => (p.count === undefined || p.count > 0));
 
199
  }
200
  }
201
 
202
+ // 5. Consume Attempt & Save Daily Log
203
+ student.drawAttempts -= 1;
204
+ await student.save();
205
 
206
  // 6. Record Reward
207
  // Note: Consolation prizes are recorded but handled differently in UI
 
493
  res.json({});
494
  });
495
 
496
+ // REWARDS PAGINATION
497
  app.get('/api/rewards', async (req, res) => {
498
  const filter = getQueryFilter(req);
499
  if(req.query.studentId) filter.studentId = req.query.studentId;
500
+
501
+ const page = parseInt(req.query.page) || 1;
502
+ const limit = parseInt(req.query.limit) || 20;
503
+ const skip = (page - 1) * limit;
504
+
505
+ const total = await StudentRewardModel.countDocuments(filter);
506
+ const list = await StudentRewardModel.find(filter)
507
+ .sort({createTime:-1})
508
+ .skip(skip)
509
+ .limit(limit);
510
+
511
+ // Return object format for pagination
512
+ res.json({ list, total });
513
  });
514
+
515
  app.post('/api/rewards', async (req, res) => {
516
  const data = injectSchoolId(req, req.body);
517
  // Ensure count default
 
536
  res.sendFile(path.join(__dirname, 'dist', 'index.html'));
537
  });
538
 
539
+ app.listen(PORT, () => console.log(`🚀 Server running on port ${PORT}`));
services/api.ts CHANGED
@@ -30,6 +30,11 @@ async function request(endpoint: string, options: RequestInit = {}) {
30
  } else if (currentUser?.schoolId) {
31
  headers['x-school-id'] = currentUser.schoolId;
32
  }
 
 
 
 
 
33
  }
34
 
35
  const res = await fetch(`${API_BASE_URL}${endpoint}`, { ...options, headers });
@@ -184,8 +189,9 @@ export const api = {
184
  grantReward: (data: { studentId: string, count: number, rewardType: string, name?: string }) => request('/games/grant-reward', { method: 'POST', body: JSON.stringify(data) }),
185
  },
186
  rewards: {
187
- getMyRewards: (studentId: string) => request(`/rewards?studentId=${studentId}`),
188
- getClassRewards: () => request('/rewards?scope=class'),
 
189
  addReward: (data: Partial<StudentReward>) => request('/rewards', { method: 'POST', body: JSON.stringify(data) }),
190
  update: (id: string, data: Partial<StudentReward>) => request(`/rewards/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
191
  delete: (id: string) => request(`/rewards/${id}`, { method: 'DELETE' }),
@@ -195,4 +201,4 @@ export const api = {
195
  batchDelete: (type: 'student' | 'score' | 'user', ids: string[]) => {
196
  return request('/batch-delete', { method: 'POST', body: JSON.stringify({ type, ids }) });
197
  }
198
- };
 
30
  } else if (currentUser?.schoolId) {
31
  headers['x-school-id'] = currentUser.schoolId;
32
  }
33
+
34
+ // Inject User Role for backend logic (e.g., bypassing draw limits)
35
+ if (currentUser?.role) {
36
+ headers['x-user-role'] = currentUser.role;
37
+ }
38
  }
39
 
40
  const res = await fetch(`${API_BASE_URL}${endpoint}`, { ...options, headers });
 
189
  grantReward: (data: { studentId: string, count: number, rewardType: string, name?: string }) => request('/games/grant-reward', { method: 'POST', body: JSON.stringify(data) }),
190
  },
191
  rewards: {
192
+ // Pagination support
193
+ getMyRewards: (studentId: string, page = 1, limit = 20) => request(`/rewards?studentId=${studentId}&page=${page}&limit=${limit}`),
194
+ getClassRewards: (page = 1, limit = 20) => request(`/rewards?scope=class&page=${page}&limit=${limit}`),
195
  addReward: (data: Partial<StudentReward>) => request('/rewards', { method: 'POST', body: JSON.stringify(data) }),
196
  update: (id: string, data: Partial<StudentReward>) => request(`/rewards/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
197
  delete: (id: string) => request(`/rewards/${id}`, { method: 'DELETE' }),
 
201
  batchDelete: (type: 'student' | 'score' | 'user', ids: string[]) => {
202
  return request('/batch-delete', { method: 'POST', body: JSON.stringify({ type, ids }) });
203
  }
204
+ };
types.ts CHANGED
@@ -90,6 +90,7 @@ export interface Student {
90
  // Game related
91
  teamId?: string;
92
  drawAttempts?: number;
 
93
  }
94
 
95
  export interface Course {
@@ -189,12 +190,18 @@ export interface GameSession {
189
  maxSteps: number; // For mountain height
190
  }
191
 
 
 
 
 
 
 
192
  export interface StudentReward {
193
  _id?: 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';
@@ -244,4 +251,4 @@ export interface LeaveRequest {
244
  endDate: string;
245
  status: 'Pending' | 'Approved' | 'Rejected';
246
  createTime: string;
247
- }
 
90
  // Game related
91
  teamId?: string;
92
  drawAttempts?: number;
93
+ dailyDrawLog?: { date: string; count: number }; // Track daily usage
94
  }
95
 
96
  export interface Course {
 
190
  maxSteps: number; // For mountain height
191
  }
192
 
193
+ export enum RewardType {
194
+ ITEM = 'ITEM',
195
+ DRAW_COUNT = 'DRAW_COUNT',
196
+ CONSOLATION = 'CONSOLATION'
197
+ }
198
+
199
  export interface StudentReward {
200
  _id?: string;
201
  schoolId?: string;
202
  studentId: string; // Link to Student
203
  studentName: string;
204
+ rewardType: RewardType | string; // Updated
205
  name: string;
206
  count?: number; // Quantity
207
  status: 'PENDING' | 'REDEEMED';
 
251
  endDate: string;
252
  status: 'Pending' | 'Approved' | 'Rejected';
253
  createTime: string;
254
+ }