Spaces:
Sleeping
Sleeping
Upload 27 files
Browse files- pages/ClassList.tsx +9 -4
- pages/CourseList.tsx +24 -3
- pages/Dashboard.tsx +79 -88
- pages/Settings.tsx +101 -68
- pages/StudentList.tsx +55 -22
- server.js +40 -54
- services/api.ts +8 -2
- types.ts +8 -3
pages/ClassList.tsx
CHANGED
|
@@ -11,11 +11,16 @@ export const ClassList: React.FC = () => {
|
|
| 11 |
const [submitting, setSubmitting] = useState(false);
|
| 12 |
|
| 13 |
// Form
|
| 14 |
-
const [grade, setGrade] = useState('
|
| 15 |
const [className, setClassName] = useState('(1)班');
|
| 16 |
const [teacherName, setTeacherName] = useState('');
|
| 17 |
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
|
| 20 |
const loadClasses = async () => {
|
| 21 |
setLoading(true);
|
|
@@ -111,7 +116,7 @@ export const ClassList: React.FC = () => {
|
|
| 111 |
<form onSubmit={handleSubmit} className="space-y-4">
|
| 112 |
<div>
|
| 113 |
<label className="text-sm font-medium text-gray-700">年级</label>
|
| 114 |
-
<select value={grade} onChange={e => setGrade(e.target.value)} className="w-full mt-1 px-3 py-2 border rounded-lg">
|
| 115 |
{grades.map(g => <option key={g} value={g}>{g}</option>)}
|
| 116 |
</select>
|
| 117 |
</div>
|
|
@@ -121,7 +126,7 @@ export const ClassList: React.FC = () => {
|
|
| 121 |
</div>
|
| 122 |
<div>
|
| 123 |
<label className="text-sm font-medium text-gray-700">班主任姓名</label>
|
| 124 |
-
<input
|
| 125 |
</div>
|
| 126 |
<div className="flex space-x-3 pt-4">
|
| 127 |
<button type="button" onClick={() => setIsModalOpen(false)} className="flex-1 py-2 border rounded-lg">取消</button>
|
|
|
|
| 11 |
const [submitting, setSubmitting] = useState(false);
|
| 12 |
|
| 13 |
// Form
|
| 14 |
+
const [grade, setGrade] = useState('一年级');
|
| 15 |
const [className, setClassName] = useState('(1)班');
|
| 16 |
const [teacherName, setTeacherName] = useState('');
|
| 17 |
|
| 18 |
+
// Expanded K12 Grades
|
| 19 |
+
const grades = [
|
| 20 |
+
'一年级', '二年级', '三年级', '四年级', '五年级', '六年级',
|
| 21 |
+
'初一/七年级', '初二/八年级', '初三/九年级',
|
| 22 |
+
'高一', '高二', '高三'
|
| 23 |
+
];
|
| 24 |
|
| 25 |
const loadClasses = async () => {
|
| 26 |
setLoading(true);
|
|
|
|
| 116 |
<form onSubmit={handleSubmit} className="space-y-4">
|
| 117 |
<div>
|
| 118 |
<label className="text-sm font-medium text-gray-700">年级</label>
|
| 119 |
+
<select value={grade} onChange={e => setGrade(e.target.value)} className="w-full mt-1 px-3 py-2 border rounded-lg max-h-40">
|
| 120 |
{grades.map(g => <option key={g} value={g}>{g}</option>)}
|
| 121 |
</select>
|
| 122 |
</div>
|
|
|
|
| 126 |
</div>
|
| 127 |
<div>
|
| 128 |
<label className="text-sm font-medium text-gray-700">班主任姓名</label>
|
| 129 |
+
<input type="text" value={teacherName} onChange={e => setTeacherName(e.target.value)} className="w-full mt-1 px-3 py-2 border rounded-lg" placeholder="可选填" />
|
| 130 |
</div>
|
| 131 |
<div className="flex space-x-3 pt-4">
|
| 132 |
<button type="button" onClick={() => setIsModalOpen(false)} className="flex-1 py-2 border rounded-lg">取消</button>
|
pages/CourseList.tsx
CHANGED
|
@@ -2,11 +2,12 @@
|
|
| 2 |
import React, { useEffect, useState } from 'react';
|
| 3 |
import { Plus, Clock, Loader2, Edit, Trash2, X } from 'lucide-react';
|
| 4 |
import { api } from '../services/api';
|
| 5 |
-
import { Course, Subject } from '../types';
|
| 6 |
|
| 7 |
export const CourseList: React.FC = () => {
|
| 8 |
const [courses, setCourses] = useState<Course[]>([]);
|
| 9 |
const [subjects, setSubjects] = useState<Subject[]>([]);
|
|
|
|
| 10 |
const [loading, setLoading] = useState(true);
|
| 11 |
const [isModalOpen, setIsModalOpen] = useState(false);
|
| 12 |
const [formData, setFormData] = useState({ courseCode: '', courseName: '', teacherName: '', credits: 2, capacity: 45 });
|
|
@@ -15,9 +16,14 @@ export const CourseList: React.FC = () => {
|
|
| 15 |
const loadData = async () => {
|
| 16 |
setLoading(true);
|
| 17 |
try {
|
| 18 |
-
const [c, s] = await Promise.all([
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
setCourses(c);
|
| 20 |
setSubjects(s);
|
|
|
|
| 21 |
} catch (e) { console.error(e); } finally { setLoading(false); }
|
| 22 |
};
|
| 23 |
|
|
@@ -69,7 +75,22 @@ export const CourseList: React.FC = () => {
|
|
| 69 |
{subjects.map(s=><option key={s._id} value={s.name}>{s.name}</option>)}
|
| 70 |
</select>
|
| 71 |
</div>
|
| 72 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
<button className="w-full bg-blue-600 text-white py-2 rounded">保存</button>
|
| 74 |
<button type="button" onClick={()=>setIsModalOpen(false)} className="w-full border py-2 rounded">取消</button>
|
| 75 |
</form>
|
|
|
|
| 2 |
import React, { useEffect, useState } from 'react';
|
| 3 |
import { Plus, Clock, Loader2, Edit, Trash2, X } from 'lucide-react';
|
| 4 |
import { api } from '../services/api';
|
| 5 |
+
import { Course, Subject, User } from '../types';
|
| 6 |
|
| 7 |
export const CourseList: React.FC = () => {
|
| 8 |
const [courses, setCourses] = useState<Course[]>([]);
|
| 9 |
const [subjects, setSubjects] = useState<Subject[]>([]);
|
| 10 |
+
const [teachers, setTeachers] = useState<User[]>([]); // New: List of teachers
|
| 11 |
const [loading, setLoading] = useState(true);
|
| 12 |
const [isModalOpen, setIsModalOpen] = useState(false);
|
| 13 |
const [formData, setFormData] = useState({ courseCode: '', courseName: '', teacherName: '', credits: 2, capacity: 45 });
|
|
|
|
| 16 |
const loadData = async () => {
|
| 17 |
setLoading(true);
|
| 18 |
try {
|
| 19 |
+
const [c, s, t] = await Promise.all([
|
| 20 |
+
api.courses.getAll(),
|
| 21 |
+
api.subjects.getAll(),
|
| 22 |
+
api.users.getAll({ role: 'TEACHER' }) // Fetch teachers
|
| 23 |
+
]);
|
| 24 |
setCourses(c);
|
| 25 |
setSubjects(s);
|
| 26 |
+
setTeachers(t);
|
| 27 |
} catch (e) { console.error(e); } finally { setLoading(false); }
|
| 28 |
};
|
| 29 |
|
|
|
|
| 75 |
{subjects.map(s=><option key={s._id} value={s.name}>{s.name}</option>)}
|
| 76 |
</select>
|
| 77 |
</div>
|
| 78 |
+
|
| 79 |
+
<div>
|
| 80 |
+
<label className="text-sm font-bold text-gray-500">任课教师</label>
|
| 81 |
+
<input
|
| 82 |
+
className="w-full border p-2 rounded"
|
| 83 |
+
placeholder="选择或输入教师姓名"
|
| 84 |
+
value={formData.teacherName}
|
| 85 |
+
onChange={e=>setFormData({...formData, teacherName:e.target.value})}
|
| 86 |
+
list="teacherList"
|
| 87 |
+
required
|
| 88 |
+
/>
|
| 89 |
+
<datalist id="teacherList">
|
| 90 |
+
{teachers.map(t => <option key={t._id} value={t.trueName || t.username}>{t.username}</option>)}
|
| 91 |
+
</datalist>
|
| 92 |
+
</div>
|
| 93 |
+
|
| 94 |
<button className="w-full bg-blue-600 text-white py-2 rounded">保存</button>
|
| 95 |
<button type="button" onClick={()=>setIsModalOpen(false)} className="w-full border py-2 rounded">取消</button>
|
| 96 |
</form>
|
pages/Dashboard.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
|
|
| 1 |
import React, { useEffect, useState } from 'react';
|
| 2 |
-
import { Users, BookOpen, GraduationCap, TrendingUp, AlertTriangle, ArrowRight, Activity, Calendar } from 'lucide-react';
|
| 3 |
import { api } from '../services/api';
|
| 4 |
import { Score, ClassInfo, Subject } from '../types';
|
| 5 |
|
|
@@ -11,6 +12,10 @@ export const Dashboard: React.FC<DashboardProps> = ({ onNavigate }) => {
|
|
| 11 |
const [stats, setStats] = useState({ studentCount: 0, courseCount: 0, avgScore: 0, excellentRate: '0%' });
|
| 12 |
const [warnings, setWarnings] = useState<string[]>([]);
|
| 13 |
const [loading, setLoading] = useState(true);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
useEffect(() => {
|
| 16 |
const loadData = async () => {
|
|
@@ -24,48 +29,20 @@ export const Dashboard: React.FC<DashboardProps> = ({ onNavigate }) => {
|
|
| 24 |
]);
|
| 25 |
setStats(summary);
|
| 26 |
|
| 27 |
-
// Generate Warnings
|
| 28 |
const newWarnings: string[] = [];
|
| 29 |
-
|
| 30 |
-
// 1. Analyze Subject Averages
|
| 31 |
subjects.forEach((sub: Subject) => {
|
| 32 |
const subScores = scores.filter((s: Score) => s.courseName === sub.name && s.status === 'Normal');
|
| 33 |
if (subScores.length > 0) {
|
| 34 |
const avg = subScores.reduce((a: number, b: Score) => a + b.score, 0) / subScores.length;
|
| 35 |
-
if (avg < 60) {
|
| 36 |
-
newWarnings.push(`全校 ${sub.name} 学科平均分过低 (${avg.toFixed(1)}分),请关注。`);
|
| 37 |
-
}
|
| 38 |
}
|
| 39 |
});
|
| 40 |
-
|
| 41 |
-
// 2. Analyze Class Performance
|
| 42 |
-
classes.forEach((cls: ClassInfo) => {
|
| 43 |
-
const className = cls.grade + cls.className;
|
| 44 |
-
// We need to fetch students for this class first to filter scores properly,
|
| 45 |
-
// but for dashboard speed we might approximate if student info is not linked in scores.
|
| 46 |
-
// Since Score schema has studentNo, we can't easily filter by class without student list.
|
| 47 |
-
// Skipping class-specific warnings for this quick dashboard view to avoid n+1 query performance hit,
|
| 48 |
-
// or we rely on backend aggregated stats.
|
| 49 |
-
});
|
| 50 |
-
|
| 51 |
-
// 3. Count failed students
|
| 52 |
const failedCount = scores.filter((s: Score) => s.score < 60 && s.status === 'Normal').length;
|
| 53 |
-
if (failedCount > 10) {
|
| 54 |
-
newWarnings.push(`近期共有 ${failedCount} 人次考试不及格,请关注后进生辅导。`);
|
| 55 |
-
}
|
| 56 |
-
|
| 57 |
-
const absentCount = scores.filter((s: Score) => s.status === 'Absent').length;
|
| 58 |
-
if (absentCount > 5) {
|
| 59 |
-
newWarnings.push(`近期有 ${absentCount} 人次缺考,请核实情况。`);
|
| 60 |
-
}
|
| 61 |
-
|
| 62 |
setWarnings(newWarnings);
|
| 63 |
|
| 64 |
-
} catch (error) {
|
| 65 |
-
|
| 66 |
-
} finally {
|
| 67 |
-
setLoading(false);
|
| 68 |
-
}
|
| 69 |
};
|
| 70 |
loadData();
|
| 71 |
}, []);
|
|
@@ -85,18 +62,15 @@ export const Dashboard: React.FC<DashboardProps> = ({ onNavigate }) => {
|
|
| 85 |
<p className="text-gray-500 mt-1">欢迎回来,今天是 {new Date().toLocaleDateString()}</p>
|
| 86 |
</div>
|
| 87 |
<div className="flex space-x-3 mt-4 md:mt-0">
|
| 88 |
-
<button 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">
|
| 89 |
-
<Calendar size={16}
|
| 90 |
-
<span>日程安排</span>
|
| 91 |
</button>
|
| 92 |
-
<button 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">
|
| 93 |
-
<Activity size={16}
|
| 94 |
-
<span>系统状态</span>
|
| 95 |
</button>
|
| 96 |
</div>
|
| 97 |
</div>
|
| 98 |
|
| 99 |
-
{/* Stats Grid */}
|
| 100 |
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
| 101 |
{cards.map((card, index) => (
|
| 102 |
<div key={index} className="bg-white rounded-xl shadow-sm border border-gray-100 p-6 flex items-start justify-between hover:shadow-md transition-shadow relative overflow-hidden group">
|
|
@@ -111,31 +85,19 @@ export const Dashboard: React.FC<DashboardProps> = ({ onNavigate }) => {
|
|
| 111 |
<div className={`p-3 rounded-xl ${card.color} bg-opacity-10 text-white shadow-sm group-hover:scale-110 transition-transform`}>
|
| 112 |
<card.icon className={`h-6 w-6 ${card.color.replace('bg-', 'text-')}`} />
|
| 113 |
</div>
|
| 114 |
-
{/* Background Decoration */}
|
| 115 |
<div className={`absolute -bottom-4 -right-4 w-24 h-24 rounded-full ${card.color} opacity-5`}></div>
|
| 116 |
</div>
|
| 117 |
))}
|
| 118 |
</div>
|
| 119 |
|
| 120 |
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
| 121 |
-
{/* Warning Section */}
|
| 122 |
<div className="lg:col-span-2 bg-white rounded-xl shadow-sm border border-gray-100 p-6">
|
| 123 |
<div className="flex items-center justify-between mb-6">
|
| 124 |
-
<h3 className="text-lg font-bold text-gray-800 flex items-center">
|
| 125 |
-
|
| 126 |
-
教学质量预警
|
| 127 |
-
</h3>
|
| 128 |
-
<span className="text-xs font-semibold bg-amber-100 text-amber-700 px-2 py-1 rounded-full">
|
| 129 |
-
{warnings.length} 条待处理
|
| 130 |
-
</span>
|
| 131 |
</div>
|
| 132 |
-
|
| 133 |
<div className="space-y-4">
|
| 134 |
-
{loading ? (
|
| 135 |
-
<div className="space-y-3">
|
| 136 |
-
{[1,2,3].map(i => <div key={i} className="h-16 bg-gray-50 rounded-lg animate-pulse"></div>)}
|
| 137 |
-
</div>
|
| 138 |
-
) : warnings.length > 0 ? (
|
| 139 |
warnings.map((w, i) => (
|
| 140 |
<div key={i} className="flex items-start p-4 bg-amber-50/50 border border-amber-100 rounded-lg">
|
| 141 |
<div className="min-w-[4px] h-full bg-amber-400 rounded-full mr-3 self-stretch"></div>
|
|
@@ -145,53 +107,82 @@ export const Dashboard: React.FC<DashboardProps> = ({ onNavigate }) => {
|
|
| 145 |
</div>
|
| 146 |
</div>
|
| 147 |
))
|
| 148 |
-
) :
|
| 149 |
-
<div className="text-center py-10 bg-gray-50 rounded-lg">
|
| 150 |
-
<div className="bg-green-100 w-12 h-12 rounded-full flex items-center justify-center mx-auto mb-3">
|
| 151 |
-
<Activity className="text-green-600" size={24}/>
|
| 152 |
-
</div>
|
| 153 |
-
<p className="text-gray-500">各项指标正常,暂无预警</p>
|
| 154 |
-
</div>
|
| 155 |
-
)}
|
| 156 |
</div>
|
| 157 |
</div>
|
| 158 |
|
| 159 |
-
{/* Quick Actions */}
|
| 160 |
<div className="bg-gradient-to-br from-indigo-600 to-blue-700 rounded-xl shadow-lg shadow-blue-200 p-6 text-white relative overflow-hidden">
|
| 161 |
<div className="relative z-10">
|
| 162 |
<h3 className="text-xl font-bold mb-2">快速开始</h3>
|
| 163 |
<p className="text-blue-100 text-sm mb-6">管理您的日常教务工作</p>
|
| 164 |
-
|
| 165 |
<div className="space-y-3">
|
| 166 |
-
<button
|
| 167 |
-
|
| 168 |
-
className="w-full bg-white/10 hover:bg-white/20 border border-white/20 p-3 rounded-lg flex items-center justify-between transition-colors text-sm font-medium group cursor-pointer"
|
| 169 |
-
>
|
| 170 |
-
<span>录入新成绩</span>
|
| 171 |
-
<ArrowRight size={16} className="opacity-0 group-hover:opacity-100 transition-opacity"/>
|
| 172 |
</button>
|
| 173 |
-
<button
|
| 174 |
-
|
| 175 |
-
className="w-full bg-white/10 hover:bg-white/20 border border-white/20 p-3 rounded-lg flex items-center justify-between transition-colors text-sm font-medium group cursor-pointer"
|
| 176 |
-
>
|
| 177 |
-
<span>添加学生档案</span>
|
| 178 |
-
<ArrowRight size={16} className="opacity-0 group-hover:opacity-100 transition-opacity"/>
|
| 179 |
</button>
|
| 180 |
-
<button
|
| 181 |
-
|
| 182 |
-
className="w-full bg-white/10 hover:bg-white/20 border border-white/20 p-3 rounded-lg flex items-center justify-between transition-colors text-sm font-medium group cursor-pointer"
|
| 183 |
-
>
|
| 184 |
-
<span>下载本月报表</span>
|
| 185 |
-
<ArrowRight size={16} className="opacity-0 group-hover:opacity-100 transition-opacity"/>
|
| 186 |
</button>
|
| 187 |
</div>
|
| 188 |
</div>
|
| 189 |
-
|
| 190 |
-
{/* Decorative circles */}
|
| 191 |
-
<div className="absolute top-[-20%] right-[-10%] w-40 h-40 bg-white/10 rounded-full blur-2xl"></div>
|
| 192 |
-
<div className="absolute bottom-[-10%] left-[-10%] w-32 h-32 bg-indigo-400/20 rounded-full blur-xl"></div>
|
| 193 |
</div>
|
| 194 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 195 |
</div>
|
| 196 |
);
|
| 197 |
-
};
|
|
|
|
| 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 |
|
|
|
|
| 12 |
const [stats, setStats] = useState({ studentCount: 0, courseCount: 0, avgScore: 0, excellentRate: '0%' });
|
| 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 () => {
|
|
|
|
| 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;
|
| 37 |
+
if (avg < 60) newWarnings.push(`全校 ${sub.name} 学科平均分过低 (${avg.toFixed(1)}分),请关注。`);
|
|
|
|
|
|
|
| 38 |
}
|
| 39 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
const failedCount = scores.filter((s: Score) => s.score < 60 && s.status === 'Normal').length;
|
| 41 |
+
if (failedCount > 10) newWarnings.push(`近期共有 ${failedCount} 人次考试不及格,请关注后进生辅导。`);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
setWarnings(newWarnings);
|
| 43 |
|
| 44 |
+
} catch (error) { console.error(error); }
|
| 45 |
+
finally { setLoading(false); }
|
|
|
|
|
|
|
|
|
|
| 46 |
};
|
| 47 |
loadData();
|
| 48 |
}, []);
|
|
|
|
| 62 |
<p className="text-gray-500 mt-1">欢迎回来,今天是 {new Date().toLocaleDateString()}</p>
|
| 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>
|
|
|
|
| 70 |
</button>
|
| 71 |
</div>
|
| 72 |
</div>
|
| 73 |
|
|
|
|
| 74 |
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
| 75 |
{cards.map((card, index) => (
|
| 76 |
<div key={index} className="bg-white rounded-xl shadow-sm border border-gray-100 p-6 flex items-start justify-between hover:shadow-md transition-shadow relative overflow-hidden group">
|
|
|
|
| 85 |
<div className={`p-3 rounded-xl ${card.color} bg-opacity-10 text-white shadow-sm group-hover:scale-110 transition-transform`}>
|
| 86 |
<card.icon className={`h-6 w-6 ${card.color.replace('bg-', 'text-')}`} />
|
| 87 |
</div>
|
|
|
|
| 88 |
<div className={`absolute -bottom-4 -right-4 w-24 h-24 rounded-full ${card.color} opacity-5`}></div>
|
| 89 |
</div>
|
| 90 |
))}
|
| 91 |
</div>
|
| 92 |
|
| 93 |
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
|
|
| 94 |
<div className="lg:col-span-2 bg-white rounded-xl shadow-sm border border-gray-100 p-6">
|
| 95 |
<div className="flex items-center justify-between mb-6">
|
| 96 |
+
<h3 className="text-lg font-bold text-gray-800 flex items-center"><AlertTriangle className="text-amber-500 mr-2" size={20}/>教学质量预警</h3>
|
| 97 |
+
<span className="text-xs font-semibold bg-amber-100 text-amber-700 px-2 py-1 rounded-full">{warnings.length} 条待处理</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
</div>
|
|
|
|
| 99 |
<div className="space-y-4">
|
| 100 |
+
{loading ? <div className="space-y-3 animate-pulse">{[1,2].map(i => <div key={i} className="h-12 bg-gray-50 rounded"></div>)}</div> : warnings.length > 0 ? (
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
warnings.map((w, i) => (
|
| 102 |
<div key={i} className="flex items-start p-4 bg-amber-50/50 border border-amber-100 rounded-lg">
|
| 103 |
<div className="min-w-[4px] h-full bg-amber-400 rounded-full mr-3 self-stretch"></div>
|
|
|
|
| 107 |
</div>
|
| 108 |
</div>
|
| 109 |
))
|
| 110 |
+
) : <div className="text-center py-10 text-gray-400">各项指标正常</div>}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
</div>
|
| 112 |
</div>
|
| 113 |
|
|
|
|
| 114 |
<div className="bg-gradient-to-br from-indigo-600 to-blue-700 rounded-xl shadow-lg shadow-blue-200 p-6 text-white relative overflow-hidden">
|
| 115 |
<div className="relative z-10">
|
| 116 |
<h3 className="text-xl font-bold mb-2">快速开始</h3>
|
| 117 |
<p className="text-blue-100 text-sm mb-6">管理您的日常教务工作</p>
|
|
|
|
| 118 |
<div className="space-y-3">
|
| 119 |
+
<button onClick={() => onNavigate('grades')} className="w-full bg-white/10 hover:bg-white/20 border border-white/20 p-3 rounded-lg flex items-center justify-between transition-colors text-sm font-medium group cursor-pointer">
|
| 120 |
+
<span>录入新成绩</span><ArrowRight size={16} className="opacity-0 group-hover:opacity-100 transition-opacity"/>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
</button>
|
| 122 |
+
<button onClick={() => onNavigate('students')} className="w-full bg-white/10 hover:bg-white/20 border border-white/20 p-3 rounded-lg flex items-center justify-between transition-colors text-sm font-medium group cursor-pointer">
|
| 123 |
+
<span>添加学生档案</span><ArrowRight size={16} className="opacity-0 group-hover:opacity-100 transition-opacity"/>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
</button>
|
| 125 |
+
<button onClick={() => onNavigate('reports')} className="w-full bg-white/10 hover:bg-white/20 border border-white/20 p-3 rounded-lg flex items-center justify-between transition-colors text-sm font-medium group cursor-pointer">
|
| 126 |
+
<span>下载本月报表</span><ArrowRight size={16} className="opacity-0 group-hover:opacity-100 transition-opacity"/>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
</button>
|
| 128 |
</div>
|
| 129 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
|
| 158 |
+
{/* System Status Modal */}
|
| 159 |
+
{showStatus && (
|
| 160 |
+
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
| 161 |
+
<div className="bg-white rounded-xl w-full max-w-sm p-6 relative animate-in fade-in zoom-in-95">
|
| 162 |
+
<button onClick={()=>setShowStatus(false)} className="absolute top-4 right-4 text-gray-400 hover:text-gray-600"><X size={20}/></button>
|
| 163 |
+
<h3 className="text-xl font-bold mb-6 flex items-center"><Activity className="mr-2 text-green-600"/>系统运行状态</h3>
|
| 164 |
+
<div className="space-y-4">
|
| 165 |
+
<div className="flex justify-between items-center">
|
| 166 |
+
<span className="text-gray-600">数据库连接</span>
|
| 167 |
+
<span className="text-green-600 font-bold flex items-center"><CheckCircle size={14} className="mr-1"/> 正常</span>
|
| 168 |
+
</div>
|
| 169 |
+
<div className="flex justify-between items-center">
|
| 170 |
+
<span className="text-gray-600">API 响应延迟</span>
|
| 171 |
+
<span className="text-green-600 font-bold">24ms</span>
|
| 172 |
+
</div>
|
| 173 |
+
<div className="flex justify-between items-center">
|
| 174 |
+
<span className="text-gray-600">内存占用</span>
|
| 175 |
+
<span className="text-blue-600 font-bold">128MB / 512MB</span>
|
| 176 |
+
</div>
|
| 177 |
+
<div className="flex justify-between items-center">
|
| 178 |
+
<span className="text-gray-600">版本号</span>
|
| 179 |
+
<span className="text-gray-400 font-mono text-xs">v1.2.4</span>
|
| 180 |
+
</div>
|
| 181 |
+
</div>
|
| 182 |
+
<button onClick={()=>setShowStatus(false)} className="w-full mt-6 bg-blue-600 text-white py-2 rounded-lg hover:bg-blue-700">确认</button>
|
| 183 |
+
</div>
|
| 184 |
+
</div>
|
| 185 |
+
)}
|
| 186 |
</div>
|
| 187 |
);
|
| 188 |
+
};
|
pages/Settings.tsx
CHANGED
|
@@ -1,35 +1,33 @@
|
|
| 1 |
|
| 2 |
import React, { useState, useEffect } from 'react';
|
| 3 |
-
import { Save, Bell, Lock, Database, Loader2, Plus, X } from 'lucide-react';
|
| 4 |
import { api } from '../services/api';
|
|
|
|
| 5 |
|
| 6 |
export const Settings: React.FC = () => {
|
| 7 |
const [loading, setLoading] = useState(false);
|
| 8 |
const [fetching, setFetching] = useState(true);
|
| 9 |
|
| 10 |
-
const [config, setConfig] = useState({
|
| 11 |
systemName: '智慧校园管理系统',
|
| 12 |
allowRegister: true,
|
| 13 |
allowAdminRegister: false,
|
| 14 |
maintenanceMode: false,
|
| 15 |
emailNotify: true,
|
| 16 |
-
semester: '2023-2024学年 第一学期'
|
|
|
|
| 17 |
});
|
| 18 |
|
| 19 |
-
|
| 20 |
-
'2023-2024学年 第一学期',
|
| 21 |
-
'2023-2024学年 第二学期',
|
| 22 |
-
'2024-2025学年 第一学期'
|
| 23 |
-
]);
|
| 24 |
-
const [isAddingSemester, setIsAddingSemester] = useState(false);
|
| 25 |
const [newSemesterVal, setNewSemesterVal] = useState('');
|
|
|
|
|
|
|
| 26 |
|
| 27 |
useEffect(() => {
|
| 28 |
const loadConfig = async () => {
|
| 29 |
try {
|
| 30 |
const data = await api.config.get();
|
| 31 |
if (data && data.systemName) {
|
| 32 |
-
// Merge with defaults to ensure all fields exist
|
| 33 |
setConfig(prev => ({ ...prev, ...data }));
|
| 34 |
}
|
| 35 |
} catch (e) {
|
|
@@ -45,7 +43,7 @@ export const Settings: React.FC = () => {
|
|
| 45 |
setLoading(true);
|
| 46 |
try {
|
| 47 |
await api.config.save(config);
|
| 48 |
-
alert('
|
| 49 |
} catch (e) {
|
| 50 |
alert('保存失败');
|
| 51 |
} finally {
|
|
@@ -53,81 +51,116 @@ export const Settings: React.FC = () => {
|
|
| 53 |
}
|
| 54 |
};
|
| 55 |
|
| 56 |
-
|
|
|
|
| 57 |
if (newSemesterVal.trim()) {
|
| 58 |
const newVal = newSemesterVal.trim();
|
| 59 |
-
if (!semesters
|
| 60 |
-
|
| 61 |
}
|
| 62 |
-
setConfig(prev => ({ ...prev, semester: newVal }));
|
| 63 |
setNewSemesterVal('');
|
| 64 |
-
setIsAddingSemester(false);
|
| 65 |
}
|
| 66 |
};
|
| 67 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
if (fetching) return <div className="p-10 text-center text-gray-500">加载配置中...</div>;
|
| 69 |
|
| 70 |
return (
|
| 71 |
<div className="space-y-6">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
{/* General Settings */}
|
| 73 |
<div className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
|
| 74 |
<div className="p-6 border-b border-gray-100 flex items-center space-x-3">
|
| 75 |
<div className="p-2 bg-blue-50 rounded-lg text-blue-600">
|
| 76 |
<Database size={20} />
|
| 77 |
</div>
|
| 78 |
-
<h3 className="text-lg font-bold text-gray-800"
|
| 79 |
</div>
|
| 80 |
|
| 81 |
-
<div className="p-6 space-y-
|
| 82 |
-
<div className="
|
| 83 |
-
<
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
|
|
|
|
|
|
|
|
|
| 131 |
</div>
|
| 132 |
</div>
|
| 133 |
</div>
|
|
@@ -207,7 +240,7 @@ export const Settings: React.FC = () => {
|
|
| 207 |
className="flex items-center space-x-2 px-6 py-3 bg-blue-600 text-white rounded-xl font-medium hover:bg-blue-700 transition-colors shadow-lg shadow-blue-200"
|
| 208 |
>
|
| 209 |
{loading ? <Loader2 className="animate-spin" size={20} /> : <Save size={20} />}
|
| 210 |
-
<span
|
| 211 |
</button>
|
| 212 |
</div>
|
| 213 |
</div>
|
|
|
|
| 1 |
|
| 2 |
import React, { useState, useEffect } from 'react';
|
| 3 |
+
import { Save, Bell, Lock, Database, Loader2, Plus, X, Trash2, Globe, Edit } from 'lucide-react';
|
| 4 |
import { api } from '../services/api';
|
| 5 |
+
import { SystemConfig } from '../types';
|
| 6 |
|
| 7 |
export const Settings: React.FC = () => {
|
| 8 |
const [loading, setLoading] = useState(false);
|
| 9 |
const [fetching, setFetching] = useState(true);
|
| 10 |
|
| 11 |
+
const [config, setConfig] = useState<SystemConfig>({
|
| 12 |
systemName: '智慧校园管理系统',
|
| 13 |
allowRegister: true,
|
| 14 |
allowAdminRegister: false,
|
| 15 |
maintenanceMode: false,
|
| 16 |
emailNotify: true,
|
| 17 |
+
semester: '2023-2024学年 第一学期',
|
| 18 |
+
semesters: ['2023-2024学年 第一学期', '2023-2024学年 第二学期']
|
| 19 |
});
|
| 20 |
|
| 21 |
+
// Semester Management
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
const [newSemesterVal, setNewSemesterVal] = useState('');
|
| 23 |
+
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
| 24 |
+
const [editVal, setEditVal] = useState('');
|
| 25 |
|
| 26 |
useEffect(() => {
|
| 27 |
const loadConfig = async () => {
|
| 28 |
try {
|
| 29 |
const data = await api.config.get();
|
| 30 |
if (data && data.systemName) {
|
|
|
|
| 31 |
setConfig(prev => ({ ...prev, ...data }));
|
| 32 |
}
|
| 33 |
} catch (e) {
|
|
|
|
| 43 |
setLoading(true);
|
| 44 |
try {
|
| 45 |
await api.config.save(config);
|
| 46 |
+
alert('全局系统配置已保存!');
|
| 47 |
} catch (e) {
|
| 48 |
alert('保存失败');
|
| 49 |
} finally {
|
|
|
|
| 51 |
}
|
| 52 |
};
|
| 53 |
|
| 54 |
+
// --- Semester Logic ---
|
| 55 |
+
const addSemester = () => {
|
| 56 |
if (newSemesterVal.trim()) {
|
| 57 |
const newVal = newSemesterVal.trim();
|
| 58 |
+
if (!config.semesters?.includes(newVal)) {
|
| 59 |
+
setConfig(prev => ({ ...prev, semesters: [...(prev.semesters || []), newVal] }));
|
| 60 |
}
|
|
|
|
| 61 |
setNewSemesterVal('');
|
|
|
|
| 62 |
}
|
| 63 |
};
|
| 64 |
|
| 65 |
+
const removeSemester = (idx: number) => {
|
| 66 |
+
const list = [...(config.semesters || [])];
|
| 67 |
+
const valToRemove = list[idx];
|
| 68 |
+
if (config.semester === valToRemove) {
|
| 69 |
+
return alert('无法删除当前正在使用的学期,请先切换学期。');
|
| 70 |
+
}
|
| 71 |
+
list.splice(idx, 1);
|
| 72 |
+
setConfig(prev => ({ ...prev, semesters: list }));
|
| 73 |
+
};
|
| 74 |
+
|
| 75 |
+
const startEdit = (idx: number, val: string) => {
|
| 76 |
+
setEditingIndex(idx);
|
| 77 |
+
setEditVal(val);
|
| 78 |
+
};
|
| 79 |
+
|
| 80 |
+
const saveEdit = (idx: number) => {
|
| 81 |
+
if(!editVal.trim()) return;
|
| 82 |
+
const list = [...(config.semesters || [])];
|
| 83 |
+
// If we are editing the currently active semester, update selection too
|
| 84 |
+
if (config.semester === list[idx]) {
|
| 85 |
+
setConfig(prev => ({ ...prev, semester: editVal.trim() }));
|
| 86 |
+
}
|
| 87 |
+
list[idx] = editVal.trim();
|
| 88 |
+
setConfig(prev => ({ ...prev, semesters: list }));
|
| 89 |
+
setEditingIndex(null);
|
| 90 |
+
};
|
| 91 |
+
|
| 92 |
if (fetching) return <div className="p-10 text-center text-gray-500">加载配置中...</div>;
|
| 93 |
|
| 94 |
return (
|
| 95 |
<div className="space-y-6">
|
| 96 |
+
<div className="flex items-center space-x-2 bg-blue-50 text-blue-800 p-4 rounded-xl border border-blue-100">
|
| 97 |
+
<Globe size={20}/>
|
| 98 |
+
<span className="font-bold">全局系统配置中心</span>
|
| 99 |
+
<span className="text-xs bg-blue-200 text-blue-800 px-2 py-0.5 rounded-full ml-2">仅管理员可见</span>
|
| 100 |
+
</div>
|
| 101 |
+
|
| 102 |
{/* General Settings */}
|
| 103 |
<div className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
|
| 104 |
<div className="p-6 border-b border-gray-100 flex items-center space-x-3">
|
| 105 |
<div className="p-2 bg-blue-50 rounded-lg text-blue-600">
|
| 106 |
<Database size={20} />
|
| 107 |
</div>
|
| 108 |
+
<h3 className="text-lg font-bold text-gray-800">基础信息</h3>
|
| 109 |
</div>
|
| 110 |
|
| 111 |
+
<div className="p-6 space-y-6">
|
| 112 |
+
<div className="space-y-2">
|
| 113 |
+
<label className="text-sm font-medium text-gray-700">系统名称</label>
|
| 114 |
+
<input
|
| 115 |
+
type="text"
|
| 116 |
+
value={config.systemName}
|
| 117 |
+
onChange={(e) => setConfig({...config, systemName: e.target.value})}
|
| 118 |
+
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
| 119 |
+
/>
|
| 120 |
+
</div>
|
| 121 |
+
|
| 122 |
+
<div className="space-y-2">
|
| 123 |
+
<label className="text-sm font-medium text-gray-700">当前生效学期</label>
|
| 124 |
+
<select
|
| 125 |
+
value={config.semester}
|
| 126 |
+
onChange={(e) => setConfig({...config, semester: e.target.value})}
|
| 127 |
+
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none bg-white"
|
| 128 |
+
>
|
| 129 |
+
{(config.semesters || []).map(s => <option key={s} value={s}>{s}</option>)}
|
| 130 |
+
</select>
|
| 131 |
+
</div>
|
| 132 |
+
|
| 133 |
+
{/* Semester Management List */}
|
| 134 |
+
<div className="bg-gray-50 p-4 rounded-lg border border-gray-200">
|
| 135 |
+
<label className="text-xs font-bold text-gray-500 uppercase mb-2 block">学期列表管理</label>
|
| 136 |
+
<div className="space-y-2 mb-3">
|
| 137 |
+
{(config.semesters || []).map((s, idx) => (
|
| 138 |
+
<div key={idx} className="flex items-center gap-2 bg-white p-2 rounded border border-gray-200">
|
| 139 |
+
{editingIndex === idx ? (
|
| 140 |
+
<>
|
| 141 |
+
<input className="flex-1 border p-1 rounded text-sm" value={editVal} onChange={e=>setEditVal(e.target.value)} autoFocus/>
|
| 142 |
+
<button onClick={()=>saveEdit(idx)} className="text-green-600 hover:bg-green-50 p-1 rounded"><Save size={16}/></button>
|
| 143 |
+
<button onClick={()=>setEditingIndex(null)} className="text-gray-400 hover:bg-gray-50 p-1 rounded"><X size={16}/></button>
|
| 144 |
+
</>
|
| 145 |
+
) : (
|
| 146 |
+
<>
|
| 147 |
+
<span className="flex-1 text-sm text-gray-700">{s} {config.semester===s && <span className="text-xs text-blue-600 bg-blue-50 px-1 rounded ml-2">当前</span>}</span>
|
| 148 |
+
<button onClick={()=>startEdit(idx, s)} className="text-gray-400 hover:text-blue-600 p-1"><Edit size={14}/></button>
|
| 149 |
+
<button onClick={()=>removeSemester(idx)} className="text-gray-400 hover:text-red-600 p-1"><Trash2 size={14}/></button>
|
| 150 |
+
</>
|
| 151 |
+
)}
|
| 152 |
+
</div>
|
| 153 |
+
))}
|
| 154 |
+
</div>
|
| 155 |
+
<div className="flex gap-2">
|
| 156 |
+
<input
|
| 157 |
+
className="flex-1 border border-gray-300 rounded px-3 py-1 text-sm"
|
| 158 |
+
placeholder="输入新学期名称"
|
| 159 |
+
value={newSemesterVal}
|
| 160 |
+
onChange={e=>setNewSemesterVal(e.target.value)}
|
| 161 |
+
/>
|
| 162 |
+
<button onClick={addSemester} disabled={!newSemesterVal.trim()} className="px-3 py-1 bg-blue-600 text-white rounded text-sm disabled:opacity-50">添加</button>
|
| 163 |
+
</div>
|
| 164 |
</div>
|
| 165 |
</div>
|
| 166 |
</div>
|
|
|
|
| 240 |
className="flex items-center space-x-2 px-6 py-3 bg-blue-600 text-white rounded-xl font-medium hover:bg-blue-700 transition-colors shadow-lg shadow-blue-200"
|
| 241 |
>
|
| 242 |
{loading ? <Loader2 className="animate-spin" size={20} /> : <Save size={20} />}
|
| 243 |
+
<span>保存全局配置</span>
|
| 244 |
</button>
|
| 245 |
</div>
|
| 246 |
</div>
|
pages/StudentList.tsx
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
|
|
| 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';
|
|
@@ -21,14 +22,17 @@ export const StudentList: React.FC = () => {
|
|
| 21 |
const [importFile, setImportFile] = useState<File | null>(null);
|
| 22 |
const [importTargetClass, setImportTargetClass] = useState('');
|
| 23 |
|
| 24 |
-
// Form State
|
| 25 |
const [formData, setFormData] = useState({
|
| 26 |
name: '',
|
| 27 |
studentNo: '',
|
| 28 |
gender: 'Male',
|
| 29 |
className: '',
|
| 30 |
phone: '',
|
| 31 |
-
idCard: ''
|
|
|
|
|
|
|
|
|
|
| 32 |
});
|
| 33 |
|
| 34 |
const loadData = async () => {
|
|
@@ -140,7 +144,11 @@ export const StudentList: React.FC = () => {
|
|
| 140 |
className: targetClassName,
|
| 141 |
phone: cleanRow['电话'] || String(cleanRow['联系方式'] || ''),
|
| 142 |
birthday: '2015-01-01',
|
| 143 |
-
status: 'Enrolled'
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
}).then(() => successCount++).catch(err => console.error(err));
|
| 145 |
});
|
| 146 |
|
|
@@ -167,8 +175,6 @@ export const StudentList: React.FC = () => {
|
|
| 167 |
});
|
| 168 |
|
| 169 |
const uniqueGrades = Array.from(new Set(classList.map(c => c.grade))).sort();
|
| 170 |
-
|
| 171 |
-
// FIX: Cascading Filter - Only show classes belonging to selected grade
|
| 172 |
const filteredClasses = classList.filter(c => selectedGrade === 'All' || c.grade === selectedGrade);
|
| 173 |
const uniqueClasses = Array.from(new Set(filteredClasses.map(c => c.className))).sort();
|
| 174 |
|
|
@@ -217,6 +223,7 @@ export const StudentList: React.FC = () => {
|
|
| 217 |
<th className="px-6 py-3">基本信息</th>
|
| 218 |
<th className="px-6 py-3">学号</th>
|
| 219 |
<th className="px-6 py-3">班级</th>
|
|
|
|
| 220 |
<th className="px-6 py-3 text-right">操作</th>
|
| 221 |
</tr>
|
| 222 |
</thead>
|
|
@@ -228,19 +235,30 @@ export const StudentList: React.FC = () => {
|
|
| 228 |
</td>
|
| 229 |
<td className="px-6 py-4 flex items-center space-x-3">
|
| 230 |
<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>
|
| 231 |
-
<
|
|
|
|
|
|
|
|
|
|
| 232 |
</td>
|
| 233 |
<td className="px-6 py-4 text-sm font-mono text-gray-600">{s.studentNo}</td>
|
| 234 |
<td className="px-6 py-4 text-sm">
|
| 235 |
<span className="bg-gray-100 px-2 py-1 rounded text-gray-600 text-xs">{s.className}</span>
|
| 236 |
</td>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 237 |
<td className="px-6 py-4 text-right">
|
| 238 |
<button onClick={() => handleDelete(s._id || String(s.id))} className="text-red-400 hover:text-red-600"><Trash2 size={16}/></button>
|
| 239 |
</td>
|
| 240 |
</tr>
|
| 241 |
)) : (
|
| 242 |
<tr>
|
| 243 |
-
<td colSpan={
|
| 244 |
</tr>
|
| 245 |
)}
|
| 246 |
</tbody>
|
|
@@ -256,20 +274,35 @@ export const StudentList: React.FC = () => {
|
|
| 256 |
|
| 257 |
{isModalOpen && (
|
| 258 |
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
| 259 |
-
<div className="bg-white rounded-xl p-6 w-full max-w-
|
| 260 |
-
<h3 className="font-bold text-lg mb-4"
|
| 261 |
<form onSubmit={handleAddSubmit} className="space-y-4">
|
| 262 |
-
<
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 273 |
<button type="button" onClick={()=>setIsModalOpen(false)} className="w-full border py-2 rounded mt-2">取消</button>
|
| 274 |
</form>
|
| 275 |
</div>
|
|
@@ -296,7 +329,7 @@ export const StudentList: React.FC = () => {
|
|
| 296 |
<div className="relative border-2 border-dashed border-gray-300 rounded-lg p-6 text-center hover:bg-gray-50 transition-colors">
|
| 297 |
<Upload className="mx-auto h-10 w-10 text-gray-400" />
|
| 298 |
<p className="mt-2 text-sm text-gray-600">点击上传 .xlsx 文件</p>
|
| 299 |
-
<p className="text-xs text-gray-400 mt-1"
|
| 300 |
<input
|
| 301 |
type="file"
|
| 302 |
accept=".xlsx, .xls"
|
|
@@ -326,4 +359,4 @@ export const StudentList: React.FC = () => {
|
|
| 326 |
)}
|
| 327 |
</div>
|
| 328 |
);
|
| 329 |
-
};
|
|
|
|
| 1 |
+
|
| 2 |
import React, { useState, useEffect } from 'react';
|
| 3 |
import { Search, Plus, Upload, Edit, Trash2, X, Loader2, User, FileSpreadsheet } from 'lucide-react';
|
| 4 |
import { api } from '../services/api';
|
|
|
|
| 22 |
const [importFile, setImportFile] = useState<File | null>(null);
|
| 23 |
const [importTargetClass, setImportTargetClass] = useState('');
|
| 24 |
|
| 25 |
+
// Form State (Extended)
|
| 26 |
const [formData, setFormData] = useState({
|
| 27 |
name: '',
|
| 28 |
studentNo: '',
|
| 29 |
gender: 'Male',
|
| 30 |
className: '',
|
| 31 |
phone: '',
|
| 32 |
+
idCard: '',
|
| 33 |
+
parentName: '',
|
| 34 |
+
parentPhone: '',
|
| 35 |
+
address: ''
|
| 36 |
});
|
| 37 |
|
| 38 |
const loadData = async () => {
|
|
|
|
| 144 |
className: targetClassName,
|
| 145 |
phone: cleanRow['电话'] || String(cleanRow['联系方式'] || ''),
|
| 146 |
birthday: '2015-01-01',
|
| 147 |
+
status: 'Enrolled',
|
| 148 |
+
// Extended
|
| 149 |
+
parentName: cleanRow['家长姓名'] || cleanRow['家长'] || '',
|
| 150 |
+
parentPhone: cleanRow['家长电话'] || cleanRow['联系电话'] || '',
|
| 151 |
+
address: cleanRow['家庭住址'] || cleanRow['住址'] || cleanRow['地址'] || ''
|
| 152 |
}).then(() => successCount++).catch(err => console.error(err));
|
| 153 |
});
|
| 154 |
|
|
|
|
| 175 |
});
|
| 176 |
|
| 177 |
const uniqueGrades = Array.from(new Set(classList.map(c => c.grade))).sort();
|
|
|
|
|
|
|
| 178 |
const filteredClasses = classList.filter(c => selectedGrade === 'All' || c.grade === selectedGrade);
|
| 179 |
const uniqueClasses = Array.from(new Set(filteredClasses.map(c => c.className))).sort();
|
| 180 |
|
|
|
|
| 223 |
<th className="px-6 py-3">基本信息</th>
|
| 224 |
<th className="px-6 py-3">学号</th>
|
| 225 |
<th className="px-6 py-3">班级</th>
|
| 226 |
+
<th className="px-6 py-3">家庭信息</th>
|
| 227 |
<th className="px-6 py-3 text-right">操作</th>
|
| 228 |
</tr>
|
| 229 |
</thead>
|
|
|
|
| 235 |
</td>
|
| 236 |
<td className="px-6 py-4 flex items-center space-x-3">
|
| 237 |
<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>
|
| 238 |
+
<div>
|
| 239 |
+
<span className="font-bold text-gray-800">{s.name}</span>
|
| 240 |
+
<div className="text-xs text-gray-400">{s.idCard || '身份证未录入'}</div>
|
| 241 |
+
</div>
|
| 242 |
</td>
|
| 243 |
<td className="px-6 py-4 text-sm font-mono text-gray-600">{s.studentNo}</td>
|
| 244 |
<td className="px-6 py-4 text-sm">
|
| 245 |
<span className="bg-gray-100 px-2 py-1 rounded text-gray-600 text-xs">{s.className}</span>
|
| 246 |
</td>
|
| 247 |
+
<td className="px-6 py-4 text-xs text-gray-500 max-w-xs truncate">
|
| 248 |
+
{s.parentName ? (
|
| 249 |
+
<div className="flex flex-col">
|
| 250 |
+
<span>{s.parentName} {s.parentPhone}</span>
|
| 251 |
+
<span className="text-gray-400 truncate" title={s.address}>{s.address}</span>
|
| 252 |
+
</div>
|
| 253 |
+
) : '-'}
|
| 254 |
+
</td>
|
| 255 |
<td className="px-6 py-4 text-right">
|
| 256 |
<button onClick={() => handleDelete(s._id || String(s.id))} className="text-red-400 hover:text-red-600"><Trash2 size={16}/></button>
|
| 257 |
</td>
|
| 258 |
</tr>
|
| 259 |
)) : (
|
| 260 |
<tr>
|
| 261 |
+
<td colSpan={6} className="text-center py-10 text-gray-400">没有找到匹配的学生</td>
|
| 262 |
</tr>
|
| 263 |
)}
|
| 264 |
</tbody>
|
|
|
|
| 274 |
|
| 275 |
{isModalOpen && (
|
| 276 |
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
| 277 |
+
<div className="bg-white rounded-xl p-6 w-full max-w-lg max-h-[90vh] overflow-y-auto">
|
| 278 |
+
<h3 className="font-bold text-lg mb-4">新增学生档案</h3>
|
| 279 |
<form onSubmit={handleAddSubmit} className="space-y-4">
|
| 280 |
+
<div className="grid grid-cols-2 gap-4">
|
| 281 |
+
<input className="w-full border p-2 rounded" placeholder="姓名 *" value={formData.name} onChange={e=>setFormData({...formData, name:e.target.value})} required/>
|
| 282 |
+
<input className="w-full border p-2 rounded" placeholder="学号 *" value={formData.studentNo} onChange={e=>setFormData({...formData, studentNo:e.target.value})} required/>
|
| 283 |
+
</div>
|
| 284 |
+
<div className="grid grid-cols-2 gap-4">
|
| 285 |
+
<select className="w-full border p-2 rounded" value={formData.className} onChange={e=>setFormData({...formData, className:e.target.value})} required>
|
| 286 |
+
<option value="">选择班级 *</option>
|
| 287 |
+
{classList.map(c=><option key={c._id} value={c.grade+c.className}>{c.grade}{c.className}</option>)}
|
| 288 |
+
</select>
|
| 289 |
+
<select className="w-full border p-2 rounded" value={formData.gender} onChange={e=>setFormData({...formData, gender:e.target.value})} required>
|
| 290 |
+
<option value="Male">男</option>
|
| 291 |
+
<option value="Female">女</option>
|
| 292 |
+
</select>
|
| 293 |
+
</div>
|
| 294 |
+
<input className="w-full border p-2 rounded" placeholder="身份证号 (选填)" value={formData.idCard} onChange={e=>setFormData({...formData, idCard:e.target.value})}/>
|
| 295 |
+
|
| 296 |
+
<div className="border-t border-gray-100 pt-4 mt-2">
|
| 297 |
+
<p className="text-xs font-bold text-gray-500 uppercase mb-2">家庭信息 (选填)</p>
|
| 298 |
+
<div className="grid grid-cols-2 gap-4 mb-2">
|
| 299 |
+
<input className="w-full border p-2 rounded" placeholder="家长姓名" value={formData.parentName} onChange={e=>setFormData({...formData, parentName:e.target.value})}/>
|
| 300 |
+
<input className="w-full border p-2 rounded" placeholder="家长电话" value={formData.parentPhone} onChange={e=>setFormData({...formData, parentPhone:e.target.value})}/>
|
| 301 |
+
</div>
|
| 302 |
+
<input className="w-full border p-2 rounded" placeholder="家庭住址" value={formData.address} onChange={e=>setFormData({...formData, address:e.target.value})}/>
|
| 303 |
+
</div>
|
| 304 |
+
|
| 305 |
+
<button type="submit" disabled={submitting} className="w-full bg-blue-600 text-white py-2 rounded mt-4">{submitting?'提交中':'保存'}</button>
|
| 306 |
<button type="button" onClick={()=>setIsModalOpen(false)} className="w-full border py-2 rounded mt-2">取消</button>
|
| 307 |
</form>
|
| 308 |
</div>
|
|
|
|
| 329 |
<div className="relative border-2 border-dashed border-gray-300 rounded-lg p-6 text-center hover:bg-gray-50 transition-colors">
|
| 330 |
<Upload className="mx-auto h-10 w-10 text-gray-400" />
|
| 331 |
<p className="mt-2 text-sm text-gray-600">点击上传 .xlsx 文件</p>
|
| 332 |
+
<p className="text-xs text-gray-400 mt-1">支持列名:姓名, 学号, 家长姓名, 家长电话, 地址</p>
|
| 333 |
<input
|
| 334 |
type="file"
|
| 335 |
accept=".xlsx, .xls"
|
|
|
|
| 359 |
)}
|
| 360 |
</div>
|
| 361 |
);
|
| 362 |
+
};
|
server.js
CHANGED
|
@@ -14,7 +14,7 @@ const MONGO_URI = 'mongodb+srv://dv890a:db8822723@chatpro.gw3v0v7.mongodb.net/ch
|
|
| 14 |
const app = express();
|
| 15 |
|
| 16 |
app.use(cors());
|
| 17 |
-
app.use(bodyParser.json());
|
| 18 |
app.use(express.static(path.join(__dirname, 'dist')));
|
| 19 |
|
| 20 |
// ==========================================
|
|
@@ -72,8 +72,8 @@ const UserSchema = new mongoose.Schema({
|
|
| 72 |
status: { type: String, enum: ['active', 'pending', 'banned'], default: 'pending' },
|
| 73 |
avatar: String,
|
| 74 |
createTime: { type: Date, default: Date.now },
|
| 75 |
-
teachingSubject: String,
|
| 76 |
-
homeroomClass: String
|
| 77 |
});
|
| 78 |
const User = mongoose.model('User', UserSchema);
|
| 79 |
|
|
@@ -86,7 +86,11 @@ const StudentSchema = new mongoose.Schema({
|
|
| 86 |
idCard: String,
|
| 87 |
phone: String,
|
| 88 |
className: String,
|
| 89 |
-
status: { type: String, default: 'Enrolled' }
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
});
|
| 91 |
// Composite index for uniqueness within school
|
| 92 |
StudentSchema.index({ schoolId: 1, studentNo: 1 }, { unique: true });
|
|
@@ -142,12 +146,13 @@ const ExamSchema = new mongoose.Schema({
|
|
| 142 |
const ExamModel = mongoose.model('Exam', ExamSchema);
|
| 143 |
|
| 144 |
const ConfigSchema = new mongoose.Schema({
|
| 145 |
-
|
| 146 |
-
key: { type: String, default: 'main' },
|
| 147 |
systemName: String,
|
| 148 |
semester: String,
|
|
|
|
| 149 |
allowRegister: Boolean,
|
| 150 |
-
allowAdminRegister: { type: Boolean, default: false },
|
| 151 |
maintenanceMode: Boolean,
|
| 152 |
emailNotify: Boolean
|
| 153 |
});
|
|
@@ -159,24 +164,19 @@ const initData = async () => {
|
|
| 159 |
try {
|
| 160 |
console.log('🔄 Checking database initialization...');
|
| 161 |
|
| 162 |
-
//
|
| 163 |
-
// In multi-tenancy, 'name' on subjects should NOT be unique globally.
|
| 164 |
-
// We try to drop the index 'name_1' if it exists.
|
| 165 |
try {
|
| 166 |
await mongoose.connection.collection('subjects').dropIndex('name_1');
|
| 167 |
-
|
| 168 |
-
} catch (e) {
|
| 169 |
-
// Index might not exist or error code 27 (index not found), ignore
|
| 170 |
-
}
|
| 171 |
|
| 172 |
-
// 1.
|
| 173 |
let defaultSchool = await School.findOne({ code: 'EXP01' });
|
| 174 |
if (!defaultSchool) {
|
| 175 |
defaultSchool = await School.create({ name: '第一实验小学', code: 'EXP01' });
|
| 176 |
console.log('✅ Initialized Default School');
|
| 177 |
}
|
| 178 |
|
| 179 |
-
// 2.
|
| 180 |
const adminExists = await User.findOne({ username: 'admin' });
|
| 181 |
if (!adminExists) {
|
| 182 |
await User.create({
|
|
@@ -191,23 +191,19 @@ const initData = async () => {
|
|
| 191 |
console.log('✅ Initialized Admin User');
|
| 192 |
}
|
| 193 |
|
| 194 |
-
// 3.
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
{ $set: sub },
|
| 207 |
-
{ upsert: true }
|
| 208 |
-
);
|
| 209 |
}
|
| 210 |
-
console.log('✅ Initialized/Updated Default Subjects');
|
| 211 |
|
| 212 |
} catch (err) {
|
| 213 |
console.error('❌ Init Data Error', err);
|
|
@@ -241,10 +237,9 @@ app.get('/api/public/schools', async (req, res) => {
|
|
| 241 |
});
|
| 242 |
|
| 243 |
app.get('/api/public/config', async (req, res) => {
|
| 244 |
-
// Try to find any config or default
|
| 245 |
if (InMemoryDB.isFallback) return res.json({ allowRegister: true, allowAdminRegister: true });
|
| 246 |
-
//
|
| 247 |
-
const config = await ConfigModel.findOne() || { allowRegister: true, allowAdminRegister: false };
|
| 248 |
res.json(config);
|
| 249 |
});
|
| 250 |
|
|
@@ -284,7 +279,6 @@ app.post('/api/auth/login', async (req, res) => {
|
|
| 284 |
});
|
| 285 |
|
| 286 |
app.post('/api/auth/register', async (req, res) => {
|
| 287 |
-
// Accepted fields
|
| 288 |
const {
|
| 289 |
username, password, role, schoolId, trueName, phone, email, avatar,
|
| 290 |
teachingSubject, homeroomClass
|
|
@@ -331,20 +325,20 @@ app.put('/api/schools/:id', async (req, res) => {
|
|
| 331 |
|
| 332 |
// --- Users ---
|
| 333 |
app.get('/api/users', async (req, res) => {
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
const filter = global === 'true' ? {} : getQueryFilter(req);
|
| 337 |
|
|
|
|
|
|
|
| 338 |
if (InMemoryDB.isFallback) {
|
| 339 |
if (global === 'true') return res.json(InMemoryDB.users);
|
| 340 |
-
return res.json(InMemoryDB.users.filter(u => !filter.schoolId || u.schoolId === filter.schoolId));
|
| 341 |
}
|
| 342 |
const users = await User.find(filter).sort({ createTime: -1 });
|
| 343 |
res.json(users);
|
| 344 |
});
|
| 345 |
app.put('/api/users/:id', async (req, res) => {
|
| 346 |
try {
|
| 347 |
-
// Check if activating user
|
| 348 |
const updateData = req.body;
|
| 349 |
|
| 350 |
if (InMemoryDB.isFallback) {
|
|
@@ -355,18 +349,13 @@ app.put('/api/users/:id', async (req, res) => {
|
|
| 355 |
|
| 356 |
const user = await User.findByIdAndUpdate(req.params.id, updateData, { new: true });
|
| 357 |
|
| 358 |
-
// Linkage Logic: If approving a teacher, and they are a homeroom teacher, update the Class record.
|
| 359 |
if (updateData.status === 'active' && user.role === 'TEACHER' && user.homeroomClass) {
|
| 360 |
-
// Find Class by name (e.g. "六年级(1)班") and SchoolId
|
| 361 |
const classes = await ClassModel.find({ schoolId: user.schoolId });
|
| 362 |
const targetClass = classes.find(c => (c.grade + c.className) === user.homeroomClass);
|
| 363 |
-
|
| 364 |
if (targetClass) {
|
| 365 |
await ClassModel.findByIdAndUpdate(targetClass._id, { teacherName: user.trueName || user.username });
|
| 366 |
-
console.log(`Linked teacher ${user.trueName} to class ${targetClass.grade}${targetClass.className}`);
|
| 367 |
}
|
| 368 |
}
|
| 369 |
-
|
| 370 |
res.json({ success: true });
|
| 371 |
} catch (e) { res.status(500).json({ error: e.message }); }
|
| 372 |
});
|
|
@@ -503,8 +492,6 @@ app.delete('/api/scores/:id', async (req, res) => {
|
|
| 503 |
// --- Stats & Config ---
|
| 504 |
app.get('/api/stats', async (req, res) => {
|
| 505 |
const filter = getQueryFilter(req);
|
| 506 |
-
// ... (Stats Logic adjusted for filter) ...
|
| 507 |
-
// Keeping simple for now
|
| 508 |
if (InMemoryDB.isFallback) return res.json({ studentCount: 0, courseCount: 0, avgScore: 0, excellentRate: '0%' });
|
| 509 |
const studentCount = await Student.countDocuments(filter);
|
| 510 |
const courseCount = await Course.countDocuments(filter);
|
|
@@ -517,20 +504,19 @@ app.get('/api/stats', async (req, res) => {
|
|
| 517 |
res.json({ studentCount, courseCount, avgScore, excellentRate });
|
| 518 |
});
|
| 519 |
|
|
|
|
| 520 |
app.get('/api/config', async (req, res) => {
|
| 521 |
-
const filter = getQueryFilter(req);
|
| 522 |
if (InMemoryDB.isFallback) return res.json(InMemoryDB.config);
|
| 523 |
-
res.json(await ConfigModel.findOne({
|
| 524 |
});
|
| 525 |
app.post('/api/config', async (req, res) => {
|
| 526 |
-
|
| 527 |
-
if (InMemoryDB.isFallback) { InMemoryDB.config =
|
| 528 |
-
res.json(await ConfigModel.findOneAndUpdate({
|
| 529 |
});
|
| 530 |
|
| 531 |
app.post('/api/batch-delete', async (req, res) => {
|
| 532 |
const { type, ids } = req.body;
|
| 533 |
-
// Note: For batch delete, strict school checking should ideally be here too.
|
| 534 |
if (type === 'student') await Student.deleteMany({ _id: { $in: ids } });
|
| 535 |
if (type === 'score') await Score.deleteMany({ _id: { $in: ids } });
|
| 536 |
if (type === 'user') await User.deleteMany({ _id: { $in: ids } });
|
|
|
|
| 14 |
const app = express();
|
| 15 |
|
| 16 |
app.use(cors());
|
| 17 |
+
app.use(bodyParser.json({ limit: '10mb' })); // Increased limit for avatar base64
|
| 18 |
app.use(express.static(path.join(__dirname, 'dist')));
|
| 19 |
|
| 20 |
// ==========================================
|
|
|
|
| 72 |
status: { type: String, enum: ['active', 'pending', 'banned'], default: 'pending' },
|
| 73 |
avatar: String,
|
| 74 |
createTime: { type: Date, default: Date.now },
|
| 75 |
+
teachingSubject: String,
|
| 76 |
+
homeroomClass: String
|
| 77 |
});
|
| 78 |
const User = mongoose.model('User', UserSchema);
|
| 79 |
|
|
|
|
| 86 |
idCard: String,
|
| 87 |
phone: String,
|
| 88 |
className: String,
|
| 89 |
+
status: { type: String, default: 'Enrolled' },
|
| 90 |
+
// Extended Fields
|
| 91 |
+
parentName: String,
|
| 92 |
+
parentPhone: String,
|
| 93 |
+
address: String
|
| 94 |
});
|
| 95 |
// Composite index for uniqueness within school
|
| 96 |
StudentSchema.index({ schoolId: 1, studentNo: 1 }, { unique: true });
|
|
|
|
| 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 },
|
| 151 |
systemName: String,
|
| 152 |
semester: String,
|
| 153 |
+
semesters: [String], // Array of semester strings
|
| 154 |
allowRegister: Boolean,
|
| 155 |
+
allowAdminRegister: { type: Boolean, default: false },
|
| 156 |
maintenanceMode: Boolean,
|
| 157 |
emailNotify: Boolean
|
| 158 |
});
|
|
|
|
| 164 |
try {
|
| 165 |
console.log('🔄 Checking database initialization...');
|
| 166 |
|
| 167 |
+
// Drop legacy indexes
|
|
|
|
|
|
|
| 168 |
try {
|
| 169 |
await mongoose.connection.collection('subjects').dropIndex('name_1');
|
| 170 |
+
} catch (e) {}
|
|
|
|
|
|
|
|
|
|
| 171 |
|
| 172 |
+
// 1. Default School
|
| 173 |
let defaultSchool = await School.findOne({ code: 'EXP01' });
|
| 174 |
if (!defaultSchool) {
|
| 175 |
defaultSchool = await School.create({ name: '第一实验小学', code: 'EXP01' });
|
| 176 |
console.log('✅ Initialized Default School');
|
| 177 |
}
|
| 178 |
|
| 179 |
+
// 2. Admin
|
| 180 |
const adminExists = await User.findOne({ username: 'admin' });
|
| 181 |
if (!adminExists) {
|
| 182 |
await User.create({
|
|
|
|
| 191 |
console.log('✅ Initialized Admin User');
|
| 192 |
}
|
| 193 |
|
| 194 |
+
// 3. Global Config
|
| 195 |
+
const configExists = await ConfigModel.findOne({ key: 'main' });
|
| 196 |
+
if (!configExists) {
|
| 197 |
+
await ConfigModel.create({
|
| 198 |
+
key: 'main',
|
| 199 |
+
systemName: '智慧校园管理系统',
|
| 200 |
+
semester: '2023-2024学年 第一学期',
|
| 201 |
+
semesters: ['2023-2024学年 第一学期', '2023-2024学年 第二学期'],
|
| 202 |
+
allowRegister: true,
|
| 203 |
+
allowAdminRegister: false
|
| 204 |
+
});
|
| 205 |
+
console.log('✅ Initialized Global Config');
|
|
|
|
|
|
|
|
|
|
| 206 |
}
|
|
|
|
| 207 |
|
| 208 |
} catch (err) {
|
| 209 |
console.error('❌ Init Data Error', err);
|
|
|
|
| 237 |
});
|
| 238 |
|
| 239 |
app.get('/api/public/config', async (req, res) => {
|
|
|
|
| 240 |
if (InMemoryDB.isFallback) return res.json({ allowRegister: true, allowAdminRegister: true });
|
| 241 |
+
// Global config
|
| 242 |
+
const config = await ConfigModel.findOne({ key: 'main' }) || { allowRegister: true, allowAdminRegister: false };
|
| 243 |
res.json(config);
|
| 244 |
});
|
| 245 |
|
|
|
|
| 279 |
});
|
| 280 |
|
| 281 |
app.post('/api/auth/register', async (req, res) => {
|
|
|
|
| 282 |
const {
|
| 283 |
username, password, role, schoolId, trueName, phone, email, avatar,
|
| 284 |
teachingSubject, homeroomClass
|
|
|
|
| 325 |
|
| 326 |
// --- Users ---
|
| 327 |
app.get('/api/users', async (req, res) => {
|
| 328 |
+
const { global, role } = req.query; // Add role filter support
|
| 329 |
+
let filter = global === 'true' ? {} : getQueryFilter(req);
|
|
|
|
| 330 |
|
| 331 |
+
if (role) filter.role = role;
|
| 332 |
+
|
| 333 |
if (InMemoryDB.isFallback) {
|
| 334 |
if (global === 'true') return res.json(InMemoryDB.users);
|
| 335 |
+
return res.json(InMemoryDB.users.filter(u => (!filter.schoolId || u.schoolId === filter.schoolId) && (!role || u.role === role)));
|
| 336 |
}
|
| 337 |
const users = await User.find(filter).sort({ createTime: -1 });
|
| 338 |
res.json(users);
|
| 339 |
});
|
| 340 |
app.put('/api/users/:id', async (req, res) => {
|
| 341 |
try {
|
|
|
|
| 342 |
const updateData = req.body;
|
| 343 |
|
| 344 |
if (InMemoryDB.isFallback) {
|
|
|
|
| 349 |
|
| 350 |
const user = await User.findByIdAndUpdate(req.params.id, updateData, { new: true });
|
| 351 |
|
|
|
|
| 352 |
if (updateData.status === 'active' && user.role === 'TEACHER' && user.homeroomClass) {
|
|
|
|
| 353 |
const classes = await ClassModel.find({ schoolId: user.schoolId });
|
| 354 |
const targetClass = classes.find(c => (c.grade + c.className) === user.homeroomClass);
|
|
|
|
| 355 |
if (targetClass) {
|
| 356 |
await ClassModel.findByIdAndUpdate(targetClass._id, { teacherName: user.trueName || user.username });
|
|
|
|
| 357 |
}
|
| 358 |
}
|
|
|
|
| 359 |
res.json({ success: true });
|
| 360 |
} catch (e) { res.status(500).json({ error: e.message }); }
|
| 361 |
});
|
|
|
|
| 492 |
// --- Stats & Config ---
|
| 493 |
app.get('/api/stats', async (req, res) => {
|
| 494 |
const filter = getQueryFilter(req);
|
|
|
|
|
|
|
| 495 |
if (InMemoryDB.isFallback) return res.json({ studentCount: 0, courseCount: 0, avgScore: 0, excellentRate: '0%' });
|
| 496 |
const studentCount = await Student.countDocuments(filter);
|
| 497 |
const courseCount = await Course.countDocuments(filter);
|
|
|
|
| 504 |
res.json({ studentCount, courseCount, avgScore, excellentRate });
|
| 505 |
});
|
| 506 |
|
| 507 |
+
// Global Config (No school filter)
|
| 508 |
app.get('/api/config', async (req, res) => {
|
|
|
|
| 509 |
if (InMemoryDB.isFallback) return res.json(InMemoryDB.config);
|
| 510 |
+
res.json(await ConfigModel.findOne({ key: 'main' }) || {});
|
| 511 |
});
|
| 512 |
app.post('/api/config', async (req, res) => {
|
| 513 |
+
// Global config update
|
| 514 |
+
if (InMemoryDB.isFallback) { InMemoryDB.config = req.body; return res.json({}); }
|
| 515 |
+
res.json(await ConfigModel.findOneAndUpdate({ key: 'main' }, req.body, { upsert: true, new: true }));
|
| 516 |
});
|
| 517 |
|
| 518 |
app.post('/api/batch-delete', async (req, res) => {
|
| 519 |
const { type, ids } = req.body;
|
|
|
|
| 520 |
if (type === 'student') await Student.deleteMany({ _id: { $in: ids } });
|
| 521 |
if (type === 'score') await Score.deleteMany({ _id: { $in: ids } });
|
| 522 |
if (type === 'user') await User.deleteMany({ _id: { $in: ids } });
|
services/api.ts
CHANGED
|
@@ -93,7 +93,13 @@ export const api = {
|
|
| 93 |
|
| 94 |
users: {
|
| 95 |
// If global is true, ask backend to ignore schoolId filter
|
| 96 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
update: (id: string, data: Partial<User>) => request(`/users/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
| 98 |
delete: (id: string) => request(`/users/${id}`, { method: 'DELETE' })
|
| 99 |
},
|
|
@@ -141,7 +147,7 @@ export const api = {
|
|
| 141 |
},
|
| 142 |
|
| 143 |
config: {
|
| 144 |
-
get: () => request('/config'),
|
| 145 |
getPublic: () => request('/public/config'),
|
| 146 |
save: (data: SystemConfig) => request('/config', { method: 'POST', body: JSON.stringify(data) })
|
| 147 |
},
|
|
|
|
| 93 |
|
| 94 |
users: {
|
| 95 |
// If global is true, ask backend to ignore schoolId filter
|
| 96 |
+
// Added role parameter to filter teachers
|
| 97 |
+
getAll: (options?: { global?: boolean; role?: string }) => {
|
| 98 |
+
const params = new URLSearchParams();
|
| 99 |
+
if (options?.global) params.append('global', 'true');
|
| 100 |
+
if (options?.role) params.append('role', options.role);
|
| 101 |
+
return request(`/users?${params.toString()}`);
|
| 102 |
+
},
|
| 103 |
update: (id: string, data: Partial<User>) => request(`/users/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
| 104 |
delete: (id: string) => request(`/users/${id}`, { method: 'DELETE' })
|
| 105 |
},
|
|
|
|
| 147 |
},
|
| 148 |
|
| 149 |
config: {
|
| 150 |
+
get: () => request('/config'), // Global
|
| 151 |
getPublic: () => request('/public/config'),
|
| 152 |
save: (data: SystemConfig) => request('/config', { method: 'POST', body: JSON.stringify(data) })
|
| 153 |
},
|
types.ts
CHANGED
|
@@ -57,11 +57,12 @@ export interface Subject {
|
|
| 57 |
}
|
| 58 |
|
| 59 |
export interface SystemConfig {
|
| 60 |
-
schoolId
|
| 61 |
systemName: string;
|
| 62 |
-
semester: string;
|
|
|
|
| 63 |
allowRegister: boolean;
|
| 64 |
-
allowAdminRegister: boolean;
|
| 65 |
maintenanceMode: boolean;
|
| 66 |
emailNotify: boolean;
|
| 67 |
}
|
|
@@ -78,6 +79,10 @@ export interface Student {
|
|
| 78 |
phone: string;
|
| 79 |
className: string;
|
| 80 |
status: 'Enrolled' | 'Graduated' | 'Suspended';
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
}
|
| 82 |
|
| 83 |
export interface Course {
|
|
|
|
| 57 |
}
|
| 58 |
|
| 59 |
export interface SystemConfig {
|
| 60 |
+
// Global config, no schoolId
|
| 61 |
systemName: string;
|
| 62 |
+
semester: string; // Current active semester
|
| 63 |
+
semesters?: string[]; // List of available semesters
|
| 64 |
allowRegister: boolean;
|
| 65 |
+
allowAdminRegister: boolean;
|
| 66 |
maintenanceMode: boolean;
|
| 67 |
emailNotify: boolean;
|
| 68 |
}
|
|
|
|
| 79 |
phone: string;
|
| 80 |
className: string;
|
| 81 |
status: 'Enrolled' | 'Graduated' | 'Suspended';
|
| 82 |
+
// Extended info
|
| 83 |
+
parentName?: string;
|
| 84 |
+
parentPhone?: string;
|
| 85 |
+
address?: string;
|
| 86 |
}
|
| 87 |
|
| 88 |
export interface Course {
|