dvc890 commited on
Commit
58b7bd4
·
verified ·
1 Parent(s): 62383cd

Upload 45 files

Browse files
models.js CHANGED
@@ -1,3 +1,4 @@
 
1
  const mongoose = require('mongoose');
2
 
3
  const SchoolSchema = new mongoose.Schema({ name: String, code: String });
@@ -52,13 +53,31 @@ const StudentSchema = new mongoose.Schema({
52
  });
53
  const Student = mongoose.model('Student', StudentSchema);
54
 
55
- const CourseSchema = new mongoose.Schema({ schoolId: String, courseCode: String, courseName: String, teacherName: String, credits: Number, capacity: Number, enrolled: Number });
 
 
 
 
 
 
 
 
 
 
 
 
56
  const Course = mongoose.model('Course', CourseSchema);
57
 
58
  const ScoreSchema = new mongoose.Schema({ schoolId: String, studentName: String, studentNo: String, courseName: String, score: Number, semester: String, type: String, examName: String, status: String });
59
  const Score = mongoose.model('Score', ScoreSchema);
60
 
61
- const ClassSchema = new mongoose.Schema({ schoolId: String, grade: String, className: String, teacherName: String });
 
 
 
 
 
 
62
  const ClassModel = mongoose.model('Class', ClassSchema);
63
 
64
  const SubjectSchema = new mongoose.Schema({ schoolId: String, name: String, code: String, color: String, excellenceThreshold: Number, thresholds: { type: Map, of: Number } });
@@ -87,18 +106,23 @@ const ConfigModel = mongoose.model('Config', ConfigSchema);
87
  const NotificationSchema = new mongoose.Schema({ schoolId: String, targetRole: String, targetUserId: String, title: String, content: String, type: String, createTime: { type: Date, default: Date.now } });
88
  const NotificationModel = mongoose.model('Notification', NotificationSchema);
89
 
 
90
  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 }] });
91
  const GameSessionModel = mongoose.model('GameSession', GameSessionSchema);
92
 
93
- 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 } });
 
94
  const StudentRewardModel = mongoose.model('StudentReward', StudentRewardSchema);
95
 
96
- 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 } });
 
97
  const LuckyDrawConfigModel = mongoose.model('LuckyDrawConfig', LuckyDrawConfigSchema);
98
 
 
99
  const GameMonsterConfigSchema = new mongoose.Schema({
100
  schoolId: String,
101
  className: String,
 
102
  duration: { type: Number, default: 300 },
103
  sensitivity: { type: Number, default: 25 },
104
  difficulty: { type: Number, default: 5 },
@@ -112,9 +136,11 @@ const GameMonsterConfigSchema = new mongoose.Schema({
112
  });
113
  const GameMonsterConfigModel = mongoose.model('GameMonsterConfig', GameMonsterConfigSchema);
114
 
 
115
  const GameZenConfigSchema = new mongoose.Schema({
116
  schoolId: String,
117
  className: String,
 
118
  durationMinutes: { type: Number, default: 40 },
119
  threshold: { type: Number, default: 30 },
120
  passRate: { type: Number, default: 90 },
@@ -146,4 +172,4 @@ module.exports = {
146
  School, User, Student, Course, Score, ClassModel, SubjectModel, ExamModel, ScheduleModel,
147
  ConfigModel, NotificationModel, GameSessionModel, StudentRewardModel, LuckyDrawConfigModel, GameMonsterConfigModel, GameZenConfigModel,
148
  AchievementConfigModel, StudentAchievementModel, AttendanceModel, LeaveRequestModel, SchoolCalendarModel
149
- };
 
1
+
2
  const mongoose = require('mongoose');
3
 
4
  const SchoolSchema = new mongoose.Schema({ name: String, code: String });
 
53
  });
54
  const Student = mongoose.model('Student', StudentSchema);
55
 
56
+ const CourseSchema = new mongoose.Schema({
57
+ schoolId: String,
58
+ courseCode: String,
59
+ courseName: String,
60
+ className: String, // New: Target Class
61
+ teacherName: String,
62
+ teacherId: String, // New: Link to User
63
+ credits: Number,
64
+ capacity: Number,
65
+ enrolled: Number
66
+ });
67
+ // Ensure one teacher per subject per class
68
+ CourseSchema.index({ schoolId: 1, className: 1, courseName: 1 }, { unique: true });
69
  const Course = mongoose.model('Course', CourseSchema);
70
 
71
  const ScoreSchema = new mongoose.Schema({ schoolId: String, studentName: String, studentNo: String, courseName: String, score: Number, semester: String, type: String, examName: String, status: String });
72
  const Score = mongoose.model('Score', ScoreSchema);
73
 
74
+ const ClassSchema = new mongoose.Schema({
75
+ schoolId: String,
76
+ grade: String,
77
+ className: String,
78
+ teacherName: String, // Display string
79
+ homeroomTeacherIds: [String] // Logic IDs
80
+ });
81
  const ClassModel = mongoose.model('Class', ClassSchema);
82
 
83
  const SubjectSchema = new mongoose.Schema({ schoolId: String, name: String, code: String, color: String, excellenceThreshold: Number, thresholds: { type: Map, of: Number } });
 
106
  const NotificationSchema = new mongoose.Schema({ schoolId: String, targetRole: String, targetUserId: String, title: String, content: String, type: String, createTime: { type: Date, default: Date.now } });
107
  const NotificationModel = mongoose.model('Notification', NotificationSchema);
108
 
109
+ // Game Session (Mountain) - Shared by Class, Controlled by Homeroom Teachers
110
  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 }] });
111
  const GameSessionModel = mongoose.model('GameSession', GameSessionSchema);
112
 
113
+ // Student Reward - Owned by Teacher (or System)
114
+ 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 }, ownerId: String });
115
  const StudentRewardModel = mongoose.model('StudentReward', StudentRewardSchema);
116
 
117
+ // Lucky Draw - Isolated by Teacher
118
+ const LuckyDrawConfigSchema = new mongoose.Schema({ schoolId: String, className: String, ownerId: String, prizes: [{ id: String, name: String, probability: Number, count: Number, icon: String }], dailyLimit: Number, cardCount: Number, defaultPrize: String, consolationWeight: { type: Number, default: 0 } });
119
  const LuckyDrawConfigModel = mongoose.model('LuckyDrawConfig', LuckyDrawConfigSchema);
120
 
121
+ // Monster - Isolated by Teacher
122
  const GameMonsterConfigSchema = new mongoose.Schema({
123
  schoolId: String,
124
  className: String,
125
+ ownerId: String,
126
  duration: { type: Number, default: 300 },
127
  sensitivity: { type: Number, default: 25 },
128
  difficulty: { type: Number, default: 5 },
 
136
  });
137
  const GameMonsterConfigModel = mongoose.model('GameMonsterConfig', GameMonsterConfigSchema);
138
 
139
+ // Zen - Isolated by Teacher
140
  const GameZenConfigSchema = new mongoose.Schema({
141
  schoolId: String,
142
  className: String,
143
+ ownerId: String,
144
  durationMinutes: { type: Number, default: 40 },
145
  threshold: { type: Number, default: 30 },
146
  passRate: { type: Number, default: 90 },
 
172
  School, User, Student, Course, Score, ClassModel, SubjectModel, ExamModel, ScheduleModel,
173
  ConfigModel, NotificationModel, GameSessionModel, StudentRewardModel, LuckyDrawConfigModel, GameMonsterConfigModel, GameZenConfigModel,
174
  AchievementConfigModel, StudentAchievementModel, AttendanceModel, LeaveRequestModel, SchoolCalendarModel
175
+ };
pages/ClassList.tsx CHANGED
@@ -30,7 +30,7 @@ export const ClassList: React.FC = () => {
30
  // Form
31
  const [grade, setGrade] = useState('一年级');
32
  const [className, setClassName] = useState('(1)班');
33
- const [teacherName, setTeacherName] = useState('');
34
 
35
  // Expanded K12 Grades
36
  const grades = [
@@ -84,7 +84,7 @@ export const ClassList: React.FC = () => {
84
  setEditClassId(null);
85
  setGrade('一年级');
86
  setClassName('(1)班');
87
- setTeacherName('');
88
  setIsModalOpen(true);
89
  };
90
 
@@ -92,7 +92,8 @@ export const ClassList: React.FC = () => {
92
  setEditClassId(cls._id || String(cls.id));
93
  setGrade(cls.grade);
94
  setClassName(cls.className);
95
- setTeacherName(cls.teacherName || '');
 
96
  setIsModalOpen(true);
97
  };
98
 
@@ -100,7 +101,9 @@ export const ClassList: React.FC = () => {
100
  e.preventDefault();
101
  setSubmitting(true);
102
  try {
103
- const payload = { grade, className, teacherName };
 
 
104
  if (editClassId) {
105
  // Update via PUT
106
  await fetch(`/api/classes/${editClassId}`, {
@@ -112,7 +115,7 @@ export const ClassList: React.FC = () => {
112
  body: JSON.stringify(payload)
113
  });
114
  } else {
115
- await api.classes.add(payload);
116
  }
117
  setIsModalOpen(false);
118
  loadData();
@@ -145,6 +148,13 @@ export const ClassList: React.FC = () => {
145
  setExpandedGrades(newSet);
146
  };
147
 
 
 
 
 
 
 
 
148
  // Group Classes by Grade
149
  const groupedClasses = classes.reduce((acc, cls) => {
150
  if (!acc[cls.grade]) acc[cls.grade] = [];
@@ -248,13 +258,13 @@ export const ClassList: React.FC = () => {
248
  </div>
249
  </div>
250
  <div className="flex justify-between items-center text-sm mt-3">
251
- <div className="flex items-center text-gray-500" title="班主任">
252
- <UserIcon size={14} className="mr-1"/>
253
  {cls.teacherName ? (
254
- <span className="font-bold text-gray-700">{cls.teacherName}</span>
255
  ) : <span className="text-gray-300">未设置</span>}
256
  </div>
257
- <div className="flex items-center text-gray-500" title="学生人数">
258
  <Users size={14} className="mr-1"/>
259
  <span>{cls.studentCount || 0}</span>
260
  </div>
@@ -272,9 +282,9 @@ export const ClassList: React.FC = () => {
272
  {/* Modal */}
273
  {isModalOpen && (
274
  <div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 backdrop-blur-sm">
275
- <div className="bg-white rounded-xl shadow-xl max-w-sm w-full p-6 animate-in zoom-in-95">
276
- <h3 className="text-lg font-bold text-gray-800 mb-4">{editClassId ? '编辑班级 / 任命班主任' : '新增班级'}</h3>
277
- <form onSubmit={handleSubmit} className="space-y-4">
278
  <div>
279
  <label className="text-sm font-medium text-gray-700">年级</label>
280
  <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">
@@ -285,19 +295,28 @@ export const ClassList: React.FC = () => {
285
  <label className="text-sm font-medium text-gray-700">班级名 (如: (1)班)</label>
286
  <input required type="text" value={className} onChange={e => setClassName(e.target.value)} className="w-full mt-1 px-3 py-2 border rounded-lg" placeholder="(1)班" />
287
  </div>
288
- <div>
289
- <label className="text-sm font-medium text-gray-700">指定班主任 (按姓名排序)</label>
290
- <select value={teacherName} onChange={e => setTeacherName(e.target.value)} className="w-full mt-1 px-3 py-2 border rounded-lg bg-white">
291
- <option value="">-- 暂不指定 --</option>
292
- {teachers.map(t => (
293
- <option key={t._id} value={t.trueName || t.username}>
294
- {t.trueName || t.username} {t.homeroomClass ? `(已任: ${t.homeroomClass})` : ''}
295
- </option>
296
- ))}
297
- </select>
298
- <p className="text-xs text-gray-500 mt-1">注意:如果该老师已是其他班班主任,将会被重新任命为本班班主任。</p>
 
 
 
 
 
 
 
 
299
  </div>
300
- <div className="flex space-x-3 pt-4">
 
301
  <button type="button" onClick={() => setIsModalOpen(false)} className="flex-1 py-2 border rounded-lg hover:bg-gray-50">取消</button>
302
  <button type="submit" disabled={submitting} className="flex-1 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 shadow-md">
303
  {submitting ? '提交中...' : '保存'}
 
30
  // Form
31
  const [grade, setGrade] = useState('一年级');
32
  const [className, setClassName] = useState('(1)班');
33
+ const [selectedTeacherIds, setSelectedTeacherIds] = useState<string[]>([]); // Multi-select array
34
 
35
  // Expanded K12 Grades
36
  const grades = [
 
84
  setEditClassId(null);
85
  setGrade('一年级');
86
  setClassName('(1)班');
87
+ setSelectedTeacherIds([]);
88
  setIsModalOpen(true);
89
  };
90
 
 
92
  setEditClassId(cls._id || String(cls.id));
93
  setGrade(cls.grade);
94
  setClassName(cls.className);
95
+ // Map existing ids or parse name logic if legacy
96
+ setSelectedTeacherIds(cls.homeroomTeacherIds || []);
97
  setIsModalOpen(true);
98
  };
99
 
 
101
  e.preventDefault();
102
  setSubmitting(true);
103
  try {
104
+ // Backend will handle generating teacherName string from IDs
105
+ const payload = { grade, className, homeroomTeacherIds: selectedTeacherIds };
106
+
107
  if (editClassId) {
108
  // Update via PUT
109
  await fetch(`/api/classes/${editClassId}`, {
 
115
  body: JSON.stringify(payload)
116
  });
117
  } else {
118
+ await api.classes.add(payload as any);
119
  }
120
  setIsModalOpen(false);
121
  loadData();
 
148
  setExpandedGrades(newSet);
149
  };
150
 
151
+ const toggleTeacherSelection = (tId: string) => {
152
+ setSelectedTeacherIds(prev => {
153
+ if (prev.includes(tId)) return prev.filter(id => id !== tId);
154
+ return [...prev, tId];
155
+ });
156
+ };
157
+
158
  // Group Classes by Grade
159
  const groupedClasses = classes.reduce((acc, cls) => {
160
  if (!acc[cls.grade]) acc[cls.grade] = [];
 
258
  </div>
259
  </div>
260
  <div className="flex justify-between items-center text-sm mt-3">
261
+ <div className="flex items-center text-gray-500 flex-1 overflow-hidden" title="班主任">
262
+ <UserIcon size={14} className="mr-1 shrink-0"/>
263
  {cls.teacherName ? (
264
+ <span className="font-bold text-gray-700 truncate">{cls.teacherName}</span>
265
  ) : <span className="text-gray-300">未设置</span>}
266
  </div>
267
+ <div className="flex items-center text-gray-500 ml-2" title="学生人数">
268
  <Users size={14} className="mr-1"/>
269
  <span>{cls.studentCount || 0}</span>
270
  </div>
 
282
  {/* Modal */}
283
  {isModalOpen && (
284
  <div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 backdrop-blur-sm">
285
+ <div className="bg-white rounded-xl shadow-xl max-w-sm w-full p-6 animate-in zoom-in-95 flex flex-col max-h-[90vh]">
286
+ <h3 className="text-lg font-bold text-gray-800 mb-4">{editClassId ? '编辑班级 / 班主任' : '新增班级'}</h3>
287
+ <form onSubmit={handleSubmit} className="space-y-4 flex-1 flex flex-col overflow-hidden">
288
  <div>
289
  <label className="text-sm font-medium text-gray-700">年级</label>
290
  <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">
 
295
  <label className="text-sm font-medium text-gray-700">班级名 (如: (1)班)</label>
296
  <input required type="text" value={className} onChange={e => setClassName(e.target.value)} className="w-full mt-1 px-3 py-2 border rounded-lg" placeholder="(1)班" />
297
  </div>
298
+
299
+ <div className="flex-1 flex flex-col overflow-hidden">
300
+ <label className="text-sm font-medium text-gray-700 mb-1">指定班主任 (可多选)</label>
301
+ <div className="border rounded-lg overflow-y-auto flex-1 p-2 bg-gray-50 space-y-1 custom-scrollbar">
302
+ {teachers.map(t => {
303
+ const isSelected = selectedTeacherIds.includes(t._id || '');
304
+ return (
305
+ <div
306
+ key={t._id}
307
+ onClick={() => toggleTeacherSelection(t._id || '')}
308
+ className={`flex items-center justify-between p-2 rounded cursor-pointer transition-colors text-sm ${isSelected ? 'bg-blue-100 border-blue-200 text-blue-800' : 'hover:bg-white text-gray-700'}`}
309
+ >
310
+ <span>{t.trueName || t.username}</span>
311
+ {isSelected && <Check size={14} className="text-blue-600"/>}
312
+ </div>
313
+ );
314
+ })}
315
+ </div>
316
+ <p className="text-xs text-gray-500 mt-1">选中的教师将拥有该班级的最高管理权限。</p>
317
  </div>
318
+
319
+ <div className="flex space-x-3 pt-4 shrink-0">
320
  <button type="button" onClick={() => setIsModalOpen(false)} className="flex-1 py-2 border rounded-lg hover:bg-gray-50">取消</button>
321
  <button type="submit" disabled={submitting} className="flex-1 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 shadow-md">
322
  {submitting ? '提交中...' : '保存'}
pages/CourseList.tsx CHANGED
@@ -2,32 +2,56 @@
2
  import React, { useEffect, useState } from 'react';
3
  import { Plus, Clock, Loader2, Edit, Trash2, X, Filter } from 'lucide-react';
4
  import { api } from '../services/api';
5
- import { Course, Subject, User } from '../types';
 
6
 
7
  export const CourseList: React.FC = () => {
8
  const [courses, setCourses] = useState<Course[]>([]);
9
  const [subjects, setSubjects] = useState<Subject[]>([]);
10
  const [teachers, setTeachers] = useState<User[]>([]);
 
11
  const [loading, setLoading] = useState(true);
12
 
13
  // Filter state
14
  const [filterSubject, setFilterSubject] = useState('All');
15
 
16
  const [isModalOpen, setIsModalOpen] = useState(false);
17
- const [formData, setFormData] = useState({ courseCode: '', courseName: '', teacherName: '', credits: 2, capacity: 45 });
 
 
 
 
 
 
 
 
 
18
  const [editId, setEditId] = useState<string | null>(null);
19
 
 
 
 
 
20
  const loadData = async () => {
21
  setLoading(true);
22
  try {
23
- const [c, s, t] = await Promise.all([
24
  api.courses.getAll(),
25
  api.subjects.getAll(),
26
- api.users.getAll({ role: 'TEACHER' })
 
27
  ]);
28
- setCourses(c);
 
 
 
 
 
 
 
29
  setSubjects(s);
30
  setTeachers(t);
 
31
  } catch (e) { console.error(e); } finally { setLoading(false); }
32
  };
33
 
@@ -36,21 +60,58 @@ export const CourseList: React.FC = () => {
36
  const handleSubmit = async (e: React.FormEvent) => {
37
  e.preventDefault();
38
  if (editId) await api.courses.update(editId, formData);
39
- else await api.courses.add(formData);
 
 
 
 
 
 
 
 
 
 
 
40
  setIsModalOpen(false);
41
  loadData();
42
  };
43
 
44
  const handleDelete = async (id: string) => {
45
- if(confirm('删除课程?')) { await api.courses.delete(id); loadData(); }
46
  };
47
 
48
  const filteredCourses = courses.filter(c => filterSubject === 'All' || c.courseName === filterSubject);
49
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
  return (
51
  <div className="space-y-6">
52
  <div className="flex flex-col md:flex-row justify-between items-center gap-4">
53
- <h2 className="text-xl font-bold text-gray-800">课程列表</h2>
 
 
 
54
 
55
  <div className="flex items-center gap-3 w-full md:w-auto">
56
  <div className="flex items-center bg-white border rounded-lg px-2 py-1.5 flex-1 md:flex-none">
@@ -64,8 +125,8 @@ export const CourseList: React.FC = () => {
64
  {subjects.map(s => <option key={s._id} value={s.name}>{s.name}</option>)}
65
  </select>
66
  </div>
67
- <button onClick={() => { setEditId(null); setIsModalOpen(true); }} className="px-4 py-2 bg-indigo-600 text-white rounded-lg flex items-center text-sm hover:bg-indigo-700 transition-colors whitespace-nowrap">
68
- <Plus size={16} className="mr-1"/> 新增课程
69
  </button>
70
  </div>
71
  </div>
@@ -74,22 +135,45 @@ export const CourseList: React.FC = () => {
74
  filteredCourses.length > 0 ? (
75
  <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
76
  {filteredCourses.map(c => (
77
- <div key={c._id || c.id} className="bg-white p-6 rounded-xl border shadow-sm hover:shadow-md transition-shadow">
78
- <h3 className="font-bold text-lg text-gray-800 mb-1">{c.courseName}</h3>
79
- <p className="text-sm text-gray-500 mb-4 flex items-center gap-2">
80
- <span className="bg-gray-100 px-2 py-0.5 rounded text-xs">{c.courseCode || 'No Code'}</span>
81
- <span>教师: {c.teacherName}</span>
82
- </p>
83
- <div className="flex gap-2 pt-2 border-t border-gray-50">
84
- <button onClick={() => { setFormData({ courseCode: c.courseCode, courseName: c.courseName, teacherName: c.teacherName, credits: c.credits, capacity: c.capacity }); setEditId(c._id || String(c.id)); setIsModalOpen(true); }} className="flex-1 bg-gray-50 text-blue-600 py-1.5 rounded text-sm hover:bg-blue-50 transition-colors font-medium">编辑</button>
85
- <button onClick={() => handleDelete(c._id || String(c.id))} className="flex-1 bg-gray-50 text-red-600 py-1.5 rounded text-sm hover:bg-red-50 transition-colors font-medium">删除</button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
  </div>
87
  </div>
88
  ))}
89
  </div>
90
  ) : (
91
  <div className="text-center py-12 text-gray-400 bg-white rounded-xl border border-dashed border-gray-200">
92
- 没有找到符合条件的课程
93
  </div>
94
  )
95
  )}
@@ -97,30 +181,44 @@ export const CourseList: React.FC = () => {
97
  {isModalOpen && (
98
  <div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50 backdrop-blur-sm">
99
  <div className="bg-white p-6 rounded-xl w-full max-w-md shadow-xl animate-in zoom-in-95">
100
- <h3 className="font-bold mb-4 text-lg text-gray-800">{editId ? '编辑课程' : '新增课程'}</h3>
101
  <form onSubmit={handleSubmit} className="space-y-4">
 
 
 
 
 
 
 
 
102
  <div>
103
  <label className="text-sm font-bold text-gray-500 mb-1 block">科目</label>
104
  <select className="w-full border p-2 rounded-lg focus:ring-2 focus:ring-indigo-500 outline-none" value={formData.courseName} onChange={e=>setFormData({...formData, courseName:e.target.value})} required>
105
- <option value="">选择科目</option>
106
  {subjects.map(s=><option key={s._id} value={s.name}>{s.name}</option>)}
107
  </select>
108
  </div>
109
 
110
- <div>
111
- <label className="text-sm font-bold text-gray-500 mb-1 block">任课教师</label>
112
- <input
113
- className="w-full border p-2 rounded-lg focus:ring-2 focus:ring-indigo-500 outline-none"
114
- placeholder="选择或输入教师姓名"
115
- value={formData.teacherName}
116
- onChange={e=>setFormData({...formData, teacherName:e.target.value})}
117
- list="teacherList"
118
- required
119
- />
120
- <datalist id="teacherList">
121
- {teachers.map(t => <option key={t._id} value={t.trueName || t.username}>{t.username}</option>)}
122
- </datalist>
123
- </div>
 
 
 
 
 
 
124
 
125
  <div className="flex gap-3 pt-2">
126
  <button type="button" onClick={()=>setIsModalOpen(false)} className="flex-1 border py-2 rounded-lg hover:bg-gray-50">取消</button>
 
2
  import React, { useEffect, useState } from 'react';
3
  import { Plus, Clock, Loader2, Edit, Trash2, X, Filter } from 'lucide-react';
4
  import { api } from '../services/api';
5
+ import { Course, Subject, User, ClassInfo } from '../types';
6
+ import { sortClasses } from './Dashboard';
7
 
8
  export const CourseList: React.FC = () => {
9
  const [courses, setCourses] = useState<Course[]>([]);
10
  const [subjects, setSubjects] = useState<Subject[]>([]);
11
  const [teachers, setTeachers] = useState<User[]>([]);
12
+ const [classes, setClasses] = useState<ClassInfo[]>([]);
13
  const [loading, setLoading] = useState(true);
14
 
15
  // Filter state
16
  const [filterSubject, setFilterSubject] = useState('All');
17
 
18
  const [isModalOpen, setIsModalOpen] = useState(false);
19
+ const [formData, setFormData] = useState<{
20
+ courseCode: string,
21
+ courseName: string,
22
+ teacherName: string,
23
+ teacherId: string,
24
+ className: string,
25
+ credits: number,
26
+ capacity: number
27
+ }>({ courseCode: '', courseName: '', teacherName: '', teacherId: '', className: '', credits: 2, capacity: 45 });
28
+
29
  const [editId, setEditId] = useState<string | null>(null);
30
 
31
+ const currentUser = api.auth.getCurrentUser();
32
+ const isAdmin = currentUser?.role === 'ADMIN';
33
+ const isTeacher = currentUser?.role === 'TEACHER';
34
+
35
  const loadData = async () => {
36
  setLoading(true);
37
  try {
38
+ const [c, s, t, cls] = await Promise.all([
39
  api.courses.getAll(),
40
  api.subjects.getAll(),
41
+ api.users.getAll({ role: 'TEACHER' }),
42
+ api.classes.getAll()
43
  ]);
44
+
45
+ let filteredCourses = c;
46
+ if (isTeacher) {
47
+ // Teachers only see their own courses
48
+ filteredCourses = c.filter((course: Course) => course.teacherId === currentUser._id || course.teacherName === (currentUser.trueName || currentUser.username));
49
+ }
50
+
51
+ setCourses(filteredCourses);
52
  setSubjects(s);
53
  setTeachers(t);
54
+ setClasses(cls.sort(sortClasses));
55
  } catch (e) { console.error(e); } finally { setLoading(false); }
56
  };
57
 
 
60
  const handleSubmit = async (e: React.FormEvent) => {
61
  e.preventDefault();
62
  if (editId) await api.courses.update(editId, formData);
63
+ else {
64
+ try {
65
+ await api.courses.add(formData);
66
+ } catch (e: any) {
67
+ if (e.message.includes('DUPLICATE')) {
68
+ alert('该班级的该科目已经有任课老师了,请勿重复添加。');
69
+ return;
70
+ }
71
+ alert('添加失败');
72
+ return;
73
+ }
74
+ }
75
  setIsModalOpen(false);
76
  loadData();
77
  };
78
 
79
  const handleDelete = async (id: string) => {
80
+ if(confirm('删除课程关联?')) { await api.courses.delete(id); loadData(); }
81
  };
82
 
83
  const filteredCourses = courses.filter(c => filterSubject === 'All' || c.courseName === filterSubject);
84
 
85
+ const handleTeacherSelect = (e: React.ChangeEvent<HTMLSelectElement>) => {
86
+ const tId = e.target.value;
87
+ const t = teachers.find(u => u._id === tId);
88
+ if(t) {
89
+ setFormData({ ...formData, teacherId: tId, teacherName: t.trueName || t.username });
90
+ }
91
+ };
92
+
93
+ // If teacher is logged in, default the teacher selection to themselves
94
+ const handleOpenAdd = () => {
95
+ setEditId(null);
96
+ setFormData({
97
+ courseCode: '',
98
+ courseName: '',
99
+ teacherName: isTeacher ? (currentUser?.trueName || currentUser?.username || '') : '',
100
+ teacherId: isTeacher ? (currentUser?._id || '') : '',
101
+ className: '',
102
+ credits: 2,
103
+ capacity: 45
104
+ });
105
+ setIsModalOpen(true);
106
+ };
107
+
108
  return (
109
  <div className="space-y-6">
110
  <div className="flex flex-col md:flex-row justify-between items-center gap-4">
111
+ <div>
112
+ <h2 className="text-xl font-bold text-gray-800">任课安排</h2>
113
+ <p className="text-xs text-gray-500">在此设置每个班级各科目的任课老师</p>
114
+ </div>
115
 
116
  <div className="flex items-center gap-3 w-full md:w-auto">
117
  <div className="flex items-center bg-white border rounded-lg px-2 py-1.5 flex-1 md:flex-none">
 
125
  {subjects.map(s => <option key={s._id} value={s.name}>{s.name}</option>)}
126
  </select>
127
  </div>
128
+ <button onClick={handleOpenAdd} className="px-4 py-2 bg-indigo-600 text-white rounded-lg flex items-center text-sm hover:bg-indigo-700 transition-colors whitespace-nowrap">
129
+ <Plus size={16} className="mr-1"/> 绑定新班级
130
  </button>
131
  </div>
132
  </div>
 
135
  filteredCourses.length > 0 ? (
136
  <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
137
  {filteredCourses.map(c => (
138
+ <div key={c._id || c.id} className="bg-white p-6 rounded-xl border shadow-sm hover:shadow-md transition-shadow relative overflow-hidden group">
139
+ <div className="absolute top-0 right-0 p-4 opacity-10 font-black text-6xl text-indigo-900 pointer-events-none -mr-4 -mt-4">
140
+ {c.courseName[0]}
141
+ </div>
142
+
143
+ <div className="relative z-10">
144
+ <h3 className="font-bold text-lg text-gray-800 mb-1">{c.className}</h3>
145
+ <div className="text-indigo-600 font-bold text-md mb-4 flex items-center">
146
+ {c.courseName}
147
+ </div>
148
+
149
+ <div className="text-sm text-gray-500 mb-4 space-y-1">
150
+ <p className="flex items-center"><span className="w-16 text-gray-400">教师:</span> {c.teacherName}</p>
151
+ {c.courseCode && <p className="flex items-center"><span className="w-16 text-gray-400">代码:</span> {c.courseCode}</p>}
152
+ </div>
153
+
154
+ <div className="flex gap-2 pt-2 border-t border-gray-50">
155
+ <button onClick={() => {
156
+ setFormData({
157
+ courseCode: c.courseCode,
158
+ courseName: c.courseName,
159
+ teacherName: c.teacherName,
160
+ teacherId: c.teacherId || '',
161
+ className: c.className,
162
+ credits: c.credits,
163
+ capacity: c.capacity
164
+ });
165
+ setEditId(c._id || String(c.id));
166
+ setIsModalOpen(true);
167
+ }} className="flex-1 bg-gray-50 text-blue-600 py-1.5 rounded text-sm hover:bg-blue-50 transition-colors font-medium">编辑</button>
168
+ <button onClick={() => handleDelete(c._id || String(c.id))} className="flex-1 bg-gray-50 text-red-600 py-1.5 rounded text-sm hover:bg-red-50 transition-colors font-medium">解绑</button>
169
+ </div>
170
  </div>
171
  </div>
172
  ))}
173
  </div>
174
  ) : (
175
  <div className="text-center py-12 text-gray-400 bg-white rounded-xl border border-dashed border-gray-200">
176
+ {isTeacher ? '您尚未绑定任何教学班级,请点击右上角绑定' : '没有找到课程安排'}
177
  </div>
178
  )
179
  )}
 
181
  {isModalOpen && (
182
  <div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50 backdrop-blur-sm">
183
  <div className="bg-white p-6 rounded-xl w-full max-w-md shadow-xl animate-in zoom-in-95">
184
+ <h3 className="font-bold mb-4 text-lg text-gray-800">{editId ? '编辑课程关联' : '新增课程关联'}</h3>
185
  <form onSubmit={handleSubmit} className="space-y-4">
186
+ <div>
187
+ <label className="text-sm font-bold text-gray-500 mb-1 block">教学班级</label>
188
+ <select className="w-full border p-2 rounded-lg focus:ring-2 focus:ring-indigo-500 outline-none" value={formData.className} onChange={e=>setFormData({...formData, className:e.target.value})} required>
189
+ <option value="">-- 选择班级 --</option>
190
+ {classes.map(cls => <option key={cls._id} value={cls.grade + cls.className}>{cls.grade}{cls.className}</option>)}
191
+ </select>
192
+ </div>
193
+
194
  <div>
195
  <label className="text-sm font-bold text-gray-500 mb-1 block">科目</label>
196
  <select className="w-full border p-2 rounded-lg focus:ring-2 focus:ring-indigo-500 outline-none" value={formData.courseName} onChange={e=>setFormData({...formData, courseName:e.target.value})} required>
197
+ <option value="">-- 选择科目 --</option>
198
  {subjects.map(s=><option key={s._id} value={s.name}>{s.name}</option>)}
199
  </select>
200
  </div>
201
 
202
+ {!isTeacher && (
203
+ <div>
204
+ <label className="text-sm font-bold text-gray-500 mb-1 block">任课教师</label>
205
+ <select
206
+ className="w-full border p-2 rounded-lg focus:ring-2 focus:ring-indigo-500 outline-none"
207
+ value={formData.teacherId}
208
+ onChange={handleTeacherSelect}
209
+ required
210
+ >
211
+ <option value="">-- 选择教师 --</option>
212
+ {teachers.map(t => <option key={t._id} value={t._id}>{t.trueName || t.username}</option>)}
213
+ </select>
214
+ </div>
215
+ )}
216
+
217
+ {isTeacher && (
218
+ <div className="bg-blue-50 p-2 rounded text-sm text-blue-700">
219
+ 您正在将自己 ({formData.teacherName}) 绑定为 {formData.className || '某班'} 的 {formData.courseName || '某科'} 老师。
220
+ </div>
221
+ )}
222
 
223
  <div className="flex gap-3 pt-2">
224
  <button type="button" onClick={()=>setIsModalOpen(false)} className="flex-1 border py-2 rounded-lg hover:bg-gray-50">取消</button>
pages/GameLucky.tsx CHANGED
@@ -6,21 +6,19 @@ import { LuckyDrawConfig, Student, LuckyPrize } from '../types';
6
  import { Gift, Settings, Loader2, Save, Trash2, X, UserCircle, RefreshCcw, HelpCircle, Maximize, Minimize, Disc, LayoutGrid, ArrowDown } from 'lucide-react';
7
  import { Emoji } from '../components/Emoji';
8
 
9
- // --- Card Component ---
10
  const FlipCard = ({ index, prize, onFlip, isRevealed, activeIndex }: { index: number, prize: string, onFlip: (idx: number) => void, isRevealed: boolean, activeIndex: number | null }) => {
11
  const showBack = isRevealed && activeIndex === index;
12
 
13
  return (
14
  <div className="relative w-full aspect-[3/4] cursor-pointer perspective-1000 group" onClick={() => !isRevealed && onFlip(index)}>
15
  <div className={`relative w-full h-full text-center transition-transform duration-700 transform-style-3d shadow-md rounded-xl ${showBack ? 'rotate-y-180' : 'hover:scale-[1.02] active:scale-95'}`}>
16
- {/* Front */}
17
  <div className="absolute w-full h-full backface-hidden bg-gradient-to-br from-red-500 to-red-600 rounded-xl flex flex-col items-center justify-center border-4 border-yellow-400 shadow-inner">
18
  <div className="w-10 h-10 md:w-14 md:h-14 bg-yellow-100 rounded-full flex items-center justify-center mb-2 shadow-lg border-2 border-yellow-300">
19
  <span className="text-xl md:text-2xl"><Emoji symbol="🧧"/></span>
20
  </div>
21
  <span className="text-yellow-100 font-black text-lg md:text-xl tracking-widest drop-shadow-md">開</span>
22
  </div>
23
- {/* Back */}
24
  <div className="absolute w-full h-full backface-hidden bg-white rounded-xl flex flex-col items-center justify-center border-4 border-red-200 rotate-y-180 shadow-inner p-2">
25
  <span className="text-3xl mb-2 animate-bounce"><Emoji symbol="🎁"/></span>
26
  <span className="text-red-600 font-bold text-xs md:text-sm break-words leading-tight text-center px-1">{prize}</span>
@@ -30,7 +28,7 @@ const FlipCard = ({ index, prize, onFlip, isRevealed, activeIndex }: { index: nu
30
  );
31
  };
32
 
33
- // --- Wheel Component ---
34
  const WHEEL_COLORS = ['#ef4444', '#f97316', '#eab308', '#84cc16', '#22c55e', '#3b82f6', '#8b5cf6', '#ec4899'];
35
 
36
  const LuckyWheel = ({ config, isSpinning, result, onSpin }: {
@@ -40,8 +38,6 @@ const LuckyWheel = ({ config, isSpinning, result, onSpin }: {
40
  onSpin: () => void
41
  }) => {
42
  const [rotation, setRotation] = useState(0);
43
-
44
- // Construct Segments (Config Prizes + Consolation)
45
  const segments = useMemo(() => {
46
  const list = config.prizes.map((p, i) => ({
47
  name: p.name,
@@ -49,13 +45,11 @@ const LuckyWheel = ({ config, isSpinning, result, onSpin }: {
49
  color: WHEEL_COLORS[i % WHEEL_COLORS.length],
50
  isConsolation: false
51
  }));
52
-
53
- // Add Consolation Segment
54
  if ((config.consolationWeight || 0) > 0 || list.length === 0) {
55
  list.push({
56
  name: config.defaultPrize || '再接再厉',
57
- weight: config.consolationWeight || 10, // Default to 10 if 0 but needed
58
- color: '#94a3b8', // Grey for consolation
59
  isConsolation: true
60
  });
61
  }
@@ -63,53 +57,30 @@ const LuckyWheel = ({ config, isSpinning, result, onSpin }: {
63
  }, [config]);
64
 
65
  const totalWeight = segments.reduce((acc, s) => acc + s.weight, 0);
66
-
67
- // Calculate Slices
68
  let currentAngle = 0;
69
  const slices = segments.map(seg => {
70
  const angle = (seg.weight / totalWeight) * 360;
71
  const start = currentAngle;
72
  currentAngle += angle;
73
- // Center angle relative to start of wheel (0 deg)
74
  const center = start + (angle / 2);
75
  return { ...seg, startAngle: start, endAngle: currentAngle, angle, centerAngle: center };
76
  });
77
 
78
  useEffect(() => {
79
  if (result && isSpinning) {
80
- // Find target slice index
81
  const targetSlice = slices.find(s => s.name === result.prize) || slices[slices.length - 1];
82
-
83
- // LOGIC: The POINTER is fixed at TOP (0 deg or 360 deg).
84
- // We need to rotate the WHEEL so that the Target Slice center hits 0 deg.
85
- // Target Rotation = (360 - TargetSliceCenter) + FullSpins.
86
-
87
  const minSpins = 5;
88
  const spinAngle = minSpins * 360;
89
-
90
- // Calculate where we need to land relative to 0
91
- // Since SVG 0 starts at Top (after -90deg rotation),
92
- // if we rotate by (360 - centerAngle), that slice will be at Top.
93
  const targetLandingAngle = 360 - targetSlice.centerAngle;
94
-
95
- // Current Rotation Modulo
96
  const currentMod = rotation % 360;
97
-
98
- // Calculate forward distance to target
99
  let distance = targetLandingAngle - currentMod;
100
- if (distance < 0) distance += 360; // Ensure positive to spin clockwise
101
-
102
- // Add slight randomness within the slice (-40% to +40% width)
103
- // Note: If we add angle, we shift the slice CW, meaning pointer hits earlier (CCW shift relative to slice)
104
  const jitter = (Math.random() - 0.5) * (targetSlice.angle * 0.8);
105
-
106
  const finalRotation = rotation + spinAngle + distance + jitter;
107
-
108
  setRotation(finalRotation);
109
  }
110
  }, [result, isSpinning]);
111
 
112
- // SVG Path Generator for Slice
113
  const getCoordinatesForPercent = (percent: number) => {
114
  const x = Math.cos(2 * Math.PI * percent);
115
  const y = Math.sin(2 * Math.PI * percent);
@@ -119,26 +90,18 @@ const LuckyWheel = ({ config, isSpinning, result, onSpin }: {
119
  return (
120
  <div className="flex flex-col items-center justify-center h-full w-full max-w-3xl mx-auto p-4">
121
  <div className="relative w-full aspect-square max-w-[500px] md:max-w-[600px]">
122
- {/* 1. Wheel Container (Rotates) */}
123
  <div
124
  className="w-full h-full rounded-full border-8 border-yellow-400 shadow-2xl overflow-hidden relative bg-white"
125
- style={{
126
- transform: `rotate(${rotation}deg)`,
127
- transition: isSpinning ? 'transform 4s cubic-bezier(0.15, 0.85, 0.35, 1)' : 'none'
128
- }}
129
  >
130
  <svg viewBox="-1 -1 2 2" style={{ transform: 'rotate(-90deg)' }} className="w-full h-full">
131
  {slices.map((slice, i) => {
132
- // Calculate SVG path
133
  const start = slice.startAngle / 360;
134
  const end = slice.endAngle / 360;
135
- const [startX, startY] = getCoordinatesForPercent(end); // Clockwise
136
  const [endX, endY] = getCoordinatesForPercent(start);
137
  const largeArcFlag = slice.angle > 180 ? 1 : 0;
138
  const pathData = `M 0 0 L ${startX} ${startY} A 1 1 0 ${largeArcFlag} 0 ${endX} ${endY} Z`;
139
-
140
- // Text Layout Logic
141
- // If slice is narrow (<45 deg) OR text is long (>4 chars), use Vertical Stack
142
  const isVertical = slice.angle < 45 || slice.name.length > 4;
143
  const midAngle = (slice.startAngle + slice.endAngle) / 2;
144
  const displayText = slice.name.length > 8 ? slice.name.substring(0,7)+'..' : slice.name;
@@ -146,50 +109,17 @@ const LuckyWheel = ({ config, isSpinning, result, onSpin }: {
146
  return (
147
  <g key={i}>
148
  <path d={pathData} fill={slice.color} stroke="white" strokeWidth="0.01" />
149
-
150
  <g transform={`rotate(${midAngle})`}>
151
  {isVertical ? (
152
- // Vertical Stack Mode (Characters stacked radially)
153
  displayText.split('').map((char, idx) => {
154
- const charSize = 0.08;
155
  const spacing = 0.09;
156
- // Center the stack around radius ~0.6
157
  const totalHeight = displayText.length * spacing;
158
  const startR = 0.65 - (totalHeight / 2) + (spacing/2);
159
  const r = startR + idx * spacing;
160
-
161
- return (
162
- <text
163
- key={idx}
164
- x={r}
165
- y={0}
166
- fill="white"
167
- fontSize={charSize}
168
- fontWeight="bold"
169
- textAnchor="middle"
170
- dominantBaseline="middle"
171
- transform={`rotate(90, ${r}, 0)`}
172
- style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.3)' }}
173
- >
174
- {char}
175
- </text>
176
- );
177
  })
178
  ) : (
179
- // Horizontal Mode (Tangential)
180
- <text
181
- x={0.65}
182
- y={0}
183
- fill="white"
184
- fontSize="0.09"
185
- fontWeight="bold"
186
- textAnchor="middle"
187
- dominantBaseline="middle"
188
- transform="rotate(90, 0.65, 0)"
189
- style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.3)' }}
190
- >
191
- {displayText}
192
- </text>
193
  )}
194
  </g>
195
  </g>
@@ -197,45 +127,16 @@ const LuckyWheel = ({ config, isSpinning, result, onSpin }: {
197
  })}
198
  </svg>
199
  </div>
200
-
201
- {/* 2. Static Pointer (Fixed Overlay) */}
202
  <div className="absolute inset-0 pointer-events-none z-20 flex justify-center">
203
- {/* Container spanning from top to center (height 50%) */}
204
  <div className="relative w-10 md:w-14 h-[50%]">
205
- {/*
206
- The Pointer Shape:
207
- A long triangle stretching from the bottom (center of wheel) to the top (edge).
208
- Using SVG to ensure perfect sharpness and responsiveness.
209
- */}
210
- <svg
211
- viewBox="0 0 40 300"
212
- preserveAspectRatio="none"
213
- className="w-full h-full drop-shadow-xl"
214
- style={{ filter: 'drop-shadow(0px 4px 4px rgba(0,0,0,0.4))' }}
215
- >
216
- {/* Gradient Def */}
217
- <defs>
218
- <linearGradient id="pointerGrad" x1="0%" y1="0%" x2="100%" y2="0%">
219
- <stop offset="0%" stopColor="#dc2626" />
220
- <stop offset="50%" stopColor="#ef4444" />
221
- <stop offset="100%" stopColor="#b91c1c" />
222
- </linearGradient>
223
- </defs>
224
- {/* Triangle Path: Top-Center (20,0) -> Bottom-Right (40,300) -> Bottom-Left (0,300) */}
225
  <path d="M 20 0 L 40 300 L 0 300 Z" fill="url(#pointerGrad)" stroke="white" strokeWidth="2" />
226
- {/* Inner Highlight line for 3D effect */}
227
- <path d="M 20 0 L 20 300" stroke="rgba(255,255,255,0.3)" strokeWidth="1" />
228
  </svg>
229
  </div>
230
  </div>
231
-
232
- {/* 3. Center Button (Static Pivot) */}
233
  <div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-30 pointer-events-auto">
234
- <button
235
- onClick={onSpin}
236
- disabled={isSpinning}
237
- className="w-20 h-20 md:w-24 md:h-24 bg-gradient-to-b from-yellow-300 to-yellow-500 rounded-full border-4 border-white shadow-[0_4px_15px_rgba(0,0,0,0.4)] flex items-center justify-center font-black text-red-600 text-xl md:text-2xl hover:scale-105 active:scale-95 disabled:grayscale transition-all active:shadow-inner"
238
- >
239
  {isSpinning ? '...' : '抽奖'}
240
  </button>
241
  </div>
@@ -244,7 +145,7 @@ const LuckyWheel = ({ config, isSpinning, result, onSpin }: {
244
  );
245
  };
246
 
247
- export const GameLucky: React.FC = () => {
248
  const [loading, setLoading] = useState(true);
249
  const [luckyConfig, setLuckyConfig] = useState<LuckyDrawConfig | null>(null);
250
  const [studentInfo, setStudentInfo] = useState<Student | null>(null);
@@ -255,46 +156,53 @@ export const GameLucky: React.FC = () => {
255
  const [isFullscreen, setIsFullscreen] = useState(false);
256
 
257
  // Game State
258
- const [viewMode, setViewMode] = useState<'CARD' | 'WHEEL'>('CARD'); // New: Switch modes
259
  const [drawResult, setDrawResult] = useState<{prize: string, rewardType?: string} | null>(null);
260
 
261
- // Card Mode State
262
  const [activeCardIndex, setActiveCardIndex] = useState<number | null>(null);
263
- // Wheel Mode State
264
  const [isWheelSpinning, setIsWheelSpinning] = useState(false);
265
 
266
  const currentUser = api.auth.getCurrentUser();
267
  const isTeacher = currentUser?.role === 'TEACHER';
268
  const isStudent = currentUser?.role === 'STUDENT';
269
 
270
- const currentClassName = isTeacher ? currentUser?.homeroomClass : (studentInfo?.className || '');
 
271
 
272
  useEffect(() => {
273
  loadData();
274
- }, [proxyStudentId]);
275
 
276
  const loadData = async () => {
 
 
 
 
 
 
 
 
 
277
  if(!luckyConfig) setLoading(true);
 
278
  try {
279
  const allStus = await api.students.getAll();
280
- let targetClass = '';
 
 
 
 
281
 
282
  if (isTeacher) {
283
- if (currentUser.homeroomClass) {
284
- targetClass = currentUser.homeroomClass;
285
- const filtered = allStus.filter((s: Student) => s.className === currentUser.homeroomClass);
286
-
287
- // Sort by Seat No
288
- filtered.sort((a: Student, b: Student) => {
289
- const seatA = parseInt(a.seatNo || '99999');
290
- const seatB = parseInt(b.seatNo || '99999');
291
- if (seatA !== seatB) return seatA - seatB;
292
- return a.name.localeCompare(b.name, 'zh-CN');
293
- });
294
- setStudents(filtered);
295
- } else {
296
- setStudents(allStus);
297
- }
298
 
299
  if (proxyStudentId) {
300
  const proxy = allStus.find((s: Student) => (s._id || String(s.id)) === proxyStudentId);
@@ -305,12 +213,11 @@ export const GameLucky: React.FC = () => {
305
  } else if (isStudent) {
306
  const me = allStus.find((s: Student) => s.name === (currentUser.trueName || currentUser.username));
307
  setStudentInfo(me || null);
308
- if(me) targetClass = me.className;
309
  }
310
 
311
- if (targetClass) {
312
- const config = await api.games.getLuckyConfig(targetClass);
313
- if(!config.className) config.className = targetClass;
314
  setLuckyConfig(config);
315
  }
316
 
@@ -318,7 +225,6 @@ export const GameLucky: React.FC = () => {
318
  finally { setLoading(false); }
319
  };
320
 
321
- // Unified Draw Handler
322
  const executeDraw = async (triggerIndex?: number) => {
323
  const isBusy = viewMode === 'CARD' ? activeCardIndex !== null : isWheelSpinning;
324
  if (isBusy) return;
@@ -327,24 +233,19 @@ export const GameLucky: React.FC = () => {
327
  if (!targetId) return alert(isTeacher ? '请先在右侧选择要代抽的学生' : '学生信息未加载');
328
  if (!studentInfo || (studentInfo.drawAttempts || 0) <= 0) return alert('抽奖次数不足!');
329
 
330
- // Set Loading State
331
  if (viewMode === 'CARD' && triggerIndex !== undefined) setActiveCardIndex(triggerIndex);
332
  if (viewMode === 'WHEEL') setIsWheelSpinning(true);
333
 
334
  try {
335
  const res = await api.games.drawLucky(targetId);
336
- setDrawResult(res); // Wheel will react to this change
337
  setStudentInfo(prev => prev ? ({ ...prev, drawAttempts: (prev.drawAttempts || 0) - 1 }) : null);
338
 
339
- // Wait for animation to finish
340
- const delay = viewMode === 'WHEEL' ? 4500 : 1500; // Wheel spins for 4s, Cards flip for 1s
341
 
342
  setTimeout(() => {
343
- // Show Result
344
  const msg = res.rewardType !== 'CONSOLATION' ? `🎁 恭喜!抽中了:${res.prize}` : `💪 ${res.prize}`;
345
  alert(msg);
346
-
347
- // Reset State
348
  setDrawResult(null);
349
  setActiveCardIndex(null);
350
  setIsWheelSpinning(false);
@@ -365,18 +266,15 @@ export const GameLucky: React.FC = () => {
365
  if (loading) return <div className="h-full flex items-center justify-center"><Loader2 className="animate-spin text-blue-600"/></div>;
366
  if (!luckyConfig) return <div className="h-full flex items-center justify-center text-gray-400">配置加载失败</div>;
367
 
 
 
 
368
  const GameContent = (
369
  <div className={`${isFullscreen ? 'fixed inset-0 z-[9999]' : 'h-full relative'} flex flex-col md:flex-row bg-gradient-to-br from-red-50 to-orange-50 overflow-hidden`}>
370
- {/* Floating Fullscreen Button */}
371
- <button
372
- onClick={() => setIsFullscreen(!isFullscreen)}
373
- className="absolute top-4 right-4 z-50 p-2 rounded-full bg-white/50 hover:bg-white backdrop-blur shadow-sm border transition-colors md:top-4 md:left-4 md:right-auto"
374
- title={isFullscreen ? "退出全屏" : "全屏"}
375
- >
376
  {isFullscreen ? <Minimize size={20} className="text-gray-700"/> : <Maximize size={20} className="text-gray-700"/>}
377
  </button>
378
 
379
- {/* LEFT: GAME AREA (Card Grid OR Wheel) */}
380
  <div className="flex-1 overflow-y-auto p-4 md:p-8 custom-scrollbar order-1 md:order-1 flex flex-col justify-center">
381
  {viewMode === 'CARD' ? (
382
  <div className={`grid gap-2 md:gap-6 w-full max-w-5xl mx-auto transition-all place-content-center ${
@@ -386,33 +284,20 @@ export const GameLucky: React.FC = () => {
386
  'md:grid-cols-4 lg:grid-cols-5')
387
  }`}>
388
  {Array.from({ length: luckyConfig.cardCount || 9 }).map((_, i) => (
389
- <FlipCard
390
- key={i}
391
- index={i}
392
- prize={drawResult ? drawResult.prize : '???'}
393
- onFlip={executeDraw}
394
- isRevealed={activeCardIndex === i && !!drawResult}
395
- activeIndex={activeCardIndex}
396
- />
397
  ))}
398
  </div>
399
  ) : (
400
- <LuckyWheel
401
- config={luckyConfig}
402
- isSpinning={isWheelSpinning}
403
- result={drawResult}
404
- onSpin={() => executeDraw()}
405
- />
406
  )}
407
  </div>
408
 
409
- {/* Right/Bottom: Controls */}
410
  <div className="w-full md:w-80 bg-white border-t md:border-t-0 md:border-l border-gray-200 shadow-xl flex flex-col shrink-0 z-20 order-2 md:order-2">
411
  <div className="p-4 md:p-6 border-b border-gray-100 bg-red-600 text-white relative overflow-hidden shrink-0">
412
  <div className="relative z-10 flex justify-between items-center md:block">
413
  <div>
414
  <h3 className="text-lg md:text-xl font-bold flex items-center"><Gift className="mr-2"/> 幸运抽奖</h3>
415
- <p className="text-red-100 text-xs mt-1">班级专属奖池 | {currentClassName}</p>
416
  </div>
417
  </div>
418
  <div className="absolute -right-4 -bottom-4 text-red-700 opacity-30"><Gift size={80}/></div>
@@ -437,31 +322,15 @@ export const GameLucky: React.FC = () => {
437
 
438
  {isTeacher && (
439
  <div className="space-y-3 md:space-y-4">
440
- {/* Mode Switcher (Teacher Only) */}
441
  <div className="bg-gray-100 p-1 rounded-lg flex text-xs font-bold">
442
- <button
443
- onClick={() => setViewMode('CARD')}
444
- className={`flex-1 py-2 rounded flex items-center justify-center transition-all ${viewMode==='CARD' ? 'bg-white shadow text-red-600' : 'text-gray-500'}`}
445
- >
446
- <LayoutGrid size={14} className="mr-1"/> 翻红包
447
- </button>
448
- <button
449
- onClick={() => setViewMode('WHEEL')}
450
- className={`flex-1 py-2 rounded flex items-center justify-center transition-all ${viewMode==='WHEEL' ? 'bg-white shadow text-red-600' : 'text-gray-500'}`}
451
- >
452
- <Disc size={14} className="mr-1"/> 大转盘
453
- </button>
454
  </div>
455
-
456
  <div>
457
  <label className="text-xs font-bold text-gray-500 uppercase block mb-1">选择代抽学生</label>
458
  <div className="relative">
459
  <UserCircle className="absolute left-3 top-2.5 text-gray-400" size={18}/>
460
- <select
461
- className="w-full pl-10 pr-3 py-2 border border-gray-300 rounded-lg text-sm outline-none focus:ring-2 focus:ring-blue-500 bg-white"
462
- value={proxyStudentId}
463
- onChange={e => setProxyStudentId(e.target.value)}
464
- >
465
  <option value="">-- 请选择 --</option>
466
  {students.map(s => <option key={s._id} value={s._id || String(s.id)}>{s.seatNo ? s.seatNo+'.':''}{s.name}</option>)}
467
  </select>
@@ -478,15 +347,15 @@ export const GameLucky: React.FC = () => {
478
  </div>
479
  </div>
480
 
481
- {/* Settings Modal */}
482
  {isSettingsOpen && (
483
  <div className="fixed inset-0 bg-black/60 z-[1000] flex items-center justify-center p-4 backdrop-blur-sm">
484
  <div className="bg-white rounded-2xl w-full max-w-4xl h-[90vh] flex flex-col shadow-2xl animate-in zoom-in-95">
485
  <div className="p-6 border-b border-gray-100 flex justify-between items-center">
486
- <h3 className="text-xl font-bold text-gray-800">奖池配置 - {currentClassName}</h3>
487
  <button onClick={() => setIsSettingsOpen(false)}><X size={24} className="text-gray-400 hover:text-gray-600"/></button>
488
  </div>
489
-
490
  <div className="flex-1 overflow-y-auto p-6 bg-gray-50/50">
491
  <div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
492
  <div className="bg-white p-4 rounded-xl border shadow-sm">
@@ -502,22 +371,7 @@ export const GameLucky: React.FC = () => {
502
  <input className="w-full border rounded-lg px-3 py-2 text-center focus:ring-2 focus:ring-blue-500 outline-none" value={luckyConfig.defaultPrize} onChange={e => setLuckyConfig({...luckyConfig, defaultPrize: e.target.value})}/>
503
  </div>
504
  </div>
505
-
506
- <div className="mb-6 bg-amber-50 p-4 rounded-xl border border-amber-100">
507
- <h4 className="font-bold text-amber-800 text-sm mb-2 flex items-center"><HelpCircle size={16} className="mr-1"/> 抽奖权重规则说明</h4>
508
- <p className="text-xs text-amber-700 mb-2">中奖概率 = (该奖品权重 / 总权重池) * 100%。<br/>总权重池 = 所有上架奖品的权重之和 + 安慰奖权重。</p>
509
- <div className="flex items-center gap-4 mt-3">
510
- <label className="text-sm font-bold text-gray-700">设置安慰奖(未中奖)权重:</label>
511
- <input
512
- type="number"
513
- className="border rounded px-2 py-1 w-24 text-center font-bold"
514
- value={luckyConfig.consolationWeight || 0}
515
- onChange={e => setLuckyConfig({...luckyConfig, consolationWeight: Number(e.target.value)})}
516
- />
517
- <span className="text-xs text-gray-500">此权重将作为大转盘中的灰色区域占比</span>
518
- </div>
519
- </div>
520
-
521
  <div className="bg-white rounded-xl border shadow-sm overflow-hidden">
522
  <table className="w-full text-sm text-left">
523
  <thead className="bg-gray-100 text-gray-500 uppercase text-xs">
@@ -564,7 +418,6 @@ export const GameLucky: React.FC = () => {
564
  </div>
565
  </div>
566
  </div>
567
-
568
  <div className="p-4 border-t border-gray-100 bg-white rounded-b-2xl flex justify-end gap-3 shrink-0">
569
  <button onClick={() => setIsSettingsOpen(false)} className="px-5 py-2.5 text-gray-600 hover:bg-gray-100 rounded-xl transition-colors">取消</button>
570
  <button onClick={saveSettings} className="px-8 py-2.5 bg-blue-600 text-white rounded-xl hover:bg-blue-700 shadow-lg shadow-blue-200 font-bold transition-all">保存配置</button>
 
6
  import { Gift, Settings, Loader2, Save, Trash2, X, UserCircle, RefreshCcw, HelpCircle, Maximize, Minimize, Disc, LayoutGrid, ArrowDown } from 'lucide-react';
7
  import { Emoji } from '../components/Emoji';
8
 
9
+ // ... (Keep FlipCard component unchanged) ...
10
  const FlipCard = ({ index, prize, onFlip, isRevealed, activeIndex }: { index: number, prize: string, onFlip: (idx: number) => void, isRevealed: boolean, activeIndex: number | null }) => {
11
  const showBack = isRevealed && activeIndex === index;
12
 
13
  return (
14
  <div className="relative w-full aspect-[3/4] cursor-pointer perspective-1000 group" onClick={() => !isRevealed && onFlip(index)}>
15
  <div className={`relative w-full h-full text-center transition-transform duration-700 transform-style-3d shadow-md rounded-xl ${showBack ? 'rotate-y-180' : 'hover:scale-[1.02] active:scale-95'}`}>
 
16
  <div className="absolute w-full h-full backface-hidden bg-gradient-to-br from-red-500 to-red-600 rounded-xl flex flex-col items-center justify-center border-4 border-yellow-400 shadow-inner">
17
  <div className="w-10 h-10 md:w-14 md:h-14 bg-yellow-100 rounded-full flex items-center justify-center mb-2 shadow-lg border-2 border-yellow-300">
18
  <span className="text-xl md:text-2xl"><Emoji symbol="🧧"/></span>
19
  </div>
20
  <span className="text-yellow-100 font-black text-lg md:text-xl tracking-widest drop-shadow-md">開</span>
21
  </div>
 
22
  <div className="absolute w-full h-full backface-hidden bg-white rounded-xl flex flex-col items-center justify-center border-4 border-red-200 rotate-y-180 shadow-inner p-2">
23
  <span className="text-3xl mb-2 animate-bounce"><Emoji symbol="🎁"/></span>
24
  <span className="text-red-600 font-bold text-xs md:text-sm break-words leading-tight text-center px-1">{prize}</span>
 
28
  );
29
  };
30
 
31
+ // ... (Keep LuckyWheel component unchanged) ...
32
  const WHEEL_COLORS = ['#ef4444', '#f97316', '#eab308', '#84cc16', '#22c55e', '#3b82f6', '#8b5cf6', '#ec4899'];
33
 
34
  const LuckyWheel = ({ config, isSpinning, result, onSpin }: {
 
38
  onSpin: () => void
39
  }) => {
40
  const [rotation, setRotation] = useState(0);
 
 
41
  const segments = useMemo(() => {
42
  const list = config.prizes.map((p, i) => ({
43
  name: p.name,
 
45
  color: WHEEL_COLORS[i % WHEEL_COLORS.length],
46
  isConsolation: false
47
  }));
 
 
48
  if ((config.consolationWeight || 0) > 0 || list.length === 0) {
49
  list.push({
50
  name: config.defaultPrize || '再接再厉',
51
+ weight: config.consolationWeight || 10,
52
+ color: '#94a3b8',
53
  isConsolation: true
54
  });
55
  }
 
57
  }, [config]);
58
 
59
  const totalWeight = segments.reduce((acc, s) => acc + s.weight, 0);
 
 
60
  let currentAngle = 0;
61
  const slices = segments.map(seg => {
62
  const angle = (seg.weight / totalWeight) * 360;
63
  const start = currentAngle;
64
  currentAngle += angle;
 
65
  const center = start + (angle / 2);
66
  return { ...seg, startAngle: start, endAngle: currentAngle, angle, centerAngle: center };
67
  });
68
 
69
  useEffect(() => {
70
  if (result && isSpinning) {
 
71
  const targetSlice = slices.find(s => s.name === result.prize) || slices[slices.length - 1];
 
 
 
 
 
72
  const minSpins = 5;
73
  const spinAngle = minSpins * 360;
 
 
 
 
74
  const targetLandingAngle = 360 - targetSlice.centerAngle;
 
 
75
  const currentMod = rotation % 360;
 
 
76
  let distance = targetLandingAngle - currentMod;
77
+ if (distance < 0) distance += 360;
 
 
 
78
  const jitter = (Math.random() - 0.5) * (targetSlice.angle * 0.8);
 
79
  const finalRotation = rotation + spinAngle + distance + jitter;
 
80
  setRotation(finalRotation);
81
  }
82
  }, [result, isSpinning]);
83
 
 
84
  const getCoordinatesForPercent = (percent: number) => {
85
  const x = Math.cos(2 * Math.PI * percent);
86
  const y = Math.sin(2 * Math.PI * percent);
 
90
  return (
91
  <div className="flex flex-col items-center justify-center h-full w-full max-w-3xl mx-auto p-4">
92
  <div className="relative w-full aspect-square max-w-[500px] md:max-w-[600px]">
 
93
  <div
94
  className="w-full h-full rounded-full border-8 border-yellow-400 shadow-2xl overflow-hidden relative bg-white"
95
+ style={{ transform: `rotate(${rotation}deg)`, transition: isSpinning ? 'transform 4s cubic-bezier(0.15, 0.85, 0.35, 1)' : 'none' }}
 
 
 
96
  >
97
  <svg viewBox="-1 -1 2 2" style={{ transform: 'rotate(-90deg)' }} className="w-full h-full">
98
  {slices.map((slice, i) => {
 
99
  const start = slice.startAngle / 360;
100
  const end = slice.endAngle / 360;
101
+ const [startX, startY] = getCoordinatesForPercent(end);
102
  const [endX, endY] = getCoordinatesForPercent(start);
103
  const largeArcFlag = slice.angle > 180 ? 1 : 0;
104
  const pathData = `M 0 0 L ${startX} ${startY} A 1 1 0 ${largeArcFlag} 0 ${endX} ${endY} Z`;
 
 
 
105
  const isVertical = slice.angle < 45 || slice.name.length > 4;
106
  const midAngle = (slice.startAngle + slice.endAngle) / 2;
107
  const displayText = slice.name.length > 8 ? slice.name.substring(0,7)+'..' : slice.name;
 
109
  return (
110
  <g key={i}>
111
  <path d={pathData} fill={slice.color} stroke="white" strokeWidth="0.01" />
 
112
  <g transform={`rotate(${midAngle})`}>
113
  {isVertical ? (
 
114
  displayText.split('').map((char, idx) => {
 
115
  const spacing = 0.09;
 
116
  const totalHeight = displayText.length * spacing;
117
  const startR = 0.65 - (totalHeight / 2) + (spacing/2);
118
  const r = startR + idx * spacing;
119
+ return <text key={idx} x={r} y={0} fill="white" fontSize={0.08} fontWeight="bold" textAnchor="middle" dominantBaseline="middle" transform={`rotate(90, ${r}, 0)`}>{char}</text>;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
120
  })
121
  ) : (
122
+ <text x={0.65} y={0} fill="white" fontSize="0.09" fontWeight="bold" textAnchor="middle" dominantBaseline="middle" transform="rotate(90, 0.65, 0)">{displayText}</text>
 
 
 
 
 
 
 
 
 
 
 
 
 
123
  )}
124
  </g>
125
  </g>
 
127
  })}
128
  </svg>
129
  </div>
 
 
130
  <div className="absolute inset-0 pointer-events-none z-20 flex justify-center">
 
131
  <div className="relative w-10 md:w-14 h-[50%]">
132
+ <svg viewBox="0 0 40 300" preserveAspectRatio="none" className="w-full h-full drop-shadow-xl" style={{ filter: 'drop-shadow(0px 4px 4px rgba(0,0,0,0.4))' }}>
133
+ <defs><linearGradient id="pointerGrad" x1="0%" y1="0%" x2="100%" y2="0%"><stop offset="0%" stopColor="#dc2626" /><stop offset="50%" stopColor="#ef4444" /><stop offset="100%" stopColor="#b91c1c" /></linearGradient></defs>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
134
  <path d="M 20 0 L 40 300 L 0 300 Z" fill="url(#pointerGrad)" stroke="white" strokeWidth="2" />
 
 
135
  </svg>
136
  </div>
137
  </div>
 
 
138
  <div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-30 pointer-events-auto">
139
+ <button onClick={onSpin} disabled={isSpinning} className="w-20 h-20 md:w-24 md:h-24 bg-gradient-to-b from-yellow-300 to-yellow-500 rounded-full border-4 border-white shadow-[0_4px_15px_rgba(0,0,0,0.4)] flex items-center justify-center font-black text-red-600 text-xl md:text-2xl hover:scale-105 active:scale-95 disabled:grayscale transition-all active:shadow-inner">
 
 
 
 
140
  {isSpinning ? '...' : '抽奖'}
141
  </button>
142
  </div>
 
145
  );
146
  };
147
 
148
+ export const GameLucky: React.FC<{className?: string}> = ({ className }) => {
149
  const [loading, setLoading] = useState(true);
150
  const [luckyConfig, setLuckyConfig] = useState<LuckyDrawConfig | null>(null);
151
  const [studentInfo, setStudentInfo] = useState<Student | null>(null);
 
156
  const [isFullscreen, setIsFullscreen] = useState(false);
157
 
158
  // Game State
159
+ const [viewMode, setViewMode] = useState<'CARD' | 'WHEEL'>('CARD');
160
  const [drawResult, setDrawResult] = useState<{prize: string, rewardType?: string} | null>(null);
161
 
 
162
  const [activeCardIndex, setActiveCardIndex] = useState<number | null>(null);
 
163
  const [isWheelSpinning, setIsWheelSpinning] = useState(false);
164
 
165
  const currentUser = api.auth.getCurrentUser();
166
  const isTeacher = currentUser?.role === 'TEACHER';
167
  const isStudent = currentUser?.role === 'STUDENT';
168
 
169
+ // Use prop className or fallback to homeroom (legacy)
170
+ const targetClass = className || currentUser?.homeroomClass || (isStudent ? 'MY_CLASS' : '');
171
 
172
  useEffect(() => {
173
  loadData();
174
+ }, [proxyStudentId, targetClass]);
175
 
176
  const loadData = async () => {
177
+ // Resolve "MY_CLASS" for students
178
+ let resolvedClass = targetClass;
179
+ if (targetClass === 'MY_CLASS' && isStudent) {
180
+ // Assume student dashboard or context provides it, here we assume currentUser.className is avail locally or fetch
181
+ // In real app, `currentUser` in localstorage might be stale, but usually `className` doesn't change often.
182
+ // We'll rely on API to fetch fresh student data below anyway.
183
+ }
184
+
185
+ if(!resolvedClass) return;
186
  if(!luckyConfig) setLoading(true);
187
+
188
  try {
189
  const allStus = await api.students.getAll();
190
+
191
+ if (isStudent && targetClass === 'MY_CLASS') {
192
+ const me = allStus.find((s: Student) => s.name === (currentUser.trueName || currentUser.username));
193
+ if(me) resolvedClass = me.className;
194
+ }
195
 
196
  if (isTeacher) {
197
+ const filtered = allStus.filter((s: Student) => s.className === resolvedClass);
198
+ // Sort by Seat No
199
+ filtered.sort((a: Student, b: Student) => {
200
+ const seatA = parseInt(a.seatNo || '99999');
201
+ const seatB = parseInt(b.seatNo || '99999');
202
+ if (seatA !== seatB) return seatA - seatB;
203
+ return a.name.localeCompare(b.name, 'zh-CN');
204
+ });
205
+ setStudents(filtered);
 
 
 
 
 
 
206
 
207
  if (proxyStudentId) {
208
  const proxy = allStus.find((s: Student) => (s._id || String(s.id)) === proxyStudentId);
 
213
  } else if (isStudent) {
214
  const me = allStus.find((s: Student) => s.name === (currentUser.trueName || currentUser.username));
215
  setStudentInfo(me || null);
 
216
  }
217
 
218
+ if (resolvedClass) {
219
+ const config = await api.games.getLuckyConfig(resolvedClass);
220
+ if(!config.className) config.className = resolvedClass;
221
  setLuckyConfig(config);
222
  }
223
 
 
225
  finally { setLoading(false); }
226
  };
227
 
 
228
  const executeDraw = async (triggerIndex?: number) => {
229
  const isBusy = viewMode === 'CARD' ? activeCardIndex !== null : isWheelSpinning;
230
  if (isBusy) return;
 
233
  if (!targetId) return alert(isTeacher ? '请先在右侧选择要代抽的学生' : '学生信息未加载');
234
  if (!studentInfo || (studentInfo.drawAttempts || 0) <= 0) return alert('抽奖次数不足!');
235
 
 
236
  if (viewMode === 'CARD' && triggerIndex !== undefined) setActiveCardIndex(triggerIndex);
237
  if (viewMode === 'WHEEL') setIsWheelSpinning(true);
238
 
239
  try {
240
  const res = await api.games.drawLucky(targetId);
241
+ setDrawResult(res);
242
  setStudentInfo(prev => prev ? ({ ...prev, drawAttempts: (prev.drawAttempts || 0) - 1 }) : null);
243
 
244
+ const delay = viewMode === 'WHEEL' ? 4500 : 1500;
 
245
 
246
  setTimeout(() => {
 
247
  const msg = res.rewardType !== 'CONSOLATION' ? `🎁 恭喜!抽中了:${res.prize}` : `💪 ${res.prize}`;
248
  alert(msg);
 
 
249
  setDrawResult(null);
250
  setActiveCardIndex(null);
251
  setIsWheelSpinning(false);
 
266
  if (loading) return <div className="h-full flex items-center justify-center"><Loader2 className="animate-spin text-blue-600"/></div>;
267
  if (!luckyConfig) return <div className="h-full flex items-center justify-center text-gray-400">配置加载失败</div>;
268
 
269
+ // ... (Keep the render logic the same, just ensure it uses targetClass for display) ...
270
+ const displayClassName = targetClass === 'MY_CLASS' ? (studentInfo?.className || '') : targetClass;
271
+
272
  const GameContent = (
273
  <div className={`${isFullscreen ? 'fixed inset-0 z-[9999]' : 'h-full relative'} flex flex-col md:flex-row bg-gradient-to-br from-red-50 to-orange-50 overflow-hidden`}>
274
+ <button onClick={() => setIsFullscreen(!isFullscreen)} className="absolute top-4 right-4 z-50 p-2 rounded-full bg-white/50 hover:bg-white backdrop-blur shadow-sm border transition-colors md:top-4 md:left-4 md:right-auto">
 
 
 
 
 
275
  {isFullscreen ? <Minimize size={20} className="text-gray-700"/> : <Maximize size={20} className="text-gray-700"/>}
276
  </button>
277
 
 
278
  <div className="flex-1 overflow-y-auto p-4 md:p-8 custom-scrollbar order-1 md:order-1 flex flex-col justify-center">
279
  {viewMode === 'CARD' ? (
280
  <div className={`grid gap-2 md:gap-6 w-full max-w-5xl mx-auto transition-all place-content-center ${
 
284
  'md:grid-cols-4 lg:grid-cols-5')
285
  }`}>
286
  {Array.from({ length: luckyConfig.cardCount || 9 }).map((_, i) => (
287
+ <FlipCard key={i} index={i} prize={drawResult ? drawResult.prize : '???'} onFlip={executeDraw} isRevealed={activeCardIndex === i && !!drawResult} activeIndex={activeCardIndex} />
 
 
 
 
 
 
 
288
  ))}
289
  </div>
290
  ) : (
291
+ <LuckyWheel config={luckyConfig} isSpinning={isWheelSpinning} result={drawResult} onSpin={() => executeDraw()} />
 
 
 
 
 
292
  )}
293
  </div>
294
 
 
295
  <div className="w-full md:w-80 bg-white border-t md:border-t-0 md:border-l border-gray-200 shadow-xl flex flex-col shrink-0 z-20 order-2 md:order-2">
296
  <div className="p-4 md:p-6 border-b border-gray-100 bg-red-600 text-white relative overflow-hidden shrink-0">
297
  <div className="relative z-10 flex justify-between items-center md:block">
298
  <div>
299
  <h3 className="text-lg md:text-xl font-bold flex items-center"><Gift className="mr-2"/> 幸运抽奖</h3>
300
+ <p className="text-red-100 text-xs mt-1">班级专属奖池 | {displayClassName}</p>
301
  </div>
302
  </div>
303
  <div className="absolute -right-4 -bottom-4 text-red-700 opacity-30"><Gift size={80}/></div>
 
322
 
323
  {isTeacher && (
324
  <div className="space-y-3 md:space-y-4">
 
325
  <div className="bg-gray-100 p-1 rounded-lg flex text-xs font-bold">
326
+ <button onClick={() => setViewMode('CARD')} className={`flex-1 py-2 rounded flex items-center justify-center transition-all ${viewMode==='CARD' ? 'bg-white shadow text-red-600' : 'text-gray-500'}`}><LayoutGrid size={14} className="mr-1"/> 翻红包</button>
327
+ <button onClick={() => setViewMode('WHEEL')} className={`flex-1 py-2 rounded flex items-center justify-center transition-all ${viewMode==='WHEEL' ? 'bg-white shadow text-red-600' : 'text-gray-500'}`}><Disc size={14} className="mr-1"/> 大转盘</button>
 
 
 
 
 
 
 
 
 
 
328
  </div>
 
329
  <div>
330
  <label className="text-xs font-bold text-gray-500 uppercase block mb-1">选择代抽学生</label>
331
  <div className="relative">
332
  <UserCircle className="absolute left-3 top-2.5 text-gray-400" size={18}/>
333
+ <select className="w-full pl-10 pr-3 py-2 border border-gray-300 rounded-lg text-sm outline-none focus:ring-2 focus:ring-blue-500 bg-white" value={proxyStudentId} onChange={e => setProxyStudentId(e.target.value)}>
 
 
 
 
334
  <option value="">-- 请选择 --</option>
335
  {students.map(s => <option key={s._id} value={s._id || String(s.id)}>{s.seatNo ? s.seatNo+'.':''}{s.name}</option>)}
336
  </select>
 
347
  </div>
348
  </div>
349
 
350
+ {/* Settings Modal (Same structure, just ensures saveLuckyConfig works) */}
351
  {isSettingsOpen && (
352
  <div className="fixed inset-0 bg-black/60 z-[1000] flex items-center justify-center p-4 backdrop-blur-sm">
353
  <div className="bg-white rounded-2xl w-full max-w-4xl h-[90vh] flex flex-col shadow-2xl animate-in zoom-in-95">
354
  <div className="p-6 border-b border-gray-100 flex justify-between items-center">
355
+ <h3 className="text-xl font-bold text-gray-800">奖池配置 - {displayClassName}</h3>
356
  <button onClick={() => setIsSettingsOpen(false)}><X size={24} className="text-gray-400 hover:text-gray-600"/></button>
357
  </div>
358
+ {/* ... (Keep existing settings UI) ... */}
359
  <div className="flex-1 overflow-y-auto p-6 bg-gray-50/50">
360
  <div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
361
  <div className="bg-white p-4 rounded-xl border shadow-sm">
 
371
  <input className="w-full border rounded-lg px-3 py-2 text-center focus:ring-2 focus:ring-blue-500 outline-none" value={luckyConfig.defaultPrize} onChange={e => setLuckyConfig({...luckyConfig, defaultPrize: e.target.value})}/>
372
  </div>
373
  </div>
374
+ {/* ... (Rest of UI) ... */}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
375
  <div className="bg-white rounded-xl border shadow-sm overflow-hidden">
376
  <table className="w-full text-sm text-left">
377
  <thead className="bg-gray-100 text-gray-500 uppercase text-xs">
 
418
  </div>
419
  </div>
420
  </div>
 
421
  <div className="p-4 border-t border-gray-100 bg-white rounded-b-2xl flex justify-end gap-3 shrink-0">
422
  <button onClick={() => setIsSettingsOpen(false)} className="px-5 py-2.5 text-gray-600 hover:bg-gray-100 rounded-xl transition-colors">取消</button>
423
  <button onClick={saveSettings} className="px-8 py-2.5 bg-blue-600 text-white rounded-xl hover:bg-blue-700 shadow-lg shadow-blue-200 font-bold transition-all">保存配置</button>
pages/GameMonster.tsx CHANGED
@@ -9,9 +9,9 @@ import { Emoji } from '../components/Emoji';
9
  // Monster visual assets
10
  const MONSTER_TYPES = ['👾', '👹', '👺', '👻', '💀', '👽'];
11
 
12
- export const GameMonster: React.FC = () => {
13
  const currentUser = api.auth.getCurrentUser();
14
- const homeroomClass = currentUser?.homeroomClass;
15
 
16
  // React State (for UI rendering)
17
  const [isPlaying, setIsPlaying] = useState(false);
 
9
  // Monster visual assets
10
  const MONSTER_TYPES = ['👾', '👹', '👺', '👻', '💀', '👽'];
11
 
12
+ export const GameMonster: React.FC<{className?: string}> = ({ className }) => {
13
  const currentUser = api.auth.getCurrentUser();
14
+ const homeroomClass = className || currentUser?.homeroomClass;
15
 
16
  // React State (for UI rendering)
17
  const [isPlaying, setIsPlaying] = useState(false);
pages/GameMountain.tsx CHANGED
@@ -3,10 +3,10 @@ import React, { useState, useEffect, useRef } from 'react';
3
  import { createPortal } from 'react-dom';
4
  import { api } from '../services/api';
5
  import { GameSession, GameTeam, Student, GameRewardConfig, AchievementConfig } from '../types';
6
- import { Settings, Plus, Minus, Users, CheckSquare, Loader2, Trash2, X, Flag, Gift, Star, Trophy, Maximize, Minimize } from 'lucide-react';
7
  import { Emoji } from '../components/Emoji';
8
 
9
- // --- CSS Animations for Clouds and Bounce ---
10
  const styles = `
11
  @keyframes float-cloud {
12
  0% { transform: translateX(0); }
@@ -39,33 +39,22 @@ const styles = `
39
  .animate-confetti { animation: confetti-fall 2s ease-out forwards; }
40
  `;
41
 
42
- // --- Math Helper for Consistent Path ---
43
- // Returns x (0-100%) and y (0-100%) based on progress (0-1)
44
- // We map progress 0 -> bottom of path, 1 -> top of path
45
  const calculatePathPoint = (progress: number) => {
46
- // Y: Map 0-1 to roughly 10% - 90% of container height to stay on mountain
47
  const y = 8 + (progress * 80);
48
-
49
- // X: Center is 50%. Sine wave creates winding path.
50
- // Amplitude tapers off slightly at the top to converge at peak
51
  const amplitude = 15 * (1 - (progress * 0.3));
52
- const frequency = 3; // Number of winds
53
  const x = 50 + amplitude * Math.sin(progress * Math.PI * frequency);
54
-
55
  return { x, y };
56
  };
57
 
58
- // Generate SVG Path String dynamically based on the math function
59
  const generatePathString = () => {
60
  let d = "";
61
- // Sample points every 2%
62
  for (let i = 0; i <= 100; i+=2) {
63
  const p = i / 100;
64
  const point = calculatePathPoint(p);
65
-
66
- const svgX = point.x * 2; // 0-100 -> 0-200
67
- const svgY = 300 - (point.y * 3); // 0-100 -> 300-0
68
-
69
  if (i === 0) d += `M ${svgX} ${svgY}`;
70
  else d += ` L ${svgX} ${svgY}`;
71
  }
@@ -94,8 +83,6 @@ const MountainStage = ({ team, index, rewardsConfig, maxSteps, onScoreChange, is
94
  const currentPos = calculatePathPoint(percentage);
95
  const pathString = generatePathString();
96
  const isFinished = team.score >= maxSteps;
97
-
98
- // Width classes: slightly wider in fullscreen
99
  const widthClass = isFullscreen ? 'w-56 md:w-64' : 'w-40 md:w-48';
100
 
101
  return (
@@ -105,7 +92,6 @@ const MountainStage = ({ team, index, rewardsConfig, maxSteps, onScoreChange, is
105
  <div className="absolute top-[2%] z-20 transition-transform duration-300 hover:-translate-y-2 hover:scale-105 cursor-pointer w-full flex justify-center">
106
  <div className="relative bg-white/90 backdrop-blur-md px-3 py-1.5 rounded-xl shadow-md border-2 border-white text-center max-w-[90%]">
107
  <h3 className="text-sm md:text-base font-black text-slate-800 truncate">{team.name}</h3>
108
- {/* Score Badge */}
109
  <div className={`absolute -top-3 -right-3 w-7 h-7 rounded-full flex items-center justify-center font-black border-2 border-white shadow-sm text-xs ${isFinished ? 'bg-yellow-400 text-yellow-900 animate-bounce' : 'bg-gradient-to-br from-amber-400 to-orange-500 text-white'}`}>
110
  {team.score}
111
  </div>
@@ -123,34 +109,9 @@ const MountainStage = ({ team, index, rewardsConfig, maxSteps, onScoreChange, is
123
  <stop offset="100%" stopColor={team.color} stopOpacity="0.5" />
124
  </linearGradient>
125
  </defs>
126
-
127
- {/* Main Mountain Shape */}
128
- <path
129
- d="M100 20 L 180 300 L 20 300 Z"
130
- fill={`url(#grad-${index})`}
131
- stroke={team.color}
132
- strokeWidth="2"
133
- strokeLinejoin="round"
134
- />
135
-
136
- {/* Snow Cap */}
137
- <path
138
- d="M100 20 L 115 60 Q 100 50, 85 60 Z"
139
- fill="white"
140
- opacity="0.9"
141
- />
142
-
143
- {/* Path */}
144
- <path
145
- d={pathString}
146
- fill="none"
147
- stroke="rgba(255,255,255,0.5)"
148
- strokeWidth="2"
149
- strokeDasharray="4,4"
150
- strokeLinecap="round"
151
- />
152
-
153
- {/* Vegetation */}
154
  <g transform="translate(0, 280)">
155
  <circle cx="30" cy="10" r="8" fill="#166534" opacity="0.8"/>
156
  <circle cx="45" cy="15" r="10" fill="#15803d" opacity="0.9"/>
@@ -159,33 +120,22 @@ const MountainStage = ({ team, index, rewardsConfig, maxSteps, onScoreChange, is
159
  </svg>
160
  </div>
161
 
162
- {/* Rewards Placed on Path */}
163
  <div className="absolute bottom-0 left-0 w-full h-[90%] z-10 pointer-events-none">
164
  {rewardsConfig.map((reward, i) => {
165
  const rPct = reward.scoreThreshold / maxSteps;
166
  const isUnlocked = team.score >= reward.scoreThreshold;
167
  const pos = calculatePathPoint(rPct);
168
-
169
  let Icon = Gift;
170
  if (reward.rewardType === 'DRAW_COUNT') Icon = Star;
171
  if (reward.rewardType === 'ACHIEVEMENT') Icon = Trophy;
172
 
173
  return (
174
- <div
175
- key={i}
176
- className={`absolute transition-all duration-500 flex flex-col items-center justify-center transform -translate-x-1/2 -translate-y-1/2
177
- ${isUnlocked ? 'scale-110 z-20' : 'scale-90 opacity-80 z-0'}
178
- `}
179
- style={{ bottom: `${pos.y}%`, left: `${pos.x}%` }}
180
- >
181
- <div className={`
182
- p-1.5 rounded-full shadow-md border relative
183
- ${isUnlocked ? 'bg-yellow-100 border-yellow-400' : 'bg-white border-gray-300'}
184
- `}>
185
  <Icon size={12} className={isUnlocked ? 'text-amber-600' : 'text-gray-400'} />
186
  {isUnlocked && <div className="absolute inset-0 bg-yellow-400 rounded-full blur-sm opacity-50 animate-pulse"></div>}
187
  </div>
188
- {/* Reward Name Label - Always Visible */}
189
  <div className={`mt-1 px-2 py-0.5 rounded text-[10px] font-bold shadow-sm whitespace-nowrap border max-w-[100px] truncate transition-colors ${isUnlocked ? 'bg-yellow-50 text-yellow-700 border-yellow-200' : 'bg-white/90 text-gray-500 border-gray-200'}`}>
190
  {reward.rewardName}
191
  </div>
@@ -194,23 +144,12 @@ const MountainStage = ({ team, index, rewardsConfig, maxSteps, onScoreChange, is
194
  })}
195
  </div>
196
 
197
- {/* Climber Avatar */}
198
- <div
199
- className="absolute z-30 transition-all duration-700 ease-in-out flex flex-col items-center -translate-x-1/2 transform translate-y-1/2"
200
- style={{ bottom: `${currentPos.y}%`, left: `${currentPos.x}%` }}
201
- >
202
- <div className={`
203
- w-12 h-12 md:w-14 md:h-14 bg-white rounded-full border-4 shadow-xl flex items-center justify-center transition-transform relative
204
- ${isFinished ? 'animate-bounce border-yellow-400' : 'hover:scale-110'}
205
- `} style={{ borderColor: isFinished ? '#facc15' : team.color }}>
206
  <span className="text-2xl md:text-3xl"><Emoji symbol={team.avatar || '🧗'} /></span>
207
-
208
- {isFinished && (
209
- <>
210
- <div className="absolute -right-4 -top-6 text-3xl drop-shadow-md origin-bottom-left animate-wave"><Emoji symbol="🚩"/></div>
211
- <CelebrationEffects />
212
- </>
213
- )}
214
  </div>
215
  </div>
216
 
@@ -225,29 +164,20 @@ const MountainStage = ({ team, index, rewardsConfig, maxSteps, onScoreChange, is
225
  );
226
  };
227
 
228
- // Toast Component
229
  const GameToast = ({ title, message, type, onClose }: { title: string, message: string, type: 'success' | 'info', onClose: () => void }) => {
230
- useEffect(() => {
231
- const timer = setTimeout(onClose, 3000);
232
- return () => clearTimeout(timer);
233
- }, [onClose]);
234
-
235
  return (
236
  <div className="fixed top-20 left-1/2 -translate-x-1/2 z-[10000] animate-in slide-in-from-top-5 fade-in duration-300">
237
  <div className={`flex items-center gap-3 px-6 py-4 rounded-xl shadow-2xl border-2 backdrop-blur-md ${type === 'success' ? 'bg-yellow-50/95 border-yellow-400 text-yellow-900' : 'bg-white/95 border-blue-200 text-slate-800'}`}>
238
- <div className={`p-2 rounded-full ${type === 'success' ? 'bg-yellow-400 text-white' : 'bg-blue-500 text-white'}`}>
239
- {type === 'success' ? <Trophy size={24} /> : <Gift size={24} />}
240
- </div>
241
- <div>
242
- <h4 className="font-black text-lg leading-tight">{title}</h4>
243
- <p className="text-sm opacity-90 font-medium">{message}</p>
244
- </div>
245
  </div>
246
  </div>
247
  );
248
  };
249
 
250
- export const GameMountain: React.FC = () => {
251
  const [session, setSession] = useState<GameSession | null>(null);
252
  const [loading, setLoading] = useState(true);
253
  const [students, setStudents] = useState<Student[]>([]);
@@ -256,36 +186,41 @@ export const GameMountain: React.FC = () => {
256
  const [isFullscreen, setIsFullscreen] = useState(false);
257
  const [selectedTeamId, setSelectedTeamId] = useState<string | null>(null);
258
  const [achConfig, setAchConfig] = useState<AchievementConfig | null>(null);
 
259
 
260
- // Toast State
261
  const [toast, setToast] = useState<{title: string, message: string, type: 'success' | 'info'} | null>(null);
262
 
263
  const currentUser = api.auth.getCurrentUser();
264
  const isTeacher = currentUser?.role === 'TEACHER';
265
  const isAdmin = currentUser?.role === 'ADMIN';
266
 
267
- useEffect(() => { loadData(); }, []);
 
 
 
268
 
269
  const loadData = async () => {
 
270
  setLoading(true);
 
 
 
 
 
 
 
 
 
 
 
271
  try {
272
- if (!currentUser) return;
273
- const allStudents = await api.students.getAll();
274
- let targetClass = '';
275
- let filteredStudents: Student[] = [];
276
 
277
- if (isTeacher && currentUser.homeroomClass) {
278
- targetClass = currentUser.homeroomClass;
279
- filteredStudents = allStudents.filter((s: Student) => s.className === targetClass);
280
- const ac = await api.achievements.getConfig(targetClass);
281
- setAchConfig(ac);
282
- } else if (currentUser.role === 'STUDENT') {
283
- const me = allStudents.find((s: Student) => s.name === (currentUser.trueName || currentUser.username));
284
- if (me) targetClass = me.className;
285
- } else if (isAdmin) {
286
- filteredStudents = allStudents;
287
- }
288
-
289
  filteredStudents.sort((a, b) => {
290
  const seatA = parseInt(a.seatNo || '99999');
291
  const seatB = parseInt(b.seatNo || '99999');
@@ -294,36 +229,53 @@ export const GameMountain: React.FC = () => {
294
  });
295
  setStudents(filteredStudents);
296
 
297
- if (targetClass) {
298
- const sess = await api.games.getMountainSession(targetClass);
299
- if (sess) {
300
- setSession(sess);
301
- } else if (isTeacher && currentUser?.schoolId) {
302
- const newSess: GameSession = {
303
- schoolId: currentUser.schoolId,
304
- className: targetClass,
305
- subject: '综合',
306
- isEnabled: true,
307
- maxSteps: 10,
308
- teams: [
309
- { id: '1', name: '猛虎队', score: 0, avatar: '🐯', color: '#ef4444', members: [] },
310
- { id: '2', name: '雄鹰队', score: 0, avatar: '🦅', color: '#3b82f6', members: [] },
311
- { id: '3', name: '飞龙队', score: 0, avatar: '🐉', color: '#10b981', members: [] }
312
- ],
313
- rewardsConfig: [
314
- { scoreThreshold: 5, rewardType: 'DRAW_COUNT', rewardName: '抽奖券x1', rewardValue: 1 },
315
- { scoreThreshold: 10, rewardType: 'ITEM', rewardName: '神秘大礼', rewardValue: 1 }
316
- ]
317
- };
318
- setSession(newSess);
319
- }
320
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
321
  } catch (e) { console.error(e); }
322
  finally { setLoading(false); }
323
  };
324
 
325
  const handleScoreChange = async (teamId: string, delta: number) => {
326
- if (!session || !isTeacher) return;
327
  const sysConfig = await api.config.getPublic();
328
  const currentSemester = sysConfig?.semester || '当前学期';
329
 
@@ -331,10 +283,8 @@ export const GameMountain: React.FC = () => {
331
  if (t.id !== teamId) return t;
332
  const newScore = Math.max(0, Math.min(session.maxSteps, t.score + delta));
333
 
334
- // Detect Reward Trigger or Summit
335
  if (delta > 0 && newScore > t.score) {
336
  const reward = session.rewardsConfig.find(r => r.scoreThreshold === newScore);
337
-
338
  if (newScore === session.maxSteps) {
339
  setToast({ title: `🏆 巅峰时刻!`, message: `恭喜 [${t.name}] 成功登顶!`, type: 'success' });
340
  } else if (reward) {
@@ -371,12 +321,22 @@ export const GameMountain: React.FC = () => {
371
 
372
  const newSession = { ...session, teams: newTeams };
373
  setSession(newSession);
374
- await api.games.saveMountainSession(newSession);
 
 
 
 
 
 
375
  };
376
 
377
  const saveSettings = async () => {
378
- if (session) await api.games.saveMountainSession(session);
379
- setIsSettingsOpen(false);
 
 
 
 
380
  };
381
 
382
  const toggleTeamMember = (studentId: string, teamId: string) => {
@@ -395,7 +355,7 @@ export const GameMountain: React.FC = () => {
395
  };
396
 
397
  if (loading) return <div className="h-full flex items-center justify-center"><Loader2 className="animate-spin text-blue-600"/></div>;
398
- if (!session) return <div className="h-full flex items-center justify-center text-gray-400">暂无游戏会话,请联系班主任开启。</div>;
399
 
400
  const GameContent = (
401
  <div className={`${isFullscreen ? 'fixed inset-0 z-[9999] w-screen h-screen' : 'h-full w-full relative'} flex flex-col bg-gradient-to-b from-sky-300 via-sky-100 to-emerald-50 overflow-hidden`}>
@@ -406,8 +366,6 @@ export const GameMountain: React.FC = () => {
406
  <div className="absolute top-10 right-20 w-24 h-24 bg-yellow-300 rounded-full blur-xl opacity-60 animate-pulse"></div>
407
  <div className="absolute top-16 -left-20 text-white/60 text-9xl select-none animate-drift-slow opacity-80" style={{filter: 'blur(2px)'}}><Emoji symbol="☁️"/></div>
408
  <div className="absolute top-32 -left-40 text-white/40 text-8xl select-none animate-drift-medium opacity-60" style={{animationDelay: '5s'}}><Emoji symbol="☁️"/></div>
409
- <div className="absolute top-10 -left-10 text-white/50 text-[10rem] select-none animate-drift-fast opacity-40" style={{animationDelay: '15s'}}><Emoji symbol="☁️"/></div>
410
- <div className="absolute top-24 left-1/4 text-slate-600/30 text-2xl animate-bounce-slow"><Emoji symbol="🕊️"/></div>
411
  </div>
412
 
413
  {/* Toast Overlay */}
@@ -415,15 +373,15 @@ export const GameMountain: React.FC = () => {
415
 
416
  {/* Toolbar */}
417
  <div className="absolute top-4 right-4 z-50 flex gap-2">
418
- <button
419
- onClick={() => setIsFullscreen(!isFullscreen)}
420
- className="p-2 bg-white/80 backdrop-blur rounded-full hover:bg-white shadow-sm border border-white/50 transition-colors"
421
- title={isFullscreen ? "退出全屏" : "全屏"}
422
- >
 
423
  {isFullscreen ? <Minimize size={20} className="text-gray-700"/> : <Maximize size={20} className="text-gray-700"/>}
424
  </button>
425
-
426
- {isTeacher && (
427
  <button onClick={() => setIsSettingsOpen(true)} className="flex items-center text-xs font-bold text-slate-700 bg-white/90 backdrop-blur px-4 py-2 rounded-2xl border border-white/50 hover:bg-white shadow-md transition-all hover:scale-105 active:scale-95">
428
  <Settings size={16} className="mr-2"/> 设置
429
  </button>
@@ -440,7 +398,7 @@ export const GameMountain: React.FC = () => {
440
  index={idx}
441
  rewardsConfig={session.rewardsConfig}
442
  maxSteps={session.maxSteps}
443
- onScoreChange={isTeacher ? handleScoreChange : undefined}
444
  isFullscreen={isFullscreen}
445
  />
446
  ))}
@@ -448,7 +406,7 @@ export const GameMountain: React.FC = () => {
448
  </div>
449
 
450
  {/* SETTINGS MODAL */}
451
- {isSettingsOpen && (
452
  <div className="fixed inset-0 bg-black/60 z-[100000] flex items-center justify-center p-4 backdrop-blur-sm">
453
  <div className="bg-white rounded-2xl w-full max-w-5xl h-[90vh] flex flex-col shadow-2xl animate-in zoom-in-95">
454
  <div className="p-6 border-b border-gray-100 flex justify-between items-center shrink-0">
@@ -466,13 +424,6 @@ export const GameMountain: React.FC = () => {
466
  <label className="text-xs text-gray-500 block mb-1">山峰高度 (步数)</label>
467
  <input type="number" className="border rounded-lg px-3 py-2 w-24 text-center font-bold text-lg focus:ring-2 focus:ring-blue-500 outline-none" value={session.maxSteps} onChange={e => setSession({...session, maxSteps: Number(e.target.value)})} min={5} max={50}/>
468
  </div>
469
- <div className="flex-1">
470
- <label className="text-xs text-gray-500 block mb-1">状态</label>
471
- <div className="flex items-center h-10">
472
- <input type="checkbox" checked={session.isEnabled} onChange={e => setSession({...session, isEnabled: e.target.checked})} className="w-5 h-5 text-blue-600 rounded focus:ring-blue-500"/>
473
- <span className="ml-2 text-sm font-medium">启用游戏</span>
474
- </div>
475
- </div>
476
  </div>
477
  </section>
478
 
 
3
  import { createPortal } from 'react-dom';
4
  import { api } from '../services/api';
5
  import { GameSession, GameTeam, Student, GameRewardConfig, AchievementConfig } from '../types';
6
+ import { Settings, Plus, Minus, Users, CheckSquare, Loader2, Trash2, X, Flag, Gift, Star, Trophy, Maximize, Minimize, Lock } from 'lucide-react';
7
  import { Emoji } from '../components/Emoji';
8
 
9
+ // ... (Keep CSS styles unchanged) ...
10
  const styles = `
11
  @keyframes float-cloud {
12
  0% { transform: translateX(0); }
 
39
  .animate-confetti { animation: confetti-fall 2s ease-out forwards; }
40
  `;
41
 
42
+ // ... (Keep calculatePathPoint and generatePathString unchanged) ...
 
 
43
  const calculatePathPoint = (progress: number) => {
 
44
  const y = 8 + (progress * 80);
 
 
 
45
  const amplitude = 15 * (1 - (progress * 0.3));
46
+ const frequency = 3;
47
  const x = 50 + amplitude * Math.sin(progress * Math.PI * frequency);
 
48
  return { x, y };
49
  };
50
 
 
51
  const generatePathString = () => {
52
  let d = "";
 
53
  for (let i = 0; i <= 100; i+=2) {
54
  const p = i / 100;
55
  const point = calculatePathPoint(p);
56
+ const svgX = point.x * 2;
57
+ const svgY = 300 - (point.y * 3);
 
 
58
  if (i === 0) d += `M ${svgX} ${svgY}`;
59
  else d += ` L ${svgX} ${svgY}`;
60
  }
 
83
  const currentPos = calculatePathPoint(percentage);
84
  const pathString = generatePathString();
85
  const isFinished = team.score >= maxSteps;
 
 
86
  const widthClass = isFullscreen ? 'w-56 md:w-64' : 'w-40 md:w-48';
87
 
88
  return (
 
92
  <div className="absolute top-[2%] z-20 transition-transform duration-300 hover:-translate-y-2 hover:scale-105 cursor-pointer w-full flex justify-center">
93
  <div className="relative bg-white/90 backdrop-blur-md px-3 py-1.5 rounded-xl shadow-md border-2 border-white text-center max-w-[90%]">
94
  <h3 className="text-sm md:text-base font-black text-slate-800 truncate">{team.name}</h3>
 
95
  <div className={`absolute -top-3 -right-3 w-7 h-7 rounded-full flex items-center justify-center font-black border-2 border-white shadow-sm text-xs ${isFinished ? 'bg-yellow-400 text-yellow-900 animate-bounce' : 'bg-gradient-to-br from-amber-400 to-orange-500 text-white'}`}>
96
  {team.score}
97
  </div>
 
109
  <stop offset="100%" stopColor={team.color} stopOpacity="0.5" />
110
  </linearGradient>
111
  </defs>
112
+ <path d="M100 20 L 180 300 L 20 300 Z" fill={`url(#grad-${index})`} stroke={team.color} strokeWidth="2" strokeLinejoin="round"/>
113
+ <path d="M100 20 L 115 60 Q 100 50, 85 60 Z" fill="white" opacity="0.9"/>
114
+ <path d={pathString} fill="none" stroke="rgba(255,255,255,0.5)" strokeWidth="2" strokeDasharray="4,4" strokeLinecap="round"/>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
  <g transform="translate(0, 280)">
116
  <circle cx="30" cy="10" r="8" fill="#166534" opacity="0.8"/>
117
  <circle cx="45" cy="15" r="10" fill="#15803d" opacity="0.9"/>
 
120
  </svg>
121
  </div>
122
 
123
+ {/* Rewards */}
124
  <div className="absolute bottom-0 left-0 w-full h-[90%] z-10 pointer-events-none">
125
  {rewardsConfig.map((reward, i) => {
126
  const rPct = reward.scoreThreshold / maxSteps;
127
  const isUnlocked = team.score >= reward.scoreThreshold;
128
  const pos = calculatePathPoint(rPct);
 
129
  let Icon = Gift;
130
  if (reward.rewardType === 'DRAW_COUNT') Icon = Star;
131
  if (reward.rewardType === 'ACHIEVEMENT') Icon = Trophy;
132
 
133
  return (
134
+ <div key={i} className={`absolute transition-all duration-500 flex flex-col items-center justify-center transform -translate-x-1/2 -translate-y-1/2 ${isUnlocked ? 'scale-110 z-20' : 'scale-90 opacity-80 z-0'}`} style={{ bottom: `${pos.y}%`, left: `${pos.x}%` }}>
135
+ <div className={`p-1.5 rounded-full shadow-md border relative ${isUnlocked ? 'bg-yellow-100 border-yellow-400' : 'bg-white border-gray-300'}`}>
 
 
 
 
 
 
 
 
 
136
  <Icon size={12} className={isUnlocked ? 'text-amber-600' : 'text-gray-400'} />
137
  {isUnlocked && <div className="absolute inset-0 bg-yellow-400 rounded-full blur-sm opacity-50 animate-pulse"></div>}
138
  </div>
 
139
  <div className={`mt-1 px-2 py-0.5 rounded text-[10px] font-bold shadow-sm whitespace-nowrap border max-w-[100px] truncate transition-colors ${isUnlocked ? 'bg-yellow-50 text-yellow-700 border-yellow-200' : 'bg-white/90 text-gray-500 border-gray-200'}`}>
140
  {reward.rewardName}
141
  </div>
 
144
  })}
145
  </div>
146
 
147
+ {/* Climber */}
148
+ <div className="absolute z-30 transition-all duration-700 ease-in-out flex flex-col items-center -translate-x-1/2 transform translate-y-1/2" style={{ bottom: `${currentPos.y}%`, left: `${currentPos.x}%` }}>
149
+ <div className={`w-12 h-12 md:w-14 md:h-14 bg-white rounded-full border-4 shadow-xl flex items-center justify-center transition-transform relative ${isFinished ? 'animate-bounce border-yellow-400' : 'hover:scale-110'}`} style={{ borderColor: isFinished ? '#facc15' : team.color }}>
 
 
 
 
 
 
150
  <span className="text-2xl md:text-3xl"><Emoji symbol={team.avatar || '🧗'} /></span>
151
+ {isFinished && <div className="absolute -right-4 -top-6 text-3xl drop-shadow-md origin-bottom-left animate-wave"><Emoji symbol="🚩"/></div>}
152
+ {isFinished && <CelebrationEffects />}
 
 
 
 
 
153
  </div>
154
  </div>
155
 
 
164
  );
165
  };
166
 
167
+ // Toast
168
  const GameToast = ({ title, message, type, onClose }: { title: string, message: string, type: 'success' | 'info', onClose: () => void }) => {
169
+ useEffect(() => { const timer = setTimeout(onClose, 3000); return () => clearTimeout(timer); }, [onClose]);
 
 
 
 
170
  return (
171
  <div className="fixed top-20 left-1/2 -translate-x-1/2 z-[10000] animate-in slide-in-from-top-5 fade-in duration-300">
172
  <div className={`flex items-center gap-3 px-6 py-4 rounded-xl shadow-2xl border-2 backdrop-blur-md ${type === 'success' ? 'bg-yellow-50/95 border-yellow-400 text-yellow-900' : 'bg-white/95 border-blue-200 text-slate-800'}`}>
173
+ <div className={`p-2 rounded-full ${type === 'success' ? 'bg-yellow-400 text-white' : 'bg-blue-500 text-white'}`}>{type === 'success' ? <Trophy size={24} /> : <Gift size={24} />}</div>
174
+ <div><h4 className="font-black text-lg leading-tight">{title}</h4><p className="text-sm opacity-90 font-medium">{message}</p></div>
 
 
 
 
 
175
  </div>
176
  </div>
177
  );
178
  };
179
 
180
+ export const GameMountain: React.FC<{className?: string}> = ({ className }) => {
181
  const [session, setSession] = useState<GameSession | null>(null);
182
  const [loading, setLoading] = useState(true);
183
  const [students, setStudents] = useState<Student[]>([]);
 
186
  const [isFullscreen, setIsFullscreen] = useState(false);
187
  const [selectedTeamId, setSelectedTeamId] = useState<string | null>(null);
188
  const [achConfig, setAchConfig] = useState<AchievementConfig | null>(null);
189
+ const [canEdit, setCanEdit] = useState(false); // New: Read-only check
190
 
 
191
  const [toast, setToast] = useState<{title: string, message: string, type: 'success' | 'info'} | null>(null);
192
 
193
  const currentUser = api.auth.getCurrentUser();
194
  const isTeacher = currentUser?.role === 'TEACHER';
195
  const isAdmin = currentUser?.role === 'ADMIN';
196
 
197
+ // Use prop className or fallback to homeroom (for legacy)
198
+ const targetClass = className || currentUser?.homeroomClass || (currentUser?.role === 'STUDENT' ? 'MY_CLASS' : '');
199
+
200
+ useEffect(() => { loadData(); }, [targetClass]);
201
 
202
  const loadData = async () => {
203
+ if (!targetClass || targetClass === 'MY_CLASS' && !currentUser.homeroomClass) return;
204
  setLoading(true);
205
+
206
+ // Resolve "MY_CLASS" for students
207
+ let resolvedClass = targetClass;
208
+ if (targetClass === 'MY_CLASS' && currentUser.role === 'STUDENT') {
209
+ // We need to fetch student's class first? Actually dashboard loads it.
210
+ // Assuming passed prop is correct or we fetch student
211
+ const stus = await api.students.getAll();
212
+ const me = stus.find((s: Student) => s.name === (currentUser.trueName || currentUser.username));
213
+ if(me) resolvedClass = me.className;
214
+ }
215
+
216
  try {
217
+ const [allStudents, sess] = await Promise.all([
218
+ api.students.getAll(),
219
+ api.games.getMountainSession(resolvedClass)
220
+ ]);
221
 
222
+ const filteredStudents = allStudents.filter((s: Student) => s.className === resolvedClass);
223
+ // Sort
 
 
 
 
 
 
 
 
 
 
224
  filteredStudents.sort((a, b) => {
225
  const seatA = parseInt(a.seatNo || '99999');
226
  const seatB = parseInt(b.seatNo || '99999');
 
229
  });
230
  setStudents(filteredStudents);
231
 
232
+ if (sess) {
233
+ setSession(sess);
234
+ } else if (isTeacher && currentUser?.schoolId) {
235
+ // Initialize if empty
236
+ const newSess: GameSession = {
237
+ schoolId: currentUser.schoolId,
238
+ className: resolvedClass,
239
+ subject: '综合',
240
+ isEnabled: true,
241
+ maxSteps: 10,
242
+ teams: [
243
+ { id: '1', name: '猛虎队', score: 0, avatar: '🐯', color: '#ef4444', members: [] },
244
+ { id: '2', name: '雄鹰队', score: 0, avatar: '🦅', color: '#3b82f6', members: [] },
245
+ { id: '3', name: '飞龙队', score: 0, avatar: '🐉', color: '#10b981', members: [] }
246
+ ],
247
+ rewardsConfig: [
248
+ { scoreThreshold: 5, rewardType: 'DRAW_COUNT', rewardName: '抽奖券x1', rewardValue: 1 },
249
+ { scoreThreshold: 10, rewardType: 'ITEM', rewardName: '神秘大礼', rewardValue: 1 }
250
+ ]
251
+ };
252
+ setSession(newSess);
 
 
253
  }
254
+
255
+ // Permissions Check
256
+ if (isAdmin) setCanEdit(true);
257
+ else if (isTeacher) {
258
+ // Fetch class info to check homeroomTeacherIds
259
+ const clsList = await api.classes.getAll();
260
+ const cls = clsList.find(c => c.grade + c.className === resolvedClass);
261
+ if (cls && (cls.homeroomTeacherIds?.includes(currentUser._id) || cls.teacherName?.includes(currentUser.trueName || currentUser.username))) {
262
+ setCanEdit(true);
263
+ // Only load Ach config if can edit
264
+ const ac = await api.achievements.getConfig(resolvedClass);
265
+ setAchConfig(ac);
266
+ } else {
267
+ setCanEdit(false);
268
+ }
269
+ } else {
270
+ setCanEdit(false);
271
+ }
272
+
273
  } catch (e) { console.error(e); }
274
  finally { setLoading(false); }
275
  };
276
 
277
  const handleScoreChange = async (teamId: string, delta: number) => {
278
+ if (!session || !canEdit) return;
279
  const sysConfig = await api.config.getPublic();
280
  const currentSemester = sysConfig?.semester || '当前学期';
281
 
 
283
  if (t.id !== teamId) return t;
284
  const newScore = Math.max(0, Math.min(session.maxSteps, t.score + delta));
285
 
 
286
  if (delta > 0 && newScore > t.score) {
287
  const reward = session.rewardsConfig.find(r => r.scoreThreshold === newScore);
 
288
  if (newScore === session.maxSteps) {
289
  setToast({ title: `🏆 巅峰时刻!`, message: `恭喜 [${t.name}] 成功登顶!`, type: 'success' });
290
  } else if (reward) {
 
321
 
322
  const newSession = { ...session, teams: newTeams };
323
  setSession(newSession);
324
+
325
+ try {
326
+ await api.games.saveMountainSession(newSession);
327
+ } catch(e) {
328
+ alert('保存失败:您可能没有权限修改此班级数据');
329
+ loadData(); // Revert
330
+ }
331
  };
332
 
333
  const saveSettings = async () => {
334
+ if (session) {
335
+ try {
336
+ await api.games.saveMountainSession(session);
337
+ setIsSettingsOpen(false);
338
+ } catch(e) { alert('保存失败'); }
339
+ }
340
  };
341
 
342
  const toggleTeamMember = (studentId: string, teamId: string) => {
 
355
  };
356
 
357
  if (loading) return <div className="h-full flex items-center justify-center"><Loader2 className="animate-spin text-blue-600"/></div>;
358
+ if (!session) return <div className="h-full flex items-center justify-center text-gray-400">暂无数据</div>;
359
 
360
  const GameContent = (
361
  <div className={`${isFullscreen ? 'fixed inset-0 z-[9999] w-screen h-screen' : 'h-full w-full relative'} flex flex-col bg-gradient-to-b from-sky-300 via-sky-100 to-emerald-50 overflow-hidden`}>
 
366
  <div className="absolute top-10 right-20 w-24 h-24 bg-yellow-300 rounded-full blur-xl opacity-60 animate-pulse"></div>
367
  <div className="absolute top-16 -left-20 text-white/60 text-9xl select-none animate-drift-slow opacity-80" style={{filter: 'blur(2px)'}}><Emoji symbol="☁️"/></div>
368
  <div className="absolute top-32 -left-40 text-white/40 text-8xl select-none animate-drift-medium opacity-60" style={{animationDelay: '5s'}}><Emoji symbol="☁️"/></div>
 
 
369
  </div>
370
 
371
  {/* Toast Overlay */}
 
373
 
374
  {/* Toolbar */}
375
  <div className="absolute top-4 right-4 z-50 flex gap-2">
376
+ {!canEdit && isTeacher && (
377
+ <div className="px-3 py-1.5 bg-gray-100/80 backdrop-blur rounded-full text-xs font-bold text-gray-500 flex items-center border border-gray-200">
378
+ <Lock size={14} className="mr-1"/> 只读模式 (非班主任)
379
+ </div>
380
+ )}
381
+ <button onClick={() => setIsFullscreen(!isFullscreen)} className="p-2 bg-white/80 backdrop-blur rounded-full hover:bg-white shadow-sm border border-white/50 transition-colors">
382
  {isFullscreen ? <Minimize size={20} className="text-gray-700"/> : <Maximize size={20} className="text-gray-700"/>}
383
  </button>
384
+ {canEdit && (
 
385
  <button onClick={() => setIsSettingsOpen(true)} className="flex items-center text-xs font-bold text-slate-700 bg-white/90 backdrop-blur px-4 py-2 rounded-2xl border border-white/50 hover:bg-white shadow-md transition-all hover:scale-105 active:scale-95">
386
  <Settings size={16} className="mr-2"/> 设置
387
  </button>
 
398
  index={idx}
399
  rewardsConfig={session.rewardsConfig}
400
  maxSteps={session.maxSteps}
401
+ onScoreChange={canEdit ? handleScoreChange : undefined}
402
  isFullscreen={isFullscreen}
403
  />
404
  ))}
 
406
  </div>
407
 
408
  {/* SETTINGS MODAL */}
409
+ {isSettingsOpen && canEdit && (
410
  <div className="fixed inset-0 bg-black/60 z-[100000] flex items-center justify-center p-4 backdrop-blur-sm">
411
  <div className="bg-white rounded-2xl w-full max-w-5xl h-[90vh] flex flex-col shadow-2xl animate-in zoom-in-95">
412
  <div className="p-6 border-b border-gray-100 flex justify-between items-center shrink-0">
 
424
  <label className="text-xs text-gray-500 block mb-1">山峰高度 (步数)</label>
425
  <input type="number" className="border rounded-lg px-3 py-2 w-24 text-center font-bold text-lg focus:ring-2 focus:ring-blue-500 outline-none" value={session.maxSteps} onChange={e => setSession({...session, maxSteps: Number(e.target.value)})} min={5} max={50}/>
426
  </div>
 
 
 
 
 
 
 
427
  </div>
428
  </section>
429
 
pages/GameRandom.tsx CHANGED
@@ -6,7 +6,7 @@ import { Student, GameSession, GameTeam, AchievementConfig } from '../types';
6
  import { Loader2, User, Users, Play, Pause, Gift, CheckCircle, XCircle, Award, Volume2, Settings, Maximize, Minimize, UserX, RotateCcw, Repeat } from 'lucide-react';
7
  import { Emoji } from '../components/Emoji';
8
 
9
- export const GameRandom: React.FC = () => {
10
  const [loading, setLoading] = useState(true);
11
  const [students, setStudents] = useState<Student[]>([]);
12
  const [teams, setTeams] = useState<GameTeam[]>([]);
@@ -39,7 +39,7 @@ export const GameRandom: React.FC = () => {
39
  const speedRef = useRef<number>(50);
40
 
41
  const currentUser = api.auth.getCurrentUser();
42
- const homeroomClass = currentUser?.homeroomClass;
43
 
44
  useEffect(() => {
45
  loadData();
 
6
  import { Loader2, User, Users, Play, Pause, Gift, CheckCircle, XCircle, Award, Volume2, Settings, Maximize, Minimize, UserX, RotateCcw, Repeat } from 'lucide-react';
7
  import { Emoji } from '../components/Emoji';
8
 
9
+ export const GameRandom: React.FC<{className?: string}> = ({ className }) => {
10
  const [loading, setLoading] = useState(true);
11
  const [students, setStudents] = useState<Student[]>([]);
12
  const [teams, setTeams] = useState<GameTeam[]>([]);
 
39
  const speedRef = useRef<number>(50);
40
 
41
  const currentUser = api.auth.getCurrentUser();
42
+ const homeroomClass = className || currentUser?.homeroomClass;
43
 
44
  useEffect(() => {
45
  loadData();
pages/GameZen.tsx CHANGED
@@ -6,9 +6,9 @@ import { AchievementConfig, Student } from '../types';
6
  import { Play, Pause, Settings, Maximize, Minimize, Gift, Trophy, Package, Moon, UserX, CheckCircle, Volume2 } from 'lucide-react';
7
  import { Emoji } from '../components/Emoji';
8
 
9
- export const GameZen: React.FC = () => {
10
  const currentUser = api.auth.getCurrentUser();
11
- const homeroomClass = currentUser?.homeroomClass;
12
 
13
  // React State
14
  const [isPlaying, setIsPlaying] = useState(false);
 
6
  import { Play, Pause, Settings, Maximize, Minimize, Gift, Trophy, Package, Moon, UserX, CheckCircle, Volume2 } from 'lucide-react';
7
  import { Emoji } from '../components/Emoji';
8
 
9
+ export const GameZen: React.FC<{className?: string}> = ({ className }) => {
10
  const currentUser = api.auth.getCurrentUser();
11
+ const homeroomClass = className || currentUser?.homeroomClass;
12
 
13
  // React State
14
  const [isPlaying, setIsPlaying] = useState(false);
pages/Games.tsx CHANGED
@@ -1,6 +1,6 @@
1
 
2
- import React, { useState } from 'react';
3
- import { Trophy, Star, Award, Zap, Mic, Moon } from 'lucide-react';
4
  import { GameMountain } from './GameMountain';
5
  import { GameLucky } from './GameLucky';
6
  import { GameRandom } from './GameRandom';
@@ -10,27 +10,104 @@ import { GameRewards } from './GameRewards';
10
  import { AchievementTeacher } from './AchievementTeacher';
11
  import { AchievementStudent } from './AchievementStudent';
12
  import { api } from '../services/api';
 
13
 
14
  export const Games: React.FC = () => {
15
  const [activeTab, setActiveTab] = useState<'games' | 'achievements' | 'rewards'>('games');
16
  const [activeGame, setActiveGame] = useState<'mountain' | 'lucky' | 'random' | 'monster' | 'zen'>('mountain');
 
 
 
 
 
17
 
18
  const currentUser = api.auth.getCurrentUser();
19
  const isStudent = currentUser?.role === 'STUDENT';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
 
21
  return (
22
  <div className="flex flex-col h-[calc(100vh-120px)] w-full max-w-full overflow-hidden">
23
- {/* Top Tabs Switcher */}
24
- <div className="flex justify-center space-x-4 mb-4 shrink-0 overflow-x-auto px-4 py-1">
25
- <button onClick={() => setActiveTab('games')} className={`px-6 py-2.5 rounded-full font-bold flex items-center transition-all whitespace-nowrap ${activeTab === 'games' ? 'bg-blue-600 text-white shadow-lg shadow-blue-200 scale-105' : 'bg-white text-gray-500 hover:bg-gray-50 border border-transparent'}`}>
26
- <Trophy className="mr-2" size={18}/> 互动游戏
27
- </button>
28
- <button onClick={() => setActiveTab('achievements')} className={`px-6 py-2.5 rounded-full font-bold flex items-center transition-all whitespace-nowrap ${activeTab === 'achievements' ? 'bg-amber-500 text-white shadow-lg shadow-amber-200 scale-105' : 'bg-white text-gray-500 hover:bg-gray-50 border border-transparent'}`}>
29
- <Award className="mr-2" size={18}/> 成就系统
30
- </button>
31
- <button onClick={() => setActiveTab('rewards')} className={`px-6 py-2.5 rounded-full font-bold flex items-center transition-all whitespace-nowrap ${activeTab === 'rewards' ? 'bg-emerald-500 text-white shadow-lg shadow-emerald-200 scale-105' : 'bg-white text-gray-500 hover:bg-gray-50 border border-transparent'}`}>
32
- <Star className="mr-2" size={18}/> 奖励管理
33
- </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  </div>
35
 
36
  <div className="flex-1 min-h-0 relative animate-in fade-in slide-in-from-bottom-4 duration-500">
@@ -39,39 +116,47 @@ export const Games: React.FC = () => {
39
  <div className="h-full flex flex-col">
40
  {/* Sub Game Switcher */}
41
  <div className="flex justify-center space-x-2 mb-4 shrink-0 overflow-x-auto px-2">
42
- <button onClick={() => setActiveGame('mountain')} className={`px-4 py-1.5 rounded-xl text-sm font-bold transition-all whitespace-nowrap ${activeGame === 'mountain' ? 'bg-sky-100 text-sky-700 border-2 border-sky-200 shadow-sm' : 'text-gray-500 hover:bg-white hover:shadow-sm border-2 border-transparent'}`}>
43
- 🏔️ 群岳争锋
44
  </button>
45
- <button onClick={() => setActiveGame('lucky')} className={`px-4 py-1.5 rounded-xl text-sm font-bold transition-all whitespace-nowrap ${activeGame === 'lucky' ? 'bg-red-100 text-red-700 border-2 border-red-200 shadow-sm' : 'text-gray-500 hover:bg-white hover:shadow-sm border-2 border-transparent'}`}>
46
- 🧧 幸运抽奖
47
  </button>
48
- <button onClick={() => setActiveGame('random')} className={`px-4 py-1.5 rounded-xl text-sm font-bold transition-all whitespace-nowrap ${activeGame === 'random' ? 'bg-yellow-100 text-yellow-700 border-2 border-yellow-200 shadow-sm' : 'text-gray-500 hover:bg-white hover:shadow-sm border-2 border-transparent'}`}>
49
- <Zap size={14} className="inline mr-1"/> 随机点名
50
  </button>
51
- <button onClick={() => setActiveGame('monster')} className={`px-4 py-1.5 rounded-xl text-sm font-bold transition-all whitespace-nowrap ${activeGame === 'monster' ? 'bg-purple-100 text-purple-700 border-2 border-purple-200 shadow-sm' : 'text-gray-500 hover:bg-white hover:shadow-sm border-2 border-transparent'}`}>
52
- <Mic size={14} className="inline mr-1"/> 早读战怪兽
53
  </button>
54
- <button onClick={() => setActiveGame('zen')} className={`px-4 py-1.5 rounded-xl text-sm font-bold transition-all whitespace-nowrap ${activeGame === 'zen' ? 'bg-teal-100 text-teal-700 border-2 border-teal-200 shadow-sm' : 'text-gray-500 hover:bg-white hover:shadow-sm border-2 border-transparent'}`}>
55
- <Moon size={14} className="inline mr-1"/> 禅道修行
56
  </button>
57
  </div>
58
 
59
  <div className="flex-1 bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden relative">
60
- {activeGame === 'mountain' ? <GameMountain /> :
61
- activeGame === 'lucky' ? <GameLucky /> :
62
- activeGame === 'random' ? <GameRandom /> :
63
- activeGame === 'zen' ? <GameZen /> : <GameMonster />}
 
 
 
 
 
 
 
 
64
  </div>
65
  </div>
66
  )}
67
 
68
  {/* ACHIEVEMENTS VIEW */}
69
  {activeTab === 'achievements' && (
70
- isStudent ? <AchievementStudent /> : <AchievementTeacher />
71
  )}
72
 
73
  {/* REWARD VIEW */}
74
- {activeTab === 'rewards' && <GameRewards />}
75
  </div>
76
  </div>
77
  );
 
1
 
2
+ import React, { useState, useEffect } from 'react';
3
+ import { Trophy, Star, Award, Zap, Mic, Moon, ChevronDown, Layout } from 'lucide-react';
4
  import { GameMountain } from './GameMountain';
5
  import { GameLucky } from './GameLucky';
6
  import { GameRandom } from './GameRandom';
 
10
  import { AchievementTeacher } from './AchievementTeacher';
11
  import { AchievementStudent } from './AchievementStudent';
12
  import { api } from '../services/api';
13
+ import { ClassInfo, Course, User } from '../types';
14
 
15
  export const Games: React.FC = () => {
16
  const [activeTab, setActiveTab] = useState<'games' | 'achievements' | 'rewards'>('games');
17
  const [activeGame, setActiveGame] = useState<'mountain' | 'lucky' | 'random' | 'monster' | 'zen'>('mountain');
18
+
19
+ // Class Context State
20
+ const [myClasses, setMyClasses] = useState<string[]>([]);
21
+ const [selectedClass, setSelectedClass] = useState<string>('');
22
+ const [isLoading, setIsLoading] = useState(true);
23
 
24
  const currentUser = api.auth.getCurrentUser();
25
  const isStudent = currentUser?.role === 'STUDENT';
26
+ const isTeacher = currentUser?.role === 'TEACHER';
27
+
28
+ useEffect(() => {
29
+ loadContext();
30
+ }, []);
31
+
32
+ const loadContext = async () => {
33
+ if (isTeacher) {
34
+ try {
35
+ // Fetch Classes where I am Homeroom
36
+ const allClasses = await api.classes.getAll();
37
+ const homerooms = allClasses
38
+ .filter((c: ClassInfo) => c.homeroomTeacherIds?.includes(currentUser._id || '') || c.teacherName?.includes(currentUser.trueName || currentUser.username))
39
+ .map((c: ClassInfo) => c.grade + c.className);
40
+
41
+ // Fetch Courses where I am Subject Teacher
42
+ const allCourses = await api.courses.getAll();
43
+ const teachingClasses = allCourses
44
+ .filter((c: Course) => c.teacherId === currentUser._id)
45
+ .map((c: Course) => c.className);
46
+
47
+ // Merge unique
48
+ const uniqueClasses = Array.from(new Set([...homerooms, ...teachingClasses])).sort();
49
+
50
+ setMyClasses(uniqueClasses);
51
+ if (uniqueClasses.length > 0) {
52
+ // Default to first class, or restore from local storage if possible
53
+ setSelectedClass(uniqueClasses[0]);
54
+ }
55
+ } catch(e) { console.error(e); }
56
+ } else if (isStudent && currentUser.homeroomClass) {
57
+ setMyClasses([currentUser.homeroomClass]);
58
+ setSelectedClass(currentUser.homeroomClass);
59
+ }
60
+ setIsLoading(false);
61
+ };
62
+
63
+ // Provide a Context Provider or just pass props down
64
+ // Since we don't have a complex Context setup, prop drilling is fine for now
65
+
66
+ if (isLoading) return null;
67
+
68
+ if (isTeacher && myClasses.length === 0) {
69
+ return (
70
+ <div className="flex flex-col items-center justify-center h-[60vh] text-gray-500">
71
+ <Layout size={48} className="mb-4 opacity-20"/>
72
+ <h3 className="text-lg font-bold">暂无关联班级</h3>
73
+ <p className="text-sm mt-2">请先在「课程安排」或「班级管理」中绑定您任教的班级。</p>
74
+ </div>
75
+ );
76
+ }
77
 
78
  return (
79
  <div className="flex flex-col h-[calc(100vh-120px)] w-full max-w-full overflow-hidden">
80
+ {/* Top Header & Class Switcher */}
81
+ <div className="flex flex-col md:flex-row justify-between items-center mb-4 shrink-0 px-4 gap-4">
82
+ {isTeacher && (
83
+ <div className="relative group z-20">
84
+ <div className="flex items-center gap-2 bg-white px-4 py-2 rounded-xl shadow-sm border border-blue-100 cursor-pointer hover:border-blue-300 transition-all">
85
+ <span className="text-xs text-gray-400 font-bold uppercase">当前班级</span>
86
+ <span className="font-bold text-blue-700 text-lg">{selectedClass}</span>
87
+ <ChevronDown size={16} className="text-gray-400"/>
88
+ </div>
89
+ <select
90
+ className="absolute inset-0 opacity-0 cursor-pointer w-full h-full"
91
+ value={selectedClass}
92
+ onChange={(e) => setSelectedClass(e.target.value)}
93
+ >
94
+ {myClasses.map(c => <option key={c} value={c}>{c}</option>)}
95
+ </select>
96
+ </div>
97
+ )}
98
+
99
+ {/* Tabs Switcher */}
100
+ <div className="flex space-x-2 overflow-x-auto p-1">
101
+ <button onClick={() => setActiveTab('games')} className={`px-4 py-2 rounded-lg font-bold flex items-center transition-all whitespace-nowrap text-sm ${activeTab === 'games' ? 'bg-blue-600 text-white shadow-md' : 'bg-white text-gray-500 hover:bg-gray-50'}`}>
102
+ <Trophy className="mr-2" size={16}/> 互动
103
+ </button>
104
+ <button onClick={() => setActiveTab('achievements')} className={`px-4 py-2 rounded-lg font-bold flex items-center transition-all whitespace-nowrap text-sm ${activeTab === 'achievements' ? 'bg-amber-500 text-white shadow-md' : 'bg-white text-gray-500 hover:bg-gray-50'}`}>
105
+ <Award className="mr-2" size={16}/> 成就
106
+ </button>
107
+ <button onClick={() => setActiveTab('rewards')} className={`px-4 py-2 rounded-lg font-bold flex items-center transition-all whitespace-nowrap text-sm ${activeTab === 'rewards' ? 'bg-emerald-500 text-white shadow-md' : 'bg-white text-gray-500 hover:bg-gray-50'}`}>
108
+ <Star className="mr-2" size={16}/> 奖励
109
+ </button>
110
+ </div>
111
  </div>
112
 
113
  <div className="flex-1 min-h-0 relative animate-in fade-in slide-in-from-bottom-4 duration-500">
 
116
  <div className="h-full flex flex-col">
117
  {/* Sub Game Switcher */}
118
  <div className="flex justify-center space-x-2 mb-4 shrink-0 overflow-x-auto px-2">
119
+ <button onClick={() => setActiveGame('mountain')} className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all whitespace-nowrap ${activeGame === 'mountain' ? 'bg-sky-100 text-sky-700 border border-sky-200' : 'bg-white text-gray-500 border border-transparent'}`}>
120
+ 🏔️ 登峰
121
  </button>
122
+ <button onClick={() => setActiveGame('lucky')} className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all whitespace-nowrap ${activeGame === 'lucky' ? 'bg-red-100 text-red-700 border border-red-200' : 'bg-white text-gray-500 border border-transparent'}`}>
123
+ 🧧 抽奖
124
  </button>
125
+ <button onClick={() => setActiveGame('random')} className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all whitespace-nowrap ${activeGame === 'random' ? 'bg-yellow-100 text-yellow-700 border border-yellow-200' : 'bg-white text-gray-500 border border-transparent'}`}>
126
+ <Zap size={14} className="inline mr-1"/> 点名
127
  </button>
128
+ <button onClick={() => setActiveGame('monster')} className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all whitespace-nowrap ${activeGame === 'monster' ? 'bg-purple-100 text-purple-700 border border-purple-200' : 'bg-white text-gray-500 border border-transparent'}`}>
129
+ <Mic size={14} className="inline mr-1"/> 怪兽
130
  </button>
131
+ <button onClick={() => setActiveGame('zen')} className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all whitespace-nowrap ${activeGame === 'zen' ? 'bg-teal-100 text-teal-700 border border-teal-200' : 'bg-white text-gray-500 border border-transparent'}`}>
132
+ <Moon size={14} className="inline mr-1"/> 禅道
133
  </button>
134
  </div>
135
 
136
  <div className="flex-1 bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden relative">
137
+ {/* Pass the selectedClass as homeroomClass prop override to children components if they use it */}
138
+ {/* NOTE: Most child components were fetching `currentUser.homeroomClass`. We need to update them or patch the API call context.
139
+ Since we can't easily change all child internal logic without rewriting them, we will use a key to force re-mount and
140
+ pass the class context explicitly via props (we'd need to update children to accept props) OR
141
+ we rely on the fact that `Game*` components usually fetch based on a prop or assume currentUser.
142
+
143
+ UPDATE: I will assume children need `className` prop.
144
+ */}
145
+ {activeGame === 'mountain' ? <GameMountain className={selectedClass} /> :
146
+ activeGame === 'lucky' ? <GameLucky className={selectedClass} /> :
147
+ activeGame === 'random' ? <GameRandom className={selectedClass} /> :
148
+ activeGame === 'zen' ? <GameZen className={selectedClass} /> : <GameMonster className={selectedClass} />}
149
  </div>
150
  </div>
151
  )}
152
 
153
  {/* ACHIEVEMENTS VIEW */}
154
  {activeTab === 'achievements' && (
155
+ isStudent ? <AchievementStudent /> : <AchievementTeacher className={selectedClass} />
156
  )}
157
 
158
  {/* REWARD VIEW */}
159
+ {activeTab === 'rewards' && <GameRewards className={selectedClass} />}
160
  </div>
161
  </div>
162
  );
server.js CHANGED
@@ -76,6 +76,20 @@ const getQueryFilter = (req) => {
76
  };
77
  const injectSchoolId = (req, b) => ({ ...b, schoolId: req.headers['x-school-id'] });
78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  const getAutoSemester = () => {
80
  const now = new Date();
81
  const month = now.getMonth() + 1; // 1-12
@@ -132,15 +146,9 @@ app.post('/api/auth/update-profile', async (req, res) => {
132
  { name: user.trueName || user.username, phone: user.phone }
133
  );
134
  }
135
- // If user is a teacher, sync name to Class collection
136
- if (user.role === 'TEACHER' && user.homeroomClass) {
137
- const cls = await ClassModel.findOne({ schoolId: user.schoolId, $expr: { $eq: [{ $concat: ["$grade", "$className"] }, user.homeroomClass] } });
138
- if (cls) {
139
- cls.teacherName = user.trueName || user.username;
140
- await cls.save();
141
- }
142
- }
143
-
144
  res.json({ success: true, user });
145
  } catch (e) {
146
  console.error(e);
@@ -247,12 +255,8 @@ app.put('/api/users/:id', async (req, res) => {
247
  }
248
 
249
  if (user.status !== 'active' && updates.status === 'active') {
250
- if (user.role === 'TEACHER' && user.homeroomClass) {
251
- await ClassModel.updateOne(
252
- { schoolId: user.schoolId, $expr: { $eq: [{ $concat: ["$grade", "$className"] }, user.homeroomClass] } },
253
- { teacherName: user.trueName || user.username }
254
- );
255
- }
256
  if (user.role === 'STUDENT') {
257
  const profileData = {
258
  schoolId: user.schoolId,
@@ -289,6 +293,7 @@ app.post('/api/users/class-application', async (req, res) => {
289
  const schoolId = req.headers['x-school-id'];
290
 
291
  if (action === 'APPLY') {
 
292
  try {
293
  const user = await User.findById(userId);
294
  if(!user) return res.status(404).json({error:'User not found'});
@@ -300,12 +305,8 @@ app.post('/api/users/class-application', async (req, res) => {
300
  schoolId, targetUserId: userId, title: '申请已提交', content: `您已成功提交 ${typeText} (${type === 'CLAIM' ? targetClass : user.homeroomClass}) 的申请,等待管理员审核。`, type: 'info'
301
  });
302
  await NotificationModel.create({ schoolId, targetRole: 'ADMIN', title: '新的班主任任免申请', content: `${user.trueName || user.username} 申请 ${typeText},请及时处理。`, type: 'warning' });
303
- await NotificationModel.create({ schoolId, targetRole: 'PRINCIPAL', title: '新的班主任任免申请', content: `${user.trueName || user.username} 申请 ${typeText},请及时处理。`, type: 'warning' });
304
  return res.json({ success: true });
305
- } catch (e) {
306
- console.error(e);
307
- return res.status(500).json({ error: e.message });
308
- }
309
  }
310
 
311
  if (userRole === 'ADMIN' || userRole === 'PRINCIPAL') {
@@ -316,20 +317,37 @@ app.post('/api/users/class-application', async (req, res) => {
316
 
317
  if (action === 'APPROVE') {
318
  const updates = { classApplication: null };
 
 
319
  if (appType === 'CLAIM') {
320
  updates.homeroomClass = appTarget;
321
- const classes = await ClassModel.find({ schoolId });
322
  const matchedClass = classes.find(c => (c.grade + c.className) === appTarget);
323
  if (matchedClass) {
324
- if (matchedClass.teacherName) await User.updateOne({ trueName: matchedClass.teacherName, schoolId }, { homeroomClass: '' });
325
- await ClassModel.findByIdAndUpdate(matchedClass._id, { teacherName: user.trueName || user.username });
 
 
 
 
 
 
 
 
 
 
326
  }
327
  } else if (appType === 'RESIGN') {
328
  updates.homeroomClass = '';
329
- if (user.homeroomClass) {
330
- const classes = await ClassModel.find({ schoolId });
331
- const matchedClass = classes.find(c => (c.grade + c.className) === user.homeroomClass);
332
- if (matchedClass) await ClassModel.findByIdAndUpdate(matchedClass._id, { teacherName: '' });
 
 
 
 
 
 
333
  }
334
  }
335
  await User.findByIdAndUpdate(userId, updates);
@@ -344,6 +362,8 @@ app.post('/api/users/class-application', async (req, res) => {
344
  });
345
 
346
  app.post('/api/students/promote', async (req, res) => {
 
 
347
  const { teacherFollows } = req.body;
348
  const sId = req.headers['x-school-id'];
349
  const role = req.headers['x-user-role'];
@@ -366,93 +386,68 @@ app.post('/api/students/promote', async (req, res) => {
366
  if (nextGrade === '毕业') {
367
  const oldFullClass = cls.grade + cls.className;
368
  await Student.updateMany({ className: oldFullClass, ...getQueryFilter(req) }, { status: 'Graduated', className: '已毕业' });
369
- if (teacherFollows && cls.teacherName) {
370
- await User.updateOne({ trueName: cls.teacherName, schoolId: sId }, { homeroomClass: '' });
371
- await ClassModel.findByIdAndUpdate(cls._id, { teacherName: '' });
372
  }
373
  } else {
374
  const oldFullClass = cls.grade + cls.className;
375
  const newFullClass = nextGrade + suffix;
376
- await ClassModel.findOneAndUpdate({ grade: nextGrade, className: suffix, schoolId: sId }, { schoolId: sId, grade: nextGrade, className: suffix, teacherName: teacherFollows ? cls.teacherName : undefined }, { upsert: true });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
377
  const result = await Student.updateMany({ className: oldFullClass, status: 'Enrolled', ...getQueryFilter(req) }, { className: newFullClass });
378
  promotedCount += result.modifiedCount;
379
 
380
- if (teacherFollows && cls.teacherName) {
381
- await User.updateOne({ trueName: cls.teacherName, schoolId: sId, homeroomClass: oldFullClass }, { homeroomClass: newFullClass });
382
- await ClassModel.findByIdAndUpdate(cls._id, { teacherName: '' });
 
383
  }
384
  }
385
  }
386
  res.json({ success: true, count: promotedCount });
387
  });
388
 
389
- app.post('/api/students/transfer', async (req, res) => {
390
- const { studentId, targetClass } = req.body;
391
- const student = await Student.findById(studentId);
392
- if (!student) return res.status(404).json({ error: 'Student not found' });
393
- student.className = targetClass;
394
- await student.save();
395
- res.json({ success: true });
396
- });
397
 
398
- app.get('/api/achievements/config', async (req, res) => {
399
- const { className } = req.query;
400
- if (!className) return res.status(400).json({ error: 'Class name required' });
401
- const config = await AchievementConfigModel.findOne({ ...getQueryFilter(req), className });
402
- res.json(config || { className, achievements: [], exchangeRules: [] });
403
- });
404
- app.post('/api/achievements/config', async (req, res) => {
405
- const { className } = req.body;
406
- const sId = req.headers['x-school-id'];
407
- await AchievementConfigModel.findOneAndUpdate({ className, schoolId: sId }, injectSchoolId(req, req.body), { upsert: true });
408
- res.json({ success: true });
409
- });
410
- app.get('/api/achievements/student', async (req, res) => {
411
- const { studentId, semester } = req.query;
412
- const filter = getQueryFilter(req);
413
- if (studentId) filter.studentId = studentId;
414
- if (semester && semester !== '全部时间' && semester !== 'All') filter.semester = semester;
415
- const list = await StudentAchievementModel.find(filter).sort({ createTime: -1 });
416
- res.json(list);
417
- });
418
- app.post('/api/achievements/grant', async (req, res) => {
419
- const { studentId, achievementId, semester } = req.body;
420
- const sId = req.headers['x-school-id'];
421
- const student = await Student.findById(studentId);
422
- const config = await AchievementConfigModel.findOne({ className: student.className, ...getQueryFilter(req) });
423
- const ach = config?.achievements.find(a => a.id === achievementId);
424
- if (!ach) return res.status(404).json({ error: 'Achievement not found' });
425
- await StudentAchievementModel.create({ schoolId: sId, studentId, studentName: student.name, achievementId: ach.id, achievementName: ach.name, achievementIcon: ach.icon, semester: semester || '当前学期' });
426
- await Student.findByIdAndUpdate(studentId, { $inc: { flowerBalance: ach.points } });
427
- res.json({ success: true });
428
- });
429
- app.post('/api/achievements/exchange', async (req, res) => {
430
- const { studentId, ruleId } = req.body;
431
- const sId = req.headers['x-school-id'];
432
- const student = await Student.findById(studentId);
433
- const config = await AchievementConfigModel.findOne({ className: student.className, ...getQueryFilter(req) });
434
- const rule = config?.exchangeRules.find(r => r.id === ruleId);
435
- if (!rule) return res.status(404).json({ error: 'Exchange rule not found' });
436
- if ((student.flowerBalance || 0) < rule.cost) return res.status(400).json({ error: 'INSUFFICIENT_FLOWERS', message: '小红花余额不足' });
437
- await Student.findByIdAndUpdate(studentId, { $inc: { flowerBalance: -rule.cost } });
438
- let status = 'PENDING';
439
- if (rule.rewardType === 'DRAW_COUNT') {
440
- status = 'REDEEMED';
441
- await Student.findByIdAndUpdate(studentId, { $inc: { drawAttempts: rule.rewardValue } });
442
- }
443
- await StudentRewardModel.create({ schoolId: sId, studentId, studentName: student.name, rewardType: rule.rewardType, name: rule.rewardName, count: rule.rewardValue, status: status, source: `积分兑换` });
444
- res.json({ success: true });
445
- });
446
 
 
447
  app.get('/api/games/lucky-config', async (req, res) => {
448
  const filter = getQueryFilter(req);
 
449
  if (req.query.className) filter.className = req.query.className;
 
 
 
450
  const config = await LuckyDrawConfigModel.findOne(filter);
451
  res.json(config || { prizes: [], dailyLimit: 3, cardCount: 9, defaultPrize: '再接再厉' });
452
  });
453
  app.post('/api/games/lucky-config', async (req, res) => {
454
  const data = injectSchoolId(req, req.body);
455
- await LuckyDrawConfigModel.findOneAndUpdate({ className: data.className, ...getQueryFilter(req) }, data, { upsert: true });
 
 
 
 
 
 
 
 
 
456
  res.json({ success: true });
457
  });
458
  app.post('/api/games/lucky-draw', async (req, res) => {
@@ -462,12 +457,30 @@ app.post('/api/games/lucky-draw', async (req, res) => {
462
  try {
463
  const student = await Student.findById(studentId);
464
  if (!student) return res.status(404).json({ error: 'Student not found' });
465
- const config = await LuckyDrawConfigModel.findOne({ className: student.className, ...getQueryFilter(req) });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
466
  const prizes = config?.prizes || [];
467
  const defaultPrize = config?.defaultPrize || '再接再厉';
468
  const dailyLimit = config?.dailyLimit || 3;
469
  const consolationWeight = config?.consolationWeight || 0;
470
 
 
 
471
  const availablePrizes = prizes.filter(p => (p.count === undefined || p.count > 0));
472
  if (availablePrizes.length === 0 && consolationWeight === 0) return res.status(400).json({ error: 'POOL_EMPTY', message: '奖品库存不足' });
473
 
@@ -504,40 +517,224 @@ app.post('/api/games/lucky-draw', async (req, res) => {
504
  rewardType = 'ITEM';
505
  if (config._id) await LuckyDrawConfigModel.updateOne({ _id: config._id, "prizes.id": matchedPrize.id }, { $inc: { "prizes.$.count": -1 } });
506
  }
507
- await StudentRewardModel.create({ schoolId, studentId, studentName: student.name, rewardType, name: selectedPrize, count: 1, status: 'PENDING', source: '幸运大抽奖' });
 
 
 
508
  res.json({ prize: selectedPrize, rewardType });
509
  } catch (e) { res.status(500).json({ error: e.message }); }
510
  });
511
 
 
512
  app.get('/api/games/monster-config', async (req, res) => {
513
  const filter = getQueryFilter(req);
 
514
  if (req.query.className) filter.className = req.query.className;
 
515
  const config = await GameMonsterConfigModel.findOne(filter);
516
  res.json(config || {});
517
  });
518
  app.post('/api/games/monster-config', async (req, res) => {
519
  const data = injectSchoolId(req, req.body);
520
- await GameMonsterConfigModel.findOneAndUpdate({ className: data.className, ...getQueryFilter(req) }, data, { upsert: true });
 
 
 
 
521
  res.json({ success: true });
522
  });
523
 
 
524
  app.get('/api/games/zen-config', async (req, res) => {
525
  const filter = getQueryFilter(req);
 
526
  if (req.query.className) filter.className = req.query.className;
 
527
  const config = await GameZenConfigModel.findOne(filter);
528
  res.json(config || {});
529
  });
530
  app.post('/api/games/zen-config', async (req, res) => {
531
  const data = injectSchoolId(req, req.body);
532
- await GameZenConfigModel.findOneAndUpdate({ className: data.className, ...getQueryFilter(req) }, data, { upsert: true });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
533
  res.json({ success: true });
534
  });
535
 
536
- app.get('/api/notifications', async (req, res) => {
537
- const { role, userId } = req.query;
538
- const query = { $and: [ getQueryFilter(req), { $or: [ { targetRole: null, targetUserId: null }, { targetRole: role }, { targetUserId: userId } ] } ] };
539
- res.json(await NotificationModel.find(query).sort({ createTime: -1 }).limit(20));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
540
  });
 
 
 
 
 
 
 
 
 
 
 
 
541
  app.get('/api/public/schools', async (req, res) => { res.json(await School.find({}, 'name code _id')); });
542
  app.get('/api/public/config', async (req, res) => {
543
  const currentSem = getAutoSemester();
@@ -633,31 +830,9 @@ app.get('/api/classes', async (req, res) => {
633
  }));
634
  res.json(resData);
635
  });
636
- app.post('/api/classes', async (req, res) => {
637
- const data = injectSchoolId(req, req.body);
638
- await ClassModel.create(data);
639
- if (data.teacherName) await User.updateOne({ trueName: data.teacherName, schoolId: data.schoolId }, { homeroomClass: data.grade + data.className });
640
- res.json({});
641
- });
642
- app.put('/api/classes/:id', async (req, res) => {
643
- const classId = req.params.id;
644
- const { grade, className, teacherName } = req.body;
645
- const sId = req.headers['x-school-id'];
646
- const oldClass = await ClassModel.findById(classId);
647
- if (!oldClass) return res.status(404).json({ error: 'Class not found' });
648
- const newFullClass = grade + className;
649
- const oldFullClass = oldClass.grade + oldClass.className;
650
- if (oldClass.teacherName && (oldClass.teacherName !== teacherName || oldFullClass !== newFullClass)) {
651
- await User.updateOne({ trueName: oldClass.teacherName, schoolId: sId, homeroomClass: oldFullClass }, { homeroomClass: '' });
652
- }
653
- if (teacherName) await User.updateOne({ trueName: teacherName, schoolId: sId }, { homeroomClass: newFullClass });
654
- await ClassModel.findByIdAndUpdate(classId, { grade, className, teacherName });
655
- if (oldFullClass !== newFullClass) await Student.updateMany({ className: oldFullClass, schoolId: sId }, { className: newFullClass });
656
- res.json({ success: true });
657
- });
658
  app.delete('/api/classes/:id', async (req, res) => {
659
  const cls = await ClassModel.findById(req.params.id);
660
- if (cls && cls.teacherName) await User.updateOne({ trueName: cls.teacherName, schoolId: cls.schoolId, homeroomClass: cls.grade + cls.className }, { homeroomClass: '' });
661
  await ClassModel.findByIdAndDelete(req.params.id);
662
  res.json({});
663
  });
@@ -665,8 +840,6 @@ app.get('/api/subjects', async (req, res) => { res.json(await SubjectModel.find(
665
  app.post('/api/subjects', async (req, res) => { await SubjectModel.create(injectSchoolId(req, req.body)); res.json({}); });
666
  app.put('/api/subjects/:id', async (req, res) => { await SubjectModel.findByIdAndUpdate(req.params.id, req.body); res.json({}); });
667
  app.delete('/api/subjects/:id', async (req, res) => { await SubjectModel.findByIdAndDelete(req.params.id); res.json({}); });
668
- app.get('/api/courses', async (req, res) => { res.json(await Course.find(getQueryFilter(req))); });
669
- app.post('/api/courses', async (req, res) => { await Course.create(injectSchoolId(req, req.body)); res.json({}); });
670
  app.put('/api/courses/:id', async (req, res) => { await Course.findByIdAndUpdate(req.params.id, req.body); res.json({}); });
671
  app.delete('/api/courses/:id', async (req, res) => { await Course.findByIdAndDelete(req.params.id); res.json({}); });
672
  app.get('/api/scores', async (req, res) => { res.json(await Score.find(getQueryFilter(req))); });
@@ -719,22 +892,6 @@ app.get('/api/config', async (req, res) => {
719
  res.json(config);
720
  });
721
  app.post('/api/config', async (req, res) => { await ConfigModel.findOneAndUpdate({key:'main'}, req.body, {upsert:true}); res.json({}); });
722
- app.get('/api/games/mountain', async (req, res) => { res.json(await GameSessionModel.findOne({...getQueryFilter(req), className: req.query.className})); });
723
- app.post('/api/games/mountain', async (req, res) => {
724
- const filter = { className: req.body.className };
725
- const sId = req.headers['x-school-id'];
726
- if(sId) filter.schoolId = sId;
727
- await GameSessionModel.findOneAndUpdate(filter, injectSchoolId(req, req.body), {upsert:true});
728
- res.json({});
729
- });
730
- app.post('/api/games/grant-reward', async (req, res) => {
731
- const { studentId, count, rewardType, name } = req.body;
732
- const finalCount = count || 1;
733
- const finalName = name || (rewardType === 'DRAW_COUNT' ? '抽奖券' : '奖品');
734
- if (rewardType === 'DRAW_COUNT') await Student.findByIdAndUpdate(studentId, { $inc: { drawAttempts: finalCount } });
735
- 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: '教师发放' });
736
- res.json({});
737
- });
738
  app.put('/api/rewards/:id', async (req, res) => { await StudentRewardModel.findByIdAndUpdate(req.params.id, req.body); res.json({}); });
739
  app.delete('/api/rewards/:id', async (req, res) => {
740
  const reward = await StudentRewardModel.findById(req.params.id);
@@ -747,28 +904,6 @@ app.delete('/api/rewards/:id', async (req, res) => {
747
  await StudentRewardModel.findByIdAndDelete(req.params.id);
748
  res.json({});
749
  });
750
- app.get('/api/rewards', async (req, res) => {
751
- const filter = getQueryFilter(req);
752
- if(req.query.studentId) filter.studentId = req.query.studentId;
753
- if (req.query.className) {
754
- const classStudents = await Student.find({ className: req.query.className, ...getQueryFilter(req) }, '_id');
755
- filter.studentId = { $in: classStudents.map(s => s._id.toString()) };
756
- }
757
- if (req.query.excludeType) filter.rewardType = { $ne: req.query.excludeType };
758
- const page = parseInt(req.query.page) || 1;
759
- const limit = parseInt(req.query.limit) || 20;
760
- const skip = (page - 1) * limit;
761
- const total = await StudentRewardModel.countDocuments(filter);
762
- const list = await StudentRewardModel.find(filter).sort({createTime:-1}).skip(skip).limit(limit);
763
- res.json({ list, total });
764
- });
765
- app.post('/api/rewards', async (req, res) => {
766
- const data = injectSchoolId(req, req.body);
767
- if (!data.count) data.count = 1;
768
- if(data.rewardType==='DRAW_COUNT') { data.status='REDEEMED'; await Student.findByIdAndUpdate(data.studentId, {$inc:{drawAttempts:data.count}}); }
769
- await StudentRewardModel.create(data);
770
- res.json({});
771
- });
772
  app.post('/api/rewards/:id/redeem', async (req, res) => { await StudentRewardModel.findByIdAndUpdate(req.params.id, {status:'REDEEMED'}); res.json({}); });
773
  app.get('/api/attendance', async (req, res) => {
774
  const { className, date, studentId } = req.query;
@@ -835,4 +970,4 @@ app.post('/api/batch-delete', async (req, res) => {
835
  });
836
 
837
  app.get('*', (req, res) => { res.sendFile(path.join(__dirname, 'dist', 'index.html')); });
838
- app.listen(PORT, () => console.log(`🚀 Server running on port ${PORT}`));
 
76
  };
77
  const injectSchoolId = (req, b) => ({ ...b, schoolId: req.headers['x-school-id'] });
78
 
79
+ // Helper to get Owner ID for games (Teacher isolation)
80
+ const getGameOwnerFilter = async (req) => {
81
+ const role = req.headers['x-user-role'];
82
+ const username = req.headers['x-user-username'];
83
+
84
+ if (role === 'TEACHER') {
85
+ const user = await User.findOne({ username });
86
+ return { ownerId: user ? user._id.toString() : 'unknown' };
87
+ }
88
+ // Students or Admins might view differently, but for config saving/loading by teacher:
89
+ // If Admin/Principal, maybe show global? For now, if no username provided, return empty
90
+ return {};
91
+ };
92
+
93
  const getAutoSemester = () => {
94
  const now = new Date();
95
  const month = now.getMonth() + 1; // 1-12
 
146
  { name: user.trueName || user.username, phone: user.phone }
147
  );
148
  }
149
+ // If user is a teacher, sync name to Class collection (Legacy single teacher)
150
+ // For multi-teacher, we don't sync names automatically as it's a list.
151
+
 
 
 
 
 
 
152
  res.json({ success: true, user });
153
  } catch (e) {
154
  console.error(e);
 
255
  }
256
 
257
  if (user.status !== 'active' && updates.status === 'active') {
258
+ // Updated logic: We don't automatically assign teacherName on user activation anymore for multi-homeroom
259
+ // The class assignment happens in Class management
 
 
 
 
260
  if (user.role === 'STUDENT') {
261
  const profileData = {
262
  schoolId: user.schoolId,
 
293
  const schoolId = req.headers['x-school-id'];
294
 
295
  if (action === 'APPLY') {
296
+ // ... (existing apply logic)
297
  try {
298
  const user = await User.findById(userId);
299
  if(!user) return res.status(404).json({error:'User not found'});
 
305
  schoolId, targetUserId: userId, title: '申请已提交', content: `您已成功提交 ${typeText} (${type === 'CLAIM' ? targetClass : user.homeroomClass}) 的申请,等待管理员审核。`, type: 'info'
306
  });
307
  await NotificationModel.create({ schoolId, targetRole: 'ADMIN', title: '新的班主任任免申请', content: `${user.trueName || user.username} 申请 ${typeText},请及时处理。`, type: 'warning' });
 
308
  return res.json({ success: true });
309
+ } catch (e) { return res.status(500).json({ error: e.message }); }
 
 
 
310
  }
311
 
312
  if (userRole === 'ADMIN' || userRole === 'PRINCIPAL') {
 
317
 
318
  if (action === 'APPROVE') {
319
  const updates = { classApplication: null };
320
+ const classes = await ClassModel.find({ schoolId });
321
+
322
  if (appType === 'CLAIM') {
323
  updates.homeroomClass = appTarget;
 
324
  const matchedClass = classes.find(c => (c.grade + c.className) === appTarget);
325
  if (matchedClass) {
326
+ // Add user ID to homeroomTeacherIds
327
+ const teacherIds = matchedClass.homeroomTeacherIds || [];
328
+ if (!teacherIds.includes(userId)) {
329
+ teacherIds.push(userId);
330
+ // Re-generate teacherName string
331
+ const teachers = await User.find({ _id: { $in: teacherIds } });
332
+ const names = teachers.map(t => t.trueName || t.username).join(', ');
333
+ await ClassModel.findByIdAndUpdate(matchedClass._id, {
334
+ homeroomTeacherIds: teacherIds,
335
+ teacherName: names
336
+ });
337
+ }
338
  }
339
  } else if (appType === 'RESIGN') {
340
  updates.homeroomClass = '';
341
+ const matchedClass = classes.find(c => (c.grade + c.className) === user.homeroomClass);
342
+ if (matchedClass) {
343
+ // Remove user ID
344
+ const teacherIds = (matchedClass.homeroomTeacherIds || []).filter(id => id !== userId);
345
+ const teachers = await User.find({ _id: { $in: teacherIds } });
346
+ const names = teachers.map(t => t.trueName || t.username).join(', ');
347
+ await ClassModel.findByIdAndUpdate(matchedClass._id, {
348
+ homeroomTeacherIds: teacherIds,
349
+ teacherName: names
350
+ });
351
  }
352
  }
353
  await User.findByIdAndUpdate(userId, updates);
 
362
  });
363
 
364
  app.post('/api/students/promote', async (req, res) => {
365
+ // ... Existing Promote Logic
366
+ // Update: If teacherFollows is true, move the homeroomTeacherIds to new class
367
  const { teacherFollows } = req.body;
368
  const sId = req.headers['x-school-id'];
369
  const role = req.headers['x-user-role'];
 
386
  if (nextGrade === '毕业') {
387
  const oldFullClass = cls.grade + cls.className;
388
  await Student.updateMany({ className: oldFullClass, ...getQueryFilter(req) }, { status: 'Graduated', className: '已毕业' });
389
+ if (teacherFollows && cls.homeroomTeacherIds?.length) {
390
+ await User.updateMany({ _id: { $in: cls.homeroomTeacherIds }, schoolId: sId }, { homeroomClass: '' });
391
+ await ClassModel.findByIdAndUpdate(cls._id, { teacherName: '', homeroomTeacherIds: [] });
392
  }
393
  } else {
394
  const oldFullClass = cls.grade + cls.className;
395
  const newFullClass = nextGrade + suffix;
396
+
397
+ // Find or create next class
398
+ await ClassModel.findOneAndUpdate(
399
+ { grade: nextGrade, className: suffix, schoolId: sId },
400
+ {
401
+ schoolId: sId,
402
+ grade: nextGrade,
403
+ className: suffix,
404
+ // If teacher follows, copy the IDs and Names
405
+ teacherName: teacherFollows ? cls.teacherName : undefined,
406
+ homeroomTeacherIds: teacherFollows ? cls.homeroomTeacherIds : []
407
+ },
408
+ { upsert: true }
409
+ );
410
+
411
  const result = await Student.updateMany({ className: oldFullClass, status: 'Enrolled', ...getQueryFilter(req) }, { className: newFullClass });
412
  promotedCount += result.modifiedCount;
413
 
414
+ if (teacherFollows && cls.homeroomTeacherIds?.length) {
415
+ await User.updateMany({ _id: { $in: cls.homeroomTeacherIds }, schoolId: sId, homeroomClass: oldFullClass }, { homeroomClass: newFullClass });
416
+ // Clear old class teachers
417
+ await ClassModel.findByIdAndUpdate(cls._id, { teacherName: '', homeroomTeacherIds: [] });
418
  }
419
  }
420
  }
421
  res.json({ success: true, count: promotedCount });
422
  });
423
 
424
+ // ... (Rest of existing endpoints) ...
 
 
 
 
 
 
 
425
 
426
+ // --- GAMES ISOLATION & PERMISSION LOGIC ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
427
 
428
+ // 1. Lucky Draw
429
  app.get('/api/games/lucky-config', async (req, res) => {
430
  const filter = getQueryFilter(req);
431
+ const ownerFilter = await getGameOwnerFilter(req);
432
  if (req.query.className) filter.className = req.query.className;
433
+ // Merge owner filter
434
+ Object.assign(filter, ownerFilter);
435
+
436
  const config = await LuckyDrawConfigModel.findOne(filter);
437
  res.json(config || { prizes: [], dailyLimit: 3, cardCount: 9, defaultPrize: '再接再厉' });
438
  });
439
  app.post('/api/games/lucky-config', async (req, res) => {
440
  const data = injectSchoolId(req, req.body);
441
+ // Inject ownerId for teachers
442
+ if (req.headers['x-user-role'] === 'TEACHER') {
443
+ const user = await User.findOne({ username: req.headers['x-user-username'] });
444
+ data.ownerId = user ? user._id.toString() : null;
445
+ }
446
+ await LuckyDrawConfigModel.findOneAndUpdate(
447
+ { className: data.className, ...getQueryFilter(req), ownerId: data.ownerId },
448
+ data,
449
+ { upsert: true }
450
+ );
451
  res.json({ success: true });
452
  });
453
  app.post('/api/games/lucky-draw', async (req, res) => {
 
457
  try {
458
  const student = await Student.findById(studentId);
459
  if (!student) return res.status(404).json({ error: 'Student not found' });
460
+
461
+ // Determine which config to use.
462
+ // If student triggering, they need context (not currently passed well).
463
+ // For now, assume STUDENT role triggers draw against *their Homeroom Teacher's* config OR global if none.
464
+ // Ideally, student selects a "Class Session".
465
+ // Simplified: If Teacher triggers, use their config. If Student, find their homeroom teacher config.
466
+
467
+ let configFilter = { className: student.className, schoolId };
468
+ if (userRole === 'TEACHER') {
469
+ const user = await User.findOne({ username: req.headers['x-user-username'] });
470
+ configFilter.ownerId = user ? user._id.toString() : null;
471
+ } else {
472
+ // Student logic: Find one valid config or default?
473
+ // Fallback: Find *any* config for this class.
474
+ }
475
+
476
+ const config = await LuckyDrawConfigModel.findOne(configFilter);
477
  const prizes = config?.prizes || [];
478
  const defaultPrize = config?.defaultPrize || '再接再厉';
479
  const dailyLimit = config?.dailyLimit || 3;
480
  const consolationWeight = config?.consolationWeight || 0;
481
 
482
+ // ... (Rest of Draw Logic same as before) ...
483
+
484
  const availablePrizes = prizes.filter(p => (p.count === undefined || p.count > 0));
485
  if (availablePrizes.length === 0 && consolationWeight === 0) return res.status(400).json({ error: 'POOL_EMPTY', message: '奖品库存不足' });
486
 
 
517
  rewardType = 'ITEM';
518
  if (config._id) await LuckyDrawConfigModel.updateOne({ _id: config._id, "prizes.id": matchedPrize.id }, { $inc: { "prizes.$.count": -1 } });
519
  }
520
+
521
+ // Save reward with ownerId if teacher triggered
522
+ let ownerId = config?.ownerId;
523
+ await StudentRewardModel.create({ schoolId, studentId, studentName: student.name, rewardType, name: selectedPrize, count: 1, status: 'PENDING', source: '幸运大抽奖', ownerId });
524
  res.json({ prize: selectedPrize, rewardType });
525
  } catch (e) { res.status(500).json({ error: e.message }); }
526
  });
527
 
528
+ // 2. Monster Config (Isolated)
529
  app.get('/api/games/monster-config', async (req, res) => {
530
  const filter = getQueryFilter(req);
531
+ const ownerFilter = await getGameOwnerFilter(req);
532
  if (req.query.className) filter.className = req.query.className;
533
+ Object.assign(filter, ownerFilter);
534
  const config = await GameMonsterConfigModel.findOne(filter);
535
  res.json(config || {});
536
  });
537
  app.post('/api/games/monster-config', async (req, res) => {
538
  const data = injectSchoolId(req, req.body);
539
+ if (req.headers['x-user-role'] === 'TEACHER') {
540
+ const user = await User.findOne({ username: req.headers['x-user-username'] });
541
+ data.ownerId = user ? user._id.toString() : null;
542
+ }
543
+ await GameMonsterConfigModel.findOneAndUpdate({ className: data.className, ...getQueryFilter(req), ownerId: data.ownerId }, data, { upsert: true });
544
  res.json({ success: true });
545
  });
546
 
547
+ // 3. Zen Config (Isolated)
548
  app.get('/api/games/zen-config', async (req, res) => {
549
  const filter = getQueryFilter(req);
550
+ const ownerFilter = await getGameOwnerFilter(req);
551
  if (req.query.className) filter.className = req.query.className;
552
+ Object.assign(filter, ownerFilter);
553
  const config = await GameZenConfigModel.findOne(filter);
554
  res.json(config || {});
555
  });
556
  app.post('/api/games/zen-config', async (req, res) => {
557
  const data = injectSchoolId(req, req.body);
558
+ if (req.headers['x-user-role'] === 'TEACHER') {
559
+ const user = await User.findOne({ username: req.headers['x-user-username'] });
560
+ data.ownerId = user ? user._id.toString() : null;
561
+ }
562
+ await GameZenConfigModel.findOneAndUpdate({ className: data.className, ...getQueryFilter(req), ownerId: data.ownerId }, data, { upsert: true });
563
+ res.json({ success: true });
564
+ });
565
+
566
+ // 4. MOUNTAIN GAME - SPECIAL (Shared Class + Permission Check)
567
+ app.get('/api/games/mountain', async (req, res) => {
568
+ // Everyone sees the same mountain state for a class
569
+ res.json(await GameSessionModel.findOne({...getQueryFilter(req), className: req.query.className}));
570
+ });
571
+ app.post('/api/games/mountain', async (req, res) => {
572
+ const { className } = req.body;
573
+ const sId = req.headers['x-school-id'];
574
+ const role = req.headers['x-user-role'];
575
+ const username = req.headers['x-user-username'];
576
+
577
+ // Permission Check: Must be Homeroom Teacher
578
+ if (role === 'TEACHER') {
579
+ const user = await User.findOne({ username });
580
+ const cls = await ClassModel.findOne({ schoolId: sId, $expr: { $eq: [{ $concat: ["$grade", "$className"] }, className] } });
581
+
582
+ if (!cls) return res.status(404).json({ error: 'Class not found' });
583
+
584
+ // Check if user ID is in the allowed list
585
+ const allowedIds = cls.homeroomTeacherIds || [];
586
+ if (!allowedIds.includes(user._id.toString())) {
587
+ return res.status(403).json({ error: 'PERMISSION_DENIED', message: '只有班主任可以操作登峰游戏' });
588
+ }
589
+ }
590
+
591
+ const filter = { className };
592
+ if(sId) filter.schoolId = sId;
593
+ await GameSessionModel.findOneAndUpdate(filter, injectSchoolId(req, req.body), {upsert:true});
594
+ res.json({});
595
+ });
596
+
597
+ // 5. Rewards List (Isolated)
598
+ app.get('/api/rewards', async (req, res) => {
599
+ const filter = getQueryFilter(req);
600
+ // If Teacher, only show rewards they own
601
+ if (req.headers['x-user-role'] === 'TEACHER') {
602
+ const user = await User.findOne({ username: req.headers['x-user-username'] });
603
+ if (user) filter.ownerId = user._id.toString();
604
+ }
605
+
606
+ if(req.query.studentId) filter.studentId = req.query.studentId;
607
+ if (req.query.className) {
608
+ const classStudents = await Student.find({ className: req.query.className, ...getQueryFilter(req) }, '_id');
609
+ filter.studentId = { $in: classStudents.map(s => s._id.toString()) };
610
+ }
611
+ if (req.query.excludeType) filter.rewardType = { $ne: req.query.excludeType };
612
+ const page = parseInt(req.query.page) || 1;
613
+ const limit = parseInt(req.query.limit) || 20;
614
+ const skip = (page - 1) * limit;
615
+ const total = await StudentRewardModel.countDocuments(filter);
616
+ const list = await StudentRewardModel.find(filter).sort({createTime:-1}).skip(skip).limit(limit);
617
+ res.json({ list, total });
618
+ });
619
+ app.post('/api/rewards', async (req, res) => {
620
+ const data = injectSchoolId(req, req.body);
621
+ if (!data.count) data.count = 1;
622
+ // Inject ownerId
623
+ if (req.headers['x-user-role'] === 'TEACHER') {
624
+ const user = await User.findOne({ username: req.headers['x-user-username'] });
625
+ data.ownerId = user ? user._id.toString() : null;
626
+ }
627
+ if(data.rewardType==='DRAW_COUNT') { data.status='REDEEMED'; await Student.findByIdAndUpdate(data.studentId, {$inc:{drawAttempts:data.count}}); }
628
+ await StudentRewardModel.create(data);
629
+ res.json({});
630
+ });
631
+ app.post('/api/games/grant-reward', async (req, res) => {
632
+ const { studentId, count, rewardType, name } = req.body;
633
+ const finalCount = count || 1;
634
+ const finalName = name || (rewardType === 'DRAW_COUNT' ? '抽奖券' : '奖品');
635
+
636
+ let ownerId = null;
637
+ if (req.headers['x-user-role'] === 'TEACHER') {
638
+ const user = await User.findOne({ username: req.headers['x-user-username'] });
639
+ ownerId = user ? user._id.toString() : null;
640
+ }
641
+
642
+ if (rewardType === 'DRAW_COUNT') await Student.findByIdAndUpdate(studentId, { $inc: { drawAttempts: finalCount } });
643
+ await StudentRewardModel.create({
644
+ schoolId: req.headers['x-school-id'],
645
+ studentId,
646
+ studentName: (await Student.findById(studentId)).name,
647
+ rewardType,
648
+ name: finalName,
649
+ count: finalCount,
650
+ status: rewardType === 'DRAW_COUNT' ? 'REDEEMED' : 'PENDING',
651
+ source: '教师发放',
652
+ ownerId
653
+ });
654
+ res.json({});
655
+ });
656
+
657
+ // Update Class (Support multi-teachers)
658
+ app.put('/api/classes/:id', async (req, res) => {
659
+ const classId = req.params.id;
660
+ const { grade, className, teacherName, homeroomTeacherIds } = req.body;
661
+ const sId = req.headers['x-school-id'];
662
+ const oldClass = await ClassModel.findById(classId);
663
+
664
+ if (!oldClass) return res.status(404).json({ error: 'Class not found' });
665
+ const newFullClass = grade + className;
666
+ const oldFullClass = oldClass.grade + oldClass.className;
667
+
668
+ // Logic: Sync User's `homeroomClass` field
669
+ // 1. Remove from old teachers if class changed or teachers removed
670
+ const oldTeacherIds = oldClass.homeroomTeacherIds || [];
671
+ const newTeacherIds = homeroomTeacherIds || [];
672
+
673
+ const removedIds = oldTeacherIds.filter(id => !newTeacherIds.includes(id));
674
+ if (removedIds.length > 0) {
675
+ await User.updateMany({ _id: { $in: removedIds }, schoolId: sId }, { homeroomClass: '' });
676
+ }
677
+
678
+ // 2. Add to new teachers
679
+ if (newTeacherIds.length > 0) {
680
+ await User.updateMany({ _id: { $in: newTeacherIds }, schoolId: sId }, { homeroomClass: newFullClass });
681
+ }
682
+
683
+ // 3. Update class name string
684
+ let displayTeacherName = teacherName;
685
+ if (newTeacherIds.length > 0) {
686
+ const teachers = await User.find({ _id: { $in: newTeacherIds } });
687
+ displayTeacherName = teachers.map(t => t.trueName || t.username).join(', ');
688
+ }
689
+
690
+ await ClassModel.findByIdAndUpdate(classId, { grade, className, teacherName: displayTeacherName, homeroomTeacherIds: newTeacherIds });
691
+
692
+ if (oldFullClass !== newFullClass) {
693
+ await Student.updateMany({ className: oldFullClass, schoolId: sId }, { className: newFullClass });
694
+ // Also update users if class name changed
695
+ await User.updateMany({ homeroomClass: oldFullClass, schoolId: sId }, { homeroomClass: newFullClass });
696
+ }
697
  res.json({ success: true });
698
  });
699
 
700
+ // Create Class
701
+ app.post('/api/classes', async (req, res) => {
702
+ const data = injectSchoolId(req, req.body);
703
+ const { homeroomTeacherIds } = req.body;
704
+
705
+ // Generate display name
706
+ if (homeroomTeacherIds && homeroomTeacherIds.length > 0) {
707
+ const teachers = await User.find({ _id: { $in: homeroomTeacherIds } });
708
+ data.teacherName = teachers.map(t => t.trueName || t.username).join(', ');
709
+ }
710
+
711
+ await ClassModel.create(data);
712
+
713
+ if (homeroomTeacherIds && homeroomTeacherIds.length > 0) {
714
+ await User.updateMany({ _id: { $in: homeroomTeacherIds }, schoolId: data.schoolId }, { homeroomClass: data.grade + data.className });
715
+ }
716
+ res.json({});
717
+ });
718
+
719
+ // Update Courses to support className
720
+ app.get('/api/courses', async (req, res) => {
721
+ const filter = getQueryFilter(req);
722
+ // Teachers only see their own courses usually, or we can filter by teacherId if passed
723
+ if (req.query.teacherId) filter.teacherId = req.query.teacherId;
724
+ res.json(await Course.find(filter));
725
  });
726
+ app.post('/api/courses', async (req, res) => {
727
+ const data = injectSchoolId(req, req.body);
728
+ try {
729
+ await Course.create(data);
730
+ res.json({});
731
+ } catch(e) {
732
+ if (e.code === 11000) return res.status(409).json({ error: 'DUPLICATE', message: '该班级该科目已有任课老师' });
733
+ res.status(500).json({ error: e.message });
734
+ }
735
+ });
736
+
737
+ // ... (Rest of server.js) ...
738
  app.get('/api/public/schools', async (req, res) => { res.json(await School.find({}, 'name code _id')); });
739
  app.get('/api/public/config', async (req, res) => {
740
  const currentSem = getAutoSemester();
 
830
  }));
831
  res.json(resData);
832
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
833
  app.delete('/api/classes/:id', async (req, res) => {
834
  const cls = await ClassModel.findById(req.params.id);
835
+ if (cls && cls.homeroomTeacherIds) await User.updateMany({ _id: { $in: cls.homeroomTeacherIds }, schoolId: cls.schoolId }, { homeroomClass: '' });
836
  await ClassModel.findByIdAndDelete(req.params.id);
837
  res.json({});
838
  });
 
840
  app.post('/api/subjects', async (req, res) => { await SubjectModel.create(injectSchoolId(req, req.body)); res.json({}); });
841
  app.put('/api/subjects/:id', async (req, res) => { await SubjectModel.findByIdAndUpdate(req.params.id, req.body); res.json({}); });
842
  app.delete('/api/subjects/:id', async (req, res) => { await SubjectModel.findByIdAndDelete(req.params.id); res.json({}); });
 
 
843
  app.put('/api/courses/:id', async (req, res) => { await Course.findByIdAndUpdate(req.params.id, req.body); res.json({}); });
844
  app.delete('/api/courses/:id', async (req, res) => { await Course.findByIdAndDelete(req.params.id); res.json({}); });
845
  app.get('/api/scores', async (req, res) => { res.json(await Score.find(getQueryFilter(req))); });
 
892
  res.json(config);
893
  });
894
  app.post('/api/config', async (req, res) => { await ConfigModel.findOneAndUpdate({key:'main'}, req.body, {upsert:true}); res.json({}); });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
895
  app.put('/api/rewards/:id', async (req, res) => { await StudentRewardModel.findByIdAndUpdate(req.params.id, req.body); res.json({}); });
896
  app.delete('/api/rewards/:id', async (req, res) => {
897
  const reward = await StudentRewardModel.findById(req.params.id);
 
904
  await StudentRewardModel.findByIdAndDelete(req.params.id);
905
  res.json({});
906
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
907
  app.post('/api/rewards/:id/redeem', async (req, res) => { await StudentRewardModel.findByIdAndUpdate(req.params.id, {status:'REDEEMED'}); res.json({}); });
908
  app.get('/api/attendance', async (req, res) => {
909
  const { className, date, studentId } = req.query;
 
970
  });
971
 
972
  app.get('*', (req, res) => { res.sendFile(path.join(__dirname, 'dist', 'index.html')); });
973
+ app.listen(PORT, () => console.log(`🚀 Server running on port ${PORT}`));
services/mockData.ts CHANGED
@@ -1,3 +1,4 @@
 
1
  import { Student, Course, Score, User, UserRole, UserStatus } from '../types';
2
 
3
  export const MOCK_USER: User = {
@@ -31,12 +32,12 @@ export const STUDENTS: Student[] = Array.from({ length: 50 }).map((_, i) => ({
31
  }));
32
 
33
  export const COURSES: Course[] = [
34
- { id: 1, courseCode: 'CHI01', courseName: '语文', teacherName: '王老师', credits: 5, capacity: 45, enrolled: 45 },
35
- { id: 2, courseCode: 'MAT01', courseName: '数学', teacherName: '张老师', credits: 5, capacity: 45, enrolled: 45 },
36
- { id: 3, courseCode: 'ENG01', courseName: '英语', teacherName: '李老师', credits: 4, capacity: 45, enrolled: 42 },
37
- { id: 4, courseCode: 'SCI01', courseName: '科学', teacherName: '赵老师', credits: 2, capacity: 45, enrolled: 40 },
38
- { id: 5, courseCode: 'ART01', courseName: '美术', teacherName: '陈老师', credits: 2, capacity: 45, enrolled: 45 },
39
- { id: 6, courseCode: 'PE001', courseName: '体育', teacherName: '刘教练', credits: 2, capacity: 60, enrolled: 58 },
40
  ];
41
 
42
  export const SCORES: Score[] = Array.from({ length: 30 }).map((_, i) => ({
@@ -63,4 +64,4 @@ export const CHART_DATA_DISTRIBUTION = [
63
  { name: '六年级(2)班', value: 42 },
64
  { name: '五年级(1)班', value: 40 },
65
  { name: '五年级(2)班', value: 38 },
66
- ];
 
1
+
2
  import { Student, Course, Score, User, UserRole, UserStatus } from '../types';
3
 
4
  export const MOCK_USER: User = {
 
32
  }));
33
 
34
  export const COURSES: Course[] = [
35
+ { id: 1, courseCode: 'CHI01', courseName: '语文', teacherName: '王老师', className: '六年级(1)班', credits: 5, capacity: 45, enrolled: 45 },
36
+ { id: 2, courseCode: 'MAT01', courseName: '数学', teacherName: '张老师', className: '六年级(1)班', credits: 5, capacity: 45, enrolled: 45 },
37
+ { id: 3, courseCode: 'ENG01', courseName: '英语', teacherName: '李老师', className: '六年级(1)班', credits: 4, capacity: 45, enrolled: 42 },
38
+ { id: 4, courseCode: 'SCI01', courseName: '科学', teacherName: '赵老师', className: '六年级(1)班', credits: 2, capacity: 45, enrolled: 40 },
39
+ { id: 5, courseCode: 'ART01', courseName: '美术', teacherName: '陈老师', className: '六年级(1)班', credits: 2, capacity: 45, enrolled: 45 },
40
+ { id: 6, courseCode: 'PE001', courseName: '体育', teacherName: '刘教练', className: '六年级(1)班', credits: 2, capacity: 60, enrolled: 58 },
41
  ];
42
 
43
  export const SCORES: Score[] = Array.from({ length: 30 }).map((_, i) => ({
 
64
  { name: '六年级(2)班', value: 42 },
65
  { name: '五年级(1)班', value: 40 },
66
  { name: '五年级(2)班', value: 38 },
67
+ ];
types.ts CHANGED
@@ -33,7 +33,7 @@ export interface User {
33
  avatar?: string;
34
  createTime?: string;
35
  teachingSubject?: string;
36
- homeroomClass?: string;
37
  // Class Application
38
  classApplication?: {
39
  type: 'CLAIM' | 'RESIGN';
@@ -56,7 +56,8 @@ export interface ClassInfo {
56
  schoolId?: string;
57
  grade: string;
58
  className: string;
59
- teacherName?: string;
 
60
  studentCount?: number;
61
  }
62
 
@@ -112,8 +113,10 @@ export interface Course {
112
  _id?: string;
113
  schoolId?: string;
114
  courseCode: string;
115
- courseName: string;
 
116
  teacherName: string;
 
117
  credits: number;
118
  capacity: number;
119
  enrolled: number;
@@ -238,6 +241,7 @@ export interface LuckyDrawConfig {
238
  _id?: string;
239
  schoolId: string;
240
  className?: string;
 
241
  prizes: LuckyPrize[];
242
  dailyLimit: number;
243
  cardCount?: number;
@@ -249,6 +253,7 @@ export interface GameMonsterConfig {
249
  _id?: string;
250
  schoolId: string;
251
  className: string;
 
252
  duration: number;
253
  sensitivity: number;
254
  difficulty: number;
@@ -334,4 +339,4 @@ export interface SchoolCalendarEntry {
334
  startDate: string; // YYYY-MM-DD
335
  endDate: string; // YYYY-MM-DD
336
  name: string;
337
- }
 
33
  avatar?: string;
34
  createTime?: string;
35
  teachingSubject?: string;
36
+ homeroomClass?: string; // 废弃或仅作显示,主要逻辑移至 ClassInfo.homeroomTeacherIds
37
  // Class Application
38
  classApplication?: {
39
  type: 'CLAIM' | 'RESIGN';
 
56
  schoolId?: string;
57
  grade: string;
58
  className: string;
59
+ teacherName?: string; // Display string (e.g., "张三, 李四")
60
+ homeroomTeacherIds?: string[]; // Actual IDs for logic
61
  studentCount?: number;
62
  }
63
 
 
113
  _id?: string;
114
  schoolId?: string;
115
  courseCode: string;
116
+ courseName: string; // Subject Name
117
+ className: string; // Target Class (e.g. "一年级(1)班")
118
  teacherName: string;
119
+ teacherId?: string; // Optional linkage
120
  credits: number;
121
  capacity: number;
122
  enrolled: number;
 
241
  _id?: string;
242
  schoolId: string;
243
  className?: string;
244
+ ownerId?: string; // Isolated by teacher
245
  prizes: LuckyPrize[];
246
  dailyLimit: number;
247
  cardCount?: number;
 
253
  _id?: string;
254
  schoolId: string;
255
  className: string;
256
+ ownerId?: string; // Isolated by teacher
257
  duration: number;
258
  sensitivity: number;
259
  difficulty: number;
 
339
  startDate: string; // YYYY-MM-DD
340
  endDate: string; // YYYY-MM-DD
341
  name: string;
342
+ }