dvc890 commited on
Commit
5ba43ca
·
verified ·
1 Parent(s): 58efa17

Upload 22 files

Browse files
App.tsx CHANGED
@@ -6,6 +6,7 @@ import { Dashboard } from './pages/Dashboard';
6
  import { StudentList } from './pages/StudentList';
7
  import { CourseList } from './pages/CourseList';
8
  import { ScoreList } from './pages/ScoreList';
 
9
  import { Settings } from './pages/Settings';
10
  import { Reports } from './pages/Reports';
11
  import { Login } from './pages/Login';
@@ -16,10 +17,19 @@ const App: React.FC = () => {
16
  const [isAuthenticated, setIsAuthenticated] = useState(false);
17
  const [currentUser, setCurrentUser] = useState<User | null>(null);
18
  const [currentView, setCurrentView] = useState('dashboard');
 
19
 
20
  useEffect(() => {
21
- // Initialize the local database
22
  api.init();
 
 
 
 
 
 
 
 
23
  }, []);
24
 
25
  const handleLogin = (user: User) => {
@@ -29,6 +39,7 @@ const App: React.FC = () => {
29
  };
30
 
31
  const handleLogout = () => {
 
32
  setIsAuthenticated(false);
33
  setCurrentUser(null);
34
  };
@@ -39,6 +50,8 @@ const App: React.FC = () => {
39
  return <Dashboard />;
40
  case 'students':
41
  return <StudentList />;
 
 
42
  case 'courses':
43
  return <CourseList />;
44
  case 'grades':
@@ -56,12 +69,17 @@ const App: React.FC = () => {
56
  const viewTitles: Record<string, string> = {
57
  dashboard: '工作台',
58
  students: '学生档案管理',
 
59
  courses: '课程安排',
60
  grades: '成绩管理',
61
  settings: '系统设置',
62
  reports: '统计报表'
63
  };
64
 
 
 
 
 
65
  if (!isAuthenticated) {
66
  return <Login onLogin={handleLogin} />;
67
  }
 
6
  import { StudentList } from './pages/StudentList';
7
  import { CourseList } from './pages/CourseList';
8
  import { ScoreList } from './pages/ScoreList';
9
+ import { ClassList } from './pages/ClassList';
10
  import { Settings } from './pages/Settings';
11
  import { Reports } from './pages/Reports';
12
  import { Login } from './pages/Login';
 
17
  const [isAuthenticated, setIsAuthenticated] = useState(false);
18
  const [currentUser, setCurrentUser] = useState<User | null>(null);
19
  const [currentView, setCurrentView] = useState('dashboard');
20
+ const [loading, setLoading] = useState(true);
21
 
22
  useEffect(() => {
23
+ // Initialize the API config
24
  api.init();
25
+
26
+ // Check for existing session
27
+ const storedUser = api.auth.getCurrentUser();
28
+ if (storedUser) {
29
+ setCurrentUser(storedUser);
30
+ setIsAuthenticated(true);
31
+ }
32
+ setLoading(false);
33
  }, []);
34
 
35
  const handleLogin = (user: User) => {
 
39
  };
40
 
41
  const handleLogout = () => {
42
+ api.auth.logout();
43
  setIsAuthenticated(false);
44
  setCurrentUser(null);
45
  };
 
50
  return <Dashboard />;
51
  case 'students':
52
  return <StudentList />;
53
+ case 'classes':
54
+ return <ClassList />;
55
  case 'courses':
56
  return <CourseList />;
57
  case 'grades':
 
69
  const viewTitles: Record<string, string> = {
70
  dashboard: '工作台',
71
  students: '学生档案管理',
72
+ classes: '班级管理',
73
  courses: '课程安排',
74
  grades: '成绩管理',
75
  settings: '系统设置',
76
  reports: '统计报表'
77
  };
78
 
79
+ if (loading) {
80
+ return <div className="min-h-screen flex items-center justify-center bg-gray-50 text-blue-600">加载中...</div>;
81
+ }
82
+
83
  if (!isAuthenticated) {
84
  return <Login onLogin={handleLogin} />;
85
  }
components/Sidebar.tsx CHANGED
@@ -1,5 +1,6 @@
 
1
  import React from 'react';
2
- import { LayoutDashboard, Users, BookOpen, GraduationCap, Settings, LogOut, FileText } from 'lucide-react';
3
  import { UserRole } from '../types';
4
 
5
  interface SidebarProps {
@@ -13,6 +14,7 @@ export const Sidebar: React.FC<SidebarProps> = ({ currentView, onChangeView, use
13
  const menuItems = [
14
  { id: 'dashboard', label: '工作台', icon: LayoutDashboard, roles: [UserRole.ADMIN, UserRole.TEACHER] },
15
  { id: 'students', label: '学生管理', icon: Users, roles: [UserRole.ADMIN, UserRole.TEACHER] },
 
16
  { id: 'courses', label: '课程管理', icon: BookOpen, roles: [UserRole.ADMIN, UserRole.TEACHER, UserRole.STUDENT] },
17
  { id: 'grades', label: '成绩管理', icon: GraduationCap, roles: [UserRole.ADMIN, UserRole.TEACHER, UserRole.STUDENT] },
18
  { id: 'reports', label: '报表统计', icon: FileText, roles: [UserRole.ADMIN] },
@@ -65,4 +67,4 @@ export const Sidebar: React.FC<SidebarProps> = ({ currentView, onChangeView, use
65
  </div>
66
  </div>
67
  );
68
- };
 
1
+
2
  import React from 'react';
3
+ import { LayoutDashboard, Users, BookOpen, GraduationCap, Settings, LogOut, FileText, School } from 'lucide-react';
4
  import { UserRole } from '../types';
5
 
6
  interface SidebarProps {
 
14
  const menuItems = [
15
  { id: 'dashboard', label: '工作台', icon: LayoutDashboard, roles: [UserRole.ADMIN, UserRole.TEACHER] },
16
  { id: 'students', label: '学生管理', icon: Users, roles: [UserRole.ADMIN, UserRole.TEACHER] },
17
+ { id: 'classes', label: '班级管理', icon: School, roles: [UserRole.ADMIN] },
18
  { id: 'courses', label: '课程管理', icon: BookOpen, roles: [UserRole.ADMIN, UserRole.TEACHER, UserRole.STUDENT] },
19
  { id: 'grades', label: '成绩管理', icon: GraduationCap, roles: [UserRole.ADMIN, UserRole.TEACHER, UserRole.STUDENT] },
20
  { id: 'reports', label: '报表统计', icon: FileText, roles: [UserRole.ADMIN] },
 
67
  </div>
68
  </div>
69
  );
70
+ };
pages/ClassList.tsx ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import React, { useState, useEffect } from 'react';
3
+ import { Plus, Trash2, Users, School, Loader2 } from 'lucide-react';
4
+ import { api } from '../services/api';
5
+ import { ClassInfo } from '../types';
6
+
7
+ export const ClassList: React.FC = () => {
8
+ const [classes, setClasses] = useState<ClassInfo[]>([]);
9
+ const [loading, setLoading] = useState(true);
10
+ const [isModalOpen, setIsModalOpen] = useState(false);
11
+ const [submitting, setSubmitting] = useState(false);
12
+
13
+ // Form
14
+ const [grade, setGrade] = useState('六年级');
15
+ const [className, setClassName] = useState('(1)班');
16
+ const [teacherName, setTeacherName] = useState('');
17
+
18
+ const grades = ['一年级', '二年级', '三年级', '四年级', '五年级', '六年级'];
19
+
20
+ const loadClasses = async () => {
21
+ setLoading(true);
22
+ try {
23
+ const data = await api.classes.getAll();
24
+ setClasses(data);
25
+ } catch (e) {
26
+ console.error(e);
27
+ } finally {
28
+ setLoading(false);
29
+ }
30
+ };
31
+
32
+ useEffect(() => {
33
+ loadClasses();
34
+ }, []);
35
+
36
+ const handleDelete = async (id: string | number) => {
37
+ if (confirm('确定要删除这个班级吗?')) {
38
+ await api.classes.delete(id);
39
+ loadClasses();
40
+ }
41
+ };
42
+
43
+ const handleSubmit = async (e: React.FormEvent) => {
44
+ e.preventDefault();
45
+ setSubmitting(true);
46
+ try {
47
+ await api.classes.add({ grade, className, teacherName });
48
+ setIsModalOpen(false);
49
+ setTeacherName('');
50
+ loadClasses();
51
+ } catch (e) {
52
+ alert('添加失败');
53
+ } finally {
54
+ setSubmitting(false);
55
+ }
56
+ };
57
+
58
+ return (
59
+ <div className="space-y-6">
60
+ <div className="flex justify-between items-center">
61
+ <h2 className="text-xl font-bold text-gray-800">班级管理</h2>
62
+ <button
63
+ onClick={() => setIsModalOpen(true)}
64
+ className="flex items-center space-x-2 px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 shadow-sm"
65
+ >
66
+ <Plus size={16} />
67
+ <span>新增班级</span>
68
+ </button>
69
+ </div>
70
+
71
+ {loading ? (
72
+ <div className="flex justify-center py-10"><Loader2 className="animate-spin text-blue-600" /></div>
73
+ ) : (
74
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
75
+ {classes.map((cls) => (
76
+ <div key={cls._id || cls.id} className="bg-white rounded-xl shadow-sm border border-gray-100 p-6 flex flex-col justify-between hover:shadow-md transition-shadow">
77
+ <div className="flex justify-between items-start">
78
+ <div className="flex items-center space-x-3">
79
+ <div className="p-3 bg-blue-50 text-blue-600 rounded-lg">
80
+ <School size={24} />
81
+ </div>
82
+ <div>
83
+ <h3 className="text-lg font-bold text-gray-800">{cls.grade}{cls.className}</h3>
84
+ <p className="text-sm text-gray-500">班主任: {cls.teacherName || '未设置'}</p>
85
+ </div>
86
+ </div>
87
+ <button
88
+ onClick={() => handleDelete(cls._id || cls.id!)}
89
+ className="text-gray-400 hover:text-red-500 transition-colors"
90
+ >
91
+ <Trash2 size={18} />
92
+ </button>
93
+ </div>
94
+
95
+ <div className="mt-6 pt-4 border-t border-gray-50 flex items-center justify-between text-sm">
96
+ <span className="text-gray-500 flex items-center">
97
+ <Users size={16} className="mr-2" /> 学生人数
98
+ </span>
99
+ <span className="font-bold text-gray-800">{cls.studentCount || 0} 人</span>
100
+ </div>
101
+ </div>
102
+ ))}
103
+ </div>
104
+ )}
105
+
106
+ {/* Modal */}
107
+ {isModalOpen && (
108
+ <div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
109
+ <div className="bg-white rounded-xl shadow-xl max-w-sm w-full p-6">
110
+ <h3 className="text-lg font-bold text-gray-800 mb-4">新增班级</h3>
111
+ <form onSubmit={handleSubmit} className="space-y-4">
112
+ <div>
113
+ <label className="text-sm font-medium text-gray-700">年级</label>
114
+ <select value={grade} onChange={e => setGrade(e.target.value)} className="w-full mt-1 px-3 py-2 border rounded-lg">
115
+ {grades.map(g => <option key={g} value={g}>{g}</option>)}
116
+ </select>
117
+ </div>
118
+ <div>
119
+ <label className="text-sm font-medium text-gray-700">班级名 (如: (1)班)</label>
120
+ <input required type="text" value={className} onChange={e => setClassName(e.target.value)} className="w-full mt-1 px-3 py-2 border rounded-lg" placeholder="(1)班" />
121
+ </div>
122
+ <div>
123
+ <label className="text-sm font-medium text-gray-700">班主任姓名</label>
124
+ <input required type="text" value={teacherName} onChange={e => setTeacherName(e.target.value)} className="w-full mt-1 px-3 py-2 border rounded-lg" placeholder="张老师" />
125
+ </div>
126
+ <div className="flex space-x-3 pt-4">
127
+ <button type="button" onClick={() => setIsModalOpen(false)} className="flex-1 py-2 border rounded-lg">取消</button>
128
+ <button type="submit" disabled={submitting} className="flex-1 py-2 bg-blue-600 text-white rounded-lg">
129
+ {submitting ? '提交中...' : '创建'}
130
+ </button>
131
+ </div>
132
+ </form>
133
+ </div>
134
+ </div>
135
+ )}
136
+ </div>
137
+ );
138
+ };
pages/CourseList.tsx CHANGED
@@ -1,28 +1,80 @@
1
 
2
  import React, { useEffect, useState } from 'react';
3
- import { Plus, Users, Clock, Loader2 } from 'lucide-react';
4
  import { api } from '../services/api';
5
  import { Course } from '../types';
6
 
7
  export const CourseList: React.FC = () => {
8
  const [courses, setCourses] = useState<Course[]>([]);
9
  const [loading, setLoading] = useState(true);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
 
11
  useEffect(() => {
12
- const loadCourses = async () => {
13
- try {
14
- const data = await api.courses.getAll();
15
- setCourses(data);
16
- } catch (error) {
17
- console.error(error);
18
- } finally {
19
- setLoading(false);
20
- }
21
- };
22
  loadCourses();
23
  }, []);
24
 
25
- if (loading) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  return (
27
  <div className="flex justify-center items-center h-64">
28
  <Loader2 className="animate-spin text-blue-500" size={32} />
@@ -34,7 +86,10 @@ export const CourseList: React.FC = () => {
34
  <div className="space-y-6">
35
  <div className="flex justify-between items-center">
36
  <h2 className="text-xl font-bold text-gray-800">课程列表</h2>
37
- <button className="flex items-center space-x-2 px-4 py-2 bg-indigo-600 text-white rounded-lg text-sm font-medium hover:bg-indigo-700 transition-colors shadow-sm">
 
 
 
38
  <Plus size={16} />
39
  <span>新增课程</span>
40
  </button>
@@ -42,7 +97,7 @@ export const CourseList: React.FC = () => {
42
 
43
  <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
44
  {courses.map((course) => (
45
- <div key={course.id} className="bg-white rounded-xl shadow-sm border border-gray-100 p-6 hover:shadow-md transition-shadow">
46
  <div className="flex justify-between items-start mb-4">
47
  <div>
48
  <span className="inline-block px-2 py-1 bg-blue-50 text-blue-600 text-xs font-semibold rounded mb-2">
@@ -78,17 +133,64 @@ export const CourseList: React.FC = () => {
78
  </div>
79
 
80
  <div className="pt-4 border-t border-gray-100 flex space-x-2">
81
- <button className="flex-1 px-3 py-2 bg-gray-50 text-gray-700 rounded-lg text-sm font-medium hover:bg-gray-100">
82
- 详情
 
 
 
83
  </button>
84
- <button className="flex-1 px-3 py-2 bg-blue-50 text-blue-600 rounded-lg text-sm font-medium hover:bg-blue-100">
85
- 管理
 
 
 
86
  </button>
87
  </div>
88
  </div>
89
  </div>
90
  ))}
91
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
  </div>
93
  );
94
  };
 
1
 
2
  import React, { useEffect, useState } from 'react';
3
+ import { Plus, Users, Clock, Loader2, Edit, Trash2, X } from 'lucide-react';
4
  import { api } from '../services/api';
5
  import { Course } from '../types';
6
 
7
  export const CourseList: React.FC = () => {
8
  const [courses, setCourses] = useState<Course[]>([]);
9
  const [loading, setLoading] = useState(true);
10
+ const [isModalOpen, setIsModalOpen] = useState(false);
11
+ const [submitting, setSubmitting] = useState(false);
12
+ const [editId, setEditId] = useState<string | number | null>(null);
13
+
14
+ const [formData, setFormData] = useState({
15
+ courseCode: '',
16
+ courseName: '',
17
+ teacherName: '',
18
+ credits: 2,
19
+ capacity: 45
20
+ });
21
+
22
+ const loadCourses = async () => {
23
+ setLoading(true);
24
+ try {
25
+ const data = await api.courses.getAll();
26
+ setCourses(data);
27
+ } catch (error) {
28
+ console.error(error);
29
+ } finally {
30
+ setLoading(false);
31
+ }
32
+ };
33
 
34
  useEffect(() => {
 
 
 
 
 
 
 
 
 
 
35
  loadCourses();
36
  }, []);
37
 
38
+ const handleEdit = (c: Course) => {
39
+ setFormData({
40
+ courseCode: c.courseCode,
41
+ courseName: c.courseName,
42
+ teacherName: c.teacherName,
43
+ credits: c.credits,
44
+ capacity: c.capacity
45
+ });
46
+ setEditId(c._id || c.id || null);
47
+ setIsModalOpen(true);
48
+ };
49
+
50
+ const handleDelete = async (id: string | number) => {
51
+ if (confirm('确定要删除这门课程吗?')) {
52
+ await api.courses.delete(id);
53
+ loadCourses();
54
+ }
55
+ };
56
+
57
+ const handleSubmit = async (e: React.FormEvent) => {
58
+ e.preventDefault();
59
+ setSubmitting(true);
60
+ try {
61
+ if (editId) {
62
+ await api.courses.update(editId, formData);
63
+ } else {
64
+ await api.courses.add(formData);
65
+ }
66
+ setIsModalOpen(false);
67
+ setFormData({ courseCode: '', courseName: '', teacherName: '', credits: 2, capacity: 45 });
68
+ setEditId(null);
69
+ loadCourses();
70
+ } catch (e) {
71
+ alert('保存失败');
72
+ } finally {
73
+ setSubmitting(false);
74
+ }
75
+ };
76
+
77
+ if (loading && !courses.length) {
78
  return (
79
  <div className="flex justify-center items-center h-64">
80
  <Loader2 className="animate-spin text-blue-500" size={32} />
 
86
  <div className="space-y-6">
87
  <div className="flex justify-between items-center">
88
  <h2 className="text-xl font-bold text-gray-800">课程列表</h2>
89
+ <button
90
+ onClick={() => { setEditId(null); setFormData({ courseCode: '', courseName: '', teacherName: '', credits: 2, capacity: 45 }); setIsModalOpen(true); }}
91
+ className="flex items-center space-x-2 px-4 py-2 bg-indigo-600 text-white rounded-lg text-sm font-medium hover:bg-indigo-700 transition-colors shadow-sm"
92
+ >
93
  <Plus size={16} />
94
  <span>新增课程</span>
95
  </button>
 
97
 
98
  <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
99
  {courses.map((course) => (
100
+ <div key={course._id || course.id} className="bg-white rounded-xl shadow-sm border border-gray-100 p-6 hover:shadow-md transition-shadow">
101
  <div className="flex justify-between items-start mb-4">
102
  <div>
103
  <span className="inline-block px-2 py-1 bg-blue-50 text-blue-600 text-xs font-semibold rounded mb-2">
 
133
  </div>
134
 
135
  <div className="pt-4 border-t border-gray-100 flex space-x-2">
136
+ <button
137
+ onClick={() => handleEdit(course)}
138
+ className="flex-1 px-3 py-2 bg-blue-50 text-blue-600 rounded-lg text-sm font-medium hover:bg-blue-100 flex items-center justify-center"
139
+ >
140
+ <Edit size={14} className="mr-1" /> 编辑
141
  </button>
142
+ <button
143
+ onClick={() => handleDelete(course._id || course.id!)}
144
+ className="flex-1 px-3 py-2 bg-red-50 text-red-600 rounded-lg text-sm font-medium hover:bg-red-100 flex items-center justify-center"
145
+ >
146
+ <Trash2 size={14} className="mr-1" /> 删除
147
  </button>
148
  </div>
149
  </div>
150
  </div>
151
  ))}
152
  </div>
153
+
154
+ {/* Modal */}
155
+ {isModalOpen && (
156
+ <div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
157
+ <div className="bg-white rounded-xl shadow-xl max-w-md w-full p-6">
158
+ <div className="flex justify-between items-center mb-4">
159
+ <h3 className="text-lg font-bold text-gray-800">{editId ? '编辑课程' : '新增课程'}</h3>
160
+ <button onClick={() => setIsModalOpen(false)}><X size={20} className="text-gray-400" /></button>
161
+ </div>
162
+ <form onSubmit={handleSubmit} className="space-y-4">
163
+ <div className="grid grid-cols-2 gap-4">
164
+ <div>
165
+ <label className="text-sm font-medium text-gray-700">课程代码</label>
166
+ <input type="text" value={formData.courseCode} onChange={e => setFormData({...formData, courseCode: e.target.value})} className="w-full mt-1 px-3 py-2 border rounded-lg" placeholder="CHI01" required />
167
+ </div>
168
+ <div>
169
+ <label className="text-sm font-medium text-gray-700">课程名称</label>
170
+ <input type="text" value={formData.courseName} onChange={e => setFormData({...formData, courseName: e.target.value})} className="w-full mt-1 px-3 py-2 border rounded-lg" placeholder="语文" required />
171
+ </div>
172
+ </div>
173
+ <div>
174
+ <label className="text-sm font-medium text-gray-700">任课教师</label>
175
+ <input type="text" value={formData.teacherName} onChange={e => setFormData({...formData, teacherName: e.target.value})} className="w-full mt-1 px-3 py-2 border rounded-lg" required />
176
+ </div>
177
+ <div className="grid grid-cols-2 gap-4">
178
+ <div>
179
+ <label className="text-sm font-medium text-gray-700">周课时</label>
180
+ <input type="number" value={formData.credits} onChange={e => setFormData({...formData, credits: parseInt(e.target.value)})} className="w-full mt-1 px-3 py-2 border rounded-lg" required />
181
+ </div>
182
+ <div>
183
+ <label className="text-sm font-medium text-gray-700">容量</label>
184
+ <input type="number" value={formData.capacity} onChange={e => setFormData({...formData, capacity: parseInt(e.target.value)})} className="w-full mt-1 px-3 py-2 border rounded-lg" required />
185
+ </div>
186
+ </div>
187
+ <button type="submit" disabled={submitting} className="w-full py-2 bg-blue-600 text-white rounded-lg mt-4">
188
+ {submitting ? '保存中...' : '保存'}
189
+ </button>
190
+ </form>
191
+ </div>
192
+ </div>
193
+ )}
194
  </div>
195
  );
196
  };
pages/Dashboard.tsx CHANGED
@@ -2,11 +2,20 @@
2
  import React, { useEffect, useState } from 'react';
3
  import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell, Legend, Area, AreaChart } from 'recharts';
4
  import { Users, BookOpen, Award, TrendingUp, Loader2 } from 'lucide-react';
5
- import { CHART_DATA_TRENDS, CHART_DATA_DISTRIBUTION } from '../services/mockData';
6
  import { api } from '../services/api';
7
 
8
  const COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444'];
9
 
 
 
 
 
 
 
 
 
 
 
10
  const StatCard: React.FC<{ title: string; value: string; trend: string; icon: React.ElementType; color: string }> = ({ title, value, trend, icon: Icon, color }) => (
11
  <div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100 flex items-start justify-between">
12
  <div>
@@ -53,20 +62,20 @@ export const Dashboard: React.FC = () => {
53
  <div className="space-y-6">
54
  {/* Stats Grid */}
55
  <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
56
- <StatCard title="学生总数" value={stats.studentCount.toString()} trend="较上月增长 2%" icon={Users} color="bg-blue-500" />
57
- <StatCard title="本学期课程" value={stats.courseCount.toString()} trend="本周新增 1 门" icon={BookOpen} color="bg-emerald-500" />
58
- <StatCard title="平均分" value={stats.avgScore} trend="较上学期提升 1.2%" icon={Award} color="bg-amber-500" />
59
- <StatCard title="优秀率" value={stats.excellentRate} trend="全区排名前 5%" icon={TrendingUp} color="bg-indigo-500" />
60
  </div>
61
 
62
  {/* Charts */}
63
  <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
64
  {/* Line Chart */}
65
  <div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100">
66
- <h3 className="text-lg font-bold text-gray-800 mb-4">学业成绩趋势分析</h3>
67
  <div className="h-80">
68
  <ResponsiveContainer width="100%" height="100%">
69
- <AreaChart data={CHART_DATA_TRENDS} margin={{ top: 10, right: 30, left: 0, bottom: 0 }}>
70
  <defs>
71
  <linearGradient id="colorAvg" x1="0" y1="0" x2="0" y2="1">
72
  <stop offset="5%" stopColor="#3b82f6" stopOpacity={0.8}/>
@@ -76,14 +85,8 @@ export const Dashboard: React.FC = () => {
76
  <CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e5e7eb" />
77
  <XAxis dataKey="name" axisLine={false} tickLine={false} tick={{ fill: '#6b7280' }} />
78
  <YAxis axisLine={false} tickLine={false} tick={{ fill: '#6b7280' }} />
79
- <Tooltip
80
- contentStyle={{ backgroundColor: '#fff', borderRadius: '8px', border: 'none', boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)' }}
81
- itemStyle={{ color: '#1f2937' }}
82
- labelStyle={{ color: '#6b7280' }}
83
- formatter={(value: any, name: any) => [value, name === 'avg' ? '平均分' : '最高分']}
84
- />
85
  <Area type="monotone" dataKey="avg" stroke="#3b82f6" strokeWidth={3} fillOpacity={1} fill="url(#colorAvg)" name="平均分" />
86
- <Line type="monotone" dataKey="max" stroke="#10b981" strokeWidth={2} strokeDasharray="5 5" dot={false} name="最高分" />
87
  </AreaChart>
88
  </ResponsiveContainer>
89
  </div>
@@ -91,12 +94,12 @@ export const Dashboard: React.FC = () => {
91
 
92
  {/* Pie Chart */}
93
  <div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100">
94
- <h3 className="text-lg font-bold text-gray-800 mb-4">各班级学生分布</h3>
95
  <div className="h-80">
96
  <ResponsiveContainer width="100%" height="100%">
97
  <PieChart>
98
  <Pie
99
- data={CHART_DATA_DISTRIBUTION}
100
  cx="50%"
101
  cy="50%"
102
  innerRadius={60}
@@ -105,9 +108,7 @@ export const Dashboard: React.FC = () => {
105
  paddingAngle={5}
106
  dataKey="value"
107
  >
108
- {CHART_DATA_DISTRIBUTION.map((entry, index) => (
109
- <Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
110
- ))}
111
  </Pie>
112
  <Tooltip />
113
  <Legend verticalAlign="bottom" height={36} iconType="circle" />
 
2
  import React, { useEffect, useState } from 'react';
3
  import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell, Legend, Area, AreaChart } from 'recharts';
4
  import { Users, BookOpen, Award, TrendingUp, Loader2 } from 'lucide-react';
 
5
  import { api } from '../services/api';
6
 
7
  const COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444'];
8
 
9
+ // Placeholder charts with empty state if no data
10
+ const EMPTY_TRENDS = [
11
+ { name: '9月', avg: 0, max: 0 },
12
+ { name: '10月', avg: 0, max: 0 },
13
+ { name: '11月', avg: 0, max: 0 },
14
+ ];
15
+ const EMPTY_DIST = [
16
+ { name: '无数据', value: 100 },
17
+ ];
18
+
19
  const StatCard: React.FC<{ title: string; value: string; trend: string; icon: React.ElementType; color: string }> = ({ title, value, trend, icon: Icon, color }) => (
20
  <div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100 flex items-start justify-between">
21
  <div>
 
62
  <div className="space-y-6">
63
  {/* Stats Grid */}
64
  <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
65
+ <StatCard title="学生总数" value={stats.studentCount.toString()} trend="当前实时数据" icon={Users} color="bg-blue-500" />
66
+ <StatCard title="课程总数" value={stats.courseCount.toString()} trend="系统已录入" icon={BookOpen} color="bg-emerald-500" />
67
+ <StatCard title="平均分" value={stats.avgScore} trend="基于现有成绩" icon={Award} color="bg-amber-500" />
68
+ <StatCard title="优秀率" value={stats.excellentRate} trend=">90分比例" icon={TrendingUp} color="bg-indigo-500" />
69
  </div>
70
 
71
  {/* Charts */}
72
  <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
73
  {/* Line Chart */}
74
  <div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100">
75
+ <h3 className="text-lg font-bold text-gray-800 mb-4">学业成绩趋势 (示例)</h3>
76
  <div className="h-80">
77
  <ResponsiveContainer width="100%" height="100%">
78
+ <AreaChart data={EMPTY_TRENDS} margin={{ top: 10, right: 30, left: 0, bottom: 0 }}>
79
  <defs>
80
  <linearGradient id="colorAvg" x1="0" y1="0" x2="0" y2="1">
81
  <stop offset="5%" stopColor="#3b82f6" stopOpacity={0.8}/>
 
85
  <CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e5e7eb" />
86
  <XAxis dataKey="name" axisLine={false} tickLine={false} tick={{ fill: '#6b7280' }} />
87
  <YAxis axisLine={false} tickLine={false} tick={{ fill: '#6b7280' }} />
88
+ <Tooltip />
 
 
 
 
 
89
  <Area type="monotone" dataKey="avg" stroke="#3b82f6" strokeWidth={3} fillOpacity={1} fill="url(#colorAvg)" name="平均分" />
 
90
  </AreaChart>
91
  </ResponsiveContainer>
92
  </div>
 
94
 
95
  {/* Pie Chart */}
96
  <div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100">
97
+ <h3 className="text-lg font-bold text-gray-800 mb-4">各班级分布 (示例)</h3>
98
  <div className="h-80">
99
  <ResponsiveContainer width="100%" height="100%">
100
  <PieChart>
101
  <Pie
102
+ data={EMPTY_DIST}
103
  cx="50%"
104
  cy="50%"
105
  innerRadius={60}
 
108
  paddingAngle={5}
109
  dataKey="value"
110
  >
111
+ <Cell fill="#e5e7eb" />
 
 
112
  </Pie>
113
  <Tooltip />
114
  <Legend verticalAlign="bottom" height={36} iconType="circle" />
pages/Reports.tsx CHANGED
@@ -1,489 +1,128 @@
1
 
2
- import React, { useState } from 'react';
3
- import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, RadarChart, PolarGrid, PolarAngleAxis, PolarRadiusAxis, Radar, PieChart, Pie, Cell, LineChart, Line } from 'recharts';
4
- import { Download, Printer, ChevronDown, Users, User, Calendar, TrendingUp, Award } from 'lucide-react';
5
-
6
- // ==========================================
7
- // Mock Data & Generators
8
- // ==========================================
9
-
10
- const COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6'];
11
-
12
- // 模拟教师列表
13
- const MOCK_TEACHERS = ['王老师 (语文)', '张老师 (数学)', '李老师 (英语)', '赵老师 (科学)', '刘教练 (体育)'];
14
-
15
- // 模拟学科列表
16
- const MOCK_SUBJECTS = ['语文', '数学', '英语', '科学', '体育'];
17
-
18
- // 1. 班级维度数据
19
- const generateClassComparisonData = (grade: string, cls: string) => {
20
- const seed = cls.charCodeAt(1) + grade.length;
21
- const base = grade === '六年级' ? 85 : 82;
22
- return [
23
- { name: '语文', classScore: base + (seed % 5), gradeAvg: base },
24
- { name: '数学', classScore: base - 2 + (seed % 4), gradeAvg: base - 1 },
25
- { name: '英语', classScore: base + 3 + (seed % 6), gradeAvg: base + 2 },
26
- { name: '科学', classScore: base + 1 + (seed % 3), gradeAvg: base - 2 },
27
- ];
28
- };
29
-
30
- const generateClassRadarData = (grade: string, cls: string) => {
31
- return [
32
- { subject: '语文', class: 115, grade: 110, fullMark: 150 },
33
- { subject: '数学', class: 125, grade: 120, fullMark: 150 },
34
- { subject: '英语', class: 105, grade: 115, fullMark: 150 },
35
- { subject: '科学', class: 90, grade: 85, fullMark: 100 },
36
- { subject: '综合', class: 88, grade: 90, fullMark: 100 },
37
- ];
38
- };
39
-
40
- const generateClassDistData = (cls: string) => {
41
- const isClass1 = cls.includes('1');
42
- return [
43
- { name: '优秀 (90-100)', value: isClass1 ? 45 : 30 },
44
- { name: '良好 (80-89)', value: isClass1 ? 35 : 40 },
45
- { name: '及格 (60-79)', value: isClass1 ? 15 : 20 },
46
- { name: '待努力 (<60)', value: isClass1 ? 5 : 10 },
47
- ];
48
- };
49
-
50
- // 2. 学科维度数据 (横向对比各班级)
51
- const generateSubjectTrendData = (subject: string, grade: string) => {
52
- return [
53
- { name: '(1)班', score: 88, avg: 85 },
54
- { name: '(2)班', score: 84, avg: 85 },
55
- { name: '(3)班', score: 86, avg: 85 },
56
- { name: '(4)班', score: 82, avg: 85 },
57
- ];
58
- };
59
-
60
- // 3. 学科历史趋势数据 (新增)
61
- const generateSubjectTimeTrendData = (subject: string) => {
62
- // Simple deterministic random based on subject length to vary data slightly
63
- const offset = subject.length * 2;
64
- return [
65
- { semester: '2021-2022 上', score: 78 + (offset % 5) },
66
- { semester: '2021-2022 下', score: 80 + (offset % 4) },
67
- { semester: '2022-2023 上', score: 82 + (offset % 3) },
68
- { semester: '2022-2023 下', score: 81 + (offset % 6) },
69
- { semester: '2023-2024 上', score: 85 + (offset % 2) },
70
- ];
71
- };
72
-
73
- // 4. 教师维度数据
74
- const generateTeacherStats = (teacher: string) => {
75
- return {
76
- classes: ['六年级(1)班', '六年级(2)班'],
77
- avgScore: 87.5,
78
- passRate: '98%',
79
- studentRating: 4.8
80
- };
81
- };
82
-
83
- const generateTeacherRadarData = () => {
84
- return [
85
- { metric: '教学态度', value: 95, full: 100 },
86
- { metric: '课堂互动', value: 88, full: 100 },
87
- { metric: '作业批改', value: 92, full: 100 },
88
- { metric: '学生成绩', value: 85, full: 100 },
89
- { metric: '教研能力', value: 90, full: 100 },
90
- ];
91
- };
92
 
93
  export const Reports: React.FC = () => {
94
- // ==================== State Management ====================
95
-
96
- // Global Filter
97
- const [semester, setSemester] = useState('2023-2024学年 第一学期');
98
- const semesters = ['2023-2024学年 第一学期', '2023-2024学年 第二学期', '2022-2023学年 第二学期'];
99
-
100
- // Dimension Mode
101
- type AnalysisMode = 'CLASS' | 'SUBJECT' | 'TEACHER';
102
- const [mode, setMode] = useState<AnalysisMode>('CLASS');
103
-
104
- // Filters
105
- const gradeClassMap: Record<string, string[]> = {
106
- '一年级': ['(1)班', '(2)班', '(3)班'],
107
- '二年级': ['(1)班', '(2)班', '(3)班'],
108
- '三年级': ['(1)班', '(2)班', '(3)班', '(4)班'],
109
- '四年级': ['(1)班', '(2)班', '(3)班', '(4)班'],
110
- '五年级': ['(1)班', '(2)班', '(3)班', '(4)班'],
111
- '六年级': ['(1)班', '(2)班', '(3)班', '(4)班'],
112
- };
113
- const grades = Object.keys(gradeClassMap);
114
-
115
- const [selectedGrade, setSelectedGrade] = useState('六年级');
116
- const [availableClasses, setAvailableClasses] = useState(gradeClassMap['六年级']);
117
- const [selectedClass, setSelectedClass] = useState(gradeClassMap['六年级'][0]);
118
 
119
- const [selectedSubject, setSelectedSubject] = useState('数学');
120
- const [selectedTeacher, setSelectedTeacher] = useState(MOCK_TEACHERS[0]);
121
-
122
- // Handlers
123
- const handleGradeChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
124
- const newGrade = e.target.value;
125
- const newClasses = gradeClassMap[newGrade] || [];
126
- setSelectedGrade(newGrade);
127
- setAvailableClasses(newClasses);
128
- if (newClasses.length > 0) setSelectedClass(newClasses[0]);
129
- };
130
-
131
- // ==================== Render Components ====================
132
-
133
- const renderClassAnalysis = () => (
134
- <>
135
- {/* Chart Row 1 */}
136
- <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
137
- <div className="lg:col-span-2 bg-white p-6 rounded-xl shadow-sm border border-gray-100">
138
- <div className="flex justify-between items-center mb-6">
139
- <div>
140
- <h3 className="text-lg font-bold text-gray-800">学科平均分对比</h3>
141
- <p className="text-xs text-gray-400">本班级 vs 年级平均水平</p>
142
- </div>
143
- <LegendWrapper />
144
- </div>
145
- <div className="h-80">
146
- <ResponsiveContainer width="100%" height="100%">
147
- <BarChart data={generateClassComparisonData(selectedGrade, selectedClass)} barGap={8}>
148
- <CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f3f4f6" />
149
- <XAxis dataKey="name" tickLine={false} axisLine={false} tick={{fill: '#6b7280'}} />
150
- <YAxis tickLine={false} axisLine={false} tick={{fill: '#6b7280'}} domain={[0, 100]} />
151
- <Tooltip cursor={{fill: '#f9fafb'}} contentStyle={TOOLTIP_STYLE} />
152
- <Bar name="本班平均" dataKey="classScore" fill="#3b82f6" radius={[4, 4, 0, 0]} barSize={24} />
153
- <Bar name="年级平均" dataKey="gradeAvg" fill="#e5e7eb" radius={[4, 4, 0, 0]} barSize={24} />
154
- </BarChart>
155
- </ResponsiveContainer>
156
- </div>
157
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
158
 
159
- <div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100">
160
- <div className="flex justify-between items-center mb-2">
161
- <h3 className="text-lg font-bold text-gray-800">班级五维能力</h3>
162
- <span className="text-xs px-2 py-1 bg-purple-50 text-purple-600 rounded">综合素质</span>
163
- </div>
164
- <div className="h-80 relative">
165
- <ResponsiveContainer width="100%" height="100%">
166
- <RadarChart cx="50%" cy="50%" outerRadius="75%" data={generateClassRadarData(selectedGrade, selectedClass)}>
167
- <PolarGrid stroke="#e5e7eb" />
168
- <PolarAngleAxis dataKey="subject" tick={{ fill: '#4b5563', fontSize: 12 }} />
169
- <PolarRadiusAxis angle={30} domain={[0, 150]} tick={false} axisLine={false} />
170
- <Radar name="本班" dataKey="class" stroke="#8b5cf6" fill="#8b5cf6" fillOpacity={0.5} />
171
- <Radar name="年级" dataKey="grade" stroke="#9ca3af" fill="#9ca3af" fillOpacity={0.1} />
172
- <Tooltip contentStyle={TOOLTIP_STYLE} />
173
- </RadarChart>
174
- </ResponsiveContainer>
175
- </div>
176
  </div>
 
 
 
 
177
  </div>
178
 
179
- {/* Chart Row 2 */}
180
  <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
 
181
  <div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100">
182
- <h3 className="text-lg font-bold text-gray-800 mb-4">{selectedGrade}{selectedClass} 综合成绩分布</h3>
183
- <div className="flex items-center">
184
- <div className="h-64 w-1/2">
185
- <ResponsiveContainer width="100%" height="100%">
186
- <PieChart>
187
- <Pie
188
- data={generateClassDistData(selectedClass)}
189
- cx="50%"
190
- cy="50%"
191
- innerRadius={60}
192
- outerRadius={80}
193
- paddingAngle={5}
194
- dataKey="value"
195
- >
196
- {generateClassDistData(selectedClass).map((entry, index) => (
197
- <Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
198
- ))}
199
- </Pie>
200
- <Tooltip contentStyle={TOOLTIP_STYLE} />
201
- </PieChart>
202
- </ResponsiveContainer>
203
- </div>
204
- <div className="w-1/2 pl-6 space-y-4">
205
- {generateClassDistData(selectedClass).map((item, idx) => (
206
- <div key={idx} className="flex items-center justify-between">
207
- <div className="flex items-center">
208
- <div className="w-3 h-3 rounded-full mr-2" style={{ backgroundColor: COLORS[idx] }}></div>
209
- <span className="text-sm text-gray-600">{item.name}</span>
210
- </div>
211
- <span className="font-bold text-gray-800">{item.value}%</span>
212
- </div>
213
- ))}
214
- </div>
215
  </div>
216
  </div>
217
 
218
- {/* Warning List (Keep simplified for brevity) */}
219
  <div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100">
220
- <div className="flex justify-between items-center mb-4">
221
- <h3 className="text-lg font-bold text-gray-800 text-amber-600">重点关注名单</h3>
222
- <button className="text-xs text-blue-600 hover:underline">查看全部</button>
223
- </div>
224
- <table className="w-full text-sm text-left">
225
- <thead className="bg-gray-50 text-gray-500">
226
- <tr><th className="px-4 py-2">姓名</th><th className="px-4 py-2">薄弱点</th><th className="px-4 py-2 text-right">状态</th></tr>
227
- </thead>
228
- <tbody className="divide-y divide-gray-100">
229
- <tr><td className="px-4 py-3 font-medium">刘小虎</td><td className="px-4 py-3 text-red-500">数学 (58)</td><td className="px-4 py-3 text-right"><span className="px-2 py-0.5 bg-red-50 text-red-600 rounded text-xs">未谈话</span></td></tr>
230
- <tr><td className="px-4 py-3 font-medium">陈美丽</td><td className="px-4 py-3 text-red-500">英语 (62)</td><td className="px-4 py-3 text-right"><span className="px-2 py-0.5 bg-blue-50 text-blue-600 rounded text-xs">辅导中</span></td></tr>
231
- </tbody>
232
- </table>
233
- </div>
234
- </div>
235
- </>
236
- );
237
-
238
- const renderSubjectAnalysis = () => (
239
- <div className="space-y-6">
240
- {/* Existing Charts */}
241
- <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
242
- <div className="lg:col-span-2 bg-white p-6 rounded-xl shadow-sm border border-gray-100">
243
- <h3 className="text-lg font-bold text-gray-800 mb-6">{selectedGrade}各班 {selectedSubject} 成绩对比</h3>
244
- <div className="h-96">
245
- <ResponsiveContainer width="100%" height="100%">
246
- <BarChart data={generateSubjectTrendData(selectedSubject, selectedGrade)}>
247
- <CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f3f4f6" />
248
- <XAxis dataKey="name" axisLine={false} tickLine={false} tick={{fill: '#6b7280'}} />
249
- <YAxis axisLine={false} tickLine={false} tick={{fill: '#6b7280'}} />
250
- <Tooltip contentStyle={TOOLTIP_STYLE} cursor={{fill: '#f9fafb'}} />
251
- <Bar name="班级平均分" dataKey="score" fill="#3b82f6" radius={[6, 6, 0, 0]} barSize={40}>
252
- {
253
- generateSubjectTrendData(selectedSubject, selectedGrade).map((entry, index) => (
254
- <Cell key={`cell-${index}`} fill={entry.score < entry.avg ? '#ef4444' : '#3b82f6'} />
255
- ))
256
- }
257
- </Bar>
258
- <Line type="monotone" dataKey="avg" stroke="#10b981" strokeDasharray="5 5" name="年级平均线" />
259
- <Legend />
260
- </BarChart>
261
- </ResponsiveContainer>
262
- </div>
263
- <p className="text-sm text-gray-500 mt-4 text-center">注:<span className="text-red-500">红色柱体</span>表示低于年级平均分的班级</p>
264
- </div>
265
-
266
- <div className="space-y-6">
267
- <div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100">
268
- <h3 className="text-lg font-bold text-gray-800 mb-2">学科概览</h3>
269
- <div className="space-y-4 mt-4">
270
- <div className="flex justify-between items-center p-3 bg-blue-50 rounded-lg">
271
- <span className="text-blue-700 font-medium">年级平均分</span>
272
- <span className="text-2xl font-bold text-blue-800">85.0</span>
273
- </div>
274
- <div className="flex justify-between items-center p-3 bg-green-50 rounded-lg">
275
- <span className="text-green-700 font-medium">年级优秀率</span>
276
- <span className="text-2xl font-bold text-green-800">42%</span>
277
- </div>
278
- <div className="flex justify-between items-center p-3 bg-red-50 rounded-lg">
279
- <span className="text-red-700 font-medium">不及格人数</span>
280
- <span className="text-2xl font-bold text-red-800">12人</span>
281
- </div>
282
  </div>
283
- </div>
284
- </div>
285
- </div>
286
-
287
- {/* New Time Trend Chart */}
288
- <div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100">
289
- <div className="flex justify-between items-center mb-6">
290
- <h3 className="text-lg font-bold text-gray-800">{selectedSubject} 历史成绩趋势分析</h3>
291
- <span className="text-xs px-2 py-1 bg-blue-50 text-blue-600 rounded">近5个学期数据</span>
292
- </div>
293
- <div className="h-80">
294
- <ResponsiveContainer width="100%" height="100%">
295
- <LineChart data={generateSubjectTimeTrendData(selectedSubject)}>
296
- <CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f3f4f6" />
297
- <XAxis dataKey="semester" axisLine={false} tickLine={false} tick={{fill: '#6b7280'}} />
298
- <YAxis domain={[60, 100]} axisLine={false} tickLine={false} tick={{fill: '#6b7280'}} />
299
- <Tooltip contentStyle={TOOLTIP_STYLE} />
300
- <Line
301
- type="monotone"
302
- dataKey="score"
303
- name="学期平均分"
304
- stroke="#3b82f6"
305
- strokeWidth={3}
306
- activeDot={{ r: 8 }}
307
- dot={{ r: 4, fill: '#fff', stroke: '#3b82f6', strokeWidth: 2 }}
308
- />
309
- </LineChart>
310
- </ResponsiveContainer>
311
  </div>
312
  </div>
313
- </div>
314
- );
315
-
316
- const renderTeacherAnalysis = () => {
317
- const stats = generateTeacherStats(selectedTeacher);
318
- return (
319
- <div className="space-y-6">
320
- {/* Teacher Stats Cards */}
321
- <div className="grid grid-cols-1 md:grid-cols-4 gap-6">
322
- <div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100 flex items-center space-x-4">
323
- <div className="p-3 bg-blue-100 text-blue-600 rounded-full"><Users size={24} /></div>
324
- <div><p className="text-sm text-gray-500">执教班级</p><p className="text-lg font-bold">{stats.classes.join(', ')}</p></div>
325
- </div>
326
- <div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100 flex items-center space-x-4">
327
- <div className="p-3 bg-green-100 text-green-600 rounded-full"><Award size={24} /></div>
328
- <div><p className="text-sm text-gray-500">平均分排名</p><p className="text-xl font-bold">No. 2 <span className="text-xs font-normal text-gray-400">/ 12</span></p></div>
329
- </div>
330
- <div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100 flex items-center space-x-4">
331
- <div className="p-3 bg-amber-100 text-amber-600 rounded-full"><TrendingUp size={24} /></div>
332
- <div><p className="text-sm text-gray-500">及格率</p><p className="text-xl font-bold">{stats.passRate}</p></div>
333
- </div>
334
- <div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100 flex items-center space-x-4">
335
- <div className="p-3 bg-purple-100 text-purple-600 rounded-full"><User size={24} /></div>
336
- <div><p className="text-sm text-gray-500">学生评分</p><p className="text-xl font-bold">{stats.studentRating}</p></div>
337
- </div>
338
- </div>
339
-
340
- <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
341
- <div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100">
342
- <h3 className="text-lg font-bold text-gray-800 mb-6">教师能力评估雷达</h3>
343
- <div className="h-80">
344
- <ResponsiveContainer width="100%" height="100%">
345
- <RadarChart cx="50%" cy="50%" outerRadius="80%" data={generateTeacherRadarData()}>
346
- <PolarGrid stroke="#e5e7eb" />
347
- <PolarAngleAxis dataKey="metric" tick={{ fill: '#4b5563', fontSize: 13 }} />
348
- <PolarRadiusAxis angle={30} domain={[0, 100]} tick={false} axisLine={false} />
349
- <Radar name="教师评分" dataKey="value" stroke="#3b82f6" fill="#3b82f6" fillOpacity={0.5} />
350
- <Tooltip contentStyle={TOOLTIP_STYLE} />
351
- </RadarChart>
352
- </ResponsiveContainer>
353
- </div>
354
- </div>
355
-
356
- <div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100">
357
- <h3 className="text-lg font-bold text-gray-800 mb-4">近期教学记录</h3>
358
- <div className="space-y-4">
359
- {[
360
- { date: '2023-12-10', event: '六年级(1)班 期中考试', result: '平均分 88 (年级第一)', type: 'exam' },
361
- { date: '2023-11-25', event: '公开课《圆的面积》', result: '教研组评分 9.5', type: 'activity' },
362
- { date: '2023-11-15', event: '作业检查', result: '批改率 100%, 评语详实', type: 'check' },
363
- ].map((item, i) => (
364
- <div key={i} className="flex items-start p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors">
365
- <div className="mt-1 mr-3 w-2 h-2 rounded-full bg-blue-500"></div>
366
- <div>
367
- <p className="text-sm font-bold text-gray-800">{item.event}</p>
368
- <p className="text-xs text-gray-500 mt-1">{item.date} · {item.result}</p>
369
- </div>
370
- </div>
371
- ))}
372
- </div>
373
- </div>
374
  </div>
375
- </div>
376
- );
377
- };
378
-
379
- return (
380
- <div className="space-y-6">
381
- {/* 1. Header & Global Filters */}
382
- <div className="bg-white p-5 rounded-xl shadow-sm border border-gray-100">
383
- <div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 mb-6">
384
- <div>
385
- <h2 className="text-xl font-bold text-gray-800">统计报表中心</h2>
386
- <p className="text-sm text-gray-500 mt-1">全方位数据分析,辅助教学决策</p>
387
- </div>
388
- <div className="flex items-center space-x-3 bg-gray-50 p-1.5 rounded-lg border border-gray-200">
389
- <Calendar size={18} className="ml-2 text-gray-400" />
390
- <select
391
- value={semester}
392
- onChange={(e) => setSemester(e.target.value)}
393
- className="bg-transparent border-none text-sm font-medium text-gray-700 focus:ring-0 cursor-pointer py-1 pr-8"
394
- >
395
- {semesters.map(s => <option key={s} value={s}>{s}</option>)}
396
- </select>
397
- </div>
398
- </div>
399
-
400
- {/* 2. Mode Switch & Secondary Filters */}
401
- <div className="flex flex-col lg:flex-row justify-between items-center gap-4 border-t border-gray-100 pt-5">
402
- {/* Mode Tabs */}
403
- <div className="flex p-1 bg-gray-100 rounded-lg w-full lg:w-auto">
404
- <button
405
- onClick={() => setMode('CLASS')}
406
- className={`flex-1 lg:flex-none px-6 py-2 rounded-md text-sm font-bold transition-all ${mode === 'CLASS' ? 'bg-white text-blue-600 shadow-sm' : 'text-gray-500 hover:text-gray-700'}`}
407
- >
408
- 按班级分析
409
- </button>
410
- <button
411
- onClick={() => setMode('SUBJECT')}
412
- className={`flex-1 lg:flex-none px-6 py-2 rounded-md text-sm font-bold transition-all ${mode === 'SUBJECT' ? 'bg-white text-blue-600 shadow-sm' : 'text-gray-500 hover:text-gray-700'}`}
413
- >
414
- 按学科分析
415
- </button>
416
- <button
417
- onClick={() => setMode('TEACHER')}
418
- className={`flex-1 lg:flex-none px-6 py-2 rounded-md text-sm font-bold transition-all ${mode === 'TEACHER' ? 'bg-white text-blue-600 shadow-sm' : 'text-gray-500 hover:text-gray-700'}`}
419
- >
420
- 按教师分析
421
- </button>
422
- </div>
423
-
424
- {/* Contextual Filters */}
425
- <div className="flex items-center space-x-3 w-full lg:w-auto justify-end">
426
- {mode === 'CLASS' && (
427
- <>
428
- <SelectBox value={selectedGrade} onChange={handleGradeChange} options={grades} />
429
- <span className="text-gray-300">/</span>
430
- <SelectBox value={selectedClass} onChange={(e: any) => setSelectedClass(e.target.value)} options={availableClasses} />
431
- </>
432
- )}
433
- {mode === 'SUBJECT' && (
434
- <>
435
- <SelectBox value={selectedGrade} onChange={(e: any) => setSelectedGrade(e.target.value)} options={grades} />
436
- <span className="text-gray-300">/</span>
437
- <SelectBox value={selectedSubject} onChange={(e: any) => setSelectedSubject(e.target.value)} options={MOCK_SUBJECTS} />
438
- </>
439
- )}
440
- {mode === 'TEACHER' && (
441
- <SelectBox value={selectedTeacher} onChange={(e: any) => setSelectedTeacher(e.target.value)} options={MOCK_TEACHERS} icon={User} />
442
- )}
443
-
444
- <div className="h-6 w-px bg-gray-300 mx-2 hidden md:block"></div>
445
- <IconButton icon={Printer} />
446
- <IconButton icon={Download} />
447
- </div>
448
- </div>
449
- </div>
450
-
451
- {/* 3. Main Content Area */}
452
- <div className="animate-in fade-in slide-in-from-bottom-4 duration-500">
453
- {mode === 'CLASS' && renderClassAnalysis()}
454
- {mode === 'SUBJECT' && renderSubjectAnalysis()}
455
- {mode === 'TEACHER' && renderTeacherAnalysis()}
456
- </div>
457
  </div>
458
  );
459
  };
460
-
461
- // Helpers & Styles
462
- const TOOLTIP_STYLE = { borderRadius: '8px', border: 'none', boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)' };
463
-
464
- const LegendWrapper = () => (
465
- <div className="flex items-center space-x-4 text-xs">
466
- <span className="flex items-center"><span className="w-3 h-3 bg-blue-500 rounded mr-1"></span> 本项数据</span>
467
- <span className="flex items-center"><span className="w-3 h-3 bg-gray-300 rounded mr-1"></span> 平均参考</span>
468
- </div>
469
- );
470
-
471
- const SelectBox = ({ value, onChange, options, icon: Icon }: any) => (
472
- <div className="relative">
473
- {Icon && <Icon className="absolute left-3 top-2.5 text-gray-500" size={16} />}
474
- <select
475
- value={value}
476
- onChange={onChange}
477
- className={`appearance-none ${Icon ? 'pl-10' : 'pl-4'} pr-10 py-2 bg-white border border-gray-200 rounded-lg text-sm font-bold text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 cursor-pointer shadow-sm min-w-[120px]`}
478
- >
479
- {options.map((o: string) => <option key={o} value={o}>{o}</option>)}
480
- </select>
481
- <ChevronDown className="absolute right-3 top-2.5 text-gray-400 pointer-events-none" size={16} />
482
- </div>
483
- );
484
-
485
- const IconButton = ({ icon: Icon }: any) => (
486
- <button className="p-2 text-gray-500 hover:bg-white hover:text-blue-600 hover:shadow-sm rounded transition-all bg-gray-50 border border-transparent hover:border-gray-200">
487
- <Icon size={18} />
488
- </button>
489
- );
 
1
 
2
+ import React, { useState, useEffect } from 'react';
3
+ import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
4
+ import { Calendar, Download, Loader2 } from 'lucide-react';
5
+ import { api } from '../services/api';
6
+ import { Score, Student, ClassInfo } from '../types';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
 
8
  export const Reports: React.FC = () => {
9
+ const [loading, setLoading] = useState(true);
10
+ const [scores, setScores] = useState<Score[]>([]);
11
+ const [students, setStudents] = useState<Student[]>([]);
12
+ const [classes, setClasses] = useState<ClassInfo[]>([]);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
 
14
+ // Computed Data for Charts
15
+ const [classAvgData, setClassAvgData] = useState<any[]>([]);
16
+ const [subjectAvgData, setSubjectAvgData] = useState<any[]>([]);
17
+
18
+ useEffect(() => {
19
+ const fetchData = async () => {
20
+ try {
21
+ const [scoreData, studentData, classData] = await Promise.all([
22
+ api.scores.getAll(),
23
+ api.students.getAll(),
24
+ api.classes.getAll()
25
+ ]);
26
+ setScores(scoreData);
27
+ setStudents(studentData);
28
+ setClasses(classData);
29
+
30
+ // --- Calculate Stats ---
31
+
32
+ // 1. Class Averages
33
+ // Group students by class
34
+ const classMap: Record<string, string[]> = {}; // ClassName -> [StudentNos]
35
+ studentData.forEach(s => {
36
+ if (!classMap[s.className]) classMap[s.className] = [];
37
+ classMap[s.className].push(s.studentNo);
38
+ });
39
+
40
+ // Calculate avg score for each class (aggregated across all subjects)
41
+ const cData = classData.map(cls => {
42
+ const fullClassName = cls.grade + cls.className;
43
+ const studentNos = classMap[fullClassName] || [];
44
+ // Find scores for these students
45
+ const classScores = scoreData.filter(s => studentNos.includes(s.studentNo));
46
+ const total = classScores.reduce((sum, s) => sum + s.score, 0);
47
+ const avg = classScores.length > 0 ? Math.round(total / classScores.length) : 0;
48
+ return { name: fullClassName, avg };
49
+ });
50
+ setClassAvgData(cData);
51
+
52
+ // 2. Subject Averages
53
+ const subjects = Array.from(new Set(scoreData.map(s => s.courseName)));
54
+ const sData = subjects.map(subj => {
55
+ const subjScores = scoreData.filter(s => s.courseName === subj);
56
+ const total = subjScores.reduce((sum, s) => sum + s.score, 0);
57
+ const avg = subjScores.length > 0 ? Math.round(total / subjScores.length) : 0;
58
+ return { name: subj, avg };
59
+ });
60
+ setSubjectAvgData(sData);
61
+
62
+ } catch (e) {
63
+ console.error(e);
64
+ } finally {
65
+ setLoading(false);
66
+ }
67
+ };
68
+ fetchData();
69
+ }, []);
70
+
71
+ if (loading) return <div className="flex justify-center py-20"><Loader2 className="animate-spin text-blue-600" size={32} /></div>;
72
 
73
+ return (
74
+ <div className="space-y-6">
75
+ {/* Header */}
76
+ <div className="bg-white p-5 rounded-xl shadow-sm border border-gray-100 flex justify-between items-center">
77
+ <div>
78
+ <h2 className="text-xl font-bold text-gray-800">统计报表中心</h2>
79
+ <p className="text-sm text-gray-500 mt-1">基于 {scores.length} 条真实成绩数据生成</p>
 
 
 
 
 
 
 
 
 
 
80
  </div>
81
+ <button className="flex items-center space-x-2 px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 text-sm">
82
+ <Download size={16} />
83
+ <span>导出报表</span>
84
+ </button>
85
  </div>
86
 
 
87
  <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
88
+ {/* Class Performance */}
89
  <div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100">
90
+ <h3 className="text-lg font-bold text-gray-800 mb-4">各班级综合平均分</h3>
91
+ <div className="h-80">
92
+ <ResponsiveContainer width="100%" height="100%">
93
+ <BarChart data={classAvgData}>
94
+ <CartesianGrid strokeDasharray="3 3" vertical={false} />
95
+ <XAxis dataKey="name" tick={{ fontSize: 12 }} interval={0} />
96
+ <YAxis domain={[0, 100]} />
97
+ <Tooltip cursor={{ fill: '#f3f4f6' }} />
98
+ <Bar dataKey="avg" name="平均分" fill="#3b82f6" radius={[4, 4, 0, 0]} barSize={40} />
99
+ </BarChart>
100
+ </ResponsiveContainer>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
101
  </div>
102
  </div>
103
 
104
+ {/* Subject Performance */}
105
  <div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100">
106
+ <h3 className="text-lg font-bold text-gray-800 mb-4">全校学科平均分概览</h3>
107
+ <div className="h-80">
108
+ <ResponsiveContainer width="100%" height="100%">
109
+ <BarChart data={subjectAvgData}>
110
+ <CartesianGrid strokeDasharray="3 3" vertical={false} />
111
+ <XAxis dataKey="name" />
112
+ <YAxis domain={[0, 100]} />
113
+ <Tooltip cursor={{ fill: '#f3f4f6' }} />
114
+ <Bar dataKey="avg" name="平均分" fill="#10b981" radius={[4, 4, 0, 0]} barSize={40} />
115
+ </BarChart>
116
+ </ResponsiveContainer>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
  </div>
119
  </div>
120
+
121
+ {scores.length === 0 && (
122
+ <div className="text-center py-10 text-gray-400 bg-gray-50 rounded-lg border border-dashed border-gray-300">
123
+ 暂无足够的成绩数据来生成详细图表,请先录入成绩。
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
124
  </div>
125
+ )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
126
  </div>
127
  );
128
  };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
pages/Settings.tsx CHANGED
@@ -1,9 +1,12 @@
1
 
2
- import React, { useState } from 'react';
3
  import { Save, Bell, Lock, Database, Loader2, Plus, X } from 'lucide-react';
 
4
 
5
  export const Settings: React.FC = () => {
6
  const [loading, setLoading] = useState(false);
 
 
7
  const [config, setConfig] = useState({
8
  systemName: '智慧校园管理系统',
9
  allowRegister: true,
@@ -12,7 +15,6 @@ export const Settings: React.FC = () => {
12
  semester: '2023-2024学年 第一学期'
13
  });
14
 
15
- // 学期管理状态
16
  const [semesters, setSemesters] = useState([
17
  '2023-2024学年 第一学期',
18
  '2023-2024学年 第二学期',
@@ -21,13 +23,33 @@ export const Settings: React.FC = () => {
21
  const [isAddingSemester, setIsAddingSemester] = useState(false);
22
  const [newSemesterVal, setNewSemesterVal] = useState('');
23
 
24
- const handleSave = () => {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  setLoading(true);
26
- // 模拟保存延迟
27
- setTimeout(() => {
28
- setLoading(false);
29
  alert('系统配置已保存!');
30
- }, 1000);
 
 
 
 
31
  };
32
 
33
  const handleAddSemester = () => {
@@ -42,6 +64,8 @@ export const Settings: React.FC = () => {
42
  }
43
  };
44
 
 
 
45
  return (
46
  <div className="space-y-6">
47
  {/* General Settings */}
 
1
 
2
+ import React, { useState, useEffect } from 'react';
3
  import { Save, Bell, Lock, Database, Loader2, Plus, X } from 'lucide-react';
4
+ import { api } from '../services/api';
5
 
6
  export const Settings: React.FC = () => {
7
  const [loading, setLoading] = useState(false);
8
+ const [fetching, setFetching] = useState(true);
9
+
10
  const [config, setConfig] = useState({
11
  systemName: '智慧校园管理系统',
12
  allowRegister: true,
 
15
  semester: '2023-2024学年 第一学期'
16
  });
17
 
 
18
  const [semesters, setSemesters] = useState([
19
  '2023-2024学年 第一学期',
20
  '2023-2024学年 第二学期',
 
23
  const [isAddingSemester, setIsAddingSemester] = useState(false);
24
  const [newSemesterVal, setNewSemesterVal] = useState('');
25
 
26
+ useEffect(() => {
27
+ const loadConfig = async () => {
28
+ try {
29
+ const data = await api.config.get();
30
+ if (data && data.systemName) {
31
+ // Merge with defaults to ensure all fields exist
32
+ setConfig(prev => ({ ...prev, ...data }));
33
+ }
34
+ } catch (e) {
35
+ console.error(e);
36
+ } finally {
37
+ setFetching(false);
38
+ }
39
+ };
40
+ loadConfig();
41
+ }, []);
42
+
43
+ const handleSave = async () => {
44
  setLoading(true);
45
+ try {
46
+ await api.config.save(config);
 
47
  alert('系统配置已保存!');
48
+ } catch (e) {
49
+ alert('保存失败');
50
+ } finally {
51
+ setLoading(false);
52
+ }
53
  };
54
 
55
  const handleAddSemester = () => {
 
64
  }
65
  };
66
 
67
+ if (fetching) return <div className="p-10 text-center text-gray-500">加载配置中...</div>;
68
+
69
  return (
70
  <div className="space-y-6">
71
  {/* General Settings */}
pages/StudentList.tsx CHANGED
@@ -1,20 +1,25 @@
1
 
2
  import React, { useState, useEffect } from 'react';
3
- import { Search, Plus, Download, Edit, Trash2, X, Loader2, User } from 'lucide-react';
4
  import { api } from '../services/api';
5
- import { Student } from '../types';
6
 
7
  export const StudentList: React.FC = () => {
8
  const [searchTerm, setSearchTerm] = useState('');
9
  const [students, setStudents] = useState<Student[]>([]);
 
10
  const [loading, setLoading] = useState(true);
11
  const [isModalOpen, setIsModalOpen] = useState(false);
 
12
  const [submitting, setSubmitting] = useState(false);
13
 
14
  // Filters
15
  const [selectedGrade, setSelectedGrade] = useState('All');
16
  const [selectedClass, setSelectedClass] = useState('All');
17
 
 
 
 
18
  // Form State
19
  const [formData, setFormData] = useState({
20
  name: '',
@@ -28,8 +33,12 @@ export const StudentList: React.FC = () => {
28
  const loadData = async () => {
29
  setLoading(true);
30
  try {
31
- const data = await api.students.getAll();
32
- setStudents(data);
 
 
 
 
33
  } catch (error) {
34
  console.error('Failed to load students', error);
35
  } finally {
@@ -69,9 +78,43 @@ export const StudentList: React.FC = () => {
69
  }
70
  };
71
 
72
- // 智能提取现有数据中的年级和班级选项
73
- const grades = ['一年级', '二年级', '三年级', '四年级', '五年级', '六年级'];
74
- const classes = ['(1)班', '(2)班', '(3)班', '(4)班'];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
 
76
  const filteredStudents = students.filter((s) => {
77
  const matchesSearch =
@@ -115,9 +158,12 @@ export const StudentList: React.FC = () => {
115
  <p className="text-sm text-gray-500">共找到 {filteredStudents.length} 名学生</p>
116
  </div>
117
  <div className="flex space-x-3">
118
- <button className="flex items-center justify-center space-x-2 px-4 py-2 border border-gray-200 rounded-lg text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors">
119
- <Download size={16} />
120
- <span>导出Excel</span>
 
 
 
121
  </button>
122
  <button
123
  onClick={() => setIsModalOpen(true)}
@@ -150,7 +196,7 @@ export const StudentList: React.FC = () => {
150
  onChange={(e) => setSelectedGrade(e.target.value)}
151
  >
152
  <option value="All">所有年级</option>
153
- {grades.map(g => <option key={g} value={g}>{g}</option>)}
154
  </select>
155
  </div>
156
  <div className="w-40">
@@ -160,7 +206,7 @@ export const StudentList: React.FC = () => {
160
  onChange={(e) => setSelectedClass(e.target.value)}
161
  >
162
  <option value="All">所有班级</option>
163
- {classes.map(c => <option key={c} value={c}>{c}</option>)}
164
  </select>
165
  </div>
166
  </div>
@@ -236,6 +282,33 @@ export const StudentList: React.FC = () => {
236
  </table>
237
  </div>
238
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
239
  {/* Add Student Modal */}
240
  {isModalOpen && (
241
  <div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 backdrop-blur-sm">
@@ -291,10 +364,8 @@ export const StudentList: React.FC = () => {
291
  value={formData.className} onChange={e => setFormData({...formData, className: e.target.value})} required
292
  >
293
  <option value="">-- 请选择班级 --</option>
294
- {grades.map(g => (
295
- classes.map(c => (
296
- <option key={`${g}${c}`} value={`${g}${c}`}>{g}{c}</option>
297
- ))
298
  ))}
299
  </select>
300
  </div>
 
1
 
2
  import React, { useState, useEffect } from 'react';
3
+ import { Search, Plus, Download, Edit, Trash2, X, Loader2, User, Upload } from 'lucide-react';
4
  import { api } from '../services/api';
5
+ import { Student, ClassInfo } from '../types';
6
 
7
  export const StudentList: React.FC = () => {
8
  const [searchTerm, setSearchTerm] = useState('');
9
  const [students, setStudents] = useState<Student[]>([]);
10
+ const [classList, setClassList] = useState<ClassInfo[]>([]);
11
  const [loading, setLoading] = useState(true);
12
  const [isModalOpen, setIsModalOpen] = useState(false);
13
+ const [isImportOpen, setIsImportOpen] = useState(false);
14
  const [submitting, setSubmitting] = useState(false);
15
 
16
  // Filters
17
  const [selectedGrade, setSelectedGrade] = useState('All');
18
  const [selectedClass, setSelectedClass] = useState('All');
19
 
20
+ // Import Data
21
+ const [importText, setImportText] = useState('');
22
+
23
  // Form State
24
  const [formData, setFormData] = useState({
25
  name: '',
 
33
  const loadData = async () => {
34
  setLoading(true);
35
  try {
36
+ const [studentData, classData] = await Promise.all([
37
+ api.students.getAll(),
38
+ api.classes.getAll()
39
+ ]);
40
+ setStudents(studentData);
41
+ setClassList(classData);
42
  } catch (error) {
43
  console.error('Failed to load students', error);
44
  } finally {
 
78
  }
79
  };
80
 
81
+ const handleImport = async () => {
82
+ if (!importText.trim()) return;
83
+ setSubmitting(true);
84
+ // Parse CSV-like text: Name, StudentNo, Gender, Class, Phone
85
+ const lines = importText.trim().split('\n');
86
+ let successCount = 0;
87
+
88
+ for (const line of lines) {
89
+ const parts = line.split(/[,\t\s]+/).filter(Boolean);
90
+ if (parts.length >= 3) {
91
+ try {
92
+ // Simple mapping assumption: Name, No, Gender, ClassName(Full), Phone
93
+ const [name, studentNo, genderRaw, className, phone] = parts;
94
+ await api.students.add({
95
+ name,
96
+ studentNo,
97
+ gender: genderRaw === '女' ? 'Female' : 'Male',
98
+ className: className || '未分配',
99
+ phone: phone || '无',
100
+ birthday: '2015-01-01',
101
+ status: 'Enrolled'
102
+ });
103
+ successCount++;
104
+ } catch (e) { console.error('Row failed', line); }
105
+ }
106
+ }
107
+
108
+ alert(`成功导入 ${successCount} 条数据`);
109
+ setIsImportOpen(false);
110
+ setImportText('');
111
+ setSubmitting(false);
112
+ loadData();
113
+ };
114
+
115
+ // Build Filter Options from Real Classes
116
+ const uniqueGrades = Array.from(new Set(classList.map(c => c.grade)));
117
+ const uniqueClasses = Array.from(new Set(classList.map(c => c.className)));
118
 
119
  const filteredStudents = students.filter((s) => {
120
  const matchesSearch =
 
158
  <p className="text-sm text-gray-500">共找到 {filteredStudents.length} 名学生</p>
159
  </div>
160
  <div className="flex space-x-3">
161
+ <button
162
+ onClick={() => setIsImportOpen(true)}
163
+ className="flex items-center justify-center space-x-2 px-4 py-2 border border-gray-200 rounded-lg text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
164
+ >
165
+ <Upload size={16} />
166
+ <span>批量导入</span>
167
  </button>
168
  <button
169
  onClick={() => setIsModalOpen(true)}
 
196
  onChange={(e) => setSelectedGrade(e.target.value)}
197
  >
198
  <option value="All">所有年级</option>
199
+ {uniqueGrades.map(g => <option key={g} value={g}>{g}</option>)}
200
  </select>
201
  </div>
202
  <div className="w-40">
 
206
  onChange={(e) => setSelectedClass(e.target.value)}
207
  >
208
  <option value="All">所有班级</option>
209
+ {uniqueClasses.map(c => <option key={c} value={c}>{c}</option>)}
210
  </select>
211
  </div>
212
  </div>
 
282
  </table>
283
  </div>
284
 
285
+ {/* Import Modal */}
286
+ {isImportOpen && (
287
+ <div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
288
+ <div className="bg-white rounded-xl shadow-2xl max-w-lg w-full p-6">
289
+ <h3 className="font-bold text-gray-800 text-lg mb-4">批量导入学生</h3>
290
+ <p className="text-sm text-gray-500 mb-2">请按以下格式粘贴文本 (空格分隔):</p>
291
+ <div className="bg-gray-50 p-2 text-xs font-mono text-gray-600 mb-4 rounded border">
292
+ 姓名 学号 性别 班级全称 手机号<br/>
293
+ 张三 202401 男 六年级(1)班 13800000000<br/>
294
+ 李四 202402 女 六年级(2)班 13900000000
295
+ </div>
296
+ <textarea
297
+ className="w-full h-48 border rounded-lg p-3 text-sm font-mono focus:ring-2 focus:ring-blue-500 focus:outline-none"
298
+ placeholder="请粘贴内容..."
299
+ value={importText}
300
+ onChange={e => setImportText(e.target.value)}
301
+ />
302
+ <div className="flex space-x-3 pt-4">
303
+ <button onClick={() => setIsImportOpen(false)} className="flex-1 py-2 border rounded-lg">取消</button>
304
+ <button onClick={handleImport} disabled={submitting} className="flex-1 py-2 bg-blue-600 text-white rounded-lg">
305
+ {submitting ? '导入中...' : '开始导入'}
306
+ </button>
307
+ </div>
308
+ </div>
309
+ </div>
310
+ )}
311
+
312
  {/* Add Student Modal */}
313
  {isModalOpen && (
314
  <div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 backdrop-blur-sm">
 
364
  value={formData.className} onChange={e => setFormData({...formData, className: e.target.value})} required
365
  >
366
  <option value="">-- 请选择班级 --</option>
367
+ {classList.map(cls => (
368
+ <option key={cls._id || cls.id} value={`${cls.grade}${cls.className}`}>{cls.grade}{cls.className}</option>
 
 
369
  ))}
370
  </select>
371
  </div>
server.js CHANGED
@@ -1,3 +1,4 @@
 
1
  const express = require('express');
2
  const mongoose = require('mongoose');
3
  const cors = require('cors');
@@ -33,6 +34,14 @@ const InMemoryDB = {
33
  students: [],
34
  courses: [],
35
  scores: [],
 
 
 
 
 
 
 
 
36
  isFallback: false
37
  };
38
 
@@ -102,6 +111,24 @@ const ScoreSchema = new mongoose.Schema({
102
  });
103
  const Score = mongoose.model('Score', ScoreSchema);
104
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
  // 初始化数据逻辑 (仅 MongoDB 模式)
106
  const initData = async () => {
107
  if (InMemoryDB.isFallback) return;
@@ -113,8 +140,32 @@ const initData = async () => {
113
  { username: 'admin', password: 'admin', role: 'ADMIN', email: 'admin@school.edu' },
114
  { username: 'teacher', password: 'teacher', role: 'TEACHER', email: 'teacher@school.edu' }
115
  ]);
116
- console.log('✅ 数据初始化完成');
117
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
  } catch (err) {
119
  console.error('初始化数据失败', err);
120
  }
@@ -188,6 +239,57 @@ app.delete('/api/students/:id', async (req, res) => {
188
  } catch (e) { res.status(500).json({ error: e.message }); }
189
  });
190
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
191
  // --- Courses ---
192
  app.get('/api/courses', async (req, res) => {
193
  if (InMemoryDB.isFallback) return res.json(InMemoryDB.courses);
@@ -219,6 +321,16 @@ app.put('/api/courses/:id', async (req, res) => {
219
  res.json(updated);
220
  } catch (e) { res.status(400).json({ error: e.message }); }
221
  });
 
 
 
 
 
 
 
 
 
 
222
 
223
  // --- Scores ---
224
  app.get('/api/scores', async (req, res) => {
@@ -256,13 +368,21 @@ app.get('/api/stats', async (req, res) => {
256
  const courseCount = InMemoryDB.courses.length;
257
  const scores = InMemoryDB.scores;
258
  const avgScore = scores.length > 0 ? (scores.reduce((a, b) => a + b.score, 0) / scores.length).toFixed(1) : 0;
259
- return res.json({ studentCount, courseCount, avgScore, excellentRate: '88%' });
260
  }
261
  const studentCount = await Student.countDocuments();
262
  const courseCount = await Course.countDocuments();
263
  const scores = await Score.find();
264
- const avgScore = scores.length > 0 ? (scores.reduce((a, b) => a + b.score, 0) / scores.length).toFixed(1) : 0;
265
- res.json({ studentCount, courseCount, avgScore, excellentRate: '88%' });
 
 
 
 
 
 
 
 
266
  } catch (e) { res.status(500).json({ error: e.message }); }
267
  });
268
 
@@ -277,4 +397,4 @@ app.get('*', (req, res) => {
277
  // 启动服务器
278
  app.listen(PORT, () => {
279
  console.log(`🚀 服务已启动,监听端口: ${PORT}`);
280
- });
 
1
+
2
  const express = require('express');
3
  const mongoose = require('mongoose');
4
  const cors = require('cors');
 
34
  students: [],
35
  courses: [],
36
  scores: [],
37
+ classes: [],
38
+ config: {
39
+ systemName: '智慧校园管理系统',
40
+ semester: '2023-2024学年 第一学期',
41
+ allowRegister: true,
42
+ maintenanceMode: false,
43
+ emailNotify: true
44
+ },
45
  isFallback: false
46
  };
47
 
 
111
  });
112
  const Score = mongoose.model('Score', ScoreSchema);
113
 
114
+ const ClassSchema = new mongoose.Schema({
115
+ grade: String,
116
+ className: String,
117
+ teacherName: String
118
+ });
119
+ const ClassModel = mongoose.model('Class', ClassSchema);
120
+
121
+ const ConfigSchema = new mongoose.Schema({
122
+ key: { type: String, default: 'main' }, // Singleton
123
+ systemName: String,
124
+ semester: String,
125
+ allowRegister: Boolean,
126
+ maintenanceMode: Boolean,
127
+ emailNotify: Boolean
128
+ });
129
+ const ConfigModel = mongoose.model('Config', ConfigSchema);
130
+
131
+
132
  // 初始化数据逻辑 (仅 MongoDB 模式)
133
  const initData = async () => {
134
  if (InMemoryDB.isFallback) return;
 
140
  { username: 'admin', password: 'admin', role: 'ADMIN', email: 'admin@school.edu' },
141
  { username: 'teacher', password: 'teacher', role: 'TEACHER', email: 'teacher@school.edu' }
142
  ]);
 
143
  }
144
+
145
+ // 初始化配置
146
+ const config = await ConfigModel.findOne({ key: 'main' });
147
+ if (!config) {
148
+ await ConfigModel.create({
149
+ key: 'main',
150
+ systemName: '智慧校园管理系统',
151
+ semester: '2023-2024学年 第一学期',
152
+ allowRegister: true,
153
+ maintenanceMode: false,
154
+ emailNotify: true
155
+ });
156
+ }
157
+
158
+ // 初始化班级 (如果为空)
159
+ const classCount = await ClassModel.countDocuments();
160
+ if (classCount === 0) {
161
+ await ClassModel.create([
162
+ { grade: '六年级', className: '(1)班', teacherName: '王老师' },
163
+ { grade: '六年级', className: '(2)班', teacherName: '李老师' },
164
+ { grade: '五年级', className: '(1)班', teacherName: '张老师' }
165
+ ]);
166
+ }
167
+
168
+ console.log('✅ 数据初始化完成');
169
  } catch (err) {
170
  console.error('初始化数据失败', err);
171
  }
 
239
  } catch (e) { res.status(500).json({ error: e.message }); }
240
  });
241
 
242
+ // --- Classes ---
243
+ app.get('/api/classes', async (req, res) => {
244
+ if (InMemoryDB.isFallback) return res.json(InMemoryDB.classes);
245
+ const classes = await ClassModel.find().sort({ grade: 1, className: 1 });
246
+ // Add student count
247
+ const result = await Promise.all(classes.map(async (cls) => {
248
+ const count = await Student.countDocuments({ className: cls.grade + cls.className });
249
+ return { ...cls.toObject(), studentCount: count };
250
+ }));
251
+ res.json(result);
252
+ });
253
+ app.post('/api/classes', async (req, res) => {
254
+ try {
255
+ if (InMemoryDB.isFallback) {
256
+ const newClass = { ...req.body, id: Date.now(), _id: Date.now().toString() };
257
+ InMemoryDB.classes.push(newClass);
258
+ return res.json(newClass);
259
+ }
260
+ const newClass = await ClassModel.create(req.body);
261
+ res.json(newClass);
262
+ } catch (e) { res.status(400).json({ error: e.message }); }
263
+ });
264
+ app.delete('/api/classes/:id', async (req, res) => {
265
+ try {
266
+ if (InMemoryDB.isFallback) {
267
+ InMemoryDB.classes = InMemoryDB.classes.filter(c => c._id !== req.params.id);
268
+ return res.json({ success: true });
269
+ }
270
+ await ClassModel.findByIdAndDelete(req.params.id);
271
+ res.json({ success: true });
272
+ } catch (e) { res.status(500).json({ error: e.message }); }
273
+ });
274
+
275
+ // --- Config ---
276
+ app.get('/api/config', async (req, res) => {
277
+ if (InMemoryDB.isFallback) return res.json(InMemoryDB.config);
278
+ let config = await ConfigModel.findOne({ key: 'main' });
279
+ if (!config) config = {};
280
+ res.json(config);
281
+ });
282
+ app.post('/api/config', async (req, res) => {
283
+ try {
284
+ if (InMemoryDB.isFallback) {
285
+ InMemoryDB.config = { ...InMemoryDB.config, ...req.body };
286
+ return res.json(InMemoryDB.config);
287
+ }
288
+ const config = await ConfigModel.findOneAndUpdate({ key: 'main' }, req.body, { new: true, upsert: true });
289
+ res.json(config);
290
+ } catch (e) { res.status(400).json({ error: e.message }); }
291
+ });
292
+
293
  // --- Courses ---
294
  app.get('/api/courses', async (req, res) => {
295
  if (InMemoryDB.isFallback) return res.json(InMemoryDB.courses);
 
321
  res.json(updated);
322
  } catch (e) { res.status(400).json({ error: e.message }); }
323
  });
324
+ app.delete('/api/courses/:id', async (req, res) => {
325
+ try {
326
+ if (InMemoryDB.isFallback) {
327
+ InMemoryDB.courses = InMemoryDB.courses.filter(c => c._id !== req.params.id && c.id != req.params.id);
328
+ return res.json({ success: true });
329
+ }
330
+ await Course.findByIdAndDelete(req.params.id);
331
+ res.json({ success: true });
332
+ } catch (e) { res.status(500).json({ error: e.message }); }
333
+ });
334
 
335
  // --- Scores ---
336
  app.get('/api/scores', async (req, res) => {
 
368
  const courseCount = InMemoryDB.courses.length;
369
  const scores = InMemoryDB.scores;
370
  const avgScore = scores.length > 0 ? (scores.reduce((a, b) => a + b.score, 0) / scores.length).toFixed(1) : 0;
371
+ return res.json({ studentCount, courseCount, avgScore, excellentRate: '0%' });
372
  }
373
  const studentCount = await Student.countDocuments();
374
  const courseCount = await Course.countDocuments();
375
  const scores = await Score.find();
376
+
377
+ // Calculate average
378
+ const totalScore = scores.reduce((sum, s) => sum + (s.score || 0), 0);
379
+ const avgScore = scores.length > 0 ? (totalScore / scores.length).toFixed(1) : 0;
380
+
381
+ // Calculate excellent rate (>90)
382
+ const excellentCount = scores.filter(s => s.score >= 90).length;
383
+ const excellentRate = scores.length > 0 ? Math.round((excellentCount / scores.length) * 100) + '%' : '0%';
384
+
385
+ res.json({ studentCount, courseCount, avgScore, excellentRate });
386
  } catch (e) { res.status(500).json({ error: e.message }); }
387
  });
388
 
 
397
  // 启动服务器
398
  app.listen(PORT, () => {
399
  console.log(`🚀 服务已启动,监听端口: ${PORT}`);
400
+ });
services/api.ts CHANGED
@@ -1,6 +1,6 @@
1
 
2
  /// <reference types="vite/client" />
3
- import { User } from '../types';
4
 
5
  // ==========================================
6
  // 智能环境配置 (Smart Environment Config)
@@ -11,7 +11,6 @@ const getBaseUrl = () => {
11
 
12
  try {
13
  // Safely check for Vite environment variable with try-catch
14
- // This prevents "Cannot read properties of undefined" if import.meta is not supported
15
  // @ts-ignore
16
  if (typeof import.meta !== 'undefined' && import.meta.env && import.meta.env.PROD) {
17
  isProd = true;
@@ -25,7 +24,6 @@ const getBaseUrl = () => {
25
  }
26
 
27
  // Runtime check for Hugging Face or Production Port 7860
28
- // If the page is currently being served from port 7860, we are in "Production" mode
29
  if (typeof window !== 'undefined' && window.location.port === '7860') {
30
  return '/api';
31
  }
@@ -59,10 +57,15 @@ export const api = {
59
  auth: {
60
  login: async (username: string, password: string): Promise<User> => {
61
  try {
62
- return await request('/auth/login', {
63
  method: 'POST',
64
  body: JSON.stringify({ username, password })
65
  });
 
 
 
 
 
66
  } catch (err: any) {
67
  throw new Error('用户名或密码错误');
68
  }
@@ -72,6 +75,18 @@ export const api = {
72
  method: 'POST',
73
  body: JSON.stringify(data)
74
  });
 
 
 
 
 
 
 
 
 
 
 
 
75
  }
76
  },
77
 
@@ -81,10 +96,17 @@ export const api = {
81
  delete: (id: string | number) => request(`/students/${id}`, { method: 'DELETE' })
82
  },
83
 
 
 
 
 
 
 
84
  courses: {
85
  getAll: () => request('/courses'),
86
  add: (data: any) => request('/courses', { method: 'POST', body: JSON.stringify(data) }),
87
- update: (id: string | number, data: any) => request(`/courses/${id}`, { method: 'PUT', body: JSON.stringify(data) })
 
88
  },
89
 
90
  scores: {
@@ -95,5 +117,10 @@ export const api = {
95
 
96
  stats: {
97
  getSummary: () => request('/stats')
 
 
 
 
 
98
  }
99
  };
 
1
 
2
  /// <reference types="vite/client" />
3
+ import { User, ClassInfo, SystemConfig } from '../types';
4
 
5
  // ==========================================
6
  // 智能环境配置 (Smart Environment Config)
 
11
 
12
  try {
13
  // Safely check for Vite environment variable with try-catch
 
14
  // @ts-ignore
15
  if (typeof import.meta !== 'undefined' && import.meta.env && import.meta.env.PROD) {
16
  isProd = true;
 
24
  }
25
 
26
  // Runtime check for Hugging Face or Production Port 7860
 
27
  if (typeof window !== 'undefined' && window.location.port === '7860') {
28
  return '/api';
29
  }
 
57
  auth: {
58
  login: async (username: string, password: string): Promise<User> => {
59
  try {
60
+ const user = await request('/auth/login', {
61
  method: 'POST',
62
  body: JSON.stringify({ username, password })
63
  });
64
+ // Save to LocalStorage for persistence
65
+ if (typeof window !== 'undefined') {
66
+ localStorage.setItem('user', JSON.stringify(user));
67
+ }
68
+ return user;
69
  } catch (err: any) {
70
  throw new Error('用户名或密码错误');
71
  }
 
75
  method: 'POST',
76
  body: JSON.stringify(data)
77
  });
78
+ },
79
+ logout: () => {
80
+ if (typeof window !== 'undefined') {
81
+ localStorage.removeItem('user');
82
+ }
83
+ },
84
+ getCurrentUser: (): User | null => {
85
+ if (typeof window !== 'undefined') {
86
+ const stored = localStorage.getItem('user');
87
+ if (stored) return JSON.parse(stored);
88
+ }
89
+ return null;
90
  }
91
  },
92
 
 
96
  delete: (id: string | number) => request(`/students/${id}`, { method: 'DELETE' })
97
  },
98
 
99
+ classes: {
100
+ getAll: () => request('/classes'),
101
+ add: (data: ClassInfo) => request('/classes', { method: 'POST', body: JSON.stringify(data) }),
102
+ delete: (id: string | number) => request(`/classes/${id}`, { method: 'DELETE' })
103
+ },
104
+
105
  courses: {
106
  getAll: () => request('/courses'),
107
  add: (data: any) => request('/courses', { method: 'POST', body: JSON.stringify(data) }),
108
+ update: (id: string | number, data: any) => request(`/courses/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
109
+ delete: (id: string | number) => request(`/courses/${id}`, { method: 'DELETE' })
110
  },
111
 
112
  scores: {
 
117
 
118
  stats: {
119
  getSummary: () => request('/stats')
120
+ },
121
+
122
+ config: {
123
+ get: () => request('/config'),
124
+ save: (data: SystemConfig) => request('/config', { method: 'POST', body: JSON.stringify(data) })
125
  }
126
  };
types.ts CHANGED
@@ -22,6 +22,23 @@ export interface User {
22
  avatar?: string;
23
  }
24
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  export interface Student {
26
  id?: number;
27
  _id?: string; // MongoDB ID
 
22
  avatar?: string;
23
  }
24
 
25
+ export interface ClassInfo {
26
+ id?: number;
27
+ _id?: string;
28
+ grade: string;
29
+ className: string; // e.g., "(1)班"
30
+ teacherName?: string;
31
+ studentCount?: number; // Calculated field
32
+ }
33
+
34
+ export interface SystemConfig {
35
+ systemName: string;
36
+ semester: string;
37
+ allowRegister: boolean;
38
+ maintenanceMode: boolean;
39
+ emailNotify: boolean;
40
+ }
41
+
42
  export interface Student {
43
  id?: number;
44
  _id?: string; // MongoDB ID