dvc890 commited on
Commit
be82d40
·
verified ·
1 Parent(s): 83c5448

Upload 41 files

Browse files
App.tsx CHANGED
@@ -15,6 +15,7 @@ const UserList = React.lazy(() => import('./pages/UserList').then(module => ({ d
15
  const SchoolList = React.lazy(() => import('./pages/SchoolList').then(module => ({ default: module.SchoolList })));
16
  const Games = React.lazy(() => import('./pages/Games').then(module => ({ default: module.Games })));
17
  const AttendancePage = React.lazy(() => import('./pages/Attendance').then(module => ({ default: module.AttendancePage })));
 
18
 
19
  import { Login } from './pages/Login';
20
  import { User, UserRole } from './types';
@@ -121,6 +122,7 @@ const AppContent: React.FC = () => {
121
  case 'schools': return <SchoolList />;
122
  case 'games': return <Games />;
123
  case 'attendance': return <AttendancePage />;
 
124
  default: return <Dashboard onNavigate={(view) => setCurrentView(view)} />;
125
  }
126
  };
@@ -137,7 +139,8 @@ const AppContent: React.FC = () => {
137
  users: '用户权限管理',
138
  schools: '学校维度管理',
139
  games: '互动教学中心',
140
- attendance: '考勤管理'
 
141
  };
142
 
143
  if (loading) return <div className="min-h-screen flex items-center justify-center bg-gray-50 text-blue-600">加载中...</div>;
@@ -175,4 +178,4 @@ const App: React.FC = () => {
175
  );
176
  };
177
 
178
- export default App;
 
15
  const SchoolList = React.lazy(() => import('./pages/SchoolList').then(module => ({ default: module.SchoolList })));
16
  const Games = React.lazy(() => import('./pages/Games').then(module => ({ default: module.Games })));
17
  const AttendancePage = React.lazy(() => import('./pages/Attendance').then(module => ({ default: module.AttendancePage })));
18
+ const Profile = React.lazy(() => import('./pages/Profile').then(module => ({ default: module.Profile })));
19
 
20
  import { Login } from './pages/Login';
21
  import { User, UserRole } from './types';
 
122
  case 'schools': return <SchoolList />;
123
  case 'games': return <Games />;
124
  case 'attendance': return <AttendancePage />;
125
+ case 'profile': return <Profile />;
126
  default: return <Dashboard onNavigate={(view) => setCurrentView(view)} />;
127
  }
128
  };
 
139
  users: '用户权限管理',
140
  schools: '学校维度管理',
141
  games: '互动教学中心',
142
+ attendance: '考勤管理',
143
+ profile: '个人中心'
144
  };
145
 
146
  if (loading) return <div className="min-h-screen flex items-center justify-center bg-gray-50 text-blue-600">加载中...</div>;
 
178
  );
179
  };
180
 
181
+ export default App;
components/Sidebar.tsx CHANGED
@@ -1,6 +1,6 @@
1
 
2
  import React from 'react';
3
- import { LayoutDashboard, Users, BookOpen, GraduationCap, Settings, LogOut, FileText, School, UserCog, Palette, X, Building, Gamepad2, CalendarCheck } from 'lucide-react';
4
  import { UserRole } from '../types';
5
 
6
  interface SidebarProps {
@@ -27,6 +27,7 @@ export const Sidebar: React.FC<SidebarProps> = ({ currentView, onChangeView, use
27
  { id: 'reports', label: '报表统计', icon: FileText, roles: [UserRole.ADMIN, UserRole.PRINCIPAL, UserRole.TEACHER, UserRole.STUDENT] },
28
  { id: 'subjects', label: '学科设置', icon: Palette, roles: [UserRole.ADMIN, UserRole.PRINCIPAL, UserRole.TEACHER] },
29
  { id: 'users', label: '用户管理', icon: UserCog, roles: [UserRole.ADMIN, UserRole.PRINCIPAL, UserRole.TEACHER] },
 
30
  { id: 'settings', label: '系统设置', icon: Settings, roles: [UserRole.ADMIN, UserRole.PRINCIPAL] },
31
  ];
32
 
 
1
 
2
  import React from 'react';
3
+ import { LayoutDashboard, Users, BookOpen, GraduationCap, Settings, LogOut, FileText, School, UserCog, Palette, X, Building, Gamepad2, CalendarCheck, UserCircle } from 'lucide-react';
4
  import { UserRole } from '../types';
5
 
6
  interface SidebarProps {
 
27
  { id: 'reports', label: '报表统计', icon: FileText, roles: [UserRole.ADMIN, UserRole.PRINCIPAL, UserRole.TEACHER, UserRole.STUDENT] },
28
  { id: 'subjects', label: '学科设置', icon: Palette, roles: [UserRole.ADMIN, UserRole.PRINCIPAL, UserRole.TEACHER] },
29
  { id: 'users', label: '用户管理', icon: UserCog, roles: [UserRole.ADMIN, UserRole.PRINCIPAL, UserRole.TEACHER] },
30
+ { id: 'profile', label: '个人中心', icon: UserCircle, roles: [UserRole.ADMIN, UserRole.PRINCIPAL, UserRole.TEACHER, UserRole.STUDENT] },
31
  { id: 'settings', label: '系统设置', icon: Settings, roles: [UserRole.ADMIN, UserRole.PRINCIPAL] },
32
  ];
33
 
pages/Login.tsx CHANGED
@@ -10,7 +10,10 @@ interface LoginProps {
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
  const gradeOrder: Record<string, number> = {
@@ -315,7 +318,7 @@ export const Login: React.FC<LoginProps> = ({ onLogin }) => {
315
  const renderStep3 = () => (
316
  <div className="space-y-4 animate-in fade-in">
317
  <h3 className="text-center font-bold text-gray-700">第三步: 选择头像</h3>
318
- <div className="grid grid-cols-4 gap-2 max-h-48 overflow-y-auto p-2 border rounded bg-gray-50">
319
  {AVATAR_SEEDS.map(seed => {
320
  const url = `https://api.dicebear.com/7.x/avataaars/svg?seed=${seed}`;
321
  const isSelected = regForm.avatar === url;
 
10
 
11
  const AVATAR_SEEDS = [
12
  'Felix', 'Aneka', 'Zoe', 'Jack', 'Precious', 'Bandit', 'Bubba', 'Cuddles',
13
+ 'Miss%20kitty', 'Loki', 'Sassy', 'Simba', 'Oliver', 'Princess', 'Garfield', 'Salem',
14
+ 'Misty', 'Tigger', 'Bella', 'Pepper', 'Milo', 'Oscar', 'Lily', 'Cookie',
15
+ 'Daisy', 'Molly', 'Jasper', 'George', 'Smokey', 'Pumpkin', 'Bear', 'Sam',
16
+ 'Max', 'Buddy', 'Charlie', 'Rocky'
17
  ];
18
 
19
  const gradeOrder: Record<string, number> = {
 
318
  const renderStep3 = () => (
319
  <div className="space-y-4 animate-in fade-in">
320
  <h3 className="text-center font-bold text-gray-700">第三步: 选择头像</h3>
321
+ <div className="grid grid-cols-4 sm:grid-cols-6 gap-2 max-h-48 overflow-y-auto p-2 border rounded bg-gray-50 custom-scrollbar">
322
  {AVATAR_SEEDS.map(seed => {
323
  const url = `https://api.dicebear.com/7.x/avataaars/svg?seed=${seed}`;
324
  const isSelected = regForm.avatar === url;
pages/Profile.tsx ADDED
@@ -0,0 +1,236 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import React, { useState, useEffect } from 'react';
3
+ import { api } from '../services/api';
4
+ import { User } from '../types';
5
+ import { Save, Lock, User as UserIcon, Camera, Loader2, Link } from 'lucide-react';
6
+
7
+ const AVATAR_PRESETS = [
8
+ 'https://api.dicebear.com/7.x/avataaars/svg?seed=Felix',
9
+ 'https://api.dicebear.com/7.x/avataaars/svg?seed=Aneka',
10
+ 'https://api.dicebear.com/7.x/avataaars/svg?seed=Zoe',
11
+ 'https://api.dicebear.com/7.x/avataaars/svg?seed=Jack',
12
+ 'https://api.dicebear.com/7.x/avataaars/svg?seed=Precious',
13
+ 'https://api.dicebear.com/7.x/avataaars/svg?seed=Bandit',
14
+ 'https://api.dicebear.com/7.x/avataaars/svg?seed=Bubba',
15
+ 'https://api.dicebear.com/7.x/avataaars/svg?seed=Cuddles',
16
+ 'https://api.dicebear.com/7.x/bottts/svg?seed=Ginger',
17
+ 'https://api.dicebear.com/7.x/bottts/svg?seed=Lucky',
18
+ 'https://api.dicebear.com/7.x/bottts/svg?seed=Sassy',
19
+ 'https://api.dicebear.com/7.x/bottts/svg?seed=Loki',
20
+ 'https://api.dicebear.com/7.x/thumbs/svg?seed=Bandit',
21
+ 'https://api.dicebear.com/7.x/thumbs/svg?seed=Miss%20kitty',
22
+ 'https://api.dicebear.com/7.x/thumbs/svg?seed=Garfield',
23
+ 'https://api.dicebear.com/7.x/fun-emoji/svg?seed=Simba',
24
+ 'https://api.dicebear.com/7.x/fun-emoji/svg?seed=Misty',
25
+ 'https://api.dicebear.com/7.x/fun-emoji/svg?seed=Tigger',
26
+ 'https://api.dicebear.com/7.x/adventurer/svg?seed=Oliver',
27
+ 'https://api.dicebear.com/7.x/adventurer/svg?seed=Bella',
28
+ 'https://api.dicebear.com/7.x/adventurer/svg?seed=Pepper',
29
+ 'https://api.dicebear.com/7.x/big-ears/svg?seed=Milo',
30
+ 'https://api.dicebear.com/7.x/big-ears/svg?seed=Oscar',
31
+ 'https://api.dicebear.com/7.x/big-ears/svg?seed=Lily',
32
+ 'https://api.dicebear.com/7.x/micah/svg?seed=Bubba',
33
+ 'https://api.dicebear.com/7.x/micah/svg?seed=Cookie',
34
+ 'https://api.dicebear.com/7.x/micah/svg?seed=Daisy',
35
+ 'https://api.dicebear.com/7.x/notionists/svg?seed=Molly',
36
+ 'https://api.dicebear.com/7.x/notionists/svg?seed=Jasper',
37
+ 'https://api.dicebear.com/7.x/notionists/svg?seed=George',
38
+ 'https://api.dicebear.com/7.x/pixel-art/svg?seed=Smokey',
39
+ 'https://api.dicebear.com/7.x/pixel-art/svg?seed=Pumpkin',
40
+ 'https://api.dicebear.com/7.x/lorelei/svg?seed=Bear',
41
+ 'https://api.dicebear.com/7.x/lorelei/svg?seed=Sam',
42
+ 'https://api.dicebear.com/7.x/open-peeps/svg?seed=Max',
43
+ 'https://api.dicebear.com/7.x/open-peeps/svg?seed=Buddy'
44
+ ];
45
+
46
+ export const Profile: React.FC = () => {
47
+ const [user, setUser] = useState<User | null>(null);
48
+ const [loading, setLoading] = useState(true);
49
+ const [saving, setSaving] = useState(false);
50
+
51
+ // Forms
52
+ const [profileForm, setProfileForm] = useState({ trueName: '', phone: '', avatar: '' });
53
+ const [passwordForm, setPasswordForm] = useState({ currentPassword: '', newPassword: '', confirmPassword: '' });
54
+
55
+ // UI State
56
+ const [customAvatar, setCustomAvatar] = useState('');
57
+ const [activeTab, setActiveTab] = useState<'info' | 'password'>('info');
58
+
59
+ useEffect(() => {
60
+ loadUser();
61
+ }, []);
62
+
63
+ const loadUser = async () => {
64
+ setLoading(true);
65
+ try {
66
+ const u = await api.auth.refreshSession();
67
+ if (u) {
68
+ setUser(u);
69
+ setProfileForm({
70
+ trueName: u.trueName || '',
71
+ phone: u.phone || '',
72
+ avatar: u.avatar || ''
73
+ });
74
+ }
75
+ } catch(e) { console.error(e); }
76
+ finally { setLoading(false); }
77
+ };
78
+
79
+ const handleProfileUpdate = async () => {
80
+ setSaving(true);
81
+ try {
82
+ await api.auth.updateProfile({
83
+ userId: user?._id || user?.id,
84
+ ...profileForm
85
+ });
86
+ alert('个人资料更新成功!');
87
+ loadUser();
88
+ } catch(e: any) { alert('更新失败: ' + e.message); }
89
+ finally { setSaving(false); }
90
+ };
91
+
92
+ const handlePasswordUpdate = async () => {
93
+ if (!passwordForm.currentPassword || !passwordForm.newPassword) return alert('请填写完整');
94
+ if (passwordForm.newPassword !== passwordForm.confirmPassword) return alert('两次输入的新密码不一致');
95
+ if (passwordForm.newPassword.length < 6) return alert('新密码长度不能少于6位');
96
+
97
+ setSaving(true);
98
+ try {
99
+ await api.auth.updateProfile({
100
+ userId: user?._id || user?.id,
101
+ currentPassword: passwordForm.currentPassword,
102
+ newPassword: passwordForm.newPassword
103
+ });
104
+ alert('密码修改成功!下次登录请使用新密码。');
105
+ setPasswordForm({ currentPassword: '', newPassword: '', confirmPassword: '' });
106
+ } catch(e: any) {
107
+ if (e.message === 'INVALID_PASSWORD') alert('当前密码错误,请重试');
108
+ else alert('修改失败: ' + e.message);
109
+ } finally { setSaving(false); }
110
+ };
111
+
112
+ if (loading || !user) return <div className="flex justify-center p-10"><Loader2 className="animate-spin text-blue-600"/></div>;
113
+
114
+ return (
115
+ <div className="max-w-4xl mx-auto space-y-6 animate-in fade-in">
116
+ {/* Header Card */}
117
+ <div className="bg-white rounded-xl p-6 shadow-sm border border-gray-100 flex flex-col md:flex-row items-center gap-6">
118
+ <div className="relative group">
119
+ <img src={profileForm.avatar || user.avatar} alt="Avatar" className="w-24 h-24 rounded-full border-4 border-white shadow-lg object-cover"/>
120
+ <div className="absolute inset-0 bg-black/40 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer text-white text-xs font-bold" onClick={()=>document.getElementById('avatar-section')?.scrollIntoView({behavior:'smooth'})}>
121
+ <Camera size={24}/>
122
+ </div>
123
+ </div>
124
+ <div className="text-center md:text-left flex-1">
125
+ <h2 className="text-2xl font-bold text-gray-800">{user.trueName || user.username}</h2>
126
+ <p className="text-gray-500">@{user.username} | {user.role === 'ADMIN' ? '管理员' : user.role === 'PRINCIPAL' ? '校长' : user.role === 'TEACHER' ? '教师' : '学生'}</p>
127
+ {user.schoolId && <p className="text-xs text-blue-600 bg-blue-50 inline-block px-2 py-1 rounded mt-2">ID: {user.schoolId}</p>}
128
+ </div>
129
+ </div>
130
+
131
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
132
+ {/* Sidebar Tabs */}
133
+ <div className="md:col-span-1 space-y-2">
134
+ <button onClick={()=>setActiveTab('info')} className={`w-full text-left px-4 py-3 rounded-lg flex items-center gap-3 transition-colors ${activeTab==='info' ? 'bg-blue-600 text-white shadow-md' : 'bg-white text-gray-600 hover:bg-gray-50 border border-gray-100'}`}>
135
+ <UserIcon size={18}/> 基本资料 & 头像
136
+ </button>
137
+ <button onClick={()=>setActiveTab('password')} className={`w-full text-left px-4 py-3 rounded-lg flex items-center gap-3 transition-colors ${activeTab==='password' ? 'bg-blue-600 text-white shadow-md' : 'bg-white text-gray-600 hover:bg-gray-50 border border-gray-100'}`}>
138
+ <Lock size={18}/> 安全设置 (修改密码)
139
+ </button>
140
+ </div>
141
+
142
+ {/* Content Area */}
143
+ <div className="md:col-span-2 bg-white rounded-xl shadow-sm border border-gray-100 p-6">
144
+ {activeTab === 'info' && (
145
+ <div className="space-y-6">
146
+ <h3 className="font-bold text-lg text-gray-800 border-b pb-2">基本资料</h3>
147
+ <div className="grid grid-cols-1 gap-4">
148
+ <div>
149
+ <label className="block text-sm font-medium text-gray-700 mb-1">真实姓名</label>
150
+ <input className="w-full border rounded-lg p-2" value={profileForm.trueName} onChange={e=>setProfileForm({...profileForm, trueName:e.target.value})}/>
151
+ </div>
152
+ <div>
153
+ <label className="block text-sm font-medium text-gray-700 mb-1">手机号码</label>
154
+ <input className="w-full border rounded-lg p-2" value={profileForm.phone} onChange={e=>setProfileForm({...profileForm, phone:e.target.value})}/>
155
+ </div>
156
+ </div>
157
+
158
+ <div id="avatar-section" className="pt-4 border-t border-gray-100">
159
+ <h3 className="font-bold text-lg text-gray-800 mb-4">头像设置</h3>
160
+
161
+ {/* Custom URL Input */}
162
+ <div className="mb-4">
163
+ <label className="block text-xs font-bold text-gray-500 uppercase mb-1">使用网络图片链接</label>
164
+ <div className="flex gap-2">
165
+ <div className="relative flex-1">
166
+ <Link size={16} className="absolute left-3 top-3 text-gray-400"/>
167
+ <input
168
+ className="w-full border rounded-lg pl-9 pr-3 py-2 text-sm"
169
+ placeholder="https://example.com/avatar.png"
170
+ value={customAvatar}
171
+ onChange={e=>setCustomAvatar(e.target.value)}
172
+ />
173
+ </div>
174
+ <button
175
+ onClick={() => { if(customAvatar) setProfileForm({...profileForm, avatar: customAvatar}); }}
176
+ className="px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 text-sm font-medium"
177
+ >
178
+ 应用
179
+ </button>
180
+ </div>
181
+ </div>
182
+
183
+ {/* Preset Grid */}
184
+ <label className="block text-xs font-bold text-gray-500 uppercase mb-2">或选择预设头像</label>
185
+ <div className="grid grid-cols-6 sm:grid-cols-8 gap-2 max-h-60 overflow-y-auto p-2 border rounded-lg bg-gray-50">
186
+ {AVATAR_PRESETS.map((url, idx) => (
187
+ <div
188
+ key={idx}
189
+ onClick={() => setProfileForm({...profileForm, avatar: url})}
190
+ className={`cursor-pointer rounded-full p-1 border-2 transition-all ${profileForm.avatar === url ? 'border-blue-500 scale-110 bg-blue-100 shadow-sm' : 'border-transparent hover:bg-white hover:shadow-sm'}`}
191
+ >
192
+ <img src={url} className="w-full h-auto rounded-full" loading="lazy"/>
193
+ </div>
194
+ ))}
195
+ </div>
196
+ </div>
197
+
198
+ <div className="pt-4">
199
+ <button onClick={handleProfileUpdate} disabled={saving} className="bg-blue-600 text-white px-6 py-2 rounded-lg font-bold hover:bg-blue-700 disabled:opacity-50 flex items-center gap-2">
200
+ {saving ? <Loader2 className="animate-spin" size={18}/> : <Save size={18}/>}
201
+ 保存修改
202
+ </button>
203
+ </div>
204
+ </div>
205
+ )}
206
+
207
+ {activeTab === 'password' && (
208
+ <div className="space-y-6">
209
+ <h3 className="font-bold text-lg text-gray-800 border-b pb-2">修改密码</h3>
210
+ <div className="space-y-4 max-w-md">
211
+ <div>
212
+ <label className="block text-sm font-medium text-gray-700 mb-1">当前密码</label>
213
+ <input type="password" className="w-full border rounded-lg p-2" value={passwordForm.currentPassword} onChange={e=>setPasswordForm({...passwordForm, currentPassword:e.target.value})}/>
214
+ </div>
215
+ <div>
216
+ <label className="block text-sm font-medium text-gray-700 mb-1">新密码 (至少6位)</label>
217
+ <input type="password" className="w-full border rounded-lg p-2" value={passwordForm.newPassword} onChange={e=>setPasswordForm({...passwordForm, newPassword:e.target.value})}/>
218
+ </div>
219
+ <div>
220
+ <label className="block text-sm font-medium text-gray-700 mb-1">确认新密码</label>
221
+ <input type="password" className="w-full border rounded-lg p-2" value={passwordForm.confirmPassword} onChange={e=>setPasswordForm({...passwordForm, confirmPassword:e.target.value})}/>
222
+ </div>
223
+ <div className="pt-2">
224
+ <button onClick={handlePasswordUpdate} disabled={saving} className="bg-blue-600 text-white px-6 py-2 rounded-lg font-bold hover:bg-blue-700 disabled:opacity-50 flex items-center gap-2">
225
+ {saving ? <Loader2 className="animate-spin" size={18}/> : <Save size={18}/>}
226
+ 确认修改
227
+ </button>
228
+ </div>
229
+ </div>
230
+ </div>
231
+ )}
232
+ </div>
233
+ </div>
234
+ </div>
235
+ );
236
+ };
pages/StudentReports.tsx CHANGED
@@ -11,6 +11,7 @@ import { Score, Student, Subject, Exam } from '../types';
11
  // Custom Tick for XAxis to highlight exams
12
  const CustomizedAxisTick = (props: any) => {
13
  const { x, y, payload, exams } = props;
 
14
  const examInfo = exams.find((e: any) => (e.name || e.type) === payload.value);
15
  const isMajor = examInfo?.type === 'Midterm' || examInfo?.type === 'Final' || payload.value.includes('期中') || payload.value.includes('期末');
16
 
 
11
  // Custom Tick for XAxis to highlight exams
12
  const CustomizedAxisTick = (props: any) => {
13
  const { x, y, payload, exams } = props;
14
+ if (!payload || !payload.value) return null;
15
  const examInfo = exams.find((e: any) => (e.name || e.type) === payload.value);
16
  const isMajor = examInfo?.type === 'Midterm' || examInfo?.type === 'Final' || payload.value.includes('期中') || payload.value.includes('期末');
17
 
server.js CHANGED
@@ -104,6 +104,50 @@ app.get('/api/auth/me', async (req, res) => {
104
  res.json(user);
105
  });
106
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
  app.post('/api/auth/register', async (req, res) => {
108
  const { role, username, password, schoolId, trueName, seatNo } = req.body;
109
  const className = req.body.className || req.body.homeroomClass;
 
104
  res.json(user);
105
  });
106
 
107
+ app.post('/api/auth/update-profile', async (req, res) => {
108
+ const { userId, trueName, phone, avatar, currentPassword, newPassword } = req.body;
109
+
110
+ try {
111
+ const user = await User.findById(userId);
112
+ if (!user) return res.status(404).json({ error: 'User not found' });
113
+
114
+ // If changing password, verify old one
115
+ if (newPassword) {
116
+ if (user.password !== currentPassword) {
117
+ return res.status(401).json({ error: 'INVALID_PASSWORD', message: '旧密码错误' });
118
+ }
119
+ user.password = newPassword;
120
+ }
121
+
122
+ if (trueName) user.trueName = trueName;
123
+ if (phone) user.phone = phone;
124
+ if (avatar) user.avatar = avatar;
125
+
126
+ await user.save();
127
+
128
+ // If user is a student, sync profile data to Student collection
129
+ if (user.role === 'STUDENT') {
130
+ await Student.findOneAndUpdate(
131
+ { studentNo: user.studentNo },
132
+ { name: user.trueName || user.username, phone: user.phone }
133
+ );
134
+ }
135
+ // If user is a teacher, sync name to Class collection
136
+ if (user.role === 'TEACHER' && user.homeroomClass) {
137
+ const cls = await ClassModel.findOne({ schoolId: user.schoolId, $expr: { $eq: [{ $concat: ["$grade", "$className"] }, user.homeroomClass] } });
138
+ if (cls) {
139
+ cls.teacherName = user.trueName || user.username;
140
+ await cls.save();
141
+ }
142
+ }
143
+
144
+ res.json({ success: true, user });
145
+ } catch (e) {
146
+ console.error(e);
147
+ res.status(500).json({ error: e.message });
148
+ }
149
+ });
150
+
151
  app.post('/api/auth/register', async (req, res) => {
152
  const { role, username, password, schoolId, trueName, seatNo } = req.body;
153
  const className = req.body.className || req.body.homeroomClass;
services/api.ts CHANGED
@@ -47,6 +47,7 @@ async function request(endpoint: string, options: RequestInit = {}) {
47
  if (errorData.error === 'PENDING_APPROVAL') throw new Error('PENDING_APPROVAL');
48
  if (errorData.error === 'BANNED') throw new Error('BANNED');
49
  if (errorData.error === 'CONFLICT') throw new Error(errorData.message);
 
50
  throw new Error(errorMessage);
51
  }
52
  return res.json();
@@ -76,6 +77,9 @@ export const api = {
76
  register: async (data: any): Promise<User> => {
77
  return await request('/auth/register', { method: 'POST', body: JSON.stringify(data) });
78
  },
 
 
 
79
  logout: () => {
80
  if (typeof window !== 'undefined') {
81
  localStorage.removeItem('user');
@@ -228,4 +232,4 @@ export const api = {
228
  batchDelete: (type: 'student' | 'score' | 'user', ids: string[]) => {
229
  return request('/batch-delete', { method: 'POST', body: JSON.stringify({ type, ids }) });
230
  }
231
- };
 
47
  if (errorData.error === 'PENDING_APPROVAL') throw new Error('PENDING_APPROVAL');
48
  if (errorData.error === 'BANNED') throw new Error('BANNED');
49
  if (errorData.error === 'CONFLICT') throw new Error(errorData.message);
50
+ if (errorData.error === 'INVALID_PASSWORD') throw new Error('INVALID_PASSWORD');
51
  throw new Error(errorMessage);
52
  }
53
  return res.json();
 
77
  register: async (data: any): Promise<User> => {
78
  return await request('/auth/register', { method: 'POST', body: JSON.stringify(data) });
79
  },
80
+ updateProfile: async (data: any): Promise<any> => {
81
+ return await request('/auth/update-profile', { method: 'POST', body: JSON.stringify(data) });
82
+ },
83
  logout: () => {
84
  if (typeof window !== 'undefined') {
85
  localStorage.removeItem('user');
 
232
  batchDelete: (type: 'student' | 'score' | 'user', ids: string[]) => {
233
  return request('/batch-delete', { method: 'POST', body: JSON.stringify({ type, ids }) });
234
  }
235
+ };