Spaces:
Sleeping
Sleeping
Upload 22 files
Browse files- App.tsx +19 -1
- components/Sidebar.tsx +4 -2
- pages/ClassList.tsx +138 -0
- pages/CourseList.tsx +120 -18
- pages/Dashboard.tsx +20 -19
- pages/Reports.tsx +107 -468
- pages/Settings.tsx +31 -7
- pages/StudentList.tsx +87 -16
- server.js +125 -5
- services/api.ts +32 -5
- types.ts +17 -0
App.tsx
CHANGED
|
@@ -6,6 +6,7 @@ import { Dashboard } from './pages/Dashboard';
|
|
| 6 |
import { StudentList } from './pages/StudentList';
|
| 7 |
import { CourseList } from './pages/CourseList';
|
| 8 |
import { ScoreList } from './pages/ScoreList';
|
|
|
|
| 9 |
import { Settings } from './pages/Settings';
|
| 10 |
import { Reports } from './pages/Reports';
|
| 11 |
import { Login } from './pages/Login';
|
|
@@ -16,10 +17,19 @@ const App: React.FC = () => {
|
|
| 16 |
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
| 17 |
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
| 18 |
const [currentView, setCurrentView] = useState('dashboard');
|
|
|
|
| 19 |
|
| 20 |
useEffect(() => {
|
| 21 |
-
// Initialize the
|
| 22 |
api.init();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
}, []);
|
| 24 |
|
| 25 |
const handleLogin = (user: User) => {
|
|
@@ -29,6 +39,7 @@ const App: React.FC = () => {
|
|
| 29 |
};
|
| 30 |
|
| 31 |
const handleLogout = () => {
|
|
|
|
| 32 |
setIsAuthenticated(false);
|
| 33 |
setCurrentUser(null);
|
| 34 |
};
|
|
@@ -39,6 +50,8 @@ const App: React.FC = () => {
|
|
| 39 |
return <Dashboard />;
|
| 40 |
case 'students':
|
| 41 |
return <StudentList />;
|
|
|
|
|
|
|
| 42 |
case 'courses':
|
| 43 |
return <CourseList />;
|
| 44 |
case 'grades':
|
|
@@ -56,12 +69,17 @@ const App: React.FC = () => {
|
|
| 56 |
const viewTitles: Record<string, string> = {
|
| 57 |
dashboard: '工作台',
|
| 58 |
students: '学生档案管理',
|
|
|
|
| 59 |
courses: '课程安排',
|
| 60 |
grades: '成绩管理',
|
| 61 |
settings: '系统设置',
|
| 62 |
reports: '统计报表'
|
| 63 |
};
|
| 64 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
if (!isAuthenticated) {
|
| 66 |
return <Login onLogin={handleLogin} />;
|
| 67 |
}
|
|
|
|
| 6 |
import { StudentList } from './pages/StudentList';
|
| 7 |
import { CourseList } from './pages/CourseList';
|
| 8 |
import { ScoreList } from './pages/ScoreList';
|
| 9 |
+
import { ClassList } from './pages/ClassList';
|
| 10 |
import { Settings } from './pages/Settings';
|
| 11 |
import { Reports } from './pages/Reports';
|
| 12 |
import { Login } from './pages/Login';
|
|
|
|
| 17 |
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
| 18 |
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
| 19 |
const [currentView, setCurrentView] = useState('dashboard');
|
| 20 |
+
const [loading, setLoading] = useState(true);
|
| 21 |
|
| 22 |
useEffect(() => {
|
| 23 |
+
// Initialize the API config
|
| 24 |
api.init();
|
| 25 |
+
|
| 26 |
+
// Check for existing session
|
| 27 |
+
const storedUser = api.auth.getCurrentUser();
|
| 28 |
+
if (storedUser) {
|
| 29 |
+
setCurrentUser(storedUser);
|
| 30 |
+
setIsAuthenticated(true);
|
| 31 |
+
}
|
| 32 |
+
setLoading(false);
|
| 33 |
}, []);
|
| 34 |
|
| 35 |
const handleLogin = (user: User) => {
|
|
|
|
| 39 |
};
|
| 40 |
|
| 41 |
const handleLogout = () => {
|
| 42 |
+
api.auth.logout();
|
| 43 |
setIsAuthenticated(false);
|
| 44 |
setCurrentUser(null);
|
| 45 |
};
|
|
|
|
| 50 |
return <Dashboard />;
|
| 51 |
case 'students':
|
| 52 |
return <StudentList />;
|
| 53 |
+
case 'classes':
|
| 54 |
+
return <ClassList />;
|
| 55 |
case 'courses':
|
| 56 |
return <CourseList />;
|
| 57 |
case 'grades':
|
|
|
|
| 69 |
const viewTitles: Record<string, string> = {
|
| 70 |
dashboard: '工作台',
|
| 71 |
students: '学生档案管理',
|
| 72 |
+
classes: '班级管理',
|
| 73 |
courses: '课程安排',
|
| 74 |
grades: '成绩管理',
|
| 75 |
settings: '系统设置',
|
| 76 |
reports: '统计报表'
|
| 77 |
};
|
| 78 |
|
| 79 |
+
if (loading) {
|
| 80 |
+
return <div className="min-h-screen flex items-center justify-center bg-gray-50 text-blue-600">加载中...</div>;
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
if (!isAuthenticated) {
|
| 84 |
return <Login onLogin={handleLogin} />;
|
| 85 |
}
|
components/Sidebar.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
|
|
| 1 |
import React from 'react';
|
| 2 |
-
import { LayoutDashboard, Users, BookOpen, GraduationCap, Settings, LogOut, FileText } from 'lucide-react';
|
| 3 |
import { UserRole } from '../types';
|
| 4 |
|
| 5 |
interface SidebarProps {
|
|
@@ -13,6 +14,7 @@ export const Sidebar: React.FC<SidebarProps> = ({ currentView, onChangeView, use
|
|
| 13 |
const menuItems = [
|
| 14 |
{ id: 'dashboard', label: '工作台', icon: LayoutDashboard, roles: [UserRole.ADMIN, UserRole.TEACHER] },
|
| 15 |
{ id: 'students', label: '学生管理', icon: Users, roles: [UserRole.ADMIN, UserRole.TEACHER] },
|
|
|
|
| 16 |
{ id: 'courses', label: '课程管理', icon: BookOpen, roles: [UserRole.ADMIN, UserRole.TEACHER, UserRole.STUDENT] },
|
| 17 |
{ id: 'grades', label: '成绩管理', icon: GraduationCap, roles: [UserRole.ADMIN, UserRole.TEACHER, UserRole.STUDENT] },
|
| 18 |
{ id: 'reports', label: '报表统计', icon: FileText, roles: [UserRole.ADMIN] },
|
|
@@ -65,4 +67,4 @@ export const Sidebar: React.FC<SidebarProps> = ({ currentView, onChangeView, use
|
|
| 65 |
</div>
|
| 66 |
</div>
|
| 67 |
);
|
| 68 |
-
};
|
|
|
|
| 1 |
+
|
| 2 |
import React from 'react';
|
| 3 |
+
import { LayoutDashboard, Users, BookOpen, GraduationCap, Settings, LogOut, FileText, School } from 'lucide-react';
|
| 4 |
import { UserRole } from '../types';
|
| 5 |
|
| 6 |
interface SidebarProps {
|
|
|
|
| 14 |
const menuItems = [
|
| 15 |
{ id: 'dashboard', label: '工作台', icon: LayoutDashboard, roles: [UserRole.ADMIN, UserRole.TEACHER] },
|
| 16 |
{ id: 'students', label: '学生管理', icon: Users, roles: [UserRole.ADMIN, UserRole.TEACHER] },
|
| 17 |
+
{ id: 'classes', label: '班级管理', icon: School, roles: [UserRole.ADMIN] },
|
| 18 |
{ id: 'courses', label: '课程管理', icon: BookOpen, roles: [UserRole.ADMIN, UserRole.TEACHER, UserRole.STUDENT] },
|
| 19 |
{ id: 'grades', label: '成绩管理', icon: GraduationCap, roles: [UserRole.ADMIN, UserRole.TEACHER, UserRole.STUDENT] },
|
| 20 |
{ id: 'reports', label: '报表统计', icon: FileText, roles: [UserRole.ADMIN] },
|
|
|
|
| 67 |
</div>
|
| 68 |
</div>
|
| 69 |
);
|
| 70 |
+
};
|
pages/ClassList.tsx
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import React, { useState, useEffect } from 'react';
|
| 3 |
+
import { Plus, Trash2, Users, School, Loader2 } from 'lucide-react';
|
| 4 |
+
import { api } from '../services/api';
|
| 5 |
+
import { ClassInfo } from '../types';
|
| 6 |
+
|
| 7 |
+
export const ClassList: React.FC = () => {
|
| 8 |
+
const [classes, setClasses] = useState<ClassInfo[]>([]);
|
| 9 |
+
const [loading, setLoading] = useState(true);
|
| 10 |
+
const [isModalOpen, setIsModalOpen] = useState(false);
|
| 11 |
+
const [submitting, setSubmitting] = useState(false);
|
| 12 |
+
|
| 13 |
+
// Form
|
| 14 |
+
const [grade, setGrade] = useState('六年级');
|
| 15 |
+
const [className, setClassName] = useState('(1)班');
|
| 16 |
+
const [teacherName, setTeacherName] = useState('');
|
| 17 |
+
|
| 18 |
+
const grades = ['一年级', '二年级', '三年级', '四年级', '五年级', '六年级'];
|
| 19 |
+
|
| 20 |
+
const loadClasses = async () => {
|
| 21 |
+
setLoading(true);
|
| 22 |
+
try {
|
| 23 |
+
const data = await api.classes.getAll();
|
| 24 |
+
setClasses(data);
|
| 25 |
+
} catch (e) {
|
| 26 |
+
console.error(e);
|
| 27 |
+
} finally {
|
| 28 |
+
setLoading(false);
|
| 29 |
+
}
|
| 30 |
+
};
|
| 31 |
+
|
| 32 |
+
useEffect(() => {
|
| 33 |
+
loadClasses();
|
| 34 |
+
}, []);
|
| 35 |
+
|
| 36 |
+
const handleDelete = async (id: string | number) => {
|
| 37 |
+
if (confirm('确定要删除这个班级吗?')) {
|
| 38 |
+
await api.classes.delete(id);
|
| 39 |
+
loadClasses();
|
| 40 |
+
}
|
| 41 |
+
};
|
| 42 |
+
|
| 43 |
+
const handleSubmit = async (e: React.FormEvent) => {
|
| 44 |
+
e.preventDefault();
|
| 45 |
+
setSubmitting(true);
|
| 46 |
+
try {
|
| 47 |
+
await api.classes.add({ grade, className, teacherName });
|
| 48 |
+
setIsModalOpen(false);
|
| 49 |
+
setTeacherName('');
|
| 50 |
+
loadClasses();
|
| 51 |
+
} catch (e) {
|
| 52 |
+
alert('添加失败');
|
| 53 |
+
} finally {
|
| 54 |
+
setSubmitting(false);
|
| 55 |
+
}
|
| 56 |
+
};
|
| 57 |
+
|
| 58 |
+
return (
|
| 59 |
+
<div className="space-y-6">
|
| 60 |
+
<div className="flex justify-between items-center">
|
| 61 |
+
<h2 className="text-xl font-bold text-gray-800">班级管理</h2>
|
| 62 |
+
<button
|
| 63 |
+
onClick={() => setIsModalOpen(true)}
|
| 64 |
+
className="flex items-center space-x-2 px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 shadow-sm"
|
| 65 |
+
>
|
| 66 |
+
<Plus size={16} />
|
| 67 |
+
<span>新增班级</span>
|
| 68 |
+
</button>
|
| 69 |
+
</div>
|
| 70 |
+
|
| 71 |
+
{loading ? (
|
| 72 |
+
<div className="flex justify-center py-10"><Loader2 className="animate-spin text-blue-600" /></div>
|
| 73 |
+
) : (
|
| 74 |
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
| 75 |
+
{classes.map((cls) => (
|
| 76 |
+
<div key={cls._id || cls.id} className="bg-white rounded-xl shadow-sm border border-gray-100 p-6 flex flex-col justify-between hover:shadow-md transition-shadow">
|
| 77 |
+
<div className="flex justify-between items-start">
|
| 78 |
+
<div className="flex items-center space-x-3">
|
| 79 |
+
<div className="p-3 bg-blue-50 text-blue-600 rounded-lg">
|
| 80 |
+
<School size={24} />
|
| 81 |
+
</div>
|
| 82 |
+
<div>
|
| 83 |
+
<h3 className="text-lg font-bold text-gray-800">{cls.grade}{cls.className}</h3>
|
| 84 |
+
<p className="text-sm text-gray-500">班主任: {cls.teacherName || '未设置'}</p>
|
| 85 |
+
</div>
|
| 86 |
+
</div>
|
| 87 |
+
<button
|
| 88 |
+
onClick={() => handleDelete(cls._id || cls.id!)}
|
| 89 |
+
className="text-gray-400 hover:text-red-500 transition-colors"
|
| 90 |
+
>
|
| 91 |
+
<Trash2 size={18} />
|
| 92 |
+
</button>
|
| 93 |
+
</div>
|
| 94 |
+
|
| 95 |
+
<div className="mt-6 pt-4 border-t border-gray-50 flex items-center justify-between text-sm">
|
| 96 |
+
<span className="text-gray-500 flex items-center">
|
| 97 |
+
<Users size={16} className="mr-2" /> 学生人数
|
| 98 |
+
</span>
|
| 99 |
+
<span className="font-bold text-gray-800">{cls.studentCount || 0} 人</span>
|
| 100 |
+
</div>
|
| 101 |
+
</div>
|
| 102 |
+
))}
|
| 103 |
+
</div>
|
| 104 |
+
)}
|
| 105 |
+
|
| 106 |
+
{/* Modal */}
|
| 107 |
+
{isModalOpen && (
|
| 108 |
+
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
| 109 |
+
<div className="bg-white rounded-xl shadow-xl max-w-sm w-full p-6">
|
| 110 |
+
<h3 className="text-lg font-bold text-gray-800 mb-4">新增班级</h3>
|
| 111 |
+
<form onSubmit={handleSubmit} className="space-y-4">
|
| 112 |
+
<div>
|
| 113 |
+
<label className="text-sm font-medium text-gray-700">年级</label>
|
| 114 |
+
<select value={grade} onChange={e => setGrade(e.target.value)} className="w-full mt-1 px-3 py-2 border rounded-lg">
|
| 115 |
+
{grades.map(g => <option key={g} value={g}>{g}</option>)}
|
| 116 |
+
</select>
|
| 117 |
+
</div>
|
| 118 |
+
<div>
|
| 119 |
+
<label className="text-sm font-medium text-gray-700">班级名 (如: (1)班)</label>
|
| 120 |
+
<input required type="text" value={className} onChange={e => setClassName(e.target.value)} className="w-full mt-1 px-3 py-2 border rounded-lg" placeholder="(1)班" />
|
| 121 |
+
</div>
|
| 122 |
+
<div>
|
| 123 |
+
<label className="text-sm font-medium text-gray-700">班主任姓名</label>
|
| 124 |
+
<input required type="text" value={teacherName} onChange={e => setTeacherName(e.target.value)} className="w-full mt-1 px-3 py-2 border rounded-lg" placeholder="张老师" />
|
| 125 |
+
</div>
|
| 126 |
+
<div className="flex space-x-3 pt-4">
|
| 127 |
+
<button type="button" onClick={() => setIsModalOpen(false)} className="flex-1 py-2 border rounded-lg">取消</button>
|
| 128 |
+
<button type="submit" disabled={submitting} className="flex-1 py-2 bg-blue-600 text-white rounded-lg">
|
| 129 |
+
{submitting ? '提交中...' : '创建'}
|
| 130 |
+
</button>
|
| 131 |
+
</div>
|
| 132 |
+
</form>
|
| 133 |
+
</div>
|
| 134 |
+
</div>
|
| 135 |
+
)}
|
| 136 |
+
</div>
|
| 137 |
+
);
|
| 138 |
+
};
|
pages/CourseList.tsx
CHANGED
|
@@ -1,28 +1,80 @@
|
|
| 1 |
|
| 2 |
import React, { useEffect, useState } from 'react';
|
| 3 |
-
import { Plus, Users, Clock, Loader2 } from 'lucide-react';
|
| 4 |
import { api } from '../services/api';
|
| 5 |
import { Course } from '../types';
|
| 6 |
|
| 7 |
export const CourseList: React.FC = () => {
|
| 8 |
const [courses, setCourses] = useState<Course[]>([]);
|
| 9 |
const [loading, setLoading] = useState(true);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
useEffect(() => {
|
| 12 |
-
const loadCourses = async () => {
|
| 13 |
-
try {
|
| 14 |
-
const data = await api.courses.getAll();
|
| 15 |
-
setCourses(data);
|
| 16 |
-
} catch (error) {
|
| 17 |
-
console.error(error);
|
| 18 |
-
} finally {
|
| 19 |
-
setLoading(false);
|
| 20 |
-
}
|
| 21 |
-
};
|
| 22 |
loadCourses();
|
| 23 |
}, []);
|
| 24 |
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
return (
|
| 27 |
<div className="flex justify-center items-center h-64">
|
| 28 |
<Loader2 className="animate-spin text-blue-500" size={32} />
|
|
@@ -34,7 +86,10 @@ export const CourseList: React.FC = () => {
|
|
| 34 |
<div className="space-y-6">
|
| 35 |
<div className="flex justify-between items-center">
|
| 36 |
<h2 className="text-xl font-bold text-gray-800">课程列表</h2>
|
| 37 |
-
<button
|
|
|
|
|
|
|
|
|
|
| 38 |
<Plus size={16} />
|
| 39 |
<span>新增课程</span>
|
| 40 |
</button>
|
|
@@ -42,7 +97,7 @@ export const CourseList: React.FC = () => {
|
|
| 42 |
|
| 43 |
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
| 44 |
{courses.map((course) => (
|
| 45 |
-
<div key={course.id} className="bg-white rounded-xl shadow-sm border border-gray-100 p-6 hover:shadow-md transition-shadow">
|
| 46 |
<div className="flex justify-between items-start mb-4">
|
| 47 |
<div>
|
| 48 |
<span className="inline-block px-2 py-1 bg-blue-50 text-blue-600 text-xs font-semibold rounded mb-2">
|
|
@@ -78,17 +133,64 @@ export const CourseList: React.FC = () => {
|
|
| 78 |
</div>
|
| 79 |
|
| 80 |
<div className="pt-4 border-t border-gray-100 flex space-x-2">
|
| 81 |
-
<button
|
| 82 |
-
|
|
|
|
|
|
|
|
|
|
| 83 |
</button>
|
| 84 |
-
<button
|
| 85 |
-
|
|
|
|
|
|
|
|
|
|
| 86 |
</button>
|
| 87 |
</div>
|
| 88 |
</div>
|
| 89 |
</div>
|
| 90 |
))}
|
| 91 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
</div>
|
| 93 |
);
|
| 94 |
};
|
|
|
|
| 1 |
|
| 2 |
import React, { useEffect, useState } from 'react';
|
| 3 |
+
import { Plus, Users, Clock, Loader2, Edit, Trash2, X } from 'lucide-react';
|
| 4 |
import { api } from '../services/api';
|
| 5 |
import { Course } from '../types';
|
| 6 |
|
| 7 |
export const CourseList: React.FC = () => {
|
| 8 |
const [courses, setCourses] = useState<Course[]>([]);
|
| 9 |
const [loading, setLoading] = useState(true);
|
| 10 |
+
const [isModalOpen, setIsModalOpen] = useState(false);
|
| 11 |
+
const [submitting, setSubmitting] = useState(false);
|
| 12 |
+
const [editId, setEditId] = useState<string | number | null>(null);
|
| 13 |
+
|
| 14 |
+
const [formData, setFormData] = useState({
|
| 15 |
+
courseCode: '',
|
| 16 |
+
courseName: '',
|
| 17 |
+
teacherName: '',
|
| 18 |
+
credits: 2,
|
| 19 |
+
capacity: 45
|
| 20 |
+
});
|
| 21 |
+
|
| 22 |
+
const loadCourses = async () => {
|
| 23 |
+
setLoading(true);
|
| 24 |
+
try {
|
| 25 |
+
const data = await api.courses.getAll();
|
| 26 |
+
setCourses(data);
|
| 27 |
+
} catch (error) {
|
| 28 |
+
console.error(error);
|
| 29 |
+
} finally {
|
| 30 |
+
setLoading(false);
|
| 31 |
+
}
|
| 32 |
+
};
|
| 33 |
|
| 34 |
useEffect(() => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
loadCourses();
|
| 36 |
}, []);
|
| 37 |
|
| 38 |
+
const handleEdit = (c: Course) => {
|
| 39 |
+
setFormData({
|
| 40 |
+
courseCode: c.courseCode,
|
| 41 |
+
courseName: c.courseName,
|
| 42 |
+
teacherName: c.teacherName,
|
| 43 |
+
credits: c.credits,
|
| 44 |
+
capacity: c.capacity
|
| 45 |
+
});
|
| 46 |
+
setEditId(c._id || c.id || null);
|
| 47 |
+
setIsModalOpen(true);
|
| 48 |
+
};
|
| 49 |
+
|
| 50 |
+
const handleDelete = async (id: string | number) => {
|
| 51 |
+
if (confirm('确定要删除这门课程吗?')) {
|
| 52 |
+
await api.courses.delete(id);
|
| 53 |
+
loadCourses();
|
| 54 |
+
}
|
| 55 |
+
};
|
| 56 |
+
|
| 57 |
+
const handleSubmit = async (e: React.FormEvent) => {
|
| 58 |
+
e.preventDefault();
|
| 59 |
+
setSubmitting(true);
|
| 60 |
+
try {
|
| 61 |
+
if (editId) {
|
| 62 |
+
await api.courses.update(editId, formData);
|
| 63 |
+
} else {
|
| 64 |
+
await api.courses.add(formData);
|
| 65 |
+
}
|
| 66 |
+
setIsModalOpen(false);
|
| 67 |
+
setFormData({ courseCode: '', courseName: '', teacherName: '', credits: 2, capacity: 45 });
|
| 68 |
+
setEditId(null);
|
| 69 |
+
loadCourses();
|
| 70 |
+
} catch (e) {
|
| 71 |
+
alert('保存失败');
|
| 72 |
+
} finally {
|
| 73 |
+
setSubmitting(false);
|
| 74 |
+
}
|
| 75 |
+
};
|
| 76 |
+
|
| 77 |
+
if (loading && !courses.length) {
|
| 78 |
return (
|
| 79 |
<div className="flex justify-center items-center h-64">
|
| 80 |
<Loader2 className="animate-spin text-blue-500" size={32} />
|
|
|
|
| 86 |
<div className="space-y-6">
|
| 87 |
<div className="flex justify-between items-center">
|
| 88 |
<h2 className="text-xl font-bold text-gray-800">课程列表</h2>
|
| 89 |
+
<button
|
| 90 |
+
onClick={() => { setEditId(null); setFormData({ courseCode: '', courseName: '', teacherName: '', credits: 2, capacity: 45 }); setIsModalOpen(true); }}
|
| 91 |
+
className="flex items-center space-x-2 px-4 py-2 bg-indigo-600 text-white rounded-lg text-sm font-medium hover:bg-indigo-700 transition-colors shadow-sm"
|
| 92 |
+
>
|
| 93 |
<Plus size={16} />
|
| 94 |
<span>新增课程</span>
|
| 95 |
</button>
|
|
|
|
| 97 |
|
| 98 |
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
| 99 |
{courses.map((course) => (
|
| 100 |
+
<div key={course._id || course.id} className="bg-white rounded-xl shadow-sm border border-gray-100 p-6 hover:shadow-md transition-shadow">
|
| 101 |
<div className="flex justify-between items-start mb-4">
|
| 102 |
<div>
|
| 103 |
<span className="inline-block px-2 py-1 bg-blue-50 text-blue-600 text-xs font-semibold rounded mb-2">
|
|
|
|
| 133 |
</div>
|
| 134 |
|
| 135 |
<div className="pt-4 border-t border-gray-100 flex space-x-2">
|
| 136 |
+
<button
|
| 137 |
+
onClick={() => handleEdit(course)}
|
| 138 |
+
className="flex-1 px-3 py-2 bg-blue-50 text-blue-600 rounded-lg text-sm font-medium hover:bg-blue-100 flex items-center justify-center"
|
| 139 |
+
>
|
| 140 |
+
<Edit size={14} className="mr-1" /> 编辑
|
| 141 |
</button>
|
| 142 |
+
<button
|
| 143 |
+
onClick={() => handleDelete(course._id || course.id!)}
|
| 144 |
+
className="flex-1 px-3 py-2 bg-red-50 text-red-600 rounded-lg text-sm font-medium hover:bg-red-100 flex items-center justify-center"
|
| 145 |
+
>
|
| 146 |
+
<Trash2 size={14} className="mr-1" /> 删除
|
| 147 |
</button>
|
| 148 |
</div>
|
| 149 |
</div>
|
| 150 |
</div>
|
| 151 |
))}
|
| 152 |
</div>
|
| 153 |
+
|
| 154 |
+
{/* Modal */}
|
| 155 |
+
{isModalOpen && (
|
| 156 |
+
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
| 157 |
+
<div className="bg-white rounded-xl shadow-xl max-w-md w-full p-6">
|
| 158 |
+
<div className="flex justify-between items-center mb-4">
|
| 159 |
+
<h3 className="text-lg font-bold text-gray-800">{editId ? '编辑课程' : '新增课程'}</h3>
|
| 160 |
+
<button onClick={() => setIsModalOpen(false)}><X size={20} className="text-gray-400" /></button>
|
| 161 |
+
</div>
|
| 162 |
+
<form onSubmit={handleSubmit} className="space-y-4">
|
| 163 |
+
<div className="grid grid-cols-2 gap-4">
|
| 164 |
+
<div>
|
| 165 |
+
<label className="text-sm font-medium text-gray-700">课程代码</label>
|
| 166 |
+
<input type="text" value={formData.courseCode} onChange={e => setFormData({...formData, courseCode: e.target.value})} className="w-full mt-1 px-3 py-2 border rounded-lg" placeholder="CHI01" required />
|
| 167 |
+
</div>
|
| 168 |
+
<div>
|
| 169 |
+
<label className="text-sm font-medium text-gray-700">课程名称</label>
|
| 170 |
+
<input type="text" value={formData.courseName} onChange={e => setFormData({...formData, courseName: e.target.value})} className="w-full mt-1 px-3 py-2 border rounded-lg" placeholder="语文" required />
|
| 171 |
+
</div>
|
| 172 |
+
</div>
|
| 173 |
+
<div>
|
| 174 |
+
<label className="text-sm font-medium text-gray-700">任课教师</label>
|
| 175 |
+
<input type="text" value={formData.teacherName} onChange={e => setFormData({...formData, teacherName: e.target.value})} className="w-full mt-1 px-3 py-2 border rounded-lg" required />
|
| 176 |
+
</div>
|
| 177 |
+
<div className="grid grid-cols-2 gap-4">
|
| 178 |
+
<div>
|
| 179 |
+
<label className="text-sm font-medium text-gray-700">周课时</label>
|
| 180 |
+
<input type="number" value={formData.credits} onChange={e => setFormData({...formData, credits: parseInt(e.target.value)})} className="w-full mt-1 px-3 py-2 border rounded-lg" required />
|
| 181 |
+
</div>
|
| 182 |
+
<div>
|
| 183 |
+
<label className="text-sm font-medium text-gray-700">容量</label>
|
| 184 |
+
<input type="number" value={formData.capacity} onChange={e => setFormData({...formData, capacity: parseInt(e.target.value)})} className="w-full mt-1 px-3 py-2 border rounded-lg" required />
|
| 185 |
+
</div>
|
| 186 |
+
</div>
|
| 187 |
+
<button type="submit" disabled={submitting} className="w-full py-2 bg-blue-600 text-white rounded-lg mt-4">
|
| 188 |
+
{submitting ? '保存中...' : '保存'}
|
| 189 |
+
</button>
|
| 190 |
+
</form>
|
| 191 |
+
</div>
|
| 192 |
+
</div>
|
| 193 |
+
)}
|
| 194 |
</div>
|
| 195 |
);
|
| 196 |
};
|
pages/Dashboard.tsx
CHANGED
|
@@ -2,11 +2,20 @@
|
|
| 2 |
import React, { useEffect, useState } from 'react';
|
| 3 |
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell, Legend, Area, AreaChart } from 'recharts';
|
| 4 |
import { Users, BookOpen, Award, TrendingUp, Loader2 } from 'lucide-react';
|
| 5 |
-
import { CHART_DATA_TRENDS, CHART_DATA_DISTRIBUTION } from '../services/mockData';
|
| 6 |
import { api } from '../services/api';
|
| 7 |
|
| 8 |
const COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444'];
|
| 9 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
const StatCard: React.FC<{ title: string; value: string; trend: string; icon: React.ElementType; color: string }> = ({ title, value, trend, icon: Icon, color }) => (
|
| 11 |
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100 flex items-start justify-between">
|
| 12 |
<div>
|
|
@@ -53,20 +62,20 @@ export const Dashboard: React.FC = () => {
|
|
| 53 |
<div className="space-y-6">
|
| 54 |
{/* Stats Grid */}
|
| 55 |
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
| 56 |
-
<StatCard title="学生总数" value={stats.studentCount.toString()} trend="
|
| 57 |
-
<StatCard title="
|
| 58 |
-
<StatCard title="平均分" value={stats.avgScore} trend="
|
| 59 |
-
<StatCard title="优秀率" value={stats.excellentRate} trend="
|
| 60 |
</div>
|
| 61 |
|
| 62 |
{/* Charts */}
|
| 63 |
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
| 64 |
{/* Line Chart */}
|
| 65 |
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100">
|
| 66 |
-
<h3 className="text-lg font-bold text-gray-800 mb-4"
|
| 67 |
<div className="h-80">
|
| 68 |
<ResponsiveContainer width="100%" height="100%">
|
| 69 |
-
<AreaChart data={
|
| 70 |
<defs>
|
| 71 |
<linearGradient id="colorAvg" x1="0" y1="0" x2="0" y2="1">
|
| 72 |
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.8}/>
|
|
@@ -76,14 +85,8 @@ export const Dashboard: React.FC = () => {
|
|
| 76 |
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e5e7eb" />
|
| 77 |
<XAxis dataKey="name" axisLine={false} tickLine={false} tick={{ fill: '#6b7280' }} />
|
| 78 |
<YAxis axisLine={false} tickLine={false} tick={{ fill: '#6b7280' }} />
|
| 79 |
-
<Tooltip
|
| 80 |
-
contentStyle={{ backgroundColor: '#fff', borderRadius: '8px', border: 'none', boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)' }}
|
| 81 |
-
itemStyle={{ color: '#1f2937' }}
|
| 82 |
-
labelStyle={{ color: '#6b7280' }}
|
| 83 |
-
formatter={(value: any, name: any) => [value, name === 'avg' ? '平均分' : '最高分']}
|
| 84 |
-
/>
|
| 85 |
<Area type="monotone" dataKey="avg" stroke="#3b82f6" strokeWidth={3} fillOpacity={1} fill="url(#colorAvg)" name="平均分" />
|
| 86 |
-
<Line type="monotone" dataKey="max" stroke="#10b981" strokeWidth={2} strokeDasharray="5 5" dot={false} name="最高分" />
|
| 87 |
</AreaChart>
|
| 88 |
</ResponsiveContainer>
|
| 89 |
</div>
|
|
@@ -91,12 +94,12 @@ export const Dashboard: React.FC = () => {
|
|
| 91 |
|
| 92 |
{/* Pie Chart */}
|
| 93 |
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100">
|
| 94 |
-
<h3 className="text-lg font-bold text-gray-800 mb-4"
|
| 95 |
<div className="h-80">
|
| 96 |
<ResponsiveContainer width="100%" height="100%">
|
| 97 |
<PieChart>
|
| 98 |
<Pie
|
| 99 |
-
data={
|
| 100 |
cx="50%"
|
| 101 |
cy="50%"
|
| 102 |
innerRadius={60}
|
|
@@ -105,9 +108,7 @@ export const Dashboard: React.FC = () => {
|
|
| 105 |
paddingAngle={5}
|
| 106 |
dataKey="value"
|
| 107 |
>
|
| 108 |
-
|
| 109 |
-
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
| 110 |
-
))}
|
| 111 |
</Pie>
|
| 112 |
<Tooltip />
|
| 113 |
<Legend verticalAlign="bottom" height={36} iconType="circle" />
|
|
|
|
| 2 |
import React, { useEffect, useState } from 'react';
|
| 3 |
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell, Legend, Area, AreaChart } from 'recharts';
|
| 4 |
import { Users, BookOpen, Award, TrendingUp, Loader2 } from 'lucide-react';
|
|
|
|
| 5 |
import { api } from '../services/api';
|
| 6 |
|
| 7 |
const COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444'];
|
| 8 |
|
| 9 |
+
// Placeholder charts with empty state if no data
|
| 10 |
+
const EMPTY_TRENDS = [
|
| 11 |
+
{ name: '9月', avg: 0, max: 0 },
|
| 12 |
+
{ name: '10月', avg: 0, max: 0 },
|
| 13 |
+
{ name: '11月', avg: 0, max: 0 },
|
| 14 |
+
];
|
| 15 |
+
const EMPTY_DIST = [
|
| 16 |
+
{ name: '无数据', value: 100 },
|
| 17 |
+
];
|
| 18 |
+
|
| 19 |
const StatCard: React.FC<{ title: string; value: string; trend: string; icon: React.ElementType; color: string }> = ({ title, value, trend, icon: Icon, color }) => (
|
| 20 |
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100 flex items-start justify-between">
|
| 21 |
<div>
|
|
|
|
| 62 |
<div className="space-y-6">
|
| 63 |
{/* Stats Grid */}
|
| 64 |
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
| 65 |
+
<StatCard title="学生总数" value={stats.studentCount.toString()} trend="当前实时数据" icon={Users} color="bg-blue-500" />
|
| 66 |
+
<StatCard title="课程总数" value={stats.courseCount.toString()} trend="系统已录入" icon={BookOpen} color="bg-emerald-500" />
|
| 67 |
+
<StatCard title="平均分" value={stats.avgScore} trend="基于现有成绩" icon={Award} color="bg-amber-500" />
|
| 68 |
+
<StatCard title="优秀率" value={stats.excellentRate} trend=">90分比例" icon={TrendingUp} color="bg-indigo-500" />
|
| 69 |
</div>
|
| 70 |
|
| 71 |
{/* Charts */}
|
| 72 |
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
| 73 |
{/* Line Chart */}
|
| 74 |
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100">
|
| 75 |
+
<h3 className="text-lg font-bold text-gray-800 mb-4">学业成绩趋势 (示例)</h3>
|
| 76 |
<div className="h-80">
|
| 77 |
<ResponsiveContainer width="100%" height="100%">
|
| 78 |
+
<AreaChart data={EMPTY_TRENDS} margin={{ top: 10, right: 30, left: 0, bottom: 0 }}>
|
| 79 |
<defs>
|
| 80 |
<linearGradient id="colorAvg" x1="0" y1="0" x2="0" y2="1">
|
| 81 |
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.8}/>
|
|
|
|
| 85 |
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e5e7eb" />
|
| 86 |
<XAxis dataKey="name" axisLine={false} tickLine={false} tick={{ fill: '#6b7280' }} />
|
| 87 |
<YAxis axisLine={false} tickLine={false} tick={{ fill: '#6b7280' }} />
|
| 88 |
+
<Tooltip />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
<Area type="monotone" dataKey="avg" stroke="#3b82f6" strokeWidth={3} fillOpacity={1} fill="url(#colorAvg)" name="平均分" />
|
|
|
|
| 90 |
</AreaChart>
|
| 91 |
</ResponsiveContainer>
|
| 92 |
</div>
|
|
|
|
| 94 |
|
| 95 |
{/* Pie Chart */}
|
| 96 |
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100">
|
| 97 |
+
<h3 className="text-lg font-bold text-gray-800 mb-4">各班级分布 (示例)</h3>
|
| 98 |
<div className="h-80">
|
| 99 |
<ResponsiveContainer width="100%" height="100%">
|
| 100 |
<PieChart>
|
| 101 |
<Pie
|
| 102 |
+
data={EMPTY_DIST}
|
| 103 |
cx="50%"
|
| 104 |
cy="50%"
|
| 105 |
innerRadius={60}
|
|
|
|
| 108 |
paddingAngle={5}
|
| 109 |
dataKey="value"
|
| 110 |
>
|
| 111 |
+
<Cell fill="#e5e7eb" />
|
|
|
|
|
|
|
| 112 |
</Pie>
|
| 113 |
<Tooltip />
|
| 114 |
<Legend verticalAlign="bottom" height={36} iconType="circle" />
|
pages/Reports.tsx
CHANGED
|
@@ -1,489 +1,128 @@
|
|
| 1 |
|
| 2 |
-
import React, { useState } from 'react';
|
| 3 |
-
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip,
|
| 4 |
-
import {
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
// Mock Data & Generators
|
| 8 |
-
// ==========================================
|
| 9 |
-
|
| 10 |
-
const COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6'];
|
| 11 |
-
|
| 12 |
-
// 模拟教师列表
|
| 13 |
-
const MOCK_TEACHERS = ['王老师 (语文)', '张老师 (数学)', '李老师 (英语)', '赵老师 (科学)', '刘教练 (体育)'];
|
| 14 |
-
|
| 15 |
-
// 模拟学科列表
|
| 16 |
-
const MOCK_SUBJECTS = ['语文', '数学', '英语', '科学', '体育'];
|
| 17 |
-
|
| 18 |
-
// 1. 班级维度数据
|
| 19 |
-
const generateClassComparisonData = (grade: string, cls: string) => {
|
| 20 |
-
const seed = cls.charCodeAt(1) + grade.length;
|
| 21 |
-
const base = grade === '六年级' ? 85 : 82;
|
| 22 |
-
return [
|
| 23 |
-
{ name: '语文', classScore: base + (seed % 5), gradeAvg: base },
|
| 24 |
-
{ name: '数学', classScore: base - 2 + (seed % 4), gradeAvg: base - 1 },
|
| 25 |
-
{ name: '英语', classScore: base + 3 + (seed % 6), gradeAvg: base + 2 },
|
| 26 |
-
{ name: '科学', classScore: base + 1 + (seed % 3), gradeAvg: base - 2 },
|
| 27 |
-
];
|
| 28 |
-
};
|
| 29 |
-
|
| 30 |
-
const generateClassRadarData = (grade: string, cls: string) => {
|
| 31 |
-
return [
|
| 32 |
-
{ subject: '语文', class: 115, grade: 110, fullMark: 150 },
|
| 33 |
-
{ subject: '数学', class: 125, grade: 120, fullMark: 150 },
|
| 34 |
-
{ subject: '英语', class: 105, grade: 115, fullMark: 150 },
|
| 35 |
-
{ subject: '科学', class: 90, grade: 85, fullMark: 100 },
|
| 36 |
-
{ subject: '综合', class: 88, grade: 90, fullMark: 100 },
|
| 37 |
-
];
|
| 38 |
-
};
|
| 39 |
-
|
| 40 |
-
const generateClassDistData = (cls: string) => {
|
| 41 |
-
const isClass1 = cls.includes('1');
|
| 42 |
-
return [
|
| 43 |
-
{ name: '优秀 (90-100)', value: isClass1 ? 45 : 30 },
|
| 44 |
-
{ name: '良好 (80-89)', value: isClass1 ? 35 : 40 },
|
| 45 |
-
{ name: '及格 (60-79)', value: isClass1 ? 15 : 20 },
|
| 46 |
-
{ name: '待努力 (<60)', value: isClass1 ? 5 : 10 },
|
| 47 |
-
];
|
| 48 |
-
};
|
| 49 |
-
|
| 50 |
-
// 2. 学科维度数据 (横向对比各班级)
|
| 51 |
-
const generateSubjectTrendData = (subject: string, grade: string) => {
|
| 52 |
-
return [
|
| 53 |
-
{ name: '(1)班', score: 88, avg: 85 },
|
| 54 |
-
{ name: '(2)班', score: 84, avg: 85 },
|
| 55 |
-
{ name: '(3)班', score: 86, avg: 85 },
|
| 56 |
-
{ name: '(4)班', score: 82, avg: 85 },
|
| 57 |
-
];
|
| 58 |
-
};
|
| 59 |
-
|
| 60 |
-
// 3. 学科历史趋势数据 (新增)
|
| 61 |
-
const generateSubjectTimeTrendData = (subject: string) => {
|
| 62 |
-
// Simple deterministic random based on subject length to vary data slightly
|
| 63 |
-
const offset = subject.length * 2;
|
| 64 |
-
return [
|
| 65 |
-
{ semester: '2021-2022 上', score: 78 + (offset % 5) },
|
| 66 |
-
{ semester: '2021-2022 下', score: 80 + (offset % 4) },
|
| 67 |
-
{ semester: '2022-2023 上', score: 82 + (offset % 3) },
|
| 68 |
-
{ semester: '2022-2023 下', score: 81 + (offset % 6) },
|
| 69 |
-
{ semester: '2023-2024 上', score: 85 + (offset % 2) },
|
| 70 |
-
];
|
| 71 |
-
};
|
| 72 |
-
|
| 73 |
-
// 4. 教师维度数据
|
| 74 |
-
const generateTeacherStats = (teacher: string) => {
|
| 75 |
-
return {
|
| 76 |
-
classes: ['六年级(1)班', '六年级(2)班'],
|
| 77 |
-
avgScore: 87.5,
|
| 78 |
-
passRate: '98%',
|
| 79 |
-
studentRating: 4.8
|
| 80 |
-
};
|
| 81 |
-
};
|
| 82 |
-
|
| 83 |
-
const generateTeacherRadarData = () => {
|
| 84 |
-
return [
|
| 85 |
-
{ metric: '教学态度', value: 95, full: 100 },
|
| 86 |
-
{ metric: '课堂互动', value: 88, full: 100 },
|
| 87 |
-
{ metric: '作业批改', value: 92, full: 100 },
|
| 88 |
-
{ metric: '学生成绩', value: 85, full: 100 },
|
| 89 |
-
{ metric: '教研能力', value: 90, full: 100 },
|
| 90 |
-
];
|
| 91 |
-
};
|
| 92 |
|
| 93 |
export const Reports: React.FC = () => {
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
const [
|
| 98 |
-
const semesters = ['2023-2024学年 第一学期', '2023-2024学年 第二学期', '2022-2023学年 第二学期'];
|
| 99 |
-
|
| 100 |
-
// Dimension Mode
|
| 101 |
-
type AnalysisMode = 'CLASS' | 'SUBJECT' | 'TEACHER';
|
| 102 |
-
const [mode, setMode] = useState<AnalysisMode>('CLASS');
|
| 103 |
-
|
| 104 |
-
// Filters
|
| 105 |
-
const gradeClassMap: Record<string, string[]> = {
|
| 106 |
-
'一年级': ['(1)班', '(2)班', '(3)班'],
|
| 107 |
-
'二年级': ['(1)班', '(2)班', '(3)班'],
|
| 108 |
-
'三年级': ['(1)班', '(2)班', '(3)班', '(4)班'],
|
| 109 |
-
'四年级': ['(1)班', '(2)班', '(3)班', '(4)班'],
|
| 110 |
-
'五年级': ['(1)班', '(2)班', '(3)班', '(4)班'],
|
| 111 |
-
'六年级': ['(1)班', '(2)班', '(3)班', '(4)班'],
|
| 112 |
-
};
|
| 113 |
-
const grades = Object.keys(gradeClassMap);
|
| 114 |
-
|
| 115 |
-
const [selectedGrade, setSelectedGrade] = useState('六年级');
|
| 116 |
-
const [availableClasses, setAvailableClasses] = useState(gradeClassMap['六年级']);
|
| 117 |
-
const [selectedClass, setSelectedClass] = useState(gradeClassMap['六年级'][0]);
|
| 118 |
|
| 119 |
-
|
| 120 |
-
const [
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
const
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
<RadarChart cx="50%" cy="50%" outerRadius="75%" data={generateClassRadarData(selectedGrade, selectedClass)}>
|
| 167 |
-
<PolarGrid stroke="#e5e7eb" />
|
| 168 |
-
<PolarAngleAxis dataKey="subject" tick={{ fill: '#4b5563', fontSize: 12 }} />
|
| 169 |
-
<PolarRadiusAxis angle={30} domain={[0, 150]} tick={false} axisLine={false} />
|
| 170 |
-
<Radar name="本班" dataKey="class" stroke="#8b5cf6" fill="#8b5cf6" fillOpacity={0.5} />
|
| 171 |
-
<Radar name="年级" dataKey="grade" stroke="#9ca3af" fill="#9ca3af" fillOpacity={0.1} />
|
| 172 |
-
<Tooltip contentStyle={TOOLTIP_STYLE} />
|
| 173 |
-
</RadarChart>
|
| 174 |
-
</ResponsiveContainer>
|
| 175 |
-
</div>
|
| 176 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 177 |
</div>
|
| 178 |
|
| 179 |
-
{/* Chart Row 2 */}
|
| 180 |
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
|
|
| 181 |
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100">
|
| 182 |
-
<h3 className="text-lg font-bold text-gray-800 mb-4"
|
| 183 |
-
<div className="
|
| 184 |
-
<
|
| 185 |
-
<
|
| 186 |
-
<
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
paddingAngle={5}
|
| 194 |
-
dataKey="value"
|
| 195 |
-
>
|
| 196 |
-
{generateClassDistData(selectedClass).map((entry, index) => (
|
| 197 |
-
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
| 198 |
-
))}
|
| 199 |
-
</Pie>
|
| 200 |
-
<Tooltip contentStyle={TOOLTIP_STYLE} />
|
| 201 |
-
</PieChart>
|
| 202 |
-
</ResponsiveContainer>
|
| 203 |
-
</div>
|
| 204 |
-
<div className="w-1/2 pl-6 space-y-4">
|
| 205 |
-
{generateClassDistData(selectedClass).map((item, idx) => (
|
| 206 |
-
<div key={idx} className="flex items-center justify-between">
|
| 207 |
-
<div className="flex items-center">
|
| 208 |
-
<div className="w-3 h-3 rounded-full mr-2" style={{ backgroundColor: COLORS[idx] }}></div>
|
| 209 |
-
<span className="text-sm text-gray-600">{item.name}</span>
|
| 210 |
-
</div>
|
| 211 |
-
<span className="font-bold text-gray-800">{item.value}%</span>
|
| 212 |
-
</div>
|
| 213 |
-
))}
|
| 214 |
-
</div>
|
| 215 |
</div>
|
| 216 |
</div>
|
| 217 |
|
| 218 |
-
{/*
|
| 219 |
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100">
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
</tbody>
|
| 232 |
-
</table>
|
| 233 |
-
</div>
|
| 234 |
-
</div>
|
| 235 |
-
</>
|
| 236 |
-
);
|
| 237 |
-
|
| 238 |
-
const renderSubjectAnalysis = () => (
|
| 239 |
-
<div className="space-y-6">
|
| 240 |
-
{/* Existing Charts */}
|
| 241 |
-
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
| 242 |
-
<div className="lg:col-span-2 bg-white p-6 rounded-xl shadow-sm border border-gray-100">
|
| 243 |
-
<h3 className="text-lg font-bold text-gray-800 mb-6">{selectedGrade}各班 {selectedSubject} 成绩对比</h3>
|
| 244 |
-
<div className="h-96">
|
| 245 |
-
<ResponsiveContainer width="100%" height="100%">
|
| 246 |
-
<BarChart data={generateSubjectTrendData(selectedSubject, selectedGrade)}>
|
| 247 |
-
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f3f4f6" />
|
| 248 |
-
<XAxis dataKey="name" axisLine={false} tickLine={false} tick={{fill: '#6b7280'}} />
|
| 249 |
-
<YAxis axisLine={false} tickLine={false} tick={{fill: '#6b7280'}} />
|
| 250 |
-
<Tooltip contentStyle={TOOLTIP_STYLE} cursor={{fill: '#f9fafb'}} />
|
| 251 |
-
<Bar name="班级平均分" dataKey="score" fill="#3b82f6" radius={[6, 6, 0, 0]} barSize={40}>
|
| 252 |
-
{
|
| 253 |
-
generateSubjectTrendData(selectedSubject, selectedGrade).map((entry, index) => (
|
| 254 |
-
<Cell key={`cell-${index}`} fill={entry.score < entry.avg ? '#ef4444' : '#3b82f6'} />
|
| 255 |
-
))
|
| 256 |
-
}
|
| 257 |
-
</Bar>
|
| 258 |
-
<Line type="monotone" dataKey="avg" stroke="#10b981" strokeDasharray="5 5" name="年级平均线" />
|
| 259 |
-
<Legend />
|
| 260 |
-
</BarChart>
|
| 261 |
-
</ResponsiveContainer>
|
| 262 |
-
</div>
|
| 263 |
-
<p className="text-sm text-gray-500 mt-4 text-center">注:<span className="text-red-500">红色柱体</span>表示低于年级平均分的班级</p>
|
| 264 |
-
</div>
|
| 265 |
-
|
| 266 |
-
<div className="space-y-6">
|
| 267 |
-
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100">
|
| 268 |
-
<h3 className="text-lg font-bold text-gray-800 mb-2">学科概览</h3>
|
| 269 |
-
<div className="space-y-4 mt-4">
|
| 270 |
-
<div className="flex justify-between items-center p-3 bg-blue-50 rounded-lg">
|
| 271 |
-
<span className="text-blue-700 font-medium">年级平均分</span>
|
| 272 |
-
<span className="text-2xl font-bold text-blue-800">85.0</span>
|
| 273 |
-
</div>
|
| 274 |
-
<div className="flex justify-between items-center p-3 bg-green-50 rounded-lg">
|
| 275 |
-
<span className="text-green-700 font-medium">年级优秀率</span>
|
| 276 |
-
<span className="text-2xl font-bold text-green-800">42%</span>
|
| 277 |
-
</div>
|
| 278 |
-
<div className="flex justify-between items-center p-3 bg-red-50 rounded-lg">
|
| 279 |
-
<span className="text-red-700 font-medium">不及格人数</span>
|
| 280 |
-
<span className="text-2xl font-bold text-red-800">12人</span>
|
| 281 |
-
</div>
|
| 282 |
</div>
|
| 283 |
-
</div>
|
| 284 |
-
</div>
|
| 285 |
-
</div>
|
| 286 |
-
|
| 287 |
-
{/* New Time Trend Chart */}
|
| 288 |
-
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100">
|
| 289 |
-
<div className="flex justify-between items-center mb-6">
|
| 290 |
-
<h3 className="text-lg font-bold text-gray-800">{selectedSubject} 历史成绩趋势分析</h3>
|
| 291 |
-
<span className="text-xs px-2 py-1 bg-blue-50 text-blue-600 rounded">近5个学期数据</span>
|
| 292 |
-
</div>
|
| 293 |
-
<div className="h-80">
|
| 294 |
-
<ResponsiveContainer width="100%" height="100%">
|
| 295 |
-
<LineChart data={generateSubjectTimeTrendData(selectedSubject)}>
|
| 296 |
-
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f3f4f6" />
|
| 297 |
-
<XAxis dataKey="semester" axisLine={false} tickLine={false} tick={{fill: '#6b7280'}} />
|
| 298 |
-
<YAxis domain={[60, 100]} axisLine={false} tickLine={false} tick={{fill: '#6b7280'}} />
|
| 299 |
-
<Tooltip contentStyle={TOOLTIP_STYLE} />
|
| 300 |
-
<Line
|
| 301 |
-
type="monotone"
|
| 302 |
-
dataKey="score"
|
| 303 |
-
name="学期平均分"
|
| 304 |
-
stroke="#3b82f6"
|
| 305 |
-
strokeWidth={3}
|
| 306 |
-
activeDot={{ r: 8 }}
|
| 307 |
-
dot={{ r: 4, fill: '#fff', stroke: '#3b82f6', strokeWidth: 2 }}
|
| 308 |
-
/>
|
| 309 |
-
</LineChart>
|
| 310 |
-
</ResponsiveContainer>
|
| 311 |
</div>
|
| 312 |
</div>
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
const stats = generateTeacherStats(selectedTeacher);
|
| 318 |
-
return (
|
| 319 |
-
<div className="space-y-6">
|
| 320 |
-
{/* Teacher Stats Cards */}
|
| 321 |
-
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
| 322 |
-
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100 flex items-center space-x-4">
|
| 323 |
-
<div className="p-3 bg-blue-100 text-blue-600 rounded-full"><Users size={24} /></div>
|
| 324 |
-
<div><p className="text-sm text-gray-500">执教班级</p><p className="text-lg font-bold">{stats.classes.join(', ')}</p></div>
|
| 325 |
-
</div>
|
| 326 |
-
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100 flex items-center space-x-4">
|
| 327 |
-
<div className="p-3 bg-green-100 text-green-600 rounded-full"><Award size={24} /></div>
|
| 328 |
-
<div><p className="text-sm text-gray-500">平均分排名</p><p className="text-xl font-bold">No. 2 <span className="text-xs font-normal text-gray-400">/ 12</span></p></div>
|
| 329 |
-
</div>
|
| 330 |
-
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100 flex items-center space-x-4">
|
| 331 |
-
<div className="p-3 bg-amber-100 text-amber-600 rounded-full"><TrendingUp size={24} /></div>
|
| 332 |
-
<div><p className="text-sm text-gray-500">及格率</p><p className="text-xl font-bold">{stats.passRate}</p></div>
|
| 333 |
-
</div>
|
| 334 |
-
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100 flex items-center space-x-4">
|
| 335 |
-
<div className="p-3 bg-purple-100 text-purple-600 rounded-full"><User size={24} /></div>
|
| 336 |
-
<div><p className="text-sm text-gray-500">学生评分</p><p className="text-xl font-bold">{stats.studentRating}</p></div>
|
| 337 |
-
</div>
|
| 338 |
-
</div>
|
| 339 |
-
|
| 340 |
-
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
| 341 |
-
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100">
|
| 342 |
-
<h3 className="text-lg font-bold text-gray-800 mb-6">教师能力评估雷达</h3>
|
| 343 |
-
<div className="h-80">
|
| 344 |
-
<ResponsiveContainer width="100%" height="100%">
|
| 345 |
-
<RadarChart cx="50%" cy="50%" outerRadius="80%" data={generateTeacherRadarData()}>
|
| 346 |
-
<PolarGrid stroke="#e5e7eb" />
|
| 347 |
-
<PolarAngleAxis dataKey="metric" tick={{ fill: '#4b5563', fontSize: 13 }} />
|
| 348 |
-
<PolarRadiusAxis angle={30} domain={[0, 100]} tick={false} axisLine={false} />
|
| 349 |
-
<Radar name="教师评分" dataKey="value" stroke="#3b82f6" fill="#3b82f6" fillOpacity={0.5} />
|
| 350 |
-
<Tooltip contentStyle={TOOLTIP_STYLE} />
|
| 351 |
-
</RadarChart>
|
| 352 |
-
</ResponsiveContainer>
|
| 353 |
-
</div>
|
| 354 |
-
</div>
|
| 355 |
-
|
| 356 |
-
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100">
|
| 357 |
-
<h3 className="text-lg font-bold text-gray-800 mb-4">近期教学记录</h3>
|
| 358 |
-
<div className="space-y-4">
|
| 359 |
-
{[
|
| 360 |
-
{ date: '2023-12-10', event: '六年级(1)班 期中考试', result: '平均分 88 (年级第一)', type: 'exam' },
|
| 361 |
-
{ date: '2023-11-25', event: '公开课《圆的面积》', result: '教研组评分 9.5', type: 'activity' },
|
| 362 |
-
{ date: '2023-11-15', event: '作业检查', result: '批改率 100%, 评语详实', type: 'check' },
|
| 363 |
-
].map((item, i) => (
|
| 364 |
-
<div key={i} className="flex items-start p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors">
|
| 365 |
-
<div className="mt-1 mr-3 w-2 h-2 rounded-full bg-blue-500"></div>
|
| 366 |
-
<div>
|
| 367 |
-
<p className="text-sm font-bold text-gray-800">{item.event}</p>
|
| 368 |
-
<p className="text-xs text-gray-500 mt-1">{item.date} · {item.result}</p>
|
| 369 |
-
</div>
|
| 370 |
-
</div>
|
| 371 |
-
))}
|
| 372 |
-
</div>
|
| 373 |
-
</div>
|
| 374 |
</div>
|
| 375 |
-
|
| 376 |
-
);
|
| 377 |
-
};
|
| 378 |
-
|
| 379 |
-
return (
|
| 380 |
-
<div className="space-y-6">
|
| 381 |
-
{/* 1. Header & Global Filters */}
|
| 382 |
-
<div className="bg-white p-5 rounded-xl shadow-sm border border-gray-100">
|
| 383 |
-
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 mb-6">
|
| 384 |
-
<div>
|
| 385 |
-
<h2 className="text-xl font-bold text-gray-800">统计报表中心</h2>
|
| 386 |
-
<p className="text-sm text-gray-500 mt-1">全方位数据分析,辅助教学决策</p>
|
| 387 |
-
</div>
|
| 388 |
-
<div className="flex items-center space-x-3 bg-gray-50 p-1.5 rounded-lg border border-gray-200">
|
| 389 |
-
<Calendar size={18} className="ml-2 text-gray-400" />
|
| 390 |
-
<select
|
| 391 |
-
value={semester}
|
| 392 |
-
onChange={(e) => setSemester(e.target.value)}
|
| 393 |
-
className="bg-transparent border-none text-sm font-medium text-gray-700 focus:ring-0 cursor-pointer py-1 pr-8"
|
| 394 |
-
>
|
| 395 |
-
{semesters.map(s => <option key={s} value={s}>{s}</option>)}
|
| 396 |
-
</select>
|
| 397 |
-
</div>
|
| 398 |
-
</div>
|
| 399 |
-
|
| 400 |
-
{/* 2. Mode Switch & Secondary Filters */}
|
| 401 |
-
<div className="flex flex-col lg:flex-row justify-between items-center gap-4 border-t border-gray-100 pt-5">
|
| 402 |
-
{/* Mode Tabs */}
|
| 403 |
-
<div className="flex p-1 bg-gray-100 rounded-lg w-full lg:w-auto">
|
| 404 |
-
<button
|
| 405 |
-
onClick={() => setMode('CLASS')}
|
| 406 |
-
className={`flex-1 lg:flex-none px-6 py-2 rounded-md text-sm font-bold transition-all ${mode === 'CLASS' ? 'bg-white text-blue-600 shadow-sm' : 'text-gray-500 hover:text-gray-700'}`}
|
| 407 |
-
>
|
| 408 |
-
按班级分析
|
| 409 |
-
</button>
|
| 410 |
-
<button
|
| 411 |
-
onClick={() => setMode('SUBJECT')}
|
| 412 |
-
className={`flex-1 lg:flex-none px-6 py-2 rounded-md text-sm font-bold transition-all ${mode === 'SUBJECT' ? 'bg-white text-blue-600 shadow-sm' : 'text-gray-500 hover:text-gray-700'}`}
|
| 413 |
-
>
|
| 414 |
-
按学科分析
|
| 415 |
-
</button>
|
| 416 |
-
<button
|
| 417 |
-
onClick={() => setMode('TEACHER')}
|
| 418 |
-
className={`flex-1 lg:flex-none px-6 py-2 rounded-md text-sm font-bold transition-all ${mode === 'TEACHER' ? 'bg-white text-blue-600 shadow-sm' : 'text-gray-500 hover:text-gray-700'}`}
|
| 419 |
-
>
|
| 420 |
-
按教师分析
|
| 421 |
-
</button>
|
| 422 |
-
</div>
|
| 423 |
-
|
| 424 |
-
{/* Contextual Filters */}
|
| 425 |
-
<div className="flex items-center space-x-3 w-full lg:w-auto justify-end">
|
| 426 |
-
{mode === 'CLASS' && (
|
| 427 |
-
<>
|
| 428 |
-
<SelectBox value={selectedGrade} onChange={handleGradeChange} options={grades} />
|
| 429 |
-
<span className="text-gray-300">/</span>
|
| 430 |
-
<SelectBox value={selectedClass} onChange={(e: any) => setSelectedClass(e.target.value)} options={availableClasses} />
|
| 431 |
-
</>
|
| 432 |
-
)}
|
| 433 |
-
{mode === 'SUBJECT' && (
|
| 434 |
-
<>
|
| 435 |
-
<SelectBox value={selectedGrade} onChange={(e: any) => setSelectedGrade(e.target.value)} options={grades} />
|
| 436 |
-
<span className="text-gray-300">/</span>
|
| 437 |
-
<SelectBox value={selectedSubject} onChange={(e: any) => setSelectedSubject(e.target.value)} options={MOCK_SUBJECTS} />
|
| 438 |
-
</>
|
| 439 |
-
)}
|
| 440 |
-
{mode === 'TEACHER' && (
|
| 441 |
-
<SelectBox value={selectedTeacher} onChange={(e: any) => setSelectedTeacher(e.target.value)} options={MOCK_TEACHERS} icon={User} />
|
| 442 |
-
)}
|
| 443 |
-
|
| 444 |
-
<div className="h-6 w-px bg-gray-300 mx-2 hidden md:block"></div>
|
| 445 |
-
<IconButton icon={Printer} />
|
| 446 |
-
<IconButton icon={Download} />
|
| 447 |
-
</div>
|
| 448 |
-
</div>
|
| 449 |
-
</div>
|
| 450 |
-
|
| 451 |
-
{/* 3. Main Content Area */}
|
| 452 |
-
<div className="animate-in fade-in slide-in-from-bottom-4 duration-500">
|
| 453 |
-
{mode === 'CLASS' && renderClassAnalysis()}
|
| 454 |
-
{mode === 'SUBJECT' && renderSubjectAnalysis()}
|
| 455 |
-
{mode === 'TEACHER' && renderTeacherAnalysis()}
|
| 456 |
-
</div>
|
| 457 |
</div>
|
| 458 |
);
|
| 459 |
};
|
| 460 |
-
|
| 461 |
-
// Helpers & Styles
|
| 462 |
-
const TOOLTIP_STYLE = { borderRadius: '8px', border: 'none', boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)' };
|
| 463 |
-
|
| 464 |
-
const LegendWrapper = () => (
|
| 465 |
-
<div className="flex items-center space-x-4 text-xs">
|
| 466 |
-
<span className="flex items-center"><span className="w-3 h-3 bg-blue-500 rounded mr-1"></span> 本项数据</span>
|
| 467 |
-
<span className="flex items-center"><span className="w-3 h-3 bg-gray-300 rounded mr-1"></span> 平均参考</span>
|
| 468 |
-
</div>
|
| 469 |
-
);
|
| 470 |
-
|
| 471 |
-
const SelectBox = ({ value, onChange, options, icon: Icon }: any) => (
|
| 472 |
-
<div className="relative">
|
| 473 |
-
{Icon && <Icon className="absolute left-3 top-2.5 text-gray-500" size={16} />}
|
| 474 |
-
<select
|
| 475 |
-
value={value}
|
| 476 |
-
onChange={onChange}
|
| 477 |
-
className={`appearance-none ${Icon ? 'pl-10' : 'pl-4'} pr-10 py-2 bg-white border border-gray-200 rounded-lg text-sm font-bold text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 cursor-pointer shadow-sm min-w-[120px]`}
|
| 478 |
-
>
|
| 479 |
-
{options.map((o: string) => <option key={o} value={o}>{o}</option>)}
|
| 480 |
-
</select>
|
| 481 |
-
<ChevronDown className="absolute right-3 top-2.5 text-gray-400 pointer-events-none" size={16} />
|
| 482 |
-
</div>
|
| 483 |
-
);
|
| 484 |
-
|
| 485 |
-
const IconButton = ({ icon: Icon }: any) => (
|
| 486 |
-
<button className="p-2 text-gray-500 hover:bg-white hover:text-blue-600 hover:shadow-sm rounded transition-all bg-gray-50 border border-transparent hover:border-gray-200">
|
| 487 |
-
<Icon size={18} />
|
| 488 |
-
</button>
|
| 489 |
-
);
|
|
|
|
| 1 |
|
| 2 |
+
import React, { useState, useEffect } from 'react';
|
| 3 |
+
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
| 4 |
+
import { Calendar, Download, Loader2 } from 'lucide-react';
|
| 5 |
+
import { api } from '../services/api';
|
| 6 |
+
import { Score, Student, ClassInfo } from '../types';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
export const Reports: React.FC = () => {
|
| 9 |
+
const [loading, setLoading] = useState(true);
|
| 10 |
+
const [scores, setScores] = useState<Score[]>([]);
|
| 11 |
+
const [students, setStudents] = useState<Student[]>([]);
|
| 12 |
+
const [classes, setClasses] = useState<ClassInfo[]>([]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
+
// Computed Data for Charts
|
| 15 |
+
const [classAvgData, setClassAvgData] = useState<any[]>([]);
|
| 16 |
+
const [subjectAvgData, setSubjectAvgData] = useState<any[]>([]);
|
| 17 |
+
|
| 18 |
+
useEffect(() => {
|
| 19 |
+
const fetchData = async () => {
|
| 20 |
+
try {
|
| 21 |
+
const [scoreData, studentData, classData] = await Promise.all([
|
| 22 |
+
api.scores.getAll(),
|
| 23 |
+
api.students.getAll(),
|
| 24 |
+
api.classes.getAll()
|
| 25 |
+
]);
|
| 26 |
+
setScores(scoreData);
|
| 27 |
+
setStudents(studentData);
|
| 28 |
+
setClasses(classData);
|
| 29 |
+
|
| 30 |
+
// --- Calculate Stats ---
|
| 31 |
+
|
| 32 |
+
// 1. Class Averages
|
| 33 |
+
// Group students by class
|
| 34 |
+
const classMap: Record<string, string[]> = {}; // ClassName -> [StudentNos]
|
| 35 |
+
studentData.forEach(s => {
|
| 36 |
+
if (!classMap[s.className]) classMap[s.className] = [];
|
| 37 |
+
classMap[s.className].push(s.studentNo);
|
| 38 |
+
});
|
| 39 |
+
|
| 40 |
+
// Calculate avg score for each class (aggregated across all subjects)
|
| 41 |
+
const cData = classData.map(cls => {
|
| 42 |
+
const fullClassName = cls.grade + cls.className;
|
| 43 |
+
const studentNos = classMap[fullClassName] || [];
|
| 44 |
+
// Find scores for these students
|
| 45 |
+
const classScores = scoreData.filter(s => studentNos.includes(s.studentNo));
|
| 46 |
+
const total = classScores.reduce((sum, s) => sum + s.score, 0);
|
| 47 |
+
const avg = classScores.length > 0 ? Math.round(total / classScores.length) : 0;
|
| 48 |
+
return { name: fullClassName, avg };
|
| 49 |
+
});
|
| 50 |
+
setClassAvgData(cData);
|
| 51 |
+
|
| 52 |
+
// 2. Subject Averages
|
| 53 |
+
const subjects = Array.from(new Set(scoreData.map(s => s.courseName)));
|
| 54 |
+
const sData = subjects.map(subj => {
|
| 55 |
+
const subjScores = scoreData.filter(s => s.courseName === subj);
|
| 56 |
+
const total = subjScores.reduce((sum, s) => sum + s.score, 0);
|
| 57 |
+
const avg = subjScores.length > 0 ? Math.round(total / subjScores.length) : 0;
|
| 58 |
+
return { name: subj, avg };
|
| 59 |
+
});
|
| 60 |
+
setSubjectAvgData(sData);
|
| 61 |
+
|
| 62 |
+
} catch (e) {
|
| 63 |
+
console.error(e);
|
| 64 |
+
} finally {
|
| 65 |
+
setLoading(false);
|
| 66 |
+
}
|
| 67 |
+
};
|
| 68 |
+
fetchData();
|
| 69 |
+
}, []);
|
| 70 |
+
|
| 71 |
+
if (loading) return <div className="flex justify-center py-20"><Loader2 className="animate-spin text-blue-600" size={32} /></div>;
|
| 72 |
|
| 73 |
+
return (
|
| 74 |
+
<div className="space-y-6">
|
| 75 |
+
{/* Header */}
|
| 76 |
+
<div className="bg-white p-5 rounded-xl shadow-sm border border-gray-100 flex justify-between items-center">
|
| 77 |
+
<div>
|
| 78 |
+
<h2 className="text-xl font-bold text-gray-800">统计报表中心</h2>
|
| 79 |
+
<p className="text-sm text-gray-500 mt-1">基于 {scores.length} 条真实成绩数据生成</p>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
</div>
|
| 81 |
+
<button className="flex items-center space-x-2 px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 text-sm">
|
| 82 |
+
<Download size={16} />
|
| 83 |
+
<span>导出报表</span>
|
| 84 |
+
</button>
|
| 85 |
</div>
|
| 86 |
|
|
|
|
| 87 |
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
| 88 |
+
{/* Class Performance */}
|
| 89 |
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100">
|
| 90 |
+
<h3 className="text-lg font-bold text-gray-800 mb-4">各班级综合平均分</h3>
|
| 91 |
+
<div className="h-80">
|
| 92 |
+
<ResponsiveContainer width="100%" height="100%">
|
| 93 |
+
<BarChart data={classAvgData}>
|
| 94 |
+
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
| 95 |
+
<XAxis dataKey="name" tick={{ fontSize: 12 }} interval={0} />
|
| 96 |
+
<YAxis domain={[0, 100]} />
|
| 97 |
+
<Tooltip cursor={{ fill: '#f3f4f6' }} />
|
| 98 |
+
<Bar dataKey="avg" name="平均分" fill="#3b82f6" radius={[4, 4, 0, 0]} barSize={40} />
|
| 99 |
+
</BarChart>
|
| 100 |
+
</ResponsiveContainer>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
</div>
|
| 102 |
</div>
|
| 103 |
|
| 104 |
+
{/* Subject Performance */}
|
| 105 |
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100">
|
| 106 |
+
<h3 className="text-lg font-bold text-gray-800 mb-4">全校学科平均分概览</h3>
|
| 107 |
+
<div className="h-80">
|
| 108 |
+
<ResponsiveContainer width="100%" height="100%">
|
| 109 |
+
<BarChart data={subjectAvgData}>
|
| 110 |
+
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
| 111 |
+
<XAxis dataKey="name" />
|
| 112 |
+
<YAxis domain={[0, 100]} />
|
| 113 |
+
<Tooltip cursor={{ fill: '#f3f4f6' }} />
|
| 114 |
+
<Bar dataKey="avg" name="平均分" fill="#10b981" radius={[4, 4, 0, 0]} barSize={40} />
|
| 115 |
+
</BarChart>
|
| 116 |
+
</ResponsiveContainer>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
</div>
|
| 119 |
</div>
|
| 120 |
+
|
| 121 |
+
{scores.length === 0 && (
|
| 122 |
+
<div className="text-center py-10 text-gray-400 bg-gray-50 rounded-lg border border-dashed border-gray-300">
|
| 123 |
+
暂无足够的成绩数据来生成详细图表,请先录入成绩。
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
</div>
|
| 125 |
+
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
</div>
|
| 127 |
);
|
| 128 |
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
pages/Settings.tsx
CHANGED
|
@@ -1,9 +1,12 @@
|
|
| 1 |
|
| 2 |
-
import React, { useState } from 'react';
|
| 3 |
import { Save, Bell, Lock, Database, Loader2, Plus, X } from 'lucide-react';
|
|
|
|
| 4 |
|
| 5 |
export const Settings: React.FC = () => {
|
| 6 |
const [loading, setLoading] = useState(false);
|
|
|
|
|
|
|
| 7 |
const [config, setConfig] = useState({
|
| 8 |
systemName: '智慧校园管理系统',
|
| 9 |
allowRegister: true,
|
|
@@ -12,7 +15,6 @@ export const Settings: React.FC = () => {
|
|
| 12 |
semester: '2023-2024学年 第一学期'
|
| 13 |
});
|
| 14 |
|
| 15 |
-
// 学期管理状态
|
| 16 |
const [semesters, setSemesters] = useState([
|
| 17 |
'2023-2024学年 第一学期',
|
| 18 |
'2023-2024学年 第二学期',
|
|
@@ -21,13 +23,33 @@ export const Settings: React.FC = () => {
|
|
| 21 |
const [isAddingSemester, setIsAddingSemester] = useState(false);
|
| 22 |
const [newSemesterVal, setNewSemesterVal] = useState('');
|
| 23 |
|
| 24 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
setLoading(true);
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
setLoading(false);
|
| 29 |
alert('系统配置已保存!');
|
| 30 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
};
|
| 32 |
|
| 33 |
const handleAddSemester = () => {
|
|
@@ -42,6 +64,8 @@ export const Settings: React.FC = () => {
|
|
| 42 |
}
|
| 43 |
};
|
| 44 |
|
|
|
|
|
|
|
| 45 |
return (
|
| 46 |
<div className="space-y-6">
|
| 47 |
{/* General Settings */}
|
|
|
|
| 1 |
|
| 2 |
+
import React, { useState, useEffect } from 'react';
|
| 3 |
import { Save, Bell, Lock, Database, Loader2, Plus, X } from 'lucide-react';
|
| 4 |
+
import { api } from '../services/api';
|
| 5 |
|
| 6 |
export const Settings: React.FC = () => {
|
| 7 |
const [loading, setLoading] = useState(false);
|
| 8 |
+
const [fetching, setFetching] = useState(true);
|
| 9 |
+
|
| 10 |
const [config, setConfig] = useState({
|
| 11 |
systemName: '智慧校园管理系统',
|
| 12 |
allowRegister: true,
|
|
|
|
| 15 |
semester: '2023-2024学年 第一学期'
|
| 16 |
});
|
| 17 |
|
|
|
|
| 18 |
const [semesters, setSemesters] = useState([
|
| 19 |
'2023-2024学年 第一学期',
|
| 20 |
'2023-2024学年 第二学期',
|
|
|
|
| 23 |
const [isAddingSemester, setIsAddingSemester] = useState(false);
|
| 24 |
const [newSemesterVal, setNewSemesterVal] = useState('');
|
| 25 |
|
| 26 |
+
useEffect(() => {
|
| 27 |
+
const loadConfig = async () => {
|
| 28 |
+
try {
|
| 29 |
+
const data = await api.config.get();
|
| 30 |
+
if (data && data.systemName) {
|
| 31 |
+
// Merge with defaults to ensure all fields exist
|
| 32 |
+
setConfig(prev => ({ ...prev, ...data }));
|
| 33 |
+
}
|
| 34 |
+
} catch (e) {
|
| 35 |
+
console.error(e);
|
| 36 |
+
} finally {
|
| 37 |
+
setFetching(false);
|
| 38 |
+
}
|
| 39 |
+
};
|
| 40 |
+
loadConfig();
|
| 41 |
+
}, []);
|
| 42 |
+
|
| 43 |
+
const handleSave = async () => {
|
| 44 |
setLoading(true);
|
| 45 |
+
try {
|
| 46 |
+
await api.config.save(config);
|
|
|
|
| 47 |
alert('系统配置已保存!');
|
| 48 |
+
} catch (e) {
|
| 49 |
+
alert('保存失败');
|
| 50 |
+
} finally {
|
| 51 |
+
setLoading(false);
|
| 52 |
+
}
|
| 53 |
};
|
| 54 |
|
| 55 |
const handleAddSemester = () => {
|
|
|
|
| 64 |
}
|
| 65 |
};
|
| 66 |
|
| 67 |
+
if (fetching) return <div className="p-10 text-center text-gray-500">加载配置中...</div>;
|
| 68 |
+
|
| 69 |
return (
|
| 70 |
<div className="space-y-6">
|
| 71 |
{/* General Settings */}
|
pages/StudentList.tsx
CHANGED
|
@@ -1,20 +1,25 @@
|
|
| 1 |
|
| 2 |
import React, { useState, useEffect } from 'react';
|
| 3 |
-
import { Search, Plus, Download, Edit, Trash2, X, Loader2, User } from 'lucide-react';
|
| 4 |
import { api } from '../services/api';
|
| 5 |
-
import { Student } from '../types';
|
| 6 |
|
| 7 |
export const StudentList: React.FC = () => {
|
| 8 |
const [searchTerm, setSearchTerm] = useState('');
|
| 9 |
const [students, setStudents] = useState<Student[]>([]);
|
|
|
|
| 10 |
const [loading, setLoading] = useState(true);
|
| 11 |
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
|
|
| 12 |
const [submitting, setSubmitting] = useState(false);
|
| 13 |
|
| 14 |
// Filters
|
| 15 |
const [selectedGrade, setSelectedGrade] = useState('All');
|
| 16 |
const [selectedClass, setSelectedClass] = useState('All');
|
| 17 |
|
|
|
|
|
|
|
|
|
|
| 18 |
// Form State
|
| 19 |
const [formData, setFormData] = useState({
|
| 20 |
name: '',
|
|
@@ -28,8 +33,12 @@ export const StudentList: React.FC = () => {
|
|
| 28 |
const loadData = async () => {
|
| 29 |
setLoading(true);
|
| 30 |
try {
|
| 31 |
-
const
|
| 32 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
} catch (error) {
|
| 34 |
console.error('Failed to load students', error);
|
| 35 |
} finally {
|
|
@@ -69,9 +78,43 @@ export const StudentList: React.FC = () => {
|
|
| 69 |
}
|
| 70 |
};
|
| 71 |
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
|
| 76 |
const filteredStudents = students.filter((s) => {
|
| 77 |
const matchesSearch =
|
|
@@ -115,9 +158,12 @@ export const StudentList: React.FC = () => {
|
|
| 115 |
<p className="text-sm text-gray-500">共找到 {filteredStudents.length} 名学生</p>
|
| 116 |
</div>
|
| 117 |
<div className="flex space-x-3">
|
| 118 |
-
<button
|
| 119 |
-
|
| 120 |
-
|
|
|
|
|
|
|
|
|
|
| 121 |
</button>
|
| 122 |
<button
|
| 123 |
onClick={() => setIsModalOpen(true)}
|
|
@@ -150,7 +196,7 @@ export const StudentList: React.FC = () => {
|
|
| 150 |
onChange={(e) => setSelectedGrade(e.target.value)}
|
| 151 |
>
|
| 152 |
<option value="All">所有年级</option>
|
| 153 |
-
{
|
| 154 |
</select>
|
| 155 |
</div>
|
| 156 |
<div className="w-40">
|
|
@@ -160,7 +206,7 @@ export const StudentList: React.FC = () => {
|
|
| 160 |
onChange={(e) => setSelectedClass(e.target.value)}
|
| 161 |
>
|
| 162 |
<option value="All">所有班级</option>
|
| 163 |
-
{
|
| 164 |
</select>
|
| 165 |
</div>
|
| 166 |
</div>
|
|
@@ -236,6 +282,33 @@ export const StudentList: React.FC = () => {
|
|
| 236 |
</table>
|
| 237 |
</div>
|
| 238 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 239 |
{/* Add Student Modal */}
|
| 240 |
{isModalOpen && (
|
| 241 |
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 backdrop-blur-sm">
|
|
@@ -291,10 +364,8 @@ export const StudentList: React.FC = () => {
|
|
| 291 |
value={formData.className} onChange={e => setFormData({...formData, className: e.target.value})} required
|
| 292 |
>
|
| 293 |
<option value="">-- 请选择班级 --</option>
|
| 294 |
-
{
|
| 295 |
-
|
| 296 |
-
<option key={`${g}${c}`} value={`${g}${c}`}>{g}{c}</option>
|
| 297 |
-
))
|
| 298 |
))}
|
| 299 |
</select>
|
| 300 |
</div>
|
|
|
|
| 1 |
|
| 2 |
import React, { useState, useEffect } from 'react';
|
| 3 |
+
import { Search, Plus, Download, Edit, Trash2, X, Loader2, User, Upload } from 'lucide-react';
|
| 4 |
import { api } from '../services/api';
|
| 5 |
+
import { Student, ClassInfo } from '../types';
|
| 6 |
|
| 7 |
export const StudentList: React.FC = () => {
|
| 8 |
const [searchTerm, setSearchTerm] = useState('');
|
| 9 |
const [students, setStudents] = useState<Student[]>([]);
|
| 10 |
+
const [classList, setClassList] = useState<ClassInfo[]>([]);
|
| 11 |
const [loading, setLoading] = useState(true);
|
| 12 |
const [isModalOpen, setIsModalOpen] = useState(false);
|
| 13 |
+
const [isImportOpen, setIsImportOpen] = useState(false);
|
| 14 |
const [submitting, setSubmitting] = useState(false);
|
| 15 |
|
| 16 |
// Filters
|
| 17 |
const [selectedGrade, setSelectedGrade] = useState('All');
|
| 18 |
const [selectedClass, setSelectedClass] = useState('All');
|
| 19 |
|
| 20 |
+
// Import Data
|
| 21 |
+
const [importText, setImportText] = useState('');
|
| 22 |
+
|
| 23 |
// Form State
|
| 24 |
const [formData, setFormData] = useState({
|
| 25 |
name: '',
|
|
|
|
| 33 |
const loadData = async () => {
|
| 34 |
setLoading(true);
|
| 35 |
try {
|
| 36 |
+
const [studentData, classData] = await Promise.all([
|
| 37 |
+
api.students.getAll(),
|
| 38 |
+
api.classes.getAll()
|
| 39 |
+
]);
|
| 40 |
+
setStudents(studentData);
|
| 41 |
+
setClassList(classData);
|
| 42 |
} catch (error) {
|
| 43 |
console.error('Failed to load students', error);
|
| 44 |
} finally {
|
|
|
|
| 78 |
}
|
| 79 |
};
|
| 80 |
|
| 81 |
+
const handleImport = async () => {
|
| 82 |
+
if (!importText.trim()) return;
|
| 83 |
+
setSubmitting(true);
|
| 84 |
+
// Parse CSV-like text: Name, StudentNo, Gender, Class, Phone
|
| 85 |
+
const lines = importText.trim().split('\n');
|
| 86 |
+
let successCount = 0;
|
| 87 |
+
|
| 88 |
+
for (const line of lines) {
|
| 89 |
+
const parts = line.split(/[,\t\s]+/).filter(Boolean);
|
| 90 |
+
if (parts.length >= 3) {
|
| 91 |
+
try {
|
| 92 |
+
// Simple mapping assumption: Name, No, Gender, ClassName(Full), Phone
|
| 93 |
+
const [name, studentNo, genderRaw, className, phone] = parts;
|
| 94 |
+
await api.students.add({
|
| 95 |
+
name,
|
| 96 |
+
studentNo,
|
| 97 |
+
gender: genderRaw === '女' ? 'Female' : 'Male',
|
| 98 |
+
className: className || '未分配',
|
| 99 |
+
phone: phone || '无',
|
| 100 |
+
birthday: '2015-01-01',
|
| 101 |
+
status: 'Enrolled'
|
| 102 |
+
});
|
| 103 |
+
successCount++;
|
| 104 |
+
} catch (e) { console.error('Row failed', line); }
|
| 105 |
+
}
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
alert(`成功导入 ${successCount} 条数据`);
|
| 109 |
+
setIsImportOpen(false);
|
| 110 |
+
setImportText('');
|
| 111 |
+
setSubmitting(false);
|
| 112 |
+
loadData();
|
| 113 |
+
};
|
| 114 |
+
|
| 115 |
+
// Build Filter Options from Real Classes
|
| 116 |
+
const uniqueGrades = Array.from(new Set(classList.map(c => c.grade)));
|
| 117 |
+
const uniqueClasses = Array.from(new Set(classList.map(c => c.className)));
|
| 118 |
|
| 119 |
const filteredStudents = students.filter((s) => {
|
| 120 |
const matchesSearch =
|
|
|
|
| 158 |
<p className="text-sm text-gray-500">共找到 {filteredStudents.length} 名学生</p>
|
| 159 |
</div>
|
| 160 |
<div className="flex space-x-3">
|
| 161 |
+
<button
|
| 162 |
+
onClick={() => setIsImportOpen(true)}
|
| 163 |
+
className="flex items-center justify-center space-x-2 px-4 py-2 border border-gray-200 rounded-lg text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
|
| 164 |
+
>
|
| 165 |
+
<Upload size={16} />
|
| 166 |
+
<span>批量导入</span>
|
| 167 |
</button>
|
| 168 |
<button
|
| 169 |
onClick={() => setIsModalOpen(true)}
|
|
|
|
| 196 |
onChange={(e) => setSelectedGrade(e.target.value)}
|
| 197 |
>
|
| 198 |
<option value="All">所有年级</option>
|
| 199 |
+
{uniqueGrades.map(g => <option key={g} value={g}>{g}</option>)}
|
| 200 |
</select>
|
| 201 |
</div>
|
| 202 |
<div className="w-40">
|
|
|
|
| 206 |
onChange={(e) => setSelectedClass(e.target.value)}
|
| 207 |
>
|
| 208 |
<option value="All">所有班级</option>
|
| 209 |
+
{uniqueClasses.map(c => <option key={c} value={c}>{c}</option>)}
|
| 210 |
</select>
|
| 211 |
</div>
|
| 212 |
</div>
|
|
|
|
| 282 |
</table>
|
| 283 |
</div>
|
| 284 |
|
| 285 |
+
{/* Import Modal */}
|
| 286 |
+
{isImportOpen && (
|
| 287 |
+
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
| 288 |
+
<div className="bg-white rounded-xl shadow-2xl max-w-lg w-full p-6">
|
| 289 |
+
<h3 className="font-bold text-gray-800 text-lg mb-4">批量导入学生</h3>
|
| 290 |
+
<p className="text-sm text-gray-500 mb-2">请按以下格式粘贴文本 (空格分隔):</p>
|
| 291 |
+
<div className="bg-gray-50 p-2 text-xs font-mono text-gray-600 mb-4 rounded border">
|
| 292 |
+
姓名 学号 性别 班级全称 手机号<br/>
|
| 293 |
+
张三 202401 男 六年级(1)班 13800000000<br/>
|
| 294 |
+
李四 202402 女 六年级(2)班 13900000000
|
| 295 |
+
</div>
|
| 296 |
+
<textarea
|
| 297 |
+
className="w-full h-48 border rounded-lg p-3 text-sm font-mono focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
| 298 |
+
placeholder="请粘贴内容..."
|
| 299 |
+
value={importText}
|
| 300 |
+
onChange={e => setImportText(e.target.value)}
|
| 301 |
+
/>
|
| 302 |
+
<div className="flex space-x-3 pt-4">
|
| 303 |
+
<button onClick={() => setIsImportOpen(false)} className="flex-1 py-2 border rounded-lg">取消</button>
|
| 304 |
+
<button onClick={handleImport} disabled={submitting} className="flex-1 py-2 bg-blue-600 text-white rounded-lg">
|
| 305 |
+
{submitting ? '导入中...' : '开始导入'}
|
| 306 |
+
</button>
|
| 307 |
+
</div>
|
| 308 |
+
</div>
|
| 309 |
+
</div>
|
| 310 |
+
)}
|
| 311 |
+
|
| 312 |
{/* Add Student Modal */}
|
| 313 |
{isModalOpen && (
|
| 314 |
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 backdrop-blur-sm">
|
|
|
|
| 364 |
value={formData.className} onChange={e => setFormData({...formData, className: e.target.value})} required
|
| 365 |
>
|
| 366 |
<option value="">-- 请选择班级 --</option>
|
| 367 |
+
{classList.map(cls => (
|
| 368 |
+
<option key={cls._id || cls.id} value={`${cls.grade}${cls.className}`}>{cls.grade}{cls.className}</option>
|
|
|
|
|
|
|
| 369 |
))}
|
| 370 |
</select>
|
| 371 |
</div>
|
server.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
|
|
| 1 |
const express = require('express');
|
| 2 |
const mongoose = require('mongoose');
|
| 3 |
const cors = require('cors');
|
|
@@ -33,6 +34,14 @@ const InMemoryDB = {
|
|
| 33 |
students: [],
|
| 34 |
courses: [],
|
| 35 |
scores: [],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
isFallback: false
|
| 37 |
};
|
| 38 |
|
|
@@ -102,6 +111,24 @@ const ScoreSchema = new mongoose.Schema({
|
|
| 102 |
});
|
| 103 |
const Score = mongoose.model('Score', ScoreSchema);
|
| 104 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
// 初始化数据逻辑 (仅 MongoDB 模式)
|
| 106 |
const initData = async () => {
|
| 107 |
if (InMemoryDB.isFallback) return;
|
|
@@ -113,8 +140,32 @@ const initData = async () => {
|
|
| 113 |
{ username: 'admin', password: 'admin', role: 'ADMIN', email: 'admin@school.edu' },
|
| 114 |
{ username: 'teacher', password: 'teacher', role: 'TEACHER', email: 'teacher@school.edu' }
|
| 115 |
]);
|
| 116 |
-
console.log('✅ 数据初始化完成');
|
| 117 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
} catch (err) {
|
| 119 |
console.error('初始化数据失败', err);
|
| 120 |
}
|
|
@@ -188,6 +239,57 @@ app.delete('/api/students/:id', async (req, res) => {
|
|
| 188 |
} catch (e) { res.status(500).json({ error: e.message }); }
|
| 189 |
});
|
| 190 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 191 |
// --- Courses ---
|
| 192 |
app.get('/api/courses', async (req, res) => {
|
| 193 |
if (InMemoryDB.isFallback) return res.json(InMemoryDB.courses);
|
|
@@ -219,6 +321,16 @@ app.put('/api/courses/:id', async (req, res) => {
|
|
| 219 |
res.json(updated);
|
| 220 |
} catch (e) { res.status(400).json({ error: e.message }); }
|
| 221 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 222 |
|
| 223 |
// --- Scores ---
|
| 224 |
app.get('/api/scores', async (req, res) => {
|
|
@@ -256,13 +368,21 @@ app.get('/api/stats', async (req, res) => {
|
|
| 256 |
const courseCount = InMemoryDB.courses.length;
|
| 257 |
const scores = InMemoryDB.scores;
|
| 258 |
const avgScore = scores.length > 0 ? (scores.reduce((a, b) => a + b.score, 0) / scores.length).toFixed(1) : 0;
|
| 259 |
-
return res.json({ studentCount, courseCount, avgScore, excellentRate: '
|
| 260 |
}
|
| 261 |
const studentCount = await Student.countDocuments();
|
| 262 |
const courseCount = await Course.countDocuments();
|
| 263 |
const scores = await Score.find();
|
| 264 |
-
|
| 265 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 266 |
} catch (e) { res.status(500).json({ error: e.message }); }
|
| 267 |
});
|
| 268 |
|
|
@@ -277,4 +397,4 @@ app.get('*', (req, res) => {
|
|
| 277 |
// 启动服务器
|
| 278 |
app.listen(PORT, () => {
|
| 279 |
console.log(`🚀 服务已启动,监听端口: ${PORT}`);
|
| 280 |
-
});
|
|
|
|
| 1 |
+
|
| 2 |
const express = require('express');
|
| 3 |
const mongoose = require('mongoose');
|
| 4 |
const cors = require('cors');
|
|
|
|
| 34 |
students: [],
|
| 35 |
courses: [],
|
| 36 |
scores: [],
|
| 37 |
+
classes: [],
|
| 38 |
+
config: {
|
| 39 |
+
systemName: '智慧校园管理系统',
|
| 40 |
+
semester: '2023-2024学年 第一学期',
|
| 41 |
+
allowRegister: true,
|
| 42 |
+
maintenanceMode: false,
|
| 43 |
+
emailNotify: true
|
| 44 |
+
},
|
| 45 |
isFallback: false
|
| 46 |
};
|
| 47 |
|
|
|
|
| 111 |
});
|
| 112 |
const Score = mongoose.model('Score', ScoreSchema);
|
| 113 |
|
| 114 |
+
const ClassSchema = new mongoose.Schema({
|
| 115 |
+
grade: String,
|
| 116 |
+
className: String,
|
| 117 |
+
teacherName: String
|
| 118 |
+
});
|
| 119 |
+
const ClassModel = mongoose.model('Class', ClassSchema);
|
| 120 |
+
|
| 121 |
+
const ConfigSchema = new mongoose.Schema({
|
| 122 |
+
key: { type: String, default: 'main' }, // Singleton
|
| 123 |
+
systemName: String,
|
| 124 |
+
semester: String,
|
| 125 |
+
allowRegister: Boolean,
|
| 126 |
+
maintenanceMode: Boolean,
|
| 127 |
+
emailNotify: Boolean
|
| 128 |
+
});
|
| 129 |
+
const ConfigModel = mongoose.model('Config', ConfigSchema);
|
| 130 |
+
|
| 131 |
+
|
| 132 |
// 初始化数据逻辑 (仅 MongoDB 模式)
|
| 133 |
const initData = async () => {
|
| 134 |
if (InMemoryDB.isFallback) return;
|
|
|
|
| 140 |
{ username: 'admin', password: 'admin', role: 'ADMIN', email: 'admin@school.edu' },
|
| 141 |
{ username: 'teacher', password: 'teacher', role: 'TEACHER', email: 'teacher@school.edu' }
|
| 142 |
]);
|
|
|
|
| 143 |
}
|
| 144 |
+
|
| 145 |
+
// 初始化配置
|
| 146 |
+
const config = await ConfigModel.findOne({ key: 'main' });
|
| 147 |
+
if (!config) {
|
| 148 |
+
await ConfigModel.create({
|
| 149 |
+
key: 'main',
|
| 150 |
+
systemName: '智慧校园管理系统',
|
| 151 |
+
semester: '2023-2024学年 第一学期',
|
| 152 |
+
allowRegister: true,
|
| 153 |
+
maintenanceMode: false,
|
| 154 |
+
emailNotify: true
|
| 155 |
+
});
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
// 初始化班级 (如果为空)
|
| 159 |
+
const classCount = await ClassModel.countDocuments();
|
| 160 |
+
if (classCount === 0) {
|
| 161 |
+
await ClassModel.create([
|
| 162 |
+
{ grade: '六年级', className: '(1)班', teacherName: '王老师' },
|
| 163 |
+
{ grade: '六年级', className: '(2)班', teacherName: '李老师' },
|
| 164 |
+
{ grade: '五年级', className: '(1)班', teacherName: '张老师' }
|
| 165 |
+
]);
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
console.log('✅ 数据初始化完成');
|
| 169 |
} catch (err) {
|
| 170 |
console.error('初始化数据失败', err);
|
| 171 |
}
|
|
|
|
| 239 |
} catch (e) { res.status(500).json({ error: e.message }); }
|
| 240 |
});
|
| 241 |
|
| 242 |
+
// --- Classes ---
|
| 243 |
+
app.get('/api/classes', async (req, res) => {
|
| 244 |
+
if (InMemoryDB.isFallback) return res.json(InMemoryDB.classes);
|
| 245 |
+
const classes = await ClassModel.find().sort({ grade: 1, className: 1 });
|
| 246 |
+
// Add student count
|
| 247 |
+
const result = await Promise.all(classes.map(async (cls) => {
|
| 248 |
+
const count = await Student.countDocuments({ className: cls.grade + cls.className });
|
| 249 |
+
return { ...cls.toObject(), studentCount: count };
|
| 250 |
+
}));
|
| 251 |
+
res.json(result);
|
| 252 |
+
});
|
| 253 |
+
app.post('/api/classes', async (req, res) => {
|
| 254 |
+
try {
|
| 255 |
+
if (InMemoryDB.isFallback) {
|
| 256 |
+
const newClass = { ...req.body, id: Date.now(), _id: Date.now().toString() };
|
| 257 |
+
InMemoryDB.classes.push(newClass);
|
| 258 |
+
return res.json(newClass);
|
| 259 |
+
}
|
| 260 |
+
const newClass = await ClassModel.create(req.body);
|
| 261 |
+
res.json(newClass);
|
| 262 |
+
} catch (e) { res.status(400).json({ error: e.message }); }
|
| 263 |
+
});
|
| 264 |
+
app.delete('/api/classes/:id', async (req, res) => {
|
| 265 |
+
try {
|
| 266 |
+
if (InMemoryDB.isFallback) {
|
| 267 |
+
InMemoryDB.classes = InMemoryDB.classes.filter(c => c._id !== req.params.id);
|
| 268 |
+
return res.json({ success: true });
|
| 269 |
+
}
|
| 270 |
+
await ClassModel.findByIdAndDelete(req.params.id);
|
| 271 |
+
res.json({ success: true });
|
| 272 |
+
} catch (e) { res.status(500).json({ error: e.message }); }
|
| 273 |
+
});
|
| 274 |
+
|
| 275 |
+
// --- Config ---
|
| 276 |
+
app.get('/api/config', async (req, res) => {
|
| 277 |
+
if (InMemoryDB.isFallback) return res.json(InMemoryDB.config);
|
| 278 |
+
let config = await ConfigModel.findOne({ key: 'main' });
|
| 279 |
+
if (!config) config = {};
|
| 280 |
+
res.json(config);
|
| 281 |
+
});
|
| 282 |
+
app.post('/api/config', async (req, res) => {
|
| 283 |
+
try {
|
| 284 |
+
if (InMemoryDB.isFallback) {
|
| 285 |
+
InMemoryDB.config = { ...InMemoryDB.config, ...req.body };
|
| 286 |
+
return res.json(InMemoryDB.config);
|
| 287 |
+
}
|
| 288 |
+
const config = await ConfigModel.findOneAndUpdate({ key: 'main' }, req.body, { new: true, upsert: true });
|
| 289 |
+
res.json(config);
|
| 290 |
+
} catch (e) { res.status(400).json({ error: e.message }); }
|
| 291 |
+
});
|
| 292 |
+
|
| 293 |
// --- Courses ---
|
| 294 |
app.get('/api/courses', async (req, res) => {
|
| 295 |
if (InMemoryDB.isFallback) return res.json(InMemoryDB.courses);
|
|
|
|
| 321 |
res.json(updated);
|
| 322 |
} catch (e) { res.status(400).json({ error: e.message }); }
|
| 323 |
});
|
| 324 |
+
app.delete('/api/courses/:id', async (req, res) => {
|
| 325 |
+
try {
|
| 326 |
+
if (InMemoryDB.isFallback) {
|
| 327 |
+
InMemoryDB.courses = InMemoryDB.courses.filter(c => c._id !== req.params.id && c.id != req.params.id);
|
| 328 |
+
return res.json({ success: true });
|
| 329 |
+
}
|
| 330 |
+
await Course.findByIdAndDelete(req.params.id);
|
| 331 |
+
res.json({ success: true });
|
| 332 |
+
} catch (e) { res.status(500).json({ error: e.message }); }
|
| 333 |
+
});
|
| 334 |
|
| 335 |
// --- Scores ---
|
| 336 |
app.get('/api/scores', async (req, res) => {
|
|
|
|
| 368 |
const courseCount = InMemoryDB.courses.length;
|
| 369 |
const scores = InMemoryDB.scores;
|
| 370 |
const avgScore = scores.length > 0 ? (scores.reduce((a, b) => a + b.score, 0) / scores.length).toFixed(1) : 0;
|
| 371 |
+
return res.json({ studentCount, courseCount, avgScore, excellentRate: '0%' });
|
| 372 |
}
|
| 373 |
const studentCount = await Student.countDocuments();
|
| 374 |
const courseCount = await Course.countDocuments();
|
| 375 |
const scores = await Score.find();
|
| 376 |
+
|
| 377 |
+
// Calculate average
|
| 378 |
+
const totalScore = scores.reduce((sum, s) => sum + (s.score || 0), 0);
|
| 379 |
+
const avgScore = scores.length > 0 ? (totalScore / scores.length).toFixed(1) : 0;
|
| 380 |
+
|
| 381 |
+
// Calculate excellent rate (>90)
|
| 382 |
+
const excellentCount = scores.filter(s => s.score >= 90).length;
|
| 383 |
+
const excellentRate = scores.length > 0 ? Math.round((excellentCount / scores.length) * 100) + '%' : '0%';
|
| 384 |
+
|
| 385 |
+
res.json({ studentCount, courseCount, avgScore, excellentRate });
|
| 386 |
} catch (e) { res.status(500).json({ error: e.message }); }
|
| 387 |
});
|
| 388 |
|
|
|
|
| 397 |
// 启动服务器
|
| 398 |
app.listen(PORT, () => {
|
| 399 |
console.log(`🚀 服务已启动,监听端口: ${PORT}`);
|
| 400 |
+
});
|
services/api.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
|
| 2 |
/// <reference types="vite/client" />
|
| 3 |
-
import { User } from '../types';
|
| 4 |
|
| 5 |
// ==========================================
|
| 6 |
// 智能环境配置 (Smart Environment Config)
|
|
@@ -11,7 +11,6 @@ const getBaseUrl = () => {
|
|
| 11 |
|
| 12 |
try {
|
| 13 |
// Safely check for Vite environment variable with try-catch
|
| 14 |
-
// This prevents "Cannot read properties of undefined" if import.meta is not supported
|
| 15 |
// @ts-ignore
|
| 16 |
if (typeof import.meta !== 'undefined' && import.meta.env && import.meta.env.PROD) {
|
| 17 |
isProd = true;
|
|
@@ -25,7 +24,6 @@ const getBaseUrl = () => {
|
|
| 25 |
}
|
| 26 |
|
| 27 |
// Runtime check for Hugging Face or Production Port 7860
|
| 28 |
-
// If the page is currently being served from port 7860, we are in "Production" mode
|
| 29 |
if (typeof window !== 'undefined' && window.location.port === '7860') {
|
| 30 |
return '/api';
|
| 31 |
}
|
|
@@ -59,10 +57,15 @@ export const api = {
|
|
| 59 |
auth: {
|
| 60 |
login: async (username: string, password: string): Promise<User> => {
|
| 61 |
try {
|
| 62 |
-
|
| 63 |
method: 'POST',
|
| 64 |
body: JSON.stringify({ username, password })
|
| 65 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
} catch (err: any) {
|
| 67 |
throw new Error('用户名或密码错误');
|
| 68 |
}
|
|
@@ -72,6 +75,18 @@ export const api = {
|
|
| 72 |
method: 'POST',
|
| 73 |
body: JSON.stringify(data)
|
| 74 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
}
|
| 76 |
},
|
| 77 |
|
|
@@ -81,10 +96,17 @@ export const api = {
|
|
| 81 |
delete: (id: string | number) => request(`/students/${id}`, { method: 'DELETE' })
|
| 82 |
},
|
| 83 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
courses: {
|
| 85 |
getAll: () => request('/courses'),
|
| 86 |
add: (data: any) => request('/courses', { method: 'POST', body: JSON.stringify(data) }),
|
| 87 |
-
update: (id: string | number, data: any) => request(`/courses/${id}`, { method: 'PUT', body: JSON.stringify(data) })
|
|
|
|
| 88 |
},
|
| 89 |
|
| 90 |
scores: {
|
|
@@ -95,5 +117,10 @@ export const api = {
|
|
| 95 |
|
| 96 |
stats: {
|
| 97 |
getSummary: () => request('/stats')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
}
|
| 99 |
};
|
|
|
|
| 1 |
|
| 2 |
/// <reference types="vite/client" />
|
| 3 |
+
import { User, ClassInfo, SystemConfig } from '../types';
|
| 4 |
|
| 5 |
// ==========================================
|
| 6 |
// 智能环境配置 (Smart Environment Config)
|
|
|
|
| 11 |
|
| 12 |
try {
|
| 13 |
// Safely check for Vite environment variable with try-catch
|
|
|
|
| 14 |
// @ts-ignore
|
| 15 |
if (typeof import.meta !== 'undefined' && import.meta.env && import.meta.env.PROD) {
|
| 16 |
isProd = true;
|
|
|
|
| 24 |
}
|
| 25 |
|
| 26 |
// Runtime check for Hugging Face or Production Port 7860
|
|
|
|
| 27 |
if (typeof window !== 'undefined' && window.location.port === '7860') {
|
| 28 |
return '/api';
|
| 29 |
}
|
|
|
|
| 57 |
auth: {
|
| 58 |
login: async (username: string, password: string): Promise<User> => {
|
| 59 |
try {
|
| 60 |
+
const user = await request('/auth/login', {
|
| 61 |
method: 'POST',
|
| 62 |
body: JSON.stringify({ username, password })
|
| 63 |
});
|
| 64 |
+
// Save to LocalStorage for persistence
|
| 65 |
+
if (typeof window !== 'undefined') {
|
| 66 |
+
localStorage.setItem('user', JSON.stringify(user));
|
| 67 |
+
}
|
| 68 |
+
return user;
|
| 69 |
} catch (err: any) {
|
| 70 |
throw new Error('用户名或密码错误');
|
| 71 |
}
|
|
|
|
| 75 |
method: 'POST',
|
| 76 |
body: JSON.stringify(data)
|
| 77 |
});
|
| 78 |
+
},
|
| 79 |
+
logout: () => {
|
| 80 |
+
if (typeof window !== 'undefined') {
|
| 81 |
+
localStorage.removeItem('user');
|
| 82 |
+
}
|
| 83 |
+
},
|
| 84 |
+
getCurrentUser: (): User | null => {
|
| 85 |
+
if (typeof window !== 'undefined') {
|
| 86 |
+
const stored = localStorage.getItem('user');
|
| 87 |
+
if (stored) return JSON.parse(stored);
|
| 88 |
+
}
|
| 89 |
+
return null;
|
| 90 |
}
|
| 91 |
},
|
| 92 |
|
|
|
|
| 96 |
delete: (id: string | number) => request(`/students/${id}`, { method: 'DELETE' })
|
| 97 |
},
|
| 98 |
|
| 99 |
+
classes: {
|
| 100 |
+
getAll: () => request('/classes'),
|
| 101 |
+
add: (data: ClassInfo) => request('/classes', { method: 'POST', body: JSON.stringify(data) }),
|
| 102 |
+
delete: (id: string | number) => request(`/classes/${id}`, { method: 'DELETE' })
|
| 103 |
+
},
|
| 104 |
+
|
| 105 |
courses: {
|
| 106 |
getAll: () => request('/courses'),
|
| 107 |
add: (data: any) => request('/courses', { method: 'POST', body: JSON.stringify(data) }),
|
| 108 |
+
update: (id: string | number, data: any) => request(`/courses/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
| 109 |
+
delete: (id: string | number) => request(`/courses/${id}`, { method: 'DELETE' })
|
| 110 |
},
|
| 111 |
|
| 112 |
scores: {
|
|
|
|
| 117 |
|
| 118 |
stats: {
|
| 119 |
getSummary: () => request('/stats')
|
| 120 |
+
},
|
| 121 |
+
|
| 122 |
+
config: {
|
| 123 |
+
get: () => request('/config'),
|
| 124 |
+
save: (data: SystemConfig) => request('/config', { method: 'POST', body: JSON.stringify(data) })
|
| 125 |
}
|
| 126 |
};
|
types.ts
CHANGED
|
@@ -22,6 +22,23 @@ export interface User {
|
|
| 22 |
avatar?: string;
|
| 23 |
}
|
| 24 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
export interface Student {
|
| 26 |
id?: number;
|
| 27 |
_id?: string; // MongoDB ID
|
|
|
|
| 22 |
avatar?: string;
|
| 23 |
}
|
| 24 |
|
| 25 |
+
export interface ClassInfo {
|
| 26 |
+
id?: number;
|
| 27 |
+
_id?: string;
|
| 28 |
+
grade: string;
|
| 29 |
+
className: string; // e.g., "(1)班"
|
| 30 |
+
teacherName?: string;
|
| 31 |
+
studentCount?: number; // Calculated field
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
export interface SystemConfig {
|
| 35 |
+
systemName: string;
|
| 36 |
+
semester: string;
|
| 37 |
+
allowRegister: boolean;
|
| 38 |
+
maintenanceMode: boolean;
|
| 39 |
+
emailNotify: boolean;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
export interface Student {
|
| 43 |
id?: number;
|
| 44 |
_id?: string; // MongoDB ID
|