dvc890 commited on
Commit
ecc1164
·
verified ·
1 Parent(s): d252532

Upload 37 files

Browse files
pages/AchievementTeacher.tsx CHANGED
@@ -46,8 +46,12 @@ export const AchievementTeacher: React.FC = () => {
46
  api.config.getPublic() as Promise<SystemConfig>
47
  ]);
48
 
49
- // Filter students for homeroom
50
- setStudents(stus.filter((s: Student) => s.className === homeroomClass));
 
 
 
 
51
 
52
  setConfig(cfg || {
53
  schoolId: currentUser?.schoolId || '',
@@ -335,4 +339,4 @@ export const AchievementTeacher: React.FC = () => {
335
  </div>
336
  </div>
337
  );
338
- };
 
46
  api.config.getPublic() as Promise<SystemConfig>
47
  ]);
48
 
49
+ // Filter students for homeroom & Sort by name
50
+ const sortedStudents = stus
51
+ .filter((s: Student) => s.className === homeroomClass)
52
+ .sort((a: Student, b: Student) => a.name.localeCompare(b.name, 'zh-CN'));
53
+
54
+ setStudents(sortedStudents);
55
 
56
  setConfig(cfg || {
57
  schoolId: currentUser?.schoolId || '',
 
339
  </div>
340
  </div>
341
  );
342
+ };
pages/ClassList.tsx CHANGED
@@ -1,15 +1,18 @@
1
 
2
  import React, { useState, useEffect } from 'react';
3
- import { Plus, Trash2, Users, School, Loader2, User as UserIcon } from 'lucide-react';
4
  import { api } from '../services/api';
5
  import { ClassInfo, User } from '../types';
6
 
7
  export const ClassList: React.FC = () => {
8
  const [classes, setClasses] = useState<ClassInfo[]>([]);
9
  const [teachers, setTeachers] = useState<User[]>([]);
 
10
  const [loading, setLoading] = useState(true);
 
11
  const [isModalOpen, setIsModalOpen] = useState(false);
12
  const [submitting, setSubmitting] = useState(false);
 
13
 
14
  // Form
15
  const [grade, setGrade] = useState('一年级');
@@ -28,10 +31,15 @@ export const ClassList: React.FC = () => {
28
  try {
29
  const [clsData, userData] = await Promise.all([
30
  api.classes.getAll(),
31
- api.users.getAll({ role: 'TEACHER' })
32
  ]);
33
  setClasses(clsData);
34
  setTeachers(userData);
 
 
 
 
 
35
  } catch (e) {
36
  console.error(e);
37
  } finally {
@@ -50,27 +58,113 @@ export const ClassList: React.FC = () => {
50
  }
51
  };
52
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  const handleSubmit = async (e: React.FormEvent) => {
54
  e.preventDefault();
55
  setSubmitting(true);
56
  try {
57
- await api.classes.add({ grade, className, teacherName });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
  setIsModalOpen(false);
59
- setTeacherName('');
60
  loadData();
61
  } catch (e) {
62
- alert('添加失败');
63
  } finally {
64
  setSubmitting(false);
65
  }
66
  };
67
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
  return (
69
  <div className="space-y-6">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
  <div className="flex justify-between items-center">
71
  <h2 className="text-xl font-bold text-gray-800">班级管理</h2>
72
  <button
73
- onClick={() => setIsModalOpen(true)}
74
  className="flex items-center space-x-2 px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 shadow-sm"
75
  >
76
  <Plus size={16} />
@@ -83,7 +177,7 @@ export const ClassList: React.FC = () => {
83
  ) : (
84
  <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
85
  {classes.map((cls) => (
86
- <div key={cls._id || cls.id} className="bg-white rounded-xl shadow-sm border border-gray-100 p-6 flex flex-col justify-between hover:shadow-md transition-shadow">
87
  <div className="flex justify-between items-start">
88
  <div className="flex items-center space-x-3">
89
  <div className="p-3 bg-blue-50 text-blue-600 rounded-lg">
@@ -91,20 +185,32 @@ export const ClassList: React.FC = () => {
91
  </div>
92
  <div>
93
  <h3 className="text-lg font-bold text-gray-800">{cls.grade}{cls.className}</h3>
94
- <p className="text-sm text-gray-500 flex items-center">
95
- <UserIcon size={12} className="mr-1"/>
96
  {cls.teacherName ? (
97
- <span className="text-blue-600 font-bold">{cls.teacherName}</span>
98
- ) : '未任命'}
 
 
99
  </p>
100
  </div>
101
  </div>
102
- <button
103
- onClick={() => handleDelete(cls._id || cls.id!)}
104
- className="text-gray-400 hover:text-red-500 transition-colors"
105
- >
106
- <Trash2 size={18} />
107
- </button>
 
 
 
 
 
 
 
 
 
 
108
  </div>
109
 
110
  <div className="mt-6 pt-4 border-t border-gray-50 flex items-center justify-between text-sm">
@@ -120,13 +226,13 @@ export const ClassList: React.FC = () => {
120
 
121
  {/* Modal */}
122
  {isModalOpen && (
123
- <div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
124
- <div className="bg-white rounded-xl shadow-xl max-w-sm w-full p-6">
125
- <h3 className="text-lg font-bold text-gray-800 mb-4">新增班级</h3>
126
  <form onSubmit={handleSubmit} className="space-y-4">
127
  <div>
128
  <label className="text-sm font-medium text-gray-700">年级</label>
129
- <select value={grade} onChange={e => setGrade(e.target.value)} className="w-full mt-1 px-3 py-2 border rounded-lg max-h-40">
130
  {grades.map(g => <option key={g} value={g}>{g}</option>)}
131
  </select>
132
  </div>
@@ -136,9 +242,9 @@ export const ClassList: React.FC = () => {
136
  </div>
137
  <div>
138
  <label className="text-sm font-medium text-gray-700">指定班主任</label>
139
- <select value={teacherName} onChange={e => setTeacherName(e.target.value)} className="w-full mt-1 px-3 py-2 border rounded-lg">
140
  <option value="">-- 暂不指定 --</option>
141
- {teachers.map(t => (
142
  <option key={t._id} value={t.trueName || t.username}>
143
  {t.trueName || t.username} {t.homeroomClass ? `(已任: ${t.homeroomClass})` : ''}
144
  </option>
@@ -147,9 +253,9 @@ export const ClassList: React.FC = () => {
147
  <p className="text-xs text-gray-500 mt-1">注意:如果该老师已是其他班班主任,将会被重新任命为本班班主任。</p>
148
  </div>
149
  <div className="flex space-x-3 pt-4">
150
- <button type="button" onClick={() => setIsModalOpen(false)} className="flex-1 py-2 border rounded-lg">取消</button>
151
- <button type="submit" disabled={submitting} className="flex-1 py-2 bg-blue-600 text-white rounded-lg">
152
- {submitting ? '提交中...' : '创建'}
153
  </button>
154
  </div>
155
  </form>
 
1
 
2
  import React, { useState, useEffect } from 'react';
3
+ import { Plus, Trash2, Users, School, Loader2, User as UserIcon, Edit, Check, X, ShieldAlert } from 'lucide-react';
4
  import { api } from '../services/api';
5
  import { ClassInfo, User } from '../types';
6
 
7
  export const ClassList: React.FC = () => {
8
  const [classes, setClasses] = useState<ClassInfo[]>([]);
9
  const [teachers, setTeachers] = useState<User[]>([]);
10
+ const [pendingApps, setPendingApps] = useState<User[]>([]);
11
  const [loading, setLoading] = useState(true);
12
+
13
  const [isModalOpen, setIsModalOpen] = useState(false);
14
  const [submitting, setSubmitting] = useState(false);
15
+ const [editClassId, setEditClassId] = useState<string | null>(null);
16
 
17
  // Form
18
  const [grade, setGrade] = useState('一年级');
 
31
  try {
32
  const [clsData, userData] = await Promise.all([
33
  api.classes.getAll(),
34
+ api.users.getAll({ role: 'TEACHER', global: true }) // Get all users to check apps
35
  ]);
36
  setClasses(clsData);
37
  setTeachers(userData);
38
+
39
+ // Filter users with pending class applications
40
+ const apps = userData.filter((u: User) => u.classApplication && u.classApplication.status === 'PENDING');
41
+ setPendingApps(apps);
42
+
43
  } catch (e) {
44
  console.error(e);
45
  } finally {
 
58
  }
59
  };
60
 
61
+ const openAddModal = () => {
62
+ setEditClassId(null);
63
+ setGrade('一年级');
64
+ setClassName('(1)班');
65
+ setTeacherName('');
66
+ setIsModalOpen(true);
67
+ };
68
+
69
+ const openEditModal = (cls: ClassInfo) => {
70
+ setEditClassId(cls._id || String(cls.id));
71
+ setGrade(cls.grade);
72
+ setClassName(cls.className);
73
+ setTeacherName(cls.teacherName || '');
74
+ setIsModalOpen(true);
75
+ };
76
+
77
  const handleSubmit = async (e: React.FormEvent) => {
78
  e.preventDefault();
79
  setSubmitting(true);
80
  try {
81
+ const payload = { grade, className, teacherName };
82
+ if (editClassId) {
83
+ // Update
84
+ // Use a direct fetch or update api method to handle PUT
85
+ // The api.classes.add uses POST, we need a custom call or add method update
86
+ await fetch(`/api/classes/${editClassId}`, {
87
+ method: 'PUT',
88
+ headers: {
89
+ 'Content-Type': 'application/json',
90
+ 'x-school-id': localStorage.getItem('admin_view_school_id') || ''
91
+ },
92
+ body: JSON.stringify(payload)
93
+ });
94
+ } else {
95
+ await api.classes.add(payload);
96
+ }
97
  setIsModalOpen(false);
 
98
  loadData();
99
  } catch (e) {
100
+ alert('操作失败');
101
  } finally {
102
  setSubmitting(false);
103
  }
104
  };
105
 
106
+ const handleApplicationReview = async (user: User, action: 'APPROVE'|'REJECT') => {
107
+ if (!user.classApplication) return;
108
+ const typeText = user.classApplication.type === 'CLAIM' ? '任教' : '卸任';
109
+ if (!confirm(`确认${action === 'APPROVE' ? '同意' : '拒绝'} ${user.trueName || user.username} 的${typeText}申请?`)) return;
110
+ try {
111
+ await api.users.applyClass({
112
+ userId: user._id!,
113
+ type: user.classApplication.type,
114
+ targetClass: user.classApplication.targetClass,
115
+ action
116
+ });
117
+ loadData();
118
+ } catch(e) { alert('操作失败'); }
119
+ };
120
+
121
  return (
122
  <div className="space-y-6">
123
+
124
+ {/* Pending Approvals Section */}
125
+ {pendingApps.length > 0 && (
126
+ <div className="bg-amber-50 border border-amber-200 rounded-xl p-6 animate-in fade-in slide-in-from-top-4">
127
+ <h3 className="text-lg font-bold text-amber-800 mb-4 flex items-center">
128
+ <ShieldAlert className="mr-2"/> 待审核的班主任任免申请
129
+ </h3>
130
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
131
+ {pendingApps.map(appUser => (
132
+ <div key={appUser._id} className="bg-white p-4 rounded-lg shadow-sm border border-amber-100 flex flex-col gap-2">
133
+ <div className="flex items-center gap-3 mb-2">
134
+ <div className="w-10 h-10 rounded-full bg-gray-100 flex items-center justify-center text-lg">
135
+ {appUser.trueName?.[0] || appUser.username[0]}
136
+ </div>
137
+ <div>
138
+ <div className="font-bold text-gray-800">{appUser.trueName || appUser.username}</div>
139
+ <div className="text-xs text-gray-500">@{appUser.username}</div>
140
+ </div>
141
+ </div>
142
+ <div className="text-sm bg-gray-50 p-2 rounded">
143
+ <span className="font-bold text-gray-700">申请类型:</span>
144
+ {appUser.classApplication?.type === 'CLAIM' ? (
145
+ <span className="text-blue-600 font-bold">申请成为 {appUser.classApplication.targetClass} 班主任</span>
146
+ ) : (
147
+ <span className="text-red-600 font-bold">申请卸任 {appUser.homeroomClass} 班主任</span>
148
+ )}
149
+ </div>
150
+ <div className="flex gap-2 mt-2">
151
+ <button onClick={() => handleApplicationReview(appUser, 'APPROVE')} className="flex-1 bg-green-500 hover:bg-green-600 text-white py-1.5 rounded text-sm font-medium flex items-center justify-center">
152
+ <Check size={16} className="mr-1"/> 同意
153
+ </button>
154
+ <button onClick={() => handleApplicationReview(appUser, 'REJECT')} className="flex-1 bg-red-400 hover:bg-red-500 text-white py-1.5 rounded text-sm font-medium flex items-center justify-center">
155
+ <X size={16} className="mr-1"/> 拒绝
156
+ </button>
157
+ </div>
158
+ </div>
159
+ ))}
160
+ </div>
161
+ </div>
162
+ )}
163
+
164
  <div className="flex justify-between items-center">
165
  <h2 className="text-xl font-bold text-gray-800">班级管理</h2>
166
  <button
167
+ onClick={openAddModal}
168
  className="flex items-center space-x-2 px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 shadow-sm"
169
  >
170
  <Plus size={16} />
 
177
  ) : (
178
  <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
179
  {classes.map((cls) => (
180
+ <div key={cls._id || cls.id} className="bg-white rounded-xl shadow-sm border border-gray-100 p-6 flex flex-col justify-between hover:shadow-md transition-shadow group">
181
  <div className="flex justify-between items-start">
182
  <div className="flex items-center space-x-3">
183
  <div className="p-3 bg-blue-50 text-blue-600 rounded-lg">
 
185
  </div>
186
  <div>
187
  <h3 className="text-lg font-bold text-gray-800">{cls.grade}{cls.className}</h3>
188
+ <p className="text-sm text-gray-500 flex items-center mt-1">
189
+ <UserIcon size={14} className="mr-1"/>
190
  {cls.teacherName ? (
191
+ <span className="text-blue-600 font-bold bg-blue-50 px-1.5 py-0.5 rounded text-xs">{cls.teacherName}</span>
192
+ ) : (
193
+ <span className="text-gray-400 text-xs">未任命班主任</span>
194
+ )}
195
  </p>
196
  </div>
197
  </div>
198
+ <div className="flex gap-1 opacity-100 md:opacity-0 group-hover:opacity-100 transition-opacity">
199
+ <button
200
+ onClick={() => openEditModal(cls)}
201
+ className="p-1.5 text-gray-400 hover:text-blue-500 hover:bg-blue-50 rounded transition-colors"
202
+ title="编辑班级信息"
203
+ >
204
+ <Edit size={18} />
205
+ </button>
206
+ <button
207
+ onClick={() => handleDelete(cls._id || cls.id!)}
208
+ className="p-1.5 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded transition-colors"
209
+ title="删除班级"
210
+ >
211
+ <Trash2 size={18} />
212
+ </button>
213
+ </div>
214
  </div>
215
 
216
  <div className="mt-6 pt-4 border-t border-gray-50 flex items-center justify-between text-sm">
 
226
 
227
  {/* Modal */}
228
  {isModalOpen && (
229
+ <div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 backdrop-blur-sm">
230
+ <div className="bg-white rounded-xl shadow-xl max-w-sm w-full p-6 animate-in zoom-in-95">
231
+ <h3 className="text-lg font-bold text-gray-800 mb-4">{editClassId ? '编辑班级 / 任命班主任' : '新增班级'}</h3>
232
  <form onSubmit={handleSubmit} className="space-y-4">
233
  <div>
234
  <label className="text-sm font-medium text-gray-700">年级</label>
235
+ <select value={grade} onChange={e => setGrade(e.target.value)} className="w-full mt-1 px-3 py-2 border rounded-lg max-h-40 bg-white">
236
  {grades.map(g => <option key={g} value={g}>{g}</option>)}
237
  </select>
238
  </div>
 
242
  </div>
243
  <div>
244
  <label className="text-sm font-medium text-gray-700">指定班主任</label>
245
+ <select value={teacherName} onChange={e => setTeacherName(e.target.value)} className="w-full mt-1 px-3 py-2 border rounded-lg bg-white">
246
  <option value="">-- 暂不指定 --</option>
247
+ {teachers.sort((a,b)=>(a.trueName||a.username).localeCompare(b.trueName||b.username, 'zh-CN')).map(t => (
248
  <option key={t._id} value={t.trueName || t.username}>
249
  {t.trueName || t.username} {t.homeroomClass ? `(已任: ${t.homeroomClass})` : ''}
250
  </option>
 
253
  <p className="text-xs text-gray-500 mt-1">注意:如果该老师已是其他班班主任,将会被重新任命为本班班主任。</p>
254
  </div>
255
  <div className="flex space-x-3 pt-4">
256
+ <button type="button" onClick={() => setIsModalOpen(false)} className="flex-1 py-2 border rounded-lg hover:bg-gray-50">取消</button>
257
+ <button type="submit" disabled={submitting} className="flex-1 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 shadow-md">
258
+ {submitting ? '提交中...' : '保存'}
259
  </button>
260
  </div>
261
  </form>
pages/GameRewards.tsx CHANGED
@@ -66,7 +66,8 @@ export const GameRewards: React.FC = () => {
66
 
67
  setRewards(res.list || []);
68
  setTotal(res.total || 0);
69
- setStudents(filteredStudents);
 
70
  }
71
  } catch (e) { console.error(e); }
72
  finally { setLoading(false); }
 
66
 
67
  setRewards(res.list || []);
68
  setTotal(res.total || 0);
69
+ // Sort students for dropdown by name (pinyin)
70
+ setStudents(filteredStudents.sort((a: Student, b: Student) => a.name.localeCompare(b.name, 'zh-CN')));
71
  }
72
  } catch (e) { console.error(e); }
73
  finally { setLoading(false); }
pages/StudentList.tsx CHANGED
@@ -52,7 +52,8 @@ export const StudentList: React.FC = () => {
52
  api.students.getAll(),
53
  api.classes.getAll()
54
  ]);
55
- setStudents(studentData);
 
56
  setClassList(classData);
57
  } catch (error) {
58
  console.error(error);
@@ -323,8 +324,8 @@ export const StudentList: React.FC = () => {
323
  />
324
  )}
325
  </th>
326
- <th className="px-6 py-3">基本信息</th>
327
  <th className="px-6 py-3">学号</th>
 
328
  <th className="px-6 py-3">班级</th>
329
  <th className="px-6 py-3">家长/住址</th>
330
  <th className="px-6 py-3 text-right">操作</th>
@@ -338,6 +339,7 @@ export const StudentList: React.FC = () => {
338
  <td className="px-6 py-4">
339
  {canEdit && <input type="checkbox" checked={selectedIds.has(s._id || String(s.id))} onChange={() => toggleSelect(s._id || String(s.id))} />}
340
  </td>
 
341
  <td className="px-6 py-4 flex items-center space-x-3">
342
  <div className={`w-8 h-8 rounded-full flex items-center justify-center text-xs text-white ${s.gender==='Male'?'bg-blue-400':'bg-pink-400'}`}>{s.name[0]}</div>
343
  <div>
@@ -345,7 +347,6 @@ export const StudentList: React.FC = () => {
345
  <div className="text-xs text-gray-400">{s.idCard || '身份证未录入'}</div>
346
  </div>
347
  </td>
348
- <td className="px-6 py-4 text-sm font-mono text-gray-600">{s.studentNo}</td>
349
  <td className="px-6 py-4 text-sm">
350
  <span className="bg-gray-100 px-2 py-1 rounded text-gray-600 text-xs">{s.className}</span>
351
  </td>
 
52
  api.students.getAll(),
53
  api.classes.getAll()
54
  ]);
55
+ // Sort students by studentNo by default
56
+ setStudents(studentData.sort((a: Student, b: Student) => a.studentNo.localeCompare(b.studentNo, undefined, {numeric: true})));
57
  setClassList(classData);
58
  } catch (error) {
59
  console.error(error);
 
324
  />
325
  )}
326
  </th>
 
327
  <th className="px-6 py-3">学号</th>
328
+ <th className="px-6 py-3">姓名/性别</th>
329
  <th className="px-6 py-3">班级</th>
330
  <th className="px-6 py-3">家长/住址</th>
331
  <th className="px-6 py-3 text-right">操作</th>
 
339
  <td className="px-6 py-4">
340
  {canEdit && <input type="checkbox" checked={selectedIds.has(s._id || String(s.id))} onChange={() => toggleSelect(s._id || String(s.id))} />}
341
  </td>
342
+ <td className="px-6 py-4 text-sm font-mono text-gray-600">{s.studentNo}</td>
343
  <td className="px-6 py-4 flex items-center space-x-3">
344
  <div className={`w-8 h-8 rounded-full flex items-center justify-center text-xs text-white ${s.gender==='Male'?'bg-blue-400':'bg-pink-400'}`}>{s.name[0]}</div>
345
  <div>
 
347
  <div className="text-xs text-gray-400">{s.idCard || '身份证未录入'}</div>
348
  </div>
349
  </td>
 
350
  <td className="px-6 py-4 text-sm">
351
  <span className="bg-gray-100 px-2 py-1 rounded text-gray-600 text-xs">{s.className}</span>
352
  </td>
pages/UserList.tsx CHANGED
@@ -2,7 +2,7 @@
2
  import React, { useState, useEffect } from 'react';
3
  import { User, UserRole, UserStatus, School, ClassInfo } from '../types';
4
  import { api } from '../services/api';
5
- import { Loader2, Check, X, Trash2, Building, Edit, Shield, Briefcase, GraduationCap, MinusCircle } from 'lucide-react';
6
 
7
  export const UserList: React.FC = () => {
8
  const [users, setUsers] = useState<User[]>([]);
@@ -89,21 +89,6 @@ export const UserList: React.FC = () => {
89
  } catch(e) { alert('提交失败'); }
90
  };
91
 
92
- const handleApplicationReview = async (user: User, action: 'APPROVE'|'REJECT') => {
93
- if (!user.classApplication) return;
94
- if (!confirm(`确认${action === 'APPROVE' ? '同意' : '拒绝'}该申请?`)) return;
95
- try {
96
- await api.users.applyClass({
97
- userId: user._id!,
98
- type: user.classApplication.type,
99
- targetClass: user.classApplication.targetClass,
100
- action
101
- });
102
- loadData();
103
- } catch(e) { alert('操作失败'); }
104
- };
105
-
106
-
107
  const getSchoolName = (id?: string) => schools.find(s => s._id === id)?.name || '未分配';
108
 
109
  const filteredUsers = users.filter(user => {
@@ -121,7 +106,7 @@ export const UserList: React.FC = () => {
121
  <div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6">
122
  <div className="flex justify-between items-center mb-4">
123
  <div className="flex items-center gap-4">
124
- <h2 className="text-xl font-bold text-gray-800">用户权限与审核</h2>
125
  {isTeacher && (
126
  <div className="flex items-center gap-2">
127
  {currentUser.homeroomClass ? (
@@ -138,7 +123,6 @@ export const UserList: React.FC = () => {
138
  )}
139
  </div>
140
  {isAdmin && <span className="text-xs text-gray-500 bg-gray-100 px-2 py-1 rounded">全局视图</span>}
141
- {isTeacher && <span className="text-xs text-blue-500 bg-blue-50 px-2 py-1 rounded border border-blue-100">仅显示 {currentUser.homeroomClass || '我班'} 学生</span>}
142
  </div>
143
 
144
  {filteredUsers.length === 0 ? (
@@ -152,14 +136,13 @@ export const UserList: React.FC = () => {
152
  <th className="px-4 py-3">详细信息</th>
153
  <th className="px-4 py-3">所属学校</th>
154
  <th className="px-4 py-3">角色/班级</th>
155
- <th className="px-4 py-3">状态/申请</th>
156
  <th className="px-4 py-3 text-right">操作</th>
157
  </tr>
158
  </thead>
159
  <tbody className="divide-y divide-gray-100">
160
  {filteredUsers.map(user => {
161
  const isSelf = !!(currentUser && (user._id === currentUser._id || user.username === currentUser.username));
162
- const canEdit = isAdmin;
163
 
164
  const hasApp = user.classApplication && user.classApplication.status === 'PENDING';
165
 
@@ -228,36 +211,22 @@ export const UserList: React.FC = () => {
228
  )}
229
  </td>
230
  <td className="px-4 py-3">
231
- {hasApp && isAdmin ? (
232
- <div className="bg-purple-50 border border-purple-200 rounded p-1 text-xs">
233
- <span className="font-bold text-purple-700 block mb-1">
234
- {user.classApplication?.type === 'CLAIM' ? `申请任教: ${user.classApplication.targetClass}` : '申请卸任'}
235
- </span>
236
- <div className="flex gap-1">
237
- <button onClick={()=>handleApplicationReview(user, 'APPROVE')} className="bg-green-500 text-white px-2 rounded hover:bg-green-600">同意</button>
238
- <button onClick={()=>handleApplicationReview(user, 'REJECT')} className="bg-red-400 text-white px-2 rounded hover:bg-red-500">拒绝</button>
239
- </div>
240
- </div>
241
- ) : (
242
- <>
243
- {user.status === 'active' && <span className="bg-green-100 text-green-700 px-2 py-1 rounded-full text-xs">已激活</span>}
244
- {user.status === 'pending' && <span className="bg-yellow-100 text-yellow-700 px-2 py-1 rounded-full text-xs animate-pulse">待审核</span>}
245
- {user.status === 'banned' && <span className="bg-red-100 text-red-700 px-2 py-1 rounded-full text-xs">已停用</span>}
246
- {user.classApplication?.status === 'PENDING' && !isAdmin && <span className="block mt-1 text-[10px] text-gray-400">申请审核中...</span>}
247
- </>
248
- )}
249
  </td>
250
  <td className="px-4 py-3 text-right space-x-2">
251
  {user.status === UserStatus.PENDING && (
252
- <button onClick={() => handleApprove(user)} className="text-green-600 hover:bg-green-50 p-1 rounded border border-green-200" title="通过审核">
253
- <span className="flex items-center text-xs font-bold"><Check size={14} className="mr-1"/> 通过</span>
254
  </button>
255
  )}
256
  {isAdmin && (
257
  <button
258
  onClick={() => handleDelete(user)}
259
  className={`p-1 rounded ${user.username === 'admin' || isSelf ? 'text-gray-300 cursor-not-allowed' : 'text-red-600 hover:bg-red-50'}`}
260
- title="删除"
261
  disabled={user.username === 'admin' || isSelf}
262
  >
263
  <Trash2 size={18} />
 
2
  import React, { useState, useEffect } from 'react';
3
  import { User, UserRole, UserStatus, School, ClassInfo } from '../types';
4
  import { api } from '../services/api';
5
+ import { Loader2, Check, X, Trash2, Edit, Briefcase, GraduationCap } from 'lucide-react';
6
 
7
  export const UserList: React.FC = () => {
8
  const [users, setUsers] = useState<User[]>([]);
 
89
  } catch(e) { alert('提交失败'); }
90
  };
91
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
  const getSchoolName = (id?: string) => schools.find(s => s._id === id)?.name || '未分配';
93
 
94
  const filteredUsers = users.filter(user => {
 
106
  <div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6">
107
  <div className="flex justify-between items-center mb-4">
108
  <div className="flex items-center gap-4">
109
+ <h2 className="text-xl font-bold text-gray-800">用户权限与账号审核</h2>
110
  {isTeacher && (
111
  <div className="flex items-center gap-2">
112
  {currentUser.homeroomClass ? (
 
123
  )}
124
  </div>
125
  {isAdmin && <span className="text-xs text-gray-500 bg-gray-100 px-2 py-1 rounded">全局视图</span>}
 
126
  </div>
127
 
128
  {filteredUsers.length === 0 ? (
 
136
  <th className="px-4 py-3">详细信息</th>
137
  <th className="px-4 py-3">所属学校</th>
138
  <th className="px-4 py-3">角色/班级</th>
139
+ <th className="px-4 py-3">账号状态</th>
140
  <th className="px-4 py-3 text-right">操作</th>
141
  </tr>
142
  </thead>
143
  <tbody className="divide-y divide-gray-100">
144
  {filteredUsers.map(user => {
145
  const isSelf = !!(currentUser && (user._id === currentUser._id || user.username === currentUser.username));
 
146
 
147
  const hasApp = user.classApplication && user.classApplication.status === 'PENDING';
148
 
 
211
  )}
212
  </td>
213
  <td className="px-4 py-3">
214
+ {user.status === 'active' && <span className="bg-green-100 text-green-700 px-2 py-1 rounded-full text-xs">已激活</span>}
215
+ {user.status === 'pending' && <span className="bg-yellow-100 text-yellow-700 px-2 py-1 rounded-full text-xs animate-pulse">待审核</span>}
216
+ {user.status === 'banned' && <span className="bg-red-100 text-red-700 px-2 py-1 rounded-full text-xs">已停用</span>}
217
+ {hasApp && <span className="block mt-1 text-[10px] text-purple-500 font-bold bg-purple-50 px-1 rounded w-fit">申请变动中 (见班级管理)</span>}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
218
  </td>
219
  <td className="px-4 py-3 text-right space-x-2">
220
  {user.status === UserStatus.PENDING && (
221
+ <button onClick={() => handleApprove(user)} className="text-green-600 hover:bg-green-50 p-1 rounded border border-green-200" title="通过注册审核">
222
+ <span className="flex items-center text-xs font-bold"><Check size={14} className="mr-1"/> 激活</span>
223
  </button>
224
  )}
225
  {isAdmin && (
226
  <button
227
  onClick={() => handleDelete(user)}
228
  className={`p-1 rounded ${user.username === 'admin' || isSelf ? 'text-gray-300 cursor-not-allowed' : 'text-red-600 hover:bg-red-50'}`}
229
+ title="删除账号"
230
  disabled={user.username === 'admin' || isSelf}
231
  >
232
  <Trash2 size={18} />
server.js CHANGED
@@ -104,7 +104,7 @@ const ScheduleSchema = new mongoose.Schema({ schoolId: String, className: String
104
  const ScheduleModel = mongoose.model('Schedule', ScheduleSchema);
105
  const ConfigSchema = new mongoose.Schema({ key: String, systemName: String, semester: String, semesters: [String], allowRegister: Boolean, allowAdminRegister: Boolean, allowStudentRegister: Boolean, maintenanceMode: Boolean, emailNotify: Boolean });
106
  const ConfigModel = mongoose.model('Config', ConfigSchema);
107
- const NotificationSchema = new mongoose.Schema({ schoolId: String, targetRole: String, targetUserId: String, title: String, content: String, type: String, createTime: Date });
108
  const NotificationModel = mongoose.model('Notification', NotificationSchema);
109
  const GameSessionSchema = new mongoose.Schema({ schoolId: String, className: String, isEnabled: Boolean, maxSteps: Number, teams: [{ id: String, name: String, score: Number, avatar: String, color: String, members: [String] }], rewardsConfig: [{ scoreThreshold: Number, rewardType: String, rewardName: String, rewardValue: Number, achievementId: String }] });
110
  const GameSessionModel = mongoose.model('GameSession', GameSessionSchema);
@@ -140,12 +140,7 @@ const getAutoSemester = () => {
140
  const now = new Date();
141
  const month = now.getMonth() + 1; // 1-12
142
  const year = now.getFullYear();
143
-
144
- // Logic:
145
- // Aug(8) to Jan(1) -> First Semester
146
- // Feb(2) to Jul(7) -> Second Semester
147
  if (month >= 8 || month === 1) {
148
- // If Jan, it's still the academic year starting previous year
149
  const startYear = month === 1 ? year - 1 : year;
150
  return `${startYear}-${startYear + 1}学年 第一学期`;
151
  } else {
@@ -164,6 +159,9 @@ app.post('/api/users/class-application', async (req, res) => {
164
 
165
  if (action === 'APPLY') {
166
  try {
 
 
 
167
  await User.findByIdAndUpdate(userId, {
168
  classApplication: {
169
  type: type,
@@ -171,6 +169,26 @@ app.post('/api/users/class-application', async (req, res) => {
171
  status: 'PENDING'
172
  }
173
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
174
  return res.json({ success: true });
175
  } catch (e) {
176
  console.error(e);
@@ -182,38 +200,29 @@ app.post('/api/users/class-application', async (req, res) => {
182
  const user = await User.findById(userId);
183
  if (!user || !user.classApplication) return res.status(404).json({ error: 'Application not found' });
184
 
 
 
 
185
  if (action === 'APPROVE') {
186
  const updates = { classApplication: null };
187
 
188
- // Logic:
189
- // 1. If Claiming: Set user homeroomClass = target.
190
- // Ideally, also remove any other teacher from this class or check conflict?
191
- // For now, we simply overwrite.
192
- // 2. If Resigning: Set user homeroomClass = ''.
193
-
194
- if (user.classApplication.type === 'CLAIM') {
195
- const target = user.classApplication.targetClass;
196
- // Update user
197
- updates.homeroomClass = target;
198
-
199
- // Also update Class Record to show this teacher
200
- const gradePart = target.match(/^(.+?年级)/)?.[1] || ''; // simple regex guess
201
- const classPart = target.replace(gradePart, '');
202
-
203
- // Try to find the class to update teacherName field for display
204
- // Note: Class structure separates grade and className.
205
- // We need to match string concat.
206
- // This is a bit tricky without ID, but let's try our best match
207
- // Actually, strict matching is better if we have IDs, but here we use names.
208
- // Let's iterate or find one.
209
  const classes = await ClassModel.find({ schoolId });
210
- const matchedClass = classes.find(c => (c.grade + c.className) === target);
211
 
212
  if (matchedClass) {
 
 
 
 
 
 
 
213
  await ClassModel.findByIdAndUpdate(matchedClass._id, { teacherName: user.trueName || user.username });
214
  }
215
-
216
- } else if (user.classApplication.type === 'RESIGN') {
217
  updates.homeroomClass = '';
218
  // Clear teacher from Class Record
219
  if (user.homeroomClass) {
@@ -225,95 +234,72 @@ app.post('/api/users/class-application', async (req, res) => {
225
  }
226
  }
227
  await User.findByIdAndUpdate(userId, updates);
 
 
 
 
 
 
 
 
 
 
228
  } else {
 
229
  await User.findByIdAndUpdate(userId, { 'classApplication.status': 'REJECTED' });
 
 
 
 
 
 
 
 
 
230
  }
231
  return res.json({ success: true });
232
  }
233
  res.status(403).json({ error: 'Permission denied' });
234
  });
235
 
236
- // --- STUDENT PROMOTION & TRANSFER ROUTES ---
237
- const GRADE_MAP = {
238
- '一年级': '二年级',
239
- '二年级': '三年级',
240
- '三年级': '四年级',
241
- '四年级': '五年级',
242
- '五年级': '六年级',
243
- '六年级': '毕业',
244
- '初一': '初二',
245
- '七年级': '八年级',
246
- '初二': '初三',
247
- '八年级': '九年级',
248
- '初三': '毕业',
249
- '九年级': '毕业',
250
- '高一': '高二',
251
- '高二': '高三',
252
- '高三': '毕业'
253
- };
254
-
255
- const getNextGrade = (grade) => {
256
- return GRADE_MAP[grade] || grade;
257
- };
258
-
259
  app.post('/api/students/promote', async (req, res) => {
 
260
  const { teacherFollows } = req.body;
261
  const sId = req.headers['x-school-id'];
262
  const role = req.headers['x-user-role'];
263
 
264
  if (role !== 'ADMIN') return res.status(403).json({ error: 'Permission denied' });
265
 
 
 
 
 
 
 
266
  const classes = await ClassModel.find(getQueryFilter(req));
267
  let promotedCount = 0;
268
 
269
  for (const cls of classes) {
270
  const currentGrade = cls.grade;
271
- const nextGrade = getNextGrade(currentGrade);
272
- const suffix = cls.className; // e.g. "(1)班"
273
 
274
  if (nextGrade === '毕业') {
275
  const oldFullClass = cls.grade + cls.className;
276
- await Student.updateMany(
277
- { className: oldFullClass, ...getQueryFilter(req) },
278
- { status: 'Graduated', className: '已毕业' }
279
- );
280
  if (teacherFollows && cls.teacherName) {
281
  await User.updateOne({ trueName: cls.teacherName, schoolId: sId }, { homeroomClass: '' });
282
- // Clear class teacher info
283
  await ClassModel.findByIdAndUpdate(cls._id, { teacherName: '' });
284
  }
285
  } else {
286
  const oldFullClass = cls.grade + cls.className;
287
  const newFullClass = nextGrade + suffix;
288
-
289
- // Check if next class exists, if not create
290
- await ClassModel.findOneAndUpdate(
291
- { grade: nextGrade, className: suffix, schoolId: sId },
292
- {
293
- schoolId: sId,
294
- grade: nextGrade,
295
- className: suffix,
296
- teacherName: teacherFollows ? cls.teacherName : undefined
297
- },
298
- { upsert: true }
299
- );
300
-
301
- const result = await Student.updateMany(
302
- { className: oldFullClass, status: 'Enrolled', ...getQueryFilter(req) },
303
- { className: newFullClass }
304
- );
305
  promotedCount += result.modifiedCount;
306
-
307
  if (teacherFollows && cls.teacherName) {
308
- // Teacher moves to new class
309
- await User.updateOne(
310
- { trueName: cls.teacherName, schoolId: sId, homeroomClass: oldFullClass },
311
- { homeroomClass: newFullClass }
312
- );
313
- // Clear old class teacher if teacher moved
314
  await ClassModel.findByIdAndUpdate(cls._id, { teacherName: '' });
315
- } else {
316
- // Teacher stays. Old class has no students now.
317
  }
318
  }
319
  }
@@ -324,7 +310,6 @@ app.post('/api/students/transfer', async (req, res) => {
324
  const { studentId, targetClass } = req.body;
325
  const student = await Student.findById(studentId);
326
  if (!student) return res.status(404).json({ error: 'Student not found' });
327
-
328
  student.className = targetClass;
329
  await student.save();
330
  res.json({ success: true });
@@ -337,17 +322,12 @@ app.get('/api/achievements/config', async (req, res) => {
337
  const config = await AchievementConfigModel.findOne({ ...getQueryFilter(req), className });
338
  res.json(config || { className, achievements: [], exchangeRules: [] });
339
  });
340
-
341
  app.post('/api/achievements/config', async (req, res) => {
342
  const { className } = req.body;
343
- if (!className) return res.status(400).json({ error: 'Class name required' });
344
  const sId = req.headers['x-school-id'];
345
- const filter = { className };
346
- if (sId) filter.schoolId = sId;
347
- await AchievementConfigModel.findOneAndUpdate(filter, injectSchoolId(req, req.body), { upsert: true });
348
  res.json({ success: true });
349
  });
350
-
351
  app.get('/api/achievements/student', async (req, res) => {
352
  const { studentId, semester } = req.query;
353
  const filter = getQueryFilter(req);
@@ -356,63 +336,34 @@ app.get('/api/achievements/student', async (req, res) => {
356
  const list = await StudentAchievementModel.find(filter).sort({ createTime: -1 });
357
  res.json(list);
358
  });
359
-
360
  app.post('/api/achievements/grant', async (req, res) => {
361
  const { studentId, achievementId, semester } = req.body;
362
  const sId = req.headers['x-school-id'];
363
  const student = await Student.findById(studentId);
364
- if (!student) return res.status(404).json({ error: 'Student not found' });
365
-
366
  const config = await AchievementConfigModel.findOne({ className: student.className, ...getQueryFilter(req) });
367
  const ach = config?.achievements.find(a => a.id === achievementId);
368
- if (!ach) return res.status(404).json({ error: 'Achievement definition not found' });
369
 
370
- await StudentAchievementModel.create({
371
- schoolId: sId,
372
- studentId,
373
- studentName: student.name,
374
- achievementId: ach.id,
375
- achievementName: ach.name,
376
- achievementIcon: ach.icon,
377
- semester: semester || '当前学期',
378
- });
379
  await Student.findByIdAndUpdate(studentId, { $inc: { flowerBalance: ach.points } });
380
  res.json({ success: true });
381
  });
382
-
383
  app.post('/api/achievements/exchange', async (req, res) => {
384
  const { studentId, ruleId } = req.body;
385
  const sId = req.headers['x-school-id'];
386
-
387
  const student = await Student.findById(studentId);
388
- if (!student) return res.status(404).json({ error: 'Student not found' });
389
-
390
  const config = await AchievementConfigModel.findOne({ className: student.className, ...getQueryFilter(req) });
391
  const rule = config?.exchangeRules.find(r => r.id === ruleId);
392
  if (!rule) return res.status(404).json({ error: 'Exchange rule not found' });
393
-
394
- if ((student.flowerBalance || 0) < rule.cost) {
395
- return res.status(400).json({ error: 'INSUFFICIENT_FLOWERS', message: '小红花余额不足' });
396
- }
397
 
398
  await Student.findByIdAndUpdate(studentId, { $inc: { flowerBalance: -rule.cost } });
399
-
400
  let status = 'PENDING';
401
  if (rule.rewardType === 'DRAW_COUNT') {
402
  status = 'REDEEMED';
403
  await Student.findByIdAndUpdate(studentId, { $inc: { drawAttempts: rule.rewardValue } });
404
  }
405
-
406
- await StudentRewardModel.create({
407
- schoolId: sId,
408
- studentId,
409
- studentName: student.name,
410
- rewardType: rule.rewardType,
411
- name: rule.rewardName,
412
- count: rule.rewardValue,
413
- status: status,
414
- source: `积分兑换`
415
- });
416
  res.json({ success: true });
417
  });
418
 
@@ -423,18 +374,11 @@ app.get('/api/games/lucky-config', async (req, res) => {
423
  const config = await LuckyDrawConfigModel.findOne(filter);
424
  res.json(config || { prizes: [], dailyLimit: 3, cardCount: 9, defaultPrize: '再接再厉' });
425
  });
426
-
427
  app.post('/api/games/lucky-config', async (req, res) => {
428
  const data = injectSchoolId(req, req.body);
429
- if (!data.className) return res.status(400).json({ error: 'Class name required' });
430
- await LuckyDrawConfigModel.findOneAndUpdate(
431
- { className: data.className, ...getQueryFilter(req) },
432
- data,
433
- { upsert: true }
434
- );
435
  res.json({ success: true });
436
  });
437
-
438
  app.post('/api/games/lucky-draw', async (req, res) => {
439
  const { studentId } = req.body;
440
  const schoolId = req.headers['x-school-id'];
@@ -443,37 +387,25 @@ app.post('/api/games/lucky-draw', async (req, res) => {
443
  try {
444
  const student = await Student.findById(studentId);
445
  if (!student) return res.status(404).json({ error: 'Student not found' });
446
-
447
- const filter = { className: student.className };
448
- if (schoolId) Object.assign(filter, { $or: [{ schoolId }, { schoolId: { $exists: false } }] });
449
-
450
- const config = await LuckyDrawConfigModel.findOne(filter);
451
  const prizes = config?.prizes || [];
452
  const defaultPrize = config?.defaultPrize || '再接再厉';
453
  const dailyLimit = config?.dailyLimit || 3;
454
  const consolationWeight = config?.consolationWeight || 0;
455
 
456
- // WEIGHTED RANDOM LOGIC
457
- // 1. Filter prizes with inventory
458
  const availablePrizes = prizes.filter(p => (p.count === undefined || p.count > 0));
459
-
460
- // 2. Check if pool is empty (no prizes AND no consolation weight)
461
  if (availablePrizes.length === 0 && consolationWeight === 0) {
462
  const isTeacherOrAdmin = userRole === 'TEACHER' || userRole === 'ADMIN';
463
- const msg = isTeacherOrAdmin
464
- ? '奖品库存不足,不能抽奖,请先补充库存'
465
- : '奖品库存不足,不能抽奖,请联系班主任补充库存';
466
  return res.status(400).json({ error: 'POOL_EMPTY', message: msg });
467
  }
468
 
469
- // 3. Limit Check
470
  if (userRole === 'STUDENT') {
471
  if (student.drawAttempts <= 0) return res.status(403).json({ error: '次数不足', message: '您的抽奖次数已用完' });
472
  const today = new Date().toISOString().split('T')[0];
473
  let dailyLog = student.dailyDrawLog || { date: today, count: 0 };
474
  if (dailyLog.date !== today) dailyLog = { date: today, count: 0 };
475
  if (dailyLog.count >= dailyLimit) return res.status(403).json({ error: 'DAILY_LIMIT_REACHED', message: `今日抽奖次数已达上限 (${dailyLimit}次)` });
476
-
477
  dailyLog.count += 1;
478
  student.drawAttempts -= 1;
479
  student.dailyDrawLog = dailyLog;
@@ -484,10 +416,8 @@ app.post('/api/games/lucky-draw', async (req, res) => {
484
  await student.save();
485
  }
486
 
487
- // 4. Calculate Total Weight
488
  let totalWeight = consolationWeight;
489
  availablePrizes.forEach(p => totalWeight += (p.probability || 0));
490
-
491
  let random = Math.random() * totalWeight;
492
  let selectedPrize = defaultPrize;
493
  let rewardType = 'CONSOLATION';
@@ -504,70 +434,37 @@ app.post('/api/games/lucky-draw', async (req, res) => {
504
  if (matchedPrize) {
505
  selectedPrize = matchedPrize.name;
506
  rewardType = 'ITEM';
507
- if (config._id) {
508
- await LuckyDrawConfigModel.updateOne(
509
- { _id: config._id, "prizes.id": matchedPrize.id },
510
- { $inc: { "prizes.$.count": -1 } }
511
- );
512
- }
513
  }
514
-
515
- await StudentRewardModel.create({
516
- schoolId,
517
- studentId,
518
- studentName: student.name,
519
- rewardType,
520
- name: selectedPrize,
521
- count: 1,
522
- status: 'PENDING',
523
- source: '幸运大抽奖'
524
- });
525
  res.json({ prize: selectedPrize, rewardType });
526
-
527
- } catch (e) {
528
- res.status(500).json({ error: e.message });
529
- }
530
  });
531
 
532
  // --- Standard Routes ---
533
  app.get('/api/notifications', async (req, res) => {
534
  const { role, userId } = req.query;
535
- const query = {
536
- $and: [
537
- getQueryFilter(req),
538
- { $or: [ { targetRole: null, targetUserId: null }, { targetRole: role }, { targetUserId: userId } ] }
539
- ]
540
- };
541
  res.json(await NotificationModel.find(query).sort({ createTime: -1 }).limit(20));
542
  });
543
-
544
  app.get('/api/public/schools', async (req, res) => { res.json(await School.find({}, 'name code _id')); });
545
-
546
- // Updated Config to Auto Generate Semester
547
  app.get('/api/public/config', async (req, res) => {
548
  const currentSem = getAutoSemester();
549
- const config = await ConfigModel.findOne({ key: 'main' });
550
  if (config) {
551
- // Auto update database if semester is missing or outdated (optional logic,
552
- // but user requested "Auto" so we prioritize current date calculation if not pinned?
553
- // Let's assume we always return the calculated one as 'current', but keep DB list for history)
554
-
555
- // Ensure current sem is in list
556
  let semesters = config.semesters || [];
557
  if (!semesters.includes(currentSem)) {
558
- semesters.unshift(currentSem); // Add to top
559
- await ConfigModel.updateOne({ key: 'main' }, { semesters, semester: currentSem });
560
  config.semesters = semesters;
561
  config.semester = currentSem;
 
562
  }
563
- res.json(config);
564
  } else {
565
- res.json({ allowRegister: true, semester: currentSem, semesters: [currentSem] });
566
  }
 
567
  });
568
-
569
  app.get('/api/public/meta', async (req, res) => { res.json({ classes: await ClassModel.find({ schoolId: req.query.schoolId }), subjects: await SubjectModel.find({ schoolId: req.query.schoolId }) }); });
570
-
571
  app.post('/api/auth/login', async (req, res) => {
572
  const { username, password } = req.body;
573
  const user = await User.findOne({ username, password });
@@ -575,80 +472,97 @@ app.post('/api/auth/login', async (req, res) => {
575
  if (user.status !== 'active') return res.status(403).json({ error: 'PENDING_APPROVAL' });
576
  res.json(user);
577
  });
578
- app.post('/api/auth/register', async (req, res) => {
579
- try { await User.create({...req.body, status: 'pending'}); res.json({}); } catch(e) { res.status(500).json({}); }
580
- });
581
-
582
  app.get('/api/schools', async (req, res) => { res.json(await School.find()); });
583
  app.post('/api/schools', async (req, res) => { res.json(await School.create(req.body)); });
584
  app.put('/api/schools/:id', async (req, res) => { await School.findByIdAndUpdate(req.params.id, req.body); res.json({}); });
585
-
586
  app.get('/api/users', async (req, res) => { res.json(await User.find(getQueryFilter(req))); });
587
  app.put('/api/users/:id', async (req, res) => { await User.findByIdAndUpdate(req.params.id, req.body); res.json({}); });
588
  app.delete('/api/users/:id', async (req, res) => { await User.findByIdAndDelete(req.params.id); res.json({}); });
589
-
590
  app.get('/api/students', async (req, res) => { res.json(await Student.find(getQueryFilter(req))); });
591
  app.post('/api/students', async (req, res) => { await Student.findOneAndUpdate({ studentNo: req.body.studentNo }, injectSchoolId(req, req.body), {upsert:true}); res.json({}); });
592
  app.put('/api/students/:id', async (req, res) => { await Student.findByIdAndUpdate(req.params.id, req.body); res.json({}); });
593
  app.delete('/api/students/:id', async (req, res) => { await Student.findByIdAndDelete(req.params.id); res.json({}); });
594
-
595
  app.get('/api/classes', async (req, res) => {
596
  const cls = await ClassModel.find(getQueryFilter(req));
597
  const resData = await Promise.all(cls.map(async c => ({...c.toObject(), studentCount: await Student.countDocuments({className:c.grade+c.className})})));
598
  res.json(resData);
599
  });
600
  app.post('/api/classes', async (req, res) => {
601
- // Creating a class -> Sync teacher's homeroom
602
  const data = injectSchoolId(req, req.body);
603
  await ClassModel.create(data);
604
  if (data.teacherName) {
605
  const fullClass = data.grade + data.className;
606
- // Update user record
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
607
  await User.updateOne(
608
- { trueName: data.teacherName, schoolId: data.schoolId },
609
- { homeroomClass: fullClass }
610
  );
611
  }
612
- res.json({});
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
613
  });
 
614
  app.delete('/api/classes/:id', async (req, res) => {
615
- // Deleting a class -> clear teacher's homeroom
616
  const cls = await ClassModel.findById(req.params.id);
617
  if (cls && cls.teacherName) {
618
  const fullClass = cls.grade + cls.className;
619
- await User.updateOne(
620
- { trueName: cls.teacherName, schoolId: cls.schoolId, homeroomClass: fullClass },
621
- { homeroomClass: '' }
622
- );
623
  }
624
  await ClassModel.findByIdAndDelete(req.params.id);
625
  res.json({});
626
  });
627
-
628
  app.get('/api/subjects', async (req, res) => { res.json(await SubjectModel.find(getQueryFilter(req))); });
629
  app.post('/api/subjects', async (req, res) => { await SubjectModel.create(injectSchoolId(req, req.body)); res.json({}); });
630
  app.put('/api/subjects/:id', async (req, res) => { await SubjectModel.findByIdAndUpdate(req.params.id, req.body); res.json({}); });
631
  app.delete('/api/subjects/:id', async (req, res) => { await SubjectModel.findByIdAndDelete(req.params.id); res.json({}); });
632
-
633
  app.get('/api/courses', async (req, res) => { res.json(await Course.find(getQueryFilter(req))); });
634
  app.post('/api/courses', async (req, res) => { await Course.create(injectSchoolId(req, req.body)); res.json({}); });
635
  app.put('/api/courses/:id', async (req, res) => { await Course.findByIdAndUpdate(req.params.id, req.body); res.json({}); });
636
  app.delete('/api/courses/:id', async (req, res) => { await Course.findByIdAndDelete(req.params.id); res.json({}); });
637
-
638
  app.get('/api/scores', async (req, res) => { res.json(await Score.find(getQueryFilter(req))); });
639
  app.post('/api/scores', async (req, res) => { await Score.create(injectSchoolId(req, req.body)); res.json({}); });
640
  app.put('/api/scores/:id', async (req, res) => { await Score.findByIdAndUpdate(req.params.id, req.body); res.json({}); });
641
  app.delete('/api/scores/:id', async (req, res) => { await Score.findByIdAndDelete(req.params.id); res.json({}); });
642
-
643
  app.get('/api/exams', async (req, res) => { res.json(await ExamModel.find(getQueryFilter(req))); });
644
  app.post('/api/exams', async (req, res) => { await ExamModel.findOneAndUpdate({name:req.body.name}, injectSchoolId(req, req.body), {upsert:true}); res.json({}); });
645
-
646
  app.get('/api/schedules', async (req, res) => {
647
  const query = { ...getQueryFilter(req), ...req.query };
648
- if (query.grade) {
649
- query.className = { $regex: '^' + query.grade };
650
- delete query.grade;
651
- }
652
  res.json(await ScheduleModel.find(query));
653
  });
654
  app.post('/api/schedules', async (req, res) => {
@@ -659,14 +573,12 @@ app.post('/api/schedules', async (req, res) => {
659
  res.json({});
660
  });
661
  app.delete('/api/schedules', async (req, res) => { await ScheduleModel.deleteOne({...getQueryFilter(req), ...req.query}); res.json({}); });
662
-
663
  app.get('/api/stats', async (req, res) => {
664
  const filter = getQueryFilter(req);
665
  const studentCount = await Student.countDocuments(filter);
666
  const courseCount = await Course.countDocuments(filter);
667
  const scores = await Score.find({...filter, status: 'Normal'});
668
- let avgScore = 0;
669
- let excellentRate = '0%';
670
  if (scores.length > 0) {
671
  const total = scores.reduce((sum, s) => sum + s.score, 0);
672
  avgScore = parseFloat((total / scores.length).toFixed(1));
@@ -675,18 +587,15 @@ app.get('/api/stats', async (req, res) => {
675
  }
676
  res.json({ studentCount, courseCount, avgScore, excellentRate });
677
  });
678
-
679
  app.get('/api/config', async (req, res) => {
680
- // When Admin fetches config, we also check auto-gen semester
681
  const currentSem = getAutoSemester();
682
  let config = await ConfigModel.findOne({key:'main'});
683
-
684
  if (config) {
685
  let semesters = config.semesters || [];
686
  if (!semesters.includes(currentSem)) {
687
  semesters.unshift(currentSem);
688
  config.semesters = semesters;
689
- config.semester = currentSem; // Auto switch to new sem
690
  await ConfigModel.updateOne({ key: 'main' }, { semesters, semester: currentSem });
691
  }
692
  } else {
@@ -695,7 +604,6 @@ app.get('/api/config', async (req, res) => {
695
  res.json(config);
696
  });
697
  app.post('/api/config', async (req, res) => { await ConfigModel.findOneAndUpdate({key:'main'}, req.body, {upsert:true}); res.json({}); });
698
-
699
  app.get('/api/games/mountain', async (req, res) => { res.json(await GameSessionModel.findOne({...getQueryFilter(req), className: req.query.className})); });
700
  app.post('/api/games/mountain', async (req, res) => {
701
  const filter = { className: req.body.className };
@@ -704,25 +612,14 @@ app.post('/api/games/mountain', async (req, res) => {
704
  await GameSessionModel.findOneAndUpdate(filter, injectSchoolId(req, req.body), {upsert:true});
705
  res.json({});
706
  });
707
-
708
  app.post('/api/games/grant-reward', async (req, res) => {
709
  const { studentId, count, rewardType, name } = req.body;
710
  const finalCount = count || 1;
711
  const finalName = name || (rewardType === 'DRAW_COUNT' ? '抽奖券' : '奖品');
712
  if (rewardType === 'DRAW_COUNT') await Student.findByIdAndUpdate(studentId, { $inc: { drawAttempts: finalCount } });
713
- await StudentRewardModel.create({
714
- schoolId: req.headers['x-school-id'],
715
- studentId,
716
- studentName: (await Student.findById(studentId)).name,
717
- rewardType,
718
- name: finalName,
719
- count: finalCount,
720
- status: rewardType === 'DRAW_COUNT' ? 'REDEEMED' : 'PENDING',
721
- source: '教师发放'
722
- });
723
  res.json({});
724
  });
725
-
726
  app.put('/api/rewards/:id', async (req, res) => { await StudentRewardModel.findByIdAndUpdate(req.params.id, req.body); res.json({}); });
727
  app.delete('/api/rewards/:id', async (req, res) => {
728
  const reward = await StudentRewardModel.findById(req.params.id);
@@ -735,7 +632,6 @@ app.delete('/api/rewards/:id', async (req, res) => {
735
  await StudentRewardModel.findByIdAndDelete(req.params.id);
736
  res.json({});
737
  });
738
-
739
  app.get('/api/rewards', async (req, res) => {
740
  const filter = getQueryFilter(req);
741
  if(req.query.studentId) filter.studentId = req.query.studentId;
@@ -751,19 +647,14 @@ app.get('/api/rewards', async (req, res) => {
751
  const list = await StudentRewardModel.find(filter).sort({createTime:-1}).skip(skip).limit(limit);
752
  res.json({ list, total });
753
  });
754
-
755
  app.post('/api/rewards', async (req, res) => {
756
  const data = injectSchoolId(req, req.body);
757
  if (!data.count) data.count = 1;
758
- if(data.rewardType==='DRAW_COUNT') {
759
- data.status='REDEEMED';
760
- await Student.findByIdAndUpdate(data.studentId, {$inc:{drawAttempts:data.count}});
761
- }
762
  await StudentRewardModel.create(data);
763
  res.json({});
764
  });
765
  app.post('/api/rewards/:id/redeem', async (req, res) => { await StudentRewardModel.findByIdAndUpdate(req.params.id, {status:'REDEEMED'}); res.json({}); });
766
-
767
  app.get('/api/attendance', async (req, res) => {
768
  const { className, date, studentId } = req.query;
769
  const filter = getQueryFilter(req);
@@ -800,7 +691,6 @@ app.post('/api/leave', async (req, res) => {
800
  if (student) await AttendanceModel.findOneAndUpdate({ studentId, date: startDate }, { schoolId: req.headers['x-school-id'], studentId, studentName: student.name, className: student.className, date: startDate, status: 'Leave', checkInTime: new Date() }, { upsert: true });
801
  res.json({ success: true });
802
  });
803
-
804
  app.post('/api/batch-delete', async (req, res) => {
805
  if(req.body.type==='student') await Student.deleteMany({_id:{$in:req.body.ids}});
806
  if(req.body.type==='score') await Score.deleteMany({_id:{$in:req.body.ids}});
 
104
  const ScheduleModel = mongoose.model('Schedule', ScheduleSchema);
105
  const ConfigSchema = new mongoose.Schema({ key: String, systemName: String, semester: String, semesters: [String], allowRegister: Boolean, allowAdminRegister: Boolean, allowStudentRegister: Boolean, maintenanceMode: Boolean, emailNotify: Boolean });
106
  const ConfigModel = mongoose.model('Config', ConfigSchema);
107
+ const NotificationSchema = new mongoose.Schema({ schoolId: String, targetRole: String, targetUserId: String, title: String, content: String, type: String, createTime: { type: Date, default: Date.now } });
108
  const NotificationModel = mongoose.model('Notification', NotificationSchema);
109
  const GameSessionSchema = new mongoose.Schema({ schoolId: String, className: String, isEnabled: Boolean, maxSteps: Number, teams: [{ id: String, name: String, score: Number, avatar: String, color: String, members: [String] }], rewardsConfig: [{ scoreThreshold: Number, rewardType: String, rewardName: String, rewardValue: Number, achievementId: String }] });
110
  const GameSessionModel = mongoose.model('GameSession', GameSessionSchema);
 
140
  const now = new Date();
141
  const month = now.getMonth() + 1; // 1-12
142
  const year = now.getFullYear();
 
 
 
 
143
  if (month >= 8 || month === 1) {
 
144
  const startYear = month === 1 ? year - 1 : year;
145
  return `${startYear}-${startYear + 1}学年 第一学期`;
146
  } else {
 
159
 
160
  if (action === 'APPLY') {
161
  try {
162
+ const user = await User.findById(userId);
163
+ if(!user) return res.status(404).json({error:'User not found'});
164
+
165
  await User.findByIdAndUpdate(userId, {
166
  classApplication: {
167
  type: type,
 
169
  status: 'PENDING'
170
  }
171
  });
172
+
173
+ // Notify Teacher (Self)
174
+ const typeText = type === 'CLAIM' ? '申请班主任' : '申请卸任';
175
+ await NotificationModel.create({
176
+ schoolId,
177
+ targetUserId: userId,
178
+ title: '申请已提交',
179
+ content: `您已成功提交 ${typeText} (${type === 'CLAIM' ? targetClass : user.homeroomClass}) 的申请,等待管理员审核。`,
180
+ type: 'info'
181
+ });
182
+
183
+ // Notify Admins
184
+ await NotificationModel.create({
185
+ schoolId,
186
+ targetRole: 'ADMIN',
187
+ title: '新的班主任任免申请',
188
+ content: `${user.trueName || user.username} 申请 ${typeText},请及时处理。`,
189
+ type: 'warning'
190
+ });
191
+
192
  return res.json({ success: true });
193
  } catch (e) {
194
  console.error(e);
 
200
  const user = await User.findById(userId);
201
  if (!user || !user.classApplication) return res.status(404).json({ error: 'Application not found' });
202
 
203
+ const appType = user.classApplication.type;
204
+ const appTarget = user.classApplication.targetClass;
205
+
206
  if (action === 'APPROVE') {
207
  const updates = { classApplication: null };
208
 
209
+ if (appType === 'CLAIM') {
210
+ updates.homeroomClass = appTarget;
211
+ // Update Class Record
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
212
  const classes = await ClassModel.find({ schoolId });
213
+ const matchedClass = classes.find(c => (c.grade + c.className) === appTarget);
214
 
215
  if (matchedClass) {
216
+ // Remove old teacher if any
217
+ if (matchedClass.teacherName) {
218
+ await User.updateOne(
219
+ { trueName: matchedClass.teacherName, schoolId },
220
+ { homeroomClass: '' }
221
+ );
222
+ }
223
  await ClassModel.findByIdAndUpdate(matchedClass._id, { teacherName: user.trueName || user.username });
224
  }
225
+ } else if (appType === 'RESIGN') {
 
226
  updates.homeroomClass = '';
227
  // Clear teacher from Class Record
228
  if (user.homeroomClass) {
 
234
  }
235
  }
236
  await User.findByIdAndUpdate(userId, updates);
237
+
238
+ // Notify Teacher
239
+ await NotificationModel.create({
240
+ schoolId,
241
+ targetUserId: userId,
242
+ title: '申请已通过',
243
+ content: `管理员已同意您的${appType === 'CLAIM' ? '任教' : '卸任'}申请。`,
244
+ type: 'success'
245
+ });
246
+
247
  } else {
248
+ // REJECT
249
  await User.findByIdAndUpdate(userId, { 'classApplication.status': 'REJECTED' });
250
+
251
+ // Notify Teacher
252
+ await NotificationModel.create({
253
+ schoolId,
254
+ targetUserId: userId,
255
+ title: '申请被拒绝',
256
+ content: `管理员拒绝了您的${appType === 'CLAIM' ? '任教' : '卸任'}申请。`,
257
+ type: 'error'
258
+ });
259
  }
260
  return res.json({ success: true });
261
  }
262
  res.status(403).json({ error: 'Permission denied' });
263
  });
264
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
265
  app.post('/api/students/promote', async (req, res) => {
266
+ // ... existing promotion logic ...
267
  const { teacherFollows } = req.body;
268
  const sId = req.headers['x-school-id'];
269
  const role = req.headers['x-user-role'];
270
 
271
  if (role !== 'ADMIN') return res.status(403).json({ error: 'Permission denied' });
272
 
273
+ const GRADE_MAP = {
274
+ '一年级': '二年级', '二年级': '三年级', '三年级': '四年级', '四年级': '五年级', '五年级': '六年级', '六年级': '毕业',
275
+ '初一': '初二', '七年级': '八年级', '初二': '初三', '八年级': '九年级', '初三': '毕业', '九年级': '毕业',
276
+ '高一': '高二', '高二': '高三', '高三': '毕业'
277
+ };
278
+
279
  const classes = await ClassModel.find(getQueryFilter(req));
280
  let promotedCount = 0;
281
 
282
  for (const cls of classes) {
283
  const currentGrade = cls.grade;
284
+ const nextGrade = GRADE_MAP[currentGrade] || currentGrade;
285
+ const suffix = cls.className;
286
 
287
  if (nextGrade === '毕业') {
288
  const oldFullClass = cls.grade + cls.className;
289
+ await Student.updateMany({ className: oldFullClass, ...getQueryFilter(req) }, { status: 'Graduated', className: '已毕业' });
 
 
 
290
  if (teacherFollows && cls.teacherName) {
291
  await User.updateOne({ trueName: cls.teacherName, schoolId: sId }, { homeroomClass: '' });
 
292
  await ClassModel.findByIdAndUpdate(cls._id, { teacherName: '' });
293
  }
294
  } else {
295
  const oldFullClass = cls.grade + cls.className;
296
  const newFullClass = nextGrade + suffix;
297
+ await ClassModel.findOneAndUpdate({ grade: nextGrade, className: suffix, schoolId: sId }, { schoolId: sId, grade: nextGrade, className: suffix, teacherName: teacherFollows ? cls.teacherName : undefined }, { upsert: true });
298
+ const result = await Student.updateMany({ className: oldFullClass, status: 'Enrolled', ...getQueryFilter(req) }, { className: newFullClass });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
299
  promotedCount += result.modifiedCount;
 
300
  if (teacherFollows && cls.teacherName) {
301
+ await User.updateOne({ trueName: cls.teacherName, schoolId: sId, homeroomClass: oldFullClass }, { homeroomClass: newFullClass });
 
 
 
 
 
302
  await ClassModel.findByIdAndUpdate(cls._id, { teacherName: '' });
 
 
303
  }
304
  }
305
  }
 
310
  const { studentId, targetClass } = req.body;
311
  const student = await Student.findById(studentId);
312
  if (!student) return res.status(404).json({ error: 'Student not found' });
 
313
  student.className = targetClass;
314
  await student.save();
315
  res.json({ success: true });
 
322
  const config = await AchievementConfigModel.findOne({ ...getQueryFilter(req), className });
323
  res.json(config || { className, achievements: [], exchangeRules: [] });
324
  });
 
325
  app.post('/api/achievements/config', async (req, res) => {
326
  const { className } = req.body;
 
327
  const sId = req.headers['x-school-id'];
328
+ await AchievementConfigModel.findOneAndUpdate({ className, schoolId: sId }, injectSchoolId(req, req.body), { upsert: true });
 
 
329
  res.json({ success: true });
330
  });
 
331
  app.get('/api/achievements/student', async (req, res) => {
332
  const { studentId, semester } = req.query;
333
  const filter = getQueryFilter(req);
 
336
  const list = await StudentAchievementModel.find(filter).sort({ createTime: -1 });
337
  res.json(list);
338
  });
 
339
  app.post('/api/achievements/grant', async (req, res) => {
340
  const { studentId, achievementId, semester } = req.body;
341
  const sId = req.headers['x-school-id'];
342
  const student = await Student.findById(studentId);
 
 
343
  const config = await AchievementConfigModel.findOne({ className: student.className, ...getQueryFilter(req) });
344
  const ach = config?.achievements.find(a => a.id === achievementId);
345
+ if (!ach) return res.status(404).json({ error: 'Achievement not found' });
346
 
347
+ await StudentAchievementModel.create({ schoolId: sId, studentId, studentName: student.name, achievementId: ach.id, achievementName: ach.name, achievementIcon: ach.icon, semester: semester || '当前学期' });
 
 
 
 
 
 
 
 
348
  await Student.findByIdAndUpdate(studentId, { $inc: { flowerBalance: ach.points } });
349
  res.json({ success: true });
350
  });
 
351
  app.post('/api/achievements/exchange', async (req, res) => {
352
  const { studentId, ruleId } = req.body;
353
  const sId = req.headers['x-school-id'];
 
354
  const student = await Student.findById(studentId);
 
 
355
  const config = await AchievementConfigModel.findOne({ className: student.className, ...getQueryFilter(req) });
356
  const rule = config?.exchangeRules.find(r => r.id === ruleId);
357
  if (!rule) return res.status(404).json({ error: 'Exchange rule not found' });
358
+ if ((student.flowerBalance || 0) < rule.cost) return res.status(400).json({ error: 'INSUFFICIENT_FLOWERS', message: '小红花余额不足' });
 
 
 
359
 
360
  await Student.findByIdAndUpdate(studentId, { $inc: { flowerBalance: -rule.cost } });
 
361
  let status = 'PENDING';
362
  if (rule.rewardType === 'DRAW_COUNT') {
363
  status = 'REDEEMED';
364
  await Student.findByIdAndUpdate(studentId, { $inc: { drawAttempts: rule.rewardValue } });
365
  }
366
+ await StudentRewardModel.create({ schoolId: sId, studentId, studentName: student.name, rewardType: rule.rewardType, name: rule.rewardName, count: rule.rewardValue, status: status, source: `积分兑换` });
 
 
 
 
 
 
 
 
 
 
367
  res.json({ success: true });
368
  });
369
 
 
374
  const config = await LuckyDrawConfigModel.findOne(filter);
375
  res.json(config || { prizes: [], dailyLimit: 3, cardCount: 9, defaultPrize: '再接再厉' });
376
  });
 
377
  app.post('/api/games/lucky-config', async (req, res) => {
378
  const data = injectSchoolId(req, req.body);
379
+ await LuckyDrawConfigModel.findOneAndUpdate({ className: data.className, ...getQueryFilter(req) }, data, { upsert: true });
 
 
 
 
 
380
  res.json({ success: true });
381
  });
 
382
  app.post('/api/games/lucky-draw', async (req, res) => {
383
  const { studentId } = req.body;
384
  const schoolId = req.headers['x-school-id'];
 
387
  try {
388
  const student = await Student.findById(studentId);
389
  if (!student) return res.status(404).json({ error: 'Student not found' });
390
+ const config = await LuckyDrawConfigModel.findOne({ className: student.className, ...getQueryFilter(req) });
 
 
 
 
391
  const prizes = config?.prizes || [];
392
  const defaultPrize = config?.defaultPrize || '再接再厉';
393
  const dailyLimit = config?.dailyLimit || 3;
394
  const consolationWeight = config?.consolationWeight || 0;
395
 
 
 
396
  const availablePrizes = prizes.filter(p => (p.count === undefined || p.count > 0));
 
 
397
  if (availablePrizes.length === 0 && consolationWeight === 0) {
398
  const isTeacherOrAdmin = userRole === 'TEACHER' || userRole === 'ADMIN';
399
+ const msg = isTeacherOrAdmin ? '奖品库存不足,不能抽奖,请先补充库存' : '奖品库存不足,不能抽奖,请联系班主任补充库存';
 
 
400
  return res.status(400).json({ error: 'POOL_EMPTY', message: msg });
401
  }
402
 
 
403
  if (userRole === 'STUDENT') {
404
  if (student.drawAttempts <= 0) return res.status(403).json({ error: '次数不足', message: '您的抽奖次数已用完' });
405
  const today = new Date().toISOString().split('T')[0];
406
  let dailyLog = student.dailyDrawLog || { date: today, count: 0 };
407
  if (dailyLog.date !== today) dailyLog = { date: today, count: 0 };
408
  if (dailyLog.count >= dailyLimit) return res.status(403).json({ error: 'DAILY_LIMIT_REACHED', message: `今日抽奖次数已达上限 (${dailyLimit}次)` });
 
409
  dailyLog.count += 1;
410
  student.drawAttempts -= 1;
411
  student.dailyDrawLog = dailyLog;
 
416
  await student.save();
417
  }
418
 
 
419
  let totalWeight = consolationWeight;
420
  availablePrizes.forEach(p => totalWeight += (p.probability || 0));
 
421
  let random = Math.random() * totalWeight;
422
  let selectedPrize = defaultPrize;
423
  let rewardType = 'CONSOLATION';
 
434
  if (matchedPrize) {
435
  selectedPrize = matchedPrize.name;
436
  rewardType = 'ITEM';
437
+ if (config._id) await LuckyDrawConfigModel.updateOne({ _id: config._id, "prizes.id": matchedPrize.id }, { $inc: { "prizes.$.count": -1 } });
 
 
 
 
 
438
  }
439
+ await StudentRewardModel.create({ schoolId, studentId, studentName: student.name, rewardType, name: selectedPrize, count: 1, status: 'PENDING', source: '幸运大抽奖' });
 
 
 
 
 
 
 
 
 
 
440
  res.json({ prize: selectedPrize, rewardType });
441
+ } catch (e) { res.status(500).json({ error: e.message }); }
 
 
 
442
  });
443
 
444
  // --- Standard Routes ---
445
  app.get('/api/notifications', async (req, res) => {
446
  const { role, userId } = req.query;
447
+ const query = { $and: [ getQueryFilter(req), { $or: [ { targetRole: null, targetUserId: null }, { targetRole: role }, { targetUserId: userId } ] } ] };
 
 
 
 
 
448
  res.json(await NotificationModel.find(query).sort({ createTime: -1 }).limit(20));
449
  });
 
450
  app.get('/api/public/schools', async (req, res) => { res.json(await School.find({}, 'name code _id')); });
 
 
451
  app.get('/api/public/config', async (req, res) => {
452
  const currentSem = getAutoSemester();
453
+ let config = await ConfigModel.findOne({ key: 'main' });
454
  if (config) {
 
 
 
 
 
455
  let semesters = config.semesters || [];
456
  if (!semesters.includes(currentSem)) {
457
+ semesters.unshift(currentSem);
 
458
  config.semesters = semesters;
459
  config.semester = currentSem;
460
+ await ConfigModel.updateOne({ key: 'main' }, { semesters, semester: currentSem });
461
  }
 
462
  } else {
463
+ config = { key: 'main', allowRegister: true, semester: currentSem, semesters: [currentSem] };
464
  }
465
+ res.json(config);
466
  });
 
467
  app.get('/api/public/meta', async (req, res) => { res.json({ classes: await ClassModel.find({ schoolId: req.query.schoolId }), subjects: await SubjectModel.find({ schoolId: req.query.schoolId }) }); });
 
468
  app.post('/api/auth/login', async (req, res) => {
469
  const { username, password } = req.body;
470
  const user = await User.findOne({ username, password });
 
472
  if (user.status !== 'active') return res.status(403).json({ error: 'PENDING_APPROVAL' });
473
  res.json(user);
474
  });
475
+ app.post('/api/auth/register', async (req, res) => { try { await User.create({...req.body, status: 'pending'}); res.json({}); } catch(e) { res.status(500).json({}); } });
 
 
 
476
  app.get('/api/schools', async (req, res) => { res.json(await School.find()); });
477
  app.post('/api/schools', async (req, res) => { res.json(await School.create(req.body)); });
478
  app.put('/api/schools/:id', async (req, res) => { await School.findByIdAndUpdate(req.params.id, req.body); res.json({}); });
 
479
  app.get('/api/users', async (req, res) => { res.json(await User.find(getQueryFilter(req))); });
480
  app.put('/api/users/:id', async (req, res) => { await User.findByIdAndUpdate(req.params.id, req.body); res.json({}); });
481
  app.delete('/api/users/:id', async (req, res) => { await User.findByIdAndDelete(req.params.id); res.json({}); });
 
482
  app.get('/api/students', async (req, res) => { res.json(await Student.find(getQueryFilter(req))); });
483
  app.post('/api/students', async (req, res) => { await Student.findOneAndUpdate({ studentNo: req.body.studentNo }, injectSchoolId(req, req.body), {upsert:true}); res.json({}); });
484
  app.put('/api/students/:id', async (req, res) => { await Student.findByIdAndUpdate(req.params.id, req.body); res.json({}); });
485
  app.delete('/api/students/:id', async (req, res) => { await Student.findByIdAndDelete(req.params.id); res.json({}); });
 
486
  app.get('/api/classes', async (req, res) => {
487
  const cls = await ClassModel.find(getQueryFilter(req));
488
  const resData = await Promise.all(cls.map(async c => ({...c.toObject(), studentCount: await Student.countDocuments({className:c.grade+c.className})})));
489
  res.json(resData);
490
  });
491
  app.post('/api/classes', async (req, res) => {
 
492
  const data = injectSchoolId(req, req.body);
493
  await ClassModel.create(data);
494
  if (data.teacherName) {
495
  const fullClass = data.grade + data.className;
496
+ await User.updateOne({ trueName: data.teacherName, schoolId: data.schoolId }, { homeroomClass: fullClass });
497
+ }
498
+ res.json({});
499
+ });
500
+ // UPDATE CLASS (Edit Teacher)
501
+ app.put('/api/classes/:id', async (req, res) => {
502
+ const classId = req.params.id;
503
+ const { grade, className, teacherName } = req.body;
504
+ const sId = req.headers['x-school-id'];
505
+
506
+ const oldClass = await ClassModel.findById(classId);
507
+ if (!oldClass) return res.status(404).json({ error: 'Class not found' });
508
+
509
+ const newFullClass = grade + className;
510
+ const oldFullClass = oldClass.grade + oldClass.className;
511
+
512
+ // 1. If teacher changed or class name changed, update old teacher
513
+ if (oldClass.teacherName && (oldClass.teacherName !== teacherName || oldFullClass !== newFullClass)) {
514
  await User.updateOne(
515
+ { trueName: oldClass.teacherName, schoolId: sId, homeroomClass: oldFullClass },
516
+ { homeroomClass: '' }
517
  );
518
  }
519
+
520
+ // 2. If new teacher assigned, update their homeroomClass
521
+ if (teacherName) {
522
+ // Clear any previous homeroom for this teacher? Assuming one teacher one class
523
+ await User.updateOne(
524
+ { trueName: teacherName, schoolId: sId },
525
+ { homeroomClass: newFullClass }
526
+ );
527
+ }
528
+
529
+ // 3. Update Class Record
530
+ await ClassModel.findByIdAndUpdate(classId, { grade, className, teacherName });
531
+
532
+ // 4. Update Students if class name changed
533
+ if (oldFullClass !== newFullClass) {
534
+ await Student.updateMany({ className: oldFullClass, schoolId: sId }, { className: newFullClass });
535
+ }
536
+
537
+ res.json({ success: true });
538
  });
539
+
540
  app.delete('/api/classes/:id', async (req, res) => {
 
541
  const cls = await ClassModel.findById(req.params.id);
542
  if (cls && cls.teacherName) {
543
  const fullClass = cls.grade + cls.className;
544
+ await User.updateOne({ trueName: cls.teacherName, schoolId: cls.schoolId, homeroomClass: fullClass }, { homeroomClass: '' });
 
 
 
545
  }
546
  await ClassModel.findByIdAndDelete(req.params.id);
547
  res.json({});
548
  });
 
549
  app.get('/api/subjects', async (req, res) => { res.json(await SubjectModel.find(getQueryFilter(req))); });
550
  app.post('/api/subjects', async (req, res) => { await SubjectModel.create(injectSchoolId(req, req.body)); res.json({}); });
551
  app.put('/api/subjects/:id', async (req, res) => { await SubjectModel.findByIdAndUpdate(req.params.id, req.body); res.json({}); });
552
  app.delete('/api/subjects/:id', async (req, res) => { await SubjectModel.findByIdAndDelete(req.params.id); res.json({}); });
 
553
  app.get('/api/courses', async (req, res) => { res.json(await Course.find(getQueryFilter(req))); });
554
  app.post('/api/courses', async (req, res) => { await Course.create(injectSchoolId(req, req.body)); res.json({}); });
555
  app.put('/api/courses/:id', async (req, res) => { await Course.findByIdAndUpdate(req.params.id, req.body); res.json({}); });
556
  app.delete('/api/courses/:id', async (req, res) => { await Course.findByIdAndDelete(req.params.id); res.json({}); });
 
557
  app.get('/api/scores', async (req, res) => { res.json(await Score.find(getQueryFilter(req))); });
558
  app.post('/api/scores', async (req, res) => { await Score.create(injectSchoolId(req, req.body)); res.json({}); });
559
  app.put('/api/scores/:id', async (req, res) => { await Score.findByIdAndUpdate(req.params.id, req.body); res.json({}); });
560
  app.delete('/api/scores/:id', async (req, res) => { await Score.findByIdAndDelete(req.params.id); res.json({}); });
 
561
  app.get('/api/exams', async (req, res) => { res.json(await ExamModel.find(getQueryFilter(req))); });
562
  app.post('/api/exams', async (req, res) => { await ExamModel.findOneAndUpdate({name:req.body.name}, injectSchoolId(req, req.body), {upsert:true}); res.json({}); });
 
563
  app.get('/api/schedules', async (req, res) => {
564
  const query = { ...getQueryFilter(req), ...req.query };
565
+ if (query.grade) { query.className = { $regex: '^' + query.grade }; delete query.grade; }
 
 
 
566
  res.json(await ScheduleModel.find(query));
567
  });
568
  app.post('/api/schedules', async (req, res) => {
 
573
  res.json({});
574
  });
575
  app.delete('/api/schedules', async (req, res) => { await ScheduleModel.deleteOne({...getQueryFilter(req), ...req.query}); res.json({}); });
 
576
  app.get('/api/stats', async (req, res) => {
577
  const filter = getQueryFilter(req);
578
  const studentCount = await Student.countDocuments(filter);
579
  const courseCount = await Course.countDocuments(filter);
580
  const scores = await Score.find({...filter, status: 'Normal'});
581
+ let avgScore = 0; let excellentRate = '0%';
 
582
  if (scores.length > 0) {
583
  const total = scores.reduce((sum, s) => sum + s.score, 0);
584
  avgScore = parseFloat((total / scores.length).toFixed(1));
 
587
  }
588
  res.json({ studentCount, courseCount, avgScore, excellentRate });
589
  });
 
590
  app.get('/api/config', async (req, res) => {
 
591
  const currentSem = getAutoSemester();
592
  let config = await ConfigModel.findOne({key:'main'});
 
593
  if (config) {
594
  let semesters = config.semesters || [];
595
  if (!semesters.includes(currentSem)) {
596
  semesters.unshift(currentSem);
597
  config.semesters = semesters;
598
+ config.semester = currentSem;
599
  await ConfigModel.updateOne({ key: 'main' }, { semesters, semester: currentSem });
600
  }
601
  } else {
 
604
  res.json(config);
605
  });
606
  app.post('/api/config', async (req, res) => { await ConfigModel.findOneAndUpdate({key:'main'}, req.body, {upsert:true}); res.json({}); });
 
607
  app.get('/api/games/mountain', async (req, res) => { res.json(await GameSessionModel.findOne({...getQueryFilter(req), className: req.query.className})); });
608
  app.post('/api/games/mountain', async (req, res) => {
609
  const filter = { className: req.body.className };
 
612
  await GameSessionModel.findOneAndUpdate(filter, injectSchoolId(req, req.body), {upsert:true});
613
  res.json({});
614
  });
 
615
  app.post('/api/games/grant-reward', async (req, res) => {
616
  const { studentId, count, rewardType, name } = req.body;
617
  const finalCount = count || 1;
618
  const finalName = name || (rewardType === 'DRAW_COUNT' ? '抽奖券' : '奖品');
619
  if (rewardType === 'DRAW_COUNT') await Student.findByIdAndUpdate(studentId, { $inc: { drawAttempts: finalCount } });
620
+ await StudentRewardModel.create({ schoolId: req.headers['x-school-id'], studentId, studentName: (await Student.findById(studentId)).name, rewardType, name: finalName, count: finalCount, status: rewardType === 'DRAW_COUNT' ? 'REDEEMED' : 'PENDING', source: '教师发放' });
 
 
 
 
 
 
 
 
 
621
  res.json({});
622
  });
 
623
  app.put('/api/rewards/:id', async (req, res) => { await StudentRewardModel.findByIdAndUpdate(req.params.id, req.body); res.json({}); });
624
  app.delete('/api/rewards/:id', async (req, res) => {
625
  const reward = await StudentRewardModel.findById(req.params.id);
 
632
  await StudentRewardModel.findByIdAndDelete(req.params.id);
633
  res.json({});
634
  });
 
635
  app.get('/api/rewards', async (req, res) => {
636
  const filter = getQueryFilter(req);
637
  if(req.query.studentId) filter.studentId = req.query.studentId;
 
647
  const list = await StudentRewardModel.find(filter).sort({createTime:-1}).skip(skip).limit(limit);
648
  res.json({ list, total });
649
  });
 
650
  app.post('/api/rewards', async (req, res) => {
651
  const data = injectSchoolId(req, req.body);
652
  if (!data.count) data.count = 1;
653
+ if(data.rewardType==='DRAW_COUNT') { data.status='REDEEMED'; await Student.findByIdAndUpdate(data.studentId, {$inc:{drawAttempts:data.count}}); }
 
 
 
654
  await StudentRewardModel.create(data);
655
  res.json({});
656
  });
657
  app.post('/api/rewards/:id/redeem', async (req, res) => { await StudentRewardModel.findByIdAndUpdate(req.params.id, {status:'REDEEMED'}); res.json({}); });
 
658
  app.get('/api/attendance', async (req, res) => {
659
  const { className, date, studentId } = req.query;
660
  const filter = getQueryFilter(req);
 
691
  if (student) await AttendanceModel.findOneAndUpdate({ studentId, date: startDate }, { schoolId: req.headers['x-school-id'], studentId, studentName: student.name, className: student.className, date: startDate, status: 'Leave', checkInTime: new Date() }, { upsert: true });
692
  res.json({ success: true });
693
  });
 
694
  app.post('/api/batch-delete', async (req, res) => {
695
  if(req.body.type==='student') await Student.deleteMany({_id:{$in:req.body.ids}});
696
  if(req.body.type==='score') await Score.deleteMany({_id:{$in:req.body.ids}});