dvc890 commited on
Commit
8e99bae
·
verified ·
1 Parent(s): fc41832

Update pages/Reports.tsx

Browse files
Files changed (1) hide show
  1. pages/Reports.tsx +209 -339
pages/Reports.tsx CHANGED
@@ -1,45 +1,56 @@
1
  import React, { useState, useEffect } from 'react';
2
  import {
3
  BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer,
4
- LineChart, Line, AreaChart, Area, PieChart as RePieChart, Pie, Cell, RadarChart, Radar, PolarGrid, PolarAngleAxis, PolarRadiusAxis
5
  } from 'recharts';
6
  import { api } from '../services/api';
7
  import { Loader2, Download, Filter, TrendingUp, Grid, BarChart2, User, PieChart as PieChartIcon } from 'lucide-react';
8
- import { Score, Student, ClassInfo, Subject, Exam } from '../types';
 
 
 
 
 
9
 
10
  export const Reports: React.FC = () => {
11
  const [loading, setLoading] = useState(true);
12
  const [activeTab, setActiveTab] = useState<'overview' | 'grade' | 'trend' | 'matrix' | 'student'>('overview');
13
 
14
- // Data
15
- const [scores, setScores] = useState<Score[]>([]);
16
  const [students, setStudents] = useState<Student[]>([]);
17
- const [classes, setClasses] = useState<ClassInfo[]>([]);
18
  const [subjects, setSubjects] = useState<Subject[]>([]);
19
  const [exams, setExams] = useState<Exam[]>([]);
20
 
 
 
 
 
21
  // Filters
22
- const [selectedGrade, setSelectedGrade] = useState<string>('六年级');
23
- const [selectedClass, setSelectedClass] = useState<string>(''); // For Trend/Student
24
- const [selectedSubject, setSelectedSubject] = useState<string>(''); // For Trend
25
 
26
- // Computed Data
27
  const [gradeAnalysisData, setGradeAnalysisData] = useState<any[]>([]);
28
  const [trendData, setTrendData] = useState<any[]>([]);
29
  const [matrixData, setMatrixData] = useState<any[]>([]);
 
30
 
31
- // Overview Data
32
- const [overviewData, setOverviewData] = useState<{
33
- totalStudents: number;
34
- avgScore: number;
35
- passRate: number;
36
- gradeLadder: any[];
37
- subjectDist: any[];
38
- }>({ totalStudents: 0, avgScore: 0, passRate: 0, gradeLadder: [], subjectDist: [] });
39
-
40
- // Student Focus State
41
  const [selectedStudent, setSelectedStudent] = useState<Student | null>(null);
42
 
 
 
 
 
 
 
 
 
 
 
43
  useEffect(() => {
44
  const loadData = async () => {
45
  setLoading(true);
@@ -51,65 +62,115 @@ export const Reports: React.FC = () => {
51
  api.subjects.getAll(),
52
  api.exams.getAll()
53
  ]);
54
- setScores(scs);
55
  setStudents(stus);
56
- setClasses(cls);
57
  setSubjects(subs);
58
  setExams(exs);
59
 
60
- // Set Defaults
61
- if (cls.length > 0) setSelectedClass(cls[0].grade + cls[0].className);
62
  if (subs.length > 0) setSelectedSubject(subs[0].name);
63
- } catch (e) {
64
- console.error(e);
65
- } finally {
66
- setLoading(false);
67
- }
68
  };
69
  loadData();
70
  }, []);
71
 
72
- // Compute Metrics whenever filters or data change
73
  useEffect(() => {
74
- if (scores.length === 0 || students.length === 0) return;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
 
76
- // --- 0. Overview Calculations ---
77
- const normalScores = scores.filter(s => s.status === 'Normal');
78
- const totalAvg = normalScores.length ? normalScores.reduce((a,b)=>a+b.score,0)/normalScores.length : 0;
79
- const totalPass = normalScores.filter(s => s.score >= 60).length;
80
- const passRate = normalScores.length ? (totalPass / normalScores.length)*100 : 0;
81
-
82
- // Grade Ladder (Avg score by grade)
83
- const uniqueGradesList = Array.from(new Set(classes.map(c => c.grade))).sort();
84
- const ladderData = uniqueGradesList.map(g => {
85
- const gradeClasses = classes.filter(c => c.grade === g).map(c => c.grade+c.className);
86
- const gradeStus = students.filter(s => gradeClasses.includes(s.className)).map(s => s.studentNo);
87
- const gradeScores = normalScores.filter(s => gradeStus.includes(s.studentNo));
88
- const gAvg = gradeScores.length ? gradeScores.reduce((a,b)=>a+b.score,0)/gradeScores.length : 0;
89
- return { name: g, 平均分: Number(gAvg.toFixed(1)) };
90
- });
91
 
92
- // Subject Distribution
93
- const subDist = subjects.map(sub => {
94
- const subScores = normalScores.filter(s => s.courseName === sub.name);
95
- const sAvg = subScores.length ? subScores.reduce((a,b)=>a+b.score,0)/subScores.length : 0;
96
- return { name: sub.name, value: Number(sAvg.toFixed(1)), color: sub.color };
97
- });
98
 
99
- setOverviewData({
100
- totalStudents: students.length,
101
- avgScore: Number(totalAvg.toFixed(1)),
102
- passRate: Number(passRate.toFixed(1)),
103
- gradeLadder: ladderData,
104
- subjectDist: subDist
105
- });
 
 
 
106
 
 
 
 
 
 
 
107
 
108
- // --- 1. Grade Analysis (Horizontal Class Comparison) ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
109
  const gradeClasses = classes.filter(c => c.grade === selectedGrade);
110
  const gaData = gradeClasses.map(cls => {
111
  const fullClassName = cls.grade + cls.className;
112
- const classStudentIds = students.filter(s => s.className === fullClassName).map(s => s.studentNo);
 
113
 
114
  const classScores = scores.filter(s => classStudentIds.includes(s.studentNo) && s.status === 'Normal');
115
 
@@ -120,32 +181,27 @@ export const Reports: React.FC = () => {
120
  const passRate = classScores.length ? (passed / classScores.length) * 100 : 0;
121
 
122
  const excellent = classScores.filter(s => {
123
- // Find subject threshold
124
  const sub = subjects.find(sub => sub.name === s.courseName);
125
  return s.score >= (sub?.excellenceThreshold || 90);
126
  }).length;
127
  const excellentRate = classScores.length ? (excellent / classScores.length) * 100 : 0;
128
 
129
  return {
130
- name: cls.className, // Label X-axis with just class name to save space
131
  fullName: fullClassName,
132
  平均分: Number(avg.toFixed(1)),
133
  及格率: Number(passRate.toFixed(1)),
134
  优秀率: Number(excellentRate.toFixed(1)),
135
- studentCount: classStudentIds.length
136
  };
137
  });
138
  setGradeAnalysisData(gaData);
139
 
140
-
141
- // --- 2. Trend Analysis (Time/Exam based) ---
142
- // Group by Exam Name or Type. Sort using Exam Dates if available.
143
  if (selectedClass && selectedSubject) {
144
  const classStudentIds = students.filter(s => s.className === selectedClass).map(s => s.studentNo);
145
-
146
  const uniqueExamNames = Array.from(new Set(scores.map(s => s.examName || s.type)));
147
 
148
- // Sort exams by date
149
  uniqueExamNames.sort((a, b) => {
150
  const dateA = exams.find(e => e.name === a)?.date || '9999-99-99';
151
  const dateB = exams.find(e => e.name === b)?.date || '9999-99-99';
@@ -153,48 +209,31 @@ export const Reports: React.FC = () => {
153
  });
154
 
155
  const tData = uniqueExamNames.map(exam => {
156
- const examScores = scores.filter(s =>
157
- (s.examName === exam || s.type === exam) &&
158
- s.courseName === selectedSubject &&
159
- s.status === 'Normal'
160
- );
161
 
162
- // Class Avg
163
  const classExamScores = examScores.filter(s => classStudentIds.includes(s.studentNo));
164
- const classAvg = classExamScores.length
165
- ? classExamScores.reduce((a,b)=>a+b.score,0) / classExamScores.length
166
- : 0;
167
 
168
- // Grade Avg (for comparison)
 
 
169
  const currentGrade = classes.find(c => c.grade + c.className === selectedClass)?.grade || '';
170
  const gradeStudentIds = students.filter(s => s.className.startsWith(currentGrade)).map(s => s.studentNo);
171
  const gradeExamScores = examScores.filter(s => gradeStudentIds.includes(s.studentNo));
172
- const gradeAvg = gradeExamScores.length
173
- ? gradeExamScores.reduce((a,b)=>a+b.score,0) / gradeExamScores.length
174
- : 0;
175
-
176
- return {
177
- name: exam,
178
- 班级平均: Number(classAvg.toFixed(1)),
179
- 年级平均: Number(gradeAvg.toFixed(1))
180
- };
181
  });
182
  setTrendData(tData);
183
  }
184
 
185
- // --- 3. Subject Matrix (Heatmap Table) ---
186
  const mData = gradeClasses.map(cls => {
187
  const fullClassName = cls.grade + cls.className;
188
  const classStudentIds = students.filter(s => s.className === fullClassName).map(s => s.studentNo);
189
-
190
  const row: any = { className: cls.className, fullName: fullClassName };
191
-
192
  subjects.forEach(sub => {
193
- const subScores = scores.filter(s =>
194
- s.courseName === sub.name &&
195
- classStudentIds.includes(s.studentNo) &&
196
- s.status === 'Normal'
197
- );
198
  const avg = subScores.length ? subScores.reduce((a,b)=>a+b.score,0)/subScores.length : 0;
199
  row[sub.name] = Number(avg.toFixed(1));
200
  });
@@ -202,7 +241,7 @@ export const Reports: React.FC = () => {
202
  });
203
  setMatrixData(mData);
204
 
205
- }, [scores, students, classes, subjects, exams, selectedGrade, selectedClass, selectedSubject]);
206
 
207
 
208
  const exportExcel = () => {
@@ -210,23 +249,14 @@ export const Reports: React.FC = () => {
210
  if (!window.XLSX) return alert('Excel 组件未加载');
211
  // @ts-ignore
212
  const XLSX = window.XLSX;
213
-
214
- // Export current active tab data
215
- let dataToExport: any[] = [];
216
- if (activeTab === 'grade') dataToExport = gradeAnalysisData;
217
- else if (activeTab === 'trend') dataToExport = trendData;
218
- else if (activeTab === 'matrix') dataToExport = matrixData;
219
- else dataToExport = gradeAnalysisData; // Default
220
-
221
  const ws = XLSX.utils.json_to_sheet(dataToExport);
222
  const wb = XLSX.utils.book_new();
223
  XLSX.utils.book_append_sheet(wb, ws, "Report");
224
- XLSX.writeFile(wb, `Report_${activeTab}_${new Date().toISOString().slice(0,10)}.xlsx`);
225
  };
226
 
227
- // Helper for Student Focus
228
  const getStudentTrend = (studentNo: string) => {
229
- // Get all scores for this student, grouped by exam, sorted by date
230
  const stuScores = scores.filter(s => s.studentNo === studentNo && s.status === 'Normal');
231
  const uniqueExamNames = Array.from(new Set(stuScores.map(s => s.examName || s.type)));
232
  uniqueExamNames.sort((a, b) => {
@@ -234,7 +264,6 @@ export const Reports: React.FC = () => {
234
  const dateB = exams.find(e => e.name === b)?.date || '9999-99-99';
235
  return dateA.localeCompare(dateB);
236
  });
237
-
238
  return uniqueExamNames.map(exam => {
239
  const s = stuScores.find(s => (s.examName || s.type) === exam);
240
  return { name: exam, score: s ? s.score : 0 };
@@ -252,16 +281,11 @@ export const Reports: React.FC = () => {
252
 
253
  const getStudentAttendance = (studentNo: string) => {
254
  const all = scores.filter(s => s.studentNo === studentNo);
255
- return {
256
- absent: all.filter(s => s.status === 'Absent').length,
257
- leave: all.filter(s => s.status === 'Leave').length
258
- };
259
  };
260
 
261
- const uniqueGrades = Array.from(new Set(classes.map(c => c.grade))).sort();
262
- const allClasses = classes.map(c => c.grade + c.className);
263
-
264
- // Student List for Focus Tab
265
  const focusStudents = students.filter(s => selectedClass ? s.className === selectedClass : true);
266
 
267
  if (loading) return <div className="flex justify-center py-20"><Loader2 className="animate-spin text-blue-600"/></div>;
@@ -271,7 +295,7 @@ export const Reports: React.FC = () => {
271
  <div className="flex flex-col md:flex-row justify-between items-center gap-4">
272
  <div>
273
  <h2 className="text-xl font-bold text-gray-800">教务数据分析中心</h2>
274
- <p className="text-sm text-gray-500">基于 {scores.length} 条成绩数据分析</p>
275
  </div>
276
  <div className="flex gap-2">
277
  <button onClick={exportExcel} className="flex items-center space-x-2 px-4 py-2 bg-emerald-50 text-emerald-600 border border-emerald-200 rounded-lg text-sm hover:bg-emerald-100 transition-colors">
@@ -280,15 +304,14 @@ export const Reports: React.FC = () => {
280
  </div>
281
  </div>
282
 
283
- {/* Tabs */}
284
  <div className="flex space-x-1 bg-gray-100 p-1 rounded-xl w-full md:w-auto overflow-x-auto">
285
  {[
286
- { id: 'overview', label: '全校概览', icon: PieChartIcon },
287
- { id: 'grade', label: '年级横向分析', icon: BarChart2 },
288
- { id: 'trend', label: '教学成长轨迹', icon: TrendingUp },
289
- { id: 'matrix', label: '学科质量透视', icon: Grid },
290
  { id: 'student', label: '学生个像', icon: User },
291
- ].map(tab => (
292
  <button
293
  key={tab.id}
294
  onClick={() => setActiveTab(tab.id as any)}
@@ -302,23 +325,20 @@ export const Reports: React.FC = () => {
302
  ))}
303
  </div>
304
 
305
- {/* Content Area */}
306
  <div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6 min-h-[500px]">
307
-
308
- {/* Filter Bar */}
309
  {activeTab !== 'overview' && (
310
  <div className="flex flex-wrap gap-4 mb-8 items-center bg-gray-50 p-4 rounded-lg">
311
- <div className="flex items-center text-sm font-bold text-gray-500"><Filter size={16} className="mr-2"/> 筛选维度:</div>
312
 
313
  {(activeTab === 'grade' || activeTab === 'matrix') && (
314
- <select className="border border-gray-300 p-2 rounded text-sm text-gray-800 focus:ring-2 focus:ring-blue-500" value={selectedGrade} onChange={e => setSelectedGrade(e.target.value)}>
315
- {uniqueGrades.map(g => <option key={g} value={g}>{g}</option>)}
316
  </select>
317
  )}
318
 
319
  {(activeTab === 'trend' || activeTab === 'student') && (
320
  <select className="border border-gray-300 p-2 rounded text-sm text-gray-800" value={selectedClass} onChange={e => setSelectedClass(e.target.value)}>
321
- {allClasses.map(c => <option key={c} value={c}>{c}</option>)}
322
  </select>
323
  )}
324
 
@@ -330,10 +350,9 @@ export const Reports: React.FC = () => {
330
  </div>
331
  )}
332
 
333
- {/* --- 0. Overview View --- */}
334
- {activeTab === 'overview' && (
335
  <div className="animate-in fade-in space-y-8">
336
- {/* KPI Cards */}
337
  <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
338
  <div className="bg-blue-50 p-6 rounded-xl border border-blue-100">
339
  <p className="text-gray-500 text-sm font-medium mb-1">全校总人数</p>
@@ -348,256 +367,107 @@ export const Reports: React.FC = () => {
348
  <h3 className="text-3xl font-bold text-emerald-700">{overviewData.passRate}%</h3>
349
  </div>
350
  </div>
351
-
352
  <div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
353
- {/* Grade Ladder */}
354
- <div className="bg-white p-4 rounded-lg border border-gray-100 shadow-sm">
355
  <h3 className="font-bold text-gray-800 mb-4 text-center">各年级平均分阶梯</h3>
356
- <div className="h-72">
357
- <ResponsiveContainer width="100%" height="100%">
358
- <AreaChart data={overviewData.gradeLadder}>
359
- <defs>
360
- <linearGradient id="colorAvg" x1="0" y1="0" x2="0" y2="1">
361
- <stop offset="5%" stopColor="#8884d8" stopOpacity={0.8}/>
362
- <stop offset="95%" stopColor="#8884d8" stopOpacity={0}/>
363
- </linearGradient>
364
- </defs>
365
- <XAxis dataKey="name" />
366
- <YAxis domain={[0, 100]}/>
367
- <CartesianGrid strokeDasharray="3 3" vertical={false} />
368
- <Tooltip />
369
- <Area type="monotone" dataKey="平均分" stroke="#8884d8" fillOpacity={1} fill="url(#colorAvg)" />
370
- </AreaChart>
371
- </ResponsiveContainer>
372
- </div>
373
  </div>
374
-
375
- {/* Subject Distribution */}
376
- <div className="bg-white p-4 rounded-lg border border-gray-100 shadow-sm">
377
- <h3 className="font-bold text-gray-800 mb-4 text-center">全校学科平均分雷达</h3>
378
- <div className="h-72">
379
- <ResponsiveContainer width="100%" height="100%">
380
- <RadarChart cx="50%" cy="50%" outerRadius="80%" data={overviewData.subjectDist}>
381
- <PolarGrid />
382
- <PolarAngleAxis dataKey="name" />
383
- <PolarRadiusAxis angle={30} domain={[0, 100]} />
384
- <Radar name="平均分" dataKey="value" stroke="#82ca9d" fill="#82ca9d" fillOpacity={0.6} />
385
- <Tooltip />
386
- </RadarChart>
387
- </ResponsiveContainer>
388
- </div>
389
  </div>
390
  </div>
391
  </div>
392
  )}
393
 
394
- {/* --- 1. Grade Analysis View --- */}
395
  {activeTab === 'grade' && (
396
  <div className="space-y-10 animate-in fade-in">
 
397
  <div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
398
- <div className="bg-white p-4 rounded-lg border border-gray-100">
399
- <h3 className="font-bold text-gray-800 mb-4 flex items-center">
400
- <span className="w-1 h-6 bg-blue-500 mr-2 rounded-full"></span>
401
- {selectedGrade} - 各班平均分对比
402
- </h3>
403
- <div className="h-80">
404
- <ResponsiveContainer width="100%" height="100%">
405
- <BarChart data={gradeAnalysisData} layout="vertical" margin={{ left: 20 }}>
406
- <CartesianGrid strokeDasharray="3 3" horizontal={true} vertical={false}/>
407
- <XAxis type="number" domain={[0, 100]} hide/>
408
- <YAxis dataKey="name" type="category" width={80} tick={{fontSize: 12}}/>
409
- <Tooltip cursor={{fill: '#f3f4f6'}} contentStyle={{borderRadius: '8px', border: 'none', boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)'}}/>
410
- <Bar dataKey="平均分" fill="#3b82f6" radius={[0, 4, 4, 0]} barSize={20} label={{ position: 'right', fill: '#666', fontSize: 12 }}/>
411
- </BarChart>
412
- </ResponsiveContainer>
413
- </div>
414
  </div>
415
-
416
- <div className="bg-white p-4 rounded-lg border border-gray-100">
417
- <h3 className="font-bold text-gray-800 mb-4 flex items-center">
418
- <span className="w-1 h-6 bg-emerald-500 mr-2 rounded-full"></span>
419
- {selectedGrade} - 优良率 vs 及格率
420
- </h3>
421
- <div className="h-80">
422
- <ResponsiveContainer width="100%" height="100%">
423
- <BarChart data={gradeAnalysisData}>
424
- <CartesianGrid strokeDasharray="3 3" vertical={false}/>
425
- <XAxis dataKey="name" tick={{fontSize: 12}}/>
426
- <YAxis domain={[0, 100]}/>
427
- <Tooltip contentStyle={{borderRadius: '8px'}}/>
428
- <Legend />
429
- <Bar dataKey="及格率" fill="#10b981" radius={[4, 4, 0, 0]} />
430
- <Bar dataKey="优秀率" fill="#f59e0b" radius={[4, 4, 0, 0]} />
431
- </BarChart>
432
- </ResponsiveContainer>
433
- </div>
434
  </div>
435
  </div>
 
436
  </div>
437
  )}
438
 
439
- {/* --- 2. Trend Analysis View --- */}
440
  {activeTab === 'trend' && (
441
  <div className="animate-in fade-in space-y-6">
442
- <div className="bg-white p-4 rounded-lg border border-gray-100">
443
- <h3 className="font-bold text-gray-800 mb-6 text-center">
444
- {selectedClass} {selectedSubject} - 成绩成长轨迹
445
- </h3>
446
- <div className="h-96">
447
- <ResponsiveContainer width="100%" height="100%">
448
- <LineChart data={trendData} margin={{ top: 20, right: 30, left: 20, bottom: 20 }}>
449
- <CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f0f0f0"/>
450
- <XAxis dataKey="name" axisLine={false} tickLine={false} dy={10}/>
451
- <YAxis domain={[0, 100]} axisLine={false} tickLine={false}/>
452
- <Tooltip contentStyle={{borderRadius: '8px', boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1)'}}/>
453
- <Legend verticalAlign="top" height={36}/>
454
- <Line type="monotone" dataKey="班级平均" stroke="#3b82f6" strokeWidth={3} dot={{r: 6}} activeDot={{r: 8}} />
455
- <Line type="monotone" dataKey="年级平均" stroke="#94a3b8" strokeWidth={2} strokeDasharray="5 5" dot={{r: 4}} />
456
- </LineChart>
457
- </ResponsiveContainer>
458
- </div>
459
  </div>
460
  </div>
461
  )}
462
 
463
- {/* --- 3. Subject Matrix View --- */}
464
  {activeTab === 'matrix' && (
465
  <div className="animate-in fade-in overflow-x-auto">
466
- <h3 className="font-bold text-gray-800 mb-6">
467
- {selectedGrade} - 学科质量透视矩阵
468
- </h3>
469
  <table className="w-full text-sm text-left">
470
  <thead className="bg-gray-50 text-gray-500 uppercase">
471
- <tr>
472
- <th className="px-6 py-4 rounded-tl-lg">班级</th>
473
- {subjects.map(s => <th key={s._id} className="px-6 py-4 font-bold text-gray-700">{s.name}</th>)}
474
- </tr>
475
  </thead>
476
  <tbody className="divide-y divide-gray-100">
477
  {matrixData.map((row, idx) => (
478
- <tr key={idx} className="hover:bg-gray-50 transition-colors">
479
- <td className="px-6 py-4 font-bold text-gray-800 bg-gray-50/50">{row.className}</td>
480
- {subjects.map(sub => {
481
- const val = row[sub.name];
482
- let colorClass = 'text-gray-800';
483
- let bgClass = '';
484
- if (val >= (sub.excellenceThreshold || 90)) { colorClass = 'text-green-700 font-bold'; bgClass = 'bg-green-50'; }
485
- else if (val < 60) { colorClass = 'text-red-600 font-bold'; bgClass = 'bg-red-50'; }
486
-
487
- return (
488
- <td key={sub.name} className={`px-6 py-4 ${bgClass}`}>
489
- <span className={colorClass}>{val}</span>
490
- </td>
491
- );
492
- })}
493
- </tr>
494
  ))}
495
  </tbody>
496
  </table>
497
  </div>
498
  )}
499
 
500
- {/* --- 4. Student Focus View --- */}
501
  {activeTab === 'student' && (
502
- <div className="animate-in fade-in">
503
- <h3 className="font-bold text-gray-800 mb-4">{selectedClass} - 学生个像分析</h3>
504
- <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
505
- {focusStudents.map(s => {
506
- const trend = getStudentTrend(s.studentNo);
507
- return (
508
- <div
509
- key={s._id || s.id}
510
- onClick={() => setSelectedStudent(s)}
511
- className="bg-white border border-gray-200 p-4 rounded-xl cursor-pointer hover:shadow-lg hover:border-blue-400 transition-all group"
512
- >
513
- <div className="flex items-center space-x-3 mb-3">
514
- <div className={`w-10 h-10 rounded-full flex items-center justify-center font-bold text-white ${s.gender === 'Female' ? 'bg-pink-400' : 'bg-blue-400'}`}>
515
- {s.name[0]}
516
- </div>
517
- <div>
518
- <h4 className="font-bold text-gray-800 group-hover:text-blue-600">{s.name}</h4>
519
- <p className="text-xs text-gray-500 font-mono">{s.studentNo}</p>
520
- </div>
521
- </div>
522
- {/* Mini Sparkline */}
523
- <div className="h-16 w-full">
524
- <ResponsiveContainer width="100%" height="100%">
525
- <LineChart data={trend}>
526
- <Line type="monotone" dataKey="score" stroke="#94a3b8" strokeWidth={2} dot={false} />
527
- </LineChart>
528
- </ResponsiveContainer>
529
- </div>
530
- </div>
531
- );
532
- })}
533
- </div>
534
  </div>
535
  )}
536
  </div>
537
 
538
- {/* Student Details Modal */}
539
  {selectedStudent && (
540
  <div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4 backdrop-blur-sm animate-in fade-in">
541
- <div className="bg-white rounded-2xl p-6 w-full max-w-4xl max-h-[90vh] overflow-y-auto shadow-2xl">
542
- <div className="flex justify-between items-start mb-6">
543
- <div className="flex items-center space-x-4">
544
- <div className={`w-16 h-16 rounded-full flex items-center justify-center text-2xl font-bold text-white ${selectedStudent.gender === 'Female' ? 'bg-pink-500' : 'bg-blue-500'}`}>
545
- {selectedStudent.name[0]}
546
- </div>
547
- <div>
548
- <h2 className="text-2xl font-bold text-gray-900">{selectedStudent.name}</h2>
549
- <div className="flex items-center space-x-3 text-sm text-gray-500 mt-1">
550
- <span className="bg-gray-100 px-2 py-0.5 rounded text-gray-700">{selectedStudent.className}</span>
551
- <span className="font-mono">{selectedStudent.studentNo}</span>
552
- </div>
553
- </div>
554
- </div>
555
- <button onClick={() => setSelectedStudent(null)} className="p-2 hover:bg-gray-100 rounded-full transition-colors"><Grid size={20}/></button>
556
- </div>
557
-
558
- <div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
559
- <div className="bg-blue-50 p-4 rounded-xl border border-blue-100">
560
- <p className="text-blue-600 text-sm font-bold mb-1">考勤状况</p>
561
- <div className="flex justify-between items-end">
562
- <span className="text-3xl font-bold text-blue-800">{getStudentAttendance(selectedStudent.studentNo).absent}</span>
563
- <span className="text-xs text-blue-400 mb-1">次缺考</span>
564
- </div>
565
- </div>
566
- {/* Add more stats here if needed */}
567
- </div>
568
-
569
  <div className="grid grid-cols-1 md:grid-cols-2 gap-8">
570
- {/* Radar */}
571
- <div className="bg-white border border-gray-100 rounded-xl p-4 shadow-sm">
572
- <h3 className="font-bold text-gray-800 mb-4 text-center">学科能力模型</h3>
573
- <div className="h-64">
574
- <ResponsiveContainer width="100%" height="100%">
575
- <RadarChart cx="50%" cy="50%" outerRadius="70%" data={getStudentRadar(selectedStudent.studentNo)}>
576
- <PolarGrid />
577
- <PolarAngleAxis dataKey="subject" />
578
- <PolarRadiusAxis angle={30} domain={[0, 100]} />
579
- <Radar name={selectedStudent.name} dataKey="score" stroke="#8884d8" fill="#8884d8" fillOpacity={0.6} />
580
- <Tooltip />
581
- </RadarChart>
582
- </ResponsiveContainer>
583
- </div>
584
- </div>
585
-
586
- {/* Trend */}
587
- <div className="bg-white border border-gray-100 rounded-xl p-4 shadow-sm">
588
- <h3 className="font-bold text-gray-800 mb-4 text-center">综合成绩走势</h3>
589
- <div className="h-64">
590
- <ResponsiveContainer width="100%" height="100%">
591
- <LineChart data={getStudentTrend(selectedStudent.studentNo)}>
592
- <CartesianGrid strokeDasharray="3 3" vertical={false}/>
593
- <XAxis dataKey="name" />
594
- <YAxis domain={[0, 100]}/>
595
- <Tooltip />
596
- <Line type="monotone" dataKey="score" stroke="#2563eb" strokeWidth={3} />
597
- </LineChart>
598
- </ResponsiveContainer>
599
- </div>
600
- </div>
601
  </div>
602
  </div>
603
  </div>
 
1
  import React, { useState, useEffect } from 'react';
2
  import {
3
  BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer,
4
+ LineChart, Line, AreaChart, Area, RadarChart, Radar, PolarGrid, PolarAngleAxis, PolarRadiusAxis
5
  } from 'recharts';
6
  import { api } from '../services/api';
7
  import { Loader2, Download, Filter, TrendingUp, Grid, BarChart2, User, PieChart as PieChartIcon } from 'lucide-react';
8
+ import { Score, Student, ClassInfo, Subject, Exam, UserRole } from '../types';
9
+
10
+ // 排序辅助
11
+ const gradeOrder: Record<string, number> = { '一年级': 1, '二年级': 2, '三年级': 3, '四年级': 4, '五年级': 5, '六年级': 6, '初一': 7, '七年级': 7, '初二': 8, '八年级': 8, '初三': 9, '九年级': 9, '高一': 10, '高二': 11, '高三': 12 };
12
+ const sortGrades = (a: string, b: string) => (gradeOrder[a] || 99) - (gradeOrder[b] || 99);
13
+ const sortClasses = (a: string, b: string) => a.localeCompare(b, 'zh-CN', { numeric: true });
14
 
15
  export const Reports: React.FC = () => {
16
  const [loading, setLoading] = useState(true);
17
  const [activeTab, setActiveTab] = useState<'overview' | 'grade' | 'trend' | 'matrix' | 'student'>('overview');
18
 
19
+ // Raw Data
20
+ const [rawScores, setRawScores] = useState<Score[]>([]);
21
  const [students, setStudents] = useState<Student[]>([]);
22
+ const [rawClasses, setRawClasses] = useState<ClassInfo[]>([]);
23
  const [subjects, setSubjects] = useState<Subject[]>([]);
24
  const [exams, setExams] = useState<Exam[]>([]);
25
 
26
+ // Derived/Filtered Data for Display
27
+ const [scores, setScores] = useState<Score[]>([]);
28
+ const [classes, setClasses] = useState<ClassInfo[]>([]);
29
+
30
  // Filters
31
+ const [selectedGrade, setSelectedGrade] = useState<string>('');
32
+ const [selectedClass, setSelectedClass] = useState<string>('');
33
+ const [selectedSubject, setSelectedSubject] = useState<string>('');
34
 
35
+ // Computed Chart Data
36
  const [gradeAnalysisData, setGradeAnalysisData] = useState<any[]>([]);
37
  const [trendData, setTrendData] = useState<any[]>([]);
38
  const [matrixData, setMatrixData] = useState<any[]>([]);
39
+ const [overviewData, setOverviewData] = useState<any>({ totalStudents: 0, avgScore: 0, passRate: 0, gradeLadder: [], subjectDist: [] });
40
 
41
+ // Student Focus
 
 
 
 
 
 
 
 
 
42
  const [selectedStudent, setSelectedStudent] = useState<Student | null>(null);
43
 
44
+ const currentUser = api.auth.getCurrentUser();
45
+ const isTeacher = currentUser?.role === UserRole.TEACHER;
46
+
47
+ useEffect(() => {
48
+ // If teacher, default to 'grade' view as 'overview' is hidden
49
+ if (isTeacher && activeTab === 'overview') {
50
+ setActiveTab('grade');
51
+ }
52
+ }, [isTeacher]);
53
+
54
  useEffect(() => {
55
  const loadData = async () => {
56
  setLoading(true);
 
62
  api.subjects.getAll(),
63
  api.exams.getAll()
64
  ]);
65
+ setRawScores(scs);
66
  setStudents(stus);
67
+ setRawClasses(cls);
68
  setSubjects(subs);
69
  setExams(exs);
70
 
71
+ // Initial Defaults
 
72
  if (subs.length > 0) setSelectedSubject(subs[0].name);
73
+ } catch (e) { console.error(e); }
74
+ finally { setLoading(false); }
 
 
 
75
  };
76
  loadData();
77
  }, []);
78
 
79
+ // Filter Data based on Role
80
  useEffect(() => {
81
+ if (rawScores.length === 0 || rawClasses.length === 0) return;
82
+
83
+ let filteredScores = rawScores;
84
+ let filteredClasses = rawClasses;
85
+
86
+ if (isTeacher) {
87
+ // Teacher Filter Logic:
88
+ // 1. Scores where student is in Homeroom Class
89
+ // 2. Scores where subject is Teaching Subject
90
+ const mySubject = currentUser.teachingSubject;
91
+ const myHomeroom = currentUser.homeroomClass;
92
+
93
+ filteredScores = rawScores.filter(s => {
94
+ // Find student to check class
95
+ const stu = students.find(st => st.studentNo === s.studentNo);
96
+ if (!stu) return false;
97
+
98
+ const isMyHomeroomStudent = myHomeroom && stu.className === myHomeroom;
99
+ const isMySubjectScore = mySubject && s.courseName === mySubject;
100
+
101
+ return isMyHomeroomStudent || isMySubjectScore;
102
+ });
103
+
104
+ // Determine relevant classes based on filtered scores + homeroom
105
+ const relevantClassNames = new Set(filteredScores.map(s => {
106
+ const stu = students.find(st => st.studentNo === s.studentNo);
107
+ return stu ? stu.className : null;
108
+ }).filter(Boolean));
109
+
110
+ if (myHomeroom) relevantClassNames.add(myHomeroom);
111
 
112
+ filteredClasses = rawClasses.filter(c => relevantClassNames.has(c.grade + c.className));
113
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
114
 
115
+ setScores(filteredScores);
116
+ setClasses(filteredClasses);
 
 
 
 
117
 
118
+ // Set default filters if not set or invalid
119
+ const validGrades = Array.from(new Set(filteredClasses.map(c => c.grade))).sort(sortGrades);
120
+ if (!selectedGrade || !validGrades.includes(selectedGrade)) {
121
+ if (validGrades.length > 0) setSelectedGrade(validGrades[0]);
122
+ }
123
+
124
+ const validClasses = filteredClasses.map(c => c.grade + c.className);
125
+ if (!selectedClass || !validClasses.includes(selectedClass)) {
126
+ if (validClasses.length > 0) setSelectedClass(validClasses[0]);
127
+ }
128
 
129
+ }, [rawScores, rawClasses, students, isTeacher]);
130
+
131
+
132
+ // Compute Metrics
133
+ useEffect(() => {
134
+ if (scores.length === 0 && !isTeacher) return; // Allow teachers to see empty states
135
 
136
+ const normalScores = scores.filter(s => s.status === 'Normal');
137
+
138
+ // --- Overview (Admin Only) ---
139
+ if (!isTeacher) {
140
+ const totalAvg = normalScores.length ? normalScores.reduce((a,b)=>a+b.score,0)/normalScores.length : 0;
141
+ const totalPass = normalScores.filter(s => s.score >= 60).length;
142
+ const passRate = normalScores.length ? (totalPass / normalScores.length)*100 : 0;
143
+
144
+ const uniqueGradesList = Array.from(new Set(classes.map(c => c.grade))).sort(sortGrades);
145
+ const ladderData = uniqueGradesList.map(g => {
146
+ const gradeClasses = classes.filter(c => c.grade === g).map(c => c.grade+c.className);
147
+ const gradeStus = students.filter(s => gradeClasses.includes(s.className)).map(s => s.studentNo);
148
+ const gradeScores = normalScores.filter(s => gradeStus.includes(s.studentNo));
149
+ const gAvg = gradeScores.length ? gradeScores.reduce((a,b)=>a+b.score,0)/gradeScores.length : 0;
150
+ return { name: g, 平均分: Number(gAvg.toFixed(1)) };
151
+ });
152
+
153
+ const subDist = subjects.map(sub => {
154
+ const subScores = normalScores.filter(s => s.courseName === sub.name);
155
+ const sAvg = subScores.length ? subScores.reduce((a,b)=>a+b.score,0)/subScores.length : 0;
156
+ return { name: sub.name, value: Number(sAvg.toFixed(1)), color: sub.color };
157
+ });
158
+
159
+ setOverviewData({
160
+ totalStudents: students.length,
161
+ avgScore: Number(totalAvg.toFixed(1)),
162
+ passRate: Number(passRate.toFixed(1)),
163
+ gradeLadder: ladderData,
164
+ subjectDist: subDist
165
+ });
166
+ }
167
+
168
+ // --- Grade Analysis ---
169
  const gradeClasses = classes.filter(c => c.grade === selectedGrade);
170
  const gaData = gradeClasses.map(cls => {
171
  const fullClassName = cls.grade + cls.className;
172
+ const classStudents = students.filter(s => s.className === fullClassName);
173
+ const classStudentIds = classStudents.map(s => s.studentNo);
174
 
175
  const classScores = scores.filter(s => classStudentIds.includes(s.studentNo) && s.status === 'Normal');
176
 
 
181
  const passRate = classScores.length ? (passed / classScores.length) * 100 : 0;
182
 
183
  const excellent = classScores.filter(s => {
 
184
  const sub = subjects.find(sub => sub.name === s.courseName);
185
  return s.score >= (sub?.excellenceThreshold || 90);
186
  }).length;
187
  const excellentRate = classScores.length ? (excellent / classScores.length) * 100 : 0;
188
 
189
  return {
190
+ name: cls.className,
191
  fullName: fullClassName,
192
  平均分: Number(avg.toFixed(1)),
193
  及格率: Number(passRate.toFixed(1)),
194
  优秀率: Number(excellentRate.toFixed(1)),
195
+ studentCount: classStudents.length
196
  };
197
  });
198
  setGradeAnalysisData(gaData);
199
 
200
+ // --- Trend ---
 
 
201
  if (selectedClass && selectedSubject) {
202
  const classStudentIds = students.filter(s => s.className === selectedClass).map(s => s.studentNo);
 
203
  const uniqueExamNames = Array.from(new Set(scores.map(s => s.examName || s.type)));
204
 
 
205
  uniqueExamNames.sort((a, b) => {
206
  const dateA = exams.find(e => e.name === a)?.date || '9999-99-99';
207
  const dateB = exams.find(e => e.name === b)?.date || '9999-99-99';
 
209
  });
210
 
211
  const tData = uniqueExamNames.map(exam => {
212
+ const examScores = scores.filter(s => (s.examName === exam || s.type === exam) && s.courseName === selectedSubject && s.status === 'Normal');
 
 
 
 
213
 
 
214
  const classExamScores = examScores.filter(s => classStudentIds.includes(s.studentNo));
215
+ const classAvg = classExamScores.length ? classExamScores.reduce((a,b)=>a+b.score,0) / classExamScores.length : 0;
 
 
216
 
217
+ // For Teacher, Grade Average might be restricted, but useful for comparison if allowed
218
+ // We calculate Grade Avg based on filtered scores (so only scores visible to teacher)
219
+ // If teacher sees all scores of subject, this is valid grade avg for that subject.
220
  const currentGrade = classes.find(c => c.grade + c.className === selectedClass)?.grade || '';
221
  const gradeStudentIds = students.filter(s => s.className.startsWith(currentGrade)).map(s => s.studentNo);
222
  const gradeExamScores = examScores.filter(s => gradeStudentIds.includes(s.studentNo));
223
+ const gradeAvg = gradeExamScores.length ? gradeExamScores.reduce((a,b)=>a+b.score,0) / gradeExamScores.length : 0;
224
+
225
+ return { name: exam, 班级平均: Number(classAvg.toFixed(1)), 年级平均: Number(gradeAvg.toFixed(1)) };
 
 
 
 
 
 
226
  });
227
  setTrendData(tData);
228
  }
229
 
230
+ // --- Matrix ---
231
  const mData = gradeClasses.map(cls => {
232
  const fullClassName = cls.grade + cls.className;
233
  const classStudentIds = students.filter(s => s.className === fullClassName).map(s => s.studentNo);
 
234
  const row: any = { className: cls.className, fullName: fullClassName };
 
235
  subjects.forEach(sub => {
236
+ const subScores = scores.filter(s => s.courseName === sub.name && classStudentIds.includes(s.studentNo) && s.status === 'Normal');
 
 
 
 
237
  const avg = subScores.length ? subScores.reduce((a,b)=>a+b.score,0)/subScores.length : 0;
238
  row[sub.name] = Number(avg.toFixed(1));
239
  });
 
241
  });
242
  setMatrixData(mData);
243
 
244
+ }, [scores, students, classes, subjects, exams, selectedGrade, selectedClass, selectedSubject, isTeacher]);
245
 
246
 
247
  const exportExcel = () => {
 
249
  if (!window.XLSX) return alert('Excel 组件未加载');
250
  // @ts-ignore
251
  const XLSX = window.XLSX;
252
+ let dataToExport: any[] = activeTab === 'grade' ? gradeAnalysisData : (activeTab === 'trend' ? trendData : matrixData);
 
 
 
 
 
 
 
253
  const ws = XLSX.utils.json_to_sheet(dataToExport);
254
  const wb = XLSX.utils.book_new();
255
  XLSX.utils.book_append_sheet(wb, ws, "Report");
256
+ XLSX.writeFile(wb, `Report_${activeTab}.xlsx`);
257
  };
258
 
 
259
  const getStudentTrend = (studentNo: string) => {
 
260
  const stuScores = scores.filter(s => s.studentNo === studentNo && s.status === 'Normal');
261
  const uniqueExamNames = Array.from(new Set(stuScores.map(s => s.examName || s.type)));
262
  uniqueExamNames.sort((a, b) => {
 
264
  const dateB = exams.find(e => e.name === b)?.date || '9999-99-99';
265
  return dateA.localeCompare(dateB);
266
  });
 
267
  return uniqueExamNames.map(exam => {
268
  const s = stuScores.find(s => (s.examName || s.type) === exam);
269
  return { name: exam, score: s ? s.score : 0 };
 
281
 
282
  const getStudentAttendance = (studentNo: string) => {
283
  const all = scores.filter(s => s.studentNo === studentNo);
284
+ return { absent: all.filter(s => s.status === 'Absent').length, leave: all.filter(s => s.status === 'Leave').length };
 
 
 
285
  };
286
 
287
+ const uniqueGrades = Array.from(new Set(classes.map(c => c.grade))).sort(sortGrades);
288
+ const allClasses = classes.map(c => c.grade + c.className).sort(sortClasses);
 
 
289
  const focusStudents = students.filter(s => selectedClass ? s.className === selectedClass : true);
290
 
291
  if (loading) return <div className="flex justify-center py-20"><Loader2 className="animate-spin text-blue-600"/></div>;
 
295
  <div className="flex flex-col md:flex-row justify-between items-center gap-4">
296
  <div>
297
  <h2 className="text-xl font-bold text-gray-800">教务数据分析中心</h2>
298
+ <p className="text-sm text-gray-500">基于 {scores.length} 条成绩数据分析 {isTeacher && '(仅展示您相关班级/科目)'}</p>
299
  </div>
300
  <div className="flex gap-2">
301
  <button onClick={exportExcel} className="flex items-center space-x-2 px-4 py-2 bg-emerald-50 text-emerald-600 border border-emerald-200 rounded-lg text-sm hover:bg-emerald-100 transition-colors">
 
304
  </div>
305
  </div>
306
 
 
307
  <div className="flex space-x-1 bg-gray-100 p-1 rounded-xl w-full md:w-auto overflow-x-auto">
308
  {[
309
+ { id: 'overview', label: '全校概览', icon: PieChartIcon, hidden: isTeacher },
310
+ { id: 'grade', label: '年级分析', icon: BarChart2 },
311
+ { id: 'trend', label: '教学趋势', icon: TrendingUp },
312
+ { id: 'matrix', label: '质量透视', icon: Grid },
313
  { id: 'student', label: '学生个像', icon: User },
314
+ ].filter(t => !t.hidden).map(tab => (
315
  <button
316
  key={tab.id}
317
  onClick={() => setActiveTab(tab.id as any)}
 
325
  ))}
326
  </div>
327
 
 
328
  <div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6 min-h-[500px]">
 
 
329
  {activeTab !== 'overview' && (
330
  <div className="flex flex-wrap gap-4 mb-8 items-center bg-gray-50 p-4 rounded-lg">
331
+ <div className="flex items-center text-sm font-bold text-gray-500"><Filter size={16} className="mr-2"/> 筛选:</div>
332
 
333
  {(activeTab === 'grade' || activeTab === 'matrix') && (
334
+ <select className="border border-gray-300 p-2 rounded text-sm text-gray-800" value={selectedGrade} onChange={e => setSelectedGrade(e.target.value)}>
335
+ {uniqueGrades.length > 0 ? uniqueGrades.map(g => <option key={g} value={g}>{g}</option>) : <option value="">暂无相关数据</option>}
336
  </select>
337
  )}
338
 
339
  {(activeTab === 'trend' || activeTab === 'student') && (
340
  <select className="border border-gray-300 p-2 rounded text-sm text-gray-800" value={selectedClass} onChange={e => setSelectedClass(e.target.value)}>
341
+ {allClasses.length > 0 ? allClasses.map(c => <option key={c} value={c}>{c}</option>) : <option value="">暂无相关数据</option>}
342
  </select>
343
  )}
344
 
 
350
  </div>
351
  )}
352
 
353
+ {/* Overview - Admin Only */}
354
+ {activeTab === 'overview' && !isTeacher && (
355
  <div className="animate-in fade-in space-y-8">
 
356
  <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
357
  <div className="bg-blue-50 p-6 rounded-xl border border-blue-100">
358
  <p className="text-gray-500 text-sm font-medium mb-1">全校总人数</p>
 
367
  <h3 className="text-3xl font-bold text-emerald-700">{overviewData.passRate}%</h3>
368
  </div>
369
  </div>
 
370
  <div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
371
+ <div className="bg-white p-4 rounded-lg border border-gray-100 shadow-sm h-72">
 
372
  <h3 className="font-bold text-gray-800 mb-4 text-center">各年级平均分阶梯</h3>
373
+ <ResponsiveContainer width="100%" height="100%">
374
+ <AreaChart data={overviewData.gradeLadder}>
375
+ <XAxis dataKey="name" /> <YAxis domain={[0, 100]}/> <Tooltip /> <Area type="monotone" dataKey="平均分" stroke="#8884d8" fill="#8884d8" fillOpacity={0.3} />
376
+ </AreaChart>
377
+ </ResponsiveContainer>
 
 
 
 
 
 
 
 
 
 
 
 
378
  </div>
379
+ <div className="bg-white p-4 rounded-lg border border-gray-100 shadow-sm h-72">
380
+ <h3 className="font-bold text-gray-800 mb-4 text-center">全校学科分布</h3>
381
+ <ResponsiveContainer width="100%" height="100%">
382
+ <RadarChart cx="50%" cy="50%" outerRadius="80%" data={overviewData.subjectDist}>
383
+ <PolarGrid /> <PolarAngleAxis dataKey="name" /> <PolarRadiusAxis angle={30} domain={[0, 100]} /> <Radar name="平均分" dataKey="value" stroke="#82ca9d" fill="#82ca9d" fillOpacity={0.6} /> <Tooltip />
384
+ </RadarChart>
385
+ </ResponsiveContainer>
 
 
 
 
 
 
 
 
386
  </div>
387
  </div>
388
  </div>
389
  )}
390
 
391
+ {/* Grade Analysis */}
392
  {activeTab === 'grade' && (
393
  <div className="space-y-10 animate-in fade-in">
394
+ {gradeAnalysisData.length === 0 ? <div className="text-center py-20 text-gray-400">该年级暂无数据或无权查看</div> : (
395
  <div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
396
+ <div className="bg-white p-4 rounded-lg border border-gray-100 h-80">
397
+ <h3 className="font-bold text-gray-800 mb-4">{selectedGrade} - 各班平均分</h3>
398
+ <ResponsiveContainer width="100%" height="100%">
399
+ <BarChart data={gradeAnalysisData} layout="vertical" margin={{ left: 20 }}>
400
+ <CartesianGrid strokeDasharray="3 3" horizontal={true} vertical={false}/> <XAxis type="number" domain={[0, 100]} hide/> <YAxis dataKey="name" type="category" width={80}/> <Tooltip/> <Bar dataKey="平均分" fill="#3b82f6" radius={[0, 4, 4, 0]} barSize={20} label={{ position: 'right' }}/>
401
+ </BarChart>
402
+ </ResponsiveContainer>
 
 
 
 
 
 
 
 
 
403
  </div>
404
+ <div className="bg-white p-4 rounded-lg border border-gray-100 h-80">
405
+ <h3 className="font-bold text-gray-800 mb-4">优良率 vs 及格率</h3>
406
+ <ResponsiveContainer width="100%" height="100%">
407
+ <BarChart data={gradeAnalysisData}>
408
+ <CartesianGrid strokeDasharray="3 3" vertical={false}/> <XAxis dataKey="name"/> <YAxis domain={[0, 100]}/> <Tooltip/> <Legend /> <Bar dataKey="及格率" fill="#10b981" /> <Bar dataKey="优秀率" fill="#f59e0b" />
409
+ </BarChart>
410
+ </ResponsiveContainer>
 
 
 
 
 
 
 
 
 
 
 
 
411
  </div>
412
  </div>
413
+ )}
414
  </div>
415
  )}
416
 
417
+ {/* Trend Analysis */}
418
  {activeTab === 'trend' && (
419
  <div className="animate-in fade-in space-y-6">
420
+ <div className="bg-white p-4 rounded-lg border border-gray-100 h-96">
421
+ <h3 className="font-bold text-gray-800 mb-6 text-center">{selectedClass} {selectedSubject} - 成绩轨迹</h3>
422
+ <ResponsiveContainer width="100%" height="100%">
423
+ <LineChart data={trendData}>
424
+ <CartesianGrid strokeDasharray="3 3" vertical={false}/> <XAxis dataKey="name"/> <YAxis domain={[0, 100]}/> <Tooltip/> <Legend/> <Line type="monotone" dataKey="班级平均" stroke="#3b82f6" strokeWidth={3} /> <Line type="monotone" dataKey="年级平均" stroke="#94a3b8" strokeWidth={2} strokeDasharray="5 5" />
425
+ </LineChart>
426
+ </ResponsiveContainer>
 
 
 
 
 
 
 
 
 
 
427
  </div>
428
  </div>
429
  )}
430
 
431
+ {/* Matrix */}
432
  {activeTab === 'matrix' && (
433
  <div className="animate-in fade-in overflow-x-auto">
 
 
 
434
  <table className="w-full text-sm text-left">
435
  <thead className="bg-gray-50 text-gray-500 uppercase">
436
+ <tr><th className="px-6 py-4">班级</th>{subjects.map(s => <th key={s._id} className="px-6 py-4">{s.name}</th>)}</tr>
 
 
 
437
  </thead>
438
  <tbody className="divide-y divide-gray-100">
439
  {matrixData.map((row, idx) => (
440
+ <tr key={idx} className="hover:bg-gray-50"><td className="px-6 py-4 font-bold bg-gray-50/50">{row.className}</td>{subjects.map(sub => <td key={sub.name} className="px-6 py-4">{row[sub.name] || '-'}</td>)}</tr>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
441
  ))}
442
  </tbody>
443
  </table>
444
  </div>
445
  )}
446
 
447
+ {/* Student Focus */}
448
  {activeTab === 'student' && (
449
+ <div className="animate-in fade-in grid grid-cols-1 md:grid-cols-3 xl:grid-cols-4 gap-4">
450
+ {focusStudents.map(s => (
451
+ <div key={s._id} onClick={() => setSelectedStudent(s)} className="bg-white border p-4 rounded-xl cursor-pointer hover:shadow-lg hover:border-blue-400 transition-all">
452
+ <div className="flex items-center space-x-3 mb-3">
453
+ <div className={`w-10 h-10 rounded-full flex items-center justify-center text-white ${s.gender==='Female'?'bg-pink-400':'bg-blue-400'}`}>{s.name[0]}</div>
454
+ <div><h4 className="font-bold">{s.name}</h4><p className="text-xs text-gray-500">{s.studentNo}</p></div>
455
+ </div>
456
+ <div className="h-16"><ResponsiveContainer><LineChart data={getStudentTrend(s.studentNo)}><Line type="monotone" dataKey="score" stroke="#94a3b8" dot={false} /></LineChart></ResponsiveContainer></div>
457
+ </div>
458
+ ))}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
459
  </div>
460
  )}
461
  </div>
462
 
 
463
  {selectedStudent && (
464
  <div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4 backdrop-blur-sm animate-in fade-in">
465
+ <div className="bg-white rounded-2xl p-6 w-full max-w-4xl max-h-[90vh] overflow-y-auto shadow-2xl relative">
466
+ <button onClick={() => setSelectedStudent(null)} className="absolute top-4 right-4 p-2 hover:bg-gray-100 rounded-full"><Grid size={20}/></button>
467
+ <h2 className="text-2xl font-bold mb-6">{selectedStudent.name} <span className="text-sm font-normal text-gray-500">{selectedStudent.className}</span></h2>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
468
  <div className="grid grid-cols-1 md:grid-cols-2 gap-8">
469
+ <div className="h-64 border rounded p-4"><h3 className="text-center font-bold mb-4">能力模型</h3><ResponsiveContainer><RadarChart data={getStudentRadar(selectedStudent.studentNo)}><PolarGrid/><PolarAngleAxis dataKey="subject"/><PolarRadiusAxis angle={30} domain={[0,100]}/><Radar dataKey="score" stroke="#8884d8" fill="#8884d8" fillOpacity={0.6}/><Tooltip/></RadarChart></ResponsiveContainer></div>
470
+ <div className="h-64 border rounded p-4"><h3 className="text-center font-bold mb-4">成绩走势</h3><ResponsiveContainer><LineChart data={getStudentTrend(selectedStudent.studentNo)}><CartesianGrid vertical={false}/><XAxis dataKey="name"/><YAxis domain={[0,100]}/><Tooltip/><Line type="monotone" dataKey="score" stroke="#2563eb" strokeWidth={3}/></LineChart></ResponsiveContainer></div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
471
  </div>
472
  </div>
473
  </div>