dvc890 commited on
Commit
dcc7a3c
·
verified ·
1 Parent(s): 28ab858

Update pages/Reports.tsx

Browse files
Files changed (1) hide show
  1. pages/Reports.tsx +304 -300
pages/Reports.tsx CHANGED
@@ -1,354 +1,358 @@
1
 
2
  import React, { useState, useEffect } from 'react';
3
  import {
4
- BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
5
- RadarChart, PolarGrid, PolarAngleAxis, PolarRadiusAxis, Radar,
6
- PieChart, Pie, Cell, Legend
7
  } from 'recharts';
8
- import { Loader2, Download, Filter, TrendingUp, Users, BookOpen, Award, PieChart as PieChartIcon } from 'lucide-react';
9
  import { api } from '../services/api';
10
- import { Score, Subject, Student, ClassInfo } from '../types';
11
-
12
- // UI Components
13
- const Card: React.FC<{ title: string; subtitle?: string; children: React.ReactNode; className?: string }> = ({ title, subtitle, children, className = '' }) => (
14
- <div className={`bg-white p-6 rounded-xl border border-gray-100 shadow-sm hover:shadow-md transition-shadow ${className}`}>
15
- <div className="mb-6">
16
- <h3 className="text-lg font-bold text-gray-800">{title}</h3>
17
- {subtitle && <p className="text-sm text-gray-400 mt-1">{subtitle}</p>}
18
- </div>
19
- {children}
20
- </div>
21
- );
22
-
23
- const StatBox: React.FC<{ label: string; value: string | number; color: string; icon: React.ElementType }> = ({ label, value, color, icon: Icon }) => (
24
- <div className="bg-white p-5 rounded-xl border border-gray-100 shadow-sm flex items-center space-x-4">
25
- <div className={`p-3 rounded-lg ${color} bg-opacity-10 text-${color.split('-')[1]}-600`}>
26
- <Icon size={24} className={color.replace('bg-', 'text-')} />
27
- </div>
28
- <div>
29
- <p className="text-sm text-gray-500">{label}</p>
30
- <p className="text-2xl font-bold text-gray-800">{value}</p>
31
- </div>
32
- </div>
33
- );
34
 
35
  export const Reports: React.FC = () => {
36
  const [loading, setLoading] = useState(true);
37
- const [activeTab, setActiveTab] = useState<'overview' | 'class' | 'subject'>('overview');
38
- const [selectedGrade, setSelectedGrade] = useState('All');
39
 
40
- // Raw Data
41
  const [scores, setScores] = useState<Score[]>([]);
42
- const [subjects, setSubjects] = useState<Subject[]>([]);
43
  const [students, setStudents] = useState<Student[]>([]);
44
  const [classes, setClasses] = useState<ClassInfo[]>([]);
 
 
 
 
 
 
45
 
46
- // Derived Data for Charts
47
- const [stats, setStats] = useState({ avg: 0, max: 0, passRate: 0, excellentRate: 0 });
48
- const [classComparisonData, setClassComparisonData] = useState<any[]>([]);
49
- const [subjectRadarData, setSubjectRadarData] = useState<any[]>([]);
50
- const [gradeDistData, setGradeDistData] = useState<any[]>([]);
51
 
52
  useEffect(() => {
53
- const load = async () => {
 
54
  try {
55
- const [sc, su, st, cl] = await Promise.all([
56
- api.scores.getAll(),
57
- api.subjects.getAll(),
58
  api.students.getAll(),
59
- api.classes.getAll()
60
- ]) as [Score[], Subject[], Student[], ClassInfo[]];
 
 
 
 
 
61
 
62
- setScores(sc);
63
- setSubjects(su);
64
- setStudents(st);
65
- setClasses(cl);
66
- } catch (e) { console.error(e); }
67
- finally { setLoading(false); }
 
 
68
  };
69
- load();
70
  }, []);
71
 
72
- // Recalculate when data or filter changes
73
  useEffect(() => {
74
- if (loading) return;
75
 
76
- // Filter Scores based on Grade
77
- let filteredScores: Score[] = scores;
78
-
79
- if (selectedGrade !== 'All') {
80
- filteredScores = scores.filter((s: Score) => {
81
- const student = students.find((st: Student) => st.studentNo === s.studentNo);
82
- return student && student.className.includes(selectedGrade);
83
- });
84
- }
 
 
 
 
 
 
 
 
 
 
 
85
 
86
- // Only count Normal exams for average/max statistics
87
- const validScores = filteredScores.filter((s: Score) => s.status === 'Normal');
88
-
89
- // 1. Basic Stats
90
- const totalScore = validScores.reduce((sum: number, s: Score) => sum + (s.score || 0), 0);
91
- const avg = validScores.length ? Math.round(totalScore / validScores.length) : 0;
92
- const max = validScores.length ? Math.max(...validScores.map((s: Score) => s.score || 0)) : 0;
93
- const passCount = validScores.filter((s: Score) => (s.score || 0) >= 60).length;
94
-
95
- // Dynamic Excellence Calculation based on subject threshold
96
- let excellentCount = 0;
97
- validScores.forEach((s: Score) => {
98
- const sub = subjects.find(su => su.name === s.courseName);
99
- const threshold = sub?.excellenceThreshold || 90;
100
- if (s.score >= threshold) excellentCount++;
101
- });
102
-
103
- setStats({
104
- avg,
105
- max,
106
- passRate: validScores.length ? Math.round((passCount / validScores.length) * 100) : 0,
107
- excellentRate: validScores.length ? Math.round((excellentCount / validScores.length) * 100) : 0
108
- });
109
-
110
- // 2. Class Comparison Data (Bar Chart)
111
- const classMap = new Map<string, number[]>();
112
- filteredScores.forEach((s: Score) => {
113
- if (s.status !== 'Normal') return; // Skip abnormal for class avg
114
- const stu = students.find((st: Student) => st.studentNo === s.studentNo);
115
- if (stu) {
116
- const clsName = stu.className;
117
- if (!classMap.has(clsName)) classMap.set(clsName, []);
118
- classMap.get(clsName)?.push(s.score || 0);
119
- }
120
- });
121
-
122
- const clsData: any[] = [];
123
- classMap.forEach((scores, name) => {
124
- const clsAvg = Math.round(scores.reduce((a, b) => a + b, 0) / scores.length);
125
- clsData.push({ name, avg: clsAvg, count: scores.length });
126
- });
127
- setClassComparisonData(clsData.sort((a, b) => b.avg - a.avg));
128
-
129
- // 3. Subject Radar Data
130
- const subData: any[] = subjects.map((sub: Subject) => {
131
- const subScores = validScores.filter((s: Score) => s.courseName === sub.name);
132
- const subTotal = subScores.reduce((sum: number, s: Score) => sum + (s.score || 0), 0);
133
  return {
134
- subject: sub.name,
135
- A: subScores.length ? Math.round(subTotal / subScores.length) : 0,
136
- fullMark: 100,
137
- threshold: sub.excellenceThreshold || 90
 
 
138
  };
139
  });
140
- setSubjectRadarData(subData);
141
 
142
- // 4. Distribution Pie (Use valid scores)
143
- setGradeDistData([
144
- { name: '优秀', value: excellentCount, color: '#10b981' },
145
- { name: '良好', value: validScores.filter((s: Score) => {
146
- const threshold = subjects.find(sub => sub.name === s.courseName)?.excellenceThreshold || 90;
147
- return s.score >= 80 && s.score < threshold;
148
- }).length, color: '#3b82f6' },
149
- { name: '及格', value: validScores.filter((s: Score) => s.score >= 60 && s.score < 80).length, color: '#f59e0b' },
150
- { name: '不及格', value: validScores.filter((s: Score) => s.score < 60).length, color: '#ef4444' }
151
- ]);
152
 
153
- }, [scores, students, selectedGrade, loading, subjects]);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
154
 
155
- const handleExport = () => {
156
- // @ts-ignore
157
- if (!window.XLSX) return alert('Excel 组件未加载,请检查网络');
 
 
 
 
 
158
 
159
- try {
160
- // @ts-ignore
161
- const wb = window.XLSX.utils.book_new();
162
-
163
- const statsData = [{
164
- '年级': selectedGrade,
165
- '平均分': stats.avg,
166
- '最高分': stats.max,
167
- '及格率': stats.passRate + '%',
168
- '优秀率': stats.excellentRate + '%'
169
- }];
170
- // @ts-ignore
171
- const wsStats = window.XLSX.utils.json_to_sheet(statsData);
172
- // @ts-ignore
173
- window.XLSX.utils.book_append_sheet(wb, wsStats, "概览");
174
 
175
- // @ts-ignore
176
- const wsClass = window.XLSX.utils.json_to_sheet(classComparisonData.map((c: any) => ({
177
- '班级': c.name,
178
- '平均分': c.avg,
179
- '参考人数': c.count
180
- })));
181
- // @ts-ignore
182
- window.XLSX.utils.book_append_sheet(wb, wsClass, "班级排名");
183
 
184
- // @ts-ignore
185
- window.XLSX.writeFile(wb, `教学报表_${selectedGrade}_${new Date().toLocaleDateString()}.xlsx`);
186
- } catch (e) {
187
- alert('导出失败,请重试');
188
- console.error(e);
189
- }
190
- };
 
 
 
 
 
 
 
191
 
192
- if (loading) return <div className="flex justify-center py-20"><Loader2 className="animate-spin text-blue-600" /></div>;
193
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
194
  const uniqueGrades = Array.from(new Set(classes.map(c => c.grade))).sort();
 
 
 
195
 
196
  return (
197
  <div className="space-y-6">
198
- {/* Header & Filter */}
199
- <div className="flex flex-col md:flex-row justify-between items-center bg-white p-5 rounded-xl border border-gray-100 shadow-sm">
200
- <div>
201
- <h2 className="text-xl font-bold text-gray-800">教务数据分析中心</h2>
202
- <p className="text-sm text-gray-500 mt-1">基于 {scores.length} 条成绩数据分析</p>
203
- </div>
204
- <div className="flex items-center gap-3 mt-4 md:mt-0">
205
- <div className="flex items-center bg-gray-50 px-3 py-2 rounded-lg border border-gray-200">
206
- <Filter size={16} className="text-gray-500 mr-2" />
207
- <select
208
- value={selectedGrade}
209
- onChange={e => setSelectedGrade(e.target.value)}
210
- className="bg-transparent text-sm font-medium text-gray-700 focus:outline-none"
211
- >
212
- <option value="All">所有年级</option>
213
- {uniqueGrades.map(g => <option key={g} value={g}>{g}</option>)}
214
- </select>
215
- </div>
216
- <button onClick={handleExport} className="btn-secondary flex items-center gap-2 text-sm px-4 py-2 border rounded-lg hover:bg-emerald-50 text-emerald-600 border-emerald-200 transition-colors">
217
- <Download size={16} /> 导出报表
218
- </button>
219
- </div>
220
- </div>
221
-
222
- {/* Overview Cards */}
223
- <div className="grid grid-cols-1 md:grid-cols-4 gap-6">
224
- <StatBox label="平均分" value={stats.avg} icon={Award} color="bg-blue-100 text-blue-600" />
225
- <StatBox label="最高分" value={stats.max} icon={TrendingUp} color="bg-emerald-100 text-emerald-600" />
226
- <StatBox label="及格率" value={`${stats.passRate}%`} icon={BookOpen} color="bg-amber-100 text-amber-600" />
227
- <StatBox label="优秀率" value={`${stats.excellentRate}%`} icon={Users} color="bg-indigo-100 text-indigo-600" />
228
  </div>
229
 
230
- {/* Tab Navigation */}
231
- <div className="border-b border-gray-200">
232
- <div className="flex space-x-8">
233
- {[
234
- { id: 'overview', label: '全校概览' },
235
- { id: 'class', label: '班级对比' },
236
- { id: 'subject', label: '学科分析' }
237
- ].map(tab => (
238
  <button
239
- key={tab.id}
240
- onClick={() => setActiveTab(tab.id as any)}
241
- className={`py-4 text-sm font-medium border-b-2 transition-colors ${
242
- activeTab === tab.id
243
- ? 'border-blue-600 text-blue-600'
244
- : 'border-transparent text-gray-500 hover:text-gray-700'
245
- }`}
246
  >
247
- {tab.label}
 
248
  </button>
249
- ))}
250
- </div>
251
  </div>
252
 
253
- {/* Tab Content */}
254
- <div className="animate-in fade-in slide-in-from-bottom-4 duration-500">
255
-
256
- {/* OVERVIEW TAB */}
257
- {activeTab === 'overview' && (
258
- <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
259
- <Card title="成绩等级分布" subtitle="剔除缺考数据">
260
- <div className="h-80">
261
- {scores.length > 0 ? (
262
- <ResponsiveContainer width="100%" height="100%">
263
- <PieChart>
264
- <Pie data={gradeDistData} cx="50%" cy="50%" innerRadius={80} outerRadius={110} paddingAngle={5} dataKey="value">
265
- {gradeDistData.map((entry: any, index: number) => (
266
- <Cell key={`cell-${index}`} fill={entry.color} />
267
- ))}
268
- </Pie>
269
- <Tooltip />
270
- <Legend verticalAlign="bottom" height={36} iconType="circle"/>
271
- </PieChart>
272
- </ResponsiveContainer>
273
- ) : (
274
- <div className="h-full flex flex-col items-center justify-center text-gray-400">
275
- <PieChartIcon size={48} className="mb-2 opacity-20" />
276
- <p>暂无数据</p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
277
  </div>
278
- )}
279
- </div>
280
- </Card>
281
 
282
- <Card title="学科综合能力雷达" subtitle="各学科平均分维度分析">
283
- <div className="h-80">
284
- {scores.length > 0 ? (
285
- <ResponsiveContainer width="100%" height="100%">
286
- <RadarChart cx="50%" cy="50%" outerRadius="70%" data={subjectRadarData}>
287
- <PolarGrid />
288
- <PolarAngleAxis dataKey="subject" />
289
- <PolarRadiusAxis angle={30} domain={[0, 100]} />
290
- <Radar name="平均分" dataKey="A" stroke="#8b5cf6" fill="#8b5cf6" fillOpacity={0.5} />
291
- <Tooltip />
292
- </RadarChart>
293
- </ResponsiveContainer>
294
- ) : (
295
- <div className="h-full flex flex-col items-center justify-center text-gray-400">
296
- <p>暂无数据</p>
297
- </div>
298
- )}
299
- </div>
300
- </Card>
301
- </div>
302
- )}
 
303
 
304
- {/* CLASS TAB */}
305
- {activeTab === 'class' && (
306
- <Card title="班级成绩对比" subtitle="按班级平均分排序">
307
- <div className="h-96">
308
- {classComparisonData.length > 0 ? (
309
- <ResponsiveContainer width="100%" height="100%">
310
- <BarChart data={classComparisonData} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
311
- <CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f0f0f0" />
312
- <XAxis dataKey="name" axisLine={false} tickLine={false} />
313
- <YAxis axisLine={false} tickLine={false} />
314
- <Tooltip cursor={{ fill: '#f9fafb' }} contentStyle={{ borderRadius: '8px', border: 'none', boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)' }} />
315
- <Bar dataKey="avg" name="平均分" fill="#3b82f6" radius={[4, 4, 0, 0]} barSize={40} />
316
- </BarChart>
317
- </ResponsiveContainer>
318
- ) : (
319
- <div className="h-full flex items-center justify-center text-gray-400">暂无班级数据</div>
320
- )}
 
 
 
 
321
  </div>
322
- </Card>
323
- )}
324
 
325
- {/* SUBJECT TAB */}
326
- {activeTab === 'subject' && (
327
- <div className="grid grid-cols-1 gap-6">
328
- {subjectRadarData.map((sub: any) => (
329
- <div key={sub.subject} className="bg-white p-4 rounded-lg border border-gray-100 flex items-center justify-between">
330
- <div className="flex items-center space-x-4">
331
- <div className="w-10 h-10 rounded-full bg-blue-50 flex items-center justify-center text-blue-600 font-bold">
332
- {sub.subject[0]}
333
- </div>
334
- <div>
335
- <h4 className="font-bold text-gray-800">{sub.subject}</h4>
336
- <p className="text-xs text-gray-400">平均分 | 优秀线: {sub.threshold}</p>
337
- </div>
338
- </div>
339
- <div className="text-right">
340
- <span className={`text-2xl font-bold ${sub.A >= sub.threshold ? 'text-emerald-500' : sub.A >= 60 ? 'text-blue-600' : 'text-red-500'}`}>
341
- {sub.A}
342
- </span>
343
- <span className="text-xs text-gray-400 ml-1">/ 100</span>
344
- </div>
345
- <div className="w-32 h-2 bg-gray-100 rounded-full overflow-hidden">
346
- <div className="h-full bg-blue-500" style={{ width: `${sub.A}%` }}></div>
347
- </div>
348
- </div>
349
- ))}
350
- </div>
351
- )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
352
 
353
  </div>
354
  </div>
 
1
 
2
  import React, { useState, useEffect } from 'react';
3
  import {
4
+ BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer,
5
+ LineChart, Line, AreaChart, Area, PieChart as RePieChart, Pie, Cell
 
6
  } from 'recharts';
 
7
  import { api } from '../services/api';
8
+ import { Loader2, Download, Filter, TrendingUp, Users, BookOpen, PieChart as PieChartIcon, Grid, BarChart2 } from 'lucide-react';
9
+ import { Score, Student, ClassInfo, Subject } from '../types';
10
+ import * as XLSX from 'xlsx';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
 
12
  export const Reports: React.FC = () => {
13
  const [loading, setLoading] = useState(true);
14
+ const [activeTab, setActiveTab] = useState<'overview' | 'grade' | 'trend' | 'matrix'>('grade');
 
15
 
16
+ // Data
17
  const [scores, setScores] = useState<Score[]>([]);
 
18
  const [students, setStudents] = useState<Student[]>([]);
19
  const [classes, setClasses] = useState<ClassInfo[]>([]);
20
+ const [subjects, setSubjects] = useState<Subject[]>([]);
21
+
22
+ // Filters
23
+ const [selectedGrade, setSelectedGrade] = useState<string>('六年级');
24
+ const [selectedClass, setSelectedClass] = useState<string>(''); // For Trend
25
+ const [selectedSubject, setSelectedSubject] = useState<string>(''); // For Trend
26
 
27
+ // Computed Data
28
+ const [gradeAnalysisData, setGradeAnalysisData] = useState<any[]>([]);
29
+ const [trendData, setTrendData] = useState<any[]>([]);
30
+ const [matrixData, setMatrixData] = useState<any[]>([]);
 
31
 
32
  useEffect(() => {
33
+ const loadData = async () => {
34
+ setLoading(true);
35
  try {
36
+ const [scs, stus, cls, subs] = await Promise.all([
37
+ api.scores.getAll(),
 
38
  api.students.getAll(),
39
+ api.classes.getAll(),
40
+ api.subjects.getAll()
41
+ ]);
42
+ setScores(scs);
43
+ setStudents(stus);
44
+ setClasses(cls);
45
+ setSubjects(subs);
46
 
47
+ // Set Defaults
48
+ if (cls.length > 0) setSelectedClass(cls[0].grade + cls[0].className);
49
+ if (subs.length > 0) setSelectedSubject(subs[0].name);
50
+ } catch (e) {
51
+ console.error(e);
52
+ } finally {
53
+ setLoading(false);
54
+ }
55
  };
56
+ loadData();
57
  }, []);
58
 
59
+ // Compute Metrics whenever filters or data change
60
  useEffect(() => {
61
+ if (scores.length === 0 || students.length === 0) return;
62
 
63
+ // --- 1. Grade Analysis (Horizontal Class Comparison) ---
64
+ const gradeClasses = classes.filter(c => c.grade === selectedGrade);
65
+ const gaData = gradeClasses.map(cls => {
66
+ const fullClassName = cls.grade + cls.className;
67
+ const classStudentIds = students.filter(s => s.className === fullClassName).map(s => s.studentNo);
68
+
69
+ const classScores = scores.filter(s => classStudentIds.includes(s.studentNo) && s.status === 'Normal');
70
+
71
+ const totalScore = classScores.reduce((sum, s) => sum + s.score, 0);
72
+ const avg = classScores.length ? (totalScore / classScores.length) : 0;
73
+
74
+ const passed = classScores.filter(s => s.score >= 60).length;
75
+ const passRate = classScores.length ? (passed / classScores.length) * 100 : 0;
76
+
77
+ const excellent = classScores.filter(s => {
78
+ // Find subject threshold
79
+ const sub = subjects.find(sub => sub.name === s.courseName);
80
+ return s.score >= (sub?.excellenceThreshold || 90);
81
+ }).length;
82
+ const excellentRate = classScores.length ? (excellent / classScores.length) * 100 : 0;
83
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
  return {
85
+ name: cls.className, // Label X-axis with just class name to save space
86
+ fullName: fullClassName,
87
+ 平均分: Number(avg.toFixed(1)),
88
+ 及格率: Number(passRate.toFixed(1)),
89
+ 优秀率: Number(excellentRate.toFixed(1)),
90
+ studentCount: classStudentIds.length
91
  };
92
  });
93
+ setGradeAnalysisData(gaData);
94
 
 
 
 
 
 
 
 
 
 
 
95
 
96
+ // --- 2. Trend Analysis (Time/Exam based) ---
97
+ // Group by Exam Name or Type. Since we don't have real dates, we'll use examName as categories.
98
+ // Filter for selectedClass and selectedSubject
99
+ if (selectedClass && selectedSubject) {
100
+ const classStudentIds = students.filter(s => s.className === selectedClass).map(s => s.studentNo);
101
+
102
+ // Get all unique exam names in database order (assuming insertion order implies time for mock)
103
+ // In real app, sort by exam date.
104
+ const uniqueExams = Array.from(new Set(scores.map(s => s.examName || s.type)));
105
+
106
+ const tData = uniqueExams.map(exam => {
107
+ const examScores = scores.filter(s =>
108
+ (s.examName === exam || s.type === exam) &&
109
+ s.courseName === selectedSubject &&
110
+ s.status === 'Normal'
111
+ );
112
+
113
+ // Class Avg
114
+ const classExamScores = examScores.filter(s => classStudentIds.includes(s.studentNo));
115
+ const classAvg = classExamScores.length
116
+ ? classExamScores.reduce((a,b)=>a+b.score,0) / classExamScores.length
117
+ : 0;
118
 
119
+ // Grade Avg (for comparison)
120
+ // Find grade of selected class
121
+ const currentGrade = classes.find(c => c.grade + c.className === selectedClass)?.grade || '';
122
+ const gradeStudentIds = students.filter(s => s.className.startsWith(currentGrade)).map(s => s.studentNo);
123
+ const gradeExamScores = examScores.filter(s => gradeStudentIds.includes(s.studentNo));
124
+ const gradeAvg = gradeExamScores.length
125
+ ? gradeExamScores.reduce((a,b)=>a+b.score,0) / gradeExamScores.length
126
+ : 0;
127
 
128
+ return {
129
+ name: exam,
130
+ 班级平均: Number(classAvg.toFixed(1)),
131
+ 年级平均: Number(gradeAvg.toFixed(1))
132
+ };
133
+ });
134
+ setTrendData(tData);
135
+ }
 
 
 
 
 
 
 
136
 
137
+ // --- 3. Subject Matrix (Heatmap Table) ---
138
+ // Rows: Classes in selectedGrade. Cols: Subjects.
139
+ const mData = gradeClasses.map(cls => {
140
+ const fullClassName = cls.grade + cls.className;
141
+ const classStudentIds = students.filter(s => s.className === fullClassName).map(s => s.studentNo);
 
 
 
142
 
143
+ const row: any = { className: cls.className, fullName: fullClassName };
144
+
145
+ subjects.forEach(sub => {
146
+ const subScores = scores.filter(s =>
147
+ s.courseName === sub.name &&
148
+ classStudentIds.includes(s.studentNo) &&
149
+ s.status === 'Normal'
150
+ );
151
+ const avg = subScores.length ? subScores.reduce((a,b)=>a+b.score,0)/subScores.length : 0;
152
+ row[sub.name] = Number(avg.toFixed(1));
153
+ });
154
+ return row;
155
+ });
156
+ setMatrixData(mData);
157
 
158
+ }, [scores, students, classes, subjects, selectedGrade, selectedClass, selectedSubject]);
159
 
160
+
161
+ const exportExcel = () => {
162
+ // Export current active tab data
163
+ let dataToExport: any[] = [];
164
+ if (activeTab === 'grade') dataToExport = gradeAnalysisData;
165
+ else if (activeTab === 'trend') dataToExport = trendData;
166
+ else if (activeTab === 'matrix') dataToExport = matrixData;
167
+ else dataToExport = gradeAnalysisData; // Default
168
+
169
+ const ws = XLSX.utils.json_to_sheet(dataToExport);
170
+ const wb = XLSX.utils.book_new();
171
+ XLSX.utils.book_append_sheet(wb, ws, "Report");
172
+ XLSX.writeFile(wb, `Report_${activeTab}_${new Date().toISOString().slice(0,10)}.xlsx`);
173
+ };
174
+
175
+ const COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6'];
176
  const uniqueGrades = Array.from(new Set(classes.map(c => c.grade))).sort();
177
+ const allClasses = classes.map(c => c.grade + c.className);
178
+
179
+ if (loading) return <div className="flex justify-center py-20"><Loader2 className="animate-spin text-blue-600"/></div>;
180
 
181
  return (
182
  <div className="space-y-6">
183
+ <div className="flex flex-col md:flex-row justify-between items-center gap-4">
184
+ <div>
185
+ <h2 className="text-xl font-bold text-gray-800">教务数据分析中心</h2>
186
+ <p className="text-sm text-gray-500">基于 {scores.length} 条成绩数据分析</p>
187
+ </div>
188
+ <div className="flex gap-2">
189
+ <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">
190
+ <Download size={16}/><span>导出报表</span>
191
+ </button>
192
+ </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
193
  </div>
194
 
195
+ {/* Tabs */}
196
+ <div className="flex space-x-1 bg-gray-100 p-1 rounded-xl w-full md:w-auto overflow-x-auto">
197
+ {[
198
+ { id: 'grade', label: '年级横向分析', icon: BarChart2 },
199
+ { id: 'trend', label: '教学成长轨迹', icon: TrendingUp },
200
+ { id: 'matrix', label: '学科质量透视', icon: Grid },
201
+ { id: 'overview', label: '全校概览', icon: PieChartIcon },
202
+ ].map(tab => (
203
  <button
204
+ key={tab.id}
205
+ onClick={() => setActiveTab(tab.id as any)}
206
+ className={`flex items-center space-x-2 px-4 py-2 rounded-lg text-sm font-medium transition-all whitespace-nowrap ${
207
+ activeTab === tab.id ? 'bg-white text-blue-600 shadow-sm' : 'text-gray-500 hover:text-gray-700'
208
+ }`}
 
 
209
  >
210
+ <tab.icon size={16}/>
211
+ <span>{tab.label}</span>
212
  </button>
213
+ ))}
 
214
  </div>
215
 
216
+ {/* Content Area */}
217
+ <div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6 min-h-[500px]">
218
+
219
+ {/* Filter Bar */}
220
+ <div className="flex flex-wrap gap-4 mb-8 items-center bg-gray-50 p-4 rounded-lg">
221
+ <div className="flex items-center text-sm font-bold text-gray-500"><Filter size={16} className="mr-2"/> 筛选维度:</div>
222
+
223
+ {(activeTab === 'grade' || activeTab === 'matrix') && (
224
+ <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)}>
225
+ {uniqueGrades.map(g => <option key={g} value={g}>{g}</option>)}
226
+ </select>
227
+ )}
228
+
229
+ {activeTab === 'trend' && (
230
+ <>
231
+ <select className="border border-gray-300 p-2 rounded text-sm text-gray-800" value={selectedClass} onChange={e => setSelectedClass(e.target.value)}>
232
+ {allClasses.map(c => <option key={c} value={c}>{c}</option>)}
233
+ </select>
234
+ <select className="border border-gray-300 p-2 rounded text-sm text-gray-800" value={selectedSubject} onChange={e => setSelectedSubject(e.target.value)}>
235
+ {subjects.map(s => <option key={s._id} value={s.name}>{s.name}</option>)}
236
+ </select>
237
+ </>
238
+ )}
239
+ </div>
240
+
241
+ {/* --- 1. Grade Analysis View --- */}
242
+ {activeTab === 'grade' && (
243
+ <div className="space-y-10 animate-in fade-in">
244
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
245
+ <div className="bg-white p-4 rounded-lg border border-gray-100">
246
+ <h3 className="font-bold text-gray-800 mb-4 flex items-center">
247
+ <span className="w-1 h-6 bg-blue-500 mr-2 rounded-full"></span>
248
+ {selectedGrade} - 各班平均分对比
249
+ </h3>
250
+ <div className="h-80">
251
+ <ResponsiveContainer width="100%" height="100%">
252
+ <BarChart data={gradeAnalysisData} layout="vertical" margin={{ left: 20 }}>
253
+ <CartesianGrid strokeDasharray="3 3" horizontal={true} vertical={false}/>
254
+ <XAxis type="number" domain={[0, 100]} hide/>
255
+ <YAxis dataKey="name" type="category" width={80} tick={{fontSize: 12}}/>
256
+ <Tooltip cursor={{fill: '#f3f4f6'}} contentStyle={{borderRadius: '8px', border: 'none', boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)'}}/>
257
+ <Bar dataKey="平均分" fill="#3b82f6" radius={[0, 4, 4, 0]} barSize={20} label={{ position: 'right', fill: '#666', fontSize: 12 }}/>
258
+ </BarChart>
259
+ </ResponsiveContainer>
260
+ </div>
261
  </div>
 
 
 
262
 
263
+ <div className="bg-white p-4 rounded-lg border border-gray-100">
264
+ <h3 className="font-bold text-gray-800 mb-4 flex items-center">
265
+ <span className="w-1 h-6 bg-emerald-500 mr-2 rounded-full"></span>
266
+ {selectedGrade} - 优良率 vs 及格率
267
+ </h3>
268
+ <div className="h-80">
269
+ <ResponsiveContainer width="100%" height="100%">
270
+ <BarChart data={gradeAnalysisData}>
271
+ <CartesianGrid strokeDasharray="3 3" vertical={false}/>
272
+ <XAxis dataKey="name" tick={{fontSize: 12}}/>
273
+ <YAxis domain={[0, 100]}/>
274
+ <Tooltip contentStyle={{borderRadius: '8px'}}/>
275
+ <Legend />
276
+ <Bar dataKey="及格率" fill="#10b981" radius={[4, 4, 0, 0]} />
277
+ <Bar dataKey="优秀率" fill="#f59e0b" radius={[4, 4, 0, 0]} />
278
+ </BarChart>
279
+ </ResponsiveContainer>
280
+ </div>
281
+ </div>
282
+ </div>
283
+ </div>
284
+ )}
285
 
286
+ {/* --- 2. Trend Analysis View --- */}
287
+ {activeTab === 'trend' && (
288
+ <div className="animate-in fade-in space-y-6">
289
+ <div className="bg-white p-4 rounded-lg border border-gray-100">
290
+ <h3 className="font-bold text-gray-800 mb-6 text-center">
291
+ {selectedClass} {selectedSubject} - 成绩成长轨迹
292
+ </h3>
293
+ <div className="h-96">
294
+ <ResponsiveContainer width="100%" height="100%">
295
+ <LineChart data={trendData} margin={{ top: 20, right: 30, left: 20, bottom: 20 }}>
296
+ <CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f0f0f0"/>
297
+ <XAxis dataKey="name" axisLine={false} tickLine={false} dy={10}/>
298
+ <YAxis domain={[0, 100]} axisLine={false} tickLine={false}/>
299
+ <Tooltip contentStyle={{borderRadius: '8px', boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1)'}}/>
300
+ <Legend verticalAlign="top" height={36}/>
301
+ <Line type="monotone" dataKey="班级平均" stroke="#3b82f6" strokeWidth={3} dot={{r: 6}} activeDot={{r: 8}} />
302
+ <Line type="monotone" dataKey="年级平均" stroke="#94a3b8" strokeWidth={2} strokeDasharray="5 5" dot={{r: 4}} />
303
+ </LineChart>
304
+ </ResponsiveContainer>
305
+ </div>
306
+ </div>
307
  </div>
308
+ )}
 
309
 
310
+ {/* --- 3. Subject Matrix View --- */}
311
+ {activeTab === 'matrix' && (
312
+ <div className="animate-in fade-in overflow-x-auto">
313
+ <h3 className="font-bold text-gray-800 mb-6">
314
+ {selectedGrade} - 学科质量透视矩阵
315
+ </h3>
316
+ <table className="w-full text-sm text-left">
317
+ <thead className="bg-gray-50 text-gray-500 uppercase">
318
+ <tr>
319
+ <th className="px-6 py-4 rounded-tl-lg">班级</th>
320
+ {subjects.map(s => <th key={s._id} className="px-6 py-4 font-bold text-gray-700">{s.name}</th>)}
321
+ </tr>
322
+ </thead>
323
+ <tbody className="divide-y divide-gray-100">
324
+ {matrixData.map((row, idx) => (
325
+ <tr key={idx} className="hover:bg-gray-50 transition-colors">
326
+ <td className="px-6 py-4 font-bold text-gray-800 bg-gray-50/50">{row.className}</td>
327
+ {subjects.map(sub => {
328
+ const val = row[sub.name];
329
+ let colorClass = 'text-gray-800';
330
+ let bgClass = '';
331
+ if (val >= (sub.excellenceThreshold || 90)) { colorClass = 'text-green-700 font-bold'; bgClass = 'bg-green-50'; }
332
+ else if (val < 60) { colorClass = 'text-red-600 font-bold'; bgClass = 'bg-red-50'; }
333
+
334
+ return (
335
+ <td key={sub._id} className={`px-6 py-4 ${bgClass}`}>
336
+ <span className={colorClass}>{val || '-'}</span>
337
+ </td>
338
+ );
339
+ })}
340
+ </tr>
341
+ ))}
342
+ </tbody>
343
+ </table>
344
+ <p className="text-xs text-gray-400 mt-4 text-right">* 绿色底色代表优秀,红色底色代表需要关注</p>
345
+ </div>
346
+ )}
347
+
348
+ {/* --- 4. Overview View --- */}
349
+ {activeTab === 'overview' && (
350
+ <div className="flex flex-col items-center justify-center py-20 text-gray-400">
351
+ <PieChartIcon size={64} className="mb-4 opacity-20"/>
352
+ <p>全校概览模式 - 整合所有年级的大数据分析</p>
353
+ <button onClick={()=>setActiveTab('grade')} className="mt-4 text-blue-600 hover:underline">查看年级详情</button>
354
+ </div>
355
+ )}
356
 
357
  </div>
358
  </div>