dvc890 commited on
Commit
9743332
·
verified ·
1 Parent(s): e4ab02b

Upload 40 files

Browse files
Files changed (4) hide show
  1. models.js +116 -0
  2. pages/StudentReports.tsx +42 -5
  3. pages/TeacherReports.tsx +101 -26
  4. 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, Attendance } from '../types';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- return { subject: sub.name, score: Number(avg.toFixed(1)), fullMark: 100 };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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={{fontSize:11}} />
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 midAvg = midScores.length ? midScores.reduce((a,b)=>a+b.score,0)/midScores.length : 0;
161
- const finalAvg = finalScores.length ? finalScores.reduce((a,b)=>a+b.score,0)/finalScores.length : 0;
 
 
 
 
 
 
 
 
 
 
162
 
163
  return {
164
  name: g,
165
- 期中: Number(midAvg.toFixed(1)),
166
- : Number(finalAvg.toFixed(1)),
167
- delta: Number((finalAvg - midAvg).toFixed(1))
 
168
  };
169
- }).filter(d => d.期中 > 0 || d.期末 > 0);
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
- return subjects.map(sub => {
 
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
- return { subject: sub.name, score: Number(avg.toFixed(1)), fullMark: 100 };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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={40}>
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="flex justify-between gap-4 mb-1">
425
- <span className="text-blue-600 font-bold">期中: {data.期中}</span>
426
- <span className="text-amber-500 font-bold">期: {data.期}</span>
427
- </div>
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="期中" fill="#3b82f6" name="期中均分" radius={[4,4,0,0]} />
439
- <Bar dataKey="期" fill="#f59e0b" name="期末平均分" radius={[4,4,0,0]} />
 
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>暂无期中/期末对比数据</p>
446
- <p className="text-xs mt-1">请在“成绩管理 -&gt; 考试排期”中标记考试类型</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={{fontSize:10}} />
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={{r:4}} activeDot={{r:6}} />
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">请在“成绩管理 -&gt; 考试排期”中标记考试类型,并确保已录入成绩</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
- students: [],
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 }; // Should not happen, but safe fail
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); // 6 digits
214
  return `${year}${random}`;
215
  };
216
 
217
- // ... ROUTES (Copying all routes from previous correct version) ...
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
- return res.status(403).json({ error: 'Permission denied' });
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
- // Notify Admins and Principals
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) { await ClassModel.findByIdAndUpdate(matchedClass._id, { teacherName: '' }); }
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 ClassModel.findOneAndUpdate(
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
  });