Spaces:
Sleeping
Sleeping
Upload 24 files
Browse files- App.tsx +17 -27
- components/Sidebar.tsx +3 -1
- pages/CourseList.tsx +54 -169
- pages/Login.tsx +18 -25
- pages/Reports.tsx +72 -102
- pages/ScoreList.tsx +124 -330
- pages/StudentList.tsx +115 -274
- pages/SubjectList.tsx +90 -0
- pages/UserList.tsx +110 -0
- server.js +166 -182
- services/api.ts +30 -43
- types.ts +10 -1
App.tsx
CHANGED
|
@@ -9,6 +9,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';
|
| 13 |
import { User, UserRole } from './types';
|
| 14 |
import { api } from './services/api';
|
|
@@ -20,10 +22,7 @@ const App: React.FC = () => {
|
|
| 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);
|
|
@@ -46,26 +45,19 @@ const App: React.FC = () => {
|
|
| 46 |
|
| 47 |
const renderContent = () => {
|
| 48 |
switch (currentView) {
|
| 49 |
-
case 'dashboard':
|
| 50 |
-
|
| 51 |
-
case '
|
| 52 |
-
|
| 53 |
-
case '
|
| 54 |
-
|
| 55 |
-
case '
|
| 56 |
-
|
| 57 |
-
case '
|
| 58 |
-
|
| 59 |
-
case 'settings':
|
| 60 |
-
return <Settings />;
|
| 61 |
-
case 'reports':
|
| 62 |
-
return <Reports />;
|
| 63 |
-
default:
|
| 64 |
-
return <Dashboard />;
|
| 65 |
}
|
| 66 |
};
|
| 67 |
|
| 68 |
-
// Map view IDs to Chinese Titles
|
| 69 |
const viewTitles: Record<string, string> = {
|
| 70 |
dashboard: '工作台',
|
| 71 |
students: '学生档案管理',
|
|
@@ -73,16 +65,14 @@ const App: React.FC = () => {
|
|
| 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 |
-
}
|
| 86 |
|
| 87 |
return (
|
| 88 |
<div className="flex h-screen bg-gray-50">
|
|
|
|
| 9 |
import { ClassList } from './pages/ClassList';
|
| 10 |
import { Settings } from './pages/Settings';
|
| 11 |
import { Reports } from './pages/Reports';
|
| 12 |
+
import { SubjectList } from './pages/SubjectList';
|
| 13 |
+
import { UserList } from './pages/UserList';
|
| 14 |
import { Login } from './pages/Login';
|
| 15 |
import { User, UserRole } from './types';
|
| 16 |
import { api } from './services/api';
|
|
|
|
| 22 |
const [loading, setLoading] = useState(true);
|
| 23 |
|
| 24 |
useEffect(() => {
|
|
|
|
| 25 |
api.init();
|
|
|
|
|
|
|
| 26 |
const storedUser = api.auth.getCurrentUser();
|
| 27 |
if (storedUser) {
|
| 28 |
setCurrentUser(storedUser);
|
|
|
|
| 45 |
|
| 46 |
const renderContent = () => {
|
| 47 |
switch (currentView) {
|
| 48 |
+
case 'dashboard': return <Dashboard />;
|
| 49 |
+
case 'students': return <StudentList />;
|
| 50 |
+
case 'classes': return <ClassList />;
|
| 51 |
+
case 'courses': return <CourseList />;
|
| 52 |
+
case 'grades': return <ScoreList />;
|
| 53 |
+
case 'settings': return <Settings />;
|
| 54 |
+
case 'reports': return <Reports />;
|
| 55 |
+
case 'subjects': return <SubjectList />;
|
| 56 |
+
case 'users': return <UserList />;
|
| 57 |
+
default: return <Dashboard />;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
}
|
| 59 |
};
|
| 60 |
|
|
|
|
| 61 |
const viewTitles: Record<string, string> = {
|
| 62 |
dashboard: '工作台',
|
| 63 |
students: '学生档案管理',
|
|
|
|
| 65 |
courses: '课程安排',
|
| 66 |
grades: '成绩管理',
|
| 67 |
settings: '系统设置',
|
| 68 |
+
reports: '统计报表',
|
| 69 |
+
subjects: '学科设置',
|
| 70 |
+
users: '用户权限管理'
|
| 71 |
};
|
| 72 |
|
| 73 |
+
if (loading) return <div className="min-h-screen flex items-center justify-center bg-gray-50 text-blue-600">加载中...</div>;
|
|
|
|
|
|
|
| 74 |
|
| 75 |
+
if (!isAuthenticated) return <Login onLogin={handleLogin} />;
|
|
|
|
|
|
|
| 76 |
|
| 77 |
return (
|
| 78 |
<div className="flex h-screen bg-gray-50">
|
components/Sidebar.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 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 {
|
|
@@ -18,6 +18,8 @@ export const Sidebar: React.FC<SidebarProps> = ({ currentView, onChangeView, use
|
|
| 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] },
|
|
|
|
|
|
|
| 21 |
{ id: 'settings', label: '系统设置', icon: Settings, roles: [UserRole.ADMIN] },
|
| 22 |
];
|
| 23 |
|
|
|
|
| 1 |
|
| 2 |
import React from 'react';
|
| 3 |
+
import { LayoutDashboard, Users, BookOpen, GraduationCap, Settings, LogOut, FileText, School, UserCog, Palette } from 'lucide-react';
|
| 4 |
import { UserRole } from '../types';
|
| 5 |
|
| 6 |
interface SidebarProps {
|
|
|
|
| 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] },
|
| 21 |
+
{ id: 'subjects', label: '学科设置', icon: Palette, roles: [UserRole.ADMIN] },
|
| 22 |
+
{ id: 'users', label: '用户管理', icon: UserCog, roles: [UserRole.ADMIN] },
|
| 23 |
{ id: 'settings', label: '系统设置', icon: Settings, roles: [UserRole.ADMIN] },
|
| 24 |
];
|
| 25 |
|
pages/CourseList.tsx
CHANGED
|
@@ -1,196 +1,81 @@
|
|
| 1 |
|
| 2 |
import React, { useEffect, useState } from 'react';
|
| 3 |
-
import { Plus,
|
| 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 [
|
| 12 |
-
const [editId, setEditId] = useState<string |
|
| 13 |
|
| 14 |
-
const
|
| 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
|
| 26 |
-
setCourses(
|
| 27 |
-
|
| 28 |
-
|
| 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 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 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 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
<Loader2 className="animate-spin text-blue-500" size={32} />
|
| 81 |
-
</div>
|
| 82 |
-
);
|
| 83 |
-
}
|
| 84 |
|
| 85 |
return (
|
| 86 |
<div className="space-y-6">
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 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>
|
| 96 |
-
</div>
|
| 97 |
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
</div>
|
| 113 |
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
<
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
></div>
|
| 132 |
-
</div>
|
| 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 |
};
|
|
|
|
| 1 |
|
| 2 |
import React, { useEffect, useState } from 'react';
|
| 3 |
+
import { Plus, Clock, Loader2, Edit, Trash2, X } from 'lucide-react';
|
| 4 |
import { api } from '../services/api';
|
| 5 |
+
import { Course, Subject } from '../types';
|
| 6 |
|
| 7 |
export const CourseList: React.FC = () => {
|
| 8 |
const [courses, setCourses] = useState<Course[]>([]);
|
| 9 |
+
const [subjects, setSubjects] = useState<Subject[]>([]);
|
| 10 |
const [loading, setLoading] = useState(true);
|
| 11 |
const [isModalOpen, setIsModalOpen] = useState(false);
|
| 12 |
+
const [formData, setFormData] = useState({ courseCode: '', courseName: '', teacherName: '', credits: 2, capacity: 45 });
|
| 13 |
+
const [editId, setEditId] = useState<string | null>(null);
|
| 14 |
|
| 15 |
+
const loadData = async () => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
setLoading(true);
|
| 17 |
try {
|
| 18 |
+
const [c, s] = await Promise.all([api.courses.getAll(), api.subjects.getAll()]);
|
| 19 |
+
setCourses(c);
|
| 20 |
+
setSubjects(s);
|
| 21 |
+
} catch (e) { console.error(e); } finally { setLoading(false); }
|
|
|
|
|
|
|
|
|
|
| 22 |
};
|
| 23 |
|
| 24 |
+
useEffect(() => { loadData(); }, []);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
const handleSubmit = async (e: React.FormEvent) => {
|
| 27 |
e.preventDefault();
|
| 28 |
+
if (editId) await api.courses.update(editId, formData);
|
| 29 |
+
else await api.courses.add(formData);
|
| 30 |
+
setIsModalOpen(false);
|
| 31 |
+
loadData();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
};
|
| 33 |
|
| 34 |
+
const handleDelete = async (id: string) => {
|
| 35 |
+
if(confirm('删除课程?')) { await api.courses.delete(id); loadData(); }
|
| 36 |
+
};
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
|
| 38 |
return (
|
| 39 |
<div className="space-y-6">
|
| 40 |
+
<div className="flex justify-between items-center">
|
| 41 |
+
<h2 className="text-xl font-bold">课程列表</h2>
|
| 42 |
+
<button onClick={() => { setEditId(null); setIsModalOpen(true); }} className="px-4 py-2 bg-indigo-600 text-white rounded-lg flex items-center text-sm"><Plus size={16} className="mr-1"/> 新增课程</button>
|
| 43 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
|
| 45 |
+
{loading ? <Loader2 className="animate-spin"/> : (
|
| 46 |
+
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
| 47 |
+
{courses.map(c => (
|
| 48 |
+
<div key={c._id || c.id} className="bg-white p-6 rounded-xl border shadow-sm">
|
| 49 |
+
<h3 className="font-bold text-lg">{c.courseName}</h3>
|
| 50 |
+
<p className="text-sm text-gray-500">教师: {c.teacherName}</p>
|
| 51 |
+
<div className="mt-4 flex gap-2">
|
| 52 |
+
<button onClick={() => { setFormData({ courseCode: c.courseCode, courseName: c.courseName, teacherName: c.teacherName, credits: c.credits, capacity: c.capacity }); setEditId(c._id || String(c.id)); setIsModalOpen(true); }} className="flex-1 bg-gray-50 text-blue-600 py-1 rounded text-sm">编辑</button>
|
| 53 |
+
<button onClick={() => handleDelete(c._id || String(c.id))} className="flex-1 bg-gray-50 text-red-600 py-1 rounded text-sm">删除</button>
|
| 54 |
+
</div>
|
| 55 |
+
</div>
|
| 56 |
+
))}
|
| 57 |
+
</div>
|
| 58 |
+
)}
|
|
|
|
| 59 |
|
| 60 |
+
{isModalOpen && (
|
| 61 |
+
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
|
| 62 |
+
<div className="bg-white p-6 rounded-xl w-full max-w-md">
|
| 63 |
+
<h3 className="font-bold mb-4">{editId ? '编辑' : '新增'}</h3>
|
| 64 |
+
<form onSubmit={handleSubmit} className="space-y-4">
|
| 65 |
+
<div>
|
| 66 |
+
<label className="text-sm font-bold text-gray-500">科目</label>
|
| 67 |
+
<select className="w-full border p-2 rounded" value={formData.courseName} onChange={e=>setFormData({...formData, courseName:e.target.value})} required>
|
| 68 |
+
<option value="">选择科目</option>
|
| 69 |
+
{subjects.map(s=><option key={s._id} value={s.name}>{s.name}</option>)}
|
| 70 |
+
</select>
|
| 71 |
+
</div>
|
| 72 |
+
<input className="w-full border p-2 rounded" placeholder="教师姓名" value={formData.teacherName} onChange={e=>setFormData({...formData, teacherName:e.target.value})} required/>
|
| 73 |
+
<button className="w-full bg-blue-600 text-white py-2 rounded">保存</button>
|
| 74 |
+
<button type="button" onClick={()=>setIsModalOpen(false)} className="w-full border py-2 rounded">取消</button>
|
| 75 |
+
</form>
|
| 76 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
</div>
|
| 78 |
+
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
</div>
|
| 80 |
);
|
| 81 |
};
|
pages/Login.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
|
| 2 |
import React, { useState } from 'react';
|
| 3 |
-
import { GraduationCap, Lock, User as UserIcon, AlertCircle, ArrowRight } from 'lucide-react';
|
| 4 |
import { User, UserRole } from '../types';
|
| 5 |
import { api } from '../services/api';
|
| 6 |
|
|
@@ -12,7 +12,7 @@ export const Login: React.FC<LoginProps> = ({ onLogin }) => {
|
|
| 12 |
const [isRegistering, setIsRegistering] = useState(false);
|
| 13 |
const [username, setUsername] = useState('');
|
| 14 |
const [password, setPassword] = useState('');
|
| 15 |
-
const [role, setRole] = useState<UserRole>(UserRole.
|
| 16 |
|
| 17 |
const [error, setError] = useState('');
|
| 18 |
const [loading, setLoading] = useState(false);
|
|
@@ -26,26 +26,23 @@ export const Login: React.FC<LoginProps> = ({ onLogin }) => {
|
|
| 26 |
|
| 27 |
try {
|
| 28 |
if (isRegistering) {
|
| 29 |
-
// Register Logic
|
| 30 |
await api.auth.register({ username, password, role });
|
| 31 |
-
setSuccessMsg('
|
| 32 |
-
|
| 33 |
-
setTimeout(async () => {
|
| 34 |
-
const user = await api.auth.login(username, password);
|
| 35 |
-
onLogin(user);
|
| 36 |
-
}, 1500);
|
| 37 |
} else {
|
| 38 |
-
// Login Logic
|
| 39 |
const user = await api.auth.login(username, password);
|
| 40 |
onLogin(user);
|
| 41 |
}
|
| 42 |
} catch (err: any) {
|
| 43 |
console.error(err);
|
| 44 |
-
if (
|
| 45 |
-
setError('
|
|
|
|
|
|
|
| 46 |
} else {
|
| 47 |
-
setError('用户名或密码错误');
|
| 48 |
}
|
|
|
|
| 49 |
setLoading(false);
|
| 50 |
}
|
| 51 |
};
|
|
@@ -68,16 +65,16 @@ export const Login: React.FC<LoginProps> = ({ onLogin }) => {
|
|
| 68 |
<div className="mb-6 flex justify-center">
|
| 69 |
<div className="bg-gray-100 p-1 rounded-lg flex text-sm font-medium">
|
| 70 |
<button
|
| 71 |
-
onClick={() => { setIsRegistering(false); setError(''); }}
|
| 72 |
className={`px-6 py-2 rounded-md transition-all ${!isRegistering ? 'bg-white text-blue-600 shadow-sm' : 'text-gray-500 hover:text-gray-700'}`}
|
| 73 |
>
|
| 74 |
登录
|
| 75 |
</button>
|
| 76 |
<button
|
| 77 |
-
onClick={() => { setIsRegistering(true); setError(''); }}
|
| 78 |
className={`px-6 py-2 rounded-md transition-all ${isRegistering ? 'bg-white text-blue-600 shadow-sm' : 'text-gray-500 hover:text-gray-700'}`}
|
| 79 |
>
|
| 80 |
-
|
| 81 |
</button>
|
| 82 |
</div>
|
| 83 |
</div>
|
|
@@ -133,17 +130,16 @@ export const Login: React.FC<LoginProps> = ({ onLogin }) => {
|
|
| 133 |
|
| 134 |
{isRegistering && (
|
| 135 |
<div className="space-y-2 animate-in fade-in slide-in-from-top-2">
|
| 136 |
-
<label className="text-sm font-medium text-gray-700"
|
| 137 |
<select
|
| 138 |
value={role}
|
| 139 |
onChange={(e) => setRole(e.target.value as UserRole)}
|
| 140 |
className="block w-full px-3 py-2.5 border border-gray-300 rounded-lg focus:ring-blue-500 focus:border-blue-500 transition-colors bg-white"
|
| 141 |
>
|
| 142 |
-
<option value={UserRole.ADMIN}>管理员 (拥有所有权限)</option>
|
| 143 |
<option value={UserRole.TEACHER}>教师</option>
|
| 144 |
<option value={UserRole.STUDENT}>学生</option>
|
| 145 |
</select>
|
| 146 |
-
<p className="text-xs text-gray-500 mt-1">*
|
| 147 |
</div>
|
| 148 |
)}
|
| 149 |
|
|
@@ -152,15 +148,12 @@ export const Login: React.FC<LoginProps> = ({ onLogin }) => {
|
|
| 152 |
disabled={loading}
|
| 153 |
className={`w-full flex justify-center py-3 px-4 border border-transparent rounded-lg shadow-lg shadow-blue-200 text-sm font-bold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-all transform hover:scale-[1.02] active:scale-[0.98] ${loading ? 'opacity-70 cursor-not-allowed' : ''}`}
|
| 154 |
>
|
| 155 |
-
{loading ?
|
| 156 |
-
{!loading && <ArrowRight size={16} className="ml-2" />}
|
| 157 |
</button>
|
| 158 |
-
|
| 159 |
{!isRegistering && (
|
| 160 |
<div className="text-center text-xs text-gray-400 mt-4 border-t border-gray-100 pt-4">
|
| 161 |
-
<p
|
| 162 |
-
<span className="bg-gray-100 px-2 py-1 rounded mr-2">admin / admin</span>
|
| 163 |
-
<span className="bg-gray-100 px-2 py-1 rounded">teacher / teacher</span>
|
| 164 |
</div>
|
| 165 |
)}
|
| 166 |
</form>
|
|
|
|
| 1 |
|
| 2 |
import React, { useState } from 'react';
|
| 3 |
+
import { GraduationCap, Lock, User as UserIcon, AlertCircle, ArrowRight, Loader2 } from 'lucide-react';
|
| 4 |
import { User, UserRole } from '../types';
|
| 5 |
import { api } from '../services/api';
|
| 6 |
|
|
|
|
| 12 |
const [isRegistering, setIsRegistering] = useState(false);
|
| 13 |
const [username, setUsername] = useState('');
|
| 14 |
const [password, setPassword] = useState('');
|
| 15 |
+
const [role, setRole] = useState<UserRole>(UserRole.STUDENT);
|
| 16 |
|
| 17 |
const [error, setError] = useState('');
|
| 18 |
const [loading, setLoading] = useState(false);
|
|
|
|
| 26 |
|
| 27 |
try {
|
| 28 |
if (isRegistering) {
|
|
|
|
| 29 |
await api.auth.register({ username, password, role });
|
| 30 |
+
setSuccessMsg('注册申请已提交,请等待管理员审核通过后登录。');
|
| 31 |
+
setIsRegistering(false);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
} else {
|
|
|
|
| 33 |
const user = await api.auth.login(username, password);
|
| 34 |
onLogin(user);
|
| 35 |
}
|
| 36 |
} catch (err: any) {
|
| 37 |
console.error(err);
|
| 38 |
+
if (err.message === 'PENDING_APPROVAL') {
|
| 39 |
+
setError('您的账号正在等待管理员审核,请稍后再试。');
|
| 40 |
+
} else if (err.message === 'BANNED') {
|
| 41 |
+
setError('您的账号已被停用,请联系管理员。');
|
| 42 |
} else {
|
| 43 |
+
setError(isRegistering ? '注册失败:用户可能已存在' : '用户名或密码错误');
|
| 44 |
}
|
| 45 |
+
} finally {
|
| 46 |
setLoading(false);
|
| 47 |
}
|
| 48 |
};
|
|
|
|
| 65 |
<div className="mb-6 flex justify-center">
|
| 66 |
<div className="bg-gray-100 p-1 rounded-lg flex text-sm font-medium">
|
| 67 |
<button
|
| 68 |
+
onClick={() => { setIsRegistering(false); setError(''); setSuccessMsg(''); }}
|
| 69 |
className={`px-6 py-2 rounded-md transition-all ${!isRegistering ? 'bg-white text-blue-600 shadow-sm' : 'text-gray-500 hover:text-gray-700'}`}
|
| 70 |
>
|
| 71 |
登录
|
| 72 |
</button>
|
| 73 |
<button
|
| 74 |
+
onClick={() => { setIsRegistering(true); setError(''); setSuccessMsg(''); }}
|
| 75 |
className={`px-6 py-2 rounded-md transition-all ${isRegistering ? 'bg-white text-blue-600 shadow-sm' : 'text-gray-500 hover:text-gray-700'}`}
|
| 76 |
>
|
| 77 |
+
注册
|
| 78 |
</button>
|
| 79 |
</div>
|
| 80 |
</div>
|
|
|
|
| 130 |
|
| 131 |
{isRegistering && (
|
| 132 |
<div className="space-y-2 animate-in fade-in slide-in-from-top-2">
|
| 133 |
+
<label className="text-sm font-medium text-gray-700">申请角色</label>
|
| 134 |
<select
|
| 135 |
value={role}
|
| 136 |
onChange={(e) => setRole(e.target.value as UserRole)}
|
| 137 |
className="block w-full px-3 py-2.5 border border-gray-300 rounded-lg focus:ring-blue-500 focus:border-blue-500 transition-colors bg-white"
|
| 138 |
>
|
|
|
|
| 139 |
<option value={UserRole.TEACHER}>教师</option>
|
| 140 |
<option value={UserRole.STUDENT}>学生</option>
|
| 141 |
</select>
|
| 142 |
+
<p className="text-xs text-gray-500 mt-1">* 注册后需等待管理员审核</p>
|
| 143 |
</div>
|
| 144 |
)}
|
| 145 |
|
|
|
|
| 148 |
disabled={loading}
|
| 149 |
className={`w-full flex justify-center py-3 px-4 border border-transparent rounded-lg shadow-lg shadow-blue-200 text-sm font-bold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-all transform hover:scale-[1.02] active:scale-[0.98] ${loading ? 'opacity-70 cursor-not-allowed' : ''}`}
|
| 150 |
>
|
| 151 |
+
{loading ? <Loader2 className="animate-spin" size={20} /> : (isRegistering ? '提交申请' : '登 录')}
|
|
|
|
| 152 |
</button>
|
| 153 |
+
|
| 154 |
{!isRegistering && (
|
| 155 |
<div className="text-center text-xs text-gray-400 mt-4 border-t border-gray-100 pt-4">
|
| 156 |
+
<p>演示账号: admin / admin</p>
|
|
|
|
|
|
|
| 157 |
</div>
|
| 158 |
)}
|
| 159 |
</form>
|
pages/Reports.tsx
CHANGED
|
@@ -1,129 +1,99 @@
|
|
| 1 |
|
| 2 |
import React, { useState, useEffect } from 'react';
|
| 3 |
-
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
| 4 |
-
import {
|
| 5 |
import { api } from '../services/api';
|
| 6 |
-
import { Score,
|
| 7 |
|
| 8 |
export const Reports: React.FC = () => {
|
| 9 |
const [loading, setLoading] = useState(true);
|
| 10 |
const [scores, setScores] = useState<Score[]>([]);
|
| 11 |
-
const [
|
| 12 |
-
const [classes, setClasses] = useState<ClassInfo[]>([]);
|
| 13 |
|
| 14 |
-
//
|
| 15 |
-
const [
|
| 16 |
-
const [
|
| 17 |
|
| 18 |
useEffect(() => {
|
| 19 |
-
const
|
| 20 |
try {
|
| 21 |
-
const [
|
| 22 |
-
|
| 23 |
-
|
| 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: Student) => {
|
| 36 |
-
if (!classMap[s.className]) classMap[s.className] = [];
|
| 37 |
-
classMap[s.className].push(s.studentNo);
|
| 38 |
-
});
|
| 39 |
|
| 40 |
-
//
|
| 41 |
-
const
|
| 42 |
-
const
|
| 43 |
-
const
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
const total = classScores.reduce((sum: number, s: Score) => sum + s.score, 0);
|
| 47 |
-
const avg = classScores.length > 0 ? Math.round(total / classScores.length) : 0;
|
| 48 |
-
return { name: fullClassName, avg };
|
| 49 |
});
|
| 50 |
-
|
| 51 |
|
| 52 |
-
// 2.
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
return { name: subjectName, avg };
|
| 60 |
});
|
| 61 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
|
| 63 |
-
} catch (e) {
|
| 64 |
-
|
| 65 |
-
} finally {
|
| 66 |
-
setLoading(false);
|
| 67 |
-
}
|
| 68 |
};
|
| 69 |
-
|
| 70 |
}, []);
|
| 71 |
|
| 72 |
-
if (loading) return <div className="flex justify-center py-20"><Loader2 className="animate-spin text-blue-600"
|
| 73 |
|
| 74 |
return (
|
| 75 |
<div className="space-y-6">
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
<Download size={16} />
|
| 84 |
-
<span>导出报表</span>
|
| 85 |
-
</button>
|
| 86 |
-
</div>
|
| 87 |
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
</div>
|
| 103 |
-
</div>
|
| 104 |
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
{scores.length === 0 && (
|
| 123 |
-
<div className="text-center py-10 text-gray-400 bg-gray-50 rounded-lg border border-dashed border-gray-300">
|
| 124 |
-
暂无足够的成绩数据来生成详细图表,请先录入成绩。
|
| 125 |
-
</div>
|
| 126 |
-
)}
|
| 127 |
</div>
|
| 128 |
);
|
| 129 |
};
|
|
|
|
| 1 |
|
| 2 |
import React, { useState, useEffect } from 'react';
|
| 3 |
+
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, RadarChart, PolarGrid, PolarAngleAxis, PolarRadiusAxis, Radar, PieChart, Pie, Cell, Legend } from 'recharts';
|
| 4 |
+
import { Loader2, Download } from 'lucide-react';
|
| 5 |
import { api } from '../services/api';
|
| 6 |
+
import { Score, Subject } from '../types';
|
| 7 |
|
| 8 |
export const Reports: React.FC = () => {
|
| 9 |
const [loading, setLoading] = useState(true);
|
| 10 |
const [scores, setScores] = useState<Score[]>([]);
|
| 11 |
+
const [subjects, setSubjects] = useState<Subject[]>([]);
|
|
|
|
| 12 |
|
| 13 |
+
// Chart Data
|
| 14 |
+
const [radarData, setRadarData] = useState<any[]>([]);
|
| 15 |
+
const [pieData, setPieData] = useState<any[]>([]);
|
| 16 |
|
| 17 |
useEffect(() => {
|
| 18 |
+
const load = async () => {
|
| 19 |
try {
|
| 20 |
+
const [sc, su] = await Promise.all([api.scores.getAll(), api.subjects.getAll()]);
|
| 21 |
+
setScores(sc);
|
| 22 |
+
setSubjects(su);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
|
| 24 |
+
// 1. Radar Data (Avg Score by Subject)
|
| 25 |
+
const rData = su.map(sub => {
|
| 26 |
+
const subScores = sc.filter(s => s.courseName === sub.name);
|
| 27 |
+
const total = subScores.reduce((sum, s) => sum + s.score, 0);
|
| 28 |
+
const avg = subScores.length ? Math.round(total / subScores.length) : 0;
|
| 29 |
+
return { subject: sub.name, A: avg, fullMark: 100 };
|
|
|
|
|
|
|
|
|
|
| 30 |
});
|
| 31 |
+
setRadarData(rData);
|
| 32 |
|
| 33 |
+
// 2. Pie Data (Score Distribution: Excellent, Good, Pass, Fail)
|
| 34 |
+
let excellent = 0, good = 0, pass = 0, fail = 0;
|
| 35 |
+
sc.forEach(s => {
|
| 36 |
+
if (s.score >= 90) excellent++;
|
| 37 |
+
else if (s.score >= 80) good++;
|
| 38 |
+
else if (s.score >= 60) pass++;
|
| 39 |
+
else fail++;
|
|
|
|
| 40 |
});
|
| 41 |
+
setPieData([
|
| 42 |
+
{ name: '优秀 (90-100)', value: excellent, color: '#10b981' },
|
| 43 |
+
{ name: '良好 (80-89)', value: good, color: '#3b82f6' },
|
| 44 |
+
{ name: '及格 (60-79)', value: pass, color: '#f59e0b' },
|
| 45 |
+
{ name: '不及格 (<60)', value: fail, color: '#ef4444' }
|
| 46 |
+
]);
|
| 47 |
|
| 48 |
+
} catch (e) { console.error(e); }
|
| 49 |
+
finally { setLoading(false); }
|
|
|
|
|
|
|
|
|
|
| 50 |
};
|
| 51 |
+
load();
|
| 52 |
}, []);
|
| 53 |
|
| 54 |
+
if (loading) return <div className="flex justify-center py-20"><Loader2 className="animate-spin text-blue-600" /></div>;
|
| 55 |
|
| 56 |
return (
|
| 57 |
<div className="space-y-6">
|
| 58 |
+
<div className="bg-white p-5 rounded-xl border flex justify-between items-center shadow-sm">
|
| 59 |
+
<div>
|
| 60 |
+
<h2 className="text-xl font-bold">全校学情分析报表</h2>
|
| 61 |
+
<p className="text-sm text-gray-500">基于 {scores.length} 条成绩样本</p>
|
| 62 |
+
</div>
|
| 63 |
+
<button className="bg-gray-100 px-4 py-2 rounded text-sm flex items-center gap-2"><Download size={16}/> 导出PDF</button>
|
| 64 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
|
| 66 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
| 67 |
+
{/* Radar Chart */}
|
| 68 |
+
<div className="bg-white p-6 rounded-xl border shadow-sm h-96">
|
| 69 |
+
<h3 className="font-bold mb-4 text-gray-800">学科能力模型 (平均分)</h3>
|
| 70 |
+
<ResponsiveContainer width="100%" height="100%">
|
| 71 |
+
<RadarChart cx="50%" cy="50%" outerRadius="80%" data={radarData}>
|
| 72 |
+
<PolarGrid />
|
| 73 |
+
<PolarAngleAxis dataKey="subject" />
|
| 74 |
+
<PolarRadiusAxis angle={30} domain={[0, 100]} />
|
| 75 |
+
<Radar name="平均分" dataKey="A" stroke="#8884d8" fill="#8884d8" fillOpacity={0.6} />
|
| 76 |
+
<Tooltip />
|
| 77 |
+
</RadarChart>
|
| 78 |
+
</ResponsiveContainer>
|
| 79 |
+
</div>
|
|
|
|
|
|
|
| 80 |
|
| 81 |
+
{/* Pie Chart */}
|
| 82 |
+
<div className="bg-white p-6 rounded-xl border shadow-sm h-96">
|
| 83 |
+
<h3 className="font-bold mb-4 text-gray-800">成绩等级分布</h3>
|
| 84 |
+
<ResponsiveContainer width="100%" height="100%">
|
| 85 |
+
<PieChart>
|
| 86 |
+
<Pie data={pieData} cx="50%" cy="50%" innerRadius={60} outerRadius={100} paddingAngle={5} dataKey="value">
|
| 87 |
+
{pieData.map((entry, index) => (
|
| 88 |
+
<Cell key={`cell-${index}`} fill={entry.color} />
|
| 89 |
+
))}
|
| 90 |
+
</Pie>
|
| 91 |
+
<Tooltip />
|
| 92 |
+
<Legend />
|
| 93 |
+
</PieChart>
|
| 94 |
+
</ResponsiveContainer>
|
| 95 |
+
</div>
|
| 96 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
</div>
|
| 98 |
);
|
| 99 |
};
|
pages/ScoreList.tsx
CHANGED
|
@@ -1,368 +1,162 @@
|
|
| 1 |
|
| 2 |
import React, { useState, useEffect } from 'react';
|
| 3 |
-
import { Search, Filter, Upload, Loader2, BookOpen, Award, Plus, X, Trash2 } from 'lucide-react';
|
| 4 |
import { api } from '../services/api';
|
| 5 |
-
import { Score, Student } from '../types';
|
|
|
|
| 6 |
|
| 7 |
export const ScoreList: React.FC = () => {
|
| 8 |
-
const [
|
| 9 |
-
const [
|
| 10 |
-
const [selectedClass, setSelectedClass] = useState('(1)班'); // Default Class
|
| 11 |
-
|
| 12 |
const [scores, setScores] = useState<Score[]>([]);
|
| 13 |
-
const [students, setStudents] = useState<Student[]>([]);
|
| 14 |
const [loading, setLoading] = useState(true);
|
| 15 |
|
| 16 |
-
|
| 17 |
-
const [
|
| 18 |
-
const [
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
score: '',
|
| 23 |
-
semester: '2023-2024学年 第一学期',
|
| 24 |
-
type: 'Final'
|
| 25 |
-
});
|
| 26 |
-
|
| 27 |
-
const subjects = ['语文', '数学', '英语', '科学'];
|
| 28 |
-
const grades = ['一年级', '二年级', '三年级', '四年级', '五年级', '六年级'];
|
| 29 |
-
const classes = ['(1)班', '(2)班', '(3)班', '(4)班'];
|
| 30 |
|
| 31 |
const loadData = async () => {
|
| 32 |
setLoading(true);
|
| 33 |
try {
|
| 34 |
-
const [
|
| 35 |
-
|
| 36 |
-
|
|
|
|
| 37 |
]);
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
}
|
| 43 |
-
|
| 44 |
-
}
|
| 45 |
};
|
| 46 |
|
| 47 |
-
useEffect(() => {
|
| 48 |
-
loadData();
|
| 49 |
-
}, []);
|
| 50 |
-
|
| 51 |
-
const handleAddSubmit = async (e: React.FormEvent) => {
|
| 52 |
-
e.preventDefault();
|
| 53 |
-
setSubmitting(true);
|
| 54 |
-
try {
|
| 55 |
-
const selectedStudent = students.find(s => (s._id || s.id) == formData.studentId);
|
| 56 |
-
if (!selectedStudent) {
|
| 57 |
-
alert('请选择有效的学生');
|
| 58 |
-
setSubmitting(false);
|
| 59 |
-
return;
|
| 60 |
-
}
|
| 61 |
-
|
| 62 |
-
await api.scores.add({
|
| 63 |
-
studentName: selectedStudent.name,
|
| 64 |
-
studentNo: selectedStudent.studentNo,
|
| 65 |
-
courseName: formData.courseName,
|
| 66 |
-
score: Number(formData.score),
|
| 67 |
-
semester: formData.semester,
|
| 68 |
-
type: formData.type
|
| 69 |
-
});
|
| 70 |
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
} catch (err) {
|
| 77 |
-
console.error(err);
|
| 78 |
-
alert('保存失败');
|
| 79 |
-
} finally {
|
| 80 |
-
setSubmitting(false);
|
| 81 |
-
}
|
| 82 |
-
};
|
| 83 |
|
| 84 |
-
const
|
| 85 |
-
if (confirm(
|
| 86 |
-
await api.
|
|
|
|
| 87 |
loadData();
|
| 88 |
}
|
| 89 |
};
|
| 90 |
|
| 91 |
-
const
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
if (
|
| 95 |
-
|
| 96 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
};
|
| 98 |
|
| 99 |
-
const
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
case 'Quiz': return '单元测试';
|
| 104 |
-
default: return type;
|
| 105 |
-
}
|
| 106 |
};
|
| 107 |
|
| 108 |
-
// Logic: Join Score with Student to get ClassName, then Filter
|
| 109 |
-
const filteredScores = scores.filter(score => {
|
| 110 |
-
// 1. Filter by Subject Tab
|
| 111 |
-
if (score.courseName !== activeSubject) return false;
|
| 112 |
-
|
| 113 |
-
// 2. Find Student info for this score
|
| 114 |
-
const student = students.find(s => s.studentNo === score.studentNo);
|
| 115 |
-
|
| 116 |
-
// 3. Filter by Grade and Class
|
| 117 |
-
if (!student) return false; // Orphan score
|
| 118 |
-
|
| 119 |
-
const targetClassName = selectedGrade + selectedClass;
|
| 120 |
-
return student.className === targetClassName;
|
| 121 |
-
});
|
| 122 |
-
|
| 123 |
-
// Calculate Average for current view
|
| 124 |
-
const currentAvg = filteredScores.length > 0
|
| 125 |
-
? (filteredScores.reduce((acc, curr) => acc + curr.score, 0) / filteredScores.length).toFixed(1)
|
| 126 |
-
: '0';
|
| 127 |
-
|
| 128 |
return (
|
| 129 |
<div className="space-y-6">
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
</div>
|
| 137 |
-
)}
|
| 138 |
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
<button
|
| 151 |
-
onClick={() => setIsAddModalOpen(true)}
|
| 152 |
-
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 transition-colors shadow-sm shadow-blue-200"
|
| 153 |
-
>
|
| 154 |
-
<Plus size={16} />
|
| 155 |
-
<span>手动录入</span>
|
| 156 |
-
</button>
|
| 157 |
-
<button className="flex items-center space-x-2 px-4 py-2 border border-gray-200 text-gray-700 rounded-lg text-sm font-medium hover:bg-gray-50 transition-colors">
|
| 158 |
-
<Upload size={16} />
|
| 159 |
-
<span>批量导入</span>
|
| 160 |
-
</button>
|
| 161 |
-
</div>
|
| 162 |
-
</div>
|
| 163 |
-
|
| 164 |
-
{/* Filters */}
|
| 165 |
-
<div className="flex flex-wrap gap-4 bg-gray-50 p-4 rounded-lg items-center">
|
| 166 |
-
<span className="text-sm font-semibold text-gray-600">筛选维度:</span>
|
| 167 |
-
<select
|
| 168 |
-
className="px-4 py-2 bg-white border border-gray-200 rounded-lg text-sm font-medium focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
| 169 |
-
value={selectedGrade}
|
| 170 |
-
onChange={(e) => setSelectedGrade(e.target.value)}
|
| 171 |
-
>
|
| 172 |
-
{grades.map(g => <option key={g} value={g}>{g}</option>)}
|
| 173 |
-
</select>
|
| 174 |
-
<select
|
| 175 |
-
className="px-4 py-2 bg-white border border-gray-200 rounded-lg text-sm font-medium focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
| 176 |
-
value={selectedClass}
|
| 177 |
-
onChange={(e) => setSelectedClass(e.target.value)}
|
| 178 |
-
>
|
| 179 |
-
{classes.map(c => <option key={c} value={c}>{c}</option>)}
|
| 180 |
-
</select>
|
| 181 |
-
|
| 182 |
-
<div className="ml-auto flex items-center text-sm">
|
| 183 |
-
<span className="text-gray-500 mr-2">本班{activeSubject}平均分:</span>
|
| 184 |
-
<span className="text-lg font-bold text-blue-600">{currentAvg}</span>
|
| 185 |
-
</div>
|
| 186 |
-
</div>
|
| 187 |
-
</div>
|
| 188 |
-
|
| 189 |
-
{/* Subject Tabs */}
|
| 190 |
-
<div className="flex border-b border-gray-100 px-6">
|
| 191 |
-
{subjects.map((subject) => (
|
| 192 |
-
<button
|
| 193 |
-
key={subject}
|
| 194 |
-
onClick={() => setActiveSubject(subject)}
|
| 195 |
-
className={`pb-4 pt-4 px-6 text-sm font-medium transition-colors relative ${
|
| 196 |
-
activeSubject === subject
|
| 197 |
-
? 'text-blue-600'
|
| 198 |
-
: 'text-gray-500 hover:text-gray-700'
|
| 199 |
-
}`}
|
| 200 |
-
>
|
| 201 |
-
{subject}
|
| 202 |
-
{activeSubject === subject && (
|
| 203 |
-
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-blue-600 rounded-t-full"></div>
|
| 204 |
-
)}
|
| 205 |
-
</button>
|
| 206 |
-
))}
|
| 207 |
-
</div>
|
| 208 |
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
<table className="w-full text-left border-collapse">
|
| 212 |
-
<thead>
|
| 213 |
-
<tr className="bg-gray-50/50">
|
| 214 |
-
<th className="px-6 py-4 text-xs font-semibold text-gray-500 uppercase">学生姓名</th>
|
| 215 |
-
<th className="px-6 py-4 text-xs font-semibold text-gray-500 uppercase">学号</th>
|
| 216 |
-
<th className="px-6 py-4 text-xs font-semibold text-gray-500 uppercase">考试类型</th>
|
| 217 |
-
<th className="px-6 py-4 text-xs font-semibold text-gray-500 uppercase text-center">卷面分数</th>
|
| 218 |
-
<th className="px-6 py-4 text-xs font-semibold text-gray-500 uppercase text-right">操作</th>
|
| 219 |
-
</tr>
|
| 220 |
-
</thead>
|
| 221 |
-
<tbody className="divide-y divide-gray-100">
|
| 222 |
-
{filteredScores.length === 0 ? (
|
| 223 |
<tr>
|
| 224 |
-
<
|
| 225 |
-
<
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
</
|
|
|
|
|
|
|
|
|
|
| 230 |
</tr>
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
<
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
{
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
{translateExamType(score.type)}
|
| 243 |
-
</span>
|
| 244 |
-
</td>
|
| 245 |
-
<td className="px-6 py-4 text-center">
|
| 246 |
-
<span className={`inline-block px-4 py-1.5 rounded-full text-sm font-bold ring-1 ring-inset ${getScoreColor(score.score)}`}>
|
| 247 |
-
{score.score}
|
| 248 |
-
</span>
|
| 249 |
-
</td>
|
| 250 |
-
<td className="px-6 py-4 text-right">
|
| 251 |
-
<button
|
| 252 |
-
onClick={() => handleDelete(score._id || score.id!)}
|
| 253 |
-
className="p-1.5 text-red-400 hover:bg-red-50 hover:text-red-600 rounded transition-colors opacity-0 group-hover:opacity-100"
|
| 254 |
-
title="删除"
|
| 255 |
-
>
|
| 256 |
-
<Trash2 size={16} />
|
| 257 |
-
</button>
|
| 258 |
-
</td>
|
| 259 |
-
</tr>
|
| 260 |
-
))
|
| 261 |
-
)}
|
| 262 |
-
</tbody>
|
| 263 |
</table>
|
| 264 |
-
|
| 265 |
-
</div>
|
| 266 |
-
|
| 267 |
-
{/* Add Score Modal */}
|
| 268 |
-
{isAddModalOpen && (
|
| 269 |
-
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 backdrop-blur-sm">
|
| 270 |
-
<div className="bg-white rounded-xl shadow-2xl max-w-md w-full overflow-hidden transform transition-all">
|
| 271 |
-
<div className="px-6 py-4 border-b border-gray-100 flex justify-between items-center bg-gray-50">
|
| 272 |
-
<h3 className="font-bold text-gray-800 text-lg">录入学生成绩</h3>
|
| 273 |
-
<button onClick={() => setIsAddModalOpen(false)} className="text-gray-400 hover:text-gray-600">
|
| 274 |
-
<X size={20} />
|
| 275 |
-
</button>
|
| 276 |
-
</div>
|
| 277 |
-
|
| 278 |
-
<form onSubmit={handleAddSubmit} className="p-6 space-y-5">
|
| 279 |
-
<div className="space-y-1.5">
|
| 280 |
-
<label className="text-sm font-semibold text-gray-700">选择学生</label>
|
| 281 |
-
<select
|
| 282 |
-
required
|
| 283 |
-
className="w-full px-3 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none bg-white"
|
| 284 |
-
value={formData.studentId}
|
| 285 |
-
onChange={(e) => setFormData({...formData, studentId: e.target.value})}
|
| 286 |
-
>
|
| 287 |
-
<option value="">-- 请选择学生 --</option>
|
| 288 |
-
{/* 只显示当前选中班级的学生,方便录入 */}
|
| 289 |
-
{students
|
| 290 |
-
.filter(s => s.className === selectedGrade + selectedClass)
|
| 291 |
-
.map(s => (
|
| 292 |
-
<option key={s._id || s.id} value={s._id || s.id}>
|
| 293 |
-
{s.name} ({s.studentNo})
|
| 294 |
-
</option>
|
| 295 |
-
))}
|
| 296 |
-
{students.filter(s => s.className === selectedGrade + selectedClass).length === 0 && (
|
| 297 |
-
<option disabled>当前班级无学生</option>
|
| 298 |
-
)}
|
| 299 |
-
</select>
|
| 300 |
-
<p className="text-xs text-gray-400">仅显示 {selectedGrade}{selectedClass} 的学生</p>
|
| 301 |
-
</div>
|
| 302 |
-
|
| 303 |
-
<div className="grid grid-cols-2 gap-4">
|
| 304 |
-
<div className="space-y-1.5">
|
| 305 |
-
<label className="text-sm font-semibold text-gray-700">科目</label>
|
| 306 |
-
<select
|
| 307 |
-
className="w-full px-3 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
| 308 |
-
value={formData.courseName}
|
| 309 |
-
onChange={(e) => setFormData({...formData, courseName: e.target.value})}
|
| 310 |
-
>
|
| 311 |
-
{subjects.map(s => <option key={s} value={s}>{s}</option>)}
|
| 312 |
-
</select>
|
| 313 |
-
</div>
|
| 314 |
-
<div className="space-y-1.5">
|
| 315 |
-
<label className="text-sm font-semibold text-gray-700">分数</label>
|
| 316 |
-
<input
|
| 317 |
-
type="number" min="0" max="100" required
|
| 318 |
-
className="w-full px-3 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
| 319 |
-
value={formData.score}
|
| 320 |
-
onChange={(e) => setFormData({...formData, score: e.target.value})}
|
| 321 |
-
placeholder="0-100"
|
| 322 |
-
/>
|
| 323 |
-
</div>
|
| 324 |
-
</div>
|
| 325 |
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
value={formData.semester}
|
| 350 |
-
readOnly
|
| 351 |
-
/>
|
| 352 |
-
</div>
|
| 353 |
-
|
| 354 |
-
<div className="pt-4 flex space-x-3">
|
| 355 |
-
<button type="button" onClick={() => setIsAddModalOpen(false)} className="flex-1 px-4 py-2.5 border border-gray-200 text-gray-700 rounded-lg hover:bg-gray-50 font-medium transition-colors">
|
| 356 |
-
取消
|
| 357 |
-
</button>
|
| 358 |
-
<button type="submit" disabled={submitting} className="flex-1 px-4 py-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 flex items-center justify-center font-medium shadow-lg shadow-blue-200 transition-colors">
|
| 359 |
-
{submitting ? <Loader2 className="animate-spin" size={20} /> : '确认录入'}
|
| 360 |
-
</button>
|
| 361 |
-
</div>
|
| 362 |
-
</form>
|
| 363 |
</div>
|
| 364 |
-
|
| 365 |
-
)}
|
| 366 |
</div>
|
| 367 |
);
|
| 368 |
};
|
|
|
|
| 1 |
|
| 2 |
import React, { useState, useEffect } from 'react';
|
|
|
|
| 3 |
import { api } from '../services/api';
|
| 4 |
+
import { Score, Student, Subject } from '../types';
|
| 5 |
+
import { Loader2, Plus, Trash2, Award } from 'lucide-react';
|
| 6 |
|
| 7 |
export const ScoreList: React.FC = () => {
|
| 8 |
+
const [subjects, setSubjects] = useState<Subject[]>([]);
|
| 9 |
+
const [activeSubject, setActiveSubject] = useState('');
|
|
|
|
|
|
|
| 10 |
const [scores, setScores] = useState<Score[]>([]);
|
| 11 |
+
const [students, setStudents] = useState<Student[]>([]);
|
| 12 |
const [loading, setLoading] = useState(true);
|
| 13 |
|
| 14 |
+
const [selectedGrade, setSelectedGrade] = useState('六年级');
|
| 15 |
+
const [selectedClass, setSelectedClass] = useState('(1)班');
|
| 16 |
+
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
| 17 |
+
|
| 18 |
+
const [isAddOpen, setIsAddOpen] = useState(false);
|
| 19 |
+
const [formData, setFormData] = useState({ studentId: '', score: '', type: 'Final' });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
const loadData = async () => {
|
| 22 |
setLoading(true);
|
| 23 |
try {
|
| 24 |
+
const [subs, scs, stus] = await Promise.all([
|
| 25 |
+
api.subjects.getAll(),
|
| 26 |
+
api.scores.getAll(),
|
| 27 |
+
api.students.getAll()
|
| 28 |
]);
|
| 29 |
+
setSubjects(subs);
|
| 30 |
+
if (subs.length > 0 && !activeSubject) setActiveSubject(subs[0].name);
|
| 31 |
+
setScores(scs);
|
| 32 |
+
setStudents(stus);
|
| 33 |
+
} catch (e) { console.error(e); }
|
| 34 |
+
finally { setLoading(false); }
|
|
|
|
| 35 |
};
|
| 36 |
|
| 37 |
+
useEffect(() => { loadData(); }, []);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
|
| 39 |
+
const filteredScores = scores.filter(s => {
|
| 40 |
+
if (s.courseName !== activeSubject) return false;
|
| 41 |
+
const stu = students.find(st => st.studentNo === s.studentNo);
|
| 42 |
+
return stu && stu.className === (selectedGrade + selectedClass);
|
| 43 |
+
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
|
| 45 |
+
const handleBatchDelete = async () => {
|
| 46 |
+
if (confirm(`删除 ${selectedIds.size} 条记录?`)) {
|
| 47 |
+
await api.batchDelete('score', Array.from(selectedIds));
|
| 48 |
+
setSelectedIds(new Set());
|
| 49 |
loadData();
|
| 50 |
}
|
| 51 |
};
|
| 52 |
|
| 53 |
+
const handleSubmit = async (e: React.FormEvent) => {
|
| 54 |
+
e.preventDefault();
|
| 55 |
+
const stu = students.find(s => (s._id || s.id) == formData.studentId);
|
| 56 |
+
if (!stu) return;
|
| 57 |
+
await api.scores.add({
|
| 58 |
+
studentName: stu.name,
|
| 59 |
+
studentNo: stu.studentNo,
|
| 60 |
+
courseName: activeSubject,
|
| 61 |
+
score: Number(formData.score),
|
| 62 |
+
semester: '2023-Fall',
|
| 63 |
+
type: formData.type
|
| 64 |
+
});
|
| 65 |
+
setIsAddOpen(false);
|
| 66 |
+
loadData();
|
| 67 |
};
|
| 68 |
|
| 69 |
+
const toggleSelect = (id: string) => {
|
| 70 |
+
const newSet = new Set(selectedIds);
|
| 71 |
+
if (newSet.has(id)) newSet.delete(id); else newSet.add(id);
|
| 72 |
+
setSelectedIds(newSet);
|
|
|
|
|
|
|
|
|
|
| 73 |
};
|
| 74 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
return (
|
| 76 |
<div className="space-y-6">
|
| 77 |
+
<div className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden min-h-[500px]">
|
| 78 |
+
{loading && <div className="p-10 text-center"><Loader2 className="animate-spin inline"/></div>}
|
| 79 |
+
|
| 80 |
+
<div className="p-6 border-b border-gray-100">
|
| 81 |
+
<div className="flex justify-between items-center mb-4">
|
| 82 |
+
<h2 className="text-xl font-bold flex items-center text-gray-800"><Award className="mr-2 text-blue-600"/>成绩管理</h2>
|
| 83 |
+
<button onClick={() => setIsAddOpen(true)} className="px-4 py-2 bg-blue-600 text-white rounded-lg flex items-center text-sm"><Plus size={16} className="mr-1"/> 录入{activeSubject}</button>
|
| 84 |
+
</div>
|
| 85 |
+
|
| 86 |
+
<div className="flex gap-4 items-center bg-gray-50 p-3 rounded-lg">
|
| 87 |
+
<select className="border p-2 rounded" value={selectedGrade} onChange={e=>setSelectedGrade(e.target.value)}>
|
| 88 |
+
{['一年级','二年级','三年级','四年级','五年级','六年级'].map(g=><option key={g} value={g}>{g}</option>)}
|
| 89 |
+
</select>
|
| 90 |
+
<select className="border p-2 rounded" value={selectedClass} onChange={e=>setSelectedClass(e.target.value)}>
|
| 91 |
+
{['(1)班','(2)班','(3)班'].map(c=><option key={c} value={c}>{c}</option>)}
|
| 92 |
+
</select>
|
| 93 |
+
</div>
|
| 94 |
</div>
|
|
|
|
| 95 |
|
| 96 |
+
<div className="flex border-b border-gray-100 px-4 overflow-x-auto">
|
| 97 |
+
{subjects.map(sub => (
|
| 98 |
+
<button
|
| 99 |
+
key={sub._id || sub.id}
|
| 100 |
+
onClick={() => setActiveSubject(sub.name)}
|
| 101 |
+
className={`px-6 py-4 text-sm font-medium border-b-2 transition-colors ${activeSubject === sub.name ? 'border-blue-600 text-blue-600' : 'border-transparent text-gray-500'}`}
|
| 102 |
+
>
|
| 103 |
+
{sub.name}
|
| 104 |
+
</button>
|
| 105 |
+
))}
|
| 106 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
|
| 108 |
+
<table className="w-full text-left">
|
| 109 |
+
<thead className="bg-gray-50 text-xs text-gray-500 uppercase">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
<tr>
|
| 111 |
+
<th className="px-6 py-3 w-10">
|
| 112 |
+
<input type="checkbox" onChange={e => {
|
| 113 |
+
if(e.target.checked) setSelectedIds(new Set(filteredScores.map(s=>s._id||String(s.id))));
|
| 114 |
+
else setSelectedIds(new Set());
|
| 115 |
+
}} checked={filteredScores.length > 0 && selectedIds.size === filteredScores.length}/>
|
| 116 |
+
</th>
|
| 117 |
+
<th className="px-6 py-3">姓名</th>
|
| 118 |
+
<th className="px-6 py-3">分数</th>
|
| 119 |
+
<th className="px-6 py-3 text-right">操作</th>
|
| 120 |
</tr>
|
| 121 |
+
</thead>
|
| 122 |
+
<tbody>
|
| 123 |
+
{filteredScores.map(s => (
|
| 124 |
+
<tr key={s._id || s.id} className="hover:bg-gray-50">
|
| 125 |
+
<td className="px-6 py-3"><input type="checkbox" checked={selectedIds.has(s._id||String(s.id))} onChange={()=>toggleSelect(s._id||String(s.id))}/></td>
|
| 126 |
+
<td className="px-6 py-3">{s.studentName}</td>
|
| 127 |
+
<td className="px-6 py-3 font-bold text-blue-600">{s.score}</td>
|
| 128 |
+
<td className="px-6 py-3 text-right"><button onClick={async()=>{await api.scores.delete(s._id||String(s.id));loadData();}} className="text-red-400 hover:text-red-600"><Trash2 size={16}/></button></td>
|
| 129 |
+
</tr>
|
| 130 |
+
))}
|
| 131 |
+
</tbody>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
</table>
|
| 133 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
|
| 135 |
+
{selectedIds.size > 0 && (
|
| 136 |
+
<div className="fixed bottom-6 right-6 bg-white shadow-lg p-4 rounded-xl border flex items-center gap-4 animate-in slide-in-from-bottom-5">
|
| 137 |
+
<span>已选 {selectedIds.size} 项</span>
|
| 138 |
+
<button onClick={handleBatchDelete} className="bg-red-600 text-white px-4 py-2 rounded-lg text-sm">批量删除</button>
|
| 139 |
+
</div>
|
| 140 |
+
)}
|
| 141 |
+
|
| 142 |
+
{isAddOpen && (
|
| 143 |
+
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
| 144 |
+
<div className="bg-white rounded-xl p-6 w-full max-w-sm">
|
| 145 |
+
<h3 className="font-bold mb-4">录入 {activeSubject} 成绩</h3>
|
| 146 |
+
<form onSubmit={handleSubmit} className="space-y-4">
|
| 147 |
+
<select className="w-full border p-2 rounded" value={formData.studentId} onChange={e=>setFormData({...formData, studentId:e.target.value})} required>
|
| 148 |
+
<option value="">选择学生</option>
|
| 149 |
+
{students.filter(s=>s.className===selectedGrade+selectedClass).map(s=><option key={s._id||s.id} value={s._id||s.id}>{s.name}</option>)}
|
| 150 |
+
</select>
|
| 151 |
+
<input type="number" className="w-full border p-2 rounded" placeholder="分数" value={formData.score} onChange={e=>setFormData({...formData, score:e.target.value})} required/>
|
| 152 |
+
<div className="flex gap-2">
|
| 153 |
+
<button type="submit" className="flex-1 bg-blue-600 text-white py-2 rounded">保存</button>
|
| 154 |
+
<button type="button" onClick={()=>setIsAddOpen(false)} className="flex-1 border py-2 rounded">取消</button>
|
| 155 |
+
</div>
|
| 156 |
+
</form>
|
| 157 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
</div>
|
| 159 |
+
)}
|
|
|
|
| 160 |
</div>
|
| 161 |
);
|
| 162 |
};
|
pages/StudentList.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
|
| 2 |
import React, { useState, useEffect } from 'react';
|
| 3 |
-
import { Search, Plus,
|
| 4 |
import { api } from '../services/api';
|
| 5 |
import { Student, ClassInfo } from '../types';
|
| 6 |
|
|
@@ -12,6 +12,7 @@ export const StudentList: React.FC = () => {
|
|
| 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');
|
|
@@ -40,354 +41,194 @@ export const StudentList: React.FC = () => {
|
|
| 40 |
setStudents(studentData);
|
| 41 |
setClassList(classData);
|
| 42 |
} catch (error) {
|
| 43 |
-
console.error(
|
| 44 |
} finally {
|
| 45 |
setLoading(false);
|
| 46 |
}
|
| 47 |
};
|
| 48 |
|
| 49 |
-
useEffect(() => {
|
| 50 |
-
loadData();
|
| 51 |
-
}, []);
|
| 52 |
|
| 53 |
-
const handleDelete = async (id:
|
| 54 |
-
if (confirm('
|
| 55 |
await api.students.delete(id);
|
| 56 |
loadData();
|
| 57 |
}
|
| 58 |
};
|
| 59 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
const handleAddSubmit = async (e: React.FormEvent) => {
|
| 61 |
e.preventDefault();
|
| 62 |
setSubmitting(true);
|
| 63 |
try {
|
| 64 |
await api.students.add({
|
| 65 |
...formData,
|
| 66 |
-
birthday: '2015-01-01',
|
| 67 |
-
status: 'Enrolled'
|
| 68 |
gender: formData.gender as any
|
| 69 |
});
|
| 70 |
setIsModalOpen(false);
|
| 71 |
-
setFormData({ name: '', studentNo: '', gender: 'Male', className: '', phone: '', idCard: '' });
|
| 72 |
loadData();
|
| 73 |
-
} catch (error) {
|
| 74 |
-
|
| 75 |
-
alert('添加失败,请检查网络或学号是否重复');
|
| 76 |
-
} finally {
|
| 77 |
-
setSubmitting(false);
|
| 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 >=
|
| 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:
|
| 98 |
-
className:
|
| 99 |
-
phone:
|
| 100 |
birthday: '2015-01-01',
|
| 101 |
status: 'Enrolled'
|
| 102 |
});
|
| 103 |
-
|
| 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 =
|
| 121 |
-
s.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
| 122 |
-
s.studentNo.includes(searchTerm);
|
| 123 |
-
|
| 124 |
const matchesGrade = selectedGrade === 'All' || s.className.includes(selectedGrade);
|
| 125 |
const matchesClass = selectedClass === 'All' || s.className.includes(selectedClass);
|
| 126 |
-
|
| 127 |
return matchesSearch && matchesGrade && matchesClass;
|
| 128 |
});
|
| 129 |
|
| 130 |
-
const
|
| 131 |
-
|
| 132 |
-
};
|
| 133 |
-
|
| 134 |
-
const translateStatus = (status: string) => {
|
| 135 |
-
switch (status) {
|
| 136 |
-
case 'Enrolled': return '在读';
|
| 137 |
-
case 'Graduated': return '毕业';
|
| 138 |
-
case 'Suspended': return '休学';
|
| 139 |
-
default: return status;
|
| 140 |
-
}
|
| 141 |
-
};
|
| 142 |
|
| 143 |
return (
|
| 144 |
<div className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden relative min-h-[600px] flex flex-col">
|
| 145 |
-
|
| 146 |
-
{/* Loading Overlay */}
|
| 147 |
-
{loading && (
|
| 148 |
-
<div className="absolute inset-0 bg-white/50 z-10 flex items-center justify-center">
|
| 149 |
-
<Loader2 className="animate-spin text-blue-600" size={32} />
|
| 150 |
-
</div>
|
| 151 |
-
)}
|
| 152 |
|
| 153 |
-
{/* Toolbar */}
|
| 154 |
<div className="p-6 border-b border-gray-100 space-y-4">
|
| 155 |
-
<div className="flex
|
| 156 |
<div>
|
| 157 |
-
<h2 className="text-lg font-bold text-gray-800"
|
| 158 |
-
<p className="text-sm text-gray-500"
|
| 159 |
</div>
|
| 160 |
-
<div className="flex space-x-
|
| 161 |
-
<button
|
| 162 |
-
|
| 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)}
|
| 170 |
-
className="flex items-center justify-center space-x-2 px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 transition-colors shadow-sm shadow-blue-200"
|
| 171 |
-
>
|
| 172 |
-
<Plus size={16} />
|
| 173 |
-
<span>新增学生</span>
|
| 174 |
-
</button>
|
| 175 |
</div>
|
| 176 |
</div>
|
| 177 |
|
| 178 |
-
|
| 179 |
-
<div className="flex flex-col md:flex-row gap-4 bg-gray-50 p-4 rounded-lg">
|
| 180 |
<div className="relative flex-1">
|
| 181 |
-
<input
|
| 182 |
-
type="text"
|
| 183 |
-
placeholder="搜索姓名或学号..."
|
| 184 |
-
value={searchTerm}
|
| 185 |
-
onChange={(e) => setSearchTerm(e.target.value)}
|
| 186 |
-
className="w-full pl-10 pr-4 py-2 bg-white border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
| 187 |
-
/>
|
| 188 |
<Search className="absolute left-3 top-2.5 text-gray-400" size={16} />
|
| 189 |
</div>
|
| 190 |
-
|
| 191 |
-
<
|
| 192 |
-
<div className="w-40">
|
| 193 |
-
<select
|
| 194 |
-
className="w-full px-3 py-2 bg-white border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
| 195 |
-
value={selectedGrade}
|
| 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">
|
| 203 |
-
<select
|
| 204 |
-
className="w-full px-3 py-2 bg-white border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
| 205 |
-
value={selectedClass}
|
| 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>
|
| 213 |
</div>
|
| 214 |
</div>
|
| 215 |
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
<th className="px-6 py-
|
|
|
|
|
|
|
|
|
|
| 227 |
</tr>
|
| 228 |
</thead>
|
| 229 |
<tbody className="divide-y divide-gray-100">
|
| 230 |
-
{filteredStudents.
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
<p className="text-sm font-bold text-gray-800">{student.name}</p>
|
| 247 |
-
<p className="text-xs text-gray-500">{translateGender(student.gender)} | 2012年生</p>
|
| 248 |
-
</div>
|
| 249 |
-
</div>
|
| 250 |
-
</td>
|
| 251 |
-
<td className="px-6 py-4 text-sm text-gray-600 font-mono">{student.studentNo}</td>
|
| 252 |
-
<td className="px-6 py-4">
|
| 253 |
-
<span className="inline-flex items-center px-2.5 py-0.5 rounded-md text-xs font-medium bg-gray-100 text-gray-800 border border-gray-200">
|
| 254 |
-
{student.className}
|
| 255 |
-
</span>
|
| 256 |
-
</td>
|
| 257 |
-
<td className="px-6 py-4 text-sm text-gray-600 font-mono tracking-wide">{student.phone}</td>
|
| 258 |
-
<td className="px-6 py-4">
|
| 259 |
-
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
| 260 |
-
student.status === 'Enrolled' ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'
|
| 261 |
-
}`}>
|
| 262 |
-
{translateStatus(student.status)}
|
| 263 |
-
</span>
|
| 264 |
-
</td>
|
| 265 |
-
<td className="px-6 py-4 text-right">
|
| 266 |
-
<div className="flex items-center justify-end space-x-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
| 267 |
-
<button className="p-1.5 text-blue-600 hover:bg-blue-50 rounded-md transition-colors" title="编辑">
|
| 268 |
-
<Edit size={16} />
|
| 269 |
-
</button>
|
| 270 |
-
<button
|
| 271 |
-
onClick={() => handleDelete((student._id || student.id)!)}
|
| 272 |
-
className="p-1.5 text-red-600 hover:bg-red-50 rounded-md transition-colors" title="删除"
|
| 273 |
-
>
|
| 274 |
-
<Trash2 size={16} />
|
| 275 |
-
</button>
|
| 276 |
-
</div>
|
| 277 |
-
</td>
|
| 278 |
-
</tr>
|
| 279 |
-
))
|
| 280 |
-
)}
|
| 281 |
</tbody>
|
| 282 |
</table>
|
| 283 |
</div>
|
| 284 |
|
| 285 |
-
{
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 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
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
<
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
/>
|
| 331 |
-
</div>
|
| 332 |
-
<div className="space-y-1.5">
|
| 333 |
-
<label className="text-sm font-semibold text-gray-700">学号</label>
|
| 334 |
-
<input required type="text" className="w-full px-3 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none transition-all"
|
| 335 |
-
value={formData.studentNo} onChange={e => setFormData({...formData, studentNo: e.target.value})}
|
| 336 |
-
placeholder="例如: 2024001"
|
| 337 |
-
/>
|
| 338 |
-
</div>
|
| 339 |
-
</div>
|
| 340 |
-
|
| 341 |
-
<div className="space-y-1.5">
|
| 342 |
-
<label className="text-sm font-semibold text-gray-700">性别</label>
|
| 343 |
-
<div className="flex space-x-6 mt-1 p-1">
|
| 344 |
-
<label className="flex items-center space-x-2 cursor-pointer group">
|
| 345 |
-
<div className={`w-5 h-5 rounded-full border flex items-center justify-center ${formData.gender === 'Male' ? 'border-blue-600' : 'border-gray-300'}`}>
|
| 346 |
-
{formData.gender === 'Male' && <div className="w-3 h-3 rounded-full bg-blue-600" />}
|
| 347 |
-
</div>
|
| 348 |
-
<input type="radio" className="hidden" name="gender" value="Male" checked={formData.gender === 'Male'} onChange={() => setFormData({...formData, gender: 'Male'})} />
|
| 349 |
-
<span className="text-sm text-gray-700 group-hover:text-blue-600">男</span>
|
| 350 |
-
</label>
|
| 351 |
-
<label className="flex items-center space-x-2 cursor-pointer group">
|
| 352 |
-
<div className={`w-5 h-5 rounded-full border flex items-center justify-center ${formData.gender === 'Female' ? 'border-pink-600' : 'border-gray-300'}`}>
|
| 353 |
-
{formData.gender === 'Female' && <div className="w-3 h-3 rounded-full bg-pink-600" />}
|
| 354 |
-
</div>
|
| 355 |
-
<input type="radio" className="hidden" name="gender" value="Female" checked={formData.gender === 'Female'} onChange={() => setFormData({...formData, gender: 'Female'})} />
|
| 356 |
-
<span className="text-sm text-gray-700 group-hover:text-pink-600">女</span>
|
| 357 |
-
</label>
|
| 358 |
-
</div>
|
| 359 |
-
</div>
|
| 360 |
-
|
| 361 |
-
<div className="space-y-1.5">
|
| 362 |
-
<label className="text-sm font-semibold text-gray-700">班级</label>
|
| 363 |
-
<select className="w-full px-3 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none bg-white"
|
| 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>
|
| 372 |
-
|
| 373 |
-
<div className="space-y-1.5">
|
| 374 |
-
<label className="text-sm font-semibold text-gray-700">家长联系电话</label>
|
| 375 |
-
<input required type="tel" className="w-full px-3 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
| 376 |
-
value={formData.phone} onChange={e => setFormData({...formData, phone: e.target.value})}
|
| 377 |
-
placeholder="11位手机号码"
|
| 378 |
-
/>
|
| 379 |
-
</div>
|
| 380 |
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
|
|
|
| 388 |
</div>
|
| 389 |
-
|
| 390 |
-
</div>
|
| 391 |
</div>
|
| 392 |
)}
|
| 393 |
</div>
|
|
|
|
| 1 |
|
| 2 |
import React, { useState, useEffect } from 'react';
|
| 3 |
+
import { Search, Plus, Upload, Edit, Trash2, X, Loader2, User } from 'lucide-react';
|
| 4 |
import { api } from '../services/api';
|
| 5 |
import { Student, ClassInfo } from '../types';
|
| 6 |
|
|
|
|
| 12 |
const [isModalOpen, setIsModalOpen] = useState(false);
|
| 13 |
const [isImportOpen, setIsImportOpen] = useState(false);
|
| 14 |
const [submitting, setSubmitting] = useState(false);
|
| 15 |
+
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
| 16 |
|
| 17 |
// Filters
|
| 18 |
const [selectedGrade, setSelectedGrade] = useState('All');
|
|
|
|
| 41 |
setStudents(studentData);
|
| 42 |
setClassList(classData);
|
| 43 |
} catch (error) {
|
| 44 |
+
console.error(error);
|
| 45 |
} finally {
|
| 46 |
setLoading(false);
|
| 47 |
}
|
| 48 |
};
|
| 49 |
|
| 50 |
+
useEffect(() => { loadData(); }, []);
|
|
|
|
|
|
|
| 51 |
|
| 52 |
+
const handleDelete = async (id: string) => {
|
| 53 |
+
if (confirm('确定删除?')) {
|
| 54 |
await api.students.delete(id);
|
| 55 |
loadData();
|
| 56 |
}
|
| 57 |
};
|
| 58 |
|
| 59 |
+
const handleBatchDelete = async () => {
|
| 60 |
+
if (selectedIds.size === 0) return;
|
| 61 |
+
if (confirm(`确定要删除选中的 ${selectedIds.size} 名学生吗?`)) {
|
| 62 |
+
await api.batchDelete('student', Array.from(selectedIds));
|
| 63 |
+
setSelectedIds(new Set());
|
| 64 |
+
loadData();
|
| 65 |
+
}
|
| 66 |
+
};
|
| 67 |
+
|
| 68 |
+
const toggleSelect = (id: string) => {
|
| 69 |
+
const newSet = new Set(selectedIds);
|
| 70 |
+
if (newSet.has(id)) newSet.delete(id);
|
| 71 |
+
else newSet.add(id);
|
| 72 |
+
setSelectedIds(newSet);
|
| 73 |
+
};
|
| 74 |
+
|
| 75 |
+
const toggleSelectAll = (filtered: Student[]) => {
|
| 76 |
+
if (selectedIds.size === filtered.length && filtered.length > 0) {
|
| 77 |
+
setSelectedIds(new Set());
|
| 78 |
+
} else {
|
| 79 |
+
setSelectedIds(new Set(filtered.map(s => s._id || String(s.id))));
|
| 80 |
+
}
|
| 81 |
+
};
|
| 82 |
+
|
| 83 |
const handleAddSubmit = async (e: React.FormEvent) => {
|
| 84 |
e.preventDefault();
|
| 85 |
setSubmitting(true);
|
| 86 |
try {
|
| 87 |
await api.students.add({
|
| 88 |
...formData,
|
| 89 |
+
birthday: '2015-01-01',
|
| 90 |
+
status: 'Enrolled',
|
| 91 |
gender: formData.gender as any
|
| 92 |
});
|
| 93 |
setIsModalOpen(false);
|
|
|
|
| 94 |
loadData();
|
| 95 |
+
} catch (error) { alert('添加失败'); }
|
| 96 |
+
finally { setSubmitting(false); }
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
};
|
| 98 |
|
| 99 |
const handleImport = async () => {
|
| 100 |
if (!importText.trim()) return;
|
| 101 |
setSubmitting(true);
|
|
|
|
| 102 |
const lines = importText.trim().split('\n');
|
|
|
|
|
|
|
| 103 |
for (const line of lines) {
|
| 104 |
const parts = line.split(/[,\t\s]+/).filter(Boolean);
|
| 105 |
+
if (parts.length >= 2) {
|
| 106 |
try {
|
|
|
|
|
|
|
| 107 |
await api.students.add({
|
| 108 |
+
name: parts[0],
|
| 109 |
+
studentNo: parts[1],
|
| 110 |
+
gender: (parts[2] === '女' ? 'Female' : 'Male'),
|
| 111 |
+
className: parts[3] || '未分配',
|
| 112 |
+
phone: parts[4] || '无',
|
| 113 |
birthday: '2015-01-01',
|
| 114 |
status: 'Enrolled'
|
| 115 |
});
|
| 116 |
+
} catch (e) {}
|
|
|
|
| 117 |
}
|
| 118 |
}
|
|
|
|
|
|
|
| 119 |
setIsImportOpen(false);
|
|
|
|
| 120 |
setSubmitting(false);
|
| 121 |
loadData();
|
| 122 |
};
|
| 123 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
const filteredStudents = students.filter((s) => {
|
| 125 |
+
const matchesSearch = s.name.toLowerCase().includes(searchTerm.toLowerCase()) || s.studentNo.includes(searchTerm);
|
|
|
|
|
|
|
|
|
|
| 126 |
const matchesGrade = selectedGrade === 'All' || s.className.includes(selectedGrade);
|
| 127 |
const matchesClass = selectedClass === 'All' || s.className.includes(selectedClass);
|
|
|
|
| 128 |
return matchesSearch && matchesGrade && matchesClass;
|
| 129 |
});
|
| 130 |
|
| 131 |
+
const uniqueGrades = Array.from(new Set(classList.map(c => c.grade)));
|
| 132 |
+
const uniqueClasses = Array.from(new Set(classList.map(c => c.className)));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
|
| 134 |
return (
|
| 135 |
<div className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden relative min-h-[600px] flex flex-col">
|
| 136 |
+
{loading && <div className="absolute inset-0 bg-white/50 z-10 flex items-center justify-center"><Loader2 className="animate-spin text-blue-600" /></div>}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
|
|
|
|
| 138 |
<div className="p-6 border-b border-gray-100 space-y-4">
|
| 139 |
+
<div className="flex justify-between items-center">
|
| 140 |
<div>
|
| 141 |
+
<h2 className="text-lg font-bold text-gray-800">学生档案</h2>
|
| 142 |
+
<p className="text-sm text-gray-500">选中 {selectedIds.size} / {filteredStudents.length} 人</p>
|
| 143 |
</div>
|
| 144 |
+
<div className="flex space-x-2">
|
| 145 |
+
<button onClick={() => setIsImportOpen(true)} className="btn-secondary flex items-center space-x-2 px-3 py-2 border rounded-lg text-sm"><Upload size={16}/><span>导入</span></button>
|
| 146 |
+
<button onClick={() => setIsModalOpen(true)} className="btn-primary flex items-center space-x-2 px-3 py-2 bg-blue-600 text-white rounded-lg text-sm"><Plus size={16}/><span>新增</span></button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
</div>
|
| 148 |
</div>
|
| 149 |
|
| 150 |
+
<div className="flex gap-4 bg-gray-50 p-3 rounded-lg">
|
|
|
|
| 151 |
<div className="relative flex-1">
|
| 152 |
+
<input type="text" placeholder="搜索..." value={searchTerm} onChange={e => setSearchTerm(e.target.value)} className="w-full pl-9 pr-3 py-2 border rounded-md text-sm" />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
<Search className="absolute left-3 top-2.5 text-gray-400" size={16} />
|
| 154 |
</div>
|
| 155 |
+
<select className="w-32 border rounded-md text-sm" value={selectedGrade} onChange={e => setSelectedGrade(e.target.value)}><option value="All">年级</option>{uniqueGrades.map(g=><option key={g} value={g}>{g}</option>)}</select>
|
| 156 |
+
<select className="w-32 border rounded-md text-sm" value={selectedClass} onChange={e => setSelectedClass(e.target.value)}><option value="All">班级</option>{uniqueClasses.map(c=><option key={c} value={c}>{c}</option>)}</select>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 157 |
</div>
|
| 158 |
</div>
|
| 159 |
|
| 160 |
+
<div className="flex-1 overflow-auto">
|
| 161 |
+
<table className="w-full text-left">
|
| 162 |
+
<thead className="bg-gray-50 text-xs text-gray-500 uppercase sticky top-0">
|
| 163 |
+
<tr>
|
| 164 |
+
<th className="px-6 py-3 w-10">
|
| 165 |
+
<input type="checkbox"
|
| 166 |
+
checked={filteredStudents.length > 0 && selectedIds.size === filteredStudents.length}
|
| 167 |
+
onChange={() => toggleSelectAll(filteredStudents)}
|
| 168 |
+
/>
|
| 169 |
+
</th>
|
| 170 |
+
<th className="px-6 py-3">基本信息</th>
|
| 171 |
+
<th className="px-6 py-3">学号</th>
|
| 172 |
+
<th className="px-6 py-3">班级</th>
|
| 173 |
+
<th className="px-6 py-3 text-right">操作</th>
|
| 174 |
</tr>
|
| 175 |
</thead>
|
| 176 |
<tbody className="divide-y divide-gray-100">
|
| 177 |
+
{filteredStudents.map(s => (
|
| 178 |
+
<tr key={s._id || s.id} className="hover:bg-blue-50/30">
|
| 179 |
+
<td className="px-6 py-4">
|
| 180 |
+
<input type="checkbox" checked={selectedIds.has(s._id || String(s.id))} onChange={() => toggleSelect(s._id || String(s.id))} />
|
| 181 |
+
</td>
|
| 182 |
+
<td className="px-6 py-4 flex items-center space-x-3">
|
| 183 |
+
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-xs text-white ${s.gender==='Male'?'bg-blue-400':'bg-pink-400'}`}>{s.name[0]}</div>
|
| 184 |
+
<span className="font-bold text-gray-800">{s.name}</span>
|
| 185 |
+
</td>
|
| 186 |
+
<td className="px-6 py-4 text-sm font-mono text-gray-600">{s.studentNo}</td>
|
| 187 |
+
<td className="px-6 py-4 text-sm">{s.className}</td>
|
| 188 |
+
<td className="px-6 py-4 text-right">
|
| 189 |
+
<button onClick={() => handleDelete(s._id || String(s.id))} className="text-red-400 hover:text-red-600"><Trash2 size={16}/></button>
|
| 190 |
+
</td>
|
| 191 |
+
</tr>
|
| 192 |
+
))}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 193 |
</tbody>
|
| 194 |
</table>
|
| 195 |
</div>
|
| 196 |
|
| 197 |
+
{selectedIds.size > 0 && (
|
| 198 |
+
<div className="p-4 border-t border-gray-200 bg-white flex justify-between items-center shadow-[0_-4px_6px_-1px_rgba(0,0,0,0.1)]">
|
| 199 |
+
<span className="text-sm font-medium text-gray-700">已选择 {selectedIds.size} 项</span>
|
| 200 |
+
<button onClick={handleBatchDelete} className="px-4 py-2 bg-red-600 text-white text-sm rounded-lg hover:bg-red-700">批量删除</button>
|
| 201 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 202 |
)}
|
| 203 |
|
|
|
|
| 204 |
{isModalOpen && (
|
| 205 |
+
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
| 206 |
+
<div className="bg-white rounded-xl p-6 w-full max-w-md">
|
| 207 |
+
<h3 className="font-bold text-lg mb-4">新增学生</h3>
|
| 208 |
+
<form onSubmit={handleAddSubmit} className="space-y-4">
|
| 209 |
+
<input className="w-full border p-2 rounded" placeholder="姓名" value={formData.name} onChange={e=>setFormData({...formData, name:e.target.value})} required/>
|
| 210 |
+
<input className="w-full border p-2 rounded" placeholder="学号" value={formData.studentNo} onChange={e=>setFormData({...formData, studentNo:e.target.value})} required/>
|
| 211 |
+
<select className="w-full border p-2 rounded" value={formData.className} onChange={e=>setFormData({...formData, className:e.target.value})} required>
|
| 212 |
+
<option value="">选择班级</option>
|
| 213 |
+
{classList.map(c=><option key={c._id} value={c.grade+c.className}>{c.grade}{c.className}</option>)}
|
| 214 |
+
</select>
|
| 215 |
+
<button type="submit" disabled={submitting} className="w-full bg-blue-600 text-white py-2 rounded">{submitting?'提交中':'保存'}</button>
|
| 216 |
+
<button type="button" onClick={()=>setIsModalOpen(false)} className="w-full border py-2 rounded mt-2">取消</button>
|
| 217 |
+
</form>
|
| 218 |
+
</div>
|
| 219 |
+
</div>
|
| 220 |
+
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 221 |
|
| 222 |
+
{isImportOpen && (
|
| 223 |
+
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
| 224 |
+
<div className="bg-white rounded-xl p-6 w-full max-w-lg">
|
| 225 |
+
<h3 className="font-bold mb-2">粘贴导入 (姓名 学号 性别 班级)</h3>
|
| 226 |
+
<textarea className="w-full h-40 border p-2 text-sm" value={importText} onChange={e=>setImportText(e.target.value)}></textarea>
|
| 227 |
+
<div className="flex gap-2 mt-4">
|
| 228 |
+
<button onClick={handleImport} className="flex-1 bg-blue-600 text-white py-2 rounded">确定导入</button>
|
| 229 |
+
<button onClick={()=>setIsImportOpen(false)} className="flex-1 border py-2 rounded">取消</button>
|
| 230 |
</div>
|
| 231 |
+
</div>
|
|
|
|
| 232 |
</div>
|
| 233 |
)}
|
| 234 |
</div>
|
pages/SubjectList.tsx
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import React, { useState, useEffect } from 'react';
|
| 3 |
+
import { Subject } from '../types';
|
| 4 |
+
import { api } from '../services/api';
|
| 5 |
+
import { Loader2, Plus, Trash2, Palette } from 'lucide-react';
|
| 6 |
+
|
| 7 |
+
export const SubjectList: React.FC = () => {
|
| 8 |
+
const [subjects, setSubjects] = useState<Subject[]>([]);
|
| 9 |
+
const [loading, setLoading] = useState(true);
|
| 10 |
+
const [newSub, setNewSub] = useState({ name: '', code: '', color: '#3b82f6' });
|
| 11 |
+
|
| 12 |
+
const loadSubjects = async () => {
|
| 13 |
+
setLoading(true);
|
| 14 |
+
try { setSubjects(await api.subjects.getAll()); }
|
| 15 |
+
catch(e) { console.error(e); }
|
| 16 |
+
finally { setLoading(false); }
|
| 17 |
+
};
|
| 18 |
+
|
| 19 |
+
useEffect(() => { loadSubjects(); }, []);
|
| 20 |
+
|
| 21 |
+
const handleAdd = async () => {
|
| 22 |
+
if (!newSub.name) return;
|
| 23 |
+
await api.subjects.add(newSub);
|
| 24 |
+
setNewSub({ name: '', code: '', color: '#3b82f6' });
|
| 25 |
+
loadSubjects();
|
| 26 |
+
};
|
| 27 |
+
|
| 28 |
+
const handleDelete = async (id: string) => {
|
| 29 |
+
if(confirm('删除学科可能会影响相关成绩数据,确定继续吗?')) {
|
| 30 |
+
await api.subjects.delete(id);
|
| 31 |
+
loadSubjects();
|
| 32 |
+
}
|
| 33 |
+
};
|
| 34 |
+
|
| 35 |
+
if(loading) return <div className="flex justify-center p-10"><Loader2 className="animate-spin text-blue-600"/></div>;
|
| 36 |
+
|
| 37 |
+
return (
|
| 38 |
+
<div className="space-y-6">
|
| 39 |
+
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6">
|
| 40 |
+
<h2 className="text-xl font-bold text-gray-800 mb-6 flex items-center">
|
| 41 |
+
<Palette className="mr-2 text-purple-600" />
|
| 42 |
+
学科设置
|
| 43 |
+
</h2>
|
| 44 |
+
|
| 45 |
+
{/* Add Form */}
|
| 46 |
+
<div className="flex gap-4 mb-8 bg-gray-50 p-4 rounded-lg items-end">
|
| 47 |
+
<div>
|
| 48 |
+
<label className="text-xs font-bold text-gray-500 uppercase">学科名称</label>
|
| 49 |
+
<input type="text" placeholder="如: 编程" className="w-full mt-1 px-3 py-2 border rounded"
|
| 50 |
+
value={newSub.name} onChange={e => setNewSub({...newSub, name: e.target.value})}
|
| 51 |
+
/>
|
| 52 |
+
</div>
|
| 53 |
+
<div>
|
| 54 |
+
<label className="text-xs font-bold text-gray-500 uppercase">代码(选填)</label>
|
| 55 |
+
<input type="text" placeholder="如: CODE" className="w-full mt-1 px-3 py-2 border rounded"
|
| 56 |
+
value={newSub.code} onChange={e => setNewSub({...newSub, code: e.target.value})}
|
| 57 |
+
/>
|
| 58 |
+
</div>
|
| 59 |
+
<div>
|
| 60 |
+
<label className="text-xs font-bold text-gray-500 uppercase">代表色</label>
|
| 61 |
+
<input type="color" className="w-full mt-1 h-10 border rounded cursor-pointer"
|
| 62 |
+
value={newSub.color} onChange={e => setNewSub({...newSub, color: e.target.value})}
|
| 63 |
+
/>
|
| 64 |
+
</div>
|
| 65 |
+
<button onClick={handleAdd} className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 h-10 flex items-center">
|
| 66 |
+
<Plus size={16} className="mr-1" /> 添加
|
| 67 |
+
</button>
|
| 68 |
+
</div>
|
| 69 |
+
|
| 70 |
+
{/* List */}
|
| 71 |
+
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
| 72 |
+
{subjects.map(sub => (
|
| 73 |
+
<div key={sub._id || sub.id} className="border border-gray-200 rounded-lg p-4 flex justify-between items-center bg-white shadow-sm">
|
| 74 |
+
<div className="flex items-center space-x-3">
|
| 75 |
+
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: sub.color }}></div>
|
| 76 |
+
<div>
|
| 77 |
+
<p className="font-bold text-gray-800">{sub.name}</p>
|
| 78 |
+
<p className="text-xs text-gray-500">{sub.code}</p>
|
| 79 |
+
</div>
|
| 80 |
+
</div>
|
| 81 |
+
<button onClick={() => handleDelete(sub._id || String(sub.id))} className="text-gray-400 hover:text-red-500">
|
| 82 |
+
<Trash2 size={16} />
|
| 83 |
+
</button>
|
| 84 |
+
</div>
|
| 85 |
+
))}
|
| 86 |
+
</div>
|
| 87 |
+
</div>
|
| 88 |
+
</div>
|
| 89 |
+
);
|
| 90 |
+
};
|
pages/UserList.tsx
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import React, { useState, useEffect } from 'react';
|
| 3 |
+
import { User, UserRole, UserStatus } from '../types';
|
| 4 |
+
import { api } from '../services/api';
|
| 5 |
+
import { Loader2, Check, X, Shield, Trash2, Edit } from 'lucide-react';
|
| 6 |
+
|
| 7 |
+
export const UserList: React.FC = () => {
|
| 8 |
+
const [users, setUsers] = useState<User[]>([]);
|
| 9 |
+
const [loading, setLoading] = useState(true);
|
| 10 |
+
|
| 11 |
+
const loadUsers = async () => {
|
| 12 |
+
setLoading(true);
|
| 13 |
+
try {
|
| 14 |
+
const data = await api.users.getAll();
|
| 15 |
+
setUsers(data);
|
| 16 |
+
} catch (e) { console.error(e); }
|
| 17 |
+
finally { setLoading(false); }
|
| 18 |
+
};
|
| 19 |
+
|
| 20 |
+
useEffect(() => { loadUsers(); }, []);
|
| 21 |
+
|
| 22 |
+
const handleApprove = async (user: User) => {
|
| 23 |
+
if (confirm(`确认批准 ${user.username} 的注册申请?`)) {
|
| 24 |
+
await api.users.update(user._id || String(user.id), { status: UserStatus.ACTIVE });
|
| 25 |
+
loadUsers();
|
| 26 |
+
}
|
| 27 |
+
};
|
| 28 |
+
|
| 29 |
+
const handleDelete = async (user: User) => {
|
| 30 |
+
if (user.role === UserRole.ADMIN && user.username === 'admin') {
|
| 31 |
+
return alert('无法删除超级管理员');
|
| 32 |
+
}
|
| 33 |
+
if (confirm(`警告:确定要删除用户 ${user.username} 吗?`)) {
|
| 34 |
+
await api.users.delete(user._id || String(user.id));
|
| 35 |
+
loadUsers();
|
| 36 |
+
}
|
| 37 |
+
};
|
| 38 |
+
|
| 39 |
+
const handleRoleChange = async (user: User, newRole: UserRole) => {
|
| 40 |
+
if (user.username === 'admin') return alert('无法修改超级管理员');
|
| 41 |
+
await api.users.update(user._id || String(user.id), { role: newRole });
|
| 42 |
+
loadUsers();
|
| 43 |
+
};
|
| 44 |
+
|
| 45 |
+
const getStatusBadge = (status: UserStatus) => {
|
| 46 |
+
switch(status) {
|
| 47 |
+
case UserStatus.ACTIVE: return <span className="bg-green-100 text-green-700 px-2 py-1 rounded-full text-xs">已激活</span>;
|
| 48 |
+
case UserStatus.PENDING: return <span className="bg-yellow-100 text-yellow-700 px-2 py-1 rounded-full text-xs animate-pulse">待审核</span>;
|
| 49 |
+
case UserStatus.BANNED: return <span className="bg-red-100 text-red-700 px-2 py-1 rounded-full text-xs">已停用</span>;
|
| 50 |
+
}
|
| 51 |
+
};
|
| 52 |
+
|
| 53 |
+
if (loading) return <div className="flex justify-center py-10"><Loader2 className="animate-spin text-blue-600" /></div>;
|
| 54 |
+
|
| 55 |
+
return (
|
| 56 |
+
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6">
|
| 57 |
+
<h2 className="text-xl font-bold text-gray-800 mb-4">用户权限与审核</h2>
|
| 58 |
+
|
| 59 |
+
<div className="overflow-x-auto">
|
| 60 |
+
<table className="w-full text-left border-collapse">
|
| 61 |
+
<thead>
|
| 62 |
+
<tr className="bg-gray-50 text-gray-500 text-xs uppercase">
|
| 63 |
+
<th className="px-4 py-3">用户名</th>
|
| 64 |
+
<th className="px-4 py-3">角色</th>
|
| 65 |
+
<th className="px-4 py-3">状态</th>
|
| 66 |
+
<th className="px-4 py-3 text-right">操作</th>
|
| 67 |
+
</tr>
|
| 68 |
+
</thead>
|
| 69 |
+
<tbody className="divide-y divide-gray-100">
|
| 70 |
+
{users.map(user => (
|
| 71 |
+
<tr key={user._id || user.id} className="hover:bg-gray-50">
|
| 72 |
+
<td className="px-4 py-3 font-medium text-gray-800">{user.username}</td>
|
| 73 |
+
<td className="px-4 py-3">
|
| 74 |
+
<select
|
| 75 |
+
value={user.role}
|
| 76 |
+
onChange={(e) => handleRoleChange(user, e.target.value as UserRole)}
|
| 77 |
+
disabled={user.username === 'admin'}
|
| 78 |
+
className="bg-white border border-gray-200 rounded px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
| 79 |
+
>
|
| 80 |
+
<option value={UserRole.ADMIN}>管理员</option>
|
| 81 |
+
<option value={UserRole.TEACHER}>教师</option>
|
| 82 |
+
<option value={UserRole.STUDENT}>学生</option>
|
| 83 |
+
</select>
|
| 84 |
+
</td>
|
| 85 |
+
<td className="px-4 py-3">{getStatusBadge(user.status)}</td>
|
| 86 |
+
<td className="px-4 py-3 text-right space-x-2">
|
| 87 |
+
{user.status === UserStatus.PENDING && (
|
| 88 |
+
<button
|
| 89 |
+
onClick={() => handleApprove(user)}
|
| 90 |
+
className="text-green-600 hover:bg-green-50 p-1 rounded" title="通过审核"
|
| 91 |
+
>
|
| 92 |
+
<Check size={18} />
|
| 93 |
+
</button>
|
| 94 |
+
)}
|
| 95 |
+
<button
|
| 96 |
+
onClick={() => handleDelete(user)}
|
| 97 |
+
className="text-red-600 hover:bg-red-50 p-1 rounded" title="删除用户"
|
| 98 |
+
disabled={user.username === 'admin'}
|
| 99 |
+
>
|
| 100 |
+
<Trash2 size={18} />
|
| 101 |
+
</button>
|
| 102 |
+
</td>
|
| 103 |
+
</tr>
|
| 104 |
+
))}
|
| 105 |
+
</tbody>
|
| 106 |
+
</table>
|
| 107 |
+
</div>
|
| 108 |
+
</div>
|
| 109 |
+
);
|
| 110 |
+
};
|
server.js
CHANGED
|
@@ -9,32 +9,25 @@ const path = require('path');
|
|
| 9 |
// 核心配置 (Hugging Face 必须使用 7860 端口)
|
| 10 |
// ==========================================
|
| 11 |
const PORT = 7860;
|
| 12 |
-
// 修改点:添加 authSource=admin 参数,这是解决 "bad auth" 的关键
|
| 13 |
const MONGO_URI = 'mongodb+srv://dv890a:db8822723@chatpro.gw3v0v7.mongodb.net/chatpro?retryWrites=true&w=majority&appName=chatpro&authSource=admin';
|
| 14 |
|
| 15 |
const app = express();
|
| 16 |
|
| 17 |
-
// 中间件
|
| 18 |
app.use(cors());
|
| 19 |
app.use(bodyParser.json());
|
| 20 |
-
|
| 21 |
-
// ==========================================
|
| 22 |
-
// 1. 托管静态前端文件 (Production Build)
|
| 23 |
-
// ==========================================
|
| 24 |
-
// 当部署到云端时,Node.js 会负责发送 React 编译好的 HTML/JS/CSS
|
| 25 |
app.use(express.static(path.join(__dirname, 'dist')));
|
| 26 |
|
| 27 |
// ==========================================
|
| 28 |
-
//
|
| 29 |
// ==========================================
|
| 30 |
|
| 31 |
-
// 内存数据库 (降级方案)
|
| 32 |
const InMemoryDB = {
|
| 33 |
users: [],
|
| 34 |
students: [],
|
| 35 |
courses: [],
|
| 36 |
scores: [],
|
| 37 |
classes: [],
|
|
|
|
| 38 |
config: {
|
| 39 |
systemName: '智慧校园管理系统',
|
| 40 |
semester: '2023-2024学年 第一学期',
|
|
@@ -45,34 +38,28 @@ const InMemoryDB = {
|
|
| 45 |
isFallback: false
|
| 46 |
};
|
| 47 |
|
| 48 |
-
// 连接逻辑
|
| 49 |
const connectDB = async () => {
|
| 50 |
try {
|
| 51 |
-
|
| 52 |
-
await mongoose.connect(MONGO_URI, {
|
| 53 |
-
serverSelectionTimeoutMS: 5000
|
| 54 |
-
});
|
| 55 |
console.log('✅ MongoDB 连接成功 (Real Data)');
|
| 56 |
} catch (err) {
|
| 57 |
console.error('❌ MongoDB 连接失败:', err.message);
|
| 58 |
-
console.warn('⚠️ 启动内存数据库模式
|
| 59 |
InMemoryDB.isFallback = true;
|
| 60 |
-
|
| 61 |
-
// 初始化内存默认用户
|
| 62 |
InMemoryDB.users.push(
|
| 63 |
-
{ username: 'admin', password: 'admin', role: 'ADMIN', email: 'admin@school.edu' }
|
| 64 |
-
{ username: 'teacher', password: 'teacher', role: 'TEACHER', email: 'teacher@school.edu' }
|
| 65 |
);
|
| 66 |
}
|
| 67 |
};
|
| 68 |
connectDB();
|
| 69 |
|
| 70 |
-
//
|
| 71 |
const UserSchema = new mongoose.Schema({
|
| 72 |
username: { type: String, required: true, unique: true },
|
| 73 |
password: { type: String, required: true },
|
| 74 |
role: { type: String, enum: ['ADMIN', 'TEACHER', 'STUDENT'], default: 'STUDENT' },
|
| 75 |
-
status: { type: String,
|
| 76 |
email: String,
|
| 77 |
avatar: String,
|
| 78 |
createTime: { type: Date, default: Date.now }
|
|
@@ -118,8 +105,15 @@ const ClassSchema = new mongoose.Schema({
|
|
| 118 |
});
|
| 119 |
const ClassModel = mongoose.model('Class', ClassSchema);
|
| 120 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
const ConfigSchema = new mongoose.Schema({
|
| 122 |
-
key: { type: String, default: 'main' },
|
| 123 |
systemName: String,
|
| 124 |
semester: String,
|
| 125 |
allowRegister: Boolean,
|
|
@@ -128,273 +122,263 @@ const ConfigSchema = new mongoose.Schema({
|
|
| 128 |
});
|
| 129 |
const ConfigModel = mongoose.model('Config', ConfigSchema);
|
| 130 |
|
| 131 |
-
|
| 132 |
-
// 初始化数据逻辑 (仅 MongoDB 模式)
|
| 133 |
const initData = async () => {
|
| 134 |
if (InMemoryDB.isFallback) return;
|
| 135 |
try {
|
|
|
|
| 136 |
const userCount = await User.countDocuments();
|
| 137 |
if (userCount === 0) {
|
| 138 |
-
console.log('正在初始化基础数据...');
|
| 139 |
await User.create([
|
| 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('
|
| 171 |
}
|
| 172 |
};
|
| 173 |
mongoose.connection.once('open', initData);
|
| 174 |
|
| 175 |
// ==========================================
|
| 176 |
-
//
|
| 177 |
// ==========================================
|
| 178 |
|
| 179 |
// --- Auth ---
|
| 180 |
app.post('/api/auth/login', async (req, res) => {
|
| 181 |
const { username, password } = req.body;
|
| 182 |
try {
|
|
|
|
| 183 |
if (InMemoryDB.isFallback) {
|
| 184 |
-
|
| 185 |
-
|
|
|
|
| 186 |
}
|
|
|
|
|
|
|
| 187 |
|
| 188 |
-
|
| 189 |
-
if (user)
|
| 190 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 191 |
} catch (e) { res.status(500).json({ error: e.message }); }
|
| 192 |
});
|
| 193 |
|
| 194 |
app.post('/api/auth/register', async (req, res) => {
|
| 195 |
const { username, password, role } = req.body;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 196 |
try {
|
| 197 |
if (InMemoryDB.isFallback) {
|
| 198 |
-
if (InMemoryDB.users.find(u => u.username === username)) {
|
| 199 |
-
|
| 200 |
-
}
|
| 201 |
-
const newUser = { id: Date.now(), username, password, role: role || 'STUDENT', status: 'active' };
|
| 202 |
InMemoryDB.users.push(newUser);
|
| 203 |
return res.json(newUser);
|
| 204 |
}
|
| 205 |
-
|
| 206 |
const existing = await User.findOne({ username });
|
| 207 |
-
if (existing) return res.status(400).json({ error: '
|
| 208 |
|
| 209 |
-
const newUser = await User.create({ username, password, role: role || 'STUDENT' });
|
| 210 |
res.json(newUser);
|
| 211 |
} catch (e) { res.status(500).json({ error: e.message }); }
|
| 212 |
});
|
| 213 |
|
| 214 |
-
// ---
|
| 215 |
-
app.get('/api/
|
| 216 |
-
if (InMemoryDB.isFallback) return res.json(InMemoryDB.
|
| 217 |
-
const
|
| 218 |
-
res.json(
|
| 219 |
});
|
| 220 |
-
app.
|
| 221 |
try {
|
| 222 |
if (InMemoryDB.isFallback) {
|
| 223 |
-
const
|
| 224 |
-
InMemoryDB.
|
| 225 |
-
return res.json(
|
| 226 |
}
|
| 227 |
-
|
| 228 |
-
res.json(
|
| 229 |
-
} catch (e) { res.status(
|
| 230 |
});
|
| 231 |
-
app.delete('/api/
|
| 232 |
try {
|
| 233 |
if (InMemoryDB.isFallback) {
|
| 234 |
-
InMemoryDB.
|
| 235 |
return res.json({ success: true });
|
| 236 |
}
|
| 237 |
-
await
|
| 238 |
res.json({ success: true });
|
| 239 |
} catch (e) { res.status(500).json({ error: e.message }); }
|
| 240 |
});
|
| 241 |
|
| 242 |
-
// ---
|
| 243 |
-
app.get('/api/
|
| 244 |
-
if (InMemoryDB.isFallback) return res.json(InMemoryDB.
|
| 245 |
-
const
|
| 246 |
-
|
| 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/
|
| 254 |
try {
|
| 255 |
if (InMemoryDB.isFallback) {
|
| 256 |
-
const
|
| 257 |
-
InMemoryDB.
|
| 258 |
-
return res.json(
|
| 259 |
}
|
| 260 |
-
const
|
| 261 |
-
res.json(
|
| 262 |
} catch (e) { res.status(400).json({ error: e.message }); }
|
| 263 |
});
|
| 264 |
-
app.delete('/api/
|
| 265 |
try {
|
| 266 |
if (InMemoryDB.isFallback) {
|
| 267 |
-
InMemoryDB.
|
| 268 |
return res.json({ success: true });
|
| 269 |
}
|
| 270 |
-
await
|
| 271 |
res.json({ success: true });
|
| 272 |
} catch (e) { res.status(500).json({ error: e.message }); }
|
| 273 |
});
|
| 274 |
|
| 275 |
-
// ---
|
| 276 |
-
app.
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
res.json(config);
|
| 281 |
-
});
|
| 282 |
-
app.post('/api/config', async (req, res) => {
|
| 283 |
try {
|
| 284 |
if (InMemoryDB.isFallback) {
|
| 285 |
-
InMemoryDB.
|
| 286 |
-
|
|
|
|
|
|
|
| 287 |
}
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 291 |
});
|
| 292 |
|
| 293 |
-
// ---
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 294 |
app.get('/api/courses', async (req, res) => {
|
| 295 |
if (InMemoryDB.isFallback) return res.json(InMemoryDB.courses);
|
| 296 |
-
|
| 297 |
-
res.json(courses);
|
| 298 |
});
|
| 299 |
app.post('/api/courses', async (req, res) => {
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
const newCourse = { ...req.body, id: Date.now(), _id: Date.now().toString() };
|
| 303 |
-
InMemoryDB.courses.push(newCourse);
|
| 304 |
-
return res.json(newCourse);
|
| 305 |
-
}
|
| 306 |
-
const newCourse = await Course.create(req.body);
|
| 307 |
-
res.json(newCourse);
|
| 308 |
-
} catch (e) { res.status(400).json({ error: e.message }); }
|
| 309 |
});
|
| 310 |
app.put('/api/courses/:id', async (req, res) => {
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
const idx = InMemoryDB.courses.findIndex(c => c.id == req.params.id || c._id == req.params.id);
|
| 314 |
-
if (idx !== -1) {
|
| 315 |
-
InMemoryDB.courses[idx] = { ...InMemoryDB.courses[idx], ...req.body };
|
| 316 |
-
return res.json(InMemoryDB.courses[idx]);
|
| 317 |
-
}
|
| 318 |
-
return res.status(404).json({ error: 'Not found' });
|
| 319 |
-
}
|
| 320 |
-
const updated = await Course.findByIdAndUpdate(req.params.id, req.body, { new: true });
|
| 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 |
-
|
| 326 |
-
|
| 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 |
-
//
|
| 336 |
app.get('/api/scores', async (req, res) => {
|
| 337 |
if (InMemoryDB.isFallback) return res.json(InMemoryDB.scores);
|
| 338 |
-
|
| 339 |
-
res.json(scores);
|
| 340 |
});
|
| 341 |
app.post('/api/scores', async (req, res) => {
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
const newScore = { ...req.body, id: Date.now(), _id: Date.now().toString() };
|
| 345 |
-
InMemoryDB.scores.push(newScore);
|
| 346 |
-
return res.json(newScore);
|
| 347 |
-
}
|
| 348 |
-
const newScore = await Score.create(req.body);
|
| 349 |
-
res.json(newScore);
|
| 350 |
-
} catch (e) { res.status(400).json({ error: e.message }); }
|
| 351 |
});
|
| 352 |
app.delete('/api/scores/:id', async (req, res) => {
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
InMemoryDB.scores = InMemoryDB.scores.filter(s => s._id !== req.params.id && s.id != req.params.id);
|
| 356 |
-
return res.json({ success: true });
|
| 357 |
-
}
|
| 358 |
-
await Score.findByIdAndDelete(req.params.id);
|
| 359 |
-
res.json({ success: true });
|
| 360 |
-
} catch (e) { res.status(500).json({ error: e.message }); }
|
| 361 |
});
|
| 362 |
|
| 363 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 364 |
app.get('/api/stats', async (req, res) => {
|
| 365 |
try {
|
| 366 |
if (InMemoryDB.isFallback) {
|
| 367 |
-
|
| 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
|
| 387 |
});
|
| 388 |
|
| 389 |
-
//
|
| 390 |
-
// 4. 处理前端路由 (Catch-all)
|
| 391 |
-
// ==========================================
|
| 392 |
-
// 所有未匹配到 API 的请求,都返回 index.html,让 React Router 处理页面跳转
|
| 393 |
app.get('*', (req, res) => {
|
| 394 |
res.sendFile(path.join(__dirname, 'dist', 'index.html'));
|
| 395 |
});
|
| 396 |
|
| 397 |
-
|
| 398 |
-
app.listen(PORT, () => {
|
| 399 |
-
console.log(`🚀 服务已启动,监听端口: ${PORT}`);
|
| 400 |
-
});
|
|
|
|
| 9 |
// 核心配置 (Hugging Face 必须使用 7860 端口)
|
| 10 |
// ==========================================
|
| 11 |
const PORT = 7860;
|
|
|
|
| 12 |
const MONGO_URI = 'mongodb+srv://dv890a:db8822723@chatpro.gw3v0v7.mongodb.net/chatpro?retryWrites=true&w=majority&appName=chatpro&authSource=admin';
|
| 13 |
|
| 14 |
const app = express();
|
| 15 |
|
|
|
|
| 16 |
app.use(cors());
|
| 17 |
app.use(bodyParser.json());
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
app.use(express.static(path.join(__dirname, 'dist')));
|
| 19 |
|
| 20 |
// ==========================================
|
| 21 |
+
// Database Models
|
| 22 |
// ==========================================
|
| 23 |
|
|
|
|
| 24 |
const InMemoryDB = {
|
| 25 |
users: [],
|
| 26 |
students: [],
|
| 27 |
courses: [],
|
| 28 |
scores: [],
|
| 29 |
classes: [],
|
| 30 |
+
subjects: [],
|
| 31 |
config: {
|
| 32 |
systemName: '智慧校园管理系统',
|
| 33 |
semester: '2023-2024学年 第一学期',
|
|
|
|
| 38 |
isFallback: false
|
| 39 |
};
|
| 40 |
|
|
|
|
| 41 |
const connectDB = async () => {
|
| 42 |
try {
|
| 43 |
+
await mongoose.connect(MONGO_URI, { serverSelectionTimeoutMS: 5000 });
|
|
|
|
|
|
|
|
|
|
| 44 |
console.log('✅ MongoDB 连接成功 (Real Data)');
|
| 45 |
} catch (err) {
|
| 46 |
console.error('❌ MongoDB 连接失败:', err.message);
|
| 47 |
+
console.warn('⚠️ 启动内存数据库模式');
|
| 48 |
InMemoryDB.isFallback = true;
|
| 49 |
+
// Init Memory Admin
|
|
|
|
| 50 |
InMemoryDB.users.push(
|
| 51 |
+
{ username: 'admin', password: 'admin', role: 'ADMIN', status: 'active', email: 'admin@school.edu' }
|
|
|
|
| 52 |
);
|
| 53 |
}
|
| 54 |
};
|
| 55 |
connectDB();
|
| 56 |
|
| 57 |
+
// Schemas
|
| 58 |
const UserSchema = new mongoose.Schema({
|
| 59 |
username: { type: String, required: true, unique: true },
|
| 60 |
password: { type: String, required: true },
|
| 61 |
role: { type: String, enum: ['ADMIN', 'TEACHER', 'STUDENT'], default: 'STUDENT' },
|
| 62 |
+
status: { type: String, enum: ['active', 'pending', 'banned'], default: 'pending' },
|
| 63 |
email: String,
|
| 64 |
avatar: String,
|
| 65 |
createTime: { type: Date, default: Date.now }
|
|
|
|
| 105 |
});
|
| 106 |
const ClassModel = mongoose.model('Class', ClassSchema);
|
| 107 |
|
| 108 |
+
const SubjectSchema = new mongoose.Schema({
|
| 109 |
+
name: { type: String, required: true, unique: true },
|
| 110 |
+
code: String,
|
| 111 |
+
color: String
|
| 112 |
+
});
|
| 113 |
+
const SubjectModel = mongoose.model('Subject', SubjectSchema);
|
| 114 |
+
|
| 115 |
const ConfigSchema = new mongoose.Schema({
|
| 116 |
+
key: { type: String, default: 'main' },
|
| 117 |
systemName: String,
|
| 118 |
semester: String,
|
| 119 |
allowRegister: Boolean,
|
|
|
|
| 122 |
});
|
| 123 |
const ConfigModel = mongoose.model('Config', ConfigSchema);
|
| 124 |
|
| 125 |
+
// Init Data
|
|
|
|
| 126 |
const initData = async () => {
|
| 127 |
if (InMemoryDB.isFallback) return;
|
| 128 |
try {
|
| 129 |
+
// Admin
|
| 130 |
const userCount = await User.countDocuments();
|
| 131 |
if (userCount === 0) {
|
|
|
|
| 132 |
await User.create([
|
| 133 |
+
{ username: 'admin', password: 'admin', role: 'ADMIN', status: 'active', email: 'admin@school.edu' }
|
|
|
|
| 134 |
]);
|
| 135 |
}
|
| 136 |
+
// Subjects
|
| 137 |
+
const subjCount = await SubjectModel.countDocuments();
|
| 138 |
+
if (subjCount === 0) {
|
| 139 |
+
await SubjectModel.create([
|
| 140 |
+
{ name: '语文', code: 'CHI', color: '#ef4444' },
|
| 141 |
+
{ name: '数学', code: 'MAT', color: '#3b82f6' },
|
| 142 |
+
{ name: '英语', code: 'ENG', color: '#f59e0b' },
|
| 143 |
+
{ name: '科学', code: 'SCI', color: '#10b981' }
|
| 144 |
+
]);
|
| 145 |
+
}
|
| 146 |
+
// Config
|
| 147 |
const config = await ConfigModel.findOne({ key: 'main' });
|
| 148 |
if (!config) {
|
| 149 |
+
await ConfigModel.create({ key: 'main', systemName: '智慧校园管理系统', semester: '2023-2024学年 第一学期' });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
}
|
|
|
|
|
|
|
| 151 |
} catch (err) {
|
| 152 |
+
console.error('Init Data Error', err);
|
| 153 |
}
|
| 154 |
};
|
| 155 |
mongoose.connection.once('open', initData);
|
| 156 |
|
| 157 |
// ==========================================
|
| 158 |
+
// API Routes
|
| 159 |
// ==========================================
|
| 160 |
|
| 161 |
// --- Auth ---
|
| 162 |
app.post('/api/auth/login', async (req, res) => {
|
| 163 |
const { username, password } = req.body;
|
| 164 |
try {
|
| 165 |
+
let user;
|
| 166 |
if (InMemoryDB.isFallback) {
|
| 167 |
+
user = InMemoryDB.users.find(u => u.username === username && u.password === password);
|
| 168 |
+
} else {
|
| 169 |
+
user = await User.findOne({ username, password });
|
| 170 |
}
|
| 171 |
+
|
| 172 |
+
if (!user) return res.status(401).json({ message: '用户名或密码错误' });
|
| 173 |
|
| 174 |
+
// Check Status
|
| 175 |
+
if (user.status === 'pending') {
|
| 176 |
+
return res.status(403).json({ error: 'PENDING_APPROVAL', message: '账号待审核,请联系管理员' });
|
| 177 |
+
}
|
| 178 |
+
if (user.status === 'banned') {
|
| 179 |
+
return res.status(403).json({ error: 'BANNED', message: '账号已被停用' });
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
res.json(user);
|
| 183 |
} catch (e) { res.status(500).json({ error: e.message }); }
|
| 184 |
});
|
| 185 |
|
| 186 |
app.post('/api/auth/register', async (req, res) => {
|
| 187 |
const { username, password, role } = req.body;
|
| 188 |
+
// Default status: Admin auto-active (for demo simplicity if needed, but standard is Pending for all)
|
| 189 |
+
// Here we set 'pending' for everyone except the very first user (handled by init)
|
| 190 |
+
const status = 'pending';
|
| 191 |
+
|
| 192 |
try {
|
| 193 |
if (InMemoryDB.isFallback) {
|
| 194 |
+
if (InMemoryDB.users.find(u => u.username === username)) return res.status(400).json({ error: 'Existed' });
|
| 195 |
+
const newUser = { id: Date.now(), username, password, role: role || 'STUDENT', status };
|
|
|
|
|
|
|
| 196 |
InMemoryDB.users.push(newUser);
|
| 197 |
return res.json(newUser);
|
| 198 |
}
|
| 199 |
+
|
| 200 |
const existing = await User.findOne({ username });
|
| 201 |
+
if (existing) return res.status(400).json({ error: 'Existed' });
|
| 202 |
|
| 203 |
+
const newUser = await User.create({ username, password, role: role || 'STUDENT', status });
|
| 204 |
res.json(newUser);
|
| 205 |
} catch (e) { res.status(500).json({ error: e.message }); }
|
| 206 |
});
|
| 207 |
|
| 208 |
+
// --- Users (Admin) ---
|
| 209 |
+
app.get('/api/users', async (req, res) => {
|
| 210 |
+
if (InMemoryDB.isFallback) return res.json(InMemoryDB.users);
|
| 211 |
+
const users = await User.find().sort({ createTime: -1 });
|
| 212 |
+
res.json(users);
|
| 213 |
});
|
| 214 |
+
app.put('/api/users/:id', async (req, res) => {
|
| 215 |
try {
|
| 216 |
if (InMemoryDB.isFallback) {
|
| 217 |
+
const idx = InMemoryDB.users.findIndex(u => u._id === req.params.id || u.id == req.params.id);
|
| 218 |
+
if (idx !== -1) InMemoryDB.users[idx] = { ...InMemoryDB.users[idx], ...req.body };
|
| 219 |
+
return res.json({ success: true });
|
| 220 |
}
|
| 221 |
+
await User.findByIdAndUpdate(req.params.id, req.body);
|
| 222 |
+
res.json({ success: true });
|
| 223 |
+
} catch (e) { res.status(500).json({ error: e.message }); }
|
| 224 |
});
|
| 225 |
+
app.delete('/api/users/:id', async (req, res) => {
|
| 226 |
try {
|
| 227 |
if (InMemoryDB.isFallback) {
|
| 228 |
+
InMemoryDB.users = InMemoryDB.users.filter(u => u._id !== req.params.id && u.id != req.params.id);
|
| 229 |
return res.json({ success: true });
|
| 230 |
}
|
| 231 |
+
await User.findByIdAndDelete(req.params.id);
|
| 232 |
res.json({ success: true });
|
| 233 |
} catch (e) { res.status(500).json({ error: e.message }); }
|
| 234 |
});
|
| 235 |
|
| 236 |
+
// --- Subjects ---
|
| 237 |
+
app.get('/api/subjects', async (req, res) => {
|
| 238 |
+
if (InMemoryDB.isFallback) return res.json(InMemoryDB.subjects.length ? InMemoryDB.subjects : [{name:'语文',code:'CHI',color:'#ef4444'}]);
|
| 239 |
+
const subs = await SubjectModel.find();
|
| 240 |
+
res.json(subs);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 241 |
});
|
| 242 |
+
app.post('/api/subjects', async (req, res) => {
|
| 243 |
try {
|
| 244 |
if (InMemoryDB.isFallback) {
|
| 245 |
+
const newSub = { ...req.body, id: Date.now(), _id: Date.now().toString() };
|
| 246 |
+
InMemoryDB.subjects.push(newSub);
|
| 247 |
+
return res.json(newSub);
|
| 248 |
}
|
| 249 |
+
const newSub = await SubjectModel.create(req.body);
|
| 250 |
+
res.json(newSub);
|
| 251 |
} catch (e) { res.status(400).json({ error: e.message }); }
|
| 252 |
});
|
| 253 |
+
app.delete('/api/subjects/:id', async (req, res) => {
|
| 254 |
try {
|
| 255 |
if (InMemoryDB.isFallback) {
|
| 256 |
+
InMemoryDB.subjects = InMemoryDB.subjects.filter(s => s._id !== req.params.id);
|
| 257 |
return res.json({ success: true });
|
| 258 |
}
|
| 259 |
+
await SubjectModel.findByIdAndDelete(req.params.id);
|
| 260 |
res.json({ success: true });
|
| 261 |
} catch (e) { res.status(500).json({ error: e.message }); }
|
| 262 |
});
|
| 263 |
|
| 264 |
+
// --- Batch Operations ---
|
| 265 |
+
app.post('/api/batch-delete', async (req, res) => {
|
| 266 |
+
const { type, ids } = req.body; // type: 'student' | 'score' | 'user'
|
| 267 |
+
if (!ids || !Array.isArray(ids)) return res.status(400).json({ error: 'Invalid IDs' });
|
| 268 |
+
|
|
|
|
|
|
|
|
|
|
| 269 |
try {
|
| 270 |
if (InMemoryDB.isFallback) {
|
| 271 |
+
if (type === 'student') InMemoryDB.students = InMemoryDB.students.filter(s => !ids.includes(s._id) && !ids.includes(s.id));
|
| 272 |
+
if (type === 'score') InMemoryDB.scores = InMemoryDB.scores.filter(s => !ids.includes(s._id) && !ids.includes(s.id));
|
| 273 |
+
if (type === 'user') InMemoryDB.users = InMemoryDB.users.filter(u => !ids.includes(u._id) && !ids.includes(u.id));
|
| 274 |
+
return res.json({ success: true });
|
| 275 |
}
|
| 276 |
+
|
| 277 |
+
if (type === 'student') await Student.deleteMany({ _id: { $in: ids } });
|
| 278 |
+
if (type === 'score') await Score.deleteMany({ _id: { $in: ids } });
|
| 279 |
+
if (type === 'user') await User.deleteMany({ _id: { $in: ids } });
|
| 280 |
+
|
| 281 |
+
res.json({ success: true });
|
| 282 |
+
} catch (e) { res.status(500).json({ error: e.message }); }
|
| 283 |
});
|
| 284 |
|
| 285 |
+
// --- Other Existing Routes (Simplified for brevity, assuming they exist as before) ---
|
| 286 |
+
// Students
|
| 287 |
+
app.get('/api/students', async (req, res) => {
|
| 288 |
+
if (InMemoryDB.isFallback) return res.json(InMemoryDB.students);
|
| 289 |
+
res.json(await Student.find().sort({ studentNo: 1 }));
|
| 290 |
+
});
|
| 291 |
+
app.post('/api/students', async (req, res) => {
|
| 292 |
+
if (InMemoryDB.isFallback) { InMemoryDB.students.push({ ...req.body, id: Date.now(), _id: String(Date.now()) }); return res.json({}); }
|
| 293 |
+
res.json(await Student.create(req.body));
|
| 294 |
+
});
|
| 295 |
+
app.delete('/api/students/:id', async (req, res) => {
|
| 296 |
+
if (InMemoryDB.isFallback) { InMemoryDB.students = InMemoryDB.students.filter(s => s._id != req.params.id); return res.json({}); }
|
| 297 |
+
await Student.findByIdAndDelete(req.params.id); res.json({});
|
| 298 |
+
});
|
| 299 |
+
|
| 300 |
+
// Classes
|
| 301 |
+
app.get('/api/classes', async (req, res) => {
|
| 302 |
+
if (InMemoryDB.isFallback) return res.json(InMemoryDB.classes);
|
| 303 |
+
const classes = await ClassModel.find();
|
| 304 |
+
// Add counts
|
| 305 |
+
const result = await Promise.all(classes.map(async (c) => {
|
| 306 |
+
const count = await Student.countDocuments({ className: c.grade + c.className });
|
| 307 |
+
return { ...c.toObject(), studentCount: count };
|
| 308 |
+
}));
|
| 309 |
+
res.json(result);
|
| 310 |
+
});
|
| 311 |
+
app.post('/api/classes', async (req, res) => {
|
| 312 |
+
if (InMemoryDB.isFallback) { InMemoryDB.classes.push({ ...req.body, id: Date.now(), _id: String(Date.now()) }); return res.json({}); }
|
| 313 |
+
res.json(await ClassModel.create(req.body));
|
| 314 |
+
});
|
| 315 |
+
app.delete('/api/classes/:id', async (req, res) => {
|
| 316 |
+
if (InMemoryDB.isFallback) { InMemoryDB.classes = InMemoryDB.classes.filter(c => c._id != req.params.id); return res.json({}); }
|
| 317 |
+
await ClassModel.findByIdAndDelete(req.params.id); res.json({});
|
| 318 |
+
});
|
| 319 |
+
|
| 320 |
+
// Courses
|
| 321 |
app.get('/api/courses', async (req, res) => {
|
| 322 |
if (InMemoryDB.isFallback) return res.json(InMemoryDB.courses);
|
| 323 |
+
res.json(await Course.find());
|
|
|
|
| 324 |
});
|
| 325 |
app.post('/api/courses', async (req, res) => {
|
| 326 |
+
if (InMemoryDB.isFallback) { InMemoryDB.courses.push({ ...req.body, id: Date.now(), _id: String(Date.now()) }); return res.json({}); }
|
| 327 |
+
res.json(await Course.create(req.body));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 328 |
});
|
| 329 |
app.put('/api/courses/:id', async (req, res) => {
|
| 330 |
+
if (InMemoryDB.isFallback) return res.json({});
|
| 331 |
+
res.json(await Course.findByIdAndUpdate(req.params.id, req.body));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 332 |
});
|
| 333 |
app.delete('/api/courses/:id', async (req, res) => {
|
| 334 |
+
if (InMemoryDB.isFallback) { InMemoryDB.courses = InMemoryDB.courses.filter(c => c._id != req.params.id); return res.json({}); }
|
| 335 |
+
await Course.findByIdAndDelete(req.params.id); res.json({});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 336 |
});
|
| 337 |
|
| 338 |
+
// Scores
|
| 339 |
app.get('/api/scores', async (req, res) => {
|
| 340 |
if (InMemoryDB.isFallback) return res.json(InMemoryDB.scores);
|
| 341 |
+
res.json(await Score.find());
|
|
|
|
| 342 |
});
|
| 343 |
app.post('/api/scores', async (req, res) => {
|
| 344 |
+
if (InMemoryDB.isFallback) { InMemoryDB.scores.push({ ...req.body, id: Date.now(), _id: String(Date.now()) }); return res.json({}); }
|
| 345 |
+
res.json(await Score.create(req.body));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 346 |
});
|
| 347 |
app.delete('/api/scores/:id', async (req, res) => {
|
| 348 |
+
if (InMemoryDB.isFallback) { InMemoryDB.scores = InMemoryDB.scores.filter(s => s._id != req.params.id); return res.json({}); }
|
| 349 |
+
await Score.findByIdAndDelete(req.params.id); res.json({});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 350 |
});
|
| 351 |
|
| 352 |
+
// Config
|
| 353 |
+
app.get('/api/config', async (req, res) => {
|
| 354 |
+
if (InMemoryDB.isFallback) return res.json(InMemoryDB.config);
|
| 355 |
+
res.json(await ConfigModel.findOne({ key: 'main' }) || {});
|
| 356 |
+
});
|
| 357 |
+
app.post('/api/config', async (req, res) => {
|
| 358 |
+
if (InMemoryDB.isFallback) { InMemoryDB.config = req.body; return res.json({}); }
|
| 359 |
+
res.json(await ConfigModel.findOneAndUpdate({ key: 'main' }, req.body, { upsert: true, new: true }));
|
| 360 |
+
});
|
| 361 |
+
|
| 362 |
+
// Stats
|
| 363 |
app.get('/api/stats', async (req, res) => {
|
| 364 |
try {
|
| 365 |
if (InMemoryDB.isFallback) {
|
| 366 |
+
return res.json({ studentCount: InMemoryDB.students.length, courseCount: InMemoryDB.courses.length, avgScore: 0, excellentRate: '0%' });
|
|
|
|
|
|
|
|
|
|
|
|
|
| 367 |
}
|
| 368 |
const studentCount = await Student.countDocuments();
|
| 369 |
const courseCount = await Course.countDocuments();
|
| 370 |
const scores = await Score.find();
|
|
|
|
|
|
|
| 371 |
const totalScore = scores.reduce((sum, s) => sum + (s.score || 0), 0);
|
| 372 |
const avgScore = scores.length > 0 ? (totalScore / scores.length).toFixed(1) : 0;
|
|
|
|
|
|
|
| 373 |
const excellentCount = scores.filter(s => s.score >= 90).length;
|
| 374 |
const excellentRate = scores.length > 0 ? Math.round((excellentCount / scores.length) * 100) + '%' : '0%';
|
|
|
|
| 375 |
res.json({ studentCount, courseCount, avgScore, excellentRate });
|
| 376 |
+
} catch(e) { res.status(500).json({}); }
|
| 377 |
});
|
| 378 |
|
| 379 |
+
// Frontend
|
|
|
|
|
|
|
|
|
|
| 380 |
app.get('*', (req, res) => {
|
| 381 |
res.sendFile(path.join(__dirname, 'dist', 'index.html'));
|
| 382 |
});
|
| 383 |
|
| 384 |
+
app.listen(PORT, () => console.log(`🚀 Service running on port ${PORT}`));
|
|
|
|
|
|
|
|
|
services/api.ts
CHANGED
|
@@ -1,40 +1,24 @@
|
|
| 1 |
|
| 2 |
/// <reference types="vite/client" />
|
| 3 |
-
import { User, ClassInfo, SystemConfig } from '../types';
|
| 4 |
-
|
| 5 |
-
// ==========================================
|
| 6 |
-
// 智能环境配置 (Smart Environment Config)
|
| 7 |
-
// ==========================================
|
| 8 |
|
| 9 |
const getBaseUrl = () => {
|
| 10 |
let isProd = false;
|
| 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;
|
| 17 |
}
|
| 18 |
-
} catch (e) {
|
| 19 |
-
// Ignore environment check errors
|
| 20 |
-
}
|
| 21 |
|
| 22 |
-
if (isProd) {
|
| 23 |
return '/api';
|
| 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 |
-
}
|
| 30 |
-
|
| 31 |
-
// Default to local dev backend URL
|
| 32 |
return 'http://localhost:7860/api';
|
| 33 |
};
|
| 34 |
|
| 35 |
const API_BASE_URL = getBaseUrl();
|
| 36 |
|
| 37 |
-
// 简单封装 Fetch
|
| 38 |
async function request(endpoint: string, options: RequestInit = {}) {
|
| 39 |
const res = await fetch(`${API_BASE_URL}${endpoint}`, {
|
| 40 |
...options,
|
|
@@ -44,42 +28,29 @@ async function request(endpoint: string, options: RequestInit = {}) {
|
|
| 44 |
if (!res.ok) {
|
| 45 |
if (res.status === 401) throw new Error('AUTH_FAILED');
|
| 46 |
const errorData = await res.json().catch(() => ({}));
|
| 47 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
}
|
| 49 |
return res.json();
|
| 50 |
}
|
| 51 |
|
| 52 |
export const api = {
|
| 53 |
-
init: () =>
|
| 54 |
-
console.log('🔗 API Initialized pointing to:', API_BASE_URL);
|
| 55 |
-
},
|
| 56 |
|
| 57 |
auth: {
|
| 58 |
login: async (username: string, password: string): Promise<User> => {
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 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 |
-
}
|
| 72 |
},
|
| 73 |
register: async (data: any): Promise<User> => {
|
| 74 |
-
return await request('/auth/register', {
|
| 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') {
|
|
@@ -90,6 +61,12 @@ export const api = {
|
|
| 90 |
}
|
| 91 |
},
|
| 92 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
students: {
|
| 94 |
getAll: () => request('/students'),
|
| 95 |
add: (data: any) => request('/students', { method: 'POST', body: JSON.stringify(data) }),
|
|
@@ -102,6 +79,12 @@ export const api = {
|
|
| 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) }),
|
|
@@ -122,5 +105,9 @@ export const api = {
|
|
| 122 |
config: {
|
| 123 |
get: () => request('/config'),
|
| 124 |
save: (data: SystemConfig) => request('/config', { method: 'POST', body: JSON.stringify(data) })
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
}
|
| 126 |
};
|
|
|
|
| 1 |
|
| 2 |
/// <reference types="vite/client" />
|
| 3 |
+
import { User, ClassInfo, SystemConfig, Subject } from '../types';
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
const getBaseUrl = () => {
|
| 6 |
let isProd = false;
|
|
|
|
| 7 |
try {
|
|
|
|
| 8 |
// @ts-ignore
|
| 9 |
if (typeof import.meta !== 'undefined' && import.meta.env && import.meta.env.PROD) {
|
| 10 |
isProd = true;
|
| 11 |
}
|
| 12 |
+
} catch (e) {}
|
|
|
|
|
|
|
| 13 |
|
| 14 |
+
if (isProd || (typeof window !== 'undefined' && window.location.port === '7860')) {
|
| 15 |
return '/api';
|
| 16 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
return 'http://localhost:7860/api';
|
| 18 |
};
|
| 19 |
|
| 20 |
const API_BASE_URL = getBaseUrl();
|
| 21 |
|
|
|
|
| 22 |
async function request(endpoint: string, options: RequestInit = {}) {
|
| 23 |
const res = await fetch(`${API_BASE_URL}${endpoint}`, {
|
| 24 |
...options,
|
|
|
|
| 28 |
if (!res.ok) {
|
| 29 |
if (res.status === 401) throw new Error('AUTH_FAILED');
|
| 30 |
const errorData = await res.json().catch(() => ({}));
|
| 31 |
+
const errorMessage = errorData.error || errorData.message || `Server Error: ${res.status}`;
|
| 32 |
+
// Pass special error codes
|
| 33 |
+
if (errorData.error === 'PENDING_APPROVAL') throw new Error('PENDING_APPROVAL');
|
| 34 |
+
if (errorData.error === 'BANNED') throw new Error('BANNED');
|
| 35 |
+
throw new Error(errorMessage);
|
| 36 |
}
|
| 37 |
return res.json();
|
| 38 |
}
|
| 39 |
|
| 40 |
export const api = {
|
| 41 |
+
init: () => console.log('🔗 API:', API_BASE_URL),
|
|
|
|
|
|
|
| 42 |
|
| 43 |
auth: {
|
| 44 |
login: async (username: string, password: string): Promise<User> => {
|
| 45 |
+
const user = await request('/auth/login', { method: 'POST', body: JSON.stringify({ username, password }) });
|
| 46 |
+
if (typeof window !== 'undefined') localStorage.setItem('user', JSON.stringify(user));
|
| 47 |
+
return user;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
},
|
| 49 |
register: async (data: any): Promise<User> => {
|
| 50 |
+
return await request('/auth/register', { method: 'POST', body: JSON.stringify(data) });
|
|
|
|
|
|
|
|
|
|
| 51 |
},
|
| 52 |
logout: () => {
|
| 53 |
+
if (typeof window !== 'undefined') localStorage.removeItem('user');
|
|
|
|
|
|
|
| 54 |
},
|
| 55 |
getCurrentUser: (): User | null => {
|
| 56 |
if (typeof window !== 'undefined') {
|
|
|
|
| 61 |
}
|
| 62 |
},
|
| 63 |
|
| 64 |
+
users: {
|
| 65 |
+
getAll: () => request('/users'),
|
| 66 |
+
update: (id: string, data: Partial<User>) => request(`/users/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
| 67 |
+
delete: (id: string) => request(`/users/${id}`, { method: 'DELETE' })
|
| 68 |
+
},
|
| 69 |
+
|
| 70 |
students: {
|
| 71 |
getAll: () => request('/students'),
|
| 72 |
add: (data: any) => request('/students', { method: 'POST', body: JSON.stringify(data) }),
|
|
|
|
| 79 |
delete: (id: string | number) => request(`/classes/${id}`, { method: 'DELETE' })
|
| 80 |
},
|
| 81 |
|
| 82 |
+
subjects: {
|
| 83 |
+
getAll: () => request('/subjects'),
|
| 84 |
+
add: (data: Subject) => request('/subjects', { method: 'POST', body: JSON.stringify(data) }),
|
| 85 |
+
delete: (id: string | number) => request(`/subjects/${id}`, { method: 'DELETE' })
|
| 86 |
+
},
|
| 87 |
+
|
| 88 |
courses: {
|
| 89 |
getAll: () => request('/courses'),
|
| 90 |
add: (data: any) => request('/courses', { method: 'POST', body: JSON.stringify(data) }),
|
|
|
|
| 105 |
config: {
|
| 106 |
get: () => request('/config'),
|
| 107 |
save: (data: SystemConfig) => request('/config', { method: 'POST', body: JSON.stringify(data) })
|
| 108 |
+
},
|
| 109 |
+
|
| 110 |
+
batchDelete: (type: 'student' | 'score' | 'user', ids: string[]) => {
|
| 111 |
+
return request('/batch-delete', { method: 'POST', body: JSON.stringify({ type, ids }) });
|
| 112 |
}
|
| 113 |
};
|
types.ts
CHANGED
|
@@ -3,7 +3,7 @@ export enum UserRole {
|
|
| 3 |
ADMIN = 'ADMIN',
|
| 4 |
TEACHER = 'TEACHER',
|
| 5 |
STUDENT = 'STUDENT',
|
| 6 |
-
USER = 'USER'
|
| 7 |
}
|
| 8 |
|
| 9 |
export enum UserStatus {
|
|
@@ -20,6 +20,7 @@ export interface User {
|
|
| 20 |
status: UserStatus;
|
| 21 |
email: string;
|
| 22 |
avatar?: string;
|
|
|
|
| 23 |
}
|
| 24 |
|
| 25 |
export interface ClassInfo {
|
|
@@ -31,6 +32,14 @@ export interface ClassInfo {
|
|
| 31 |
studentCount?: number; // Calculated field
|
| 32 |
}
|
| 33 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
export interface SystemConfig {
|
| 35 |
systemName: string;
|
| 36 |
semester: string;
|
|
|
|
| 3 |
ADMIN = 'ADMIN',
|
| 4 |
TEACHER = 'TEACHER',
|
| 5 |
STUDENT = 'STUDENT',
|
| 6 |
+
USER = 'USER'
|
| 7 |
}
|
| 8 |
|
| 9 |
export enum UserStatus {
|
|
|
|
| 20 |
status: UserStatus;
|
| 21 |
email: string;
|
| 22 |
avatar?: string;
|
| 23 |
+
createTime?: string;
|
| 24 |
}
|
| 25 |
|
| 26 |
export interface ClassInfo {
|
|
|
|
| 32 |
studentCount?: number; // Calculated field
|
| 33 |
}
|
| 34 |
|
| 35 |
+
export interface Subject {
|
| 36 |
+
id?: number;
|
| 37 |
+
_id?: string;
|
| 38 |
+
name: string; // e.g. "语文"
|
| 39 |
+
code: string; // e.g. "CHI"
|
| 40 |
+
color: string; // Hex color for charts
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
export interface SystemConfig {
|
| 44 |
systemName: string;
|
| 45 |
semester: string;
|