Spaces:
Sleeping
Sleeping
Upload 25 files
Browse files- index.html +7 -6
- pages/Reports.tsx +168 -26
- pages/ScoreList.tsx +60 -11
- server.js +29 -0
- services/api.ts +5 -0
- types.ts +8 -0
index.html
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
|
|
| 1 |
<!DOCTYPE html>
|
| 2 |
<html lang="zh-CN">
|
| 3 |
<head>
|
|
@@ -32,13 +33,13 @@
|
|
| 32 |
<script type="importmap">
|
| 33 |
{
|
| 34 |
"imports": {
|
| 35 |
-
"
|
| 36 |
-
"
|
| 37 |
"react/": "https://aistudiocdn.com/react@^19.2.0/",
|
|
|
|
|
|
|
| 38 |
"lucide-react": "https://aistudiocdn.com/lucide-react@^0.555.0",
|
| 39 |
-
"
|
| 40 |
-
"vite": "https://aistudiocdn.com/vite@^7.2.4",
|
| 41 |
-
"@vitejs/plugin-react": "https://aistudiocdn.com/@vitejs/plugin-react@^5.1.1"
|
| 42 |
}
|
| 43 |
}
|
| 44 |
</script>
|
|
@@ -48,4 +49,4 @@
|
|
| 48 |
<div id="root"></div>
|
| 49 |
<script type="module" src="/index.tsx"></script>
|
| 50 |
</body>
|
| 51 |
-
</html>
|
|
|
|
| 1 |
+
|
| 2 |
<!DOCTYPE html>
|
| 3 |
<html lang="zh-CN">
|
| 4 |
<head>
|
|
|
|
| 33 |
<script type="importmap">
|
| 34 |
{
|
| 35 |
"imports": {
|
| 36 |
+
"vite": "https://aistudiocdn.com/vite@^7.2.6",
|
| 37 |
+
"recharts": "https://aistudiocdn.com/recharts@^3.5.1",
|
| 38 |
"react/": "https://aistudiocdn.com/react@^19.2.0/",
|
| 39 |
+
"react": "https://aistudiocdn.com/react@^19.2.0",
|
| 40 |
+
"@vitejs/plugin-react": "https://aistudiocdn.com/@vitejs/plugin-react@^5.1.1",
|
| 41 |
"lucide-react": "https://aistudiocdn.com/lucide-react@^0.555.0",
|
| 42 |
+
"react-dom/": "https://aistudiocdn.com/react-dom@^19.2.0/"
|
|
|
|
|
|
|
| 43 |
}
|
| 44 |
}
|
| 45 |
</script>
|
|
|
|
| 49 |
<div id="root"></div>
|
| 50 |
<script type="module" src="/index.tsx"></script>
|
| 51 |
</body>
|
| 52 |
+
</html>
|
pages/Reports.tsx
CHANGED
|
@@ -2,25 +2,26 @@
|
|
| 2 |
import React, { useState, useEffect } from 'react';
|
| 3 |
import {
|
| 4 |
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer,
|
| 5 |
-
LineChart, Line, AreaChart, Area, PieChart as RePieChart, Pie, Cell
|
| 6 |
} from 'recharts';
|
| 7 |
import { api } from '../services/api';
|
| 8 |
-
import { Loader2, Download, Filter, TrendingUp, Users, BookOpen, PieChart as PieChartIcon, Grid, BarChart2 } from 'lucide-react';
|
| 9 |
-
import { Score, Student, ClassInfo, Subject } from '../types';
|
| 10 |
|
| 11 |
export const Reports: React.FC = () => {
|
| 12 |
const [loading, setLoading] = useState(true);
|
| 13 |
-
const [activeTab, setActiveTab] = useState<'overview' | 'grade' | 'trend' | 'matrix'>('grade');
|
| 14 |
|
| 15 |
// Data
|
| 16 |
const [scores, setScores] = useState<Score[]>([]);
|
| 17 |
const [students, setStudents] = useState<Student[]>([]);
|
| 18 |
const [classes, setClasses] = useState<ClassInfo[]>([]);
|
| 19 |
const [subjects, setSubjects] = useState<Subject[]>([]);
|
|
|
|
| 20 |
|
| 21 |
// Filters
|
| 22 |
const [selectedGrade, setSelectedGrade] = useState<string>('六年级');
|
| 23 |
-
const [selectedClass, setSelectedClass] = useState<string>(''); // For Trend
|
| 24 |
const [selectedSubject, setSelectedSubject] = useState<string>(''); // For Trend
|
| 25 |
|
| 26 |
// Computed Data
|
|
@@ -28,20 +29,25 @@ export const Reports: React.FC = () => {
|
|
| 28 |
const [trendData, setTrendData] = useState<any[]>([]);
|
| 29 |
const [matrixData, setMatrixData] = useState<any[]>([]);
|
| 30 |
|
|
|
|
|
|
|
|
|
|
| 31 |
useEffect(() => {
|
| 32 |
const loadData = async () => {
|
| 33 |
setLoading(true);
|
| 34 |
try {
|
| 35 |
-
const [scs, stus, cls, subs] = await Promise.all([
|
| 36 |
api.scores.getAll(),
|
| 37 |
api.students.getAll(),
|
| 38 |
api.classes.getAll(),
|
| 39 |
-
api.subjects.getAll()
|
|
|
|
| 40 |
]);
|
| 41 |
setScores(scs);
|
| 42 |
setStudents(stus);
|
| 43 |
setClasses(cls);
|
| 44 |
setSubjects(subs);
|
|
|
|
| 45 |
|
| 46 |
// Set Defaults
|
| 47 |
if (cls.length > 0) setSelectedClass(cls[0].grade + cls[0].className);
|
|
@@ -93,16 +99,20 @@ export const Reports: React.FC = () => {
|
|
| 93 |
|
| 94 |
|
| 95 |
// --- 2. Trend Analysis (Time/Exam based) ---
|
| 96 |
-
// Group by Exam Name or Type.
|
| 97 |
-
// Filter for selectedClass and selectedSubject
|
| 98 |
if (selectedClass && selectedSubject) {
|
| 99 |
const classStudentIds = students.filter(s => s.className === selectedClass).map(s => s.studentNo);
|
| 100 |
|
| 101 |
-
|
| 102 |
-
// In real app, sort by exam date.
|
| 103 |
-
const uniqueExams = Array.from(new Set(scores.map(s => s.examName || s.type)));
|
| 104 |
|
| 105 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
const examScores = scores.filter(s =>
|
| 107 |
(s.examName === exam || s.type === exam) &&
|
| 108 |
s.courseName === selectedSubject &&
|
|
@@ -116,7 +126,6 @@ export const Reports: React.FC = () => {
|
|
| 116 |
: 0;
|
| 117 |
|
| 118 |
// Grade Avg (for comparison)
|
| 119 |
-
// Find grade of selected class
|
| 120 |
const currentGrade = classes.find(c => c.grade + c.className === selectedClass)?.grade || '';
|
| 121 |
const gradeStudentIds = students.filter(s => s.className.startsWith(currentGrade)).map(s => s.studentNo);
|
| 122 |
const gradeExamScores = examScores.filter(s => gradeStudentIds.includes(s.studentNo));
|
|
@@ -134,7 +143,6 @@ export const Reports: React.FC = () => {
|
|
| 134 |
}
|
| 135 |
|
| 136 |
// --- 3. Subject Matrix (Heatmap Table) ---
|
| 137 |
-
// Rows: Classes in selectedGrade. Cols: Subjects.
|
| 138 |
const mData = gradeClasses.map(cls => {
|
| 139 |
const fullClassName = cls.grade + cls.className;
|
| 140 |
const classStudentIds = students.filter(s => s.className === fullClassName).map(s => s.studentNo);
|
|
@@ -154,7 +162,7 @@ export const Reports: React.FC = () => {
|
|
| 154 |
});
|
| 155 |
setMatrixData(mData);
|
| 156 |
|
| 157 |
-
}, [scores, students, classes, subjects, selectedGrade, selectedClass, selectedSubject]);
|
| 158 |
|
| 159 |
|
| 160 |
const exportExcel = () => {
|
|
@@ -176,9 +184,45 @@ export const Reports: React.FC = () => {
|
|
| 176 |
XLSX.writeFile(wb, `Report_${activeTab}_${new Date().toISOString().slice(0,10)}.xlsx`);
|
| 177 |
};
|
| 178 |
|
| 179 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
const uniqueGrades = Array.from(new Set(classes.map(c => c.grade))).sort();
|
| 181 |
const allClasses = classes.map(c => c.grade + c.className);
|
|
|
|
|
|
|
|
|
|
| 182 |
|
| 183 |
if (loading) return <div className="flex justify-center py-20"><Loader2 className="animate-spin text-blue-600"/></div>;
|
| 184 |
|
|
@@ -202,6 +246,7 @@ export const Reports: React.FC = () => {
|
|
| 202 |
{ id: 'grade', label: '年级横向分析', icon: BarChart2 },
|
| 203 |
{ id: 'trend', label: '教学成长轨迹', icon: TrendingUp },
|
| 204 |
{ id: 'matrix', label: '学科质量透视', icon: Grid },
|
|
|
|
| 205 |
{ id: 'overview', label: '全校概览', icon: PieChartIcon },
|
| 206 |
].map(tab => (
|
| 207 |
<button
|
|
@@ -230,15 +275,16 @@ export const Reports: React.FC = () => {
|
|
| 230 |
</select>
|
| 231 |
)}
|
| 232 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 233 |
{activeTab === 'trend' && (
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
</select>
|
| 238 |
-
<select className="border border-gray-300 p-2 rounded text-sm text-gray-800" value={selectedSubject} onChange={e => setSelectedSubject(e.target.value)}>
|
| 239 |
-
{subjects.map(s => <option key={s._id} value={s.name}>{s.name}</option>)}
|
| 240 |
-
</select>
|
| 241 |
-
</>
|
| 242 |
)}
|
| 243 |
</div>
|
| 244 |
|
|
@@ -348,8 +394,39 @@ export const Reports: React.FC = () => {
|
|
| 348 |
<p className="text-xs text-gray-400 mt-4 text-right">* 绿色底色代表优秀,红色底色代表需要关注</p>
|
| 349 |
</div>
|
| 350 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 351 |
|
| 352 |
-
{/* ---
|
| 353 |
{activeTab === 'overview' && (
|
| 354 |
<div className="flex flex-col items-center justify-center py-20 text-gray-400">
|
| 355 |
<PieChartIcon size={64} className="mb-4 opacity-20"/>
|
|
@@ -359,6 +436,71 @@ export const Reports: React.FC = () => {
|
|
| 359 |
)}
|
| 360 |
|
| 361 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 362 |
</div>
|
| 363 |
);
|
| 364 |
};
|
|
|
|
| 2 |
import React, { useState, useEffect } from 'react';
|
| 3 |
import {
|
| 4 |
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer,
|
| 5 |
+
LineChart, Line, AreaChart, Area, PieChart as RePieChart, Pie, Cell, RadarChart, Radar, PolarGrid, PolarAngleAxis, PolarRadiusAxis
|
| 6 |
} from 'recharts';
|
| 7 |
import { api } from '../services/api';
|
| 8 |
+
import { Loader2, Download, Filter, TrendingUp, Users, BookOpen, PieChart as PieChartIcon, Grid, BarChart2, User, UserCheck } from 'lucide-react';
|
| 9 |
+
import { Score, Student, ClassInfo, Subject, Exam } from '../types';
|
| 10 |
|
| 11 |
export const Reports: React.FC = () => {
|
| 12 |
const [loading, setLoading] = useState(true);
|
| 13 |
+
const [activeTab, setActiveTab] = useState<'overview' | 'grade' | 'trend' | 'matrix' | 'student'>('grade');
|
| 14 |
|
| 15 |
// Data
|
| 16 |
const [scores, setScores] = useState<Score[]>([]);
|
| 17 |
const [students, setStudents] = useState<Student[]>([]);
|
| 18 |
const [classes, setClasses] = useState<ClassInfo[]>([]);
|
| 19 |
const [subjects, setSubjects] = useState<Subject[]>([]);
|
| 20 |
+
const [exams, setExams] = useState<Exam[]>([]);
|
| 21 |
|
| 22 |
// Filters
|
| 23 |
const [selectedGrade, setSelectedGrade] = useState<string>('六年级');
|
| 24 |
+
const [selectedClass, setSelectedClass] = useState<string>(''); // For Trend/Student
|
| 25 |
const [selectedSubject, setSelectedSubject] = useState<string>(''); // For Trend
|
| 26 |
|
| 27 |
// Computed Data
|
|
|
|
| 29 |
const [trendData, setTrendData] = useState<any[]>([]);
|
| 30 |
const [matrixData, setMatrixData] = useState<any[]>([]);
|
| 31 |
|
| 32 |
+
// Student Focus State
|
| 33 |
+
const [selectedStudent, setSelectedStudent] = useState<Student | null>(null);
|
| 34 |
+
|
| 35 |
useEffect(() => {
|
| 36 |
const loadData = async () => {
|
| 37 |
setLoading(true);
|
| 38 |
try {
|
| 39 |
+
const [scs, stus, cls, subs, exs] = await Promise.all([
|
| 40 |
api.scores.getAll(),
|
| 41 |
api.students.getAll(),
|
| 42 |
api.classes.getAll(),
|
| 43 |
+
api.subjects.getAll(),
|
| 44 |
+
api.exams.getAll()
|
| 45 |
]);
|
| 46 |
setScores(scs);
|
| 47 |
setStudents(stus);
|
| 48 |
setClasses(cls);
|
| 49 |
setSubjects(subs);
|
| 50 |
+
setExams(exs);
|
| 51 |
|
| 52 |
// Set Defaults
|
| 53 |
if (cls.length > 0) setSelectedClass(cls[0].grade + cls[0].className);
|
|
|
|
| 99 |
|
| 100 |
|
| 101 |
// --- 2. Trend Analysis (Time/Exam based) ---
|
| 102 |
+
// Group by Exam Name or Type. Sort using Exam Dates if available.
|
|
|
|
| 103 |
if (selectedClass && selectedSubject) {
|
| 104 |
const classStudentIds = students.filter(s => s.className === selectedClass).map(s => s.studentNo);
|
| 105 |
|
| 106 |
+
const uniqueExamNames = Array.from(new Set(scores.map(s => s.examName || s.type)));
|
|
|
|
|
|
|
| 107 |
|
| 108 |
+
// Sort exams by date
|
| 109 |
+
uniqueExamNames.sort((a, b) => {
|
| 110 |
+
const dateA = exams.find(e => e.name === a)?.date || '9999-99-99';
|
| 111 |
+
const dateB = exams.find(e => e.name === b)?.date || '9999-99-99';
|
| 112 |
+
return dateA.localeCompare(dateB);
|
| 113 |
+
});
|
| 114 |
+
|
| 115 |
+
const tData = uniqueExamNames.map(exam => {
|
| 116 |
const examScores = scores.filter(s =>
|
| 117 |
(s.examName === exam || s.type === exam) &&
|
| 118 |
s.courseName === selectedSubject &&
|
|
|
|
| 126 |
: 0;
|
| 127 |
|
| 128 |
// Grade Avg (for comparison)
|
|
|
|
| 129 |
const currentGrade = classes.find(c => c.grade + c.className === selectedClass)?.grade || '';
|
| 130 |
const gradeStudentIds = students.filter(s => s.className.startsWith(currentGrade)).map(s => s.studentNo);
|
| 131 |
const gradeExamScores = examScores.filter(s => gradeStudentIds.includes(s.studentNo));
|
|
|
|
| 143 |
}
|
| 144 |
|
| 145 |
// --- 3. Subject Matrix (Heatmap Table) ---
|
|
|
|
| 146 |
const mData = gradeClasses.map(cls => {
|
| 147 |
const fullClassName = cls.grade + cls.className;
|
| 148 |
const classStudentIds = students.filter(s => s.className === fullClassName).map(s => s.studentNo);
|
|
|
|
| 162 |
});
|
| 163 |
setMatrixData(mData);
|
| 164 |
|
| 165 |
+
}, [scores, students, classes, subjects, exams, selectedGrade, selectedClass, selectedSubject]);
|
| 166 |
|
| 167 |
|
| 168 |
const exportExcel = () => {
|
|
|
|
| 184 |
XLSX.writeFile(wb, `Report_${activeTab}_${new Date().toISOString().slice(0,10)}.xlsx`);
|
| 185 |
};
|
| 186 |
|
| 187 |
+
// Helper for Student Focus
|
| 188 |
+
const getStudentTrend = (studentNo: string) => {
|
| 189 |
+
// Get all scores for this student, grouped by exam, sorted by date
|
| 190 |
+
const stuScores = scores.filter(s => s.studentNo === studentNo && s.status === 'Normal');
|
| 191 |
+
const uniqueExamNames = Array.from(new Set(stuScores.map(s => s.examName || s.type)));
|
| 192 |
+
uniqueExamNames.sort((a, b) => {
|
| 193 |
+
const dateA = exams.find(e => e.name === a)?.date || '9999-99-99';
|
| 194 |
+
const dateB = exams.find(e => e.name === b)?.date || '9999-99-99';
|
| 195 |
+
return dateA.localeCompare(dateB);
|
| 196 |
+
});
|
| 197 |
+
|
| 198 |
+
return uniqueExamNames.map(exam => {
|
| 199 |
+
const s = stuScores.find(s => (s.examName || s.type) === exam);
|
| 200 |
+
return { name: exam, score: s ? s.score : 0 };
|
| 201 |
+
});
|
| 202 |
+
};
|
| 203 |
+
|
| 204 |
+
const getStudentRadar = (studentNo: string) => {
|
| 205 |
+
const stuScores = scores.filter(s => s.studentNo === studentNo && s.status === 'Normal');
|
| 206 |
+
return subjects.map(sub => {
|
| 207 |
+
const subScores = stuScores.filter(s => s.courseName === sub.name);
|
| 208 |
+
const avg = subScores.length ? subScores.reduce((a,b)=>a+b.score,0)/subScores.length : 0;
|
| 209 |
+
return { subject: sub.name, score: Number(avg.toFixed(1)), fullMark: 100 };
|
| 210 |
+
});
|
| 211 |
+
};
|
| 212 |
+
|
| 213 |
+
const getStudentAttendance = (studentNo: string) => {
|
| 214 |
+
const all = scores.filter(s => s.studentNo === studentNo);
|
| 215 |
+
return {
|
| 216 |
+
absent: all.filter(s => s.status === 'Absent').length,
|
| 217 |
+
leave: all.filter(s => s.status === 'Leave').length
|
| 218 |
+
};
|
| 219 |
+
};
|
| 220 |
+
|
| 221 |
const uniqueGrades = Array.from(new Set(classes.map(c => c.grade))).sort();
|
| 222 |
const allClasses = classes.map(c => c.grade + c.className);
|
| 223 |
+
|
| 224 |
+
// Student List for Focus Tab
|
| 225 |
+
const focusStudents = students.filter(s => selectedClass ? s.className === selectedClass : true);
|
| 226 |
|
| 227 |
if (loading) return <div className="flex justify-center py-20"><Loader2 className="animate-spin text-blue-600"/></div>;
|
| 228 |
|
|
|
|
| 246 |
{ id: 'grade', label: '年级横向分析', icon: BarChart2 },
|
| 247 |
{ id: 'trend', label: '教学成长轨迹', icon: TrendingUp },
|
| 248 |
{ id: 'matrix', label: '学科质量透视', icon: Grid },
|
| 249 |
+
{ id: 'student', label: '学生个像', icon: User },
|
| 250 |
{ id: 'overview', label: '全校概览', icon: PieChartIcon },
|
| 251 |
].map(tab => (
|
| 252 |
<button
|
|
|
|
| 275 |
</select>
|
| 276 |
)}
|
| 277 |
|
| 278 |
+
{(activeTab === 'trend' || activeTab === 'student') && (
|
| 279 |
+
<select className="border border-gray-300 p-2 rounded text-sm text-gray-800" value={selectedClass} onChange={e => setSelectedClass(e.target.value)}>
|
| 280 |
+
{allClasses.map(c => <option key={c} value={c}>{c}</option>)}
|
| 281 |
+
</select>
|
| 282 |
+
)}
|
| 283 |
+
|
| 284 |
{activeTab === 'trend' && (
|
| 285 |
+
<select className="border border-gray-300 p-2 rounded text-sm text-gray-800" value={selectedSubject} onChange={e => setSelectedSubject(e.target.value)}>
|
| 286 |
+
{subjects.map(s => <option key={s._id} value={s.name}>{s.name}</option>)}
|
| 287 |
+
</select>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 288 |
)}
|
| 289 |
</div>
|
| 290 |
|
|
|
|
| 394 |
<p className="text-xs text-gray-400 mt-4 text-right">* 绿色底色代表优秀,红色底色代表需要关注</p>
|
| 395 |
</div>
|
| 396 |
)}
|
| 397 |
+
|
| 398 |
+
{/* --- 4. Student Focus View --- */}
|
| 399 |
+
{activeTab === 'student' && (
|
| 400 |
+
<div className="animate-in fade-in">
|
| 401 |
+
<h3 className="font-bold text-gray-800 mb-4">{selectedClass} - 学生个像分析</h3>
|
| 402 |
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
| 403 |
+
{focusStudents.map(s => {
|
| 404 |
+
const trend = getStudentTrend(s.studentNo);
|
| 405 |
+
return (
|
| 406 |
+
<div key={s._id||s.id} onClick={() => setSelectedStudent(s)} className="bg-white border rounded-xl p-4 hover:shadow-md cursor-pointer transition-shadow">
|
| 407 |
+
<div className="flex items-center space-x-3 mb-3">
|
| 408 |
+
<div className={`w-10 h-10 rounded-full flex items-center justify-center text-white font-bold ${s.gender==='Male'?'bg-blue-500':'bg-pink-500'}`}>{s.name[0]}</div>
|
| 409 |
+
<div>
|
| 410 |
+
<p className="font-bold text-gray-800">{s.name}</p>
|
| 411 |
+
<p className="text-xs text-gray-500">{s.studentNo}</p>
|
| 412 |
+
</div>
|
| 413 |
+
</div>
|
| 414 |
+
{/* Sparkline */}
|
| 415 |
+
<div className="h-12">
|
| 416 |
+
<ResponsiveContainer width="100%" height="100%">
|
| 417 |
+
<LineChart data={trend}>
|
| 418 |
+
<Line type="monotone" dataKey="score" stroke="#94a3b8" strokeWidth={2} dot={false} />
|
| 419 |
+
</LineChart>
|
| 420 |
+
</ResponsiveContainer>
|
| 421 |
+
</div>
|
| 422 |
+
</div>
|
| 423 |
+
);
|
| 424 |
+
})}
|
| 425 |
+
</div>
|
| 426 |
+
</div>
|
| 427 |
+
)}
|
| 428 |
|
| 429 |
+
{/* --- 5. Overview View --- */}
|
| 430 |
{activeTab === 'overview' && (
|
| 431 |
<div className="flex flex-col items-center justify-center py-20 text-gray-400">
|
| 432 |
<PieChartIcon size={64} className="mb-4 opacity-20"/>
|
|
|
|
| 436 |
)}
|
| 437 |
|
| 438 |
</div>
|
| 439 |
+
|
| 440 |
+
{/* Student Detail Modal */}
|
| 441 |
+
{selectedStudent && (
|
| 442 |
+
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
| 443 |
+
<div className="bg-white rounded-xl w-full max-w-4xl max-h-[90vh] overflow-y-auto p-6 relative">
|
| 444 |
+
<button onClick={()=>setSelectedStudent(null)} className="absolute top-4 right-4 text-gray-400 hover:text-gray-600"><UserCheck size={24}/></button>
|
| 445 |
+
|
| 446 |
+
<div className="flex items-center space-x-4 mb-8">
|
| 447 |
+
<div className={`w-16 h-16 rounded-full flex items-center justify-center text-2xl text-white font-bold ${selectedStudent.gender==='Male'?'bg-blue-600':'bg-pink-500'}`}>
|
| 448 |
+
{selectedStudent.name[0]}
|
| 449 |
+
</div>
|
| 450 |
+
<div>
|
| 451 |
+
<h2 className="text-2xl font-bold text-gray-800">{selectedStudent.name}</h2>
|
| 452 |
+
<p className="text-gray-500">{selectedStudent.studentNo} | {selectedStudent.className}</p>
|
| 453 |
+
</div>
|
| 454 |
+
</div>
|
| 455 |
+
|
| 456 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
| 457 |
+
{/* Radar */}
|
| 458 |
+
<div className="bg-gray-50 rounded-xl p-4">
|
| 459 |
+
<h3 className="font-bold text-gray-700 mb-4 text-center">学科能力模型</h3>
|
| 460 |
+
<div className="h-64">
|
| 461 |
+
<ResponsiveContainer width="100%" height="100%">
|
| 462 |
+
<RadarChart cx="50%" cy="50%" outerRadius="80%" data={getStudentRadar(selectedStudent.studentNo)}>
|
| 463 |
+
<PolarGrid />
|
| 464 |
+
<PolarAngleAxis dataKey="subject" />
|
| 465 |
+
<PolarRadiusAxis angle={30} domain={[0, 100]} />
|
| 466 |
+
<Radar name={selectedStudent.name} dataKey="score" stroke="#3b82f6" fill="#3b82f6" fillOpacity={0.6} />
|
| 467 |
+
<Tooltip />
|
| 468 |
+
</RadarChart>
|
| 469 |
+
</ResponsiveContainer>
|
| 470 |
+
</div>
|
| 471 |
+
</div>
|
| 472 |
+
|
| 473 |
+
{/* Trend */}
|
| 474 |
+
<div className="bg-gray-50 rounded-xl p-4">
|
| 475 |
+
<h3 className="font-bold text-gray-700 mb-4 text-center">综合成绩走势</h3>
|
| 476 |
+
<div className="h-64">
|
| 477 |
+
<ResponsiveContainer width="100%" height="100%">
|
| 478 |
+
<LineChart data={getStudentTrend(selectedStudent.studentNo)}>
|
| 479 |
+
<CartesianGrid strokeDasharray="3 3" vertical={false}/>
|
| 480 |
+
<XAxis dataKey="name" fontSize={10}/>
|
| 481 |
+
<YAxis domain={[0, 100]}/>
|
| 482 |
+
<Tooltip />
|
| 483 |
+
<Line type="monotone" dataKey="score" stroke="#10b981" strokeWidth={3} />
|
| 484 |
+
</LineChart>
|
| 485 |
+
</ResponsiveContainer>
|
| 486 |
+
</div>
|
| 487 |
+
</div>
|
| 488 |
+
|
| 489 |
+
{/* Attendance */}
|
| 490 |
+
<div className="bg-gray-50 rounded-xl p-4 md:col-span-2 flex justify-around">
|
| 491 |
+
<div className="text-center">
|
| 492 |
+
<p className="text-gray-500 text-sm">缺考次数</p>
|
| 493 |
+
<p className="text-2xl font-bold text-red-500">{getStudentAttendance(selectedStudent.studentNo).absent}</p>
|
| 494 |
+
</div>
|
| 495 |
+
<div className="text-center">
|
| 496 |
+
<p className="text-gray-500 text-sm">请假次数</p>
|
| 497 |
+
<p className="text-2xl font-bold text-orange-500">{getStudentAttendance(selectedStudent.studentNo).leave}</p>
|
| 498 |
+
</div>
|
| 499 |
+
</div>
|
| 500 |
+
</div>
|
| 501 |
+
</div>
|
| 502 |
+
</div>
|
| 503 |
+
)}
|
| 504 |
</div>
|
| 505 |
);
|
| 506 |
};
|
pages/ScoreList.tsx
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
|
|
| 1 |
import React, { useState, useEffect } from 'react';
|
| 2 |
import { api } from '../services/api';
|
| 3 |
-
import { Score, Student, Subject, ClassInfo, ExamStatus } from '../types';
|
| 4 |
-
import { Loader2, Plus, Trash2, Award, FileSpreadsheet, X, Upload, Edit, Save } from 'lucide-react';
|
| 5 |
|
| 6 |
export const ScoreList: React.FC = () => {
|
| 7 |
const [subjects, setSubjects] = useState<Subject[]>([]);
|
|
@@ -9,6 +10,7 @@ export const ScoreList: React.FC = () => {
|
|
| 9 |
const [scores, setScores] = useState<Score[]>([]);
|
| 10 |
const [students, setStudents] = useState<Student[]>([]);
|
| 11 |
const [classList, setClassList] = useState<ClassInfo[]>([]);
|
|
|
|
| 12 |
const [loading, setLoading] = useState(true);
|
| 13 |
|
| 14 |
const [selectedGrade, setSelectedGrade] = useState('All');
|
|
@@ -17,6 +19,8 @@ export const ScoreList: React.FC = () => {
|
|
| 17 |
|
| 18 |
const [isAddOpen, setIsAddOpen] = useState(false);
|
| 19 |
const [isImportOpen, setIsImportOpen] = useState(false);
|
|
|
|
|
|
|
| 20 |
const [importFile, setImportFile] = useState<File | null>(null);
|
| 21 |
const [submitting, setSubmitting] = useState(false);
|
| 22 |
|
|
@@ -36,17 +40,19 @@ export const ScoreList: React.FC = () => {
|
|
| 36 |
const loadData = async () => {
|
| 37 |
setLoading(true);
|
| 38 |
try {
|
| 39 |
-
const [subs, scs, stus, cls] = await Promise.all([
|
| 40 |
api.subjects.getAll(),
|
| 41 |
api.scores.getAll(),
|
| 42 |
api.students.getAll(),
|
| 43 |
-
api.classes.getAll()
|
|
|
|
| 44 |
]);
|
| 45 |
setSubjects(subs);
|
| 46 |
if (subs.length > 0 && !activeSubject) setActiveSubject(subs[0].name);
|
| 47 |
setScores(scs);
|
| 48 |
setStudents(stus);
|
| 49 |
setClassList(cls);
|
|
|
|
| 50 |
} catch (e) { console.error(e); }
|
| 51 |
finally { setLoading(false); }
|
| 52 |
};
|
|
@@ -194,6 +200,11 @@ export const ScoreList: React.FC = () => {
|
|
| 194 |
reader.readAsArrayBuffer(importFile);
|
| 195 |
};
|
| 196 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 197 |
|
| 198 |
const toggleSelect = (id: string) => {
|
| 199 |
const newSet = new Set(selectedIds);
|
|
@@ -202,12 +213,12 @@ export const ScoreList: React.FC = () => {
|
|
| 202 |
};
|
| 203 |
|
| 204 |
const uniqueGrades = Array.from(new Set(classList.map(c => c.grade))).sort();
|
| 205 |
-
|
| 206 |
-
// FIX: Cascading filter logic.
|
| 207 |
-
// If a grade is selected, ONLY show classes from that grade.
|
| 208 |
const filteredClasses = classList.filter(c => selectedGrade === 'All' || c.grade === selectedGrade);
|
| 209 |
const uniqueClasses = Array.from(new Set(filteredClasses.map(c => c.className))).sort();
|
| 210 |
|
|
|
|
|
|
|
|
|
|
| 211 |
return (
|
| 212 |
<div className="space-y-6">
|
| 213 |
<div className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden min-h-[500px]">
|
|
@@ -216,11 +227,14 @@ export const ScoreList: React.FC = () => {
|
|
| 216 |
<div className="p-4 md:p-6 border-b border-gray-100">
|
| 217 |
<div className="flex flex-col md:flex-row justify-between items-start md:items-center mb-4 gap-4">
|
| 218 |
<h2 className="text-xl font-bold flex items-center text-gray-800"><Award className="mr-2 text-blue-600"/>成绩管理</h2>
|
| 219 |
-
<div className="flex gap-2 w-full md:w-auto">
|
| 220 |
-
<button onClick={() =>
|
|
|
|
|
|
|
|
|
|
| 221 |
<FileSpreadsheet size={16} className="mr-1"/> Excel导入
|
| 222 |
</button>
|
| 223 |
-
<button onClick={() => setIsAddOpen(true)} className="
|
| 224 |
<Plus size={16} className="mr-1"/> 手动录入
|
| 225 |
</button>
|
| 226 |
</div>
|
|
@@ -362,6 +376,41 @@ export const ScoreList: React.FC = () => {
|
|
| 362 |
</div>
|
| 363 |
)}
|
| 364 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 365 |
{/* Excel Import Modal */}
|
| 366 |
{isImportOpen && (
|
| 367 |
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
|
@@ -419,4 +468,4 @@ export const ScoreList: React.FC = () => {
|
|
| 419 |
)}
|
| 420 |
</div>
|
| 421 |
);
|
| 422 |
-
};
|
|
|
|
| 1 |
+
|
| 2 |
import React, { useState, useEffect } from 'react';
|
| 3 |
import { api } from '../services/api';
|
| 4 |
+
import { Score, Student, Subject, ClassInfo, ExamStatus, Exam } from '../types';
|
| 5 |
+
import { Loader2, Plus, Trash2, Award, FileSpreadsheet, X, Upload, Edit, Save, Calendar } from 'lucide-react';
|
| 6 |
|
| 7 |
export const ScoreList: React.FC = () => {
|
| 8 |
const [subjects, setSubjects] = useState<Subject[]>([]);
|
|
|
|
| 10 |
const [scores, setScores] = useState<Score[]>([]);
|
| 11 |
const [students, setStudents] = useState<Student[]>([]);
|
| 12 |
const [classList, setClassList] = useState<ClassInfo[]>([]);
|
| 13 |
+
const [exams, setExams] = useState<Exam[]>([]);
|
| 14 |
const [loading, setLoading] = useState(true);
|
| 15 |
|
| 16 |
const [selectedGrade, setSelectedGrade] = useState('All');
|
|
|
|
| 19 |
|
| 20 |
const [isAddOpen, setIsAddOpen] = useState(false);
|
| 21 |
const [isImportOpen, setIsImportOpen] = useState(false);
|
| 22 |
+
const [isExamModalOpen, setIsExamModalOpen] = useState(false);
|
| 23 |
+
|
| 24 |
const [importFile, setImportFile] = useState<File | null>(null);
|
| 25 |
const [submitting, setSubmitting] = useState(false);
|
| 26 |
|
|
|
|
| 40 |
const loadData = async () => {
|
| 41 |
setLoading(true);
|
| 42 |
try {
|
| 43 |
+
const [subs, scs, stus, cls, exs] = await Promise.all([
|
| 44 |
api.subjects.getAll(),
|
| 45 |
api.scores.getAll(),
|
| 46 |
api.students.getAll(),
|
| 47 |
+
api.classes.getAll(),
|
| 48 |
+
api.exams.getAll()
|
| 49 |
]);
|
| 50 |
setSubjects(subs);
|
| 51 |
if (subs.length > 0 && !activeSubject) setActiveSubject(subs[0].name);
|
| 52 |
setScores(scs);
|
| 53 |
setStudents(stus);
|
| 54 |
setClassList(cls);
|
| 55 |
+
setExams(exs);
|
| 56 |
} catch (e) { console.error(e); }
|
| 57 |
finally { setLoading(false); }
|
| 58 |
};
|
|
|
|
| 200 |
reader.readAsArrayBuffer(importFile);
|
| 201 |
};
|
| 202 |
|
| 203 |
+
const handleUpdateExamDate = async (name: string, date: string) => {
|
| 204 |
+
await api.exams.save({ name, date });
|
| 205 |
+
loadData();
|
| 206 |
+
};
|
| 207 |
+
|
| 208 |
|
| 209 |
const toggleSelect = (id: string) => {
|
| 210 |
const newSet = new Set(selectedIds);
|
|
|
|
| 213 |
};
|
| 214 |
|
| 215 |
const uniqueGrades = Array.from(new Set(classList.map(c => c.grade))).sort();
|
|
|
|
|
|
|
|
|
|
| 216 |
const filteredClasses = classList.filter(c => selectedGrade === 'All' || c.grade === selectedGrade);
|
| 217 |
const uniqueClasses = Array.from(new Set(filteredClasses.map(c => c.className))).sort();
|
| 218 |
|
| 219 |
+
// Unique Exam Names for Modal
|
| 220 |
+
const uniqueExamNames = Array.from(new Set(scores.map(s => s.examName || s.type)));
|
| 221 |
+
|
| 222 |
return (
|
| 223 |
<div className="space-y-6">
|
| 224 |
<div className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden min-h-[500px]">
|
|
|
|
| 227 |
<div className="p-4 md:p-6 border-b border-gray-100">
|
| 228 |
<div className="flex flex-col md:flex-row justify-between items-start md:items-center mb-4 gap-4">
|
| 229 |
<h2 className="text-xl font-bold flex items-center text-gray-800"><Award className="mr-2 text-blue-600"/>成绩管理</h2>
|
| 230 |
+
<div className="grid grid-cols-2 md:flex gap-2 w-full md:w-auto">
|
| 231 |
+
<button onClick={() => setIsExamModalOpen(true)} className="col-span-2 md:col-span-1 px-4 py-2 bg-indigo-50 text-indigo-600 border border-indigo-200 rounded-lg flex items-center justify-center text-sm hover:bg-indigo-100 transition-colors">
|
| 232 |
+
<Calendar size={16} className="mr-1"/> 考试排期
|
| 233 |
+
</button>
|
| 234 |
+
<button onClick={() => { setIsImportOpen(true); setImportFile(null); }} className="px-4 py-2 bg-emerald-50 text-emerald-600 border border-emerald-200 rounded-lg flex items-center justify-center text-sm hover:bg-emerald-100 transition-colors">
|
| 235 |
<FileSpreadsheet size={16} className="mr-1"/> Excel导入
|
| 236 |
</button>
|
| 237 |
+
<button onClick={() => setIsAddOpen(true)} className="px-4 py-2 bg-blue-600 text-white rounded-lg flex items-center justify-center text-sm hover:bg-blue-700 transition-colors">
|
| 238 |
<Plus size={16} className="mr-1"/> 手动录入
|
| 239 |
</button>
|
| 240 |
</div>
|
|
|
|
| 376 |
</div>
|
| 377 |
)}
|
| 378 |
|
| 379 |
+
{/* Exam Schedule Modal */}
|
| 380 |
+
{isExamModalOpen && (
|
| 381 |
+
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
| 382 |
+
<div className="bg-white rounded-xl p-6 w-full max-w-lg max-h-[80vh] overflow-y-auto">
|
| 383 |
+
<div className="flex justify-between items-center mb-4">
|
| 384 |
+
<h3 className="font-bold text-lg">考试排期管理</h3>
|
| 385 |
+
<button onClick={()=>setIsExamModalOpen(false)}><X size={20}/></button>
|
| 386 |
+
</div>
|
| 387 |
+
<p className="text-sm text-gray-500 mb-4">设置考试的具体日期,以便生成准确的时间轴趋势图。</p>
|
| 388 |
+
|
| 389 |
+
<div className="space-y-3">
|
| 390 |
+
{uniqueExamNames.map((name, idx) => {
|
| 391 |
+
const examInfo = exams.find(e => e.name === name);
|
| 392 |
+
return (
|
| 393 |
+
<div key={idx} className="flex items-center justify-between bg-gray-50 p-3 rounded">
|
| 394 |
+
<span className="font-medium text-gray-800">{name}</span>
|
| 395 |
+
<input
|
| 396 |
+
type="date"
|
| 397 |
+
className="border rounded px-2 py-1 text-sm"
|
| 398 |
+
value={examInfo?.date || ''}
|
| 399 |
+
onChange={(e) => handleUpdateExamDate(name!, e.target.value)}
|
| 400 |
+
/>
|
| 401 |
+
</div>
|
| 402 |
+
);
|
| 403 |
+
})}
|
| 404 |
+
{uniqueExamNames.length === 0 && <p className="text-center text-gray-400 py-4">暂无考试记录</p>}
|
| 405 |
+
</div>
|
| 406 |
+
|
| 407 |
+
<div className="mt-6 flex justify-end">
|
| 408 |
+
<button onClick={()=>setIsExamModalOpen(false)} className="px-4 py-2 bg-blue-600 text-white rounded">完成</button>
|
| 409 |
+
</div>
|
| 410 |
+
</div>
|
| 411 |
+
</div>
|
| 412 |
+
)}
|
| 413 |
+
|
| 414 |
{/* Excel Import Modal */}
|
| 415 |
{isImportOpen && (
|
| 416 |
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
|
|
|
| 468 |
)}
|
| 469 |
</div>
|
| 470 |
);
|
| 471 |
+
};
|
server.js
CHANGED
|
@@ -28,6 +28,7 @@ const InMemoryDB = {
|
|
| 28 |
scores: [],
|
| 29 |
classes: [],
|
| 30 |
subjects: [],
|
|
|
|
| 31 |
config: {
|
| 32 |
systemName: '智慧校园管理系统',
|
| 33 |
semester: '2023-2024学年 第一学期',
|
|
@@ -115,6 +116,13 @@ const SubjectSchema = new mongoose.Schema({
|
|
| 115 |
});
|
| 116 |
const SubjectModel = mongoose.model('Subject', SubjectSchema);
|
| 117 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
const ConfigSchema = new mongoose.Schema({
|
| 119 |
key: { type: String, default: 'main' },
|
| 120 |
systemName: String,
|
|
@@ -273,6 +281,27 @@ app.delete('/api/subjects/:id', async (req, res) => {
|
|
| 273 |
} catch (e) { res.status(500).json({ error: e.message }); }
|
| 274 |
});
|
| 275 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 276 |
// --- Batch Operations ---
|
| 277 |
app.post('/api/batch-delete', async (req, res) => {
|
| 278 |
const { type, ids } = req.body; // type: 'student' | 'score' | 'user'
|
|
|
|
| 28 |
scores: [],
|
| 29 |
classes: [],
|
| 30 |
subjects: [],
|
| 31 |
+
exams: [],
|
| 32 |
config: {
|
| 33 |
systemName: '智慧校园管理系统',
|
| 34 |
semester: '2023-2024学年 第一学期',
|
|
|
|
| 116 |
});
|
| 117 |
const SubjectModel = mongoose.model('Subject', SubjectSchema);
|
| 118 |
|
| 119 |
+
const ExamSchema = new mongoose.Schema({
|
| 120 |
+
name: { type: String, required: true, unique: true },
|
| 121 |
+
date: String,
|
| 122 |
+
semester: String
|
| 123 |
+
});
|
| 124 |
+
const ExamModel = mongoose.model('Exam', ExamSchema);
|
| 125 |
+
|
| 126 |
const ConfigSchema = new mongoose.Schema({
|
| 127 |
key: { type: String, default: 'main' },
|
| 128 |
systemName: String,
|
|
|
|
| 281 |
} catch (e) { res.status(500).json({ error: e.message }); }
|
| 282 |
});
|
| 283 |
|
| 284 |
+
// --- Exams ---
|
| 285 |
+
app.get('/api/exams', async (req, res) => {
|
| 286 |
+
if (InMemoryDB.isFallback) return res.json(InMemoryDB.exams);
|
| 287 |
+
const exams = await ExamModel.find().sort({ date: 1 });
|
| 288 |
+
res.json(exams);
|
| 289 |
+
});
|
| 290 |
+
app.post('/api/exams', async (req, res) => {
|
| 291 |
+
try {
|
| 292 |
+
// Upsert based on name
|
| 293 |
+
const { name, date, semester } = req.body;
|
| 294 |
+
if (InMemoryDB.isFallback) {
|
| 295 |
+
const idx = InMemoryDB.exams.findIndex(e => e.name === name);
|
| 296 |
+
if (idx >= 0) InMemoryDB.exams[idx] = { ...InMemoryDB.exams[idx], date, semester };
|
| 297 |
+
else InMemoryDB.exams.push({ name, date, semester, id: Date.now(), _id: String(Date.now()) });
|
| 298 |
+
return res.json({ success: true });
|
| 299 |
+
}
|
| 300 |
+
await ExamModel.findOneAndUpdate({ name }, { date, semester }, { upsert: true });
|
| 301 |
+
res.json({ success: true });
|
| 302 |
+
} catch (e) { res.status(500).json({ error: e.message }); }
|
| 303 |
+
});
|
| 304 |
+
|
| 305 |
// --- Batch Operations ---
|
| 306 |
app.post('/api/batch-delete', async (req, res) => {
|
| 307 |
const { type, ids } = req.body; // type: 'student' | 'score' | 'user'
|
services/api.ts
CHANGED
|
@@ -91,6 +91,11 @@ export const api = {
|
|
| 91 |
delete: (id: string | number) => request(`/subjects/${id}`, { method: 'DELETE' })
|
| 92 |
},
|
| 93 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
courses: {
|
| 95 |
getAll: () => request('/courses'),
|
| 96 |
add: (data: any) => request('/courses', { method: 'POST', body: JSON.stringify(data) }),
|
|
|
|
| 91 |
delete: (id: string | number) => request(`/subjects/${id}`, { method: 'DELETE' })
|
| 92 |
},
|
| 93 |
|
| 94 |
+
exams: {
|
| 95 |
+
getAll: () => request('/exams'),
|
| 96 |
+
save: (data: any) => request('/exams', { method: 'POST', body: JSON.stringify(data) })
|
| 97 |
+
},
|
| 98 |
+
|
| 99 |
courses: {
|
| 100 |
getAll: () => request('/courses'),
|
| 101 |
add: (data: any) => request('/courses', { method: 'POST', body: JSON.stringify(data) }),
|
types.ts
CHANGED
|
@@ -88,6 +88,14 @@ export interface Score {
|
|
| 88 |
status?: ExamStatus; // Default: Normal
|
| 89 |
}
|
| 90 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
export interface ApiResponse<T> {
|
| 92 |
code: number;
|
| 93 |
message: string;
|
|
|
|
| 88 |
status?: ExamStatus; // Default: Normal
|
| 89 |
}
|
| 90 |
|
| 91 |
+
export interface Exam {
|
| 92 |
+
id?: number;
|
| 93 |
+
_id?: string;
|
| 94 |
+
name: string; // e.g. "期中考试"
|
| 95 |
+
date: string; // e.g. "2023-11-01"
|
| 96 |
+
semester?: string;
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
export interface ApiResponse<T> {
|
| 100 |
code: number;
|
| 101 |
message: string;
|