dvc890 commited on
Commit
42332c7
·
verified ·
1 Parent(s): b2bb4f0

Upload 25 files

Browse files
Files changed (6) hide show
  1. index.html +7 -6
  2. pages/Reports.tsx +168 -26
  3. pages/ScoreList.tsx +60 -11
  4. server.js +29 -0
  5. services/api.ts +5 -0
  6. types.ts +8 -0
index.html CHANGED
@@ -1,3 +1,4 @@
 
1
  <!DOCTYPE html>
2
  <html lang="zh-CN">
3
  <head>
@@ -32,13 +33,13 @@
32
  <script type="importmap">
33
  {
34
  "imports": {
35
- "react": "https://aistudiocdn.com/react@^19.2.0",
36
- "react-dom/": "https://aistudiocdn.com/react-dom@^19.2.0/",
37
  "react/": "https://aistudiocdn.com/react@^19.2.0/",
 
 
38
  "lucide-react": "https://aistudiocdn.com/lucide-react@^0.555.0",
39
- "recharts": "https://aistudiocdn.com/recharts@^3.5.1",
40
- "vite": "https://aistudiocdn.com/vite@^7.2.4",
41
- "@vitejs/plugin-react": "https://aistudiocdn.com/@vitejs/plugin-react@^5.1.1"
42
  }
43
  }
44
  </script>
@@ -48,4 +49,4 @@
48
  <div id="root"></div>
49
  <script type="module" src="/index.tsx"></script>
50
  </body>
51
- </html>
 
1
+
2
  <!DOCTYPE html>
3
  <html lang="zh-CN">
4
  <head>
 
33
  <script type="importmap">
34
  {
35
  "imports": {
36
+ "vite": "https://aistudiocdn.com/vite@^7.2.6",
37
+ "recharts": "https://aistudiocdn.com/recharts@^3.5.1",
38
  "react/": "https://aistudiocdn.com/react@^19.2.0/",
39
+ "react": "https://aistudiocdn.com/react@^19.2.0",
40
+ "@vitejs/plugin-react": "https://aistudiocdn.com/@vitejs/plugin-react@^5.1.1",
41
  "lucide-react": "https://aistudiocdn.com/lucide-react@^0.555.0",
42
+ "react-dom/": "https://aistudiocdn.com/react-dom@^19.2.0/"
 
 
43
  }
44
  }
45
  </script>
 
49
  <div id="root"></div>
50
  <script type="module" src="/index.tsx"></script>
51
  </body>
52
+ </html>
pages/Reports.tsx CHANGED
@@ -2,25 +2,26 @@
2
  import React, { useState, useEffect } from 'react';
3
  import {
4
  BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer,
5
- LineChart, Line, AreaChart, Area, PieChart as RePieChart, Pie, Cell
6
  } from 'recharts';
7
  import { api } from '../services/api';
8
- import { Loader2, Download, Filter, TrendingUp, Users, BookOpen, PieChart as PieChartIcon, Grid, BarChart2 } from 'lucide-react';
9
- import { Score, Student, ClassInfo, Subject } from '../types';
10
 
11
  export const Reports: React.FC = () => {
12
  const [loading, setLoading] = useState(true);
13
- const [activeTab, setActiveTab] = useState<'overview' | 'grade' | 'trend' | 'matrix'>('grade');
14
 
15
  // Data
16
  const [scores, setScores] = useState<Score[]>([]);
17
  const [students, setStudents] = useState<Student[]>([]);
18
  const [classes, setClasses] = useState<ClassInfo[]>([]);
19
  const [subjects, setSubjects] = useState<Subject[]>([]);
 
20
 
21
  // Filters
22
  const [selectedGrade, setSelectedGrade] = useState<string>('六年级');
23
- const [selectedClass, setSelectedClass] = useState<string>(''); // For Trend
24
  const [selectedSubject, setSelectedSubject] = useState<string>(''); // For Trend
25
 
26
  // Computed Data
@@ -28,20 +29,25 @@ export const Reports: React.FC = () => {
28
  const [trendData, setTrendData] = useState<any[]>([]);
29
  const [matrixData, setMatrixData] = useState<any[]>([]);
30
 
 
 
 
31
  useEffect(() => {
32
  const loadData = async () => {
33
  setLoading(true);
34
  try {
35
- const [scs, stus, cls, subs] = await Promise.all([
36
  api.scores.getAll(),
37
  api.students.getAll(),
38
  api.classes.getAll(),
39
- api.subjects.getAll()
 
40
  ]);
41
  setScores(scs);
42
  setStudents(stus);
43
  setClasses(cls);
44
  setSubjects(subs);
 
45
 
46
  // Set Defaults
47
  if (cls.length > 0) setSelectedClass(cls[0].grade + cls[0].className);
@@ -93,16 +99,20 @@ export const Reports: React.FC = () => {
93
 
94
 
95
  // --- 2. Trend Analysis (Time/Exam based) ---
96
- // Group by Exam Name or Type. Since we don't have real dates, we'll use examName as categories.
97
- // Filter for selectedClass and selectedSubject
98
  if (selectedClass && selectedSubject) {
99
  const classStudentIds = students.filter(s => s.className === selectedClass).map(s => s.studentNo);
100
 
101
- // Get all unique exam names in database order (assuming insertion order implies time for mock)
102
- // In real app, sort by exam date.
103
- const uniqueExams = Array.from(new Set(scores.map(s => s.examName || s.type)));
104
 
105
- const tData = uniqueExams.map(exam => {
 
 
 
 
 
 
 
106
  const examScores = scores.filter(s =>
107
  (s.examName === exam || s.type === exam) &&
108
  s.courseName === selectedSubject &&
@@ -116,7 +126,6 @@ export const Reports: React.FC = () => {
116
  : 0;
117
 
118
  // Grade Avg (for comparison)
119
- // Find grade of selected class
120
  const currentGrade = classes.find(c => c.grade + c.className === selectedClass)?.grade || '';
121
  const gradeStudentIds = students.filter(s => s.className.startsWith(currentGrade)).map(s => s.studentNo);
122
  const gradeExamScores = examScores.filter(s => gradeStudentIds.includes(s.studentNo));
@@ -134,7 +143,6 @@ export const Reports: React.FC = () => {
134
  }
135
 
136
  // --- 3. Subject Matrix (Heatmap Table) ---
137
- // Rows: Classes in selectedGrade. Cols: Subjects.
138
  const mData = gradeClasses.map(cls => {
139
  const fullClassName = cls.grade + cls.className;
140
  const classStudentIds = students.filter(s => s.className === fullClassName).map(s => s.studentNo);
@@ -154,7 +162,7 @@ export const Reports: React.FC = () => {
154
  });
155
  setMatrixData(mData);
156
 
157
- }, [scores, students, classes, subjects, selectedGrade, selectedClass, selectedSubject]);
158
 
159
 
160
  const exportExcel = () => {
@@ -176,9 +184,45 @@ export const Reports: React.FC = () => {
176
  XLSX.writeFile(wb, `Report_${activeTab}_${new Date().toISOString().slice(0,10)}.xlsx`);
177
  };
178
 
179
- const COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6'];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
180
  const uniqueGrades = Array.from(new Set(classes.map(c => c.grade))).sort();
181
  const allClasses = classes.map(c => c.grade + c.className);
 
 
 
182
 
183
  if (loading) return <div className="flex justify-center py-20"><Loader2 className="animate-spin text-blue-600"/></div>;
184
 
@@ -202,6 +246,7 @@ export const Reports: React.FC = () => {
202
  { id: 'grade', label: '年级横向分析', icon: BarChart2 },
203
  { id: 'trend', label: '教学成长轨迹', icon: TrendingUp },
204
  { id: 'matrix', label: '学科质量透视', icon: Grid },
 
205
  { id: 'overview', label: '全校概览', icon: PieChartIcon },
206
  ].map(tab => (
207
  <button
@@ -230,15 +275,16 @@ export const Reports: React.FC = () => {
230
  </select>
231
  )}
232
 
 
 
 
 
 
 
233
  {activeTab === 'trend' && (
234
- <>
235
- <select className="border border-gray-300 p-2 rounded text-sm text-gray-800" value={selectedClass} onChange={e => setSelectedClass(e.target.value)}>
236
- {allClasses.map(c => <option key={c} value={c}>{c}</option>)}
237
- </select>
238
- <select className="border border-gray-300 p-2 rounded text-sm text-gray-800" value={selectedSubject} onChange={e => setSelectedSubject(e.target.value)}>
239
- {subjects.map(s => <option key={s._id} value={s.name}>{s.name}</option>)}
240
- </select>
241
- </>
242
  )}
243
  </div>
244
 
@@ -348,8 +394,39 @@ export const Reports: React.FC = () => {
348
  <p className="text-xs text-gray-400 mt-4 text-right">* 绿色底色代表优秀,红色底色代表需要关注</p>
349
  </div>
350
  )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
351
 
352
- {/* --- 4. Overview View --- */}
353
  {activeTab === 'overview' && (
354
  <div className="flex flex-col items-center justify-center py-20 text-gray-400">
355
  <PieChartIcon size={64} className="mb-4 opacity-20"/>
@@ -359,6 +436,71 @@ export const Reports: React.FC = () => {
359
  )}
360
 
361
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
362
  </div>
363
  );
364
  };
 
2
  import React, { useState, useEffect } from 'react';
3
  import {
4
  BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer,
5
+ LineChart, Line, AreaChart, Area, PieChart as RePieChart, Pie, Cell, RadarChart, Radar, PolarGrid, PolarAngleAxis, PolarRadiusAxis
6
  } from 'recharts';
7
  import { api } from '../services/api';
8
+ import { Loader2, Download, Filter, TrendingUp, Users, BookOpen, PieChart as PieChartIcon, Grid, BarChart2, User, UserCheck } from 'lucide-react';
9
+ import { Score, Student, ClassInfo, Subject, Exam } from '../types';
10
 
11
  export const Reports: React.FC = () => {
12
  const [loading, setLoading] = useState(true);
13
+ const [activeTab, setActiveTab] = useState<'overview' | 'grade' | 'trend' | 'matrix' | 'student'>('grade');
14
 
15
  // Data
16
  const [scores, setScores] = useState<Score[]>([]);
17
  const [students, setStudents] = useState<Student[]>([]);
18
  const [classes, setClasses] = useState<ClassInfo[]>([]);
19
  const [subjects, setSubjects] = useState<Subject[]>([]);
20
+ const [exams, setExams] = useState<Exam[]>([]);
21
 
22
  // Filters
23
  const [selectedGrade, setSelectedGrade] = useState<string>('六年级');
24
+ const [selectedClass, setSelectedClass] = useState<string>(''); // For Trend/Student
25
  const [selectedSubject, setSelectedSubject] = useState<string>(''); // For Trend
26
 
27
  // Computed Data
 
29
  const [trendData, setTrendData] = useState<any[]>([]);
30
  const [matrixData, setMatrixData] = useState<any[]>([]);
31
 
32
+ // Student Focus State
33
+ const [selectedStudent, setSelectedStudent] = useState<Student | null>(null);
34
+
35
  useEffect(() => {
36
  const loadData = async () => {
37
  setLoading(true);
38
  try {
39
+ const [scs, stus, cls, subs, exs] = await Promise.all([
40
  api.scores.getAll(),
41
  api.students.getAll(),
42
  api.classes.getAll(),
43
+ api.subjects.getAll(),
44
+ api.exams.getAll()
45
  ]);
46
  setScores(scs);
47
  setStudents(stus);
48
  setClasses(cls);
49
  setSubjects(subs);
50
+ setExams(exs);
51
 
52
  // Set Defaults
53
  if (cls.length > 0) setSelectedClass(cls[0].grade + cls[0].className);
 
99
 
100
 
101
  // --- 2. Trend Analysis (Time/Exam based) ---
102
+ // Group by Exam Name or Type. Sort using Exam Dates if available.
 
103
  if (selectedClass && selectedSubject) {
104
  const classStudentIds = students.filter(s => s.className === selectedClass).map(s => s.studentNo);
105
 
106
+ const uniqueExamNames = Array.from(new Set(scores.map(s => s.examName || s.type)));
 
 
107
 
108
+ // Sort exams by date
109
+ uniqueExamNames.sort((a, b) => {
110
+ const dateA = exams.find(e => e.name === a)?.date || '9999-99-99';
111
+ const dateB = exams.find(e => e.name === b)?.date || '9999-99-99';
112
+ return dateA.localeCompare(dateB);
113
+ });
114
+
115
+ const tData = uniqueExamNames.map(exam => {
116
  const examScores = scores.filter(s =>
117
  (s.examName === exam || s.type === exam) &&
118
  s.courseName === selectedSubject &&
 
126
  : 0;
127
 
128
  // Grade Avg (for comparison)
 
129
  const currentGrade = classes.find(c => c.grade + c.className === selectedClass)?.grade || '';
130
  const gradeStudentIds = students.filter(s => s.className.startsWith(currentGrade)).map(s => s.studentNo);
131
  const gradeExamScores = examScores.filter(s => gradeStudentIds.includes(s.studentNo));
 
143
  }
144
 
145
  // --- 3. Subject Matrix (Heatmap Table) ---
 
146
  const mData = gradeClasses.map(cls => {
147
  const fullClassName = cls.grade + cls.className;
148
  const classStudentIds = students.filter(s => s.className === fullClassName).map(s => s.studentNo);
 
162
  });
163
  setMatrixData(mData);
164
 
165
+ }, [scores, students, classes, subjects, exams, selectedGrade, selectedClass, selectedSubject]);
166
 
167
 
168
  const exportExcel = () => {
 
184
  XLSX.writeFile(wb, `Report_${activeTab}_${new Date().toISOString().slice(0,10)}.xlsx`);
185
  };
186
 
187
+ // Helper for Student Focus
188
+ const getStudentTrend = (studentNo: string) => {
189
+ // Get all scores for this student, grouped by exam, sorted by date
190
+ const stuScores = scores.filter(s => s.studentNo === studentNo && s.status === 'Normal');
191
+ const uniqueExamNames = Array.from(new Set(stuScores.map(s => s.examName || s.type)));
192
+ uniqueExamNames.sort((a, b) => {
193
+ const dateA = exams.find(e => e.name === a)?.date || '9999-99-99';
194
+ const dateB = exams.find(e => e.name === b)?.date || '9999-99-99';
195
+ return dateA.localeCompare(dateB);
196
+ });
197
+
198
+ return uniqueExamNames.map(exam => {
199
+ const s = stuScores.find(s => (s.examName || s.type) === exam);
200
+ return { name: exam, score: s ? s.score : 0 };
201
+ });
202
+ };
203
+
204
+ const getStudentRadar = (studentNo: string) => {
205
+ const stuScores = scores.filter(s => s.studentNo === studentNo && s.status === 'Normal');
206
+ return subjects.map(sub => {
207
+ const subScores = stuScores.filter(s => s.courseName === sub.name);
208
+ const avg = subScores.length ? subScores.reduce((a,b)=>a+b.score,0)/subScores.length : 0;
209
+ return { subject: sub.name, score: Number(avg.toFixed(1)), fullMark: 100 };
210
+ });
211
+ };
212
+
213
+ const getStudentAttendance = (studentNo: string) => {
214
+ const all = scores.filter(s => s.studentNo === studentNo);
215
+ return {
216
+ absent: all.filter(s => s.status === 'Absent').length,
217
+ leave: all.filter(s => s.status === 'Leave').length
218
+ };
219
+ };
220
+
221
  const uniqueGrades = Array.from(new Set(classes.map(c => c.grade))).sort();
222
  const allClasses = classes.map(c => c.grade + c.className);
223
+
224
+ // Student List for Focus Tab
225
+ const focusStudents = students.filter(s => selectedClass ? s.className === selectedClass : true);
226
 
227
  if (loading) return <div className="flex justify-center py-20"><Loader2 className="animate-spin text-blue-600"/></div>;
228
 
 
246
  { id: 'grade', label: '年级横向分析', icon: BarChart2 },
247
  { id: 'trend', label: '教学成长轨迹', icon: TrendingUp },
248
  { id: 'matrix', label: '学科质量透视', icon: Grid },
249
+ { id: 'student', label: '学生个像', icon: User },
250
  { id: 'overview', label: '全校概览', icon: PieChartIcon },
251
  ].map(tab => (
252
  <button
 
275
  </select>
276
  )}
277
 
278
+ {(activeTab === 'trend' || activeTab === 'student') && (
279
+ <select className="border border-gray-300 p-2 rounded text-sm text-gray-800" value={selectedClass} onChange={e => setSelectedClass(e.target.value)}>
280
+ {allClasses.map(c => <option key={c} value={c}>{c}</option>)}
281
+ </select>
282
+ )}
283
+
284
  {activeTab === 'trend' && (
285
+ <select className="border border-gray-300 p-2 rounded text-sm text-gray-800" value={selectedSubject} onChange={e => setSelectedSubject(e.target.value)}>
286
+ {subjects.map(s => <option key={s._id} value={s.name}>{s.name}</option>)}
287
+ </select>
 
 
 
 
 
288
  )}
289
  </div>
290
 
 
394
  <p className="text-xs text-gray-400 mt-4 text-right">* 绿色底色代表优秀,红色底色代表需要关注</p>
395
  </div>
396
  )}
397
+
398
+ {/* --- 4. Student Focus View --- */}
399
+ {activeTab === 'student' && (
400
+ <div className="animate-in fade-in">
401
+ <h3 className="font-bold text-gray-800 mb-4">{selectedClass} - 学生个像分析</h3>
402
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
403
+ {focusStudents.map(s => {
404
+ const trend = getStudentTrend(s.studentNo);
405
+ return (
406
+ <div key={s._id||s.id} onClick={() => setSelectedStudent(s)} className="bg-white border rounded-xl p-4 hover:shadow-md cursor-pointer transition-shadow">
407
+ <div className="flex items-center space-x-3 mb-3">
408
+ <div className={`w-10 h-10 rounded-full flex items-center justify-center text-white font-bold ${s.gender==='Male'?'bg-blue-500':'bg-pink-500'}`}>{s.name[0]}</div>
409
+ <div>
410
+ <p className="font-bold text-gray-800">{s.name}</p>
411
+ <p className="text-xs text-gray-500">{s.studentNo}</p>
412
+ </div>
413
+ </div>
414
+ {/* Sparkline */}
415
+ <div className="h-12">
416
+ <ResponsiveContainer width="100%" height="100%">
417
+ <LineChart data={trend}>
418
+ <Line type="monotone" dataKey="score" stroke="#94a3b8" strokeWidth={2} dot={false} />
419
+ </LineChart>
420
+ </ResponsiveContainer>
421
+ </div>
422
+ </div>
423
+ );
424
+ })}
425
+ </div>
426
+ </div>
427
+ )}
428
 
429
+ {/* --- 5. Overview View --- */}
430
  {activeTab === 'overview' && (
431
  <div className="flex flex-col items-center justify-center py-20 text-gray-400">
432
  <PieChartIcon size={64} className="mb-4 opacity-20"/>
 
436
  )}
437
 
438
  </div>
439
+
440
+ {/* Student Detail Modal */}
441
+ {selectedStudent && (
442
+ <div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
443
+ <div className="bg-white rounded-xl w-full max-w-4xl max-h-[90vh] overflow-y-auto p-6 relative">
444
+ <button onClick={()=>setSelectedStudent(null)} className="absolute top-4 right-4 text-gray-400 hover:text-gray-600"><UserCheck size={24}/></button>
445
+
446
+ <div className="flex items-center space-x-4 mb-8">
447
+ <div className={`w-16 h-16 rounded-full flex items-center justify-center text-2xl text-white font-bold ${selectedStudent.gender==='Male'?'bg-blue-600':'bg-pink-500'}`}>
448
+ {selectedStudent.name[0]}
449
+ </div>
450
+ <div>
451
+ <h2 className="text-2xl font-bold text-gray-800">{selectedStudent.name}</h2>
452
+ <p className="text-gray-500">{selectedStudent.studentNo} | {selectedStudent.className}</p>
453
+ </div>
454
+ </div>
455
+
456
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-8">
457
+ {/* Radar */}
458
+ <div className="bg-gray-50 rounded-xl p-4">
459
+ <h3 className="font-bold text-gray-700 mb-4 text-center">学科能力模型</h3>
460
+ <div className="h-64">
461
+ <ResponsiveContainer width="100%" height="100%">
462
+ <RadarChart cx="50%" cy="50%" outerRadius="80%" data={getStudentRadar(selectedStudent.studentNo)}>
463
+ <PolarGrid />
464
+ <PolarAngleAxis dataKey="subject" />
465
+ <PolarRadiusAxis angle={30} domain={[0, 100]} />
466
+ <Radar name={selectedStudent.name} dataKey="score" stroke="#3b82f6" fill="#3b82f6" fillOpacity={0.6} />
467
+ <Tooltip />
468
+ </RadarChart>
469
+ </ResponsiveContainer>
470
+ </div>
471
+ </div>
472
+
473
+ {/* Trend */}
474
+ <div className="bg-gray-50 rounded-xl p-4">
475
+ <h3 className="font-bold text-gray-700 mb-4 text-center">综合成绩走势</h3>
476
+ <div className="h-64">
477
+ <ResponsiveContainer width="100%" height="100%">
478
+ <LineChart data={getStudentTrend(selectedStudent.studentNo)}>
479
+ <CartesianGrid strokeDasharray="3 3" vertical={false}/>
480
+ <XAxis dataKey="name" fontSize={10}/>
481
+ <YAxis domain={[0, 100]}/>
482
+ <Tooltip />
483
+ <Line type="monotone" dataKey="score" stroke="#10b981" strokeWidth={3} />
484
+ </LineChart>
485
+ </ResponsiveContainer>
486
+ </div>
487
+ </div>
488
+
489
+ {/* Attendance */}
490
+ <div className="bg-gray-50 rounded-xl p-4 md:col-span-2 flex justify-around">
491
+ <div className="text-center">
492
+ <p className="text-gray-500 text-sm">缺考次数</p>
493
+ <p className="text-2xl font-bold text-red-500">{getStudentAttendance(selectedStudent.studentNo).absent}</p>
494
+ </div>
495
+ <div className="text-center">
496
+ <p className="text-gray-500 text-sm">请假次数</p>
497
+ <p className="text-2xl font-bold text-orange-500">{getStudentAttendance(selectedStudent.studentNo).leave}</p>
498
+ </div>
499
+ </div>
500
+ </div>
501
+ </div>
502
+ </div>
503
+ )}
504
  </div>
505
  );
506
  };
pages/ScoreList.tsx CHANGED
@@ -1,7 +1,8 @@
 
1
  import React, { useState, useEffect } from 'react';
2
  import { api } from '../services/api';
3
- import { Score, Student, Subject, ClassInfo, ExamStatus } from '../types';
4
- import { Loader2, Plus, Trash2, Award, FileSpreadsheet, X, Upload, Edit, Save } from 'lucide-react';
5
 
6
  export const ScoreList: React.FC = () => {
7
  const [subjects, setSubjects] = useState<Subject[]>([]);
@@ -9,6 +10,7 @@ export const ScoreList: React.FC = () => {
9
  const [scores, setScores] = useState<Score[]>([]);
10
  const [students, setStudents] = useState<Student[]>([]);
11
  const [classList, setClassList] = useState<ClassInfo[]>([]);
 
12
  const [loading, setLoading] = useState(true);
13
 
14
  const [selectedGrade, setSelectedGrade] = useState('All');
@@ -17,6 +19,8 @@ export const ScoreList: React.FC = () => {
17
 
18
  const [isAddOpen, setIsAddOpen] = useState(false);
19
  const [isImportOpen, setIsImportOpen] = useState(false);
 
 
20
  const [importFile, setImportFile] = useState<File | null>(null);
21
  const [submitting, setSubmitting] = useState(false);
22
 
@@ -36,17 +40,19 @@ export const ScoreList: React.FC = () => {
36
  const loadData = async () => {
37
  setLoading(true);
38
  try {
39
- const [subs, scs, stus, cls] = await Promise.all([
40
  api.subjects.getAll(),
41
  api.scores.getAll(),
42
  api.students.getAll(),
43
- api.classes.getAll()
 
44
  ]);
45
  setSubjects(subs);
46
  if (subs.length > 0 && !activeSubject) setActiveSubject(subs[0].name);
47
  setScores(scs);
48
  setStudents(stus);
49
  setClassList(cls);
 
50
  } catch (e) { console.error(e); }
51
  finally { setLoading(false); }
52
  };
@@ -194,6 +200,11 @@ export const ScoreList: React.FC = () => {
194
  reader.readAsArrayBuffer(importFile);
195
  };
196
 
 
 
 
 
 
197
 
198
  const toggleSelect = (id: string) => {
199
  const newSet = new Set(selectedIds);
@@ -202,12 +213,12 @@ export const ScoreList: React.FC = () => {
202
  };
203
 
204
  const uniqueGrades = Array.from(new Set(classList.map(c => c.grade))).sort();
205
-
206
- // FIX: Cascading filter logic.
207
- // If a grade is selected, ONLY show classes from that grade.
208
  const filteredClasses = classList.filter(c => selectedGrade === 'All' || c.grade === selectedGrade);
209
  const uniqueClasses = Array.from(new Set(filteredClasses.map(c => c.className))).sort();
210
 
 
 
 
211
  return (
212
  <div className="space-y-6">
213
  <div className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden min-h-[500px]">
@@ -216,11 +227,14 @@ export const ScoreList: React.FC = () => {
216
  <div className="p-4 md:p-6 border-b border-gray-100">
217
  <div className="flex flex-col md:flex-row justify-between items-start md:items-center mb-4 gap-4">
218
  <h2 className="text-xl font-bold flex items-center text-gray-800"><Award className="mr-2 text-blue-600"/>成绩管理</h2>
219
- <div className="flex gap-2 w-full md:w-auto">
220
- <button onClick={() => { setIsImportOpen(true); setImportFile(null); }} className="flex-1 md:flex-none px-4 py-2 bg-emerald-50 text-emerald-600 border border-emerald-200 rounded-lg flex items-center justify-center text-sm hover:bg-emerald-100 transition-colors">
 
 
 
221
  <FileSpreadsheet size={16} className="mr-1"/> Excel导入
222
  </button>
223
- <button onClick={() => setIsAddOpen(true)} className="flex-1 md:flex-none px-4 py-2 bg-blue-600 text-white rounded-lg flex items-center justify-center text-sm hover:bg-blue-700 transition-colors">
224
  <Plus size={16} className="mr-1"/> 手动录入
225
  </button>
226
  </div>
@@ -362,6 +376,41 @@ export const ScoreList: React.FC = () => {
362
  </div>
363
  )}
364
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
365
  {/* Excel Import Modal */}
366
  {isImportOpen && (
367
  <div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
@@ -419,4 +468,4 @@ export const ScoreList: React.FC = () => {
419
  )}
420
  </div>
421
  );
422
- };
 
1
+
2
  import React, { useState, useEffect } from 'react';
3
  import { api } from '../services/api';
4
+ import { Score, Student, Subject, ClassInfo, ExamStatus, Exam } from '../types';
5
+ import { Loader2, Plus, Trash2, Award, FileSpreadsheet, X, Upload, Edit, Save, Calendar } from 'lucide-react';
6
 
7
  export const ScoreList: React.FC = () => {
8
  const [subjects, setSubjects] = useState<Subject[]>([]);
 
10
  const [scores, setScores] = useState<Score[]>([]);
11
  const [students, setStudents] = useState<Student[]>([]);
12
  const [classList, setClassList] = useState<ClassInfo[]>([]);
13
+ const [exams, setExams] = useState<Exam[]>([]);
14
  const [loading, setLoading] = useState(true);
15
 
16
  const [selectedGrade, setSelectedGrade] = useState('All');
 
19
 
20
  const [isAddOpen, setIsAddOpen] = useState(false);
21
  const [isImportOpen, setIsImportOpen] = useState(false);
22
+ const [isExamModalOpen, setIsExamModalOpen] = useState(false);
23
+
24
  const [importFile, setImportFile] = useState<File | null>(null);
25
  const [submitting, setSubmitting] = useState(false);
26
 
 
40
  const loadData = async () => {
41
  setLoading(true);
42
  try {
43
+ const [subs, scs, stus, cls, exs] = await Promise.all([
44
  api.subjects.getAll(),
45
  api.scores.getAll(),
46
  api.students.getAll(),
47
+ api.classes.getAll(),
48
+ api.exams.getAll()
49
  ]);
50
  setSubjects(subs);
51
  if (subs.length > 0 && !activeSubject) setActiveSubject(subs[0].name);
52
  setScores(scs);
53
  setStudents(stus);
54
  setClassList(cls);
55
+ setExams(exs);
56
  } catch (e) { console.error(e); }
57
  finally { setLoading(false); }
58
  };
 
200
  reader.readAsArrayBuffer(importFile);
201
  };
202
 
203
+ const handleUpdateExamDate = async (name: string, date: string) => {
204
+ await api.exams.save({ name, date });
205
+ loadData();
206
+ };
207
+
208
 
209
  const toggleSelect = (id: string) => {
210
  const newSet = new Set(selectedIds);
 
213
  };
214
 
215
  const uniqueGrades = Array.from(new Set(classList.map(c => c.grade))).sort();
 
 
 
216
  const filteredClasses = classList.filter(c => selectedGrade === 'All' || c.grade === selectedGrade);
217
  const uniqueClasses = Array.from(new Set(filteredClasses.map(c => c.className))).sort();
218
 
219
+ // Unique Exam Names for Modal
220
+ const uniqueExamNames = Array.from(new Set(scores.map(s => s.examName || s.type)));
221
+
222
  return (
223
  <div className="space-y-6">
224
  <div className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden min-h-[500px]">
 
227
  <div className="p-4 md:p-6 border-b border-gray-100">
228
  <div className="flex flex-col md:flex-row justify-between items-start md:items-center mb-4 gap-4">
229
  <h2 className="text-xl font-bold flex items-center text-gray-800"><Award className="mr-2 text-blue-600"/>成绩管理</h2>
230
+ <div className="grid grid-cols-2 md:flex gap-2 w-full md:w-auto">
231
+ <button onClick={() => setIsExamModalOpen(true)} className="col-span-2 md:col-span-1 px-4 py-2 bg-indigo-50 text-indigo-600 border border-indigo-200 rounded-lg flex items-center justify-center text-sm hover:bg-indigo-100 transition-colors">
232
+ <Calendar size={16} className="mr-1"/> 考试排期
233
+ </button>
234
+ <button onClick={() => { setIsImportOpen(true); setImportFile(null); }} className="px-4 py-2 bg-emerald-50 text-emerald-600 border border-emerald-200 rounded-lg flex items-center justify-center text-sm hover:bg-emerald-100 transition-colors">
235
  <FileSpreadsheet size={16} className="mr-1"/> Excel导入
236
  </button>
237
+ <button onClick={() => setIsAddOpen(true)} className="px-4 py-2 bg-blue-600 text-white rounded-lg flex items-center justify-center text-sm hover:bg-blue-700 transition-colors">
238
  <Plus size={16} className="mr-1"/> 手动录入
239
  </button>
240
  </div>
 
376
  </div>
377
  )}
378
 
379
+ {/* Exam Schedule Modal */}
380
+ {isExamModalOpen && (
381
+ <div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
382
+ <div className="bg-white rounded-xl p-6 w-full max-w-lg max-h-[80vh] overflow-y-auto">
383
+ <div className="flex justify-between items-center mb-4">
384
+ <h3 className="font-bold text-lg">考试排期管理</h3>
385
+ <button onClick={()=>setIsExamModalOpen(false)}><X size={20}/></button>
386
+ </div>
387
+ <p className="text-sm text-gray-500 mb-4">设置考试的具体日期,以便生成准确的时间轴趋势图。</p>
388
+
389
+ <div className="space-y-3">
390
+ {uniqueExamNames.map((name, idx) => {
391
+ const examInfo = exams.find(e => e.name === name);
392
+ return (
393
+ <div key={idx} className="flex items-center justify-between bg-gray-50 p-3 rounded">
394
+ <span className="font-medium text-gray-800">{name}</span>
395
+ <input
396
+ type="date"
397
+ className="border rounded px-2 py-1 text-sm"
398
+ value={examInfo?.date || ''}
399
+ onChange={(e) => handleUpdateExamDate(name!, e.target.value)}
400
+ />
401
+ </div>
402
+ );
403
+ })}
404
+ {uniqueExamNames.length === 0 && <p className="text-center text-gray-400 py-4">暂无考试记录</p>}
405
+ </div>
406
+
407
+ <div className="mt-6 flex justify-end">
408
+ <button onClick={()=>setIsExamModalOpen(false)} className="px-4 py-2 bg-blue-600 text-white rounded">完成</button>
409
+ </div>
410
+ </div>
411
+ </div>
412
+ )}
413
+
414
  {/* Excel Import Modal */}
415
  {isImportOpen && (
416
  <div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
 
468
  )}
469
  </div>
470
  );
471
+ };
server.js CHANGED
@@ -28,6 +28,7 @@ const InMemoryDB = {
28
  scores: [],
29
  classes: [],
30
  subjects: [],
 
31
  config: {
32
  systemName: '智慧校园管理系统',
33
  semester: '2023-2024学年 第一学期',
@@ -115,6 +116,13 @@ const SubjectSchema = new mongoose.Schema({
115
  });
116
  const SubjectModel = mongoose.model('Subject', SubjectSchema);
117
 
 
 
 
 
 
 
 
118
  const ConfigSchema = new mongoose.Schema({
119
  key: { type: String, default: 'main' },
120
  systemName: String,
@@ -273,6 +281,27 @@ app.delete('/api/subjects/:id', async (req, res) => {
273
  } catch (e) { res.status(500).json({ error: e.message }); }
274
  });
275
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
276
  // --- Batch Operations ---
277
  app.post('/api/batch-delete', async (req, res) => {
278
  const { type, ids } = req.body; // type: 'student' | 'score' | 'user'
 
28
  scores: [],
29
  classes: [],
30
  subjects: [],
31
+ exams: [],
32
  config: {
33
  systemName: '智慧校园管理系统',
34
  semester: '2023-2024学年 第一学期',
 
116
  });
117
  const SubjectModel = mongoose.model('Subject', SubjectSchema);
118
 
119
+ const ExamSchema = new mongoose.Schema({
120
+ name: { type: String, required: true, unique: true },
121
+ date: String,
122
+ semester: String
123
+ });
124
+ const ExamModel = mongoose.model('Exam', ExamSchema);
125
+
126
  const ConfigSchema = new mongoose.Schema({
127
  key: { type: String, default: 'main' },
128
  systemName: String,
 
281
  } catch (e) { res.status(500).json({ error: e.message }); }
282
  });
283
 
284
+ // --- Exams ---
285
+ app.get('/api/exams', async (req, res) => {
286
+ if (InMemoryDB.isFallback) return res.json(InMemoryDB.exams);
287
+ const exams = await ExamModel.find().sort({ date: 1 });
288
+ res.json(exams);
289
+ });
290
+ app.post('/api/exams', async (req, res) => {
291
+ try {
292
+ // Upsert based on name
293
+ const { name, date, semester } = req.body;
294
+ if (InMemoryDB.isFallback) {
295
+ const idx = InMemoryDB.exams.findIndex(e => e.name === name);
296
+ if (idx >= 0) InMemoryDB.exams[idx] = { ...InMemoryDB.exams[idx], date, semester };
297
+ else InMemoryDB.exams.push({ name, date, semester, id: Date.now(), _id: String(Date.now()) });
298
+ return res.json({ success: true });
299
+ }
300
+ await ExamModel.findOneAndUpdate({ name }, { date, semester }, { upsert: true });
301
+ res.json({ success: true });
302
+ } catch (e) { res.status(500).json({ error: e.message }); }
303
+ });
304
+
305
  // --- Batch Operations ---
306
  app.post('/api/batch-delete', async (req, res) => {
307
  const { type, ids } = req.body; // type: 'student' | 'score' | 'user'
services/api.ts CHANGED
@@ -91,6 +91,11 @@ export const api = {
91
  delete: (id: string | number) => request(`/subjects/${id}`, { method: 'DELETE' })
92
  },
93
 
 
 
 
 
 
94
  courses: {
95
  getAll: () => request('/courses'),
96
  add: (data: any) => request('/courses', { method: 'POST', body: JSON.stringify(data) }),
 
91
  delete: (id: string | number) => request(`/subjects/${id}`, { method: 'DELETE' })
92
  },
93
 
94
+ exams: {
95
+ getAll: () => request('/exams'),
96
+ save: (data: any) => request('/exams', { method: 'POST', body: JSON.stringify(data) })
97
+ },
98
+
99
  courses: {
100
  getAll: () => request('/courses'),
101
  add: (data: any) => request('/courses', { method: 'POST', body: JSON.stringify(data) }),
types.ts CHANGED
@@ -88,6 +88,14 @@ export interface Score {
88
  status?: ExamStatus; // Default: Normal
89
  }
90
 
 
 
 
 
 
 
 
 
91
  export interface ApiResponse<T> {
92
  code: number;
93
  message: string;
 
88
  status?: ExamStatus; // Default: Normal
89
  }
90
 
91
+ export interface Exam {
92
+ id?: number;
93
+ _id?: string;
94
+ name: string; // e.g. "期中考试"
95
+ date: string; // e.g. "2023-11-01"
96
+ semester?: string;
97
+ }
98
+
99
  export interface ApiResponse<T> {
100
  code: number;
101
  message: string;