Spaces:
Sleeping
Sleeping
Upload 37 files
Browse files- components/Header.tsx +36 -21
- pages/ClassList.tsx +3 -3
- pages/Login.tsx +98 -69
- pages/SchoolList.tsx +3 -1
- pages/StudentList.tsx +38 -13
- server.js +138 -5
- types.ts +4 -2
components/Header.tsx
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
|
|
| 1 |
import React, { useState, useEffect } from 'react';
|
| 2 |
import { Bell, Search, Menu, Building, Info, Check, AlertTriangle } from 'lucide-react';
|
| 3 |
import { User, School, Notification } from '../types';
|
|
@@ -19,36 +20,46 @@ export const Header: React.FC<HeaderProps> = ({ user, title, onMenuClick }) => {
|
|
| 19 |
const [showNotif, setShowNotif] = useState(false);
|
| 20 |
const [hasUnread, setHasUnread] = useState(false);
|
| 21 |
|
| 22 |
-
|
| 23 |
if (user.role === 'ADMIN') {
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
|
|
|
| 38 |
} else {
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
|
|
|
| 43 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
|
| 45 |
// Fetch notifications
|
| 46 |
const fetchNotifs = async () => {
|
| 47 |
try {
|
| 48 |
const list = await api.notifications.getAll(user._id || '', user.role);
|
| 49 |
setNotifications(list);
|
| 50 |
-
// Simple logic: if recent notifications exist, show red dot.
|
| 51 |
-
// In real app, track 'read' IDs in local storage or DB.
|
| 52 |
const lastReadTime = localStorage.getItem('last_read_notif_time');
|
| 53 |
if (list.length > 0) {
|
| 54 |
if (!lastReadTime || new Date(list[0].createTime) > new Date(lastReadTime)) {
|
|
@@ -58,6 +69,10 @@ export const Header: React.FC<HeaderProps> = ({ user, title, onMenuClick }) => {
|
|
| 58 |
} catch (e) { console.error(e); }
|
| 59 |
};
|
| 60 |
fetchNotifs();
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
}, [user]);
|
| 62 |
|
| 63 |
const handleSchoolChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
|
|
|
| 1 |
+
|
| 2 |
import React, { useState, useEffect } from 'react';
|
| 3 |
import { Bell, Search, Menu, Building, Info, Check, AlertTriangle } from 'lucide-react';
|
| 4 |
import { User, School, Notification } from '../types';
|
|
|
|
| 20 |
const [showNotif, setShowNotif] = useState(false);
|
| 21 |
const [hasUnread, setHasUnread] = useState(false);
|
| 22 |
|
| 23 |
+
const fetchSchools = async () => {
|
| 24 |
if (user.role === 'ADMIN') {
|
| 25 |
+
try {
|
| 26 |
+
const data = await api.schools.getAll();
|
| 27 |
+
setSchools(data);
|
| 28 |
+
if (data.length > 0) {
|
| 29 |
+
if (!selectedSchool || !data.find((s: School) => s._id === selectedSchool)) {
|
| 30 |
+
const defaultId = data[0]._id!;
|
| 31 |
+
setSelectedSchool(defaultId);
|
| 32 |
+
localStorage.setItem('admin_view_school_id', defaultId);
|
| 33 |
+
if (!localStorage.getItem('admin_view_school_id_init')) {
|
| 34 |
+
localStorage.setItem('admin_view_school_id_init', 'true');
|
| 35 |
+
window.location.reload();
|
| 36 |
+
}
|
| 37 |
+
}
|
| 38 |
+
}
|
| 39 |
+
} catch (e) { console.error(e); }
|
| 40 |
} else {
|
| 41 |
+
try {
|
| 42 |
+
const data = await api.schools.getPublic();
|
| 43 |
+
const mySchool = data.find((s: School) => s._id === user.schoolId);
|
| 44 |
+
if (mySchool) setCurrentSchoolName(mySchool.name);
|
| 45 |
+
} catch(e) { console.error(e); }
|
| 46 |
}
|
| 47 |
+
};
|
| 48 |
+
|
| 49 |
+
useEffect(() => {
|
| 50 |
+
fetchSchools();
|
| 51 |
+
|
| 52 |
+
// Listen for custom event to refresh schools
|
| 53 |
+
const handleSchoolUpdate = () => {
|
| 54 |
+
fetchSchools();
|
| 55 |
+
};
|
| 56 |
+
window.addEventListener('school-updated', handleSchoolUpdate);
|
| 57 |
|
| 58 |
// Fetch notifications
|
| 59 |
const fetchNotifs = async () => {
|
| 60 |
try {
|
| 61 |
const list = await api.notifications.getAll(user._id || '', user.role);
|
| 62 |
setNotifications(list);
|
|
|
|
|
|
|
| 63 |
const lastReadTime = localStorage.getItem('last_read_notif_time');
|
| 64 |
if (list.length > 0) {
|
| 65 |
if (!lastReadTime || new Date(list[0].createTime) > new Date(lastReadTime)) {
|
|
|
|
| 69 |
} catch (e) { console.error(e); }
|
| 70 |
};
|
| 71 |
fetchNotifs();
|
| 72 |
+
|
| 73 |
+
return () => {
|
| 74 |
+
window.removeEventListener('school-updated', handleSchoolUpdate);
|
| 75 |
+
};
|
| 76 |
}, [user]);
|
| 77 |
|
| 78 |
const handleSchoolChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
pages/ClassList.tsx
CHANGED
|
@@ -31,10 +31,10 @@ export const ClassList: React.FC = () => {
|
|
| 31 |
try {
|
| 32 |
const [clsData, userData] = await Promise.all([
|
| 33 |
api.classes.getAll(),
|
| 34 |
-
api.users.getAll({ role: 'TEACHER', global: true }) // Get
|
| 35 |
]);
|
| 36 |
setClasses(clsData);
|
| 37 |
-
setTeachers(userData);
|
| 38 |
|
| 39 |
// Filter users with pending class applications
|
| 40 |
const apps = userData.filter((u: User) => u.classApplication && u.classApplication.status === 'PENDING');
|
|
@@ -264,4 +264,4 @@ export const ClassList: React.FC = () => {
|
|
| 264 |
)}
|
| 265 |
</div>
|
| 266 |
);
|
| 267 |
-
};
|
|
|
|
| 31 |
try {
|
| 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 |
+
setTeachers(userData); // Now only contains teachers
|
| 38 |
|
| 39 |
// Filter users with pending class applications
|
| 40 |
const apps = userData.filter((u: User) => u.classApplication && u.classApplication.status === 'PENDING');
|
|
|
|
| 264 |
)}
|
| 265 |
</div>
|
| 266 |
);
|
| 267 |
+
};
|
pages/Login.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
|
| 2 |
import React, { useState, useEffect } from 'react';
|
| 3 |
-
import { GraduationCap, Lock, User as UserIcon, AlertCircle, ArrowRight, Loader2, ArrowLeft, School as SchoolIcon, Mail, Phone, Smile, BookOpen, Users } from 'lucide-react';
|
| 4 |
import { User, UserRole, School, ClassInfo, Subject } from '../types';
|
| 5 |
import { api } from '../services/api';
|
| 6 |
|
|
@@ -28,7 +28,7 @@ export const Login: React.FC<LoginProps> = ({ onLogin }) => {
|
|
| 28 |
schoolId: '', trueName: '', phone: '', email: '', avatar: '',
|
| 29 |
teachingSubject: '', homeroomClass: '',
|
| 30 |
// Student specific
|
| 31 |
-
|
| 32 |
});
|
| 33 |
|
| 34 |
const [schools, setSchools] = useState<School[]>([]);
|
|
@@ -40,6 +40,7 @@ export const Login: React.FC<LoginProps> = ({ onLogin }) => {
|
|
| 40 |
const [error, setError] = useState('');
|
| 41 |
const [loading, setLoading] = useState(false);
|
| 42 |
const [successMsg, setSuccessMsg] = useState('');
|
|
|
|
| 43 |
|
| 44 |
useEffect(() => {
|
| 45 |
if (view === 'register') {
|
|
@@ -80,20 +81,23 @@ export const Login: React.FC<LoginProps> = ({ onLogin }) => {
|
|
| 80 |
setLoading(true);
|
| 81 |
try {
|
| 82 |
// Default avatar if none selected
|
| 83 |
-
const finalAvatar = regForm.avatar || `https://api.dicebear.com/7.x/avataaars/svg?seed=${regForm.username}`;
|
| 84 |
-
|
| 85 |
const payload = { ...regForm, avatar: finalAvatar };
|
| 86 |
-
if (payload.role === UserRole.STUDENT) {
|
| 87 |
-
payload.username = payload.studentNo; // Use Student No as username
|
| 88 |
-
payload.trueName = payload.trueName;
|
| 89 |
-
}
|
| 90 |
|
| 91 |
-
await api.auth.register(payload);
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
} catch (err: any) {
|
| 96 |
-
setError(err.message || '注册失败:
|
| 97 |
} finally { setLoading(false); }
|
| 98 |
};
|
| 99 |
|
|
@@ -145,38 +149,26 @@ export const Login: React.FC<LoginProps> = ({ onLogin }) => {
|
|
| 145 |
{/* Dynamic Fields based on Role */}
|
| 146 |
{regForm.role === UserRole.STUDENT ? (
|
| 147 |
<>
|
|
|
|
|
|
|
|
|
|
| 148 |
<div className="grid grid-cols-2 gap-2">
|
| 149 |
<input
|
| 150 |
className="w-full p-2 border rounded-lg"
|
| 151 |
-
placeholder="
|
| 152 |
-
value={regForm.studentNo} onChange={e => setRegForm({...regForm, studentNo: e.target.value})}
|
| 153 |
-
/>
|
| 154 |
-
<input
|
| 155 |
-
className="w-full p-2 border rounded-lg"
|
| 156 |
-
placeholder="真实姓名"
|
| 157 |
value={regForm.trueName} onChange={e => setRegForm({...regForm, trueName: e.target.value})}
|
| 158 |
/>
|
| 159 |
-
</div>
|
| 160 |
-
<div className="grid grid-cols-2 gap-2">
|
| 161 |
<input
|
| 162 |
className="w-full p-2 border rounded-lg"
|
| 163 |
-
type="password" placeholder="
|
| 164 |
value={regForm.password} onChange={e => setRegForm({...regForm, password: e.target.value})}
|
| 165 |
/>
|
| 166 |
-
<select
|
| 167 |
-
className="w-full p-2 border rounded-lg bg-white"
|
| 168 |
-
value={regForm.gender}
|
| 169 |
-
onChange={e => setRegForm({...regForm, gender: e.target.value})}
|
| 170 |
-
>
|
| 171 |
-
<option value="Male">男</option>
|
| 172 |
-
<option value="Female">女</option>
|
| 173 |
-
</select>
|
| 174 |
</div>
|
| 175 |
|
| 176 |
<div className="bg-blue-50 p-3 rounded-lg space-y-2 border border-blue-100">
|
| 177 |
-
<p className="text-xs font-bold text-blue-600 uppercase"
|
| 178 |
<select
|
| 179 |
-
className="w-full p-1.5 border rounded text-sm"
|
| 180 |
value={regForm.homeroomClass} // Reusing field for class selection
|
| 181 |
onChange={e => setRegForm({...regForm, homeroomClass: e.target.value})}
|
| 182 |
>
|
|
@@ -184,10 +176,24 @@ export const Login: React.FC<LoginProps> = ({ onLogin }) => {
|
|
| 184 |
{schoolClasses.map(c => <option key={c._id} value={c.grade+c.className}>{c.grade}{c.className}</option>)}
|
| 185 |
</select>
|
| 186 |
<div className="grid grid-cols-2 gap-2">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 187 |
<input className="border p-1.5 rounded text-sm w-full" placeholder="家长姓名" value={regForm.parentName} onChange={e => setRegForm({...regForm, parentName: e.target.value})}/>
|
| 188 |
<input className="border p-1.5 rounded text-sm w-full" placeholder="家长电话" value={regForm.parentPhone} onChange={e => setRegForm({...regForm, parentPhone: e.target.value})}/>
|
| 189 |
</div>
|
| 190 |
-
<input className="border p-1.5 rounded text-sm w-full" placeholder="家庭住址" value={regForm.address} onChange={e => setRegForm({...regForm, address: e.target.value})}/>
|
| 191 |
</div>
|
| 192 |
</>
|
| 193 |
) : (
|
|
@@ -251,7 +257,7 @@ export const Login: React.FC<LoginProps> = ({ onLogin }) => {
|
|
| 251 |
<button onClick={() => setStep(1)} className="flex-1 border py-2 rounded-lg">上一步</button>
|
| 252 |
<button
|
| 253 |
onClick={() => setStep(3)}
|
| 254 |
-
disabled={regForm.role === UserRole.STUDENT ? (!regForm.
|
| 255 |
className="flex-1 bg-blue-600 text-white py-2 rounded-lg disabled:opacity-50"
|
| 256 |
>
|
| 257 |
下一步
|
|
@@ -287,6 +293,25 @@ export const Login: React.FC<LoginProps> = ({ onLogin }) => {
|
|
| 287 |
</div>
|
| 288 |
);
|
| 289 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 290 |
return (
|
| 291 |
<div className="min-h-screen bg-gray-100 flex items-center justify-center p-4">
|
| 292 |
<div className="max-w-md w-full bg-white rounded-2xl shadow-xl overflow-hidden transition-all duration-300">
|
|
@@ -302,45 +327,49 @@ export const Login: React.FC<LoginProps> = ({ onLogin }) => {
|
|
| 302 |
</div>
|
| 303 |
|
| 304 |
<div className="p-8">
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
<
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
|
|
|
|
|
|
| 311 |
|
| 312 |
-
|
| 313 |
-
|
| 314 |
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
|
|
|
|
|
|
| 341 |
)}
|
| 342 |
</div>
|
| 343 |
</div>
|
| 344 |
</div>
|
| 345 |
);
|
| 346 |
-
};
|
|
|
|
| 1 |
|
| 2 |
import React, { useState, useEffect } from 'react';
|
| 3 |
+
import { GraduationCap, Lock, User as UserIcon, AlertCircle, ArrowRight, Loader2, ArrowLeft, School as SchoolIcon, Mail, Phone, Smile, BookOpen, Users, Clipboard } from 'lucide-react';
|
| 4 |
import { User, UserRole, School, ClassInfo, Subject } from '../types';
|
| 5 |
import { api } from '../services/api';
|
| 6 |
|
|
|
|
| 28 |
schoolId: '', trueName: '', phone: '', email: '', avatar: '',
|
| 29 |
teachingSubject: '', homeroomClass: '',
|
| 30 |
// Student specific
|
| 31 |
+
seatNo: '', parentName: '', parentPhone: '', address: '', gender: 'Male'
|
| 32 |
});
|
| 33 |
|
| 34 |
const [schools, setSchools] = useState<School[]>([]);
|
|
|
|
| 40 |
const [error, setError] = useState('');
|
| 41 |
const [loading, setLoading] = useState(false);
|
| 42 |
const [successMsg, setSuccessMsg] = useState('');
|
| 43 |
+
const [generatedId, setGeneratedId] = useState('');
|
| 44 |
|
| 45 |
useEffect(() => {
|
| 46 |
if (view === 'register') {
|
|
|
|
| 81 |
setLoading(true);
|
| 82 |
try {
|
| 83 |
// Default avatar if none selected
|
| 84 |
+
const finalAvatar = regForm.avatar || `https://api.dicebear.com/7.x/avataaars/svg?seed=${regForm.username || regForm.trueName}`;
|
| 85 |
+
|
| 86 |
const payload = { ...regForm, avatar: finalAvatar };
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
|
| 88 |
+
const res = await api.auth.register(payload);
|
| 89 |
+
|
| 90 |
+
if (regForm.role === UserRole.STUDENT && res.username) {
|
| 91 |
+
setGeneratedId(res.username);
|
| 92 |
+
setSuccessMsg('注册成功!');
|
| 93 |
+
// Don't switch view immediately, let them see the ID
|
| 94 |
+
} else {
|
| 95 |
+
setSuccessMsg('注册成功!请等待审核。');
|
| 96 |
+
setView('login');
|
| 97 |
+
setStep(1);
|
| 98 |
+
}
|
| 99 |
} catch (err: any) {
|
| 100 |
+
setError(err.message || '注册失败: 可能用户名冲突');
|
| 101 |
} finally { setLoading(false); }
|
| 102 |
};
|
| 103 |
|
|
|
|
| 149 |
{/* Dynamic Fields based on Role */}
|
| 150 |
{regForm.role === UserRole.STUDENT ? (
|
| 151 |
<>
|
| 152 |
+
<div className="bg-yellow-50 p-2 rounded text-xs text-yellow-800 border border-yellow-200 mb-2">
|
| 153 |
+
注意:注册成功后系统将自动生成您的 <b>登录账号(系统学号)</b>,请务必牢记。
|
| 154 |
+
</div>
|
| 155 |
<div className="grid grid-cols-2 gap-2">
|
| 156 |
<input
|
| 157 |
className="w-full p-2 border rounded-lg"
|
| 158 |
+
placeholder="真实姓名 (必填)"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
value={regForm.trueName} onChange={e => setRegForm({...regForm, trueName: e.target.value})}
|
| 160 |
/>
|
|
|
|
|
|
|
| 161 |
<input
|
| 162 |
className="w-full p-2 border rounded-lg"
|
| 163 |
+
type="password" placeholder="设置密码 (必填)"
|
| 164 |
value={regForm.password} onChange={e => setRegForm({...regForm, password: e.target.value})}
|
| 165 |
/>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
</div>
|
| 167 |
|
| 168 |
<div className="bg-blue-50 p-3 rounded-lg space-y-2 border border-blue-100">
|
| 169 |
+
<p className="text-xs font-bold text-blue-600 uppercase">班级信息 (必填)</p>
|
| 170 |
<select
|
| 171 |
+
className="w-full p-1.5 border rounded text-sm bg-white"
|
| 172 |
value={regForm.homeroomClass} // Reusing field for class selection
|
| 173 |
onChange={e => setRegForm({...regForm, homeroomClass: e.target.value})}
|
| 174 |
>
|
|
|
|
| 176 |
{schoolClasses.map(c => <option key={c._id} value={c.grade+c.className}>{c.grade}{c.className}</option>)}
|
| 177 |
</select>
|
| 178 |
<div className="grid grid-cols-2 gap-2">
|
| 179 |
+
<select
|
| 180 |
+
className="w-full p-1.5 border rounded text-sm bg-white"
|
| 181 |
+
value={regForm.gender}
|
| 182 |
+
onChange={e => setRegForm({...regForm, gender: e.target.value})}
|
| 183 |
+
>
|
| 184 |
+
<option value="Male">男</option>
|
| 185 |
+
<option value="Female">女</option>
|
| 186 |
+
</select>
|
| 187 |
+
<input className="border p-1.5 rounded text-sm w-full" placeholder="座号 (选填, 如: 05)" value={regForm.seatNo} onChange={e => setRegForm({...regForm, seatNo: e.target.value})}/>
|
| 188 |
+
</div>
|
| 189 |
+
</div>
|
| 190 |
+
|
| 191 |
+
<div className="border-t pt-2">
|
| 192 |
+
<p className="text-xs font-bold text-gray-400 uppercase mb-1">家庭信息 (选填)</p>
|
| 193 |
+
<div className="grid grid-cols-2 gap-2 mb-2">
|
| 194 |
<input className="border p-1.5 rounded text-sm w-full" placeholder="家长姓名" value={regForm.parentName} onChange={e => setRegForm({...regForm, parentName: e.target.value})}/>
|
| 195 |
<input className="border p-1.5 rounded text-sm w-full" placeholder="家长电话" value={regForm.parentPhone} onChange={e => setRegForm({...regForm, parentPhone: e.target.value})}/>
|
| 196 |
</div>
|
|
|
|
| 197 |
</div>
|
| 198 |
</>
|
| 199 |
) : (
|
|
|
|
| 257 |
<button onClick={() => setStep(1)} className="flex-1 border py-2 rounded-lg">上一步</button>
|
| 258 |
<button
|
| 259 |
onClick={() => setStep(3)}
|
| 260 |
+
disabled={regForm.role === UserRole.STUDENT ? (!regForm.trueName || !regForm.homeroomClass) : (!regForm.username || !regForm.password)}
|
| 261 |
className="flex-1 bg-blue-600 text-white py-2 rounded-lg disabled:opacity-50"
|
| 262 |
>
|
| 263 |
下一步
|
|
|
|
| 293 |
</div>
|
| 294 |
);
|
| 295 |
|
| 296 |
+
const renderSuccess = () => (
|
| 297 |
+
<div className="text-center animate-in zoom-in space-y-4">
|
| 298 |
+
<div className="w-16 h-16 bg-green-100 text-green-600 rounded-full flex items-center justify-center mx-auto mb-4">
|
| 299 |
+
<Smile size={32}/>
|
| 300 |
+
</div>
|
| 301 |
+
<h3 className="text-2xl font-bold text-gray-800">注册成功!</h3>
|
| 302 |
+
<div className="bg-blue-50 border border-blue-200 rounded-xl p-6">
|
| 303 |
+
<p className="text-sm text-gray-600 mb-2">您的登录账号 (系统学号)</p>
|
| 304 |
+
<div className="text-3xl font-black text-blue-700 tracking-wider font-mono select-all">
|
| 305 |
+
{generatedId}
|
| 306 |
+
</div>
|
| 307 |
+
<p className="text-xs text-red-500 mt-2 font-bold">请务必截图或记下此号码,用于以后登录!</p>
|
| 308 |
+
</div>
|
| 309 |
+
<button onClick={() => window.location.reload()} className="w-full bg-blue-600 text-white py-3 rounded-lg font-bold hover:bg-blue-700">
|
| 310 |
+
去登录
|
| 311 |
+
</button>
|
| 312 |
+
</div>
|
| 313 |
+
);
|
| 314 |
+
|
| 315 |
return (
|
| 316 |
<div className="min-h-screen bg-gray-100 flex items-center justify-center p-4">
|
| 317 |
<div className="max-w-md w-full bg-white rounded-2xl shadow-xl overflow-hidden transition-all duration-300">
|
|
|
|
| 327 |
</div>
|
| 328 |
|
| 329 |
<div className="p-8">
|
| 330 |
+
{generatedId ? renderSuccess() : (
|
| 331 |
+
<>
|
| 332 |
+
<div className="mb-6 flex justify-center">
|
| 333 |
+
<div className="bg-gray-100 p-1 rounded-lg flex text-sm font-medium">
|
| 334 |
+
<button onClick={() => { setView('login'); setError(''); }} className={`px-6 py-2 rounded-md transition-all ${view==='login' ? 'bg-white text-blue-600 shadow-sm' : 'text-gray-500'}`}>登录</button>
|
| 335 |
+
<button onClick={() => { setView('register'); setError(''); }} className={`px-6 py-2 rounded-md transition-all ${view==='register' ? 'bg-white text-blue-600 shadow-sm' : 'text-gray-500'}`}>注册账号</button>
|
| 336 |
+
</div>
|
| 337 |
+
</div>
|
| 338 |
|
| 339 |
+
{error && <div className="bg-red-50 text-red-600 p-3 rounded-lg text-sm mb-4 flex items-center"><AlertCircle size={16} className="mr-2"/>{error}</div>}
|
| 340 |
+
{successMsg && !generatedId && <div className="bg-green-50 text-green-600 p-3 rounded-lg text-sm mb-4">{successMsg}</div>}
|
| 341 |
|
| 342 |
+
{view === 'login' ? (
|
| 343 |
+
<form onSubmit={handleLogin} className="space-y-5">
|
| 344 |
+
<div className="space-y-2">
|
| 345 |
+
<label className="text-sm font-medium text-gray-700">用户名 / 学号</label>
|
| 346 |
+
<div className="relative">
|
| 347 |
+
<UserIcon className="absolute left-3 top-2.5 h-5 w-5 text-gray-400" />
|
| 348 |
+
<input type="text" required value={loginForm.username} onChange={e => setLoginForm({...loginForm, username:e.target.value})} className="block w-full pl-10 pr-3 py-2.5 border border-gray-300 rounded-lg focus:ring-blue-500" placeholder="请输入用户名" />
|
| 349 |
+
</div>
|
| 350 |
+
</div>
|
| 351 |
+
<div className="space-y-2">
|
| 352 |
+
<label className="text-sm font-medium text-gray-700">密码</label>
|
| 353 |
+
<div className="relative">
|
| 354 |
+
<Lock className="absolute left-3 top-2.5 h-5 w-5 text-gray-400" />
|
| 355 |
+
<input type="password" required value={loginForm.password} onChange={e => setLoginForm({...loginForm, password:e.target.value})} className="block w-full pl-10 pr-3 py-2.5 border border-gray-300 rounded-lg focus:ring-blue-500" placeholder="请输入密码" />
|
| 356 |
+
</div>
|
| 357 |
+
</div>
|
| 358 |
+
<button type="submit" disabled={loading} className="w-full py-3 bg-blue-600 text-white rounded-lg font-bold hover:bg-blue-700 transition-colors">
|
| 359 |
+
{loading ? <Loader2 className="animate-spin inline"/> : '登 录'}
|
| 360 |
+
</button>
|
| 361 |
+
</form>
|
| 362 |
+
) : (
|
| 363 |
+
<div className="min-h-[300px]">
|
| 364 |
+
{step === 1 && renderStep1()}
|
| 365 |
+
{step === 2 && renderStep2()}
|
| 366 |
+
{step === 3 && renderStep3()}
|
| 367 |
+
</div>
|
| 368 |
+
)}
|
| 369 |
+
</>
|
| 370 |
)}
|
| 371 |
</div>
|
| 372 |
</div>
|
| 373 |
</div>
|
| 374 |
);
|
| 375 |
+
};
|
pages/SchoolList.tsx
CHANGED
|
@@ -31,6 +31,8 @@ export const SchoolList: React.FC = () => {
|
|
| 31 |
setIsAdding(false);
|
| 32 |
setForm({ name: '', code: '' });
|
| 33 |
loadSchools();
|
|
|
|
|
|
|
| 34 |
} catch (e) { alert('保存失败,代码可能重复'); }
|
| 35 |
};
|
| 36 |
|
|
@@ -97,4 +99,4 @@ export const SchoolList: React.FC = () => {
|
|
| 97 |
</div>
|
| 98 |
</div>
|
| 99 |
);
|
| 100 |
-
};
|
|
|
|
| 31 |
setIsAdding(false);
|
| 32 |
setForm({ name: '', code: '' });
|
| 33 |
loadSchools();
|
| 34 |
+
// Notify Header to update
|
| 35 |
+
window.dispatchEvent(new Event('school-updated'));
|
| 36 |
} catch (e) { alert('保存失败,代码可能重复'); }
|
| 37 |
};
|
| 38 |
|
|
|
|
| 99 |
</div>
|
| 100 |
</div>
|
| 101 |
);
|
| 102 |
+
};
|
pages/StudentList.tsx
CHANGED
|
@@ -32,7 +32,8 @@ export const StudentList: React.FC = () => {
|
|
| 32 |
|
| 33 |
const initialForm = {
|
| 34 |
name: '',
|
| 35 |
-
studentNo: '',
|
|
|
|
| 36 |
gender: 'Male',
|
| 37 |
className: '',
|
| 38 |
phone: '',
|
|
@@ -52,8 +53,14 @@ export const StudentList: React.FC = () => {
|
|
| 52 |
api.students.getAll(),
|
| 53 |
api.classes.getAll()
|
| 54 |
]);
|
| 55 |
-
// Sort students by
|
| 56 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
setClassList(classData);
|
| 58 |
} catch (error) {
|
| 59 |
console.error(error);
|
|
@@ -117,6 +124,7 @@ export const StudentList: React.FC = () => {
|
|
| 117 |
setFormData({
|
| 118 |
name: student.name,
|
| 119 |
studentNo: student.studentNo,
|
|
|
|
| 120 |
gender: (student.gender === 'Female' ? 'Female' : 'Male'),
|
| 121 |
className: student.className,
|
| 122 |
phone: student.phone || '',
|
|
@@ -225,8 +233,8 @@ export const StudentList: React.FC = () => {
|
|
| 225 |
};
|
| 226 |
|
| 227 |
const name = getVal(['姓名', 'Name'], 1);
|
| 228 |
-
const
|
| 229 |
-
if (!name
|
| 230 |
|
| 231 |
const genderVal = getVal(['性别', 'Gender'], 2);
|
| 232 |
const gender = (genderVal === '女' || genderVal === 'Female') ? 'Female' : 'Male';
|
|
@@ -241,7 +249,8 @@ export const StudentList: React.FC = () => {
|
|
| 241 |
promises.push(
|
| 242 |
api.students.add({
|
| 243 |
name: String(name).trim(),
|
| 244 |
-
|
|
|
|
| 245 |
gender,
|
| 246 |
className: targetClassName,
|
| 247 |
phone: String(getVal(['电话', '联系方式', 'Mobile'], 3)).trim(),
|
|
@@ -324,7 +333,7 @@ export const StudentList: React.FC = () => {
|
|
| 324 |
/>
|
| 325 |
)}
|
| 326 |
</th>
|
| 327 |
-
<th className="px-6 py-3"
|
| 328 |
<th className="px-6 py-3">姓名/性别</th>
|
| 329 |
<th className="px-6 py-3">班级</th>
|
| 330 |
<th className="px-6 py-3">家长/住址</th>
|
|
@@ -339,7 +348,12 @@ export const StudentList: React.FC = () => {
|
|
| 339 |
<td className="px-6 py-4">
|
| 340 |
{canEdit && <input type="checkbox" checked={selectedIds.has(s._id || String(s.id))} onChange={() => toggleSelect(s._id || String(s.id))} />}
|
| 341 |
</td>
|
| 342 |
-
<td className="px-6 py-4
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 343 |
<td className="px-6 py-4 flex items-center space-x-3">
|
| 344 |
<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>
|
| 345 |
<div>
|
|
@@ -390,9 +404,20 @@ export const StudentList: React.FC = () => {
|
|
| 390 |
<h3 className="font-bold text-lg mb-4">{editStudentId ? '编辑学生档案' : '新增学生档案'}</h3>
|
| 391 |
<form onSubmit={handleSubmit} className="space-y-4">
|
| 392 |
<div className="grid grid-cols-2 gap-4">
|
| 393 |
-
<
|
| 394 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 395 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 396 |
<div className="grid grid-cols-2 gap-4">
|
| 397 |
<select className="w-full border p-2 rounded" value={formData.className} onChange={e=>setFormData({...formData, className:e.target.value})} required>
|
| 398 |
<option value="">选择班级 *</option>
|
|
@@ -460,8 +485,8 @@ export const StudentList: React.FC = () => {
|
|
| 460 |
<div className="relative border-2 border-dashed border-gray-300 rounded-lg p-6 text-center hover:bg-gray-50 transition-colors">
|
| 461 |
<Upload className="mx-auto h-10 w-10 text-gray-400" />
|
| 462 |
<p className="mt-2 text-sm text-gray-600">点击上传 .xlsx 文件</p>
|
| 463 |
-
<p className="text-xs text-gray-400 mt-1"
|
| 464 |
-
<p className="text-xs text-gray-400"
|
| 465 |
<input type="file" accept=".xlsx, .xls" className="opacity-0 absolute inset-0 cursor-pointer h-full w-full z-10"
|
| 466 |
// @ts-ignore
|
| 467 |
onChange={e => setImportFile(e.target.files?.[0])}
|
|
@@ -477,4 +502,4 @@ export const StudentList: React.FC = () => {
|
|
| 477 |
)}
|
| 478 |
</div>
|
| 479 |
);
|
| 480 |
-
};
|
|
|
|
| 32 |
|
| 33 |
const initialForm = {
|
| 34 |
name: '',
|
| 35 |
+
studentNo: '', // System ID
|
| 36 |
+
seatNo: '', // Seat No
|
| 37 |
gender: 'Male',
|
| 38 |
className: '',
|
| 39 |
phone: '',
|
|
|
|
| 53 |
api.students.getAll(),
|
| 54 |
api.classes.getAll()
|
| 55 |
]);
|
| 56 |
+
// Sort students: Primary by SeatNo (if numeric), Secondary by System ID
|
| 57 |
+
const sorted = studentData.sort((a: Student, b: Student) => {
|
| 58 |
+
const seatA = parseInt(a.seatNo || '9999');
|
| 59 |
+
const seatB = parseInt(b.seatNo || '9999');
|
| 60 |
+
if (seatA !== seatB) return seatA - seatB;
|
| 61 |
+
return a.studentNo.localeCompare(b.studentNo, undefined, {numeric: true});
|
| 62 |
+
});
|
| 63 |
+
setStudents(sorted);
|
| 64 |
setClassList(classData);
|
| 65 |
} catch (error) {
|
| 66 |
console.error(error);
|
|
|
|
| 124 |
setFormData({
|
| 125 |
name: student.name,
|
| 126 |
studentNo: student.studentNo,
|
| 127 |
+
seatNo: student.seatNo || '',
|
| 128 |
gender: (student.gender === 'Female' ? 'Female' : 'Male'),
|
| 129 |
className: student.className,
|
| 130 |
phone: student.phone || '',
|
|
|
|
| 233 |
};
|
| 234 |
|
| 235 |
const name = getVal(['姓名', 'Name'], 1);
|
| 236 |
+
const seatNo = getVal(['学号', '座号', 'No', 'ID'], 0); // Re-mapped: treat import "No" as Seat No
|
| 237 |
+
if (!name) continue;
|
| 238 |
|
| 239 |
const genderVal = getVal(['性别', 'Gender'], 2);
|
| 240 |
const gender = (genderVal === '女' || genderVal === 'Female') ? 'Female' : 'Male';
|
|
|
|
| 249 |
promises.push(
|
| 250 |
api.students.add({
|
| 251 |
name: String(name).trim(),
|
| 252 |
+
seatNo: seatNo ? String(seatNo).trim() : '',
|
| 253 |
+
studentNo: '', // Auto Generate
|
| 254 |
gender,
|
| 255 |
className: targetClassName,
|
| 256 |
phone: String(getVal(['电话', '联系方式', 'Mobile'], 3)).trim(),
|
|
|
|
| 333 |
/>
|
| 334 |
)}
|
| 335 |
</th>
|
| 336 |
+
<th className="px-6 py-3">座号 / 系统ID</th>
|
| 337 |
<th className="px-6 py-3">姓名/性别</th>
|
| 338 |
<th className="px-6 py-3">班级</th>
|
| 339 |
<th className="px-6 py-3">家长/住址</th>
|
|
|
|
| 348 |
<td className="px-6 py-4">
|
| 349 |
{canEdit && <input type="checkbox" checked={selectedIds.has(s._id || String(s.id))} onChange={() => toggleSelect(s._id || String(s.id))} />}
|
| 350 |
</td>
|
| 351 |
+
<td className="px-6 py-4">
|
| 352 |
+
<div className="flex flex-col">
|
| 353 |
+
<span className="font-bold text-gray-800 text-lg">{s.seatNo || '-'}</span>
|
| 354 |
+
<span className="text-xs text-gray-400 font-mono" title="系统登录账号">{s.studentNo}</span>
|
| 355 |
+
</div>
|
| 356 |
+
</td>
|
| 357 |
<td className="px-6 py-4 flex items-center space-x-3">
|
| 358 |
<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>
|
| 359 |
<div>
|
|
|
|
| 404 |
<h3 className="font-bold text-lg mb-4">{editStudentId ? '编辑学生档案' : '新增学生档案'}</h3>
|
| 405 |
<form onSubmit={handleSubmit} className="space-y-4">
|
| 406 |
<div className="grid grid-cols-2 gap-4">
|
| 407 |
+
<div>
|
| 408 |
+
<label className="text-xs font-bold text-gray-500 block mb-1">姓名 *</label>
|
| 409 |
+
<input className="w-full border p-2 rounded" value={formData.name} onChange={e=>setFormData({...formData, name:e.target.value})} required/>
|
| 410 |
+
</div>
|
| 411 |
+
<div>
|
| 412 |
+
<label className="text-xs font-bold text-gray-500 block mb-1">班级座号 (选填)</label>
|
| 413 |
+
<input className="w-full border p-2 rounded" value={formData.seatNo} onChange={e=>setFormData({...formData, seatNo:e.target.value})} placeholder="如: 05"/>
|
| 414 |
+
</div>
|
| 415 |
</div>
|
| 416 |
+
{editStudentId && (
|
| 417 |
+
<div className="bg-gray-50 p-2 rounded text-xs text-gray-600 mb-2">
|
| 418 |
+
<span className="font-bold">系统ID (登录账号): </span> {formData.studentNo}
|
| 419 |
+
</div>
|
| 420 |
+
)}
|
| 421 |
<div className="grid grid-cols-2 gap-4">
|
| 422 |
<select className="w-full border p-2 rounded" value={formData.className} onChange={e=>setFormData({...formData, className:e.target.value})} required>
|
| 423 |
<option value="">选择班级 *</option>
|
|
|
|
| 485 |
<div className="relative border-2 border-dashed border-gray-300 rounded-lg p-6 text-center hover:bg-gray-50 transition-colors">
|
| 486 |
<Upload className="mx-auto h-10 w-10 text-gray-400" />
|
| 487 |
<p className="mt-2 text-sm text-gray-600">点击上传 .xlsx 文件</p>
|
| 488 |
+
<p className="text-xs text-gray-400 mt-1">支持列名:座号(学号), 姓名, 家长姓名, 家长电话, 地址</p>
|
| 489 |
+
<p className="text-xs text-gray-400">注意: 导入的"学号"列将作为座号</p>
|
| 490 |
<input type="file" accept=".xlsx, .xls" className="opacity-0 absolute inset-0 cursor-pointer h-full w-full z-10"
|
| 491 |
// @ts-ignore
|
| 492 |
onChange={e => setImportFile(e.target.files?.[0])}
|
|
|
|
| 502 |
)}
|
| 503 |
</div>
|
| 504 |
);
|
| 505 |
+
};
|
server.js
CHANGED
|
@@ -80,6 +80,7 @@ const UserSchema = new mongoose.Schema({
|
|
| 80 |
parentPhone: String,
|
| 81 |
address: String,
|
| 82 |
gender: String,
|
|
|
|
| 83 |
classApplication: {
|
| 84 |
type: { type: String }, // Explicitly define type to avoid Mongoose casting error
|
| 85 |
targetClass: String,
|
|
@@ -88,7 +89,25 @@ const UserSchema = new mongoose.Schema({
|
|
| 88 |
});
|
| 89 |
const User = mongoose.model('User', UserSchema);
|
| 90 |
|
| 91 |
-
const StudentSchema = new mongoose.Schema({
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
const Student = mongoose.model('Student', StudentSchema);
|
| 93 |
const CourseSchema = new mongoose.Schema({ schoolId: String, courseCode: String, courseName: String, teacherName: String, credits: Number, capacity: Number, enrolled: Number });
|
| 94 |
const Course = mongoose.model('Course', CourseSchema);
|
|
@@ -149,8 +168,119 @@ const getAutoSemester = () => {
|
|
| 149 |
}
|
| 150 |
};
|
| 151 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
// --- ROUTES ---
|
| 153 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
// --- TEACHER CLASS APPLICATION ROUTES ---
|
| 155 |
app.post('/api/users/class-application', async (req, res) => {
|
| 156 |
const { userId, type, targetClass, action } = req.body;
|
|
@@ -472,15 +602,18 @@ app.post('/api/auth/login', async (req, res) => {
|
|
| 472 |
if (user.status !== 'active') return res.status(403).json({ error: 'PENDING_APPROVAL' });
|
| 473 |
res.json(user);
|
| 474 |
});
|
| 475 |
-
app.post('/api/auth/register', async (req, res) => { try { await User.create({...req.body, status: 'pending'}); res.json({}); } catch(e) { res.status(500).json({}); } });
|
| 476 |
app.get('/api/schools', async (req, res) => { res.json(await School.find()); });
|
| 477 |
app.post('/api/schools', async (req, res) => { res.json(await School.create(req.body)); });
|
| 478 |
app.put('/api/schools/:id', async (req, res) => { await School.findByIdAndUpdate(req.params.id, req.body); res.json({}); });
|
| 479 |
-
app.get('/api/users', async (req, res) => { res.json(await User.find(getQueryFilter(req))); });
|
| 480 |
app.put('/api/users/:id', async (req, res) => { await User.findByIdAndUpdate(req.params.id, req.body); res.json({}); });
|
| 481 |
app.delete('/api/users/:id', async (req, res) => { await User.findByIdAndDelete(req.params.id); res.json({}); });
|
| 482 |
app.get('/api/students', async (req, res) => { res.json(await Student.find(getQueryFilter(req))); });
|
| 483 |
-
app.post('/api/students', async (req, res) => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 484 |
app.put('/api/students/:id', async (req, res) => { await Student.findByIdAndUpdate(req.params.id, req.body); res.json({}); });
|
| 485 |
app.delete('/api/students/:id', async (req, res) => { await Student.findByIdAndDelete(req.params.id); res.json({}); });
|
| 486 |
app.get('/api/classes', async (req, res) => {
|
|
@@ -698,4 +831,4 @@ app.post('/api/batch-delete', async (req, res) => {
|
|
| 698 |
});
|
| 699 |
|
| 700 |
app.get('*', (req, res) => { res.sendFile(path.join(__dirname, 'dist', 'index.html')); });
|
| 701 |
-
app.listen(PORT, () => console.log(`🚀 Server running on port ${PORT}`));
|
|
|
|
| 80 |
parentPhone: String,
|
| 81 |
address: String,
|
| 82 |
gender: String,
|
| 83 |
+
seatNo: String, // Added seatNo
|
| 84 |
classApplication: {
|
| 85 |
type: { type: String }, // Explicitly define type to avoid Mongoose casting error
|
| 86 |
targetClass: String,
|
|
|
|
| 89 |
});
|
| 90 |
const User = mongoose.model('User', UserSchema);
|
| 91 |
|
| 92 |
+
const StudentSchema = new mongoose.Schema({
|
| 93 |
+
schoolId: String,
|
| 94 |
+
studentNo: String,
|
| 95 |
+
seatNo: String, // Added seatNo
|
| 96 |
+
name: String,
|
| 97 |
+
gender: String,
|
| 98 |
+
birthday: String,
|
| 99 |
+
idCard: String,
|
| 100 |
+
phone: String,
|
| 101 |
+
className: String,
|
| 102 |
+
status: String,
|
| 103 |
+
parentName: String,
|
| 104 |
+
parentPhone: String,
|
| 105 |
+
address: String,
|
| 106 |
+
teamId: String,
|
| 107 |
+
drawAttempts: { type: Number, default: 0 },
|
| 108 |
+
dailyDrawLog: { date: String, count: { type: Number, default: 0 } },
|
| 109 |
+
flowerBalance: { type: Number, default: 0 }
|
| 110 |
+
});
|
| 111 |
const Student = mongoose.model('Student', StudentSchema);
|
| 112 |
const CourseSchema = new mongoose.Schema({ schoolId: String, courseCode: String, courseName: String, teacherName: String, credits: Number, capacity: Number, enrolled: Number });
|
| 113 |
const Course = mongoose.model('Course', CourseSchema);
|
|
|
|
| 168 |
}
|
| 169 |
};
|
| 170 |
|
| 171 |
+
const generateStudentNo = async () => {
|
| 172 |
+
const year = new Date().getFullYear();
|
| 173 |
+
const random = Math.floor(100000 + Math.random() * 900000); // 6 digits
|
| 174 |
+
return `${year}${random}`;
|
| 175 |
+
};
|
| 176 |
+
|
| 177 |
// --- ROUTES ---
|
| 178 |
|
| 179 |
+
// --- AUTH / REGISTER ---
|
| 180 |
+
|
| 181 |
+
app.post('/api/auth/register', async (req, res) => {
|
| 182 |
+
const { role, username, password, schoolId, trueName, className, seatNo } = req.body;
|
| 183 |
+
|
| 184 |
+
try {
|
| 185 |
+
if (role === 'STUDENT') {
|
| 186 |
+
// Student Registration Logic
|
| 187 |
+
// 1. Check if a Student Profile already exists for this Name + Class + School
|
| 188 |
+
let student = await Student.findOne({ name: trueName, className, schoolId });
|
| 189 |
+
let finalUsername = '';
|
| 190 |
+
|
| 191 |
+
if (student) {
|
| 192 |
+
// Profile exists.
|
| 193 |
+
// 1a. Check if it already has a User account
|
| 194 |
+
const existingUser = await User.findOne({
|
| 195 |
+
role: 'STUDENT',
|
| 196 |
+
schoolId,
|
| 197 |
+
$or: [{ username: student.studentNo }, { trueName: trueName, homeroomClass: className }] // Defensive check
|
| 198 |
+
});
|
| 199 |
+
|
| 200 |
+
if (existingUser) {
|
| 201 |
+
return res.status(409).json({ error: 'ACCOUNT_EXISTS', message: '该学生账号已存在,请直接登录或联系老师重置密码。' });
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
// 1b. Migration Logic: If current studentNo looks like a seat number (short), move it to seatNo
|
| 205 |
+
// and generate a new System ID for login.
|
| 206 |
+
if (student.studentNo && student.studentNo.length < 6) {
|
| 207 |
+
if (!student.seatNo) student.seatNo = student.studentNo;
|
| 208 |
+
student.studentNo = await generateStudentNo();
|
| 209 |
+
await student.save();
|
| 210 |
+
} else if (!student.studentNo) {
|
| 211 |
+
student.studentNo = await generateStudentNo();
|
| 212 |
+
await student.save();
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
// If user provided a seatNo during reg, update it
|
| 216 |
+
if (seatNo) {
|
| 217 |
+
student.seatNo = seatNo;
|
| 218 |
+
await student.save();
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
finalUsername = student.studentNo;
|
| 222 |
+
|
| 223 |
+
} else {
|
| 224 |
+
// Profile does NOT exist. Create new.
|
| 225 |
+
finalUsername = await generateStudentNo();
|
| 226 |
+
student = await Student.create({
|
| 227 |
+
schoolId,
|
| 228 |
+
studentNo: finalUsername,
|
| 229 |
+
seatNo: seatNo || '',
|
| 230 |
+
name: trueName,
|
| 231 |
+
className: className,
|
| 232 |
+
gender: req.body.gender || 'Male',
|
| 233 |
+
parentName: req.body.parentName,
|
| 234 |
+
parentPhone: req.body.parentPhone,
|
| 235 |
+
address: req.body.address,
|
| 236 |
+
status: 'Enrolled',
|
| 237 |
+
birthday: '2015-01-01'
|
| 238 |
+
});
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
// Create User Account
|
| 242 |
+
await User.create({
|
| 243 |
+
username: finalUsername,
|
| 244 |
+
password,
|
| 245 |
+
role: 'STUDENT',
|
| 246 |
+
trueName,
|
| 247 |
+
schoolId,
|
| 248 |
+
status: 'pending', // Requires approval? Or auto active? User said "Pending approval"
|
| 249 |
+
homeroomClass: className, // Store class in user for easy access
|
| 250 |
+
studentNo: finalUsername,
|
| 251 |
+
seatNo: student.seatNo,
|
| 252 |
+
parentName: req.body.parentName,
|
| 253 |
+
parentPhone: req.body.parentPhone,
|
| 254 |
+
address: req.body.address,
|
| 255 |
+
gender: req.body.gender
|
| 256 |
+
});
|
| 257 |
+
|
| 258 |
+
return res.json({ username: finalUsername });
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
// Teacher/Admin Registration
|
| 262 |
+
const existing = await User.findOne({ username });
|
| 263 |
+
if (existing) return res.status(409).json({ error: 'USERNAME_EXISTS', message: '用户名已存在' });
|
| 264 |
+
|
| 265 |
+
await User.create({...req.body, status: 'pending'});
|
| 266 |
+
res.json({ username });
|
| 267 |
+
|
| 268 |
+
} catch(e) {
|
| 269 |
+
console.error(e);
|
| 270 |
+
res.status(500).json({ error: e.message });
|
| 271 |
+
}
|
| 272 |
+
});
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
// --- USERS ---
|
| 276 |
+
app.get('/api/users', async (req, res) => {
|
| 277 |
+
const filter = getQueryFilter(req);
|
| 278 |
+
// Explicitly handle role filtering
|
| 279 |
+
if (req.query.role) filter.role = req.query.role;
|
| 280 |
+
res.json(await User.find(filter));
|
| 281 |
+
});
|
| 282 |
+
|
| 283 |
+
|
| 284 |
// --- TEACHER CLASS APPLICATION ROUTES ---
|
| 285 |
app.post('/api/users/class-application', async (req, res) => {
|
| 286 |
const { userId, type, targetClass, action } = req.body;
|
|
|
|
| 602 |
if (user.status !== 'active') return res.status(403).json({ error: 'PENDING_APPROVAL' });
|
| 603 |
res.json(user);
|
| 604 |
});
|
|
|
|
| 605 |
app.get('/api/schools', async (req, res) => { res.json(await School.find()); });
|
| 606 |
app.post('/api/schools', async (req, res) => { res.json(await School.create(req.body)); });
|
| 607 |
app.put('/api/schools/:id', async (req, res) => { await School.findByIdAndUpdate(req.params.id, req.body); res.json({}); });
|
|
|
|
| 608 |
app.put('/api/users/:id', async (req, res) => { await User.findByIdAndUpdate(req.params.id, req.body); res.json({}); });
|
| 609 |
app.delete('/api/users/:id', async (req, res) => { await User.findByIdAndDelete(req.params.id); res.json({}); });
|
| 610 |
app.get('/api/students', async (req, res) => { res.json(await Student.find(getQueryFilter(req))); });
|
| 611 |
+
app.post('/api/students', async (req, res) => {
|
| 612 |
+
const data = injectSchoolId(req, req.body);
|
| 613 |
+
if (!data.studentNo) data.studentNo = await generateStudentNo();
|
| 614 |
+
await Student.findOneAndUpdate({ studentNo: data.studentNo }, data, {upsert:true});
|
| 615 |
+
res.json({});
|
| 616 |
+
});
|
| 617 |
app.put('/api/students/:id', async (req, res) => { await Student.findByIdAndUpdate(req.params.id, req.body); res.json({}); });
|
| 618 |
app.delete('/api/students/:id', async (req, res) => { await Student.findByIdAndDelete(req.params.id); res.json({}); });
|
| 619 |
app.get('/api/classes', async (req, res) => {
|
|
|
|
| 831 |
});
|
| 832 |
|
| 833 |
app.get('*', (req, res) => { res.sendFile(path.join(__dirname, 'dist', 'index.html')); });
|
| 834 |
+
app.listen(PORT, () => console.log(`🚀 Server running on port ${PORT}`));
|
types.ts
CHANGED
|
@@ -45,6 +45,7 @@ export interface User {
|
|
| 45 |
parentPhone?: string;
|
| 46 |
address?: string;
|
| 47 |
gender?: 'Male' | 'Female';
|
|
|
|
| 48 |
}
|
| 49 |
|
| 50 |
export interface ClassInfo {
|
|
@@ -82,7 +83,8 @@ export interface Student {
|
|
| 82 |
id?: number;
|
| 83 |
_id?: string;
|
| 84 |
schoolId?: string;
|
| 85 |
-
studentNo: string;
|
|
|
|
| 86 |
name: string;
|
| 87 |
gender: 'Male' | 'Female' | 'Other';
|
| 88 |
birthday: string;
|
|
@@ -301,4 +303,4 @@ export interface LeaveRequest {
|
|
| 301 |
endDate: string;
|
| 302 |
status: 'Pending' | 'Approved' | 'Rejected';
|
| 303 |
createTime: string;
|
| 304 |
-
}
|
|
|
|
| 45 |
parentPhone?: string;
|
| 46 |
address?: string;
|
| 47 |
gender?: 'Male' | 'Female';
|
| 48 |
+
seatNo?: string; // NEW
|
| 49 |
}
|
| 50 |
|
| 51 |
export interface ClassInfo {
|
|
|
|
| 83 |
id?: number;
|
| 84 |
_id?: string;
|
| 85 |
schoolId?: string;
|
| 86 |
+
studentNo: string; // System ID (Login ID)
|
| 87 |
+
seatNo?: string; // Class Seat Number (Optional, for sorting/display)
|
| 88 |
name: string;
|
| 89 |
gender: 'Male' | 'Female' | 'Other';
|
| 90 |
birthday: string;
|
|
|
|
| 303 |
endDate: string;
|
| 304 |
status: 'Pending' | 'Approved' | 'Rejected';
|
| 305 |
createTime: string;
|
| 306 |
+
}
|