Spaces:
Sleeping
Sleeping
Upload 40 files
Browse files- models.js +116 -0
- pages/StudentReports.tsx +42 -5
- pages/TeacherReports.tsx +101 -26
- server.js +32 -252
models.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
const mongoose = require('mongoose');
|
| 3 |
+
|
| 4 |
+
const SchoolSchema = new mongoose.Schema({ name: String, code: String });
|
| 5 |
+
const School = mongoose.model('School', SchoolSchema);
|
| 6 |
+
|
| 7 |
+
const UserSchema = new mongoose.Schema({
|
| 8 |
+
username: String,
|
| 9 |
+
password: String,
|
| 10 |
+
trueName: String,
|
| 11 |
+
phone: String,
|
| 12 |
+
email: String,
|
| 13 |
+
schoolId: String,
|
| 14 |
+
role: String,
|
| 15 |
+
status: String,
|
| 16 |
+
avatar: String,
|
| 17 |
+
createTime: Date,
|
| 18 |
+
teachingSubject: String,
|
| 19 |
+
homeroomClass: String,
|
| 20 |
+
studentNo: String,
|
| 21 |
+
parentName: String,
|
| 22 |
+
parentPhone: String,
|
| 23 |
+
address: String,
|
| 24 |
+
gender: String,
|
| 25 |
+
seatNo: String,
|
| 26 |
+
idCard: String,
|
| 27 |
+
classApplication: {
|
| 28 |
+
type: { type: String },
|
| 29 |
+
targetClass: String,
|
| 30 |
+
status: String
|
| 31 |
+
}
|
| 32 |
+
});
|
| 33 |
+
const User = mongoose.model('User', UserSchema);
|
| 34 |
+
|
| 35 |
+
const StudentSchema = new mongoose.Schema({
|
| 36 |
+
schoolId: String,
|
| 37 |
+
studentNo: String,
|
| 38 |
+
seatNo: String,
|
| 39 |
+
name: String,
|
| 40 |
+
gender: String,
|
| 41 |
+
birthday: String,
|
| 42 |
+
idCard: String,
|
| 43 |
+
phone: String,
|
| 44 |
+
className: String,
|
| 45 |
+
status: String,
|
| 46 |
+
parentName: String,
|
| 47 |
+
parentPhone: String,
|
| 48 |
+
address: String,
|
| 49 |
+
teamId: String,
|
| 50 |
+
drawAttempts: { type: Number, default: 0 },
|
| 51 |
+
dailyDrawLog: { date: String, count: { type: Number, default: 0 } },
|
| 52 |
+
flowerBalance: { type: Number, default: 0 }
|
| 53 |
+
});
|
| 54 |
+
const Student = mongoose.model('Student', StudentSchema);
|
| 55 |
+
|
| 56 |
+
const CourseSchema = new mongoose.Schema({ schoolId: String, courseCode: String, courseName: String, teacherName: String, credits: Number, capacity: Number, enrolled: Number });
|
| 57 |
+
const Course = mongoose.model('Course', CourseSchema);
|
| 58 |
+
|
| 59 |
+
const ScoreSchema = new mongoose.Schema({ schoolId: String, studentName: String, studentNo: String, courseName: String, score: Number, semester: String, type: String, examName: String, status: String });
|
| 60 |
+
const Score = mongoose.model('Score', ScoreSchema);
|
| 61 |
+
|
| 62 |
+
const ClassSchema = new mongoose.Schema({ schoolId: String, grade: String, className: String, teacherName: String });
|
| 63 |
+
const ClassModel = mongoose.model('Class', ClassSchema);
|
| 64 |
+
|
| 65 |
+
const SubjectSchema = new mongoose.Schema({ schoolId: String, name: String, code: String, color: String, excellenceThreshold: Number, thresholds: { type: Map, of: Number } });
|
| 66 |
+
const SubjectModel = mongoose.model('Subject', SubjectSchema);
|
| 67 |
+
|
| 68 |
+
const ExamSchema = new mongoose.Schema({ schoolId: String, name: String, date: String, type: String, semester: String });
|
| 69 |
+
const ExamModel = mongoose.model('Exam', ExamSchema);
|
| 70 |
+
|
| 71 |
+
const ScheduleSchema = new mongoose.Schema({ schoolId: String, className: String, teacherName: String, subject: String, dayOfWeek: Number, period: Number });
|
| 72 |
+
const ScheduleModel = mongoose.model('Schedule', ScheduleSchema);
|
| 73 |
+
|
| 74 |
+
const ConfigSchema = new mongoose.Schema({
|
| 75 |
+
key: String,
|
| 76 |
+
systemName: String,
|
| 77 |
+
semester: String,
|
| 78 |
+
semesters: [String],
|
| 79 |
+
allowRegister: Boolean,
|
| 80 |
+
allowAdminRegister: Boolean,
|
| 81 |
+
allowPrincipalRegister: Boolean,
|
| 82 |
+
allowStudentRegister: Boolean,
|
| 83 |
+
maintenanceMode: Boolean,
|
| 84 |
+
emailNotify: Boolean
|
| 85 |
+
});
|
| 86 |
+
const ConfigModel = mongoose.model('Config', ConfigSchema);
|
| 87 |
+
|
| 88 |
+
const NotificationSchema = new mongoose.Schema({ schoolId: String, targetRole: String, targetUserId: String, title: String, content: String, type: String, createTime: { type: Date, default: Date.now } });
|
| 89 |
+
const NotificationModel = mongoose.model('Notification', NotificationSchema);
|
| 90 |
+
|
| 91 |
+
const GameSessionSchema = new mongoose.Schema({ schoolId: String, className: String, isEnabled: Boolean, maxSteps: Number, teams: [{ id: String, name: String, score: Number, avatar: String, color: String, members: [String] }], rewardsConfig: [{ scoreThreshold: Number, rewardType: String, rewardName: String, rewardValue: Number, achievementId: String }] });
|
| 92 |
+
const GameSessionModel = mongoose.model('GameSession', GameSessionSchema);
|
| 93 |
+
|
| 94 |
+
const StudentRewardSchema = new mongoose.Schema({ schoolId: String, studentId: String, studentName: String, rewardType: String, name: String, count: { type: Number, default: 1 }, status: String, source: String, createTime: { type: Date, default: Date.now } });
|
| 95 |
+
const StudentRewardModel = mongoose.model('StudentReward', StudentRewardSchema);
|
| 96 |
+
|
| 97 |
+
const LuckyDrawConfigSchema = new mongoose.Schema({ schoolId: String, className: String, prizes: [{ id: String, name: String, probability: Number, count: Number, icon: String }], dailyLimit: Number, cardCount: Number, defaultPrize: String, consolationWeight: { type: Number, default: 0 } });
|
| 98 |
+
const LuckyDrawConfigModel = mongoose.model('LuckyDrawConfig', LuckyDrawConfigSchema);
|
| 99 |
+
|
| 100 |
+
const AchievementConfigSchema = new mongoose.Schema({ schoolId: String, className: String, achievements: [{ id: String, name: String, icon: String, points: Number, description: String }], exchangeRules: [{ id: String, cost: Number, rewardType: String, rewardName: String, rewardValue: Number }] });
|
| 101 |
+
const AchievementConfigModel = mongoose.model('AchievementConfig', AchievementConfigSchema);
|
| 102 |
+
|
| 103 |
+
const StudentAchievementSchema = new mongoose.Schema({ schoolId: String, studentId: String, studentName: String, achievementId: String, achievementName: String, achievementIcon: String, semester: String, createTime: { type: Date, default: Date.now } });
|
| 104 |
+
const StudentAchievementModel = mongoose.model('StudentAchievement', StudentAchievementSchema);
|
| 105 |
+
|
| 106 |
+
const AttendanceSchema = new mongoose.Schema({ schoolId: String, studentId: String, studentName: String, className: String, date: String, status: String, checkInTime: Date });
|
| 107 |
+
const AttendanceModel = mongoose.model('Attendance', AttendanceSchema);
|
| 108 |
+
|
| 109 |
+
const LeaveRequestSchema = new mongoose.Schema({ schoolId: String, studentId: String, studentName: String, className: String, reason: String, startDate: String, endDate: String, status: { type: String, default: 'Pending' }, createTime: { type: Date, default: Date.now } });
|
| 110 |
+
const LeaveRequestModel = mongoose.model('LeaveRequest', LeaveRequestSchema);
|
| 111 |
+
|
| 112 |
+
module.exports = {
|
| 113 |
+
School, User, Student, Course, Score, ClassModel, SubjectModel, ExamModel, ScheduleModel,
|
| 114 |
+
ConfigModel, NotificationModel, GameSessionModel, StudentRewardModel, LuckyDrawConfigModel,
|
| 115 |
+
AchievementConfigModel, StudentAchievementModel, AttendanceModel, LeaveRequestModel
|
| 116 |
+
};
|
pages/StudentReports.tsx
CHANGED
|
@@ -6,7 +6,22 @@ import {
|
|
| 6 |
} from 'recharts';
|
| 7 |
import { api } from '../services/api';
|
| 8 |
import { Loader2, TrendingUp, Grid } from 'lucide-react';
|
| 9 |
-
import { Score, Student, Subject, Exam
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
export const StudentReports: React.FC = () => {
|
| 12 |
const [loading, setLoading] = useState(true);
|
|
@@ -65,10 +80,29 @@ export const StudentReports: React.FC = () => {
|
|
| 65 |
};
|
| 66 |
|
| 67 |
const getRadarData = () => {
|
|
|
|
|
|
|
|
|
|
| 68 |
return subjects.map(sub => {
|
| 69 |
const subScores = scores.filter(s => s.courseName === sub.name);
|
| 70 |
const avg = subScores.length ? subScores.reduce((a,b)=>a+b.score,0)/subScores.length : 0;
|
| 71 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
});
|
| 73 |
};
|
| 74 |
|
|
@@ -85,15 +119,18 @@ export const StudentReports: React.FC = () => {
|
|
| 85 |
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
| 86 |
{/* 1. Radar Chart */}
|
| 87 |
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100">
|
| 88 |
-
<h3 className="font-bold text-gray-800 mb-6 flex items-center"><Grid size={18} className="mr-2 text-purple-600"/> 学科能力雷达</h3>
|
| 89 |
<div className="h-[350px]">
|
| 90 |
<ResponsiveContainer width="100%" height="100%">
|
| 91 |
<RadarChart cx="50%" cy="50%" outerRadius="70%" data={getRadarData()}>
|
| 92 |
<PolarGrid />
|
| 93 |
<PolarAngleAxis dataKey="subject" />
|
| 94 |
<PolarRadiusAxis angle={30} domain={[0, 100]} />
|
| 95 |
-
<Radar name="平均成绩" dataKey="score" stroke="#8b5cf6" fill="#8b5cf6" fillOpacity={0.5} />
|
| 96 |
<Tooltip />
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
</RadarChart>
|
| 98 |
</ResponsiveContainer>
|
| 99 |
</div>
|
|
@@ -106,7 +143,7 @@ export const StudentReports: React.FC = () => {
|
|
| 106 |
<ResponsiveContainer width="100%" height="100%">
|
| 107 |
<LineChart data={getMultiLineTrendData()}>
|
| 108 |
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
| 109 |
-
<XAxis dataKey="name" tick={{
|
| 110 |
<YAxis domain={[0, 100]} />
|
| 111 |
<Tooltip contentStyle={{borderRadius:'8px', border:'none', boxShadow:'0 4px 12px rgba(0,0,0,0.1)'}}/>
|
| 112 |
<Legend />
|
|
|
|
| 6 |
} from 'recharts';
|
| 7 |
import { api } from '../services/api';
|
| 8 |
import { Loader2, TrendingUp, Grid } from 'lucide-react';
|
| 9 |
+
import { Score, Student, Subject, Exam } from '../types';
|
| 10 |
+
|
| 11 |
+
// Custom Tick for XAxis to highlight exams
|
| 12 |
+
const CustomizedAxisTick = (props: any) => {
|
| 13 |
+
const { x, y, payload, exams } = props;
|
| 14 |
+
const examInfo = exams.find((e: any) => (e.name || e.type) === payload.value);
|
| 15 |
+
const isMajor = examInfo?.type === 'Midterm' || examInfo?.type === 'Final' || payload.value.includes('期中') || payload.value.includes('期末');
|
| 16 |
+
|
| 17 |
+
return (
|
| 18 |
+
<g transform={`translate(${x},${y})`}>
|
| 19 |
+
<text x={0} y={0} dy={16} textAnchor="middle" fill={isMajor ? '#b91c1c' : '#666'} fontWeight={isMajor ? 'bold' : 'normal'} fontSize={10}>
|
| 20 |
+
{payload.value}
|
| 21 |
+
</text>
|
| 22 |
+
</g>
|
| 23 |
+
);
|
| 24 |
+
};
|
| 25 |
|
| 26 |
export const StudentReports: React.FC = () => {
|
| 27 |
const [loading, setLoading] = useState(true);
|
|
|
|
| 80 |
};
|
| 81 |
|
| 82 |
const getRadarData = () => {
|
| 83 |
+
const examMap = new Map<string, string>(); // Name -> Type
|
| 84 |
+
exams.forEach(e => { if(e.name && e.type) examMap.set(e.name, e.type); });
|
| 85 |
+
|
| 86 |
return subjects.map(sub => {
|
| 87 |
const subScores = scores.filter(s => s.courseName === sub.name);
|
| 88 |
const avg = subScores.length ? subScores.reduce((a,b)=>a+b.score,0)/subScores.length : 0;
|
| 89 |
+
|
| 90 |
+
const midScore = subScores.find(s => {
|
| 91 |
+
const t = examMap.get(s.examName || s.type);
|
| 92 |
+
return t === 'Midterm' || (!t && s.type === 'Midterm');
|
| 93 |
+
});
|
| 94 |
+
const finalScore = subScores.find(s => {
|
| 95 |
+
const t = examMap.get(s.examName || s.type);
|
| 96 |
+
return t === 'Final' || (!t && s.type === 'Final');
|
| 97 |
+
});
|
| 98 |
+
|
| 99 |
+
return {
|
| 100 |
+
subject: sub.name,
|
| 101 |
+
avg: Number(avg.toFixed(1)),
|
| 102 |
+
midterm: midScore ? midScore.score : null,
|
| 103 |
+
final: finalScore ? finalScore.score : null,
|
| 104 |
+
fullMark: 100
|
| 105 |
+
};
|
| 106 |
});
|
| 107 |
};
|
| 108 |
|
|
|
|
| 119 |
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
| 120 |
{/* 1. Radar Chart */}
|
| 121 |
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100">
|
| 122 |
+
<h3 className="font-bold text-gray-800 mb-6 flex items-center"><Grid size={18} className="mr-2 text-purple-600"/> 学科能力雷达 (复合)</h3>
|
| 123 |
<div className="h-[350px]">
|
| 124 |
<ResponsiveContainer width="100%" height="100%">
|
| 125 |
<RadarChart cx="50%" cy="50%" outerRadius="70%" data={getRadarData()}>
|
| 126 |
<PolarGrid />
|
| 127 |
<PolarAngleAxis dataKey="subject" />
|
| 128 |
<PolarRadiusAxis angle={30} domain={[0, 100]} />
|
|
|
|
| 129 |
<Tooltip />
|
| 130 |
+
<Legend />
|
| 131 |
+
<Radar name="综合平均" dataKey="avg" stroke="#8b5cf6" fill="#8b5cf6" fillOpacity={0.3} />
|
| 132 |
+
<Radar name="期中" dataKey="midterm" stroke="#3b82f6" fill="transparent" />
|
| 133 |
+
<Radar name="期末" dataKey="final" stroke="#f59e0b" fill="transparent" />
|
| 134 |
</RadarChart>
|
| 135 |
</ResponsiveContainer>
|
| 136 |
</div>
|
|
|
|
| 143 |
<ResponsiveContainer width="100%" height="100%">
|
| 144 |
<LineChart data={getMultiLineTrendData()}>
|
| 145 |
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
| 146 |
+
<XAxis dataKey="name" tick={<CustomizedAxisTick exams={exams} />} height={40}/>
|
| 147 |
<YAxis domain={[0, 100]} />
|
| 148 |
<Tooltip contentStyle={{borderRadius:'8px', border:'none', boxShadow:'0 4px 12px rgba(0,0,0,0.1)'}}/>
|
| 149 |
<Legend />
|
pages/TeacherReports.tsx
CHANGED
|
@@ -23,6 +23,35 @@ const calcMedian = (values: number[]) => {
|
|
| 23 |
return (sorted[half - 1] + sorted[half]) / 2.0;
|
| 24 |
};
|
| 25 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
export const TeacherReports: React.FC = () => {
|
| 27 |
const [loading, setLoading] = useState(true);
|
| 28 |
const [activeTab, setActiveTab] = useState<'overview' | 'matrix' | 'student'>('overview');
|
|
@@ -138,7 +167,7 @@ export const TeacherReports: React.FC = () => {
|
|
| 138 |
return { name: sub.name, 平均分: Number(avg.toFixed(1)), color: sub.color };
|
| 139 |
}).sort((a,b) => a.平均分 - b.平均分);
|
| 140 |
|
| 141 |
-
// 3. Key Exam Analysis (Midterm vs Final)
|
| 142 |
const examMap = new Map<string, string>(); // Name -> Type
|
| 143 |
exams.forEach(e => { if(e.name && e.type) examMap.set(e.name, e.type); });
|
| 144 |
|
|
@@ -148,25 +177,36 @@ export const TeacherReports: React.FC = () => {
|
|
| 148 |
// Base grade scores
|
| 149 |
const gradeScores = allNormalScores.filter(s => gStus.includes(s.studentNo) && (ovSubject === 'All' || s.courseName === ovSubject));
|
| 150 |
|
| 151 |
-
// Helper to check type: Use Exam Metadata Type if exists, else fallback to Score Type
|
| 152 |
const isType = (s: Score, targetType: string) => {
|
| 153 |
const metaType = examMap.get(s.examName || s.type);
|
| 154 |
return metaType === targetType || (!metaType && s.type === targetType);
|
| 155 |
};
|
| 156 |
|
|
|
|
| 157 |
const midScores = gradeScores.filter(s => isType(s, 'Midterm'));
|
| 158 |
const finalScores = gradeScores.filter(s => isType(s, 'Final'));
|
| 159 |
|
| 160 |
-
const
|
| 161 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
|
| 163 |
return {
|
| 164 |
name: g,
|
| 165 |
-
|
| 166 |
-
期
|
| 167 |
-
|
|
|
|
| 168 |
};
|
| 169 |
-
}).filter(
|
| 170 |
|
| 171 |
return { totalAvg, failCount, gradeStack, subjectAnalysis, midtermFinalData };
|
| 172 |
}, [scores, students, classes, subjects, ovSubject, exams]);
|
|
@@ -278,18 +318,42 @@ export const TeacherReports: React.FC = () => {
|
|
| 278 |
|
| 279 |
const getStudentRadar = () => {
|
| 280 |
if (!selectedStudent) return [];
|
|
|
|
|
|
|
|
|
|
| 281 |
const stuScores = scores.filter(s => s.studentNo === selectedStudent.studentNo && s.status === 'Normal');
|
| 282 |
-
|
|
|
|
| 283 |
const subScores = stuScores.filter(s => s.courseName === sub.name);
|
| 284 |
const avg = subScores.length ? subScores.reduce((a,b)=>a+b.score,0)/subScores.length : 0;
|
| 285 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 286 |
});
|
|
|
|
| 287 |
};
|
| 288 |
|
| 289 |
// Complex Trend Chart: Student vs Class Avg vs Grade Avg vs Excellent Line
|
| 290 |
const getDetailedStudentTrend = () => {
|
| 291 |
if (!selectedStudent || !spSubject) return [];
|
| 292 |
|
|
|
|
|
|
|
|
|
|
| 293 |
// 1. Get Student Scores for this subject
|
| 294 |
const myScores = scores.filter(s =>
|
| 295 |
s.studentNo === selectedStudent.studentNo &&
|
|
@@ -312,6 +376,9 @@ export const TeacherReports: React.FC = () => {
|
|
| 312 |
// My Score
|
| 313 |
const myScoreItem = myScores.find(s => (s.examName || s.type) === exName);
|
| 314 |
const myScore = myScoreItem ? myScoreItem.score : 0;
|
|
|
|
|
|
|
|
|
|
| 315 |
|
| 316 |
// Find Peers Scores for this Exam & Subject
|
| 317 |
const peerScores = scores.filter(s =>
|
|
@@ -331,6 +398,7 @@ export const TeacherReports: React.FC = () => {
|
|
| 331 |
|
| 332 |
return {
|
| 333 |
name: exName,
|
|
|
|
| 334 |
我: myScore,
|
| 335 |
班级平均: Number(classAvg.toFixed(1)),
|
| 336 |
年级平均: Number(gradeAvg.toFixed(1)),
|
|
@@ -401,7 +469,7 @@ export const TeacherReports: React.FC = () => {
|
|
| 401 |
{/* 3. New Key Exam Analysis Section */}
|
| 402 |
<div className="bg-white p-6 rounded-xl border border-gray-100 shadow-sm">
|
| 403 |
<div className="flex justify-between items-center mb-6">
|
| 404 |
-
<h3 className="font-bold text-gray-800 flex items-center text-lg"><Award className="mr-2 text-amber-500"/> 阶段性大考分析 (期中 vs 期末)</h3>
|
| 405 |
<div className="flex items-center gap-2">
|
| 406 |
<span className="text-xs text-gray-400 bg-gray-50 px-2 py-1 rounded border">统计口径: {ovSubject === 'All' ? '综合平均分' : ovSubject + '单科平均分'}</span>
|
| 407 |
</div>
|
|
@@ -409,7 +477,7 @@ export const TeacherReports: React.FC = () => {
|
|
| 409 |
<div className="h-72">
|
| 410 |
{overviewMetrics.midtermFinalData.length > 0 ? (
|
| 411 |
<ResponsiveContainer width="100%" height="100%">
|
| 412 |
-
<BarChart data={overviewMetrics.midtermFinalData} barSize={
|
| 413 |
<CartesianGrid strokeDasharray="3 3" vertical={false}/>
|
| 414 |
<XAxis dataKey="name" />
|
| 415 |
<YAxis domain={[0, 100]} />
|
|
@@ -421,13 +489,16 @@ export const TeacherReports: React.FC = () => {
|
|
| 421 |
return (
|
| 422 |
<div className="bg-white p-3 border rounded shadow-lg text-xs">
|
| 423 |
<p className="font-bold text-gray-800 mb-2">{data.name}</p>
|
| 424 |
-
<div className="
|
| 425 |
-
<
|
| 426 |
-
<
|
| 427 |
-
|
| 428 |
-
<div className={`mt-2 font-bold ${data.delta >= 0 ? 'text-green-600' : 'text-red-500'}`}>
|
| 429 |
-
{data.delta >= 0 ? '📈 进步' : '📉 退步'} {Math.abs(data.delta)} 分
|
| 430 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 431 |
</div>
|
| 432 |
);
|
| 433 |
}
|
|
@@ -435,15 +506,16 @@ export const TeacherReports: React.FC = () => {
|
|
| 435 |
}}
|
| 436 |
/>
|
| 437 |
<Legend />
|
| 438 |
-
<Bar dataKey="
|
| 439 |
-
<Bar dataKey="期
|
|
|
|
| 440 |
</BarChart>
|
| 441 |
</ResponsiveContainer>
|
| 442 |
) : (
|
| 443 |
<div className="h-full flex flex-col items-center justify-center text-gray-400 bg-gray-50 rounded-xl border border-dashed">
|
| 444 |
<Activity size={32} className="mb-2 opacity-50"/>
|
| 445 |
-
<p>暂无
|
| 446 |
-
<p className="text-xs mt-1">请在“成绩管理 -> 考试排期”中标记考试类型</p>
|
| 447 |
</div>
|
| 448 |
)}
|
| 449 |
</div>
|
|
@@ -709,11 +781,11 @@ export const TeacherReports: React.FC = () => {
|
|
| 709 |
<ResponsiveContainer width="100%" height="100%">
|
| 710 |
<LineChart data={getDetailedStudentTrend()}>
|
| 711 |
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
| 712 |
-
<XAxis dataKey="name" tick={{
|
| 713 |
<YAxis domain={[0,100]} />
|
| 714 |
<Tooltip contentStyle={{borderRadius:'8px', border:'none', boxShadow:'0 4px 12px rgba(0,0,0,0.1)'}}/>
|
| 715 |
<Legend verticalAlign="top" height={36}/>
|
| 716 |
-
<Line type="monotone" dataKey="我" stroke="#3b82f6" strokeWidth={3} dot={
|
| 717 |
<Line type="monotone" dataKey="班级平均" stroke="#f59e0b" strokeWidth={2} strokeDasharray="5 5" dot={false} />
|
| 718 |
<Line type="monotone" dataKey="年级平均" stroke="#10b981" strokeWidth={2} strokeDasharray="3 3" dot={false} />
|
| 719 |
<Line type="monotone" dataKey="优秀线" stroke="#ef4444" strokeWidth={1} dot={false} />
|
|
@@ -724,15 +796,18 @@ export const TeacherReports: React.FC = () => {
|
|
| 724 |
|
| 725 |
{/* Radar */}
|
| 726 |
<div className="bg-white p-4 rounded-xl border border-gray-100 shadow-sm flex flex-col">
|
| 727 |
-
<h4 className="font-bold text-gray-700 mb-4 text-sm flex items-center"><Grid size={16} className="mr-2 text-purple-500"/> 综合学科能力模型</h4>
|
| 728 |
<div className="flex-1 min-h-[250px]">
|
| 729 |
<ResponsiveContainer width="100%" height="100%">
|
| 730 |
<RadarChart cx="50%" cy="50%" outerRadius="70%" data={getStudentRadar()}>
|
| 731 |
<PolarGrid />
|
| 732 |
<PolarAngleAxis dataKey="subject" />
|
| 733 |
<PolarRadiusAxis angle={30} domain={[0, 100]} />
|
| 734 |
-
<Radar name={selectedStudent.name} dataKey="score" stroke="#8b5cf6" fill="#8b5cf6" fillOpacity={0.5} />
|
| 735 |
<Tooltip />
|
|
|
|
|
|
|
|
|
|
|
|
|
| 736 |
</RadarChart>
|
| 737 |
</ResponsiveContainer>
|
| 738 |
</div>
|
|
|
|
| 23 |
return (sorted[half - 1] + sorted[half]) / 2.0;
|
| 24 |
};
|
| 25 |
|
| 26 |
+
// Custom Dot for Line Chart to highlight exams
|
| 27 |
+
const CustomizedDot = (props: any) => {
|
| 28 |
+
const { cx, cy, payload } = props;
|
| 29 |
+
if (!cx || !cy) return null;
|
| 30 |
+
if (payload.type === 'Midterm' || payload.type === 'Final') {
|
| 31 |
+
return (
|
| 32 |
+
<svg x={cx - 6} y={cy - 6} width={12} height={12} fill="white">
|
| 33 |
+
<circle cx="6" cy="6" r="6" stroke={payload.type === 'Midterm' ? '#3b82f6' : '#f59e0b'} strokeWidth="3" fill="white" />
|
| 34 |
+
</svg>
|
| 35 |
+
);
|
| 36 |
+
}
|
| 37 |
+
return <circle cx={cx} cy={cy} r={4} stroke="none" fill="#8884d8" />;
|
| 38 |
+
};
|
| 39 |
+
|
| 40 |
+
// Custom Tick for XAxis to highlight exams
|
| 41 |
+
const CustomizedAxisTick = (props: any) => {
|
| 42 |
+
const { x, y, payload, exams } = props;
|
| 43 |
+
const examInfo = exams.find((e: any) => (e.name || e.type) === payload.value);
|
| 44 |
+
const isMajor = examInfo?.type === 'Midterm' || examInfo?.type === 'Final' || payload.value.includes('期中') || payload.value.includes('期末');
|
| 45 |
+
|
| 46 |
+
return (
|
| 47 |
+
<g transform={`translate(${x},${y})`}>
|
| 48 |
+
<text x={0} y={0} dy={16} textAnchor="middle" fill={isMajor ? '#b91c1c' : '#666'} fontWeight={isMajor ? 'bold' : 'normal'} fontSize={10}>
|
| 49 |
+
{payload.value}
|
| 50 |
+
</text>
|
| 51 |
+
</g>
|
| 52 |
+
);
|
| 53 |
+
};
|
| 54 |
+
|
| 55 |
export const TeacherReports: React.FC = () => {
|
| 56 |
const [loading, setLoading] = useState(true);
|
| 57 |
const [activeTab, setActiveTab] = useState<'overview' | 'matrix' | 'student'>('overview');
|
|
|
|
| 167 |
return { name: sub.name, 平均分: Number(avg.toFixed(1)), color: sub.color };
|
| 168 |
}).sort((a,b) => a.平均分 - b.平均分);
|
| 169 |
|
| 170 |
+
// 3. Key Exam Analysis (Quiz vs Midterm vs Final)
|
| 171 |
const examMap = new Map<string, string>(); // Name -> Type
|
| 172 |
exams.forEach(e => { if(e.name && e.type) examMap.set(e.name, e.type); });
|
| 173 |
|
|
|
|
| 177 |
// Base grade scores
|
| 178 |
const gradeScores = allNormalScores.filter(s => gStus.includes(s.studentNo) && (ovSubject === 'All' || s.courseName === ovSubject));
|
| 179 |
|
|
|
|
| 180 |
const isType = (s: Score, targetType: string) => {
|
| 181 |
const metaType = examMap.get(s.examName || s.type);
|
| 182 |
return metaType === targetType || (!metaType && s.type === targetType);
|
| 183 |
};
|
| 184 |
|
| 185 |
+
const quizScores = gradeScores.filter(s => isType(s, 'Quiz'));
|
| 186 |
const midScores = gradeScores.filter(s => isType(s, 'Midterm'));
|
| 187 |
const finalScores = gradeScores.filter(s => isType(s, 'Final'));
|
| 188 |
|
| 189 |
+
const quizAvg = quizScores.length ? Number((quizScores.reduce((a,b)=>a+b.score,0)/quizScores.length).toFixed(1)) : null;
|
| 190 |
+
const midAvg = midScores.length ? Number((midScores.reduce((a,b)=>a+b.score,0)/midScores.length).toFixed(1)) : null;
|
| 191 |
+
const finalAvg = finalScores.length ? Number((finalScores.reduce((a,b)=>a+b.score,0)/finalScores.length).toFixed(1)) : null;
|
| 192 |
+
|
| 193 |
+
// Delta calculation (Final - Midterm if both exist, else Final - Quiz, etc.)
|
| 194 |
+
// We only calculate delta if Midterm and Final exist for proper "Key Exam" analysis
|
| 195 |
+
let delta = null;
|
| 196 |
+
if (midAvg !== null && finalAvg !== null) {
|
| 197 |
+
delta = Number((finalAvg - midAvg).toFixed(1));
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
if (quizAvg === null && midAvg === null && finalAvg === null) return null;
|
| 201 |
|
| 202 |
return {
|
| 203 |
name: g,
|
| 204 |
+
平时: quizAvg,
|
| 205 |
+
期中: midAvg,
|
| 206 |
+
期末: finalAvg,
|
| 207 |
+
delta
|
| 208 |
};
|
| 209 |
+
}).filter(Boolean); // Remove null grades (no data)
|
| 210 |
|
| 211 |
return { totalAvg, failCount, gradeStack, subjectAnalysis, midtermFinalData };
|
| 212 |
}, [scores, students, classes, subjects, ovSubject, exams]);
|
|
|
|
| 318 |
|
| 319 |
const getStudentRadar = () => {
|
| 320 |
if (!selectedStudent) return [];
|
| 321 |
+
const examMap = new Map<string, string>(); // Name -> Type
|
| 322 |
+
exams.forEach(e => { if(e.name && e.type) examMap.set(e.name, e.type); });
|
| 323 |
+
|
| 324 |
const stuScores = scores.filter(s => s.studentNo === selectedStudent.studentNo && s.status === 'Normal');
|
| 325 |
+
|
| 326 |
+
const data = subjects.map(sub => {
|
| 327 |
const subScores = stuScores.filter(s => s.courseName === sub.name);
|
| 328 |
const avg = subScores.length ? subScores.reduce((a,b)=>a+b.score,0)/subScores.length : 0;
|
| 329 |
+
|
| 330 |
+
const midScore = subScores.find(s => {
|
| 331 |
+
const t = examMap.get(s.examName || s.type);
|
| 332 |
+
return t === 'Midterm' || (!t && s.type === 'Midterm');
|
| 333 |
+
});
|
| 334 |
+
const finalScore = subScores.find(s => {
|
| 335 |
+
const t = examMap.get(s.examName || s.type);
|
| 336 |
+
return t === 'Final' || (!t && s.type === 'Final');
|
| 337 |
+
});
|
| 338 |
+
|
| 339 |
+
return {
|
| 340 |
+
subject: sub.name,
|
| 341 |
+
avg: Number(avg.toFixed(1)),
|
| 342 |
+
midterm: midScore ? midScore.score : null,
|
| 343 |
+
final: finalScore ? finalScore.score : null,
|
| 344 |
+
fullMark: 100
|
| 345 |
+
};
|
| 346 |
});
|
| 347 |
+
return data;
|
| 348 |
};
|
| 349 |
|
| 350 |
// Complex Trend Chart: Student vs Class Avg vs Grade Avg vs Excellent Line
|
| 351 |
const getDetailedStudentTrend = () => {
|
| 352 |
if (!selectedStudent || !spSubject) return [];
|
| 353 |
|
| 354 |
+
const examMap = new Map<string, string>();
|
| 355 |
+
exams.forEach(e => { if(e.name && e.type) examMap.set(e.name, e.type); });
|
| 356 |
+
|
| 357 |
// 1. Get Student Scores for this subject
|
| 358 |
const myScores = scores.filter(s =>
|
| 359 |
s.studentNo === selectedStudent.studentNo &&
|
|
|
|
| 376 |
// My Score
|
| 377 |
const myScoreItem = myScores.find(s => (s.examName || s.type) === exName);
|
| 378 |
const myScore = myScoreItem ? myScoreItem.score : 0;
|
| 379 |
+
|
| 380 |
+
// Determine Type for highlighting
|
| 381 |
+
const type = examMap.get(exName) || myScoreItem?.type || 'Quiz';
|
| 382 |
|
| 383 |
// Find Peers Scores for this Exam & Subject
|
| 384 |
const peerScores = scores.filter(s =>
|
|
|
|
| 398 |
|
| 399 |
return {
|
| 400 |
name: exName,
|
| 401 |
+
type: type,
|
| 402 |
我: myScore,
|
| 403 |
班级平均: Number(classAvg.toFixed(1)),
|
| 404 |
年级平均: Number(gradeAvg.toFixed(1)),
|
|
|
|
| 469 |
{/* 3. New Key Exam Analysis Section */}
|
| 470 |
<div className="bg-white p-6 rounded-xl border border-gray-100 shadow-sm">
|
| 471 |
<div className="flex justify-between items-center mb-6">
|
| 472 |
+
<h3 className="font-bold text-gray-800 flex items-center text-lg"><Award className="mr-2 text-amber-500"/> 阶段性大考分析 (平时 vs 期中 vs 期末)</h3>
|
| 473 |
<div className="flex items-center gap-2">
|
| 474 |
<span className="text-xs text-gray-400 bg-gray-50 px-2 py-1 rounded border">统计口径: {ovSubject === 'All' ? '综合平均分' : ovSubject + '单科平均分'}</span>
|
| 475 |
</div>
|
|
|
|
| 477 |
<div className="h-72">
|
| 478 |
{overviewMetrics.midtermFinalData.length > 0 ? (
|
| 479 |
<ResponsiveContainer width="100%" height="100%">
|
| 480 |
+
<BarChart data={overviewMetrics.midtermFinalData} barSize={30}>
|
| 481 |
<CartesianGrid strokeDasharray="3 3" vertical={false}/>
|
| 482 |
<XAxis dataKey="name" />
|
| 483 |
<YAxis domain={[0, 100]} />
|
|
|
|
| 489 |
return (
|
| 490 |
<div className="bg-white p-3 border rounded shadow-lg text-xs">
|
| 491 |
<p className="font-bold text-gray-800 mb-2">{data.name}</p>
|
| 492 |
+
<div className="space-y-1">
|
| 493 |
+
{data.平时 && <div className="text-gray-600">平时测验: {data.平时}</div>}
|
| 494 |
+
{data.期中 && <div className="text-blue-600 font-bold">期中考试: {data.期中}</div>}
|
| 495 |
+
{data.期末 && <div className="text-amber-500 font-bold">期末考试: {data.期末}</div>}
|
|
|
|
|
|
|
| 496 |
</div>
|
| 497 |
+
{data.delta !== null && (
|
| 498 |
+
<div className={`mt-2 font-bold ${data.delta >= 0 ? 'text-green-600' : 'text-red-500'}`}>
|
| 499 |
+
{data.delta >= 0 ? '📈 进步' : '📉 退步'} {Math.abs(data.delta)} 分 (期末vs期中)
|
| 500 |
+
</div>
|
| 501 |
+
)}
|
| 502 |
</div>
|
| 503 |
);
|
| 504 |
}
|
|
|
|
| 506 |
}}
|
| 507 |
/>
|
| 508 |
<Legend />
|
| 509 |
+
<Bar dataKey="平时" fill="#e5e7eb" name="平时测验" radius={[4,4,0,0]} />
|
| 510 |
+
<Bar dataKey="期中" fill="#3b82f6" name="期中考试" radius={[4,4,0,0]} />
|
| 511 |
+
<Bar dataKey="期末" fill="#f59e0b" name="期末考试" radius={[4,4,0,0]} />
|
| 512 |
</BarChart>
|
| 513 |
</ResponsiveContainer>
|
| 514 |
) : (
|
| 515 |
<div className="h-full flex flex-col items-center justify-center text-gray-400 bg-gray-50 rounded-xl border border-dashed">
|
| 516 |
<Activity size={32} className="mb-2 opacity-50"/>
|
| 517 |
+
<p>暂无相关考试数据</p>
|
| 518 |
+
<p className="text-xs mt-1">请在“成绩管理 -> 考试排期”中标记考试类型,并确保已录入成绩</p>
|
| 519 |
</div>
|
| 520 |
)}
|
| 521 |
</div>
|
|
|
|
| 781 |
<ResponsiveContainer width="100%" height="100%">
|
| 782 |
<LineChart data={getDetailedStudentTrend()}>
|
| 783 |
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
| 784 |
+
<XAxis dataKey="name" tick={<CustomizedAxisTick exams={exams}/>} height={40}/>
|
| 785 |
<YAxis domain={[0,100]} />
|
| 786 |
<Tooltip contentStyle={{borderRadius:'8px', border:'none', boxShadow:'0 4px 12px rgba(0,0,0,0.1)'}}/>
|
| 787 |
<Legend verticalAlign="top" height={36}/>
|
| 788 |
+
<Line type="monotone" dataKey="我" stroke="#3b82f6" strokeWidth={3} dot={<CustomizedDot/>} activeDot={{r:6}} />
|
| 789 |
<Line type="monotone" dataKey="班级平均" stroke="#f59e0b" strokeWidth={2} strokeDasharray="5 5" dot={false} />
|
| 790 |
<Line type="monotone" dataKey="年级平均" stroke="#10b981" strokeWidth={2} strokeDasharray="3 3" dot={false} />
|
| 791 |
<Line type="monotone" dataKey="优秀线" stroke="#ef4444" strokeWidth={1} dot={false} />
|
|
|
|
| 796 |
|
| 797 |
{/* Radar */}
|
| 798 |
<div className="bg-white p-4 rounded-xl border border-gray-100 shadow-sm flex flex-col">
|
| 799 |
+
<h4 className="font-bold text-gray-700 mb-4 text-sm flex items-center"><Grid size={16} className="mr-2 text-purple-500"/> 综合学科能力模型 (复合)</h4>
|
| 800 |
<div className="flex-1 min-h-[250px]">
|
| 801 |
<ResponsiveContainer width="100%" height="100%">
|
| 802 |
<RadarChart cx="50%" cy="50%" outerRadius="70%" data={getStudentRadar()}>
|
| 803 |
<PolarGrid />
|
| 804 |
<PolarAngleAxis dataKey="subject" />
|
| 805 |
<PolarRadiusAxis angle={30} domain={[0, 100]} />
|
|
|
|
| 806 |
<Tooltip />
|
| 807 |
+
<Legend verticalAlign="bottom" height={24}/>
|
| 808 |
+
<Radar name="综合平均" dataKey="avg" stroke="#8b5cf6" fill="#8b5cf6" fillOpacity={0.3} />
|
| 809 |
+
<Radar name="期中" dataKey="midterm" stroke="#3b82f6" fill="transparent" />
|
| 810 |
+
<Radar name="期末" dataKey="final" stroke="#f59e0b" fill="transparent" />
|
| 811 |
</RadarChart>
|
| 812 |
</ResponsiveContainer>
|
| 813 |
</div>
|
server.js
CHANGED
|
@@ -6,6 +6,11 @@ const cors = require('cors');
|
|
| 6 |
const bodyParser = require('body-parser');
|
| 7 |
const path = require('path');
|
| 8 |
const compression = require('compression');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
// ... constants
|
| 11 |
const PORT = 7860;
|
|
@@ -20,42 +25,20 @@ app.use(cors());
|
|
| 20 |
app.use(bodyParser.json({ limit: '10mb' }));
|
| 21 |
|
| 22 |
// PERFORMANCE 2: Smart Caching Strategy
|
| 23 |
-
// - HTML files: No Cache (Ensure users always get the latest version pointer)
|
| 24 |
-
// - Assets (JS/CSS/Images): Long Cache (Vite uses content-hashing, so these are safe to cache forever)
|
| 25 |
app.use(express.static(path.join(__dirname, 'dist'), {
|
| 26 |
setHeaders: (res, filePath) => {
|
| 27 |
if (filePath.endsWith('.html')) {
|
| 28 |
-
// User must revalidate index.html every time
|
| 29 |
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
| 30 |
} else {
|
| 31 |
-
// JS/CSS/Images are immutable (1 year cache)
|
| 32 |
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
| 33 |
}
|
| 34 |
}
|
| 35 |
}));
|
| 36 |
|
| 37 |
-
// ... Rest of the file (DB connection, Models, Routes) remains exactly the same ...
|
| 38 |
-
// For brevity, I am keeping the logic intact.
|
| 39 |
-
|
| 40 |
-
// ... DB Models ...
|
| 41 |
const InMemoryDB = {
|
| 42 |
schools: [],
|
| 43 |
users: [],
|
| 44 |
-
|
| 45 |
-
courses: [],
|
| 46 |
-
scores: [],
|
| 47 |
-
classes: [],
|
| 48 |
-
subjects: [],
|
| 49 |
-
exams: [],
|
| 50 |
-
schedules: [],
|
| 51 |
-
notifications: [],
|
| 52 |
-
gameSessions: [],
|
| 53 |
-
rewards: [],
|
| 54 |
-
luckyConfig: [],
|
| 55 |
-
config: {},
|
| 56 |
-
attendance: [],
|
| 57 |
-
achievementsConfig: [],
|
| 58 |
-
studentAchievements: [],
|
| 59 |
isFallback: false
|
| 60 |
};
|
| 61 |
|
|
@@ -65,121 +48,19 @@ const connectDB = async () => {
|
|
| 65 |
console.log('✅ MongoDB 连接成功 (Real Data)');
|
| 66 |
} catch (err) {
|
| 67 |
console.error('❌ MongoDB 连接失败:', err.message);
|
| 68 |
-
console.warn('⚠️ 启动内存数据库模式');
|
| 69 |
InMemoryDB.isFallback = true;
|
| 70 |
-
const defaultSchoolId = 'school_default_' + Date.now();
|
| 71 |
-
InMemoryDB.schools.push({ _id: defaultSchoolId, name: '第一实验小学', code: 'EXP01' });
|
| 72 |
-
InMemoryDB.users.push(
|
| 73 |
-
{ _id: 'admin_01', username: 'admin', password: 'admin', role: 'ADMIN', status: 'active', schoolId: defaultSchoolId, trueName: '系统管理员' }
|
| 74 |
-
);
|
| 75 |
}
|
| 76 |
};
|
| 77 |
connectDB();
|
| 78 |
|
| 79 |
-
// --- Schema Definitions ---
|
| 80 |
-
|
| 81 |
-
const SchoolSchema = new mongoose.Schema({ name: String, code: String });
|
| 82 |
-
const School = mongoose.model('School', SchoolSchema);
|
| 83 |
-
|
| 84 |
-
const UserSchema = new mongoose.Schema({
|
| 85 |
-
username: String,
|
| 86 |
-
password: String,
|
| 87 |
-
trueName: String,
|
| 88 |
-
phone: String,
|
| 89 |
-
email: String,
|
| 90 |
-
schoolId: String,
|
| 91 |
-
role: String, // Updated: ADMIN, PRINCIPAL, TEACHER, STUDENT
|
| 92 |
-
status: String,
|
| 93 |
-
avatar: String,
|
| 94 |
-
createTime: Date,
|
| 95 |
-
teachingSubject: String,
|
| 96 |
-
homeroomClass: String,
|
| 97 |
-
studentNo: String,
|
| 98 |
-
parentName: String,
|
| 99 |
-
parentPhone: String,
|
| 100 |
-
address: String,
|
| 101 |
-
gender: String,
|
| 102 |
-
seatNo: String,
|
| 103 |
-
idCard: String, // NEW
|
| 104 |
-
classApplication: {
|
| 105 |
-
type: { type: String },
|
| 106 |
-
targetClass: String,
|
| 107 |
-
status: String
|
| 108 |
-
}
|
| 109 |
-
});
|
| 110 |
-
const User = mongoose.model('User', UserSchema);
|
| 111 |
-
|
| 112 |
-
const StudentSchema = new mongoose.Schema({
|
| 113 |
-
schoolId: String,
|
| 114 |
-
studentNo: String,
|
| 115 |
-
seatNo: String,
|
| 116 |
-
name: String,
|
| 117 |
-
gender: String,
|
| 118 |
-
birthday: String,
|
| 119 |
-
idCard: String,
|
| 120 |
-
phone: String,
|
| 121 |
-
className: String,
|
| 122 |
-
status: String,
|
| 123 |
-
parentName: String,
|
| 124 |
-
parentPhone: String,
|
| 125 |
-
address: String,
|
| 126 |
-
teamId: String,
|
| 127 |
-
drawAttempts: { type: Number, default: 0 },
|
| 128 |
-
dailyDrawLog: { date: String, count: { type: Number, default: 0 } },
|
| 129 |
-
flowerBalance: { type: Number, default: 0 }
|
| 130 |
-
});
|
| 131 |
-
const Student = mongoose.model('Student', StudentSchema);
|
| 132 |
-
const CourseSchema = new mongoose.Schema({ schoolId: String, courseCode: String, courseName: String, teacherName: String, credits: Number, capacity: Number, enrolled: Number });
|
| 133 |
-
const Course = mongoose.model('Course', CourseSchema);
|
| 134 |
-
const ScoreSchema = new mongoose.Schema({ schoolId: String, studentName: String, studentNo: String, courseName: String, score: Number, semester: String, type: String, examName: String, status: String });
|
| 135 |
-
const Score = mongoose.model('Score', ScoreSchema);
|
| 136 |
-
const ClassSchema = new mongoose.Schema({ schoolId: String, grade: String, className: String, teacherName: String });
|
| 137 |
-
const ClassModel = mongoose.model('Class', ClassSchema);
|
| 138 |
-
const SubjectSchema = new mongoose.Schema({ schoolId: String, name: String, code: String, color: String, excellenceThreshold: Number, thresholds: { type: Map, of: Number } });
|
| 139 |
-
const SubjectModel = mongoose.model('Subject', SubjectSchema);
|
| 140 |
-
// Updated ExamSchema with 'type'
|
| 141 |
-
const ExamSchema = new mongoose.Schema({ schoolId: String, name: String, date: String, type: String, semester: String });
|
| 142 |
-
const ExamModel = mongoose.model('Exam', ExamSchema);
|
| 143 |
-
const ScheduleSchema = new mongoose.Schema({ schoolId: String, className: String, teacherName: String, subject: String, dayOfWeek: Number, period: Number });
|
| 144 |
-
const ScheduleModel = mongoose.model('Schedule', ScheduleSchema);
|
| 145 |
-
const ConfigSchema = new mongoose.Schema({
|
| 146 |
-
key: String,
|
| 147 |
-
systemName: String,
|
| 148 |
-
semester: String,
|
| 149 |
-
semesters: [String],
|
| 150 |
-
allowRegister: Boolean,
|
| 151 |
-
allowAdminRegister: Boolean,
|
| 152 |
-
allowPrincipalRegister: Boolean, // NEW
|
| 153 |
-
allowStudentRegister: Boolean,
|
| 154 |
-
maintenanceMode: Boolean,
|
| 155 |
-
emailNotify: Boolean
|
| 156 |
-
});
|
| 157 |
-
const ConfigModel = mongoose.model('Config', ConfigSchema);
|
| 158 |
-
const NotificationSchema = new mongoose.Schema({ schoolId: String, targetRole: String, targetUserId: String, title: String, content: String, type: String, createTime: { type: Date, default: Date.now } });
|
| 159 |
-
const NotificationModel = mongoose.model('Notification', NotificationSchema);
|
| 160 |
-
const GameSessionSchema = new mongoose.Schema({ schoolId: String, className: String, isEnabled: Boolean, maxSteps: Number, teams: [{ id: String, name: String, score: Number, avatar: String, color: String, members: [String] }], rewardsConfig: [{ scoreThreshold: Number, rewardType: String, rewardName: String, rewardValue: Number, achievementId: String }] });
|
| 161 |
-
const GameSessionModel = mongoose.model('GameSession', GameSessionSchema);
|
| 162 |
-
const StudentRewardSchema = new mongoose.Schema({ schoolId: String, studentId: String, studentName: String, rewardType: String, name: String, count: { type: Number, default: 1 }, status: String, source: String, createTime: { type: Date, default: Date.now } });
|
| 163 |
-
const StudentRewardModel = mongoose.model('StudentReward', StudentRewardSchema);
|
| 164 |
-
const LuckyDrawConfigSchema = new mongoose.Schema({ schoolId: String, className: String, prizes: [{ id: String, name: String, probability: Number, count: Number, icon: String }], dailyLimit: Number, cardCount: Number, defaultPrize: String, consolationWeight: { type: Number, default: 0 } });
|
| 165 |
-
const LuckyDrawConfigModel = mongoose.model('LuckyDrawConfig', LuckyDrawConfigSchema);
|
| 166 |
-
const AchievementConfigSchema = new mongoose.Schema({ schoolId: String, className: String, achievements: [{ id: String, name: String, icon: String, points: Number, description: String }], exchangeRules: [{ id: String, cost: Number, rewardType: String, rewardName: String, rewardValue: Number }] });
|
| 167 |
-
const AchievementConfigModel = mongoose.model('AchievementConfig', AchievementConfigSchema);
|
| 168 |
-
const StudentAchievementSchema = new mongoose.Schema({ schoolId: String, studentId: String, studentName: String, achievementId: String, achievementName: String, achievementIcon: String, semester: String, createTime: { type: Date, default: Date.now } });
|
| 169 |
-
const StudentAchievementModel = mongoose.model('StudentAchievement', StudentAchievementSchema);
|
| 170 |
-
const AttendanceSchema = new mongoose.Schema({ schoolId: String, studentId: String, studentName: String, className: String, date: String, status: String, checkInTime: Date });
|
| 171 |
-
const AttendanceModel = mongoose.model('Attendance', AttendanceSchema);
|
| 172 |
-
const LeaveRequestSchema = new mongoose.Schema({ schoolId: String, studentId: String, studentName: String, className: String, reason: String, startDate: String, endDate: String, status: { type: String, default: 'Pending' }, createTime: { type: Date, default: Date.now } });
|
| 173 |
-
const LeaveRequestModel = mongoose.model('LeaveRequest', LeaveRequestSchema);
|
| 174 |
-
|
| 175 |
// ... Helpers ...
|
| 176 |
const getQueryFilter = (req) => {
|
| 177 |
const s = req.headers['x-school-id'];
|
| 178 |
const role = req.headers['x-user-role'];
|
| 179 |
|
| 180 |
-
// If user is Principal, they MUST be restricted to their school
|
| 181 |
if (role === 'PRINCIPAL') {
|
| 182 |
-
if (!s) return { _id: null };
|
| 183 |
return { schoolId: s };
|
| 184 |
}
|
| 185 |
|
|
@@ -210,11 +91,11 @@ const getAutoSemester = () => {
|
|
| 210 |
|
| 211 |
const generateStudentNo = async () => {
|
| 212 |
const year = new Date().getFullYear();
|
| 213 |
-
const random = Math.floor(100000 + Math.random() * 900000);
|
| 214 |
return `${year}${random}`;
|
| 215 |
};
|
| 216 |
|
| 217 |
-
// ... ROUTES
|
| 218 |
app.get('/api/auth/me', async (req, res) => {
|
| 219 |
const username = req.headers['x-user-username'];
|
| 220 |
if (!username) return res.status(401).json({ error: 'Unauthorized' });
|
|
@@ -302,14 +183,8 @@ app.post('/api/auth/register', async (req, res) => {
|
|
| 302 |
app.get('/api/users', async (req, res) => {
|
| 303 |
const filter = getQueryFilter(req);
|
| 304 |
const requesterRole = req.headers['x-user-role'];
|
| 305 |
-
|
| 306 |
-
// Additional filtering for Principal: Cannot see Admin role
|
| 307 |
-
if (requesterRole === 'PRINCIPAL') {
|
| 308 |
-
filter.role = { $ne: 'ADMIN' };
|
| 309 |
-
}
|
| 310 |
-
|
| 311 |
if (req.query.role) filter.role = req.query.role;
|
| 312 |
-
|
| 313 |
res.json(await User.find(filter).sort({ createTime: -1 }));
|
| 314 |
});
|
| 315 |
|
|
@@ -322,14 +197,9 @@ app.put('/api/users/:id', async (req, res) => {
|
|
| 322 |
const user = await User.findById(userId);
|
| 323 |
if (!user) return res.status(404).json({ error: 'User not found' });
|
| 324 |
|
| 325 |
-
// Security check for Principal
|
| 326 |
if (requesterRole === 'PRINCIPAL') {
|
| 327 |
-
if (user.schoolId !== req.headers['x-school-id']) {
|
| 328 |
-
|
| 329 |
-
}
|
| 330 |
-
if (user.role === 'ADMIN' || updates.role === 'ADMIN') {
|
| 331 |
-
return res.status(403).json({ error: 'Cannot modify Admin users' });
|
| 332 |
-
}
|
| 333 |
}
|
| 334 |
|
| 335 |
if (user.status !== 'active' && updates.status === 'active') {
|
|
@@ -385,13 +255,8 @@ app.post('/api/users/class-application', async (req, res) => {
|
|
| 385 |
await NotificationModel.create({
|
| 386 |
schoolId, targetUserId: userId, title: '申请已提交', content: `您已成功提交 ${typeText} (${type === 'CLAIM' ? targetClass : user.homeroomClass}) 的申请,等待管理员审核。`, type: 'info'
|
| 387 |
});
|
| 388 |
-
|
| 389 |
-
await NotificationModel.create({
|
| 390 |
-
schoolId, targetRole: 'ADMIN', title: '新的班主任任免申请', content: `${user.trueName || user.username} 申请 ${typeText},请及时处理。`, type: 'warning'
|
| 391 |
-
});
|
| 392 |
-
await NotificationModel.create({
|
| 393 |
-
schoolId, targetRole: 'PRINCIPAL', title: '新的班主任任免申请', content: `${user.trueName || user.username} 申请 ${typeText},请及时处理。`, type: 'warning'
|
| 394 |
-
});
|
| 395 |
return res.json({ success: true });
|
| 396 |
} catch (e) {
|
| 397 |
console.error(e);
|
|
@@ -412,9 +277,7 @@ app.post('/api/users/class-application', async (req, res) => {
|
|
| 412 |
const classes = await ClassModel.find({ schoolId });
|
| 413 |
const matchedClass = classes.find(c => (c.grade + c.className) === appTarget);
|
| 414 |
if (matchedClass) {
|
| 415 |
-
if (matchedClass.teacherName) {
|
| 416 |
-
await User.updateOne({ trueName: matchedClass.teacherName, schoolId }, { homeroomClass: '' });
|
| 417 |
-
}
|
| 418 |
await ClassModel.findByIdAndUpdate(matchedClass._id, { teacherName: user.trueName || user.username });
|
| 419 |
}
|
| 420 |
} else if (appType === 'RESIGN') {
|
|
@@ -422,18 +285,14 @@ app.post('/api/users/class-application', async (req, res) => {
|
|
| 422 |
if (user.homeroomClass) {
|
| 423 |
const classes = await ClassModel.find({ schoolId });
|
| 424 |
const matchedClass = classes.find(c => (c.grade + c.className) === user.homeroomClass);
|
| 425 |
-
if (matchedClass)
|
| 426 |
}
|
| 427 |
}
|
| 428 |
await User.findByIdAndUpdate(userId, updates);
|
| 429 |
-
await NotificationModel.create({
|
| 430 |
-
schoolId, targetUserId: userId, title: '申请已通过', content: `管理员已同意您的${appType === 'CLAIM' ? '任教' : '卸任'}申请。`, type: 'success'
|
| 431 |
-
});
|
| 432 |
} else {
|
| 433 |
await User.findByIdAndUpdate(userId, { 'classApplication.status': 'REJECTED' });
|
| 434 |
-
await NotificationModel.create({
|
| 435 |
-
schoolId, targetUserId: userId, title: '申请被拒绝', content: `管理员拒绝了您的${appType === 'CLAIM' ? '任教' : '卸任'}申请。`, type: 'error'
|
| 436 |
-
});
|
| 437 |
}
|
| 438 |
return res.json({ success: true });
|
| 439 |
}
|
|
@@ -444,7 +303,6 @@ app.post('/api/students/promote', async (req, res) => {
|
|
| 444 |
const { teacherFollows } = req.body;
|
| 445 |
const sId = req.headers['x-school-id'];
|
| 446 |
const role = req.headers['x-user-role'];
|
| 447 |
-
// Allow Principal to promote
|
| 448 |
if (role !== 'ADMIN' && role !== 'PRINCIPAL') return res.status(403).json({ error: 'Permission denied' });
|
| 449 |
|
| 450 |
const GRADE_MAP = {
|
|
@@ -471,47 +329,14 @@ app.post('/api/students/promote', async (req, res) => {
|
|
| 471 |
} else {
|
| 472 |
const oldFullClass = cls.grade + cls.className;
|
| 473 |
const newFullClass = nextGrade + suffix;
|
| 474 |
-
|
| 475 |
-
await
|
| 476 |
-
{ grade: nextGrade, className: suffix, schoolId: sId },
|
| 477 |
-
{ schoolId: sId, grade: nextGrade, className: suffix, teacherName: teacherFollows ? cls.teacherName : undefined },
|
| 478 |
-
{ upsert: true }
|
| 479 |
-
);
|
| 480 |
-
|
| 481 |
-
const result = await Student.updateMany(
|
| 482 |
-
{ className: oldFullClass, status: 'Enrolled', ...getQueryFilter(req) },
|
| 483 |
-
{ className: newFullClass }
|
| 484 |
-
);
|
| 485 |
promotedCount += result.modifiedCount;
|
| 486 |
|
| 487 |
if (teacherFollows && cls.teacherName) {
|
| 488 |
await User.updateOne({ trueName: cls.teacherName, schoolId: sId, homeroomClass: oldFullClass }, { homeroomClass: newFullClass });
|
| 489 |
await ClassModel.findByIdAndUpdate(cls._id, { teacherName: '' });
|
| 490 |
-
|
| 491 |
-
const achConfig = await AchievementConfigModel.findOne({ className: oldFullClass, schoolId: sId });
|
| 492 |
-
if (achConfig) {
|
| 493 |
-
await AchievementConfigModel.findOneAndUpdate(
|
| 494 |
-
{ className: newFullClass, schoolId: sId },
|
| 495 |
-
{ schoolId: sId, className: newFullClass, achievements: achConfig.achievements, exchangeRules: achConfig.exchangeRules },
|
| 496 |
-
{ upsert: true }
|
| 497 |
-
);
|
| 498 |
-
}
|
| 499 |
-
const luckyConfig = await LuckyDrawConfigModel.findOne({ className: oldFullClass, schoolId: sId });
|
| 500 |
-
if (luckyConfig) {
|
| 501 |
-
await LuckyDrawConfigModel.findOneAndUpdate(
|
| 502 |
-
{ className: newFullClass, schoolId: sId },
|
| 503 |
-
{ schoolId: sId, className: newFullClass, prizes: luckyConfig.prizes, dailyLimit: luckyConfig.dailyLimit, cardCount: luckyConfig.cardCount, defaultPrize: luckyConfig.defaultPrize, consolationWeight: luckyConfig.consolationWeight },
|
| 504 |
-
{ upsert: true }
|
| 505 |
-
);
|
| 506 |
-
}
|
| 507 |
-
const gameSession = await GameSessionModel.findOne({ className: oldFullClass, schoolId: sId });
|
| 508 |
-
if (gameSession) {
|
| 509 |
-
await GameSessionModel.findOneAndUpdate(
|
| 510 |
-
{ className: newFullClass, schoolId: sId },
|
| 511 |
-
{ schoolId: sId, className: newFullClass, isEnabled: gameSession.isEnabled, maxSteps: gameSession.maxSteps, teams: gameSession.teams, rewardsConfig: gameSession.rewardsConfig },
|
| 512 |
-
{ upsert: true }
|
| 513 |
-
);
|
| 514 |
-
}
|
| 515 |
}
|
| 516 |
}
|
| 517 |
}
|
|
@@ -527,7 +352,6 @@ app.post('/api/students/transfer', async (req, res) => {
|
|
| 527 |
res.json({ success: true });
|
| 528 |
});
|
| 529 |
|
| 530 |
-
// ... Achievements/Rewards/Attendance/etc ROUTES (keeping simplified for brevity, assume they exist as before) ...
|
| 531 |
app.get('/api/achievements/config', async (req, res) => {
|
| 532 |
const { className } = req.query;
|
| 533 |
if (!className) return res.status(400).json({ error: 'Class name required' });
|
|
@@ -602,11 +426,7 @@ app.post('/api/games/lucky-draw', async (req, res) => {
|
|
| 602 |
const consolationWeight = config?.consolationWeight || 0;
|
| 603 |
|
| 604 |
const availablePrizes = prizes.filter(p => (p.count === undefined || p.count > 0));
|
| 605 |
-
if (availablePrizes.length === 0 && consolationWeight === 0) {
|
| 606 |
-
const isTeacherOrAdmin = userRole === 'TEACHER' || userRole === 'ADMIN' || userRole === 'PRINCIPAL';
|
| 607 |
-
const msg = isTeacherOrAdmin ? '奖品库存不足,不能抽奖,请先补充库存' : '奖品库存不足,不能抽奖,请联系班主任补充库存';
|
| 608 |
-
return res.status(400).json({ error: 'POOL_EMPTY', message: msg });
|
| 609 |
-
}
|
| 610 |
|
| 611 |
if (userRole === 'STUDENT') {
|
| 612 |
if (student.drawAttempts <= 0) return res.status(403).json({ error: '次数不足', message: '您的抽奖次数已用完' });
|
|
@@ -633,10 +453,7 @@ app.post('/api/games/lucky-draw', async (req, res) => {
|
|
| 633 |
|
| 634 |
for (const p of availablePrizes) {
|
| 635 |
random -= (p.probability || 0);
|
| 636 |
-
if (random <= 0) {
|
| 637 |
-
matchedPrize = p;
|
| 638 |
-
break;
|
| 639 |
-
}
|
| 640 |
}
|
| 641 |
|
| 642 |
if (matchedPrize) {
|
|
@@ -682,49 +499,30 @@ app.post('/api/auth/login', async (req, res) => {
|
|
| 682 |
app.get('/api/schools', async (req, res) => { res.json(await School.find()); });
|
| 683 |
app.post('/api/schools', async (req, res) => { res.json(await School.create(req.body)); });
|
| 684 |
app.put('/api/schools/:id', async (req, res) => { await School.findByIdAndUpdate(req.params.id, req.body); res.json({}); });
|
| 685 |
-
// NEW: Delete School and ALL associated data
|
| 686 |
app.delete('/api/schools/:id', async (req, res) => {
|
| 687 |
const schoolId = req.params.id;
|
| 688 |
try {
|
| 689 |
-
// 1. Delete the School itself
|
| 690 |
await School.findByIdAndDelete(schoolId);
|
| 691 |
-
|
| 692 |
-
// 2. Delete Users (Teachers, Students, Admins linked to this school)
|
| 693 |
await User.deleteMany({ schoolId });
|
| 694 |
-
|
| 695 |
-
// 3. Delete Student Profiles
|
| 696 |
await Student.deleteMany({ schoolId });
|
| 697 |
-
|
| 698 |
-
// 4. Delete Classes
|
| 699 |
await ClassModel.deleteMany({ schoolId });
|
| 700 |
-
|
| 701 |
-
// 5. Delete Academic Data
|
| 702 |
await SubjectModel.deleteMany({ schoolId });
|
| 703 |
await Course.deleteMany({ schoolId });
|
| 704 |
await Score.deleteMany({ schoolId });
|
| 705 |
await ExamModel.deleteMany({ schoolId });
|
| 706 |
await ScheduleModel.deleteMany({ schoolId });
|
| 707 |
-
|
| 708 |
-
// 6. Delete Operational Data
|
| 709 |
await NotificationModel.deleteMany({ schoolId });
|
| 710 |
await AttendanceModel.deleteMany({ schoolId });
|
| 711 |
await LeaveRequestModel.deleteMany({ schoolId });
|
| 712 |
-
|
| 713 |
-
// 7. Delete Interactive/Game Data
|
| 714 |
await GameSessionModel.deleteMany({ schoolId });
|
| 715 |
await StudentRewardModel.deleteMany({ schoolId });
|
| 716 |
await LuckyDrawConfigModel.deleteMany({ schoolId });
|
| 717 |
await AchievementConfigModel.deleteMany({ schoolId });
|
| 718 |
await StudentAchievementModel.deleteMany({ schoolId });
|
| 719 |
-
|
| 720 |
res.json({ success: true });
|
| 721 |
-
} catch (e) {
|
| 722 |
-
console.error('Delete School Error:', e);
|
| 723 |
-
res.status(500).json({ error: e.message });
|
| 724 |
-
}
|
| 725 |
});
|
| 726 |
app.delete('/api/users/:id', async (req, res) => {
|
| 727 |
-
// Principal Safeguard
|
| 728 |
const requesterRole = req.headers['x-user-role'];
|
| 729 |
if (requesterRole === 'PRINCIPAL') {
|
| 730 |
const user = await User.findById(req.params.id);
|
|
@@ -735,7 +533,6 @@ app.delete('/api/users/:id', async (req, res) => {
|
|
| 735 |
res.json({});
|
| 736 |
});
|
| 737 |
app.get('/api/students', async (req, res) => { res.json(await Student.find(getQueryFilter(req))); });
|
| 738 |
-
|
| 739 |
app.post('/api/students', async (req, res) => {
|
| 740 |
const data = injectSchoolId(req, req.body);
|
| 741 |
if (data.studentNo === '') delete data.studentNo;
|
|
@@ -743,25 +540,18 @@ app.post('/api/students', async (req, res) => {
|
|
| 743 |
const existing = await Student.findOne({ schoolId: data.schoolId, name: data.name, className: data.className });
|
| 744 |
if (existing) {
|
| 745 |
Object.assign(existing, data);
|
| 746 |
-
if (!existing.studentNo) {
|
| 747 |
-
existing.studentNo = await generateStudentNo();
|
| 748 |
-
while (await Student.findOne({ studentNo: existing.studentNo })) existing.studentNo = await generateStudentNo();
|
| 749 |
-
}
|
| 750 |
await existing.save();
|
| 751 |
} else {
|
| 752 |
-
if (!data.studentNo) {
|
| 753 |
-
data.studentNo = await generateStudentNo();
|
| 754 |
-
while (await Student.findOne({ studentNo: data.studentNo })) data.studentNo = await generateStudentNo();
|
| 755 |
-
}
|
| 756 |
await Student.create(data);
|
| 757 |
}
|
| 758 |
res.json({ success: true });
|
| 759 |
} catch (e) {
|
| 760 |
-
if (e.code === 11000) return res.status(409).json({ error: 'DUPLICATE', message: '保存失败:存在重复的系统ID
|
| 761 |
res.status(500).json({ error: e.message });
|
| 762 |
}
|
| 763 |
});
|
| 764 |
-
|
| 765 |
app.put('/api/students/:id', async (req, res) => { await Student.findByIdAndUpdate(req.params.id, req.body); res.json({}); });
|
| 766 |
app.delete('/api/students/:id', async (req, res) => { await Student.findByIdAndDelete(req.params.id); res.json({}); });
|
| 767 |
app.get('/api/classes', async (req, res) => {
|
|
@@ -776,10 +566,7 @@ app.get('/api/classes', async (req, res) => {
|
|
| 776 |
app.post('/api/classes', async (req, res) => {
|
| 777 |
const data = injectSchoolId(req, req.body);
|
| 778 |
await ClassModel.create(data);
|
| 779 |
-
if (data.teacherName) {
|
| 780 |
-
const fullClass = data.grade + data.className;
|
| 781 |
-
await User.updateOne({ trueName: data.teacherName, schoolId: data.schoolId }, { homeroomClass: fullClass });
|
| 782 |
-
}
|
| 783 |
res.json({});
|
| 784 |
});
|
| 785 |
app.put('/api/classes/:id', async (req, res) => {
|
|
@@ -793,21 +580,14 @@ app.put('/api/classes/:id', async (req, res) => {
|
|
| 793 |
if (oldClass.teacherName && (oldClass.teacherName !== teacherName || oldFullClass !== newFullClass)) {
|
| 794 |
await User.updateOne({ trueName: oldClass.teacherName, schoolId: sId, homeroomClass: oldFullClass }, { homeroomClass: '' });
|
| 795 |
}
|
| 796 |
-
if (teacherName) {
|
| 797 |
-
await User.updateOne({ trueName: teacherName, schoolId: sId }, { homeroomClass: newFullClass });
|
| 798 |
-
}
|
| 799 |
await ClassModel.findByIdAndUpdate(classId, { grade, className, teacherName });
|
| 800 |
-
if (oldFullClass !== newFullClass) {
|
| 801 |
-
await Student.updateMany({ className: oldFullClass, schoolId: sId }, { className: newFullClass });
|
| 802 |
-
}
|
| 803 |
res.json({ success: true });
|
| 804 |
});
|
| 805 |
app.delete('/api/classes/:id', async (req, res) => {
|
| 806 |
const cls = await ClassModel.findById(req.params.id);
|
| 807 |
-
if (cls && cls.teacherName) {
|
| 808 |
-
const fullClass = cls.grade + cls.className;
|
| 809 |
-
await User.updateOne({ trueName: cls.teacherName, schoolId: cls.schoolId, homeroomClass: fullClass }, { homeroomClass: '' });
|
| 810 |
-
}
|
| 811 |
await ClassModel.findByIdAndDelete(req.params.id);
|
| 812 |
res.json({});
|
| 813 |
});
|
|
|
|
| 6 |
const bodyParser = require('body-parser');
|
| 7 |
const path = require('path');
|
| 8 |
const compression = require('compression');
|
| 9 |
+
const {
|
| 10 |
+
School, User, Student, Course, Score, ClassModel, SubjectModel, ExamModel, ScheduleModel,
|
| 11 |
+
ConfigModel, NotificationModel, GameSessionModel, StudentRewardModel, LuckyDrawConfigModel,
|
| 12 |
+
AchievementConfigModel, StudentAchievementModel, AttendanceModel, LeaveRequestModel
|
| 13 |
+
} = require('./models');
|
| 14 |
|
| 15 |
// ... constants
|
| 16 |
const PORT = 7860;
|
|
|
|
| 25 |
app.use(bodyParser.json({ limit: '10mb' }));
|
| 26 |
|
| 27 |
// PERFORMANCE 2: Smart Caching Strategy
|
|
|
|
|
|
|
| 28 |
app.use(express.static(path.join(__dirname, 'dist'), {
|
| 29 |
setHeaders: (res, filePath) => {
|
| 30 |
if (filePath.endsWith('.html')) {
|
|
|
|
| 31 |
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
| 32 |
} else {
|
|
|
|
| 33 |
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
| 34 |
}
|
| 35 |
}
|
| 36 |
}));
|
| 37 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
const InMemoryDB = {
|
| 39 |
schools: [],
|
| 40 |
users: [],
|
| 41 |
+
// ... other mock data if needed, but we rely on Mongo mostly now
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
isFallback: false
|
| 43 |
};
|
| 44 |
|
|
|
|
| 48 |
console.log('✅ MongoDB 连接成功 (Real Data)');
|
| 49 |
} catch (err) {
|
| 50 |
console.error('❌ MongoDB 连接失败:', err.message);
|
| 51 |
+
console.warn('⚠️ 启动内存数据库模式 (Limited functionality)');
|
| 52 |
InMemoryDB.isFallback = true;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
}
|
| 54 |
};
|
| 55 |
connectDB();
|
| 56 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
// ... Helpers ...
|
| 58 |
const getQueryFilter = (req) => {
|
| 59 |
const s = req.headers['x-school-id'];
|
| 60 |
const role = req.headers['x-user-role'];
|
| 61 |
|
|
|
|
| 62 |
if (role === 'PRINCIPAL') {
|
| 63 |
+
if (!s) return { _id: null };
|
| 64 |
return { schoolId: s };
|
| 65 |
}
|
| 66 |
|
|
|
|
| 91 |
|
| 92 |
const generateStudentNo = async () => {
|
| 93 |
const year = new Date().getFullYear();
|
| 94 |
+
const random = Math.floor(100000 + Math.random() * 900000);
|
| 95 |
return `${year}${random}`;
|
| 96 |
};
|
| 97 |
|
| 98 |
+
// ... ROUTES ...
|
| 99 |
app.get('/api/auth/me', async (req, res) => {
|
| 100 |
const username = req.headers['x-user-username'];
|
| 101 |
if (!username) return res.status(401).json({ error: 'Unauthorized' });
|
|
|
|
| 183 |
app.get('/api/users', async (req, res) => {
|
| 184 |
const filter = getQueryFilter(req);
|
| 185 |
const requesterRole = req.headers['x-user-role'];
|
| 186 |
+
if (requesterRole === 'PRINCIPAL') filter.role = { $ne: 'ADMIN' };
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 187 |
if (req.query.role) filter.role = req.query.role;
|
|
|
|
| 188 |
res.json(await User.find(filter).sort({ createTime: -1 }));
|
| 189 |
});
|
| 190 |
|
|
|
|
| 197 |
const user = await User.findById(userId);
|
| 198 |
if (!user) return res.status(404).json({ error: 'User not found' });
|
| 199 |
|
|
|
|
| 200 |
if (requesterRole === 'PRINCIPAL') {
|
| 201 |
+
if (user.schoolId !== req.headers['x-school-id']) return res.status(403).json({ error: 'Permission denied' });
|
| 202 |
+
if (user.role === 'ADMIN' || updates.role === 'ADMIN') return res.status(403).json({ error: 'Cannot modify Admin users' });
|
|
|
|
|
|
|
|
|
|
|
|
|
| 203 |
}
|
| 204 |
|
| 205 |
if (user.status !== 'active' && updates.status === 'active') {
|
|
|
|
| 255 |
await NotificationModel.create({
|
| 256 |
schoolId, targetUserId: userId, title: '申请已提交', content: `您已成功提交 ${typeText} (${type === 'CLAIM' ? targetClass : user.homeroomClass}) 的申请,等待管理员审核。`, type: 'info'
|
| 257 |
});
|
| 258 |
+
await NotificationModel.create({ schoolId, targetRole: 'ADMIN', title: '新的班主任任免申请', content: `${user.trueName || user.username} 申请 ${typeText},请及时处理。`, type: 'warning' });
|
| 259 |
+
await NotificationModel.create({ schoolId, targetRole: 'PRINCIPAL', title: '新的班主任任免申请', content: `${user.trueName || user.username} 申请 ${typeText},请及时处理。`, type: 'warning' });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 260 |
return res.json({ success: true });
|
| 261 |
} catch (e) {
|
| 262 |
console.error(e);
|
|
|
|
| 277 |
const classes = await ClassModel.find({ schoolId });
|
| 278 |
const matchedClass = classes.find(c => (c.grade + c.className) === appTarget);
|
| 279 |
if (matchedClass) {
|
| 280 |
+
if (matchedClass.teacherName) await User.updateOne({ trueName: matchedClass.teacherName, schoolId }, { homeroomClass: '' });
|
|
|
|
|
|
|
| 281 |
await ClassModel.findByIdAndUpdate(matchedClass._id, { teacherName: user.trueName || user.username });
|
| 282 |
}
|
| 283 |
} else if (appType === 'RESIGN') {
|
|
|
|
| 285 |
if (user.homeroomClass) {
|
| 286 |
const classes = await ClassModel.find({ schoolId });
|
| 287 |
const matchedClass = classes.find(c => (c.grade + c.className) === user.homeroomClass);
|
| 288 |
+
if (matchedClass) await ClassModel.findByIdAndUpdate(matchedClass._id, { teacherName: '' });
|
| 289 |
}
|
| 290 |
}
|
| 291 |
await User.findByIdAndUpdate(userId, updates);
|
| 292 |
+
await NotificationModel.create({ schoolId, targetUserId: userId, title: '申请已通过', content: `管理员已同意您的${appType === 'CLAIM' ? '任教' : '卸任'}申请。`, type: 'success' });
|
|
|
|
|
|
|
| 293 |
} else {
|
| 294 |
await User.findByIdAndUpdate(userId, { 'classApplication.status': 'REJECTED' });
|
| 295 |
+
await NotificationModel.create({ schoolId, targetUserId: userId, title: '申请被拒绝', content: `管理员拒绝了您的${appType === 'CLAIM' ? '任教' : '卸任'}申请。`, type: 'error' });
|
|
|
|
|
|
|
| 296 |
}
|
| 297 |
return res.json({ success: true });
|
| 298 |
}
|
|
|
|
| 303 |
const { teacherFollows } = req.body;
|
| 304 |
const sId = req.headers['x-school-id'];
|
| 305 |
const role = req.headers['x-user-role'];
|
|
|
|
| 306 |
if (role !== 'ADMIN' && role !== 'PRINCIPAL') return res.status(403).json({ error: 'Permission denied' });
|
| 307 |
|
| 308 |
const GRADE_MAP = {
|
|
|
|
| 329 |
} else {
|
| 330 |
const oldFullClass = cls.grade + cls.className;
|
| 331 |
const newFullClass = nextGrade + suffix;
|
| 332 |
+
await ClassModel.findOneAndUpdate({ grade: nextGrade, className: suffix, schoolId: sId }, { schoolId: sId, grade: nextGrade, className: suffix, teacherName: teacherFollows ? cls.teacherName : undefined }, { upsert: true });
|
| 333 |
+
const result = await Student.updateMany({ className: oldFullClass, status: 'Enrolled', ...getQueryFilter(req) }, { className: newFullClass });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 334 |
promotedCount += result.modifiedCount;
|
| 335 |
|
| 336 |
if (teacherFollows && cls.teacherName) {
|
| 337 |
await User.updateOne({ trueName: cls.teacherName, schoolId: sId, homeroomClass: oldFullClass }, { homeroomClass: newFullClass });
|
| 338 |
await ClassModel.findByIdAndUpdate(cls._id, { teacherName: '' });
|
| 339 |
+
// Migrate other configs (omitted for brevity)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 340 |
}
|
| 341 |
}
|
| 342 |
}
|
|
|
|
| 352 |
res.json({ success: true });
|
| 353 |
});
|
| 354 |
|
|
|
|
| 355 |
app.get('/api/achievements/config', async (req, res) => {
|
| 356 |
const { className } = req.query;
|
| 357 |
if (!className) return res.status(400).json({ error: 'Class name required' });
|
|
|
|
| 426 |
const consolationWeight = config?.consolationWeight || 0;
|
| 427 |
|
| 428 |
const availablePrizes = prizes.filter(p => (p.count === undefined || p.count > 0));
|
| 429 |
+
if (availablePrizes.length === 0 && consolationWeight === 0) return res.status(400).json({ error: 'POOL_EMPTY', message: '奖品库存不足' });
|
|
|
|
|
|
|
|
|
|
|
|
|
| 430 |
|
| 431 |
if (userRole === 'STUDENT') {
|
| 432 |
if (student.drawAttempts <= 0) return res.status(403).json({ error: '次数不足', message: '您的抽奖次数已用完' });
|
|
|
|
| 453 |
|
| 454 |
for (const p of availablePrizes) {
|
| 455 |
random -= (p.probability || 0);
|
| 456 |
+
if (random <= 0) { matchedPrize = p; break; }
|
|
|
|
|
|
|
|
|
|
| 457 |
}
|
| 458 |
|
| 459 |
if (matchedPrize) {
|
|
|
|
| 499 |
app.get('/api/schools', async (req, res) => { res.json(await School.find()); });
|
| 500 |
app.post('/api/schools', async (req, res) => { res.json(await School.create(req.body)); });
|
| 501 |
app.put('/api/schools/:id', async (req, res) => { await School.findByIdAndUpdate(req.params.id, req.body); res.json({}); });
|
|
|
|
| 502 |
app.delete('/api/schools/:id', async (req, res) => {
|
| 503 |
const schoolId = req.params.id;
|
| 504 |
try {
|
|
|
|
| 505 |
await School.findByIdAndDelete(schoolId);
|
|
|
|
|
|
|
| 506 |
await User.deleteMany({ schoolId });
|
|
|
|
|
|
|
| 507 |
await Student.deleteMany({ schoolId });
|
|
|
|
|
|
|
| 508 |
await ClassModel.deleteMany({ schoolId });
|
|
|
|
|
|
|
| 509 |
await SubjectModel.deleteMany({ schoolId });
|
| 510 |
await Course.deleteMany({ schoolId });
|
| 511 |
await Score.deleteMany({ schoolId });
|
| 512 |
await ExamModel.deleteMany({ schoolId });
|
| 513 |
await ScheduleModel.deleteMany({ schoolId });
|
|
|
|
|
|
|
| 514 |
await NotificationModel.deleteMany({ schoolId });
|
| 515 |
await AttendanceModel.deleteMany({ schoolId });
|
| 516 |
await LeaveRequestModel.deleteMany({ schoolId });
|
|
|
|
|
|
|
| 517 |
await GameSessionModel.deleteMany({ schoolId });
|
| 518 |
await StudentRewardModel.deleteMany({ schoolId });
|
| 519 |
await LuckyDrawConfigModel.deleteMany({ schoolId });
|
| 520 |
await AchievementConfigModel.deleteMany({ schoolId });
|
| 521 |
await StudentAchievementModel.deleteMany({ schoolId });
|
|
|
|
| 522 |
res.json({ success: true });
|
| 523 |
+
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
|
|
|
|
|
|
|
|
| 524 |
});
|
| 525 |
app.delete('/api/users/:id', async (req, res) => {
|
|
|
|
| 526 |
const requesterRole = req.headers['x-user-role'];
|
| 527 |
if (requesterRole === 'PRINCIPAL') {
|
| 528 |
const user = await User.findById(req.params.id);
|
|
|
|
| 533 |
res.json({});
|
| 534 |
});
|
| 535 |
app.get('/api/students', async (req, res) => { res.json(await Student.find(getQueryFilter(req))); });
|
|
|
|
| 536 |
app.post('/api/students', async (req, res) => {
|
| 537 |
const data = injectSchoolId(req, req.body);
|
| 538 |
if (data.studentNo === '') delete data.studentNo;
|
|
|
|
| 540 |
const existing = await Student.findOne({ schoolId: data.schoolId, name: data.name, className: data.className });
|
| 541 |
if (existing) {
|
| 542 |
Object.assign(existing, data);
|
| 543 |
+
if (!existing.studentNo) { existing.studentNo = await generateStudentNo(); }
|
|
|
|
|
|
|
|
|
|
| 544 |
await existing.save();
|
| 545 |
} else {
|
| 546 |
+
if (!data.studentNo) { data.studentNo = await generateStudentNo(); }
|
|
|
|
|
|
|
|
|
|
| 547 |
await Student.create(data);
|
| 548 |
}
|
| 549 |
res.json({ success: true });
|
| 550 |
} catch (e) {
|
| 551 |
+
if (e.code === 11000) return res.status(409).json({ error: 'DUPLICATE', message: '保存失败:存在重复的系统ID' });
|
| 552 |
res.status(500).json({ error: e.message });
|
| 553 |
}
|
| 554 |
});
|
|
|
|
| 555 |
app.put('/api/students/:id', async (req, res) => { await Student.findByIdAndUpdate(req.params.id, req.body); res.json({}); });
|
| 556 |
app.delete('/api/students/:id', async (req, res) => { await Student.findByIdAndDelete(req.params.id); res.json({}); });
|
| 557 |
app.get('/api/classes', async (req, res) => {
|
|
|
|
| 566 |
app.post('/api/classes', async (req, res) => {
|
| 567 |
const data = injectSchoolId(req, req.body);
|
| 568 |
await ClassModel.create(data);
|
| 569 |
+
if (data.teacherName) await User.updateOne({ trueName: data.teacherName, schoolId: data.schoolId }, { homeroomClass: data.grade + data.className });
|
|
|
|
|
|
|
|
|
|
| 570 |
res.json({});
|
| 571 |
});
|
| 572 |
app.put('/api/classes/:id', async (req, res) => {
|
|
|
|
| 580 |
if (oldClass.teacherName && (oldClass.teacherName !== teacherName || oldFullClass !== newFullClass)) {
|
| 581 |
await User.updateOne({ trueName: oldClass.teacherName, schoolId: sId, homeroomClass: oldFullClass }, { homeroomClass: '' });
|
| 582 |
}
|
| 583 |
+
if (teacherName) await User.updateOne({ trueName: teacherName, schoolId: sId }, { homeroomClass: newFullClass });
|
|
|
|
|
|
|
| 584 |
await ClassModel.findByIdAndUpdate(classId, { grade, className, teacherName });
|
| 585 |
+
if (oldFullClass !== newFullClass) await Student.updateMany({ className: oldFullClass, schoolId: sId }, { className: newFullClass });
|
|
|
|
|
|
|
| 586 |
res.json({ success: true });
|
| 587 |
});
|
| 588 |
app.delete('/api/classes/:id', async (req, res) => {
|
| 589 |
const cls = await ClassModel.findById(req.params.id);
|
| 590 |
+
if (cls && cls.teacherName) await User.updateOne({ trueName: cls.teacherName, schoolId: cls.schoolId, homeroomClass: cls.grade + cls.className }, { homeroomClass: '' });
|
|
|
|
|
|
|
|
|
|
| 591 |
await ClassModel.findByIdAndDelete(req.params.id);
|
| 592 |
res.json({});
|
| 593 |
});
|