dvc890 commited on
Commit
b166dac
·
verified ·
1 Parent(s): be82d40

Upload 41 files

Browse files
pages/AchievementTeacher.tsx CHANGED
@@ -46,10 +46,15 @@ export const AchievementTeacher: React.FC = () => {
46
  api.config.getPublic() as Promise<SystemConfig>
47
  ]);
48
 
49
- // Filter students for homeroom & Sort by name
50
  const sortedStudents = stus
51
  .filter((s: Student) => s.className === homeroomClass)
52
- .sort((a: Student, b: Student) => a.name.localeCompare(b.name, 'zh-CN'));
 
 
 
 
 
53
 
54
  setStudents(sortedStudents);
55
 
@@ -209,7 +214,7 @@ export const AchievementTeacher: React.FC = () => {
209
  }}
210
  className={`p-2 rounded border cursor-pointer flex items-center justify-between ${selectedStudents.has(s._id || String(s.id)) ? 'bg-blue-50 border-blue-400' : 'bg-white border-gray-200 hover:bg-gray-50'}`}
211
  >
212
- <span className="text-sm font-medium">{s.name}</span>
213
  {selectedStudents.has(s._id || String(s.id)) && <CheckCircle size={16} className="text-blue-500"/>}
214
  </div>
215
  ))}
@@ -309,7 +314,7 @@ export const AchievementTeacher: React.FC = () => {
309
  <tbody className="divide-y divide-gray-100">
310
  {students.map(s => (
311
  <tr key={s._id} className="hover:bg-gray-50">
312
- <td className="p-4 font-bold text-gray-700">{s.name}</td>
313
  <td className="p-4">
314
  <span className="text-amber-600 font-bold bg-amber-50 px-2 py-1 rounded border border-amber-100">
315
  {s.flowerBalance || 0} 🌺
 
46
  api.config.getPublic() as Promise<SystemConfig>
47
  ]);
48
 
49
+ // Filter students for homeroom & Sort by SeatNo > Name
50
  const sortedStudents = stus
51
  .filter((s: Student) => s.className === homeroomClass)
52
+ .sort((a: Student, b: Student) => {
53
+ const seatA = parseInt(a.seatNo || '99999');
54
+ const seatB = parseInt(b.seatNo || '99999');
55
+ if (seatA !== seatB) return seatA - seatB;
56
+ return a.name.localeCompare(b.name, 'zh-CN');
57
+ });
58
 
59
  setStudents(sortedStudents);
60
 
 
214
  }}
215
  className={`p-2 rounded border cursor-pointer flex items-center justify-between ${selectedStudents.has(s._id || String(s.id)) ? 'bg-blue-50 border-blue-400' : 'bg-white border-gray-200 hover:bg-gray-50'}`}
216
  >
217
+ <span className="text-sm font-medium">{s.seatNo ? s.seatNo+'.':''}{s.name}</span>
218
  {selectedStudents.has(s._id || String(s.id)) && <CheckCircle size={16} className="text-blue-500"/>}
219
  </div>
220
  ))}
 
314
  <tbody className="divide-y divide-gray-100">
315
  {students.map(s => (
316
  <tr key={s._id} className="hover:bg-gray-50">
317
+ <td className="p-4 font-bold text-gray-700">{s.seatNo ? s.seatNo+'.':''}{s.name}</td>
318
  <td className="p-4">
319
  <span className="text-amber-600 font-bold bg-amber-50 px-2 py-1 rounded border border-amber-100">
320
  {s.flowerBalance || 0} 🌺
pages/Attendance.tsx CHANGED
@@ -22,6 +22,15 @@ export const AttendancePage: React.FC = () => {
22
  api.attendance.get({ className: targetClass, date })
23
  ]);
24
  const classStudents = stus.filter((s: Student) => s.className === targetClass);
 
 
 
 
 
 
 
 
 
25
  setStudents(classStudents);
26
 
27
  const map: Record<string, Attendance> = {};
@@ -138,7 +147,7 @@ export const AttendancePage: React.FC = () => {
138
  </div>
139
  <div className="text-center">
140
  <p className="font-bold text-gray-800 text-sm">{s.name}</p>
141
- <p className="text-[10px] text-gray-400">{s.studentNo}</p>
142
  </div>
143
  <div className={`flex items-center gap-1 text-xs font-bold ${textColor}`}>
144
  {icon} {text}
 
22
  api.attendance.get({ className: targetClass, date })
23
  ]);
24
  const classStudents = stus.filter((s: Student) => s.className === targetClass);
25
+
26
+ // SORTING LOGIC: Seat No > Name
27
+ classStudents.sort((a: Student, b: Student) => {
28
+ const seatA = parseInt(a.seatNo || '99999');
29
+ const seatB = parseInt(b.seatNo || '99999');
30
+ if (seatA !== seatB) return seatA - seatB;
31
+ return a.name.localeCompare(b.name, 'zh-CN');
32
+ });
33
+
34
  setStudents(classStudents);
35
 
36
  const map: Record<string, Attendance> = {};
 
147
  </div>
148
  <div className="text-center">
149
  <p className="font-bold text-gray-800 text-sm">{s.name}</p>
150
+ <p className="text-[10px] text-gray-400">{s.seatNo ? `${s.seatNo}号` : s.studentNo}</p>
151
  </div>
152
  <div className={`flex items-center gap-1 text-xs font-bold ${textColor}`}>
153
  {icon} {text}
pages/ClassList.tsx CHANGED
@@ -1,8 +1,18 @@
1
 
2
  import React, { useState, useEffect } from 'react';
3
- import { Plus, Trash2, Users, School, Loader2, User as UserIcon, Edit, Check, X, ShieldAlert } from 'lucide-react';
4
  import { api } from '../services/api';
5
  import { ClassInfo, User } from '../types';
 
 
 
 
 
 
 
 
 
 
6
 
7
  export const ClassList: React.FC = () => {
8
  const [classes, setClasses] = useState<ClassInfo[]>([]);
@@ -14,6 +24,9 @@ export const ClassList: React.FC = () => {
14
  const [submitting, setSubmitting] = useState(false);
15
  const [editClassId, setEditClassId] = useState<string | null>(null);
16
 
 
 
 
17
  // Form
18
  const [grade, setGrade] = useState('一年级');
19
  const [className, setClassName] = useState('(1)班');
@@ -32,14 +45,23 @@ export const ClassList: React.FC = () => {
32
  const [clsData, userData] = await Promise.all([
33
  api.classes.getAll(),
34
  api.users.getAll({ role: 'TEACHER', global: true }) // Get filtered users
35
- ]);
36
  setClasses(clsData);
37
- setTeachers(userData); // Now only contains teachers
 
 
 
 
 
38
 
39
  // Filter users with pending class applications
40
  const apps = userData.filter((u: User) => u.classApplication && u.classApplication.status === 'PENDING');
41
  setPendingApps(apps);
42
 
 
 
 
 
43
  } catch (e) {
44
  console.error(e);
45
  } finally {
@@ -80,9 +102,7 @@ export const ClassList: React.FC = () => {
80
  try {
81
  const payload = { grade, className, teacherName };
82
  if (editClassId) {
83
- // Update
84
- // Use a direct fetch or update api method to handle PUT
85
- // The api.classes.add uses POST, we need a custom call or add method update
86
  await fetch(`/api/classes/${editClassId}`, {
87
  method: 'PUT',
88
  headers: {
@@ -118,6 +138,22 @@ export const ClassList: React.FC = () => {
118
  } catch(e) { alert('操作失败'); }
119
  };
120
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
121
  return (
122
  <div className="space-y-6">
123
 
@@ -174,53 +210,62 @@ export const ClassList: React.FC = () => {
174
 
175
  {loading ? (
176
  <div className="flex justify-center py-10"><Loader2 className="animate-spin text-blue-600" /></div>
 
 
177
  ) : (
178
- <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
179
- {classes.map((cls) => (
180
- <div key={cls._id || cls.id} className="bg-white rounded-xl shadow-sm border border-gray-100 p-6 flex flex-col justify-between hover:shadow-md transition-shadow group">
181
- <div className="flex justify-between items-start">
182
- <div className="flex items-center space-x-3">
183
- <div className="p-3 bg-blue-50 text-blue-600 rounded-lg">
184
- <School size={24} />
185
- </div>
186
- <div>
187
- <h3 className="text-lg font-bold text-gray-800">{cls.grade}{cls.className}</h3>
188
- <p className="text-sm text-gray-500 flex items-center mt-1">
189
- <UserIcon size={14} className="mr-1"/>
190
- {cls.teacherName ? (
191
- <span className="text-blue-600 font-bold bg-blue-50 px-1.5 py-0.5 rounded text-xs">{cls.teacherName}</span>
192
- ) : (
193
- <span className="text-gray-400 text-xs">未任命班主��</span>
194
- )}
195
- </p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
196
  </div>
197
- </div>
198
- <div className="flex gap-1 opacity-100 md:opacity-0 group-hover:opacity-100 transition-opacity">
199
- <button
200
- onClick={() => openEditModal(cls)}
201
- className="p-1.5 text-gray-400 hover:text-blue-500 hover:bg-blue-50 rounded transition-colors"
202
- title="编辑班级信息"
203
- >
204
- <Edit size={18} />
205
- </button>
206
- <button
207
- onClick={() => handleDelete(cls._id || cls.id!)}
208
- className="p-1.5 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded transition-colors"
209
- title="删除班级"
210
- >
211
- <Trash2 size={18} />
212
- </button>
213
- </div>
214
- </div>
215
-
216
- <div className="mt-6 pt-4 border-t border-gray-50 flex items-center justify-between text-sm">
217
- <span className="text-gray-500 flex items-center">
218
- <Users size={16} className="mr-2" /> 学生人数
219
- </span>
220
- <span className="font-bold text-gray-800">{cls.studentCount || 0} 人</span>
221
- </div>
222
- </div>
223
- ))}
224
  </div>
225
  )}
226
 
@@ -241,10 +286,10 @@ export const ClassList: React.FC = () => {
241
  <input required type="text" value={className} onChange={e => setClassName(e.target.value)} className="w-full mt-1 px-3 py-2 border rounded-lg" placeholder="(1)班" />
242
  </div>
243
  <div>
244
- <label className="text-sm font-medium text-gray-700">指定班主任</label>
245
  <select value={teacherName} onChange={e => setTeacherName(e.target.value)} className="w-full mt-1 px-3 py-2 border rounded-lg bg-white">
246
  <option value="">-- 暂不指定 --</option>
247
- {teachers.sort((a,b)=>(a.trueName||a.username).localeCompare(b.trueName||b.username, 'zh-CN')).map(t => (
248
  <option key={t._id} value={t.trueName || t.username}>
249
  {t.trueName || t.username} {t.homeroomClass ? `(已任: ${t.homeroomClass})` : ''}
250
  </option>
 
1
 
2
  import React, { useState, useEffect } from 'react';
3
+ import { Plus, Trash2, Users, School, Loader2, User as UserIcon, Edit, Check, X, ShieldAlert, ChevronDown, ChevronRight } from 'lucide-react';
4
  import { api } from '../services/api';
5
  import { ClassInfo, User } from '../types';
6
+ import { gradeOrder, sortGrades } from './Dashboard';
7
+
8
+ // Helper to sort classes within a grade (numerically)
9
+ const sortClassesInGrade = (a: ClassInfo, b: ClassInfo) => {
10
+ const getNum = (s: string) => {
11
+ const match = s.match(/(\d+)/);
12
+ return match ? parseInt(match[1]) : 0;
13
+ };
14
+ return getNum(a.className) - getNum(b.className);
15
+ };
16
 
17
  export const ClassList: React.FC = () => {
18
  const [classes, setClasses] = useState<ClassInfo[]>([]);
 
24
  const [submitting, setSubmitting] = useState(false);
25
  const [editClassId, setEditClassId] = useState<string | null>(null);
26
 
27
+ // Expanded/Collapsed State for Grades
28
+ const [expandedGrades, setExpandedGrades] = useState<Set<string>>(new Set());
29
+
30
  // Form
31
  const [grade, setGrade] = useState('一年级');
32
  const [className, setClassName] = useState('(1)班');
 
45
  const [clsData, userData] = await Promise.all([
46
  api.classes.getAll(),
47
  api.users.getAll({ role: 'TEACHER', global: true }) // Get filtered users
48
+ ]) as [ClassInfo[], User[]];
49
  setClasses(clsData);
50
+
51
+ // Sort teachers alphabetically (Pinyin)
52
+ const sortedTeachers = userData.sort((a: User, b: User) =>
53
+ (a.trueName || a.username).localeCompare(b.trueName || b.username, 'zh-CN')
54
+ );
55
+ setTeachers(sortedTeachers);
56
 
57
  // Filter users with pending class applications
58
  const apps = userData.filter((u: User) => u.classApplication && u.classApplication.status === 'PENDING');
59
  setPendingApps(apps);
60
 
61
+ // Auto expand all grades initially
62
+ const allGrades = new Set<string>(clsData.map((c: ClassInfo) => c.grade));
63
+ setExpandedGrades(allGrades);
64
+
65
  } catch (e) {
66
  console.error(e);
67
  } finally {
 
102
  try {
103
  const payload = { grade, className, teacherName };
104
  if (editClassId) {
105
+ // Update via PUT
 
 
106
  await fetch(`/api/classes/${editClassId}`, {
107
  method: 'PUT',
108
  headers: {
 
138
  } catch(e) { alert('操作失败'); }
139
  };
140
 
141
+ const toggleGradeExpand = (g: string) => {
142
+ const newSet = new Set(expandedGrades);
143
+ if (newSet.has(g)) newSet.delete(g);
144
+ else newSet.add(g);
145
+ setExpandedGrades(newSet);
146
+ };
147
+
148
+ // Group Classes by Grade
149
+ const groupedClasses = classes.reduce((acc, cls) => {
150
+ if (!acc[cls.grade]) acc[cls.grade] = [];
151
+ acc[cls.grade].push(cls);
152
+ return acc;
153
+ }, {} as Record<string, ClassInfo[]>);
154
+
155
+ const sortedGradeKeys = Object.keys(groupedClasses).sort(sortGrades);
156
+
157
  return (
158
  <div className="space-y-6">
159
 
 
210
 
211
  {loading ? (
212
  <div className="flex justify-center py-10"><Loader2 className="animate-spin text-blue-600" /></div>
213
+ ) : sortedGradeKeys.length === 0 ? (
214
+ <div className="text-center py-20 text-gray-400 border-2 border-dashed rounded-xl">暂无班级数据,请点击“新增班级”</div>
215
  ) : (
216
+ <div className="space-y-6">
217
+ {sortedGradeKeys.map(gradeKey => {
218
+ const gradeClasses = groupedClasses[gradeKey].sort(sortClassesInGrade);
219
+ const isExpanded = expandedGrades.has(gradeKey);
220
+
221
+ return (
222
+ <div key={gradeKey} className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
223
+ <div
224
+ className="p-4 bg-gray-50 flex items-center justify-between cursor-pointer hover:bg-gray-100 transition-colors"
225
+ onClick={() => toggleGradeExpand(gradeKey)}
226
+ >
227
+ <h3 className="font-bold text-gray-800 flex items-center">
228
+ {isExpanded ? <ChevronDown size={20} className="mr-2 text-gray-500"/> : <ChevronRight size={20} className="mr-2 text-gray-500"/>}
229
+ {gradeKey}
230
+ <span className="ml-2 text-xs bg-white border px-2 py-0.5 rounded-full text-gray-500 font-normal">{gradeClasses.length} 个班级</span>
231
+ </h3>
232
+ </div>
233
+
234
+ {isExpanded && (
235
+ <div className="p-4 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 animate-in slide-in-from-top-2 duration-300">
236
+ {gradeClasses.map(cls => (
237
+ <div key={cls._id || cls.id} className="border border-gray-200 rounded-lg p-4 hover:shadow-md transition-shadow relative group bg-white">
238
+ <div className="flex justify-between items-start mb-2">
239
+ <div className="flex items-center gap-2">
240
+ <div className="p-2 bg-blue-50 text-blue-600 rounded-lg">
241
+ <School size={20} />
242
+ </div>
243
+ <h4 className="font-bold text-gray-800 text-lg">{cls.className}</h4>
244
+ </div>
245
+ <div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
246
+ <button onClick={(e) => {e.stopPropagation(); openEditModal(cls);}} className="p-1 text-gray-400 hover:text-blue-500 hover:bg-blue-50 rounded"><Edit size={16}/></button>
247
+ <button onClick={(e) => {e.stopPropagation(); handleDelete(cls._id || cls.id!);}} className="p-1 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded"><Trash2 size={16}/></button>
248
+ </div>
249
+ </div>
250
+ <div className="flex justify-between items-center text-sm mt-3">
251
+ <div className="flex items-center text-gray-500" title="班主任">
252
+ <UserIcon size={14} className="mr-1"/>
253
+ {cls.teacherName ? (
254
+ <span className="font-bold text-gray-700">{cls.teacherName}</span>
255
+ ) : <span className="text-gray-300">未设置</span>}
256
+ </div>
257
+ <div className="flex items-center text-gray-500" title="学生人数">
258
+ <Users size={14} className="mr-1"/>
259
+ <span>{cls.studentCount || 0}</span>
260
+ </div>
261
+ </div>
262
+ </div>
263
+ ))}
264
+ </div>
265
+ )}
266
  </div>
267
+ );
268
+ })}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
269
  </div>
270
  )}
271
 
 
286
  <input required type="text" value={className} onChange={e => setClassName(e.target.value)} className="w-full mt-1 px-3 py-2 border rounded-lg" placeholder="(1)班" />
287
  </div>
288
  <div>
289
+ <label className="text-sm font-medium text-gray-700">指定班主任 (按姓名排序)</label>
290
  <select value={teacherName} onChange={e => setTeacherName(e.target.value)} className="w-full mt-1 px-3 py-2 border rounded-lg bg-white">
291
  <option value="">-- 暂不指定 --</option>
292
+ {teachers.map(t => (
293
  <option key={t._id} value={t.trueName || t.username}>
294
  {t.trueName || t.username} {t.homeroomClass ? `(已任: ${t.homeroomClass})` : ''}
295
  </option>
pages/CourseList.tsx CHANGED
@@ -1,14 +1,18 @@
1
 
2
  import React, { useEffect, useState } from 'react';
3
- import { Plus, Clock, Loader2, Edit, Trash2, X } from 'lucide-react';
4
  import { api } from '../services/api';
5
  import { Course, Subject, User } from '../types';
6
 
7
  export const CourseList: React.FC = () => {
8
  const [courses, setCourses] = useState<Course[]>([]);
9
  const [subjects, setSubjects] = useState<Subject[]>([]);
10
- const [teachers, setTeachers] = useState<User[]>([]); // New: List of teachers
11
  const [loading, setLoading] = useState(true);
 
 
 
 
12
  const [isModalOpen, setIsModalOpen] = useState(false);
13
  const [formData, setFormData] = useState({ courseCode: '', courseName: '', teacherName: '', credits: 2, capacity: 45 });
14
  const [editId, setEditId] = useState<string | null>(null);
@@ -19,7 +23,7 @@ export const CourseList: React.FC = () => {
19
  const [c, s, t] = await Promise.all([
20
  api.courses.getAll(),
21
  api.subjects.getAll(),
22
- api.users.getAll({ role: 'TEACHER' }) // Fetch teachers
23
  ]);
24
  setCourses(c);
25
  setSubjects(s);
@@ -41,45 +45,72 @@ export const CourseList: React.FC = () => {
41
  if(confirm('删除课程?')) { await api.courses.delete(id); loadData(); }
42
  };
43
 
 
 
44
  return (
45
  <div className="space-y-6">
46
- <div className="flex justify-between items-center">
47
- <h2 className="text-xl font-bold">课程列表</h2>
48
- <button onClick={() => { setEditId(null); setIsModalOpen(true); }} className="px-4 py-2 bg-indigo-600 text-white rounded-lg flex items-center text-sm"><Plus size={16} className="mr-1"/> 新增课程</button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  </div>
50
 
51
- {loading ? <Loader2 className="animate-spin"/> : (
52
- <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
53
- {courses.map(c => (
54
- <div key={c._id || c.id} className="bg-white p-6 rounded-xl border shadow-sm">
55
- <h3 className="font-bold text-lg">{c.courseName}</h3>
56
- <p className="text-sm text-gray-500">教师: {c.teacherName}</p>
57
- <div className="mt-4 flex gap-2">
58
- <button onClick={() => { setFormData({ courseCode: c.courseCode, courseName: c.courseName, teacherName: c.teacherName, credits: c.credits, capacity: c.capacity }); setEditId(c._id || String(c.id)); setIsModalOpen(true); }} className="flex-1 bg-gray-50 text-blue-600 py-1 rounded text-sm">编辑</button>
59
- <button onClick={() => handleDelete(c._id || String(c.id))} className="flex-1 bg-gray-50 text-red-600 py-1 rounded text-sm">删除</button>
60
- </div>
61
- </div>
62
- ))}
63
- </div>
 
 
 
 
 
 
 
 
 
64
  )}
65
 
66
  {isModalOpen && (
67
- <div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
68
- <div className="bg-white p-6 rounded-xl w-full max-w-md">
69
- <h3 className="font-bold mb-4">{editId ? '编辑' : '新增'}</h3>
70
  <form onSubmit={handleSubmit} className="space-y-4">
71
  <div>
72
- <label className="text-sm font-bold text-gray-500">科目</label>
73
- <select className="w-full border p-2 rounded" value={formData.courseName} onChange={e=>setFormData({...formData, courseName:e.target.value})} required>
74
  <option value="">选择科目</option>
75
  {subjects.map(s=><option key={s._id} value={s.name}>{s.name}</option>)}
76
  </select>
77
  </div>
78
 
79
  <div>
80
- <label className="text-sm font-bold text-gray-500">任课教师</label>
81
  <input
82
- className="w-full border p-2 rounded"
83
  placeholder="选择或输入教师姓名"
84
  value={formData.teacherName}
85
  onChange={e=>setFormData({...formData, teacherName:e.target.value})}
@@ -91,8 +122,10 @@ export const CourseList: React.FC = () => {
91
  </datalist>
92
  </div>
93
 
94
- <button className="w-full bg-blue-600 text-white py-2 rounded">保存</button>
95
- <button type="button" onClick={()=>setIsModalOpen(false)} className="w-full border py-2 rounded">取消</button>
 
 
96
  </form>
97
  </div>
98
  </div>
 
1
 
2
  import React, { useEffect, useState } from 'react';
3
+ import { Plus, Clock, Loader2, Edit, Trash2, X, Filter } from 'lucide-react';
4
  import { api } from '../services/api';
5
  import { Course, Subject, User } from '../types';
6
 
7
  export const CourseList: React.FC = () => {
8
  const [courses, setCourses] = useState<Course[]>([]);
9
  const [subjects, setSubjects] = useState<Subject[]>([]);
10
+ const [teachers, setTeachers] = useState<User[]>([]);
11
  const [loading, setLoading] = useState(true);
12
+
13
+ // Filter state
14
+ const [filterSubject, setFilterSubject] = useState('All');
15
+
16
  const [isModalOpen, setIsModalOpen] = useState(false);
17
  const [formData, setFormData] = useState({ courseCode: '', courseName: '', teacherName: '', credits: 2, capacity: 45 });
18
  const [editId, setEditId] = useState<string | null>(null);
 
23
  const [c, s, t] = await Promise.all([
24
  api.courses.getAll(),
25
  api.subjects.getAll(),
26
+ api.users.getAll({ role: 'TEACHER' })
27
  ]);
28
  setCourses(c);
29
  setSubjects(s);
 
45
  if(confirm('删除课程?')) { await api.courses.delete(id); loadData(); }
46
  };
47
 
48
+ const filteredCourses = courses.filter(c => filterSubject === 'All' || c.courseName === filterSubject);
49
+
50
  return (
51
  <div className="space-y-6">
52
+ <div className="flex flex-col md:flex-row justify-between items-center gap-4">
53
+ <h2 className="text-xl font-bold text-gray-800">课程列表</h2>
54
+
55
+ <div className="flex items-center gap-3 w-full md:w-auto">
56
+ <div className="flex items-center bg-white border rounded-lg px-2 py-1.5 flex-1 md:flex-none">
57
+ <Filter size={16} className="text-gray-400 mr-2"/>
58
+ <select
59
+ className="bg-transparent text-sm outline-none text-gray-700 min-w-[100px]"
60
+ value={filterSubject}
61
+ onChange={e => setFilterSubject(e.target.value)}
62
+ >
63
+ <option value="All">所有科目</option>
64
+ {subjects.map(s => <option key={s._id} value={s.name}>{s.name}</option>)}
65
+ </select>
66
+ </div>
67
+ <button onClick={() => { setEditId(null); setIsModalOpen(true); }} className="px-4 py-2 bg-indigo-600 text-white rounded-lg flex items-center text-sm hover:bg-indigo-700 transition-colors whitespace-nowrap">
68
+ <Plus size={16} className="mr-1"/> 新增课程
69
+ </button>
70
+ </div>
71
  </div>
72
 
73
+ {loading ? <div className="flex justify-center py-10"><Loader2 className="animate-spin text-indigo-600"/></div> : (
74
+ filteredCourses.length > 0 ? (
75
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
76
+ {filteredCourses.map(c => (
77
+ <div key={c._id || c.id} className="bg-white p-6 rounded-xl border shadow-sm hover:shadow-md transition-shadow">
78
+ <h3 className="font-bold text-lg text-gray-800 mb-1">{c.courseName}</h3>
79
+ <p className="text-sm text-gray-500 mb-4 flex items-center gap-2">
80
+ <span className="bg-gray-100 px-2 py-0.5 rounded text-xs">{c.courseCode || 'No Code'}</span>
81
+ <span>教师: {c.teacherName}</span>
82
+ </p>
83
+ <div className="flex gap-2 pt-2 border-t border-gray-50">
84
+ <button onClick={() => { setFormData({ courseCode: c.courseCode, courseName: c.courseName, teacherName: c.teacherName, credits: c.credits, capacity: c.capacity }); setEditId(c._id || String(c.id)); setIsModalOpen(true); }} className="flex-1 bg-gray-50 text-blue-600 py-1.5 rounded text-sm hover:bg-blue-50 transition-colors font-medium">编辑</button>
85
+ <button onClick={() => handleDelete(c._id || String(c.id))} className="flex-1 bg-gray-50 text-red-600 py-1.5 rounded text-sm hover:bg-red-50 transition-colors font-medium">删除</button>
86
+ </div>
87
+ </div>
88
+ ))}
89
+ </div>
90
+ ) : (
91
+ <div className="text-center py-12 text-gray-400 bg-white rounded-xl border border-dashed border-gray-200">
92
+ 没有找到符合条件的课程
93
+ </div>
94
+ )
95
  )}
96
 
97
  {isModalOpen && (
98
+ <div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50 backdrop-blur-sm">
99
+ <div className="bg-white p-6 rounded-xl w-full max-w-md shadow-xl animate-in zoom-in-95">
100
+ <h3 className="font-bold mb-4 text-lg text-gray-800">{editId ? '编辑课程' : '新增课程'}</h3>
101
  <form onSubmit={handleSubmit} className="space-y-4">
102
  <div>
103
+ <label className="text-sm font-bold text-gray-500 mb-1 block">科目</label>
104
+ <select className="w-full border p-2 rounded-lg focus:ring-2 focus:ring-indigo-500 outline-none" value={formData.courseName} onChange={e=>setFormData({...formData, courseName:e.target.value})} required>
105
  <option value="">选择科目</option>
106
  {subjects.map(s=><option key={s._id} value={s.name}>{s.name}</option>)}
107
  </select>
108
  </div>
109
 
110
  <div>
111
+ <label className="text-sm font-bold text-gray-500 mb-1 block">任课教师</label>
112
  <input
113
+ className="w-full border p-2 rounded-lg focus:ring-2 focus:ring-indigo-500 outline-none"
114
  placeholder="选择或输入教师姓名"
115
  value={formData.teacherName}
116
  onChange={e=>setFormData({...formData, teacherName:e.target.value})}
 
122
  </datalist>
123
  </div>
124
 
125
+ <div className="flex gap-3 pt-2">
126
+ <button type="button" onClick={()=>setIsModalOpen(false)} className="flex-1 border py-2 rounded-lg hover:bg-gray-50">取消</button>
127
+ <button className="flex-1 bg-indigo-600 text-white py-2 rounded-lg hover:bg-indigo-700 shadow-md">保存</button>
128
+ </div>
129
  </form>
130
  </div>
131
  </div>
pages/Dashboard.tsx CHANGED
@@ -15,13 +15,22 @@ export const gradeOrder: Record<string, number> = {
15
  '初一': 7, '七年级': 7, '初二': 8, '八年级': 8, '初三': 9, '九年级': 9,
16
  '高一': 10, '高二': 11, '高三': 12
17
  };
 
18
  export const sortGrades = (a: string, b: string) => (gradeOrder[a] || 99) - (gradeOrder[b] || 99);
 
19
  export const sortClasses = (a: string, b: string) => {
 
20
  const getGrade = (s: string) => Object.keys(gradeOrder).find(g => s.startsWith(g)) || '';
21
  const gradeA = getGrade(a);
22
  const gradeB = getGrade(b);
23
- if (gradeA !== gradeB) return sortGrades(gradeA, gradeB);
24
- const getNum = (s: string) => parseInt(s.replace(/[^0-9]/g, '')) || 0;
 
 
 
 
 
 
25
  return getNum(a) - getNum(b);
26
  };
27
 
 
15
  '初一': 7, '七年级': 7, '初二': 8, '八年级': 8, '初三': 9, '九年级': 9,
16
  '高一': 10, '高二': 11, '高三': 12
17
  };
18
+
19
  export const sortGrades = (a: string, b: string) => (gradeOrder[a] || 99) - (gradeOrder[b] || 99);
20
+
21
  export const sortClasses = (a: string, b: string) => {
22
+ // 1. Sort by Grade First
23
  const getGrade = (s: string) => Object.keys(gradeOrder).find(g => s.startsWith(g)) || '';
24
  const gradeA = getGrade(a);
25
  const gradeB = getGrade(b);
26
+ const gradeDiff = sortGrades(gradeA, gradeB);
27
+ if (gradeDiff !== 0) return gradeDiff;
28
+
29
+ // 2. Sort by Class Number (extract digits)
30
+ const getNum = (s: string) => {
31
+ const match = s.match(/(\d+)/);
32
+ return match ? parseInt(match[1]) : 0;
33
+ };
34
  return getNum(a) - getNum(b);
35
  };
36
 
pages/GameLucky.tsx CHANGED
@@ -59,7 +59,16 @@ export const GameLucky: React.FC = () => {
59
  if (isTeacher) {
60
  if (currentUser.homeroomClass) {
61
  targetClass = currentUser.homeroomClass;
62
- setStudents(allStus.filter((s: Student) => s.className === currentUser.homeroomClass));
 
 
 
 
 
 
 
 
 
63
  } else {
64
  setStudents(allStus);
65
  }
@@ -180,7 +189,7 @@ export const GameLucky: React.FC = () => {
180
  onChange={e => setProxyStudentId(e.target.value)}
181
  >
182
  <option value="">-- 请选择 --</option>
183
- {students.map(s => <option key={s._id} value={s._id || String(s.id)}>{s.name} ({s.studentNo})</option>)}
184
  </select>
185
  </div>
186
  </div>
 
59
  if (isTeacher) {
60
  if (currentUser.homeroomClass) {
61
  targetClass = currentUser.homeroomClass;
62
+ const filtered = allStus.filter((s: Student) => s.className === currentUser.homeroomClass);
63
+
64
+ // Sort by Seat No
65
+ filtered.sort((a, b) => {
66
+ const seatA = parseInt(a.seatNo || '99999');
67
+ const seatB = parseInt(b.seatNo || '99999');
68
+ if (seatA !== seatB) return seatA - seatB;
69
+ return a.name.localeCompare(b.name, 'zh-CN');
70
+ });
71
+ setStudents(filtered);
72
  } else {
73
  setStudents(allStus);
74
  }
 
189
  onChange={e => setProxyStudentId(e.target.value)}
190
  >
191
  <option value="">-- 请选择 --</option>
192
+ {students.map(s => <option key={s._id} value={s._id || String(s.id)}>{s.seatNo ? s.seatNo+'.':''}{s.name}</option>)}
193
  </select>
194
  </div>
195
  </div>
pages/GameMountain.tsx CHANGED
@@ -93,10 +93,12 @@ export const GameMountain: React.FC = () => {
93
 
94
  const allStudents = await api.students.getAll();
95
  let targetClass = '';
 
96
 
97
  if (isTeacher && currentUser.homeroomClass) {
98
  targetClass = currentUser.homeroomClass;
99
- setStudents(allStudents.filter((s: Student) => s.className === targetClass));
 
100
  // Load Achievements for this class
101
  const ac = await api.achievements.getConfig(targetClass);
102
  setAchConfig(ac);
@@ -104,10 +106,18 @@ export const GameMountain: React.FC = () => {
104
  const me = allStudents.find((s: Student) => s.name === (currentUser.trueName || currentUser.username));
105
  if (me) targetClass = me.className;
106
  } else if (isAdmin) {
107
- setStudents(allStudents); // Admin sees all (simplified)
108
- // Ideally admin selects a class first, but for now fallback to first class or empty
109
  }
110
 
 
 
 
 
 
 
 
 
 
111
  if (targetClass) {
112
  const sess = await api.games.getMountainSession(targetClass);
113
  if (sess) {
@@ -140,7 +150,6 @@ export const GameMountain: React.FC = () => {
140
  if (!session || !isTeacher) return;
141
 
142
  // We need current config to check for semester (for achievements)
143
- // Assuming current semester from global config if needed, or default
144
  const sysConfig = await api.config.getPublic();
145
  const currentSemester = sysConfig?.semester || '当前学期';
146
 
@@ -177,7 +186,6 @@ export const GameMountain: React.FC = () => {
177
  }
178
  }
179
  });
180
- // alert(`🎉 ${t.name} 到达 ${newScore} 步!奖励已发放!`); // Optional: Notification
181
  }
182
  }
183
  return { ...t, score: newScore };
@@ -392,7 +400,7 @@ export const GameMountain: React.FC = () => {
392
  }`}
393
  title={isInOther ? `已在 ${otherTeam?.name}` : ''}
394
  >
395
- <span className="truncate font-medium">{s.name}</span>
396
  {isInCurrent && <CheckSquare size={14} className="text-blue-200"/>}
397
  {isInOther && <span className="text-[10px]">{otherTeam?.avatar}</span>}
398
  </div>
 
93
 
94
  const allStudents = await api.students.getAll();
95
  let targetClass = '';
96
+ let filteredStudents: Student[] = [];
97
 
98
  if (isTeacher && currentUser.homeroomClass) {
99
  targetClass = currentUser.homeroomClass;
100
+ filteredStudents = allStudents.filter((s: Student) => s.className === targetClass);
101
+
102
  // Load Achievements for this class
103
  const ac = await api.achievements.getConfig(targetClass);
104
  setAchConfig(ac);
 
106
  const me = allStudents.find((s: Student) => s.name === (currentUser.trueName || currentUser.username));
107
  if (me) targetClass = me.className;
108
  } else if (isAdmin) {
109
+ filteredStudents = allStudents;
 
110
  }
111
 
112
+ // Sort students
113
+ filteredStudents.sort((a, b) => {
114
+ const seatA = parseInt(a.seatNo || '99999');
115
+ const seatB = parseInt(b.seatNo || '99999');
116
+ if (seatA !== seatB) return seatA - seatB;
117
+ return a.name.localeCompare(b.name, 'zh-CN');
118
+ });
119
+ setStudents(filteredStudents);
120
+
121
  if (targetClass) {
122
  const sess = await api.games.getMountainSession(targetClass);
123
  if (sess) {
 
150
  if (!session || !isTeacher) return;
151
 
152
  // We need current config to check for semester (for achievements)
 
153
  const sysConfig = await api.config.getPublic();
154
  const currentSemester = sysConfig?.semester || '当前学期';
155
 
 
186
  }
187
  }
188
  });
 
189
  }
190
  }
191
  return { ...t, score: newScore };
 
400
  }`}
401
  title={isInOther ? `已在 ${otherTeam?.name}` : ''}
402
  >
403
+ <span className="truncate font-medium">{s.seatNo ? s.seatNo+'.':''}{s.name}</span>
404
  {isInCurrent && <CheckSquare size={14} className="text-blue-200"/>}
405
  {isInOther && <span className="text-[10px]">{otherTeam?.avatar}</span>}
406
  </div>
pages/GameRewards.tsx CHANGED
@@ -49,7 +49,6 @@ export const GameRewards: React.FC = () => {
49
  }
50
  } else {
51
  // Teacher View
52
- // Only fetch students for grant dropdown
53
  const allStus = await api.students.getAll();
54
 
55
  let targetClass = '';
@@ -60,14 +59,19 @@ export const GameRewards: React.FC = () => {
60
  filteredStudents = allStus.filter((s: Student) => s.className === currentUser.homeroomClass);
61
  }
62
 
63
- // Fetch filtered by class directly from API
64
- // Backend handles excluding CONSOLATION now
65
  const res = await api.rewards.getClassRewards(page, PAGE_SIZE, targetClass);
66
 
67
  setRewards(res.list || []);
68
  setTotal(res.total || 0);
69
- // Sort students for dropdown by name (pinyin)
70
- setStudents(filteredStudents.sort((a: Student, b: Student) => a.name.localeCompare(b.name, 'zh-CN')));
 
 
 
 
 
 
 
71
  }
72
  } catch (e) { console.error(e); }
73
  finally { setLoading(false); }
@@ -116,8 +120,7 @@ export const GameRewards: React.FC = () => {
116
  setEditForm({ name: r.name, count: r.count || 1 });
117
  };
118
 
119
- // Client-side Filtering for Type/Status/Search on the current page data
120
- // (In a full prod app, these would be API params, but okay for small page sizes)
121
  const displayRewards = rewards.filter(r => {
122
  if (filterType !== 'ALL' && r.rewardType !== filterType) return false;
123
  if (filterStatus !== 'ALL' && r.status !== filterStatus) return false;
@@ -271,7 +274,7 @@ export const GameRewards: React.FC = () => {
271
  <label className="block text-xs font-bold text-gray-500 uppercase mb-1">选择学生</label>
272
  <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})}>
273
  <option value="">-- 请选择 --</option>
274
- {students.map(s => <option key={s._id} value={s._id || String(s.id)}>{s.name} ({s.studentNo})</option>)}
275
  </select>
276
  </div>
277
  <div>
 
49
  }
50
  } else {
51
  // Teacher View
 
52
  const allStus = await api.students.getAll();
53
 
54
  let targetClass = '';
 
59
  filteredStudents = allStus.filter((s: Student) => s.className === currentUser.homeroomClass);
60
  }
61
 
 
 
62
  const res = await api.rewards.getClassRewards(page, PAGE_SIZE, targetClass);
63
 
64
  setRewards(res.list || []);
65
  setTotal(res.total || 0);
66
+
67
+ // Sort students: Seat No > Name
68
+ filteredStudents.sort((a: Student, b: Student) => {
69
+ const seatA = parseInt(a.seatNo || '99999');
70
+ const seatB = parseInt(b.seatNo || '99999');
71
+ if (seatA !== seatB) return seatA - seatB;
72
+ return a.name.localeCompare(b.name, 'zh-CN');
73
+ });
74
+ setStudents(filteredStudents);
75
  }
76
  } catch (e) { console.error(e); }
77
  finally { setLoading(false); }
 
120
  setEditForm({ name: r.name, count: r.count || 1 });
121
  };
122
 
123
+ // Client-side Filtering
 
124
  const displayRewards = rewards.filter(r => {
125
  if (filterType !== 'ALL' && r.rewardType !== filterType) return false;
126
  if (filterStatus !== 'ALL' && r.status !== filterStatus) return false;
 
274
  <label className="block text-xs font-bold text-gray-500 uppercase mb-1">选择学生</label>
275
  <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})}>
276
  <option value="">-- 请选择 --</option>
277
+ {students.map(s => <option key={s._id} value={s._id || String(s.id)}>{s.seatNo ? s.seatNo+'.':''}{s.name}</option>)}
278
  </select>
279
  </div>
280
  <div>
pages/ScoreList.tsx CHANGED
@@ -2,27 +2,30 @@
2
  import React, { useState, useEffect } from 'react';
3
  import { api } from '../services/api';
4
  import { Score, Student, Subject, ClassInfo, ExamStatus, Exam, SystemConfig } from '../types';
5
- import { Loader2, Plus, Trash2, Award, FileSpreadsheet, X, Upload, Edit, Save, Calendar } from 'lucide-react';
6
 
7
- // Reuse sort helper
8
  const localSortGrades = (a: string, b: string) => {
9
  const order: Record<string, number> = { '一年级': 1, '二年级': 2, '三年级': 3, '四年级': 4, '五年级': 5, '六年级': 6, '初一': 7, '七年级': 7, '初二': 8, '八年级': 8, '初三': 9, '九年级': 9, '高一': 10, '高二': 11, '高三': 12 };
10
  return (order[a] || 99) - (order[b] || 99);
11
  };
12
 
13
  export const ScoreList: React.FC = () => {
14
- // ... state ...
15
  const [subjects, setSubjects] = useState<Subject[]>([]);
16
  const [activeSubject, setActiveSubject] = useState('');
17
  const [scores, setScores] = useState<Score[]>([]);
18
  const [students, setStudents] = useState<Student[]>([]);
19
  const [classList, setClassList] = useState<ClassInfo[]>([]);
20
  const [exams, setExams] = useState<Exam[]>([]);
21
- const [semesters, setSemesters] = useState<string[]>([]); // New State
22
  const [loading, setLoading] = useState(true);
23
 
 
24
  const [selectedGrade, setSelectedGrade] = useState('All');
25
  const [selectedClass, setSelectedClass] = useState('All');
 
 
 
 
26
  const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
27
 
28
  const [isAddOpen, setIsAddOpen] = useState(false);
@@ -30,7 +33,7 @@ export const ScoreList: React.FC = () => {
30
  const [isExamModalOpen, setIsExamModalOpen] = useState(false);
31
  const [importFile, setImportFile] = useState<File | null>(null);
32
  const [submitting, setSubmitting] = useState(false);
33
- const [importSemester, setImportSemester] = useState(''); // Default empty, wait for fetch
34
  const [importType, setImportType] = useState('Final');
35
  const [importExamName, setImportExamName] = useState('');
36
  const [formData, setFormData] = useState({ studentId: '', score: '', type: 'Final', examName: '', status: 'Normal' });
@@ -49,7 +52,7 @@ export const ScoreList: React.FC = () => {
49
  api.students.getAll(),
50
  api.classes.getAll(),
51
  api.exams.getAll(),
52
- api.config.getPublic() // Fetch config for semesters
53
  ]);
54
  setSubjects(subs);
55
  if (subs.length > 0 && !activeSubject) setActiveSubject(subs[0].name);
@@ -57,7 +60,6 @@ export const ScoreList: React.FC = () => {
57
  setStudents(stus);
58
  setClassList(cls);
59
  setExams(exs);
60
- // Set Semesters
61
  if (cfg && cfg.semesters) {
62
  setSemesters(cfg.semesters);
63
  if (cfg.semester) setImportSemester(cfg.semester);
@@ -69,24 +71,39 @@ export const ScoreList: React.FC = () => {
69
 
70
  useEffect(() => { loadData(); }, []);
71
 
 
 
 
72
  const hasPermission = (studentClass: string) => {
73
  if (currentUser?.role === 'ADMIN') return true;
74
  if (currentUser?.role === 'TEACHER' && currentUser.homeroomClass === studentClass) return true;
75
  return false;
76
  };
77
 
78
- // ... (Permission Checks same as before) ...
79
  const canModify = currentUser?.role === 'ADMIN' || !!currentUser?.homeroomClass;
80
 
 
81
  const filteredScores = scores.filter(s => {
82
  if (s.courseName !== activeSubject) return false;
 
 
 
 
 
 
 
83
  const stu = students.find(st => st.studentNo === s.studentNo);
84
  if (!stu) return false;
 
85
  const matchesGrade = selectedGrade === 'All' || stu.className.includes(selectedGrade);
86
  const matchesClass = selectedClass === 'All' || stu.className.includes(selectedClass);
87
  return matchesGrade && matchesClass;
88
  });
89
 
 
 
 
 
90
  const handleBatchDelete = async () => {
91
  if (confirm(`删除 ${selectedIds.size} 条记录?`)) {
92
  await api.batchDelete('score', Array.from(selectedIds));
@@ -106,7 +123,7 @@ export const ScoreList: React.FC = () => {
106
  studentNo: stu.studentNo,
107
  courseName: activeSubject,
108
  score: formData.status === 'Normal' ? Number(formData.score) : 0,
109
- semester: importSemester, // Use global semester state or form specific
110
  type: formData.type,
111
  examName: formData.examName,
112
  status: formData.status
@@ -116,7 +133,6 @@ export const ScoreList: React.FC = () => {
116
  loadData();
117
  };
118
 
119
- // ... (startEditing, saveEdit logic same) ...
120
  const startEditing = (s: Score) => {
121
  const stu = students.find(st => st.studentNo === s.studentNo);
122
  if (stu && !hasPermission(stu.className)) return alert('无权编辑');
@@ -190,17 +206,18 @@ export const ScoreList: React.FC = () => {
190
 
191
  const toggleSelect = (id: string) => { const newSet = new Set(selectedIds); if (newSet.has(id)) newSet.delete(id); else newSet.add(id); setSelectedIds(newSet); };
192
 
193
- // SORTED Grades
194
  const uniqueGrades = Array.from(new Set(classList.map(c => c.grade))).sort(localSortGrades);
195
-
196
  const filteredClasses = classList.filter(c => selectedGrade === 'All' || c.grade === selectedGrade);
197
  const uniqueClasses = Array.from(new Set(filteredClasses.map(c => c.className))).sort();
198
- const uniqueExamNames = Array.from(new Set(scores.map(s => s.examName || s.type)));
 
 
199
 
200
  return (
201
  <div className="space-y-6">
202
  {/* ... Header and Filters ... */}
203
- <div className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden min-h-[500px]">
204
  {loading && <div className="p-10 text-center"><Loader2 className="animate-spin inline"/></div>}
205
 
206
  <div className="p-4 md:p-6 border-b border-gray-100">
@@ -220,15 +237,22 @@ export const ScoreList: React.FC = () => {
220
  </div>
221
  )}
222
  </div>
223
- <div className="flex flex-col md:flex-row gap-2 md:gap-4 items-stretch md:items-center bg-gray-50 p-3 rounded-lg">
224
- <select className="border border-gray-300 p-2 rounded text-sm text-gray-900 focus:ring-2 focus:ring-blue-500" value={selectedGrade} onChange={e => { setSelectedGrade(e.target.value); setSelectedClass('All'); }}>
 
 
 
225
  <option value="All">所有年级</option>
226
  {uniqueGrades.map(g=><option key={g} value={g}>{g}</option>)}
227
  </select>
228
- <select className="border border-gray-300 p-2 rounded text-sm text-gray-900 focus:ring-2 focus:ring-blue-500" value={selectedClass} onChange={e=>setSelectedClass(e.target.value)}>
229
  <option value="All">所有班级</option>
230
  {uniqueClasses.map(c=><option key={c} value={c}>{c}</option>)}
231
  </select>
 
 
 
 
232
  </div>
233
  </div>
234
 
@@ -243,15 +267,12 @@ export const ScoreList: React.FC = () => {
243
  </div>
244
 
245
  {/* Table ... */}
246
- <div className="overflow-x-auto">
247
  <table className="w-full text-left min-w-[800px]">
248
- <thead className="bg-gray-50 text-xs text-gray-500 uppercase">
249
  <tr>
250
  <th className="px-6 py-3 w-10">
251
- {canModify && <input type="checkbox" onChange={e => {
252
- if(e.target.checked) setSelectedIds(new Set(filteredScores.map(s=>s._id||String(s.id))));
253
- else setSelectedIds(new Set());
254
- }} checked={filteredScores.length > 0 && selectedIds.size === filteredScores.length}/>}
255
  </th>
256
  <th className="px-6 py-3">姓名</th>
257
  <th className="px-6 py-3">考试名称</th>
@@ -260,7 +281,7 @@ export const ScoreList: React.FC = () => {
260
  </tr>
261
  </thead>
262
  <tbody>
263
- {filteredScores.map(s => {
264
  const isEditing = editingId === (s._id || String(s.id));
265
  const displayScore = s.status === 'Normal' ? s.score : (s.status === 'Absent' ? '缺考' : s.status === 'Leave' ? '请假' : '作弊');
266
  const scoreColor = s.status !== 'Normal' ? 'text-gray-400' : (s.score < 60 ? 'text-red-500' : 'text-blue-600');
@@ -300,10 +321,24 @@ export const ScoreList: React.FC = () => {
300
  </td>
301
  </tr>
302
  );
303
- })}
 
 
304
  </tbody>
305
  </table>
306
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
307
  </div>
308
 
309
  {selectedIds.size > 0 && (
@@ -346,7 +381,7 @@ export const ScoreList: React.FC = () => {
346
  </div>
347
  )}
348
 
349
- {/* Exam Schedule Modal (Enhanced with Type) */}
350
  {isExamModalOpen && (
351
  <div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
352
  <div className="bg-white rounded-xl p-6 w-full max-w-lg max-h-[80vh] overflow-y-auto">
@@ -406,7 +441,6 @@ export const ScoreList: React.FC = () => {
406
  <option value="Midterm">期中考试</option>
407
  <option value="Quiz">平时测验</option>
408
  </select>
409
- {/* Updated Semester Selection */}
410
  <select className="border border-gray-300 p-2 rounded text-sm text-gray-900" value={importSemester} onChange={e=>setImportSemester(e.target.value)}>
411
  {semesters.map(s => <option key={s} value={s}>{s}</option>)}
412
  </select>
 
2
  import React, { useState, useEffect } from 'react';
3
  import { api } from '../services/api';
4
  import { Score, Student, Subject, ClassInfo, ExamStatus, Exam, SystemConfig } from '../types';
5
+ import { Loader2, Plus, Trash2, Award, FileSpreadsheet, X, Upload, Edit, Save, Calendar, Filter, ChevronLeft, ChevronRight } from 'lucide-react';
6
 
 
7
  const localSortGrades = (a: string, b: string) => {
8
  const order: Record<string, number> = { '一年级': 1, '二年级': 2, '三年级': 3, '四年级': 4, '五年级': 5, '六年级': 6, '初一': 7, '七年级': 7, '初二': 8, '八年级': 8, '初三': 9, '九年级': 9, '高一': 10, '高二': 11, '高三': 12 };
9
  return (order[a] || 99) - (order[b] || 99);
10
  };
11
 
12
  export const ScoreList: React.FC = () => {
 
13
  const [subjects, setSubjects] = useState<Subject[]>([]);
14
  const [activeSubject, setActiveSubject] = useState('');
15
  const [scores, setScores] = useState<Score[]>([]);
16
  const [students, setStudents] = useState<Student[]>([]);
17
  const [classList, setClassList] = useState<ClassInfo[]>([]);
18
  const [exams, setExams] = useState<Exam[]>([]);
19
+ const [semesters, setSemesters] = useState<string[]>([]);
20
  const [loading, setLoading] = useState(true);
21
 
22
+ // Filters & Pagination
23
  const [selectedGrade, setSelectedGrade] = useState('All');
24
  const [selectedClass, setSelectedClass] = useState('All');
25
+ const [selectedExam, setSelectedExam] = useState('All'); // New Exam Filter
26
+ const [page, setPage] = useState(1);
27
+ const pageSize = 15;
28
+
29
  const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
30
 
31
  const [isAddOpen, setIsAddOpen] = useState(false);
 
33
  const [isExamModalOpen, setIsExamModalOpen] = useState(false);
34
  const [importFile, setImportFile] = useState<File | null>(null);
35
  const [submitting, setSubmitting] = useState(false);
36
+ const [importSemester, setImportSemester] = useState('');
37
  const [importType, setImportType] = useState('Final');
38
  const [importExamName, setImportExamName] = useState('');
39
  const [formData, setFormData] = useState({ studentId: '', score: '', type: 'Final', examName: '', status: 'Normal' });
 
52
  api.students.getAll(),
53
  api.classes.getAll(),
54
  api.exams.getAll(),
55
+ api.config.getPublic()
56
  ]);
57
  setSubjects(subs);
58
  if (subs.length > 0 && !activeSubject) setActiveSubject(subs[0].name);
 
60
  setStudents(stus);
61
  setClassList(cls);
62
  setExams(exs);
 
63
  if (cfg && cfg.semesters) {
64
  setSemesters(cfg.semesters);
65
  if (cfg.semester) setImportSemester(cfg.semester);
 
71
 
72
  useEffect(() => { loadData(); }, []);
73
 
74
+ // Reset page when filters change
75
+ useEffect(() => { setPage(1); }, [activeSubject, selectedGrade, selectedClass, selectedExam]);
76
+
77
  const hasPermission = (studentClass: string) => {
78
  if (currentUser?.role === 'ADMIN') return true;
79
  if (currentUser?.role === 'TEACHER' && currentUser.homeroomClass === studentClass) return true;
80
  return false;
81
  };
82
 
 
83
  const canModify = currentUser?.role === 'ADMIN' || !!currentUser?.homeroomClass;
84
 
85
+ // Filter Logic
86
  const filteredScores = scores.filter(s => {
87
  if (s.courseName !== activeSubject) return false;
88
+
89
+ // Exam Filter
90
+ if (selectedExam !== 'All') {
91
+ const sExamName = s.examName || s.type;
92
+ if (sExamName !== selectedExam) return false;
93
+ }
94
+
95
  const stu = students.find(st => st.studentNo === s.studentNo);
96
  if (!stu) return false;
97
+
98
  const matchesGrade = selectedGrade === 'All' || stu.className.includes(selectedGrade);
99
  const matchesClass = selectedClass === 'All' || stu.className.includes(selectedClass);
100
  return matchesGrade && matchesClass;
101
  });
102
 
103
+ // Pagination Logic
104
+ const totalPages = Math.ceil(filteredScores.length / pageSize);
105
+ const paginatedScores = filteredScores.slice((page - 1) * pageSize, page * pageSize);
106
+
107
  const handleBatchDelete = async () => {
108
  if (confirm(`删除 ${selectedIds.size} 条记录?`)) {
109
  await api.batchDelete('score', Array.from(selectedIds));
 
123
  studentNo: stu.studentNo,
124
  courseName: activeSubject,
125
  score: formData.status === 'Normal' ? Number(formData.score) : 0,
126
+ semester: importSemester,
127
  type: formData.type,
128
  examName: formData.examName,
129
  status: formData.status
 
133
  loadData();
134
  };
135
 
 
136
  const startEditing = (s: Score) => {
137
  const stu = students.find(st => st.studentNo === s.studentNo);
138
  if (stu && !hasPermission(stu.className)) return alert('无权编辑');
 
206
 
207
  const toggleSelect = (id: string) => { const newSet = new Set(selectedIds); if (newSet.has(id)) newSet.delete(id); else newSet.add(id); setSelectedIds(newSet); };
208
 
209
+ // SORTED Grades & Classes
210
  const uniqueGrades = Array.from(new Set(classList.map(c => c.grade))).sort(localSortGrades);
 
211
  const filteredClasses = classList.filter(c => selectedGrade === 'All' || c.grade === selectedGrade);
212
  const uniqueClasses = Array.from(new Set(filteredClasses.map(c => c.className))).sort();
213
+
214
+ // Unique Exam Names for Filter (Filtered by subject potentially, but simpler to show all known for now)
215
+ const uniqueExamNames = Array.from(new Set(scores.filter(s => s.courseName === activeSubject).map(s => s.examName || s.type)));
216
 
217
  return (
218
  <div className="space-y-6">
219
  {/* ... Header and Filters ... */}
220
+ <div className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden flex flex-col min-h-[600px]">
221
  {loading && <div className="p-10 text-center"><Loader2 className="animate-spin inline"/></div>}
222
 
223
  <div className="p-4 md:p-6 border-b border-gray-100">
 
237
  </div>
238
  )}
239
  </div>
240
+
241
+ {/* Filter Bar */}
242
+ <div className="flex flex-wrap gap-2 items-center bg-gray-50 p-3 rounded-lg">
243
+ <div className="flex items-center text-gray-500 text-sm font-bold mr-2"><Filter size={16} className="mr-1"/> 筛选:</div>
244
+ <select className="border border-gray-300 p-2 rounded text-sm text-gray-900 focus:ring-2 focus:ring-blue-500 outline-none bg-white" value={selectedGrade} onChange={e => { setSelectedGrade(e.target.value); setSelectedClass('All'); }}>
245
  <option value="All">所有年级</option>
246
  {uniqueGrades.map(g=><option key={g} value={g}>{g}</option>)}
247
  </select>
248
+ <select className="border border-gray-300 p-2 rounded text-sm text-gray-900 focus:ring-2 focus:ring-blue-500 outline-none bg-white" value={selectedClass} onChange={e=>setSelectedClass(e.target.value)}>
249
  <option value="All">所有班级</option>
250
  {uniqueClasses.map(c=><option key={c} value={c}>{c}</option>)}
251
  </select>
252
+ <select className="border border-gray-300 p-2 rounded text-sm text-gray-900 focus:ring-2 focus:ring-blue-500 outline-none bg-white" value={selectedExam} onChange={e=>setSelectedExam(e.target.value)}>
253
+ <option value="All">所有考试</option>
254
+ {uniqueExamNames.map(e=><option key={e} value={e}>{e}</option>)}
255
+ </select>
256
  </div>
257
  </div>
258
 
 
267
  </div>
268
 
269
  {/* Table ... */}
270
+ <div className="flex-1 overflow-x-auto">
271
  <table className="w-full text-left min-w-[800px]">
272
+ <thead className="bg-gray-50 text-xs text-gray-500 uppercase sticky top-0">
273
  <tr>
274
  <th className="px-6 py-3 w-10">
275
+ {canModify && <input type="checkbox" disabled/>} {/* Batch delete logic simplified for pagination */}
 
 
 
276
  </th>
277
  <th className="px-6 py-3">姓名</th>
278
  <th className="px-6 py-3">考试名称</th>
 
281
  </tr>
282
  </thead>
283
  <tbody>
284
+ {paginatedScores.length > 0 ? paginatedScores.map(s => {
285
  const isEditing = editingId === (s._id || String(s.id));
286
  const displayScore = s.status === 'Normal' ? s.score : (s.status === 'Absent' ? '缺考' : s.status === 'Leave' ? '请假' : '作弊');
287
  const scoreColor = s.status !== 'Normal' ? 'text-gray-400' : (s.score < 60 ? 'text-red-500' : 'text-blue-600');
 
321
  </td>
322
  </tr>
323
  );
324
+ }) : (
325
+ <tr><td colSpan={5} className="text-center py-10 text-gray-400">没有找到成绩记录</td></tr>
326
+ )}
327
  </tbody>
328
  </table>
329
  </div>
330
+
331
+ {/* Pagination Footer */}
332
+ {totalPages > 1 && (
333
+ <div className="p-4 border-t border-gray-100 flex items-center justify-between bg-gray-50">
334
+ <span className="text-xs text-gray-500">共 {filteredScores.length} 条</span>
335
+ <div className="flex items-center gap-2">
336
+ <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>
337
+ <span className="text-sm font-medium text-gray-700">第 {page} / {totalPages} 页</span>
338
+ <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>
339
+ </div>
340
+ </div>
341
+ )}
342
  </div>
343
 
344
  {selectedIds.size > 0 && (
 
381
  </div>
382
  )}
383
 
384
+ {/* Exam Schedule Modal */}
385
  {isExamModalOpen && (
386
  <div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
387
  <div className="bg-white rounded-xl p-6 w-full max-w-lg max-h-[80vh] overflow-y-auto">
 
441
  <option value="Midterm">期中考试</option>
442
  <option value="Quiz">平时测验</option>
443
  </select>
 
444
  <select className="border border-gray-300 p-2 rounded text-sm text-gray-900" value={importSemester} onChange={e=>setImportSemester(e.target.value)}>
445
  {semesters.map(s => <option key={s} value={s}>{s}</option>)}
446
  </select>