Spaces:
Sleeping
Sleeping
Upload 41 files
Browse files- pages/AchievementTeacher.tsx +9 -4
- pages/Attendance.tsx +10 -1
- pages/ClassList.tsx +98 -53
- pages/CourseList.tsx +61 -28
- pages/Dashboard.tsx +11 -2
- pages/GameLucky.tsx +11 -2
- pages/GameMountain.tsx +14 -6
- pages/GameRewards.tsx +11 -8
- pages/ScoreList.tsx +61 -27
pages/AchievementTeacher.tsx
CHANGED
|
@@ -46,10 +46,15 @@ export const AchievementTeacher: React.FC = () => {
|
|
| 46 |
api.config.getPublic() as Promise<SystemConfig>
|
| 47 |
]);
|
| 48 |
|
| 49 |
-
// Filter students for homeroom & Sort by
|
| 50 |
const sortedStudents = stus
|
| 51 |
.filter((s: Student) => s.className === homeroomClass)
|
| 52 |
-
.sort((a: Student, b: Student) =>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
|
| 54 |
setStudents(sortedStudents);
|
| 55 |
|
|
@@ -209,7 +214,7 @@ export const AchievementTeacher: React.FC = () => {
|
|
| 209 |
}}
|
| 210 |
className={`p-2 rounded border cursor-pointer flex items-center justify-between ${selectedStudents.has(s._id || String(s.id)) ? 'bg-blue-50 border-blue-400' : 'bg-white border-gray-200 hover:bg-gray-50'}`}
|
| 211 |
>
|
| 212 |
-
<span className="text-sm font-medium">{s.name}</span>
|
| 213 |
{selectedStudents.has(s._id || String(s.id)) && <CheckCircle size={16} className="text-blue-500"/>}
|
| 214 |
</div>
|
| 215 |
))}
|
|
@@ -309,7 +314,7 @@ export const AchievementTeacher: React.FC = () => {
|
|
| 309 |
<tbody className="divide-y divide-gray-100">
|
| 310 |
{students.map(s => (
|
| 311 |
<tr key={s._id} className="hover:bg-gray-50">
|
| 312 |
-
<td className="p-4 font-bold text-gray-700">{s.name}</td>
|
| 313 |
<td className="p-4">
|
| 314 |
<span className="text-amber-600 font-bold bg-amber-50 px-2 py-1 rounded border border-amber-100">
|
| 315 |
{s.flowerBalance || 0} 🌺
|
|
|
|
| 46 |
api.config.getPublic() as Promise<SystemConfig>
|
| 47 |
]);
|
| 48 |
|
| 49 |
+
// Filter students for homeroom & Sort by SeatNo > Name
|
| 50 |
const sortedStudents = stus
|
| 51 |
.filter((s: Student) => s.className === homeroomClass)
|
| 52 |
+
.sort((a: Student, b: Student) => {
|
| 53 |
+
const seatA = parseInt(a.seatNo || '99999');
|
| 54 |
+
const seatB = parseInt(b.seatNo || '99999');
|
| 55 |
+
if (seatA !== seatB) return seatA - seatB;
|
| 56 |
+
return a.name.localeCompare(b.name, 'zh-CN');
|
| 57 |
+
});
|
| 58 |
|
| 59 |
setStudents(sortedStudents);
|
| 60 |
|
|
|
|
| 214 |
}}
|
| 215 |
className={`p-2 rounded border cursor-pointer flex items-center justify-between ${selectedStudents.has(s._id || String(s.id)) ? 'bg-blue-50 border-blue-400' : 'bg-white border-gray-200 hover:bg-gray-50'}`}
|
| 216 |
>
|
| 217 |
+
<span className="text-sm font-medium">{s.seatNo ? s.seatNo+'.':''}{s.name}</span>
|
| 218 |
{selectedStudents.has(s._id || String(s.id)) && <CheckCircle size={16} className="text-blue-500"/>}
|
| 219 |
</div>
|
| 220 |
))}
|
|
|
|
| 314 |
<tbody className="divide-y divide-gray-100">
|
| 315 |
{students.map(s => (
|
| 316 |
<tr key={s._id} className="hover:bg-gray-50">
|
| 317 |
+
<td className="p-4 font-bold text-gray-700">{s.seatNo ? s.seatNo+'.':''}{s.name}</td>
|
| 318 |
<td className="p-4">
|
| 319 |
<span className="text-amber-600 font-bold bg-amber-50 px-2 py-1 rounded border border-amber-100">
|
| 320 |
{s.flowerBalance || 0} 🌺
|
pages/Attendance.tsx
CHANGED
|
@@ -22,6 +22,15 @@ export const AttendancePage: React.FC = () => {
|
|
| 22 |
api.attendance.get({ className: targetClass, date })
|
| 23 |
]);
|
| 24 |
const classStudents = stus.filter((s: Student) => s.className === targetClass);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
setStudents(classStudents);
|
| 26 |
|
| 27 |
const map: Record<string, Attendance> = {};
|
|
@@ -138,7 +147,7 @@ export const AttendancePage: React.FC = () => {
|
|
| 138 |
</div>
|
| 139 |
<div className="text-center">
|
| 140 |
<p className="font-bold text-gray-800 text-sm">{s.name}</p>
|
| 141 |
-
<p className="text-[10px] text-gray-400">{s.studentNo}</p>
|
| 142 |
</div>
|
| 143 |
<div className={`flex items-center gap-1 text-xs font-bold ${textColor}`}>
|
| 144 |
{icon} {text}
|
|
|
|
| 22 |
api.attendance.get({ className: targetClass, date })
|
| 23 |
]);
|
| 24 |
const classStudents = stus.filter((s: Student) => s.className === targetClass);
|
| 25 |
+
|
| 26 |
+
// SORTING LOGIC: Seat No > Name
|
| 27 |
+
classStudents.sort((a: Student, b: Student) => {
|
| 28 |
+
const seatA = parseInt(a.seatNo || '99999');
|
| 29 |
+
const seatB = parseInt(b.seatNo || '99999');
|
| 30 |
+
if (seatA !== seatB) return seatA - seatB;
|
| 31 |
+
return a.name.localeCompare(b.name, 'zh-CN');
|
| 32 |
+
});
|
| 33 |
+
|
| 34 |
setStudents(classStudents);
|
| 35 |
|
| 36 |
const map: Record<string, Attendance> = {};
|
|
|
|
| 147 |
</div>
|
| 148 |
<div className="text-center">
|
| 149 |
<p className="font-bold text-gray-800 text-sm">{s.name}</p>
|
| 150 |
+
<p className="text-[10px] text-gray-400">{s.seatNo ? `${s.seatNo}号` : s.studentNo}</p>
|
| 151 |
</div>
|
| 152 |
<div className={`flex items-center gap-1 text-xs font-bold ${textColor}`}>
|
| 153 |
{icon} {text}
|
pages/ClassList.tsx
CHANGED
|
@@ -1,8 +1,18 @@
|
|
| 1 |
|
| 2 |
import React, { useState, useEffect } from 'react';
|
| 3 |
-
import { Plus, Trash2, Users, School, Loader2, User as UserIcon, Edit, Check, X, ShieldAlert } from 'lucide-react';
|
| 4 |
import { api } from '../services/api';
|
| 5 |
import { ClassInfo, User } from '../types';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
export const ClassList: React.FC = () => {
|
| 8 |
const [classes, setClasses] = useState<ClassInfo[]>([]);
|
|
@@ -14,6 +24,9 @@ export const ClassList: React.FC = () => {
|
|
| 14 |
const [submitting, setSubmitting] = useState(false);
|
| 15 |
const [editClassId, setEditClassId] = useState<string | null>(null);
|
| 16 |
|
|
|
|
|
|
|
|
|
|
| 17 |
// Form
|
| 18 |
const [grade, setGrade] = useState('一年级');
|
| 19 |
const [className, setClassName] = useState('(1)班');
|
|
@@ -32,14 +45,23 @@ export const ClassList: React.FC = () => {
|
|
| 32 |
const [clsData, userData] = await Promise.all([
|
| 33 |
api.classes.getAll(),
|
| 34 |
api.users.getAll({ role: 'TEACHER', global: true }) // Get filtered users
|
| 35 |
-
]);
|
| 36 |
setClasses(clsData);
|
| 37 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
|
| 39 |
// Filter users with pending class applications
|
| 40 |
const apps = userData.filter((u: User) => u.classApplication && u.classApplication.status === 'PENDING');
|
| 41 |
setPendingApps(apps);
|
| 42 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
} catch (e) {
|
| 44 |
console.error(e);
|
| 45 |
} finally {
|
|
@@ -80,9 +102,7 @@ export const ClassList: React.FC = () => {
|
|
| 80 |
try {
|
| 81 |
const payload = { grade, className, teacherName };
|
| 82 |
if (editClassId) {
|
| 83 |
-
// Update
|
| 84 |
-
// Use a direct fetch or update api method to handle PUT
|
| 85 |
-
// The api.classes.add uses POST, we need a custom call or add method update
|
| 86 |
await fetch(`/api/classes/${editClassId}`, {
|
| 87 |
method: 'PUT',
|
| 88 |
headers: {
|
|
@@ -118,6 +138,22 @@ export const ClassList: React.FC = () => {
|
|
| 118 |
} catch(e) { alert('操作失败'); }
|
| 119 |
};
|
| 120 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
return (
|
| 122 |
<div className="space-y-6">
|
| 123 |
|
|
@@ -174,53 +210,62 @@ export const ClassList: React.FC = () => {
|
|
| 174 |
|
| 175 |
{loading ? (
|
| 176 |
<div className="flex justify-center py-10"><Loader2 className="animate-spin text-blue-600" /></div>
|
|
|
|
|
|
|
| 177 |
) : (
|
| 178 |
-
<div className="
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 196 |
</div>
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
<button
|
| 200 |
-
onClick={() => openEditModal(cls)}
|
| 201 |
-
className="p-1.5 text-gray-400 hover:text-blue-500 hover:bg-blue-50 rounded transition-colors"
|
| 202 |
-
title="编辑班级信息"
|
| 203 |
-
>
|
| 204 |
-
<Edit size={18} />
|
| 205 |
-
</button>
|
| 206 |
-
<button
|
| 207 |
-
onClick={() => handleDelete(cls._id || cls.id!)}
|
| 208 |
-
className="p-1.5 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded transition-colors"
|
| 209 |
-
title="删除班级"
|
| 210 |
-
>
|
| 211 |
-
<Trash2 size={18} />
|
| 212 |
-
</button>
|
| 213 |
-
</div>
|
| 214 |
-
</div>
|
| 215 |
-
|
| 216 |
-
<div className="mt-6 pt-4 border-t border-gray-50 flex items-center justify-between text-sm">
|
| 217 |
-
<span className="text-gray-500 flex items-center">
|
| 218 |
-
<Users size={16} className="mr-2" /> 学生人数
|
| 219 |
-
</span>
|
| 220 |
-
<span className="font-bold text-gray-800">{cls.studentCount || 0} 人</span>
|
| 221 |
-
</div>
|
| 222 |
-
</div>
|
| 223 |
-
))}
|
| 224 |
</div>
|
| 225 |
)}
|
| 226 |
|
|
@@ -241,10 +286,10 @@ export const ClassList: React.FC = () => {
|
|
| 241 |
<input required type="text" value={className} onChange={e => setClassName(e.target.value)} className="w-full mt-1 px-3 py-2 border rounded-lg" placeholder="(1)班" />
|
| 242 |
</div>
|
| 243 |
<div>
|
| 244 |
-
<label className="text-sm font-medium text-gray-700"
|
| 245 |
<select value={teacherName} onChange={e => setTeacherName(e.target.value)} className="w-full mt-1 px-3 py-2 border rounded-lg bg-white">
|
| 246 |
<option value="">-- 暂不指定 --</option>
|
| 247 |
-
{teachers.
|
| 248 |
<option key={t._id} value={t.trueName || t.username}>
|
| 249 |
{t.trueName || t.username} {t.homeroomClass ? `(已任: ${t.homeroomClass})` : ''}
|
| 250 |
</option>
|
|
|
|
| 1 |
|
| 2 |
import React, { useState, useEffect } from 'react';
|
| 3 |
+
import { Plus, Trash2, Users, School, Loader2, User as UserIcon, Edit, Check, X, ShieldAlert, ChevronDown, ChevronRight } from 'lucide-react';
|
| 4 |
import { api } from '../services/api';
|
| 5 |
import { ClassInfo, User } from '../types';
|
| 6 |
+
import { gradeOrder, sortGrades } from './Dashboard';
|
| 7 |
+
|
| 8 |
+
// Helper to sort classes within a grade (numerically)
|
| 9 |
+
const sortClassesInGrade = (a: ClassInfo, b: ClassInfo) => {
|
| 10 |
+
const getNum = (s: string) => {
|
| 11 |
+
const match = s.match(/(\d+)/);
|
| 12 |
+
return match ? parseInt(match[1]) : 0;
|
| 13 |
+
};
|
| 14 |
+
return getNum(a.className) - getNum(b.className);
|
| 15 |
+
};
|
| 16 |
|
| 17 |
export const ClassList: React.FC = () => {
|
| 18 |
const [classes, setClasses] = useState<ClassInfo[]>([]);
|
|
|
|
| 24 |
const [submitting, setSubmitting] = useState(false);
|
| 25 |
const [editClassId, setEditClassId] = useState<string | null>(null);
|
| 26 |
|
| 27 |
+
// Expanded/Collapsed State for Grades
|
| 28 |
+
const [expandedGrades, setExpandedGrades] = useState<Set<string>>(new Set());
|
| 29 |
+
|
| 30 |
// Form
|
| 31 |
const [grade, setGrade] = useState('一年级');
|
| 32 |
const [className, setClassName] = useState('(1)班');
|
|
|
|
| 45 |
const [clsData, userData] = await Promise.all([
|
| 46 |
api.classes.getAll(),
|
| 47 |
api.users.getAll({ role: 'TEACHER', global: true }) // Get filtered users
|
| 48 |
+
]) as [ClassInfo[], User[]];
|
| 49 |
setClasses(clsData);
|
| 50 |
+
|
| 51 |
+
// Sort teachers alphabetically (Pinyin)
|
| 52 |
+
const sortedTeachers = userData.sort((a: User, b: User) =>
|
| 53 |
+
(a.trueName || a.username).localeCompare(b.trueName || b.username, 'zh-CN')
|
| 54 |
+
);
|
| 55 |
+
setTeachers(sortedTeachers);
|
| 56 |
|
| 57 |
// Filter users with pending class applications
|
| 58 |
const apps = userData.filter((u: User) => u.classApplication && u.classApplication.status === 'PENDING');
|
| 59 |
setPendingApps(apps);
|
| 60 |
|
| 61 |
+
// Auto expand all grades initially
|
| 62 |
+
const allGrades = new Set<string>(clsData.map((c: ClassInfo) => c.grade));
|
| 63 |
+
setExpandedGrades(allGrades);
|
| 64 |
+
|
| 65 |
} catch (e) {
|
| 66 |
console.error(e);
|
| 67 |
} finally {
|
|
|
|
| 102 |
try {
|
| 103 |
const payload = { grade, className, teacherName };
|
| 104 |
if (editClassId) {
|
| 105 |
+
// Update via PUT
|
|
|
|
|
|
|
| 106 |
await fetch(`/api/classes/${editClassId}`, {
|
| 107 |
method: 'PUT',
|
| 108 |
headers: {
|
|
|
|
| 138 |
} catch(e) { alert('操作失败'); }
|
| 139 |
};
|
| 140 |
|
| 141 |
+
const toggleGradeExpand = (g: string) => {
|
| 142 |
+
const newSet = new Set(expandedGrades);
|
| 143 |
+
if (newSet.has(g)) newSet.delete(g);
|
| 144 |
+
else newSet.add(g);
|
| 145 |
+
setExpandedGrades(newSet);
|
| 146 |
+
};
|
| 147 |
+
|
| 148 |
+
// Group Classes by Grade
|
| 149 |
+
const groupedClasses = classes.reduce((acc, cls) => {
|
| 150 |
+
if (!acc[cls.grade]) acc[cls.grade] = [];
|
| 151 |
+
acc[cls.grade].push(cls);
|
| 152 |
+
return acc;
|
| 153 |
+
}, {} as Record<string, ClassInfo[]>);
|
| 154 |
+
|
| 155 |
+
const sortedGradeKeys = Object.keys(groupedClasses).sort(sortGrades);
|
| 156 |
+
|
| 157 |
return (
|
| 158 |
<div className="space-y-6">
|
| 159 |
|
|
|
|
| 210 |
|
| 211 |
{loading ? (
|
| 212 |
<div className="flex justify-center py-10"><Loader2 className="animate-spin text-blue-600" /></div>
|
| 213 |
+
) : sortedGradeKeys.length === 0 ? (
|
| 214 |
+
<div className="text-center py-20 text-gray-400 border-2 border-dashed rounded-xl">暂无班级数据,请点击“新增班级”</div>
|
| 215 |
) : (
|
| 216 |
+
<div className="space-y-6">
|
| 217 |
+
{sortedGradeKeys.map(gradeKey => {
|
| 218 |
+
const gradeClasses = groupedClasses[gradeKey].sort(sortClassesInGrade);
|
| 219 |
+
const isExpanded = expandedGrades.has(gradeKey);
|
| 220 |
+
|
| 221 |
+
return (
|
| 222 |
+
<div key={gradeKey} className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
|
| 223 |
+
<div
|
| 224 |
+
className="p-4 bg-gray-50 flex items-center justify-between cursor-pointer hover:bg-gray-100 transition-colors"
|
| 225 |
+
onClick={() => toggleGradeExpand(gradeKey)}
|
| 226 |
+
>
|
| 227 |
+
<h3 className="font-bold text-gray-800 flex items-center">
|
| 228 |
+
{isExpanded ? <ChevronDown size={20} className="mr-2 text-gray-500"/> : <ChevronRight size={20} className="mr-2 text-gray-500"/>}
|
| 229 |
+
{gradeKey}
|
| 230 |
+
<span className="ml-2 text-xs bg-white border px-2 py-0.5 rounded-full text-gray-500 font-normal">{gradeClasses.length} 个班级</span>
|
| 231 |
+
</h3>
|
| 232 |
+
</div>
|
| 233 |
+
|
| 234 |
+
{isExpanded && (
|
| 235 |
+
<div className="p-4 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 animate-in slide-in-from-top-2 duration-300">
|
| 236 |
+
{gradeClasses.map(cls => (
|
| 237 |
+
<div key={cls._id || cls.id} className="border border-gray-200 rounded-lg p-4 hover:shadow-md transition-shadow relative group bg-white">
|
| 238 |
+
<div className="flex justify-between items-start mb-2">
|
| 239 |
+
<div className="flex items-center gap-2">
|
| 240 |
+
<div className="p-2 bg-blue-50 text-blue-600 rounded-lg">
|
| 241 |
+
<School size={20} />
|
| 242 |
+
</div>
|
| 243 |
+
<h4 className="font-bold text-gray-800 text-lg">{cls.className}</h4>
|
| 244 |
+
</div>
|
| 245 |
+
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
| 246 |
+
<button onClick={(e) => {e.stopPropagation(); openEditModal(cls);}} className="p-1 text-gray-400 hover:text-blue-500 hover:bg-blue-50 rounded"><Edit size={16}/></button>
|
| 247 |
+
<button onClick={(e) => {e.stopPropagation(); handleDelete(cls._id || cls.id!);}} className="p-1 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded"><Trash2 size={16}/></button>
|
| 248 |
+
</div>
|
| 249 |
+
</div>
|
| 250 |
+
<div className="flex justify-between items-center text-sm mt-3">
|
| 251 |
+
<div className="flex items-center text-gray-500" title="班主任">
|
| 252 |
+
<UserIcon size={14} className="mr-1"/>
|
| 253 |
+
{cls.teacherName ? (
|
| 254 |
+
<span className="font-bold text-gray-700">{cls.teacherName}</span>
|
| 255 |
+
) : <span className="text-gray-300">未设置</span>}
|
| 256 |
+
</div>
|
| 257 |
+
<div className="flex items-center text-gray-500" title="学生人数">
|
| 258 |
+
<Users size={14} className="mr-1"/>
|
| 259 |
+
<span>{cls.studentCount || 0}</span>
|
| 260 |
+
</div>
|
| 261 |
+
</div>
|
| 262 |
+
</div>
|
| 263 |
+
))}
|
| 264 |
+
</div>
|
| 265 |
+
)}
|
| 266 |
</div>
|
| 267 |
+
);
|
| 268 |
+
})}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 269 |
</div>
|
| 270 |
)}
|
| 271 |
|
|
|
|
| 286 |
<input required type="text" value={className} onChange={e => setClassName(e.target.value)} className="w-full mt-1 px-3 py-2 border rounded-lg" placeholder="(1)班" />
|
| 287 |
</div>
|
| 288 |
<div>
|
| 289 |
+
<label className="text-sm font-medium text-gray-700">指定班主任 (按姓名排序)</label>
|
| 290 |
<select value={teacherName} onChange={e => setTeacherName(e.target.value)} className="w-full mt-1 px-3 py-2 border rounded-lg bg-white">
|
| 291 |
<option value="">-- 暂不指定 --</option>
|
| 292 |
+
{teachers.map(t => (
|
| 293 |
<option key={t._id} value={t.trueName || t.username}>
|
| 294 |
{t.trueName || t.username} {t.homeroomClass ? `(已任: ${t.homeroomClass})` : ''}
|
| 295 |
</option>
|
pages/CourseList.tsx
CHANGED
|
@@ -1,14 +1,18 @@
|
|
| 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, User } from '../types';
|
| 6 |
|
| 7 |
export const CourseList: React.FC = () => {
|
| 8 |
const [courses, setCourses] = useState<Course[]>([]);
|
| 9 |
const [subjects, setSubjects] = useState<Subject[]>([]);
|
| 10 |
-
const [teachers, setTeachers] = useState<User[]>([]);
|
| 11 |
const [loading, setLoading] = useState(true);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
const [isModalOpen, setIsModalOpen] = useState(false);
|
| 13 |
const [formData, setFormData] = useState({ courseCode: '', courseName: '', teacherName: '', credits: 2, capacity: 45 });
|
| 14 |
const [editId, setEditId] = useState<string | null>(null);
|
|
@@ -19,7 +23,7 @@ export const CourseList: React.FC = () => {
|
|
| 19 |
const [c, s, t] = await Promise.all([
|
| 20 |
api.courses.getAll(),
|
| 21 |
api.subjects.getAll(),
|
| 22 |
-
api.users.getAll({ role: 'TEACHER' })
|
| 23 |
]);
|
| 24 |
setCourses(c);
|
| 25 |
setSubjects(s);
|
|
@@ -41,45 +45,72 @@ export const CourseList: React.FC = () => {
|
|
| 41 |
if(confirm('删除课程?')) { await api.courses.delete(id); loadData(); }
|
| 42 |
};
|
| 43 |
|
|
|
|
|
|
|
| 44 |
return (
|
| 45 |
<div className="space-y-6">
|
| 46 |
-
<div className="flex justify-between items-center">
|
| 47 |
-
<h2 className="text-xl font-bold">课程列表</h2>
|
| 48 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
</div>
|
| 50 |
|
| 51 |
-
{loading ? <Loader2 className="animate-spin"
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
)}
|
| 65 |
|
| 66 |
{isModalOpen && (
|
| 67 |
-
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
|
| 68 |
-
<div className="bg-white p-6 rounded-xl w-full max-w-md">
|
| 69 |
-
<h3 className="font-bold mb-4">{editId ? '
|
| 70 |
<form onSubmit={handleSubmit} className="space-y-4">
|
| 71 |
<div>
|
| 72 |
-
<label className="text-sm font-bold text-gray-500">科目</label>
|
| 73 |
-
<select className="w-full border p-2 rounded" value={formData.courseName} onChange={e=>setFormData({...formData, courseName:e.target.value})} required>
|
| 74 |
<option value="">选择科目</option>
|
| 75 |
{subjects.map(s=><option key={s._id} value={s.name}>{s.name}</option>)}
|
| 76 |
</select>
|
| 77 |
</div>
|
| 78 |
|
| 79 |
<div>
|
| 80 |
-
<label className="text-sm font-bold text-gray-500">任课教师</label>
|
| 81 |
<input
|
| 82 |
-
className="w-full border p-2 rounded"
|
| 83 |
placeholder="选择或输入教师姓名"
|
| 84 |
value={formData.teacherName}
|
| 85 |
onChange={e=>setFormData({...formData, teacherName:e.target.value})}
|
|
@@ -91,8 +122,10 @@ export const CourseList: React.FC = () => {
|
|
| 91 |
</datalist>
|
| 92 |
</div>
|
| 93 |
|
| 94 |
-
<
|
| 95 |
-
|
|
|
|
|
|
|
| 96 |
</form>
|
| 97 |
</div>
|
| 98 |
</div>
|
|
|
|
| 1 |
|
| 2 |
import React, { useEffect, useState } from 'react';
|
| 3 |
+
import { Plus, Clock, Loader2, Edit, Trash2, X, Filter } from 'lucide-react';
|
| 4 |
import { api } from '../services/api';
|
| 5 |
import { Course, Subject, User } from '../types';
|
| 6 |
|
| 7 |
export const CourseList: React.FC = () => {
|
| 8 |
const [courses, setCourses] = useState<Course[]>([]);
|
| 9 |
const [subjects, setSubjects] = useState<Subject[]>([]);
|
| 10 |
+
const [teachers, setTeachers] = useState<User[]>([]);
|
| 11 |
const [loading, setLoading] = useState(true);
|
| 12 |
+
|
| 13 |
+
// Filter state
|
| 14 |
+
const [filterSubject, setFilterSubject] = useState('All');
|
| 15 |
+
|
| 16 |
const [isModalOpen, setIsModalOpen] = useState(false);
|
| 17 |
const [formData, setFormData] = useState({ courseCode: '', courseName: '', teacherName: '', credits: 2, capacity: 45 });
|
| 18 |
const [editId, setEditId] = useState<string | null>(null);
|
|
|
|
| 23 |
const [c, s, t] = await Promise.all([
|
| 24 |
api.courses.getAll(),
|
| 25 |
api.subjects.getAll(),
|
| 26 |
+
api.users.getAll({ role: 'TEACHER' })
|
| 27 |
]);
|
| 28 |
setCourses(c);
|
| 29 |
setSubjects(s);
|
|
|
|
| 45 |
if(confirm('删除课程?')) { await api.courses.delete(id); loadData(); }
|
| 46 |
};
|
| 47 |
|
| 48 |
+
const filteredCourses = courses.filter(c => filterSubject === 'All' || c.courseName === filterSubject);
|
| 49 |
+
|
| 50 |
return (
|
| 51 |
<div className="space-y-6">
|
| 52 |
+
<div className="flex flex-col md:flex-row justify-between items-center gap-4">
|
| 53 |
+
<h2 className="text-xl font-bold text-gray-800">课程列表</h2>
|
| 54 |
+
|
| 55 |
+
<div className="flex items-center gap-3 w-full md:w-auto">
|
| 56 |
+
<div className="flex items-center bg-white border rounded-lg px-2 py-1.5 flex-1 md:flex-none">
|
| 57 |
+
<Filter size={16} className="text-gray-400 mr-2"/>
|
| 58 |
+
<select
|
| 59 |
+
className="bg-transparent text-sm outline-none text-gray-700 min-w-[100px]"
|
| 60 |
+
value={filterSubject}
|
| 61 |
+
onChange={e => setFilterSubject(e.target.value)}
|
| 62 |
+
>
|
| 63 |
+
<option value="All">所有科目</option>
|
| 64 |
+
{subjects.map(s => <option key={s._id} value={s.name}>{s.name}</option>)}
|
| 65 |
+
</select>
|
| 66 |
+
</div>
|
| 67 |
+
<button onClick={() => { setEditId(null); setIsModalOpen(true); }} className="px-4 py-2 bg-indigo-600 text-white rounded-lg flex items-center text-sm hover:bg-indigo-700 transition-colors whitespace-nowrap">
|
| 68 |
+
<Plus size={16} className="mr-1"/> 新增课程
|
| 69 |
+
</button>
|
| 70 |
+
</div>
|
| 71 |
</div>
|
| 72 |
|
| 73 |
+
{loading ? <div className="flex justify-center py-10"><Loader2 className="animate-spin text-indigo-600"/></div> : (
|
| 74 |
+
filteredCourses.length > 0 ? (
|
| 75 |
+
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
| 76 |
+
{filteredCourses.map(c => (
|
| 77 |
+
<div key={c._id || c.id} className="bg-white p-6 rounded-xl border shadow-sm hover:shadow-md transition-shadow">
|
| 78 |
+
<h3 className="font-bold text-lg text-gray-800 mb-1">{c.courseName}</h3>
|
| 79 |
+
<p className="text-sm text-gray-500 mb-4 flex items-center gap-2">
|
| 80 |
+
<span className="bg-gray-100 px-2 py-0.5 rounded text-xs">{c.courseCode || 'No Code'}</span>
|
| 81 |
+
<span>教师: {c.teacherName}</span>
|
| 82 |
+
</p>
|
| 83 |
+
<div className="flex gap-2 pt-2 border-t border-gray-50">
|
| 84 |
+
<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.5 rounded text-sm hover:bg-blue-50 transition-colors font-medium">编辑</button>
|
| 85 |
+
<button onClick={() => handleDelete(c._id || String(c.id))} className="flex-1 bg-gray-50 text-red-600 py-1.5 rounded text-sm hover:bg-red-50 transition-colors font-medium">删除</button>
|
| 86 |
+
</div>
|
| 87 |
+
</div>
|
| 88 |
+
))}
|
| 89 |
+
</div>
|
| 90 |
+
) : (
|
| 91 |
+
<div className="text-center py-12 text-gray-400 bg-white rounded-xl border border-dashed border-gray-200">
|
| 92 |
+
没有找到符合条件的课程
|
| 93 |
+
</div>
|
| 94 |
+
)
|
| 95 |
)}
|
| 96 |
|
| 97 |
{isModalOpen && (
|
| 98 |
+
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50 backdrop-blur-sm">
|
| 99 |
+
<div className="bg-white p-6 rounded-xl w-full max-w-md shadow-xl animate-in zoom-in-95">
|
| 100 |
+
<h3 className="font-bold mb-4 text-lg text-gray-800">{editId ? '编辑课程' : '新增课程'}</h3>
|
| 101 |
<form onSubmit={handleSubmit} className="space-y-4">
|
| 102 |
<div>
|
| 103 |
+
<label className="text-sm font-bold text-gray-500 mb-1 block">科目</label>
|
| 104 |
+
<select className="w-full border p-2 rounded-lg focus:ring-2 focus:ring-indigo-500 outline-none" value={formData.courseName} onChange={e=>setFormData({...formData, courseName:e.target.value})} required>
|
| 105 |
<option value="">选择科目</option>
|
| 106 |
{subjects.map(s=><option key={s._id} value={s.name}>{s.name}</option>)}
|
| 107 |
</select>
|
| 108 |
</div>
|
| 109 |
|
| 110 |
<div>
|
| 111 |
+
<label className="text-sm font-bold text-gray-500 mb-1 block">任课教师</label>
|
| 112 |
<input
|
| 113 |
+
className="w-full border p-2 rounded-lg focus:ring-2 focus:ring-indigo-500 outline-none"
|
| 114 |
placeholder="选择或输入教师姓名"
|
| 115 |
value={formData.teacherName}
|
| 116 |
onChange={e=>setFormData({...formData, teacherName:e.target.value})}
|
|
|
|
| 122 |
</datalist>
|
| 123 |
</div>
|
| 124 |
|
| 125 |
+
<div className="flex gap-3 pt-2">
|
| 126 |
+
<button type="button" onClick={()=>setIsModalOpen(false)} className="flex-1 border py-2 rounded-lg hover:bg-gray-50">取消</button>
|
| 127 |
+
<button className="flex-1 bg-indigo-600 text-white py-2 rounded-lg hover:bg-indigo-700 shadow-md">保存</button>
|
| 128 |
+
</div>
|
| 129 |
</form>
|
| 130 |
</div>
|
| 131 |
</div>
|
pages/Dashboard.tsx
CHANGED
|
@@ -15,13 +15,22 @@ export const gradeOrder: Record<string, number> = {
|
|
| 15 |
'初一': 7, '七年级': 7, '初二': 8, '八年级': 8, '初三': 9, '九年级': 9,
|
| 16 |
'高一': 10, '高二': 11, '高三': 12
|
| 17 |
};
|
|
|
|
| 18 |
export const sortGrades = (a: string, b: string) => (gradeOrder[a] || 99) - (gradeOrder[b] || 99);
|
|
|
|
| 19 |
export const sortClasses = (a: string, b: string) => {
|
|
|
|
| 20 |
const getGrade = (s: string) => Object.keys(gradeOrder).find(g => s.startsWith(g)) || '';
|
| 21 |
const gradeA = getGrade(a);
|
| 22 |
const gradeB = getGrade(b);
|
| 23 |
-
|
| 24 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
return getNum(a) - getNum(b);
|
| 26 |
};
|
| 27 |
|
|
|
|
| 15 |
'初一': 7, '七年级': 7, '初二': 8, '八年级': 8, '初三': 9, '九年级': 9,
|
| 16 |
'高一': 10, '高二': 11, '高三': 12
|
| 17 |
};
|
| 18 |
+
|
| 19 |
export const sortGrades = (a: string, b: string) => (gradeOrder[a] || 99) - (gradeOrder[b] || 99);
|
| 20 |
+
|
| 21 |
export const sortClasses = (a: string, b: string) => {
|
| 22 |
+
// 1. Sort by Grade First
|
| 23 |
const getGrade = (s: string) => Object.keys(gradeOrder).find(g => s.startsWith(g)) || '';
|
| 24 |
const gradeA = getGrade(a);
|
| 25 |
const gradeB = getGrade(b);
|
| 26 |
+
const gradeDiff = sortGrades(gradeA, gradeB);
|
| 27 |
+
if (gradeDiff !== 0) return gradeDiff;
|
| 28 |
+
|
| 29 |
+
// 2. Sort by Class Number (extract digits)
|
| 30 |
+
const getNum = (s: string) => {
|
| 31 |
+
const match = s.match(/(\d+)/);
|
| 32 |
+
return match ? parseInt(match[1]) : 0;
|
| 33 |
+
};
|
| 34 |
return getNum(a) - getNum(b);
|
| 35 |
};
|
| 36 |
|
pages/GameLucky.tsx
CHANGED
|
@@ -59,7 +59,16 @@ export const GameLucky: React.FC = () => {
|
|
| 59 |
if (isTeacher) {
|
| 60 |
if (currentUser.homeroomClass) {
|
| 61 |
targetClass = currentUser.homeroomClass;
|
| 62 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
} else {
|
| 64 |
setStudents(allStus);
|
| 65 |
}
|
|
@@ -180,7 +189,7 @@ export const GameLucky: React.FC = () => {
|
|
| 180 |
onChange={e => setProxyStudentId(e.target.value)}
|
| 181 |
>
|
| 182 |
<option value="">-- 请选择 --</option>
|
| 183 |
-
{students.map(s => <option key={s._id} value={s._id || String(s.id)}>{s.
|
| 184 |
</select>
|
| 185 |
</div>
|
| 186 |
</div>
|
|
|
|
| 59 |
if (isTeacher) {
|
| 60 |
if (currentUser.homeroomClass) {
|
| 61 |
targetClass = currentUser.homeroomClass;
|
| 62 |
+
const filtered = allStus.filter((s: Student) => s.className === currentUser.homeroomClass);
|
| 63 |
+
|
| 64 |
+
// Sort by Seat No
|
| 65 |
+
filtered.sort((a, b) => {
|
| 66 |
+
const seatA = parseInt(a.seatNo || '99999');
|
| 67 |
+
const seatB = parseInt(b.seatNo || '99999');
|
| 68 |
+
if (seatA !== seatB) return seatA - seatB;
|
| 69 |
+
return a.name.localeCompare(b.name, 'zh-CN');
|
| 70 |
+
});
|
| 71 |
+
setStudents(filtered);
|
| 72 |
} else {
|
| 73 |
setStudents(allStus);
|
| 74 |
}
|
|
|
|
| 189 |
onChange={e => setProxyStudentId(e.target.value)}
|
| 190 |
>
|
| 191 |
<option value="">-- 请选择 --</option>
|
| 192 |
+
{students.map(s => <option key={s._id} value={s._id || String(s.id)}>{s.seatNo ? s.seatNo+'.':''}{s.name}</option>)}
|
| 193 |
</select>
|
| 194 |
</div>
|
| 195 |
</div>
|
pages/GameMountain.tsx
CHANGED
|
@@ -93,10 +93,12 @@ export const GameMountain: React.FC = () => {
|
|
| 93 |
|
| 94 |
const allStudents = await api.students.getAll();
|
| 95 |
let targetClass = '';
|
|
|
|
| 96 |
|
| 97 |
if (isTeacher && currentUser.homeroomClass) {
|
| 98 |
targetClass = currentUser.homeroomClass;
|
| 99 |
-
|
|
|
|
| 100 |
// Load Achievements for this class
|
| 101 |
const ac = await api.achievements.getConfig(targetClass);
|
| 102 |
setAchConfig(ac);
|
|
@@ -104,10 +106,18 @@ export const GameMountain: React.FC = () => {
|
|
| 104 |
const me = allStudents.find((s: Student) => s.name === (currentUser.trueName || currentUser.username));
|
| 105 |
if (me) targetClass = me.className;
|
| 106 |
} else if (isAdmin) {
|
| 107 |
-
|
| 108 |
-
// Ideally admin selects a class first, but for now fallback to first class or empty
|
| 109 |
}
|
| 110 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
if (targetClass) {
|
| 112 |
const sess = await api.games.getMountainSession(targetClass);
|
| 113 |
if (sess) {
|
|
@@ -140,7 +150,6 @@ export const GameMountain: React.FC = () => {
|
|
| 140 |
if (!session || !isTeacher) return;
|
| 141 |
|
| 142 |
// We need current config to check for semester (for achievements)
|
| 143 |
-
// Assuming current semester from global config if needed, or default
|
| 144 |
const sysConfig = await api.config.getPublic();
|
| 145 |
const currentSemester = sysConfig?.semester || '当前学期';
|
| 146 |
|
|
@@ -177,7 +186,6 @@ export const GameMountain: React.FC = () => {
|
|
| 177 |
}
|
| 178 |
}
|
| 179 |
});
|
| 180 |
-
// alert(`🎉 ${t.name} 到达 ${newScore} 步!奖励已发放!`); // Optional: Notification
|
| 181 |
}
|
| 182 |
}
|
| 183 |
return { ...t, score: newScore };
|
|
@@ -392,7 +400,7 @@ export const GameMountain: React.FC = () => {
|
|
| 392 |
}`}
|
| 393 |
title={isInOther ? `已在 ${otherTeam?.name}` : ''}
|
| 394 |
>
|
| 395 |
-
<span className="truncate font-medium">{s.name}</span>
|
| 396 |
{isInCurrent && <CheckSquare size={14} className="text-blue-200"/>}
|
| 397 |
{isInOther && <span className="text-[10px]">{otherTeam?.avatar}</span>}
|
| 398 |
</div>
|
|
|
|
| 93 |
|
| 94 |
const allStudents = await api.students.getAll();
|
| 95 |
let targetClass = '';
|
| 96 |
+
let filteredStudents: Student[] = [];
|
| 97 |
|
| 98 |
if (isTeacher && currentUser.homeroomClass) {
|
| 99 |
targetClass = currentUser.homeroomClass;
|
| 100 |
+
filteredStudents = allStudents.filter((s: Student) => s.className === targetClass);
|
| 101 |
+
|
| 102 |
// Load Achievements for this class
|
| 103 |
const ac = await api.achievements.getConfig(targetClass);
|
| 104 |
setAchConfig(ac);
|
|
|
|
| 106 |
const me = allStudents.find((s: Student) => s.name === (currentUser.trueName || currentUser.username));
|
| 107 |
if (me) targetClass = me.className;
|
| 108 |
} else if (isAdmin) {
|
| 109 |
+
filteredStudents = allStudents;
|
|
|
|
| 110 |
}
|
| 111 |
|
| 112 |
+
// Sort students
|
| 113 |
+
filteredStudents.sort((a, b) => {
|
| 114 |
+
const seatA = parseInt(a.seatNo || '99999');
|
| 115 |
+
const seatB = parseInt(b.seatNo || '99999');
|
| 116 |
+
if (seatA !== seatB) return seatA - seatB;
|
| 117 |
+
return a.name.localeCompare(b.name, 'zh-CN');
|
| 118 |
+
});
|
| 119 |
+
setStudents(filteredStudents);
|
| 120 |
+
|
| 121 |
if (targetClass) {
|
| 122 |
const sess = await api.games.getMountainSession(targetClass);
|
| 123 |
if (sess) {
|
|
|
|
| 150 |
if (!session || !isTeacher) return;
|
| 151 |
|
| 152 |
// We need current config to check for semester (for achievements)
|
|
|
|
| 153 |
const sysConfig = await api.config.getPublic();
|
| 154 |
const currentSemester = sysConfig?.semester || '当前学期';
|
| 155 |
|
|
|
|
| 186 |
}
|
| 187 |
}
|
| 188 |
});
|
|
|
|
| 189 |
}
|
| 190 |
}
|
| 191 |
return { ...t, score: newScore };
|
|
|
|
| 400 |
}`}
|
| 401 |
title={isInOther ? `已在 ${otherTeam?.name}` : ''}
|
| 402 |
>
|
| 403 |
+
<span className="truncate font-medium">{s.seatNo ? s.seatNo+'.':''}{s.name}</span>
|
| 404 |
{isInCurrent && <CheckSquare size={14} className="text-blue-200"/>}
|
| 405 |
{isInOther && <span className="text-[10px]">{otherTeam?.avatar}</span>}
|
| 406 |
</div>
|
pages/GameRewards.tsx
CHANGED
|
@@ -49,7 +49,6 @@ export const GameRewards: React.FC = () => {
|
|
| 49 |
}
|
| 50 |
} else {
|
| 51 |
// Teacher View
|
| 52 |
-
// Only fetch students for grant dropdown
|
| 53 |
const allStus = await api.students.getAll();
|
| 54 |
|
| 55 |
let targetClass = '';
|
|
@@ -60,14 +59,19 @@ export const GameRewards: React.FC = () => {
|
|
| 60 |
filteredStudents = allStus.filter((s: Student) => s.className === currentUser.homeroomClass);
|
| 61 |
}
|
| 62 |
|
| 63 |
-
// Fetch filtered by class directly from API
|
| 64 |
-
// Backend handles excluding CONSOLATION now
|
| 65 |
const res = await api.rewards.getClassRewards(page, PAGE_SIZE, targetClass);
|
| 66 |
|
| 67 |
setRewards(res.list || []);
|
| 68 |
setTotal(res.total || 0);
|
| 69 |
-
|
| 70 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
}
|
| 72 |
} catch (e) { console.error(e); }
|
| 73 |
finally { setLoading(false); }
|
|
@@ -116,8 +120,7 @@ export const GameRewards: React.FC = () => {
|
|
| 116 |
setEditForm({ name: r.name, count: r.count || 1 });
|
| 117 |
};
|
| 118 |
|
| 119 |
-
// Client-side Filtering
|
| 120 |
-
// (In a full prod app, these would be API params, but okay for small page sizes)
|
| 121 |
const displayRewards = rewards.filter(r => {
|
| 122 |
if (filterType !== 'ALL' && r.rewardType !== filterType) return false;
|
| 123 |
if (filterStatus !== 'ALL' && r.status !== filterStatus) return false;
|
|
@@ -271,7 +274,7 @@ export const GameRewards: React.FC = () => {
|
|
| 271 |
<label className="block text-xs font-bold text-gray-500 uppercase mb-1">选择学生</label>
|
| 272 |
<select className="w-full border border-gray-300 p-2 rounded-lg bg-gray-50 focus:bg-white text-sm" value={grantForm.studentId} onChange={e=>setGrantForm({...grantForm, studentId: e.target.value})}>
|
| 273 |
<option value="">-- 请选择 --</option>
|
| 274 |
-
{students.map(s => <option key={s._id} value={s._id || String(s.id)}>{s.
|
| 275 |
</select>
|
| 276 |
</div>
|
| 277 |
<div>
|
|
|
|
| 49 |
}
|
| 50 |
} else {
|
| 51 |
// Teacher View
|
|
|
|
| 52 |
const allStus = await api.students.getAll();
|
| 53 |
|
| 54 |
let targetClass = '';
|
|
|
|
| 59 |
filteredStudents = allStus.filter((s: Student) => s.className === currentUser.homeroomClass);
|
| 60 |
}
|
| 61 |
|
|
|
|
|
|
|
| 62 |
const res = await api.rewards.getClassRewards(page, PAGE_SIZE, targetClass);
|
| 63 |
|
| 64 |
setRewards(res.list || []);
|
| 65 |
setTotal(res.total || 0);
|
| 66 |
+
|
| 67 |
+
// Sort students: Seat No > Name
|
| 68 |
+
filteredStudents.sort((a: Student, b: Student) => {
|
| 69 |
+
const seatA = parseInt(a.seatNo || '99999');
|
| 70 |
+
const seatB = parseInt(b.seatNo || '99999');
|
| 71 |
+
if (seatA !== seatB) return seatA - seatB;
|
| 72 |
+
return a.name.localeCompare(b.name, 'zh-CN');
|
| 73 |
+
});
|
| 74 |
+
setStudents(filteredStudents);
|
| 75 |
}
|
| 76 |
} catch (e) { console.error(e); }
|
| 77 |
finally { setLoading(false); }
|
|
|
|
| 120 |
setEditForm({ name: r.name, count: r.count || 1 });
|
| 121 |
};
|
| 122 |
|
| 123 |
+
// Client-side Filtering
|
|
|
|
| 124 |
const displayRewards = rewards.filter(r => {
|
| 125 |
if (filterType !== 'ALL' && r.rewardType !== filterType) return false;
|
| 126 |
if (filterStatus !== 'ALL' && r.status !== filterStatus) return false;
|
|
|
|
| 274 |
<label className="block text-xs font-bold text-gray-500 uppercase mb-1">选择学生</label>
|
| 275 |
<select className="w-full border border-gray-300 p-2 rounded-lg bg-gray-50 focus:bg-white text-sm" value={grantForm.studentId} onChange={e=>setGrantForm({...grantForm, studentId: e.target.value})}>
|
| 276 |
<option value="">-- 请选择 --</option>
|
| 277 |
+
{students.map(s => <option key={s._id} value={s._id || String(s.id)}>{s.seatNo ? s.seatNo+'.':''}{s.name}</option>)}
|
| 278 |
</select>
|
| 279 |
</div>
|
| 280 |
<div>
|
pages/ScoreList.tsx
CHANGED
|
@@ -2,27 +2,30 @@
|
|
| 2 |
import React, { useState, useEffect } from 'react';
|
| 3 |
import { api } from '../services/api';
|
| 4 |
import { Score, Student, Subject, ClassInfo, ExamStatus, Exam, SystemConfig } from '../types';
|
| 5 |
-
import { Loader2, Plus, Trash2, Award, FileSpreadsheet, X, Upload, Edit, Save, Calendar } from 'lucide-react';
|
| 6 |
|
| 7 |
-
// Reuse sort helper
|
| 8 |
const localSortGrades = (a: string, b: string) => {
|
| 9 |
const order: Record<string, number> = { '一年级': 1, '二年级': 2, '三年级': 3, '四年级': 4, '五年级': 5, '六年级': 6, '初一': 7, '七年级': 7, '初二': 8, '八年级': 8, '初三': 9, '九年级': 9, '高一': 10, '高二': 11, '高三': 12 };
|
| 10 |
return (order[a] || 99) - (order[b] || 99);
|
| 11 |
};
|
| 12 |
|
| 13 |
export const ScoreList: React.FC = () => {
|
| 14 |
-
// ... state ...
|
| 15 |
const [subjects, setSubjects] = useState<Subject[]>([]);
|
| 16 |
const [activeSubject, setActiveSubject] = useState('');
|
| 17 |
const [scores, setScores] = useState<Score[]>([]);
|
| 18 |
const [students, setStudents] = useState<Student[]>([]);
|
| 19 |
const [classList, setClassList] = useState<ClassInfo[]>([]);
|
| 20 |
const [exams, setExams] = useState<Exam[]>([]);
|
| 21 |
-
const [semesters, setSemesters] = useState<string[]>([]);
|
| 22 |
const [loading, setLoading] = useState(true);
|
| 23 |
|
|
|
|
| 24 |
const [selectedGrade, setSelectedGrade] = useState('All');
|
| 25 |
const [selectedClass, setSelectedClass] = useState('All');
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
| 27 |
|
| 28 |
const [isAddOpen, setIsAddOpen] = useState(false);
|
|
@@ -30,7 +33,7 @@ export const ScoreList: React.FC = () => {
|
|
| 30 |
const [isExamModalOpen, setIsExamModalOpen] = useState(false);
|
| 31 |
const [importFile, setImportFile] = useState<File | null>(null);
|
| 32 |
const [submitting, setSubmitting] = useState(false);
|
| 33 |
-
const [importSemester, setImportSemester] = useState('');
|
| 34 |
const [importType, setImportType] = useState('Final');
|
| 35 |
const [importExamName, setImportExamName] = useState('');
|
| 36 |
const [formData, setFormData] = useState({ studentId: '', score: '', type: 'Final', examName: '', status: 'Normal' });
|
|
@@ -49,7 +52,7 @@ export const ScoreList: React.FC = () => {
|
|
| 49 |
api.students.getAll(),
|
| 50 |
api.classes.getAll(),
|
| 51 |
api.exams.getAll(),
|
| 52 |
-
api.config.getPublic()
|
| 53 |
]);
|
| 54 |
setSubjects(subs);
|
| 55 |
if (subs.length > 0 && !activeSubject) setActiveSubject(subs[0].name);
|
|
@@ -57,7 +60,6 @@ export const ScoreList: React.FC = () => {
|
|
| 57 |
setStudents(stus);
|
| 58 |
setClassList(cls);
|
| 59 |
setExams(exs);
|
| 60 |
-
// Set Semesters
|
| 61 |
if (cfg && cfg.semesters) {
|
| 62 |
setSemesters(cfg.semesters);
|
| 63 |
if (cfg.semester) setImportSemester(cfg.semester);
|
|
@@ -69,24 +71,39 @@ export const ScoreList: React.FC = () => {
|
|
| 69 |
|
| 70 |
useEffect(() => { loadData(); }, []);
|
| 71 |
|
|
|
|
|
|
|
|
|
|
| 72 |
const hasPermission = (studentClass: string) => {
|
| 73 |
if (currentUser?.role === 'ADMIN') return true;
|
| 74 |
if (currentUser?.role === 'TEACHER' && currentUser.homeroomClass === studentClass) return true;
|
| 75 |
return false;
|
| 76 |
};
|
| 77 |
|
| 78 |
-
// ... (Permission Checks same as before) ...
|
| 79 |
const canModify = currentUser?.role === 'ADMIN' || !!currentUser?.homeroomClass;
|
| 80 |
|
|
|
|
| 81 |
const filteredScores = scores.filter(s => {
|
| 82 |
if (s.courseName !== activeSubject) return false;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
const stu = students.find(st => st.studentNo === s.studentNo);
|
| 84 |
if (!stu) return false;
|
|
|
|
| 85 |
const matchesGrade = selectedGrade === 'All' || stu.className.includes(selectedGrade);
|
| 86 |
const matchesClass = selectedClass === 'All' || stu.className.includes(selectedClass);
|
| 87 |
return matchesGrade && matchesClass;
|
| 88 |
});
|
| 89 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
const handleBatchDelete = async () => {
|
| 91 |
if (confirm(`删除 ${selectedIds.size} 条记录?`)) {
|
| 92 |
await api.batchDelete('score', Array.from(selectedIds));
|
|
@@ -106,7 +123,7 @@ export const ScoreList: React.FC = () => {
|
|
| 106 |
studentNo: stu.studentNo,
|
| 107 |
courseName: activeSubject,
|
| 108 |
score: formData.status === 'Normal' ? Number(formData.score) : 0,
|
| 109 |
-
semester: importSemester,
|
| 110 |
type: formData.type,
|
| 111 |
examName: formData.examName,
|
| 112 |
status: formData.status
|
|
@@ -116,7 +133,6 @@ export const ScoreList: React.FC = () => {
|
|
| 116 |
loadData();
|
| 117 |
};
|
| 118 |
|
| 119 |
-
// ... (startEditing, saveEdit logic same) ...
|
| 120 |
const startEditing = (s: Score) => {
|
| 121 |
const stu = students.find(st => st.studentNo === s.studentNo);
|
| 122 |
if (stu && !hasPermission(stu.className)) return alert('无权编辑');
|
|
@@ -190,17 +206,18 @@ export const ScoreList: React.FC = () => {
|
|
| 190 |
|
| 191 |
const toggleSelect = (id: string) => { const newSet = new Set(selectedIds); if (newSet.has(id)) newSet.delete(id); else newSet.add(id); setSelectedIds(newSet); };
|
| 192 |
|
| 193 |
-
// SORTED Grades
|
| 194 |
const uniqueGrades = Array.from(new Set(classList.map(c => c.grade))).sort(localSortGrades);
|
| 195 |
-
|
| 196 |
const filteredClasses = classList.filter(c => selectedGrade === 'All' || c.grade === selectedGrade);
|
| 197 |
const uniqueClasses = Array.from(new Set(filteredClasses.map(c => c.className))).sort();
|
| 198 |
-
|
|
|
|
|
|
|
| 199 |
|
| 200 |
return (
|
| 201 |
<div className="space-y-6">
|
| 202 |
{/* ... Header and Filters ... */}
|
| 203 |
-
<div className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden min-h-[
|
| 204 |
{loading && <div className="p-10 text-center"><Loader2 className="animate-spin inline"/></div>}
|
| 205 |
|
| 206 |
<div className="p-4 md:p-6 border-b border-gray-100">
|
|
@@ -220,15 +237,22 @@ export const ScoreList: React.FC = () => {
|
|
| 220 |
</div>
|
| 221 |
)}
|
| 222 |
</div>
|
| 223 |
-
|
| 224 |
-
|
|
|
|
|
|
|
|
|
|
| 225 |
<option value="All">所有年级</option>
|
| 226 |
{uniqueGrades.map(g=><option key={g} value={g}>{g}</option>)}
|
| 227 |
</select>
|
| 228 |
-
<select className="border border-gray-300 p-2 rounded text-sm text-gray-900 focus:ring-2 focus:ring-blue-500" value={selectedClass} onChange={e=>setSelectedClass(e.target.value)}>
|
| 229 |
<option value="All">所有班级</option>
|
| 230 |
{uniqueClasses.map(c=><option key={c} value={c}>{c}</option>)}
|
| 231 |
</select>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 232 |
</div>
|
| 233 |
</div>
|
| 234 |
|
|
@@ -243,15 +267,12 @@ export const ScoreList: React.FC = () => {
|
|
| 243 |
</div>
|
| 244 |
|
| 245 |
{/* Table ... */}
|
| 246 |
-
<div className="overflow-x-auto">
|
| 247 |
<table className="w-full text-left min-w-[800px]">
|
| 248 |
-
<thead className="bg-gray-50 text-xs text-gray-500 uppercase">
|
| 249 |
<tr>
|
| 250 |
<th className="px-6 py-3 w-10">
|
| 251 |
-
{canModify && <input type="checkbox"
|
| 252 |
-
if(e.target.checked) setSelectedIds(new Set(filteredScores.map(s=>s._id||String(s.id))));
|
| 253 |
-
else setSelectedIds(new Set());
|
| 254 |
-
}} checked={filteredScores.length > 0 && selectedIds.size === filteredScores.length}/>}
|
| 255 |
</th>
|
| 256 |
<th className="px-6 py-3">姓名</th>
|
| 257 |
<th className="px-6 py-3">考试名称</th>
|
|
@@ -260,7 +281,7 @@ export const ScoreList: React.FC = () => {
|
|
| 260 |
</tr>
|
| 261 |
</thead>
|
| 262 |
<tbody>
|
| 263 |
-
{
|
| 264 |
const isEditing = editingId === (s._id || String(s.id));
|
| 265 |
const displayScore = s.status === 'Normal' ? s.score : (s.status === 'Absent' ? '缺考' : s.status === 'Leave' ? '请假' : '作弊');
|
| 266 |
const scoreColor = s.status !== 'Normal' ? 'text-gray-400' : (s.score < 60 ? 'text-red-500' : 'text-blue-600');
|
|
@@ -300,10 +321,24 @@ export const ScoreList: React.FC = () => {
|
|
| 300 |
</td>
|
| 301 |
</tr>
|
| 302 |
);
|
| 303 |
-
})
|
|
|
|
|
|
|
| 304 |
</tbody>
|
| 305 |
</table>
|
| 306 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 307 |
</div>
|
| 308 |
|
| 309 |
{selectedIds.size > 0 && (
|
|
@@ -346,7 +381,7 @@ export const ScoreList: React.FC = () => {
|
|
| 346 |
</div>
|
| 347 |
)}
|
| 348 |
|
| 349 |
-
{/* Exam Schedule Modal
|
| 350 |
{isExamModalOpen && (
|
| 351 |
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
| 352 |
<div className="bg-white rounded-xl p-6 w-full max-w-lg max-h-[80vh] overflow-y-auto">
|
|
@@ -406,7 +441,6 @@ export const ScoreList: React.FC = () => {
|
|
| 406 |
<option value="Midterm">期中考试</option>
|
| 407 |
<option value="Quiz">平时测验</option>
|
| 408 |
</select>
|
| 409 |
-
{/* Updated Semester Selection */}
|
| 410 |
<select className="border border-gray-300 p-2 rounded text-sm text-gray-900" value={importSemester} onChange={e=>setImportSemester(e.target.value)}>
|
| 411 |
{semesters.map(s => <option key={s} value={s}>{s}</option>)}
|
| 412 |
</select>
|
|
|
|
| 2 |
import React, { useState, useEffect } from 'react';
|
| 3 |
import { api } from '../services/api';
|
| 4 |
import { Score, Student, Subject, ClassInfo, ExamStatus, Exam, SystemConfig } from '../types';
|
| 5 |
+
import { Loader2, Plus, Trash2, Award, FileSpreadsheet, X, Upload, Edit, Save, Calendar, Filter, ChevronLeft, ChevronRight } from 'lucide-react';
|
| 6 |
|
|
|
|
| 7 |
const localSortGrades = (a: string, b: string) => {
|
| 8 |
const order: Record<string, number> = { '一年级': 1, '二年级': 2, '三年级': 3, '四年级': 4, '五年级': 5, '六年级': 6, '初一': 7, '七年级': 7, '初二': 8, '八年级': 8, '初三': 9, '九年级': 9, '高一': 10, '高二': 11, '高三': 12 };
|
| 9 |
return (order[a] || 99) - (order[b] || 99);
|
| 10 |
};
|
| 11 |
|
| 12 |
export const ScoreList: React.FC = () => {
|
|
|
|
| 13 |
const [subjects, setSubjects] = useState<Subject[]>([]);
|
| 14 |
const [activeSubject, setActiveSubject] = useState('');
|
| 15 |
const [scores, setScores] = useState<Score[]>([]);
|
| 16 |
const [students, setStudents] = useState<Student[]>([]);
|
| 17 |
const [classList, setClassList] = useState<ClassInfo[]>([]);
|
| 18 |
const [exams, setExams] = useState<Exam[]>([]);
|
| 19 |
+
const [semesters, setSemesters] = useState<string[]>([]);
|
| 20 |
const [loading, setLoading] = useState(true);
|
| 21 |
|
| 22 |
+
// Filters & Pagination
|
| 23 |
const [selectedGrade, setSelectedGrade] = useState('All');
|
| 24 |
const [selectedClass, setSelectedClass] = useState('All');
|
| 25 |
+
const [selectedExam, setSelectedExam] = useState('All'); // New Exam Filter
|
| 26 |
+
const [page, setPage] = useState(1);
|
| 27 |
+
const pageSize = 15;
|
| 28 |
+
|
| 29 |
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
| 30 |
|
| 31 |
const [isAddOpen, setIsAddOpen] = useState(false);
|
|
|
|
| 33 |
const [isExamModalOpen, setIsExamModalOpen] = useState(false);
|
| 34 |
const [importFile, setImportFile] = useState<File | null>(null);
|
| 35 |
const [submitting, setSubmitting] = useState(false);
|
| 36 |
+
const [importSemester, setImportSemester] = useState('');
|
| 37 |
const [importType, setImportType] = useState('Final');
|
| 38 |
const [importExamName, setImportExamName] = useState('');
|
| 39 |
const [formData, setFormData] = useState({ studentId: '', score: '', type: 'Final', examName: '', status: 'Normal' });
|
|
|
|
| 52 |
api.students.getAll(),
|
| 53 |
api.classes.getAll(),
|
| 54 |
api.exams.getAll(),
|
| 55 |
+
api.config.getPublic()
|
| 56 |
]);
|
| 57 |
setSubjects(subs);
|
| 58 |
if (subs.length > 0 && !activeSubject) setActiveSubject(subs[0].name);
|
|
|
|
| 60 |
setStudents(stus);
|
| 61 |
setClassList(cls);
|
| 62 |
setExams(exs);
|
|
|
|
| 63 |
if (cfg && cfg.semesters) {
|
| 64 |
setSemesters(cfg.semesters);
|
| 65 |
if (cfg.semester) setImportSemester(cfg.semester);
|
|
|
|
| 71 |
|
| 72 |
useEffect(() => { loadData(); }, []);
|
| 73 |
|
| 74 |
+
// Reset page when filters change
|
| 75 |
+
useEffect(() => { setPage(1); }, [activeSubject, selectedGrade, selectedClass, selectedExam]);
|
| 76 |
+
|
| 77 |
const hasPermission = (studentClass: string) => {
|
| 78 |
if (currentUser?.role === 'ADMIN') return true;
|
| 79 |
if (currentUser?.role === 'TEACHER' && currentUser.homeroomClass === studentClass) return true;
|
| 80 |
return false;
|
| 81 |
};
|
| 82 |
|
|
|
|
| 83 |
const canModify = currentUser?.role === 'ADMIN' || !!currentUser?.homeroomClass;
|
| 84 |
|
| 85 |
+
// Filter Logic
|
| 86 |
const filteredScores = scores.filter(s => {
|
| 87 |
if (s.courseName !== activeSubject) return false;
|
| 88 |
+
|
| 89 |
+
// Exam Filter
|
| 90 |
+
if (selectedExam !== 'All') {
|
| 91 |
+
const sExamName = s.examName || s.type;
|
| 92 |
+
if (sExamName !== selectedExam) return false;
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
const stu = students.find(st => st.studentNo === s.studentNo);
|
| 96 |
if (!stu) return false;
|
| 97 |
+
|
| 98 |
const matchesGrade = selectedGrade === 'All' || stu.className.includes(selectedGrade);
|
| 99 |
const matchesClass = selectedClass === 'All' || stu.className.includes(selectedClass);
|
| 100 |
return matchesGrade && matchesClass;
|
| 101 |
});
|
| 102 |
|
| 103 |
+
// Pagination Logic
|
| 104 |
+
const totalPages = Math.ceil(filteredScores.length / pageSize);
|
| 105 |
+
const paginatedScores = filteredScores.slice((page - 1) * pageSize, page * pageSize);
|
| 106 |
+
|
| 107 |
const handleBatchDelete = async () => {
|
| 108 |
if (confirm(`删除 ${selectedIds.size} 条记录?`)) {
|
| 109 |
await api.batchDelete('score', Array.from(selectedIds));
|
|
|
|
| 123 |
studentNo: stu.studentNo,
|
| 124 |
courseName: activeSubject,
|
| 125 |
score: formData.status === 'Normal' ? Number(formData.score) : 0,
|
| 126 |
+
semester: importSemester,
|
| 127 |
type: formData.type,
|
| 128 |
examName: formData.examName,
|
| 129 |
status: formData.status
|
|
|
|
| 133 |
loadData();
|
| 134 |
};
|
| 135 |
|
|
|
|
| 136 |
const startEditing = (s: Score) => {
|
| 137 |
const stu = students.find(st => st.studentNo === s.studentNo);
|
| 138 |
if (stu && !hasPermission(stu.className)) return alert('无权编辑');
|
|
|
|
| 206 |
|
| 207 |
const toggleSelect = (id: string) => { const newSet = new Set(selectedIds); if (newSet.has(id)) newSet.delete(id); else newSet.add(id); setSelectedIds(newSet); };
|
| 208 |
|
| 209 |
+
// SORTED Grades & Classes
|
| 210 |
const uniqueGrades = Array.from(new Set(classList.map(c => c.grade))).sort(localSortGrades);
|
|
|
|
| 211 |
const filteredClasses = classList.filter(c => selectedGrade === 'All' || c.grade === selectedGrade);
|
| 212 |
const uniqueClasses = Array.from(new Set(filteredClasses.map(c => c.className))).sort();
|
| 213 |
+
|
| 214 |
+
// Unique Exam Names for Filter (Filtered by subject potentially, but simpler to show all known for now)
|
| 215 |
+
const uniqueExamNames = Array.from(new Set(scores.filter(s => s.courseName === activeSubject).map(s => s.examName || s.type)));
|
| 216 |
|
| 217 |
return (
|
| 218 |
<div className="space-y-6">
|
| 219 |
{/* ... Header and Filters ... */}
|
| 220 |
+
<div className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden flex flex-col min-h-[600px]">
|
| 221 |
{loading && <div className="p-10 text-center"><Loader2 className="animate-spin inline"/></div>}
|
| 222 |
|
| 223 |
<div className="p-4 md:p-6 border-b border-gray-100">
|
|
|
|
| 237 |
</div>
|
| 238 |
)}
|
| 239 |
</div>
|
| 240 |
+
|
| 241 |
+
{/* Filter Bar */}
|
| 242 |
+
<div className="flex flex-wrap gap-2 items-center bg-gray-50 p-3 rounded-lg">
|
| 243 |
+
<div className="flex items-center text-gray-500 text-sm font-bold mr-2"><Filter size={16} className="mr-1"/> 筛选:</div>
|
| 244 |
+
<select className="border border-gray-300 p-2 rounded text-sm text-gray-900 focus:ring-2 focus:ring-blue-500 outline-none bg-white" value={selectedGrade} onChange={e => { setSelectedGrade(e.target.value); setSelectedClass('All'); }}>
|
| 245 |
<option value="All">所有年级</option>
|
| 246 |
{uniqueGrades.map(g=><option key={g} value={g}>{g}</option>)}
|
| 247 |
</select>
|
| 248 |
+
<select className="border border-gray-300 p-2 rounded text-sm text-gray-900 focus:ring-2 focus:ring-blue-500 outline-none bg-white" value={selectedClass} onChange={e=>setSelectedClass(e.target.value)}>
|
| 249 |
<option value="All">所有班级</option>
|
| 250 |
{uniqueClasses.map(c=><option key={c} value={c}>{c}</option>)}
|
| 251 |
</select>
|
| 252 |
+
<select className="border border-gray-300 p-2 rounded text-sm text-gray-900 focus:ring-2 focus:ring-blue-500 outline-none bg-white" value={selectedExam} onChange={e=>setSelectedExam(e.target.value)}>
|
| 253 |
+
<option value="All">所有考试</option>
|
| 254 |
+
{uniqueExamNames.map(e=><option key={e} value={e}>{e}</option>)}
|
| 255 |
+
</select>
|
| 256 |
</div>
|
| 257 |
</div>
|
| 258 |
|
|
|
|
| 267 |
</div>
|
| 268 |
|
| 269 |
{/* Table ... */}
|
| 270 |
+
<div className="flex-1 overflow-x-auto">
|
| 271 |
<table className="w-full text-left min-w-[800px]">
|
| 272 |
+
<thead className="bg-gray-50 text-xs text-gray-500 uppercase sticky top-0">
|
| 273 |
<tr>
|
| 274 |
<th className="px-6 py-3 w-10">
|
| 275 |
+
{canModify && <input type="checkbox" disabled/>} {/* Batch delete logic simplified for pagination */}
|
|
|
|
|
|
|
|
|
|
| 276 |
</th>
|
| 277 |
<th className="px-6 py-3">姓名</th>
|
| 278 |
<th className="px-6 py-3">考试名称</th>
|
|
|
|
| 281 |
</tr>
|
| 282 |
</thead>
|
| 283 |
<tbody>
|
| 284 |
+
{paginatedScores.length > 0 ? paginatedScores.map(s => {
|
| 285 |
const isEditing = editingId === (s._id || String(s.id));
|
| 286 |
const displayScore = s.status === 'Normal' ? s.score : (s.status === 'Absent' ? '缺考' : s.status === 'Leave' ? '请假' : '作弊');
|
| 287 |
const scoreColor = s.status !== 'Normal' ? 'text-gray-400' : (s.score < 60 ? 'text-red-500' : 'text-blue-600');
|
|
|
|
| 321 |
</td>
|
| 322 |
</tr>
|
| 323 |
);
|
| 324 |
+
}) : (
|
| 325 |
+
<tr><td colSpan={5} className="text-center py-10 text-gray-400">没有找到成绩记录</td></tr>
|
| 326 |
+
)}
|
| 327 |
</tbody>
|
| 328 |
</table>
|
| 329 |
</div>
|
| 330 |
+
|
| 331 |
+
{/* Pagination Footer */}
|
| 332 |
+
{totalPages > 1 && (
|
| 333 |
+
<div className="p-4 border-t border-gray-100 flex items-center justify-between bg-gray-50">
|
| 334 |
+
<span className="text-xs text-gray-500">共 {filteredScores.length} 条</span>
|
| 335 |
+
<div className="flex items-center gap-2">
|
| 336 |
+
<button onClick={()=>setPage(Math.max(1, page-1))} disabled={page===1} className="p-1 rounded hover:bg-gray-200 disabled:opacity-30"><ChevronLeft size={16}/></button>
|
| 337 |
+
<span className="text-sm font-medium text-gray-700">第 {page} / {totalPages} 页</span>
|
| 338 |
+
<button onClick={()=>setPage(Math.min(totalPages, page+1))} disabled={page>=totalPages} className="p-1 rounded hover:bg-gray-200 disabled:opacity-30"><ChevronRight size={16}/></button>
|
| 339 |
+
</div>
|
| 340 |
+
</div>
|
| 341 |
+
)}
|
| 342 |
</div>
|
| 343 |
|
| 344 |
{selectedIds.size > 0 && (
|
|
|
|
| 381 |
</div>
|
| 382 |
)}
|
| 383 |
|
| 384 |
+
{/* Exam Schedule Modal */}
|
| 385 |
{isExamModalOpen && (
|
| 386 |
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
| 387 |
<div className="bg-white rounded-xl p-6 w-full max-w-lg max-h-[80vh] overflow-y-auto">
|
|
|
|
| 441 |
<option value="Midterm">期中考试</option>
|
| 442 |
<option value="Quiz">平时测验</option>
|
| 443 |
</select>
|
|
|
|
| 444 |
<select className="border border-gray-300 p-2 rounded text-sm text-gray-900" value={importSemester} onChange={e=>setImportSemester(e.target.value)}>
|
| 445 |
{semesters.map(s => <option key={s} value={s}>{s}</option>)}
|
| 446 |
</select>
|