dvc890 commited on
Commit
8f856eb
·
verified ·
1 Parent(s): d2d48e6

Upload 26 files

Browse files
Files changed (9) hide show
  1. App.tsx +6 -5
  2. components/Header.tsx +40 -6
  3. components/Sidebar.tsx +9 -18
  4. pages/Login.tsx +177 -113
  5. pages/SchoolList.tsx +100 -0
  6. pages/UserList.tsx +62 -36
  7. server.js +205 -167
  8. services/api.ts +35 -10
  9. types.ts +18 -1
App.tsx CHANGED
@@ -1,3 +1,4 @@
 
1
  import React, { useState, useEffect } from 'react';
2
  import { Sidebar } from './components/Sidebar';
3
  import { Header } from './components/Header';
@@ -10,6 +11,7 @@ import { Settings } from './pages/Settings';
10
  import { Reports } from './pages/Reports';
11
  import { SubjectList } from './pages/SubjectList';
12
  import { UserList } from './pages/UserList';
 
13
  import { Login } from './pages/Login';
14
  import { User, UserRole } from './types';
15
  import { api } from './services/api';
@@ -71,8 +73,6 @@ const AppContent: React.FC = () => {
71
  const [currentUser, setCurrentUser] = useState<User | null>(null);
72
  const [currentView, setCurrentView] = useState('dashboard');
73
  const [loading, setLoading] = useState(true);
74
-
75
- // Mobile Sidebar State
76
  const [sidebarOpen, setSidebarOpen] = useState(false);
77
 
78
  useEffect(() => {
@@ -108,6 +108,7 @@ const AppContent: React.FC = () => {
108
  case 'reports': return <Reports />;
109
  case 'subjects': return <SubjectList />;
110
  case 'users': return <UserList />;
 
111
  default: return <Dashboard onNavigate={(view) => setCurrentView(view)} />;
112
  }
113
  };
@@ -121,7 +122,8 @@ const AppContent: React.FC = () => {
121
  settings: '系统设置',
122
  reports: '统计报表',
123
  subjects: '学科设置',
124
- users: '用户权限管理'
 
125
  };
126
 
127
  if (loading) return <div className="min-h-screen flex items-center justify-center bg-gray-50 text-blue-600">加载中...</div>;
@@ -130,7 +132,6 @@ const AppContent: React.FC = () => {
130
 
131
  return (
132
  <div className="flex h-screen bg-gray-50 overflow-hidden">
133
- {/* Sidebar - Responsive */}
134
  <Sidebar
135
  currentView={currentView}
136
  onChangeView={(view) => { setCurrentView(view); setSidebarOpen(false); }}
@@ -164,4 +165,4 @@ const App: React.FC = () => {
164
  );
165
  };
166
 
167
- export default App;
 
1
+
2
  import React, { useState, useEffect } from 'react';
3
  import { Sidebar } from './components/Sidebar';
4
  import { Header } from './components/Header';
 
11
  import { Reports } from './pages/Reports';
12
  import { SubjectList } from './pages/SubjectList';
13
  import { UserList } from './pages/UserList';
14
+ import { SchoolList } from './pages/SchoolList';
15
  import { Login } from './pages/Login';
16
  import { User, UserRole } from './types';
17
  import { api } from './services/api';
 
73
  const [currentUser, setCurrentUser] = useState<User | null>(null);
74
  const [currentView, setCurrentView] = useState('dashboard');
75
  const [loading, setLoading] = useState(true);
 
 
76
  const [sidebarOpen, setSidebarOpen] = useState(false);
77
 
78
  useEffect(() => {
 
108
  case 'reports': return <Reports />;
109
  case 'subjects': return <SubjectList />;
110
  case 'users': return <UserList />;
111
+ case 'schools': return <SchoolList />; // New Route
112
  default: return <Dashboard onNavigate={(view) => setCurrentView(view)} />;
113
  }
114
  };
 
122
  settings: '系统设置',
123
  reports: '统计报表',
124
  subjects: '学科设置',
125
+ users: '用户权限管理',
126
+ schools: '学校维度管理'
127
  };
128
 
129
  if (loading) return <div className="min-h-screen flex items-center justify-center bg-gray-50 text-blue-600">加载中...</div>;
 
132
 
133
  return (
134
  <div className="flex h-screen bg-gray-50 overflow-hidden">
 
135
  <Sidebar
136
  currentView={currentView}
137
  onChangeView={(view) => { setCurrentView(view); setSidebarOpen(false); }}
 
165
  );
166
  };
167
 
168
+ export default App;
components/Header.tsx CHANGED
@@ -1,6 +1,8 @@
1
- import React from 'react';
2
- import { Bell, Search, Menu } from 'lucide-react';
3
- import { User } from '../types';
 
 
4
 
5
  interface HeaderProps {
6
  user: User;
@@ -9,6 +11,23 @@ interface HeaderProps {
9
  }
10
 
11
  export const Header: React.FC<HeaderProps> = ({ user, title, onMenuClick }) => {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  return (
13
  <header className="h-16 md:h-20 bg-white border-b border-gray-200 flex items-center justify-between px-4 md:px-8 sticky top-0 z-10 shadow-sm w-full">
14
  <div className="flex items-center space-x-4">
@@ -22,6 +41,21 @@ export const Header: React.FC<HeaderProps> = ({ user, title, onMenuClick }) => {
22
  </div>
23
 
24
  <div className="flex items-center space-x-2 md:space-x-6">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  <div className="relative hidden md:block">
26
  <input
27
  type="text"
@@ -38,13 +72,13 @@ export const Header: React.FC<HeaderProps> = ({ user, title, onMenuClick }) => {
38
 
39
  <div className="flex items-center space-x-3 border-l pl-4 md:pl-6 border-gray-200">
40
  <div className="text-right hidden sm:block">
41
- <p className="text-sm font-semibold text-gray-800">{user.username}</p>
42
  <p className="text-xs text-gray-500">
43
  {user.role === 'ADMIN' ? '管理员' : user.role === 'TEACHER' ? '教师' : '学生'}
44
  </p>
45
  </div>
46
  <img
47
- src={user.avatar || "https://api.dicebear.com/7.x/avataaars/svg?seed=" + user.username}
48
  alt="Profile"
49
  className="h-8 w-8 md:h-10 md:w-10 rounded-full object-cover border-2 border-white shadow-sm ring-2 ring-gray-100"
50
  />
@@ -52,4 +86,4 @@ export const Header: React.FC<HeaderProps> = ({ user, title, onMenuClick }) => {
52
  </div>
53
  </header>
54
  );
55
- };
 
1
+
2
+ import React, { useState, useEffect } from 'react';
3
+ import { Bell, Search, Menu, Building } from 'lucide-react';
4
+ import { User, School } from '../types';
5
+ import { api } from '../services/api';
6
 
7
  interface HeaderProps {
8
  user: User;
 
11
  }
12
 
13
  export const Header: React.FC<HeaderProps> = ({ user, title, onMenuClick }) => {
14
+ const [schools, setSchools] = useState<School[]>([]);
15
+ const [selectedSchool, setSelectedSchool] = useState(localStorage.getItem('admin_view_school_id') || '');
16
+
17
+ useEffect(() => {
18
+ if (user.role === 'ADMIN') {
19
+ api.schools.getAll().then(setSchools).catch(console.error);
20
+ }
21
+ }, [user]);
22
+
23
+ const handleSchoolChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
24
+ const newVal = e.target.value;
25
+ setSelectedSchool(newVal);
26
+ if (newVal) localStorage.setItem('admin_view_school_id', newVal);
27
+ else localStorage.removeItem('admin_view_school_id');
28
+ window.location.reload(); // Reload to refresh data context
29
+ };
30
+
31
  return (
32
  <header className="h-16 md:h-20 bg-white border-b border-gray-200 flex items-center justify-between px-4 md:px-8 sticky top-0 z-10 shadow-sm w-full">
33
  <div className="flex items-center space-x-4">
 
41
  </div>
42
 
43
  <div className="flex items-center space-x-2 md:space-x-6">
44
+ {/* Admin School Switcher */}
45
+ {user.role === 'ADMIN' && (
46
+ <div className="hidden md:flex items-center bg-gray-100 rounded-lg px-2 py-1">
47
+ <Building size={16} className="text-gray-500 mr-2"/>
48
+ <select
49
+ className="bg-transparent text-sm border-none focus:ring-0 text-gray-700 font-medium cursor-pointer"
50
+ value={selectedSchool}
51
+ onChange={handleSchoolChange}
52
+ >
53
+ <option value="">-- 全平台视图 --</option>
54
+ {schools.map(s => <option key={s._id} value={s._id}>{s.name}</option>)}
55
+ </select>
56
+ </div>
57
+ )}
58
+
59
  <div className="relative hidden md:block">
60
  <input
61
  type="text"
 
72
 
73
  <div className="flex items-center space-x-3 border-l pl-4 md:pl-6 border-gray-200">
74
  <div className="text-right hidden sm:block">
75
+ <p className="text-sm font-semibold text-gray-800">{user.trueName || user.username}</p>
76
  <p className="text-xs text-gray-500">
77
  {user.role === 'ADMIN' ? '管理员' : user.role === 'TEACHER' ? '教师' : '学生'}
78
  </p>
79
  </div>
80
  <img
81
+ src={user.avatar || `https://api.dicebear.com/7.x/avataaars/svg?seed=${user.username}`}
82
  alt="Profile"
83
  className="h-8 w-8 md:h-10 md:w-10 rounded-full object-cover border-2 border-white shadow-sm ring-2 ring-gray-100"
84
  />
 
86
  </div>
87
  </header>
88
  );
89
+ };
components/Sidebar.tsx CHANGED
@@ -1,5 +1,6 @@
 
1
  import React from 'react';
2
- import { LayoutDashboard, Users, BookOpen, GraduationCap, Settings, LogOut, FileText, School, UserCog, Palette, X } from 'lucide-react';
3
  import { UserRole } from '../types';
4
 
5
  interface SidebarProps {
@@ -7,8 +8,8 @@ interface SidebarProps {
7
  onChangeView: (view: string) => void;
8
  userRole: UserRole;
9
  onLogout: () => void;
10
- isOpen: boolean; // Mobile state
11
- onClose: () => void; // Close handler
12
  }
13
 
14
  export const Sidebar: React.FC<SidebarProps> = ({ currentView, onChangeView, userRole, onLogout, isOpen, onClose }) => {
@@ -16,6 +17,7 @@ export const Sidebar: React.FC<SidebarProps> = ({ currentView, onChangeView, use
16
  { id: 'dashboard', label: '工作台', icon: LayoutDashboard, roles: [UserRole.ADMIN, UserRole.TEACHER] },
17
  { id: 'students', label: '学生管理', icon: Users, roles: [UserRole.ADMIN, UserRole.TEACHER] },
18
  { id: 'classes', label: '班级管理', icon: School, roles: [UserRole.ADMIN] },
 
19
  { id: 'courses', label: '课程管理', icon: BookOpen, roles: [UserRole.ADMIN, UserRole.TEACHER, UserRole.STUDENT] },
20
  { id: 'grades', label: '成绩管理', icon: GraduationCap, roles: [UserRole.ADMIN, UserRole.TEACHER, UserRole.STUDENT] },
21
  { id: 'reports', label: '报表统计', icon: FileText, roles: [UserRole.ADMIN] },
@@ -32,13 +34,7 @@ export const Sidebar: React.FC<SidebarProps> = ({ currentView, onChangeView, use
32
 
33
  return (
34
  <>
35
- {/* Mobile Backdrop */}
36
- {isOpen && (
37
- <div
38
- className="fixed inset-0 bg-black/50 z-40 lg:hidden backdrop-blur-sm"
39
- onClick={onClose}
40
- ></div>
41
- )}
42
 
43
  <div className={sidebarClasses}>
44
  <div className="flex items-center justify-between h-20 border-b border-slate-700 px-6">
@@ -48,9 +44,7 @@ export const Sidebar: React.FC<SidebarProps> = ({ currentView, onChangeView, use
48
  智慧校园
49
  </span>
50
  </div>
51
- <button onClick={onClose} className="lg:hidden text-slate-400 hover:text-white">
52
- <X size={24} />
53
- </button>
54
  </div>
55
 
56
  <div className="flex-1 overflow-y-auto py-4">
@@ -78,10 +72,7 @@ export const Sidebar: React.FC<SidebarProps> = ({ currentView, onChangeView, use
78
  </div>
79
 
80
  <div className="p-4 border-t border-slate-700">
81
- <button
82
- onClick={onLogout}
83
- className="w-full flex items-center space-x-3 px-4 py-3 rounded-lg text-slate-400 hover:bg-red-500/10 hover:text-red-400 transition-all duration-200"
84
- >
85
  <LogOut size={20} />
86
  <span className="font-medium">退出登录</span>
87
  </button>
@@ -89,4 +80,4 @@ export const Sidebar: React.FC<SidebarProps> = ({ currentView, onChangeView, use
89
  </div>
90
  </>
91
  );
92
- };
 
1
+
2
  import React from 'react';
3
+ import { LayoutDashboard, Users, BookOpen, GraduationCap, Settings, LogOut, FileText, School, UserCog, Palette, X, Building } from 'lucide-react';
4
  import { UserRole } from '../types';
5
 
6
  interface SidebarProps {
 
8
  onChangeView: (view: string) => void;
9
  userRole: UserRole;
10
  onLogout: () => void;
11
+ isOpen: boolean;
12
+ onClose: () => void;
13
  }
14
 
15
  export const Sidebar: React.FC<SidebarProps> = ({ currentView, onChangeView, userRole, onLogout, isOpen, onClose }) => {
 
17
  { id: 'dashboard', label: '工作台', icon: LayoutDashboard, roles: [UserRole.ADMIN, UserRole.TEACHER] },
18
  { id: 'students', label: '学生管理', icon: Users, roles: [UserRole.ADMIN, UserRole.TEACHER] },
19
  { id: 'classes', label: '班级管理', icon: School, roles: [UserRole.ADMIN] },
20
+ { id: 'schools', label: '学校管理', icon: Building, roles: [UserRole.ADMIN] }, // New
21
  { id: 'courses', label: '课程管理', icon: BookOpen, roles: [UserRole.ADMIN, UserRole.TEACHER, UserRole.STUDENT] },
22
  { id: 'grades', label: '成绩管理', icon: GraduationCap, roles: [UserRole.ADMIN, UserRole.TEACHER, UserRole.STUDENT] },
23
  { id: 'reports', label: '报表统计', icon: FileText, roles: [UserRole.ADMIN] },
 
34
 
35
  return (
36
  <>
37
+ {isOpen && <div className="fixed inset-0 bg-black/50 z-40 lg:hidden backdrop-blur-sm" onClick={onClose}></div>}
 
 
 
 
 
 
38
 
39
  <div className={sidebarClasses}>
40
  <div className="flex items-center justify-between h-20 border-b border-slate-700 px-6">
 
44
  智慧校园
45
  </span>
46
  </div>
47
+ <button onClick={onClose} className="lg:hidden text-slate-400 hover:text-white"><X size={24} /></button>
 
 
48
  </div>
49
 
50
  <div className="flex-1 overflow-y-auto py-4">
 
72
  </div>
73
 
74
  <div className="p-4 border-t border-slate-700">
75
+ <button onClick={onLogout} className="w-full flex items-center space-x-3 px-4 py-3 rounded-lg text-slate-400 hover:bg-red-500/10 hover:text-red-400 transition-all duration-200">
 
 
 
76
  <LogOut size={20} />
77
  <span className="font-medium">退出登录</span>
78
  </button>
 
80
  </div>
81
  </>
82
  );
83
+ };
pages/Login.tsx CHANGED
@@ -1,52 +1,173 @@
1
 
2
- import React, { useState } from 'react';
3
- import { GraduationCap, Lock, User as UserIcon, AlertCircle, ArrowRight, Loader2 } from 'lucide-react';
4
- import { User, UserRole } from '../types';
5
  import { api } from '../services/api';
6
 
7
  interface LoginProps {
8
  onLogin: (user: User) => void;
9
  }
10
 
 
 
 
 
 
11
  export const Login: React.FC<LoginProps> = ({ onLogin }) => {
12
- const [isRegistering, setIsRegistering] = useState(false);
13
- const [username, setUsername] = useState('');
14
- const [password, setPassword] = useState('');
15
- const [role, setRole] = useState<UserRole>(UserRole.STUDENT);
 
 
 
16
 
 
 
 
 
 
 
 
17
  const [error, setError] = useState('');
18
  const [loading, setLoading] = useState(false);
19
  const [successMsg, setSuccessMsg] = useState('');
20
 
21
- const handleSubmit = async (e: React.FormEvent) => {
 
 
 
 
 
 
22
  e.preventDefault();
23
  setError('');
24
- setSuccessMsg('');
25
  setLoading(true);
 
 
 
 
 
 
 
 
 
26
 
 
 
 
27
  try {
28
- if (isRegistering) {
29
- await api.auth.register({ username, password, role });
30
- setSuccessMsg('注册申请已提交,请等待管理员审核通过后登录。');
31
- setIsRegistering(false);
32
- } else {
33
- const user = await api.auth.login(username, password);
34
- onLogin(user);
35
- }
36
  } catch (err: any) {
37
- console.error(err);
38
- if (err.message === 'PENDING_APPROVAL') {
39
- setError('您的账号正在等待管理员审核,请稍后再试。');
40
- } else if (err.message === 'BANNED') {
41
- setError('您的账号已被停用,请联系管理员。');
42
- } else {
43
- setError(isRegistering ? '注册失败:用户可能已存在' : '用户名或密码错误');
44
- }
45
- } finally {
46
- setLoading(false);
47
- }
48
  };
49
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
  return (
51
  <div className="min-h-screen bg-gray-100 flex items-center justify-center p-4">
52
  <div className="max-w-md w-full bg-white rounded-2xl shadow-xl overflow-hidden transition-all duration-300">
@@ -64,99 +185,42 @@ export const Login: React.FC<LoginProps> = ({ onLogin }) => {
64
  <div className="p-8">
65
  <div className="mb-6 flex justify-center">
66
  <div className="bg-gray-100 p-1 rounded-lg flex text-sm font-medium">
67
- <button
68
- onClick={() => { setIsRegistering(false); setError(''); setSuccessMsg(''); }}
69
- className={`px-6 py-2 rounded-md transition-all ${!isRegistering ? 'bg-white text-blue-600 shadow-sm' : 'text-gray-500 hover:text-gray-700'}`}
70
- >
71
- 登录
72
- </button>
73
- <button
74
- onClick={() => { setIsRegistering(true); setError(''); setSuccessMsg(''); }}
75
- className={`px-6 py-2 rounded-md transition-all ${isRegistering ? 'bg-white text-blue-600 shadow-sm' : 'text-gray-500 hover:text-gray-700'}`}
76
- >
77
- 注册
78
- </button>
79
  </div>
80
  </div>
81
 
82
- <form onSubmit={handleSubmit} className="space-y-5">
83
- {error && (
84
- <div className="bg-red-50 text-red-600 p-3 rounded-lg text-sm flex items-center animate-in fade-in slide-in-from-top-2">
85
- <AlertCircle size={16} className="mr-2 flex-shrink-0" />
86
- {error}
87
- </div>
88
- )}
89
-
90
- {successMsg && (
91
- <div className="bg-green-50 text-green-600 p-3 rounded-lg text-sm flex items-center animate-in fade-in slide-in-from-top-2">
92
- <div className="mr-2 h-2 w-2 bg-green-500 rounded-full animate-pulse"></div>
93
- {successMsg}
94
- </div>
95
- )}
96
-
97
- <div className="space-y-2">
98
- <label className="text-sm font-medium text-gray-700">用户名</label>
99
- <div className="relative">
100
- <div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
101
- <UserIcon className="h-5 w-5 text-gray-400" />
102
  </div>
103
- <input
104
- type="text"
105
- required
106
- value={username}
107
- onChange={(e) => setUsername(e.target.value)}
108
- className="block w-full pl-10 pr-3 py-2.5 border border-gray-300 rounded-lg focus:ring-blue-500 focus:border-blue-500 transition-colors bg-gray-50 focus:bg-white"
109
- placeholder="请输入用户名"
110
- />
111
  </div>
112
- </div>
113
-
114
- <div className="space-y-2">
115
- <label className="text-sm font-medium text-gray-700">密码</label>
116
- <div className="relative">
117
- <div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
118
- <Lock className="h-5 w-5 text-gray-400" />
119
  </div>
120
- <input
121
- type="password"
122
- required
123
- value={password}
124
- onChange={(e) => setPassword(e.target.value)}
125
- className="block w-full pl-10 pr-3 py-2.5 border border-gray-300 rounded-lg focus:ring-blue-500 focus:border-blue-500 transition-colors bg-gray-50 focus:bg-white"
126
- placeholder="请输入密码"
127
- />
128
  </div>
 
 
 
 
 
 
 
 
 
 
129
  </div>
130
-
131
- {isRegistering && (
132
- <div className="space-y-2 animate-in fade-in slide-in-from-top-2">
133
- <label className="text-sm font-medium text-gray-700">申请角色</label>
134
- <select
135
- value={role}
136
- onChange={(e) => setRole(e.target.value as UserRole)}
137
- className="block w-full px-3 py-2.5 border border-gray-300 rounded-lg focus:ring-blue-500 focus:border-blue-500 transition-colors bg-white"
138
- >
139
- <option value={UserRole.TEACHER}>教师</option>
140
- <option value={UserRole.STUDENT}>学生</option>
141
- </select>
142
- <p className="text-xs text-gray-500 mt-1">* 注册后需等待管理员审核</p>
143
- </div>
144
- )}
145
-
146
- <button
147
- type="submit"
148
- disabled={loading}
149
- className={`w-full flex justify-center py-3 px-4 border border-transparent rounded-lg shadow-lg shadow-blue-200 text-sm font-bold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-all transform hover:scale-[1.02] active:scale-[0.98] ${loading ? 'opacity-70 cursor-not-allowed' : ''}`}
150
- >
151
- {loading ? <Loader2 className="animate-spin" size={20} /> : (isRegistering ? '提交申请' : '登 录')}
152
- </button>
153
-
154
- {!isRegistering && (
155
- <div className="text-center text-xs text-gray-400 mt-4 border-t border-gray-100 pt-4">
156
- <p>演示账号: admin / admin</p>
157
- </div>
158
- )}
159
- </form>
160
  </div>
161
  </div>
162
  </div>
 
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 } from 'lucide-react';
4
+ import { User, UserRole, School } from '../types';
5
  import { api } from '../services/api';
6
 
7
  interface LoginProps {
8
  onLogin: (user: User) => void;
9
  }
10
 
11
+ const AVATAR_SEEDS = [
12
+ 'Felix', 'Aneka', 'Zoe', 'Jack', 'Precious', 'Bandit', 'Bubba', 'Cuddles',
13
+ 'Miss kitty', 'Loki', 'Sassy', 'Simba', 'Oliver', 'Princess', 'Garfield', 'Salem'
14
+ ];
15
+
16
  export const Login: React.FC<LoginProps> = ({ onLogin }) => {
17
+ const [view, setView] = useState<'login' | 'register'>('login');
18
+
19
+ // Registration Wizard Step
20
+ const [step, setStep] = useState(1); // 1: School & Role, 2: Info, 3: Avatar
21
+
22
+ // Login Form
23
+ const [loginForm, setLoginForm] = useState({ username: '', password: '' });
24
 
25
+ // Register Form
26
+ const [regForm, setRegForm] = useState({
27
+ username: '', password: '', role: UserRole.STUDENT,
28
+ schoolId: '', trueName: '', phone: '', email: '', avatar: ''
29
+ });
30
+
31
+ const [schools, setSchools] = useState<School[]>([]);
32
  const [error, setError] = useState('');
33
  const [loading, setLoading] = useState(false);
34
  const [successMsg, setSuccessMsg] = useState('');
35
 
36
+ useEffect(() => {
37
+ if (view === 'register') {
38
+ api.schools.getPublic().then(setSchools).catch(console.error);
39
+ }
40
+ }, [view]);
41
+
42
+ const handleLogin = async (e: React.FormEvent) => {
43
  e.preventDefault();
44
  setError('');
 
45
  setLoading(true);
46
+ try {
47
+ const user = await api.auth.login(loginForm.username, loginForm.password);
48
+ onLogin(user);
49
+ } catch (err: any) {
50
+ if (err.message === 'PENDING_APPROVAL') setError('账号待审核');
51
+ else if (err.message === 'BANNED') setError('账号已停用');
52
+ else setError('用户名或密码错误');
53
+ } finally { setLoading(false); }
54
+ };
55
 
56
+ const handleRegister = async () => {
57
+ setError('');
58
+ setLoading(true);
59
  try {
60
+ // Default avatar if none selected
61
+ const finalAvatar = regForm.avatar || `https://api.dicebear.com/7.x/avataaars/svg?seed=${regForm.username}`;
62
+ await api.auth.register({ ...regForm, avatar: finalAvatar });
63
+ setSuccessMsg('注册成功!请等待管理员审核。');
64
+ setView('login');
65
+ setStep(1);
 
 
66
  } catch (err: any) {
67
+ setError('注册失败: 用户名可能已存在');
68
+ } finally { setLoading(false); }
 
 
 
 
 
 
 
 
 
69
  };
70
 
71
+ // Render Steps
72
+ const renderStep1 = () => (
73
+ <div className="space-y-4 animate-in fade-in">
74
+ <h3 className="text-center font-bold text-gray-700 mb-4">第一步: 选择学校与身份</h3>
75
+ <div>
76
+ <label className="text-sm font-medium text-gray-700">所属学校</label>
77
+ <select
78
+ className="w-full mt-1 p-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
79
+ value={regForm.schoolId}
80
+ onChange={e => setRegForm({...regForm, schoolId: e.target.value})}
81
+ >
82
+ <option value="">-- 请选择 --</option>
83
+ {schools.map(s => <option key={s._id} value={s._id}>{s.name}</option>)}
84
+ </select>
85
+ </div>
86
+ <div>
87
+ <label className="text-sm font-medium text-gray-700">申请角色</label>
88
+ <select
89
+ className="w-full mt-1 p-2 border rounded-lg"
90
+ value={regForm.role}
91
+ onChange={e => setRegForm({...regForm, role: e.target.value as UserRole})}
92
+ >
93
+ <option value={UserRole.STUDENT}>学生</option>
94
+ <option value={UserRole.TEACHER}>教师</option>
95
+ </select>
96
+ </div>
97
+ <button
98
+ type="button"
99
+ disabled={!regForm.schoolId}
100
+ onClick={() => setStep(2)}
101
+ className="w-full bg-blue-600 text-white py-2 rounded-lg disabled:opacity-50"
102
+ >
103
+ 下一步
104
+ </button>
105
+ </div>
106
+ );
107
+
108
+ const renderStep2 = () => (
109
+ <div className="space-y-3 animate-in fade-in">
110
+ <h3 className="text-center font-bold text-gray-700 mb-2">第二步: 填写基本信息</h3>
111
+ <input
112
+ className="w-full p-2 border rounded-lg"
113
+ placeholder="用户名 (登录用)"
114
+ value={regForm.username} onChange={e => setRegForm({...regForm, username: e.target.value})}
115
+ />
116
+ <input
117
+ className="w-full p-2 border rounded-lg"
118
+ type="password" placeholder="设置密码"
119
+ value={regForm.password} onChange={e => setRegForm({...regForm, password: e.target.value})}
120
+ />
121
+ <input
122
+ className="w-full p-2 border rounded-lg"
123
+ placeholder="真实姓名"
124
+ value={regForm.trueName} onChange={e => setRegForm({...regForm, trueName: e.target.value})}
125
+ />
126
+ <input
127
+ className="w-full p-2 border rounded-lg"
128
+ placeholder="联系电话"
129
+ value={regForm.phone} onChange={e => setRegForm({...regForm, phone: e.target.value})}
130
+ />
131
+ <div className="flex gap-2">
132
+ <button onClick={() => setStep(1)} className="flex-1 border py-2 rounded-lg">上一步</button>
133
+ <button
134
+ onClick={() => setStep(3)}
135
+ disabled={!regForm.username || !regForm.password || !regForm.trueName}
136
+ className="flex-1 bg-blue-600 text-white py-2 rounded-lg disabled:opacity-50"
137
+ >
138
+ 下一步
139
+ </button>
140
+ </div>
141
+ </div>
142
+ );
143
+
144
+ const renderStep3 = () => (
145
+ <div className="space-y-4 animate-in fade-in">
146
+ <h3 className="text-center font-bold text-gray-700">第三步: 选择头像</h3>
147
+ <div className="grid grid-cols-4 gap-2 max-h-48 overflow-y-auto p-2 border rounded bg-gray-50">
148
+ {AVATAR_SEEDS.map(seed => {
149
+ const url = `https://api.dicebear.com/7.x/avataaars/svg?seed=${seed}`;
150
+ const isSelected = regForm.avatar === url;
151
+ return (
152
+ <div
153
+ key={seed}
154
+ onClick={() => setRegForm({...regForm, avatar: url})}
155
+ className={`cursor-pointer rounded-full p-1 border-2 transition-all ${isSelected ? 'border-blue-500 scale-110 bg-blue-100' : 'border-transparent hover:bg-gray-200'}`}
156
+ >
157
+ <img src={url} alt={seed} className="w-full h-auto rounded-full" />
158
+ </div>
159
+ );
160
+ })}
161
+ </div>
162
+ <div className="flex gap-2">
163
+ <button onClick={() => setStep(2)} className="flex-1 border py-2 rounded-lg">上一步</button>
164
+ <button onClick={handleRegister} disabled={loading} className="flex-1 bg-green-600 text-white py-2 rounded-lg">
165
+ {loading ? '提交中...' : '完成注册'}
166
+ </button>
167
+ </div>
168
+ </div>
169
+ );
170
+
171
  return (
172
  <div className="min-h-screen bg-gray-100 flex items-center justify-center p-4">
173
  <div className="max-w-md w-full bg-white rounded-2xl shadow-xl overflow-hidden transition-all duration-300">
 
185
  <div className="p-8">
186
  <div className="mb-6 flex justify-center">
187
  <div className="bg-gray-100 p-1 rounded-lg flex text-sm font-medium">
188
+ <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>
189
+ <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>
 
 
 
 
 
 
 
 
 
 
190
  </div>
191
  </div>
192
 
193
+ {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>}
194
+ {successMsg && <div className="bg-green-50 text-green-600 p-3 rounded-lg text-sm mb-4">{successMsg}</div>}
195
+
196
+ {view === 'login' ? (
197
+ <form onSubmit={handleLogin} className="space-y-5">
198
+ <div className="space-y-2">
199
+ <label className="text-sm font-medium text-gray-700">用户名</label>
200
+ <div className="relative">
201
+ <UserIcon className="absolute left-3 top-2.5 h-5 w-5 text-gray-400" />
202
+ <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="请输入用户名" />
 
 
 
 
 
 
 
 
 
 
203
  </div>
 
 
 
 
 
 
 
 
204
  </div>
205
+ <div className="space-y-2">
206
+ <label className="text-sm font-medium text-gray-700">密码</label>
207
+ <div className="relative">
208
+ <Lock className="absolute left-3 top-2.5 h-5 w-5 text-gray-400" />
209
+ <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="请输入密码" />
 
 
210
  </div>
 
 
 
 
 
 
 
 
211
  </div>
212
+ <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">
213
+ {loading ? <Loader2 className="animate-spin inline"/> : '登 录'}
214
+ </button>
215
+ <div className="text-center text-xs text-gray-400 mt-4">演示账号: admin / admin</div>
216
+ </form>
217
+ ) : (
218
+ <div className="min-h-[300px]">
219
+ {step === 1 && renderStep1()}
220
+ {step === 2 && renderStep2()}
221
+ {step === 3 && renderStep3()}
222
  </div>
223
+ )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
224
  </div>
225
  </div>
226
  </div>
pages/SchoolList.tsx ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import React, { useState, useEffect } from 'react';
3
+ import { School } from '../types';
4
+ import { api } from '../services/api';
5
+ import { Loader2, Plus, Edit, Save, X, School as SchoolIcon } from 'lucide-react';
6
+
7
+ export const SchoolList: React.FC = () => {
8
+ const [schools, setSchools] = useState<School[]>([]);
9
+ const [loading, setLoading] = useState(true);
10
+ const [editId, setEditId] = useState<string | null>(null);
11
+ const [form, setForm] = useState({ name: '', code: '' });
12
+ const [isAdding, setIsAdding] = useState(false);
13
+
14
+ const loadSchools = async () => {
15
+ setLoading(true);
16
+ try {
17
+ const data = await api.schools.getAll();
18
+ setSchools(data);
19
+ } catch (e) { console.error(e); }
20
+ finally { setLoading(false); }
21
+ };
22
+
23
+ useEffect(() => { loadSchools(); }, []);
24
+
25
+ const handleSave = async () => {
26
+ if (!form.name || !form.code) return alert('请填写完整信息');
27
+ try {
28
+ if (editId) await api.schools.update(editId, form);
29
+ else await api.schools.add(form);
30
+ setEditId(null);
31
+ setIsAdding(false);
32
+ setForm({ name: '', code: '' });
33
+ loadSchools();
34
+ } catch (e) { alert('保存失败,代码可能重复'); }
35
+ };
36
+
37
+ const startEdit = (s: School) => {
38
+ setEditId(s._id || String(s.id));
39
+ setForm({ name: s.name, code: s.code });
40
+ setIsAdding(false);
41
+ };
42
+
43
+ return (
44
+ <div className="space-y-6">
45
+ <div className="flex justify-between items-center">
46
+ <h2 className="text-xl font-bold text-gray-800 flex items-center"><SchoolIcon className="mr-2"/> 学校维度管理</h2>
47
+ <button onClick={() => { setIsAdding(true); setEditId(null); setForm({name:'', code:''}); }} className="px-4 py-2 bg-blue-600 text-white rounded-lg flex items-center text-sm"><Plus size={16} className="mr-1"/> 新增学校</button>
48
+ </div>
49
+
50
+ <div className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
51
+ {loading ? <div className="p-10 text-center"><Loader2 className="animate-spin inline"/></div> : (
52
+ <table className="w-full text-left">
53
+ <thead className="bg-gray-50 text-gray-500 uppercase text-xs">
54
+ <tr>
55
+ <th className="px-6 py-4">学校名称</th>
56
+ <th className="px-6 py-4">唯一代码</th>
57
+ <th className="px-6 py-4 text-right">操作</th>
58
+ </tr>
59
+ </thead>
60
+ <tbody className="divide-y divide-gray-100">
61
+ {isAdding && (
62
+ <tr className="bg-blue-50">
63
+ <td className="px-6 py-4"><input className="border p-1 rounded w-full" placeholder="学校名称" value={form.name} onChange={e=>setForm({...form, name:e.target.value})}/></td>
64
+ <td className="px-6 py-4"><input className="border p-1 rounded w-full" placeholder="代码 (如: SCH01)" value={form.code} onChange={e=>setForm({...form, code:e.target.value})}/></td>
65
+ <td className="px-6 py-4 text-right">
66
+ <button onClick={handleSave} className="text-green-600 mr-2"><Save size={18}/></button>
67
+ <button onClick={()=>setIsAdding(false)} className="text-gray-500"><X size={18}/></button>
68
+ </td>
69
+ </tr>
70
+ )}
71
+ {schools.map(s => {
72
+ const isEditing = editId === (s._id || String(s.id));
73
+ return (
74
+ <tr key={s._id || s.id} className="hover:bg-gray-50">
75
+ <td className="px-6 py-4">
76
+ {isEditing ? <input className="border p-1 rounded" value={form.name} onChange={e=>setForm({...form, name:e.target.value})}/> : <span className="font-medium text-gray-800">{s.name}</span>}
77
+ </td>
78
+ <td className="px-6 py-4 font-mono text-sm text-gray-600">
79
+ {isEditing ? <input className="border p-1 rounded" value={form.code} onChange={e=>setForm({...form, code:e.target.value})}/> : s.code}
80
+ </td>
81
+ <td className="px-6 py-4 text-right">
82
+ {isEditing ? (
83
+ <>
84
+ <button onClick={handleSave} className="text-green-600 mr-2"><Save size={18}/></button>
85
+ <button onClick={()=>setEditId(null)} className="text-gray-500"><X size={18}/></button>
86
+ </>
87
+ ) : (
88
+ <button onClick={() => startEdit(s)} className="text-blue-500 hover:text-blue-700"><Edit size={18}/></button>
89
+ )}
90
+ </td>
91
+ </tr>
92
+ );
93
+ })}
94
+ </tbody>
95
+ </table>
96
+ )}
97
+ </div>
98
+ </div>
99
+ );
100
+ };
pages/UserList.tsx CHANGED
@@ -1,55 +1,56 @@
1
 
2
  import React, { useState, useEffect } from 'react';
3
- import { User, UserRole, UserStatus } from '../types';
4
  import { api } from '../services/api';
5
- import { Loader2, Check, X, Shield, Trash2, Edit } from 'lucide-react';
6
 
7
  export const UserList: React.FC = () => {
8
  const [users, setUsers] = useState<User[]>([]);
 
9
  const [loading, setLoading] = useState(true);
 
10
 
11
- const loadUsers = async () => {
12
  setLoading(true);
13
  try {
14
- const data = await api.users.getAll();
15
- setUsers(data);
 
16
  } catch (e) { console.error(e); }
17
  finally { setLoading(false); }
18
  };
19
 
20
- useEffect(() => { loadUsers(); }, []);
21
 
22
  const handleApprove = async (user: User) => {
23
  if (confirm(`确认批准 ${user.username} 的注册申请?`)) {
24
  await api.users.update(user._id || String(user.id), { status: UserStatus.ACTIVE });
25
- loadUsers();
26
  }
27
  };
28
 
29
  const handleDelete = async (user: User) => {
30
- if (user.role === UserRole.ADMIN && user.username === 'admin') {
31
- return alert('无法删除超级管理员');
32
- }
33
  if (confirm(`警告:确定要删除用户 ${user.username} 吗?`)) {
34
  await api.users.delete(user._id || String(user.id));
35
- loadUsers();
36
  }
37
  };
38
 
39
  const handleRoleChange = async (user: User, newRole: UserRole) => {
40
  if (user.username === 'admin') return alert('无法修改超级管理员');
41
  await api.users.update(user._id || String(user.id), { role: newRole });
42
- loadUsers();
43
  };
44
 
45
- const getStatusBadge = (status: UserStatus) => {
46
- switch(status) {
47
- case UserStatus.ACTIVE: return <span className="bg-green-100 text-green-700 px-2 py-1 rounded-full text-xs">已激活</span>;
48
- case UserStatus.PENDING: return <span className="bg-yellow-100 text-yellow-700 px-2 py-1 rounded-full text-xs animate-pulse">待审核</span>;
49
- case UserStatus.BANNED: return <span className="bg-red-100 text-red-700 px-2 py-1 rounded-full text-xs">已停用</span>;
50
- }
51
  };
52
 
 
 
53
  if (loading) return <div className="flex justify-center py-10"><Loader2 className="animate-spin text-blue-600" /></div>;
54
 
55
  return (
@@ -57,10 +58,12 @@ export const UserList: React.FC = () => {
57
  <h2 className="text-xl font-bold text-gray-800 mb-4">用户权限与审核</h2>
58
 
59
  <div className="overflow-x-auto">
60
- <table className="w-full text-left border-collapse">
61
  <thead>
62
  <tr className="bg-gray-50 text-gray-500 text-xs uppercase">
63
- <th className="px-4 py-3">用户名</th>
 
 
64
  <th className="px-4 py-3">角色</th>
65
  <th className="px-4 py-3">状态</th>
66
  <th className="px-4 py-3 text-right">操作</th>
@@ -69,36 +72,59 @@ export const UserList: React.FC = () => {
69
  <tbody className="divide-y divide-gray-100">
70
  {users.map(user => (
71
  <tr key={user._id || user.id} className="hover:bg-gray-50">
72
- <td className="px-4 py-3 font-medium text-gray-800">{user.username}</td>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
  <td className="px-4 py-3">
74
  <select
75
  value={user.role}
76
  onChange={(e) => handleRoleChange(user, e.target.value as UserRole)}
77
  disabled={user.username === 'admin'}
78
- className="bg-white border border-gray-200 rounded px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
79
  >
80
  <option value={UserRole.ADMIN}>管理员</option>
81
  <option value={UserRole.TEACHER}>教师</option>
82
  <option value={UserRole.STUDENT}>学生</option>
83
  </select>
84
  </td>
85
- <td className="px-4 py-3">{getStatusBadge(user.status)}</td>
 
 
 
 
86
  <td className="px-4 py-3 text-right space-x-2">
87
  {user.status === UserStatus.PENDING && (
88
- <button
89
- onClick={() => handleApprove(user)}
90
- className="text-green-600 hover:bg-green-50 p-1 rounded" title="通过审核"
91
- >
92
- <Check size={18} />
93
- </button>
94
  )}
95
- <button
96
- onClick={() => handleDelete(user)}
97
- className="text-red-600 hover:bg-red-50 p-1 rounded" title="删除用户"
98
- disabled={user.username === 'admin'}
99
- >
100
- <Trash2 size={18} />
101
- </button>
102
  </td>
103
  </tr>
104
  ))}
 
1
 
2
  import React, { useState, useEffect } from 'react';
3
+ import { User, UserRole, UserStatus, School } from '../types';
4
  import { api } from '../services/api';
5
+ import { Loader2, Check, X, Trash2, Building, Edit } from 'lucide-react';
6
 
7
  export const UserList: React.FC = () => {
8
  const [users, setUsers] = useState<User[]>([]);
9
+ const [schools, setSchools] = useState<School[]>([]);
10
  const [loading, setLoading] = useState(true);
11
+ const [editingUser, setEditingUser] = useState<User | null>(null);
12
 
13
+ const loadData = async () => {
14
  setLoading(true);
15
  try {
16
+ const [u, s] = await Promise.all([api.users.getAll(), api.schools.getAll()]);
17
+ setUsers(u);
18
+ setSchools(s);
19
  } catch (e) { console.error(e); }
20
  finally { setLoading(false); }
21
  };
22
 
23
+ useEffect(() => { loadData(); }, []);
24
 
25
  const handleApprove = async (user: User) => {
26
  if (confirm(`确认批准 ${user.username} 的注册申请?`)) {
27
  await api.users.update(user._id || String(user.id), { status: UserStatus.ACTIVE });
28
+ loadData();
29
  }
30
  };
31
 
32
  const handleDelete = async (user: User) => {
33
+ if (user.role === UserRole.ADMIN && user.username === 'admin') return alert('无法删除超级管理员');
 
 
34
  if (confirm(`警告:确定要删除用户 ${user.username} 吗?`)) {
35
  await api.users.delete(user._id || String(user.id));
36
+ loadData();
37
  }
38
  };
39
 
40
  const handleRoleChange = async (user: User, newRole: UserRole) => {
41
  if (user.username === 'admin') return alert('无法修改超级管理员');
42
  await api.users.update(user._id || String(user.id), { role: newRole });
43
+ loadData();
44
  };
45
 
46
+ const handleSchoolChange = async (userId: string, schoolId: string) => {
47
+ await api.users.update(userId, { schoolId });
48
+ setEditingUser(null);
49
+ loadData();
 
 
50
  };
51
 
52
+ const getSchoolName = (id?: string) => schools.find(s => s._id === id)?.name || '未分配';
53
+
54
  if (loading) return <div className="flex justify-center py-10"><Loader2 className="animate-spin text-blue-600" /></div>;
55
 
56
  return (
 
58
  <h2 className="text-xl font-bold text-gray-800 mb-4">用户权限与审核</h2>
59
 
60
  <div className="overflow-x-auto">
61
+ <table className="w-full text-left border-collapse min-w-[800px]">
62
  <thead>
63
  <tr className="bg-gray-50 text-gray-500 text-xs uppercase">
64
+ <th className="px-4 py-3">用户</th>
65
+ <th className="px-4 py-3">联系方式</th>
66
+ <th className="px-4 py-3">所属学校</th>
67
  <th className="px-4 py-3">角色</th>
68
  <th className="px-4 py-3">状态</th>
69
  <th className="px-4 py-3 text-right">操作</th>
 
72
  <tbody className="divide-y divide-gray-100">
73
  {users.map(user => (
74
  <tr key={user._id || user.id} className="hover:bg-gray-50">
75
+ <td className="px-4 py-3">
76
+ <div className="flex items-center space-x-3">
77
+ <img src={user.avatar || `https://api.dicebear.com/7.x/avataaars/svg?seed=${user.username}`} className="w-8 h-8 rounded-full" />
78
+ <div>
79
+ <p className="font-bold text-gray-800 text-sm">{user.trueName || user.username}</p>
80
+ <p className="text-xs text-gray-500">@{user.username}</p>
81
+ </div>
82
+ </div>
83
+ </td>
84
+ <td className="px-4 py-3 text-sm text-gray-600">
85
+ <div>{user.phone || '-'}</div>
86
+ <div className="text-xs text-gray-400">{user.email}</div>
87
+ </td>
88
+ <td className="px-4 py-3 text-sm">
89
+ {editingUser?._id === user._id ? (
90
+ <select
91
+ className="border rounded p-1 text-xs w-32"
92
+ value={user.schoolId}
93
+ onChange={(e) => handleSchoolChange(user._id!, e.target.value)}
94
+ onBlur={() => setEditingUser(null)}
95
+ autoFocus
96
+ >
97
+ {schools.map(s => <option key={s._id} value={s._id}>{s.name}</option>)}
98
+ </select>
99
+ ) : (
100
+ <div className="flex items-center space-x-1 group cursor-pointer" onClick={() => setEditingUser(user)}>
101
+ <span className="text-gray-700">{getSchoolName(user.schoolId)}</span>
102
+ <Edit size={12} className="text-gray-300 opacity-0 group-hover:opacity-100"/>
103
+ </div>
104
+ )}
105
+ </td>
106
  <td className="px-4 py-3">
107
  <select
108
  value={user.role}
109
  onChange={(e) => handleRoleChange(user, e.target.value as UserRole)}
110
  disabled={user.username === 'admin'}
111
+ className="bg-white border border-gray-200 rounded px-2 py-1 text-xs focus:ring-2 focus:ring-blue-500"
112
  >
113
  <option value={UserRole.ADMIN}>管理员</option>
114
  <option value={UserRole.TEACHER}>教师</option>
115
  <option value={UserRole.STUDENT}>学生</option>
116
  </select>
117
  </td>
118
+ <td className="px-4 py-3">
119
+ {user.status === 'active' && <span className="bg-green-100 text-green-700 px-2 py-1 rounded-full text-xs">已激活</span>}
120
+ {user.status === 'pending' && <span className="bg-yellow-100 text-yellow-700 px-2 py-1 rounded-full text-xs animate-pulse">待审核</span>}
121
+ {user.status === 'banned' && <span className="bg-red-100 text-red-700 px-2 py-1 rounded-full text-xs">已停用</span>}
122
+ </td>
123
  <td className="px-4 py-3 text-right space-x-2">
124
  {user.status === UserStatus.PENDING && (
125
+ <button onClick={() => handleApprove(user)} className="text-green-600 hover:bg-green-50 p-1 rounded" title="通过审核"><Check size={18} /></button>
 
 
 
 
 
126
  )}
127
+ <button onClick={() => handleDelete(user)} className="text-red-600 hover:bg-red-50 p-1 rounded" title="删除" disabled={user.username === 'admin'}><Trash2 size={18} /></button>
 
 
 
 
 
 
128
  </td>
129
  </tr>
130
  ))}
server.js CHANGED
@@ -22,6 +22,7 @@ app.use(express.static(path.join(__dirname, 'dist')));
22
  // ==========================================
23
 
24
  const InMemoryDB = {
 
25
  users: [],
26
  students: [],
27
  courses: [],
@@ -29,13 +30,7 @@ const InMemoryDB = {
29
  classes: [],
30
  subjects: [],
31
  exams: [],
32
- config: {
33
- systemName: '智慧校园管理系统',
34
- semester: '2023-2024学年 第一学期',
35
- allowRegister: true,
36
- maintenanceMode: false,
37
- emailNotify: true
38
- },
39
  isFallback: false
40
  };
41
 
@@ -47,28 +42,42 @@ const connectDB = async () => {
47
  console.error('❌ MongoDB 连接失败:', err.message);
48
  console.warn('⚠️ 启动内存数据库模式');
49
  InMemoryDB.isFallback = true;
50
- // Init Memory Admin
 
 
 
51
  InMemoryDB.users.push(
52
- { username: 'admin', password: 'admin', role: 'ADMIN', status: 'active', email: 'admin@school.edu' }
53
  );
54
  }
55
  };
56
  connectDB();
57
 
58
- // Schemas
 
 
 
 
 
 
 
59
  const UserSchema = new mongoose.Schema({
60
  username: { type: String, required: true, unique: true },
61
  password: { type: String, required: true },
 
 
 
 
62
  role: { type: String, enum: ['ADMIN', 'TEACHER', 'STUDENT'], default: 'STUDENT' },
63
  status: { type: String, enum: ['active', 'pending', 'banned'], default: 'pending' },
64
- email: String,
65
  avatar: String,
66
  createTime: { type: Date, default: Date.now }
67
  });
68
  const User = mongoose.model('User', UserSchema);
69
 
70
  const StudentSchema = new mongoose.Schema({
71
- studentNo: { type: String, required: true, unique: true },
 
72
  name: { type: String, required: true },
73
  gender: { type: String, enum: ['Male', 'Female'], default: 'Male' },
74
  birthday: String,
@@ -77,9 +86,12 @@ const StudentSchema = new mongoose.Schema({
77
  className: String,
78
  status: { type: String, default: 'Enrolled' }
79
  });
 
 
80
  const Student = mongoose.model('Student', StudentSchema);
81
 
82
  const CourseSchema = new mongoose.Schema({
 
83
  courseCode: String,
84
  courseName: String,
85
  teacherName: String,
@@ -90,6 +102,7 @@ const CourseSchema = new mongoose.Schema({
90
  const Course = mongoose.model('Course', CourseSchema);
91
 
92
  const ScoreSchema = new mongoose.Schema({
 
93
  studentName: String,
94
  studentNo: String,
95
  courseName: String,
@@ -102,6 +115,7 @@ const ScoreSchema = new mongoose.Schema({
102
  const Score = mongoose.model('Score', ScoreSchema);
103
 
104
  const ClassSchema = new mongoose.Schema({
 
105
  grade: String,
106
  className: String,
107
  teacherName: String
@@ -109,7 +123,8 @@ const ClassSchema = new mongoose.Schema({
109
  const ClassModel = mongoose.model('Class', ClassSchema);
110
 
111
  const SubjectSchema = new mongoose.Schema({
112
- name: { type: String, required: true, unique: true },
 
113
  code: String,
114
  color: String,
115
  excellenceThreshold: { type: Number, default: 90 }
@@ -117,13 +132,15 @@ const SubjectSchema = new mongoose.Schema({
117
  const SubjectModel = mongoose.model('Subject', SubjectSchema);
118
 
119
  const ExamSchema = new mongoose.Schema({
120
- name: { type: String, required: true, unique: true },
 
121
  date: String,
122
  semester: String
123
  });
124
  const ExamModel = mongoose.model('Exam', ExamSchema);
125
 
126
  const ConfigSchema = new mongoose.Schema({
 
127
  key: { type: String, default: 'main' },
128
  systemName: String,
129
  semester: String,
@@ -137,38 +154,76 @@ const ConfigModel = mongoose.model('Config', ConfigSchema);
137
  const initData = async () => {
138
  if (InMemoryDB.isFallback) return;
139
  try {
140
- // Admin
 
 
 
 
 
 
 
 
 
 
141
  const userCount = await User.countDocuments();
142
  if (userCount === 0) {
143
  await User.create([
144
- { username: 'admin', password: 'admin', role: 'ADMIN', status: 'active', email: 'admin@school.edu' }
 
 
 
 
 
 
 
 
145
  ]);
146
  }
147
- // Subjects
148
- const subjCount = await SubjectModel.countDocuments();
 
149
  if (subjCount === 0) {
150
  await SubjectModel.create([
151
- { name: '语文', code: 'CHI', color: '#ef4444', excellenceThreshold: 90 },
152
- { name: '数学', code: 'MAT', color: '#3b82f6', excellenceThreshold: 90 },
153
- { name: '英语', code: 'ENG', color: '#f59e0b', excellenceThreshold: 90 },
154
- { name: '科学', code: 'SCI', color: '#10b981', excellenceThreshold: 85 }
155
  ]);
156
  }
157
- // Config
158
- const config = await ConfigModel.findOne({ key: 'main' });
159
- if (!config) {
160
- await ConfigModel.create({ key: 'main', systemName: '智慧校园管理系统', semester: '2023-2024学年 第一学期' });
161
- }
162
  } catch (err) {
163
  console.error('Init Data Error', err);
164
  }
165
  };
166
  mongoose.connection.once('open', initData);
167
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
168
  // ==========================================
169
  // API Routes
170
  // ==========================================
171
 
 
 
 
 
 
 
 
172
  // --- Auth ---
173
  app.post('/api/auth/login', async (req, res) => {
174
  const { username, password } = req.body;
@@ -181,27 +236,21 @@ app.post('/api/auth/login', async (req, res) => {
181
  }
182
 
183
  if (!user) return res.status(401).json({ message: '用户名或密码错误' });
184
-
185
- // Check Status
186
- if (user.status === 'pending') {
187
- return res.status(403).json({ error: 'PENDING_APPROVAL', message: '账号待审核,请联系管理员' });
188
- }
189
- if (user.status === 'banned') {
190
- return res.status(403).json({ error: 'BANNED', message: '账号已被停用' });
191
- }
192
 
193
  res.json(user);
194
  } catch (e) { res.status(500).json({ error: e.message }); }
195
  });
196
 
197
  app.post('/api/auth/register', async (req, res) => {
198
- const { username, password, role } = req.body;
199
  const status = 'pending';
200
 
201
  try {
202
  if (InMemoryDB.isFallback) {
203
  if (InMemoryDB.users.find(u => u.username === username)) return res.status(400).json({ error: 'Existed' });
204
- const newUser = { id: Date.now(), username, password, role: role || 'STUDENT', status };
205
  InMemoryDB.users.push(newUser);
206
  return res.json(newUser);
207
  }
@@ -209,22 +258,42 @@ app.post('/api/auth/register', async (req, res) => {
209
  const existing = await User.findOne({ username });
210
  if (existing) return res.status(400).json({ error: 'Existed' });
211
 
212
- const newUser = await User.create({ username, password, role: role || 'STUDENT', status });
213
  res.json(newUser);
214
  } catch (e) { res.status(500).json({ error: e.message }); }
215
  });
216
 
217
- // --- Users (Admin) ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
218
  app.get('/api/users', async (req, res) => {
219
- if (InMemoryDB.isFallback) return res.json(InMemoryDB.users);
220
- const users = await User.find().sort({ createTime: -1 });
 
221
  res.json(users);
222
  });
223
  app.put('/api/users/:id', async (req, res) => {
224
  try {
225
  if (InMemoryDB.isFallback) {
226
- const idx = InMemoryDB.users.findIndex(u => u._id === req.params.id || u.id == req.params.id);
227
- if (idx !== -1) InMemoryDB.users[idx] = { ...InMemoryDB.users[idx], ...req.body };
228
  return res.json({ success: true });
229
  }
230
  await User.findByIdAndUpdate(req.params.id, req.body);
@@ -232,201 +301,170 @@ app.put('/api/users/:id', async (req, res) => {
232
  } catch (e) { res.status(500).json({ error: e.message }); }
233
  });
234
  app.delete('/api/users/:id', async (req, res) => {
235
- try {
236
- if (InMemoryDB.isFallback) {
237
- InMemoryDB.users = InMemoryDB.users.filter(u => u._id !== req.params.id && u.id != req.params.id);
238
- return res.json({ success: true });
239
- }
240
- await User.findByIdAndDelete(req.params.id);
241
- res.json({ success: true });
242
- } catch (e) { res.status(500).json({ error: e.message }); }
243
  });
244
 
245
  // --- Subjects ---
246
  app.get('/api/subjects', async (req, res) => {
247
- if (InMemoryDB.isFallback) return res.json(InMemoryDB.subjects.length ? InMemoryDB.subjects : [{name:'语文',code:'CHI',color:'#ef4444', excellenceThreshold: 90}]);
248
- const subs = await SubjectModel.find();
249
- res.json(subs);
250
  });
251
  app.post('/api/subjects', async (req, res) => {
252
- try {
253
- if (InMemoryDB.isFallback) {
254
- const newSub = { ...req.body, id: Date.now(), _id: Date.now().toString() };
255
- InMemoryDB.subjects.push(newSub);
256
- return res.json(newSub);
257
- }
258
- const newSub = await SubjectModel.create(req.body);
259
- res.json(newSub);
260
- } catch (e) { res.status(400).json({ error: e.message }); }
261
  });
262
  app.put('/api/subjects/:id', async (req, res) => {
263
- try {
264
- if (InMemoryDB.isFallback) {
265
- const idx = InMemoryDB.subjects.findIndex(s => s._id == req.params.id || s.id == req.params.id);
266
- if (idx !== -1) InMemoryDB.subjects[idx] = { ...InMemoryDB.subjects[idx], ...req.body };
267
- return res.json({ success: true });
268
- }
269
- await SubjectModel.findByIdAndUpdate(req.params.id, req.body);
270
- res.json({ success: true });
271
- } catch (e) { res.status(500).json({ error: e.message }); }
272
  });
273
  app.delete('/api/subjects/:id', async (req, res) => {
274
- try {
275
- if (InMemoryDB.isFallback) {
276
- InMemoryDB.subjects = InMemoryDB.subjects.filter(s => s._id !== req.params.id);
277
- return res.json({ success: true });
278
- }
279
- await SubjectModel.findByIdAndDelete(req.params.id);
280
- res.json({ success: true });
281
- } catch (e) { res.status(500).json({ error: e.message }); }
282
  });
283
 
284
  // --- Exams ---
285
  app.get('/api/exams', async (req, res) => {
286
- if (InMemoryDB.isFallback) return res.json(InMemoryDB.exams);
287
- const exams = await ExamModel.find().sort({ date: 1 });
288
- res.json(exams);
289
  });
290
  app.post('/api/exams', async (req, res) => {
291
- try {
292
- // Upsert based on name
293
- const { name, date, semester } = req.body;
294
- if (InMemoryDB.isFallback) {
295
- const idx = InMemoryDB.exams.findIndex(e => e.name === name);
296
- if (idx >= 0) InMemoryDB.exams[idx] = { ...InMemoryDB.exams[idx], date, semester };
297
- else InMemoryDB.exams.push({ name, date, semester, id: Date.now(), _id: String(Date.now()) });
298
- return res.json({ success: true });
299
- }
300
- await ExamModel.findOneAndUpdate({ name }, { date, semester }, { upsert: true });
301
- res.json({ success: true });
302
- } catch (e) { res.status(500).json({ error: e.message }); }
303
- });
304
-
305
- // --- Batch Operations ---
306
- app.post('/api/batch-delete', async (req, res) => {
307
- const { type, ids } = req.body; // type: 'student' | 'score' | 'user'
308
- if (!ids || !Array.isArray(ids)) return res.status(400).json({ error: 'Invalid IDs' });
309
-
310
- try {
311
- if (InMemoryDB.isFallback) {
312
- if (type === 'student') InMemoryDB.students = InMemoryDB.students.filter(s => !ids.includes(s._id) && !ids.includes(s.id));
313
- if (type === 'score') InMemoryDB.scores = InMemoryDB.scores.filter(s => !ids.includes(s._id) && !ids.includes(s.id));
314
- if (type === 'user') InMemoryDB.users = InMemoryDB.users.filter(u => !ids.includes(u._id) && !ids.includes(u.id));
315
- return res.json({ success: true });
316
- }
317
-
318
- if (type === 'student') await Student.deleteMany({ _id: { $in: ids } });
319
- if (type === 'score') await Score.deleteMany({ _id: { $in: ids } });
320
- if (type === 'user') await User.deleteMany({ _id: { $in: ids } });
321
-
322
- res.json({ success: true });
323
- } catch (e) { res.status(500).json({ error: e.message }); }
324
  });
325
 
326
- // Students
327
  app.get('/api/students', async (req, res) => {
328
- if (InMemoryDB.isFallback) return res.json(InMemoryDB.students);
329
- res.json(await Student.find().sort({ studentNo: 1 }));
 
330
  });
331
  app.post('/api/students', async (req, res) => {
332
- if (InMemoryDB.isFallback) { InMemoryDB.students.push({ ...req.body, id: Date.now(), _id: String(Date.now()) }); return res.json({}); }
333
- res.json(await Student.create(req.body));
 
334
  });
335
  app.delete('/api/students/:id', async (req, res) => {
336
  if (InMemoryDB.isFallback) { InMemoryDB.students = InMemoryDB.students.filter(s => s._id != req.params.id); return res.json({}); }
337
  await Student.findByIdAndDelete(req.params.id); res.json({});
338
  });
339
 
340
- // Classes
341
  app.get('/api/classes', async (req, res) => {
342
- if (InMemoryDB.isFallback) return res.json(InMemoryDB.classes);
343
- const classes = await ClassModel.find();
 
 
344
  const result = await Promise.all(classes.map(async (c) => {
345
- const count = await Student.countDocuments({ className: c.grade + c.className });
346
  return { ...c.toObject(), studentCount: count };
347
  }));
348
  res.json(result);
349
  });
350
  app.post('/api/classes', async (req, res) => {
351
- if (InMemoryDB.isFallback) { InMemoryDB.classes.push({ ...req.body, id: Date.now(), _id: String(Date.now()) }); return res.json({}); }
352
- res.json(await ClassModel.create(req.body));
 
353
  });
354
  app.delete('/api/classes/:id', async (req, res) => {
355
- if (InMemoryDB.isFallback) { InMemoryDB.classes = InMemoryDB.classes.filter(c => c._id != req.params.id); return res.json({}); }
356
  await ClassModel.findByIdAndDelete(req.params.id); res.json({});
357
  });
358
 
359
- // Courses
360
  app.get('/api/courses', async (req, res) => {
361
- if (InMemoryDB.isFallback) return res.json(InMemoryDB.courses);
362
- res.json(await Course.find());
 
363
  });
364
  app.post('/api/courses', async (req, res) => {
365
- if (InMemoryDB.isFallback) { InMemoryDB.courses.push({ ...req.body, id: Date.now(), _id: String(Date.now()) }); return res.json({}); }
366
- res.json(await Course.create(req.body));
 
367
  });
368
  app.put('/api/courses/:id', async (req, res) => {
369
- if (InMemoryDB.isFallback) return res.json({});
370
  res.json(await Course.findByIdAndUpdate(req.params.id, req.body));
371
  });
372
  app.delete('/api/courses/:id', async (req, res) => {
373
- if (InMemoryDB.isFallback) { InMemoryDB.courses = InMemoryDB.courses.filter(c => c._id != req.params.id); return res.json({}); }
374
  await Course.findByIdAndDelete(req.params.id); res.json({});
375
  });
376
 
377
- // Scores
378
  app.get('/api/scores', async (req, res) => {
379
- if (InMemoryDB.isFallback) return res.json(InMemoryDB.scores);
380
- res.json(await Score.find());
 
381
  });
382
  app.post('/api/scores', async (req, res) => {
383
- if (InMemoryDB.isFallback) { InMemoryDB.scores.push({ ...req.body, id: Date.now(), _id: String(Date.now()) }); return res.json({}); }
384
- res.json(await Score.create(req.body));
 
385
  });
386
  app.put('/api/scores/:id', async (req, res) => {
387
  if (InMemoryDB.isFallback) {
388
- const idx = InMemoryDB.scores.findIndex(s => s._id == req.params.id || s.id == req.params.id);
389
- if (idx !== -1) InMemoryDB.scores[idx] = { ...InMemoryDB.scores[idx], ...req.body };
390
  return res.json({ success: true });
391
  }
392
  await Score.findByIdAndUpdate(req.params.id, req.body);
393
  res.json({ success: true });
394
  });
395
  app.delete('/api/scores/:id', async (req, res) => {
396
- if (InMemoryDB.isFallback) { InMemoryDB.scores = InMemoryDB.scores.filter(s => s._id != req.params.id); return res.json({}); }
397
  await Score.findByIdAndDelete(req.params.id); res.json({});
398
  });
399
 
400
- // Config
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
401
  app.get('/api/config', async (req, res) => {
 
402
  if (InMemoryDB.isFallback) return res.json(InMemoryDB.config);
403
- res.json(await ConfigModel.findOne({ key: 'main' }) || {});
404
  });
405
  app.post('/api/config', async (req, res) => {
406
- if (InMemoryDB.isFallback) { InMemoryDB.config = req.body; return res.json({}); }
407
- res.json(await ConfigModel.findOneAndUpdate({ key: 'main' }, req.body, { upsert: true, new: true }));
 
408
  });
409
 
410
- // Stats
411
- app.get('/api/stats', async (req, res) => {
412
- try {
413
- if (InMemoryDB.isFallback) {
414
- return res.json({ studentCount: InMemoryDB.students.length, courseCount: InMemoryDB.courses.length, avgScore: 0, excellentRate: '0%' });
415
- }
416
- const studentCount = await Student.countDocuments();
417
- const courseCount = await Course.countDocuments();
418
- const scores = await Score.find();
419
- // Exclude Absent/Leave from average calculation
420
- const validScores = scores.filter(s => s.status === 'Normal');
421
- const totalScore = validScores.reduce((sum, s) => sum + (s.score || 0), 0);
422
- const avgScore = validScores.length > 0 ? (totalScore / validScores.length).toFixed(1) : 0;
423
-
424
- // Note: excellentRate here is a rough global average. Detailed stats are handled in Reports.
425
- const excellentCount = validScores.filter(s => s.score >= 90).length;
426
- const excellentRate = validScores.length > 0 ? Math.round((excellentCount / validScores.length) * 100) + '%' : '0%';
427
-
428
- res.json({ studentCount, courseCount, avgScore, excellentRate });
429
- } catch(e) { res.status(500).json({}); }
430
  });
431
 
432
  // Frontend
 
22
  // ==========================================
23
 
24
  const InMemoryDB = {
25
+ schools: [],
26
  users: [],
27
  students: [],
28
  courses: [],
 
30
  classes: [],
31
  subjects: [],
32
  exams: [],
33
+ config: {},
 
 
 
 
 
 
34
  isFallback: false
35
  };
36
 
 
42
  console.error('❌ MongoDB 连接失败:', err.message);
43
  console.warn('⚠️ 启动内存数据库模式');
44
  InMemoryDB.isFallback = true;
45
+
46
+ // Init Memory Data
47
+ const defaultSchoolId = 'school_default_' + Date.now();
48
+ InMemoryDB.schools.push({ _id: defaultSchoolId, name: '第一实验小学', code: 'EXP01' });
49
  InMemoryDB.users.push(
50
+ { _id: 'admin_01', username: 'admin', password: 'admin', role: 'ADMIN', status: 'active', schoolId: defaultSchoolId, trueName: '系统管理员' }
51
  );
52
  }
53
  };
54
  connectDB();
55
 
56
+ // --- Schemas ---
57
+
58
+ const SchoolSchema = new mongoose.Schema({
59
+ name: { type: String, required: true },
60
+ code: { type: String, required: true, unique: true }
61
+ });
62
+ const School = mongoose.model('School', SchoolSchema);
63
+
64
  const UserSchema = new mongoose.Schema({
65
  username: { type: String, required: true, unique: true },
66
  password: { type: String, required: true },
67
+ trueName: String,
68
+ phone: String,
69
+ email: String,
70
+ schoolId: String,
71
  role: { type: String, enum: ['ADMIN', 'TEACHER', 'STUDENT'], default: 'STUDENT' },
72
  status: { type: String, enum: ['active', 'pending', 'banned'], default: 'pending' },
 
73
  avatar: String,
74
  createTime: { type: Date, default: Date.now }
75
  });
76
  const User = mongoose.model('User', UserSchema);
77
 
78
  const StudentSchema = new mongoose.Schema({
79
+ schoolId: String,
80
+ studentNo: { type: String, required: true },
81
  name: { type: String, required: true },
82
  gender: { type: String, enum: ['Male', 'Female'], default: 'Male' },
83
  birthday: String,
 
86
  className: String,
87
  status: { type: String, default: 'Enrolled' }
88
  });
89
+ // Composite index for uniqueness within school
90
+ StudentSchema.index({ schoolId: 1, studentNo: 1 }, { unique: true });
91
  const Student = mongoose.model('Student', StudentSchema);
92
 
93
  const CourseSchema = new mongoose.Schema({
94
+ schoolId: String,
95
  courseCode: String,
96
  courseName: String,
97
  teacherName: String,
 
102
  const Course = mongoose.model('Course', CourseSchema);
103
 
104
  const ScoreSchema = new mongoose.Schema({
105
+ schoolId: String,
106
  studentName: String,
107
  studentNo: String,
108
  courseName: String,
 
115
  const Score = mongoose.model('Score', ScoreSchema);
116
 
117
  const ClassSchema = new mongoose.Schema({
118
+ schoolId: String,
119
  grade: String,
120
  className: String,
121
  teacherName: String
 
123
  const ClassModel = mongoose.model('Class', ClassSchema);
124
 
125
  const SubjectSchema = new mongoose.Schema({
126
+ schoolId: String,
127
+ name: { type: String, required: true },
128
  code: String,
129
  color: String,
130
  excellenceThreshold: { type: Number, default: 90 }
 
132
  const SubjectModel = mongoose.model('Subject', SubjectSchema);
133
 
134
  const ExamSchema = new mongoose.Schema({
135
+ schoolId: String,
136
+ name: { type: String, required: true },
137
  date: String,
138
  semester: String
139
  });
140
  const ExamModel = mongoose.model('Exam', ExamSchema);
141
 
142
  const ConfigSchema = new mongoose.Schema({
143
+ schoolId: String,
144
  key: { type: String, default: 'main' },
145
  systemName: String,
146
  semester: String,
 
154
  const initData = async () => {
155
  if (InMemoryDB.isFallback) return;
156
  try {
157
+ // 1. Create Default School if none
158
+ const schoolCount = await School.countDocuments();
159
+ let defaultSchool;
160
+ if (schoolCount === 0) {
161
+ defaultSchool = await School.create({ name: '第一实验小学', code: 'EXP01' });
162
+ console.log('Initialized Default School');
163
+ } else {
164
+ defaultSchool = await School.findOne();
165
+ }
166
+
167
+ // 2. Create Admin
168
  const userCount = await User.countDocuments();
169
  if (userCount === 0) {
170
  await User.create([
171
+ {
172
+ username: 'admin',
173
+ password: 'admin',
174
+ role: 'ADMIN',
175
+ status: 'active',
176
+ schoolId: defaultSchool._id.toString(),
177
+ trueName: '超级管理员',
178
+ email: 'admin@system.com'
179
+ }
180
  ]);
181
  }
182
+
183
+ // 3. Create Default Subjects for this school
184
+ const subjCount = await SubjectModel.countDocuments({ schoolId: defaultSchool._id.toString() });
185
  if (subjCount === 0) {
186
  await SubjectModel.create([
187
+ { schoolId: defaultSchool._id.toString(), name: '语文', code: 'CHI', color: '#ef4444', excellenceThreshold: 90 },
188
+ { schoolId: defaultSchool._id.toString(), name: '数学', code: 'MAT', color: '#3b82f6', excellenceThreshold: 90 },
189
+ { schoolId: defaultSchool._id.toString(), name: '英语', code: 'ENG', color: '#f59e0b', excellenceThreshold: 90 },
190
+ { schoolId: defaultSchool._id.toString(), name: '科学', code: 'SCI', color: '#10b981', excellenceThreshold: 85 }
191
  ]);
192
  }
 
 
 
 
 
193
  } catch (err) {
194
  console.error('Init Data Error', err);
195
  }
196
  };
197
  mongoose.connection.once('open', initData);
198
 
199
+ // ==========================================
200
+ // Helper: Get School Context
201
+ // ==========================================
202
+ const getQueryFilter = (req) => {
203
+ // If we had real JWT middleware, we would extract user from token.
204
+ // For this setup, we rely on the client sending 'x-school-id'.
205
+ // Security Note: In a production app, verify this against the user's token claims.
206
+ const schoolId = req.headers['x-school-id'];
207
+ if (!schoolId) return {}; // If Admin sees all (or specific logic)
208
+ return { schoolId };
209
+ };
210
+
211
+ const injectSchoolId = (req, body) => {
212
+ const schoolId = req.headers['x-school-id'];
213
+ return { ...body, schoolId };
214
+ };
215
+
216
  // ==========================================
217
  // API Routes
218
  // ==========================================
219
 
220
+ // --- Public Routes ---
221
+ app.get('/api/public/schools', async (req, res) => {
222
+ if (InMemoryDB.isFallback) return res.json(InMemoryDB.schools);
223
+ const schools = await School.find({}, 'name code _id');
224
+ res.json(schools);
225
+ });
226
+
227
  // --- Auth ---
228
  app.post('/api/auth/login', async (req, res) => {
229
  const { username, password } = req.body;
 
236
  }
237
 
238
  if (!user) return res.status(401).json({ message: '用户名或密码错误' });
239
+ if (user.status === 'pending') return res.status(403).json({ error: 'PENDING_APPROVAL', message: '账号待审核' });
240
+ if (user.status === 'banned') return res.status(403).json({ error: 'BANNED', message: '账号已被停用' });
 
 
 
 
 
 
241
 
242
  res.json(user);
243
  } catch (e) { res.status(500).json({ error: e.message }); }
244
  });
245
 
246
  app.post('/api/auth/register', async (req, res) => {
247
+ const { username, password, role, schoolId, trueName, phone, email, avatar } = req.body;
248
  const status = 'pending';
249
 
250
  try {
251
  if (InMemoryDB.isFallback) {
252
  if (InMemoryDB.users.find(u => u.username === username)) return res.status(400).json({ error: 'Existed' });
253
+ const newUser = { id: Date.now(), username, password, role, status, schoolId, trueName, phone, email, avatar };
254
  InMemoryDB.users.push(newUser);
255
  return res.json(newUser);
256
  }
 
258
  const existing = await User.findOne({ username });
259
  if (existing) return res.status(400).json({ error: 'Existed' });
260
 
261
+ const newUser = await User.create({ username, password, role, status, schoolId, trueName, phone, email, avatar });
262
  res.json(newUser);
263
  } catch (e) { res.status(500).json({ error: e.message }); }
264
  });
265
 
266
+ // --- Schools (Admin) ---
267
+ app.get('/api/schools', async (req, res) => {
268
+ if (InMemoryDB.isFallback) return res.json(InMemoryDB.schools);
269
+ res.json(await School.find());
270
+ });
271
+ app.post('/api/schools', async (req, res) => {
272
+ if (InMemoryDB.isFallback) {
273
+ const newSchool = { ...req.body, _id: String(Date.now()) };
274
+ InMemoryDB.schools.push(newSchool);
275
+ return res.json(newSchool);
276
+ }
277
+ res.json(await School.create(req.body));
278
+ });
279
+ app.put('/api/schools/:id', async (req, res) => {
280
+ if (InMemoryDB.isFallback) return res.json({success:true});
281
+ await School.findByIdAndUpdate(req.params.id, req.body);
282
+ res.json({success:true});
283
+ });
284
+
285
+ // --- Users ---
286
  app.get('/api/users', async (req, res) => {
287
+ const filter = getQueryFilter(req);
288
+ if (InMemoryDB.isFallback) return res.json(InMemoryDB.users.filter(u => !filter.schoolId || u.schoolId === filter.schoolId));
289
+ const users = await User.find(filter).sort({ createTime: -1 });
290
  res.json(users);
291
  });
292
  app.put('/api/users/:id', async (req, res) => {
293
  try {
294
  if (InMemoryDB.isFallback) {
295
+ const idx = InMemoryDB.users.findIndex(u => u._id == req.params.id);
296
+ if(idx>=0) InMemoryDB.users[idx] = { ...InMemoryDB.users[idx], ...req.body };
297
  return res.json({ success: true });
298
  }
299
  await User.findByIdAndUpdate(req.params.id, req.body);
 
301
  } catch (e) { res.status(500).json({ error: e.message }); }
302
  });
303
  app.delete('/api/users/:id', async (req, res) => {
304
+ if (InMemoryDB.isFallback) {
305
+ InMemoryDB.users = InMemoryDB.users.filter(u => u._id != req.params.id);
306
+ return res.json({ success: true });
307
+ }
308
+ await User.findByIdAndDelete(req.params.id);
309
+ res.json({ success: true });
 
 
310
  });
311
 
312
  // --- Subjects ---
313
  app.get('/api/subjects', async (req, res) => {
314
+ const filter = getQueryFilter(req);
315
+ if (InMemoryDB.isFallback) return res.json(InMemoryDB.subjects.filter(s => !filter.schoolId || s.schoolId === filter.schoolId));
316
+ res.json(await SubjectModel.find(filter));
317
  });
318
  app.post('/api/subjects', async (req, res) => {
319
+ const data = injectSchoolId(req, req.body);
320
+ if (InMemoryDB.isFallback) { InMemoryDB.subjects.push({ ...data, _id: String(Date.now()) }); return res.json(data); }
321
+ res.json(await SubjectModel.create(data));
 
 
 
 
 
 
322
  });
323
  app.put('/api/subjects/:id', async (req, res) => {
324
+ if (InMemoryDB.isFallback) return res.json({success:true});
325
+ await SubjectModel.findByIdAndUpdate(req.params.id, req.body);
326
+ res.json({success:true});
 
 
 
 
 
 
327
  });
328
  app.delete('/api/subjects/:id', async (req, res) => {
329
+ if (InMemoryDB.isFallback) { InMemoryDB.subjects = InMemoryDB.subjects.filter(s => s._id != req.params.id); return res.json({}); }
330
+ await SubjectModel.findByIdAndDelete(req.params.id); res.json({});
 
 
 
 
 
 
331
  });
332
 
333
  // --- Exams ---
334
  app.get('/api/exams', async (req, res) => {
335
+ const filter = getQueryFilter(req);
336
+ if (InMemoryDB.isFallback) return res.json(InMemoryDB.exams.filter(e => !filter.schoolId || e.schoolId === filter.schoolId));
337
+ res.json(await ExamModel.find(filter).sort({ date: 1 }));
338
  });
339
  app.post('/api/exams', async (req, res) => {
340
+ const { name, date, semester } = req.body;
341
+ const schoolId = req.headers['x-school-id'];
342
+ if (InMemoryDB.isFallback) {
343
+ InMemoryDB.exams.push({ name, date, semester, schoolId, _id: String(Date.now()) });
344
+ return res.json({ success: true });
345
+ }
346
+ await ExamModel.findOneAndUpdate({ name, schoolId }, { date, semester, schoolId }, { upsert: true });
347
+ res.json({ success: true });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
348
  });
349
 
350
+ // --- Students ---
351
  app.get('/api/students', async (req, res) => {
352
+ const filter = getQueryFilter(req);
353
+ if (InMemoryDB.isFallback) return res.json(InMemoryDB.students.filter(s => !filter.schoolId || s.schoolId === filter.schoolId));
354
+ res.json(await Student.find(filter).sort({ studentNo: 1 }));
355
  });
356
  app.post('/api/students', async (req, res) => {
357
+ const data = injectSchoolId(req, req.body);
358
+ if (InMemoryDB.isFallback) { InMemoryDB.students.push({ ...data, _id: String(Date.now()) }); return res.json({}); }
359
+ res.json(await Student.create(data));
360
  });
361
  app.delete('/api/students/:id', async (req, res) => {
362
  if (InMemoryDB.isFallback) { InMemoryDB.students = InMemoryDB.students.filter(s => s._id != req.params.id); return res.json({}); }
363
  await Student.findByIdAndDelete(req.params.id); res.json({});
364
  });
365
 
366
+ // --- Classes ---
367
  app.get('/api/classes', async (req, res) => {
368
+ const filter = getQueryFilter(req);
369
+ if (InMemoryDB.isFallback) return res.json(InMemoryDB.classes.filter(c => !filter.schoolId || c.schoolId === filter.schoolId));
370
+
371
+ const classes = await ClassModel.find(filter);
372
  const result = await Promise.all(classes.map(async (c) => {
373
+ const count = await Student.countDocuments({ className: c.grade + c.className, schoolId: filter.schoolId });
374
  return { ...c.toObject(), studentCount: count };
375
  }));
376
  res.json(result);
377
  });
378
  app.post('/api/classes', async (req, res) => {
379
+ const data = injectSchoolId(req, req.body);
380
+ if (InMemoryDB.isFallback) { InMemoryDB.classes.push({ ...data, _id: String(Date.now()) }); return res.json({}); }
381
+ res.json(await ClassModel.create(data));
382
  });
383
  app.delete('/api/classes/:id', async (req, res) => {
384
+ if (InMemoryDB.isFallback) return res.json({});
385
  await ClassModel.findByIdAndDelete(req.params.id); res.json({});
386
  });
387
 
388
+ // --- Courses ---
389
  app.get('/api/courses', async (req, res) => {
390
+ const filter = getQueryFilter(req);
391
+ if (InMemoryDB.isFallback) return res.json(InMemoryDB.courses.filter(c => !filter.schoolId || c.schoolId === filter.schoolId));
392
+ res.json(await Course.find(filter));
393
  });
394
  app.post('/api/courses', async (req, res) => {
395
+ const data = injectSchoolId(req, req.body);
396
+ if (InMemoryDB.isFallback) { InMemoryDB.courses.push({ ...data, _id: String(Date.now()) }); return res.json({}); }
397
+ res.json(await Course.create(data));
398
  });
399
  app.put('/api/courses/:id', async (req, res) => {
400
+ if (InMemoryDB.isFallback) return res.json({});
401
  res.json(await Course.findByIdAndUpdate(req.params.id, req.body));
402
  });
403
  app.delete('/api/courses/:id', async (req, res) => {
404
+ if (InMemoryDB.isFallback) return res.json({});
405
  await Course.findByIdAndDelete(req.params.id); res.json({});
406
  });
407
 
408
+ // --- Scores ---
409
  app.get('/api/scores', async (req, res) => {
410
+ const filter = getQueryFilter(req);
411
+ if (InMemoryDB.isFallback) return res.json(InMemoryDB.scores.filter(s => !filter.schoolId || s.schoolId === filter.schoolId));
412
+ res.json(await Score.find(filter));
413
  });
414
  app.post('/api/scores', async (req, res) => {
415
+ const data = injectSchoolId(req, req.body);
416
+ if (InMemoryDB.isFallback) { InMemoryDB.scores.push({ ...data, _id: String(Date.now()) }); return res.json({}); }
417
+ res.json(await Score.create(data));
418
  });
419
  app.put('/api/scores/:id', async (req, res) => {
420
  if (InMemoryDB.isFallback) {
421
+ const idx = InMemoryDB.scores.findIndex(s => s._id == req.params.id);
422
+ if (idx >= 0) InMemoryDB.scores[idx] = { ...InMemoryDB.scores[idx], ...req.body };
423
  return res.json({ success: true });
424
  }
425
  await Score.findByIdAndUpdate(req.params.id, req.body);
426
  res.json({ success: true });
427
  });
428
  app.delete('/api/scores/:id', async (req, res) => {
429
+ if (InMemoryDB.isFallback) return res.json({});
430
  await Score.findByIdAndDelete(req.params.id); res.json({});
431
  });
432
 
433
+ // --- Stats & Config ---
434
+ app.get('/api/stats', async (req, res) => {
435
+ const filter = getQueryFilter(req);
436
+ // ... (Stats Logic adjusted for filter) ...
437
+ // Keeping simple for now
438
+ if (InMemoryDB.isFallback) return res.json({ studentCount: 0, courseCount: 0, avgScore: 0, excellentRate: '0%' });
439
+ const studentCount = await Student.countDocuments(filter);
440
+ const courseCount = await Course.countDocuments(filter);
441
+ const scores = await Score.find(filter);
442
+ const validScores = scores.filter(s => s.status === 'Normal');
443
+ const totalScore = validScores.reduce((sum, s) => sum + (s.score || 0), 0);
444
+ const avgScore = validScores.length > 0 ? (totalScore / validScores.length).toFixed(1) : 0;
445
+ const excellentCount = validScores.filter(s => s.score >= 90).length;
446
+ const excellentRate = validScores.length > 0 ? Math.round((excellentCount / validScores.length) * 100) + '%' : '0%';
447
+ res.json({ studentCount, courseCount, avgScore, excellentRate });
448
+ });
449
+
450
  app.get('/api/config', async (req, res) => {
451
+ const filter = getQueryFilter(req);
452
  if (InMemoryDB.isFallback) return res.json(InMemoryDB.config);
453
+ res.json(await ConfigModel.findOne({ schoolId: filter.schoolId }) || {});
454
  });
455
  app.post('/api/config', async (req, res) => {
456
+ const data = injectSchoolId(req, req.body);
457
+ if (InMemoryDB.isFallback) { InMemoryDB.config = data; return res.json({}); }
458
+ res.json(await ConfigModel.findOneAndUpdate({ schoolId: data.schoolId }, data, { upsert: true, new: true }));
459
  });
460
 
461
+ app.post('/api/batch-delete', async (req, res) => {
462
+ const { type, ids } = req.body;
463
+ // Note: For batch delete, strict school checking should ideally be here too.
464
+ if (type === 'student') await Student.deleteMany({ _id: { $in: ids } });
465
+ if (type === 'score') await Score.deleteMany({ _id: { $in: ids } });
466
+ if (type === 'user') await User.deleteMany({ _id: { $in: ids } });
467
+ res.json({ success: true });
 
 
 
 
 
 
 
 
 
 
 
 
 
468
  });
469
 
470
  // Frontend
services/api.ts CHANGED
@@ -1,6 +1,6 @@
1
 
2
  /// <reference types="vite/client" />
3
- import { User, ClassInfo, SystemConfig, Subject } from '../types';
4
 
5
  const getBaseUrl = () => {
6
  let isProd = false;
@@ -20,16 +20,28 @@ const getBaseUrl = () => {
20
  const API_BASE_URL = getBaseUrl();
21
 
22
  async function request(endpoint: string, options: RequestInit = {}) {
23
- const res = await fetch(`${API_BASE_URL}${endpoint}`, {
24
- ...options,
25
- headers: { 'Content-Type': 'application/json', ...options.headers },
26
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
27
 
28
  if (!res.ok) {
29
  if (res.status === 401) throw new Error('AUTH_FAILED');
30
  const errorData = await res.json().catch(() => ({}));
31
  const errorMessage = errorData.error || errorData.message || `Server Error: ${res.status}`;
32
- // Pass special error codes
33
  if (errorData.error === 'PENDING_APPROVAL') throw new Error('PENDING_APPROVAL');
34
  if (errorData.error === 'BANNED') throw new Error('BANNED');
35
  throw new Error(errorMessage);
@@ -43,14 +55,21 @@ export const api = {
43
  auth: {
44
  login: async (username: string, password: string): Promise<User> => {
45
  const user = await request('/auth/login', { method: 'POST', body: JSON.stringify({ username, password }) });
46
- if (typeof window !== 'undefined') localStorage.setItem('user', JSON.stringify(user));
 
 
 
 
47
  return user;
48
  },
49
  register: async (data: any): Promise<User> => {
50
  return await request('/auth/register', { method: 'POST', body: JSON.stringify(data) });
51
  },
52
  logout: () => {
53
- if (typeof window !== 'undefined') localStorage.removeItem('user');
 
 
 
54
  },
55
  getCurrentUser: (): User | null => {
56
  if (typeof window !== 'undefined') {
@@ -58,14 +77,20 @@ export const api = {
58
  const stored = localStorage.getItem('user');
59
  if (stored) return JSON.parse(stored);
60
  } catch (e) {
61
- console.error("Error parsing user from localStorage", e);
62
- localStorage.removeItem('user'); // Clean up corrupted data
63
  }
64
  }
65
  return null;
66
  }
67
  },
68
 
 
 
 
 
 
 
 
69
  users: {
70
  getAll: () => request('/users'),
71
  update: (id: string, data: Partial<User>) => request(`/users/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
 
1
 
2
  /// <reference types="vite/client" />
3
+ import { User, ClassInfo, SystemConfig, Subject, School } from '../types';
4
 
5
  const getBaseUrl = () => {
6
  let isProd = false;
 
20
  const API_BASE_URL = getBaseUrl();
21
 
22
  async function request(endpoint: string, options: RequestInit = {}) {
23
+ const headers: any = { 'Content-Type': 'application/json', ...options.headers };
24
+
25
+ // Inject School Context
26
+ if (typeof window !== 'undefined') {
27
+ const currentUser = JSON.parse(localStorage.getItem('user') || 'null');
28
+ const selectedSchoolId = localStorage.getItem('admin_view_school_id');
29
+
30
+ // If admin has selected a specific school, use that
31
+ if (currentUser?.role === 'ADMIN' && selectedSchoolId) {
32
+ headers['x-school-id'] = selectedSchoolId;
33
+ } else if (currentUser?.schoolId) {
34
+ // Otherwise use user's own school
35
+ headers['x-school-id'] = currentUser.schoolId;
36
+ }
37
+ }
38
+
39
+ const res = await fetch(`${API_BASE_URL}${endpoint}`, { ...options, headers });
40
 
41
  if (!res.ok) {
42
  if (res.status === 401) throw new Error('AUTH_FAILED');
43
  const errorData = await res.json().catch(() => ({}));
44
  const errorMessage = errorData.error || errorData.message || `Server Error: ${res.status}`;
 
45
  if (errorData.error === 'PENDING_APPROVAL') throw new Error('PENDING_APPROVAL');
46
  if (errorData.error === 'BANNED') throw new Error('BANNED');
47
  throw new Error(errorMessage);
 
55
  auth: {
56
  login: async (username: string, password: string): Promise<User> => {
57
  const user = await request('/auth/login', { method: 'POST', body: JSON.stringify({ username, password }) });
58
+ if (typeof window !== 'undefined') {
59
+ localStorage.setItem('user', JSON.stringify(user));
60
+ // Reset admin view on login
61
+ localStorage.removeItem('admin_view_school_id');
62
+ }
63
  return user;
64
  },
65
  register: async (data: any): Promise<User> => {
66
  return await request('/auth/register', { method: 'POST', body: JSON.stringify(data) });
67
  },
68
  logout: () => {
69
+ if (typeof window !== 'undefined') {
70
+ localStorage.removeItem('user');
71
+ localStorage.removeItem('admin_view_school_id');
72
+ }
73
  },
74
  getCurrentUser: (): User | null => {
75
  if (typeof window !== 'undefined') {
 
77
  const stored = localStorage.getItem('user');
78
  if (stored) return JSON.parse(stored);
79
  } catch (e) {
80
+ localStorage.removeItem('user');
 
81
  }
82
  }
83
  return null;
84
  }
85
  },
86
 
87
+ schools: {
88
+ getPublic: () => request('/public/schools'),
89
+ getAll: () => request('/schools'),
90
+ add: (data: School) => request('/schools', { method: 'POST', body: JSON.stringify(data) }),
91
+ update: (id: string, data: Partial<School>) => request(`/schools/${id}`, { method: 'PUT', body: JSON.stringify(data) })
92
+ },
93
+
94
  users: {
95
  getAll: () => request('/users'),
96
  update: (id: string, data: Partial<User>) => request(`/users/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
types.ts CHANGED
@@ -12,13 +12,23 @@ export enum UserStatus {
12
  BANNED = 'banned'
13
  }
14
 
 
 
 
 
 
 
 
15
  export interface User {
16
  id?: number;
17
  _id?: string; // MongoDB ID
18
  username: string;
 
 
 
 
19
  role: UserRole;
20
  status: UserStatus;
21
- email: string;
22
  avatar?: string;
23
  createTime?: string;
24
  }
@@ -26,6 +36,7 @@ export interface User {
26
  export interface ClassInfo {
27
  id?: number;
28
  _id?: string;
 
29
  grade: string;
30
  className: string; // e.g., "(1)班"
31
  teacherName?: string;
@@ -35,6 +46,7 @@ export interface ClassInfo {
35
  export interface Subject {
36
  id?: number;
37
  _id?: string;
 
38
  name: string; // e.g. "语文"
39
  code: string; // e.g. "CHI"
40
  color: string; // Hex color for charts
@@ -42,6 +54,7 @@ export interface Subject {
42
  }
43
 
44
  export interface SystemConfig {
 
45
  systemName: string;
46
  semester: string;
47
  allowRegister: boolean;
@@ -52,6 +65,7 @@ export interface SystemConfig {
52
  export interface Student {
53
  id?: number;
54
  _id?: string; // MongoDB ID
 
55
  studentNo: string;
56
  name: string;
57
  gender: 'Male' | 'Female' | 'Other';
@@ -65,6 +79,7 @@ export interface Student {
65
  export interface Course {
66
  id?: number;
67
  _id?: string;
 
68
  courseCode: string;
69
  courseName: string;
70
  teacherName: string;
@@ -78,6 +93,7 @@ export type ExamStatus = 'Normal' | 'Absent' | 'Leave' | 'Cheat';
78
  export interface Score {
79
  id?: number;
80
  _id?: string;
 
81
  studentName: string;
82
  studentNo: string;
83
  courseName: string;
@@ -91,6 +107,7 @@ export interface Score {
91
  export interface Exam {
92
  id?: number;
93
  _id?: string;
 
94
  name: string; // e.g. "期中考试"
95
  date: string; // e.g. "2023-11-01"
96
  semester?: string;
 
12
  BANNED = 'banned'
13
  }
14
 
15
+ export interface School {
16
+ id?: number;
17
+ _id?: string;
18
+ name: string;
19
+ code: string; // unique identifier
20
+ }
21
+
22
  export interface User {
23
  id?: number;
24
  _id?: string; // MongoDB ID
25
  username: string;
26
+ trueName?: string; // New: Real Name
27
+ phone?: string; // New: Phone
28
+ email?: string;
29
+ schoolId?: string; // New: Multi-tenancy
30
  role: UserRole;
31
  status: UserStatus;
 
32
  avatar?: string;
33
  createTime?: string;
34
  }
 
36
  export interface ClassInfo {
37
  id?: number;
38
  _id?: string;
39
+ schoolId?: string; // New
40
  grade: string;
41
  className: string; // e.g., "(1)班"
42
  teacherName?: string;
 
46
  export interface Subject {
47
  id?: number;
48
  _id?: string;
49
+ schoolId?: string; // New
50
  name: string; // e.g. "语文"
51
  code: string; // e.g. "CHI"
52
  color: string; // Hex color for charts
 
54
  }
55
 
56
  export interface SystemConfig {
57
+ schoolId?: string;
58
  systemName: string;
59
  semester: string;
60
  allowRegister: boolean;
 
65
  export interface Student {
66
  id?: number;
67
  _id?: string; // MongoDB ID
68
+ schoolId?: string; // New
69
  studentNo: string;
70
  name: string;
71
  gender: 'Male' | 'Female' | 'Other';
 
79
  export interface Course {
80
  id?: number;
81
  _id?: string;
82
+ schoolId?: string; // New
83
  courseCode: string;
84
  courseName: string;
85
  teacherName: string;
 
93
  export interface Score {
94
  id?: number;
95
  _id?: string;
96
+ schoolId?: string; // New
97
  studentName: string;
98
  studentNo: string;
99
  courseName: string;
 
107
  export interface Exam {
108
  id?: number;
109
  _id?: string;
110
+ schoolId?: string; // New
111
  name: string; // e.g. "期中考试"
112
  date: string; // e.g. "2023-11-01"
113
  semester?: string;