Spaces:
Sleeping
Sleeping
Upload 27 files
Browse files- pages/Dashboard.tsx +157 -27
- pages/ScoreList.tsx +322 -390
- server.js +85 -4
- services/api.ts +14 -3
- 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 |
-
//
|
| 17 |
const [showSchedule, setShowSchedule] = useState(false);
|
| 18 |
-
const [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
|
| 20 |
useEffect(() => {
|
| 21 |
const loadData = async () => {
|
| 22 |
setLoading(true);
|
| 23 |
try {
|
| 24 |
-
const [summary, scores, classes,
|
| 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 |
-
|
| 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>
|
| 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 |
-
{/*
|
| 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-
|
| 137 |
-
<
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 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 {
|
| 4 |
-
import { Loader2, Plus, Trash2, Award, FileSpreadsheet, X, Upload, Edit, Save, Calendar } from 'lucide-react';
|
| 5 |
|
| 6 |
-
export const
|
| 7 |
-
const [
|
| 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 |
-
//
|
| 32 |
-
const [
|
|
|
|
| 33 |
|
| 34 |
-
//
|
| 35 |
-
const [
|
| 36 |
-
const [
|
| 37 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
|
| 39 |
const loadData = async () => {
|
| 40 |
setLoading(true);
|
| 41 |
try {
|
| 42 |
-
const [
|
| 43 |
-
api.subjects.getAll(),
|
| 44 |
-
api.scores.getAll(),
|
| 45 |
api.students.getAll(),
|
| 46 |
-
api.classes.getAll()
|
| 47 |
-
api.exams.getAll()
|
| 48 |
]);
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
}
|
| 56 |
-
finally { setLoading(false); }
|
| 57 |
};
|
| 58 |
|
| 59 |
useEffect(() => { loadData(); }, []);
|
| 60 |
|
| 61 |
-
const
|
| 62 |
-
if (
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 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 (
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
|
|
|
| 77 |
}
|
| 78 |
};
|
| 79 |
|
| 80 |
-
const
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 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
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
|
|
|
|
|
|
| 103 |
};
|
| 104 |
|
| 105 |
-
const
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 131 |
|
| 132 |
-
|
| 133 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
|
|
|
|
|
|
| 139 |
}
|
| 140 |
|
| 141 |
let successCount = 0;
|
| 142 |
-
const promises
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
|
| 144 |
-
|
| 145 |
-
const
|
| 146 |
-
|
| 147 |
|
| 148 |
-
|
| 149 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
|
| 151 |
-
const
|
|
|
|
| 152 |
|
| 153 |
-
if (
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 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
|
| 203 |
-
|
| 204 |
-
|
| 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="
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
<
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
<
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 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 |
-
<
|
| 425 |
-
<div className="
|
| 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 |
-
|
| 452 |
-
|
| 453 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 454 |
</div>
|
| 455 |
-
)}
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
</
|
| 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 |
-
|
| 418 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
}
|