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

Update pages/Reports.tsx

Browse files
Files changed (1) hide show
  1. pages/Reports.tsx +339 -209
pages/Reports.tsx CHANGED
@@ -1,55 +1,44 @@
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 () => {
@@ -62,115 +51,65 @@ export const Reports: React.FC = () => {
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,27 +120,32 @@ export const Reports: React.FC = () => {
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,31 +153,48 @@ export const Reports: React.FC = () => {
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,7 +202,7 @@ export const Reports: React.FC = () => {
241
  });
242
  setMatrixData(mData);
243
 
244
- }, [scores, students, classes, subjects, exams, selectedGrade, selectedClass, selectedSubject, isTeacher]);
245
 
246
 
247
  const exportExcel = () => {
@@ -249,14 +210,23 @@ export const Reports: React.FC = () => {
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,6 +234,7 @@ export const Reports: React.FC = () => {
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,11 +252,16 @@ export const Reports: React.FC = () => {
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,7 +271,7 @@ export const Reports: React.FC = () => {
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,14 +280,15 @@ export const Reports: React.FC = () => {
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,20 +302,23 @@ export const Reports: React.FC = () => {
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,9 +330,10 @@ export const Reports: React.FC = () => {
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,107 +348,256 @@ export const Reports: React.FC = () => {
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>
 
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 () => {
 
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
  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
  });
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
  });
203
  setMatrixData(mData);
204
 
205
+ }, [scores, students, classes, subjects, exams, selectedGrade, selectedClass, selectedSubject]);
206
 
207
 
208
  const exportExcel = () => {
 
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
  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
 
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
  <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
  </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
  ))}
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
  </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
  <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>