dvc890 commited on
Commit
bd96783
·
verified ·
1 Parent(s): c01eaaf

Upload 37 files

Browse files
Files changed (7) hide show
  1. pages/GameLucky.tsx +22 -6
  2. pages/Settings.tsx +97 -11
  3. pages/StudentList.tsx +45 -5
  4. pages/UserList.tsx +111 -10
  5. server.js +183 -37
  6. services/api.ts +9 -3
  7. types.ts +9 -2
pages/GameLucky.tsx CHANGED
@@ -2,7 +2,7 @@
2
  import React, { useState, useEffect } from 'react';
3
  import { api } from '../services/api';
4
  import { LuckyDrawConfig, Student, LuckyPrize } from '../types';
5
- import { Gift, Settings, Loader2, Save, Trash2, X, UserCircle, RefreshCcw } from 'lucide-react';
6
 
7
  const FlipCard = ({ index, prize, onFlip, isRevealed, activeIndex }: { index: number, prize: string, onFlip: (idx: number) => void, isRevealed: boolean, activeIndex: number | null }) => {
8
  const showBack = isRevealed && activeIndex === index;
@@ -118,7 +118,7 @@ export const GameLucky: React.FC = () => {
118
  setActiveCardIndex(null);
119
  }, 2000);
120
  } catch (e: any) {
121
- // Use backend message directly if available, otherwise fallback
122
  alert(e.message || '抽奖失败,请稍后重试');
123
  setIsFlipping(false);
124
  setActiveCardIndex(null);
@@ -215,7 +215,7 @@ export const GameLucky: React.FC = () => {
215
  {/* SETTINGS MODAL */}
216
  {isSettingsOpen && (
217
  <div className="fixed inset-0 bg-black/60 z-[100] flex items-center justify-center p-4 backdrop-blur-sm">
218
- <div className="bg-white rounded-2xl w-full max-w-3xl h-[85vh] flex flex-col shadow-2xl animate-in zoom-in-95">
219
  <div className="p-6 border-b border-gray-100 flex justify-between items-center">
220
  <h3 className="text-xl font-bold text-gray-800">红包奖池配置 - {currentClassName}</h3>
221
  <button onClick={() => setIsSettingsOpen(false)}><X size={24} className="text-gray-400 hover:text-gray-600"/></button>
@@ -237,12 +237,28 @@ export const GameLucky: React.FC = () => {
237
  </div>
238
  </div>
239
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
240
  <div className="bg-white rounded-xl border shadow-sm overflow-hidden">
241
  <table className="w-full text-sm text-left">
242
  <thead className="bg-gray-100 text-gray-500 uppercase text-xs">
243
  <tr>
244
  <th className="p-4">奖品名称</th>
245
- <th className="p-4 w-24 text-center">概率 (%)</th>
246
  <th className="p-4 w-24 text-center">库存</th>
247
  <th className="p-4 w-16 text-right">操作</th>
248
  </tr>
@@ -258,7 +274,7 @@ export const GameLucky: React.FC = () => {
258
  <td className="p-3">
259
  <input type="number" value={p.probability} onChange={e => {
260
  const np = [...luckyConfig.prizes]; np[idx].probability = Number(e.target.value); setLuckyConfig({...luckyConfig, prizes: np});
261
- }} className="w-full text-center border rounded bg-gray-50 py-1 focus:bg-white"/>
262
  </td>
263
  <td className="p-3">
264
  <input type="number" value={p.count} onChange={e => {
@@ -293,4 +309,4 @@ export const GameLucky: React.FC = () => {
293
  )}
294
  </div>
295
  );
296
- };
 
2
  import React, { useState, useEffect } from 'react';
3
  import { api } from '../services/api';
4
  import { LuckyDrawConfig, Student, LuckyPrize } from '../types';
5
+ import { Gift, Settings, Loader2, Save, Trash2, X, UserCircle, RefreshCcw, HelpCircle } from 'lucide-react';
6
 
7
  const FlipCard = ({ index, prize, onFlip, isRevealed, activeIndex }: { index: number, prize: string, onFlip: (idx: number) => void, isRevealed: boolean, activeIndex: number | null }) => {
8
  const showBack = isRevealed && activeIndex === index;
 
118
  setActiveCardIndex(null);
119
  }, 2000);
120
  } catch (e: any) {
121
+ // Use backend message directly
122
  alert(e.message || '抽奖失败,请稍后重试');
123
  setIsFlipping(false);
124
  setActiveCardIndex(null);
 
215
  {/* SETTINGS MODAL */}
216
  {isSettingsOpen && (
217
  <div className="fixed inset-0 bg-black/60 z-[100] flex items-center justify-center p-4 backdrop-blur-sm">
218
+ <div className="bg-white rounded-2xl w-full max-w-4xl h-[90vh] flex flex-col shadow-2xl animate-in zoom-in-95">
219
  <div className="p-6 border-b border-gray-100 flex justify-between items-center">
220
  <h3 className="text-xl font-bold text-gray-800">红包奖池配置 - {currentClassName}</h3>
221
  <button onClick={() => setIsSettingsOpen(false)}><X size={24} className="text-gray-400 hover:text-gray-600"/></button>
 
237
  </div>
238
  </div>
239
 
240
+ {/* WEIGHT CONFIG */}
241
+ <div className="mb-6 bg-amber-50 p-4 rounded-xl border border-amber-100">
242
+ <h4 className="font-bold text-amber-800 text-sm mb-2 flex items-center"><HelpCircle size={16} className="mr-1"/> 抽奖权重规则说明</h4>
243
+ <p className="text-xs text-amber-700 mb-2">中奖概率 = (该奖品权重 / 总权重池) * 100%。<br/>总权重池 = 所有上架奖品的权重之和 + 安慰奖权重。</p>
244
+ <div className="flex items-center gap-4 mt-3">
245
+ <label className="text-sm font-bold text-gray-700">设置安慰奖(未中奖)权重:</label>
246
+ <input
247
+ type="number"
248
+ className="border rounded px-2 py-1 w-24 text-center font-bold"
249
+ value={luckyConfig.consolationWeight || 0}
250
+ onChange={e => setLuckyConfig({...luckyConfig, consolationWeight: Number(e.target.value)})}
251
+ />
252
+ <span className="text-xs text-gray-500">设为0则表示百分百中奖(只要有库存)</span>
253
+ </div>
254
+ </div>
255
+
256
  <div className="bg-white rounded-xl border shadow-sm overflow-hidden">
257
  <table className="w-full text-sm text-left">
258
  <thead className="bg-gray-100 text-gray-500 uppercase text-xs">
259
  <tr>
260
  <th className="p-4">奖品名称</th>
261
+ <th className="p-4 w-32 text-center">权重 (Probability)</th>
262
  <th className="p-4 w-24 text-center">库存</th>
263
  <th className="p-4 w-16 text-right">操作</th>
264
  </tr>
 
274
  <td className="p-3">
275
  <input type="number" value={p.probability} onChange={e => {
276
  const np = [...luckyConfig.prizes]; np[idx].probability = Number(e.target.value); setLuckyConfig({...luckyConfig, prizes: np});
277
+ }} className="w-full text-center border rounded bg-gray-50 py-1 focus:bg-white font-mono"/>
278
  </td>
279
  <td className="p-3">
280
  <input type="number" value={p.count} onChange={e => {
 
309
  )}
310
  </div>
311
  );
312
+ };
pages/Settings.tsx CHANGED
@@ -1,6 +1,6 @@
1
 
2
  import React, { useState, useEffect } from 'react';
3
- import { Save, Bell, Lock, Database, Loader2, Plus, X, Trash2, Globe, Edit } from 'lucide-react';
4
  import { api } from '../services/api';
5
  import { SystemConfig } from '../types';
6
 
@@ -24,6 +24,10 @@ export const Settings: React.FC = () => {
24
  const [editingIndex, setEditingIndex] = useState<number | null>(null);
25
  const [editVal, setEditVal] = useState('');
26
 
 
 
 
 
27
  useEffect(() => {
28
  const loadConfig = async () => {
29
  try {
@@ -63,6 +67,23 @@ export const Settings: React.FC = () => {
63
  }
64
  };
65
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
  const removeSemester = (idx: number) => {
67
  const list = [...(config.semesters || [])];
68
  const valToRemove = list[idx];
@@ -90,6 +111,21 @@ export const Settings: React.FC = () => {
90
  setEditingIndex(null);
91
  };
92
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
  if (fetching) return <div className="p-10 text-center text-gray-500">加载配置中...</div>;
94
 
95
  return (
@@ -122,19 +158,26 @@ export const Settings: React.FC = () => {
122
 
123
  <div className="space-y-2">
124
  <label className="text-sm font-medium text-gray-700">当前生效学期</label>
125
- <select
126
- value={config.semester}
127
- onChange={(e) => setConfig({...config, semester: e.target.value})}
128
- className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none bg-white"
129
- >
130
- {(config.semesters || []).map(s => <option key={s} value={s}>{s}</option>)}
131
- </select>
 
 
 
132
  </div>
133
 
134
  {/* Semester Management List */}
135
  <div className="bg-gray-50 p-4 rounded-lg border border-gray-200">
136
- <label className="text-xs font-bold text-gray-500 uppercase mb-2 block">学期列表管理</label>
137
- <div className="space-y-2 mb-3">
 
 
 
 
138
  {(config.semesters || []).map((s, idx) => (
139
  <div key={idx} className="flex items-center gap-2 bg-white p-2 rounded border border-gray-200">
140
  {editingIndex === idx ? (
@@ -166,6 +209,49 @@ export const Settings: React.FC = () => {
166
  </div>
167
  </div>
168
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
169
  {/* Security & Access */}
170
  <div className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
171
  <div className="p-6 border-b border-gray-100 flex items-center space-x-3">
@@ -246,4 +332,4 @@ export const Settings: React.FC = () => {
246
  </div>
247
  </div>
248
  );
249
- };
 
1
 
2
  import React, { useState, useEffect } from 'react';
3
+ import { Save, Bell, Lock, Database, Loader2, Plus, X, Trash2, Globe, Edit, Calendar, GraduationCap } from 'lucide-react';
4
  import { api } from '../services/api';
5
  import { SystemConfig } from '../types';
6
 
 
24
  const [editingIndex, setEditingIndex] = useState<number | null>(null);
25
  const [editVal, setEditVal] = useState('');
26
 
27
+ // Promotion State
28
+ const [teacherFollows, setTeacherFollows] = useState(false);
29
+ const [promoting, setPromoting] = useState(false);
30
+
31
  useEffect(() => {
32
  const loadConfig = async () => {
33
  try {
 
67
  }
68
  };
69
 
70
+ // Auto Generate Semester Name
71
+ const autoGenerateSemester = () => {
72
+ const now = new Date();
73
+ const year = now.getFullYear();
74
+ const month = now.getMonth() + 1;
75
+ let semName = '';
76
+ if (month >= 8 || month <= 1) {
77
+ semName = `${year}-${year+1}学年 第一学期`;
78
+ } else {
79
+ semName = `${year-1}-${year}学年 第二学期`;
80
+ }
81
+ if (!config.semesters?.includes(semName)) {
82
+ setConfig(prev => ({ ...prev, semesters: [semName, ...(prev.semesters || [])] })); // Prepend
83
+ }
84
+ setNewSemesterVal(semName);
85
+ };
86
+
87
  const removeSemester = (idx: number) => {
88
  const list = [...(config.semesters || [])];
89
  const valToRemove = list[idx];
 
111
  setEditingIndex(null);
112
  };
113
 
114
+ // --- Promotion Logic ---
115
+ const handlePromotion = async () => {
116
+ if (!confirm(`⚠️ 高风险操作警告!\n\n1. 所有学生年级将+1 (如: 一年级->二年级)\n2. 六年级/初三/高三学生将标记为“毕业”\n3. ${teacherFollows ? '班主任将随班升级 (继续带该班)' : '班主任将留在原年级 (班级变空)'}\n\n确定要执行全校升学吗?`)) return;
117
+
118
+ setPromoting(true);
119
+ try {
120
+ const res = await api.students.promote({ teacherFollows });
121
+ alert(`升学操作完成!\n共处理学生: ${res.count} 人。`);
122
+ } catch (e: any) {
123
+ alert('操作失败: ' + e.message);
124
+ } finally {
125
+ setPromoting(false);
126
+ }
127
+ };
128
+
129
  if (fetching) return <div className="p-10 text-center text-gray-500">加载配置中...</div>;
130
 
131
  return (
 
158
 
159
  <div className="space-y-2">
160
  <label className="text-sm font-medium text-gray-700">当前生效学期</label>
161
+ <div className="flex gap-2">
162
+ <select
163
+ value={config.semester}
164
+ onChange={(e) => setConfig({...config, semester: e.target.value})}
165
+ className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none bg-white"
166
+ >
167
+ {(config.semesters || []).map(s => <option key={s} value={s}>{s}</option>)}
168
+ </select>
169
+ </div>
170
+ <p className="text-xs text-gray-500">切换学期后,所有成就、成绩、考勤数据将关联到新学期。</p>
171
  </div>
172
 
173
  {/* Semester Management List */}
174
  <div className="bg-gray-50 p-4 rounded-lg border border-gray-200">
175
+ <div className="flex justify-between items-center mb-2">
176
+ <label className="text-xs font-bold text-gray-500 uppercase block">学期列表管理</label>
177
+ <button onClick={autoGenerateSemester} className="text-xs bg-white border px-2 py-1 rounded hover:bg-blue-50 text-blue-600 flex items-center"><Calendar size={12} className="mr-1"/> 自动生成当前学期</button>
178
+ </div>
179
+
180
+ <div className="space-y-2 mb-3 max-h-40 overflow-y-auto">
181
  {(config.semesters || []).map((s, idx) => (
182
  <div key={idx} className="flex items-center gap-2 bg-white p-2 rounded border border-gray-200">
183
  {editingIndex === idx ? (
 
209
  </div>
210
  </div>
211
 
212
+ {/* Academic Year Management */}
213
+ <div className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
214
+ <div className="p-6 border-b border-gray-100 flex items-center space-x-3">
215
+ <div className="p-2 bg-purple-50 rounded-lg text-purple-600">
216
+ <GraduationCap size={20} />
217
+ </div>
218
+ <h3 className="text-lg font-bold text-gray-800">学年与升学管理</h3>
219
+ </div>
220
+ <div className="p-6">
221
+ <div className="bg-red-50 border border-red-100 p-4 rounded-xl">
222
+ <h4 className="font-bold text-red-800 mb-2">一键升学 (年级晋升)</h4>
223
+ <p className="text-sm text-red-700 mb-4">
224
+ 此操作将自动把所有学生升级到下一年级 (如: 一年级 -> 二年级)。<br/>
225
+ 最高年级学生将自动标记为“毕业”。如果新班级不存在,系统将自动创建。
226
+ </p>
227
+
228
+ <div className="flex items-center gap-2 mb-4">
229
+ <input
230
+ type="checkbox"
231
+ id="teacherFollows"
232
+ className="w-5 h-5 text-blue-600"
233
+ checked={teacherFollows}
234
+ onChange={e => setTeacherFollows(e.target.checked)}
235
+ />
236
+ <label htmlFor="teacherFollows" className="text-sm font-bold text-gray-700">启用“班主任随班升级”</label>
237
+ </div>
238
+ <p className="text-xs text-gray-500 mb-4 pl-7">
239
+ * 选中: 张老师带“一年级(1)班”,升学后继续带“二年级(1)班”。<br/>
240
+ * 未选: 张老师留在“一年级”,新的一年级(1)班将没有学生,二年级(1)班无班主任。
241
+ </p>
242
+
243
+ <button
244
+ onClick={handlePromotion}
245
+ disabled={promoting}
246
+ className="bg-red-600 text-white px-6 py-2 rounded-lg font-bold hover:bg-red-700 shadow-md flex items-center disabled:opacity-50"
247
+ >
248
+ {promoting && <Loader2 className="animate-spin mr-2"/>}
249
+ 执行全校升学
250
+ </button>
251
+ </div>
252
+ </div>
253
+ </div>
254
+
255
  {/* Security & Access */}
256
  <div className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
257
  <div className="p-6 border-b border-gray-100 flex items-center space-x-3">
 
332
  </div>
333
  </div>
334
  );
335
+ };
pages/StudentList.tsx CHANGED
@@ -1,11 +1,10 @@
1
 
2
  import React, { useState, useEffect } from 'react';
3
- import { Search, Plus, Upload, Edit, Trash2, X, Loader2, User, FileSpreadsheet } from 'lucide-react';
4
  import { api } from '../services/api';
5
  import { Student, ClassInfo } from '../types';
6
- import { sortGrades, sortClasses } from './Dashboard'; // Reuse Sort Logic if possible, otherwise define locally
7
 
8
- // Re-define sort logic locally to be safe if imports fail or circular dependency risks
9
  const localSortGrades = (a: string, b: string) => {
10
  const order: Record<string, number> = { '一年级': 1, '二年级': 2, '三年级': 3, '四年级': 4, '五年级': 5, '六年级': 6, '初一': 7, '七年级': 7, '初二': 8, '八年级': 8, '初三': 9, '九年级': 9, '高一': 10, '高二': 11, '高三': 12 };
11
  return (order[a] || 99) - (order[b] || 99);
@@ -19,11 +18,13 @@ export const StudentList: React.FC = () => {
19
 
20
  const [isModalOpen, setIsModalOpen] = useState(false);
21
  const [isImportOpen, setIsImportOpen] = useState(false);
 
22
  const [submitting, setSubmitting] = useState(false);
23
  const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
24
 
25
- // Edit Mode State
26
  const [editStudentId, setEditStudentId] = useState<string | null>(null);
 
27
 
28
  // Filters
29
  const [selectedGrade, setSelectedGrade] = useState('All');
@@ -131,6 +132,26 @@ export const StudentList: React.FC = () => {
131
  setIsModalOpen(true);
132
  };
133
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
134
  const handleSubmit = async (e: React.FormEvent) => {
135
  e.preventDefault();
136
  setSubmitting(true);
@@ -344,6 +365,7 @@ export const StudentList: React.FC = () => {
344
  <td className="px-6 py-4 text-right flex justify-end space-x-2">
345
  {canEdit ? (
346
  <>
 
347
  <button onClick={() => handleOpenEdit(s)} className="text-blue-400 hover:text-blue-600" title="编辑"><Edit size={16}/></button>
348
  <button onClick={() => handleDelete(s._id || String(s.id), s.className)} className="text-red-400 hover:text-red-600" title="删除"><Trash2 size={16}/></button>
349
  </>
@@ -406,6 +428,24 @@ export const StudentList: React.FC = () => {
406
  </div>
407
  </div>
408
  )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
409
 
410
 
411
  {isImportOpen && (
@@ -447,4 +487,4 @@ export const StudentList: React.FC = () => {
447
  )}
448
  </div>
449
  );
450
- };
 
1
 
2
  import React, { useState, useEffect } from 'react';
3
+ import { Search, Plus, Upload, Edit, Trash2, X, Loader2, User, FileSpreadsheet, ArrowRightLeft } from 'lucide-react';
4
  import { api } from '../services/api';
5
  import { Student, ClassInfo } from '../types';
 
6
 
7
+ // Re-define sort logic locally
8
  const localSortGrades = (a: string, b: string) => {
9
  const order: Record<string, number> = { '一年级': 1, '二年级': 2, '三年级': 3, '四年级': 4, '五年级': 5, '六年级': 6, '初一': 7, '七年级': 7, '初二': 8, '八年级': 8, '初三': 9, '九年级': 9, '高一': 10, '高二': 11, '高三': 12 };
10
  return (order[a] || 99) - (order[b] || 99);
 
18
 
19
  const [isModalOpen, setIsModalOpen] = useState(false);
20
  const [isImportOpen, setIsImportOpen] = useState(false);
21
+ const [isTransferOpen, setIsTransferOpen] = useState(false);
22
  const [submitting, setSubmitting] = useState(false);
23
  const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
24
 
25
+ // Edit/Transfer State
26
  const [editStudentId, setEditStudentId] = useState<string | null>(null);
27
+ const [transferTargetClass, setTransferTargetClass] = useState('');
28
 
29
  // Filters
30
  const [selectedGrade, setSelectedGrade] = useState('All');
 
132
  setIsModalOpen(true);
133
  };
134
 
135
+ const handleOpenTransfer = (student: Student) => {
136
+ if (!hasPermission(student.className)) return alert('无权操作调班');
137
+ setEditStudentId(student._id || String(student.id));
138
+ setTransferTargetClass(student.className);
139
+ setIsTransferOpen(true);
140
+ };
141
+
142
+ const submitTransfer = async () => {
143
+ if (!editStudentId || !transferTargetClass) return;
144
+ if (confirm(`确定将学生调转至 ${transferTargetClass} 吗?`)) {
145
+ setSubmitting(true);
146
+ try {
147
+ await api.students.transfer({ studentId: editStudentId, targetClass: transferTargetClass });
148
+ setIsTransferOpen(false);
149
+ loadData();
150
+ } catch(e) { alert('调班失败'); }
151
+ finally { setSubmitting(false); }
152
+ }
153
+ };
154
+
155
  const handleSubmit = async (e: React.FormEvent) => {
156
  e.preventDefault();
157
  setSubmitting(true);
 
365
  <td className="px-6 py-4 text-right flex justify-end space-x-2">
366
  {canEdit ? (
367
  <>
368
+ <button onClick={() => handleOpenTransfer(s)} className="text-amber-500 hover:text-amber-700" title="调班"><ArrowRightLeft size={16}/></button>
369
  <button onClick={() => handleOpenEdit(s)} className="text-blue-400 hover:text-blue-600" title="编辑"><Edit size={16}/></button>
370
  <button onClick={() => handleDelete(s._id || String(s.id), s.className)} className="text-red-400 hover:text-red-600" title="删除"><Trash2 size={16}/></button>
371
  </>
 
428
  </div>
429
  </div>
430
  )}
431
+
432
+ {/* Transfer Modal */}
433
+ {isTransferOpen && (
434
+ <div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
435
+ <div className="bg-white rounded-xl p-6 w-full max-w-sm animate-in zoom-in-95">
436
+ <h3 className="font-bold text-lg mb-4">学生调班</h3>
437
+ <p className="text-sm text-gray-500 mb-4">将选中的学生移动到新的班级。该学生在当前班级的未核销奖励可能会保留,但建议先核销。</p>
438
+ <select className="w-full border p-2 rounded mb-4" value={transferTargetClass} onChange={e=>setTransferTargetClass(e.target.value)}>
439
+ <option value="">-- 选择目标班级 --</option>
440
+ {classList.map(c => <option key={c._id} value={c.grade+c.className}>{c.grade}{c.className}</option>)}
441
+ </select>
442
+ <div className="flex gap-2">
443
+ <button onClick={submitTransfer} disabled={submitting || !transferTargetClass} className="flex-1 bg-amber-500 text-white py-2 rounded font-bold hover:bg-amber-600 disabled:opacity-50">确认调班</button>
444
+ <button onClick={()=>setIsTransferOpen(false)} className="flex-1 border py-2 rounded hover:bg-gray-50">取消</button>
445
+ </div>
446
+ </div>
447
+ </div>
448
+ )}
449
 
450
 
451
  {isImportOpen && (
 
487
  )}
488
  </div>
489
  );
490
+ };
pages/UserList.tsx CHANGED
@@ -1,15 +1,20 @@
1
 
2
  import React, { useState, useEffect } from 'react';
3
- import { User, UserRole, UserStatus, School } from '../types';
4
  import { api } from '../services/api';
5
- import { Loader2, Check, X, Trash2, Building, Edit, Shield } from 'lucide-react';
6
 
7
  export const UserList: React.FC = () => {
8
  const [users, setUsers] = useState<User[]>([]);
9
  const [schools, setSchools] = useState<School[]>([]);
 
10
  const [loading, setLoading] = useState(true);
11
  const [editingUser, setEditingUser] = useState<User | null>(null);
12
 
 
 
 
 
13
  const currentUser = api.auth.getCurrentUser();
14
  const isAdmin = currentUser?.role === UserRole.ADMIN;
15
  const isTeacher = currentUser?.role === UserRole.TEACHER;
@@ -18,12 +23,14 @@ export const UserList: React.FC = () => {
18
  setLoading(true);
19
  try {
20
  // Admin loads global, Teacher only loads local
21
- const [u, s] = await Promise.all([
22
  api.users.getAll({ global: isAdmin }),
23
- api.schools.getAll()
 
24
  ]);
25
  setUsers(u);
26
  setSchools(s);
 
27
  } catch (e) { console.error(e); }
28
  finally { setLoading(false); }
29
  };
@@ -57,6 +64,49 @@ export const UserList: React.FC = () => {
57
  loadData();
58
  };
59
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  const getSchoolName = (id?: string) => schools.find(s => s._id === id)?.name || '未分配';
61
 
62
  // Filter Logic
@@ -76,7 +126,23 @@ export const UserList: React.FC = () => {
76
  return (
77
  <div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6">
78
  <div className="flex justify-between items-center mb-4">
79
- <h2 className="text-xl font-bold text-gray-800">用户权限与审核</h2>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
  {isAdmin && <span className="text-xs text-gray-500 bg-gray-100 px-2 py-1 rounded">全局视图</span>}
81
  {isTeacher && <span className="text-xs text-blue-500 bg-blue-50 px-2 py-1 rounded border border-blue-100">仅显示 {currentUser.homeroomClass || '我班'} 学生</span>}
82
  </div>
@@ -92,7 +158,7 @@ export const UserList: React.FC = () => {
92
  <th className="px-4 py-3">详细信息</th>
93
  <th className="px-4 py-3">所属学校</th>
94
  <th className="px-4 py-3">角色/班级</th>
95
- <th className="px-4 py-3">状态</th>
96
  <th className="px-4 py-3 text-right">操作</th>
97
  </tr>
98
  </thead>
@@ -100,6 +166,8 @@ export const UserList: React.FC = () => {
100
  {filteredUsers.map(user => {
101
  const isSelf = !!(currentUser && (user._id === currentUser._id || user.username === currentUser.username));
102
  const canEdit = isAdmin; // Only admin can edit details, Teachers can only approve
 
 
103
 
104
  return (
105
  <tr key={user._id || user.id} className="hover:bg-gray-50">
@@ -166,9 +234,24 @@ export const UserList: React.FC = () => {
166
  )}
167
  </td>
168
  <td className="px-4 py-3">
169
- {user.status === 'active' && <span className="bg-green-100 text-green-700 px-2 py-1 rounded-full text-xs">已激活</span>}
170
- {user.status === 'pending' && <span className="bg-yellow-100 text-yellow-700 px-2 py-1 rounded-full text-xs animate-pulse">待审核</span>}
171
- {user.status === 'banned' && <span className="bg-red-100 text-red-700 px-2 py-1 rounded-full text-xs">已停用</span>}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
172
  </td>
173
  <td className="px-4 py-3 text-right space-x-2">
174
  {user.status === UserStatus.PENDING && (
@@ -193,6 +276,24 @@ export const UserList: React.FC = () => {
193
  </table>
194
  </div>
195
  )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
196
  </div>
197
  );
198
- };
 
1
 
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[]>([]);
9
  const [schools, setSchools] = useState<School[]>([]);
10
+ const [classList, setClassList] = useState<ClassInfo[]>([]);
11
  const [loading, setLoading] = useState(true);
12
  const [editingUser, setEditingUser] = useState<User | null>(null);
13
 
14
+ // Teacher Apply Modal
15
+ const [isApplyOpen, setIsApplyOpen] = useState(false);
16
+ const [applyTarget, setApplyTarget] = useState('');
17
+
18
  const currentUser = api.auth.getCurrentUser();
19
  const isAdmin = currentUser?.role === UserRole.ADMIN;
20
  const isTeacher = currentUser?.role === UserRole.TEACHER;
 
23
  setLoading(true);
24
  try {
25
  // Admin loads global, Teacher only loads local
26
+ const [u, s, c] = await Promise.all([
27
  api.users.getAll({ global: isAdmin }),
28
+ api.schools.getAll(),
29
+ api.classes.getAll()
30
  ]);
31
  setUsers(u);
32
  setSchools(s);
33
+ setClassList(c);
34
  } catch (e) { console.error(e); }
35
  finally { setLoading(false); }
36
  };
 
64
  loadData();
65
  };
66
 
67
+ // Class Application Logic
68
+ const submitClassApply = async () => {
69
+ if (!applyTarget) return;
70
+ try {
71
+ await api.users.applyClass({
72
+ userId: currentUser?._id!,
73
+ type: 'CLAIM',
74
+ targetClass: applyTarget,
75
+ action: 'APPLY'
76
+ });
77
+ setIsApplyOpen(false);
78
+ alert('申请已提交,请等待管理员审核');
79
+ loadData(); // To refresh own status if visible
80
+ } catch(e) { alert('申请失败'); }
81
+ };
82
+
83
+ const submitResign = async () => {
84
+ if(!confirm('确定申请卸任班主任吗?')) return;
85
+ try {
86
+ await api.users.applyClass({
87
+ userId: currentUser?._id!,
88
+ type: 'RESIGN',
89
+ action: 'APPLY'
90
+ });
91
+ alert('卸任申请已提交');
92
+ } catch(e) { alert('提交失败'); }
93
+ };
94
+
95
+ const handleApplicationReview = async (user: User, action: 'APPROVE'|'REJECT') => {
96
+ if (!user.classApplication) return;
97
+ if (!confirm(`确认${action === 'APPROVE' ? '同意' : '拒绝'}该申请?`)) return;
98
+ try {
99
+ await api.users.applyClass({
100
+ userId: user._id!,
101
+ type: user.classApplication.type,
102
+ targetClass: user.classApplication.targetClass,
103
+ action
104
+ });
105
+ loadData();
106
+ } catch(e) { alert('操作失败'); }
107
+ };
108
+
109
+
110
  const getSchoolName = (id?: string) => schools.find(s => s._id === id)?.name || '未分配';
111
 
112
  // Filter Logic
 
126
  return (
127
  <div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6">
128
  <div className="flex justify-between items-center mb-4">
129
+ <div className="flex items-center gap-4">
130
+ <h2 className="text-xl font-bold text-gray-800">用户权限与审核</h2>
131
+ {isTeacher && (
132
+ <div className="flex items-center gap-2">
133
+ {currentUser.homeroomClass ? (
134
+ <div className="flex items-center gap-2 bg-amber-50 text-amber-700 px-3 py-1 rounded-full text-sm font-bold border border-amber-100">
135
+ <GraduationCap size={16}/> {currentUser.homeroomClass} 班主任
136
+ <button onClick={submitResign} className="ml-2 text-xs text-gray-400 hover:text-red-500 underline">申请卸任</button>
137
+ </div>
138
+ ) : (
139
+ <button onClick={() => setIsApplyOpen(true)} className="flex items-center gap-1 bg-blue-50 text-blue-600 px-3 py-1 rounded-full text-sm hover:bg-blue-100 border border-blue-200">
140
+ <Briefcase size={14}/> 申请成为班主任
141
+ </button>
142
+ )}
143
+ </div>
144
+ )}
145
+ </div>
146
  {isAdmin && <span className="text-xs text-gray-500 bg-gray-100 px-2 py-1 rounded">全局视图</span>}
147
  {isTeacher && <span className="text-xs text-blue-500 bg-blue-50 px-2 py-1 rounded border border-blue-100">仅显示 {currentUser.homeroomClass || '我班'} 学生</span>}
148
  </div>
 
158
  <th className="px-4 py-3">详细信息</th>
159
  <th className="px-4 py-3">所属学校</th>
160
  <th className="px-4 py-3">角色/班级</th>
161
+ <th className="px-4 py-3">状态/申请</th>
162
  <th className="px-4 py-3 text-right">操作</th>
163
  </tr>
164
  </thead>
 
166
  {filteredUsers.map(user => {
167
  const isSelf = !!(currentUser && (user._id === currentUser._id || user.username === currentUser.username));
168
  const canEdit = isAdmin; // Only admin can edit details, Teachers can only approve
169
+
170
+ const hasApp = user.classApplication && user.classApplication.status === 'PENDING';
171
 
172
  return (
173
  <tr key={user._id || user.id} className="hover:bg-gray-50">
 
234
  )}
235
  </td>
236
  <td className="px-4 py-3">
237
+ {hasApp && isAdmin ? (
238
+ <div className="bg-purple-50 border border-purple-200 rounded p-1 text-xs">
239
+ <span className="font-bold text-purple-700 block mb-1">
240
+ {user.classApplication?.type === 'CLAIM' ? `申请任教: ${user.classApplication.targetClass}` : '申请卸任'}
241
+ </span>
242
+ <div className="flex gap-1">
243
+ <button onClick={()=>handleApplicationReview(user, 'APPROVE')} className="bg-green-500 text-white px-2 rounded hover:bg-green-600">同意</button>
244
+ <button onClick={()=>handleApplicationReview(user, 'REJECT')} className="bg-red-400 text-white px-2 rounded hover:bg-red-500">拒绝</button>
245
+ </div>
246
+ </div>
247
+ ) : (
248
+ <>
249
+ {user.status === 'active' && <span className="bg-green-100 text-green-700 px-2 py-1 rounded-full text-xs">已激活</span>}
250
+ {user.status === 'pending' && <span className="bg-yellow-100 text-yellow-700 px-2 py-1 rounded-full text-xs animate-pulse">待审核</span>}
251
+ {user.status === 'banned' && <span className="bg-red-100 text-red-700 px-2 py-1 rounded-full text-xs">已停用</span>}
252
+ {user.classApplication?.status === 'PENDING' && !isAdmin && <span className="block mt-1 text-[10px] text-gray-400">申请审核中...</span>}
253
+ </>
254
+ )}
255
  </td>
256
  <td className="px-4 py-3 text-right space-x-2">
257
  {user.status === UserStatus.PENDING && (
 
276
  </table>
277
  </div>
278
  )}
279
+
280
+ {/* Teacher Apply Modal */}
281
+ {isApplyOpen && (
282
+ <div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
283
+ <div className="bg-white rounded-xl p-6 w-full max-w-sm animate-in zoom-in-95">
284
+ <h3 className="font-bold text-lg mb-4">申请成为班主任</h3>
285
+ <p className="text-sm text-gray-500 mb-4">选择您要负责的班级,提交后需管理员审核通过。</p>
286
+ <select className="w-full border p-2 rounded mb-4" value={applyTarget} onChange={e=>setApplyTarget(e.target.value)}>
287
+ <option value="">-- 选择班级 --</option>
288
+ {classList.map(c => <option key={c._id} value={c.grade+c.className}>{c.grade}{c.className}</option>)}
289
+ </select>
290
+ <div className="flex gap-2">
291
+ <button onClick={submitClassApply} disabled={!applyTarget} className="flex-1 bg-blue-600 text-white py-2 rounded font-bold hover:bg-blue-700 disabled:opacity-50">提交申请</button>
292
+ <button onClick={()=>setIsApplyOpen(false)} className="flex-1 border py-2 rounded hover:bg-gray-50">取消</button>
293
+ </div>
294
+ </div>
295
+ </div>
296
+ )}
297
  </div>
298
  );
299
+ };
server.js CHANGED
@@ -59,7 +59,7 @@ connectDB();
59
  // ... All Schema Definitions ...
60
  const SchoolSchema = new mongoose.Schema({ name: String, code: String });
61
  const School = mongoose.model('School', SchoolSchema);
62
- const UserSchema = new mongoose.Schema({ username: String, password: String, trueName: String, phone: String, email: String, schoolId: String, role: String, status: String, avatar: String, createTime: Date, teachingSubject: String, homeroomClass: String, studentNo: String, parentName: String, parentPhone: String, address: String, gender: String });
63
  const User = mongoose.model('User', UserSchema);
64
  // Updated Student Schema with dailyDrawLog AND flowerBalance
65
  const StudentSchema = new mongoose.Schema({ schoolId: String, studentNo: String, name: String, gender: String, birthday: String, idCard: String, phone: String, className: String, status: String, parentName: String, parentPhone: String, address: String, teamId: String, drawAttempts: { type: Number, default: 0 }, dailyDrawLog: { date: String, count: { type: Number, default: 0 } }, flowerBalance: { type: Number, default: 0 } });
@@ -87,8 +87,8 @@ const GameSessionSchema = new mongoose.Schema({ schoolId: String, className: Str
87
  const GameSessionModel = mongoose.model('GameSession', GameSessionSchema);
88
  const StudentRewardSchema = new mongoose.Schema({ schoolId: String, studentId: String, studentName: String, rewardType: String, name: String, count: { type: Number, default: 1 }, status: String, source: String, createTime: { type: Date, default: Date.now } });
89
  const StudentRewardModel = mongoose.model('StudentReward', StudentRewardSchema);
90
- // Updated LuckyDrawConfigSchema to include className
91
- const LuckyDrawConfigSchema = new mongoose.Schema({ schoolId: String, className: String, prizes: [{ id: String, name: String, probability: Number, count: Number, icon: String }], dailyLimit: Number, cardCount: Number, defaultPrize: String });
92
  const LuckyDrawConfigModel = mongoose.model('LuckyDrawConfig', LuckyDrawConfigSchema);
93
 
94
  // NEW: Achievement Schemas
@@ -122,6 +122,144 @@ const injectSchoolId = (req, b) => ({ ...b, schoolId: req.headers['x-school-id']
122
 
123
  // ... Routes ...
124
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
  // --- ACHIEVEMENT ROUTES ---
126
 
127
  // Get Achievement Config for a class
@@ -265,7 +403,7 @@ app.post('/api/games/lucky-config', async (req, res) => {
265
  res.json({ success: true });
266
  });
267
 
268
- // Secure Lucky Draw Endpoint with DAILY LIMIT
269
  app.post('/api/games/lucky-draw', async (req, res) => {
270
  const { studentId } = req.body;
271
  const schoolId = req.headers['x-school-id'];
@@ -286,11 +424,15 @@ app.post('/api/games/lucky-draw', async (req, res) => {
286
  const prizes = config?.prizes || [];
287
  const defaultPrize = config?.defaultPrize || '再接再厉';
288
  const dailyLimit = config?.dailyLimit || 3;
 
289
 
290
  // 2.2 PRE-CHECK: Inventory Check
 
291
  const availablePrizes = prizes.filter(p => (p.count === undefined || p.count > 0));
292
- if (availablePrizes.length === 0) {
293
- // Determine error message based on who is drawing
 
 
294
  const isTeacherOrAdmin = userRole === 'TEACHER' || userRole === 'ADMIN';
295
  const msg = isTeacherOrAdmin
296
  ? '奖品库存不足,不能抽奖,请先补充库存'
@@ -299,7 +441,6 @@ app.post('/api/games/lucky-draw', async (req, res) => {
299
  }
300
 
301
  // 2.5 Daily Limit Check
302
- // Only limit if it's a STUDENT drawing for themselves. Teachers/Admins bypass limits.
303
  if (userRole === 'STUDENT') {
304
  if (student.drawAttempts <= 0) return res.status(403).json({ error: '次数不足', message: '您的抽奖次数已用完' });
305
 
@@ -321,46 +462,51 @@ app.post('/api/games/lucky-draw', async (req, res) => {
321
  student.dailyDrawLog = dailyLog;
322
  await student.save();
323
  } else {
324
- // Teacher/Admin proxy draw - just consume attempts, no daily limit
325
  if (student.drawAttempts <= 0) return res.status(403).json({ error: '次数不足', message: '该学生抽奖次数已用完' });
326
  student.drawAttempts -= 1;
327
  await student.save();
328
  }
329
 
330
- // 4. Weighted Random Logic
331
  let selectedPrize = defaultPrize;
332
- let rewardType = 'CONSOLATION'; // Default to consolation
333
 
334
- if (availablePrizes.length > 0) {
335
- const random = Math.random() * 100;
336
- let currentWeight = 0;
337
- let matchedPrize = null;
338
-
339
- for (const p of availablePrizes) {
340
- currentWeight += p.probability || 0;
341
- if (random <= currentWeight) {
342
- matchedPrize = p;
343
- break;
344
- }
345
- }
346
-
347
- if (matchedPrize) {
348
- selectedPrize = matchedPrize.name;
349
- rewardType = 'ITEM'; // It's a real prize
350
- // Only decrease count if it's not infinite (undefined)
351
- if (matchedPrize.count !== undefined && matchedPrize.count > 0) {
352
- if (config._id) {
353
- await LuckyDrawConfigModel.updateOne(
354
- { _id: config._id, "prizes.id": matchedPrize.id },
355
- { $inc: { "prizes.$.count": -1 } }
356
- );
357
- }
358
- }
359
  }
360
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
361
 
362
  // 6. Record Reward
363
- // Note: Consolation prizes are recorded but handled differently in UI
364
  await StudentRewardModel.create({
365
  schoolId,
366
  studentId,
@@ -720,4 +866,4 @@ app.get('*', (req, res) => {
720
  res.sendFile(path.join(__dirname, 'dist', 'index.html'));
721
  });
722
 
723
- app.listen(PORT, () => console.log(`🚀 Server running on port ${PORT}`));
 
59
  // ... All Schema Definitions ...
60
  const SchoolSchema = new mongoose.Schema({ name: String, code: String });
61
  const School = mongoose.model('School', SchoolSchema);
62
+ const UserSchema = new mongoose.Schema({ username: String, password: String, trueName: String, phone: String, email: String, schoolId: String, role: String, status: String, avatar: String, createTime: Date, teachingSubject: String, homeroomClass: String, studentNo: String, parentName: String, parentPhone: String, address: String, gender: String, classApplication: { type: String, targetClass: String, status: String } });
63
  const User = mongoose.model('User', UserSchema);
64
  // Updated Student Schema with dailyDrawLog AND flowerBalance
65
  const StudentSchema = new mongoose.Schema({ schoolId: String, studentNo: String, name: String, gender: String, birthday: String, idCard: String, phone: String, className: String, status: String, parentName: String, parentPhone: String, address: String, teamId: String, drawAttempts: { type: Number, default: 0 }, dailyDrawLog: { date: String, count: { type: Number, default: 0 } }, flowerBalance: { type: Number, default: 0 } });
 
87
  const GameSessionModel = mongoose.model('GameSession', GameSessionSchema);
88
  const StudentRewardSchema = new mongoose.Schema({ schoolId: String, studentId: String, studentName: String, rewardType: String, name: String, count: { type: Number, default: 1 }, status: String, source: String, createTime: { type: Date, default: Date.now } });
89
  const StudentRewardModel = mongoose.model('StudentReward', StudentRewardSchema);
90
+ // Updated LuckyDrawConfigSchema to include className and consolationWeight
91
+ const LuckyDrawConfigSchema = new mongoose.Schema({ schoolId: String, className: String, prizes: [{ id: String, name: String, probability: Number, count: Number, icon: String }], dailyLimit: Number, cardCount: Number, defaultPrize: String, consolationWeight: { type: Number, default: 0 } });
92
  const LuckyDrawConfigModel = mongoose.model('LuckyDrawConfig', LuckyDrawConfigSchema);
93
 
94
  // NEW: Achievement Schemas
 
122
 
123
  // ... Routes ...
124
 
125
+ // --- TEACHER CLASS APPLICATION ROUTES ---
126
+
127
+ app.post('/api/users/class-application', async (req, res) => {
128
+ const { userId, type, targetClass, action } = req.body; // action: 'APPLY' or 'APPROVE' or 'REJECT'
129
+ const sId = req.headers['x-school-id'];
130
+ const userRole = req.headers['x-user-role'];
131
+
132
+ // If APPLY
133
+ if (action === 'APPLY') {
134
+ await User.findByIdAndUpdate(userId, {
135
+ classApplication: { type, targetClass, status: 'PENDING' }
136
+ });
137
+ return res.json({ success: true });
138
+ }
139
+
140
+ // If APPROVE/REJECT (Admin Only)
141
+ if (userRole === 'ADMIN') {
142
+ const user = await User.findById(userId);
143
+ if (!user || !user.classApplication) return res.status(404).json({ error: 'Application not found' });
144
+
145
+ if (action === 'APPROVE') {
146
+ const updates = { classApplication: null };
147
+ if (user.classApplication.type === 'CLAIM') {
148
+ updates.homeroomClass = user.classApplication.targetClass;
149
+ } else if (user.classApplication.type === 'RESIGN') {
150
+ updates.homeroomClass = '';
151
+ }
152
+ await User.findByIdAndUpdate(userId, updates);
153
+ } else {
154
+ // Reject: just clear application or mark rejected
155
+ await User.findByIdAndUpdate(userId, { 'classApplication.status': 'REJECTED' });
156
+ }
157
+ return res.json({ success: true });
158
+ }
159
+
160
+ res.status(403).json({ error: 'Permission denied' });
161
+ });
162
+
163
+
164
+ // --- STUDENT PROMOTION & TRANSFER ROUTES ---
165
+
166
+ // Helper for Grade Logic
167
+ const GRADE_ORDER = ['一年级', '二年级', '三年级', '四年级', '五年级', '六年级', '初一', '初二', '初三', '高一', '高二', '高三'];
168
+ const getNextGrade = (grade) => {
169
+ const idx = GRADE_ORDER.indexOf(grade);
170
+ if (idx === -1) return grade; // Unknown
171
+ if (idx === GRADE_ORDER.length - 1) return '毕业';
172
+ return GRADE_ORDER[idx + 1];
173
+ };
174
+
175
+ app.post('/api/students/promote', async (req, res) => {
176
+ const { teacherFollows } = req.body; // Boolean
177
+ const sId = req.headers['x-school-id'];
178
+ const role = req.headers['x-user-role'];
179
+
180
+ if (role !== 'ADMIN') return res.status(403).json({ error: 'Permission denied' });
181
+
182
+ const classes = await ClassModel.find(getQueryFilter(req));
183
+ let promotedCount = 0;
184
+
185
+ // Process highest grades first to avoid conflicts (though Mongo updates are atomic per doc)
186
+ // We iterate classes and update their students
187
+ for (const cls of classes) {
188
+ const currentGrade = cls.grade;
189
+ const nextGrade = getNextGrade(currentGrade);
190
+ const suffix = cls.className.replace(currentGrade, ''); // e.g., "(1)班"
191
+
192
+ if (nextGrade === '毕业') {
193
+ // Mark students as graduated
194
+ const fullClassName = cls.grade + cls.className; // usually stored as "一年级(1)班" in student doc?
195
+ // Wait, ClassInfo: grade="一年级", className="(1)班". Student: className="一年级(1)班"
196
+ const oldFullClass = cls.grade + cls.className;
197
+
198
+ await Student.updateMany(
199
+ { className: oldFullClass, ...getQueryFilter(req) },
200
+ { status: 'Graduated', className: '已毕业' }
201
+ );
202
+ // Teacher update? If teacher follows, they are now free
203
+ if (teacherFollows && cls.teacherName) {
204
+ await User.updateOne({ trueName: cls.teacherName, schoolId: sId }, { homeroomClass: '' });
205
+ }
206
+ } else {
207
+ // Upgrade Logic
208
+ const oldFullClass = cls.grade + cls.className;
209
+ const newFullClass = nextGrade + suffix; // e.g., "二年级(1)班"
210
+
211
+ // 1. Ensure new ClassInfo exists
212
+ await ClassModel.findOneAndUpdate(
213
+ { grade: nextGrade, className: suffix, schoolId: sId },
214
+ {
215
+ schoolId: sId,
216
+ grade: nextGrade,
217
+ className: suffix,
218
+ // If teacher follows, set them here. Else leave empty or keep existing if exists
219
+ teacherName: teacherFollows ? cls.teacherName : undefined
220
+ },
221
+ { upsert: true }
222
+ );
223
+
224
+ // 2. Update Students
225
+ const result = await Student.updateMany(
226
+ { className: oldFullClass, status: 'Enrolled', ...getQueryFilter(req) },
227
+ { className: newFullClass }
228
+ );
229
+ promotedCount += result.modifiedCount;
230
+
231
+ // 3. Update Teacher if following
232
+ if (teacherFollows && cls.teacherName) {
233
+ await User.updateOne(
234
+ { trueName: cls.teacherName, schoolId: sId, homeroomClass: oldFullClass },
235
+ { homeroomClass: newFullClass }
236
+ );
237
+ }
238
+ }
239
+ }
240
+
241
+ res.json({ success: true, count: promotedCount });
242
+ });
243
+
244
+ app.post('/api/students/transfer', async (req, res) => {
245
+ const { studentId, targetClass } = req.body;
246
+ const student = await Student.findById(studentId);
247
+ if (!student) return res.status(404).json({ error: 'Student not found' });
248
+
249
+ // Check permission: Admin or Current Homeroom Teacher
250
+ const requester = await User.findOne({ username: req.headers['x-user-username'] || 'admin' }); // Simulating user check via header or token context
251
+ // In real app, rely on req.user from middleware. Here we use header role.
252
+ const role = req.headers['x-user-role'];
253
+
254
+ // Simple check: If teacher, must be homeroom of student
255
+ // NOTE: This requires storing current user info in backend context or passing it.
256
+ // For simplicity, we trust x-user-role and if Teacher, we assume frontend validated or we check param
257
+
258
+ student.className = targetClass;
259
+ await student.save();
260
+ res.json({ success: true });
261
+ });
262
+
263
  // --- ACHIEVEMENT ROUTES ---
264
 
265
  // Get Achievement Config for a class
 
403
  res.json({ success: true });
404
  });
405
 
406
+ // Secure Lucky Draw Endpoint with DAILY LIMIT AND WEIGHTED RANDOM
407
  app.post('/api/games/lucky-draw', async (req, res) => {
408
  const { studentId } = req.body;
409
  const schoolId = req.headers['x-school-id'];
 
424
  const prizes = config?.prizes || [];
425
  const defaultPrize = config?.defaultPrize || '再接再厉';
426
  const dailyLimit = config?.dailyLimit || 3;
427
+ const consolationWeight = config?.consolationWeight || 0;
428
 
429
  // 2.2 PRE-CHECK: Inventory Check
430
+ // Only consider prizes that HAVE inventory.
431
  const availablePrizes = prizes.filter(p => (p.count === undefined || p.count > 0));
432
+
433
+ // If no prizes available AND consolation weight is 0 (meaning no consolation allowed), then pool is empty.
434
+ // But if consolation weight > 0, it means we can still draw "Thanks for participating".
435
+ if (availablePrizes.length === 0 && consolationWeight === 0) {
436
  const isTeacherOrAdmin = userRole === 'TEACHER' || userRole === 'ADMIN';
437
  const msg = isTeacherOrAdmin
438
  ? '奖品库存不足,不能抽奖,请先补充库存'
 
441
  }
442
 
443
  // 2.5 Daily Limit Check
 
444
  if (userRole === 'STUDENT') {
445
  if (student.drawAttempts <= 0) return res.status(403).json({ error: '次数不足', message: '您的抽奖次数已用完' });
446
 
 
462
  student.dailyDrawLog = dailyLog;
463
  await student.save();
464
  } else {
465
+ // Teacher/Admin proxy draw
466
  if (student.drawAttempts <= 0) return res.status(403).json({ error: '次数不足', message: '该学生抽奖次数已用完' });
467
  student.drawAttempts -= 1;
468
  await student.save();
469
  }
470
 
471
+ // 4. WEIGHTED RANDOM LOGIC (抽卡算法)
472
  let selectedPrize = defaultPrize;
473
+ let rewardType = 'CONSOLATION';
474
 
475
+ // Calculate Total Weight
476
+ let totalWeight = consolationWeight;
477
+ availablePrizes.forEach(p => {
478
+ totalWeight += (p.probability || 0); // probability field is used as Weight
479
+ });
480
+
481
+ // Random Point
482
+ let random = Math.random() * totalWeight;
483
+ let matchedPrize = null;
484
+
485
+ // Iterate to find where the random point falls
486
+ for (const p of availablePrizes) {
487
+ random -= (p.probability || 0);
488
+ if (random <= 0) {
489
+ matchedPrize = p;
490
+ break;
 
 
 
 
 
 
 
 
 
491
  }
492
  }
493
+ // If loop finishes and matchedPrize is null, it means random fell into consolation zone (or pool empty logic covered above)
494
+
495
+ if (matchedPrize) {
496
+ selectedPrize = matchedPrize.name;
497
+ rewardType = 'ITEM';
498
+ // Decrease count
499
+ if (matchedPrize.count !== undefined && matchedPrize.count > 0) {
500
+ if (config._id) {
501
+ await LuckyDrawConfigModel.updateOne(
502
+ { _id: config._id, "prizes.id": matchedPrize.id },
503
+ { $inc: { "prizes.$.count": -1 } }
504
+ );
505
+ }
506
+ }
507
+ }
508
 
509
  // 6. Record Reward
 
510
  await StudentRewardModel.create({
511
  schoolId,
512
  studentId,
 
866
  res.sendFile(path.join(__dirname, 'dist', 'index.html'));
867
  });
868
 
869
+ app.listen(PORT, () => console.log(`🚀 Server running on port ${PORT}`));
services/api.ts CHANGED
@@ -34,6 +34,7 @@ async function request(endpoint: string, options: RequestInit = {}) {
34
  // Inject User Role for backend logic (e.g., bypassing draw limits)
35
  if (currentUser?.role) {
36
  headers['x-user-role'] = currentUser.role;
 
37
  }
38
  }
39
 
@@ -100,14 +101,19 @@ export const api = {
100
  return request(`/users?${params.toString()}`);
101
  },
102
  update: (id: string, data: Partial<User>) => request(`/users/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
103
- delete: (id: string) => request(`/users/${id}`, { method: 'DELETE' })
 
 
104
  },
105
 
106
  students: {
107
  getAll: () => request('/students'),
108
  add: (data: any) => request('/students', { method: 'POST', body: JSON.stringify(data) }),
109
  update: (id: string, data: any) => request(`/students/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
110
- delete: (id: string | number) => request(`/students/${id}`, { method: 'DELETE' })
 
 
 
111
  },
112
 
113
  classes: {
@@ -215,4 +221,4 @@ export const api = {
215
  batchDelete: (type: 'student' | 'score' | 'user', ids: string[]) => {
216
  return request('/batch-delete', { method: 'POST', body: JSON.stringify({ type, ids }) });
217
  }
218
- };
 
34
  // Inject User Role for backend logic (e.g., bypassing draw limits)
35
  if (currentUser?.role) {
36
  headers['x-user-role'] = currentUser.role;
37
+ headers['x-user-username'] = currentUser.username;
38
  }
39
  }
40
 
 
101
  return request(`/users?${params.toString()}`);
102
  },
103
  update: (id: string, data: Partial<User>) => request(`/users/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
104
+ delete: (id: string) => request(`/users/${id}`, { method: 'DELETE' }),
105
+ applyClass: (data: { userId: string, type: 'CLAIM'|'RESIGN', targetClass?: string, action: 'APPLY'|'APPROVE'|'REJECT' }) =>
106
+ request('/users/class-application', { method: 'POST', body: JSON.stringify(data) }),
107
  },
108
 
109
  students: {
110
  getAll: () => request('/students'),
111
  add: (data: any) => request('/students', { method: 'POST', body: JSON.stringify(data) }),
112
  update: (id: string, data: any) => request(`/students/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
113
+ delete: (id: string | number) => request(`/students/${id}`, { method: 'DELETE' }),
114
+ // NEW: Promote and Transfer
115
+ promote: (data: { teacherFollows: boolean }) => request('/students/promote', { method: 'POST', body: JSON.stringify(data) }),
116
+ transfer: (data: { studentId: string, targetClass: string }) => request('/students/transfer', { method: 'POST', body: JSON.stringify(data) })
117
  },
118
 
119
  classes: {
 
221
  batchDelete: (type: 'student' | 'score' | 'user', ids: string[]) => {
222
  return request('/batch-delete', { method: 'POST', body: JSON.stringify({ type, ids }) });
223
  }
224
+ };
types.ts CHANGED
@@ -33,6 +33,12 @@ export interface User {
33
  createTime?: string;
34
  teachingSubject?: string;
35
  homeroomClass?: string;
 
 
 
 
 
 
36
  // Student Registration Temp Fields
37
  studentNo?: string;
38
  parentName?: string;
@@ -216,7 +222,7 @@ export interface StudentReward {
216
  export interface LuckyPrize {
217
  id: string;
218
  name: string;
219
- probability: number; // 0-100
220
  count: number; // Inventory
221
  icon?: string;
222
  }
@@ -229,6 +235,7 @@ export interface LuckyDrawConfig {
229
  dailyLimit: number;
230
  cardCount?: number; // Number of cards displayed (e.g., 9, 12)
231
  defaultPrize: string; // "再接再厉"
 
232
  }
233
 
234
  // --- Achievement System Types ---
@@ -294,4 +301,4 @@ export interface LeaveRequest {
294
  endDate: string;
295
  status: 'Pending' | 'Approved' | 'Rejected';
296
  createTime: string;
297
- }
 
33
  createTime?: string;
34
  teachingSubject?: string;
35
  homeroomClass?: string;
36
+ // Class Application
37
+ classApplication?: {
38
+ type: 'CLAIM' | 'RESIGN';
39
+ targetClass?: string;
40
+ status: 'PENDING' | 'REJECTED';
41
+ };
42
  // Student Registration Temp Fields
43
  studentNo?: string;
44
  parentName?: string;
 
222
  export interface LuckyPrize {
223
  id: string;
224
  name: string;
225
+ probability: number; // Now treated as Weight
226
  count: number; // Inventory
227
  icon?: string;
228
  }
 
235
  dailyLimit: number;
236
  cardCount?: number; // Number of cards displayed (e.g., 9, 12)
237
  defaultPrize: string; // "再接再厉"
238
+ consolationWeight?: number; // NEW: Weight for NOT winning a main prize
239
  }
240
 
241
  // --- Achievement System Types ---
 
301
  endDate: string;
302
  status: 'Pending' | 'Approved' | 'Rejected';
303
  createTime: string;
304
+ }