dvc890 commited on
Commit
d1599a1
·
verified ·
1 Parent(s): 1e5e242

Upload 27 files

Browse files
Files changed (5) hide show
  1. pages/Dashboard.tsx +157 -27
  2. pages/ScoreList.tsx +322 -390
  3. server.js +85 -4
  4. services/api.ts +14 -3
  5. types.ts +12 -2
pages/Dashboard.tsx CHANGED
@@ -1,8 +1,7 @@
1
-
2
  import React, { useEffect, useState } from 'react';
3
- import { Users, BookOpen, GraduationCap, TrendingUp, AlertTriangle, ArrowRight, Activity, Calendar, X, CheckCircle } from 'lucide-react';
4
  import { api } from '../services/api';
5
- import { Score, ClassInfo, Subject } from '../types';
6
 
7
  interface DashboardProps {
8
  onNavigate: (view: string) => void;
@@ -13,24 +12,40 @@ export const Dashboard: React.FC<DashboardProps> = ({ onNavigate }) => {
13
  const [warnings, setWarnings] = useState<string[]>([]);
14
  const [loading, setLoading] = useState(true);
15
 
16
- // Modal States
17
  const [showSchedule, setShowSchedule] = useState(false);
18
- const [showStatus, setShowStatus] = useState(false);
 
 
 
 
 
 
 
 
 
 
 
 
19
 
20
  useEffect(() => {
21
  const loadData = async () => {
22
  setLoading(true);
23
  try {
24
- const [summary, scores, classes, subjects] = await Promise.all([
25
  api.stats.getSummary(),
26
  api.scores.getAll(),
27
  api.classes.getAll(),
28
- api.subjects.getAll()
 
29
  ]);
30
  setStats(summary);
 
 
 
31
 
32
  const newWarnings: string[] = [];
33
- subjects.forEach((sub: Subject) => {
34
  const subScores = scores.filter((s: Score) => s.courseName === sub.name && s.status === 'Normal');
35
  if (subScores.length > 0) {
36
  const avg = subScores.reduce((a: number, b: Score) => a + b.score, 0) / subScores.length;
@@ -47,6 +62,47 @@ export const Dashboard: React.FC<DashboardProps> = ({ onNavigate }) => {
47
  loadData();
48
  }, []);
49
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
  const cards = [
51
  { label: '在校学生', value: stats.studentCount, icon: Users, color: 'bg-blue-500', trend: '+12%' },
52
  { label: '开设课程', value: stats.courseCount, icon: BookOpen, color: 'bg-emerald-500', trend: '+4' },
@@ -63,7 +119,7 @@ export const Dashboard: React.FC<DashboardProps> = ({ onNavigate }) => {
63
  </div>
64
  <div className="flex space-x-3 mt-4 md:mt-0">
65
  <button onClick={() => setShowSchedule(true)} className="flex items-center space-x-2 px-4 py-2 bg-white border border-gray-200 rounded-lg text-sm font-medium text-gray-600 hover:bg-gray-50 shadow-sm transition-colors">
66
- <Calendar size={16}/><span>安排</span>
67
  </button>
68
  <button onClick={() => setShowStatus(true)} className="flex items-center space-x-2 px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 shadow-sm shadow-blue-200 transition-colors">
69
  <Activity size={16}/><span>系统状态</span>
@@ -130,28 +186,102 @@ export const Dashboard: React.FC<DashboardProps> = ({ onNavigate }) => {
130
  </div>
131
  </div>
132
 
133
- {/* Schedule Modal */}
134
  {showSchedule && (
135
  <div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
136
- <div className="bg-white rounded-xl w-full max-w-md p-6 relative animate-in fade-in zoom-in-95">
137
- <button onClick={()=>setShowSchedule(false)} className="absolute top-4 right-4 text-gray-400 hover:text-gray-600"><X size={20}/></button>
138
- <h3 className="text-xl font-bold mb-4 flex items-center"><Calendar className="mr-2 text-blue-600"/>本周日程</h3>
139
- <div className="space-y-3">
140
- <div className="flex gap-4 p-3 bg-blue-50 rounded-lg border-l-4 border-blue-500">
141
- <div className="text-center w-12"><div className="text-xs text-gray-500">周一</div><div className="font-bold">09:00</div></div>
142
- <div><div className="font-bold text-gray-800">全校升旗仪式</div><div className="text-xs text-gray-500">大操场</div></div>
143
- </div>
144
- <div className="flex gap-4 p-3 bg-gray-50 rounded-lg border-l-4 border-gray-300">
145
- <div className="text-center w-12"><div className="text-xs text-gray-500">周三</div><div className="font-bold">14:30</div></div>
146
- <div><div className="font-bold text-gray-800">教职工会议</div><div className="text-xs text-gray-500">第一会议室</div></div>
147
- </div>
148
- <div className="flex gap-4 p-3 bg-emerald-50 rounded-lg border-l-4 border-emerald-500">
149
- <div className="text-center w-12"><div className="text-xs text-gray-500">周五</div><div className="font-bold">16:00</div></div>
150
- <div><div className="font-bold text-gray-800">期末考试考务会</div><div className="text-xs text-gray-500">报告厅</div></div>
151
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
152
  </div>
153
- <button onClick={()=>setShowSchedule(false)} className="w-full mt-6 bg-gray-100 text-gray-600 py-2 rounded-lg hover:bg-gray-200">关闭</button>
154
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
155
  </div>
156
  )}
157
 
@@ -185,4 +315,4 @@ export const Dashboard: React.FC<DashboardProps> = ({ onNavigate }) => {
185
  )}
186
  </div>
187
  );
188
- };
 
 
1
  import React, { useEffect, useState } from 'react';
2
+ import { Users, BookOpen, GraduationCap, TrendingUp, AlertTriangle, ArrowRight, Activity, Calendar, X, CheckCircle, Plus } from 'lucide-react';
3
  import { api } from '../services/api';
4
+ import { Score, ClassInfo, Subject, Schedule, User } from '../types';
5
 
6
  interface DashboardProps {
7
  onNavigate: (view: string) => void;
 
12
  const [warnings, setWarnings] = useState<string[]>([]);
13
  const [loading, setLoading] = useState(true);
14
 
15
+ // Timetable Data
16
  const [showSchedule, setShowSchedule] = useState(false);
17
+ const [schedules, setSchedules] = useState<Schedule[]>([]);
18
+ const [classList, setClassList] = useState<ClassInfo[]>([]);
19
+ const [subjects, setSubjects] = useState<Subject[]>([]);
20
+ const [teachers, setTeachers] = useState<User[]>([]);
21
+
22
+ // Timetable Filter
23
+ const [viewClass, setViewClass] = useState('');
24
+
25
+ // Timetable Edit
26
+ const [editingCell, setEditingCell] = useState<{day: number, period: number} | null>(null);
27
+ const [editForm, setEditForm] = useState({ subject: '', teacherName: '' });
28
+
29
+ const currentUser = api.auth.getCurrentUser();
30
 
31
  useEffect(() => {
32
  const loadData = async () => {
33
  setLoading(true);
34
  try {
35
+ const [summary, scores, classes, subs, userList] = await Promise.all([
36
  api.stats.getSummary(),
37
  api.scores.getAll(),
38
  api.classes.getAll(),
39
+ api.subjects.getAll(),
40
+ api.users.getAll({ role: 'TEACHER' })
41
  ]);
42
  setStats(summary);
43
+ setClassList(classes);
44
+ setSubjects(subs);
45
+ setTeachers(userList);
46
 
47
  const newWarnings: string[] = [];
48
+ subs.forEach((sub: Subject) => {
49
  const subScores = scores.filter((s: Score) => s.courseName === sub.name && s.status === 'Normal');
50
  if (subScores.length > 0) {
51
  const avg = subScores.reduce((a: number, b: Score) => a + b.score, 0) / subScores.length;
 
62
  loadData();
63
  }, []);
64
 
65
+ useEffect(() => {
66
+ if (showSchedule) fetchSchedules();
67
+ }, [showSchedule, viewClass]);
68
+
69
+ const fetchSchedules = async () => {
70
+ try {
71
+ const params: any = {};
72
+ if (viewClass) params.className = viewClass;
73
+ // If teacher, only show own schedule unless filter applied
74
+ if (currentUser?.role === 'TEACHER' && !viewClass) {
75
+ params.teacherName = currentUser.trueName || currentUser.username;
76
+ }
77
+ const data = await api.schedules.get(params);
78
+ setSchedules(data);
79
+ } catch(e) { console.error(e); }
80
+ };
81
+
82
+ const handleSaveSchedule = async () => {
83
+ if (!editingCell || !viewClass) return alert('请先选择班级');
84
+ await api.schedules.save({
85
+ className: viewClass,
86
+ dayOfWeek: editingCell.day,
87
+ period: editingCell.period,
88
+ subject: editForm.subject,
89
+ teacherName: editForm.teacherName
90
+ });
91
+ setEditingCell(null);
92
+ fetchSchedules();
93
+ };
94
+
95
+ const handleDeleteSchedule = async () => {
96
+ if (!editingCell || !viewClass) return;
97
+ await api.schedules.delete({
98
+ className: viewClass,
99
+ dayOfWeek: editingCell.day,
100
+ period: editingCell.period
101
+ });
102
+ setEditingCell(null);
103
+ fetchSchedules();
104
+ };
105
+
106
  const cards = [
107
  { label: '在校学生', value: stats.studentCount, icon: Users, color: 'bg-blue-500', trend: '+12%' },
108
  { label: '开设课程', value: stats.courseCount, icon: BookOpen, color: 'bg-emerald-500', trend: '+4' },
 
119
  </div>
120
  <div className="flex space-x-3 mt-4 md:mt-0">
121
  <button onClick={() => setShowSchedule(true)} className="flex items-center space-x-2 px-4 py-2 bg-white border border-gray-200 rounded-lg text-sm font-medium text-gray-600 hover:bg-gray-50 shadow-sm transition-colors">
122
+ <Calendar size={16}/><span>智能课</span>
123
  </button>
124
  <button onClick={() => setShowStatus(true)} className="flex items-center space-x-2 px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 shadow-sm shadow-blue-200 transition-colors">
125
  <Activity size={16}/><span>系统状态</span>
 
186
  </div>
187
  </div>
188
 
189
+ {/* Timetable Modal */}
190
  {showSchedule && (
191
  <div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
192
+ <div className="bg-white rounded-xl w-full max-w-5xl h-[85vh] p-6 relative animate-in fade-in flex flex-col">
193
+ <div className="flex justify-between items-center mb-6">
194
+ <div className="flex items-center space-x-4">
195
+ <h3 className="text-xl font-bold flex items-center"><Calendar className="mr-2 text-blue-600"/> 智能课程表</h3>
196
+ {/* Filter for Admin */}
197
+ {currentUser?.role === 'ADMIN' && (
198
+ <select className="border rounded p-1 text-sm bg-gray-50" value={viewClass} onChange={e=>setViewClass(e.target.value)}>
199
+ <option value="">选择班级查看/排课</option>
200
+ {classList.map(c=><option key={c._id} value={c.grade+c.className}>{c.grade}{c.className}</option>)}
201
+ </select>
202
+ )}
203
+ {viewClass && <span className="text-sm font-bold text-blue-600 bg-blue-50 px-2 py-1 rounded">当前: {viewClass}</span>}
 
 
 
204
  </div>
205
+ <button onClick={()=>setShowSchedule(false)} className="text-gray-400 hover:text-gray-600"><X size={24}/></button>
206
+ </div>
207
+
208
+ <div className="flex-1 overflow-auto">
209
+ <table className="w-full border-collapse text-center">
210
+ <thead>
211
+ <tr className="bg-gray-50 text-gray-500 uppercase text-sm">
212
+ <th className="p-3 border">节次</th>
213
+ <th className="p-3 border w-1/5">周一</th>
214
+ <th className="p-3 border w-1/5">周二</th>
215
+ <th className="p-3 border w-1/5">周三</th>
216
+ <th className="p-3 border w-1/5">周四</th>
217
+ <th className="p-3 border w-1/5">周五</th>
218
+ </tr>
219
+ </thead>
220
+ <tbody className="text-sm">
221
+ {[1,2,3,4,5,6,7,8].map(period => (
222
+ <tr key={period} className="hover:bg-gray-50/50">
223
+ <td className="p-4 border font-bold text-gray-400">第{period}节</td>
224
+ {[1,2,3,4,5].map(day => {
225
+ const item = schedules.find(s => s.dayOfWeek === day && s.period === period);
226
+ return (
227
+ <td
228
+ key={day}
229
+ className={`p-2 border h-20 align-middle transition-colors ${viewClass ? 'cursor-pointer hover:bg-blue-50' : ''}`}
230
+ onClick={() => {
231
+ if (!viewClass) return;
232
+ setEditingCell({ day, period });
233
+ if(item) setEditForm({ subject: item.subject, teacherName: item.teacherName });
234
+ else setEditForm({ subject: '', teacherName: '' });
235
+ }}
236
+ >
237
+ {item ? (
238
+ <div className="flex flex-col">
239
+ <span className="font-bold text-gray-800">{item.subject}</span>
240
+ <span className="text-xs text-gray-500">{item.teacherName}</span>
241
+ {!viewClass && <span className="text-xs text-blue-400">{item.className}</span>}
242
+ </div>
243
+ ) : (
244
+ viewClass && <Plus size={16} className="text-gray-200 mx-auto"/>
245
+ )}
246
+ </td>
247
+ );
248
+ })}
249
+ </tr>
250
+ ))}
251
+ </tbody>
252
+ </table>
253
  </div>
 
254
  </div>
255
+
256
+ {/* Edit Cell Modal */}
257
+ {editingCell && (
258
+ <div className="absolute inset-0 bg-black/20 flex items-center justify-center backdrop-blur-[1px]">
259
+ <div className="bg-white p-6 rounded-lg shadow-xl w-80">
260
+ <h4 className="font-bold mb-4">排课: 周{['一','二','三','四','五'][editingCell.day-1]} 第{editingCell.period}节</h4>
261
+ <div className="space-y-3">
262
+ <div>
263
+ <label className="text-xs text-gray-500">科目</label>
264
+ <select className="w-full border rounded p-2" value={editForm.subject} onChange={e=>setEditForm({...editForm, subject: e.target.value})}>
265
+ <option value="">选择科目</option>
266
+ {subjects.map(s=><option key={s._id} value={s.name}>{s.name}</option>)}
267
+ </select>
268
+ </div>
269
+ <div>
270
+ <label className="text-xs text-gray-500">任课教师</label>
271
+ <select className="w-full border rounded p-2" value={editForm.teacherName} onChange={e=>setEditForm({...editForm, teacherName: e.target.value})}>
272
+ <option value="">选择教师</option>
273
+ {teachers.map(t=><option key={t._id} value={t.trueName || t.username}>{t.username}</option>)}
274
+ </select>
275
+ </div>
276
+ <div className="flex gap-2 pt-2">
277
+ <button onClick={handleSaveSchedule} className="flex-1 bg-blue-600 text-white py-2 rounded">保存</button>
278
+ <button onClick={handleDeleteSchedule} className="flex-1 bg-red-50 text-red-600 py-2 rounded border border-red-100">清除</button>
279
+ <button onClick={()=>setEditingCell(null)} className="flex-1 border py-2 rounded">取消</button>
280
+ </div>
281
+ </div>
282
+ </div>
283
+ </div>
284
+ )}
285
  </div>
286
  )}
287
 
 
315
  )}
316
  </div>
317
  );
318
+ };
pages/ScoreList.tsx CHANGED
@@ -1,120 +1,108 @@
1
  import React, { useState, useEffect } from 'react';
 
2
  import { api } from '../services/api';
3
- import { Score, Student, Subject, ClassInfo, ExamStatus, Exam } from '../types';
4
- import { Loader2, Plus, Trash2, Award, FileSpreadsheet, X, Upload, Edit, Save, Calendar } from 'lucide-react';
5
 
6
- export const ScoreList: React.FC = () => {
7
- const [subjects, setSubjects] = useState<Subject[]>([]);
8
- const [activeSubject, setActiveSubject] = useState('');
9
- const [scores, setScores] = useState<Score[]>([]);
10
  const [students, setStudents] = useState<Student[]>([]);
11
  const [classList, setClassList] = useState<ClassInfo[]>([]);
12
- const [exams, setExams] = useState<Exam[]>([]);
13
  const [loading, setLoading] = useState(true);
14
-
15
- const [selectedGrade, setSelectedGrade] = useState('All');
16
- const [selectedClass, setSelectedClass] = useState('All');
17
- const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
18
-
19
- const [isAddOpen, setIsAddOpen] = useState(false);
20
  const [isImportOpen, setIsImportOpen] = useState(false);
21
- const [isExamModalOpen, setIsExamModalOpen] = useState(false);
22
-
23
- const [importFile, setImportFile] = useState<File | null>(null);
24
  const [submitting, setSubmitting] = useState(false);
25
-
26
- // Import Config
27
- const [importSemester, setImportSemester] = useState('2023-2024学年 第一学期');
28
- const [importType, setImportType] = useState('Final');
29
- const [importExamName, setImportExamName] = useState('');
30
 
31
- // Manual Add Form
32
- const [formData, setFormData] = useState({ studentId: '', score: '', type: 'Final', examName: '', status: 'Normal' });
 
33
 
34
- // Edit State
35
- const [editingId, setEditingId] = useState<string | null>(null);
36
- const [editScoreVal, setEditScoreVal] = useState<string>('');
37
- const [editStatus, setEditStatus] = useState<ExamStatus>('Normal');
 
 
 
 
 
 
 
 
 
 
 
 
38
 
39
  const loadData = async () => {
40
  setLoading(true);
41
  try {
42
- const [subs, scs, stus, cls, exs] = await Promise.all([
43
- api.subjects.getAll(),
44
- api.scores.getAll(),
45
  api.students.getAll(),
46
- api.classes.getAll(),
47
- api.exams.getAll()
48
  ]);
49
- setSubjects(subs);
50
- if (subs.length > 0 && !activeSubject) setActiveSubject(subs[0].name);
51
- setScores(scs);
52
- setStudents(stus);
53
- setClassList(cls);
54
- setExams(exs);
55
- } catch (e) { console.error(e); }
56
- finally { setLoading(false); }
57
  };
58
 
59
  useEffect(() => { loadData(); }, []);
60
 
61
- const filteredScores = scores.filter(s => {
62
- if (s.courseName !== activeSubject) return false;
63
- const stu = students.find(st => st.studentNo === s.studentNo);
64
- if (!stu) return false;
65
-
66
- // Filter by grade and class
67
- const matchesGrade = selectedGrade === 'All' || stu.className.includes(selectedGrade);
68
- const matchesClass = selectedClass === 'All' || stu.className.includes(selectedClass);
69
- return matchesGrade && matchesClass;
70
- });
71
 
72
  const handleBatchDelete = async () => {
73
- if (confirm(`删除 ${selectedIds.size} 条记录?`)) {
74
- await api.batchDelete('score', Array.from(selectedIds));
75
- setSelectedIds(new Set());
76
- loadData();
 
77
  }
78
  };
79
 
80
- const handleSubmit = async (e: React.FormEvent) => {
81
- e.preventDefault();
82
- const stu = students.find(s => (s._id || s.id) == formData.studentId);
83
- if (!stu) return;
84
- await api.scores.add({
85
- studentName: stu.name,
86
- studentNo: stu.studentNo,
87
- courseName: activeSubject,
88
- score: formData.status === 'Normal' ? Number(formData.score) : 0,
89
- semester: '2023-2024学年 第一学期',
90
- type: formData.type,
91
- examName: formData.examName,
92
- status: formData.status
93
- });
94
- setIsAddOpen(false);
95
- setFormData({ studentId: '', score: '', type: 'Final', examName: '', status: 'Normal' });
96
- loadData();
97
  };
98
 
99
- const startEditing = (s: Score) => {
100
- setEditingId(s._id || String(s.id));
101
- setEditScoreVal(String(s.score));
102
- setEditStatus(s.status || 'Normal');
 
 
103
  };
104
 
105
- const saveEdit = async (id: string) => {
106
- await api.scores.update(id, {
107
- score: editStatus === 'Normal' ? Number(editScoreVal) : 0,
108
- status: editStatus
109
- });
110
- setEditingId(null);
111
- loadData();
 
 
 
 
 
 
 
112
  };
113
 
114
  const handleExcelImport = async () => {
115
  if (!importFile) return alert('请选择文件');
116
  // @ts-ignore
117
- if (!window.XLSX) return alert('Excel 组件未加载');
118
 
119
  setSubmitting(true);
120
 
@@ -126,72 +114,91 @@ export const ScoreList: React.FC = () => {
126
  const workbook = window.XLSX.read(data, { type: 'array' });
127
  const firstSheetName = workbook.SheetNames[0];
128
  const worksheet = workbook.Sheets[firstSheetName];
 
 
129
  // @ts-ignore
130
- const jsonData = window.XLSX.utils.sheet_to_json(worksheet);
131
 
132
- const headers = Object.keys(jsonData[0] || {}).map(h => h.trim());
133
- const matchedSubjects = subjects.filter(s => headers.includes(s.name));
 
 
 
 
 
134
 
135
- if (matchedSubjects.length === 0) {
136
- alert('未在表头找到匹配的学科名称(如: 语文, 数学)');
137
- setSubmitting(false);
138
- return;
 
 
139
  }
140
 
141
  let successCount = 0;
142
- const promises: Promise<any>[] = [];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
143
 
144
- jsonData.forEach((row: any) => {
145
- const cleanRow: any = {};
146
- Object.keys(row).forEach(k => cleanRow[k.trim()] = row[k]);
147
 
148
- const studentName = cleanRow['姓名'] || cleanRow['Name'];
149
- if (!studentName) return;
 
 
 
 
 
 
150
 
151
- const student = students.find(s => s.name === studentName);
 
152
 
153
- if (student) {
154
- matchedSubjects.forEach(sub => {
155
- let rawVal = cleanRow[sub.name];
156
- let scoreVal = 0;
157
- let statusVal: ExamStatus = 'Normal';
158
-
159
- if (typeof rawVal === 'string') {
160
- if (rawVal.includes('缺考')) statusVal = 'Absent';
161
- else if (rawVal.includes('请假')) statusVal = 'Leave';
162
- else if (rawVal.includes('作弊')) statusVal = 'Cheat';
163
- else scoreVal = Number(rawVal) || 0;
164
- } else {
165
- scoreVal = Number(rawVal) || 0;
166
- }
167
-
168
- if (rawVal !== undefined && rawVal !== null && rawVal !== '') {
169
- promises.push(
170
- api.scores.add({
171
- studentName: student.name,
172
- studentNo: student.studentNo,
173
- courseName: sub.name,
174
- score: scoreVal,
175
- semester: importSemester,
176
- type: importType,
177
- examName: importExamName || '批量导入',
178
- status: statusVal
179
- }).then(() => successCount++)
180
- );
181
- }
182
- });
183
- }
184
- });
185
 
186
  await Promise.all(promises);
187
- alert(`成功导入 ${successCount} 条成绩记录`);
188
  setIsImportOpen(false);
189
- setImportFile(null);
190
- setImportExamName('');
191
  loadData();
192
  } catch (err) {
193
  console.error(err);
194
- alert('导入失败,请检查文件');
195
  } finally {
196
  setSubmitting(false);
197
  }
@@ -199,272 +206,197 @@ export const ScoreList: React.FC = () => {
199
  reader.readAsArrayBuffer(importFile);
200
  };
201
 
202
- const handleUpdateExamDate = async (name: string, date: string) => {
203
- await api.exams.save({ name, date });
204
- loadData();
205
- };
206
-
207
-
208
- const toggleSelect = (id: string) => {
209
- const newSet = new Set(selectedIds);
210
- if (newSet.has(id)) newSet.delete(id); else newSet.add(id);
211
- setSelectedIds(newSet);
212
- };
213
 
214
  const uniqueGrades = Array.from(new Set(classList.map(c => c.grade))).sort();
215
  const filteredClasses = classList.filter(c => selectedGrade === 'All' || c.grade === selectedGrade);
216
  const uniqueClasses = Array.from(new Set(filteredClasses.map(c => c.className))).sort();
217
 
218
- // Unique Exam Names for Modal
219
- const uniqueExamNames = Array.from(new Set(scores.map(s => s.examName || s.type)));
220
-
221
  return (
222
- <div className="space-y-6">
223
- <div className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden min-h-[500px]">
224
- {loading && <div className="p-10 text-center"><Loader2 className="animate-spin inline"/></div>}
225
-
226
- <div className="p-4 md:p-6 border-b border-gray-100">
227
- <div className="flex flex-col md:flex-row justify-between items-start md:items-center mb-4 gap-4">
228
- <h2 className="text-xl font-bold flex items-center text-gray-800"><Award className="mr-2 text-blue-600"/>成绩管理</h2>
229
- <div className="grid grid-cols-2 md:flex gap-2 w-full md:w-auto">
230
- <button onClick={() => setIsExamModalOpen(true)} className="col-span-2 md:col-span-1 px-4 py-2 bg-indigo-50 text-indigo-600 border border-indigo-200 rounded-lg flex items-center justify-center text-sm hover:bg-indigo-100 transition-colors">
231
- <Calendar size={16} className="mr-1"/> 考试排期
232
- </button>
233
- <button onClick={() => { setIsImportOpen(true); setImportFile(null); }} className="px-4 py-2 bg-emerald-50 text-emerald-600 border border-emerald-200 rounded-lg flex items-center justify-center text-sm hover:bg-emerald-100 transition-colors">
234
- <FileSpreadsheet size={16} className="mr-1"/> Excel导入
235
- </button>
236
- <button onClick={() => setIsAddOpen(true)} className="px-4 py-2 bg-blue-600 text-white rounded-lg flex items-center justify-center text-sm hover:bg-blue-700 transition-colors">
237
- <Plus size={16} className="mr-1"/> 手动录入
238
- </button>
239
- </div>
240
- </div>
241
-
242
- {/* Responsive Filters */}
243
- <div className="flex flex-col md:flex-row gap-2 md:gap-4 items-stretch md:items-center bg-gray-50 p-3 rounded-lg">
244
- <select className="border border-gray-300 p-2 rounded text-sm text-gray-900 focus:ring-2 focus:ring-blue-500" value={selectedGrade} onChange={e => { setSelectedGrade(e.target.value); setSelectedClass('All'); }}>
245
- <option value="All">所有年级</option>
246
- {uniqueGrades.map(g=><option key={g} value={g}>{g}</option>)}
247
- </select>
248
- <select className="border border-gray-300 p-2 rounded text-sm text-gray-900 focus:ring-2 focus:ring-blue-500" value={selectedClass} onChange={e=>setSelectedClass(e.target.value)}>
249
- <option value="All">所有班级</option>
250
- {uniqueClasses.map(c=><option key={c} value={c}>{c}</option>)}
251
- </select>
252
- </div>
253
- </div>
254
-
255
- <div className="flex border-b border-gray-100 px-4 overflow-x-auto no-scrollbar">
256
- <div className="flex">
257
- {subjects.map(sub => (
258
- <button
259
- key={sub._id || sub.id}
260
- onClick={() => setActiveSubject(sub.name)}
261
- className={`whitespace-nowrap 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 hover:text-gray-700'}`}
262
- >
263
- {sub.name}
264
- </button>
265
- ))}
266
- </div>
267
- </div>
268
-
269
- {/* Responsive Table Wrapper */}
270
- <div className="overflow-x-auto">
271
- <table className="w-full text-left min-w-[800px]">
272
- <thead className="bg-gray-50 text-xs text-gray-500 uppercase">
273
- <tr>
274
- <th className="px-6 py-3 w-10">
275
- <input type="checkbox" onChange={e => {
276
- if(e.target.checked) setSelectedIds(new Set(filteredScores.map(s=>s._id||String(s.id))));
277
- else setSelectedIds(new Set());
278
- }} checked={filteredScores.length > 0 && selectedIds.size === filteredScores.length}/>
279
- </th>
280
- <th className="px-6 py-3">姓名</th>
281
- <th className="px-6 py-3">考试名称</th>
282
- <th className="px-6 py-3">状态/分数</th>
283
- <th className="px-6 py-3 text-right">操作</th>
284
- </tr>
285
- </thead>
286
- <tbody>
287
- {filteredScores.length > 0 ? filteredScores.map(s => {
288
- const isEditing = editingId === (s._id || String(s.id));
289
- const displayScore = s.status === 'Normal' ? s.score : (s.status === 'Absent' ? '缺考' : s.status === 'Leave' ? '请假' : '作弊');
290
- const scoreColor = s.status !== 'Normal' ? 'text-gray-400' : (s.score < 60 ? 'text-red-500' : 'text-blue-600');
291
-
292
- return (
293
- <tr key={s._id || s.id} className="hover:bg-gray-50 border-b border-gray-50 last:border-0">
294
- <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>
295
- <td className="px-6 py-3 font-medium text-gray-800">{s.studentName}</td>
296
- <td className="px-6 py-3 text-sm text-gray-500">{s.examName || s.type}</td>
297
- <td className="px-6 py-3 font-bold">
298
- {isEditing ? (
299
- <div className="flex items-center space-x-2">
300
- <select value={editStatus} onChange={e=>setEditStatus(e.target.value as any)} className="border rounded px-1 py-1 text-xs">
301
- <option value="Normal">正常</option>
302
- <option value="Absent">缺考</option>
303
- <option value="Leave">请假</option>
304
- <option value="Cheat">作弊</option>
305
- </select>
306
- {editStatus === 'Normal' && (
307
- <input type="number" value={editScoreVal} onChange={e=>setEditScoreVal(e.target.value)} className="w-16 border rounded px-1 py-1 text-sm"/>
308
- )}
309
- </div>
310
- ) : (
311
- <span className={scoreColor}>{displayScore}</span>
312
- )}
313
- </td>
314
- <td className="px-6 py-3 text-right flex justify-end gap-2">
315
- {isEditing ? (
316
- <>
317
- <button onClick={()=>saveEdit(s._id||String(s.id))} className="text-green-500 hover:text-green-700"><Save size={16}/></button>
318
- <button onClick={()=>setEditingId(null)} className="text-gray-400 hover:text-gray-600"><X size={16}/></button>
319
- </>
320
- ) : (
321
- <>
322
- <button onClick={()=>startEditing(s)} className="text-blue-400 hover:text-blue-600"><Edit size={16}/></button>
323
- <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>
324
- </>
325
- )}
326
- </td>
327
- </tr>
328
- );
329
- }) : (
330
- <tr><td colSpan={5} className="text-center py-10 text-gray-400">该班级暂无 {activeSubject} 成绩</td></tr>
331
- )}
332
- </tbody>
333
- </table>
334
- </div>
335
- </div>
336
-
337
- {selectedIds.size > 0 && (
338
- <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 z-20">
339
- <span>已选 {selectedIds.size} 项</span>
340
- <button onClick={handleBatchDelete} className="bg-red-600 text-white px-4 py-2 rounded-lg text-sm">批量删除</button>
341
- </div>
342
- )}
343
-
344
- {/* Manual Add Modal */}
345
- {isAddOpen && (
346
- <div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
347
- <div className="bg-white rounded-xl p-6 w-full max-w-sm">
348
- <h3 className="font-bold mb-4">录入 {activeSubject} 成绩</h3>
349
- <form onSubmit={handleSubmit} className="space-y-4">
350
- <select className="w-full border p-2 rounded" value={formData.studentId} onChange={e=>setFormData({...formData, studentId:e.target.value})} required>
351
- <option value="">选择学生</option>
352
- {students.filter(s => {
353
- if (selectedGrade !== 'All' && !s.className.includes(selectedGrade)) return false;
354
- if (selectedClass !== 'All' && !s.className.includes(selectedClass)) return false;
355
- return true;
356
- }).map(s=><option key={s._id||s.id} value={s._id||s.id}>{s.name} - {s.className}</option>)}
357
- </select>
358
- <input className="w-full border p-2 rounded" placeholder="考试名称 (如: 期中测验)" value={formData.examName} onChange={e=>setFormData({...formData, examName:e.target.value})} required/>
359
- <div className="flex gap-2">
360
- <select className="w-1/3 border p-2 rounded" value={formData.status} onChange={e=>setFormData({...formData, status: e.target.value})}>
361
- <option value="Normal">正常</option>
362
- <option value="Absent">缺考</option>
363
- <option value="Leave">请假</option>
364
- </select>
365
- {formData.status === 'Normal' && (
366
- <input type="number" className="flex-1 border p-2 rounded" placeholder="分数" value={formData.score} onChange={e=>setFormData({...formData, score:e.target.value})} required/>
367
- )}
368
- </div>
369
- <div className="flex gap-2">
370
- <button type="submit" className="flex-1 bg-blue-600 text-white py-2 rounded">保存</button>
371
- <button type="button" onClick={()=>setIsAddOpen(false)} className="flex-1 border py-2 rounded">取消</button>
372
- </div>
373
- </form>
374
- </div>
375
- </div>
376
- )}
377
-
378
- {/* Exam Schedule Modal */}
379
- {isExamModalOpen && (
380
- <div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
381
- <div className="bg-white rounded-xl p-6 w-full max-w-lg max-h-[80vh] overflow-y-auto">
382
- <div className="flex justify-between items-center mb-4">
383
- <h3 className="font-bold text-lg">考试排期管理</h3>
384
- <button onClick={()=>setIsExamModalOpen(false)}><X size={20}/></button>
385
- </div>
386
- <p className="text-sm text-gray-500 mb-4">设置考试的具体日期,以便生成准确的时间轴趋势图。</p>
387
-
388
- <div className="space-y-3">
389
- {uniqueExamNames.map((name, idx) => {
390
- const examInfo = exams.find(e => e.name === name);
391
- return (
392
- <div key={idx} className="flex items-center justify-between bg-gray-50 p-3 rounded">
393
- <span className="font-medium text-gray-800">{name}</span>
394
- <input
395
- type="date"
396
- className="border rounded px-2 py-1 text-sm"
397
- value={examInfo?.date || ''}
398
- onChange={(e) => handleUpdateExamDate(name!, e.target.value)}
399
- />
400
- </div>
401
- );
402
- })}
403
- {uniqueExamNames.length === 0 && <p className="text-center text-gray-400 py-4">暂无考试记录</p>}
404
- </div>
405
-
406
- <div className="mt-6 flex justify-end">
407
- <button onClick={()=>setIsExamModalOpen(false)} className="px-4 py-2 bg-blue-600 text-white rounded">完成</button>
408
- </div>
409
- </div>
410
- </div>
411
- )}
412
-
413
- {/* Excel Import Modal */}
414
- {isImportOpen && (
415
- <div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
416
- <div className="bg-white rounded-xl p-6 w-full max-w-md relative">
417
- <div className="flex justify-between items-center mb-4">
418
- <h3 className="font-bold text-lg text-gray-900">批量导入成绩</h3>
419
- <button onClick={()=>setIsImportOpen(false)}><X size={20} className="text-gray-400"/></button>
420
- </div>
421
-
422
- <div className="space-y-4">
423
  <div>
424
- <label className="text-sm font-bold text-gray-600">考试信息</label>
425
- <div className="space-y-2 mt-1">
426
- <input className="w-full border border-gray-300 p-2 rounded text-sm text-gray-900" value={importExamName} onChange={e=>setImportExamName(e.target.value)} placeholder="自定义考试名称 (如: 第三次月考)" />
427
- <div className="grid grid-cols-2 gap-2">
428
- <select className="border border-gray-300 p-2 rounded text-sm text-gray-900" value={importType} onChange={e=>setImportType(e.target.value)}>
429
- <option value="Final">期末考试</option>
430
- <option value="Midterm">期中考试</option>
431
- <option value="Quiz">平时测验</option>
432
- </select>
433
- <input className="border border-gray-300 p-2 rounded text-sm text-gray-900" value={importSemester} onChange={e=>setImportSemester(e.target.value)} placeholder="学期" />
434
- </div>
435
- </div>
436
- </div>
437
-
438
- <div className="relative border-2 border-dashed border-gray-300 rounded-lg p-6 text-center hover:bg-gray-50 transition-colors">
439
- <Upload className="mx-auto h-10 w-10 text-gray-400" />
440
- <p className="mt-2 text-sm text-gray-600">点击上传 .xlsx 文件</p>
441
- <p className="text-xs text-gray-400 mt-1">系统将自动识别 "缺考", "请假" 等文本</p>
442
- <input
443
- type="file"
444
- accept=".xlsx, .xls"
445
- className="opacity-0 absolute inset-0 cursor-pointer h-full w-full z-10"
446
- // @ts-ignore
447
- onChange={e => setImportFile(e.target.files?.[0])}
448
- />
449
  </div>
450
-
451
- {importFile && (
452
- <div className="text-sm text-blue-600 font-medium bg-blue-50 p-2 rounded border border-blue-100">
453
- 已选择: {importFile.name}
 
 
 
 
 
 
454
  </div>
455
- )}
456
-
457
- <button
458
- onClick={handleExcelImport}
459
- disabled={!importFile || submitting}
460
- className="w-full bg-emerald-600 text-white py-2 rounded-lg font-medium hover:bg-emerald-700 disabled:opacity-50 transition-colors relative z-20"
461
- >
462
- {submitting ? '处理中...' : '开始导入'}
463
- </button>
464
- </div>
465
- </div>
466
- </div>
467
- )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
468
  </div>
469
  );
470
  };
 
1
  import React, { useState, useEffect } from 'react';
2
+ import { Search, Plus, Upload, Edit, Trash2, X, Loader2, User, FileSpreadsheet } from 'lucide-react';
3
  import { api } from '../services/api';
4
+ import { Student, ClassInfo } from '../types';
 
5
 
6
+ export const StudentList: React.FC = () => {
7
+ const [searchTerm, setSearchTerm] = useState('');
 
 
8
  const [students, setStudents] = useState<Student[]>([]);
9
  const [classList, setClassList] = useState<ClassInfo[]>([]);
 
10
  const [loading, setLoading] = useState(true);
11
+ const [isModalOpen, setIsModalOpen] = useState(false);
 
 
 
 
 
12
  const [isImportOpen, setIsImportOpen] = useState(false);
 
 
 
13
  const [submitting, setSubmitting] = useState(false);
14
+ const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
 
 
 
 
15
 
16
+ // Filters
17
+ const [selectedGrade, setSelectedGrade] = useState('All');
18
+ const [selectedClass, setSelectedClass] = useState('All');
19
 
20
+ // Import Data
21
+ const [importFile, setImportFile] = useState<File | null>(null);
22
+ const [importTargetClass, setImportTargetClass] = useState('');
23
+
24
+ // Form State (Extended)
25
+ const [formData, setFormData] = useState({
26
+ name: '',
27
+ studentNo: '',
28
+ gender: 'Male',
29
+ className: '',
30
+ phone: '',
31
+ idCard: '',
32
+ parentName: '',
33
+ parentPhone: '',
34
+ address: ''
35
+ });
36
 
37
  const loadData = async () => {
38
  setLoading(true);
39
  try {
40
+ const [studentData, classData] = await Promise.all([
 
 
41
  api.students.getAll(),
42
+ api.classes.getAll()
 
43
  ]);
44
+ setStudents(studentData);
45
+ setClassList(classData);
46
+ } catch (error) {
47
+ console.error(error);
48
+ } finally {
49
+ setLoading(false);
50
+ }
 
51
  };
52
 
53
  useEffect(() => { loadData(); }, []);
54
 
55
+ const handleDelete = async (id: string) => {
56
+ if (confirm('确定删除?')) {
57
+ await api.students.delete(id);
58
+ loadData();
59
+ }
60
+ };
 
 
 
 
61
 
62
  const handleBatchDelete = async () => {
63
+ if (selectedIds.size === 0) return;
64
+ if (confirm(`确定要删除选中的 ${selectedIds.size} 名学生吗?`)) {
65
+ await api.batchDelete('student', Array.from(selectedIds));
66
+ setSelectedIds(new Set());
67
+ loadData();
68
  }
69
  };
70
 
71
+ const toggleSelect = (id: string) => {
72
+ const newSet = new Set(selectedIds);
73
+ if (newSet.has(id)) newSet.delete(id);
74
+ else newSet.add(id);
75
+ setSelectedIds(newSet);
 
 
 
 
 
 
 
 
 
 
 
 
76
  };
77
 
78
+ const toggleSelectAll = (filtered: Student[]) => {
79
+ if (selectedIds.size === filtered.length && filtered.length > 0) {
80
+ setSelectedIds(new Set());
81
+ } else {
82
+ setSelectedIds(new Set(filtered.map(s => s._id || String(s.id))));
83
+ }
84
  };
85
 
86
+ const handleAddSubmit = async (e: React.FormEvent) => {
87
+ e.preventDefault();
88
+ setSubmitting(true);
89
+ try {
90
+ await api.students.add({
91
+ ...formData,
92
+ birthday: '2015-01-01',
93
+ status: 'Enrolled',
94
+ gender: formData.gender as any
95
+ });
96
+ setIsModalOpen(false);
97
+ loadData();
98
+ } catch (error) { alert('添加失败'); }
99
+ finally { setSubmitting(false); }
100
  };
101
 
102
  const handleExcelImport = async () => {
103
  if (!importFile) return alert('请选择文件');
104
  // @ts-ignore
105
+ if (!window.XLSX) return alert('Excel 解析组件未加载,请刷新页面重试');
106
 
107
  setSubmitting(true);
108
 
 
114
  const workbook = window.XLSX.read(data, { type: 'array' });
115
  const firstSheetName = workbook.SheetNames[0];
116
  const worksheet = workbook.Sheets[firstSheetName];
117
+
118
+ // Use 'header: 1' to get raw array of arrays
119
  // @ts-ignore
120
+ const rawRows: any[][] = window.XLSX.utils.sheet_to_json(worksheet, { header: 1 });
121
 
122
+ console.log('Importing raw rows:', rawRows);
123
+
124
+ if (rawRows.length < 1) return alert('文件为空');
125
+
126
+ // Heuristic: Find header row
127
+ let headerRowIndex = -1;
128
+ const headerKeywords = ['姓名', 'name', '学号', 'no', 'id'];
129
 
130
+ for (let i = 0; i < Math.min(10, rawRows.length); i++) {
131
+ const rowStr = rawRows[i].join(' ').toLowerCase();
132
+ if (headerKeywords.some(k => rowStr.includes(k))) {
133
+ headerRowIndex = i;
134
+ break;
135
+ }
136
  }
137
 
138
  let successCount = 0;
139
+ const promises = [];
140
+
141
+ // Determine column indices
142
+ let colMap: Record<string, number> = {};
143
+
144
+ if (headerRowIndex !== -1) {
145
+ rawRows[headerRowIndex].forEach((cell: any, idx: number) => {
146
+ if (typeof cell === 'string') colMap[cell.trim()] = idx;
147
+ });
148
+ } else {
149
+ // Fallback: 0=StudentNo, 1=Name
150
+ colMap = { '学号': 0, '姓名': 1, '性别': 2, '电话': 3 };
151
+ }
152
+
153
+ const startRow = headerRowIndex === -1 ? 0 : headerRowIndex + 1;
154
 
155
+ for (let i = startRow; i < rawRows.length; i++) {
156
+ const row = rawRows[i];
157
+ if (!row || row.length === 0) continue;
158
 
159
+ // Helper to get val by possible keys or index
160
+ const getVal = (keys: string[], defaultIdx: number) => {
161
+ for (const k of keys) {
162
+ if (colMap[k] !== undefined && row[colMap[k]]) return row[colMap[k]];
163
+ }
164
+ if (headerRowIndex === -1 && row[defaultIdx]) return row[defaultIdx];
165
+ return '';
166
+ };
167
 
168
+ const name = getVal(['姓名', 'Name'], 1);
169
+ const studentNo = getVal(['学号', 'No', 'ID'], 0);
170
 
171
+ if (!name || !studentNo) continue;
172
+
173
+ const genderVal = getVal(['性别', 'Gender'], 2);
174
+ const gender = (genderVal === '女' || genderVal === 'Female') ? 'Female' : 'Male';
175
+
176
+ const targetClassName = importTargetClass || getVal(['班级', 'Class'], 99) || '未分配';
177
+
178
+ promises.push(
179
+ api.students.add({
180
+ name: String(name).trim(),
181
+ studentNo: String(studentNo).trim(),
182
+ gender,
183
+ className: targetClassName,
184
+ phone: String(getVal(['电话', '联系方式', 'Mobile'], 3)).trim(),
185
+ parentName: String(getVal(['家长', '家长姓名', 'Parent'], 4)).trim(),
186
+ parentPhone: String(getVal(['家长电话', '联系电话'], 5)).trim(),
187
+ address: String(getVal(['地址', '住址', 'Address'], 6)).trim(),
188
+ birthday: '2015-01-01',
189
+ status: 'Enrolled'
190
+ }).then(() => successCount++).catch(err => console.error('Row Import Error:', err))
191
+ );
192
+ }
 
 
 
 
 
 
 
 
 
 
193
 
194
  await Promise.all(promises);
195
+ alert(`成功导入/更新 ${successCount} 名学生信息`);
196
  setIsImportOpen(false);
197
+ setImportFile(null);
 
198
  loadData();
199
  } catch (err) {
200
  console.error(err);
201
+ alert('解析 Excel 文件失败,请检查文件格式');
202
  } finally {
203
  setSubmitting(false);
204
  }
 
206
  reader.readAsArrayBuffer(importFile);
207
  };
208
 
209
+ const filteredStudents = students.filter((s) => {
210
+ const matchesSearch = s.name.toLowerCase().includes(searchTerm.toLowerCase()) || s.studentNo.includes(searchTerm);
211
+ const matchesGrade = selectedGrade === 'All' || s.className.includes(selectedGrade);
212
+ const matchesClass = selectedClass === 'All' || s.className.includes(selectedClass);
213
+ return matchesSearch && matchesGrade && matchesClass;
214
+ });
 
 
 
 
 
215
 
216
  const uniqueGrades = Array.from(new Set(classList.map(c => c.grade))).sort();
217
  const filteredClasses = classList.filter(c => selectedGrade === 'All' || c.grade === selectedGrade);
218
  const uniqueClasses = Array.from(new Set(filteredClasses.map(c => c.className))).sort();
219
 
 
 
 
220
  return (
221
+ <div className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden relative min-h-[600px] flex flex-col">
222
+ {loading && <div className="absolute inset-0 bg-white/50 z-10 flex items-center justify-center"><Loader2 className="animate-spin text-blue-600" /></div>}
223
+
224
+ <div className="p-4 md:p-6 border-b border-gray-100 space-y-4">
225
+ <div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
226
+ <div>
227
+ <h2 className="text-lg font-bold text-gray-800">学生档案</h2>
228
+ <p className="text-sm text-gray-500">选中 {selectedIds.size} / {filteredStudents.length} 人</p>
229
+ </div>
230
+ <div className="flex gap-2 w-full md:w-auto">
231
+ <button onClick={() => { setIsImportOpen(true); setImportFile(null); }} className="flex-1 md:flex-none btn-secondary flex items-center justify-center space-x-2 px-3 py-2 border rounded-lg text-sm bg-emerald-50 text-emerald-600 hover:bg-emerald-100 border-emerald-200">
232
+ <FileSpreadsheet size={16}/><span>Excel导入</span>
233
+ </button>
234
+ <button onClick={() => setIsModalOpen(true)} className="flex-1 md:flex-none btn-primary flex items-center justify-center space-x-2 px-3 py-2 bg-blue-600 text-white rounded-lg text-sm hover:bg-blue-700">
235
+ <Plus size={16}/><span>新增</span>
236
+ </button>
237
+ </div>
238
+ </div>
239
+
240
+ <div className="flex flex-col md:flex-row gap-2 md:gap-4 bg-gray-50 p-3 rounded-lg">
241
+ <div className="relative flex-1">
242
+ <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 text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500" />
243
+ <Search className="absolute left-3 top-2.5 text-gray-500" size={16} />
244
+ </div>
245
+ <div className="flex gap-2">
246
+ <select className="flex-1 md:w-32 border rounded-md text-sm text-gray-900 p-2" value={selectedGrade} onChange={e => { setSelectedGrade(e.target.value); setSelectedClass('All'); }}><option value="All">所有年级</option>{uniqueGrades.map(g=><option key={g} value={g}>{g}</option>)}</select>
247
+ <select className="flex-1 md:w-32 border rounded-md text-sm text-gray-900 p-2" value={selectedClass} onChange={e => setSelectedClass(e.target.value)}><option value="All">所有班级</option>{uniqueClasses.map(c=><option key={c} value={c}>{c}</option>)}</select>
248
+ </div>
249
+ </div>
250
+ </div>
251
+
252
+ <div className="flex-1 overflow-x-auto">
253
+ <table className="w-full text-left min-w-[700px]">
254
+ <thead className="bg-gray-50 text-xs text-gray-500 uppercase sticky top-0">
255
+ <tr>
256
+ <th className="px-6 py-3 w-10">
257
+ <input type="checkbox"
258
+ checked={filteredStudents.length > 0 && selectedIds.size === filteredStudents.length}
259
+ onChange={() => toggleSelectAll(filteredStudents)}
260
+ />
261
+ </th>
262
+ <th className="px-6 py-3">基本信息</th>
263
+ <th className="px-6 py-3">学号</th>
264
+ <th className="px-6 py-3">班级</th>
265
+ <th className="px-6 py-3">家庭信息</th>
266
+ <th className="px-6 py-3 text-right">操作</th>
267
+ </tr>
268
+ </thead>
269
+ <tbody className="divide-y divide-gray-100">
270
+ {filteredStudents.length > 0 ? filteredStudents.map(s => (
271
+ <tr key={s._id || s.id} className="hover:bg-blue-50/30 transition-colors">
272
+ <td className="px-6 py-4">
273
+ <input type="checkbox" checked={selectedIds.has(s._id || String(s.id))} onChange={() => toggleSelect(s._id || String(s.id))} />
274
+ </td>
275
+ <td className="px-6 py-4 flex items-center space-x-3">
276
+ <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>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
277
  <div>
278
+ <span className="font-bold text-gray-800">{s.name}</span>
279
+ <div className="text-xs text-gray-400">{s.idCard || '身份证未录入'}</div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
280
  </div>
281
+ </td>
282
+ <td className="px-6 py-4 text-sm font-mono text-gray-600">{s.studentNo}</td>
283
+ <td className="px-6 py-4 text-sm">
284
+ <span className="bg-gray-100 px-2 py-1 rounded text-gray-600 text-xs">{s.className}</span>
285
+ </td>
286
+ <td className="px-6 py-4 text-xs text-gray-500 max-w-xs truncate">
287
+ {s.parentName ? (
288
+ <div className="flex flex-col">
289
+ <span>{s.parentName} {s.parentPhone}</span>
290
+ <span className="text-gray-400 truncate" title={s.address}>{s.address}</span>
291
  </div>
292
+ ) : '-'}
293
+ </td>
294
+ <td className="px-6 py-4 text-right">
295
+ <button onClick={() => handleDelete(s._id || String(s.id))} className="text-red-400 hover:text-red-600"><Trash2 size={16}/></button>
296
+ </td>
297
+ </tr>
298
+ )) : (
299
+ <tr>
300
+ <td colSpan={6} className="text-center py-10 text-gray-400">没有找到匹配的学生</td>
301
+ </tr>
302
+ )}
303
+ </tbody>
304
+ </table>
305
+ </div>
306
+
307
+ {selectedIds.size > 0 && (
308
+ <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)] z-20">
309
+ <span className="text-sm font-medium text-gray-700">已选择 {selectedIds.size} 项</span>
310
+ <button onClick={handleBatchDelete} className="px-4 py-2 bg-red-600 text-white text-sm rounded-lg hover:bg-red-700">批量删除</button>
311
+ </div>
312
+ )}
313
+
314
+ {isModalOpen && (
315
+ <div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
316
+ <div className="bg-white rounded-xl p-6 w-full max-w-lg max-h-[90vh] overflow-y-auto">
317
+ <h3 className="font-bold text-lg mb-4">新增学生档案</h3>
318
+ <form onSubmit={handleAddSubmit} className="space-y-4">
319
+ <div className="grid grid-cols-2 gap-4">
320
+ <input className="w-full border p-2 rounded" placeholder="姓名 *" value={formData.name} onChange={e=>setFormData({...formData, name:e.target.value})} required/>
321
+ <input className="w-full border p-2 rounded" placeholder="学号 *" value={formData.studentNo} onChange={e=>setFormData({...formData, studentNo:e.target.value})} required/>
322
+ </div>
323
+ <div className="grid grid-cols-2 gap-4">
324
+ <select className="w-full border p-2 rounded" value={formData.className} onChange={e=>setFormData({...formData, className:e.target.value})} required>
325
+ <option value="">选择班级 *</option>
326
+ {classList.map(c=><option key={c._id} value={c.grade+c.className}>{c.grade}{c.className}</option>)}
327
+ </select>
328
+ <select className="w-full border p-2 rounded" value={formData.gender} onChange={e=>setFormData({...formData, gender:e.target.value})} required>
329
+ <option value="Male">男</option>
330
+ <option value="Female">女</option>
331
+ </select>
332
+ </div>
333
+ <input className="w-full border p-2 rounded" placeholder="身份证号 (选填)" value={formData.idCard} onChange={e=>setFormData({...formData, idCard:e.target.value})}/>
334
+
335
+ <div className="border-t border-gray-100 pt-4 mt-2">
336
+ <p className="text-xs font-bold text-gray-500 uppercase mb-2">家庭信息 (选填)</p>
337
+ <div className="grid grid-cols-2 gap-4 mb-2">
338
+ <input className="w-full border p-2 rounded" placeholder="家长姓名" value={formData.parentName} onChange={e=>setFormData({...formData, parentName:e.target.value})}/>
339
+ <input className="w-full border p-2 rounded" placeholder="家长电话" value={formData.parentPhone} onChange={e=>setFormData({...formData, parentPhone:e.target.value})}/>
340
+ </div>
341
+ <input className="w-full border p-2 rounded" placeholder="家庭住址" value={formData.address} onChange={e=>setFormData({...formData, address:e.target.value})}/>
342
+ </div>
343
+
344
+ <button type="submit" disabled={submitting} className="w-full bg-blue-600 text-white py-2 rounded mt-4">{submitting?'提交中':'保存'}</button>
345
+ <button type="button" onClick={()=>setIsModalOpen(false)} className="w-full border py-2 rounded mt-2">取消</button>
346
+ </form>
347
+ </div>
348
+ </div>
349
+ )}
350
+
351
+ {isImportOpen && (
352
+ <div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
353
+ <div className="bg-white rounded-xl p-6 w-full max-w-md relative">
354
+ <div className="flex justify-between items-center mb-4">
355
+ <h3 className="font-bold text-lg text-gray-900">Excel 批量导入</h3>
356
+ <button onClick={()=>setIsImportOpen(false)} className="text-gray-400 hover:text-gray-600"><X size={20}/></button>
357
+ </div>
358
+
359
+ <div className="space-y-4">
360
+ <div>
361
+ <label className="block text-sm font-medium text-gray-700 mb-1">选择归属班级 (若Excel无班级列)</label>
362
+ <select className="w-full border border-gray-300 p-2 rounded text-sm text-gray-900" value={importTargetClass} onChange={e => setImportTargetClass(e.target.value)}>
363
+ <option value="">-- 请选择 --</option>
364
+ {classList.map(c=><option key={c._id} value={c.grade+c.className}>{c.grade}{c.className}</option>)}
365
+ </select>
366
+ </div>
367
+
368
+ <div className="relative border-2 border-dashed border-gray-300 rounded-lg p-6 text-center hover:bg-gray-50 transition-colors">
369
+ <Upload className="mx-auto h-10 w-10 text-gray-400" />
370
+ <p className="mt-2 text-sm text-gray-600">点击上传 .xlsx 文件</p>
371
+ <p className="text-xs text-gray-400 mt-1">支持列名:姓名, 学号, 家长姓名, 家长电话, 地址</p>
372
+ <p className="text-xs text-gray-400">或:第一列学号,第二列姓名</p>
373
+ <input
374
+ type="file"
375
+ accept=".xlsx, .xls"
376
+ className="opacity-0 absolute inset-0 cursor-pointer h-full w-full z-10"
377
+ // @ts-ignore
378
+ onChange={e => setImportFile(e.target.files?.[0])}
379
+ />
380
+ </div>
381
+
382
+ {importFile && (
383
+ <div className="text-sm text-blue-600 font-medium bg-blue-50 p-2 rounded border border-blue-100">
384
+ 已选择: {importFile.name}
385
+ </div>
386
+ )}
387
+
388
+ <button
389
+ onClick={handleExcelImport}
390
+ disabled={!importFile || submitting}
391
+ className="w-full bg-emerald-600 text-white py-2 rounded-lg font-medium hover:bg-emerald-700 disabled:opacity-50 transition-colors relative z-20"
392
+ style={{position: 'relative', zIndex: 20}}
393
+ >
394
+ {submitting ? '导入中...' : '开始导入'}
395
+ </button>
396
+ </div>
397
+ </div>
398
+ </div>
399
+ )}
400
  </div>
401
  );
402
  };
server.js CHANGED
@@ -1,4 +1,3 @@
1
-
2
  const express = require('express');
3
  const mongoose = require('mongoose');
4
  const cors = require('cors');
@@ -30,6 +29,7 @@ const InMemoryDB = {
30
  classes: [],
31
  subjects: [],
32
  exams: [],
 
33
  config: {},
34
  isFallback: false
35
  };
@@ -145,6 +145,18 @@ const ExamSchema = new mongoose.Schema({
145
  });
146
  const ExamModel = mongoose.model('Exam', ExamSchema);
147
 
 
 
 
 
 
 
 
 
 
 
 
 
148
  const ConfigSchema = new mongoose.Schema({
149
  // Global config, no schoolId
150
  key: { type: String, default: 'main', unique: true },
@@ -203,6 +215,12 @@ const initData = async () => {
203
  allowAdminRegister: false
204
  });
205
  console.log('✅ Initialized Global Config');
 
 
 
 
 
 
206
  }
207
 
208
  } catch (err) {
@@ -406,6 +424,52 @@ app.post('/api/exams', async (req, res) => {
406
  res.json({ success: true });
407
  });
408
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
409
  // --- Students ---
410
  app.get('/api/students', async (req, res) => {
411
  const filter = getQueryFilter(req);
@@ -414,8 +478,25 @@ app.get('/api/students', async (req, res) => {
414
  });
415
  app.post('/api/students', async (req, res) => {
416
  const data = injectSchoolId(req, req.body);
417
- if (InMemoryDB.isFallback) { InMemoryDB.students.push({ ...data, _id: String(Date.now()) }); return res.json({}); }
418
- res.json(await Student.create(data));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
419
  });
420
  app.delete('/api/students/:id', async (req, res) => {
421
  if (InMemoryDB.isFallback) { InMemoryDB.students = InMemoryDB.students.filter(s => s._id != req.params.id); return res.json({}); }
@@ -528,4 +609,4 @@ app.get('*', (req, res) => {
528
  res.sendFile(path.join(__dirname, 'dist', 'index.html'));
529
  });
530
 
531
- app.listen(PORT, () => console.log(`🚀 Service running on port ${PORT}`));
 
 
1
  const express = require('express');
2
  const mongoose = require('mongoose');
3
  const cors = require('cors');
 
29
  classes: [],
30
  subjects: [],
31
  exams: [],
32
+ schedules: [],
33
  config: {},
34
  isFallback: false
35
  };
 
145
  });
146
  const ExamModel = mongoose.model('Exam', ExamSchema);
147
 
148
+ const ScheduleSchema = new mongoose.Schema({
149
+ schoolId: String,
150
+ className: String,
151
+ teacherName: String,
152
+ subject: String,
153
+ dayOfWeek: Number, // 1-5
154
+ period: Number // 1-8
155
+ });
156
+ // Ensure unique schedule slot per class
157
+ ScheduleSchema.index({ schoolId: 1, className: 1, dayOfWeek: 1, period: 1 }, { unique: true });
158
+ const ScheduleModel = mongoose.model('Schedule', ScheduleSchema);
159
+
160
  const ConfigSchema = new mongoose.Schema({
161
  // Global config, no schoolId
162
  key: { type: String, default: 'main', unique: true },
 
215
  allowAdminRegister: false
216
  });
217
  console.log('✅ Initialized Global Config');
218
+ } else if (!configExists.semesters || configExists.semesters.length === 0) {
219
+ // Migration: add semesters if missing
220
+ configExists.semesters = ['2023-2024学年 第一学期', '2023-2024学年 第二学期'];
221
+ if (!configExists.semester) configExists.semester = '2023-2024学年 第一学期';
222
+ await configExists.save();
223
+ console.log('✅ Migrated Global Config');
224
  }
225
 
226
  } catch (err) {
 
424
  res.json({ success: true });
425
  });
426
 
427
+ // --- Schedules ---
428
+ app.get('/api/schedules', async (req, res) => {
429
+ const { className, teacherName } = req.query;
430
+ const filter = getQueryFilter(req);
431
+ if (className) filter.className = className;
432
+ if (teacherName) filter.teacherName = teacherName;
433
+
434
+ if (InMemoryDB.isFallback) return res.json(InMemoryDB.schedules.filter(s =>
435
+ (!filter.schoolId || s.schoolId === filter.schoolId) &&
436
+ (!filter.className || s.className === filter.className) &&
437
+ (!filter.teacherName || s.teacherName === filter.teacherName)
438
+ ));
439
+
440
+ res.json(await ScheduleModel.find(filter));
441
+ });
442
+ app.post('/api/schedules', async (req, res) => {
443
+ const data = injectSchoolId(req, req.body);
444
+ const { schoolId, className, dayOfWeek, period } = data;
445
+
446
+ if (InMemoryDB.isFallback) {
447
+ const idx = InMemoryDB.schedules.findIndex(s => s.schoolId === schoolId && s.className === className && s.dayOfWeek === dayOfWeek && s.period === period);
448
+ if (idx >= 0) InMemoryDB.schedules[idx] = { ...data, _id: String(Date.now()) };
449
+ else InMemoryDB.schedules.push({ ...data, _id: String(Date.now()) });
450
+ return res.json({ success: true });
451
+ }
452
+
453
+ await ScheduleModel.findOneAndUpdate(
454
+ { schoolId, className, dayOfWeek, period },
455
+ data,
456
+ { upsert: true }
457
+ );
458
+ res.json({ success: true });
459
+ });
460
+ app.delete('/api/schedules', async (req, res) => {
461
+ const { className, dayOfWeek, period } = req.query;
462
+ const schoolId = req.headers['x-school-id'];
463
+ if (InMemoryDB.isFallback) {
464
+ InMemoryDB.schedules = InMemoryDB.schedules.filter(s =>
465
+ !(s.schoolId === schoolId && s.className === className && s.dayOfWeek == dayOfWeek && s.period == period)
466
+ );
467
+ return res.json({ success: true });
468
+ }
469
+ await ScheduleModel.deleteOne({ schoolId, className, dayOfWeek, period });
470
+ res.json({ success: true });
471
+ });
472
+
473
  // --- Students ---
474
  app.get('/api/students', async (req, res) => {
475
  const filter = getQueryFilter(req);
 
478
  });
479
  app.post('/api/students', async (req, res) => {
480
  const data = injectSchoolId(req, req.body);
481
+ try {
482
+ if (InMemoryDB.isFallback) {
483
+ // Mock upsert behavior
484
+ const idx = InMemoryDB.students.findIndex(s => s.studentNo === data.studentNo && s.schoolId === data.schoolId);
485
+ if (idx >= 0) InMemoryDB.students[idx] = { ...InMemoryDB.students[idx], ...data };
486
+ else InMemoryDB.students.push({ ...data, _id: String(Date.now()) });
487
+ return res.json({});
488
+ }
489
+
490
+ // Fix: Use Upsert to prevent duplicate key errors (500)
491
+ await Student.findOneAndUpdate(
492
+ { schoolId: data.schoolId, studentNo: data.studentNo },
493
+ data,
494
+ { upsert: true, new: true }
495
+ );
496
+ res.json({});
497
+ } catch (e) {
498
+ res.status(500).json({ error: e.message });
499
+ }
500
  });
501
  app.delete('/api/students/:id', async (req, res) => {
502
  if (InMemoryDB.isFallback) { InMemoryDB.students = InMemoryDB.students.filter(s => s._id != req.params.id); return res.json({}); }
 
609
  res.sendFile(path.join(__dirname, 'dist', 'index.html'));
610
  });
611
 
612
+ app.listen(PORT, () => console.log(`🚀 Service running on port ${PORT}`));
services/api.ts CHANGED
@@ -1,6 +1,5 @@
1
-
2
  /// <reference types="vite/client" />
3
- import { User, ClassInfo, SystemConfig, Subject, School } from '../types';
4
 
5
  const getBaseUrl = () => {
6
  let isProd = false;
@@ -142,6 +141,18 @@ export const api = {
142
  delete: (id: string | number) => request(`/scores/${id}`, { method: 'DELETE' })
143
  },
144
 
 
 
 
 
 
 
 
 
 
 
 
 
145
  stats: {
146
  getSummary: () => request('/stats')
147
  },
@@ -155,4 +166,4 @@ export const api = {
155
  batchDelete: (type: 'student' | 'score' | 'user', ids: string[]) => {
156
  return request('/batch-delete', { method: 'POST', body: JSON.stringify({ type, ids }) });
157
  }
158
- };
 
 
1
  /// <reference types="vite/client" />
2
+ import { User, ClassInfo, SystemConfig, Subject, School, Schedule } from '../types';
3
 
4
  const getBaseUrl = () => {
5
  let isProd = false;
 
141
  delete: (id: string | number) => request(`/scores/${id}`, { method: 'DELETE' })
142
  },
143
 
144
+ schedules: {
145
+ get: (params: { className?: string; teacherName?: string }) => {
146
+ const qs = new URLSearchParams(params as any).toString();
147
+ return request(`/schedules?${qs}`);
148
+ },
149
+ save: (data: Schedule) => request('/schedules', { method: 'POST', body: JSON.stringify(data) }),
150
+ delete: (params: { className: string; dayOfWeek: number; period: number }) => {
151
+ const qs = new URLSearchParams(params as any).toString();
152
+ return request(`/schedules?${qs}`, { method: 'DELETE' });
153
+ }
154
+ },
155
+
156
  stats: {
157
  getSummary: () => request('/stats')
158
  },
 
166
  batchDelete: (type: 'student' | 'score' | 'user', ids: string[]) => {
167
  return request('/batch-delete', { method: 'POST', body: JSON.stringify({ type, ids }) });
168
  }
169
+ };
types.ts CHANGED
@@ -1,4 +1,3 @@
1
-
2
  export enum UserRole {
3
  ADMIN = 'ADMIN',
4
  TEACHER = 'TEACHER',
@@ -122,9 +121,20 @@ export interface Exam {
122
  semester?: string;
123
  }
124
 
 
 
 
 
 
 
 
 
 
 
 
125
  export interface ApiResponse<T> {
126
  code: number;
127
  message: string;
128
  data: T;
129
  timestamp: number;
130
- }
 
 
1
  export enum UserRole {
2
  ADMIN = 'ADMIN',
3
  TEACHER = 'TEACHER',
 
121
  semester?: string;
122
  }
123
 
124
+ export interface Schedule {
125
+ id?: number;
126
+ _id?: string;
127
+ schoolId?: string;
128
+ className: string; // Linked to ClassInfo
129
+ teacherName: string; // Linked to User
130
+ subject: string;
131
+ dayOfWeek: number; // 1 (Mon) - 5 (Fri)
132
+ period: number; // 1 - 8
133
+ }
134
+
135
  export interface ApiResponse<T> {
136
  code: number;
137
  message: string;
138
  data: T;
139
  timestamp: number;
140
+ }