dvc890 commited on
Commit
700b192
·
verified ·
1 Parent(s): 6e3d4ce

Upload 24 files

Browse files
Files changed (12) hide show
  1. App.tsx +17 -27
  2. components/Sidebar.tsx +3 -1
  3. pages/CourseList.tsx +54 -169
  4. pages/Login.tsx +18 -25
  5. pages/Reports.tsx +72 -102
  6. pages/ScoreList.tsx +124 -330
  7. pages/StudentList.tsx +115 -274
  8. pages/SubjectList.tsx +90 -0
  9. pages/UserList.tsx +110 -0
  10. server.js +166 -182
  11. services/api.ts +30 -43
  12. types.ts +10 -1
App.tsx CHANGED
@@ -9,6 +9,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';
13
  import { User, UserRole } from './types';
14
  import { api } from './services/api';
@@ -20,10 +22,7 @@ const App: React.FC = () => {
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);
@@ -46,26 +45,19 @@ const App: React.FC = () => {
46
 
47
  const renderContent = () => {
48
  switch (currentView) {
49
- case 'dashboard':
50
- return <Dashboard />;
51
- case 'students':
52
- return <StudentList />;
53
- case 'classes':
54
- return <ClassList />;
55
- case 'courses':
56
- return <CourseList />;
57
- case 'grades':
58
- return <ScoreList />;
59
- case 'settings':
60
- return <Settings />;
61
- case 'reports':
62
- return <Reports />;
63
- default:
64
- return <Dashboard />;
65
  }
66
  };
67
 
68
- // Map view IDs to Chinese Titles
69
  const viewTitles: Record<string, string> = {
70
  dashboard: '工作台',
71
  students: '学生档案管理',
@@ -73,16 +65,14 @@ const App: React.FC = () => {
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
- }
86
 
87
  return (
88
  <div className="flex h-screen bg-gray-50">
 
9
  import { ClassList } from './pages/ClassList';
10
  import { Settings } from './pages/Settings';
11
  import { Reports } from './pages/Reports';
12
+ import { SubjectList } from './pages/SubjectList';
13
+ import { UserList } from './pages/UserList';
14
  import { Login } from './pages/Login';
15
  import { User, UserRole } from './types';
16
  import { api } from './services/api';
 
22
  const [loading, setLoading] = useState(true);
23
 
24
  useEffect(() => {
 
25
  api.init();
 
 
26
  const storedUser = api.auth.getCurrentUser();
27
  if (storedUser) {
28
  setCurrentUser(storedUser);
 
45
 
46
  const renderContent = () => {
47
  switch (currentView) {
48
+ case 'dashboard': return <Dashboard />;
49
+ case 'students': return <StudentList />;
50
+ case 'classes': return <ClassList />;
51
+ case 'courses': return <CourseList />;
52
+ case 'grades': return <ScoreList />;
53
+ case 'settings': return <Settings />;
54
+ case 'reports': return <Reports />;
55
+ case 'subjects': return <SubjectList />;
56
+ case 'users': return <UserList />;
57
+ default: return <Dashboard />;
 
 
 
 
 
 
58
  }
59
  };
60
 
 
61
  const viewTitles: Record<string, string> = {
62
  dashboard: '工作台',
63
  students: '学生档案管理',
 
65
  courses: '课程安排',
66
  grades: '成绩管理',
67
  settings: '系统设置',
68
+ reports: '统计报表',
69
+ subjects: '学科设置',
70
+ users: '用户权限管理'
71
  };
72
 
73
+ if (loading) return <div className="min-h-screen flex items-center justify-center bg-gray-50 text-blue-600">加载中...</div>;
 
 
74
 
75
+ if (!isAuthenticated) return <Login onLogin={handleLogin} />;
 
 
76
 
77
  return (
78
  <div className="flex h-screen bg-gray-50">
components/Sidebar.tsx CHANGED
@@ -1,6 +1,6 @@
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 {
@@ -18,6 +18,8 @@ export const Sidebar: React.FC<SidebarProps> = ({ currentView, onChangeView, use
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] },
 
 
21
  { id: 'settings', label: '系统设置', icon: Settings, roles: [UserRole.ADMIN] },
22
  ];
23
 
 
1
 
2
  import React from 'react';
3
+ import { LayoutDashboard, Users, BookOpen, GraduationCap, Settings, LogOut, FileText, School, UserCog, Palette } from 'lucide-react';
4
  import { UserRole } from '../types';
5
 
6
  interface SidebarProps {
 
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] },
21
+ { id: 'subjects', label: '学科设置', icon: Palette, roles: [UserRole.ADMIN] },
22
+ { id: 'users', label: '用户管理', icon: UserCog, roles: [UserRole.ADMIN] },
23
  { id: 'settings', label: '系统设置', icon: Settings, roles: [UserRole.ADMIN] },
24
  ];
25
 
pages/CourseList.tsx CHANGED
@@ -1,196 +1,81 @@
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} />
81
- </div>
82
- );
83
- }
84
 
85
  return (
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>
96
- </div>
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">
104
- {course.courseCode}
105
- </span>
106
- <h3 className="text-lg font-bold text-gray-900">{course.courseName}</h3>
107
- <p className="text-sm text-gray-500 mt-1">任课教师: {course.teacherName}</p>
108
- </div>
109
- <div className="h-10 w-10 bg-indigo-50 rounded-lg flex items-center justify-center text-indigo-600">
110
- <Clock size={20} />
111
- </div>
112
- </div>
113
 
114
- <div className="space-y-4">
115
- <div className="flex justify-between text-sm text-gray-600">
116
- <span>周课时</span>
117
- <span className="font-medium">{course.credits} 节</span>
118
- </div>
119
-
120
- <div>
121
- <div className="flex justify-between text-sm mb-1">
122
- <span className="text-gray-600 flex items-center">
123
- <Users size={14} className="mr-1" /> 班级人数
124
- </span>
125
- <span className="text-gray-900 font-medium">{course.enrolled}/{course.capacity}</span>
126
- </div>
127
- <div className="w-full bg-gray-100 rounded-full h-2">
128
- <div
129
- className="bg-blue-500 h-2 rounded-full transition-all duration-500"
130
- style={{ width: `${(course.enrolled / course.capacity) * 100}%` }}
131
- ></div>
132
- </div>
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
  };
 
1
 
2
  import React, { useEffect, useState } from 'react';
3
+ import { Plus, Clock, Loader2, Edit, Trash2, X } from 'lucide-react';
4
  import { api } from '../services/api';
5
+ import { Course, Subject } from '../types';
6
 
7
  export const CourseList: React.FC = () => {
8
  const [courses, setCourses] = useState<Course[]>([]);
9
+ const [subjects, setSubjects] = useState<Subject[]>([]);
10
  const [loading, setLoading] = useState(true);
11
  const [isModalOpen, setIsModalOpen] = useState(false);
12
+ const [formData, setFormData] = useState({ courseCode: '', courseName: '', teacherName: '', credits: 2, capacity: 45 });
13
+ const [editId, setEditId] = useState<string | null>(null);
14
 
15
+ const loadData = async () => {
 
 
 
 
 
 
 
 
16
  setLoading(true);
17
  try {
18
+ const [c, s] = await Promise.all([api.courses.getAll(), api.subjects.getAll()]);
19
+ setCourses(c);
20
+ setSubjects(s);
21
+ } catch (e) { console.error(e); } finally { setLoading(false); }
 
 
 
22
  };
23
 
24
+ useEffect(() => { loadData(); }, []);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
 
26
  const handleSubmit = async (e: React.FormEvent) => {
27
  e.preventDefault();
28
+ if (editId) await api.courses.update(editId, formData);
29
+ else await api.courses.add(formData);
30
+ setIsModalOpen(false);
31
+ loadData();
 
 
 
 
 
 
 
 
 
 
 
 
32
  };
33
 
34
+ const handleDelete = async (id: string) => {
35
+ if(confirm('删除课程?')) { await api.courses.delete(id); loadData(); }
36
+ };
 
 
 
 
37
 
38
  return (
39
  <div className="space-y-6">
40
+ <div className="flex justify-between items-center">
41
+ <h2 className="text-xl font-bold">课程列表</h2>
42
+ <button onClick={() => { setEditId(null); setIsModalOpen(true); }} className="px-4 py-2 bg-indigo-600 text-white rounded-lg flex items-center text-sm"><Plus size={16} className="mr-1"/> 新增课程</button>
43
+ </div>
 
 
 
 
 
 
44
 
45
+ {loading ? <Loader2 className="animate-spin"/> : (
46
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
47
+ {courses.map(c => (
48
+ <div key={c._id || c.id} className="bg-white p-6 rounded-xl border shadow-sm">
49
+ <h3 className="font-bold text-lg">{c.courseName}</h3>
50
+ <p className="text-sm text-gray-500">教师: {c.teacherName}</p>
51
+ <div className="mt-4 flex gap-2">
52
+ <button onClick={() => { setFormData({ courseCode: c.courseCode, courseName: c.courseName, teacherName: c.teacherName, credits: c.credits, capacity: c.capacity }); setEditId(c._id || String(c.id)); setIsModalOpen(true); }} className="flex-1 bg-gray-50 text-blue-600 py-1 rounded text-sm">编辑</button>
53
+ <button onClick={() => handleDelete(c._id || String(c.id))} className="flex-1 bg-gray-50 text-red-600 py-1 rounded text-sm">删除</button>
54
+ </div>
55
+ </div>
56
+ ))}
57
+ </div>
58
+ )}
 
59
 
60
+ {isModalOpen && (
61
+ <div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
62
+ <div className="bg-white p-6 rounded-xl w-full max-w-md">
63
+ <h3 className="font-bold mb-4">{editId ? '编辑' : '新增'}</h3>
64
+ <form onSubmit={handleSubmit} className="space-y-4">
65
+ <div>
66
+ <label className="text-sm font-bold text-gray-500">科目</label>
67
+ <select className="w-full border p-2 rounded" value={formData.courseName} onChange={e=>setFormData({...formData, courseName:e.target.value})} required>
68
+ <option value="">选择科目</option>
69
+ {subjects.map(s=><option key={s._id} value={s.name}>{s.name}</option>)}
70
+ </select>
71
+ </div>
72
+ <input className="w-full border p-2 rounded" placeholder="教师姓名" value={formData.teacherName} onChange={e=>setFormData({...formData, teacherName:e.target.value})} required/>
73
+ <button className="w-full bg-blue-600 text-white py-2 rounded">保存</button>
74
+ <button type="button" onClick={()=>setIsModalOpen(false)} className="w-full border py-2 rounded">取消</button>
75
+ </form>
76
+ </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77
  </div>
78
+ )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  </div>
80
  );
81
  };
pages/Login.tsx CHANGED
@@ -1,6 +1,6 @@
1
 
2
  import React, { useState } from 'react';
3
- import { GraduationCap, Lock, User as UserIcon, AlertCircle, ArrowRight } from 'lucide-react';
4
  import { User, UserRole } from '../types';
5
  import { api } from '../services/api';
6
 
@@ -12,7 +12,7 @@ export const Login: React.FC<LoginProps> = ({ onLogin }) => {
12
  const [isRegistering, setIsRegistering] = useState(false);
13
  const [username, setUsername] = useState('');
14
  const [password, setPassword] = useState('');
15
- const [role, setRole] = useState<UserRole>(UserRole.ADMIN); // Default to Admin for easy testing
16
 
17
  const [error, setError] = useState('');
18
  const [loading, setLoading] = useState(false);
@@ -26,26 +26,23 @@ export const Login: React.FC<LoginProps> = ({ onLogin }) => {
26
 
27
  try {
28
  if (isRegistering) {
29
- // Register Logic
30
  await api.auth.register({ username, password, role });
31
- setSuccessMsg('注册成功!正在自动登录...');
32
- // Auto login after register
33
- setTimeout(async () => {
34
- const user = await api.auth.login(username, password);
35
- onLogin(user);
36
- }, 1500);
37
  } else {
38
- // Login Logic
39
  const user = await api.auth.login(username, password);
40
  onLogin(user);
41
  }
42
  } catch (err: any) {
43
  console.error(err);
44
- if (isRegistering) {
45
- setError('注册失败: 用户名可能已存在');
 
 
46
  } else {
47
- setError('用户名或密码错误');
48
  }
 
49
  setLoading(false);
50
  }
51
  };
@@ -68,16 +65,16 @@ export const Login: React.FC<LoginProps> = ({ onLogin }) => {
68
  <div className="mb-6 flex justify-center">
69
  <div className="bg-gray-100 p-1 rounded-lg flex text-sm font-medium">
70
  <button
71
- onClick={() => { setIsRegistering(false); setError(''); }}
72
  className={`px-6 py-2 rounded-md transition-all ${!isRegistering ? 'bg-white text-blue-600 shadow-sm' : 'text-gray-500 hover:text-gray-700'}`}
73
  >
74
  登录
75
  </button>
76
  <button
77
- onClick={() => { setIsRegistering(true); setError(''); }}
78
  className={`px-6 py-2 rounded-md transition-all ${isRegistering ? 'bg-white text-blue-600 shadow-sm' : 'text-gray-500 hover:text-gray-700'}`}
79
  >
80
- 注册新账号
81
  </button>
82
  </div>
83
  </div>
@@ -133,17 +130,16 @@ export const Login: React.FC<LoginProps> = ({ onLogin }) => {
133
 
134
  {isRegistering && (
135
  <div className="space-y-2 animate-in fade-in slide-in-from-top-2">
136
- <label className="text-sm font-medium text-gray-700">选择角色</label>
137
  <select
138
  value={role}
139
  onChange={(e) => setRole(e.target.value as UserRole)}
140
  className="block w-full px-3 py-2.5 border border-gray-300 rounded-lg focus:ring-blue-500 focus:border-blue-500 transition-colors bg-white"
141
  >
142
- <option value={UserRole.ADMIN}>管理员 (拥有所有权限)</option>
143
  <option value={UserRole.TEACHER}>教师</option>
144
  <option value={UserRole.STUDENT}>学生</option>
145
  </select>
146
- <p className="text-xs text-gray-500 mt-1">* 仅用于演示,实际生产环境应禁止直接注册管理员</p>
147
  </div>
148
  )}
149
 
@@ -152,15 +148,12 @@ export const Login: React.FC<LoginProps> = ({ onLogin }) => {
152
  disabled={loading}
153
  className={`w-full flex justify-center py-3 px-4 border border-transparent rounded-lg shadow-lg shadow-blue-200 text-sm font-bold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-all transform hover:scale-[1.02] active:scale-[0.98] ${loading ? 'opacity-70 cursor-not-allowed' : ''}`}
154
  >
155
- {loading ? '处理中...' : (isRegistering ? '立即注册' : '登 录')}
156
- {!loading && <ArrowRight size={16} className="ml-2" />}
157
  </button>
158
-
159
  {!isRegistering && (
160
  <div className="text-center text-xs text-gray-400 mt-4 border-t border-gray-100 pt-4">
161
- <p className="mb-1">演示账号 (如果数据库已初始化):</p>
162
- <span className="bg-gray-100 px-2 py-1 rounded mr-2">admin / admin</span>
163
- <span className="bg-gray-100 px-2 py-1 rounded">teacher / teacher</span>
164
  </div>
165
  )}
166
  </form>
 
1
 
2
  import React, { useState } from 'react';
3
+ import { GraduationCap, Lock, User as UserIcon, AlertCircle, ArrowRight, Loader2 } from 'lucide-react';
4
  import { User, UserRole } from '../types';
5
  import { api } from '../services/api';
6
 
 
12
  const [isRegistering, setIsRegistering] = useState(false);
13
  const [username, setUsername] = useState('');
14
  const [password, setPassword] = useState('');
15
+ const [role, setRole] = useState<UserRole>(UserRole.STUDENT);
16
 
17
  const [error, setError] = useState('');
18
  const [loading, setLoading] = useState(false);
 
26
 
27
  try {
28
  if (isRegistering) {
 
29
  await api.auth.register({ username, password, role });
30
+ setSuccessMsg('注册申请已提交,请等待管理员审核通过后登录。');
31
+ setIsRegistering(false);
 
 
 
 
32
  } else {
 
33
  const user = await api.auth.login(username, password);
34
  onLogin(user);
35
  }
36
  } catch (err: any) {
37
  console.error(err);
38
+ if (err.message === 'PENDING_APPROVAL') {
39
+ setError('您的账号正在等待管理员审核,请稍后再试。');
40
+ } else if (err.message === 'BANNED') {
41
+ setError('您的账号已被停用,请联系管理员。');
42
  } else {
43
+ setError(isRegistering ? '注册失败:用户可能已存在' : '用户名或密码错误');
44
  }
45
+ } finally {
46
  setLoading(false);
47
  }
48
  };
 
65
  <div className="mb-6 flex justify-center">
66
  <div className="bg-gray-100 p-1 rounded-lg flex text-sm font-medium">
67
  <button
68
+ onClick={() => { setIsRegistering(false); setError(''); setSuccessMsg(''); }}
69
  className={`px-6 py-2 rounded-md transition-all ${!isRegistering ? 'bg-white text-blue-600 shadow-sm' : 'text-gray-500 hover:text-gray-700'}`}
70
  >
71
  登录
72
  </button>
73
  <button
74
+ onClick={() => { setIsRegistering(true); setError(''); setSuccessMsg(''); }}
75
  className={`px-6 py-2 rounded-md transition-all ${isRegistering ? 'bg-white text-blue-600 shadow-sm' : 'text-gray-500 hover:text-gray-700'}`}
76
  >
77
+ 注册
78
  </button>
79
  </div>
80
  </div>
 
130
 
131
  {isRegistering && (
132
  <div className="space-y-2 animate-in fade-in slide-in-from-top-2">
133
+ <label className="text-sm font-medium text-gray-700">申请角色</label>
134
  <select
135
  value={role}
136
  onChange={(e) => setRole(e.target.value as UserRole)}
137
  className="block w-full px-3 py-2.5 border border-gray-300 rounded-lg focus:ring-blue-500 focus:border-blue-500 transition-colors bg-white"
138
  >
 
139
  <option value={UserRole.TEACHER}>教师</option>
140
  <option value={UserRole.STUDENT}>学生</option>
141
  </select>
142
+ <p className="text-xs text-gray-500 mt-1">* 注册后需等待管理员审核</p>
143
  </div>
144
  )}
145
 
 
148
  disabled={loading}
149
  className={`w-full flex justify-center py-3 px-4 border border-transparent rounded-lg shadow-lg shadow-blue-200 text-sm font-bold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-all transform hover:scale-[1.02] active:scale-[0.98] ${loading ? 'opacity-70 cursor-not-allowed' : ''}`}
150
  >
151
+ {loading ? <Loader2 className="animate-spin" size={20} /> : (isRegistering ? '提交申请' : '登 录')}
 
152
  </button>
153
+
154
  {!isRegistering && (
155
  <div className="text-center text-xs text-gray-400 mt-4 border-t border-gray-100 pt-4">
156
+ <p>演示账号: admin / admin</p>
 
 
157
  </div>
158
  )}
159
  </form>
pages/Reports.tsx CHANGED
@@ -1,129 +1,99 @@
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: Student) => {
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: ClassInfo) => {
42
- const fullClassName = cls.grade + cls.className;
43
- const studentNos = classMap[fullClassName] || [];
44
- // Find scores for these students
45
- const classScores = scoreData.filter((s: Score) => studentNos.includes(s.studentNo));
46
- const total = classScores.reduce((sum: number, s: Score) => 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: Score) => s.courseName)));
54
- const sData = subjects.map((subj: any) => {
55
- const subjectName = String(subj);
56
- const subjScores = scoreData.filter((s: Score) => s.courseName === subjectName);
57
- const total = subjScores.reduce((sum: number, s: Score) => sum + s.score, 0);
58
- const avg = subjScores.length > 0 ? Math.round(total / subjScores.length) : 0;
59
- return { name: subjectName, avg };
60
  });
61
- setSubjectAvgData(sData);
 
 
 
 
 
62
 
63
- } catch (e) {
64
- console.error(e);
65
- } finally {
66
- setLoading(false);
67
- }
68
  };
69
- fetchData();
70
  }, []);
71
 
72
- if (loading) return <div className="flex justify-center py-20"><Loader2 className="animate-spin text-blue-600" size={32} /></div>;
73
 
74
  return (
75
  <div className="space-y-6">
76
- {/* Header */}
77
- <div className="bg-white p-5 rounded-xl shadow-sm border border-gray-100 flex justify-between items-center">
78
- <div>
79
- <h2 className="text-xl font-bold text-gray-800">统计报表中心</h2>
80
- <p className="text-sm text-gray-500 mt-1">基于 {scores.length} 条真实成绩数据生成</p>
81
- </div>
82
- <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">
83
- <Download size={16} />
84
- <span>导出报表</span>
85
- </button>
86
- </div>
87
 
88
- <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
89
- {/* Class Performance */}
90
- <div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100">
91
- <h3 className="text-lg font-bold text-gray-800 mb-4">各班级综合平均分</h3>
92
- <div className="h-80">
93
- <ResponsiveContainer width="100%" height="100%">
94
- <BarChart data={classAvgData}>
95
- <CartesianGrid strokeDasharray="3 3" vertical={false} />
96
- <XAxis dataKey="name" tick={{ fontSize: 12 }} interval={0} />
97
- <YAxis domain={[0, 100]} />
98
- <Tooltip cursor={{ fill: '#f3f4f6' }} />
99
- <Bar dataKey="avg" name="平均分" fill="#3b82f6" radius={[4, 4, 0, 0]} barSize={40} />
100
- </BarChart>
101
- </ResponsiveContainer>
102
- </div>
103
- </div>
104
 
105
- {/* Subject Performance */}
106
- <div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100">
107
- <h3 className="text-lg font-bold text-gray-800 mb-4">全校学科平均分概览</h3>
108
- <div className="h-80">
109
- <ResponsiveContainer width="100%" height="100%">
110
- <BarChart data={subjectAvgData}>
111
- <CartesianGrid strokeDasharray="3 3" vertical={false} />
112
- <XAxis dataKey="name" />
113
- <YAxis domain={[0, 100]} />
114
- <Tooltip cursor={{ fill: '#f3f4f6' }} />
115
- <Bar dataKey="avg" name="平均分" fill="#10b981" radius={[4, 4, 0, 0]} barSize={40} />
116
- </BarChart>
117
- </ResponsiveContainer>
118
- </div>
119
- </div>
120
- </div>
121
-
122
- {scores.length === 0 && (
123
- <div className="text-center py-10 text-gray-400 bg-gray-50 rounded-lg border border-dashed border-gray-300">
124
- 暂无足够的成绩数据来生成详细图表,请先录入成绩。
125
- </div>
126
- )}
127
  </div>
128
  );
129
  };
 
1
 
2
  import React, { useState, useEffect } from 'react';
3
+ import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, RadarChart, PolarGrid, PolarAngleAxis, PolarRadiusAxis, Radar, PieChart, Pie, Cell, Legend } from 'recharts';
4
+ import { Loader2, Download } from 'lucide-react';
5
  import { api } from '../services/api';
6
+ import { Score, Subject } from '../types';
7
 
8
  export const Reports: React.FC = () => {
9
  const [loading, setLoading] = useState(true);
10
  const [scores, setScores] = useState<Score[]>([]);
11
+ const [subjects, setSubjects] = useState<Subject[]>([]);
 
12
 
13
+ // Chart Data
14
+ const [radarData, setRadarData] = useState<any[]>([]);
15
+ const [pieData, setPieData] = useState<any[]>([]);
16
 
17
  useEffect(() => {
18
+ const load = async () => {
19
  try {
20
+ const [sc, su] = await Promise.all([api.scores.getAll(), api.subjects.getAll()]);
21
+ setScores(sc);
22
+ setSubjects(su);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
 
24
+ // 1. Radar Data (Avg Score by Subject)
25
+ const rData = su.map(sub => {
26
+ const subScores = sc.filter(s => s.courseName === sub.name);
27
+ const total = subScores.reduce((sum, s) => sum + s.score, 0);
28
+ const avg = subScores.length ? Math.round(total / subScores.length) : 0;
29
+ return { subject: sub.name, A: avg, fullMark: 100 };
 
 
 
30
  });
31
+ setRadarData(rData);
32
 
33
+ // 2. Pie Data (Score Distribution: Excellent, Good, Pass, Fail)
34
+ let excellent = 0, good = 0, pass = 0, fail = 0;
35
+ sc.forEach(s => {
36
+ if (s.score >= 90) excellent++;
37
+ else if (s.score >= 80) good++;
38
+ else if (s.score >= 60) pass++;
39
+ else fail++;
 
40
  });
41
+ setPieData([
42
+ { name: '优秀 (90-100)', value: excellent, color: '#10b981' },
43
+ { name: '良好 (80-89)', value: good, color: '#3b82f6' },
44
+ { name: '及格 (60-79)', value: pass, color: '#f59e0b' },
45
+ { name: '不及格 (<60)', value: fail, color: '#ef4444' }
46
+ ]);
47
 
48
+ } catch (e) { console.error(e); }
49
+ finally { setLoading(false); }
 
 
 
50
  };
51
+ load();
52
  }, []);
53
 
54
+ if (loading) return <div className="flex justify-center py-20"><Loader2 className="animate-spin text-blue-600" /></div>;
55
 
56
  return (
57
  <div className="space-y-6">
58
+ <div className="bg-white p-5 rounded-xl border flex justify-between items-center shadow-sm">
59
+ <div>
60
+ <h2 className="text-xl font-bold">全校学情分析报表</h2>
61
+ <p className="text-sm text-gray-500">基于 {scores.length} 条成绩样本</p>
62
+ </div>
63
+ <button className="bg-gray-100 px-4 py-2 rounded text-sm flex items-center gap-2"><Download size={16}/> 导出PDF</button>
64
+ </div>
 
 
 
 
65
 
66
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
67
+ {/* Radar Chart */}
68
+ <div className="bg-white p-6 rounded-xl border shadow-sm h-96">
69
+ <h3 className="font-bold mb-4 text-gray-800">学科能力模型 (平均分)</h3>
70
+ <ResponsiveContainer width="100%" height="100%">
71
+ <RadarChart cx="50%" cy="50%" outerRadius="80%" data={radarData}>
72
+ <PolarGrid />
73
+ <PolarAngleAxis dataKey="subject" />
74
+ <PolarRadiusAxis angle={30} domain={[0, 100]} />
75
+ <Radar name="平均分" dataKey="A" stroke="#8884d8" fill="#8884d8" fillOpacity={0.6} />
76
+ <Tooltip />
77
+ </RadarChart>
78
+ </ResponsiveContainer>
79
+ </div>
 
 
80
 
81
+ {/* Pie Chart */}
82
+ <div className="bg-white p-6 rounded-xl border shadow-sm h-96">
83
+ <h3 className="font-bold mb-4 text-gray-800">成绩等级分布</h3>
84
+ <ResponsiveContainer width="100%" height="100%">
85
+ <PieChart>
86
+ <Pie data={pieData} cx="50%" cy="50%" innerRadius={60} outerRadius={100} paddingAngle={5} dataKey="value">
87
+ {pieData.map((entry, index) => (
88
+ <Cell key={`cell-${index}`} fill={entry.color} />
89
+ ))}
90
+ </Pie>
91
+ <Tooltip />
92
+ <Legend />
93
+ </PieChart>
94
+ </ResponsiveContainer>
95
+ </div>
96
+ </div>
 
 
 
 
 
 
97
  </div>
98
  );
99
  };
pages/ScoreList.tsx CHANGED
@@ -1,368 +1,162 @@
1
 
2
  import React, { useState, useEffect } from 'react';
3
- import { Search, Filter, Upload, Loader2, BookOpen, Award, Plus, X, Trash2 } from 'lucide-react';
4
  import { api } from '../services/api';
5
- import { Score, Student } from '../types';
 
6
 
7
  export const ScoreList: React.FC = () => {
8
- const [activeSubject, setActiveSubject] = useState('语文'); // Main Tab
9
- const [selectedGrade, setSelectedGrade] = useState('六年级'); // Default Grade
10
- const [selectedClass, setSelectedClass] = useState('(1)班'); // Default Class
11
-
12
  const [scores, setScores] = useState<Score[]>([]);
13
- const [students, setStudents] = useState<Student[]>([]); // Need student info for class mapping
14
  const [loading, setLoading] = useState(true);
15
 
16
- // Add Score Modal State
17
- const [isAddModalOpen, setIsAddModalOpen] = useState(false);
18
- const [submitting, setSubmitting] = useState(false);
19
- const [formData, setFormData] = useState({
20
- studentId: '', // To find Name and No
21
- courseName: '语文',
22
- score: '',
23
- semester: '2023-2024学年 第一学期',
24
- type: 'Final'
25
- });
26
-
27
- const subjects = ['语文', '数学', '英语', '科学'];
28
- const grades = ['一年级', '二年级', '三年级', '四年级', '五年级', '六年级'];
29
- const classes = ['(1)班', '(2)班', '(3)班', '(4)班'];
30
 
31
  const loadData = async () => {
32
  setLoading(true);
33
  try {
34
- const [scoresData, studentsData] = await Promise.all([
35
- api.scores.getAll(),
36
- api.students.getAll()
 
37
  ]);
38
- setScores(scoresData);
39
- setStudents(studentsData);
40
- } catch (error) {
41
- console.error(error);
42
- } finally {
43
- setLoading(false);
44
- }
45
  };
46
 
47
- useEffect(() => {
48
- loadData();
49
- }, []);
50
-
51
- const handleAddSubmit = async (e: React.FormEvent) => {
52
- e.preventDefault();
53
- setSubmitting(true);
54
- try {
55
- const selectedStudent = students.find(s => (s._id || s.id) == formData.studentId);
56
- if (!selectedStudent) {
57
- alert('请选择有效的学生');
58
- setSubmitting(false);
59
- return;
60
- }
61
-
62
- await api.scores.add({
63
- studentName: selectedStudent.name,
64
- studentNo: selectedStudent.studentNo,
65
- courseName: formData.courseName,
66
- score: Number(formData.score),
67
- semester: formData.semester,
68
- type: formData.type
69
- });
70
 
71
- setIsAddModalOpen(false);
72
- // Reset form (keep semester)
73
- setFormData(prev => ({ ...prev, studentId: '', score: '' }));
74
- // Refresh data
75
- loadData();
76
- } catch (err) {
77
- console.error(err);
78
- alert('保存失败');
79
- } finally {
80
- setSubmitting(false);
81
- }
82
- };
83
 
84
- const handleDelete = async (id: number | string) => {
85
- if (confirm('确定要删除这条成绩记录吗?')) {
86
- await api.scores.delete(id);
 
87
  loadData();
88
  }
89
  };
90
 
91
- const getScoreColor = (score: number) => {
92
- if (score >= 95) return 'text-emerald-700 bg-emerald-100 ring-emerald-600/20'; // A+
93
- if (score >= 90) return 'text-green-700 bg-green-100 ring-green-600/20'; // A
94
- if (score >= 80) return 'text-blue-700 bg-blue-100 ring-blue-600/20'; // B
95
- if (score >= 60) return 'text-yellow-700 bg-yellow-100 ring-yellow-600/20'; // C
96
- return 'text-red-700 bg-red-100 ring-red-600/20'; // F
 
 
 
 
 
 
 
 
97
  };
98
 
99
- const translateExamType = (type: string) => {
100
- switch (type) {
101
- case 'Midterm': return '期中考试';
102
- case 'Final': return '期末考试';
103
- case 'Quiz': return '单元测试';
104
- default: return type;
105
- }
106
  };
107
 
108
- // Logic: Join Score with Student to get ClassName, then Filter
109
- const filteredScores = scores.filter(score => {
110
- // 1. Filter by Subject Tab
111
- if (score.courseName !== activeSubject) return false;
112
-
113
- // 2. Find Student info for this score
114
- const student = students.find(s => s.studentNo === score.studentNo);
115
-
116
- // 3. Filter by Grade and Class
117
- if (!student) return false; // Orphan score
118
-
119
- const targetClassName = selectedGrade + selectedClass;
120
- return student.className === targetClassName;
121
- });
122
-
123
- // Calculate Average for current view
124
- const currentAvg = filteredScores.length > 0
125
- ? (filteredScores.reduce((acc, curr) => acc + curr.score, 0) / filteredScores.length).toFixed(1)
126
- : '0';
127
-
128
  return (
129
  <div className="space-y-6">
130
- <div className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden relative">
131
-
132
- {/* Loading Overlay */}
133
- {loading && (
134
- <div className="absolute inset-0 bg-white/50 z-10 flex items-center justify-center">
135
- <Loader2 className="animate-spin text-blue-600" size={32} />
 
 
 
 
 
 
 
 
 
 
 
136
  </div>
137
- )}
138
 
139
- {/* Header Area */}
140
- <div className="p-6 border-b border-gray-100">
141
- <div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 mb-6">
142
- <div>
143
- <h2 className="text-xl font-bold text-gray-800 flex items-center">
144
- <Award className="mr-2 text-blue-600" />
145
- 成绩录入与管理
146
- </h2>
147
- <p className="text-sm text-gray-500 mt-1">请先选择年级和班级,再查看具体科目成绩</p>
148
- </div>
149
- <div className="flex space-x-3">
150
- <button
151
- onClick={() => setIsAddModalOpen(true)}
152
- 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 transition-colors shadow-sm shadow-blue-200"
153
- >
154
- <Plus size={16} />
155
- <span>手动录入</span>
156
- </button>
157
- <button className="flex items-center space-x-2 px-4 py-2 border border-gray-200 text-gray-700 rounded-lg text-sm font-medium hover:bg-gray-50 transition-colors">
158
- <Upload size={16} />
159
- <span>批量导入</span>
160
- </button>
161
- </div>
162
- </div>
163
-
164
- {/* Filters */}
165
- <div className="flex flex-wrap gap-4 bg-gray-50 p-4 rounded-lg items-center">
166
- <span className="text-sm font-semibold text-gray-600">筛选维度:</span>
167
- <select
168
- className="px-4 py-2 bg-white border border-gray-200 rounded-lg text-sm font-medium focus:ring-2 focus:ring-blue-500 focus:outline-none"
169
- value={selectedGrade}
170
- onChange={(e) => setSelectedGrade(e.target.value)}
171
- >
172
- {grades.map(g => <option key={g} value={g}>{g}</option>)}
173
- </select>
174
- <select
175
- className="px-4 py-2 bg-white border border-gray-200 rounded-lg text-sm font-medium focus:ring-2 focus:ring-blue-500 focus:outline-none"
176
- value={selectedClass}
177
- onChange={(e) => setSelectedClass(e.target.value)}
178
- >
179
- {classes.map(c => <option key={c} value={c}>{c}</option>)}
180
- </select>
181
-
182
- <div className="ml-auto flex items-center text-sm">
183
- <span className="text-gray-500 mr-2">本班{activeSubject}平均分:</span>
184
- <span className="text-lg font-bold text-blue-600">{currentAvg}</span>
185
- </div>
186
- </div>
187
- </div>
188
-
189
- {/* Subject Tabs */}
190
- <div className="flex border-b border-gray-100 px-6">
191
- {subjects.map((subject) => (
192
- <button
193
- key={subject}
194
- onClick={() => setActiveSubject(subject)}
195
- className={`pb-4 pt-4 px-6 text-sm font-medium transition-colors relative ${
196
- activeSubject === subject
197
- ? 'text-blue-600'
198
- : 'text-gray-500 hover:text-gray-700'
199
- }`}
200
- >
201
- {subject}
202
- {activeSubject === subject && (
203
- <div className="absolute bottom-0 left-0 right-0 h-0.5 bg-blue-600 rounded-t-full"></div>
204
- )}
205
- </button>
206
- ))}
207
- </div>
208
 
209
- {/* Table Content */}
210
- <div className="overflow-x-auto min-h-[400px]">
211
- <table className="w-full text-left border-collapse">
212
- <thead>
213
- <tr className="bg-gray-50/50">
214
- <th className="px-6 py-4 text-xs font-semibold text-gray-500 uppercase">学生姓名</th>
215
- <th className="px-6 py-4 text-xs font-semibold text-gray-500 uppercase">学号</th>
216
- <th className="px-6 py-4 text-xs font-semibold text-gray-500 uppercase">考试类型</th>
217
- <th className="px-6 py-4 text-xs font-semibold text-gray-500 uppercase text-center">卷面分数</th>
218
- <th className="px-6 py-4 text-xs font-semibold text-gray-500 uppercase text-right">操作</th>
219
- </tr>
220
- </thead>
221
- <tbody className="divide-y divide-gray-100">
222
- {filteredScores.length === 0 ? (
223
  <tr>
224
- <td colSpan={5} className="py-16 text-center text-gray-400">
225
- <div className="flex flex-col items-center">
226
- <BookOpen size={48} className="mb-4 text-gray-200" />
227
- <p>暂无 {selectedGrade}{selectedClass} 的 {activeSubject} 成绩数据</p>
228
- </div>
229
- </td>
 
 
 
230
  </tr>
231
- ) : (
232
- filteredScores.map((score) => (
233
- <tr key={score._id || score.id} className="hover:bg-gray-50/80 transition-colors group">
234
- <td className="px-6 py-4">
235
- <span className="font-medium text-gray-900">{score.studentName}</span>
236
- </td>
237
- <td className="px-6 py-4 text-sm text-gray-500 font-mono">
238
- {score.studentNo}
239
- </td>
240
- <td className="px-6 py-4 text-sm text-gray-600">
241
- <span className="inline-flex items-center px-2 py-1 rounded bg-gray-100 text-xs font-medium text-gray-700">
242
- {translateExamType(score.type)}
243
- </span>
244
- </td>
245
- <td className="px-6 py-4 text-center">
246
- <span className={`inline-block px-4 py-1.5 rounded-full text-sm font-bold ring-1 ring-inset ${getScoreColor(score.score)}`}>
247
- {score.score}
248
- </span>
249
- </td>
250
- <td className="px-6 py-4 text-right">
251
- <button
252
- onClick={() => handleDelete(score._id || score.id!)}
253
- className="p-1.5 text-red-400 hover:bg-red-50 hover:text-red-600 rounded transition-colors opacity-0 group-hover:opacity-100"
254
- title="删除"
255
- >
256
- <Trash2 size={16} />
257
- </button>
258
- </td>
259
- </tr>
260
- ))
261
- )}
262
- </tbody>
263
  </table>
264
- </div>
265
- </div>
266
-
267
- {/* Add Score Modal */}
268
- {isAddModalOpen && (
269
- <div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 backdrop-blur-sm">
270
- <div className="bg-white rounded-xl shadow-2xl max-w-md w-full overflow-hidden transform transition-all">
271
- <div className="px-6 py-4 border-b border-gray-100 flex justify-between items-center bg-gray-50">
272
- <h3 className="font-bold text-gray-800 text-lg">录入学生成绩</h3>
273
- <button onClick={() => setIsAddModalOpen(false)} className="text-gray-400 hover:text-gray-600">
274
- <X size={20} />
275
- </button>
276
- </div>
277
-
278
- <form onSubmit={handleAddSubmit} className="p-6 space-y-5">
279
- <div className="space-y-1.5">
280
- <label className="text-sm font-semibold text-gray-700">选择学生</label>
281
- <select
282
- required
283
- className="w-full px-3 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none bg-white"
284
- value={formData.studentId}
285
- onChange={(e) => setFormData({...formData, studentId: e.target.value})}
286
- >
287
- <option value="">-- 请选择学生 --</option>
288
- {/* 只显示当前选中班级的学生,方便录入 */}
289
- {students
290
- .filter(s => s.className === selectedGrade + selectedClass)
291
- .map(s => (
292
- <option key={s._id || s.id} value={s._id || s.id}>
293
- {s.name} ({s.studentNo})
294
- </option>
295
- ))}
296
- {students.filter(s => s.className === selectedGrade + selectedClass).length === 0 && (
297
- <option disabled>当前班级无学生</option>
298
- )}
299
- </select>
300
- <p className="text-xs text-gray-400">仅显示 {selectedGrade}{selectedClass} 的学生</p>
301
- </div>
302
-
303
- <div className="grid grid-cols-2 gap-4">
304
- <div className="space-y-1.5">
305
- <label className="text-sm font-semibold text-gray-700">科目</label>
306
- <select
307
- className="w-full px-3 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none"
308
- value={formData.courseName}
309
- onChange={(e) => setFormData({...formData, courseName: e.target.value})}
310
- >
311
- {subjects.map(s => <option key={s} value={s}>{s}</option>)}
312
- </select>
313
- </div>
314
- <div className="space-y-1.5">
315
- <label className="text-sm font-semibold text-gray-700">分数</label>
316
- <input
317
- type="number" min="0" max="100" required
318
- className="w-full px-3 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none"
319
- value={formData.score}
320
- onChange={(e) => setFormData({...formData, score: e.target.value})}
321
- placeholder="0-100"
322
- />
323
- </div>
324
- </div>
325
 
326
- <div className="space-y-1.5">
327
- <label className="text-sm font-semibold text-gray-700">考试类型</label>
328
- <div className="flex space-x-4 pt-1">
329
- <label className="flex items-center space-x-2 cursor-pointer">
330
- <input type="radio" name="type" value="Quiz" checked={formData.type === 'Quiz'} onChange={() => setFormData({...formData, type: 'Quiz'})} className="text-blue-600 focus:ring-blue-500" />
331
- <span className="text-sm text-gray-700">单元测试</span>
332
- </label>
333
- <label className="flex items-center space-x-2 cursor-pointer">
334
- <input type="radio" name="type" value="Midterm" checked={formData.type === 'Midterm'} onChange={() => setFormData({...formData, type: 'Midterm'})} className="text-blue-600 focus:ring-blue-500" />
335
- <span className="text-sm text-gray-700">期中考试</span>
336
- </label>
337
- <label className="flex items-center space-x-2 cursor-pointer">
338
- <input type="radio" name="type" value="Final" checked={formData.type === 'Final'} onChange={() => setFormData({...formData, type: 'Final'})} className="text-blue-600 focus:ring-blue-500" />
339
- <span className="text-sm text-gray-700">期末考试</span>
340
- </label>
341
- </div>
342
- </div>
343
-
344
- <div className="space-y-1.5">
345
- <label className="text-sm font-semibold text-gray-700">所属学期</label>
346
- <input
347
- type="text"
348
- className="w-full px-3 py-2.5 border border-gray-300 rounded-lg bg-gray-50 text-gray-500"
349
- value={formData.semester}
350
- readOnly
351
- />
352
- </div>
353
-
354
- <div className="pt-4 flex space-x-3">
355
- <button type="button" onClick={() => setIsAddModalOpen(false)} className="flex-1 px-4 py-2.5 border border-gray-200 text-gray-700 rounded-lg hover:bg-gray-50 font-medium transition-colors">
356
- 取消
357
- </button>
358
- <button type="submit" disabled={submitting} className="flex-1 px-4 py-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 flex items-center justify-center font-medium shadow-lg shadow-blue-200 transition-colors">
359
- {submitting ? <Loader2 className="animate-spin" size={20} /> : '确认录入'}
360
- </button>
361
- </div>
362
- </form>
363
  </div>
364
- </div>
365
- )}
366
  </div>
367
  );
368
  };
 
1
 
2
  import React, { useState, useEffect } from 'react';
 
3
  import { api } from '../services/api';
4
+ import { Score, Student, Subject } from '../types';
5
+ import { Loader2, Plus, Trash2, Award } from 'lucide-react';
6
 
7
  export const ScoreList: React.FC = () => {
8
+ const [subjects, setSubjects] = useState<Subject[]>([]);
9
+ const [activeSubject, setActiveSubject] = useState('');
 
 
10
  const [scores, setScores] = useState<Score[]>([]);
11
+ const [students, setStudents] = useState<Student[]>([]);
12
  const [loading, setLoading] = useState(true);
13
 
14
+ const [selectedGrade, setSelectedGrade] = useState('六年级');
15
+ const [selectedClass, setSelectedClass] = useState('(1)班');
16
+ const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
17
+
18
+ const [isAddOpen, setIsAddOpen] = useState(false);
19
+ const [formData, setFormData] = useState({ studentId: '', score: '', type: 'Final' });
 
 
 
 
 
 
 
 
20
 
21
  const loadData = async () => {
22
  setLoading(true);
23
  try {
24
+ const [subs, scs, stus] = await Promise.all([
25
+ api.subjects.getAll(),
26
+ api.scores.getAll(),
27
+ api.students.getAll()
28
  ]);
29
+ setSubjects(subs);
30
+ if (subs.length > 0 && !activeSubject) setActiveSubject(subs[0].name);
31
+ setScores(scs);
32
+ setStudents(stus);
33
+ } catch (e) { console.error(e); }
34
+ finally { setLoading(false); }
 
35
  };
36
 
37
+ useEffect(() => { loadData(); }, []);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
 
39
+ const filteredScores = scores.filter(s => {
40
+ if (s.courseName !== activeSubject) return false;
41
+ const stu = students.find(st => st.studentNo === s.studentNo);
42
+ return stu && stu.className === (selectedGrade + selectedClass);
43
+ });
 
 
 
 
 
 
 
44
 
45
+ const handleBatchDelete = async () => {
46
+ if (confirm(`删除 ${selectedIds.size} 条记录?`)) {
47
+ await api.batchDelete('score', Array.from(selectedIds));
48
+ setSelectedIds(new Set());
49
  loadData();
50
  }
51
  };
52
 
53
+ const handleSubmit = async (e: React.FormEvent) => {
54
+ e.preventDefault();
55
+ const stu = students.find(s => (s._id || s.id) == formData.studentId);
56
+ if (!stu) return;
57
+ await api.scores.add({
58
+ studentName: stu.name,
59
+ studentNo: stu.studentNo,
60
+ courseName: activeSubject,
61
+ score: Number(formData.score),
62
+ semester: '2023-Fall',
63
+ type: formData.type
64
+ });
65
+ setIsAddOpen(false);
66
+ loadData();
67
  };
68
 
69
+ const toggleSelect = (id: string) => {
70
+ const newSet = new Set(selectedIds);
71
+ if (newSet.has(id)) newSet.delete(id); else newSet.add(id);
72
+ setSelectedIds(newSet);
 
 
 
73
  };
74
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
  return (
76
  <div className="space-y-6">
77
+ <div className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden min-h-[500px]">
78
+ {loading && <div className="p-10 text-center"><Loader2 className="animate-spin inline"/></div>}
79
+
80
+ <div className="p-6 border-b border-gray-100">
81
+ <div className="flex justify-between items-center mb-4">
82
+ <h2 className="text-xl font-bold flex items-center text-gray-800"><Award className="mr-2 text-blue-600"/>成绩管理</h2>
83
+ <button onClick={() => setIsAddOpen(true)} className="px-4 py-2 bg-blue-600 text-white rounded-lg flex items-center text-sm"><Plus size={16} className="mr-1"/> 录入{activeSubject}</button>
84
+ </div>
85
+
86
+ <div className="flex gap-4 items-center bg-gray-50 p-3 rounded-lg">
87
+ <select className="border p-2 rounded" value={selectedGrade} onChange={e=>setSelectedGrade(e.target.value)}>
88
+ {['一年级','二年级','三年级','四年级','五年级','六年级'].map(g=><option key={g} value={g}>{g}</option>)}
89
+ </select>
90
+ <select className="border p-2 rounded" value={selectedClass} onChange={e=>setSelectedClass(e.target.value)}>
91
+ {['(1)班','(2)班','(3)班'].map(c=><option key={c} value={c}>{c}</option>)}
92
+ </select>
93
+ </div>
94
  </div>
 
95
 
96
+ <div className="flex border-b border-gray-100 px-4 overflow-x-auto">
97
+ {subjects.map(sub => (
98
+ <button
99
+ key={sub._id || sub.id}
100
+ onClick={() => setActiveSubject(sub.name)}
101
+ className={`px-6 py-4 text-sm font-medium border-b-2 transition-colors ${activeSubject === sub.name ? 'border-blue-600 text-blue-600' : 'border-transparent text-gray-500'}`}
102
+ >
103
+ {sub.name}
104
+ </button>
105
+ ))}
106
+ </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
 
108
+ <table className="w-full text-left">
109
+ <thead className="bg-gray-50 text-xs text-gray-500 uppercase">
 
 
 
 
 
 
 
 
 
 
 
 
110
  <tr>
111
+ <th className="px-6 py-3 w-10">
112
+ <input type="checkbox" onChange={e => {
113
+ if(e.target.checked) setSelectedIds(new Set(filteredScores.map(s=>s._id||String(s.id))));
114
+ else setSelectedIds(new Set());
115
+ }} checked={filteredScores.length > 0 && selectedIds.size === filteredScores.length}/>
116
+ </th>
117
+ <th className="px-6 py-3">姓名</th>
118
+ <th className="px-6 py-3">分数</th>
119
+ <th className="px-6 py-3 text-right">操作</th>
120
  </tr>
121
+ </thead>
122
+ <tbody>
123
+ {filteredScores.map(s => (
124
+ <tr key={s._id || s.id} className="hover:bg-gray-50">
125
+ <td className="px-6 py-3"><input type="checkbox" checked={selectedIds.has(s._id||String(s.id))} onChange={()=>toggleSelect(s._id||String(s.id))}/></td>
126
+ <td className="px-6 py-3">{s.studentName}</td>
127
+ <td className="px-6 py-3 font-bold text-blue-600">{s.score}</td>
128
+ <td className="px-6 py-3 text-right"><button onClick={async()=>{await api.scores.delete(s._id||String(s.id));loadData();}} className="text-red-400 hover:text-red-600"><Trash2 size={16}/></button></td>
129
+ </tr>
130
+ ))}
131
+ </tbody>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
132
  </table>
133
+ </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
134
 
135
+ {selectedIds.size > 0 && (
136
+ <div className="fixed bottom-6 right-6 bg-white shadow-lg p-4 rounded-xl border flex items-center gap-4 animate-in slide-in-from-bottom-5">
137
+ <span>已选 {selectedIds.size} 项</span>
138
+ <button onClick={handleBatchDelete} className="bg-red-600 text-white px-4 py-2 rounded-lg text-sm">批量删除</button>
139
+ </div>
140
+ )}
141
+
142
+ {isAddOpen && (
143
+ <div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
144
+ <div className="bg-white rounded-xl p-6 w-full max-w-sm">
145
+ <h3 className="font-bold mb-4">录入 {activeSubject} 成绩</h3>
146
+ <form onSubmit={handleSubmit} className="space-y-4">
147
+ <select className="w-full border p-2 rounded" value={formData.studentId} onChange={e=>setFormData({...formData, studentId:e.target.value})} required>
148
+ <option value="">选择学生</option>
149
+ {students.filter(s=>s.className===selectedGrade+selectedClass).map(s=><option key={s._id||s.id} value={s._id||s.id}>{s.name}</option>)}
150
+ </select>
151
+ <input type="number" className="w-full border p-2 rounded" placeholder="分数" value={formData.score} onChange={e=>setFormData({...formData, score:e.target.value})} required/>
152
+ <div className="flex gap-2">
153
+ <button type="submit" className="flex-1 bg-blue-600 text-white py-2 rounded">保存</button>
154
+ <button type="button" onClick={()=>setIsAddOpen(false)} className="flex-1 border py-2 rounded">取消</button>
155
+ </div>
156
+ </form>
157
+ </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
158
  </div>
159
+ )}
 
160
  </div>
161
  );
162
  };
pages/StudentList.tsx CHANGED
@@ -1,6 +1,6 @@
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
 
@@ -12,6 +12,7 @@ export const StudentList: React.FC = () => {
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');
@@ -40,354 +41,194 @@ export const StudentList: React.FC = () => {
40
  setStudents(studentData);
41
  setClassList(classData);
42
  } catch (error) {
43
- console.error('Failed to load students', error);
44
  } finally {
45
  setLoading(false);
46
  }
47
  };
48
 
49
- useEffect(() => {
50
- loadData();
51
- }, []);
52
 
53
- const handleDelete = async (id: number | string) => {
54
- if (confirm('确定要删除这名学生吗?此操作无法撤销。')) {
55
  await api.students.delete(id);
56
  loadData();
57
  }
58
  };
59
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  const handleAddSubmit = async (e: React.FormEvent) => {
61
  e.preventDefault();
62
  setSubmitting(true);
63
  try {
64
  await api.students.add({
65
  ...formData,
66
- birthday: '2015-01-01', // Default
67
- status: 'Enrolled' as any,
68
  gender: formData.gender as any
69
  });
70
  setIsModalOpen(false);
71
- setFormData({ name: '', studentNo: '', gender: 'Male', className: '', phone: '', idCard: '' });
72
  loadData();
73
- } catch (error) {
74
- console.error(error);
75
- alert('添加失败,请检查网络或学号是否重复');
76
- } finally {
77
- setSubmitting(false);
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 =
121
- s.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
122
- s.studentNo.includes(searchTerm);
123
-
124
  const matchesGrade = selectedGrade === 'All' || s.className.includes(selectedGrade);
125
  const matchesClass = selectedClass === 'All' || s.className.includes(selectedClass);
126
-
127
  return matchesSearch && matchesGrade && matchesClass;
128
  });
129
 
130
- const translateGender = (gender: string) => {
131
- return gender === 'Male' ? '男' : '女';
132
- };
133
-
134
- const translateStatus = (status: string) => {
135
- switch (status) {
136
- case 'Enrolled': return '在读';
137
- case 'Graduated': return '毕业';
138
- case 'Suspended': return '休学';
139
- default: return status;
140
- }
141
- };
142
 
143
  return (
144
  <div className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden relative min-h-[600px] flex flex-col">
145
-
146
- {/* Loading Overlay */}
147
- {loading && (
148
- <div className="absolute inset-0 bg-white/50 z-10 flex items-center justify-center">
149
- <Loader2 className="animate-spin text-blue-600" size={32} />
150
- </div>
151
- )}
152
 
153
- {/* Toolbar */}
154
  <div className="p-6 border-b border-gray-100 space-y-4">
155
- <div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
156
  <div>
157
- <h2 className="text-lg font-bold text-gray-800">学生档案管理</h2>
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)}
170
- className="flex items-center justify-center space-x-2 px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 transition-colors shadow-sm shadow-blue-200"
171
- >
172
- <Plus size={16} />
173
- <span>新增学生</span>
174
- </button>
175
  </div>
176
  </div>
177
 
178
- {/* Filters Row */}
179
- <div className="flex flex-col md:flex-row gap-4 bg-gray-50 p-4 rounded-lg">
180
  <div className="relative flex-1">
181
- <input
182
- type="text"
183
- placeholder="搜索姓名或学号..."
184
- value={searchTerm}
185
- onChange={(e) => setSearchTerm(e.target.value)}
186
- className="w-full pl-10 pr-4 py-2 bg-white border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
187
- />
188
  <Search className="absolute left-3 top-2.5 text-gray-400" size={16} />
189
  </div>
190
-
191
- <div className="flex gap-4">
192
- <div className="w-40">
193
- <select
194
- className="w-full px-3 py-2 bg-white border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
195
- value={selectedGrade}
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">
203
- <select
204
- className="w-full px-3 py-2 bg-white border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
205
- value={selectedClass}
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>
213
  </div>
214
  </div>
215
 
216
- {/* Table */}
217
- <div className="flex-1 overflow-x-auto">
218
- <table className="w-full text-left border-collapse">
219
- <thead>
220
- <tr className="bg-white border-b border-gray-100 text-xs font-semibold text-gray-500 uppercase tracking-wider sticky top-0">
221
- <th className="px-6 py-4 bg-gray-50/50">基本信息</th>
222
- <th className="px-6 py-4 bg-gray-50/50">学号</th>
223
- <th className="px-6 py-4 bg-gray-50/50">所属班级</th>
224
- <th className="px-6 py-4 bg-gray-50/50">家长联系电话</th>
225
- <th className="px-6 py-4 bg-gray-50/50">学籍状态</th>
226
- <th className="px-6 py-4 bg-gray-50/50 text-right">操作</th>
 
 
 
227
  </tr>
228
  </thead>
229
  <tbody className="divide-y divide-gray-100">
230
- {filteredStudents.length === 0 && !loading ? (
231
- <tr><td colSpan={6} className="text-center py-20 text-gray-400 flex flex-col items-center justify-center w-full">
232
- <User size={48} className="mb-4 text-gray-200" />
233
- 没有找到符合条件的学生
234
- </td></tr>
235
- ) : (
236
- filteredStudents.map((student) => (
237
- <tr key={student._id || student.id} className="hover:bg-blue-50/30 transition-colors group">
238
- <td className="px-6 py-4">
239
- <div className="flex items-center space-x-3">
240
- <div className={`h-9 w-9 rounded-full flex items-center justify-center text-xs font-bold ${
241
- student.gender === 'Female' ? 'bg-pink-100 text-pink-600' : 'bg-blue-100 text-blue-600'
242
- }`}>
243
- {student.name.charAt(0)}
244
- </div>
245
- <div>
246
- <p className="text-sm font-bold text-gray-800">{student.name}</p>
247
- <p className="text-xs text-gray-500">{translateGender(student.gender)} | 2012年生</p>
248
- </div>
249
- </div>
250
- </td>
251
- <td className="px-6 py-4 text-sm text-gray-600 font-mono">{student.studentNo}</td>
252
- <td className="px-6 py-4">
253
- <span className="inline-flex items-center px-2.5 py-0.5 rounded-md text-xs font-medium bg-gray-100 text-gray-800 border border-gray-200">
254
- {student.className}
255
- </span>
256
- </td>
257
- <td className="px-6 py-4 text-sm text-gray-600 font-mono tracking-wide">{student.phone}</td>
258
- <td className="px-6 py-4">
259
- <span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
260
- student.status === 'Enrolled' ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'
261
- }`}>
262
- {translateStatus(student.status)}
263
- </span>
264
- </td>
265
- <td className="px-6 py-4 text-right">
266
- <div className="flex items-center justify-end space-x-2 opacity-0 group-hover:opacity-100 transition-opacity">
267
- <button className="p-1.5 text-blue-600 hover:bg-blue-50 rounded-md transition-colors" title="编辑">
268
- <Edit size={16} />
269
- </button>
270
- <button
271
- onClick={() => handleDelete((student._id || student.id)!)}
272
- className="p-1.5 text-red-600 hover:bg-red-50 rounded-md transition-colors" title="删除"
273
- >
274
- <Trash2 size={16} />
275
- </button>
276
- </div>
277
- </td>
278
- </tr>
279
- ))
280
- )}
281
  </tbody>
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">
315
- <div className="bg-white rounded-xl shadow-2xl max-w-lg w-full overflow-hidden transform transition-all">
316
- <div className="px-6 py-4 border-b border-gray-100 flex justify-between items-center bg-gray-50">
317
- <h3 className="font-bold text-gray-800 text-lg">新增学生档案</h3>
318
- <button onClick={() => setIsModalOpen(false)} className="text-gray-400 hover:text-gray-600">
319
- <X size={20} />
320
- </button>
321
- </div>
322
-
323
- <form onSubmit={handleAddSubmit} className="p-6 space-y-5">
324
- <div className="grid grid-cols-2 gap-5">
325
- <div className="space-y-1.5">
326
- <label className="text-sm font-semibold text-gray-700">姓名</label>
327
- <input required type="text" className="w-full px-3 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none transition-all"
328
- value={formData.name} onChange={e => setFormData({...formData, name: e.target.value})}
329
- placeholder="请输入姓名"
330
- />
331
- </div>
332
- <div className="space-y-1.5">
333
- <label className="text-sm font-semibold text-gray-700">学号</label>
334
- <input required type="text" className="w-full px-3 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none transition-all"
335
- value={formData.studentNo} onChange={e => setFormData({...formData, studentNo: e.target.value})}
336
- placeholder="例如: 2024001"
337
- />
338
- </div>
339
- </div>
340
-
341
- <div className="space-y-1.5">
342
- <label className="text-sm font-semibold text-gray-700">性别</label>
343
- <div className="flex space-x-6 mt-1 p-1">
344
- <label className="flex items-center space-x-2 cursor-pointer group">
345
- <div className={`w-5 h-5 rounded-full border flex items-center justify-center ${formData.gender === 'Male' ? 'border-blue-600' : 'border-gray-300'}`}>
346
- {formData.gender === 'Male' && <div className="w-3 h-3 rounded-full bg-blue-600" />}
347
- </div>
348
- <input type="radio" className="hidden" name="gender" value="Male" checked={formData.gender === 'Male'} onChange={() => setFormData({...formData, gender: 'Male'})} />
349
- <span className="text-sm text-gray-700 group-hover:text-blue-600">男</span>
350
- </label>
351
- <label className="flex items-center space-x-2 cursor-pointer group">
352
- <div className={`w-5 h-5 rounded-full border flex items-center justify-center ${formData.gender === 'Female' ? 'border-pink-600' : 'border-gray-300'}`}>
353
- {formData.gender === 'Female' && <div className="w-3 h-3 rounded-full bg-pink-600" />}
354
- </div>
355
- <input type="radio" className="hidden" name="gender" value="Female" checked={formData.gender === 'Female'} onChange={() => setFormData({...formData, gender: 'Female'})} />
356
- <span className="text-sm text-gray-700 group-hover:text-pink-600">女</span>
357
- </label>
358
- </div>
359
- </div>
360
-
361
- <div className="space-y-1.5">
362
- <label className="text-sm font-semibold text-gray-700">班级</label>
363
- <select className="w-full px-3 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none bg-white"
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>
372
-
373
- <div className="space-y-1.5">
374
- <label className="text-sm font-semibold text-gray-700">家长联系电话</label>
375
- <input required type="tel" className="w-full px-3 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none"
376
- value={formData.phone} onChange={e => setFormData({...formData, phone: e.target.value})}
377
- placeholder="11位手机号码"
378
- />
379
- </div>
380
 
381
- <div className="pt-6 flex space-x-3">
382
- <button type="button" onClick={() => setIsModalOpen(false)} className="flex-1 px-4 py-2.5 border border-gray-200 text-gray-700 rounded-lg hover:bg-gray-50 font-medium transition-colors">
383
- 取消
384
- </button>
385
- <button type="submit" disabled={submitting} className="flex-1 px-4 py-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 flex items-center justify-center font-medium shadow-lg shadow-blue-200 transition-colors">
386
- {submitting ? <Loader2 className="animate-spin" size={20} /> : '保存档案'}
387
- </button>
 
388
  </div>
389
- </form>
390
- </div>
391
  </div>
392
  )}
393
  </div>
 
1
 
2
  import React, { useState, useEffect } from 'react';
3
+ import { Search, Plus, Upload, Edit, Trash2, X, Loader2, User } from 'lucide-react';
4
  import { api } from '../services/api';
5
  import { Student, ClassInfo } from '../types';
6
 
 
12
  const [isModalOpen, setIsModalOpen] = useState(false);
13
  const [isImportOpen, setIsImportOpen] = useState(false);
14
  const [submitting, setSubmitting] = useState(false);
15
+ const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
16
 
17
  // Filters
18
  const [selectedGrade, setSelectedGrade] = useState('All');
 
41
  setStudents(studentData);
42
  setClassList(classData);
43
  } catch (error) {
44
+ console.error(error);
45
  } finally {
46
  setLoading(false);
47
  }
48
  };
49
 
50
+ useEffect(() => { loadData(); }, []);
 
 
51
 
52
+ const handleDelete = async (id: string) => {
53
+ if (confirm('确定删除?')) {
54
  await api.students.delete(id);
55
  loadData();
56
  }
57
  };
58
 
59
+ const handleBatchDelete = async () => {
60
+ if (selectedIds.size === 0) return;
61
+ if (confirm(`确定要删除选中的 ${selectedIds.size} 名学生吗?`)) {
62
+ await api.batchDelete('student', Array.from(selectedIds));
63
+ setSelectedIds(new Set());
64
+ loadData();
65
+ }
66
+ };
67
+
68
+ const toggleSelect = (id: string) => {
69
+ const newSet = new Set(selectedIds);
70
+ if (newSet.has(id)) newSet.delete(id);
71
+ else newSet.add(id);
72
+ setSelectedIds(newSet);
73
+ };
74
+
75
+ const toggleSelectAll = (filtered: Student[]) => {
76
+ if (selectedIds.size === filtered.length && filtered.length > 0) {
77
+ setSelectedIds(new Set());
78
+ } else {
79
+ setSelectedIds(new Set(filtered.map(s => s._id || String(s.id))));
80
+ }
81
+ };
82
+
83
  const handleAddSubmit = async (e: React.FormEvent) => {
84
  e.preventDefault();
85
  setSubmitting(true);
86
  try {
87
  await api.students.add({
88
  ...formData,
89
+ birthday: '2015-01-01',
90
+ status: 'Enrolled',
91
  gender: formData.gender as any
92
  });
93
  setIsModalOpen(false);
 
94
  loadData();
95
+ } catch (error) { alert('添加失败'); }
96
+ finally { setSubmitting(false); }
 
 
 
 
97
  };
98
 
99
  const handleImport = async () => {
100
  if (!importText.trim()) return;
101
  setSubmitting(true);
 
102
  const lines = importText.trim().split('\n');
 
 
103
  for (const line of lines) {
104
  const parts = line.split(/[,\t\s]+/).filter(Boolean);
105
+ if (parts.length >= 2) {
106
  try {
 
 
107
  await api.students.add({
108
+ name: parts[0],
109
+ studentNo: parts[1],
110
+ gender: (parts[2] === '女' ? 'Female' : 'Male'),
111
+ className: parts[3] || '未分配',
112
+ phone: parts[4] || '无',
113
  birthday: '2015-01-01',
114
  status: 'Enrolled'
115
  });
116
+ } catch (e) {}
 
117
  }
118
  }
 
 
119
  setIsImportOpen(false);
 
120
  setSubmitting(false);
121
  loadData();
122
  };
123
 
 
 
 
 
124
  const filteredStudents = students.filter((s) => {
125
+ const matchesSearch = s.name.toLowerCase().includes(searchTerm.toLowerCase()) || s.studentNo.includes(searchTerm);
 
 
 
126
  const matchesGrade = selectedGrade === 'All' || s.className.includes(selectedGrade);
127
  const matchesClass = selectedClass === 'All' || s.className.includes(selectedClass);
 
128
  return matchesSearch && matchesGrade && matchesClass;
129
  });
130
 
131
+ const uniqueGrades = Array.from(new Set(classList.map(c => c.grade)));
132
+ const uniqueClasses = Array.from(new Set(classList.map(c => c.className)));
 
 
 
 
 
 
 
 
 
 
133
 
134
  return (
135
  <div className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden relative min-h-[600px] flex flex-col">
136
+ {loading && <div className="absolute inset-0 bg-white/50 z-10 flex items-center justify-center"><Loader2 className="animate-spin text-blue-600" /></div>}
 
 
 
 
 
 
137
 
 
138
  <div className="p-6 border-b border-gray-100 space-y-4">
139
+ <div className="flex justify-between items-center">
140
  <div>
141
+ <h2 className="text-lg font-bold text-gray-800">学生档案</h2>
142
+ <p className="text-sm text-gray-500">选中 {selectedIds.size} / {filteredStudents.length} 人</p>
143
  </div>
144
+ <div className="flex space-x-2">
145
+ <button onClick={() => setIsImportOpen(true)} className="btn-secondary flex items-center space-x-2 px-3 py-2 border rounded-lg text-sm"><Upload size={16}/><span>导入</span></button>
146
+ <button onClick={() => setIsModalOpen(true)} className="btn-primary flex items-center space-x-2 px-3 py-2 bg-blue-600 text-white rounded-lg text-sm"><Plus size={16}/><span>新增</span></button>
 
 
 
 
 
 
 
 
 
 
 
 
147
  </div>
148
  </div>
149
 
150
+ <div className="flex gap-4 bg-gray-50 p-3 rounded-lg">
 
151
  <div className="relative flex-1">
152
+ <input type="text" placeholder="搜索..." value={searchTerm} onChange={e => setSearchTerm(e.target.value)} className="w-full pl-9 pr-3 py-2 border rounded-md text-sm" />
 
 
 
 
 
 
153
  <Search className="absolute left-3 top-2.5 text-gray-400" size={16} />
154
  </div>
155
+ <select className="w-32 border rounded-md text-sm" value={selectedGrade} onChange={e => setSelectedGrade(e.target.value)}><option value="All">年级</option>{uniqueGrades.map(g=><option key={g} value={g}>{g}</option>)}</select>
156
+ <select className="w-32 border rounded-md text-sm" value={selectedClass} onChange={e => setSelectedClass(e.target.value)}><option value="All">班级</option>{uniqueClasses.map(c=><option key={c} value={c}>{c}</option>)}</select>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
157
  </div>
158
  </div>
159
 
160
+ <div className="flex-1 overflow-auto">
161
+ <table className="w-full text-left">
162
+ <thead className="bg-gray-50 text-xs text-gray-500 uppercase sticky top-0">
163
+ <tr>
164
+ <th className="px-6 py-3 w-10">
165
+ <input type="checkbox"
166
+ checked={filteredStudents.length > 0 && selectedIds.size === filteredStudents.length}
167
+ onChange={() => toggleSelectAll(filteredStudents)}
168
+ />
169
+ </th>
170
+ <th className="px-6 py-3">基本信息</th>
171
+ <th className="px-6 py-3">学号</th>
172
+ <th className="px-6 py-3">班级</th>
173
+ <th className="px-6 py-3 text-right">操作</th>
174
  </tr>
175
  </thead>
176
  <tbody className="divide-y divide-gray-100">
177
+ {filteredStudents.map(s => (
178
+ <tr key={s._id || s.id} className="hover:bg-blue-50/30">
179
+ <td className="px-6 py-4">
180
+ <input type="checkbox" checked={selectedIds.has(s._id || String(s.id))} onChange={() => toggleSelect(s._id || String(s.id))} />
181
+ </td>
182
+ <td className="px-6 py-4 flex items-center space-x-3">
183
+ <div className={`w-8 h-8 rounded-full flex items-center justify-center text-xs text-white ${s.gender==='Male'?'bg-blue-400':'bg-pink-400'}`}>{s.name[0]}</div>
184
+ <span className="font-bold text-gray-800">{s.name}</span>
185
+ </td>
186
+ <td className="px-6 py-4 text-sm font-mono text-gray-600">{s.studentNo}</td>
187
+ <td className="px-6 py-4 text-sm">{s.className}</td>
188
+ <td className="px-6 py-4 text-right">
189
+ <button onClick={() => handleDelete(s._id || String(s.id))} className="text-red-400 hover:text-red-600"><Trash2 size={16}/></button>
190
+ </td>
191
+ </tr>
192
+ ))}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
193
  </tbody>
194
  </table>
195
  </div>
196
 
197
+ {selectedIds.size > 0 && (
198
+ <div className="p-4 border-t border-gray-200 bg-white flex justify-between items-center shadow-[0_-4px_6px_-1px_rgba(0,0,0,0.1)]">
199
+ <span className="text-sm font-medium text-gray-700">已选择 {selectedIds.size} 项</span>
200
+ <button onClick={handleBatchDelete} className="px-4 py-2 bg-red-600 text-white text-sm rounded-lg hover:bg-red-700">批量删除</button>
201
+ </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
202
  )}
203
 
 
204
  {isModalOpen && (
205
+ <div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
206
+ <div className="bg-white rounded-xl p-6 w-full max-w-md">
207
+ <h3 className="font-bold text-lg mb-4">新增学生</h3>
208
+ <form onSubmit={handleAddSubmit} className="space-y-4">
209
+ <input className="w-full border p-2 rounded" placeholder="姓名" value={formData.name} onChange={e=>setFormData({...formData, name:e.target.value})} required/>
210
+ <input className="w-full border p-2 rounded" placeholder="学号" value={formData.studentNo} onChange={e=>setFormData({...formData, studentNo:e.target.value})} required/>
211
+ <select className="w-full border p-2 rounded" value={formData.className} onChange={e=>setFormData({...formData, className:e.target.value})} required>
212
+ <option value="">选择班级</option>
213
+ {classList.map(c=><option key={c._id} value={c.grade+c.className}>{c.grade}{c.className}</option>)}
214
+ </select>
215
+ <button type="submit" disabled={submitting} className="w-full bg-blue-600 text-white py-2 rounded">{submitting?'提交中':'保存'}</button>
216
+ <button type="button" onClick={()=>setIsModalOpen(false)} className="w-full border py-2 rounded mt-2">取消</button>
217
+ </form>
218
+ </div>
219
+ </div>
220
+ )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
221
 
222
+ {isImportOpen && (
223
+ <div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
224
+ <div className="bg-white rounded-xl p-6 w-full max-w-lg">
225
+ <h3 className="font-bold mb-2">粘贴导入 (姓名 学号 性别 班级)</h3>
226
+ <textarea className="w-full h-40 border p-2 text-sm" value={importText} onChange={e=>setImportText(e.target.value)}></textarea>
227
+ <div className="flex gap-2 mt-4">
228
+ <button onClick={handleImport} className="flex-1 bg-blue-600 text-white py-2 rounded">确定导入</button>
229
+ <button onClick={()=>setIsImportOpen(false)} className="flex-1 border py-2 rounded">取消</button>
230
  </div>
231
+ </div>
 
232
  </div>
233
  )}
234
  </div>
pages/SubjectList.tsx ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import React, { useState, useEffect } from 'react';
3
+ import { Subject } from '../types';
4
+ import { api } from '../services/api';
5
+ import { Loader2, Plus, Trash2, Palette } from 'lucide-react';
6
+
7
+ export const SubjectList: React.FC = () => {
8
+ const [subjects, setSubjects] = useState<Subject[]>([]);
9
+ const [loading, setLoading] = useState(true);
10
+ const [newSub, setNewSub] = useState({ name: '', code: '', color: '#3b82f6' });
11
+
12
+ const loadSubjects = async () => {
13
+ setLoading(true);
14
+ try { setSubjects(await api.subjects.getAll()); }
15
+ catch(e) { console.error(e); }
16
+ finally { setLoading(false); }
17
+ };
18
+
19
+ useEffect(() => { loadSubjects(); }, []);
20
+
21
+ const handleAdd = async () => {
22
+ if (!newSub.name) return;
23
+ await api.subjects.add(newSub);
24
+ setNewSub({ name: '', code: '', color: '#3b82f6' });
25
+ loadSubjects();
26
+ };
27
+
28
+ const handleDelete = async (id: string) => {
29
+ if(confirm('删除学科可能会影响相关成绩数据,确定继续吗?')) {
30
+ await api.subjects.delete(id);
31
+ loadSubjects();
32
+ }
33
+ };
34
+
35
+ if(loading) return <div className="flex justify-center p-10"><Loader2 className="animate-spin text-blue-600"/></div>;
36
+
37
+ return (
38
+ <div className="space-y-6">
39
+ <div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6">
40
+ <h2 className="text-xl font-bold text-gray-800 mb-6 flex items-center">
41
+ <Palette className="mr-2 text-purple-600" />
42
+ 学科设置
43
+ </h2>
44
+
45
+ {/* Add Form */}
46
+ <div className="flex gap-4 mb-8 bg-gray-50 p-4 rounded-lg items-end">
47
+ <div>
48
+ <label className="text-xs font-bold text-gray-500 uppercase">学科名称</label>
49
+ <input type="text" placeholder="如: 编程" className="w-full mt-1 px-3 py-2 border rounded"
50
+ value={newSub.name} onChange={e => setNewSub({...newSub, name: e.target.value})}
51
+ />
52
+ </div>
53
+ <div>
54
+ <label className="text-xs font-bold text-gray-500 uppercase">代码(选填)</label>
55
+ <input type="text" placeholder="如: CODE" className="w-full mt-1 px-3 py-2 border rounded"
56
+ value={newSub.code} onChange={e => setNewSub({...newSub, code: e.target.value})}
57
+ />
58
+ </div>
59
+ <div>
60
+ <label className="text-xs font-bold text-gray-500 uppercase">代表色</label>
61
+ <input type="color" className="w-full mt-1 h-10 border rounded cursor-pointer"
62
+ value={newSub.color} onChange={e => setNewSub({...newSub, color: e.target.value})}
63
+ />
64
+ </div>
65
+ <button onClick={handleAdd} className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 h-10 flex items-center">
66
+ <Plus size={16} className="mr-1" /> 添加
67
+ </button>
68
+ </div>
69
+
70
+ {/* List */}
71
+ <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
72
+ {subjects.map(sub => (
73
+ <div key={sub._id || sub.id} className="border border-gray-200 rounded-lg p-4 flex justify-between items-center bg-white shadow-sm">
74
+ <div className="flex items-center space-x-3">
75
+ <div className="w-4 h-4 rounded-full" style={{ backgroundColor: sub.color }}></div>
76
+ <div>
77
+ <p className="font-bold text-gray-800">{sub.name}</p>
78
+ <p className="text-xs text-gray-500">{sub.code}</p>
79
+ </div>
80
+ </div>
81
+ <button onClick={() => handleDelete(sub._id || String(sub.id))} className="text-gray-400 hover:text-red-500">
82
+ <Trash2 size={16} />
83
+ </button>
84
+ </div>
85
+ ))}
86
+ </div>
87
+ </div>
88
+ </div>
89
+ );
90
+ };
pages/UserList.tsx ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import React, { useState, useEffect } from 'react';
3
+ import { User, UserRole, UserStatus } from '../types';
4
+ import { api } from '../services/api';
5
+ import { Loader2, Check, X, Shield, Trash2, Edit } from 'lucide-react';
6
+
7
+ export const UserList: React.FC = () => {
8
+ const [users, setUsers] = useState<User[]>([]);
9
+ const [loading, setLoading] = useState(true);
10
+
11
+ const loadUsers = async () => {
12
+ setLoading(true);
13
+ try {
14
+ const data = await api.users.getAll();
15
+ setUsers(data);
16
+ } catch (e) { console.error(e); }
17
+ finally { setLoading(false); }
18
+ };
19
+
20
+ useEffect(() => { loadUsers(); }, []);
21
+
22
+ const handleApprove = async (user: User) => {
23
+ if (confirm(`确认批准 ${user.username} 的注册申请?`)) {
24
+ await api.users.update(user._id || String(user.id), { status: UserStatus.ACTIVE });
25
+ loadUsers();
26
+ }
27
+ };
28
+
29
+ const handleDelete = async (user: User) => {
30
+ if (user.role === UserRole.ADMIN && user.username === 'admin') {
31
+ return alert('无法删除超级管理员');
32
+ }
33
+ if (confirm(`警告:确定要删除用户 ${user.username} 吗?`)) {
34
+ await api.users.delete(user._id || String(user.id));
35
+ loadUsers();
36
+ }
37
+ };
38
+
39
+ const handleRoleChange = async (user: User, newRole: UserRole) => {
40
+ if (user.username === 'admin') return alert('无法修改超级管理员');
41
+ await api.users.update(user._id || String(user.id), { role: newRole });
42
+ loadUsers();
43
+ };
44
+
45
+ const getStatusBadge = (status: UserStatus) => {
46
+ switch(status) {
47
+ case UserStatus.ACTIVE: return <span className="bg-green-100 text-green-700 px-2 py-1 rounded-full text-xs">已激活</span>;
48
+ case UserStatus.PENDING: return <span className="bg-yellow-100 text-yellow-700 px-2 py-1 rounded-full text-xs animate-pulse">待审核</span>;
49
+ case UserStatus.BANNED: return <span className="bg-red-100 text-red-700 px-2 py-1 rounded-full text-xs">已停用</span>;
50
+ }
51
+ };
52
+
53
+ if (loading) return <div className="flex justify-center py-10"><Loader2 className="animate-spin text-blue-600" /></div>;
54
+
55
+ return (
56
+ <div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6">
57
+ <h2 className="text-xl font-bold text-gray-800 mb-4">用户权限与审核</h2>
58
+
59
+ <div className="overflow-x-auto">
60
+ <table className="w-full text-left border-collapse">
61
+ <thead>
62
+ <tr className="bg-gray-50 text-gray-500 text-xs uppercase">
63
+ <th className="px-4 py-3">用户名</th>
64
+ <th className="px-4 py-3">角色</th>
65
+ <th className="px-4 py-3">状态</th>
66
+ <th className="px-4 py-3 text-right">操作</th>
67
+ </tr>
68
+ </thead>
69
+ <tbody className="divide-y divide-gray-100">
70
+ {users.map(user => (
71
+ <tr key={user._id || user.id} className="hover:bg-gray-50">
72
+ <td className="px-4 py-3 font-medium text-gray-800">{user.username}</td>
73
+ <td className="px-4 py-3">
74
+ <select
75
+ value={user.role}
76
+ onChange={(e) => handleRoleChange(user, e.target.value as UserRole)}
77
+ disabled={user.username === 'admin'}
78
+ className="bg-white border border-gray-200 rounded px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
79
+ >
80
+ <option value={UserRole.ADMIN}>管理员</option>
81
+ <option value={UserRole.TEACHER}>教师</option>
82
+ <option value={UserRole.STUDENT}>学生</option>
83
+ </select>
84
+ </td>
85
+ <td className="px-4 py-3">{getStatusBadge(user.status)}</td>
86
+ <td className="px-4 py-3 text-right space-x-2">
87
+ {user.status === UserStatus.PENDING && (
88
+ <button
89
+ onClick={() => handleApprove(user)}
90
+ className="text-green-600 hover:bg-green-50 p-1 rounded" title="通过审核"
91
+ >
92
+ <Check size={18} />
93
+ </button>
94
+ )}
95
+ <button
96
+ onClick={() => handleDelete(user)}
97
+ className="text-red-600 hover:bg-red-50 p-1 rounded" title="删除用户"
98
+ disabled={user.username === 'admin'}
99
+ >
100
+ <Trash2 size={18} />
101
+ </button>
102
+ </td>
103
+ </tr>
104
+ ))}
105
+ </tbody>
106
+ </table>
107
+ </div>
108
+ </div>
109
+ );
110
+ };
server.js CHANGED
@@ -9,32 +9,25 @@ const path = require('path');
9
  // 核心配置 (Hugging Face 必须使用 7860 端口)
10
  // ==========================================
11
  const PORT = 7860;
12
- // 修改点:添加 authSource=admin 参数,这是解决 "bad auth" 的关键
13
  const MONGO_URI = 'mongodb+srv://dv890a:db8822723@chatpro.gw3v0v7.mongodb.net/chatpro?retryWrites=true&w=majority&appName=chatpro&authSource=admin';
14
 
15
  const app = express();
16
 
17
- // 中间件
18
  app.use(cors());
19
  app.use(bodyParser.json());
20
-
21
- // ==========================================
22
- // 1. 托管静态前端文件 (Production Build)
23
- // ==========================================
24
- // 当部署到云端时,Node.js 会负责发送 React 编译好的 HTML/JS/CSS
25
  app.use(express.static(path.join(__dirname, 'dist')));
26
 
27
  // ==========================================
28
- // 2. 数据库连接 (带智能降级)
29
  // ==========================================
30
 
31
- // 内存数据库 (降级方案)
32
  const InMemoryDB = {
33
  users: [],
34
  students: [],
35
  courses: [],
36
  scores: [],
37
  classes: [],
 
38
  config: {
39
  systemName: '智慧校园管理系统',
40
  semester: '2023-2024学年 第一学期',
@@ -45,34 +38,28 @@ const InMemoryDB = {
45
  isFallback: false
46
  };
47
 
48
- // 连接逻辑
49
  const connectDB = async () => {
50
  try {
51
- // 增加连接超时设置,防止网络问题导致挂起
52
- await mongoose.connect(MONGO_URI, {
53
- serverSelectionTimeoutMS: 5000
54
- });
55
  console.log('✅ MongoDB 连接成功 (Real Data)');
56
  } catch (err) {
57
  console.error('❌ MongoDB 连接失败:', err.message);
58
- console.warn('⚠️ 启动内存数据库模式 (数据将在重启后丢失)');
59
  InMemoryDB.isFallback = true;
60
-
61
- // 初始化内存默认用户
62
  InMemoryDB.users.push(
63
- { username: 'admin', password: 'admin', role: 'ADMIN', email: 'admin@school.edu' },
64
- { username: 'teacher', password: 'teacher', role: 'TEACHER', email: 'teacher@school.edu' }
65
  );
66
  }
67
  };
68
  connectDB();
69
 
70
- // Schema 定义 (仅在非降级模式下使用)
71
  const UserSchema = new mongoose.Schema({
72
  username: { type: String, required: true, unique: true },
73
  password: { type: String, required: true },
74
  role: { type: String, enum: ['ADMIN', 'TEACHER', 'STUDENT'], default: 'STUDENT' },
75
- status: { type: String, default: 'active' },
76
  email: String,
77
  avatar: String,
78
  createTime: { type: Date, default: Date.now }
@@ -118,8 +105,15 @@ const ClassSchema = new mongoose.Schema({
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,
@@ -128,273 +122,263 @@ const ConfigSchema = new mongoose.Schema({
128
  });
129
  const ConfigModel = mongoose.model('Config', ConfigSchema);
130
 
131
-
132
- // 初始化数据逻辑 (仅 MongoDB 模式)
133
  const initData = async () => {
134
  if (InMemoryDB.isFallback) return;
135
  try {
 
136
  const userCount = await User.countDocuments();
137
  if (userCount === 0) {
138
- console.log('正在初始化基础数据...');
139
  await User.create([
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
  }
172
  };
173
  mongoose.connection.once('open', initData);
174
 
175
  // ==========================================
176
- // 3. API 路由 (双模支持)
177
  // ==========================================
178
 
179
  // --- Auth ---
180
  app.post('/api/auth/login', async (req, res) => {
181
  const { username, password } = req.body;
182
  try {
 
183
  if (InMemoryDB.isFallback) {
184
- const user = InMemoryDB.users.find(u => u.username === username && u.password === password);
185
- return user ? res.json(user) : res.status(401).json({ message: '用户名或密码错误' });
 
186
  }
 
 
187
 
188
- const user = await User.findOne({ username, password });
189
- if (user) res.json(user);
190
- else res.status(401).json({ message: '用户名或密码错误' });
 
 
 
 
 
 
191
  } catch (e) { res.status(500).json({ error: e.message }); }
192
  });
193
 
194
  app.post('/api/auth/register', async (req, res) => {
195
  const { username, password, role } = req.body;
 
 
 
 
196
  try {
197
  if (InMemoryDB.isFallback) {
198
- if (InMemoryDB.users.find(u => u.username === username)) {
199
- return res.status(400).json({ error: '用户已存在' });
200
- }
201
- const newUser = { id: Date.now(), username, password, role: role || 'STUDENT', status: 'active' };
202
  InMemoryDB.users.push(newUser);
203
  return res.json(newUser);
204
  }
205
-
206
  const existing = await User.findOne({ username });
207
- if (existing) return res.status(400).json({ error: '用户已存在' });
208
 
209
- const newUser = await User.create({ username, password, role: role || 'STUDENT' });
210
  res.json(newUser);
211
  } catch (e) { res.status(500).json({ error: e.message }); }
212
  });
213
 
214
- // --- Students ---
215
- app.get('/api/students', async (req, res) => {
216
- if (InMemoryDB.isFallback) return res.json(InMemoryDB.students);
217
- const students = await Student.find().sort({ studentNo: 1 });
218
- res.json(students);
219
  });
220
- app.post('/api/students', async (req, res) => {
221
  try {
222
  if (InMemoryDB.isFallback) {
223
- const newStudent = { ...req.body, id: Date.now(), _id: Date.now().toString() };
224
- InMemoryDB.students.push(newStudent);
225
- return res.json(newStudent);
226
  }
227
- const newStudent = await Student.create(req.body);
228
- res.json(newStudent);
229
- } catch (e) { res.status(400).json({ error: e.message }); }
230
  });
231
- app.delete('/api/students/:id', async (req, res) => {
232
  try {
233
  if (InMemoryDB.isFallback) {
234
- InMemoryDB.students = InMemoryDB.students.filter(s => s._id !== req.params.id && s.id != req.params.id);
235
  return res.json({ success: true });
236
  }
237
- await Student.findByIdAndDelete(req.params.id);
238
  res.json({ success: true });
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);
296
- const courses = await Course.find();
297
- res.json(courses);
298
  });
299
  app.post('/api/courses', async (req, res) => {
300
- try {
301
- if (InMemoryDB.isFallback) {
302
- const newCourse = { ...req.body, id: Date.now(), _id: Date.now().toString() };
303
- InMemoryDB.courses.push(newCourse);
304
- return res.json(newCourse);
305
- }
306
- const newCourse = await Course.create(req.body);
307
- res.json(newCourse);
308
- } catch (e) { res.status(400).json({ error: e.message }); }
309
  });
310
  app.put('/api/courses/:id', async (req, res) => {
311
- try {
312
- if (InMemoryDB.isFallback) {
313
- const idx = InMemoryDB.courses.findIndex(c => c.id == req.params.id || c._id == req.params.id);
314
- if (idx !== -1) {
315
- InMemoryDB.courses[idx] = { ...InMemoryDB.courses[idx], ...req.body };
316
- return res.json(InMemoryDB.courses[idx]);
317
- }
318
- return res.status(404).json({ error: 'Not found' });
319
- }
320
- const updated = await Course.findByIdAndUpdate(req.params.id, req.body, { new: true });
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) => {
337
  if (InMemoryDB.isFallback) return res.json(InMemoryDB.scores);
338
- const scores = await Score.find();
339
- res.json(scores);
340
  });
341
  app.post('/api/scores', async (req, res) => {
342
- try {
343
- if (InMemoryDB.isFallback) {
344
- const newScore = { ...req.body, id: Date.now(), _id: Date.now().toString() };
345
- InMemoryDB.scores.push(newScore);
346
- return res.json(newScore);
347
- }
348
- const newScore = await Score.create(req.body);
349
- res.json(newScore);
350
- } catch (e) { res.status(400).json({ error: e.message }); }
351
  });
352
  app.delete('/api/scores/:id', async (req, res) => {
353
- try {
354
- if (InMemoryDB.isFallback) {
355
- InMemoryDB.scores = InMemoryDB.scores.filter(s => s._id !== req.params.id && s.id != req.params.id);
356
- return res.json({ success: true });
357
- }
358
- await Score.findByIdAndDelete(req.params.id);
359
- res.json({ success: true });
360
- } catch (e) { res.status(500).json({ error: e.message }); }
361
  });
362
 
363
- // --- Stats ---
 
 
 
 
 
 
 
 
 
 
364
  app.get('/api/stats', async (req, res) => {
365
  try {
366
  if (InMemoryDB.isFallback) {
367
- const studentCount = InMemoryDB.students.length;
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
 
389
- // ==========================================
390
- // 4. 处理前端路由 (Catch-all)
391
- // ==========================================
392
- // 所有未匹配到 API 的请求,都返回 index.html,让 React Router 处理页面跳转
393
  app.get('*', (req, res) => {
394
  res.sendFile(path.join(__dirname, 'dist', 'index.html'));
395
  });
396
 
397
- // 启动服务器
398
- app.listen(PORT, () => {
399
- console.log(`🚀 服务已启动,监听端口: ${PORT}`);
400
- });
 
9
  // 核心配置 (Hugging Face 必须使用 7860 端口)
10
  // ==========================================
11
  const PORT = 7860;
 
12
  const MONGO_URI = 'mongodb+srv://dv890a:db8822723@chatpro.gw3v0v7.mongodb.net/chatpro?retryWrites=true&w=majority&appName=chatpro&authSource=admin';
13
 
14
  const app = express();
15
 
 
16
  app.use(cors());
17
  app.use(bodyParser.json());
 
 
 
 
 
18
  app.use(express.static(path.join(__dirname, 'dist')));
19
 
20
  // ==========================================
21
+ // Database Models
22
  // ==========================================
23
 
 
24
  const InMemoryDB = {
25
  users: [],
26
  students: [],
27
  courses: [],
28
  scores: [],
29
  classes: [],
30
+ subjects: [],
31
  config: {
32
  systemName: '智慧校园管理系统',
33
  semester: '2023-2024学年 第一学期',
 
38
  isFallback: false
39
  };
40
 
 
41
  const connectDB = async () => {
42
  try {
43
+ await mongoose.connect(MONGO_URI, { serverSelectionTimeoutMS: 5000 });
 
 
 
44
  console.log('✅ MongoDB 连接成功 (Real Data)');
45
  } catch (err) {
46
  console.error('❌ MongoDB 连接失败:', err.message);
47
+ console.warn('⚠️ 启动内存数据库模式');
48
  InMemoryDB.isFallback = true;
49
+ // Init Memory Admin
 
50
  InMemoryDB.users.push(
51
+ { username: 'admin', password: 'admin', role: 'ADMIN', status: 'active', email: 'admin@school.edu' }
 
52
  );
53
  }
54
  };
55
  connectDB();
56
 
57
+ // Schemas
58
  const UserSchema = new mongoose.Schema({
59
  username: { type: String, required: true, unique: true },
60
  password: { type: String, required: true },
61
  role: { type: String, enum: ['ADMIN', 'TEACHER', 'STUDENT'], default: 'STUDENT' },
62
+ status: { type: String, enum: ['active', 'pending', 'banned'], default: 'pending' },
63
  email: String,
64
  avatar: String,
65
  createTime: { type: Date, default: Date.now }
 
105
  });
106
  const ClassModel = mongoose.model('Class', ClassSchema);
107
 
108
+ const SubjectSchema = new mongoose.Schema({
109
+ name: { type: String, required: true, unique: true },
110
+ code: String,
111
+ color: String
112
+ });
113
+ const SubjectModel = mongoose.model('Subject', SubjectSchema);
114
+
115
  const ConfigSchema = new mongoose.Schema({
116
+ key: { type: String, default: 'main' },
117
  systemName: String,
118
  semester: String,
119
  allowRegister: Boolean,
 
122
  });
123
  const ConfigModel = mongoose.model('Config', ConfigSchema);
124
 
125
+ // Init Data
 
126
  const initData = async () => {
127
  if (InMemoryDB.isFallback) return;
128
  try {
129
+ // Admin
130
  const userCount = await User.countDocuments();
131
  if (userCount === 0) {
 
132
  await User.create([
133
+ { username: 'admin', password: 'admin', role: 'ADMIN', status: 'active', email: 'admin@school.edu' }
 
134
  ]);
135
  }
136
+ // Subjects
137
+ const subjCount = await SubjectModel.countDocuments();
138
+ if (subjCount === 0) {
139
+ await SubjectModel.create([
140
+ { name: '语文', code: 'CHI', color: '#ef4444' },
141
+ { name: '数学', code: 'MAT', color: '#3b82f6' },
142
+ { name: '英语', code: 'ENG', color: '#f59e0b' },
143
+ { name: '科学', code: 'SCI', color: '#10b981' }
144
+ ]);
145
+ }
146
+ // Config
147
  const config = await ConfigModel.findOne({ key: 'main' });
148
  if (!config) {
149
+ await ConfigModel.create({ key: 'main', systemName: '智慧校园管理系统', semester: '2023-2024学年 第一学期' });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
150
  }
 
 
151
  } catch (err) {
152
+ console.error('Init Data Error', err);
153
  }
154
  };
155
  mongoose.connection.once('open', initData);
156
 
157
  // ==========================================
158
+ // API Routes
159
  // ==========================================
160
 
161
  // --- Auth ---
162
  app.post('/api/auth/login', async (req, res) => {
163
  const { username, password } = req.body;
164
  try {
165
+ let user;
166
  if (InMemoryDB.isFallback) {
167
+ user = InMemoryDB.users.find(u => u.username === username && u.password === password);
168
+ } else {
169
+ user = await User.findOne({ username, password });
170
  }
171
+
172
+ if (!user) return res.status(401).json({ message: '用户名或密码错误' });
173
 
174
+ // Check Status
175
+ if (user.status === 'pending') {
176
+ return res.status(403).json({ error: 'PENDING_APPROVAL', message: '账号待审核,请联系管理员' });
177
+ }
178
+ if (user.status === 'banned') {
179
+ return res.status(403).json({ error: 'BANNED', message: '账号已被停用' });
180
+ }
181
+
182
+ res.json(user);
183
  } catch (e) { res.status(500).json({ error: e.message }); }
184
  });
185
 
186
  app.post('/api/auth/register', async (req, res) => {
187
  const { username, password, role } = req.body;
188
+ // Default status: Admin auto-active (for demo simplicity if needed, but standard is Pending for all)
189
+ // Here we set 'pending' for everyone except the very first user (handled by init)
190
+ const status = 'pending';
191
+
192
  try {
193
  if (InMemoryDB.isFallback) {
194
+ if (InMemoryDB.users.find(u => u.username === username)) return res.status(400).json({ error: 'Existed' });
195
+ const newUser = { id: Date.now(), username, password, role: role || 'STUDENT', status };
 
 
196
  InMemoryDB.users.push(newUser);
197
  return res.json(newUser);
198
  }
199
+
200
  const existing = await User.findOne({ username });
201
+ if (existing) return res.status(400).json({ error: 'Existed' });
202
 
203
+ const newUser = await User.create({ username, password, role: role || 'STUDENT', status });
204
  res.json(newUser);
205
  } catch (e) { res.status(500).json({ error: e.message }); }
206
  });
207
 
208
+ // --- Users (Admin) ---
209
+ app.get('/api/users', async (req, res) => {
210
+ if (InMemoryDB.isFallback) return res.json(InMemoryDB.users);
211
+ const users = await User.find().sort({ createTime: -1 });
212
+ res.json(users);
213
  });
214
+ app.put('/api/users/:id', async (req, res) => {
215
  try {
216
  if (InMemoryDB.isFallback) {
217
+ const idx = InMemoryDB.users.findIndex(u => u._id === req.params.id || u.id == req.params.id);
218
+ if (idx !== -1) InMemoryDB.users[idx] = { ...InMemoryDB.users[idx], ...req.body };
219
+ return res.json({ success: true });
220
  }
221
+ await User.findByIdAndUpdate(req.params.id, req.body);
222
+ res.json({ success: true });
223
+ } catch (e) { res.status(500).json({ error: e.message }); }
224
  });
225
+ app.delete('/api/users/:id', async (req, res) => {
226
  try {
227
  if (InMemoryDB.isFallback) {
228
+ InMemoryDB.users = InMemoryDB.users.filter(u => u._id !== req.params.id && u.id != req.params.id);
229
  return res.json({ success: true });
230
  }
231
+ await User.findByIdAndDelete(req.params.id);
232
  res.json({ success: true });
233
  } catch (e) { res.status(500).json({ error: e.message }); }
234
  });
235
 
236
+ // --- Subjects ---
237
+ app.get('/api/subjects', async (req, res) => {
238
+ if (InMemoryDB.isFallback) return res.json(InMemoryDB.subjects.length ? InMemoryDB.subjects : [{name:'语文',code:'CHI',color:'#ef4444'}]);
239
+ const subs = await SubjectModel.find();
240
+ res.json(subs);
 
 
 
 
 
241
  });
242
+ app.post('/api/subjects', async (req, res) => {
243
  try {
244
  if (InMemoryDB.isFallback) {
245
+ const newSub = { ...req.body, id: Date.now(), _id: Date.now().toString() };
246
+ InMemoryDB.subjects.push(newSub);
247
+ return res.json(newSub);
248
  }
249
+ const newSub = await SubjectModel.create(req.body);
250
+ res.json(newSub);
251
  } catch (e) { res.status(400).json({ error: e.message }); }
252
  });
253
+ app.delete('/api/subjects/:id', async (req, res) => {
254
  try {
255
  if (InMemoryDB.isFallback) {
256
+ InMemoryDB.subjects = InMemoryDB.subjects.filter(s => s._id !== req.params.id);
257
  return res.json({ success: true });
258
  }
259
+ await SubjectModel.findByIdAndDelete(req.params.id);
260
  res.json({ success: true });
261
  } catch (e) { res.status(500).json({ error: e.message }); }
262
  });
263
 
264
+ // --- Batch Operations ---
265
+ app.post('/api/batch-delete', async (req, res) => {
266
+ const { type, ids } = req.body; // type: 'student' | 'score' | 'user'
267
+ if (!ids || !Array.isArray(ids)) return res.status(400).json({ error: 'Invalid IDs' });
268
+
 
 
 
269
  try {
270
  if (InMemoryDB.isFallback) {
271
+ if (type === 'student') InMemoryDB.students = InMemoryDB.students.filter(s => !ids.includes(s._id) && !ids.includes(s.id));
272
+ if (type === 'score') InMemoryDB.scores = InMemoryDB.scores.filter(s => !ids.includes(s._id) && !ids.includes(s.id));
273
+ if (type === 'user') InMemoryDB.users = InMemoryDB.users.filter(u => !ids.includes(u._id) && !ids.includes(u.id));
274
+ return res.json({ success: true });
275
  }
276
+
277
+ if (type === 'student') await Student.deleteMany({ _id: { $in: ids } });
278
+ if (type === 'score') await Score.deleteMany({ _id: { $in: ids } });
279
+ if (type === 'user') await User.deleteMany({ _id: { $in: ids } });
280
+
281
+ res.json({ success: true });
282
+ } catch (e) { res.status(500).json({ error: e.message }); }
283
  });
284
 
285
+ // --- Other Existing Routes (Simplified for brevity, assuming they exist as before) ---
286
+ // Students
287
+ app.get('/api/students', async (req, res) => {
288
+ if (InMemoryDB.isFallback) return res.json(InMemoryDB.students);
289
+ res.json(await Student.find().sort({ studentNo: 1 }));
290
+ });
291
+ app.post('/api/students', async (req, res) => {
292
+ if (InMemoryDB.isFallback) { InMemoryDB.students.push({ ...req.body, id: Date.now(), _id: String(Date.now()) }); return res.json({}); }
293
+ res.json(await Student.create(req.body));
294
+ });
295
+ app.delete('/api/students/:id', async (req, res) => {
296
+ if (InMemoryDB.isFallback) { InMemoryDB.students = InMemoryDB.students.filter(s => s._id != req.params.id); return res.json({}); }
297
+ await Student.findByIdAndDelete(req.params.id); res.json({});
298
+ });
299
+
300
+ // Classes
301
+ app.get('/api/classes', async (req, res) => {
302
+ if (InMemoryDB.isFallback) return res.json(InMemoryDB.classes);
303
+ const classes = await ClassModel.find();
304
+ // Add counts
305
+ const result = await Promise.all(classes.map(async (c) => {
306
+ const count = await Student.countDocuments({ className: c.grade + c.className });
307
+ return { ...c.toObject(), studentCount: count };
308
+ }));
309
+ res.json(result);
310
+ });
311
+ app.post('/api/classes', async (req, res) => {
312
+ if (InMemoryDB.isFallback) { InMemoryDB.classes.push({ ...req.body, id: Date.now(), _id: String(Date.now()) }); return res.json({}); }
313
+ res.json(await ClassModel.create(req.body));
314
+ });
315
+ app.delete('/api/classes/:id', async (req, res) => {
316
+ if (InMemoryDB.isFallback) { InMemoryDB.classes = InMemoryDB.classes.filter(c => c._id != req.params.id); return res.json({}); }
317
+ await ClassModel.findByIdAndDelete(req.params.id); res.json({});
318
+ });
319
+
320
+ // Courses
321
  app.get('/api/courses', async (req, res) => {
322
  if (InMemoryDB.isFallback) return res.json(InMemoryDB.courses);
323
+ res.json(await Course.find());
 
324
  });
325
  app.post('/api/courses', async (req, res) => {
326
+ if (InMemoryDB.isFallback) { InMemoryDB.courses.push({ ...req.body, id: Date.now(), _id: String(Date.now()) }); return res.json({}); }
327
+ res.json(await Course.create(req.body));
 
 
 
 
 
 
 
328
  });
329
  app.put('/api/courses/:id', async (req, res) => {
330
+ if (InMemoryDB.isFallback) return res.json({});
331
+ res.json(await Course.findByIdAndUpdate(req.params.id, req.body));
 
 
 
 
 
 
 
 
 
 
332
  });
333
  app.delete('/api/courses/:id', async (req, res) => {
334
+ if (InMemoryDB.isFallback) { InMemoryDB.courses = InMemoryDB.courses.filter(c => c._id != req.params.id); return res.json({}); }
335
+ await Course.findByIdAndDelete(req.params.id); res.json({});
 
 
 
 
 
 
336
  });
337
 
338
+ // Scores
339
  app.get('/api/scores', async (req, res) => {
340
  if (InMemoryDB.isFallback) return res.json(InMemoryDB.scores);
341
+ res.json(await Score.find());
 
342
  });
343
  app.post('/api/scores', async (req, res) => {
344
+ if (InMemoryDB.isFallback) { InMemoryDB.scores.push({ ...req.body, id: Date.now(), _id: String(Date.now()) }); return res.json({}); }
345
+ res.json(await Score.create(req.body));
 
 
 
 
 
 
 
346
  });
347
  app.delete('/api/scores/:id', async (req, res) => {
348
+ if (InMemoryDB.isFallback) { InMemoryDB.scores = InMemoryDB.scores.filter(s => s._id != req.params.id); return res.json({}); }
349
+ await Score.findByIdAndDelete(req.params.id); res.json({});
 
 
 
 
 
 
350
  });
351
 
352
+ // Config
353
+ app.get('/api/config', async (req, res) => {
354
+ if (InMemoryDB.isFallback) return res.json(InMemoryDB.config);
355
+ res.json(await ConfigModel.findOne({ key: 'main' }) || {});
356
+ });
357
+ app.post('/api/config', async (req, res) => {
358
+ if (InMemoryDB.isFallback) { InMemoryDB.config = req.body; return res.json({}); }
359
+ res.json(await ConfigModel.findOneAndUpdate({ key: 'main' }, req.body, { upsert: true, new: true }));
360
+ });
361
+
362
+ // Stats
363
  app.get('/api/stats', async (req, res) => {
364
  try {
365
  if (InMemoryDB.isFallback) {
366
+ return res.json({ studentCount: InMemoryDB.students.length, courseCount: InMemoryDB.courses.length, avgScore: 0, excellentRate: '0%' });
 
 
 
 
367
  }
368
  const studentCount = await Student.countDocuments();
369
  const courseCount = await Course.countDocuments();
370
  const scores = await Score.find();
 
 
371
  const totalScore = scores.reduce((sum, s) => sum + (s.score || 0), 0);
372
  const avgScore = scores.length > 0 ? (totalScore / scores.length).toFixed(1) : 0;
 
 
373
  const excellentCount = scores.filter(s => s.score >= 90).length;
374
  const excellentRate = scores.length > 0 ? Math.round((excellentCount / scores.length) * 100) + '%' : '0%';
 
375
  res.json({ studentCount, courseCount, avgScore, excellentRate });
376
+ } catch(e) { res.status(500).json({}); }
377
  });
378
 
379
+ // Frontend
 
 
 
380
  app.get('*', (req, res) => {
381
  res.sendFile(path.join(__dirname, 'dist', 'index.html'));
382
  });
383
 
384
+ app.listen(PORT, () => console.log(`🚀 Service running on port ${PORT}`));
 
 
 
services/api.ts CHANGED
@@ -1,40 +1,24 @@
1
 
2
  /// <reference types="vite/client" />
3
- import { User, ClassInfo, SystemConfig } from '../types';
4
-
5
- // ==========================================
6
- // 智能环境配置 (Smart Environment Config)
7
- // ==========================================
8
 
9
  const getBaseUrl = () => {
10
  let isProd = false;
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;
17
  }
18
- } catch (e) {
19
- // Ignore environment check errors
20
- }
21
 
22
- if (isProd) {
23
  return '/api';
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
- }
30
-
31
- // Default to local dev backend URL
32
  return 'http://localhost:7860/api';
33
  };
34
 
35
  const API_BASE_URL = getBaseUrl();
36
 
37
- // 简单封装 Fetch
38
  async function request(endpoint: string, options: RequestInit = {}) {
39
  const res = await fetch(`${API_BASE_URL}${endpoint}`, {
40
  ...options,
@@ -44,42 +28,29 @@ async function request(endpoint: string, options: RequestInit = {}) {
44
  if (!res.ok) {
45
  if (res.status === 401) throw new Error('AUTH_FAILED');
46
  const errorData = await res.json().catch(() => ({}));
47
- throw new Error(errorData.error || `Server Error: ${res.status}`);
 
 
 
 
48
  }
49
  return res.json();
50
  }
51
 
52
  export const api = {
53
- init: () => {
54
- console.log('🔗 API Initialized pointing to:', API_BASE_URL);
55
- },
56
 
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
- }
72
  },
73
  register: async (data: any): Promise<User> => {
74
- return await request('/auth/register', {
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') {
@@ -90,6 +61,12 @@ export const api = {
90
  }
91
  },
92
 
 
 
 
 
 
 
93
  students: {
94
  getAll: () => request('/students'),
95
  add: (data: any) => request('/students', { method: 'POST', body: JSON.stringify(data) }),
@@ -102,6 +79,12 @@ export const api = {
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) }),
@@ -122,5 +105,9 @@ export const api = {
122
  config: {
123
  get: () => request('/config'),
124
  save: (data: SystemConfig) => request('/config', { method: 'POST', body: JSON.stringify(data) })
 
 
 
 
125
  }
126
  };
 
1
 
2
  /// <reference types="vite/client" />
3
+ import { User, ClassInfo, SystemConfig, Subject } from '../types';
 
 
 
 
4
 
5
  const getBaseUrl = () => {
6
  let isProd = false;
 
7
  try {
 
8
  // @ts-ignore
9
  if (typeof import.meta !== 'undefined' && import.meta.env && import.meta.env.PROD) {
10
  isProd = true;
11
  }
12
+ } catch (e) {}
 
 
13
 
14
+ if (isProd || (typeof window !== 'undefined' && window.location.port === '7860')) {
15
  return '/api';
16
  }
 
 
 
 
 
 
 
17
  return 'http://localhost:7860/api';
18
  };
19
 
20
  const API_BASE_URL = getBaseUrl();
21
 
 
22
  async function request(endpoint: string, options: RequestInit = {}) {
23
  const res = await fetch(`${API_BASE_URL}${endpoint}`, {
24
  ...options,
 
28
  if (!res.ok) {
29
  if (res.status === 401) throw new Error('AUTH_FAILED');
30
  const errorData = await res.json().catch(() => ({}));
31
+ const errorMessage = errorData.error || errorData.message || `Server Error: ${res.status}`;
32
+ // Pass special error codes
33
+ if (errorData.error === 'PENDING_APPROVAL') throw new Error('PENDING_APPROVAL');
34
+ if (errorData.error === 'BANNED') throw new Error('BANNED');
35
+ throw new Error(errorMessage);
36
  }
37
  return res.json();
38
  }
39
 
40
  export const api = {
41
+ init: () => console.log('🔗 API:', API_BASE_URL),
 
 
42
 
43
  auth: {
44
  login: async (username: string, password: string): Promise<User> => {
45
+ const user = await request('/auth/login', { method: 'POST', body: JSON.stringify({ username, password }) });
46
+ if (typeof window !== 'undefined') localStorage.setItem('user', JSON.stringify(user));
47
+ return user;
 
 
 
 
 
 
 
 
 
 
48
  },
49
  register: async (data: any): Promise<User> => {
50
+ return await request('/auth/register', { method: 'POST', body: JSON.stringify(data) });
 
 
 
51
  },
52
  logout: () => {
53
+ if (typeof window !== 'undefined') localStorage.removeItem('user');
 
 
54
  },
55
  getCurrentUser: (): User | null => {
56
  if (typeof window !== 'undefined') {
 
61
  }
62
  },
63
 
64
+ users: {
65
+ getAll: () => request('/users'),
66
+ update: (id: string, data: Partial<User>) => request(`/users/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
67
+ delete: (id: string) => request(`/users/${id}`, { method: 'DELETE' })
68
+ },
69
+
70
  students: {
71
  getAll: () => request('/students'),
72
  add: (data: any) => request('/students', { method: 'POST', body: JSON.stringify(data) }),
 
79
  delete: (id: string | number) => request(`/classes/${id}`, { method: 'DELETE' })
80
  },
81
 
82
+ subjects: {
83
+ getAll: () => request('/subjects'),
84
+ add: (data: Subject) => request('/subjects', { method: 'POST', body: JSON.stringify(data) }),
85
+ delete: (id: string | number) => request(`/subjects/${id}`, { method: 'DELETE' })
86
+ },
87
+
88
  courses: {
89
  getAll: () => request('/courses'),
90
  add: (data: any) => request('/courses', { method: 'POST', body: JSON.stringify(data) }),
 
105
  config: {
106
  get: () => request('/config'),
107
  save: (data: SystemConfig) => request('/config', { method: 'POST', body: JSON.stringify(data) })
108
+ },
109
+
110
+ batchDelete: (type: 'student' | 'score' | 'user', ids: string[]) => {
111
+ return request('/batch-delete', { method: 'POST', body: JSON.stringify({ type, ids }) });
112
  }
113
  };
types.ts CHANGED
@@ -3,7 +3,7 @@ export enum UserRole {
3
  ADMIN = 'ADMIN',
4
  TEACHER = 'TEACHER',
5
  STUDENT = 'STUDENT',
6
- USER = 'USER' // Pending verification
7
  }
8
 
9
  export enum UserStatus {
@@ -20,6 +20,7 @@ export interface User {
20
  status: UserStatus;
21
  email: string;
22
  avatar?: string;
 
23
  }
24
 
25
  export interface ClassInfo {
@@ -31,6 +32,14 @@ export interface ClassInfo {
31
  studentCount?: number; // Calculated field
32
  }
33
 
 
 
 
 
 
 
 
 
34
  export interface SystemConfig {
35
  systemName: string;
36
  semester: string;
 
3
  ADMIN = 'ADMIN',
4
  TEACHER = 'TEACHER',
5
  STUDENT = 'STUDENT',
6
+ USER = 'USER'
7
  }
8
 
9
  export enum UserStatus {
 
20
  status: UserStatus;
21
  email: string;
22
  avatar?: string;
23
+ createTime?: string;
24
  }
25
 
26
  export interface ClassInfo {
 
32
  studentCount?: number; // Calculated field
33
  }
34
 
35
+ export interface Subject {
36
+ id?: number;
37
+ _id?: string;
38
+ name: string; // e.g. "语文"
39
+ code: string; // e.g. "CHI"
40
+ color: string; // Hex color for charts
41
+ }
42
+
43
  export interface SystemConfig {
44
  systemName: string;
45
  semester: string;