dvc890 commited on
Commit
0b92b50
·
verified ·
1 Parent(s): a01dcda

Upload 25 files

Browse files
Files changed (6) hide show
  1. pages/Reports.tsx +34 -32
  2. pages/ScoreList.tsx +100 -27
  3. pages/SubjectList.tsx +76 -26
  4. server.js +38 -15
  5. services/api.ts +4 -1
  6. types.ts +4 -0
pages/Reports.tsx CHANGED
@@ -3,7 +3,7 @@ import React, { useState, useEffect } from 'react';
3
  import {
4
  BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
5
  RadarChart, PolarGrid, PolarAngleAxis, PolarRadiusAxis, Radar,
6
- PieChart, Pie, Cell, Legend, AreaChart, Area
7
  } from 'recharts';
8
  import { Loader2, Download, Filter, TrendingUp, Users, BookOpen, Award, PieChart as PieChartIcon } from 'lucide-react';
9
  import { api } from '../services/api';
@@ -83,24 +83,34 @@ export const Reports: React.FC = () => {
83
  });
84
  }
85
 
 
 
 
86
  // 1. Basic Stats
87
- const totalScore = filteredScores.reduce((sum: number, s: Score) => sum + (s.score || 0), 0);
88
- const avg = filteredScores.length ? Math.round(totalScore / filteredScores.length) : 0;
89
- const max = filteredScores.length ? Math.max(...filteredScores.map((s: Score) => s.score || 0)) : 0;
90
- const passCount = filteredScores.filter((s: Score) => (s.score || 0) >= 60).length;
91
- const excellentCount = filteredScores.filter((s: Score) => (s.score || 0) >= 90).length;
 
 
 
 
 
 
 
92
 
93
  setStats({
94
  avg,
95
  max,
96
- passRate: filteredScores.length ? Math.round((passCount / filteredScores.length) * 100) : 0,
97
- excellentRate: filteredScores.length ? Math.round((excellentCount / filteredScores.length) * 100) : 0
98
  });
99
 
100
  // 2. Class Comparison Data (Bar Chart)
101
- // Group scores by class
102
  const classMap = new Map<string, number[]>();
103
  filteredScores.forEach((s: Score) => {
 
104
  const stu = students.find((st: Student) => st.studentNo === s.studentNo);
105
  if (stu) {
106
  const clsName = stu.className;
@@ -114,26 +124,30 @@ export const Reports: React.FC = () => {
114
  const clsAvg = Math.round(scores.reduce((a, b) => a + b, 0) / scores.length);
115
  clsData.push({ name, avg: clsAvg, count: scores.length });
116
  });
117
- setClassComparisonData(clsData.sort((a, b) => b.avg - a.avg)); // Sort by high score
118
 
119
  // 3. Subject Radar Data
120
  const subData: any[] = subjects.map((sub: Subject) => {
121
- const subScores = filteredScores.filter((s: Score) => s.courseName === sub.name);
122
  const subTotal = subScores.reduce((sum: number, s: Score) => sum + (s.score || 0), 0);
123
  return {
124
  subject: sub.name,
125
  A: subScores.length ? Math.round(subTotal / subScores.length) : 0,
126
- fullMark: 100
 
127
  };
128
  });
129
  setSubjectRadarData(subData);
130
 
131
- // 4. Distribution Pie
132
  setGradeDistData([
133
- { name: '优秀 (90-100)', value: excellentCount, color: '#10b981' },
134
- { name: '良好 (80-89)', value: filteredScores.filter((s: Score) => s.score >= 80 && s.score < 90).length, color: '#3b82f6' },
135
- { name: '及格 (60-79)', value: filteredScores.filter((s: Score) => s.score >= 60 && s.score < 80).length, color: '#f59e0b' },
136
- { name: '不及格 (<60)', value: filteredScores.length - passCount, color: '#ef4444' }
 
 
 
137
  ]);
138
 
139
  }, [scores, students, selectedGrade, loading, subjects]);
@@ -142,12 +156,10 @@ export const Reports: React.FC = () => {
142
  // @ts-ignore
143
  if (!window.XLSX) return alert('Excel 组件未加载,请检查网络');
144
 
145
- // Export Logic using XLSX
146
  try {
147
  // @ts-ignore
148
  const wb = window.XLSX.utils.book_new();
149
 
150
- // Sheet 1: Stats
151
  const statsData = [{
152
  '年级': selectedGrade,
153
  '平均分': stats.avg,
@@ -160,7 +172,6 @@ export const Reports: React.FC = () => {
160
  // @ts-ignore
161
  window.XLSX.utils.book_append_sheet(wb, wsStats, "概览");
162
 
163
- // Sheet 2: Class Ranking
164
  // @ts-ignore
165
  const wsClass = window.XLSX.utils.json_to_sheet(classComparisonData.map((c: any) => ({
166
  '班级': c.name,
@@ -170,15 +181,6 @@ export const Reports: React.FC = () => {
170
  // @ts-ignore
171
  window.XLSX.utils.book_append_sheet(wb, wsClass, "班级排名");
172
 
173
- // Sheet 3: Subject Breakdown
174
- // @ts-ignore
175
- const wsSubject = window.XLSX.utils.json_to_sheet(subjectRadarData.map((s: any) => ({
176
- '学科': s.subject,
177
- '平均分': s.A
178
- })));
179
- // @ts-ignore
180
- window.XLSX.utils.book_append_sheet(wb, wsSubject, "学科分析");
181
-
182
  // @ts-ignore
183
  window.XLSX.writeFile(wb, `教学报表_${selectedGrade}_${new Date().toLocaleDateString()}.xlsx`);
184
  } catch (e) {
@@ -254,7 +256,7 @@ export const Reports: React.FC = () => {
254
  {/* OVERVIEW TAB */}
255
  {activeTab === 'overview' && (
256
  <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
257
- <Card title="成绩等级分布" subtitle="全校学生成绩分段统计">
258
  <div className="h-80">
259
  {scores.length > 0 ? (
260
  <ResponsiveContainer width="100%" height="100%">
@@ -331,11 +333,11 @@ export const Reports: React.FC = () => {
331
  </div>
332
  <div>
333
  <h4 className="font-bold text-gray-800">{sub.subject}</h4>
334
- <p className="text-xs text-gray-400">平均分</p>
335
  </div>
336
  </div>
337
  <div className="text-right">
338
- <span className={`text-2xl font-bold ${sub.A >= 90 ? 'text-emerald-500' : sub.A >= 60 ? 'text-blue-600' : 'text-red-500'}`}>
339
  {sub.A}
340
  </span>
341
  <span className="text-xs text-gray-400 ml-1">/ 100</span>
 
3
  import {
4
  BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
5
  RadarChart, PolarGrid, PolarAngleAxis, PolarRadiusAxis, Radar,
6
+ PieChart, Pie, Cell, Legend
7
  } from 'recharts';
8
  import { Loader2, Download, Filter, TrendingUp, Users, BookOpen, Award, PieChart as PieChartIcon } from 'lucide-react';
9
  import { api } from '../services/api';
 
83
  });
84
  }
85
 
86
+ // Only count Normal exams for average/max statistics
87
+ const validScores = filteredScores.filter((s: Score) => s.status === 'Normal');
88
+
89
  // 1. Basic Stats
90
+ const totalScore = validScores.reduce((sum: number, s: Score) => sum + (s.score || 0), 0);
91
+ const avg = validScores.length ? Math.round(totalScore / validScores.length) : 0;
92
+ const max = validScores.length ? Math.max(...validScores.map((s: Score) => s.score || 0)) : 0;
93
+ const passCount = validScores.filter((s: Score) => (s.score || 0) >= 60).length;
94
+
95
+ // Dynamic Excellence Calculation based on subject threshold
96
+ let excellentCount = 0;
97
+ validScores.forEach((s: Score) => {
98
+ const sub = subjects.find(su => su.name === s.courseName);
99
+ const threshold = sub?.excellenceThreshold || 90;
100
+ if (s.score >= threshold) excellentCount++;
101
+ });
102
 
103
  setStats({
104
  avg,
105
  max,
106
+ passRate: validScores.length ? Math.round((passCount / validScores.length) * 100) : 0,
107
+ excellentRate: validScores.length ? Math.round((excellentCount / validScores.length) * 100) : 0
108
  });
109
 
110
  // 2. Class Comparison Data (Bar Chart)
 
111
  const classMap = new Map<string, number[]>();
112
  filteredScores.forEach((s: Score) => {
113
+ if (s.status !== 'Normal') return; // Skip abnormal for class avg
114
  const stu = students.find((st: Student) => st.studentNo === s.studentNo);
115
  if (stu) {
116
  const clsName = stu.className;
 
124
  const clsAvg = Math.round(scores.reduce((a, b) => a + b, 0) / scores.length);
125
  clsData.push({ name, avg: clsAvg, count: scores.length });
126
  });
127
+ setClassComparisonData(clsData.sort((a, b) => b.avg - a.avg));
128
 
129
  // 3. Subject Radar Data
130
  const subData: any[] = subjects.map((sub: Subject) => {
131
+ const subScores = validScores.filter((s: Score) => s.courseName === sub.name);
132
  const subTotal = subScores.reduce((sum: number, s: Score) => sum + (s.score || 0), 0);
133
  return {
134
  subject: sub.name,
135
  A: subScores.length ? Math.round(subTotal / subScores.length) : 0,
136
+ fullMark: 100,
137
+ threshold: sub.excellenceThreshold || 90
138
  };
139
  });
140
  setSubjectRadarData(subData);
141
 
142
+ // 4. Distribution Pie (Use valid scores)
143
  setGradeDistData([
144
+ { name: '优秀', value: excellentCount, color: '#10b981' },
145
+ { name: '良好', value: validScores.filter((s: Score) => {
146
+ const threshold = subjects.find(sub => sub.name === s.courseName)?.excellenceThreshold || 90;
147
+ return s.score >= 80 && s.score < threshold;
148
+ }).length, color: '#3b82f6' },
149
+ { name: '及格', value: validScores.filter((s: Score) => s.score >= 60 && s.score < 80).length, color: '#f59e0b' },
150
+ { name: '不及格', value: validScores.filter((s: Score) => s.score < 60).length, color: '#ef4444' }
151
  ]);
152
 
153
  }, [scores, students, selectedGrade, loading, subjects]);
 
156
  // @ts-ignore
157
  if (!window.XLSX) return alert('Excel 组件未加载,请检查网络');
158
 
 
159
  try {
160
  // @ts-ignore
161
  const wb = window.XLSX.utils.book_new();
162
 
 
163
  const statsData = [{
164
  '年级': selectedGrade,
165
  '平均分': stats.avg,
 
172
  // @ts-ignore
173
  window.XLSX.utils.book_append_sheet(wb, wsStats, "概览");
174
 
 
175
  // @ts-ignore
176
  const wsClass = window.XLSX.utils.json_to_sheet(classComparisonData.map((c: any) => ({
177
  '班级': c.name,
 
181
  // @ts-ignore
182
  window.XLSX.utils.book_append_sheet(wb, wsClass, "班级排名");
183
 
 
 
 
 
 
 
 
 
 
184
  // @ts-ignore
185
  window.XLSX.writeFile(wb, `教学报表_${selectedGrade}_${new Date().toLocaleDateString()}.xlsx`);
186
  } catch (e) {
 
256
  {/* OVERVIEW TAB */}
257
  {activeTab === 'overview' && (
258
  <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
259
+ <Card title="成绩等级分布" subtitle="剔除缺考数据">
260
  <div className="h-80">
261
  {scores.length > 0 ? (
262
  <ResponsiveContainer width="100%" height="100%">
 
333
  </div>
334
  <div>
335
  <h4 className="font-bold text-gray-800">{sub.subject}</h4>
336
+ <p className="text-xs text-gray-400">平均分 | 优秀线: {sub.threshold}</p>
337
  </div>
338
  </div>
339
  <div className="text-right">
340
+ <span className={`text-2xl font-bold ${sub.A >= sub.threshold ? 'text-emerald-500' : sub.A >= 60 ? 'text-blue-600' : 'text-red-500'}`}>
341
  {sub.A}
342
  </span>
343
  <span className="text-xs text-gray-400 ml-1">/ 100</span>
pages/ScoreList.tsx CHANGED
@@ -1,8 +1,8 @@
1
 
2
  import React, { useState, useEffect } from 'react';
3
  import { api } from '../services/api';
4
- import { Score, Student, Subject, ClassInfo } from '../types';
5
- import { Loader2, Plus, Trash2, Award, FileSpreadsheet, X, Upload } from 'lucide-react';
6
 
7
  export const ScoreList: React.FC = () => {
8
  const [subjects, setSubjects] = useState<Subject[]>([]);
@@ -24,10 +24,15 @@ export const ScoreList: React.FC = () => {
24
  // Import Config
25
  const [importSemester, setImportSemester] = useState('2023-2024学年 第一学期');
26
  const [importType, setImportType] = useState('Final');
27
- const [importExamName, setImportExamName] = useState(''); // New: Exam Name for import
28
 
29
  // Manual Add Form
30
- const [formData, setFormData] = useState({ studentId: '', score: '', type: 'Final', examName: '' });
 
 
 
 
 
31
 
32
  const loadData = async () => {
33
  setLoading(true);
@@ -76,13 +81,29 @@ export const ScoreList: React.FC = () => {
76
  studentName: stu.name,
77
  studentNo: stu.studentNo,
78
  courseName: activeSubject,
79
- score: Number(formData.score),
80
  semester: '2023-2024学年 第一学期',
81
  type: formData.type,
82
- examName: formData.examName
 
83
  });
84
  setIsAddOpen(false);
85
- setFormData({ studentId: '', score: '', type: 'Final', examName: '' });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
  loadData();
87
  };
88
 
@@ -106,10 +127,7 @@ export const ScoreList: React.FC = () => {
106
 
107
  console.log('Importing scores:', jsonData);
108
 
109
- // Get Headers & normalize
110
  const headers = Object.keys(jsonData[0] || {}).map(h => h.trim());
111
-
112
- // Find matched subjects in headers
113
  const matchedSubjects = subjects.filter(s => headers.includes(s.name));
114
 
115
  if (matchedSubjects.length === 0) {
@@ -132,17 +150,30 @@ export const ScoreList: React.FC = () => {
132
 
133
  if (student) {
134
  matchedSubjects.forEach(sub => {
135
- const scoreVal = cleanRow[sub.name];
136
- if (scoreVal !== undefined && scoreVal !== null && scoreVal !== '') {
 
 
 
 
 
 
 
 
 
 
 
 
137
  promises.push(
138
  api.scores.add({
139
  studentName: student.name,
140
  studentNo: student.studentNo,
141
  courseName: sub.name,
142
- score: Number(scoreVal),
143
  semester: importSemester,
144
  type: importType,
145
- examName: importExamName || '批量导入'
 
146
  }).then(() => successCount++)
147
  );
148
  }
@@ -229,20 +260,54 @@ export const ScoreList: React.FC = () => {
229
  </th>
230
  <th className="px-6 py-3">姓名</th>
231
  <th className="px-6 py-3">考试名称</th>
232
- <th className="px-6 py-3">分数</th>
233
  <th className="px-6 py-3 text-right">操作</th>
234
  </tr>
235
  </thead>
236
  <tbody>
237
- {filteredScores.length > 0 ? filteredScores.map(s => (
238
- <tr key={s._id || s.id} className="hover:bg-gray-50">
239
- <td className="px-6 py-3"><input type="checkbox" checked={selectedIds.has(s._id||String(s.id))} onChange={()=>toggleSelect(s._id||String(s.id))}/></td>
240
- <td className="px-6 py-3 font-medium text-gray-800">{s.studentName}</td>
241
- <td className="px-6 py-3 text-sm text-gray-500">{s.examName || s.type}</td>
242
- <td className="px-6 py-3 font-bold text-blue-600">{s.score}</td>
243
- <td className="px-6 py-3 text-right"><button onClick={async()=>{await api.scores.delete(s._id||String(s.id));loadData();}} className="text-red-400 hover:text-red-600"><Trash2 size={16}/></button></td>
244
- </tr>
245
- )) : (
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
246
  <tr><td colSpan={5} className="text-center py-10 text-gray-400">该班级暂无 {activeSubject} 成绩</td></tr>
247
  )}
248
  </tbody>
@@ -264,7 +329,6 @@ export const ScoreList: React.FC = () => {
264
  <form onSubmit={handleSubmit} className="space-y-4">
265
  <select className="w-full border p-2 rounded" value={formData.studentId} onChange={e=>setFormData({...formData, studentId:e.target.value})} required>
266
  <option value="">选择学生</option>
267
- {/* Filter students in dropdown based on selected grade/class if specific ones selected, else show all */}
268
  {students.filter(s => {
269
  if (selectedGrade !== 'All' && !s.className.includes(selectedGrade)) return false;
270
  if (selectedClass !== 'All' && !s.className.includes(selectedClass)) return false;
@@ -272,7 +336,16 @@ export const ScoreList: React.FC = () => {
272
  }).map(s=><option key={s._id||s.id} value={s._id||s.id}>{s.name} - {s.className}</option>)}
273
  </select>
274
  <input className="w-full border p-2 rounded" placeholder="考试名称 (如: 期中测验)" value={formData.examName} onChange={e=>setFormData({...formData, examName:e.target.value})} required/>
275
- <input type="number" className="w-full border p-2 rounded" placeholder="分数" value={formData.score} onChange={e=>setFormData({...formData, score:e.target.value})} required/>
 
 
 
 
 
 
 
 
 
276
  <div className="flex gap-2">
277
  <button type="submit" className="flex-1 bg-blue-600 text-white py-2 rounded">保存</button>
278
  <button type="button" onClick={()=>setIsAddOpen(false)} className="flex-1 border py-2 rounded">取消</button>
@@ -310,7 +383,7 @@ export const ScoreList: React.FC = () => {
310
  <div className="relative border-2 border-dashed border-gray-300 rounded-lg p-6 text-center hover:bg-gray-50 transition-colors">
311
  <Upload className="mx-auto h-10 w-10 text-gray-400" />
312
  <p className="mt-2 text-sm text-gray-600">点击上传 .xlsx 文件</p>
313
- <p className="text-xs text-gray-400 mt-1">系统将自动识别表头中的学科列</p>
314
  <input
315
  type="file"
316
  accept=".xlsx, .xls"
 
1
 
2
  import React, { useState, useEffect } from 'react';
3
  import { api } from '../services/api';
4
+ import { Score, Student, Subject, ClassInfo, ExamStatus } from '../types';
5
+ import { Loader2, Plus, Trash2, Award, FileSpreadsheet, X, Upload, Edit, Save } from 'lucide-react';
6
 
7
  export const ScoreList: React.FC = () => {
8
  const [subjects, setSubjects] = useState<Subject[]>([]);
 
24
  // Import Config
25
  const [importSemester, setImportSemester] = useState('2023-2024学年 第一学期');
26
  const [importType, setImportType] = useState('Final');
27
+ const [importExamName, setImportExamName] = useState('');
28
 
29
  // Manual Add Form
30
+ const [formData, setFormData] = useState({ studentId: '', score: '', type: 'Final', examName: '', status: 'Normal' });
31
+
32
+ // Edit State
33
+ const [editingId, setEditingId] = useState<string | null>(null);
34
+ const [editScoreVal, setEditScoreVal] = useState<string>('');
35
+ const [editStatus, setEditStatus] = useState<ExamStatus>('Normal');
36
 
37
  const loadData = async () => {
38
  setLoading(true);
 
81
  studentName: stu.name,
82
  studentNo: stu.studentNo,
83
  courseName: activeSubject,
84
+ score: formData.status === 'Normal' ? Number(formData.score) : 0,
85
  semester: '2023-2024学年 第一学期',
86
  type: formData.type,
87
+ examName: formData.examName,
88
+ status: formData.status
89
  });
90
  setIsAddOpen(false);
91
+ setFormData({ studentId: '', score: '', type: 'Final', examName: '', status: 'Normal' });
92
+ loadData();
93
+ };
94
+
95
+ const startEditing = (s: Score) => {
96
+ setEditingId(s._id || String(s.id));
97
+ setEditScoreVal(String(s.score));
98
+ setEditStatus(s.status || 'Normal');
99
+ };
100
+
101
+ const saveEdit = async (id: string) => {
102
+ await api.scores.update(id, {
103
+ score: editStatus === 'Normal' ? Number(editScoreVal) : 0,
104
+ status: editStatus
105
+ });
106
+ setEditingId(null);
107
  loadData();
108
  };
109
 
 
127
 
128
  console.log('Importing scores:', jsonData);
129
 
 
130
  const headers = Object.keys(jsonData[0] || {}).map(h => h.trim());
 
 
131
  const matchedSubjects = subjects.filter(s => headers.includes(s.name));
132
 
133
  if (matchedSubjects.length === 0) {
 
150
 
151
  if (student) {
152
  matchedSubjects.forEach(sub => {
153
+ let rawVal = cleanRow[sub.name];
154
+ let scoreVal = 0;
155
+ let statusVal: ExamStatus = 'Normal';
156
+
157
+ if (typeof rawVal === 'string') {
158
+ if (rawVal.includes('缺考')) statusVal = 'Absent';
159
+ else if (rawVal.includes('请假')) statusVal = 'Leave';
160
+ else if (rawVal.includes('作弊')) statusVal = 'Cheat';
161
+ else scoreVal = Number(rawVal) || 0;
162
+ } else {
163
+ scoreVal = Number(rawVal) || 0;
164
+ }
165
+
166
+ if (rawVal !== undefined && rawVal !== null && rawVal !== '') {
167
  promises.push(
168
  api.scores.add({
169
  studentName: student.name,
170
  studentNo: student.studentNo,
171
  courseName: sub.name,
172
+ score: scoreVal,
173
  semester: importSemester,
174
  type: importType,
175
+ examName: importExamName || '批量导入',
176
+ status: statusVal
177
  }).then(() => successCount++)
178
  );
179
  }
 
260
  </th>
261
  <th className="px-6 py-3">姓名</th>
262
  <th className="px-6 py-3">考试名称</th>
263
+ <th className="px-6 py-3">状态/分数</th>
264
  <th className="px-6 py-3 text-right">操作</th>
265
  </tr>
266
  </thead>
267
  <tbody>
268
+ {filteredScores.length > 0 ? filteredScores.map(s => {
269
+ const isEditing = editingId === (s._id || String(s.id));
270
+ const displayScore = s.status === 'Normal' ? s.score : (s.status === 'Absent' ? '缺考' : s.status === 'Leave' ? '请假' : '作弊');
271
+ const scoreColor = s.status !== 'Normal' ? 'text-gray-400' : (s.score < 60 ? 'text-red-500' : 'text-blue-600');
272
+
273
+ return (
274
+ <tr key={s._id || s.id} className="hover:bg-gray-50">
275
+ <td className="px-6 py-3"><input type="checkbox" checked={selectedIds.has(s._id||String(s.id))} onChange={()=>toggleSelect(s._id||String(s.id))}/></td>
276
+ <td className="px-6 py-3 font-medium text-gray-800">{s.studentName}</td>
277
+ <td className="px-6 py-3 text-sm text-gray-500">{s.examName || s.type}</td>
278
+ <td className="px-6 py-3 font-bold">
279
+ {isEditing ? (
280
+ <div className="flex items-center space-x-2">
281
+ <select value={editStatus} onChange={e=>setEditStatus(e.target.value as any)} className="border rounded px-1 py-1 text-xs">
282
+ <option value="Normal">正常</option>
283
+ <option value="Absent">缺考</option>
284
+ <option value="Leave">请假</option>
285
+ <option value="Cheat">作弊</option>
286
+ </select>
287
+ {editStatus === 'Normal' && (
288
+ <input type="number" value={editScoreVal} onChange={e=>setEditScoreVal(e.target.value)} className="w-16 border rounded px-1 py-1 text-sm"/>
289
+ )}
290
+ </div>
291
+ ) : (
292
+ <span className={scoreColor}>{displayScore}</span>
293
+ )}
294
+ </td>
295
+ <td className="px-6 py-3 text-right flex justify-end gap-2">
296
+ {isEditing ? (
297
+ <>
298
+ <button onClick={()=>saveEdit(s._id||String(s.id))} className="text-green-500 hover:text-green-700"><Save size={16}/></button>
299
+ <button onClick={()=>setEditingId(null)} className="text-gray-400 hover:text-gray-600"><X size={16}/></button>
300
+ </>
301
+ ) : (
302
+ <>
303
+ <button onClick={()=>startEditing(s)} className="text-blue-400 hover:text-blue-600"><Edit size={16}/></button>
304
+ <button onClick={async()=>{await api.scores.delete(s._id||String(s.id));loadData();}} className="text-red-400 hover:text-red-600"><Trash2 size={16}/></button>
305
+ </>
306
+ )}
307
+ </td>
308
+ </tr>
309
+ );
310
+ }) : (
311
  <tr><td colSpan={5} className="text-center py-10 text-gray-400">该班级暂无 {activeSubject} 成绩</td></tr>
312
  )}
313
  </tbody>
 
329
  <form onSubmit={handleSubmit} className="space-y-4">
330
  <select className="w-full border p-2 rounded" value={formData.studentId} onChange={e=>setFormData({...formData, studentId:e.target.value})} required>
331
  <option value="">选择学生</option>
 
332
  {students.filter(s => {
333
  if (selectedGrade !== 'All' && !s.className.includes(selectedGrade)) return false;
334
  if (selectedClass !== 'All' && !s.className.includes(selectedClass)) return false;
 
336
  }).map(s=><option key={s._id||s.id} value={s._id||s.id}>{s.name} - {s.className}</option>)}
337
  </select>
338
  <input className="w-full border p-2 rounded" placeholder="考试名称 (如: 期中测验)" value={formData.examName} onChange={e=>setFormData({...formData, examName:e.target.value})} required/>
339
+ <div className="flex gap-2">
340
+ <select className="w-1/3 border p-2 rounded" value={formData.status} onChange={e=>setFormData({...formData, status: e.target.value})}>
341
+ <option value="Normal">正常</option>
342
+ <option value="Absent">缺考</option>
343
+ <option value="Leave">请假</option>
344
+ </select>
345
+ {formData.status === 'Normal' && (
346
+ <input type="number" className="flex-1 border p-2 rounded" placeholder="分数" value={formData.score} onChange={e=>setFormData({...formData, score:e.target.value})} required/>
347
+ )}
348
+ </div>
349
  <div className="flex gap-2">
350
  <button type="submit" className="flex-1 bg-blue-600 text-white py-2 rounded">保存</button>
351
  <button type="button" onClick={()=>setIsAddOpen(false)} className="flex-1 border py-2 rounded">取消</button>
 
383
  <div className="relative border-2 border-dashed border-gray-300 rounded-lg p-6 text-center hover:bg-gray-50 transition-colors">
384
  <Upload className="mx-auto h-10 w-10 text-gray-400" />
385
  <p className="mt-2 text-sm text-gray-600">点击上传 .xlsx 文件</p>
386
+ <p className="text-xs text-gray-400 mt-1">系统将自动识别 "缺考", "请假" 等文本</p>
387
  <input
388
  type="file"
389
  accept=".xlsx, .xls"
pages/SubjectList.tsx CHANGED
@@ -2,12 +2,18 @@
2
  import React, { useState, useEffect } from 'react';
3
  import { Subject } from '../types';
4
  import { api } from '../services/api';
5
- import { Loader2, Plus, Trash2, Palette } from 'lucide-react';
6
 
7
  export const SubjectList: React.FC = () => {
8
  const [subjects, setSubjects] = useState<Subject[]>([]);
9
  const [loading, setLoading] = useState(true);
10
- const [newSub, setNewSub] = useState({ name: '', code: '', color: '#3b82f6' });
 
 
 
 
 
 
11
 
12
  const loadSubjects = async () => {
13
  setLoading(true);
@@ -21,7 +27,24 @@ export const SubjectList: React.FC = () => {
21
  const handleAdd = async () => {
22
  if (!newSub.name) return;
23
  await api.subjects.add(newSub);
24
- setNewSub({ name: '', code: '', color: '#3b82f6' });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  loadSubjects();
26
  };
27
 
@@ -43,46 +66,73 @@ export const SubjectList: React.FC = () => {
43
  </h2>
44
 
45
  {/* Add Form */}
46
- <div className="flex gap-4 mb-8 bg-gray-50 p-4 rounded-lg items-end">
47
- <div>
48
  <label className="text-xs font-bold text-gray-500 uppercase">学科名称</label>
49
  <input type="text" placeholder="如: 编程" className="w-full mt-1 px-3 py-2 border rounded"
50
  value={newSub.name} onChange={e => setNewSub({...newSub, name: e.target.value})}
51
  />
52
  </div>
53
- <div>
54
- <label className="text-xs font-bold text-gray-500 uppercase">代码(选填)</label>
55
- <input type="text" placeholder="如: CODE" className="w-full mt-1 px-3 py-2 border rounded"
56
  value={newSub.code} onChange={e => setNewSub({...newSub, code: e.target.value})}
57
  />
58
  </div>
59
- <div>
60
- <label className="text-xs font-bold text-gray-500 uppercase">代表色</label>
 
 
 
 
 
 
61
  <input type="color" className="w-full mt-1 h-10 border rounded cursor-pointer"
62
  value={newSub.color} onChange={e => setNewSub({...newSub, color: e.target.value})}
63
  />
64
  </div>
65
- <button onClick={handleAdd} className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 h-10 flex items-center">
66
  <Plus size={16} className="mr-1" /> 添加
67
  </button>
68
  </div>
69
 
70
  {/* List */}
71
- <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
72
- {subjects.map(sub => (
73
- <div key={sub._id || sub.id} className="border border-gray-200 rounded-lg p-4 flex justify-between items-center bg-white shadow-sm">
74
- <div className="flex items-center space-x-3">
75
- <div className="w-4 h-4 rounded-full" style={{ backgroundColor: sub.color }}></div>
76
- <div>
77
- <p className="font-bold text-gray-800">{sub.name}</p>
78
- <p className="text-xs text-gray-500">{sub.code}</p>
79
- </div>
80
- </div>
81
- <button onClick={() => handleDelete(sub._id || String(sub.id))} className="text-gray-400 hover:text-red-500">
82
- <Trash2 size={16} />
83
- </button>
84
- </div>
85
- ))}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
  </div>
87
  </div>
88
  </div>
 
2
  import React, { useState, useEffect } from 'react';
3
  import { Subject } from '../types';
4
  import { api } from '../services/api';
5
+ import { Loader2, Plus, Trash2, Palette, Edit, Save, X } from 'lucide-react';
6
 
7
  export const SubjectList: React.FC = () => {
8
  const [subjects, setSubjects] = useState<Subject[]>([]);
9
  const [loading, setLoading] = useState(true);
10
+
11
+ // State for adding new subject
12
+ const [newSub, setNewSub] = useState({ name: '', code: '', color: '#3b82f6', excellenceThreshold: 90 });
13
+
14
+ // State for editing
15
+ const [editingId, setEditingId] = useState<string | null>(null);
16
+ const [editForm, setEditForm] = useState({ name: '', code: '', color: '', excellenceThreshold: 90 });
17
 
18
  const loadSubjects = async () => {
19
  setLoading(true);
 
27
  const handleAdd = async () => {
28
  if (!newSub.name) return;
29
  await api.subjects.add(newSub);
30
+ setNewSub({ name: '', code: '', color: '#3b82f6', excellenceThreshold: 90 });
31
+ loadSubjects();
32
+ };
33
+
34
+ const handleEdit = (sub: Subject) => {
35
+ setEditingId(sub._id || String(sub.id));
36
+ setEditForm({
37
+ name: sub.name,
38
+ code: sub.code,
39
+ color: sub.color,
40
+ excellenceThreshold: sub.excellenceThreshold || 90
41
+ });
42
+ };
43
+
44
+ const handleUpdate = async () => {
45
+ if (!editingId) return;
46
+ await api.subjects.update(editingId, editForm);
47
+ setEditingId(null);
48
  loadSubjects();
49
  };
50
 
 
66
  </h2>
67
 
68
  {/* Add Form */}
69
+ <div className="flex flex-col md:flex-row gap-4 mb-8 bg-gray-50 p-4 rounded-lg md:items-end">
70
+ <div className="flex-1">
71
  <label className="text-xs font-bold text-gray-500 uppercase">学科名称</label>
72
  <input type="text" placeholder="如: 编程" className="w-full mt-1 px-3 py-2 border rounded"
73
  value={newSub.name} onChange={e => setNewSub({...newSub, name: e.target.value})}
74
  />
75
  </div>
76
+ <div className="w-24">
77
+ <label className="text-xs font-bold text-gray-500 uppercase">代码</label>
78
+ <input type="text" placeholder="CODE" className="w-full mt-1 px-3 py-2 border rounded"
79
  value={newSub.code} onChange={e => setNewSub({...newSub, code: e.target.value})}
80
  />
81
  </div>
82
+ <div className="w-24">
83
+ <label className="text-xs font-bold text-gray-500 uppercase">优秀线</label>
84
+ <input type="number" className="w-full mt-1 px-3 py-2 border rounded"
85
+ value={newSub.excellenceThreshold} onChange={e => setNewSub({...newSub, excellenceThreshold: Number(e.target.value)})}
86
+ />
87
+ </div>
88
+ <div className="w-20">
89
+ <label className="text-xs font-bold text-gray-500 uppercase">颜色</label>
90
  <input type="color" className="w-full mt-1 h-10 border rounded cursor-pointer"
91
  value={newSub.color} onChange={e => setNewSub({...newSub, color: e.target.value})}
92
  />
93
  </div>
94
+ <button onClick={handleAdd} className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 h-10 flex items-center justify-center">
95
  <Plus size={16} className="mr-1" /> 添加
96
  </button>
97
  </div>
98
 
99
  {/* List */}
100
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
101
+ {subjects.map(sub => {
102
+ const isEditing = editingId === (sub._id || String(sub.id));
103
+ return (
104
+ <div key={sub._id || sub.id} className="border border-gray-200 rounded-lg p-4 bg-white shadow-sm hover:shadow-md transition-shadow">
105
+ {isEditing ? (
106
+ <div className="space-y-3">
107
+ <input className="w-full border p-1 rounded text-sm" value={editForm.name} onChange={e => setEditForm({...editForm, name: e.target.value})} />
108
+ <div className="flex gap-2">
109
+ <input className="w-1/2 border p-1 rounded text-sm" placeholder="代码" value={editForm.code} onChange={e => setEditForm({...editForm, code: e.target.value})} />
110
+ <input className="w-1/2 border p-1 rounded text-sm" type="number" placeholder="优秀线" value={editForm.excellenceThreshold} onChange={e => setEditForm({...editForm, excellenceThreshold: Number(e.target.value)})} />
111
+ </div>
112
+ <div className="flex gap-2">
113
+ <input type="color" className="h-8 flex-1 cursor-pointer" value={editForm.color} onChange={e => setEditForm({...editForm, color: e.target.value})} />
114
+ <button onClick={handleUpdate} className="text-green-600 p-1 border rounded hover:bg-green-50"><Save size={16}/></button>
115
+ <button onClick={() => setEditingId(null)} className="text-gray-500 p-1 border rounded hover:bg-gray-50"><X size={16}/></button>
116
+ </div>
117
+ </div>
118
+ ) : (
119
+ <div className="flex justify-between items-center">
120
+ <div className="flex items-center space-x-3">
121
+ <div className="w-4 h-4 rounded-full" style={{ backgroundColor: sub.color }}></div>
122
+ <div>
123
+ <p className="font-bold text-gray-800">{sub.name}</p>
124
+ <p className="text-xs text-gray-500">{sub.code || '-'} | 优秀线: {sub.excellenceThreshold || 90}</p>
125
+ </div>
126
+ </div>
127
+ <div className="flex space-x-1">
128
+ <button onClick={() => handleEdit(sub)} className="text-gray-400 hover:text-blue-500 p-1"><Edit size={16} /></button>
129
+ <button onClick={() => handleDelete(sub._id || String(sub.id))} className="text-gray-400 hover:text-red-500 p-1"><Trash2 size={16} /></button>
130
+ </div>
131
+ </div>
132
+ )}
133
+ </div>
134
+ );
135
+ })}
136
  </div>
137
  </div>
138
  </div>
server.js CHANGED
@@ -95,7 +95,8 @@ const ScoreSchema = new mongoose.Schema({
95
  score: Number,
96
  semester: String,
97
  type: String,
98
- examName: String
 
99
  });
100
  const Score = mongoose.model('Score', ScoreSchema);
101
 
@@ -109,7 +110,8 @@ const ClassModel = mongoose.model('Class', ClassSchema);
109
  const SubjectSchema = new mongoose.Schema({
110
  name: { type: String, required: true, unique: true },
111
  code: String,
112
- color: String
 
113
  });
114
  const SubjectModel = mongoose.model('Subject', SubjectSchema);
115
 
@@ -138,10 +140,10 @@ const initData = async () => {
138
  const subjCount = await SubjectModel.countDocuments();
139
  if (subjCount === 0) {
140
  await SubjectModel.create([
141
- { name: '语文', code: 'CHI', color: '#ef4444' },
142
- { name: '数学', code: 'MAT', color: '#3b82f6' },
143
- { name: '英语', code: 'ENG', color: '#f59e0b' },
144
- { name: '科学', code: 'SCI', color: '#10b981' }
145
  ]);
146
  }
147
  // Config
@@ -186,8 +188,6 @@ app.post('/api/auth/login', async (req, res) => {
186
 
187
  app.post('/api/auth/register', async (req, res) => {
188
  const { username, password, role } = req.body;
189
- // Default status: Admin auto-active (for demo simplicity if needed, but standard is Pending for all)
190
- // Here we set 'pending' for everyone except the very first user (handled by init)
191
  const status = 'pending';
192
 
193
  try {
@@ -236,7 +236,7 @@ app.delete('/api/users/:id', async (req, res) => {
236
 
237
  // --- Subjects ---
238
  app.get('/api/subjects', async (req, res) => {
239
- if (InMemoryDB.isFallback) return res.json(InMemoryDB.subjects.length ? InMemoryDB.subjects : [{name:'语文',code:'CHI',color:'#ef4444'}]);
240
  const subs = await SubjectModel.find();
241
  res.json(subs);
242
  });
@@ -251,6 +251,17 @@ app.post('/api/subjects', async (req, res) => {
251
  res.json(newSub);
252
  } catch (e) { res.status(400).json({ error: e.message }); }
253
  });
 
 
 
 
 
 
 
 
 
 
 
254
  app.delete('/api/subjects/:id', async (req, res) => {
255
  try {
256
  if (InMemoryDB.isFallback) {
@@ -283,7 +294,6 @@ app.post('/api/batch-delete', async (req, res) => {
283
  } catch (e) { res.status(500).json({ error: e.message }); }
284
  });
285
 
286
- // --- Other Existing Routes (Simplified for brevity, assuming they exist as before) ---
287
  // Students
288
  app.get('/api/students', async (req, res) => {
289
  if (InMemoryDB.isFallback) return res.json(InMemoryDB.students);
@@ -302,7 +312,6 @@ app.delete('/api/students/:id', async (req, res) => {
302
  app.get('/api/classes', async (req, res) => {
303
  if (InMemoryDB.isFallback) return res.json(InMemoryDB.classes);
304
  const classes = await ClassModel.find();
305
- // Add counts
306
  const result = await Promise.all(classes.map(async (c) => {
307
  const count = await Student.countDocuments({ className: c.grade + c.className });
308
  return { ...c.toObject(), studentCount: count };
@@ -345,6 +354,15 @@ app.post('/api/scores', async (req, res) => {
345
  if (InMemoryDB.isFallback) { InMemoryDB.scores.push({ ...req.body, id: Date.now(), _id: String(Date.now()) }); return res.json({}); }
346
  res.json(await Score.create(req.body));
347
  });
 
 
 
 
 
 
 
 
 
348
  app.delete('/api/scores/:id', async (req, res) => {
349
  if (InMemoryDB.isFallback) { InMemoryDB.scores = InMemoryDB.scores.filter(s => s._id != req.params.id); return res.json({}); }
350
  await Score.findByIdAndDelete(req.params.id); res.json({});
@@ -369,10 +387,15 @@ app.get('/api/stats', async (req, res) => {
369
  const studentCount = await Student.countDocuments();
370
  const courseCount = await Course.countDocuments();
371
  const scores = await Score.find();
372
- const totalScore = scores.reduce((sum, s) => sum + (s.score || 0), 0);
373
- const avgScore = scores.length > 0 ? (totalScore / scores.length).toFixed(1) : 0;
374
- const excellentCount = scores.filter(s => s.score >= 90).length;
375
- const excellentRate = scores.length > 0 ? Math.round((excellentCount / scores.length) * 100) + '%' : '0%';
 
 
 
 
 
376
  res.json({ studentCount, courseCount, avgScore, excellentRate });
377
  } catch(e) { res.status(500).json({}); }
378
  });
 
95
  score: Number,
96
  semester: String,
97
  type: String,
98
+ examName: String,
99
+ status: { type: String, enum: ['Normal', 'Absent', 'Leave', 'Cheat'], default: 'Normal' }
100
  });
101
  const Score = mongoose.model('Score', ScoreSchema);
102
 
 
110
  const SubjectSchema = new mongoose.Schema({
111
  name: { type: String, required: true, unique: true },
112
  code: String,
113
+ color: String,
114
+ excellenceThreshold: { type: Number, default: 90 }
115
  });
116
  const SubjectModel = mongoose.model('Subject', SubjectSchema);
117
 
 
140
  const subjCount = await SubjectModel.countDocuments();
141
  if (subjCount === 0) {
142
  await SubjectModel.create([
143
+ { name: '语文', code: 'CHI', color: '#ef4444', excellenceThreshold: 90 },
144
+ { name: '数学', code: 'MAT', color: '#3b82f6', excellenceThreshold: 90 },
145
+ { name: '英语', code: 'ENG', color: '#f59e0b', excellenceThreshold: 90 },
146
+ { name: '科学', code: 'SCI', color: '#10b981', excellenceThreshold: 85 }
147
  ]);
148
  }
149
  // Config
 
188
 
189
  app.post('/api/auth/register', async (req, res) => {
190
  const { username, password, role } = req.body;
 
 
191
  const status = 'pending';
192
 
193
  try {
 
236
 
237
  // --- Subjects ---
238
  app.get('/api/subjects', async (req, res) => {
239
+ if (InMemoryDB.isFallback) return res.json(InMemoryDB.subjects.length ? InMemoryDB.subjects : [{name:'语文',code:'CHI',color:'#ef4444', excellenceThreshold: 90}]);
240
  const subs = await SubjectModel.find();
241
  res.json(subs);
242
  });
 
251
  res.json(newSub);
252
  } catch (e) { res.status(400).json({ error: e.message }); }
253
  });
254
+ app.put('/api/subjects/:id', async (req, res) => {
255
+ try {
256
+ if (InMemoryDB.isFallback) {
257
+ const idx = InMemoryDB.subjects.findIndex(s => s._id == req.params.id || s.id == req.params.id);
258
+ if (idx !== -1) InMemoryDB.subjects[idx] = { ...InMemoryDB.subjects[idx], ...req.body };
259
+ return res.json({ success: true });
260
+ }
261
+ await SubjectModel.findByIdAndUpdate(req.params.id, req.body);
262
+ res.json({ success: true });
263
+ } catch (e) { res.status(500).json({ error: e.message }); }
264
+ });
265
  app.delete('/api/subjects/:id', async (req, res) => {
266
  try {
267
  if (InMemoryDB.isFallback) {
 
294
  } catch (e) { res.status(500).json({ error: e.message }); }
295
  });
296
 
 
297
  // Students
298
  app.get('/api/students', async (req, res) => {
299
  if (InMemoryDB.isFallback) return res.json(InMemoryDB.students);
 
312
  app.get('/api/classes', async (req, res) => {
313
  if (InMemoryDB.isFallback) return res.json(InMemoryDB.classes);
314
  const classes = await ClassModel.find();
 
315
  const result = await Promise.all(classes.map(async (c) => {
316
  const count = await Student.countDocuments({ className: c.grade + c.className });
317
  return { ...c.toObject(), studentCount: count };
 
354
  if (InMemoryDB.isFallback) { InMemoryDB.scores.push({ ...req.body, id: Date.now(), _id: String(Date.now()) }); return res.json({}); }
355
  res.json(await Score.create(req.body));
356
  });
357
+ app.put('/api/scores/:id', async (req, res) => {
358
+ if (InMemoryDB.isFallback) {
359
+ const idx = InMemoryDB.scores.findIndex(s => s._id == req.params.id || s.id == req.params.id);
360
+ if (idx !== -1) InMemoryDB.scores[idx] = { ...InMemoryDB.scores[idx], ...req.body };
361
+ return res.json({ success: true });
362
+ }
363
+ await Score.findByIdAndUpdate(req.params.id, req.body);
364
+ res.json({ success: true });
365
+ });
366
  app.delete('/api/scores/:id', async (req, res) => {
367
  if (InMemoryDB.isFallback) { InMemoryDB.scores = InMemoryDB.scores.filter(s => s._id != req.params.id); return res.json({}); }
368
  await Score.findByIdAndDelete(req.params.id); res.json({});
 
387
  const studentCount = await Student.countDocuments();
388
  const courseCount = await Course.countDocuments();
389
  const scores = await Score.find();
390
+ // Exclude Absent/Leave from average calculation
391
+ const validScores = scores.filter(s => s.status === 'Normal');
392
+ const totalScore = validScores.reduce((sum, s) => sum + (s.score || 0), 0);
393
+ const avgScore = validScores.length > 0 ? (totalScore / validScores.length).toFixed(1) : 0;
394
+
395
+ // Note: excellentRate here is a rough global average. Detailed stats are handled in Reports.
396
+ const excellentCount = validScores.filter(s => s.score >= 90).length;
397
+ const excellentRate = validScores.length > 0 ? Math.round((excellentCount / validScores.length) * 100) + '%' : '0%';
398
+
399
  res.json({ studentCount, courseCount, avgScore, excellentRate });
400
  } catch(e) { res.status(500).json({}); }
401
  });
services/api.ts CHANGED
@@ -1,3 +1,4 @@
 
1
  /// <reference types="vite/client" />
2
  import { User, ClassInfo, SystemConfig, Subject } from '../types';
3
 
@@ -86,6 +87,7 @@ export const api = {
86
  subjects: {
87
  getAll: () => request('/subjects'),
88
  add: (data: Subject) => request('/subjects', { method: 'POST', body: JSON.stringify(data) }),
 
89
  delete: (id: string | number) => request(`/subjects/${id}`, { method: 'DELETE' })
90
  },
91
 
@@ -99,6 +101,7 @@ export const api = {
99
  scores: {
100
  getAll: () => request('/scores'),
101
  add: (data: any) => request('/scores', { method: 'POST', body: JSON.stringify(data) }),
 
102
  delete: (id: string | number) => request(`/scores/${id}`, { method: 'DELETE' })
103
  },
104
 
@@ -114,4 +117,4 @@ export const api = {
114
  batchDelete: (type: 'student' | 'score' | 'user', ids: string[]) => {
115
  return request('/batch-delete', { method: 'POST', body: JSON.stringify({ type, ids }) });
116
  }
117
- };
 
1
+
2
  /// <reference types="vite/client" />
3
  import { User, ClassInfo, SystemConfig, Subject } from '../types';
4
 
 
87
  subjects: {
88
  getAll: () => request('/subjects'),
89
  add: (data: Subject) => request('/subjects', { method: 'POST', body: JSON.stringify(data) }),
90
+ update: (id: string | number, data: Partial<Subject>) => request(`/subjects/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
91
  delete: (id: string | number) => request(`/subjects/${id}`, { method: 'DELETE' })
92
  },
93
 
 
101
  scores: {
102
  getAll: () => request('/scores'),
103
  add: (data: any) => request('/scores', { method: 'POST', body: JSON.stringify(data) }),
104
+ update: (id: string | number, data: any) => request(`/scores/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
105
  delete: (id: string | number) => request(`/scores/${id}`, { method: 'DELETE' })
106
  },
107
 
 
117
  batchDelete: (type: 'student' | 'score' | 'user', ids: string[]) => {
118
  return request('/batch-delete', { method: 'POST', body: JSON.stringify({ type, ids }) });
119
  }
120
+ };
types.ts CHANGED
@@ -38,6 +38,7 @@ export interface Subject {
38
  name: string; // e.g. "语文"
39
  code: string; // e.g. "CHI"
40
  color: string; // Hex color for charts
 
41
  }
42
 
43
  export interface SystemConfig {
@@ -72,6 +73,8 @@ export interface Course {
72
  enrolled: number;
73
  }
74
 
 
 
75
  export interface Score {
76
  id?: number;
77
  _id?: string;
@@ -82,6 +85,7 @@ export interface Score {
82
  semester: string;
83
  type: 'Midterm' | 'Final' | 'Quiz';
84
  examName?: string; // e.g. "期中限时练习"
 
85
  }
86
 
87
  export interface ApiResponse<T> {
 
38
  name: string; // e.g. "语文"
39
  code: string; // e.g. "CHI"
40
  color: string; // Hex color for charts
41
+ excellenceThreshold?: number; // Score required to be considered "Excellent" (default 90)
42
  }
43
 
44
  export interface SystemConfig {
 
73
  enrolled: number;
74
  }
75
 
76
+ export type ExamStatus = 'Normal' | 'Absent' | 'Leave' | 'Cheat';
77
+
78
  export interface Score {
79
  id?: number;
80
  _id?: string;
 
85
  semester: string;
86
  type: 'Midterm' | 'Final' | 'Quiz';
87
  examName?: string; // e.g. "期中限时练习"
88
+ status?: ExamStatus; // Default: Normal
89
  }
90
 
91
  export interface ApiResponse<T> {