dvc890 commited on
Commit
0d7a8a0
·
verified ·
1 Parent(s): e10a55f

Upload 45 files

Browse files
Files changed (7) hide show
  1. models.js +32 -12
  2. pages/AchievementStudent.tsx +50 -33
  3. pages/AchievementTeacher.tsx +119 -70
  4. pages/GameLucky.tsx +82 -139
  5. server.js +250 -519
  6. services/api.ts +10 -4
  7. types.ts +44 -24
models.js CHANGED
@@ -1,6 +1,8 @@
1
 
2
  const mongoose = require('mongoose');
3
 
 
 
4
  const SchoolSchema = new mongoose.Schema({ name: String, code: String });
5
  const School = mongoose.model('School', SchoolSchema);
6
 
@@ -57,14 +59,13 @@ 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
 
@@ -75,8 +76,8 @@ 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
 
@@ -106,19 +107,15 @@ const ConfigModel = mongoose.model('Config', ConfigSchema);
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,
@@ -136,7 +133,6 @@ const GameMonsterConfigSchema = new mongoose.Schema({
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,
@@ -153,9 +149,33 @@ const GameZenConfigSchema = new mongoose.Schema({
153
  });
154
  const GameZenConfigModel = mongoose.model('GameZenConfig', GameZenConfigSchema);
155
 
156
- const AchievementConfigSchema = new mongoose.Schema({ schoolId: String, className: String, achievements: [{ id: String, name: String, icon: String, points: Number, description: String }], exchangeRules: [{ id: String, cost: Number, rewardType: String, rewardName: String, rewardValue: Number }] });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
157
  const AchievementConfigModel = mongoose.model('AchievementConfig', AchievementConfigSchema);
158
 
 
 
 
 
 
 
 
 
 
159
  const StudentAchievementSchema = new mongoose.Schema({ schoolId: String, studentId: String, studentName: String, achievementId: String, achievementName: String, achievementIcon: String, semester: String, createTime: { type: Date, default: Date.now } });
160
  const StudentAchievementModel = mongoose.model('StudentAchievement', StudentAchievementSchema);
161
 
@@ -171,5 +191,5 @@ const SchoolCalendarModel = mongoose.model('SchoolCalendar', SchoolCalendarSchem
171
  module.exports = {
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
  };
 
1
 
2
  const mongoose = require('mongoose');
3
 
4
+ // ... (Previous Models: School, User, Student, Course, Score, ClassModel, SubjectModel, ExamModel, ScheduleModel, ConfigModel, NotificationModel, GameSessionModel, StudentRewardModel, LuckyDrawConfigModel, GameMonsterConfigModel, GameZenConfigModel)
5
+
6
  const SchoolSchema = new mongoose.Schema({ name: String, code: String });
7
  const School = mongoose.model('School', SchoolSchema);
8
 
 
59
  schoolId: String,
60
  courseCode: String,
61
  courseName: String,
62
+ className: String,
63
  teacherName: String,
64
+ teacherId: String,
65
  credits: Number,
66
  capacity: Number,
67
  enrolled: Number
68
  });
 
69
  CourseSchema.index({ schoolId: 1, className: 1, courseName: 1 }, { unique: true });
70
  const Course = mongoose.model('Course', CourseSchema);
71
 
 
76
  schoolId: String,
77
  grade: String,
78
  className: String,
79
+ teacherName: String,
80
+ homeroomTeacherIds: [String]
81
  });
82
  const ClassModel = mongoose.model('Class', ClassSchema);
83
 
 
107
  const NotificationSchema = new mongoose.Schema({ schoolId: String, targetRole: String, targetUserId: String, title: String, content: String, type: String, createTime: { type: Date, default: Date.now } });
108
  const NotificationModel = mongoose.model('Notification', NotificationSchema);
109
 
 
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
  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 });
114
  const StudentRewardModel = mongoose.model('StudentReward', StudentRewardSchema);
115
 
 
116
  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 } });
117
  const LuckyDrawConfigModel = mongoose.model('LuckyDrawConfig', LuckyDrawConfigSchema);
118
 
 
119
  const GameMonsterConfigSchema = new mongoose.Schema({
120
  schoolId: String,
121
  className: String,
 
133
  });
134
  const GameMonsterConfigModel = mongoose.model('GameMonsterConfig', GameMonsterConfigSchema);
135
 
 
136
  const GameZenConfigSchema = new mongoose.Schema({
137
  schoolId: String,
138
  className: String,
 
149
  });
150
  const GameZenConfigModel = mongoose.model('GameZenConfig', GameZenConfigSchema);
151
 
152
+ // Updated Achievement Schema with addedBy
153
+ const AchievementConfigSchema = new mongoose.Schema({
154
+ schoolId: String,
155
+ className: String,
156
+ achievements: [{
157
+ id: String,
158
+ name: String,
159
+ icon: String,
160
+ points: Number,
161
+ description: String,
162
+ addedBy: String,
163
+ addedByName: String
164
+ }],
165
+ // Legacy support, rules now moving to TeacherExchangeConfig
166
+ exchangeRules: [{ id: String, cost: Number, rewardType: String, rewardName: String, rewardValue: Number }]
167
+ });
168
  const AchievementConfigModel = mongoose.model('AchievementConfig', AchievementConfigSchema);
169
 
170
+ // NEW: Independent Teacher Exchange Rules
171
+ const TeacherExchangeConfigSchema = new mongoose.Schema({
172
+ schoolId: String,
173
+ teacherId: String,
174
+ teacherName: String,
175
+ rules: [{ id: String, cost: Number, rewardType: String, rewardName: String, rewardValue: Number }]
176
+ });
177
+ const TeacherExchangeConfigModel = mongoose.model('TeacherExchangeConfig', TeacherExchangeConfigSchema);
178
+
179
  const StudentAchievementSchema = new mongoose.Schema({ schoolId: String, studentId: String, studentName: String, achievementId: String, achievementName: String, achievementIcon: String, semester: String, createTime: { type: Date, default: Date.now } });
180
  const StudentAchievementModel = mongoose.model('StudentAchievement', StudentAchievementSchema);
181
 
 
191
  module.exports = {
192
  School, User, Student, Course, Score, ClassModel, SubjectModel, ExamModel, ScheduleModel,
193
  ConfigModel, NotificationModel, GameSessionModel, StudentRewardModel, LuckyDrawConfigModel, GameMonsterConfigModel, GameZenConfigModel,
194
+ AchievementConfigModel, TeacherExchangeConfigModel, StudentAchievementModel, AttendanceModel, LeaveRequestModel, SchoolCalendarModel
195
  };
pages/AchievementStudent.tsx CHANGED
@@ -1,7 +1,7 @@
1
 
2
  import React, { useState, useEffect } from 'react';
3
  import { api } from '../services/api';
4
- import { AchievementConfig, AchievementItem, Student, StudentAchievement, SystemConfig } from '../types';
5
  import { Award, ShoppingBag, Loader2, Calendar, Lock } from 'lucide-react';
6
  import { Emoji } from '../components/Emoji';
7
 
@@ -9,6 +9,7 @@ export const AchievementStudent: React.FC = () => {
9
  const [loading, setLoading] = useState(true);
10
  const [student, setStudent] = useState<Student | null>(null);
11
  const [config, setConfig] = useState<AchievementConfig | null>(null);
 
12
  const [myAchievements, setMyAchievements] = useState<StudentAchievement[]>([]);
13
 
14
  // UI State
@@ -47,16 +48,24 @@ export const AchievementStudent: React.FC = () => {
47
  // Fetch Records
48
  const records = await api.achievements.getStudentAchievements(me._id || String(me.id), selectedSemester);
49
  setMyAchievements(records);
 
 
 
 
 
 
 
 
50
  }
51
  } catch (e) { console.error(e); }
52
  finally { setLoading(false); }
53
  };
54
 
55
- const handleExchange = async (ruleId: string) => {
56
  if (!student) return;
57
  if (!confirm('确认消耗小红花进行兑换吗?')) return;
58
  try {
59
- await api.achievements.exchange({ studentId: student._id || String(student.id), ruleId });
60
  alert('兑换成功!请到“奖励管理”查看。');
61
  loadData(); // Refresh balance
62
  } catch (e: any) { alert(e.message || '兑换失败'); }
@@ -75,12 +84,9 @@ export const AchievementStudent: React.FC = () => {
75
  const displayList = [...config.achievements];
76
 
77
  // 2. Find "Orphaned" achievements (Earned in past, but not in current config)
78
- // These happen if the teacher deleted an achievement or after a class promotion/semester change
79
  const configIds = new Set(config.achievements.map(a => a.id));
80
 
81
- // We only care about unique orphaned types
82
  const uniqueOrphans = new Map<string, StudentAchievement>();
83
-
84
  myAchievements.forEach(record => {
85
  if (!configIds.has(record.achievementId)) {
86
  if (!uniqueOrphans.has(record.achievementId)) {
@@ -89,13 +95,12 @@ export const AchievementStudent: React.FC = () => {
89
  }
90
  });
91
 
92
- // Convert Orphans to Display Items
93
  uniqueOrphans.forEach((record) => {
94
  displayList.push({
95
  id: record.achievementId,
96
  name: record.achievementName,
97
  icon: record.achievementIcon,
98
- points: 0, // Points already awarded, just display
99
  description: '历史荣誉'
100
  });
101
  });
@@ -188,32 +193,44 @@ export const AchievementStudent: React.FC = () => {
188
 
189
  {/* 2. Shop */}
190
  {activeTab === 'shop' && (
191
- <div className="max-w-4xl mx-auto grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
192
- {config.exchangeRules.map(rule => {
193
- const canAfford = (student.flowerBalance || 0) >= rule.cost;
194
- return (
195
- <div key={rule.id} className="bg-white rounded-xl border border-gray-200 shadow-sm p-5 flex flex-col items-center text-center hover:shadow-md transition-shadow relative overflow-hidden">
196
- <div className="w-16 h-16 bg-gradient-to-br from-green-100 to-emerald-100 rounded-full flex items-center justify-center mb-3 text-3xl shadow-inner">
197
- <Emoji symbol={rule.rewardType === 'DRAW_COUNT' ? '🎲' : '🎁'} size={32} />
198
- </div>
199
- <h3 className="font-bold text-gray-800 text-lg mb-1">{rule.rewardName}</h3>
200
- <p className="text-sm text-gray-500 mb-4">包含数量: x{rule.rewardValue}</p>
201
-
202
- <button
203
- onClick={() => handleExchange(rule.id)}
204
- disabled={!canAfford}
205
- className={`mt-auto w-full py-2.5 rounded-lg font-bold flex items-center justify-center gap-2 transition-all ${canAfford ? 'bg-amber-500 text-white hover:bg-amber-600 shadow-md hover:shadow-lg active:scale-95' : 'bg-gray-100 text-gray-400 cursor-not-allowed'}`}
206
- >
207
- <ShoppingBag size={16}/>
208
- {rule.cost} <Emoji symbol="🌺" size={14} /> 兑换
209
- </button>
210
- </div>
211
- );
212
- })}
213
- {config.exchangeRules.length === 0 && (
214
- <div className="col-span-full text-center py-20 text-gray-400">
215
- 老师暂时没有设置兑换商品哦~
216
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
217
  )}
218
  </div>
219
  )}
 
1
 
2
  import React, { useState, useEffect } from 'react';
3
  import { api } from '../services/api';
4
+ import { AchievementConfig, AchievementItem, Student, StudentAchievement, SystemConfig, TeacherExchangeConfig } from '../types';
5
  import { Award, ShoppingBag, Loader2, Calendar, Lock } from 'lucide-react';
6
  import { Emoji } from '../components/Emoji';
7
 
 
9
  const [loading, setLoading] = useState(true);
10
  const [student, setStudent] = useState<Student | null>(null);
11
  const [config, setConfig] = useState<AchievementConfig | null>(null);
12
+ const [teacherRules, setTeacherRules] = useState<TeacherExchangeConfig[]>([]);
13
  const [myAchievements, setMyAchievements] = useState<StudentAchievement[]>([]);
14
 
15
  // UI State
 
48
  // Fetch Records
49
  const records = await api.achievements.getStudentAchievements(me._id || String(me.id), selectedSemester);
50
  setMyAchievements(records);
51
+
52
+ // Fetch Teacher Rules
53
+ const teachers = await api.users.getTeachersForClass(me.className);
54
+ const teacherIds = teachers.map((t: any) => t._id);
55
+ if (teacherIds.length > 0) {
56
+ const rules = await api.achievements.getRulesByTeachers(teacherIds);
57
+ setTeacherRules(rules);
58
+ }
59
  }
60
  } catch (e) { console.error(e); }
61
  finally { setLoading(false); }
62
  };
63
 
64
+ const handleExchange = async (ruleId: string, teacherId: string) => {
65
  if (!student) return;
66
  if (!confirm('确认消耗小红花进行兑换吗?')) return;
67
  try {
68
+ await api.achievements.exchange({ studentId: student._id || String(student.id), ruleId, teacherId });
69
  alert('兑换成功!请到“奖励管理”查看。');
70
  loadData(); // Refresh balance
71
  } catch (e: any) { alert(e.message || '兑换失败'); }
 
84
  const displayList = [...config.achievements];
85
 
86
  // 2. Find "Orphaned" achievements (Earned in past, but not in current config)
 
87
  const configIds = new Set(config.achievements.map(a => a.id));
88
 
 
89
  const uniqueOrphans = new Map<string, StudentAchievement>();
 
90
  myAchievements.forEach(record => {
91
  if (!configIds.has(record.achievementId)) {
92
  if (!uniqueOrphans.has(record.achievementId)) {
 
95
  }
96
  });
97
 
 
98
  uniqueOrphans.forEach((record) => {
99
  displayList.push({
100
  id: record.achievementId,
101
  name: record.achievementName,
102
  icon: record.achievementIcon,
103
+ points: 0,
104
  description: '历史荣誉'
105
  });
106
  });
 
193
 
194
  {/* 2. Shop */}
195
  {activeTab === 'shop' && (
196
+ <div className="space-y-8">
197
+ {teacherRules.length === 0 ? (
198
+ <div className="text-center py-20 text-gray-400">
199
+ 老师们暂时没有设置兑换商品哦~
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
200
  </div>
201
+ ) : (
202
+ teacherRules.map(teacherConfig => (
203
+ teacherConfig.rules.length > 0 && (
204
+ <div key={teacherConfig.teacherId} className="bg-white rounded-xl border border-gray-200 p-6 shadow-sm">
205
+ <h3 className="font-bold text-lg text-gray-800 mb-4 border-b pb-2 flex items-center">
206
+ 🛍️ {teacherConfig.teacherName} 的兑换店
207
+ </h3>
208
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
209
+ {teacherConfig.rules.map(rule => {
210
+ const canAfford = (student.flowerBalance || 0) >= rule.cost;
211
+ return (
212
+ <div key={rule.id} className="bg-gray-50 rounded-xl border border-gray-100 p-4 flex flex-col items-center text-center hover:shadow-md transition-shadow relative overflow-hidden">
213
+ <div className="w-14 h-14 bg-gradient-to-br from-green-100 to-emerald-100 rounded-full flex items-center justify-center mb-3 text-2xl shadow-inner">
214
+ <Emoji symbol={rule.rewardType === 'DRAW_COUNT' ? '🎲' : '🎁'} size={28} />
215
+ </div>
216
+ <h3 className="font-bold text-gray-800 mb-1">{rule.rewardName}</h3>
217
+ <p className="text-xs text-gray-500 mb-3">包含数量: x{rule.rewardValue}</p>
218
+
219
+ <button
220
+ onClick={() => handleExchange(rule.id, teacherConfig.teacherId)}
221
+ disabled={!canAfford}
222
+ className={`mt-auto w-full py-2 rounded-lg font-bold flex items-center justify-center gap-2 text-sm transition-all ${canAfford ? 'bg-amber-500 text-white hover:bg-amber-600 shadow-sm active:scale-95' : 'bg-gray-200 text-gray-400 cursor-not-allowed'}`}
223
+ >
224
+ <ShoppingBag size={14}/>
225
+ {rule.cost} <Emoji symbol="🌺" size={12} /> 兑换
226
+ </button>
227
+ </div>
228
+ );
229
+ })}
230
+ </div>
231
+ </div>
232
+ )
233
+ ))
234
  )}
235
  </div>
236
  )}
pages/AchievementTeacher.tsx CHANGED
@@ -1,8 +1,8 @@
1
 
2
  import React, { useState, useEffect } from 'react';
3
  import { api } from '../services/api';
4
- import { AchievementConfig, AchievementItem, ExchangeRule, Student, SystemConfig } from '../types';
5
- import { Plus, Trash2, Edit, Save, Gift, Award, Coins, Users, Search, Loader2, CheckCircle } from 'lucide-react';
6
  import { Emoji } from '../components/Emoji';
7
 
8
  const PRESET_ICONS = [
@@ -16,6 +16,7 @@ const PRESET_ICONS = [
16
  export const AchievementTeacher: React.FC<{className?: string}> = ({ className }) => {
17
  const [loading, setLoading] = useState(true);
18
  const [config, setConfig] = useState<AchievementConfig | null>(null);
 
19
  const [students, setStudents] = useState<Student[]>([]);
20
  const [semester, setSemester] = useState('');
21
 
@@ -26,26 +27,33 @@ export const AchievementTeacher: React.FC<{className?: string}> = ({ className }
26
  const [newAchieve, setNewAchieve] = useState<AchievementItem>({ id: '', name: '', icon: '🏆', points: 1 });
27
  const [newRule, setNewRule] = useState<ExchangeRule>({ id: '', cost: 10, rewardType: 'DRAW_COUNT', rewardName: '抽奖券x1', rewardValue: 1 });
28
 
 
 
 
 
29
  // Grant State
30
  const [selectedStudents, setSelectedStudents] = useState<Set<string>>(new Set());
31
  const [selectedAchieveId, setSelectedAchieveId] = useState('');
32
 
33
  const currentUser = api.auth.getCurrentUser();
34
- // Use prop or fallback
35
  const homeroomClass = className || currentUser?.homeroomClass;
 
 
 
 
 
36
 
37
  useEffect(() => {
38
  loadData();
39
- }, [homeroomClass]); // Reload when homeroomClass changes
40
 
41
  const loadData = async () => {
42
  if (!homeroomClass) return;
43
  setLoading(true);
44
 
45
- // 1. Load Students (Independent)
46
  try {
 
47
  const stus = await api.students.getAll();
48
- // Filter students for homeroom & Sort by SeatNo > Name
49
  const sortedStudents = stus
50
  .filter((s: Student) => s.className.trim() === homeroomClass.trim())
51
  .sort((a: Student, b: Student) => {
@@ -55,75 +63,83 @@ export const AchievementTeacher: React.FC<{className?: string}> = ({ className }
55
  return a.name.localeCompare(b.name, 'zh-CN');
56
  });
57
  setStudents(sortedStudents);
58
- } catch (e) {
59
- console.error("Failed to load students", e);
60
- }
61
-
62
- // 2. Load Config & System (Independent)
63
- try {
64
- // Use allSettled or catch individual promises to avoid blocking
65
- const cfgPromise = api.achievements.getConfig(homeroomClass).catch(() => null);
66
- const sysPromise = api.config.getPublic().catch(() => ({ semester: '当前学期' }));
67
 
68
- const [cfg, sysCfg] = await Promise.all([cfgPromise, sysPromise]);
 
 
 
 
 
69
 
70
- // Critical: Always ensure a config object exists, even if API returns null/error
71
  const defaultConfig: AchievementConfig = {
72
  schoolId: currentUser?.schoolId || '',
73
  className: homeroomClass,
74
  achievements: [],
75
- exchangeRules: []
76
  };
77
 
78
- // If cfg is null or empty, use default
79
  setConfig(cfg || defaultConfig);
 
80
 
81
  // @ts-ignore
82
  setSemester(sysCfg?.semester || '当前学期');
83
 
84
  } catch (e) {
85
- console.error("Failed to load config", e);
86
- // Fallback just in case
87
- setConfig({
88
- schoolId: currentUser?.schoolId || '',
89
- className: homeroomClass,
90
- achievements: [],
91
- exchangeRules: []
92
- });
93
- }
94
- finally { setLoading(false); }
95
  };
96
 
 
97
  const handleSaveConfig = async (newConfig: AchievementConfig) => {
98
- // Optimistic update
99
  setConfig(newConfig);
100
  try {
101
  await api.achievements.saveConfig(newConfig);
102
  } catch (e) {
103
- alert('保存失败,请检查网络');
104
- loadData(); // Revert on fail
105
  }
106
  };
107
 
108
- // --- Tab 1: Manage Achievements ---
 
 
 
 
 
 
 
 
 
 
 
 
 
109
  const addAchievement = () => {
110
  if (!config) return;
111
  if (!newAchieve.name) return alert('请输入成就名称');
112
 
113
- const newItem = { ...newAchieve, id: Date.now().toString() };
 
 
 
 
 
114
  const updated = { ...config, achievements: [...config.achievements, newItem] };
115
 
116
  handleSaveConfig(updated);
117
  setNewAchieve({ id: '', name: '', icon: '🏆', points: 1 });
118
  };
119
 
120
- const deleteAchievement = (id: string) => {
121
- if (!config || !confirm('确认删除?')) return;
122
- const updated = { ...config, achievements: config.achievements.filter(a => a.id !== id) };
 
 
 
 
123
  handleSaveConfig(updated);
124
  };
125
 
126
- // --- Tab 2: Grant ---
127
  const handleGrant = async () => {
128
  if (selectedStudents.size === 0 || !selectedAchieveId) return alert('请选择学生和奖状');
129
  try {
@@ -133,43 +149,45 @@ export const AchievementTeacher: React.FC<{className?: string}> = ({ className }
133
  await Promise.all(promises);
134
  alert(`成功发放给 ${selectedStudents.size} 位学生`);
135
  setSelectedStudents(new Set());
136
- loadData(); // Refresh flower balances
137
  } catch (e) { alert('部分发放失败'); }
138
  };
139
 
140
- // --- Tab 3: Exchange Rules ---
141
  const addRule = () => {
142
- if (!config) return;
143
  if (!newRule.rewardName) return alert('请输入奖励名称');
144
-
145
  const newItem = { ...newRule, id: Date.now().toString() };
146
- const updated = { ...config, exchangeRules: [...config.exchangeRules, newItem] };
147
-
148
- handleSaveConfig(updated);
149
  setNewRule({ id: '', cost: 10, rewardType: 'DRAW_COUNT', rewardName: '抽奖券x1', rewardValue: 1 });
150
  };
151
 
152
  const deleteRule = (id: string) => {
153
- if (!config || !confirm('确认删除?')) return;
154
- const updated = { ...config, exchangeRules: config.exchangeRules.filter(r => r.id !== id) };
155
- handleSaveConfig(updated);
156
  };
157
 
158
- // Proxy Exchange (Teacher helps student exchange)
159
  const handleProxyExchange = async (studentId: string, ruleId: string) => {
160
  if (!confirm('确认代学生兑换?将扣除对应小红花并记录。')) return;
161
  try {
162
- await api.achievements.exchange({ studentId, ruleId });
163
  alert('兑换成功');
164
- loadData(); // Refresh balance
165
  } catch (e: any) { alert(e.message || '兑换失败,余额不足?'); }
166
  };
167
 
168
  if (!homeroomClass) return <div className="p-10 text-center text-gray-500">您不是班主任,无法使用成就管理功能。</div>;
169
  if (loading) return <div className="flex justify-center p-10"><Loader2 className="animate-spin text-blue-600"/></div>;
170
 
171
- // Safety fallback render if config is somehow still null (shouldn't happen with new logic)
172
  const safeConfig = config || { achievements: [], exchangeRules: [] };
 
 
 
 
 
 
173
 
174
  return (
175
  <div className="h-full flex flex-col bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
@@ -182,7 +200,7 @@ export const AchievementTeacher: React.FC<{className?: string}> = ({ className }
182
  <Gift size={18}/> 发放成就
183
  </button>
184
  <button onClick={() => setActiveTab('exchange')} className={`px-6 py-4 text-sm font-bold border-b-2 transition-colors flex items-center gap-2 whitespace-nowrap ${activeTab === 'exchange' ? 'border-blue-500 text-blue-600 bg-white' : 'border-transparent text-gray-500 hover:text-gray-700'}`}>
185
- <Coins size={18}/> 兑换规则
186
  </button>
187
  <button onClick={() => setActiveTab('balance')} className={`px-6 py-4 text-sm font-bold border-b-2 transition-colors flex items-center gap-2 whitespace-nowrap ${activeTab === 'balance' ? 'border-blue-500 text-blue-600 bg-white' : 'border-transparent text-gray-500 hover:text-gray-700'}`}>
188
  <Users size={18}/> 学生积分
@@ -193,6 +211,15 @@ export const AchievementTeacher: React.FC<{className?: string}> = ({ className }
193
  {/* 1. Manage Achievements */}
194
  {activeTab === 'manage' && (
195
  <div className="space-y-6 max-w-4xl mx-auto">
 
 
 
 
 
 
 
 
 
196
  <div className="bg-blue-50 p-4 rounded-xl border border-blue-100 flex flex-col md:flex-row gap-4 items-end">
197
  <div className="flex-1 w-full">
198
  <label className="text-xs font-bold text-gray-500 uppercase mb-1 block">成就名称</label>
@@ -211,22 +238,30 @@ export const AchievementTeacher: React.FC<{className?: string}> = ({ className }
211
  <button onClick={addAchievement} className="bg-blue-600 text-white px-4 py-2 rounded-lg font-bold hover:bg-blue-700 whitespace-nowrap text-sm">添加成就</button>
212
  </div>
213
 
214
- {safeConfig.achievements.length === 0 ? (
215
  <div className="text-center text-gray-400 py-10">暂无成就,请添加</div>
216
  ) : (
217
  <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
218
- {safeConfig.achievements.map(ach => (
 
 
 
219
  <div key={ach.id} className="border border-gray-200 rounded-xl p-4 flex flex-col items-center relative group hover:shadow-md transition-all bg-white">
220
  <div className="text-4xl mb-2"><Emoji symbol={ach.icon} /></div>
221
  <div className="font-bold text-gray-800 text-center">{ach.name}</div>
222
  <div className="text-xs text-amber-600 font-bold bg-amber-50 px-2 py-0.5 rounded-full mt-1 border border-amber-100">
223
  {ach.points} <Emoji symbol="🌺" size={14}/>
224
  </div>
225
- <button onClick={() => deleteAchievement(ach.id)} className="absolute top-2 right-2 text-gray-300 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-opacity">
226
- <Trash2 size={16}/>
227
- </button>
 
 
 
 
 
228
  </div>
229
- ))}
230
  </div>
231
  )}
232
  </div>
@@ -267,12 +302,22 @@ export const AchievementTeacher: React.FC<{className?: string}> = ({ className }
267
 
268
  {/* Achievement Selector */}
269
  <div className="flex-1 flex flex-col">
270
- <h3 className="font-bold text-gray-700 mb-4">2. 选择要颁发的奖状</h3>
271
- {safeConfig.achievements.length === 0 ? (
 
 
 
 
 
 
 
 
 
 
272
  <div className="text-gray-400 text-sm mb-6">暂无成就,请先去“成就库管理”添加。</div>
273
  ) : (
274
  <div className="grid grid-cols-2 sm:grid-cols-3 gap-3 mb-6 max-h-[400px] overflow-y-auto custom-scrollbar p-1">
275
- {safeConfig.achievements.map(ach => (
276
  <div
277
  key={ach.id}
278
  onClick={() => setSelectedAchieveId(ach.id)}
@@ -299,9 +344,13 @@ export const AchievementTeacher: React.FC<{className?: string}> = ({ className }
299
  </div>
300
  )}
301
 
302
- {/* 3. Exchange Rules */}
303
  {activeTab === 'exchange' && (
304
  <div className="space-y-6 max-w-4xl mx-auto">
 
 
 
 
305
  <div className="bg-green-50 p-4 rounded-xl border border-green-100 flex flex-col md:flex-row gap-4 items-end">
306
  <div className="w-full md:w-32">
307
  <label className="text-xs font-bold text-gray-500 uppercase mb-1 block">消耗小红花</label>
@@ -326,9 +375,9 @@ export const AchievementTeacher: React.FC<{className?: string}> = ({ className }
326
  </div>
327
 
328
  <div className="space-y-3">
329
- {safeConfig.exchangeRules.length === 0 ? (
330
- <div className="text-center text-gray-400 py-4">暂无兑换规则</div>
331
- ) : safeConfig.exchangeRules.map(rule => (
332
  <div key={rule.id} className="flex items-center justify-between p-4 bg-white border border-gray-200 rounded-xl hover:shadow-sm">
333
  <div className="flex items-center gap-4">
334
  <div className="bg-amber-100 text-amber-700 font-bold px-3 py-1 rounded-lg border border-amber-200">
@@ -350,7 +399,7 @@ export const AchievementTeacher: React.FC<{className?: string}> = ({ className }
350
  </div>
351
  )}
352
 
353
- {/* 4. Balance List (with Proxy Exchange) */}
354
  {activeTab === 'balance' && (
355
  <div className="overflow-x-auto">
356
  <table className="w-full text-left border-collapse">
@@ -358,7 +407,7 @@ export const AchievementTeacher: React.FC<{className?: string}> = ({ className }
358
  <tr>
359
  <th className="p-4">学生姓名</th>
360
  <th className="p-4">小红花余额</th>
361
- <th className="p-4 text-right">操作 (代兑换)</th>
362
  </tr>
363
  </thead>
364
  <tbody className="divide-y divide-gray-100">
@@ -374,7 +423,7 @@ export const AchievementTeacher: React.FC<{className?: string}> = ({ className }
374
  </td>
375
  <td className="p-4 text-right">
376
  <div className="flex justify-end gap-2">
377
- {safeConfig.exchangeRules.length > 0 ? safeConfig.exchangeRules.map(r => (
378
  <button
379
  key={r.id}
380
  disabled={(s.flowerBalance || 0) < r.cost}
@@ -384,7 +433,7 @@ export const AchievementTeacher: React.FC<{className?: string}> = ({ className }
384
  >
385
  兑换 {r.rewardName} (-{r.cost})
386
  </button>
387
- )) : <span className="text-gray-300 text-xs">无兑换规则</span>}
388
  </div>
389
  </td>
390
  </tr>
 
1
 
2
  import React, { useState, useEffect } from 'react';
3
  import { api } from '../services/api';
4
+ import { AchievementConfig, AchievementItem, ExchangeRule, Student, TeacherExchangeConfig } from '../types';
5
+ import { Plus, Trash2, Edit, Save, Gift, Award, Coins, Users, Search, Loader2, CheckCircle, Filter } from 'lucide-react';
6
  import { Emoji } from '../components/Emoji';
7
 
8
  const PRESET_ICONS = [
 
16
  export const AchievementTeacher: React.FC<{className?: string}> = ({ className }) => {
17
  const [loading, setLoading] = useState(true);
18
  const [config, setConfig] = useState<AchievementConfig | null>(null);
19
+ const [myExchangeConfig, setMyExchangeConfig] = useState<TeacherExchangeConfig | null>(null);
20
  const [students, setStudents] = useState<Student[]>([]);
21
  const [semester, setSemester] = useState('');
22
 
 
27
  const [newAchieve, setNewAchieve] = useState<AchievementItem>({ id: '', name: '', icon: '🏆', points: 1 });
28
  const [newRule, setNewRule] = useState<ExchangeRule>({ id: '', cost: 10, rewardType: 'DRAW_COUNT', rewardName: '抽奖券x1', rewardValue: 1 });
29
 
30
+ // Filters
31
+ const [filterCreator, setFilterCreator] = useState('ALL'); // For Manage
32
+ const [filterGrantCreator, setFilterGrantCreator] = useState('ALL'); // For Grant
33
+
34
  // Grant State
35
  const [selectedStudents, setSelectedStudents] = useState<Set<string>>(new Set());
36
  const [selectedAchieveId, setSelectedAchieveId] = useState('');
37
 
38
  const currentUser = api.auth.getCurrentUser();
 
39
  const homeroomClass = className || currentUser?.homeroomClass;
40
+
41
+ // Permissions
42
+ const isHomeroom = currentUser?.homeroomClass === homeroomClass; // Is this the HT?
43
+ const isPrincipal = currentUser?.role === 'PRINCIPAL';
44
+ const canManageAll = isHomeroom || isPrincipal; // HT has full control
45
 
46
  useEffect(() => {
47
  loadData();
48
+ }, [homeroomClass]);
49
 
50
  const loadData = async () => {
51
  if (!homeroomClass) return;
52
  setLoading(true);
53
 
 
54
  try {
55
+ // 1. Students
56
  const stus = await api.students.getAll();
 
57
  const sortedStudents = stus
58
  .filter((s: Student) => s.className.trim() === homeroomClass.trim())
59
  .sort((a: Student, b: Student) => {
 
63
  return a.name.localeCompare(b.name, 'zh-CN');
64
  });
65
  setStudents(sortedStudents);
 
 
 
 
 
 
 
 
 
66
 
67
+ // 2. Shared Config (Achievements) & System
68
+ const [cfg, sysCfg, myRules] = await Promise.all([
69
+ api.achievements.getConfig(homeroomClass).catch(() => null),
70
+ api.config.getPublic().catch(() => ({ semester: '当前学期' })),
71
+ api.achievements.getMyRules().catch(() => ({ rules: [] })) // Fetch Teacher's OWN rules
72
+ ]);
73
 
 
74
  const defaultConfig: AchievementConfig = {
75
  schoolId: currentUser?.schoolId || '',
76
  className: homeroomClass,
77
  achievements: [],
78
+ exchangeRules: [] // Legacy, ignored for display now
79
  };
80
 
 
81
  setConfig(cfg || defaultConfig);
82
+ setMyExchangeConfig(myRules); // Set independent teacher rules
83
 
84
  // @ts-ignore
85
  setSemester(sysCfg?.semester || '当前学期');
86
 
87
  } catch (e) {
88
+ console.error("Failed to load data", e);
89
+ } finally { setLoading(false); }
 
 
 
 
 
 
 
 
90
  };
91
 
92
+ // --- Shared Achievement Config ---
93
  const handleSaveConfig = async (newConfig: AchievementConfig) => {
 
94
  setConfig(newConfig);
95
  try {
96
  await api.achievements.saveConfig(newConfig);
97
  } catch (e) {
98
+ alert('保存失败'); loadData();
 
99
  }
100
  };
101
 
102
+ // --- Independent Teacher Rules ---
103
+ const handleSaveMyRules = async (newRules: ExchangeRule[]) => {
104
+ const payload = {
105
+ ...myExchangeConfig,
106
+ rules: newRules
107
+ } as TeacherExchangeConfig;
108
+
109
+ setMyExchangeConfig(payload); // Optimistic
110
+ try {
111
+ await api.achievements.saveMyRules(payload);
112
+ } catch(e) { alert('保存失败'); loadData(); }
113
+ };
114
+
115
+ // --- Manage Achievements ---
116
  const addAchievement = () => {
117
  if (!config) return;
118
  if (!newAchieve.name) return alert('请输入成就名称');
119
 
120
+ const newItem: AchievementItem = {
121
+ ...newAchieve,
122
+ id: Date.now().toString(),
123
+ addedBy: currentUser?._id,
124
+ addedByName: currentUser?.trueName || currentUser?.username
125
+ };
126
  const updated = { ...config, achievements: [...config.achievements, newItem] };
127
 
128
  handleSaveConfig(updated);
129
  setNewAchieve({ id: '', name: '', icon: '🏆', points: 1 });
130
  };
131
 
132
+ const deleteAchievement = (item: AchievementItem) => {
133
+ if (!config) return;
134
+ // Permission Check: HT/Principal can delete all. Teacher can only delete own.
135
+ if (!canManageAll && item.addedBy !== currentUser?._id) return alert('权限不足:只能删除自己添加的成就');
136
+
137
+ if (!confirm('确认删除?')) return;
138
+ const updated = { ...config, achievements: config.achievements.filter(a => a.id !== item.id) };
139
  handleSaveConfig(updated);
140
  };
141
 
142
+ // --- Grant ---
143
  const handleGrant = async () => {
144
  if (selectedStudents.size === 0 || !selectedAchieveId) return alert('请选择学生和奖状');
145
  try {
 
149
  await Promise.all(promises);
150
  alert(`成功发放给 ${selectedStudents.size} 位学生`);
151
  setSelectedStudents(new Set());
152
+ loadData(); // Refresh
153
  } catch (e) { alert('部分发放失败'); }
154
  };
155
 
156
+ // --- Independent Rules ---
157
  const addRule = () => {
 
158
  if (!newRule.rewardName) return alert('请输入奖励名称');
 
159
  const newItem = { ...newRule, id: Date.now().toString() };
160
+ const currentRules = myExchangeConfig?.rules || [];
161
+ handleSaveMyRules([...currentRules, newItem]);
 
162
  setNewRule({ id: '', cost: 10, rewardType: 'DRAW_COUNT', rewardName: '抽奖券x1', rewardValue: 1 });
163
  };
164
 
165
  const deleteRule = (id: string) => {
166
+ if (!confirm('确认删除?')) return;
167
+ const currentRules = myExchangeConfig?.rules || [];
168
+ handleSaveMyRules(currentRules.filter(r => r.id !== id));
169
  };
170
 
171
+ // Proxy Exchange (Using current teacher's rules)
172
  const handleProxyExchange = async (studentId: string, ruleId: string) => {
173
  if (!confirm('确认代学生兑换?将扣除对应小红花并记录。')) return;
174
  try {
175
+ await api.achievements.exchange({ studentId, ruleId, teacherId: currentUser?._id });
176
  alert('兑换成功');
177
+ loadData();
178
  } catch (e: any) { alert(e.message || '兑换失败,余额不足?'); }
179
  };
180
 
181
  if (!homeroomClass) return <div className="p-10 text-center text-gray-500">您不是班主任,无法使用成就管理功能。</div>;
182
  if (loading) return <div className="flex justify-center p-10"><Loader2 className="animate-spin text-blue-600"/></div>;
183
 
 
184
  const safeConfig = config || { achievements: [], exchangeRules: [] };
185
+ const myRules = myExchangeConfig?.rules || [];
186
+
187
+ // Filter Logic
188
+ const uniqueCreators = Array.from(new Set(safeConfig.achievements.map(a => a.addedByName || '未知'))).filter(Boolean);
189
+ const filteredAchievements = safeConfig.achievements.filter(a => filterCreator === 'ALL' || a.addedByName === filterCreator);
190
+ const filteredGrantAchievements = safeConfig.achievements.filter(a => filterGrantCreator === 'ALL' || a.addedByName === filterGrantCreator);
191
 
192
  return (
193
  <div className="h-full flex flex-col bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
 
200
  <Gift size={18}/> 发放成就
201
  </button>
202
  <button onClick={() => setActiveTab('exchange')} className={`px-6 py-4 text-sm font-bold border-b-2 transition-colors flex items-center gap-2 whitespace-nowrap ${activeTab === 'exchange' ? 'border-blue-500 text-blue-600 bg-white' : 'border-transparent text-gray-500 hover:text-gray-700'}`}>
203
+ <Coins size={18}/> 兑换规则 (我的)
204
  </button>
205
  <button onClick={() => setActiveTab('balance')} className={`px-6 py-4 text-sm font-bold border-b-2 transition-colors flex items-center gap-2 whitespace-nowrap ${activeTab === 'balance' ? 'border-blue-500 text-blue-600 bg-white' : 'border-transparent text-gray-500 hover:text-gray-700'}`}>
206
  <Users size={18}/> 学生积分
 
211
  {/* 1. Manage Achievements */}
212
  {activeTab === 'manage' && (
213
  <div className="space-y-6 max-w-4xl mx-auto">
214
+ {/* Creator Filter */}
215
+ <div className="flex justify-end items-center gap-2">
216
+ <span className="text-xs font-bold text-gray-500"><Filter size={14} className="inline mr-1"/>筛选添加者:</span>
217
+ <select className="border rounded text-xs p-1" value={filterCreator} onChange={e=>setFilterCreator(e.target.value)}>
218
+ <option value="ALL">全部老师</option>
219
+ {uniqueCreators.map(c => <option key={c} value={c}>{c}</option>)}
220
+ </select>
221
+ </div>
222
+
223
  <div className="bg-blue-50 p-4 rounded-xl border border-blue-100 flex flex-col md:flex-row gap-4 items-end">
224
  <div className="flex-1 w-full">
225
  <label className="text-xs font-bold text-gray-500 uppercase mb-1 block">成就名称</label>
 
238
  <button onClick={addAchievement} className="bg-blue-600 text-white px-4 py-2 rounded-lg font-bold hover:bg-blue-700 whitespace-nowrap text-sm">添加成就</button>
239
  </div>
240
 
241
+ {filteredAchievements.length === 0 ? (
242
  <div className="text-center text-gray-400 py-10">暂无成就,请添加</div>
243
  ) : (
244
  <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
245
+ {filteredAchievements.map(ach => {
246
+ const isMine = ach.addedBy === currentUser?._id;
247
+ const canDelete = canManageAll || isMine;
248
+ return (
249
  <div key={ach.id} className="border border-gray-200 rounded-xl p-4 flex flex-col items-center relative group hover:shadow-md transition-all bg-white">
250
  <div className="text-4xl mb-2"><Emoji symbol={ach.icon} /></div>
251
  <div className="font-bold text-gray-800 text-center">{ach.name}</div>
252
  <div className="text-xs text-amber-600 font-bold bg-amber-50 px-2 py-0.5 rounded-full mt-1 border border-amber-100">
253
  {ach.points} <Emoji symbol="🌺" size={14}/>
254
  </div>
255
+ <div className="mt-2 text-[10px] text-gray-400 bg-gray-50 px-1.5 py-0.5 rounded">
256
+ {ach.addedByName || '未知'}
257
+ </div>
258
+ {canDelete && (
259
+ <button onClick={() => deleteAchievement(ach)} className="absolute top-2 right-2 text-gray-300 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-opacity">
260
+ <Trash2 size={16}/>
261
+ </button>
262
+ )}
263
  </div>
264
+ )})}
265
  </div>
266
  )}
267
  </div>
 
302
 
303
  {/* Achievement Selector */}
304
  <div className="flex-1 flex flex-col">
305
+ <div className="flex justify-between items-center mb-4">
306
+ <h3 className="font-bold text-gray-700">2. 选择要颁发的奖状</h3>
307
+ <div className="flex items-center gap-2">
308
+ <span className="text-xs text-gray-500">来自:</span>
309
+ <select className="border rounded text-xs p-1" value={filterGrantCreator} onChange={e=>setFilterGrantCreator(e.target.value)}>
310
+ <option value="ALL">全部老师</option>
311
+ {uniqueCreators.map(c => <option key={c} value={c}>{c}</option>)}
312
+ </select>
313
+ </div>
314
+ </div>
315
+
316
+ {filteredGrantAchievements.length === 0 ? (
317
  <div className="text-gray-400 text-sm mb-6">暂无成就,请先去“成就库管理”添加。</div>
318
  ) : (
319
  <div className="grid grid-cols-2 sm:grid-cols-3 gap-3 mb-6 max-h-[400px] overflow-y-auto custom-scrollbar p-1">
320
+ {filteredGrantAchievements.map(ach => (
321
  <div
322
  key={ach.id}
323
  onClick={() => setSelectedAchieveId(ach.id)}
 
344
  </div>
345
  )}
346
 
347
+ {/* 3. Independent Teacher Exchange Rules */}
348
  {activeTab === 'exchange' && (
349
  <div className="space-y-6 max-w-4xl mx-auto">
350
+ <div className="bg-purple-50 p-4 rounded-lg border border-purple-100 text-sm text-purple-800 mb-4">
351
+ <span className="font-bold">提示:</span> 这里的兑换规则是您个人专属的,对您教的所有班级学生可见。学生兑换后,只有您能看到待处理的申请。
352
+ </div>
353
+
354
  <div className="bg-green-50 p-4 rounded-xl border border-green-100 flex flex-col md:flex-row gap-4 items-end">
355
  <div className="w-full md:w-32">
356
  <label className="text-xs font-bold text-gray-500 uppercase mb-1 block">消耗小红花</label>
 
375
  </div>
376
 
377
  <div className="space-y-3">
378
+ {myRules.length === 0 ? (
379
+ <div className="text-center text-gray-400 py-4">暂无个人兑换规则</div>
380
+ ) : myRules.map(rule => (
381
  <div key={rule.id} className="flex items-center justify-between p-4 bg-white border border-gray-200 rounded-xl hover:shadow-sm">
382
  <div className="flex items-center gap-4">
383
  <div className="bg-amber-100 text-amber-700 font-bold px-3 py-1 rounded-lg border border-amber-200">
 
399
  </div>
400
  )}
401
 
402
+ {/* 4. Balance List (Only showing MY Rules for Proxy) */}
403
  {activeTab === 'balance' && (
404
  <div className="overflow-x-auto">
405
  <table className="w-full text-left border-collapse">
 
407
  <tr>
408
  <th className="p-4">学生姓名</th>
409
  <th className="p-4">小红花余额</th>
410
+ <th className="p-4 text-right">操作 (使用我的规则兑换)</th>
411
  </tr>
412
  </thead>
413
  <tbody className="divide-y divide-gray-100">
 
423
  </td>
424
  <td className="p-4 text-right">
425
  <div className="flex justify-end gap-2">
426
+ {myRules.length > 0 ? myRules.map(r => (
427
  <button
428
  key={r.id}
429
  disabled={(s.flowerBalance || 0) < r.cost}
 
433
  >
434
  兑换 {r.rewardName} (-{r.cost})
435
  </button>
436
+ )) : <span className="text-gray-300 text-xs">您没有设置规则</span>}
437
  </div>
438
  </td>
439
  </tr>
pages/GameLucky.tsx CHANGED
@@ -2,14 +2,13 @@
2
  import React, { useState, useEffect, useRef, useMemo } from 'react';
3
  import { createPortal } from 'react-dom';
4
  import { api } from '../services/api';
5
- 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
- // ... (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'}`}>
@@ -28,118 +27,32 @@ const FlipCard = ({ index, prize, onFlip, isRevealed, activeIndex }: { index: nu
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 }: {
35
- config: LuckyDrawConfig,
36
- isSpinning: boolean,
37
- result: {prize: string} | null,
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,
44
- weight: p.probability,
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
- }
56
  return list;
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);
87
- return [x, y];
88
- };
89
-
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;
108
-
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>
126
- );
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>
143
  </div>
144
  </div>
145
  );
@@ -152,6 +65,11 @@ export const GameLucky: React.FC<{className?: string}> = ({ className }) => {
152
 
153
  const [students, setStudents] = useState<Student[]>([]);
154
  const [proxyStudentId, setProxyStudentId] = useState<string>('');
 
 
 
 
 
155
  const [isSettingsOpen, setIsSettingsOpen] = useState(false);
156
  const [isFullscreen, setIsFullscreen] = useState(false);
157
 
@@ -166,33 +84,34 @@ export const GameLucky: React.FC<{className?: string}> = ({ className }) => {
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
@@ -211,14 +130,22 @@ export const GameLucky: React.FC<{className?: string}> = ({ className }) => {
211
  setStudentInfo(null);
212
  }
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
 
224
  } catch (e) { console.error(e); }
@@ -264,31 +191,36 @@ export const GameLucky: React.FC<{className?: string}> = ({ className }) => {
264
  };
265
 
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 ${
281
- ((luckyConfig.cardCount || 9) <= 4 ? 'grid-cols-2 ' : 'grid-cols-3 ') +
282
- ((luckyConfig.cardCount || 9) <= 4 ? 'md:grid-cols-3' :
283
- (luckyConfig.cardCount || 9) <= 6 ? 'md:grid-cols-3 lg:grid-cols-4' :
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
 
@@ -304,6 +236,21 @@ export const GameLucky: React.FC<{className?: string}> = ({ className }) => {
304
  </div>
305
 
306
  <div className="p-4 md:p-6 flex-1 overflow-y-auto space-y-4 md:space-y-6">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
307
  <div className="bg-gradient-to-r from-amber-50 to-orange-50 p-4 rounded-xl border border-orange-100 text-center flex md:block justify-between items-center">
308
  <div className="text-left md:text-center">
309
  <p className="text-xs font-bold text-amber-700 uppercase tracking-wider mb-1 md:mb-2">当前抽奖人</p>
@@ -347,8 +294,8 @@ export const GameLucky: React.FC<{className?: string}> = ({ className }) => {
347
  </div>
348
  </div>
349
 
350
- {/* Settings Modal */}
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">
@@ -357,6 +304,7 @@ export const GameLucky: React.FC<{className?: string}> = ({ className }) => {
357
  </div>
358
 
359
  <div className="flex-1 overflow-y-auto p-6 bg-gray-50/50">
 
360
  <div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
361
  <div className="bg-white p-4 rounded-xl border shadow-sm">
362
  <label className="text-xs font-bold text-gray-500 uppercase block mb-2">每日抽奖上限</label>
@@ -433,9 +381,4 @@ export const GameLucky: React.FC<{className?: string}> = ({ className }) => {
433
  )}
434
  </div>
435
  );
436
-
437
- if (isFullscreen) {
438
- return createPortal(GameContent, document.body);
439
- }
440
- return GameContent;
441
  };
 
2
  import React, { useState, useEffect, useRef, useMemo } from 'react';
3
  import { createPortal } from 'react-dom';
4
  import { api } from '../services/api';
5
+ import { LuckyDrawConfig, Student, LuckyPrize, User } 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
+ // ... (Keep FlipCard and LuckyWheel Components Unchanged - omitted for brevity as they are same as before) ...
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
  return (
13
  <div className="relative w-full aspect-[3/4] cursor-pointer perspective-1000 group" onClick={() => !isRevealed && onFlip(index)}>
14
  <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'}`}>
 
27
  );
28
  };
29
 
 
30
  const WHEEL_COLORS = ['#ef4444', '#f97316', '#eab308', '#84cc16', '#22c55e', '#3b82f6', '#8b5cf6', '#ec4899'];
31
+ const LuckyWheel = ({ config, isSpinning, result, onSpin }: { config: LuckyDrawConfig, isSpinning: boolean, result: {prize: string} | null, onSpin: () => void }) => {
 
 
 
 
 
 
32
  const [rotation, setRotation] = useState(0);
33
  const segments = useMemo(() => {
34
+ const list = config.prizes.map((p, i) => ({ name: p.name, weight: p.probability, color: WHEEL_COLORS[i % WHEEL_COLORS.length], isConsolation: false }));
35
+ if ((config.consolationWeight || 0) > 0 || list.length === 0) { list.push({ name: config.defaultPrize || '再接再厉', weight: config.consolationWeight || 10, color: '#94a3b8', isConsolation: true }); }
 
 
 
 
 
 
 
 
 
 
 
 
36
  return list;
37
  }, [config]);
 
38
  const totalWeight = segments.reduce((acc, s) => acc + s.weight, 0);
39
  let currentAngle = 0;
40
+ const slices = segments.map(seg => { const angle = (seg.weight / totalWeight) * 360; const start = currentAngle; currentAngle += angle; const center = start + (angle / 2); return { ...seg, startAngle: start, endAngle: currentAngle, angle, centerAngle: center }; });
41
+ useEffect(() => { if (result && isSpinning) { const targetSlice = slices.find(s => s.name === result.prize) || slices[slices.length - 1]; const minSpins = 5; const spinAngle = minSpins * 360; const targetLandingAngle = 360 - targetSlice.centerAngle; const currentMod = rotation % 360; let distance = targetLandingAngle - currentMod; if (distance < 0) distance += 360; const jitter = (Math.random() - 0.5) * (targetSlice.angle * 0.8); const finalRotation = rotation + spinAngle + distance + jitter; setRotation(finalRotation); } }, [result, isSpinning]);
42
+ const getCoordinatesForPercent = (percent: number) => { const x = Math.cos(2 * Math.PI * percent); const y = Math.sin(2 * Math.PI * percent); return [x, y]; };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  return (
44
  <div className="flex flex-col items-center justify-center h-full w-full max-w-3xl mx-auto p-4">
45
  <div className="relative w-full aspect-square max-w-[500px] md:max-w-[600px]">
46
+ <div className="w-full h-full rounded-full border-8 border-yellow-400 shadow-2xl overflow-hidden relative bg-white" style={{ transform: `rotate(${rotation}deg)`, transition: isSpinning ? 'transform 4s cubic-bezier(0.15, 0.85, 0.35, 1)' : 'none' }}>
 
 
 
47
  <svg viewBox="-1 -1 2 2" style={{ transform: 'rotate(-90deg)' }} className="w-full h-full">
48
  {slices.map((slice, i) => {
49
+ const start = slice.startAngle / 360; const end = slice.endAngle / 360; const [startX, startY] = getCoordinatesForPercent(end); const [endX, endY] = getCoordinatesForPercent(start); const largeArcFlag = slice.angle > 180 ? 1 : 0; const pathData = `M 0 0 L ${startX} ${startY} A 1 1 0 ${largeArcFlag} 0 ${endX} ${endY} Z`; const isVertical = slice.angle < 45 || slice.name.length > 4; const midAngle = (slice.startAngle + slice.endAngle) / 2; const displayText = slice.name.length > 8 ? slice.name.substring(0,7)+'..' : slice.name;
50
+ return ( <g key={i}> <path d={pathData} fill={slice.color} stroke="white" strokeWidth="0.01" /> <g transform={`rotate(${midAngle})`}> {isVertical ? ( displayText.split('').map((char, idx) => { const spacing = 0.09; const totalHeight = displayText.length * spacing; const startR = 0.65 - (totalHeight / 2) + (spacing/2); const r = startR + idx * spacing; 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>; }) ) : ( <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> )} </g> </g> );
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  })}
52
  </svg>
53
  </div>
54
+ <div className="absolute inset-0 pointer-events-none z-20 flex justify-center"> <div className="relative w-10 md:w-14 h-[50%]"> <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))' }}> <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> <path d="M 20 0 L 40 300 L 0 300 Z" fill="url(#pointerGrad)" stroke="white" strokeWidth="2" /> </svg> </div> </div>
55
+ <div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-30 pointer-events-auto"> <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"> {isSpinning ? '...' : '抽奖'} </button> </div>
 
 
 
 
 
 
 
 
 
 
 
56
  </div>
57
  </div>
58
  );
 
65
 
66
  const [students, setStudents] = useState<Student[]>([]);
67
  const [proxyStudentId, setProxyStudentId] = useState<string>('');
68
+
69
+ // New: Student selects which teacher's pool to draw from
70
+ const [availableTeachers, setAvailableTeachers] = useState<User[]>([]);
71
+ const [selectedTeacherId, setSelectedTeacherId] = useState<string>('');
72
+
73
  const [isSettingsOpen, setIsSettingsOpen] = useState(false);
74
  const [isFullscreen, setIsFullscreen] = useState(false);
75
 
 
84
  const isTeacher = currentUser?.role === 'TEACHER';
85
  const isStudent = currentUser?.role === 'STUDENT';
86
 
 
87
  const targetClass = className || currentUser?.homeroomClass || (isStudent ? 'MY_CLASS' : '');
88
 
89
  useEffect(() => {
90
  loadData();
91
+ }, [proxyStudentId, targetClass, selectedTeacherId]);
92
 
93
  const loadData = async () => {
94
+ setLoading(true);
95
  let resolvedClass = targetClass;
 
 
 
 
 
 
 
 
96
 
97
  try {
98
  const allStus = await api.students.getAll();
99
+ let me: Student | undefined;
100
+
101
  if (isStudent && targetClass === 'MY_CLASS') {
102
+ me = allStus.find((s: Student) => s.name === (currentUser.trueName || currentUser.username));
103
  if(me) resolvedClass = me.className;
104
  }
105
 
106
+ // If Student, fetch available teachers for their class
107
+ if (isStudent && resolvedClass && availableTeachers.length === 0) {
108
+ const teachers = await api.users.getTeachersForClass(resolvedClass);
109
+ setAvailableTeachers(teachers);
110
+ if (teachers.length > 0 && !selectedTeacherId) {
111
+ setSelectedTeacherId(teachers[0]._id);
112
+ }
113
+ }
114
+
115
  if (isTeacher) {
116
  const filtered = allStus.filter((s: Student) => s.className === resolvedClass);
117
  // Sort by Seat No
 
130
  setStudentInfo(null);
131
  }
132
  } else if (isStudent) {
133
+ me = allStus.find((s: Student) => s.name === (currentUser.trueName || currentUser.username));
134
  setStudentInfo(me || null);
135
  }
136
 
137
+ // Load Config based on Teacher (Owner)
138
  if (resolvedClass) {
139
+ // If Student, use selectedTeacherId. If Teacher, use current User ID.
140
+ const ownerId = isTeacher ? currentUser._id : selectedTeacherId;
141
+ if (ownerId) {
142
+ const config = await api.games.getLuckyConfig(resolvedClass, ownerId);
143
+ if(!config.className) config.className = resolvedClass;
144
+ setLuckyConfig(config);
145
+ } else {
146
+ // No teacher selected yet
147
+ setLuckyConfig(null);
148
+ }
149
  }
150
 
151
  } catch (e) { console.error(e); }
 
191
  };
192
 
193
  if (loading) return <div className="h-full flex items-center justify-center"><Loader2 className="animate-spin text-blue-600"/></div>;
194
+
195
+ // Show message if no teacher selected for student
196
+ if (isStudent && availableTeachers.length === 0) return <div className="h-full flex items-center justify-center text-gray-400">暂无关联的老师开启抽奖</div>;
197
 
 
198
  const displayClassName = targetClass === 'MY_CLASS' ? (studentInfo?.className || '') : targetClass;
199
 
200
+ return (
201
  <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`}>
202
  <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">
203
  {isFullscreen ? <Minimize size={20} className="text-gray-700"/> : <Maximize size={20} className="text-gray-700"/>}
204
  </button>
205
 
206
  <div className="flex-1 overflow-y-auto p-4 md:p-8 custom-scrollbar order-1 md:order-1 flex flex-col justify-center">
207
+ {luckyConfig ? (
208
+ viewMode === 'CARD' ? (
209
+ <div className={`grid gap-2 md:gap-6 w-full max-w-5xl mx-auto transition-all place-content-center ${
210
+ ((luckyConfig.cardCount || 9) <= 4 ? 'grid-cols-2 ' : 'grid-cols-3 ') +
211
+ ((luckyConfig.cardCount || 9) <= 4 ? 'md:grid-cols-3' :
212
+ (luckyConfig.cardCount || 9) <= 6 ? 'md:grid-cols-3 lg:grid-cols-4' :
213
+ 'md:grid-cols-4 lg:grid-cols-5')
214
+ }`}>
215
+ {Array.from({ length: luckyConfig.cardCount || 9 }).map((_, i) => (
216
+ <FlipCard key={i} index={i} prize={drawResult ? drawResult.prize : '???'} onFlip={executeDraw} isRevealed={activeCardIndex === i && !!drawResult} activeIndex={activeCardIndex} />
217
+ ))}
218
+ </div>
219
+ ) : (
220
+ <LuckyWheel config={luckyConfig} isSpinning={isWheelSpinning} result={drawResult} onSpin={() => executeDraw()} />
221
+ )
222
  ) : (
223
+ <div className="text-center text-gray-400">该老师尚未配置奖池</div>
224
  )}
225
  </div>
226
 
 
236
  </div>
237
 
238
  <div className="p-4 md:p-6 flex-1 overflow-y-auto space-y-4 md:space-y-6">
239
+
240
+ {/* Student: Teacher Selector */}
241
+ {isStudent && (
242
+ <div>
243
+ <label className="text-xs font-bold text-gray-500 uppercase block mb-1">选择抽奖老师</label>
244
+ <select
245
+ className="w-full border border-gray-300 rounded-lg p-2 text-sm font-bold bg-white"
246
+ value={selectedTeacherId}
247
+ onChange={e => setSelectedTeacherId(e.target.value)}
248
+ >
249
+ {availableTeachers.map(t => <option key={t._id} value={t._id}>{t.trueName || t.username}的奖池</option>)}
250
+ </select>
251
+ </div>
252
+ )}
253
+
254
  <div className="bg-gradient-to-r from-amber-50 to-orange-50 p-4 rounded-xl border border-orange-100 text-center flex md:block justify-between items-center">
255
  <div className="text-left md:text-center">
256
  <p className="text-xs font-bold text-amber-700 uppercase tracking-wider mb-1 md:mb-2">当前抽奖人</p>
 
294
  </div>
295
  </div>
296
 
297
+ {/* Settings Modal (Teacher Only) */}
298
+ {isSettingsOpen && luckyConfig && (
299
  <div className="fixed inset-0 bg-black/60 z-[1000] flex items-center justify-center p-4 backdrop-blur-sm">
300
  <div className="bg-white rounded-2xl w-full max-w-4xl h-[90vh] flex flex-col shadow-2xl animate-in zoom-in-95">
301
  <div className="p-6 border-b border-gray-100 flex justify-between items-center">
 
304
  </div>
305
 
306
  <div className="flex-1 overflow-y-auto p-6 bg-gray-50/50">
307
+ {/* ... Existing Config Form ... */}
308
  <div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
309
  <div className="bg-white p-4 rounded-xl border shadow-sm">
310
  <label className="text-xs font-bold text-gray-500 uppercase block mb-2">每日抽奖上限</label>
 
381
  )}
382
  </div>
383
  );
 
 
 
 
 
384
  };
server.js CHANGED
@@ -1,30 +1,27 @@
1
 
2
- // ... imports
 
 
 
 
 
 
 
3
  const express = require('express');
4
  const mongoose = require('mongoose');
5
  const cors = require('cors');
6
  const bodyParser = require('body-parser');
7
  const path = require('path');
8
  const compression = require('compression');
9
- const {
10
- School, User, Student, Course, Score, ClassModel, SubjectModel, ExamModel, ScheduleModel,
11
- ConfigModel, NotificationModel, GameSessionModel, StudentRewardModel, LuckyDrawConfigModel, GameMonsterConfigModel, GameZenConfigModel,
12
- AchievementConfigModel, StudentAchievementModel, AttendanceModel, LeaveRequestModel, SchoolCalendarModel
13
- } = require('./models');
14
 
15
  // ... constants
16
  const PORT = 7860;
17
  const MONGO_URI = 'mongodb+srv://dv890a:db8822723@chatpro.gw3v0v7.mongodb.net/chatpro?retryWrites=true&w=majority&appName=chatpro&authSource=admin';
18
 
19
  const app = express();
20
-
21
- // PERFORMANCE 1: Enable Gzip Compression
22
  app.use(compression());
23
-
24
  app.use(cors());
25
  app.use(bodyParser.json({ limit: '10mb' }));
26
-
27
- // PERFORMANCE 2: Smart Caching Strategy
28
  app.use(express.static(path.join(__dirname, 'dist'), {
29
  setHeaders: (res, filePath) => {
30
  if (filePath.endsWith('.html')) {
@@ -35,37 +32,26 @@ app.use(express.static(path.join(__dirname, 'dist'), {
35
  }
36
  }));
37
 
38
- const InMemoryDB = {
39
- schools: [],
40
- users: [],
41
- // ... other mock data if needed, but we rely on Mongo mostly now
42
- isFallback: false
43
- };
44
-
45
  const connectDB = async () => {
46
  try {
47
  await mongoose.connect(MONGO_URI, { serverSelectionTimeoutMS: 30000 });
48
  console.log('✅ MongoDB 连接成功 (Real Data)');
49
  } catch (err) {
50
  console.error('❌ MongoDB 连接失败:', err.message);
51
- console.warn('⚠️ 启动内存数据库模式 (Limited functionality)');
52
  InMemoryDB.isFallback = true;
53
  }
54
  };
55
  connectDB();
56
 
57
- // ... Helpers ...
58
  const getQueryFilter = (req) => {
59
  const s = req.headers['x-school-id'];
60
  const role = req.headers['x-user-role'];
61
-
62
  if (role === 'PRINCIPAL') {
63
  if (!s) return { _id: null };
64
  return { schoolId: s };
65
  }
66
-
67
  if (!s) return {};
68
-
69
  return {
70
  $or: [
71
  { schoolId: s },
@@ -76,23 +62,19 @@ const getQueryFilter = (req) => {
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
96
  const year = now.getFullYear();
97
  if (month >= 8 || month === 1) {
98
  const startYear = month === 1 ? year - 1 : year;
@@ -109,7 +91,200 @@ const generateStudentNo = async () => {
109
  return `${year}${random}`;
110
  };
111
 
112
- // ... ROUTES ...
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
113
  app.get('/api/auth/me', async (req, res) => {
114
  const username = req.headers['x-user-username'];
115
  if (!username) return res.status(401).json({ error: 'Unauthorized' });
@@ -117,337 +292,148 @@ app.get('/api/auth/me', async (req, res) => {
117
  if (!user) return res.status(404).json({ error: 'User not found' });
118
  res.json(user);
119
  });
120
-
121
  app.post('/api/auth/update-profile', async (req, res) => {
122
  const { userId, trueName, phone, avatar, currentPassword, newPassword } = req.body;
123
-
124
  try {
125
  const user = await User.findById(userId);
126
  if (!user) return res.status(404).json({ error: 'User not found' });
127
-
128
- // If changing password, verify old one
129
  if (newPassword) {
130
- if (user.password !== currentPassword) {
131
- return res.status(401).json({ error: 'INVALID_PASSWORD', message: '旧密码错误' });
132
- }
133
  user.password = newPassword;
134
  }
135
-
136
  if (trueName) user.trueName = trueName;
137
  if (phone) user.phone = phone;
138
  if (avatar) user.avatar = avatar;
139
-
140
  await user.save();
141
-
142
- // If user is a student, sync profile data to Student collection
143
- if (user.role === 'STUDENT') {
144
- await Student.findOneAndUpdate(
145
- { studentNo: user.studentNo },
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);
155
- res.status(500).json({ error: e.message });
156
- }
157
  });
158
-
159
  app.post('/api/auth/register', async (req, res) => {
160
  const { role, username, password, schoolId, trueName, seatNo } = req.body;
161
  const className = req.body.className || req.body.homeroomClass;
162
-
163
  try {
164
  if (role === 'STUDENT') {
165
- if (!trueName || !className) {
166
- return res.status(400).json({ error: 'MISSING_FIELDS', message: '姓名和班级不能为空' });
167
- }
168
-
169
  const cleanName = trueName.trim();
170
  const cleanClass = className.trim();
171
-
172
- const existingProfile = await Student.findOne({
173
- schoolId,
174
- name: { $regex: new RegExp(`^${cleanName}$`, 'i') },
175
- className: cleanClass
176
- });
177
-
178
  let finalUsername = '';
179
-
180
  if (existingProfile) {
181
- if (existingProfile.studentNo && existingProfile.studentNo.length > 5) {
182
- finalUsername = existingProfile.studentNo;
183
- } else {
184
- finalUsername = await generateStudentNo();
185
- existingProfile.studentNo = finalUsername;
186
- await existingProfile.save();
187
- }
188
-
189
  const userExists = await User.findOne({ username: finalUsername, schoolId });
190
- if (userExists) {
191
- if (userExists.status === 'active') {
192
- return res.status(409).json({ error: 'ACCOUNT_EXISTS', message: '该学生账号已存在且激活,请直接登录。' });
193
- }
194
- if (userExists.status === 'pending') {
195
- return res.status(409).json({ error: 'ACCOUNT_PENDING', message: '该学生的注册申请正在审核中,请耐心等待。' });
196
- }
197
- }
198
- } else {
199
- finalUsername = await generateStudentNo();
200
- }
201
-
202
- await User.create({
203
- username: finalUsername,
204
- password,
205
- role: 'STUDENT',
206
- trueName: cleanName,
207
- schoolId,
208
- status: 'pending',
209
- homeroomClass: cleanClass,
210
- studentNo: finalUsername,
211
- seatNo: seatNo || '',
212
- parentName: req.body.parentName,
213
- parentPhone: req.body.parentPhone,
214
- address: req.body.address,
215
- idCard: req.body.idCard,
216
- gender: req.body.gender || 'Male',
217
- createTime: new Date()
218
- });
219
-
220
  return res.json({ username: finalUsername });
221
  }
222
-
223
  const existing = await User.findOne({ username });
224
  if (existing) return res.status(409).json({ error: 'USERNAME_EXISTS', message: '用户名已存在' });
225
-
226
  await User.create({...req.body, status: 'pending', createTime: new Date()});
227
  res.json({ username });
228
-
229
- } catch(e) {
230
- console.error(e);
231
- res.status(500).json({ error: e.message });
232
- }
233
  });
234
-
235
  app.get('/api/users', async (req, res) => {
236
  const filter = getQueryFilter(req);
237
- const requesterRole = req.headers['x-user-role'];
238
- if (requesterRole === 'PRINCIPAL') filter.role = { $ne: 'ADMIN' };
239
  if (req.query.role) filter.role = req.query.role;
240
  res.json(await User.find(filter).sort({ createTime: -1 }));
241
  });
242
-
243
  app.put('/api/users/:id', async (req, res) => {
244
  const userId = req.params.id;
245
  const updates = req.body;
246
- const requesterRole = req.headers['x-user-role'];
247
-
248
  try {
249
  const user = await User.findById(userId);
250
  if (!user) return res.status(404).json({ error: 'User not found' });
251
-
252
- if (requesterRole === 'PRINCIPAL') {
253
- if (user.schoolId !== req.headers['x-school-id']) return res.status(403).json({ error: 'Permission denied' });
254
- if (user.role === 'ADMIN' || updates.role === 'ADMIN') return res.status(403).json({ error: 'Cannot modify Admin users' });
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,
263
- studentNo: user.studentNo,
264
- seatNo: user.seatNo,
265
- name: user.trueName,
266
- className: user.homeroomClass,
267
- gender: user.gender || 'Male',
268
- parentName: user.parentName,
269
- parentPhone: user.parentPhone,
270
- address: user.address,
271
- idCard: user.idCard,
272
- status: 'Enrolled',
273
- birthday: '2015-01-01'
274
- };
275
- await Student.findOneAndUpdate(
276
- { studentNo: user.studentNo, schoolId: user.schoolId },
277
- { $set: profileData },
278
- { upsert: true, new: true }
279
- );
280
- }
281
  }
282
  await User.findByIdAndUpdate(userId, updates);
283
  res.json({});
284
- } catch (e) {
285
- console.error(e);
286
- res.status(500).json({ error: e.message });
287
- }
288
  });
289
-
290
  app.post('/api/users/class-application', async (req, res) => {
291
  const { userId, type, targetClass, action } = req.body;
292
  const userRole = req.headers['x-user-role'];
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'});
300
- await User.findByIdAndUpdate(userId, {
301
- classApplication: { type: type, targetClass: targetClass || '', status: 'PENDING' }
302
- });
303
- const typeText = type === 'CLAIM' ? '申请班主任' : '申请卸任';
304
- await NotificationModel.create({
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') {
313
  const user = await User.findById(userId);
314
  if (!user || !user.classApplication) return res.status(404).json({ error: 'Application not found' });
315
  const appType = user.classApplication.type;
316
  const appTarget = user.classApplication.targetClass;
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);
354
- await NotificationModel.create({ schoolId, targetUserId: userId, title: '申请已通过', content: `管理员已同意您的${appType === 'CLAIM' ? '任教' : '卸任'}申请。`, type: 'success' });
355
- } else {
356
- await User.findByIdAndUpdate(userId, { 'classApplication.status': 'REJECTED' });
357
- await NotificationModel.create({ schoolId, targetUserId: userId, title: '申请被拒绝', content: `管理员拒绝了您的${appType === 'CLAIM' ? '任教' : '卸任'}申请。`, type: 'error' });
358
- }
359
  return res.json({ success: true });
360
  }
361
  res.status(403).json({ error: 'Permission denied' });
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'];
370
- if (role !== 'ADMIN' && role !== 'PRINCIPAL') return res.status(403).json({ error: 'Permission denied' });
371
-
372
- const GRADE_MAP = {
373
- '一年级': '二年级', '二年级': '三年级', '三年级': '四年级', '四年级': '五年级', '五年级': '六年级', '六年级': '毕业',
374
- '初一': '初二', '七年级': '八年级', '初二': '初三', '八年级': '九年级', '初三': '毕业', '九年级': '毕业',
375
- '高一': '高二', '高二': '高三', '高三': '毕业'
376
- };
377
-
378
  const classes = await ClassModel.find(getQueryFilter(req));
379
  let promotedCount = 0;
380
-
381
  for (const cls of classes) {
382
  const currentGrade = cls.grade;
383
  const nextGrade = GRADE_MAP[currentGrade] || currentGrade;
384
  const suffix = cls.className;
385
-
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,33 +443,18 @@ 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
-
487
  if (userRole === 'STUDENT') {
488
  if (student.drawAttempts <= 0) return res.status(403).json({ error: '次数不足', message: '您的抽奖次数已用完' });
489
  const today = new Date().toISOString().split('T')[0];
@@ -499,33 +470,26 @@ app.post('/api/games/lucky-draw', async (req, res) => {
499
  student.drawAttempts -= 1;
500
  await student.save();
501
  }
502
-
503
  let totalWeight = consolationWeight;
504
  availablePrizes.forEach(p => totalWeight += (p.probability || 0));
505
  let random = Math.random() * totalWeight;
506
  let selectedPrize = defaultPrize;
507
  let rewardType = 'CONSOLATION';
508
  let matchedPrize = null;
509
-
510
  for (const p of availablePrizes) {
511
  random -= (p.probability || 0);
512
  if (random <= 0) { matchedPrize = p; break; }
513
  }
514
-
515
  if (matchedPrize) {
516
  selectedPrize = matchedPrize.name;
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);
@@ -543,8 +507,6 @@ app.post('/api/games/monster-config', async (req, res) => {
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);
@@ -562,47 +524,28 @@ app.post('/api/games/zen-config', async (req, res) => {
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');
@@ -619,7 +562,6 @@ app.get('/api/rewards', async (req, res) => {
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;
@@ -632,219 +574,68 @@ 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
- // ACHIEVEMENT ROUTES (New)
658
- app.get('/api/achievements/config', async (req, res) => {
659
- const { className } = req.query;
660
- const filter = getQueryFilter(req);
661
- if (className) filter.className = className;
662
- res.json(await AchievementConfigModel.findOne(filter));
663
- });
664
-
665
- app.post('/api/achievements/config', async (req, res) => {
666
- const data = injectSchoolId(req, req.body);
667
- await AchievementConfigModel.findOneAndUpdate(
668
- { className: data.className, ...getQueryFilter(req) },
669
- data,
670
- { upsert: true }
671
- );
672
- res.json({ success: true });
673
- });
674
-
675
- app.get('/api/achievements/student', async (req, res) => {
676
- const { studentId, semester } = req.query;
677
- const filter = { studentId };
678
- if (semester) filter.semester = semester;
679
- res.json(await StudentAchievementModel.find(filter).sort({ createTime: -1 }));
680
- });
681
-
682
- app.post('/api/achievements/grant', async (req, res) => {
683
- const { studentId, achievementId, semester } = req.body;
684
- const sId = req.headers['x-school-id'];
685
-
686
- const student = await Student.findById(studentId);
687
- if (!student) return res.status(404).json({ error: 'Student not found' });
688
-
689
- const config = await AchievementConfigModel.findOne({ className: student.className, schoolId: sId });
690
- const achievement = config?.achievements.find(a => a.id === achievementId);
691
-
692
- if (!achievement) return res.status(404).json({ error: 'Achievement not found' });
693
-
694
- // Add Record
695
- await StudentAchievementModel.create({
696
- schoolId: sId,
697
- studentId,
698
- studentName: student.name,
699
- achievementId: achievement.id,
700
- achievementName: achievement.name,
701
- achievementIcon: achievement.icon,
702
- semester,
703
- createTime: new Date()
704
- });
705
-
706
- // Add Points (Flowers)
707
- await Student.findByIdAndUpdate(studentId, { $inc: { flowerBalance: achievement.points } });
708
-
709
- res.json({ success: true });
710
- });
711
-
712
- app.post('/api/achievements/exchange', async (req, res) => {
713
- const { studentId, ruleId } = req.body;
714
- const sId = req.headers['x-school-id'];
715
-
716
- const student = await Student.findById(studentId);
717
- if (!student) return res.status(404).json({ error: 'Student not found' });
718
-
719
- const config = await AchievementConfigModel.findOne({ className: student.className, schoolId: sId });
720
- const rule = config?.exchangeRules.find(r => r.id === ruleId);
721
-
722
- if (!rule) return res.status(404).json({ error: 'Rule not found' });
723
-
724
- if (student.flowerBalance < rule.cost) {
725
- return res.status(400).json({ error: 'INSUFFICIENT_FUNDS', message: '小红花余额不足' });
726
- }
727
-
728
- // Deduct Flowers
729
- await Student.findByIdAndUpdate(studentId, { $inc: { flowerBalance: -rule.cost } });
730
-
731
- // Add Reward Record (using StudentRewardModel so it appears in "GameRewards" / "Shop History")
732
- await StudentRewardModel.create({
733
- schoolId: sId,
734
- studentId,
735
- studentName: student.name,
736
- rewardType: rule.rewardType, // ITEM or DRAW_COUNT
737
- name: rule.rewardName,
738
- count: rule.rewardValue,
739
- status: rule.rewardType === 'DRAW_COUNT' ? 'REDEEMED' : 'PENDING',
740
- source: '积分兑换',
741
- createTime: new Date()
742
- });
743
-
744
- // If Draw Count, add attempts immediately
745
- if (rule.rewardType === 'DRAW_COUNT') {
746
- await Student.findByIdAndUpdate(studentId, { $inc: { drawAttempts: rule.rewardValue } });
747
- }
748
-
749
- res.json({ success: true });
750
- });
751
-
752
- // Update Class (Support multi-teachers)
753
  app.put('/api/classes/:id', async (req, res) => {
754
  const classId = req.params.id;
755
  const { grade, className, teacherName, homeroomTeacherIds } = req.body;
756
  const sId = req.headers['x-school-id'];
757
  const oldClass = await ClassModel.findById(classId);
758
-
759
  if (!oldClass) return res.status(404).json({ error: 'Class not found' });
760
  const newFullClass = grade + className;
761
  const oldFullClass = oldClass.grade + oldClass.className;
762
-
763
- // Logic: Sync User's `homeroomClass` field
764
- // 1. Remove from old teachers if class changed or teachers removed
765
  const oldTeacherIds = oldClass.homeroomTeacherIds || [];
766
  const newTeacherIds = homeroomTeacherIds || [];
767
-
768
  const removedIds = oldTeacherIds.filter(id => !newTeacherIds.includes(id));
769
- if (removedIds.length > 0) {
770
- await User.updateMany({ _id: { $in: removedIds }, schoolId: sId }, { homeroomClass: '' });
771
- }
772
-
773
- // 2. Add to new teachers
774
- if (newTeacherIds.length > 0) {
775
- await User.updateMany({ _id: { $in: newTeacherIds }, schoolId: sId }, { homeroomClass: newFullClass });
776
- }
777
-
778
- // 3. Update class name string
779
  let displayTeacherName = teacherName;
780
  if (newTeacherIds.length > 0) {
781
  const teachers = await User.find({ _id: { $in: newTeacherIds } });
782
  displayTeacherName = teachers.map(t => t.trueName || t.username).join(', ');
783
  }
784
-
785
  await ClassModel.findByIdAndUpdate(classId, { grade, className, teacherName: displayTeacherName, homeroomTeacherIds: newTeacherIds });
786
-
787
  if (oldFullClass !== newFullClass) {
788
  await Student.updateMany({ className: oldFullClass, schoolId: sId }, { className: newFullClass });
789
- // Also update users if class name changed
790
  await User.updateMany({ homeroomClass: oldFullClass, schoolId: sId }, { homeroomClass: newFullClass });
791
  }
792
  res.json({ success: true });
793
  });
794
-
795
- // Create Class
796
  app.post('/api/classes', async (req, res) => {
797
  const data = injectSchoolId(req, req.body);
798
  const { homeroomTeacherIds } = req.body;
799
-
800
- // Generate display name
801
  if (homeroomTeacherIds && homeroomTeacherIds.length > 0) {
802
  const teachers = await User.find({ _id: { $in: homeroomTeacherIds } });
803
  data.teacherName = teachers.map(t => t.trueName || t.username).join(', ');
804
  }
805
-
806
  await ClassModel.create(data);
807
-
808
- if (homeroomTeacherIds && homeroomTeacherIds.length > 0) {
809
- await User.updateMany({ _id: { $in: homeroomTeacherIds }, schoolId: data.schoolId }, { homeroomClass: data.grade + data.className });
810
- }
811
  res.json({});
812
  });
813
-
814
- // Update Courses to support className
815
  app.get('/api/courses', async (req, res) => {
816
  const filter = getQueryFilter(req);
817
- // Teachers only see their own courses usually, or we can filter by teacherId if passed
818
  if (req.query.teacherId) filter.teacherId = req.query.teacherId;
819
  res.json(await Course.find(filter));
820
  });
821
  app.post('/api/courses', async (req, res) => {
822
  const data = injectSchoolId(req, req.body);
823
- try {
824
- await Course.create(data);
825
- res.json({});
826
- } catch(e) {
827
- if (e.code === 11000) return res.status(409).json({ error: 'DUPLICATE', message: '该班级该科目已有任课老师' });
828
- res.status(500).json({ error: e.message });
829
- }
830
  });
831
-
832
- // ... (Rest of server.js) ...
833
  app.get('/api/public/schools', async (req, res) => { res.json(await School.find({}, 'name code _id')); });
834
  app.get('/api/public/config', async (req, res) => {
835
  const currentSem = getAutoSemester();
836
  let config = await ConfigModel.findOne({ key: 'main' });
837
  if (config) {
838
  let semesters = config.semesters || [];
839
- if (!semesters.includes(currentSem)) {
840
- semesters.unshift(currentSem);
841
- config.semesters = semesters;
842
- config.semester = currentSem;
843
- await ConfigModel.updateOne({ key: 'main' }, { semesters, semester: currentSem });
844
- }
845
- } else {
846
- config = { key: 'main', allowRegister: true, semester: currentSem, semesters: [currentSem] };
847
- }
848
  res.json(config);
849
  });
850
  app.get('/api/public/meta', async (req, res) => { res.json({ classes: await ClassModel.find({ schoolId: req.query.schoolId }), subjects: await SubjectModel.find({ schoolId: req.query.schoolId }) }); });
@@ -860,29 +651,7 @@ app.post('/api/schools', async (req, res) => { res.json(await School.create(req.
860
  app.put('/api/schools/:id', async (req, res) => { await School.findByIdAndUpdate(req.params.id, req.body); res.json({}); });
861
  app.delete('/api/schools/:id', async (req, res) => {
862
  const schoolId = req.params.id;
863
- try {
864
- await School.findByIdAndDelete(schoolId);
865
- await User.deleteMany({ schoolId });
866
- await Student.deleteMany({ schoolId });
867
- await ClassModel.deleteMany({ schoolId });
868
- await SubjectModel.deleteMany({ schoolId });
869
- await Course.deleteMany({ schoolId });
870
- await Score.deleteMany({ schoolId });
871
- await ExamModel.deleteMany({ schoolId });
872
- await ScheduleModel.deleteMany({ schoolId });
873
- await NotificationModel.deleteMany({ schoolId });
874
- await AttendanceModel.deleteMany({ schoolId });
875
- await LeaveRequestModel.deleteMany({ schoolId });
876
- await GameSessionModel.deleteMany({ schoolId });
877
- await StudentRewardModel.deleteMany({ schoolId });
878
- await LuckyDrawConfigModel.deleteMany({ schoolId });
879
- await GameMonsterConfigModel.deleteMany({ schoolId });
880
- await GameZenConfigModel.deleteMany({ schoolId });
881
- await AchievementConfigModel.deleteMany({ schoolId });
882
- await StudentAchievementModel.deleteMany({ schoolId });
883
- await SchoolCalendarModel.deleteMany({ schoolId });
884
- res.json({ success: true });
885
- } catch (e) { res.status(500).json({ error: e.message }); }
886
  });
887
  app.delete('/api/users/:id', async (req, res) => {
888
  const requesterRole = req.headers['x-user-role'];
@@ -891,8 +660,7 @@ app.delete('/api/users/:id', async (req, res) => {
891
  if (!user || user.schoolId !== req.headers['x-school-id']) return res.status(403).json({error: 'Permission denied'});
892
  if (user.role === 'ADMIN') return res.status(403).json({error: 'Cannot delete admin'});
893
  }
894
- await User.findByIdAndDelete(req.params.id);
895
- res.json({});
896
  });
897
  app.get('/api/students', async (req, res) => { res.json(await Student.find(getQueryFilter(req))); });
898
  app.post('/api/students', async (req, res) => {
@@ -900,19 +668,9 @@ app.post('/api/students', async (req, res) => {
900
  if (data.studentNo === '') delete data.studentNo;
901
  try {
902
  const existing = await Student.findOne({ schoolId: data.schoolId, name: data.name, className: data.className });
903
- if (existing) {
904
- Object.assign(existing, data);
905
- if (!existing.studentNo) { existing.studentNo = await generateStudentNo(); }
906
- await existing.save();
907
- } else {
908
- if (!data.studentNo) { data.studentNo = await generateStudentNo(); }
909
- await Student.create(data);
910
- }
911
  res.json({ success: true });
912
- } catch (e) {
913
- if (e.code === 11000) return res.status(409).json({ error: 'DUPLICATE', message: '保存失败:存在重复的系统ID' });
914
- res.status(500).json({ error: e.message });
915
- }
916
  });
917
  app.put('/api/students/:id', async (req, res) => { await Student.findByIdAndUpdate(req.params.id, req.body); res.json({}); });
918
  app.delete('/api/students/:id', async (req, res) => { await Student.findByIdAndDelete(req.params.id); res.json({}); });
@@ -928,8 +686,7 @@ app.get('/api/classes', async (req, res) => {
928
  app.delete('/api/classes/:id', async (req, res) => {
929
  const cls = await ClassModel.findById(req.params.id);
930
  if (cls && cls.homeroomTeacherIds) await User.updateMany({ _id: { $in: cls.homeroomTeacherIds }, schoolId: cls.schoolId }, { homeroomClass: '' });
931
- await ClassModel.findByIdAndDelete(req.params.id);
932
- res.json({});
933
  });
934
  app.get('/api/subjects', async (req, res) => { res.json(await SubjectModel.find(getQueryFilter(req))); });
935
  app.post('/api/subjects', async (req, res) => { await SubjectModel.create(injectSchoolId(req, req.body)); res.json({}); });
@@ -952,8 +709,7 @@ app.post('/api/schedules', async (req, res) => {
952
  const filter = { className:req.body.className, dayOfWeek:req.body.dayOfWeek, period:req.body.period };
953
  const sId = req.headers['x-school-id'];
954
  if(sId) filter.schoolId = sId;
955
- await ScheduleModel.findOneAndUpdate(filter, injectSchoolId(req, req.body), {upsert:true});
956
- res.json({});
957
  });
958
  app.delete('/api/schedules', async (req, res) => { await ScheduleModel.deleteOne({...getQueryFilter(req), ...req.query}); res.json({}); });
959
  app.get('/api/stats', async (req, res) => {
@@ -973,17 +729,7 @@ app.get('/api/stats', async (req, res) => {
973
  app.get('/api/config', async (req, res) => {
974
  const currentSem = getAutoSemester();
975
  let config = await ConfigModel.findOne({key:'main'});
976
- if (config) {
977
- let semesters = config.semesters || [];
978
- if (!semesters.includes(currentSem)) {
979
- semesters.unshift(currentSem);
980
- config.semesters = semesters;
981
- config.semester = currentSem;
982
- await ConfigModel.updateOne({ key: 'main' }, { semesters, semester: currentSem });
983
- }
984
- } else {
985
- config = { key: 'main', allowRegister: true, semester: currentSem, semesters: [currentSem] };
986
- }
987
  res.json(config);
988
  });
989
  app.post('/api/config', async (req, res) => { await ConfigModel.findOneAndUpdate({key:'main'}, req.body, {upsert:true}); res.json({}); });
@@ -996,8 +742,7 @@ app.delete('/api/rewards/:id', async (req, res) => {
996
  if (student && student.drawAttempts < reward.count) return res.status(400).json({ error: 'FAILED_REVOKE', message: '修改失败,次数已被使用' });
997
  await Student.findByIdAndUpdate(reward.studentId, { $inc: { drawAttempts: -reward.count } });
998
  }
999
- await StudentRewardModel.findByIdAndDelete(req.params.id);
1000
- res.json({});
1001
  });
1002
  app.post('/api/rewards/:id/redeem', async (req, res) => { await StudentRewardModel.findByIdAndUpdate(req.params.id, {status:'REDEEMED'}); res.json({}); });
1003
  app.get('/api/attendance', async (req, res) => {
@@ -1036,28 +781,14 @@ app.post('/api/leave', async (req, res) => {
1036
  if (student) await AttendanceModel.findOneAndUpdate({ studentId, date: startDate }, { schoolId: req.headers['x-school-id'], studentId, studentName: student.name, className: student.className, date: startDate, status: 'Leave', checkInTime: new Date() }, { upsert: true });
1037
  res.json({ success: true });
1038
  });
1039
- // CALENDAR ROUTES
1040
  app.get('/api/attendance/calendar', async (req, res) => {
1041
  const { className } = req.query;
1042
  const filter = getQueryFilter(req);
1043
- // Return both global holidays (className undefined or null) and class specific ones
1044
- const query = {
1045
- $and: [
1046
- filter,
1047
- { $or: [{ className: { $exists: false } }, { className: null }, { className }] }
1048
- ]
1049
- };
1050
  res.json(await SchoolCalendarModel.find(query));
1051
  });
1052
- app.post('/api/attendance/calendar', async (req, res) => {
1053
- await SchoolCalendarModel.create(injectSchoolId(req, req.body));
1054
- res.json({ success: true });
1055
- });
1056
- app.delete('/api/attendance/calendar/:id', async (req, res) => {
1057
- await SchoolCalendarModel.findByIdAndDelete(req.params.id);
1058
- res.json({ success: true });
1059
- });
1060
-
1061
  app.post('/api/batch-delete', async (req, res) => {
1062
  if(req.body.type==='student') await Student.deleteMany({_id:{$in:req.body.ids}});
1063
  if(req.body.type==='score') await Score.deleteMany({_id:{$in:req.body.ids}});
 
1
 
2
+ // ... existing imports
3
+ const {
4
+ School, User, Student, Course, Score, ClassModel, SubjectModel, ExamModel, ScheduleModel,
5
+ ConfigModel, NotificationModel, GameSessionModel, StudentRewardModel, LuckyDrawConfigModel, GameMonsterConfigModel, GameZenConfigModel,
6
+ AchievementConfigModel, TeacherExchangeConfigModel, StudentAchievementModel, AttendanceModel, LeaveRequestModel, SchoolCalendarModel
7
+ } = require('./models');
8
+
9
+ // ... (existing setup code, middleware, connectDB, helpers) ...
10
  const express = require('express');
11
  const mongoose = require('mongoose');
12
  const cors = require('cors');
13
  const bodyParser = require('body-parser');
14
  const path = require('path');
15
  const compression = require('compression');
 
 
 
 
 
16
 
17
  // ... constants
18
  const PORT = 7860;
19
  const MONGO_URI = 'mongodb+srv://dv890a:db8822723@chatpro.gw3v0v7.mongodb.net/chatpro?retryWrites=true&w=majority&appName=chatpro&authSource=admin';
20
 
21
  const app = express();
 
 
22
  app.use(compression());
 
23
  app.use(cors());
24
  app.use(bodyParser.json({ limit: '10mb' }));
 
 
25
  app.use(express.static(path.join(__dirname, 'dist'), {
26
  setHeaders: (res, filePath) => {
27
  if (filePath.endsWith('.html')) {
 
32
  }
33
  }));
34
 
35
+ const InMemoryDB = { schools: [], users: [], isFallback: false };
 
 
 
 
 
 
36
  const connectDB = async () => {
37
  try {
38
  await mongoose.connect(MONGO_URI, { serverSelectionTimeoutMS: 30000 });
39
  console.log('✅ MongoDB 连接成功 (Real Data)');
40
  } catch (err) {
41
  console.error('❌ MongoDB 连接失败:', err.message);
 
42
  InMemoryDB.isFallback = true;
43
  }
44
  };
45
  connectDB();
46
 
 
47
  const getQueryFilter = (req) => {
48
  const s = req.headers['x-school-id'];
49
  const role = req.headers['x-user-role'];
 
50
  if (role === 'PRINCIPAL') {
51
  if (!s) return { _id: null };
52
  return { schoolId: s };
53
  }
 
54
  if (!s) return {};
 
55
  return {
56
  $or: [
57
  { schoolId: s },
 
62
  };
63
  const injectSchoolId = (req, b) => ({ ...b, schoolId: req.headers['x-school-id'] });
64
 
 
65
  const getGameOwnerFilter = async (req) => {
66
  const role = req.headers['x-user-role'];
67
  const username = req.headers['x-user-username'];
 
68
  if (role === 'TEACHER') {
69
  const user = await User.findOne({ username });
70
  return { ownerId: user ? user._id.toString() : 'unknown' };
71
  }
 
 
72
  return {};
73
  };
74
 
75
  const getAutoSemester = () => {
76
  const now = new Date();
77
+ const month = now.getMonth() + 1;
78
  const year = now.getFullYear();
79
  if (month >= 8 || month === 1) {
80
  const startYear = month === 1 ? year - 1 : year;
 
91
  return `${year}${random}`;
92
  };
93
 
94
+ // ... (Existing Routes: Auth, Users, Students, Classes, etc.) ...
95
+ // Insert helper route for fetching teachers of a class
96
+ app.get('/api/classes/:className/teachers', async (req, res) => {
97
+ const { className } = req.params;
98
+ const schoolId = req.headers['x-school-id'];
99
+
100
+ // 1. Get Homeroom Teachers
101
+ const cls = await ClassModel.findOne({
102
+ $expr: { $eq: [{ $concat: ["$grade", "$className"] }, className] },
103
+ schoolId
104
+ });
105
+
106
+ let teacherIds = new Set(cls ? (cls.homeroomTeacherIds || []) : []);
107
+
108
+ // 2. Get Subject Teachers from Courses
109
+ const courses = await Course.find({ className, schoolId });
110
+ courses.forEach(c => {
111
+ if(c.teacherId) teacherIds.add(c.teacherId);
112
+ });
113
+
114
+ const teachers = await User.find({ _id: { $in: Array.from(teacherIds) } }, 'trueName username _id');
115
+ res.json(teachers);
116
+ });
117
+
118
+ // ... (Existing Routes until Games) ...
119
+
120
+ // GAMES: Lucky Config (Modified to support ownerId query)
121
+ app.get('/api/games/lucky-config', async (req, res) => {
122
+ const filter = getQueryFilter(req);
123
+ // If explicit ownerId passed (e.g. from student view), use it.
124
+ // Otherwise if Teacher, force own ID.
125
+ if (req.query.ownerId) {
126
+ filter.ownerId = req.query.ownerId;
127
+ } else {
128
+ const ownerFilter = await getGameOwnerFilter(req);
129
+ Object.assign(filter, ownerFilter);
130
+ }
131
+
132
+ if (req.query.className) filter.className = req.query.className;
133
+
134
+ const config = await LuckyDrawConfigModel.findOne(filter);
135
+ res.json(config || { prizes: [], dailyLimit: 3, cardCount: 9, defaultPrize: '再接再厉' });
136
+ });
137
+ // ... (Rest of games routes unchanged except achievement/exchange below) ...
138
+
139
+ // ACHIEVEMENT ROUTES
140
+ app.get('/api/achievements/config', async (req, res) => {
141
+ const { className } = req.query;
142
+ const filter = getQueryFilter(req);
143
+ if (className) filter.className = className;
144
+ res.json(await AchievementConfigModel.findOne(filter));
145
+ });
146
+
147
+ app.post('/api/achievements/config', async (req, res) => {
148
+ const data = injectSchoolId(req, req.body);
149
+ await AchievementConfigModel.findOneAndUpdate(
150
+ { className: data.className, ...getQueryFilter(req) },
151
+ data,
152
+ { upsert: true }
153
+ );
154
+ res.json({ success: true });
155
+ });
156
+
157
+ // NEW: Teacher Exchange Rules Routes
158
+ app.get('/api/achievements/teacher-rules', async (req, res) => {
159
+ const filter = getQueryFilter(req);
160
+ // If getting for specific teacher (e.g. student viewing shop), allow query param
161
+ // If no query param and user is teacher, return their own
162
+ if (req.query.teacherId) {
163
+ filter.teacherId = req.query.teacherId;
164
+ } else if (req.headers['x-user-role'] === 'TEACHER') {
165
+ const user = await User.findOne({ username: req.headers['x-user-username'] });
166
+ if (user) filter.teacherId = user._id.toString();
167
+ }
168
+
169
+ // Support getting MULTIPLE teachers (for student shop view)
170
+ if (req.query.teacherIds) {
171
+ const ids = req.query.teacherIds.split(',');
172
+ delete filter.teacherId;
173
+ filter.teacherId = { $in: ids };
174
+ const configs = await TeacherExchangeConfigModel.find(filter);
175
+ return res.json(configs);
176
+ }
177
+
178
+ const config = await TeacherExchangeConfigModel.findOne(filter);
179
+ res.json(config || { rules: [] });
180
+ });
181
+
182
+ app.post('/api/achievements/teacher-rules', async (req, res) => {
183
+ const data = injectSchoolId(req, req.body);
184
+ const user = await User.findOne({ username: req.headers['x-user-username'] });
185
+ if (!user) return res.status(404).json({ error: 'User not found' });
186
+
187
+ data.teacherId = user._id.toString();
188
+ data.teacherName = user.trueName || user.username;
189
+
190
+ await TeacherExchangeConfigModel.findOneAndUpdate(
191
+ { teacherId: data.teacherId, ...getQueryFilter(req) },
192
+ data,
193
+ { upsert: true }
194
+ );
195
+ res.json({ success: true });
196
+ });
197
+
198
+ app.get('/api/achievements/student', async (req, res) => {
199
+ const { studentId, semester } = req.query;
200
+ const filter = { studentId };
201
+ if (semester) filter.semester = semester;
202
+ res.json(await StudentAchievementModel.find(filter).sort({ createTime: -1 }));
203
+ });
204
+
205
+ app.post('/api/achievements/grant', async (req, res) => {
206
+ const { studentId, achievementId, semester } = req.body;
207
+ const sId = req.headers['x-school-id'];
208
+
209
+ const student = await Student.findById(studentId);
210
+ if (!student) return res.status(404).json({ error: 'Student not found' });
211
+
212
+ const config = await AchievementConfigModel.findOne({ className: student.className, schoolId: sId });
213
+ const achievement = config?.achievements.find(a => a.id === achievementId);
214
+
215
+ if (!achievement) return res.status(404).json({ error: 'Achievement not found' });
216
+
217
+ // Add Record
218
+ await StudentAchievementModel.create({
219
+ schoolId: sId,
220
+ studentId,
221
+ studentName: student.name,
222
+ achievementId: achievement.id,
223
+ achievementName: achievement.name,
224
+ achievementIcon: achievement.icon,
225
+ semester,
226
+ createTime: new Date()
227
+ });
228
+
229
+ // Add Points (Flowers)
230
+ await Student.findByIdAndUpdate(studentId, { $inc: { flowerBalance: achievement.points } });
231
+
232
+ res.json({ success: true });
233
+ });
234
+
235
+ app.post('/api/achievements/exchange', async (req, res) => {
236
+ const { studentId, ruleId, teacherId } = req.body; // Added teacherId to identify which rule set
237
+ const sId = req.headers['x-school-id'];
238
+
239
+ const student = await Student.findById(studentId);
240
+ if (!student) return res.status(404).json({ error: 'Student not found' });
241
+
242
+ // Find rule in Teacher Config first
243
+ let rule = null;
244
+ let ownerId = null; // To track who receives the redemption request
245
+
246
+ if (teacherId) {
247
+ const tConfig = await TeacherExchangeConfigModel.findOne({ teacherId, schoolId: sId });
248
+ rule = tConfig?.rules.find(r => r.id === ruleId);
249
+ ownerId = teacherId;
250
+ } else {
251
+ // Fallback for legacy class-based rules (if any remain)
252
+ const config = await AchievementConfigModel.findOne({ className: student.className, schoolId: sId });
253
+ rule = config?.exchangeRules?.find(r => r.id === ruleId);
254
+ }
255
+
256
+ if (!rule) return res.status(404).json({ error: 'Rule not found' });
257
+
258
+ if (student.flowerBalance < rule.cost) {
259
+ return res.status(400).json({ error: 'INSUFFICIENT_FUNDS', message: '小红花余额不足' });
260
+ }
261
+
262
+ // Deduct Flowers
263
+ await Student.findByIdAndUpdate(studentId, { $inc: { flowerBalance: -rule.cost } });
264
+
265
+ // Add Reward Record
266
+ await StudentRewardModel.create({
267
+ schoolId: sId,
268
+ studentId,
269
+ studentName: student.name,
270
+ rewardType: rule.rewardType, // ITEM or DRAW_COUNT
271
+ name: rule.rewardName,
272
+ count: rule.rewardValue,
273
+ status: rule.rewardType === 'DRAW_COUNT' ? 'REDEEMED' : 'PENDING',
274
+ source: '积分兑换',
275
+ ownerId: ownerId, // Important: Shows up in teacher's reward list
276
+ createTime: new Date()
277
+ });
278
+
279
+ // If Draw Count, add attempts immediately
280
+ if (rule.rewardType === 'DRAW_COUNT') {
281
+ await Student.findByIdAndUpdate(studentId, { $inc: { drawAttempts: rule.rewardValue } });
282
+ }
283
+
284
+ res.json({ success: true });
285
+ });
286
+
287
+ // ... (Rest of existing routes from server.js - copy exact content or use existing file)
288
  app.get('/api/auth/me', async (req, res) => {
289
  const username = req.headers['x-user-username'];
290
  if (!username) return res.status(401).json({ error: 'Unauthorized' });
 
292
  if (!user) return res.status(404).json({ error: 'User not found' });
293
  res.json(user);
294
  });
 
295
  app.post('/api/auth/update-profile', async (req, res) => {
296
  const { userId, trueName, phone, avatar, currentPassword, newPassword } = req.body;
 
297
  try {
298
  const user = await User.findById(userId);
299
  if (!user) return res.status(404).json({ error: 'User not found' });
 
 
300
  if (newPassword) {
301
+ if (user.password !== currentPassword) return res.status(401).json({ error: 'INVALID_PASSWORD', message: '旧密码错误' });
 
 
302
  user.password = newPassword;
303
  }
 
304
  if (trueName) user.trueName = trueName;
305
  if (phone) user.phone = phone;
306
  if (avatar) user.avatar = avatar;
 
307
  await user.save();
308
+ if (user.role === 'STUDENT') await Student.findOneAndUpdate({ studentNo: user.studentNo }, { name: user.trueName || user.username, phone: user.phone });
 
 
 
 
 
 
 
 
 
 
309
  res.json({ success: true, user });
310
+ } catch (e) { res.status(500).json({ error: e.message }); }
 
 
 
311
  });
 
312
  app.post('/api/auth/register', async (req, res) => {
313
  const { role, username, password, schoolId, trueName, seatNo } = req.body;
314
  const className = req.body.className || req.body.homeroomClass;
 
315
  try {
316
  if (role === 'STUDENT') {
317
+ if (!trueName || !className) return res.status(400).json({ error: 'MISSING_FIELDS', message: '姓名和班级不能为空' });
 
 
 
318
  const cleanName = trueName.trim();
319
  const cleanClass = className.trim();
320
+ const existingProfile = await Student.findOne({ schoolId, name: { $regex: new RegExp(`^${cleanName}$`, 'i') }, className: cleanClass });
 
 
 
 
 
 
321
  let finalUsername = '';
 
322
  if (existingProfile) {
323
+ if (existingProfile.studentNo && existingProfile.studentNo.length > 5) finalUsername = existingProfile.studentNo;
324
+ else { finalUsername = await generateStudentNo(); existingProfile.studentNo = finalUsername; await existingProfile.save(); }
 
 
 
 
 
 
325
  const userExists = await User.findOne({ username: finalUsername, schoolId });
326
+ if (userExists) return res.status(409).json({ error: userExists.status === 'active' ? 'ACCOUNT_EXISTS' : 'ACCOUNT_PENDING', message: '账号已存在' });
327
+ } else finalUsername = await generateStudentNo();
328
+ await User.create({ username: finalUsername, password, role: 'STUDENT', trueName: cleanName, schoolId, status: 'pending', homeroomClass: cleanClass, studentNo: finalUsername, seatNo: seatNo || '', parentName: req.body.parentName, parentPhone: req.body.parentPhone, address: req.body.address, idCard: req.body.idCard, gender: req.body.gender || 'Male', createTime: new Date() });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
329
  return res.json({ username: finalUsername });
330
  }
 
331
  const existing = await User.findOne({ username });
332
  if (existing) return res.status(409).json({ error: 'USERNAME_EXISTS', message: '用户名已存在' });
 
333
  await User.create({...req.body, status: 'pending', createTime: new Date()});
334
  res.json({ username });
335
+ } catch(e) { res.status(500).json({ error: e.message }); }
 
 
 
 
336
  });
 
337
  app.get('/api/users', async (req, res) => {
338
  const filter = getQueryFilter(req);
339
+ if (req.headers['x-user-role'] === 'PRINCIPAL') filter.role = { $ne: 'ADMIN' };
 
340
  if (req.query.role) filter.role = req.query.role;
341
  res.json(await User.find(filter).sort({ createTime: -1 }));
342
  });
 
343
  app.put('/api/users/:id', async (req, res) => {
344
  const userId = req.params.id;
345
  const updates = req.body;
 
 
346
  try {
347
  const user = await User.findById(userId);
348
  if (!user) return res.status(404).json({ error: 'User not found' });
349
+ if (user.status !== 'active' && updates.status === 'active' && user.role === 'STUDENT') {
350
+ await Student.findOneAndUpdate({ studentNo: user.studentNo, schoolId: user.schoolId }, { $set: { schoolId: user.schoolId, studentNo: user.studentNo, seatNo: user.seatNo, name: user.trueName, className: user.homeroomClass, gender: user.gender || 'Male', parentName: user.parentName, parentPhone: user.parentPhone, address: user.address, idCard: user.idCard, status: 'Enrolled', birthday: '2015-01-01' } }, { upsert: true, new: true });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
351
  }
352
  await User.findByIdAndUpdate(userId, updates);
353
  res.json({});
354
+ } catch (e) { res.status(500).json({ error: e.message }); }
 
 
 
355
  });
 
356
  app.post('/api/users/class-application', async (req, res) => {
357
  const { userId, type, targetClass, action } = req.body;
358
  const userRole = req.headers['x-user-role'];
359
  const schoolId = req.headers['x-school-id'];
 
360
  if (action === 'APPLY') {
 
361
  try {
362
  const user = await User.findById(userId);
363
  if(!user) return res.status(404).json({error:'User not found'});
364
+ await User.findByIdAndUpdate(userId, { classApplication: { type: type, targetClass: targetClass || '', status: 'PENDING' } });
365
+ await NotificationModel.create({ schoolId, targetRole: 'ADMIN', title: '新的班主任任免申请', content: `${user.trueName || user.username} 申请 ${type === 'CLAIM' ? '任教' : '卸任'},请及时处理。`, type: 'warning' });
 
 
 
 
 
 
366
  return res.json({ success: true });
367
  } catch (e) { return res.status(500).json({ error: e.message }); }
368
  }
 
369
  if (userRole === 'ADMIN' || userRole === 'PRINCIPAL') {
370
  const user = await User.findById(userId);
371
  if (!user || !user.classApplication) return res.status(404).json({ error: 'Application not found' });
372
  const appType = user.classApplication.type;
373
  const appTarget = user.classApplication.targetClass;
 
374
  if (action === 'APPROVE') {
375
  const updates = { classApplication: null };
376
  const classes = await ClassModel.find({ schoolId });
 
377
  if (appType === 'CLAIM') {
378
  updates.homeroomClass = appTarget;
379
  const matchedClass = classes.find(c => (c.grade + c.className) === appTarget);
380
  if (matchedClass) {
 
381
  const teacherIds = matchedClass.homeroomTeacherIds || [];
382
  if (!teacherIds.includes(userId)) {
383
  teacherIds.push(userId);
 
384
  const teachers = await User.find({ _id: { $in: teacherIds } });
385
  const names = teachers.map(t => t.trueName || t.username).join(', ');
386
+ await ClassModel.findByIdAndUpdate(matchedClass._id, { homeroomTeacherIds: teacherIds, teacherName: names });
 
 
 
387
  }
388
  }
389
  } else if (appType === 'RESIGN') {
390
  updates.homeroomClass = '';
391
  const matchedClass = classes.find(c => (c.grade + c.className) === user.homeroomClass);
392
  if (matchedClass) {
 
393
  const teacherIds = (matchedClass.homeroomTeacherIds || []).filter(id => id !== userId);
394
  const teachers = await User.find({ _id: { $in: teacherIds } });
395
  const names = teachers.map(t => t.trueName || t.username).join(', ');
396
+ await ClassModel.findByIdAndUpdate(matchedClass._id, { homeroomTeacherIds: teacherIds, teacherName: names });
 
 
 
397
  }
398
  }
399
  await User.findByIdAndUpdate(userId, updates);
400
+ } else await User.findByIdAndUpdate(userId, { 'classApplication.status': 'REJECTED' });
 
 
 
 
401
  return res.json({ success: true });
402
  }
403
  res.status(403).json({ error: 'Permission denied' });
404
  });
 
405
  app.post('/api/students/promote', async (req, res) => {
 
 
406
  const { teacherFollows } = req.body;
407
  const sId = req.headers['x-school-id'];
408
+ const GRADE_MAP = { '一年级': '二年级', '二年级': '三年级', '三年级': '四年级', '四年级': '五年级', '五年级': '六年级', '六年级': '毕业', '初一': '初二', '七年级': '八年级', '初二': '初三', '八年级': '九年级', '初三': '毕业', '九年级': '毕业', '高一': '高二', '高二': '高三', '高三': '毕业' };
 
 
 
 
 
 
 
 
409
  const classes = await ClassModel.find(getQueryFilter(req));
410
  let promotedCount = 0;
 
411
  for (const cls of classes) {
412
  const currentGrade = cls.grade;
413
  const nextGrade = GRADE_MAP[currentGrade] || currentGrade;
414
  const suffix = cls.className;
 
415
  if (nextGrade === '毕业') {
416
  const oldFullClass = cls.grade + cls.className;
417
  await Student.updateMany({ className: oldFullClass, ...getQueryFilter(req) }, { status: 'Graduated', className: '已毕业' });
418
+ if (teacherFollows && cls.homeroomTeacherIds?.length) { await User.updateMany({ _id: { $in: cls.homeroomTeacherIds }, schoolId: sId }, { homeroomClass: '' }); await ClassModel.findByIdAndUpdate(cls._id, { teacherName: '', homeroomTeacherIds: [] }); }
 
 
 
419
  } else {
420
  const oldFullClass = cls.grade + cls.className;
421
  const newFullClass = nextGrade + suffix;
422
+ await ClassModel.findOneAndUpdate({ grade: nextGrade, className: suffix, schoolId: sId }, { schoolId: sId, grade: nextGrade, className: suffix, teacherName: teacherFollows ? cls.teacherName : undefined, homeroomTeacherIds: teacherFollows ? cls.homeroomTeacherIds : [] }, { upsert: true });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
423
  const result = await Student.updateMany({ className: oldFullClass, status: 'Enrolled', ...getQueryFilter(req) }, { className: newFullClass });
424
  promotedCount += result.modifiedCount;
425
+ if (teacherFollows && cls.homeroomTeacherIds?.length) { await User.updateMany({ _id: { $in: cls.homeroomTeacherIds }, schoolId: sId, homeroomClass: oldFullClass }, { homeroomClass: newFullClass }); await ClassModel.findByIdAndUpdate(cls._id, { teacherName: '', homeroomTeacherIds: [] }); }
 
 
 
 
 
426
  }
427
  }
428
  res.json({ success: true, count: promotedCount });
429
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
430
  app.post('/api/games/lucky-config', async (req, res) => {
431
  const data = injectSchoolId(req, req.body);
 
432
  if (req.headers['x-user-role'] === 'TEACHER') {
433
  const user = await User.findOne({ username: req.headers['x-user-username'] });
434
  data.ownerId = user ? user._id.toString() : null;
435
  }
436
+ await LuckyDrawConfigModel.findOneAndUpdate({ className: data.className, ...getQueryFilter(req), ownerId: data.ownerId }, data, { upsert: true });
 
 
 
 
437
  res.json({ success: true });
438
  });
439
  app.post('/api/games/lucky-draw', async (req, res) => {
 
443
  try {
444
  const student = await Student.findById(studentId);
445
  if (!student) return res.status(404).json({ error: 'Student not found' });
 
 
 
 
 
 
 
446
  let configFilter = { className: student.className, schoolId };
447
  if (userRole === 'TEACHER') {
448
  const user = await User.findOne({ username: req.headers['x-user-username'] });
449
  configFilter.ownerId = user ? user._id.toString() : null;
 
 
 
450
  }
 
451
  const config = await LuckyDrawConfigModel.findOne(configFilter);
452
  const prizes = config?.prizes || [];
453
  const defaultPrize = config?.defaultPrize || '再接再厉';
454
  const dailyLimit = config?.dailyLimit || 3;
455
  const consolationWeight = config?.consolationWeight || 0;
 
 
 
456
  const availablePrizes = prizes.filter(p => (p.count === undefined || p.count > 0));
457
  if (availablePrizes.length === 0 && consolationWeight === 0) return res.status(400).json({ error: 'POOL_EMPTY', message: '奖品库存不足' });
 
458
  if (userRole === 'STUDENT') {
459
  if (student.drawAttempts <= 0) return res.status(403).json({ error: '次数不足', message: '您的抽奖次数已用完' });
460
  const today = new Date().toISOString().split('T')[0];
 
470
  student.drawAttempts -= 1;
471
  await student.save();
472
  }
 
473
  let totalWeight = consolationWeight;
474
  availablePrizes.forEach(p => totalWeight += (p.probability || 0));
475
  let random = Math.random() * totalWeight;
476
  let selectedPrize = defaultPrize;
477
  let rewardType = 'CONSOLATION';
478
  let matchedPrize = null;
 
479
  for (const p of availablePrizes) {
480
  random -= (p.probability || 0);
481
  if (random <= 0) { matchedPrize = p; break; }
482
  }
 
483
  if (matchedPrize) {
484
  selectedPrize = matchedPrize.name;
485
  rewardType = 'ITEM';
486
  if (config._id) await LuckyDrawConfigModel.updateOne({ _id: config._id, "prizes.id": matchedPrize.id }, { $inc: { "prizes.$.count": -1 } });
487
  }
 
 
488
  let ownerId = config?.ownerId;
489
  await StudentRewardModel.create({ schoolId, studentId, studentName: student.name, rewardType, name: selectedPrize, count: 1, status: 'PENDING', source: '幸运大抽奖', ownerId });
490
  res.json({ prize: selectedPrize, rewardType });
491
  } catch (e) { res.status(500).json({ error: e.message }); }
492
  });
 
 
493
  app.get('/api/games/monster-config', async (req, res) => {
494
  const filter = getQueryFilter(req);
495
  const ownerFilter = await getGameOwnerFilter(req);
 
507
  await GameMonsterConfigModel.findOneAndUpdate({ className: data.className, ...getQueryFilter(req), ownerId: data.ownerId }, data, { upsert: true });
508
  res.json({ success: true });
509
  });
 
 
510
  app.get('/api/games/zen-config', async (req, res) => {
511
  const filter = getQueryFilter(req);
512
  const ownerFilter = await getGameOwnerFilter(req);
 
524
  await GameZenConfigModel.findOneAndUpdate({ className: data.className, ...getQueryFilter(req), ownerId: data.ownerId }, data, { upsert: true });
525
  res.json({ success: true });
526
  });
527
+ app.get('/api/games/mountain', async (req, res) => { res.json(await GameSessionModel.findOne({...getQueryFilter(req), className: req.query.className})); });
 
 
 
 
 
528
  app.post('/api/games/mountain', async (req, res) => {
529
  const { className } = req.body;
530
  const sId = req.headers['x-school-id'];
531
  const role = req.headers['x-user-role'];
532
  const username = req.headers['x-user-username'];
 
 
533
  if (role === 'TEACHER') {
534
  const user = await User.findOne({ username });
535
  const cls = await ClassModel.findOne({ schoolId: sId, $expr: { $eq: [{ $concat: ["$grade", "$className"] }, className] } });
 
536
  if (!cls) return res.status(404).json({ error: 'Class not found' });
 
 
537
  const allowedIds = cls.homeroomTeacherIds || [];
538
+ if (!allowedIds.includes(user._id.toString())) return res.status(403).json({ error: 'PERMISSION_DENIED', message: '只有班主任可以操作登峰游戏' });
 
 
539
  }
540
+ await GameSessionModel.findOneAndUpdate({ className, ...getQueryFilter(req) }, injectSchoolId(req, req.body), {upsert:true});
 
 
 
541
  res.json({});
542
  });
 
 
543
  app.get('/api/rewards', async (req, res) => {
544
  const filter = getQueryFilter(req);
 
545
  if (req.headers['x-user-role'] === 'TEACHER') {
546
  const user = await User.findOne({ username: req.headers['x-user-username'] });
547
  if (user) filter.ownerId = user._id.toString();
548
  }
 
549
  if(req.query.studentId) filter.studentId = req.query.studentId;
550
  if (req.query.className) {
551
  const classStudents = await Student.find({ className: req.query.className, ...getQueryFilter(req) }, '_id');
 
562
  app.post('/api/rewards', async (req, res) => {
563
  const data = injectSchoolId(req, req.body);
564
  if (!data.count) data.count = 1;
 
565
  if (req.headers['x-user-role'] === 'TEACHER') {
566
  const user = await User.findOne({ username: req.headers['x-user-username'] });
567
  data.ownerId = user ? user._id.toString() : null;
 
574
  const { studentId, count, rewardType, name } = req.body;
575
  const finalCount = count || 1;
576
  const finalName = name || (rewardType === 'DRAW_COUNT' ? '抽奖券' : '奖品');
 
577
  let ownerId = null;
578
  if (req.headers['x-user-role'] === 'TEACHER') {
579
  const user = await User.findOne({ username: req.headers['x-user-username'] });
580
  ownerId = user ? user._id.toString() : null;
581
  }
 
582
  if (rewardType === 'DRAW_COUNT') await Student.findByIdAndUpdate(studentId, { $inc: { drawAttempts: finalCount } });
583
+ 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: '教师发放', ownerId });
 
 
 
 
 
 
 
 
 
 
584
  res.json({});
585
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
586
  app.put('/api/classes/:id', async (req, res) => {
587
  const classId = req.params.id;
588
  const { grade, className, teacherName, homeroomTeacherIds } = req.body;
589
  const sId = req.headers['x-school-id'];
590
  const oldClass = await ClassModel.findById(classId);
 
591
  if (!oldClass) return res.status(404).json({ error: 'Class not found' });
592
  const newFullClass = grade + className;
593
  const oldFullClass = oldClass.grade + oldClass.className;
 
 
 
594
  const oldTeacherIds = oldClass.homeroomTeacherIds || [];
595
  const newTeacherIds = homeroomTeacherIds || [];
 
596
  const removedIds = oldTeacherIds.filter(id => !newTeacherIds.includes(id));
597
+ if (removedIds.length > 0) await User.updateMany({ _id: { $in: removedIds }, schoolId: sId }, { homeroomClass: '' });
598
+ if (newTeacherIds.length > 0) await User.updateMany({ _id: { $in: newTeacherIds }, schoolId: sId }, { homeroomClass: newFullClass });
 
 
 
 
 
 
 
 
599
  let displayTeacherName = teacherName;
600
  if (newTeacherIds.length > 0) {
601
  const teachers = await User.find({ _id: { $in: newTeacherIds } });
602
  displayTeacherName = teachers.map(t => t.trueName || t.username).join(', ');
603
  }
 
604
  await ClassModel.findByIdAndUpdate(classId, { grade, className, teacherName: displayTeacherName, homeroomTeacherIds: newTeacherIds });
 
605
  if (oldFullClass !== newFullClass) {
606
  await Student.updateMany({ className: oldFullClass, schoolId: sId }, { className: newFullClass });
 
607
  await User.updateMany({ homeroomClass: oldFullClass, schoolId: sId }, { homeroomClass: newFullClass });
608
  }
609
  res.json({ success: true });
610
  });
 
 
611
  app.post('/api/classes', async (req, res) => {
612
  const data = injectSchoolId(req, req.body);
613
  const { homeroomTeacherIds } = req.body;
 
 
614
  if (homeroomTeacherIds && homeroomTeacherIds.length > 0) {
615
  const teachers = await User.find({ _id: { $in: homeroomTeacherIds } });
616
  data.teacherName = teachers.map(t => t.trueName || t.username).join(', ');
617
  }
 
618
  await ClassModel.create(data);
619
+ if (homeroomTeacherIds && homeroomTeacherIds.length > 0) await User.updateMany({ _id: { $in: homeroomTeacherIds }, schoolId: data.schoolId }, { homeroomClass: data.grade + data.className });
 
 
 
620
  res.json({});
621
  });
 
 
622
  app.get('/api/courses', async (req, res) => {
623
  const filter = getQueryFilter(req);
 
624
  if (req.query.teacherId) filter.teacherId = req.query.teacherId;
625
  res.json(await Course.find(filter));
626
  });
627
  app.post('/api/courses', async (req, res) => {
628
  const data = injectSchoolId(req, req.body);
629
+ try { await Course.create(data); res.json({}); } catch(e) { if (e.code === 11000) return res.status(409).json({ error: 'DUPLICATE', message: '该班级该科目已有任课老师' }); res.status(500).json({ error: e.message }); }
 
 
 
 
 
 
630
  });
 
 
631
  app.get('/api/public/schools', async (req, res) => { res.json(await School.find({}, 'name code _id')); });
632
  app.get('/api/public/config', async (req, res) => {
633
  const currentSem = getAutoSemester();
634
  let config = await ConfigModel.findOne({ key: 'main' });
635
  if (config) {
636
  let semesters = config.semesters || [];
637
+ if (!semesters.includes(currentSem)) { semesters.unshift(currentSem); config.semesters = semesters; config.semester = currentSem; await ConfigModel.updateOne({ key: 'main' }, { semesters, semester: currentSem }); }
638
+ } else { config = { key: 'main', allowRegister: true, semester: currentSem, semesters: [currentSem] }; }
 
 
 
 
 
 
 
639
  res.json(config);
640
  });
641
  app.get('/api/public/meta', async (req, res) => { res.json({ classes: await ClassModel.find({ schoolId: req.query.schoolId }), subjects: await SubjectModel.find({ schoolId: req.query.schoolId }) }); });
 
651
  app.put('/api/schools/:id', async (req, res) => { await School.findByIdAndUpdate(req.params.id, req.body); res.json({}); });
652
  app.delete('/api/schools/:id', async (req, res) => {
653
  const schoolId = req.params.id;
654
+ try { await School.findByIdAndDelete(schoolId); await User.deleteMany({ schoolId }); await Student.deleteMany({ schoolId }); await ClassModel.deleteMany({ schoolId }); await SubjectModel.deleteMany({ schoolId }); await Course.deleteMany({ schoolId }); await Score.deleteMany({ schoolId }); await ExamModel.deleteMany({ schoolId }); await ScheduleModel.deleteMany({ schoolId }); await NotificationModel.deleteMany({ schoolId }); await AttendanceModel.deleteMany({ schoolId }); await LeaveRequestModel.deleteMany({ schoolId }); await GameSessionModel.deleteMany({ schoolId }); await StudentRewardModel.deleteMany({ schoolId }); await LuckyDrawConfigModel.deleteMany({ schoolId }); await GameMonsterConfigModel.deleteMany({ schoolId }); await GameZenConfigModel.deleteMany({ schoolId }); await AchievementConfigModel.deleteMany({ schoolId }); await StudentAchievementModel.deleteMany({ schoolId }); await SchoolCalendarModel.deleteMany({ schoolId }); res.json({ success: true }); } catch (e) { res.status(500).json({ error: e.message }); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
655
  });
656
  app.delete('/api/users/:id', async (req, res) => {
657
  const requesterRole = req.headers['x-user-role'];
 
660
  if (!user || user.schoolId !== req.headers['x-school-id']) return res.status(403).json({error: 'Permission denied'});
661
  if (user.role === 'ADMIN') return res.status(403).json({error: 'Cannot delete admin'});
662
  }
663
+ await User.findByIdAndDelete(req.params.id); res.json({});
 
664
  });
665
  app.get('/api/students', async (req, res) => { res.json(await Student.find(getQueryFilter(req))); });
666
  app.post('/api/students', async (req, res) => {
 
668
  if (data.studentNo === '') delete data.studentNo;
669
  try {
670
  const existing = await Student.findOne({ schoolId: data.schoolId, name: data.name, className: data.className });
671
+ if (existing) { Object.assign(existing, data); if (!existing.studentNo) { existing.studentNo = await generateStudentNo(); } await existing.save(); } else { if (!data.studentNo) { data.studentNo = await generateStudentNo(); } await Student.create(data); }
 
 
 
 
 
 
 
672
  res.json({ success: true });
673
+ } catch (e) { if (e.code === 11000) return res.status(409).json({ error: 'DUPLICATE', message: '保存失败:存在重复的系统ID' }); res.status(500).json({ error: e.message }); }
 
 
 
674
  });
675
  app.put('/api/students/:id', async (req, res) => { await Student.findByIdAndUpdate(req.params.id, req.body); res.json({}); });
676
  app.delete('/api/students/:id', async (req, res) => { await Student.findByIdAndDelete(req.params.id); res.json({}); });
 
686
  app.delete('/api/classes/:id', async (req, res) => {
687
  const cls = await ClassModel.findById(req.params.id);
688
  if (cls && cls.homeroomTeacherIds) await User.updateMany({ _id: { $in: cls.homeroomTeacherIds }, schoolId: cls.schoolId }, { homeroomClass: '' });
689
+ await ClassModel.findByIdAndDelete(req.params.id); res.json({});
 
690
  });
691
  app.get('/api/subjects', async (req, res) => { res.json(await SubjectModel.find(getQueryFilter(req))); });
692
  app.post('/api/subjects', async (req, res) => { await SubjectModel.create(injectSchoolId(req, req.body)); res.json({}); });
 
709
  const filter = { className:req.body.className, dayOfWeek:req.body.dayOfWeek, period:req.body.period };
710
  const sId = req.headers['x-school-id'];
711
  if(sId) filter.schoolId = sId;
712
+ await ScheduleModel.findOneAndUpdate(filter, injectSchoolId(req, req.body), {upsert:true}); res.json({});
 
713
  });
714
  app.delete('/api/schedules', async (req, res) => { await ScheduleModel.deleteOne({...getQueryFilter(req), ...req.query}); res.json({}); });
715
  app.get('/api/stats', async (req, res) => {
 
729
  app.get('/api/config', async (req, res) => {
730
  const currentSem = getAutoSemester();
731
  let config = await ConfigModel.findOne({key:'main'});
732
+ if (config) { let semesters = config.semesters || []; if (!semesters.includes(currentSem)) { semesters.unshift(currentSem); config.semesters = semesters; config.semester = currentSem; await ConfigModel.updateOne({ key: 'main' }, { semesters, semester: currentSem }); } } else { config = { key: 'main', allowRegister: true, semester: currentSem, semesters: [currentSem] }; }
 
 
 
 
 
 
 
 
 
 
733
  res.json(config);
734
  });
735
  app.post('/api/config', async (req, res) => { await ConfigModel.findOneAndUpdate({key:'main'}, req.body, {upsert:true}); res.json({}); });
 
742
  if (student && student.drawAttempts < reward.count) return res.status(400).json({ error: 'FAILED_REVOKE', message: '修改失败,次数已被使用' });
743
  await Student.findByIdAndUpdate(reward.studentId, { $inc: { drawAttempts: -reward.count } });
744
  }
745
+ await StudentRewardModel.findByIdAndDelete(req.params.id); res.json({});
 
746
  });
747
  app.post('/api/rewards/:id/redeem', async (req, res) => { await StudentRewardModel.findByIdAndUpdate(req.params.id, {status:'REDEEMED'}); res.json({}); });
748
  app.get('/api/attendance', async (req, res) => {
 
781
  if (student) await AttendanceModel.findOneAndUpdate({ studentId, date: startDate }, { schoolId: req.headers['x-school-id'], studentId, studentName: student.name, className: student.className, date: startDate, status: 'Leave', checkInTime: new Date() }, { upsert: true });
782
  res.json({ success: true });
783
  });
 
784
  app.get('/api/attendance/calendar', async (req, res) => {
785
  const { className } = req.query;
786
  const filter = getQueryFilter(req);
787
+ const query = { $and: [ filter, { $or: [{ className: { $exists: false } }, { className: null }, { className }] } ] };
 
 
 
 
 
 
788
  res.json(await SchoolCalendarModel.find(query));
789
  });
790
+ app.post('/api/attendance/calendar', async (req, res) => { await SchoolCalendarModel.create(injectSchoolId(req, req.body)); res.json({ success: true }); });
791
+ app.delete('/api/attendance/calendar/:id', async (req, res) => { await SchoolCalendarModel.findByIdAndDelete(req.params.id); res.json({ success: true }); });
 
 
 
 
 
 
 
792
  app.post('/api/batch-delete', async (req, res) => {
793
  if(req.body.type==='student') await Student.deleteMany({_id:{$in:req.body.ids}});
794
  if(req.body.type==='score') await Score.deleteMany({_id:{$in:req.body.ids}});
services/api.ts CHANGED
@@ -1,4 +1,5 @@
1
- import { User, ClassInfo, SystemConfig, Subject, School, Schedule, GameSession, StudentReward, LuckyDrawConfig, Attendance, LeaveRequest, AchievementConfig, SchoolCalendarEntry } from '../types';
 
2
 
3
  const getBaseUrl = () => {
4
  let isProd = false;
@@ -117,6 +118,7 @@ export const api = {
117
  delete: (id: string) => request(`/users/${id}`, { method: 'DELETE' }),
118
  applyClass: (data: { userId: string, type: 'CLAIM'|'RESIGN', targetClass?: string, action: 'APPLY'|'APPROVE'|'REJECT' }) =>
119
  request('/users/class-application', { method: 'POST', body: JSON.stringify(data) }),
 
120
  },
121
 
122
  students: {
@@ -207,7 +209,7 @@ export const api = {
207
  games: {
208
  getMountainSession: (className: string) => request(`/games/mountain?className=${className}`),
209
  saveMountainSession: (data: GameSession) => request('/games/mountain', { method: 'POST', body: JSON.stringify(data) }),
210
- getLuckyConfig: (className?: string) => request(`/games/lucky-config${className ? `?className=${className}` : ''}`),
211
  saveLuckyConfig: (data: LuckyDrawConfig) => request('/games/lucky-config', { method: 'POST', body: JSON.stringify(data) }),
212
  drawLucky: (studentId: string) => request('/games/lucky-draw', { method: 'POST', body: JSON.stringify({ studentId }) }),
213
  grantReward: (data: { studentId: string, count: number, rewardType: string, name?: string }) => request('/games/grant-reward', { method: 'POST', body: JSON.stringify(data) }),
@@ -222,7 +224,11 @@ export const api = {
222
  saveConfig: (data: AchievementConfig) => request('/achievements/config', { method: 'POST', body: JSON.stringify(data) }),
223
  getStudentAchievements: (studentId: string, semester?: string) => request(`/achievements/student?studentId=${studentId}${semester ? `&semester=${semester}` : ''}`),
224
  grant: (data: { studentId: string, achievementId: string, semester: string }) => request('/achievements/grant', { method: 'POST', body: JSON.stringify(data) }),
225
- exchange: (data: { studentId: string, ruleId: string }) => request('/achievements/exchange', { method: 'POST', body: JSON.stringify(data) }),
 
 
 
 
226
  },
227
 
228
  rewards: {
@@ -241,4 +247,4 @@ export const api = {
241
  batchDelete: (type: 'student' | 'score' | 'user', ids: string[]) => {
242
  return request('/batch-delete', { method: 'POST', body: JSON.stringify({ type, ids }) });
243
  }
244
- };
 
1
+
2
+ import { User, ClassInfo, SystemConfig, Subject, School, Schedule, GameSession, StudentReward, LuckyDrawConfig, Attendance, LeaveRequest, AchievementConfig, SchoolCalendarEntry, TeacherExchangeConfig } from '../types';
3
 
4
  const getBaseUrl = () => {
5
  let isProd = false;
 
118
  delete: (id: string) => request(`/users/${id}`, { method: 'DELETE' }),
119
  applyClass: (data: { userId: string, type: 'CLAIM'|'RESIGN', targetClass?: string, action: 'APPLY'|'APPROVE'|'REJECT' }) =>
120
  request('/users/class-application', { method: 'POST', body: JSON.stringify(data) }),
121
+ getTeachersForClass: (className: string) => request(`/classes/${className}/teachers`),
122
  },
123
 
124
  students: {
 
209
  games: {
210
  getMountainSession: (className: string) => request(`/games/mountain?className=${className}`),
211
  saveMountainSession: (data: GameSession) => request('/games/mountain', { method: 'POST', body: JSON.stringify(data) }),
212
+ getLuckyConfig: (className?: string, ownerId?: string) => request(`/games/lucky-config?className=${className || ''}${ownerId ? `&ownerId=${ownerId}` : ''}`),
213
  saveLuckyConfig: (data: LuckyDrawConfig) => request('/games/lucky-config', { method: 'POST', body: JSON.stringify(data) }),
214
  drawLucky: (studentId: string) => request('/games/lucky-draw', { method: 'POST', body: JSON.stringify({ studentId }) }),
215
  grantReward: (data: { studentId: string, count: number, rewardType: string, name?: string }) => request('/games/grant-reward', { method: 'POST', body: JSON.stringify(data) }),
 
224
  saveConfig: (data: AchievementConfig) => request('/achievements/config', { method: 'POST', body: JSON.stringify(data) }),
225
  getStudentAchievements: (studentId: string, semester?: string) => request(`/achievements/student?studentId=${studentId}${semester ? `&semester=${semester}` : ''}`),
226
  grant: (data: { studentId: string, achievementId: string, semester: string }) => request('/achievements/grant', { method: 'POST', body: JSON.stringify(data) }),
227
+ exchange: (data: { studentId: string, ruleId: string, teacherId?: string }) => request('/achievements/exchange', { method: 'POST', body: JSON.stringify(data) }),
228
+ // Teacher Exchange Rules
229
+ getMyRules: () => request('/achievements/teacher-rules'),
230
+ saveMyRules: (data: TeacherExchangeConfig) => request('/achievements/teacher-rules', { method: 'POST', body: JSON.stringify(data) }),
231
+ getRulesByTeachers: (teacherIds: string[]) => request(`/achievements/teacher-rules?teacherIds=${teacherIds.join(',')}`),
232
  },
233
 
234
  rewards: {
 
247
  batchDelete: (type: 'student' | 'score' | 'user', ids: string[]) => {
248
  return request('/batch-delete', { method: 'POST', body: JSON.stringify({ type, ids }) });
249
  }
250
+ };
types.ts CHANGED
@@ -1,4 +1,46 @@
1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  export enum UserRole {
3
  ADMIN = 'ADMIN',
4
  PRINCIPAL = 'PRINCIPAL', // 新增校长角色
@@ -227,6 +269,7 @@ export interface StudentReward {
227
  status: 'PENDING' | 'REDEEMED';
228
  source: string;
229
  createTime: string;
 
230
  }
231
 
232
  export interface LuckyPrize {
@@ -267,30 +310,7 @@ export interface GameMonsterConfig {
267
  }
268
 
269
  // --- Achievement System Types ---
270
-
271
- export interface AchievementItem {
272
- id: string;
273
- name: string;
274
- icon: string;
275
- points: number;
276
- description?: string;
277
- }
278
-
279
- export interface ExchangeRule {
280
- id: string;
281
- cost: number;
282
- rewardType: 'ITEM' | 'DRAW_COUNT';
283
- rewardName: string;
284
- rewardValue: number;
285
- }
286
-
287
- export interface AchievementConfig {
288
- _id?: string;
289
- schoolId: string;
290
- className: string;
291
- achievements: AchievementItem[];
292
- exchangeRules: ExchangeRule[];
293
- }
294
 
295
  export interface StudentAchievement {
296
  _id?: string;
 
1
 
2
+ // ... existing imports
3
+
4
+ // ... existing types (User, School, etc.)
5
+
6
+ export interface AchievementItem {
7
+ id: string;
8
+ name: string;
9
+ icon: string;
10
+ points: number;
11
+ description?: string;
12
+ addedBy?: string; // Teacher ID
13
+ addedByName?: string; // Teacher Name
14
+ }
15
+
16
+ export interface ExchangeRule {
17
+ id: string;
18
+ cost: number;
19
+ rewardType: 'ITEM' | 'DRAW_COUNT';
20
+ rewardName: string;
21
+ rewardValue: number;
22
+ }
23
+
24
+ // Class-based Config (Shared Achievement Library)
25
+ export interface AchievementConfig {
26
+ _id?: string;
27
+ schoolId: string;
28
+ className: string;
29
+ achievements: AchievementItem[];
30
+ // exchangeRules is deprecated here, moving to TeacherExchangeConfig
31
+ exchangeRules?: ExchangeRule[];
32
+ }
33
+
34
+ // New: Teacher-based Config (Global Rules for a teacher)
35
+ export interface TeacherExchangeConfig {
36
+ _id?: string;
37
+ schoolId: string;
38
+ teacherId: string;
39
+ teacherName: string;
40
+ rules: ExchangeRule[];
41
+ }
42
+
43
+ // ... rest of the file
44
  export enum UserRole {
45
  ADMIN = 'ADMIN',
46
  PRINCIPAL = 'PRINCIPAL', // 新增校长角色
 
269
  status: 'PENDING' | 'REDEEMED';
270
  source: string;
271
  createTime: string;
272
+ ownerId?: string;
273
  }
274
 
275
  export interface LuckyPrize {
 
310
  }
311
 
312
  // --- Achievement System Types ---
313
+ // (Already defined above with new fields, kept here for structure context if needed)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
314
 
315
  export interface StudentAchievement {
316
  _id?: string;