dvc890 commited on
Commit
5e8f957
·
verified ·
1 Parent(s): 270bc5d

Upload 51 files

Browse files
components/ConfirmModal.tsx ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import React from 'react';
3
+ import { X, AlertTriangle } from 'lucide-react';
4
+
5
+ interface ConfirmModalProps {
6
+ isOpen: boolean;
7
+ onClose: () => void;
8
+ onConfirm: () => void;
9
+ title?: string;
10
+ message: string;
11
+ confirmText?: string;
12
+ cancelText?: string;
13
+ isDanger?: boolean;
14
+ }
15
+
16
+ export const ConfirmModal: React.FC<ConfirmModalProps> = ({
17
+ isOpen, onClose, onConfirm, title = "确认操作", message,
18
+ confirmText = "确定", cancelText = "取消", isDanger = false
19
+ }) => {
20
+ if (!isOpen) return null;
21
+
22
+ return (
23
+ <div className="fixed inset-0 bg-black/60 z-[9999] flex items-center justify-center p-4 backdrop-blur-sm animate-in fade-in">
24
+ <div className="bg-white rounded-xl shadow-2xl max-w-sm w-full overflow-hidden animate-in zoom-in-95">
25
+ <div className="p-5">
26
+ <div className="flex items-start gap-4">
27
+ <div className={`p-3 rounded-full shrink-0 ${isDanger ? 'bg-red-100 text-red-600' : 'bg-blue-100 text-blue-600'}`}>
28
+ <AlertTriangle size={24} />
29
+ </div>
30
+ <div>
31
+ <h3 className="text-lg font-bold text-gray-900 mb-1">{title}</h3>
32
+ <p className="text-sm text-gray-500 leading-relaxed">{message}</p>
33
+ </div>
34
+ </div>
35
+ </div>
36
+ <div className="bg-gray-50 px-5 py-3 flex justify-end gap-3">
37
+ <button
38
+ onClick={onClose}
39
+ className="px-4 py-2 rounded-lg text-sm font-medium text-gray-700 hover:bg-gray-200 transition-colors"
40
+ >
41
+ {cancelText}
42
+ </button>
43
+ <button
44
+ onClick={() => { onConfirm(); onClose(); }}
45
+ className={`px-4 py-2 rounded-lg text-sm font-bold text-white shadow-sm transition-colors ${isDanger ? 'bg-red-600 hover:bg-red-700' : 'bg-blue-600 hover:bg-blue-700'}`}
46
+ >
47
+ {confirmText}
48
+ </button>
49
+ </div>
50
+ </div>
51
+ </div>
52
+ );
53
+ };
components/Sidebar.tsx CHANGED
@@ -1,6 +1,6 @@
1
 
2
- import React from 'react';
3
- import { LayoutDashboard, Users, BookOpen, GraduationCap, Settings, LogOut, FileText, School, UserCog, Palette, X, Building, Gamepad2, CalendarCheck, UserCircle, MessageSquare, Bot } from 'lucide-react';
4
  import { UserRole } from '../types';
5
  import { api } from '../services/api';
6
 
@@ -14,24 +14,19 @@ interface SidebarProps {
14
  }
15
 
16
  export const Sidebar: React.FC<SidebarProps> = ({ currentView, onChangeView, userRole, onLogout, isOpen, onClose }) => {
17
- // Get full user object to check aiAccess flag
18
  const currentUser = api.auth.getCurrentUser();
19
-
20
- // Logic: Admin sees it to manage it. Teachers see it only if granted. Students/Principal see nothing.
21
  const canSeeAI = userRole === UserRole.ADMIN || (userRole === UserRole.TEACHER && currentUser?.aiAccess);
22
 
23
- // Define menu items with explicit roles
24
- // PRINCIPAL has access to almost everything ADMIN has, except 'schools' management
25
- const menuItems = [
26
  { id: 'dashboard', label: '工作台', icon: LayoutDashboard, roles: [UserRole.ADMIN, UserRole.PRINCIPAL, UserRole.TEACHER, UserRole.STUDENT] },
27
- // AI Assistant: Only if hasAIAccess is true
28
  { id: 'ai-assistant', label: 'AI 智能助教', icon: Bot, roles: canSeeAI ? [UserRole.ADMIN, UserRole.TEACHER] : [] },
29
  { id: 'attendance', label: '考勤管理', icon: CalendarCheck, roles: [UserRole.TEACHER, UserRole.PRINCIPAL] },
30
- { id: 'games', label: '互动教学', icon: Gamepad2, roles: [UserRole.TEACHER, UserRole.STUDENT] }, // Removed PRINCIPAL
31
- { id: 'wishes', label: '心愿与反馈', icon: MessageSquare, roles: [UserRole.ADMIN, UserRole.PRINCIPAL, UserRole.TEACHER, UserRole.STUDENT] }, // NEW
32
  { id: 'students', label: '学生管理', icon: Users, roles: [UserRole.ADMIN, UserRole.PRINCIPAL, UserRole.TEACHER] },
33
  { id: 'classes', label: '班级管理', icon: School, roles: [UserRole.ADMIN, UserRole.PRINCIPAL] },
34
- { id: 'schools', label: '学校管理', icon: Building, roles: [UserRole.ADMIN] }, // Only Super Admin can manage schools
35
  { id: 'courses', label: '课程管理', icon: BookOpen, roles: [UserRole.ADMIN, UserRole.PRINCIPAL, UserRole.TEACHER] },
36
  { id: 'grades', label: '成绩管理', icon: GraduationCap, roles: [UserRole.ADMIN, UserRole.PRINCIPAL, UserRole.TEACHER] },
37
  { id: 'reports', label: '报表统计', icon: FileText, roles: [UserRole.ADMIN, UserRole.PRINCIPAL, UserRole.TEACHER, UserRole.STUDENT] },
@@ -41,6 +36,48 @@ export const Sidebar: React.FC<SidebarProps> = ({ currentView, onChangeView, use
41
  { id: 'settings', label: '系统设置', icon: Settings, roles: [UserRole.ADMIN, UserRole.PRINCIPAL] },
42
  ];
43
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
  const sidebarClasses = `
45
  fixed inset-y-0 left-0 z-50 w-64 bg-slate-900 text-white transition-transform duration-300 ease-in-out transform
46
  ${isOpen ? 'translate-x-0' : '-translate-x-full'}
@@ -62,25 +99,45 @@ export const Sidebar: React.FC<SidebarProps> = ({ currentView, onChangeView, use
62
  <button onClick={onClose} className="lg:hidden text-slate-400 hover:text-white"><X size={24} /></button>
63
  </div>
64
 
65
- <div className="flex-1 overflow-y-auto py-4">
 
 
 
 
 
 
 
 
 
66
  <nav className="space-y-1 px-2">
67
- {menuItems.map((item) => {
68
  if (!item.roles.includes(userRole)) return null;
 
 
69
  const Icon = item.icon;
70
  const isActive = currentView === item.id;
 
71
  return (
72
- <button
73
- key={item.id}
74
- onClick={() => onChangeView(item.id)}
75
- className={`w-full flex items-center space-x-3 px-4 py-3 rounded-lg transition-colors duration-200 ${
76
- isActive
77
- ? 'bg-blue-600 text-white shadow-lg shadow-blue-900/50'
78
- : 'text-slate-300 hover:bg-slate-800 hover:text-white'
79
- }`}
80
- >
81
- <Icon size={20} />
82
- <span className="font-medium">{item.label}</span>
83
- </button>
 
 
 
 
 
 
 
 
84
  );
85
  })}
86
  </nav>
 
1
 
2
+ import React, { useState, useEffect } from 'react';
3
+ import { LayoutDashboard, Users, BookOpen, GraduationCap, Settings, LogOut, FileText, School, UserCog, Palette, X, Building, Gamepad2, CalendarCheck, UserCircle, MessageSquare, Bot, ArrowUp, ArrowDown, Save } from 'lucide-react';
4
  import { UserRole } from '../types';
5
  import { api } from '../services/api';
6
 
 
14
  }
15
 
16
  export const Sidebar: React.FC<SidebarProps> = ({ currentView, onChangeView, userRole, onLogout, isOpen, onClose }) => {
 
17
  const currentUser = api.auth.getCurrentUser();
 
 
18
  const canSeeAI = userRole === UserRole.ADMIN || (userRole === UserRole.TEACHER && currentUser?.aiAccess);
19
 
20
+ // Default Items
21
+ const defaultItems = [
 
22
  { id: 'dashboard', label: '工作台', icon: LayoutDashboard, roles: [UserRole.ADMIN, UserRole.PRINCIPAL, UserRole.TEACHER, UserRole.STUDENT] },
 
23
  { id: 'ai-assistant', label: 'AI 智能助教', icon: Bot, roles: canSeeAI ? [UserRole.ADMIN, UserRole.TEACHER] : [] },
24
  { id: 'attendance', label: '考勤管理', icon: CalendarCheck, roles: [UserRole.TEACHER, UserRole.PRINCIPAL] },
25
+ { id: 'games', label: '互动教学', icon: Gamepad2, roles: [UserRole.TEACHER, UserRole.STUDENT] },
26
+ { id: 'wishes', label: '心愿与反馈', icon: MessageSquare, roles: [UserRole.ADMIN, UserRole.PRINCIPAL, UserRole.TEACHER, UserRole.STUDENT] },
27
  { id: 'students', label: '学生管理', icon: Users, roles: [UserRole.ADMIN, UserRole.PRINCIPAL, UserRole.TEACHER] },
28
  { id: 'classes', label: '班级管理', icon: School, roles: [UserRole.ADMIN, UserRole.PRINCIPAL] },
29
+ { id: 'schools', label: '学校管理', icon: Building, roles: [UserRole.ADMIN] },
30
  { id: 'courses', label: '课程管理', icon: BookOpen, roles: [UserRole.ADMIN, UserRole.PRINCIPAL, UserRole.TEACHER] },
31
  { id: 'grades', label: '成绩管理', icon: GraduationCap, roles: [UserRole.ADMIN, UserRole.PRINCIPAL, UserRole.TEACHER] },
32
  { id: 'reports', label: '报表统计', icon: FileText, roles: [UserRole.ADMIN, UserRole.PRINCIPAL, UserRole.TEACHER, UserRole.STUDENT] },
 
36
  { id: 'settings', label: '系统设置', icon: Settings, roles: [UserRole.ADMIN, UserRole.PRINCIPAL] },
37
  ];
38
 
39
+ const [menuItems, setMenuItems] = useState(defaultItems);
40
+ const [isEditing, setIsEditing] = useState(false);
41
+
42
+ useEffect(() => {
43
+ // Load saved order
44
+ if (currentUser?.menuOrder && currentUser.menuOrder.length > 0) {
45
+ const ordered = [];
46
+ const map = new Map(defaultItems.map(i => [i.id, i]));
47
+ // Add saved items in order
48
+ currentUser.menuOrder.forEach(id => {
49
+ if (map.has(id)) {
50
+ ordered.push(map.get(id)!);
51
+ map.delete(id);
52
+ }
53
+ });
54
+ // Append any new/remaining items
55
+ map.forEach(item => ordered.push(item));
56
+ setMenuItems(ordered);
57
+ }
58
+ }, []);
59
+
60
+ const handleMove = (index: number, direction: -1 | 1) => {
61
+ const newItems = [...menuItems];
62
+ const targetIndex = index + direction;
63
+ if (targetIndex < 0 || targetIndex >= newItems.length) return;
64
+ [newItems[index], newItems[targetIndex]] = [newItems[targetIndex], newItems[index]];
65
+ setMenuItems(newItems);
66
+ };
67
+
68
+ const saveOrder = async () => {
69
+ setIsEditing(false);
70
+ const orderIds = menuItems.map(i => i.id);
71
+ if (currentUser && currentUser._id) {
72
+ try {
73
+ await api.users.saveMenuOrder(currentUser._id, orderIds);
74
+ // Update local user object
75
+ const updatedUser = { ...currentUser, menuOrder: orderIds };
76
+ localStorage.setItem('user', JSON.stringify(updatedUser));
77
+ } catch(e) { console.error("Failed to save menu order"); }
78
+ }
79
+ };
80
+
81
  const sidebarClasses = `
82
  fixed inset-y-0 left-0 z-50 w-64 bg-slate-900 text-white transition-transform duration-300 ease-in-out transform
83
  ${isOpen ? 'translate-x-0' : '-translate-x-full'}
 
99
  <button onClick={onClose} className="lg:hidden text-slate-400 hover:text-white"><X size={24} /></button>
100
  </div>
101
 
102
+ <div className="flex-1 overflow-y-auto py-4 custom-scrollbar">
103
+ <div className="px-4 mb-2 flex justify-between items-center">
104
+ <span className="text-xs text-slate-500 font-bold uppercase">主菜单</span>
105
+ <button
106
+ onClick={() => isEditing ? saveOrder() : setIsEditing(true)}
107
+ className={`text-xs px-2 py-1 rounded hover:bg-slate-800 ${isEditing ? 'text-green-400' : 'text-slate-600'}`}
108
+ >
109
+ {isEditing ? '完成' : '调整'}
110
+ </button>
111
+ </div>
112
  <nav className="space-y-1 px-2">
113
+ {menuItems.map((item, idx) => {
114
  if (!item.roles.includes(userRole)) return null;
115
+ if (item.id === 'ai-assistant' && !canSeeAI) return null;
116
+
117
  const Icon = item.icon;
118
  const isActive = currentView === item.id;
119
+
120
  return (
121
+ <div key={item.id} className="flex items-center gap-1 group">
122
+ <button
123
+ onClick={() => !isEditing && onChangeView(item.id)}
124
+ disabled={isEditing}
125
+ className={`flex-1 flex items-center space-x-3 px-4 py-3 rounded-lg transition-colors duration-200 ${
126
+ isActive
127
+ ? 'bg-blue-600 text-white shadow-lg shadow-blue-900/50'
128
+ : 'text-slate-300 hover:bg-slate-800 hover:text-white'
129
+ } ${isEditing ? 'opacity-70 cursor-grab' : ''}`}
130
+ >
131
+ <Icon size={20} />
132
+ <span className="font-medium">{item.label}</span>
133
+ </button>
134
+ {isEditing && (
135
+ <div className="flex flex-col gap-1 pr-1">
136
+ <button onClick={()=>handleMove(idx, -1)} className="p-1 hover:bg-slate-700 rounded text-slate-400 hover:text-white"><ArrowUp size={12}/></button>
137
+ <button onClick={()=>handleMove(idx, 1)} className="p-1 hover:bg-slate-700 rounded text-slate-400 hover:text-white"><ArrowDown size={12}/></button>
138
+ </div>
139
+ )}
140
+ </div>
141
  );
142
  })}
143
  </nav>
components/TodoList.tsx ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import React, { useState, useEffect } from 'react';
3
+ import { api } from '../services/api';
4
+ import { Todo } from '../types';
5
+ import { Plus, CheckCircle, Circle, Trash2, CalendarCheck } from 'lucide-react';
6
+
7
+ export const TodoList: React.FC = () => {
8
+ const [todos, setTodos] = useState<Todo[]>([]);
9
+ const [inputValue, setInputValue] = useState('');
10
+ const [loading, setLoading] = useState(false);
11
+
12
+ useEffect(() => {
13
+ loadTodos();
14
+ }, []);
15
+
16
+ const loadTodos = async () => {
17
+ try {
18
+ const list = await api.todos.getAll();
19
+ setTodos(list);
20
+ } catch (e) { console.error(e); }
21
+ };
22
+
23
+ const handleAdd = async (e?: React.FormEvent) => {
24
+ e?.preventDefault();
25
+ if (!inputValue.trim()) return;
26
+ setLoading(true);
27
+ await api.todos.add(inputValue);
28
+ setInputValue('');
29
+ setLoading(false);
30
+ loadTodos();
31
+ };
32
+
33
+ const handleToggle = async (t: Todo) => {
34
+ // Optimistic update
35
+ const newStatus = !t.isCompleted;
36
+ setTodos(todos.map(item => item._id === t._id ? { ...item, isCompleted: newStatus } : item));
37
+ await api.todos.update(t._id!, { isCompleted: newStatus });
38
+ };
39
+
40
+ const handleDelete = async (id: string) => {
41
+ setTodos(todos.filter(t => t._id !== id));
42
+ await api.todos.delete(id);
43
+ };
44
+
45
+ return (
46
+ <div className="bg-white rounded-xl shadow-sm border border-gray-100 flex flex-col h-full">
47
+ <div className="p-4 border-b border-gray-100 bg-gradient-to-r from-blue-50 to-indigo-50 rounded-t-xl">
48
+ <h3 className="font-bold text-gray-800 flex items-center text-sm">
49
+ <CalendarCheck size={16} className="mr-2 text-indigo-600"/> 个人备忘录
50
+ </h3>
51
+ </div>
52
+
53
+ <div className="flex-1 overflow-y-auto p-2 custom-scrollbar min-h-[200px]">
54
+ {todos.length === 0 ? (
55
+ <div className="text-center text-gray-400 py-10 text-xs">
56
+ <p>暂无待办事项</p>
57
+ <p>记录今天的教学/学习任务吧!</p>
58
+ </div>
59
+ ) : (
60
+ <ul className="space-y-1">
61
+ {todos.map(t => (
62
+ <li key={t._id} className="group flex items-start gap-2 p-2 hover:bg-gray-50 rounded transition-colors text-sm">
63
+ <button onClick={() => handleToggle(t)} className={`mt-0.5 shrink-0 ${t.isCompleted ? 'text-green-500' : 'text-gray-400 hover:text-blue-500'}`}>
64
+ {t.isCompleted ? <CheckCircle size={16}/> : <Circle size={16}/>}
65
+ </button>
66
+ <span className={`flex-1 break-all ${t.isCompleted ? 'text-gray-400 line-through' : 'text-gray-700'}`}>
67
+ {t.content}
68
+ </span>
69
+ <button onClick={() => handleDelete(t._id!)} className="text-gray-300 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-opacity">
70
+ <Trash2 size={14}/>
71
+ </button>
72
+ </li>
73
+ ))}
74
+ </ul>
75
+ )}
76
+ </div>
77
+
78
+ <div className="p-3 border-t border-gray-100 bg-gray-50 rounded-b-xl">
79
+ <form onSubmit={handleAdd} className="flex gap-2">
80
+ <input
81
+ className="flex-1 border border-gray-200 rounded-lg px-3 py-1.5 text-sm outline-none focus:ring-2 focus:ring-indigo-200 focus:border-indigo-400 bg-white"
82
+ placeholder="添加新任务..."
83
+ value={inputValue}
84
+ onChange={e => setInputValue(e.target.value)}
85
+ disabled={loading}
86
+ />
87
+ <button type="submit" disabled={!inputValue.trim() || loading} className="bg-indigo-600 text-white p-1.5 rounded-lg hover:bg-indigo-700 disabled:opacity-50">
88
+ <Plus size={18}/>
89
+ </button>
90
+ </form>
91
+ </div>
92
+ </div>
93
+ );
94
+ };
models.js CHANGED
@@ -1,7 +1,7 @@
1
 
2
  const mongoose = require('mongoose');
3
 
4
- // ... (Previous Models: School, User, Student, Course, Score, ClassModel, SubjectModel, ExamModel, ScheduleModel)
5
 
6
  const SchoolSchema = new mongoose.Schema({ name: String, code: String });
7
  const School = mongoose.model('School', SchoolSchema);
@@ -26,8 +26,8 @@ const UserSchema = new mongoose.Schema({
26
  gender: String,
27
  seatNo: String,
28
  idCard: String,
29
- // NEW: Control AI feature access for teachers
30
  aiAccess: { type: Boolean, default: false },
 
31
  classApplication: {
32
  type: { type: String },
33
  targetClass: String,
@@ -36,7 +36,7 @@ const UserSchema = new mongoose.Schema({
36
  });
37
  const User = mongoose.model('User', UserSchema);
38
 
39
- // ... (Rest of the file remains unchanged until ConfigSchema)
40
  const StudentSchema = new mongoose.Schema({
41
  schoolId: String,
42
  studentNo: String,
@@ -90,7 +90,15 @@ const SubjectModel = mongoose.model('Subject', SubjectSchema);
90
  const ExamSchema = new mongoose.Schema({ schoolId: String, name: String, date: String, type: String, semester: String });
91
  const ExamModel = mongoose.model('Exam', ExamSchema);
92
 
93
- const ScheduleSchema = new mongoose.Schema({ schoolId: String, className: String, teacherName: String, subject: String, dayOfWeek: Number, period: Number });
 
 
 
 
 
 
 
 
94
  const ScheduleModel = mongoose.model('Schedule', ScheduleSchema);
95
 
96
  const ConfigSchema = new mongoose.Schema({
@@ -104,16 +112,17 @@ const ConfigSchema = new mongoose.Schema({
104
  allowStudentRegister: Boolean,
105
  maintenanceMode: Boolean,
106
  emailNotify: Boolean,
107
- // NEW: AI Global Config
108
  enableAI: { type: Boolean, default: true },
109
- aiTotalCalls: { type: Number, default: 0 }
 
110
  });
111
  const ConfigModel = mongoose.model('Config', ConfigSchema);
112
 
 
113
  const NotificationSchema = new mongoose.Schema({ schoolId: String, targetRole: String, targetUserId: String, title: String, content: String, type: String, createTime: { type: Date, default: Date.now } });
114
  const NotificationModel = mongoose.model('Notification', NotificationSchema);
115
 
116
- 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 }] });
117
  const GameSessionModel = mongoose.model('GameSession', GameSessionSchema);
118
 
119
  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 });
@@ -155,7 +164,6 @@ const GameZenConfigSchema = new mongoose.Schema({
155
  });
156
  const GameZenConfigModel = mongoose.model('GameZenConfig', GameZenConfigSchema);
157
 
158
- // Updated Achievement Schema with addedBy
159
  const AchievementConfigSchema = new mongoose.Schema({
160
  schoolId: String,
161
  className: String,
@@ -168,12 +176,10 @@ const AchievementConfigSchema = new mongoose.Schema({
168
  addedBy: String,
169
  addedByName: String
170
  }],
171
- // Legacy support, rules now moving to TeacherExchangeConfig
172
  exchangeRules: [{ id: String, cost: Number, rewardType: String, rewardName: String, rewardValue: Number }]
173
  });
174
  const AchievementConfigModel = mongoose.model('AchievementConfig', AchievementConfigSchema);
175
 
176
- // NEW: Independent Teacher Exchange Rules
177
  const TeacherExchangeConfigSchema = new mongoose.Schema({
178
  schoolId: String,
179
  teacherId: String,
@@ -194,8 +200,6 @@ const LeaveRequestModel = mongoose.model('LeaveRequest', LeaveRequestSchema);
194
  const SchoolCalendarSchema = new mongoose.Schema({ schoolId: String, className: String, type: String, startDate: String, endDate: String, name: String });
195
  const SchoolCalendarModel = mongoose.model('SchoolCalendar', SchoolCalendarSchema);
196
 
197
- // --- NEW SCHEMAS FOR WISH AND FEEDBACK ---
198
-
199
  const WishSchema = new mongoose.Schema({
200
  schoolId: String,
201
  studentId: String,
@@ -204,7 +208,7 @@ const WishSchema = new mongoose.Schema({
204
  teacherId: String,
205
  teacherName: String,
206
  content: String,
207
- status: { type: String, default: 'PENDING' }, // PENDING, FULFILLED
208
  createTime: { type: Date, default: Date.now },
209
  fulfillTime: Date
210
  });
@@ -215,20 +219,29 @@ const FeedbackSchema = new mongoose.Schema({
215
  creatorId: String,
216
  creatorName: String,
217
  creatorRole: String,
218
- targetId: String, // Teacher ID or 'ADMIN'
219
  targetName: String,
220
  content: String,
221
- type: String, // ACADEMIC, SYSTEM
222
- status: { type: String, default: 'PENDING' }, // PENDING, ACCEPTED, PROCESSED, IGNORED
223
  reply: String,
224
  createTime: { type: Date, default: Date.now },
225
  updateTime: Date
226
  });
227
  const FeedbackModel = mongoose.model('Feedback', FeedbackSchema);
228
 
 
 
 
 
 
 
 
 
 
229
  module.exports = {
230
  School, User, Student, Course, Score, ClassModel, SubjectModel, ExamModel, ScheduleModel,
231
  ConfigModel, NotificationModel, GameSessionModel, StudentRewardModel, LuckyDrawConfigModel, GameMonsterConfigModel, GameZenConfigModel,
232
  AchievementConfigModel, TeacherExchangeConfigModel, StudentAchievementModel, AttendanceModel, LeaveRequestModel, SchoolCalendarModel,
233
- WishModel, FeedbackModel
234
  };
 
1
 
2
  const mongoose = require('mongoose');
3
 
4
+ // ... (Previous Models)
5
 
6
  const SchoolSchema = new mongoose.Schema({ name: String, code: String });
7
  const School = mongoose.model('School', SchoolSchema);
 
26
  gender: String,
27
  seatNo: String,
28
  idCard: String,
 
29
  aiAccess: { type: Boolean, default: false },
30
+ menuOrder: [String], // NEW
31
  classApplication: {
32
  type: { type: String },
33
  targetClass: String,
 
36
  });
37
  const User = mongoose.model('User', UserSchema);
38
 
39
+ // ... (Student, Course, Score, Class, Subject, Exam Models - No Change)
40
  const StudentSchema = new mongoose.Schema({
41
  schoolId: String,
42
  studentNo: String,
 
90
  const ExamSchema = new mongoose.Schema({ schoolId: String, name: String, date: String, type: String, semester: String });
91
  const ExamModel = mongoose.model('Exam', ExamSchema);
92
 
93
+ const ScheduleSchema = new mongoose.Schema({
94
+ schoolId: String,
95
+ className: String,
96
+ teacherName: String,
97
+ subject: String,
98
+ dayOfWeek: Number,
99
+ period: Number,
100
+ weekType: { type: String, default: 'ALL' } // ALL, ODD, EVEN
101
+ });
102
  const ScheduleModel = mongoose.model('Schedule', ScheduleSchema);
103
 
104
  const ConfigSchema = new mongoose.Schema({
 
112
  allowStudentRegister: Boolean,
113
  maintenanceMode: Boolean,
114
  emailNotify: Boolean,
 
115
  enableAI: { type: Boolean, default: true },
116
+ aiTotalCalls: { type: Number, default: 0 },
117
+ periodConfig: [{ period: Number, name: String, startTime: String, endTime: String }] // NEW
118
  });
119
  const ConfigModel = mongoose.model('Config', ConfigSchema);
120
 
121
+ // ... (Notification, GameSession, StudentReward, LuckyDrawConfig, etc.)
122
  const NotificationSchema = new mongoose.Schema({ schoolId: String, targetRole: String, targetUserId: String, title: String, content: String, type: String, createTime: { type: Date, default: Date.now } });
123
  const NotificationModel = mongoose.model('Notification', NotificationSchema);
124
 
125
+ 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: { type: Number, default: 1 }, achievementId: String }] }); // Added rewardValue default
126
  const GameSessionModel = mongoose.model('GameSession', GameSessionSchema);
127
 
128
  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 });
 
164
  });
165
  const GameZenConfigModel = mongoose.model('GameZenConfig', GameZenConfigSchema);
166
 
 
167
  const AchievementConfigSchema = new mongoose.Schema({
168
  schoolId: String,
169
  className: String,
 
176
  addedBy: String,
177
  addedByName: String
178
  }],
 
179
  exchangeRules: [{ id: String, cost: Number, rewardType: String, rewardName: String, rewardValue: Number }]
180
  });
181
  const AchievementConfigModel = mongoose.model('AchievementConfig', AchievementConfigSchema);
182
 
 
183
  const TeacherExchangeConfigSchema = new mongoose.Schema({
184
  schoolId: String,
185
  teacherId: String,
 
200
  const SchoolCalendarSchema = new mongoose.Schema({ schoolId: String, className: String, type: String, startDate: String, endDate: String, name: String });
201
  const SchoolCalendarModel = mongoose.model('SchoolCalendar', SchoolCalendarSchema);
202
 
 
 
203
  const WishSchema = new mongoose.Schema({
204
  schoolId: String,
205
  studentId: String,
 
208
  teacherId: String,
209
  teacherName: String,
210
  content: String,
211
+ status: { type: String, default: 'PENDING' },
212
  createTime: { type: Date, default: Date.now },
213
  fulfillTime: Date
214
  });
 
219
  creatorId: String,
220
  creatorName: String,
221
  creatorRole: String,
222
+ targetId: String,
223
  targetName: String,
224
  content: String,
225
+ type: String,
226
+ status: { type: String, default: 'PENDING' },
227
  reply: String,
228
  createTime: { type: Date, default: Date.now },
229
  updateTime: Date
230
  });
231
  const FeedbackModel = mongoose.model('Feedback', FeedbackSchema);
232
 
233
+ // NEW: Todo Model
234
+ const TodoSchema = new mongoose.Schema({
235
+ userId: String,
236
+ content: String,
237
+ isCompleted: { type: Boolean, default: false },
238
+ createTime: { type: Date, default: Date.now }
239
+ });
240
+ const TodoModel = mongoose.model('Todo', TodoSchema);
241
+
242
  module.exports = {
243
  School, User, Student, Course, Score, ClassModel, SubjectModel, ExamModel, ScheduleModel,
244
  ConfigModel, NotificationModel, GameSessionModel, StudentRewardModel, LuckyDrawConfigModel, GameMonsterConfigModel, GameZenConfigModel,
245
  AchievementConfigModel, TeacherExchangeConfigModel, StudentAchievementModel, AttendanceModel, LeaveRequestModel, SchoolCalendarModel,
246
+ WishModel, FeedbackModel, TodoModel
247
  };
pages/AchievementTeacher.tsx CHANGED
@@ -4,6 +4,7 @@ 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 = [
9
  { icon: '🏆', label: '冠军杯' }, { icon: '🥇', label: '金牌' }, { icon: '🥈', label: '银牌' }, { icon: '🥉', label: '铜牌' },
@@ -19,141 +20,88 @@ export const AchievementTeacher: React.FC<{className?: string}> = ({ className }
19
  const [myExchangeConfig, setMyExchangeConfig] = useState<TeacherExchangeConfig | null>(null);
20
  const [students, setStudents] = useState<Student[]>([]);
21
  const [semester, setSemester] = useState('');
22
-
23
- // Tab State
24
  const [activeTab, setActiveTab] = useState<'manage' | 'grant' | 'exchange' | 'balance'>('manage');
25
-
26
- // Forms
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) => {
60
- const seatA = parseInt(a.seatNo || '99999');
61
- const seatB = parseInt(b.seatNo || '99999');
62
- if (seatA !== seatB) return seatA - seatB;
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 {
146
- const promises = Array.from(selectedStudents).map(sid =>
147
- api.achievements.grant({ studentId: sid, achievementId: selectedAchieveId, semester })
148
- );
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() };
@@ -163,19 +111,30 @@ export const AchievementTeacher: React.FC<{className?: string}> = ({ className }
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>;
@@ -183,64 +142,30 @@ export const AchievementTeacher: React.FC<{className?: string}> = ({ className }
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">
194
- {/* Header / Tabs */}
195
  <div className="flex border-b border-gray-100 bg-gray-50/50 overflow-x-auto">
196
- <button onClick={() => setActiveTab('manage')} className={`px-6 py-4 text-sm font-bold border-b-2 transition-colors flex items-center gap-2 whitespace-nowrap ${activeTab === 'manage' ? 'border-blue-500 text-blue-600 bg-white' : 'border-transparent text-gray-500 hover:text-gray-700'}`}>
197
- <Award size={18}/> 成就库管理
198
- </button>
199
- <button onClick={() => setActiveTab('grant')} className={`px-6 py-4 text-sm font-bold border-b-2 transition-colors flex items-center gap-2 whitespace-nowrap ${activeTab === 'grant' ? 'border-blue-500 text-blue-600 bg-white' : 'border-transparent text-gray-500 hover:text-gray-700'}`}>
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}/> 学生积分
207
- </button>
208
  </div>
209
-
210
  <div className="flex-1 overflow-y-auto p-6">
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>
226
- <input className="w-full border rounded p-2 text-sm" placeholder="如: 劳动小能手" value={newAchieve.name} onChange={e => setNewAchieve({...newAchieve, name: e.target.value})} />
227
- </div>
228
- <div className="w-full md:w-32">
229
- <label className="text-xs font-bold text-gray-500 uppercase mb-1 block">分值 (小红花)</label>
230
- <input type="number" min={1} className="w-full border rounded p-2 text-sm" value={newAchieve.points} onChange={e => setNewAchieve({...newAchieve, points: Number(e.target.value)})} />
231
- </div>
232
- <div className="w-full md:w-auto">
233
- <label className="text-xs font-bold text-gray-500 uppercase mb-1 block">图标预设</label>
234
- <select className="w-full border rounded p-2 min-w-[100px] text-sm bg-white" value={newAchieve.icon} onChange={e => setNewAchieve({...newAchieve, icon: e.target.value})}>
235
- {PRESET_ICONS.map(p => <option key={p.label} value={p.icon}>{p.icon} {p.label}</option>)}
236
- </select>
237
- </div>
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;
@@ -249,49 +174,24 @@ export const AchievementTeacher: React.FC<{className?: string}> = ({ className }
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>
268
  )}
269
-
270
- {/* 2. Grant Achievements */}
271
  {activeTab === 'grant' && (
272
  <div className="flex flex-col md:flex-row gap-6 h-full">
273
- {/* Student Selector */}
274
  <div className="w-full md:w-1/3 border-r border-gray-100 pr-4 flex flex-col min-h-[400px]">
275
  <h3 className="font-bold text-gray-700 mb-2">1. 选择学生 ({selectedStudents.size})</h3>
276
- <button onClick={() => setSelectedStudents(new Set(selectedStudents.size === students.length ? [] : students.map(s => s._id || String(s.id))))} className="text-xs text-blue-600 mb-2 text-left hover:underline">
277
- {selectedStudents.size === students.length ? '取消全选' : '全选所有'}
278
- </button>
279
- {students.length === 0 ? (
280
- <div className="text-sm text-gray-400">班级暂无学生数据</div>
281
- ) : (
282
  <div className="flex-1 overflow-y-auto space-y-2 pr-2 custom-scrollbar max-h-[500px]">
283
  {students.map(s => (
284
- <div
285
- key={s._id}
286
- onClick={() => {
287
- const newSet = new Set(selectedStudents);
288
- const sid = s._id || String(s.id);
289
- if (newSet.has(sid)) newSet.delete(sid);
290
- else newSet.add(sid);
291
- setSelectedStudents(newSet);
292
- }}
293
- className={`p-2 rounded border cursor-pointer flex items-center justify-between transition-colors ${selectedStudents.has(s._id || String(s.id)) ? 'bg-blue-50 border-blue-400' : 'bg-white border-gray-200 hover:bg-gray-50'}`}
294
- >
295
  <span className="text-sm font-medium">{s.seatNo ? s.seatNo+'.':''}{s.name}</span>
296
  {selectedStudents.has(s._id || String(s.id)) && <CheckCircle size={16} className="text-blue-500"/>}
297
  </div>
@@ -299,30 +199,12 @@ export const AchievementTeacher: React.FC<{className?: string}> = ({ className }
299
  </div>
300
  )}
301
  </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)}
324
- className={`p-4 rounded-xl border cursor-pointer text-center transition-all ${selectedAchieveId === ach.id ? 'bg-amber-50 border-amber-400 ring-2 ring-amber-200' : 'bg-white border-gray-200 hover:bg-gray-50'}`}
325
- >
326
  <div className="text-3xl mb-1"><Emoji symbol={ach.icon} /></div>
327
  <div className="font-bold text-sm text-gray-800">{ach.name}</div>
328
  <div className="text-xs text-gray-500">+{ach.points} <Emoji symbol="🌺" size={12}/></div>
@@ -330,117 +212,37 @@ export const AchievementTeacher: React.FC<{className?: string}> = ({ className }
330
  ))}
331
  </div>
332
  )}
333
-
334
  <div className="mt-auto bg-gray-50 p-4 rounded-xl border border-gray-200">
335
- <div className="flex justify-between items-center mb-2">
336
- <span className="text-sm text-gray-600">当前学期: <b>{semester}</b></span>
337
- <span className="text-sm text-gray-600">已选: <b>{selectedStudents.size}</b> 人</span>
338
- </div>
339
- <button onClick={handleGrant} disabled={!selectedAchieveId || selectedStudents.size===0} className="w-full bg-blue-600 text-white py-3 rounded-lg font-bold hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed shadow-md">
340
- 确认发放
341
- </button>
342
  </div>
343
  </div>
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>
357
- <input type="number" min={1} className="w-full border rounded p-2 text-sm" value={newRule.cost} onChange={e => setNewRule({...newRule,cost: Number(e.target.value)})}/>
358
- </div>
359
- <div className="w-full md:w-32">
360
- <label className="text-xs font-bold text-gray-500 uppercase mb-1 block">奖励类型</label>
361
- <select className="w-full border rounded p-2 text-sm bg-white" value={newRule.rewardType} onChange={e => setNewRule({...newRule, rewardType: e.target.value as any})}>
362
- <option value="DRAW_COUNT">🎲 抽奖券</option>
363
- <option value="ITEM">🎁 实物/特权</option>
364
- </select>
365
- </div>
366
- <div className="flex-1 w-full">
367
- <label className="text-xs font-bold text-gray-500 uppercase mb-1 block">奖励名称</label>
368
- <input className="w-full border rounded p-2 text-sm" placeholder="如: 免作业券" value={newRule.rewardName} onChange={e => setNewRule({...newRule, rewardName: e.target.value})}/>
369
- </div>
370
- <div className="w-24">
371
- <label className="text-xs font-bold text-gray-500 uppercase mb-1 block">数量</label>
372
- <input type="number" min={1} className="w-full border rounded p-2 text-sm" value={newRule.rewardValue} onChange={e => setNewRule({...newRule, rewardValue: Number(e.target.value)})}/>
373
- </div>
374
  <button onClick={addRule} className="bg-green-600 text-white px-4 py-2 rounded-lg font-bold hover:bg-green-700 whitespace-nowrap text-sm">添加规则</button>
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">
384
- {rule.cost} <Emoji symbol="🌺" size={14}/>
385
- </div>
386
- <div className="text-gray-400">➡️</div>
387
- <div className="flex items-center gap-2">
388
- <span className="text-2xl"><Emoji symbol={rule.rewardType === 'DRAW_COUNT' ? '🎲' : '🎁'} /></span>
389
- <div>
390
- <div className="font-bold text-gray-800">{rule.rewardName}</div>
391
- <div className="text-xs text-gray-500">x{rule.rewardValue}</div>
392
- </div>
393
- </div>
394
- </div>
395
- <button onClick={() => deleteRule(rule.id)} className="text-gray-300 hover:text-red-500 p-2"><Trash2 size={18}/></button>
396
  </div>
397
- ))}
398
- </div>
 
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">
406
- <thead className="bg-gray-50 text-gray-500 text-xs uppercase">
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">
414
- {students.length === 0 ? (
415
- <tr><td colSpan={3} className="p-4 text-center text-gray-400">暂无学生数据</td></tr>
416
- ) : students.map(s => (
417
- <tr key={s._id} className="hover:bg-gray-50">
418
- <td className="p-4 font-bold text-gray-700">{s.seatNo ? s.seatNo+'.':''}{s.name}</td>
419
- <td className="p-4">
420
- <span className="text-amber-600 font-bold bg-amber-50 px-2 py-1 rounded border border-amber-100">
421
- {s.flowerBalance || 0} <Emoji symbol="🌺" size={14}/>
422
- </span>
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}
430
- onClick={() => handleProxyExchange(s._id || String(s.id), r.id)}
431
- className="text-xs border border-gray-200 px-2 py-1 rounded hover:bg-blue-50 hover:text-blue-600 hover:border-blue-200 disabled:opacity-30 disabled:cursor-not-allowed"
432
- title={`消耗 ${r.cost} 花兑换 ${r.rewardName}`}
433
- >
434
- 兑换 {r.rewardName} (-{r.cost})
435
- </button>
436
- )) : <span className="text-gray-300 text-xs">您没有设置规则</span>}
437
- </div>
438
- </td>
439
- </tr>
440
- ))}
441
- </tbody>
442
- </table>
443
- </div>
444
  )}
445
  </div>
446
  </div>
 
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
+ import { ConfirmModal } from '../components/ConfirmModal';
8
 
9
  const PRESET_ICONS = [
10
  { icon: '🏆', label: '冠军杯' }, { icon: '🥇', label: '金牌' }, { icon: '🥈', label: '银牌' }, { icon: '🥉', label: '铜牌' },
 
20
  const [myExchangeConfig, setMyExchangeConfig] = useState<TeacherExchangeConfig | null>(null);
21
  const [students, setStudents] = useState<Student[]>([]);
22
  const [semester, setSemester] = useState('');
 
 
23
  const [activeTab, setActiveTab] = useState<'manage' | 'grant' | 'exchange' | 'balance'>('manage');
 
 
24
  const [newAchieve, setNewAchieve] = useState<AchievementItem>({ id: '', name: '', icon: '🏆', points: 1 });
25
  const [newRule, setNewRule] = useState<ExchangeRule>({ id: '', cost: 10, rewardType: 'DRAW_COUNT', rewardName: '抽奖券x1', rewardValue: 1 });
26
+ const [filterCreator, setFilterCreator] = useState('ALL');
27
+ const [filterGrantCreator, setFilterGrantCreator] = useState('ALL');
 
 
 
 
28
  const [selectedStudents, setSelectedStudents] = useState<Set<string>>(new Set());
29
  const [selectedAchieveId, setSelectedAchieveId] = useState('');
30
+ const [confirmModal, setConfirmModal] = useState<{isOpen: boolean, title: string, message: string, onConfirm: () => void}>({ isOpen: false, title: '', message: '', onConfirm: () => {} });
31
 
32
  const currentUser = api.auth.getCurrentUser();
33
  const homeroomClass = className || currentUser?.homeroomClass;
34
+ const isHomeroom = currentUser?.homeroomClass === homeroomClass;
 
 
35
  const isPrincipal = currentUser?.role === 'PRINCIPAL';
36
+ const canManageAll = isHomeroom || isPrincipal;
37
 
38
+ useEffect(() => { loadData(); }, [homeroomClass]);
 
 
39
 
40
  const loadData = async () => {
41
  if (!homeroomClass) return;
42
  setLoading(true);
 
43
  try {
 
44
  const stus = await api.students.getAll();
45
+ const sortedStudents = stus.filter((s: Student) => s.className.trim() === homeroomClass.trim()).sort((a: Student, b: Student) => { const seatA = parseInt(a.seatNo || '99999'); const seatB = parseInt(b.seatNo || '99999'); if (seatA !== seatB) return seatA - seatB; return a.name.localeCompare(b.name, 'zh-CN'); });
 
 
 
 
 
 
 
46
  setStudents(sortedStudents);
 
 
47
  const [cfg, sysCfg, myRules] = await Promise.all([
48
  api.achievements.getConfig(homeroomClass).catch(() => null),
49
  api.config.getPublic().catch(() => ({ semester: '当前学期' })),
50
+ api.achievements.getMyRules().catch(() => ({ rules: [] }))
51
  ]);
52
+ const defaultConfig: AchievementConfig = { schoolId: currentUser?.schoolId || '', className: homeroomClass, achievements: [], exchangeRules: [] };
 
 
 
 
 
 
 
53
  setConfig(cfg || defaultConfig);
54
+ setMyExchangeConfig(myRules);
 
55
  // @ts-ignore
56
  setSemester(sysCfg?.semester || '当前学期');
57
+ } catch (e) { console.error("Failed to load data", e); } finally { setLoading(false); }
 
 
 
58
  };
59
 
 
60
  const handleSaveConfig = async (newConfig: AchievementConfig) => {
61
  setConfig(newConfig);
62
+ try { await api.achievements.saveConfig(newConfig); } catch (e) { alert('保存失败'); loadData(); }
 
 
 
 
63
  };
64
 
 
65
  const handleSaveMyRules = async (newRules: ExchangeRule[]) => {
66
+ const payload = { ...myExchangeConfig, rules: newRules } as TeacherExchangeConfig;
67
+ setMyExchangeConfig(payload);
68
+ try { await api.achievements.saveMyRules(payload); } catch(e) { alert('保存失败'); loadData(); }
 
 
 
 
 
 
69
  };
70
 
 
71
  const addAchievement = () => {
72
  if (!config) return;
73
  if (!newAchieve.name) return alert('请输入成就名称');
74
+ const newItem: AchievementItem = { ...newAchieve, id: Date.now().toString(), addedBy: currentUser?._id, addedByName: currentUser?.trueName || currentUser?.username };
 
 
 
 
 
 
75
  const updated = { ...config, achievements: [...config.achievements, newItem] };
 
76
  handleSaveConfig(updated);
77
  setNewAchieve({ id: '', name: '', icon: '🏆', points: 1 });
78
  };
79
 
80
  const deleteAchievement = (item: AchievementItem) => {
81
  if (!config) return;
 
82
  if (!canManageAll && item.addedBy !== currentUser?._id) return alert('权限不足:只能删除自己添加的成就');
83
+ setConfirmModal({
84
+ isOpen: true,
85
+ title: '删除成就',
86
+ message: `确定要删除 "${item.name}" 吗?`,
87
+ onConfirm: () => {
88
+ const updated = { ...config, achievements: config.achievements.filter(a => a.id !== item.id) };
89
+ handleSaveConfig(updated);
90
+ }
91
+ });
92
  };
93
 
 
94
  const handleGrant = async () => {
95
  if (selectedStudents.size === 0 || !selectedAchieveId) return alert('请选择学生和奖状');
96
  try {
97
+ const promises = Array.from(selectedStudents).map(sid => api.achievements.grant({ studentId: sid, achievementId: selectedAchieveId, semester }));
 
 
98
  await Promise.all(promises);
99
  alert(`成功发放给 ${selectedStudents.size} 位学生`);
100
  setSelectedStudents(new Set());
101
+ loadData();
102
  } catch (e) { alert('部分发放失败'); }
103
  };
104
 
 
105
  const addRule = () => {
106
  if (!newRule.rewardName) return alert('请输入奖励名称');
107
  const newItem = { ...newRule, id: Date.now().toString() };
 
111
  };
112
 
113
  const deleteRule = (id: string) => {
114
+ setConfirmModal({
115
+ isOpen: true,
116
+ title: '删除规则',
117
+ message: '确定要删除这条兑换规则吗?',
118
+ onConfirm: () => {
119
+ const currentRules = myExchangeConfig?.rules || [];
120
+ handleSaveMyRules(currentRules.filter(r => r.id !== id));
121
+ }
122
+ });
123
  };
124
 
 
125
  const handleProxyExchange = async (studentId: string, ruleId: string) => {
126
+ setConfirmModal({
127
+ isOpen: true,
128
+ title: '代兑换',
129
+ message: '确认代学生兑换?将扣除对应小红花并记录。',
130
+ onConfirm: async () => {
131
+ try {
132
+ await api.achievements.exchange({ studentId, ruleId, teacherId: currentUser?._id });
133
+ alert('兑换成功');
134
+ loadData();
135
+ } catch (e: any) { alert(e.message || '兑换失败,余额不足?'); }
136
+ }
137
+ });
138
  };
139
 
140
  if (!homeroomClass) return <div className="p-10 text-center text-gray-500">您不是班主任,无法使用成就管理功能。</div>;
 
142
 
143
  const safeConfig = config || { achievements: [], exchangeRules: [] };
144
  const myRules = myExchangeConfig?.rules || [];
 
 
145
  const uniqueCreators = Array.from(new Set(safeConfig.achievements.map(a => a.addedByName || '未知'))).filter(Boolean);
146
  const filteredAchievements = safeConfig.achievements.filter(a => filterCreator === 'ALL' || a.addedByName === filterCreator);
147
  const filteredGrantAchievements = safeConfig.achievements.filter(a => filterGrantCreator === 'ALL' || a.addedByName === filterGrantCreator);
148
 
149
  return (
150
  <div className="h-full flex flex-col bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
151
+ <ConfirmModal isOpen={confirmModal.isOpen} title={confirmModal.title} message={confirmModal.message} onClose={()=>setConfirmModal({...confirmModal, isOpen: false})} onConfirm={confirmModal.onConfirm}/>
152
  <div className="flex border-b border-gray-100 bg-gray-50/50 overflow-x-auto">
153
+ <button onClick={() => setActiveTab('manage')} className={`px-6 py-4 text-sm font-bold border-b-2 transition-colors flex items-center gap-2 whitespace-nowrap ${activeTab === 'manage' ? 'border-blue-500 text-blue-600 bg-white' : 'border-transparent text-gray-500 hover:text-gray-700'}`}><Award size={18}/> 成就库管理</button>
154
+ <button onClick={() => setActiveTab('grant')} className={`px-6 py-4 text-sm font-bold border-b-2 transition-colors flex items-center gap-2 whitespace-nowrap ${activeTab === 'grant' ? 'border-blue-500 text-blue-600 bg-white' : 'border-transparent text-gray-500 hover:text-gray-700'}`}><Gift size={18}/> 发放成就</button>
155
+ <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'}`}><Coins size={18}/> 兑换规则 (我的)</button>
156
+ <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'}`}><Users size={18}/> 学生积分</button>
 
 
 
 
 
 
 
 
157
  </div>
 
158
  <div className="flex-1 overflow-y-auto p-6">
 
159
  {activeTab === 'manage' && (
160
  <div className="space-y-6 max-w-4xl mx-auto">
161
+ <div className="flex justify-end items-center gap-2"><span className="text-xs font-bold text-gray-500"><Filter size={14} className="inline mr-1"/>筛选添加者:</span><select className="border rounded text-xs p-1" value={filterCreator} onChange={e=>setFilterCreator(e.target.value)}><option value="ALL">全部老师</option>{uniqueCreators.map(c => <option key={c} value={c}>{c}</option>)}</select></div>
 
 
 
 
 
 
 
 
162
  <div className="bg-blue-50 p-4 rounded-xl border border-blue-100 flex flex-col md:flex-row gap-4 items-end">
163
+ <div className="flex-1 w-full"><label className="text-xs font-bold text-gray-500 uppercase mb-1 block">成就名称</label><input className="w-full border rounded p-2 text-sm" placeholder="如: 劳动小能手" value={newAchieve.name} onChange={e => setNewAchieve({...newAchieve, name: e.target.value})} /></div>
164
+ <div className="w-full md:w-32"><label className="text-xs font-bold text-gray-500 uppercase mb-1 block">分值 (小红花)</label><input type="number" min={1} className="w-full border rounded p-2 text-sm" value={newAchieve.points} onChange={e => setNewAchieve({...newAchieve, points: Number(e.target.value)})} /></div>
165
+ <div className="w-full md:w-auto"><label className="text-xs font-bold text-gray-500 uppercase mb-1 block">图标预设</label><select className="w-full border rounded p-2 min-w-[100px] text-sm bg-white" value={newAchieve.icon} onChange={e => setNewAchieve({...newAchieve, icon: e.target.value})}>{PRESET_ICONS.map(p => <option key={p.label} value={p.icon}>{p.icon} {p.label}</option>)}</select></div>
 
 
 
 
 
 
 
 
 
 
 
166
  <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>
167
  </div>
168
+ {filteredAchievements.length === 0 ? <div className="text-center text-gray-400 py-10">暂无成就,请添加</div> : (
 
 
 
169
  <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
170
  {filteredAchievements.map(ach => {
171
  const isMine = ach.addedBy === currentUser?._id;
 
174
  <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">
175
  <div className="text-4xl mb-2"><Emoji symbol={ach.icon} /></div>
176
  <div className="font-bold text-gray-800 text-center">{ach.name}</div>
177
+ <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">{ach.points} <Emoji symbol="🌺" size={14}/></div>
178
+ <div className="mt-2 text-[10px] text-gray-400 bg-gray-50 px-1.5 py-0.5 rounded">{ach.addedByName || '未知'}</div>
179
+ {canDelete && <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"><Trash2 size={16}/></button>}
 
 
 
 
 
 
 
 
180
  </div>
181
  )})}
182
  </div>
183
  )}
184
  </div>
185
  )}
 
 
186
  {activeTab === 'grant' && (
187
  <div className="flex flex-col md:flex-row gap-6 h-full">
 
188
  <div className="w-full md:w-1/3 border-r border-gray-100 pr-4 flex flex-col min-h-[400px]">
189
  <h3 className="font-bold text-gray-700 mb-2">1. 选择学生 ({selectedStudents.size})</h3>
190
+ <button onClick={() => setSelectedStudents(new Set(selectedStudents.size === students.length ? [] : students.map(s => s._id || String(s.id))))} className="text-xs text-blue-600 mb-2 text-left hover:underline">{selectedStudents.size === students.length ? '取消全选' : '全选所有'}</button>
191
+ {students.length === 0 ? <div className="text-sm text-gray-400">班级暂无学生数据</div> : (
 
 
 
 
192
  <div className="flex-1 overflow-y-auto space-y-2 pr-2 custom-scrollbar max-h-[500px]">
193
  {students.map(s => (
194
+ <div key={s._id} onClick={() => { const newSet = new Set(selectedStudents); const sid = s._id || String(s.id); if (newSet.has(sid)) newSet.delete(sid); else newSet.add(sid); setSelectedStudents(newSet); }} className={`p-2 rounded border cursor-pointer flex items-center justify-between transition-colors ${selectedStudents.has(s._id || String(s.id)) ? 'bg-blue-50 border-blue-400' : 'bg-white border-gray-200 hover:bg-gray-50'}`}>
 
 
 
 
 
 
 
 
 
 
195
  <span className="text-sm font-medium">{s.seatNo ? s.seatNo+'.':''}{s.name}</span>
196
  {selectedStudents.has(s._id || String(s.id)) && <CheckCircle size={16} className="text-blue-500"/>}
197
  </div>
 
199
  </div>
200
  )}
201
  </div>
 
 
202
  <div className="flex-1 flex flex-col">
203
+ <div className="flex justify-between items-center mb-4"><h3 className="font-bold text-gray-700">2. 选择要颁发的奖状</h3><div className="flex items-center gap-2"><span className="text-xs text-gray-500">来自:</span><select className="border rounded text-xs p-1" value={filterGrantCreator} onChange={e=>setFilterGrantCreator(e.target.value)}><option value="ALL">全部老师</option>{uniqueCreators.map(c => <option key={c} value={c}>{c}</option>)}</select></div></div>
204
+ {filteredGrantAchievements.length === 0 ? <div className="text-gray-400 text-sm mb-6">暂无成就,请先去“成就库管理”添加。</div> : (
 
 
 
 
 
 
 
 
 
 
 
 
205
  <div className="grid grid-cols-2 sm:grid-cols-3 gap-3 mb-6 max-h-[400px] overflow-y-auto custom-scrollbar p-1">
206
  {filteredGrantAchievements.map(ach => (
207
+ <div key={ach.id} onClick={() => setSelectedAchieveId(ach.id)} className={`p-4 rounded-xl border cursor-pointer text-center transition-all ${selectedAchieveId === ach.id ? 'bg-amber-50 border-amber-400 ring-2 ring-amber-200' : 'bg-white border-gray-200 hover:bg-gray-50'}`}>
 
 
 
 
208
  <div className="text-3xl mb-1"><Emoji symbol={ach.icon} /></div>
209
  <div className="font-bold text-sm text-gray-800">{ach.name}</div>
210
  <div className="text-xs text-gray-500">+{ach.points} <Emoji symbol="🌺" size={12}/></div>
 
212
  ))}
213
  </div>
214
  )}
 
215
  <div className="mt-auto bg-gray-50 p-4 rounded-xl border border-gray-200">
216
+ <div className="flex justify-between items-center mb-2"><span className="text-sm text-gray-600">当前学期: <b>{semester}</b></span><span className="text-sm text-gray-600">已选: <b>{selectedStudents.size}</b> 人</span></div>
217
+ <button onClick={handleGrant} disabled={!selectedAchieveId || selectedStudents.size===0} className="w-full bg-blue-600 text-white py-3 rounded-lg font-bold hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed shadow-md">确认发放</button>
 
 
 
 
 
218
  </div>
219
  </div>
220
  </div>
221
  )}
 
 
222
  {activeTab === 'exchange' && (
223
  <div className="space-y-6 max-w-4xl mx-auto">
224
+ <div className="bg-purple-50 p-4 rounded-lg border border-purple-100 text-sm text-purple-800 mb-4"><span className="font-bold">提示:</span> 这里的兑换规则是您个人专属的,对您教的所有班级学生可见。学生兑换后,只有您能看到待处理的申请。</div>
 
 
 
225
  <div className="bg-green-50 p-4 rounded-xl border border-green-100 flex flex-col md:flex-row gap-4 items-end">
226
+ <div className="w-full md:w-32"><label className="text-xs font-bold text-gray-500 uppercase mb-1 block">消耗小红花</label><input type="number" min={1} className="w-full border rounded p-2 text-sm" value={newRule.cost} onChange={e => setNewRule({...newRule,cost: Number(e.target.value)})}/></div>
227
+ <div className="w-full md:w-32"><label className="text-xs font-bold text-gray-500 uppercase mb-1 block">奖励类型</label><select className="w-full border rounded p-2 text-sm bg-white" value={newRule.rewardType} onChange={e => setNewRule({...newRule, rewardType: e.target.value as any})}><option value="DRAW_COUNT">🎲 抽奖券</option><option value="ITEM">🎁 实物/特权</option></select></div>
228
+ <div className="flex-1 w-full"><label className="text-xs font-bold text-gray-500 uppercase mb-1 block">奖励名称</label><input className="w-full border rounded p-2 text-sm" placeholder="如: 免作业券" value={newRule.rewardName} onChange={e => setNewRule({...newRule, rewardName: e.target.value})}/></div>
229
+ <div className="w-24"><label className="text-xs font-bold text-gray-500 uppercase mb-1 block">数量</label><input type="number" min={1} className="w-full border rounded p-2 text-sm" value={newRule.rewardValue} onChange={e => setNewRule({...newRule, rewardValue: Number(e.target.value)})}/></div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
230
  <button onClick={addRule} className="bg-green-600 text-white px-4 py-2 rounded-lg font-bold hover:bg-green-700 whitespace-nowrap text-sm">添加规则</button>
231
  </div>
232
+ <div className="space-y-3">{myRules.length === 0 ? <div className="text-center text-gray-400 py-4">暂无个人兑换规则</div> : myRules.map(rule => (
233
+ <div key={rule.id} className="flex items-center justify-between p-4 bg-white border border-gray-200 rounded-xl hover:shadow-sm">
234
+ <div className="flex items-center gap-4">
235
+ <div className="bg-amber-100 text-amber-700 font-bold px-3 py-1 rounded-lg border border-amber-200">{rule.cost} <Emoji symbol="🌺" size={14}/></div>
236
+ <div className="text-gray-400">➡️</div>
237
+ <div className="flex items-center gap-2"><span className="text-2xl"><Emoji symbol={rule.rewardType === 'DRAW_COUNT' ? '🎲' : '🎁'} /></span><div><div className="font-bold text-gray-800">{rule.rewardName}</div><div className="text-xs text-gray-500">x{rule.rewardValue}</div></div></div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
238
  </div>
239
+ <button onClick={() => deleteRule(rule.id)} className="text-gray-300 hover:text-red-500 p-2"><Trash2 size={18}/></button>
240
+ </div>
241
+ ))}</div>
242
  </div>
243
  )}
 
 
244
  {activeTab === 'balance' && (
245
+ <div className="overflow-x-auto"><table className="w-full text-left border-collapse"><thead className="bg-gray-50 text-gray-500 text-xs uppercase"><tr><th className="p-4">学生姓名</th><th className="p-4">小红花余额</th><th className="p-4 text-right">操作 (使用我的规则兑换)</th></tr></thead><tbody className="divide-y divide-gray-100">{students.length === 0 ? <tr><td colSpan={3} className="p-4 text-center text-gray-400">暂无学生数据</td></tr> : students.map(s => (<tr key={s._id} className="hover:bg-gray-50"><td className="p-4 font-bold text-gray-700">{s.seatNo ? s.seatNo+'.':''}{s.name}</td><td className="p-4"><span className="text-amber-600 font-bold bg-amber-50 px-2 py-1 rounded border border-amber-100">{s.flowerBalance || 0} <Emoji symbol="🌺" size={14}/></span></td><td className="p-4 text-right"><div className="flex justify-end gap-2">{myRules.length > 0 ? myRules.map(r => (<button key={r.id} disabled={(s.flowerBalance || 0) < r.cost} onClick={() => handleProxyExchange(s._id || String(s.id), r.id)} className="text-xs border border-gray-200 px-2 py-1 rounded hover:bg-blue-50 hover:text-blue-600 hover:border-blue-200 disabled:opacity-30 disabled:cursor-not-allowed" title={`消耗 ${r.cost} 花兑换 ${r.rewardName}`}>兑换 {r.rewardName} (-{r.cost})</button>)) : <span className="text-gray-300 text-xs">您没有设置规则</span>}</div></td></tr>))}</tbody></table></div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
246
  )}
247
  </div>
248
  </div>
pages/Attendance.tsx CHANGED
@@ -2,7 +2,7 @@
2
  import React, { useState, useEffect } from 'react';
3
  import { api } from '../services/api';
4
  import { Student, Attendance, SchoolCalendarEntry } from '../types';
5
- import { Calendar, CheckCircle, Clock, AlertCircle, Loader2, UserX, CalendarOff, Settings, Plus, Trash2, Sun } from 'lucide-react';
6
 
7
  export const AttendancePage: React.FC = () => {
8
  const [students, setStudents] = useState<Student[]>([]);
@@ -10,14 +10,12 @@ export const AttendancePage: React.FC = () => {
10
  const [loading, setLoading] = useState(true);
11
  const [date, setDate] = useState(new Date().toISOString().split('T')[0]);
12
 
13
- // Calendar State
14
  const [calendarEntries, setCalendarEntries] = useState<SchoolCalendarEntry[]>([]);
15
  const [isHoliday, setIsHoliday] = useState(false);
16
  const [holidayName, setHolidayName] = useState('');
17
 
18
- // Settings Modal
19
  const [isSettingsOpen, setIsSettingsOpen] = useState(false);
20
- const [excludeWeekends, setExcludeWeekends] = useState(true); // Default to exclude
21
  const [newHoliday, setNewHoliday] = useState({ name: '', startDate: '', endDate: '' });
22
 
23
  const currentUser = api.auth.getCurrentUser();
@@ -33,15 +31,12 @@ export const AttendancePage: React.FC = () => {
33
  api.calendar.get(targetClass)
34
  ]);
35
  const classStudents = stus.filter((s: Student) => s.className === targetClass);
36
-
37
- // SORTING LOGIC: Seat No > Name
38
  classStudents.sort((a: Student, b: Student) => {
39
  const seatA = parseInt(a.seatNo || '99999');
40
  const seatB = parseInt(b.seatNo || '99999');
41
  if (seatA !== seatB) return seatA - seatB;
42
  return a.name.localeCompare(b.name, 'zh-CN');
43
  });
44
-
45
  setStudents(classStudents);
46
  setCalendarEntries(cals);
47
 
@@ -55,40 +50,39 @@ export const AttendancePage: React.FC = () => {
55
  finally { setLoading(false); }
56
  };
57
 
58
- useEffect(() => {
59
- loadData();
60
- }, [date]);
61
 
62
- // Check if selected date is a holiday or weekend
63
  const checkIsHoliday = (checkDate: string, entries: SchoolCalendarEntry[]) => {
64
  const d = new Date(checkDate);
65
- const day = d.getDay(); // 0 is Sunday, 6 is Saturday
66
 
67
- // 1. Check Weekend
68
- if (excludeWeekends && (day === 0 || day === 6)) {
69
- setIsHoliday(true);
70
- setHolidayName(day === 0 ? '周日' : '周六');
71
- return;
72
- }
73
-
74
- // 2. Check Calendar Entries
75
  const entry = entries.find(e => {
76
  return checkDate >= e.startDate && checkDate <= e.endDate;
77
  });
78
 
79
  if (entry) {
80
- setIsHoliday(true);
81
- setHolidayName(entry.name);
 
 
 
 
 
 
82
  } else {
83
- setIsHoliday(false);
84
- setHolidayName('');
 
 
 
 
 
 
85
  }
86
  };
87
 
88
- // Re-check when excludeWeekends toggles
89
- useEffect(() => {
90
- checkIsHoliday(date, calendarEntries);
91
- }, [excludeWeekends]);
92
 
93
  const handleBatchCheckIn = async () => {
94
  if (isHoliday) return alert('当前是非考勤日,无法执行全勤操作');
@@ -98,16 +92,13 @@ export const AttendancePage: React.FC = () => {
98
  };
99
 
100
  const toggleStatus = async (studentId: string) => {
101
- if (isHoliday) return; // Disable toggling on holidays
102
  const current = attendanceMap[studentId]?.status;
103
  let nextStatus = 'Present';
104
  if (current === 'Present') nextStatus = 'Leave';
105
  else if (current === 'Leave') nextStatus = 'Absent';
106
  else if (current === 'Absent') nextStatus = 'Present';
107
-
108
- // If no record, create as Present first
109
  if (!current) nextStatus = 'Present';
110
-
111
  await api.attendance.update({ studentId, date, status: nextStatus });
112
  loadData();
113
  };
@@ -116,33 +107,47 @@ export const AttendancePage: React.FC = () => {
116
  if (!newHoliday.name || !newHoliday.startDate || !newHoliday.endDate) return alert('请填写完整');
117
  await api.calendar.add({
118
  schoolId: currentUser?.schoolId!,
119
- className: targetClass, // Class specific holiday
120
  type: 'HOLIDAY',
121
  ...newHoliday
122
  });
123
  setNewHoliday({ name: '', startDate: '', endDate: '' });
124
- loadData(); // Reload to refresh calendar entries
125
  };
126
 
127
  const handleDeleteHoliday = async (id: string) => {
128
- if(confirm('删除此假期设置?')) {
129
  await api.calendar.delete(id);
130
  loadData();
131
  }
132
  };
133
 
134
- const markTodayExempt = async () => {
135
- if(confirm('确定将今天标记为“临时停课/免打卡”吗?')) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
136
  await api.calendar.add({
137
  schoolId: currentUser?.schoolId!,
138
  className: targetClass,
139
  type: 'OFF',
140
  startDate: date,
141
  endDate: date,
142
- name: '临时停课'
143
  });
144
- loadData();
145
  }
 
146
  };
147
 
148
  if (!targetClass) return <div className="p-10 text-center text-gray-500">您不是班主任,无法管理考勤。</div>;
@@ -157,7 +162,6 @@ export const AttendancePage: React.FC = () => {
157
 
158
  return (
159
  <div className="space-y-6">
160
- {/* Top Bar */}
161
  <div className="flex flex-col md:flex-row justify-between items-center bg-white p-4 rounded-xl shadow-sm border border-gray-100 gap-4">
162
  <div className="flex items-center gap-4 w-full md:w-auto">
163
  <h2 className="text-xl font-bold text-gray-800">考勤管理</h2>
@@ -173,43 +177,34 @@ export const AttendancePage: React.FC = () => {
173
 
174
  <div className="flex gap-2 items-center w-full md:w-auto">
175
  <input type="date" className="border rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-blue-500" value={date} onChange={e => setDate(e.target.value)} max={new Date().toISOString().split('T')[0]}/>
176
- {!isHoliday ? (
177
- <>
178
- <button onClick={handleBatchCheckIn} className="bg-blue-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-blue-700 transition-colors shadow-sm whitespace-nowrap">
179
- 一键全勤
180
- </button>
181
- <button onClick={markTodayExempt} className="bg-gray-100 text-gray-600 px-3 py-2 rounded-lg hover:bg-gray-200 text-sm whitespace-nowrap" title="标记今日停课">
182
- <CalendarOff size={18}/>
183
- </button>
184
- </>
185
- ) : (
186
- <span className="text-xs text-gray-400">免打卡模式</span>
 
 
 
187
  )}
 
188
  <button onClick={() => setIsSettingsOpen(true)} className="bg-gray-100 text-gray-600 px-3 py-2 rounded-lg hover:bg-gray-200 text-sm whitespace-nowrap">
189
  <Settings size={18}/>
190
  </button>
191
  </div>
192
  </div>
193
 
194
- {/* Stats Cards */}
195
  {!isHoliday && (
196
  <div className="grid grid-cols-2 md:grid-cols-4 gap-4 animate-in slide-in-from-top-2">
197
- <div className="bg-green-50 p-4 rounded-lg border border-green-100 flex flex-col items-center">
198
- <span className="text-green-600 font-bold text-2xl">{stats.present}</span>
199
- <span className="text-xs text-green-700">勤</span>
200
- </div>
201
- <div className="bg-orange-50 p-4 rounded-lg border border-orange-100 flex flex-col items-center">
202
- <span className="text-orange-500 font-bold text-2xl">{stats.leave}</span>
203
- <span className="text-xs text-orange-700">请假</span>
204
- </div>
205
- <div className="bg-red-50 p-4 rounded-lg border border-red-100 flex flex-col items-center">
206
- <span className="text-red-500 font-bold text-2xl">{stats.absent}</span>
207
- <span className="text-xs text-red-700">缺勤</span>
208
- </div>
209
- <div className="bg-gray-50 p-4 rounded-lg border border-gray-200 flex flex-col items-center">
210
- <span className="text-gray-500 font-bold text-2xl">{missing}</span>
211
- <span className="text-xs text-gray-600">未记录</span>
212
- </div>
213
  </div>
214
  )}
215
 
@@ -218,7 +213,6 @@ export const AttendancePage: React.FC = () => {
218
  {students.map(s => {
219
  const record = attendanceMap[s._id || String(s.id)];
220
  const status = record?.status || 'None';
221
-
222
  let bg = 'bg-white border-gray-200';
223
  let icon = <Clock size={20} className="text-gray-300"/>;
224
  let text = '未打卡';
@@ -247,71 +241,31 @@ export const AttendancePage: React.FC = () => {
247
  }
248
 
249
  return (
250
- <div
251
- key={s._id}
252
- onClick={() => toggleStatus(s._id || String(s.id))}
253
- className={`p-4 rounded-xl border cursor-pointer transition-all hover:shadow-md flex flex-col items-center justify-center space-y-2 select-none relative group ${bg} ${isHoliday ? 'cursor-default' : ''}`}
254
- >
255
- <div className={`w-12 h-12 rounded-full flex items-center justify-center text-lg font-bold text-white ${s.gender==='Female'?'bg-pink-400':'bg-blue-400'} ${isHoliday ? 'grayscale opacity-50' : ''}`}>
256
- {s.name[0]}
257
- </div>
258
- <div className="text-center">
259
- <p className="font-bold text-gray-800 text-sm">{s.name}</p>
260
- <p className="text-[10px] text-gray-400">{s.seatNo ? `${s.seatNo}号` : s.studentNo}</p>
261
- </div>
262
- <div className={`flex items-center gap-1 text-xs font-bold ${textColor}`}>
263
- {icon} {text}
264
- </div>
265
- {!isHoliday && (
266
- <div className="absolute inset-0 bg-black/5 opacity-0 group-hover:opacity-100 rounded-xl transition-opacity flex items-center justify-center text-xs text-gray-600 font-bold">
267
- 点击切换
268
- </div>
269
- )}
270
  </div>
271
  );
272
  })}
273
  </div>
274
  )}
275
 
276
- {/* Calendar Settings Modal */}
277
  {isSettingsOpen && (
278
  <div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
279
  <div className="bg-white rounded-xl w-full max-w-lg p-6 shadow-2xl animate-in zoom-in-95">
280
  <h3 className="font-bold text-lg mb-4 flex items-center"><Calendar className="mr-2"/> 考勤日历设置</h3>
281
-
282
  <div className="space-y-6">
283
- <div className="bg-blue-50 p-4 rounded-lg flex justify-between items-center">
284
- <div>
285
- <p className="font-bold text-blue-800 text-sm">周末自动免打卡</p>
286
- <p className="text-xs text-blue-600">开启后,周六和周日将自动标记为休息日</p>
287
- </div>
288
- <input type="checkbox" checked={excludeWeekends} onChange={e => setExcludeWeekends(e.target.checked)} className="w-5 h-5"/>
289
- </div>
290
-
291
  <div>
292
- <label className="text-sm font-bold text-gray-700 mb-2 block">自定义假期 / 停课时段</label>
293
- <div className="flex gap-2 mb-3">
294
- <input className="border rounded p-2 text-xs flex-1" placeholder="假期名称 (如: 国庆节)" value={newHoliday.name} onChange={e=>setNewHoliday({...newHoliday, name:e.target.value})}/>
295
- <input type="date" className="border rounded p-2 text-xs" value={newHoliday.startDate} onChange={e=>setNewHoliday({...newHoliday, startDate:e.target.value})}/>
296
- <span className="self-center text-gray-400">-</span>
297
- <input type="date" className="border rounded p-2 text-xs" value={newHoliday.endDate} onChange={e=>setNewHoliday({...newHoliday, endDate:e.target.value})}/>
298
- <button onClick={handleAddHoliday} className="bg-green-600 text-white p-2 rounded hover:bg-green-700"><Plus size={16}/></button>
299
- </div>
300
-
301
  <div className="max-h-40 overflow-y-auto border rounded bg-gray-50 custom-scrollbar">
302
- {calendarEntries.length > 0 ? calendarEntries.map(c => (
303
- <div key={c._id} className="flex justify-between items-center p-2 border-b last:border-0 hover:bg-white text-sm">
304
- <div>
305
- <span className="font-bold text-gray-800 mr-2">{c.name}</span>
306
- <span className="text-xs text-gray-500">{c.startDate} ~ {c.endDate}</span>
307
- </div>
308
- <button onClick={() => handleDeleteHoliday(c._id!)} className="text-gray-400 hover:text-red-500"><Trash2 size={14}/></button>
309
- </div>
310
- )) : <p className="text-center text-gray-400 text-xs py-4">暂无自定义假期</p>}
311
  </div>
312
  </div>
313
  </div>
314
-
315
  <button onClick={() => setIsSettingsOpen(false)} className="mt-6 w-full py-2 bg-gray-100 hover:bg-gray-200 rounded-lg text-gray-700 font-bold">关闭</button>
316
  </div>
317
  </div>
 
2
  import React, { useState, useEffect } from 'react';
3
  import { api } from '../services/api';
4
  import { Student, Attendance, SchoolCalendarEntry } from '../types';
5
+ import { Calendar, CheckCircle, Clock, AlertCircle, Loader2, UserX, CalendarOff, Settings, Plus, Trash2, Sun, Briefcase } from 'lucide-react';
6
 
7
  export const AttendancePage: React.FC = () => {
8
  const [students, setStudents] = useState<Student[]>([]);
 
10
  const [loading, setLoading] = useState(true);
11
  const [date, setDate] = useState(new Date().toISOString().split('T')[0]);
12
 
 
13
  const [calendarEntries, setCalendarEntries] = useState<SchoolCalendarEntry[]>([]);
14
  const [isHoliday, setIsHoliday] = useState(false);
15
  const [holidayName, setHolidayName] = useState('');
16
 
 
17
  const [isSettingsOpen, setIsSettingsOpen] = useState(false);
18
+ const [excludeWeekends, setExcludeWeekends] = useState(true);
19
  const [newHoliday, setNewHoliday] = useState({ name: '', startDate: '', endDate: '' });
20
 
21
  const currentUser = api.auth.getCurrentUser();
 
31
  api.calendar.get(targetClass)
32
  ]);
33
  const classStudents = stus.filter((s: Student) => s.className === targetClass);
 
 
34
  classStudents.sort((a: Student, b: Student) => {
35
  const seatA = parseInt(a.seatNo || '99999');
36
  const seatB = parseInt(b.seatNo || '99999');
37
  if (seatA !== seatB) return seatA - seatB;
38
  return a.name.localeCompare(b.name, 'zh-CN');
39
  });
 
40
  setStudents(classStudents);
41
  setCalendarEntries(cals);
42
 
 
50
  finally { setLoading(false); }
51
  };
52
 
53
+ useEffect(() => { loadData(); }, [date]);
 
 
54
 
 
55
  const checkIsHoliday = (checkDate: string, entries: SchoolCalendarEntry[]) => {
56
  const d = new Date(checkDate);
57
+ const day = d.getDay();
58
 
59
+ // 1. Check for Explicit Calendar Entries (High Priority)
 
 
 
 
 
 
 
60
  const entry = entries.find(e => {
61
  return checkDate >= e.startDate && checkDate <= e.endDate;
62
  });
63
 
64
  if (entry) {
65
+ // If WORKDAY, it is NOT a holiday, even if weekend
66
+ if (entry.type === 'WORKDAY') {
67
+ setIsHoliday(false);
68
+ setHolidayName('');
69
+ } else {
70
+ setIsHoliday(true);
71
+ setHolidayName(entry.name);
72
+ }
73
  } else {
74
+ // 2. Fallback to Weekend Check
75
+ if (excludeWeekends && (day === 0 || day === 6)) {
76
+ setIsHoliday(true);
77
+ setHolidayName(day === 0 ? '周日' : '周六');
78
+ } else {
79
+ setIsHoliday(false);
80
+ setHolidayName('');
81
+ }
82
  }
83
  };
84
 
85
+ useEffect(() => { checkIsHoliday(date, calendarEntries); }, [excludeWeekends]);
 
 
 
86
 
87
  const handleBatchCheckIn = async () => {
88
  if (isHoliday) return alert('当前是非考勤日,无法执行全勤操作');
 
92
  };
93
 
94
  const toggleStatus = async (studentId: string) => {
95
+ if (isHoliday) return;
96
  const current = attendanceMap[studentId]?.status;
97
  let nextStatus = 'Present';
98
  if (current === 'Present') nextStatus = 'Leave';
99
  else if (current === 'Leave') nextStatus = 'Absent';
100
  else if (current === 'Absent') nextStatus = 'Present';
 
 
101
  if (!current) nextStatus = 'Present';
 
102
  await api.attendance.update({ studentId, date, status: nextStatus });
103
  loadData();
104
  };
 
107
  if (!newHoliday.name || !newHoliday.startDate || !newHoliday.endDate) return alert('请填写完整');
108
  await api.calendar.add({
109
  schoolId: currentUser?.schoolId!,
110
+ className: targetClass,
111
  type: 'HOLIDAY',
112
  ...newHoliday
113
  });
114
  setNewHoliday({ name: '', startDate: '', endDate: '' });
115
+ loadData();
116
  };
117
 
118
  const handleDeleteHoliday = async (id: string) => {
119
+ if(confirm('删除此设置?')) {
120
  await api.calendar.delete(id);
121
  loadData();
122
  }
123
  };
124
 
125
+ // Toggle Day Type (Workday <-> Holiday/Off)
126
+ const toggleDayType = async () => {
127
+ if (isHoliday) {
128
+ // Current is Holiday -> Set to Workday
129
+ if (!confirm(`确定将 ${date} 设为“工作日”并开启考勤吗?`)) return;
130
+ await api.calendar.add({
131
+ schoolId: currentUser?.schoolId!,
132
+ className: targetClass,
133
+ type: 'WORKDAY',
134
+ startDate: date,
135
+ endDate: date,
136
+ name: '补班/工作日'
137
+ });
138
+ } else {
139
+ // Current is Workday -> Set to Off
140
+ if (!confirm(`确定将 ${date} 标记为“休息日”并免除考勤吗?`)) return;
141
  await api.calendar.add({
142
  schoolId: currentUser?.schoolId!,
143
  className: targetClass,
144
  type: 'OFF',
145
  startDate: date,
146
  endDate: date,
147
+ name: '休息/停课'
148
  });
 
149
  }
150
+ loadData();
151
  };
152
 
153
  if (!targetClass) return <div className="p-10 text-center text-gray-500">您不是班主任,无法管理考勤。</div>;
 
162
 
163
  return (
164
  <div className="space-y-6">
 
165
  <div className="flex flex-col md:flex-row justify-between items-center bg-white p-4 rounded-xl shadow-sm border border-gray-100 gap-4">
166
  <div className="flex items-center gap-4 w-full md:w-auto">
167
  <h2 className="text-xl font-bold text-gray-800">考勤管理</h2>
 
177
 
178
  <div className="flex gap-2 items-center w-full md:w-auto">
179
  <input type="date" className="border rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-blue-500" value={date} onChange={e => setDate(e.target.value)} max={new Date().toISOString().split('T')[0]}/>
180
+
181
+ {/* Dynamic Toggle Button */}
182
+ <button
183
+ onClick={toggleDayType}
184
+ className={`px-3 py-2 rounded-lg text-sm whitespace-nowrap flex items-center ${isHoliday ? 'bg-green-100 text-green-700 hover:bg-green-200' : 'bg-orange-100 text-orange-700 hover:bg-orange-200'}`}
185
+ title={isHoliday ? "设为工作日 (开启考勤)" : "设为休息日 (免打卡)"}
186
+ >
187
+ {isHoliday ? <Briefcase size={18}/> : <CalendarOff size={18}/>}
188
+ </button>
189
+
190
+ {!isHoliday && (
191
+ <button onClick={handleBatchCheckIn} className="bg-blue-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-blue-700 transition-colors shadow-sm whitespace-nowrap">
192
+ 一键全勤
193
+ </button>
194
  )}
195
+
196
  <button onClick={() => setIsSettingsOpen(true)} className="bg-gray-100 text-gray-600 px-3 py-2 rounded-lg hover:bg-gray-200 text-sm whitespace-nowrap">
197
  <Settings size={18}/>
198
  </button>
199
  </div>
200
  </div>
201
 
 
202
  {!isHoliday && (
203
  <div className="grid grid-cols-2 md:grid-cols-4 gap-4 animate-in slide-in-from-top-2">
204
+ <div className="bg-green-50 p-4 rounded-lg border border-green-100 flex flex-col items-center"><span className="text-green-600 font-bold text-2xl">{stats.present}</span><span className="text-xs text-green-700">出勤</span></div>
205
+ <div className="bg-orange-50 p-4 rounded-lg border border-orange-100 flex flex-col items-center"><span className="text-orange-500 font-bold text-2xl">{stats.leave}</span><span className="text-xs text-orange-700">请假</span></div>
206
+ <div className="bg-red-50 p-4 rounded-lg border border-red-100 flex flex-col items-center"><span className="text-red-500 font-bold text-2xl">{stats.absent}</span><span className="text-xs text-red-700">勤</span></div>
207
+ <div className="bg-gray-50 p-4 rounded-lg border border-gray-200 flex flex-col items-center"><span className="text-gray-500 font-bold text-2xl">{missing}</span><span className="text-xs text-gray-600">未记录</span></div>
 
 
 
 
 
 
 
 
 
 
 
 
208
  </div>
209
  )}
210
 
 
213
  {students.map(s => {
214
  const record = attendanceMap[s._id || String(s.id)];
215
  const status = record?.status || 'None';
 
216
  let bg = 'bg-white border-gray-200';
217
  let icon = <Clock size={20} className="text-gray-300"/>;
218
  let text = '未打卡';
 
241
  }
242
 
243
  return (
244
+ <div key={s._id} onClick={() => toggleStatus(s._id || String(s.id))} className={`p-4 rounded-xl border cursor-pointer transition-all hover:shadow-md flex flex-col items-center justify-center space-y-2 select-none relative group ${bg} ${isHoliday ? 'cursor-default' : ''}`}>
245
+ <div className={`w-12 h-12 rounded-full flex items-center justify-center text-lg font-bold text-white ${s.gender==='Female'?'bg-pink-400':'bg-blue-400'} ${isHoliday ? 'grayscale opacity-50' : ''}`}>{s.name[0]}</div>
246
+ <div className="text-center"><p className="font-bold text-gray-800 text-sm">{s.name}</p><p className="text-[10px] text-gray-400">{s.seatNo ? `${s.seatNo}号` : s.studentNo}</p></div>
247
+ <div className={`flex items-center gap-1 text-xs font-bold ${textColor}`}>{icon} {text}</div>
248
+ {!isHoliday && <div className="absolute inset-0 bg-black/5 opacity-0 group-hover:opacity-100 rounded-xl transition-opacity flex items-center justify-center text-xs text-gray-600 font-bold">点击切换</div>}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
249
  </div>
250
  );
251
  })}
252
  </div>
253
  )}
254
 
 
255
  {isSettingsOpen && (
256
  <div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
257
  <div className="bg-white rounded-xl w-full max-w-lg p-6 shadow-2xl animate-in zoom-in-95">
258
  <h3 className="font-bold text-lg mb-4 flex items-center"><Calendar className="mr-2"/> 考勤日历设置</h3>
 
259
  <div className="space-y-6">
260
+ <div className="bg-blue-50 p-4 rounded-lg flex justify-between items-center"><div><p className="font-bold text-blue-800 text-sm">周末自动免打卡</p><p className="text-xs text-blue-600">开启后,周六和周日将自动标记为休息日</p></div><input type="checkbox" checked={excludeWeekends} onChange={e => setExcludeWeekends(e.target.checked)} className="w-5 h-5"/></div>
 
 
 
 
 
 
 
261
  <div>
262
+ <label className="text-sm font-bold text-gray-700 mb-2 block">自定义日程调整 (假期/调休)</label>
263
+ <div className="flex gap-2 mb-3"><input className="border rounded p-2 text-xs flex-1" placeholder="名称 (如: 国庆)" value={newHoliday.name} onChange={e=>setNewHoliday({...newHoliday, name:e.target.value})}/><input type="date" className="border rounded p-2 text-xs" value={newHoliday.startDate} onChange={e=>setNewHoliday({...newHoliday, startDate:e.target.value})}/><span className="self-center text-gray-400">-</span><input type="date" className="border rounded p-2 text-xs" value={newHoliday.endDate} onChange={e=>setNewHoliday({...newHoliday, endDate:e.target.value})}/><button onClick={handleAddHoliday} className="bg-green-600 text-white p-2 rounded hover:bg-green-700"><Plus size={16}/></button></div>
 
 
 
 
 
 
 
264
  <div className="max-h-40 overflow-y-auto border rounded bg-gray-50 custom-scrollbar">
265
+ {calendarEntries.length > 0 ? calendarEntries.map(c => (<div key={c._id} className="flex justify-between items-center p-2 border-b last:border-0 hover:bg-white text-sm"><div><span className={`font-bold mr-2 ${c.type==='WORKDAY'?'text-blue-600':'text-gray-800'}`}>{c.type==='WORKDAY' ? '[班]' : c.type==='OFF' ? '[休]' : ''} {c.name}</span><span className="text-xs text-gray-500">{c.startDate} ~ {c.endDate}</span></div><button onClick={() => handleDeleteHoliday(c._id!)} className="text-gray-400 hover:text-red-500"><Trash2 size={14}/></button></div>)) : <p className="text-center text-gray-400 text-xs py-4">暂无自定义设置</p>}
 
 
 
 
 
 
 
 
266
  </div>
267
  </div>
268
  </div>
 
269
  <button onClick={() => setIsSettingsOpen(false)} className="mt-6 w-full py-2 bg-gray-100 hover:bg-gray-200 rounded-lg text-gray-700 font-bold">关闭</button>
270
  </div>
271
  </div>
pages/Dashboard.tsx CHANGED
@@ -5,6 +5,8 @@ import { api } from '../services/api';
5
  import { Score, ClassInfo, Subject, Schedule, User } from '../types';
6
  import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
7
  import { StudentDashboard } from './StudentDashboard';
 
 
8
 
9
  interface DashboardProps {
10
  onNavigate: (view: string) => void;
@@ -18,34 +20,20 @@ export const gradeOrder: Record<string, number> = {
18
 
19
  export const sortGrades = (a: string, b: string) => (gradeOrder[a] || 99) - (gradeOrder[b] || 99);
20
 
21
- // Helper to extract grade from class string (e.g. "一年级(1)班" -> "一年级")
22
  const extractGrade = (s: string) => {
23
  if (!s) return '';
24
  const keys = Object.keys(gradeOrder);
25
  return keys.find(g => s.startsWith(g)) || '';
26
  };
27
 
28
- // Robust sort function
29
  export const sortClasses = (a: ClassInfo | string, b: ClassInfo | string) => {
30
- // Handle both ClassInfo objects and string names
31
  const nameA = typeof a === 'string' ? a : (a.grade + a.className);
32
  const nameB = typeof b === 'string' ? b : (b.grade + b.className);
33
-
34
  if (!nameA || !nameB) return 0;
35
-
36
- // 1. Sort by Grade First
37
  const gradeA = extractGrade(nameA);
38
  const gradeB = extractGrade(nameB);
39
-
40
- if (gradeA && gradeB && gradeA !== gradeB) {
41
- return sortGrades(gradeA, gradeB);
42
- }
43
-
44
- // 2. Sort by Class Number
45
- const getNum = (str: string) => {
46
- const match = str.match(/(\d+)/);
47
- return match ? parseInt(match[1]) : 0;
48
- };
49
  return getNum(nameA) - getNum(nameB);
50
  };
51
 
@@ -54,394 +42,126 @@ export const Dashboard: React.FC<DashboardProps> = ({ onNavigate }) => {
54
  const [warnings, setWarnings] = useState<string[]>([]);
55
  const [loading, setLoading] = useState(true);
56
 
57
- // Timetable Data
58
- const [showSchedule, setShowSchedule] = useState(false);
59
- const [showStatus, setShowStatus] = useState(false);
60
- const [schedules, setSchedules] = useState<Schedule[]>([]);
61
- const [classList, setClassList] = useState<ClassInfo[]>([]);
62
- const [subjects, setSubjects] = useState<Subject[]>([]);
63
- const [teachers, setTeachers] = useState<User[]>([]);
64
-
65
- const [viewGrade, setViewGrade] = useState('');
66
- const [editingCell, setEditingCell] = useState<{day: number, period: number} | null>(null);
67
- const [editForm, setEditForm] = useState({ className: '', subject: '', teacherName: '' });
68
-
69
  const [trendData, setTrendData] = useState<any[]>([]);
70
 
71
  const currentUser = api.auth.getCurrentUser();
72
- const isAdmin = currentUser?.role === 'ADMIN';
73
  const isTeacher = currentUser?.role === 'TEACHER';
74
  const isStudent = currentUser?.role === 'STUDENT';
75
 
76
- // If Student, Render Student Dashboard
77
- if (isStudent) {
78
- return <StudentDashboard />;
79
- }
80
-
81
- // --- Admin/Teacher Logic Below ---
82
-
83
  useEffect(() => {
84
- const loadData = async () => {
85
- setLoading(true);
 
 
 
86
  try {
87
- const [summary, scores, classes, subs, userList] = await Promise.all([
88
- api.stats.getSummary(),
89
- api.scores.getAll(),
90
- api.classes.getAll(),
91
- api.subjects.getAll(),
92
- api.users.getAll({ role: 'TEACHER' })
93
- ]);
94
- setStats(summary);
95
 
96
- // Safety check for classes
97
- const safeClasses = Array.isArray(classes) ? classes : [];
98
- setClassList(safeClasses);
 
 
 
 
 
99
 
100
- setSubjects(subs);
101
- setTeachers(userList);
102
-
103
- if (isAdmin && safeClasses.length > 0) {
104
- const grades = Array.from(new Set(safeClasses.map((c: ClassInfo) => c.grade))).sort(sortGrades);
105
- if (grades.length > 0) {
106
- setViewGrade(grades[0] as string);
107
- }
108
- }
109
-
110
- const newWarnings: string[] = [];
111
- subs.forEach((sub: Subject) => {
112
- const subScores = (scores as Score[]).filter((s: Score) => s.courseName === sub.name && s.status === 'Normal');
113
- if (subScores.length > 0) {
114
- const avg = subScores.reduce((a: number, b: Score) => a + b.score, 0) / subScores.length;
115
- if (avg < 60) newWarnings.push(`全校 ${sub.name} 学科平均分过低 (${avg.toFixed(1)}分)`);
116
- }
117
- });
118
- setWarnings(newWarnings);
119
-
120
- const examGroups: Record<string, number[]> = {};
121
- (scores as Score[]).filter((s: Score) => s.status==='Normal').forEach((s: Score) => {
122
- const key = s.examName || s.type;
123
- if(!examGroups[key]) examGroups[key] = [];
124
- examGroups[key].push(s.score);
125
- });
126
- const chartData = Object.keys(examGroups).map(k => ({
127
- name: k,
128
- avg: Math.round(examGroups[k].reduce((a,b)=>a+b,0)/examGroups[k].length)
129
- })).slice(0, 6);
130
- setTrendData(chartData);
131
-
132
- } catch (error) { console.error(error); }
133
- finally { setLoading(false); }
134
  };
135
- loadData();
136
- }, []);
137
-
138
- useEffect(() => {
139
- // Fetch schedules whenever showSchedule opens OR viewGrade changes
140
- if (showSchedule || viewGrade) fetchSchedules();
141
- }, [showSchedule, viewGrade]);
142
-
143
- const fetchSchedules = async () => {
144
- try {
145
- const params: any = {};
146
- if (isAdmin) {
147
- if (!viewGrade) return;
148
- // Sending grade parameter which backend will now treat as a regex filter for className
149
- params.grade = viewGrade;
150
- } else {
151
- if (currentUser?.role === 'TEACHER') {
152
- params.teacherName = currentUser.trueName || currentUser.username;
153
- }
154
- }
155
- const data = await api.schedules.get(params);
156
- setSchedules(data);
157
- } catch(e) { console.error(e); }
158
- };
159
-
160
- const handleOpenAddModal = (day: number, period: number) => {
161
- setEditingCell({ day, period });
162
- setEditForm({
163
- className: '',
164
- subject: isTeacher ? (currentUser?.teachingSubject || '') : '',
165
- teacherName: isTeacher ? (currentUser?.trueName || currentUser?.username || '') : ''
166
- });
167
- };
168
-
169
- const handleSaveSchedule = async () => {
170
- if (!editingCell) return;
171
- if (!editForm.className) return alert('请选择班级');
172
- if (!editForm.subject) return alert('请选择科目');
173
- if (!editForm.teacherName) return alert('请选择任课教师');
174
-
175
- try {
176
- await api.schedules.save({
177
- className: editForm.className,
178
- dayOfWeek: editingCell.day,
179
- period: editingCell.period,
180
- subject: editForm.subject,
181
- teacherName: editForm.teacherName
182
- });
183
- setEditingCell(null);
184
- fetchSchedules();
185
- } catch (err: any) {
186
- alert('排课失败:' + (err.message || '未知错误'));
187
- }
188
- };
189
-
190
- const handleDeleteSchedule = async (schedule: Schedule) => {
191
- if (!confirm(`确定删除 ${schedule.className} 的 ${schedule.subject} 课?`)) return;
192
- await api.schedules.delete({
193
- className: schedule.className,
194
- dayOfWeek: schedule.dayOfWeek,
195
- period: schedule.period
196
- });
197
- fetchSchedules();
198
- };
199
-
200
- const uniqueGrades = Array.from(new Set(classList.map(c => c.grade))).sort(sortGrades);
201
-
202
- // Robust class mapping for modal
203
- const modalClassOptions = classList
204
- .filter(c => !isAdmin || !viewGrade || c.grade === viewGrade)
205
- .map(c => ({
206
- id: c._id,
207
- name: (c.className.startsWith(c.grade) ? c.className : c.grade + c.className)
208
- }))
209
- .sort((a,b) => sortClasses(a.name, b.name))
210
- .map(c => c.name);
211
 
212
- const cards = [
213
- { label: '在校学生', value: stats.studentCount, icon: Users, color: 'bg-blue-500', trend: '实时' },
214
- { label: '开设课程', value: stats.courseCount, icon: BookOpen, color: 'bg-emerald-500', trend: '实时' },
215
- { label: '平均成绩', value: stats.avgScore, icon: GraduationCap, color: 'bg-violet-500', trend: '全校' },
216
- { label: '优秀率', value: stats.excellentRate, icon: TrendingUp, color: 'bg-orange-500', trend: '>=90分' },
217
- ];
218
 
 
219
  return (
220
  <div className="space-y-6">
221
- <div className="flex flex-col md:flex-row justify-between items-start md:items-center">
222
- <div>
223
- <h2 className="text-2xl font-bold text-gray-800">教务工作台</h2>
224
- <p className="text-gray-500 mt-1">欢迎回来,{currentUser?.trueName || currentUser?.username}</p>
 
 
 
 
 
 
 
225
  </div>
226
- <div className="flex space-x-3 mt-4 md:mt-0">
227
- <button onClick={() => setShowSchedule(true)} className="flex items-center space-x-2 px-4 py-2 bg-white border border-gray-200 rounded-lg text-sm font-medium text-gray-600 hover:bg-gray-50 shadow-sm transition-colors">
228
- <Calendar size={16}/><span>智能课程表</span>
229
- </button>
230
- <button onClick={() => setShowStatus(true)} className="flex items-center space-x-2 px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 shadow-sm shadow-blue-200 transition-colors">
231
- <Activity size={16}/><span>系统状态</span>
232
- </button>
 
233
  </div>
234
- </div>
235
-
236
- <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
237
- {cards.map((card, index) => (
238
- <div key={index} className="bg-white rounded-xl shadow-sm border border-gray-100 p-6 flex items-start justify-between hover:shadow-md transition-shadow relative overflow-hidden group">
239
- <div className="relative z-10">
240
- <p className="text-sm font-medium text-gray-500 mb-1">{card.label}</p>
241
- <h3 className="text-3xl font-bold text-gray-800">{card.value}</h3>
242
- <div className={`flex items-center mt-2 text-xs font-medium text-gray-400`}>
243
- <span className={`px-1.5 py-0.5 rounded bg-gray-100 text-gray-600 mr-2`}>{card.trend}</span>
244
- 统计数据
245
- </div>
246
  </div>
247
- <div className={`p-3 rounded-xl ${card.color} bg-opacity-10 text-white shadow-sm group-hover:scale-110 transition-transform`}>
248
- <card.icon className={`h-6 w-6 ${card.color.replace('bg-', 'text-')}`} />
 
249
  </div>
250
- </div>
251
- ))}
252
- </div>
253
-
254
- <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
255
- <div className="lg:col-span-2 bg-white rounded-xl shadow-sm border border-gray-100 p-6">
256
- <div className="flex items-center justify-between mb-6">
257
- <h3 className="text-lg font-bold text-gray-800">全校考试成绩走势</h3>
258
- <button onClick={() => onNavigate('reports')} className="text-sm text-blue-600 hover:text-blue-700 font-medium">查看详情 &rarr;</button>
259
- </div>
260
- <div className="h-64">
261
- {trendData.length > 0 ? (
262
- <ResponsiveContainer width="100%" height="100%">
263
- <LineChart data={trendData}>
264
- <CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f0f0f0"/>
265
- <XAxis dataKey="name" axisLine={false} tickLine={false} tick={{fontSize:12}}/>
266
- <YAxis axisLine={false} tickLine={false} domain={[0, 100]}/>
267
- <Tooltip contentStyle={{borderRadius: '8px', border:'none', boxShadow:'0 4px 6px -1px rgba(0,0,0,0.1)'}}/>
268
- <Line type="monotone" dataKey="avg" stroke="#3b82f6" strokeWidth={3} dot={{r:4}} name="平均分"/>
269
- </LineChart>
270
- </ResponsiveContainer>
271
- ) : <div className="h-full flex items-center justify-center text-gray-400">暂无考试数据</div>}
272
- </div>
273
  </div>
274
-
275
- <div className="space-y-6">
276
- <div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6">
277
- <div className="flex items-center justify-between mb-4">
278
- <h3 className="font-bold text-gray-800 flex items-center"><AlertTriangle className="text-amber-500 mr-2" size={18}/>教学预警</h3>
279
- <span className="bg-amber-100 text-amber-700 text-xs px-2 py-0.5 rounded-full">{warnings.length}</span>
280
- </div>
281
- <div className="space-y-3 max-h-48 overflow-y-auto custom-scrollbar">
282
- {warnings.length > 0 ? warnings.map((w, i) => (
283
- <div key={i} className="text-xs text-gray-600 bg-amber-50 p-2 rounded border border-amber-100">{w}</div>
284
- )) : <div className="text-center text-xs text-gray-400">运行平稳</div>}
285
- </div>
286
- </div>
287
-
288
- <div className="bg-gradient-to-br from-indigo-600 to-blue-700 rounded-xl shadow-lg p-5 text-white">
289
- <h3 className="font-bold mb-4">快捷操作</h3>
290
- <div className="grid grid-cols-2 gap-3">
291
- <button onClick={() => onNavigate('grades')} className="bg-white/10 hover:bg-white/20 p-2 rounded flex flex-col items-center text-xs transition-colors">
292
- <Plus size={20} className="mb-1"/> 录入成绩
293
- </button>
294
- <button onClick={() => onNavigate('students')} className="bg-white/10 hover:bg-white/20 p-2 rounded flex flex-col items-center text-xs transition-colors">
295
- <Users size={20} className="mb-1"/> 新增学生
296
- </button>
297
- </div>
298
- </div>
299
  </div>
300
  </div>
301
 
302
- {showSchedule && (
303
- <div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
304
- <div className="bg-white rounded-xl w-full max-w-6xl h-[90vh] p-6 relative animate-in fade-in flex flex-col shadow-2xl">
305
- <div className="flex justify-between items-center mb-6">
306
- <div className="flex flex-wrap items-center gap-4">
307
- <h3 className="text-xl font-bold flex items-center"><Calendar className="mr-2 text-blue-600"/> 智能课程表</h3>
308
-
309
- {isAdmin && (
310
- <div className="flex bg-gray-100 rounded-lg p-1">
311
- <select
312
- className="bg-transparent border-none text-sm p-2 focus:ring-0 font-medium text-gray-700 cursor-pointer outline-none"
313
- value={viewGrade}
314
- onChange={e => setViewGrade(e.target.value)}
315
- >
316
- {uniqueGrades.length > 0 ? uniqueGrades.map(g => <option key={g} value={g}>{g}</option>) : <option value="">无年级数据</option>}
317
- </select>
318
- </div>
319
- )}
320
-
321
- {!isAdmin && isTeacher && (
322
- <div className="text-sm text-gray-500">
323
- 查看: <span className="font-bold text-gray-800">{currentUser.trueName || currentUser.username}</span> 的课表
324
- </div>
325
- )}
326
- </div>
327
- <button onClick={()=>setShowSchedule(false)} className="text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-full p-2 transition-all"><X size={24}/></button>
328
- </div>
329
-
330
- <div className="flex-1 overflow-auto border rounded-xl shadow-inner bg-gray-50/30">
331
- <table className="w-full border-collapse text-center table-fixed min-w-[800px]">
332
- <thead className="sticky top-0 bg-white z-10 shadow-sm">
333
- <tr className="text-gray-500 uppercase text-sm">
334
- <th className="p-4 border-b border-r w-20 bg-gray-50">节次</th>
335
- {['周一','周二','周三','周四','周五'].map(d=><th key={d} className="p-4 border-b font-bold text-gray-700">{d}</th>)}
336
- </tr>
337
- </thead>
338
- <tbody className="text-sm bg-white">
339
- {[1,2,3,4,5,6,7,8].map(period => (
340
- <tr key={period} className="divide-x divide-gray-100">
341
- <td className="p-4 border-b border-r font-bold text-gray-400 bg-gray-50/50">第{period}节</td>
342
- {[1,2,3,4,5].map(day => {
343
- const slotItems = schedules.filter(s => s.dayOfWeek === day && s.period === period);
344
- const canAdd = isAdmin || isTeacher;
345
-
346
- return (
347
- <td key={day} className="p-2 border-b h-28 align-top transition-colors relative group">
348
- <div className="flex flex-wrap gap-2 content-start h-full overflow-y-auto custom-scrollbar pb-6">
349
- {slotItems.map(item => {
350
- const canDelete = isAdmin || (isTeacher && (item.teacherName === currentUser.trueName || item.teacherName === currentUser.username));
351
- return (
352
- <div key={item._id} className="text-xs text-left bg-white border border-blue-200 rounded-md p-2 w-full shadow-sm relative group/item hover:border-blue-400 hover:shadow-md transition-all">
353
- <div className="font-bold text-blue-700 flex justify-between items-center mb-1">
354
- <span className="truncate mr-1">{item.subject}</span>
355
- {canDelete && (
356
- <button
357
- onClick={(e)=>{ e.stopPropagation(); handleDeleteSchedule(item); }}
358
- className="text-gray-300 hover:text-red-500 transition-colors p-0.5"
359
- title="删除课程"
360
- >
361
- <X size={14}/>
362
- </button>
363
- )}
364
- </div>
365
- <div className="flex justify-between items-center text-gray-600">
366
- <span className="bg-blue-50 text-blue-600 px-1.5 rounded font-mono text-[10px] font-bold">
367
- {item.className.replace(viewGrade, '')}
368
- </span>
369
- <span className="text-[10px] truncate max-w-[50%]">{item.teacherName}</span>
370
- </div>
371
- </div>
372
- );
373
- })}
374
- {canAdd && (
375
- <div className="absolute bottom-1 right-1 left-1 flex justify-center opacity-0 group-hover:opacity-100 transition-opacity">
376
- <button onClick={() => handleOpenAddModal(day, period)} className="w-full bg-blue-50 border border-blue-200 text-blue-500 rounded py-1 hover:bg-blue-100 flex items-center justify-center shadow-sm">
377
- <Plus size={14}/>
378
- </button>
379
- </div>
380
- )}
381
- </div>
382
- </td>
383
- );
384
- })}
385
- </tr>
386
- ))}
387
- </tbody>
388
- </table>
389
- </div>
390
- </div>
391
-
392
- {editingCell && (
393
- <div className="absolute inset-0 bg-black/20 flex items-center justify-center backdrop-blur-[1px] z-50">
394
- <div className="bg-white p-6 rounded-xl shadow-2xl w-80 animate-in zoom-in-95 border border-gray-100">
395
- <h4 className="font-bold mb-4 text-gray-800 border-b pb-2">
396
- {isTeacher ? '我的排课' : '排课管理'} <br/>
397
- <span className="text-sm font-normal text-gray-500">周{['一','二','三','四','五'][editingCell.day-1]} 第{editingCell.period}节</span>
398
- </h4>
399
- <div className="space-y-4">
400
- <div>
401
- <label className="text-xs font-bold text-gray-500 uppercase mb-1 block">班级</label>
402
- <select className="w-full border border-gray-300 rounded-lg p-2.5 text-sm focus:ring-2 focus:ring-blue-500 outline-none" value={editForm.className} onChange={e=>setEditForm({...editForm, className: e.target.value})}>
403
- <option value="">-- 请选择班级 --</option>
404
- {modalClassOptions.map(c => <option key={c} value={c}>{c}</option>)}
405
- </select>
406
- </div>
407
- <div>
408
- <label className="text-xs font-bold text-gray-500 uppercase mb-1 block">科目</label>
409
- <select className="w-full border border-gray-300 rounded-lg p-2.5 text-sm focus:ring-2 focus:ring-blue-500 outline-none disabled:bg-gray-100 disabled:text-gray-500" value={editForm.subject} onChange={e=>setEditForm({...editForm, subject: e.target.value})} disabled={isTeacher && !!currentUser?.teachingSubject}>
410
- <option value="">-- 请选择科目 --</option>
411
- {subjects.map(s=><option key={s._id} value={s.name}>{s.name}</option>)}
412
- </select>
413
- </div>
414
- <div>
415
- <label className="text-xs font-bold text-gray-500 uppercase mb-1 block">任课教师</label>
416
- <select className="w-full border border-gray-300 rounded-lg p-2.5 text-sm focus:ring-2 focus:ring-blue-500 outline-none disabled:bg-gray-100 disabled:text-gray-500" value={editForm.teacherName} onChange={e=>setEditForm({...editForm, teacherName: e.target.value})} disabled={isTeacher}>
417
- <option value="">-- 请选择教师 --</option>
418
- {teachers.map(t=><option key={t._id} value={t.trueName || t.username}>{t.trueName} ({t.teachingSubject || '无科目'})</option>)}
419
- </select>
420
- </div>
421
- <div className="flex gap-3 pt-2">
422
- <button onClick={handleSaveSchedule} className="flex-1 bg-blue-600 text-white py-2 rounded-lg text-sm font-medium hover:bg-blue-700 transition-colors">确认添加</button>
423
- <button onClick={()=>setEditingCell(null)} className="flex-1 bg-gray-100 text-gray-700 py-2 rounded-lg text-sm font-medium hover:bg-gray-200 transition-colors">取消</button>
424
- </div>
425
- </div>
426
- </div>
427
- </div>
428
- )}
429
- </div>
430
- )}
431
 
432
- {showStatus && (
433
- <div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
434
- <div className="bg-white rounded-xl w-full max-w-sm p-6 relative animate-in fade-in zoom-in-95">
435
- <button onClick={()=>setShowStatus(false)} className="absolute top-4 right-4 text-gray-400 hover:text-gray-600"><X size={20}/></button>
436
- <h3 className="text-xl font-bold mb-6 flex items-center"><Activity className="mr-2 text-green-600"/>系统运行状态</h3>
437
- <div className="space-y-4">
438
- <div className="flex justify-between items-center"><span className="text-gray-600">数据库</span><span className="text-green-600 font-bold flex items-center"><CheckCircle size={14} className="mr-1"/> 正常</span></div>
439
- <div className="flex justify-between items-center"><span className="text-gray-600">API 延迟</span><span className="text-green-600 font-bold">24ms</span></div>
440
- </div>
441
- <button onClick={()=>setShowStatus(false)} className="w-full mt-6 bg-blue-600 text-white py-2 rounded-lg">确认</button>
442
- </div>
443
- </div>
444
- )}
 
 
 
 
 
 
 
 
 
445
  </div>
446
  );
447
  };
 
5
  import { Score, ClassInfo, Subject, Schedule, User } from '../types';
6
  import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
7
  import { StudentDashboard } from './StudentDashboard';
8
+ import { TeacherDashboard } from './TeacherDashboard';
9
+ import { TodoList } from '../components/TodoList';
10
 
11
  interface DashboardProps {
12
  onNavigate: (view: string) => void;
 
20
 
21
  export const sortGrades = (a: string, b: string) => (gradeOrder[a] || 99) - (gradeOrder[b] || 99);
22
 
 
23
  const extractGrade = (s: string) => {
24
  if (!s) return '';
25
  const keys = Object.keys(gradeOrder);
26
  return keys.find(g => s.startsWith(g)) || '';
27
  };
28
 
 
29
  export const sortClasses = (a: ClassInfo | string, b: ClassInfo | string) => {
 
30
  const nameA = typeof a === 'string' ? a : (a.grade + a.className);
31
  const nameB = typeof b === 'string' ? b : (b.grade + b.className);
 
32
  if (!nameA || !nameB) return 0;
 
 
33
  const gradeA = extractGrade(nameA);
34
  const gradeB = extractGrade(nameB);
35
+ if (gradeA && gradeB && gradeA !== gradeB) { return sortGrades(gradeA, gradeB); }
36
+ const getNum = (str: string) => { const match = str.match(/(\d+)/); return match ? parseInt(match[1]) : 0; };
 
 
 
 
 
 
 
 
37
  return getNum(nameA) - getNum(nameB);
38
  };
39
 
 
42
  const [warnings, setWarnings] = useState<string[]>([]);
43
  const [loading, setLoading] = useState(true);
44
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  const [trendData, setTrendData] = useState<any[]>([]);
46
 
47
  const currentUser = api.auth.getCurrentUser();
48
+ const isAdmin = currentUser?.role === 'ADMIN' || currentUser?.role === 'PRINCIPAL';
49
  const isTeacher = currentUser?.role === 'TEACHER';
50
  const isStudent = currentUser?.role === 'STUDENT';
51
 
 
 
 
 
 
 
 
52
  useEffect(() => {
53
+ if (isStudent || isTeacher) {
54
+ setLoading(false);
55
+ return;
56
+ }
57
+ const loadStats = async () => {
58
  try {
59
+ const data = await api.stats.getSummary();
60
+ setStats(data);
 
 
 
 
 
 
61
 
62
+ // Mock trend data for admin view
63
+ setTrendData([
64
+ { name: '9月', score: 75 },
65
+ { name: '10月', score: 78 },
66
+ { name: '11月', score: 82 },
67
+ { name: '12月', score: 80 },
68
+ { name: '1月', score: 85 }
69
+ ]);
70
 
71
+ } catch (e) {
72
+ console.error(e);
73
+ } finally {
74
+ setLoading(false);
75
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
  };
77
+ loadStats();
78
+ }, [isStudent, isTeacher]);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
 
80
+ if (isStudent) return <StudentDashboard />;
81
+ if (isTeacher) return <TeacherDashboard />;
 
 
 
 
82
 
83
+ // Admin Dashboard View
84
  return (
85
  <div className="space-y-6">
86
+ <h1 className="text-2xl font-bold text-gray-800">全校概览</h1>
87
+
88
+ <div className="grid grid-cols-1 md:grid-cols-4 gap-6">
89
+ <div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100 flex items-center">
90
+ <div className="p-3 bg-blue-100 rounded-full text-blue-600 mr-4">
91
+ <Users size={24} />
92
+ </div>
93
+ <div>
94
+ <p className="text-gray-500 text-sm">在校学生</p>
95
+ <p className="text-2xl font-bold text-gray-800">{stats.studentCount}</p>
96
+ </div>
97
  </div>
98
+ <div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100 flex items-center">
99
+ <div className="p-3 bg-green-100 rounded-full text-green-600 mr-4">
100
+ <BookOpen size={24} />
101
+ </div>
102
+ <div>
103
+ <p className="text-gray-500 text-sm">开设课程</p>
104
+ <p className="text-2xl font-bold text-gray-800">{stats.courseCount}</p>
105
+ </div>
106
  </div>
107
+ <div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100 flex items-center">
108
+ <div className="p-3 bg-yellow-100 rounded-full text-yellow-600 mr-4">
109
+ <GraduationCap size={24} />
 
 
 
 
 
 
 
 
 
110
  </div>
111
+ <div>
112
+ <p className="text-gray-500 text-sm">平均分</p>
113
+ <p className="text-2xl font-bold text-gray-800">{stats.avgScore}</p>
114
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
  </div>
116
+ <div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100 flex items-center">
117
+ <div className="p-3 bg-purple-100 rounded-full text-purple-600 mr-4">
118
+ <Activity size={24} />
119
+ </div>
120
+ <div>
121
+ <p className="text-gray-500 text-sm">优秀率</p>
122
+ <p className="text-2xl font-bold text-gray-800">{stats.excellentRate}</p>
123
+ </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
124
  </div>
125
  </div>
126
 
127
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
128
+ <div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100">
129
+ <h3 className="font-bold text-gray-800 mb-4 flex items-center"><TrendingUp size={20} className="mr-2 text-blue-600"/> 成绩走势</h3>
130
+ <div className="h-64">
131
+ <ResponsiveContainer width="100%" height="100%">
132
+ <LineChart data={trendData}>
133
+ <CartesianGrid strokeDasharray="3 3" vertical={false}/>
134
+ <XAxis dataKey="name" />
135
+ <YAxis domain={[0, 100]} />
136
+ <Tooltip />
137
+ <Line type="monotone" dataKey="score" stroke="#3b82f6" strokeWidth={3} dot={{r: 4}} />
138
+ </LineChart>
139
+ </ResponsiveContainer>
140
+ </div>
141
+ </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
142
 
143
+ <div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100">
144
+ <h3 className="font-bold text-gray-800 mb-4">快捷入口</h3>
145
+ <div className="grid grid-cols-2 gap-4">
146
+ <button onClick={() => onNavigate('students')} className="p-4 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors flex flex-col items-center justify-center text-gray-700">
147
+ <Users className="mb-2 text-blue-500" />
148
+ <span>学生管理</span>
149
+ </button>
150
+ <button onClick={() => onNavigate('classes')} className="p-4 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors flex flex-col items-center justify-center text-gray-700">
151
+ <BookOpen className="mb-2 text-green-500" />
152
+ <span>班级管理</span>
153
+ </button>
154
+ <button onClick={() => onNavigate('reports')} className="p-4 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors flex flex-col items-center justify-center text-gray-700">
155
+ <Activity className="mb-2 text-purple-500" />
156
+ <span>报表统计</span>
157
+ </button>
158
+ <button onClick={() => onNavigate('settings')} className="p-4 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors flex flex-col items-center justify-center text-gray-700">
159
+ <Activity className="mb-2 text-gray-500" />
160
+ <span>系统设置</span>
161
+ </button>
162
+ </div>
163
+ </div>
164
+ </div>
165
  </div>
166
  );
167
  };
pages/GameMountain.tsx CHANGED
@@ -6,31 +6,13 @@ import { GameSession, GameTeam, Student, GameRewardConfig, AchievementConfig, Cl
6
  import { Settings, Plus, Minus, Users, CheckSquare, Loader2, Trash2, X, Flag, Gift, Star, Trophy, Maximize, Minimize, Lock } from 'lucide-react';
7
  import { Emoji } from '../components/Emoji';
8
 
9
- // ... (Keep CSS styles unchanged) ...
10
  const styles = `
11
- @keyframes float-cloud {
12
- 0% { transform: translateX(0); }
13
- 50% { transform: translateX(20px); }
14
- 100% { transform: translateX(0); }
15
- }
16
- @keyframes drift {
17
- from { transform: translateX(-100%); }
18
- to { transform: translateX(100vw); }
19
- }
20
- @keyframes bounce-avatar {
21
- 0%, 100% { transform: translateY(0) scale(1); }
22
- 50% { transform: translateY(-10px) scale(1.1); }
23
- }
24
- @keyframes wave {
25
- 0% { transform: rotate(0deg); }
26
- 25% { transform: rotate(-10deg); }
27
- 75% { transform: rotate(10deg); }
28
- 100% { transform: rotate(0deg); }
29
- }
30
- @keyframes confetti-fall {
31
- 0% { transform: translateY(-10px) rotate(0deg); opacity: 1; }
32
- 100% { transform: translateY(60px) rotate(360deg); opacity: 0; }
33
- }
34
  .animate-drift-slow { animation: drift 60s linear infinite; }
35
  .animate-drift-medium { animation: drift 40s linear infinite; }
36
  .animate-drift-fast { animation: drift 25s linear infinite; }
@@ -39,7 +21,7 @@ const styles = `
39
  .animate-confetti { animation: confetti-fall 2s ease-out forwards; }
40
  `;
41
 
42
- // ... (Keep calculatePathPoint and generatePathString unchanged) ...
43
  const calculatePathPoint = (progress: number) => {
44
  const y = 8 + (progress * 80);
45
  const amplitude = 15 * (1 - (progress * 0.3));
@@ -71,6 +53,7 @@ const CelebrationEffects = () => (
71
  </div>
72
  );
73
 
 
74
  const MountainStage = ({ team, index, rewardsConfig, maxSteps, onScoreChange, isFullscreen }: {
75
  team: GameTeam,
76
  index: number,
@@ -87,8 +70,6 @@ const MountainStage = ({ team, index, rewardsConfig, maxSteps, onScoreChange, is
87
 
88
  return (
89
  <div className={`relative flex flex-col items-center justify-end h-[95%] ${widthClass} mx-2 flex-shrink-0 select-none group perspective-1000 transition-all duration-300`}>
90
-
91
- {/* Team Name Cloud Tag */}
92
  <div className="absolute top-[2%] z-20 transition-transform duration-300 hover:-translate-y-2 hover:scale-105 cursor-pointer w-full flex justify-center">
93
  <div className="relative bg-white/90 backdrop-blur-md px-3 py-1.5 rounded-xl shadow-md border-2 border-white text-center max-w-[90%]">
94
  <h3 className="text-sm md:text-base font-black text-slate-800 truncate">{team.name}</h3>
@@ -98,7 +79,6 @@ const MountainStage = ({ team, index, rewardsConfig, maxSteps, onScoreChange, is
98
  </div>
99
  </div>
100
 
101
- {/* Mountain SVG */}
102
  <div className="absolute bottom-0 left-0 w-full h-[90%] z-0 filter drop-shadow-lg transition-all duration-500 group-hover:drop-shadow-xl">
103
  <svg viewBox="0 0 200 300" preserveAspectRatio="none" className="w-full h-full overflow-visible">
104
  <defs>
@@ -120,7 +100,6 @@ const MountainStage = ({ team, index, rewardsConfig, maxSteps, onScoreChange, is
120
  </svg>
121
  </div>
122
 
123
- {/* Rewards */}
124
  <div className="absolute bottom-0 left-0 w-full h-[90%] z-10 pointer-events-none">
125
  {rewardsConfig.map((reward, i) => {
126
  const rPct = reward.scoreThreshold / maxSteps;
@@ -137,14 +116,13 @@ const MountainStage = ({ team, index, rewardsConfig, maxSteps, onScoreChange, is
137
  {isUnlocked && <div className="absolute inset-0 bg-yellow-400 rounded-full blur-sm opacity-50 animate-pulse"></div>}
138
  </div>
139
  <div className={`mt-1 px-2 py-0.5 rounded text-[10px] font-bold shadow-sm whitespace-nowrap border max-w-[100px] truncate transition-colors ${isUnlocked ? 'bg-yellow-50 text-yellow-700 border-yellow-200' : 'bg-white/90 text-gray-500 border-gray-200'}`}>
140
- {reward.rewardName}
141
  </div>
142
  </div>
143
  );
144
  })}
145
  </div>
146
 
147
- {/* Climber */}
148
  <div className="absolute z-30 transition-all duration-700 ease-in-out flex flex-col items-center -translate-x-1/2 transform translate-y-1/2" style={{ bottom: `${currentPos.y}%`, left: `${currentPos.x}%` }}>
149
  <div className={`w-12 h-12 md:w-14 md:h-14 bg-white rounded-full border-4 shadow-xl flex items-center justify-center transition-transform relative ${isFinished ? 'animate-bounce border-yellow-400' : 'hover:scale-110'}`} style={{ borderColor: isFinished ? '#facc15' : team.color }}>
150
  <span className="text-2xl md:text-3xl"><Emoji symbol={team.avatar || '🧗'} /></span>
@@ -153,7 +131,6 @@ const MountainStage = ({ team, index, rewardsConfig, maxSteps, onScoreChange, is
153
  </div>
154
  </div>
155
 
156
- {/* Control Panel */}
157
  {onScoreChange && (
158
  <div className="absolute bottom-6 left-1/2 -translate-x-1/2 flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-all z-40 bg-white/90 backdrop-blur px-2 py-1 rounded-full shadow-lg border border-gray-200 hover:scale-105">
159
  <button onClick={() => onScoreChange(team.id, -1)} className="p-1 rounded-full bg-slate-100 text-slate-500 hover:bg-red-100 hover:text-red-600"><Minus size={14}/></button>
@@ -164,7 +141,7 @@ const MountainStage = ({ team, index, rewardsConfig, maxSteps, onScoreChange, is
164
  );
165
  };
166
 
167
- // Toast
168
  const GameToast = ({ title, message, type, onClose }: { title: string, message: string, type: 'success' | 'info', onClose: () => void }) => {
169
  useEffect(() => { const timer = setTimeout(onClose, 3000); return () => clearTimeout(timer); }, [onClose]);
170
  return (
@@ -178,37 +155,29 @@ const GameToast = ({ title, message, type, onClose }: { title: string, message:
178
  };
179
 
180
  export const GameMountain: React.FC<{className?: string}> = ({ className }) => {
 
181
  const [session, setSession] = useState<GameSession | null>(null);
182
  const [loading, setLoading] = useState(true);
183
  const [students, setStudents] = useState<Student[]>([]);
184
-
185
  const [isSettingsOpen, setIsSettingsOpen] = useState(false);
186
  const [isFullscreen, setIsFullscreen] = useState(false);
187
  const [selectedTeamId, setSelectedTeamId] = useState<string | null>(null);
188
  const [achConfig, setAchConfig] = useState<AchievementConfig | null>(null);
189
- const [canEdit, setCanEdit] = useState(false); // New: Read-only check
190
-
191
  const [toast, setToast] = useState<{title: string, message: string, type: 'success' | 'info'} | null>(null);
192
 
193
  const currentUser = api.auth.getCurrentUser();
194
  const isTeacher = currentUser?.role === 'TEACHER';
195
  const isAdmin = currentUser?.role === 'ADMIN';
196
-
197
- // Use prop className or fallback to homeroom (for legacy)
198
  const targetClass = className || currentUser?.homeroomClass || (currentUser?.role === 'STUDENT' ? 'MY_CLASS' : '');
199
 
200
  useEffect(() => { loadData(); }, [targetClass]);
201
 
202
  const loadData = async () => {
203
- // FIX: Check for currentUser
204
  if (!targetClass || targetClass === 'MY_CLASS' && !currentUser?.homeroomClass && currentUser?.role !== 'STUDENT') return;
205
  setLoading(true);
206
-
207
- // Resolve "MY_CLASS" for students
208
  let resolvedClass = targetClass;
209
  if (targetClass === 'MY_CLASS' && currentUser?.role === 'STUDENT') {
210
- // We need to fetch student's class first? Actually dashboard loads it.
211
- // Assuming passed prop is correct or we fetch student
212
  const stus = await api.students.getAll();
213
  const me = stus.find((s: Student) => s.name === (currentUser?.trueName || currentUser?.username));
214
  if(me) resolvedClass = me.className;
@@ -221,8 +190,6 @@ export const GameMountain: React.FC<{className?: string}> = ({ className }) => {
221
  ]);
222
 
223
  const filteredStudents = allStudents.filter((s: Student) => s.className === resolvedClass);
224
- // Sort
225
- // FIX: Add explicit types for sort arguments
226
  filteredStudents.sort((a: Student, b: Student) => {
227
  const seatA = parseInt(a.seatNo || '99999');
228
  const seatB = parseInt(b.seatNo || '99999');
@@ -234,7 +201,6 @@ export const GameMountain: React.FC<{className?: string}> = ({ className }) => {
234
  if (sess) {
235
  setSession(sess);
236
  } else if (isTeacher && currentUser?.schoolId) {
237
- // Initialize if empty
238
  const newSess: GameSession = {
239
  schoolId: currentUser.schoolId,
240
  className: resolvedClass,
@@ -247,31 +213,23 @@ export const GameMountain: React.FC<{className?: string}> = ({ className }) => {
247
  { id: '3', name: '飞龙队', score: 0, avatar: '🐉', color: '#10b981', members: [] }
248
  ],
249
  rewardsConfig: [
250
- { scoreThreshold: 5, rewardType: 'DRAW_COUNT', rewardName: '抽奖券x1', rewardValue: 1 },
251
  { scoreThreshold: 10, rewardType: 'ITEM', rewardName: '神秘大礼', rewardValue: 1 }
252
  ]
253
  };
254
  setSession(newSess);
255
  }
256
 
257
- // Permissions Check
258
  if (isAdmin) setCanEdit(true);
259
  else if (isTeacher && currentUser) {
260
- // Fetch class info to check homeroomTeacherIds
261
  const clsList = await api.classes.getAll() as ClassInfo[];
262
- // FIX: Add type for find callback parameter
263
  const cls = clsList.find((c: ClassInfo) => c.grade + c.className === resolvedClass);
264
  if (cls && (cls.homeroomTeacherIds?.includes(currentUser._id || '') || cls.teacherName?.includes(currentUser.trueName || currentUser.username))) {
265
  setCanEdit(true);
266
- // Only load Ach config if can edit
267
  const ac = await api.achievements.getConfig(resolvedClass);
268
  setAchConfig(ac);
269
- } else {
270
- setCanEdit(false);
271
- }
272
- } else {
273
- setCanEdit(false);
274
- }
275
 
276
  } catch (e) { console.error(e); }
277
  finally { setLoading(false); }
@@ -291,7 +249,7 @@ export const GameMountain: React.FC<{className?: string}> = ({ className }) => {
291
  if (newScore === session.maxSteps) {
292
  setToast({ title: `🏆 巅峰时刻!`, message: `恭喜 [${t.name}] 成功登顶!`, type: 'success' });
293
  } else if (reward) {
294
- setToast({ title: `🎉 触发奖励!`, message: `[${t.name}] 获得:${reward.rewardName}`, type: 'info' });
295
  }
296
 
297
  if (reward) {
@@ -311,6 +269,7 @@ export const GameMountain: React.FC<{className?: string}> = ({ className }) => {
311
  studentName: stu.name,
312
  rewardType: reward.rewardType as any,
313
  name: reward.rewardName,
 
314
  status: 'PENDING',
315
  source: `群岳争锋 - ${t.name} ${newScore}步`
316
  });
@@ -329,7 +288,7 @@ export const GameMountain: React.FC<{className?: string}> = ({ className }) => {
329
  await api.games.saveMountainSession(newSession);
330
  } catch(e) {
331
  alert('保存失败:您可能没有权限修改此班级数据');
332
- loadData(); // Revert
333
  }
334
  };
335
 
@@ -363,51 +322,29 @@ export const GameMountain: React.FC<{className?: string}> = ({ className }) => {
363
  const GameContent = (
364
  <div className={`${isFullscreen ? 'fixed inset-0 z-[9999] w-screen h-screen' : 'h-full w-full relative'} flex flex-col bg-gradient-to-b from-sky-300 via-sky-100 to-emerald-50 overflow-hidden`}>
365
  <style>{styles}</style>
366
-
367
- {/* Background Elements */}
368
  <div className="absolute inset-0 pointer-events-none overflow-hidden">
369
  <div className="absolute top-10 right-20 w-24 h-24 bg-yellow-300 rounded-full blur-xl opacity-60 animate-pulse"></div>
370
  <div className="absolute top-16 -left-20 text-white/60 text-9xl select-none animate-drift-slow opacity-80" style={{filter: 'blur(2px)'}}><Emoji symbol="☁️"/></div>
371
  <div className="absolute top-32 -left-40 text-white/40 text-8xl select-none animate-drift-medium opacity-60" style={{animationDelay: '5s'}}><Emoji symbol="☁️"/></div>
372
  </div>
373
-
374
- {/* Toast Overlay */}
375
  {toast && <GameToast title={toast.title} message={toast.message} type={toast.type} onClose={() => setToast(null)} />}
376
-
377
  {/* Toolbar */}
378
  <div className="absolute top-4 right-4 z-50 flex gap-2">
379
- {!canEdit && isTeacher && (
380
- <div className="px-3 py-1.5 bg-gray-100/80 backdrop-blur rounded-full text-xs font-bold text-gray-500 flex items-center border border-gray-200">
381
- <Lock size={14} className="mr-1"/> 只读模式 (非班主任)
382
- </div>
383
- )}
384
  <button onClick={() => setIsFullscreen(!isFullscreen)} className="p-2 bg-white/80 backdrop-blur rounded-full hover:bg-white shadow-sm border border-white/50 transition-colors">
385
  {isFullscreen ? <Minimize size={20} className="text-gray-700"/> : <Maximize size={20} className="text-gray-700"/>}
386
  </button>
387
- {canEdit && (
388
- <button onClick={() => setIsSettingsOpen(true)} className="flex items-center text-xs font-bold text-slate-700 bg-white/90 backdrop-blur px-4 py-2 rounded-2xl border border-white/50 hover:bg-white shadow-md transition-all hover:scale-105 active:scale-95">
389
- <Settings size={16} className="mr-2"/> 设置
390
- </button>
391
- )}
392
  </div>
393
-
394
- {/* Scrollable Game Area */}
395
  <div className="flex-1 overflow-x-auto overflow-y-hidden relative custom-scrollbar z-10 w-full min-w-0">
396
  <div className="h-full flex items-end px-10 pb-4 gap-2 mx-auto w-max min-w-full justify-center">
397
  {session.teams.map((team, idx) => (
398
- <MountainStage
399
- key={team.id}
400
- team={team}
401
- index={idx}
402
- rewardsConfig={session.rewardsConfig}
403
- maxSteps={session.maxSteps}
404
- onScoreChange={canEdit ? handleScoreChange : undefined}
405
- isFullscreen={isFullscreen}
406
- />
407
  ))}
408
  </div>
409
  </div>
410
-
411
  {/* SETTINGS MODAL */}
412
  {isSettingsOpen && canEdit && (
413
  <div className="fixed inset-0 bg-black/60 z-[100000] flex items-center justify-center p-4 backdrop-blur-sm">
@@ -416,7 +353,6 @@ export const GameMountain: React.FC<{className?: string}> = ({ className }) => {
416
  <h3 className="text-xl font-bold text-gray-800 flex items-center"><Settings className="mr-2 text-blue-600"/> 游戏控制台</h3>
417
  <button onClick={() => setIsSettingsOpen(false)}><X size={24} className="text-gray-400 hover:text-gray-600"/></button>
418
  </div>
419
-
420
  <div className="flex-1 overflow-y-auto p-6 space-y-8 bg-gray-50/50">
421
  <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
422
  {/* Basic Config */}
@@ -429,8 +365,7 @@ export const GameMountain: React.FC<{className?: string}> = ({ className }) => {
429
  </div>
430
  </div>
431
  </section>
432
-
433
- {/* Rewards Config */}
434
  <section className="bg-white p-5 rounded-xl border border-gray-200 shadow-sm row-span-2">
435
  <h4 className="font-bold text-gray-700 mb-4 flex items-center text-sm uppercase tracking-wide"><Gift size={16} className="mr-2 text-amber-500"/> 奖励节点</h4>
436
  <div className="space-y-2 max-h-64 overflow-y-auto pr-1 custom-scrollbar">
@@ -451,11 +386,17 @@ export const GameMountain: React.FC<{className?: string}> = ({ className }) => {
451
  <option value="ITEM">🎁 实物</option>
452
  <option value="ACHIEVEMENT">🏆 奖状/成就</option>
453
  </select>
 
 
 
 
 
 
 
454
  <button onClick={() => {
455
  const newArr = session.rewardsConfig.filter((_, i) => i !== idx); setSession({...session, rewardsConfig: newArr});
456
  }} className="text-gray-300 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-opacity"><Trash2 size={16}/></button>
457
  </div>
458
-
459
  {rc.rewardType === 'ACHIEVEMENT' ? (
460
  <select className="w-full text-xs border border-gray-200 rounded p-1 bg-white" value={rc.achievementId || ''} onChange={e => {
461
  const newArr = [...session.rewardsConfig];
@@ -477,7 +418,6 @@ export const GameMountain: React.FC<{className?: string}> = ({ className }) => {
477
  </div>
478
  <button onClick={() => setSession({...session, rewardsConfig: [...session.rewardsConfig, { scoreThreshold: session.maxSteps, rewardType: 'DRAW_COUNT', rewardName: '奖励', rewardValue: 1 }]})} className="mt-3 w-full py-2 border border-dashed border-gray-300 rounded-lg text-sm text-gray-500 hover:border-blue-400 hover:text-blue-600 transition-colors">+ 添加节点</button>
479
  </section>
480
-
481
  {/* Team Management */}
482
  <section className="bg-white p-5 rounded-xl border border-gray-200 shadow-sm md:col-span-2">
483
  <div className="flex justify-between items-center mb-4">
@@ -487,9 +427,7 @@ export const GameMountain: React.FC<{className?: string}> = ({ className }) => {
487
  setSession({ ...session, teams: [...session.teams, newTeam] });
488
  }} className="text-xs bg-emerald-50 text-emerald-600 px-3 py-1.5 rounded-lg hover:bg-emerald-100 border border-emerald-200 font-bold transition-colors">+ 新建队伍</button>
489
  </div>
490
-
491
  <div className="flex flex-col md:flex-row gap-6 h-[400px]">
492
- {/* Team List */}
493
  <div className="w-full md:w-1/3 space-y-3 overflow-y-auto pr-2 custom-scrollbar">
494
  {session.teams.map(t => (
495
  <div key={t.id}
@@ -521,8 +459,6 @@ export const GameMountain: React.FC<{className?: string}> = ({ className }) => {
521
  </div>
522
  ))}
523
  </div>
524
-
525
- {/* Member Shuttle */}
526
  <div className="flex-1 bg-gray-50 rounded-xl border border-gray-200 p-4 flex flex-col shadow-inner">
527
  {selectedTeamId ? (
528
  <>
@@ -539,7 +475,6 @@ export const GameMountain: React.FC<{className?: string}> = ({ className }) => {
539
  const isInCurrent = currentTeamId === selectedTeamId;
540
  const isInOther = currentTeamId && !isInCurrent;
541
  const otherTeam = isInOther ? session.teams.find(t => t.id === currentTeamId) : null;
542
-
543
  return (
544
  <div key={s._id}
545
  onClick={() => toggleTeamMember(s._id || String(s.id), selectedTeamId)}
@@ -557,18 +492,12 @@ export const GameMountain: React.FC<{className?: string}> = ({ className }) => {
557
  })}
558
  </div>
559
  </>
560
- ) : (
561
- <div className="flex flex-col items-center justify-center h-full text-gray-400">
562
- <Users size={48} className="mb-2 opacity-20"/>
563
- <p>请先在左侧选择一个队伍</p>
564
- </div>
565
- )}
566
  </div>
567
  </div>
568
  </section>
569
  </div>
570
  </div>
571
-
572
  <div className="p-4 border-t border-gray-100 bg-white rounded-b-2xl flex justify-end gap-3 shrink-0">
573
  <button onClick={() => setIsSettingsOpen(false)} className="px-5 py-2.5 text-gray-600 hover:bg-gray-100 rounded-xl transition-colors font-medium">取消</button>
574
  <button onClick={saveSettings} className="px-8 py-2.5 bg-blue-600 text-white rounded-xl hover:bg-blue-700 shadow-lg shadow-blue-200 font-bold transition-all hover:scale-105 active:scale-95">保存配置</button>
@@ -578,9 +507,6 @@ export const GameMountain: React.FC<{className?: string}> = ({ className }) => {
578
  )}
579
  </div>
580
  );
581
-
582
- if (isFullscreen) {
583
- return createPortal(GameContent, document.body);
584
- }
585
  return GameContent;
586
  };
 
6
  import { Settings, Plus, Minus, Users, CheckSquare, Loader2, Trash2, X, Flag, Gift, Star, Trophy, Maximize, Minimize, Lock } from 'lucide-react';
7
  import { Emoji } from '../components/Emoji';
8
 
9
+ // ... (Styles and Animations kept same)
10
  const styles = `
11
+ @keyframes float-cloud { 0% { transform: translateX(0); } 50% { transform: translateX(20px); } 100% { transform: translateX(0); } }
12
+ @keyframes drift { from { transform: translateX(-100%); } to { transform: translateX(100vw); } }
13
+ @keyframes bounce-avatar { 0%, 100% { transform: translateY(0) scale(1); } 50% { transform: translateY(-10px) scale(1.1); } }
14
+ @keyframes wave { 0% { transform: rotate(0deg); } 25% { transform: rotate(-10deg); } 75% { transform: rotate(10deg); } 100% { transform: rotate(0deg); } }
15
+ @keyframes confetti-fall { 0% { transform: translateY(-10px) rotate(0deg); opacity: 1; } 100% { transform: translateY(60px) rotate(360deg); opacity: 0; } }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
  .animate-drift-slow { animation: drift 60s linear infinite; }
17
  .animate-drift-medium { animation: drift 40s linear infinite; }
18
  .animate-drift-fast { animation: drift 25s linear infinite; }
 
21
  .animate-confetti { animation: confetti-fall 2s ease-out forwards; }
22
  `;
23
 
24
+ // ... (Keep calculatePathPoint and generatePathString unchanged)
25
  const calculatePathPoint = (progress: number) => {
26
  const y = 8 + (progress * 80);
27
  const amplitude = 15 * (1 - (progress * 0.3));
 
53
  </div>
54
  );
55
 
56
+ // ... (MountainStage Component - Keep mostly same, update display for count)
57
  const MountainStage = ({ team, index, rewardsConfig, maxSteps, onScoreChange, isFullscreen }: {
58
  team: GameTeam,
59
  index: number,
 
70
 
71
  return (
72
  <div className={`relative flex flex-col items-center justify-end h-[95%] ${widthClass} mx-2 flex-shrink-0 select-none group perspective-1000 transition-all duration-300`}>
 
 
73
  <div className="absolute top-[2%] z-20 transition-transform duration-300 hover:-translate-y-2 hover:scale-105 cursor-pointer w-full flex justify-center">
74
  <div className="relative bg-white/90 backdrop-blur-md px-3 py-1.5 rounded-xl shadow-md border-2 border-white text-center max-w-[90%]">
75
  <h3 className="text-sm md:text-base font-black text-slate-800 truncate">{team.name}</h3>
 
79
  </div>
80
  </div>
81
 
 
82
  <div className="absolute bottom-0 left-0 w-full h-[90%] z-0 filter drop-shadow-lg transition-all duration-500 group-hover:drop-shadow-xl">
83
  <svg viewBox="0 0 200 300" preserveAspectRatio="none" className="w-full h-full overflow-visible">
84
  <defs>
 
100
  </svg>
101
  </div>
102
 
 
103
  <div className="absolute bottom-0 left-0 w-full h-[90%] z-10 pointer-events-none">
104
  {rewardsConfig.map((reward, i) => {
105
  const rPct = reward.scoreThreshold / maxSteps;
 
116
  {isUnlocked && <div className="absolute inset-0 bg-yellow-400 rounded-full blur-sm opacity-50 animate-pulse"></div>}
117
  </div>
118
  <div className={`mt-1 px-2 py-0.5 rounded text-[10px] font-bold shadow-sm whitespace-nowrap border max-w-[100px] truncate transition-colors ${isUnlocked ? 'bg-yellow-50 text-yellow-700 border-yellow-200' : 'bg-white/90 text-gray-500 border-gray-200'}`}>
119
+ {reward.rewardName} {reward.rewardValue > 1 ? `x${reward.rewardValue}` : ''}
120
  </div>
121
  </div>
122
  );
123
  })}
124
  </div>
125
 
 
126
  <div className="absolute z-30 transition-all duration-700 ease-in-out flex flex-col items-center -translate-x-1/2 transform translate-y-1/2" style={{ bottom: `${currentPos.y}%`, left: `${currentPos.x}%` }}>
127
  <div className={`w-12 h-12 md:w-14 md:h-14 bg-white rounded-full border-4 shadow-xl flex items-center justify-center transition-transform relative ${isFinished ? 'animate-bounce border-yellow-400' : 'hover:scale-110'}`} style={{ borderColor: isFinished ? '#facc15' : team.color }}>
128
  <span className="text-2xl md:text-3xl"><Emoji symbol={team.avatar || '🧗'} /></span>
 
131
  </div>
132
  </div>
133
 
 
134
  {onScoreChange && (
135
  <div className="absolute bottom-6 left-1/2 -translate-x-1/2 flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-all z-40 bg-white/90 backdrop-blur px-2 py-1 rounded-full shadow-lg border border-gray-200 hover:scale-105">
136
  <button onClick={() => onScoreChange(team.id, -1)} className="p-1 rounded-full bg-slate-100 text-slate-500 hover:bg-red-100 hover:text-red-600"><Minus size={14}/></button>
 
141
  );
142
  };
143
 
144
+ // ... (GameToast - Keep same)
145
  const GameToast = ({ title, message, type, onClose }: { title: string, message: string, type: 'success' | 'info', onClose: () => void }) => {
146
  useEffect(() => { const timer = setTimeout(onClose, 3000); return () => clearTimeout(timer); }, [onClose]);
147
  return (
 
155
  };
156
 
157
  export const GameMountain: React.FC<{className?: string}> = ({ className }) => {
158
+ // ... (State - Keep same)
159
  const [session, setSession] = useState<GameSession | null>(null);
160
  const [loading, setLoading] = useState(true);
161
  const [students, setStudents] = useState<Student[]>([]);
 
162
  const [isSettingsOpen, setIsSettingsOpen] = useState(false);
163
  const [isFullscreen, setIsFullscreen] = useState(false);
164
  const [selectedTeamId, setSelectedTeamId] = useState<string | null>(null);
165
  const [achConfig, setAchConfig] = useState<AchievementConfig | null>(null);
166
+ const [canEdit, setCanEdit] = useState(false);
 
167
  const [toast, setToast] = useState<{title: string, message: string, type: 'success' | 'info'} | null>(null);
168
 
169
  const currentUser = api.auth.getCurrentUser();
170
  const isTeacher = currentUser?.role === 'TEACHER';
171
  const isAdmin = currentUser?.role === 'ADMIN';
 
 
172
  const targetClass = className || currentUser?.homeroomClass || (currentUser?.role === 'STUDENT' ? 'MY_CLASS' : '');
173
 
174
  useEffect(() => { loadData(); }, [targetClass]);
175
 
176
  const loadData = async () => {
 
177
  if (!targetClass || targetClass === 'MY_CLASS' && !currentUser?.homeroomClass && currentUser?.role !== 'STUDENT') return;
178
  setLoading(true);
 
 
179
  let resolvedClass = targetClass;
180
  if (targetClass === 'MY_CLASS' && currentUser?.role === 'STUDENT') {
 
 
181
  const stus = await api.students.getAll();
182
  const me = stus.find((s: Student) => s.name === (currentUser?.trueName || currentUser?.username));
183
  if(me) resolvedClass = me.className;
 
190
  ]);
191
 
192
  const filteredStudents = allStudents.filter((s: Student) => s.className === resolvedClass);
 
 
193
  filteredStudents.sort((a: Student, b: Student) => {
194
  const seatA = parseInt(a.seatNo || '99999');
195
  const seatB = parseInt(b.seatNo || '99999');
 
201
  if (sess) {
202
  setSession(sess);
203
  } else if (isTeacher && currentUser?.schoolId) {
 
204
  const newSess: GameSession = {
205
  schoolId: currentUser.schoolId,
206
  className: resolvedClass,
 
213
  { id: '3', name: '飞龙队', score: 0, avatar: '🐉', color: '#10b981', members: [] }
214
  ],
215
  rewardsConfig: [
216
+ { scoreThreshold: 5, rewardType: 'DRAW_COUNT', rewardName: '抽奖券', rewardValue: 1 },
217
  { scoreThreshold: 10, rewardType: 'ITEM', rewardName: '神秘大礼', rewardValue: 1 }
218
  ]
219
  };
220
  setSession(newSess);
221
  }
222
 
 
223
  if (isAdmin) setCanEdit(true);
224
  else if (isTeacher && currentUser) {
 
225
  const clsList = await api.classes.getAll() as ClassInfo[];
 
226
  const cls = clsList.find((c: ClassInfo) => c.grade + c.className === resolvedClass);
227
  if (cls && (cls.homeroomTeacherIds?.includes(currentUser._id || '') || cls.teacherName?.includes(currentUser.trueName || currentUser.username))) {
228
  setCanEdit(true);
 
229
  const ac = await api.achievements.getConfig(resolvedClass);
230
  setAchConfig(ac);
231
+ } else { setCanEdit(false); }
232
+ } else { setCanEdit(false); }
 
 
 
 
233
 
234
  } catch (e) { console.error(e); }
235
  finally { setLoading(false); }
 
249
  if (newScore === session.maxSteps) {
250
  setToast({ title: `🏆 巅峰时刻!`, message: `恭喜 [${t.name}] 成功登顶!`, type: 'success' });
251
  } else if (reward) {
252
+ setToast({ title: `🎉 触发奖励!`, message: `[${t.name}] 获得:${reward.rewardName} x${reward.rewardValue || 1}`, type: 'info' });
253
  }
254
 
255
  if (reward) {
 
269
  studentName: stu.name,
270
  rewardType: reward.rewardType as any,
271
  name: reward.rewardName,
272
+ count: reward.rewardValue || 1, // USE REWARD VALUE
273
  status: 'PENDING',
274
  source: `群岳争锋 - ${t.name} ${newScore}步`
275
  });
 
288
  await api.games.saveMountainSession(newSession);
289
  } catch(e) {
290
  alert('保存失败:您可能没有权限修改此班级数据');
291
+ loadData();
292
  }
293
  };
294
 
 
322
  const GameContent = (
323
  <div className={`${isFullscreen ? 'fixed inset-0 z-[9999] w-screen h-screen' : 'h-full w-full relative'} flex flex-col bg-gradient-to-b from-sky-300 via-sky-100 to-emerald-50 overflow-hidden`}>
324
  <style>{styles}</style>
325
+ {/* Background */}
 
326
  <div className="absolute inset-0 pointer-events-none overflow-hidden">
327
  <div className="absolute top-10 right-20 w-24 h-24 bg-yellow-300 rounded-full blur-xl opacity-60 animate-pulse"></div>
328
  <div className="absolute top-16 -left-20 text-white/60 text-9xl select-none animate-drift-slow opacity-80" style={{filter: 'blur(2px)'}}><Emoji symbol="☁️"/></div>
329
  <div className="absolute top-32 -left-40 text-white/40 text-8xl select-none animate-drift-medium opacity-60" style={{animationDelay: '5s'}}><Emoji symbol="☁️"/></div>
330
  </div>
 
 
331
  {toast && <GameToast title={toast.title} message={toast.message} type={toast.type} onClose={() => setToast(null)} />}
 
332
  {/* Toolbar */}
333
  <div className="absolute top-4 right-4 z-50 flex gap-2">
334
+ {!canEdit && isTeacher && <div className="px-3 py-1.5 bg-gray-100/80 backdrop-blur rounded-full text-xs font-bold text-gray-500 flex items-center border border-gray-200"><Lock size={14} className="mr-1"/> 只读模式 (非班主任)</div>}
 
 
 
 
335
  <button onClick={() => setIsFullscreen(!isFullscreen)} className="p-2 bg-white/80 backdrop-blur rounded-full hover:bg-white shadow-sm border border-white/50 transition-colors">
336
  {isFullscreen ? <Minimize size={20} className="text-gray-700"/> : <Maximize size={20} className="text-gray-700"/>}
337
  </button>
338
+ {canEdit && <button onClick={() => setIsSettingsOpen(true)} className="flex items-center text-xs font-bold text-slate-700 bg-white/90 backdrop-blur px-4 py-2 rounded-2xl border border-white/50 hover:bg-white shadow-md transition-all hover:scale-105 active:scale-95"><Settings size={16} className="mr-2"/> 设置</button>}
 
 
 
 
339
  </div>
340
+ {/* Game Area */}
 
341
  <div className="flex-1 overflow-x-auto overflow-y-hidden relative custom-scrollbar z-10 w-full min-w-0">
342
  <div className="h-full flex items-end px-10 pb-4 gap-2 mx-auto w-max min-w-full justify-center">
343
  {session.teams.map((team, idx) => (
344
+ <MountainStage key={team.id} team={team} index={idx} rewardsConfig={session.rewardsConfig} maxSteps={session.maxSteps} onScoreChange={canEdit ? handleScoreChange : undefined} isFullscreen={isFullscreen} />
 
 
 
 
 
 
 
 
345
  ))}
346
  </div>
347
  </div>
 
348
  {/* SETTINGS MODAL */}
349
  {isSettingsOpen && canEdit && (
350
  <div className="fixed inset-0 bg-black/60 z-[100000] flex items-center justify-center p-4 backdrop-blur-sm">
 
353
  <h3 className="text-xl font-bold text-gray-800 flex items-center"><Settings className="mr-2 text-blue-600"/> 游戏控制台</h3>
354
  <button onClick={() => setIsSettingsOpen(false)}><X size={24} className="text-gray-400 hover:text-gray-600"/></button>
355
  </div>
 
356
  <div className="flex-1 overflow-y-auto p-6 space-y-8 bg-gray-50/50">
357
  <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
358
  {/* Basic Config */}
 
365
  </div>
366
  </div>
367
  </section>
368
+ {/* Rewards Config - ADDED QUANTITY INPUT */}
 
369
  <section className="bg-white p-5 rounded-xl border border-gray-200 shadow-sm row-span-2">
370
  <h4 className="font-bold text-gray-700 mb-4 flex items-center text-sm uppercase tracking-wide"><Gift size={16} className="mr-2 text-amber-500"/> 奖励节点</h4>
371
  <div className="space-y-2 max-h-64 overflow-y-auto pr-1 custom-scrollbar">
 
386
  <option value="ITEM">🎁 实物</option>
387
  <option value="ACHIEVEMENT">🏆 奖状/成就</option>
388
  </select>
389
+ {/* QUANTITY INPUT */}
390
+ <div className="flex items-center gap-1 bg-white border px-2 py-1 rounded">
391
+ <span className="text-xs text-gray-400">x</span>
392
+ <input type="number" min={1} className="w-8 text-center font-bold text-sm outline-none" value={rc.rewardValue || 1} onChange={e => {
393
+ const newArr = [...session.rewardsConfig]; newArr[idx].rewardValue = Number(e.target.value); setSession({...session, rewardsConfig: newArr});
394
+ }}/>
395
+ </div>
396
  <button onClick={() => {
397
  const newArr = session.rewardsConfig.filter((_, i) => i !== idx); setSession({...session, rewardsConfig: newArr});
398
  }} className="text-gray-300 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-opacity"><Trash2 size={16}/></button>
399
  </div>
 
400
  {rc.rewardType === 'ACHIEVEMENT' ? (
401
  <select className="w-full text-xs border border-gray-200 rounded p-1 bg-white" value={rc.achievementId || ''} onChange={e => {
402
  const newArr = [...session.rewardsConfig];
 
418
  </div>
419
  <button onClick={() => setSession({...session, rewardsConfig: [...session.rewardsConfig, { scoreThreshold: session.maxSteps, rewardType: 'DRAW_COUNT', rewardName: '奖励', rewardValue: 1 }]})} className="mt-3 w-full py-2 border border-dashed border-gray-300 rounded-lg text-sm text-gray-500 hover:border-blue-400 hover:text-blue-600 transition-colors">+ 添加节点</button>
420
  </section>
 
421
  {/* Team Management */}
422
  <section className="bg-white p-5 rounded-xl border border-gray-200 shadow-sm md:col-span-2">
423
  <div className="flex justify-between items-center mb-4">
 
427
  setSession({ ...session, teams: [...session.teams, newTeam] });
428
  }} className="text-xs bg-emerald-50 text-emerald-600 px-3 py-1.5 rounded-lg hover:bg-emerald-100 border border-emerald-200 font-bold transition-colors">+ 新建队伍</button>
429
  </div>
 
430
  <div className="flex flex-col md:flex-row gap-6 h-[400px]">
 
431
  <div className="w-full md:w-1/3 space-y-3 overflow-y-auto pr-2 custom-scrollbar">
432
  {session.teams.map(t => (
433
  <div key={t.id}
 
459
  </div>
460
  ))}
461
  </div>
 
 
462
  <div className="flex-1 bg-gray-50 rounded-xl border border-gray-200 p-4 flex flex-col shadow-inner">
463
  {selectedTeamId ? (
464
  <>
 
475
  const isInCurrent = currentTeamId === selectedTeamId;
476
  const isInOther = currentTeamId && !isInCurrent;
477
  const otherTeam = isInOther ? session.teams.find(t => t.id === currentTeamId) : null;
 
478
  return (
479
  <div key={s._id}
480
  onClick={() => toggleTeamMember(s._id || String(s.id), selectedTeamId)}
 
492
  })}
493
  </div>
494
  </>
495
+ ) : <div className="flex flex-col items-center justify-center h-full text-gray-400"><Users size={48} className="mb-2 opacity-20"/><p>请先在左侧选择一个队伍</p></div>}
 
 
 
 
 
496
  </div>
497
  </div>
498
  </section>
499
  </div>
500
  </div>
 
501
  <div className="p-4 border-t border-gray-100 bg-white rounded-b-2xl flex justify-end gap-3 shrink-0">
502
  <button onClick={() => setIsSettingsOpen(false)} className="px-5 py-2.5 text-gray-600 hover:bg-gray-100 rounded-xl transition-colors font-medium">取消</button>
503
  <button onClick={saveSettings} className="px-8 py-2.5 bg-blue-600 text-white rounded-xl hover:bg-blue-700 shadow-lg shadow-blue-200 font-bold transition-all hover:scale-105 active:scale-95">保存配置</button>
 
507
  )}
508
  </div>
509
  );
510
+ if (isFullscreen) { return createPortal(GameContent, document.body); }
 
 
 
511
  return GameContent;
512
  };
pages/GameRewards.tsx CHANGED
@@ -3,6 +3,7 @@ import React, { useState, useEffect } from 'react';
3
  import { api } from '../services/api';
4
  import { StudentReward, Student } from '../types';
5
  import { Gift, Loader2, Search, Filter, Trash2, Edit, Save, X, ChevronLeft, ChevronRight } from 'lucide-react';
 
6
 
7
  export const GameRewards: React.FC<{className?: string}> = ({ className }) => {
8
  const [rewards, setRewards] = useState<StudentReward[]>([]);
@@ -10,7 +11,6 @@ export const GameRewards: React.FC<{className?: string}> = ({ className }) => {
10
  const [page, setPage] = useState(1);
11
  const [loading, setLoading] = useState(true);
12
 
13
- // Grant Modal
14
  const [isGrantModalOpen, setIsGrantModalOpen] = useState(false);
15
  const [students, setStudents] = useState<Student[]>([]);
16
  const [grantForm, setGrantForm] = useState({
@@ -20,54 +20,47 @@ export const GameRewards: React.FC<{className?: string}> = ({ className }) => {
20
  name: ''
21
  });
22
 
23
- // Filters
24
- const [filterType, setFilterType] = useState('ALL'); // ALL, ITEM, DRAW_COUNT
25
- const [filterStatus, setFilterStatus] = useState('ALL'); // ALL, PENDING, REDEEMED
26
  const [searchText, setSearchText] = useState('');
27
 
28
- // Edit State
29
  const [editingId, setEditingId] = useState<string | null>(null);
30
  const [editForm, setEditForm] = useState({ name: '', count: 1 });
31
 
 
 
 
 
 
32
  const currentUser = api.auth.getCurrentUser();
33
  const isStudent = currentUser?.role === 'STUDENT';
34
  const isTeacher = currentUser?.role === 'TEACHER';
35
 
36
- // Use prop className or fallback to legacy homeroom
37
  const homeroomClass = className || currentUser?.homeroomClass;
38
-
39
  const PAGE_SIZE = 15;
40
 
41
  const loadData = async () => {
42
  setLoading(true);
43
  try {
44
  if (isStudent) {
45
- // Student View
46
  const stus = await api.students.getAll();
47
  const me = stus.find((s: Student) => s.name === (currentUser.trueName || currentUser.username));
48
  if (me) {
49
  const res = await api.rewards.getMyRewards(me._id || String(me.id), page, PAGE_SIZE);
50
- setRewards(res.list || res); // Handle both old array and new object return
51
  setTotal(res.total || (Array.isArray(res) ? res.length : 0));
52
  }
53
  } else {
54
- // Teacher View
55
  const allStus = await api.students.getAll();
56
-
57
  let targetClass = '';
58
  let filteredStudents = allStus;
59
-
60
  if (isTeacher && homeroomClass) {
61
  targetClass = homeroomClass;
62
  filteredStudents = allStus.filter((s: Student) => s.className === homeroomClass);
63
  }
64
-
65
  const res = await api.rewards.getClassRewards(page, PAGE_SIZE, targetClass);
66
-
67
  setRewards(res.list || []);
68
  setTotal(res.total || 0);
69
-
70
- // Sort students: Seat No > Name
71
  filteredStudents.sort((a: Student, b: Student) => {
72
  const seatA = parseInt(a.seatNo || '99999');
73
  const seatB = parseInt(b.seatNo || '99999');
@@ -80,12 +73,11 @@ export const GameRewards: React.FC<{className?: string}> = ({ className }) => {
80
  finally { setLoading(false); }
81
  };
82
 
83
- useEffect(() => { loadData(); }, [page, className]); // Reload when page or className prop changes
84
 
85
  const handleGrant = async () => {
86
  if(!grantForm.studentId) return alert('请选择学生');
87
  if(grantForm.rewardType === 'ITEM' && !grantForm.name) return alert('请输入奖品名称');
88
-
89
  try {
90
  await api.games.grantReward(grantForm);
91
  setIsGrantModalOpen(false);
@@ -95,21 +87,31 @@ export const GameRewards: React.FC<{className?: string}> = ({ className }) => {
95
  } catch(e) { alert('发放失败'); }
96
  };
97
 
98
- const handleRedeem = async (id: string) => {
99
- if(confirm('确认标记为已核销/已兑换?')) {
100
- await api.rewards.redeem(id);
101
- loadData();
102
- }
 
 
 
 
 
103
  };
104
 
105
- const handleDelete = async (r: StudentReward) => {
106
- if (!confirm(`确定要撤回这条奖励吗?\n如果学生已经使用了抽奖券,撤回将失败。`)) return;
107
- try {
108
- await api.rewards.delete(r._id!);
109
- loadData();
110
- } catch (e: any) {
111
- alert(e.message || '撤回��败,可能学生已使用部分次数');
112
- }
 
 
 
 
 
113
  };
114
 
115
  const handleUpdate = async (id: string) => {
@@ -123,7 +125,6 @@ export const GameRewards: React.FC<{className?: string}> = ({ className }) => {
123
  setEditForm({ name: r.name, count: r.count || 1 });
124
  };
125
 
126
- // Client-side Filtering
127
  const displayRewards = rewards.filter(r => {
128
  if (filterType !== 'ALL' && r.rewardType !== filterType) return false;
129
  if (filterStatus !== 'ALL' && r.status !== filterStatus) return false;
@@ -140,13 +141,18 @@ export const GameRewards: React.FC<{className?: string}> = ({ className }) => {
140
 
141
  return (
142
  <div className="flex flex-col h-full bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden">
 
 
 
 
 
 
 
 
 
143
  <div className="p-4 md:p-6 border-b border-gray-100 flex flex-col md:flex-row justify-between items-start md:items-center gap-4 shrink-0">
144
- <h3 className="text-xl font-bold text-gray-800">
145
- {isStudent ? '我的战利品清单' : `${homeroomClass || '全校'} 奖励核销台`}
146
- </h3>
147
-
148
  <div className="flex flex-wrap items-center gap-3 w-full md:w-auto">
149
- {/* Filters */}
150
  <div className="flex items-center bg-gray-100 rounded-lg p-1">
151
  <select className="bg-transparent text-xs p-1.5 rounded outline-none text-gray-600" value={filterType} onChange={e=>setFilterType(e.target.value)}>
152
  <option value="ALL">全部类型</option>
@@ -165,7 +171,6 @@ export const GameRewards: React.FC<{className?: string}> = ({ className }) => {
165
  <input className="pl-8 pr-3 py-1.5 text-xs border rounded-lg bg-gray-50 focus:bg-white transition-colors outline-none w-32" placeholder="搜索..." value={searchText} onChange={e=>setSearchText(e.target.value)}/>
166
  <Search className="absolute left-2.5 top-2 text-gray-400" size={12}/>
167
  </div>
168
-
169
  {!isStudent && (
170
  <button onClick={() => setIsGrantModalOpen(true)} className="flex items-center px-4 py-2 bg-amber-100 text-amber-700 rounded-lg font-bold hover:bg-amber-200 text-sm ml-auto md:ml-0">
171
  <Gift size={16} className="mr-2"/> 发放奖励
@@ -174,7 +179,6 @@ export const GameRewards: React.FC<{className?: string}> = ({ className }) => {
174
  </div>
175
  </div>
176
 
177
- {/* Scrollable List Container */}
178
  <div className="flex-1 overflow-y-auto p-0 min-h-0">
179
  <table className="w-full text-left border-collapse">
180
  <thead className="bg-gray-50 text-gray-500 text-xs uppercase sticky top-0 z-10 shadow-sm">
@@ -201,32 +205,12 @@ export const GameRewards: React.FC<{className?: string}> = ({ className }) => {
201
  <span className="text-xs text-gray-400">x</span>
202
  <input type="number" min={1} className="border rounded px-1 py-0.5 text-xs w-12" value={editForm.count} onChange={e=>setEditForm({...editForm, count:Number(e.target.value)})}/>
203
  </div>
204
- ) : (
205
- <span>{r.name} <span className="text-gray-400 text-xs ml-1">x{r.count || 1}</span></span>
206
- )}
207
- </td>
208
- <td className="p-4">
209
- <span className={`text-xs px-2 py-1 rounded border ${
210
- r.rewardType === 'DRAW_COUNT' ? 'bg-purple-50 text-purple-700 border-purple-100' :
211
- r.rewardType === 'CONSOLATION' ? 'bg-gray-100 text-gray-500 border-gray-200' :
212
- 'bg-blue-50 text-blue-700 border-blue-100'
213
- }`}>
214
- {r.rewardType==='DRAW_COUNT' ? '抽奖券' : r.rewardType==='CONSOLATION' ? '未中奖' : '实物'}
215
- </span>
216
  </td>
 
217
  <td className="p-4 text-gray-500 text-xs">{r.source}</td>
218
  <td className="p-4 text-gray-500 text-xs">{new Date(r.createTime).toLocaleDateString()}</td>
219
- <td className="p-4">
220
- {r.rewardType === 'DRAW_COUNT' ? (
221
- <span className="text-xs bg-purple-100 text-purple-700 px-2 py-1 rounded border border-purple-200">系统入账</span>
222
- ) : r.rewardType === 'CONSOLATION' ? (
223
- <span className="text-xs text-gray-400">已结束</span>
224
- ) : (
225
- r.status === 'REDEEMED'
226
- ? <span className="text-xs bg-green-100 text-green-700 px-2 py-1 rounded border border-green-200">已兑换</span>
227
- : <span className="text-xs bg-amber-100 text-amber-700 px-2 py-1 rounded border border-amber-200 animate-pulse">待核销</span>
228
- )}
229
- </td>
230
  {!isStudent && (
231
  <td className="p-4 text-right flex justify-end gap-2">
232
  {isEditing ? (
@@ -237,12 +221,10 @@ export const GameRewards: React.FC<{className?: string}> = ({ className }) => {
237
  ) : (
238
  <>
239
  {r.status !== 'REDEEMED' && r.rewardType === 'ITEM' && (
240
- <button onClick={() => handleRedeem(r._id!)} className="text-xs bg-green-600 text-white px-3 py-1 rounded hover:bg-green-700 shadow-sm transition-colors mr-2">
241
- 核销
242
- </button>
243
  )}
244
  <button onClick={()=>startEdit(r)} className="text-blue-400 hover:text-blue-600 p-1" title="编辑"><Edit size={16}/></button>
245
- <button onClick={()=>handleDelete(r)} className="text-gray-400 hover:text-red-500 p-1" title="撤回"><Trash2 size={16}/></button>
246
  </>
247
  )}
248
  </td>
@@ -250,14 +232,10 @@ export const GameRewards: React.FC<{className?: string}> = ({ className }) => {
250
  </tr>
251
  );
252
  })}
253
- {displayRewards.length === 0 && (
254
- <tr><td colSpan={7} className="text-center py-10 text-gray-400">暂无记录</td></tr>
255
- )}
256
  </tbody>
257
  </table>
258
  </div>
259
-
260
- {/* Pagination Footer */}
261
  <div className="p-3 border-t border-gray-100 flex items-center justify-between shrink-0 bg-gray-50">
262
  <span className="text-xs text-gray-500">共 {total} 条记录</span>
263
  <div className="flex items-center gap-2">
@@ -266,8 +244,6 @@ export const GameRewards: React.FC<{className?: string}> = ({ className }) => {
266
  <button onClick={()=>setPage(Math.min(totalPages, page+1))} disabled={page>=totalPages} className="p-1 rounded hover:bg-gray-200 disabled:opacity-30"><ChevronRight size={16}/></button>
267
  </div>
268
  </div>
269
-
270
- {/* Grant Modal */}
271
  {isGrantModalOpen && (
272
  <div className="fixed inset-0 bg-black/50 z-[100] flex items-center justify-center p-4">
273
  <div className="bg-white rounded-xl p-6 w-full max-w-sm animate-in fade-in zoom-in-95 shadow-2xl">
@@ -287,19 +263,16 @@ export const GameRewards: React.FC<{className?: string}> = ({ className }) => {
287
  <button onClick={()=>setGrantForm({...grantForm, rewardType: 'ITEM'})} className={`flex-1 py-2 text-sm rounded border ${grantForm.rewardType==='ITEM' ? 'bg-blue-50 border-blue-500 text-blue-700 font-bold' : 'border-gray-200 text-gray-600'}`}>实物奖品</button>
288
  </div>
289
  </div>
290
-
291
  {grantForm.rewardType === 'ITEM' && (
292
  <div>
293
  <label className="block text-xs font-bold text-gray-500 uppercase mb-1">奖品名称</label>
294
  <input className="w-full border border-gray-300 p-2 rounded-lg text-sm" placeholder="如: 笔记本、铅笔" value={grantForm.name} onChange={e=>setGrantForm({...grantForm, name: e.target.value})}/>
295
  </div>
296
  )}
297
-
298
  <div>
299
  <label className="block text-xs font-bold text-gray-500 uppercase mb-1">发放数量</label>
300
  <input type="number" min={1} className="w-full border border-gray-300 p-2 rounded-lg outline-none" value={grantForm.count} onChange={e=>setGrantForm({...grantForm, count: Number(e.target.value)})}/>
301
  </div>
302
-
303
  <div className="flex gap-2 pt-2">
304
  <button onClick={() => setIsGrantModalOpen(false)} className="flex-1 bg-gray-100 text-gray-700 py-2 rounded-lg hover:bg-gray-200 transition-colors">取消</button>
305
  <button onClick={handleGrant} className="flex-1 bg-amber-500 text-white py-2 rounded-lg font-bold hover:bg-amber-600 shadow-md transition-colors">确认发放</button>
 
3
  import { api } from '../services/api';
4
  import { StudentReward, Student } from '../types';
5
  import { Gift, Loader2, Search, Filter, Trash2, Edit, Save, X, ChevronLeft, ChevronRight } from 'lucide-react';
6
+ import { ConfirmModal } from '../components/ConfirmModal';
7
 
8
  export const GameRewards: React.FC<{className?: string}> = ({ className }) => {
9
  const [rewards, setRewards] = useState<StudentReward[]>([]);
 
11
  const [page, setPage] = useState(1);
12
  const [loading, setLoading] = useState(true);
13
 
 
14
  const [isGrantModalOpen, setIsGrantModalOpen] = useState(false);
15
  const [students, setStudents] = useState<Student[]>([]);
16
  const [grantForm, setGrantForm] = useState({
 
20
  name: ''
21
  });
22
 
23
+ const [filterType, setFilterType] = useState('ALL');
24
+ const [filterStatus, setFilterStatus] = useState('ALL');
 
25
  const [searchText, setSearchText] = useState('');
26
 
 
27
  const [editingId, setEditingId] = useState<string | null>(null);
28
  const [editForm, setEditForm] = useState({ name: '', count: 1 });
29
 
30
+ // Modal State
31
+ const [confirmModal, setConfirmModal] = useState<{isOpen: boolean, title: string, message: string, onConfirm: () => void, isDanger?: boolean}>({
32
+ isOpen: false, title: '', message: '', onConfirm: () => {}
33
+ });
34
+
35
  const currentUser = api.auth.getCurrentUser();
36
  const isStudent = currentUser?.role === 'STUDENT';
37
  const isTeacher = currentUser?.role === 'TEACHER';
38
 
 
39
  const homeroomClass = className || currentUser?.homeroomClass;
 
40
  const PAGE_SIZE = 15;
41
 
42
  const loadData = async () => {
43
  setLoading(true);
44
  try {
45
  if (isStudent) {
 
46
  const stus = await api.students.getAll();
47
  const me = stus.find((s: Student) => s.name === (currentUser.trueName || currentUser.username));
48
  if (me) {
49
  const res = await api.rewards.getMyRewards(me._id || String(me.id), page, PAGE_SIZE);
50
+ setRewards(res.list || res);
51
  setTotal(res.total || (Array.isArray(res) ? res.length : 0));
52
  }
53
  } else {
 
54
  const allStus = await api.students.getAll();
 
55
  let targetClass = '';
56
  let filteredStudents = allStus;
 
57
  if (isTeacher && homeroomClass) {
58
  targetClass = homeroomClass;
59
  filteredStudents = allStus.filter((s: Student) => s.className === homeroomClass);
60
  }
 
61
  const res = await api.rewards.getClassRewards(page, PAGE_SIZE, targetClass);
 
62
  setRewards(res.list || []);
63
  setTotal(res.total || 0);
 
 
64
  filteredStudents.sort((a: Student, b: Student) => {
65
  const seatA = parseInt(a.seatNo || '99999');
66
  const seatB = parseInt(b.seatNo || '99999');
 
73
  finally { setLoading(false); }
74
  };
75
 
76
+ useEffect(() => { loadData(); }, [page, className]);
77
 
78
  const handleGrant = async () => {
79
  if(!grantForm.studentId) return alert('请选择学生');
80
  if(grantForm.rewardType === 'ITEM' && !grantForm.name) return alert('请输入奖品名称');
 
81
  try {
82
  await api.games.grantReward(grantForm);
83
  setIsGrantModalOpen(false);
 
87
  } catch(e) { alert('发放失败'); }
88
  };
89
 
90
+ const openRedeemConfirm = (id: string) => {
91
+ setConfirmModal({
92
+ isOpen: true,
93
+ title: '确认核销',
94
+ message: '确定要将此奖励标记为“已核销/已兑换”吗?此操作不可撤销。',
95
+ onConfirm: async () => {
96
+ await api.rewards.redeem(id);
97
+ loadData();
98
+ }
99
+ });
100
  };
101
 
102
+ const openDeleteConfirm = (r: StudentReward) => {
103
+ setConfirmModal({
104
+ isOpen: true,
105
+ title: '撤回奖励',
106
+ message: `确定要撤回 "${r.name}" 吗?如果学生已经使用了抽奖券,撤回将失败。`,
107
+ isDanger: true,
108
+ onConfirm: async () => {
109
+ try {
110
+ await api.rewards.delete(r._id!);
111
+ loadData();
112
+ } catch(e: any) { alert(e.message || '撤回失败'); }
113
+ }
114
+ });
115
  };
116
 
117
  const handleUpdate = async (id: string) => {
 
125
  setEditForm({ name: r.name, count: r.count || 1 });
126
  };
127
 
 
128
  const displayRewards = rewards.filter(r => {
129
  if (filterType !== 'ALL' && r.rewardType !== filterType) return false;
130
  if (filterStatus !== 'ALL' && r.status !== filterStatus) return false;
 
141
 
142
  return (
143
  <div className="flex flex-col h-full bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden">
144
+ <ConfirmModal
145
+ isOpen={confirmModal.isOpen}
146
+ title={confirmModal.title}
147
+ message={confirmModal.message}
148
+ onClose={()=>setConfirmModal({...confirmModal, isOpen: false})}
149
+ onConfirm={confirmModal.onConfirm}
150
+ isDanger={confirmModal.isDanger}
151
+ />
152
+
153
  <div className="p-4 md:p-6 border-b border-gray-100 flex flex-col md:flex-row justify-between items-start md:items-center gap-4 shrink-0">
154
+ <h3 className="text-xl font-bold text-gray-800">{isStudent ? '我的战利品清单' : `${homeroomClass || '全校'} 奖励核销台`}</h3>
 
 
 
155
  <div className="flex flex-wrap items-center gap-3 w-full md:w-auto">
 
156
  <div className="flex items-center bg-gray-100 rounded-lg p-1">
157
  <select className="bg-transparent text-xs p-1.5 rounded outline-none text-gray-600" value={filterType} onChange={e=>setFilterType(e.target.value)}>
158
  <option value="ALL">全部类型</option>
 
171
  <input className="pl-8 pr-3 py-1.5 text-xs border rounded-lg bg-gray-50 focus:bg-white transition-colors outline-none w-32" placeholder="搜索..." value={searchText} onChange={e=>setSearchText(e.target.value)}/>
172
  <Search className="absolute left-2.5 top-2 text-gray-400" size={12}/>
173
  </div>
 
174
  {!isStudent && (
175
  <button onClick={() => setIsGrantModalOpen(true)} className="flex items-center px-4 py-2 bg-amber-100 text-amber-700 rounded-lg font-bold hover:bg-amber-200 text-sm ml-auto md:ml-0">
176
  <Gift size={16} className="mr-2"/> 发放奖励
 
179
  </div>
180
  </div>
181
 
 
182
  <div className="flex-1 overflow-y-auto p-0 min-h-0">
183
  <table className="w-full text-left border-collapse">
184
  <thead className="bg-gray-50 text-gray-500 text-xs uppercase sticky top-0 z-10 shadow-sm">
 
205
  <span className="text-xs text-gray-400">x</span>
206
  <input type="number" min={1} className="border rounded px-1 py-0.5 text-xs w-12" value={editForm.count} onChange={e=>setEditForm({...editForm, count:Number(e.target.value)})}/>
207
  </div>
208
+ ) : <span>{r.name} <span className="text-gray-400 text-xs ml-1">x{r.count || 1}</span></span>}
 
 
 
 
 
 
 
 
 
 
 
209
  </td>
210
+ <td className="p-4"><span className={`text-xs px-2 py-1 rounded border ${r.rewardType === 'DRAW_COUNT' ? 'bg-purple-50 text-purple-700 border-purple-100' : r.rewardType === 'CONSOLATION' ? 'bg-gray-100 text-gray-500 border-gray-200' : 'bg-blue-50 text-blue-700 border-blue-100'}`}>{r.rewardType==='DRAW_COUNT' ? '抽奖券' : r.rewardType==='CONSOLATION' ? '未中奖' : '实物'}</span></td>
211
  <td className="p-4 text-gray-500 text-xs">{r.source}</td>
212
  <td className="p-4 text-gray-500 text-xs">{new Date(r.createTime).toLocaleDateString()}</td>
213
+ <td className="p-4">{r.rewardType === 'DRAW_COUNT' ? <span className="text-xs bg-purple-100 text-purple-700 px-2 py-1 rounded border border-purple-200">系统入账</span> : r.rewardType === 'CONSOLATION' ? <span className="text-xs text-gray-400">已结束</span> : (r.status === 'REDEEMED' ? <span className="text-xs bg-green-100 text-green-700 px-2 py-1 rounded border border-green-200">已兑换</span> : <span className="text-xs bg-amber-100 text-amber-700 px-2 py-1 rounded border border-amber-200 animate-pulse">待核销</span>)}</td>
 
 
 
 
 
 
 
 
 
 
214
  {!isStudent && (
215
  <td className="p-4 text-right flex justify-end gap-2">
216
  {isEditing ? (
 
221
  ) : (
222
  <>
223
  {r.status !== 'REDEEMED' && r.rewardType === 'ITEM' && (
224
+ <button onClick={() => openRedeemConfirm(r._id!)} className="text-xs bg-green-600 text-white px-3 py-1 rounded hover:bg-green-700 shadow-sm transition-colors mr-2">核销</button>
 
 
225
  )}
226
  <button onClick={()=>startEdit(r)} className="text-blue-400 hover:text-blue-600 p-1" title="编辑"><Edit size={16}/></button>
227
+ <button onClick={()=>openDeleteConfirm(r)} className="text-gray-400 hover:text-red-500 p-1" title="撤回"><Trash2 size={16}/></button>
228
  </>
229
  )}
230
  </td>
 
232
  </tr>
233
  );
234
  })}
235
+ {displayRewards.length === 0 && <tr><td colSpan={7} className="text-center py-10 text-gray-400">暂无记录</td></tr>}
 
 
236
  </tbody>
237
  </table>
238
  </div>
 
 
239
  <div className="p-3 border-t border-gray-100 flex items-center justify-between shrink-0 bg-gray-50">
240
  <span className="text-xs text-gray-500">共 {total} 条记录</span>
241
  <div className="flex items-center gap-2">
 
244
  <button onClick={()=>setPage(Math.min(totalPages, page+1))} disabled={page>=totalPages} className="p-1 rounded hover:bg-gray-200 disabled:opacity-30"><ChevronRight size={16}/></button>
245
  </div>
246
  </div>
 
 
247
  {isGrantModalOpen && (
248
  <div className="fixed inset-0 bg-black/50 z-[100] flex items-center justify-center p-4">
249
  <div className="bg-white rounded-xl p-6 w-full max-w-sm animate-in fade-in zoom-in-95 shadow-2xl">
 
263
  <button onClick={()=>setGrantForm({...grantForm, rewardType: 'ITEM'})} className={`flex-1 py-2 text-sm rounded border ${grantForm.rewardType==='ITEM' ? 'bg-blue-50 border-blue-500 text-blue-700 font-bold' : 'border-gray-200 text-gray-600'}`}>实物奖品</button>
264
  </div>
265
  </div>
 
266
  {grantForm.rewardType === 'ITEM' && (
267
  <div>
268
  <label className="block text-xs font-bold text-gray-500 uppercase mb-1">奖品名称</label>
269
  <input className="w-full border border-gray-300 p-2 rounded-lg text-sm" placeholder="如: 笔记本、铅笔" value={grantForm.name} onChange={e=>setGrantForm({...grantForm, name: e.target.value})}/>
270
  </div>
271
  )}
 
272
  <div>
273
  <label className="block text-xs font-bold text-gray-500 uppercase mb-1">发放数量</label>
274
  <input type="number" min={1} className="w-full border border-gray-300 p-2 rounded-lg outline-none" value={grantForm.count} onChange={e=>setGrantForm({...grantForm, count: Number(e.target.value)})}/>
275
  </div>
 
276
  <div className="flex gap-2 pt-2">
277
  <button onClick={() => setIsGrantModalOpen(false)} className="flex-1 bg-gray-100 text-gray-700 py-2 rounded-lg hover:bg-gray-200 transition-colors">取消</button>
278
  <button onClick={handleGrant} className="flex-1 bg-amber-500 text-white py-2 rounded-lg font-bold hover:bg-amber-600 shadow-md transition-colors">确认发放</button>
pages/TeacherDashboard.tsx ADDED
@@ -0,0 +1,283 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import React, { useEffect, useState } from 'react';
3
+ import { api } from '../services/api';
4
+ import { Schedule, Student, Attendance } from '../types';
5
+ import { Calendar, UserX, AlertTriangle, Activity, Coffee, Plus, X, PaintBucket, ToggleLeft } from 'lucide-react';
6
+ import { TodoList } from '../components/TodoList';
7
+
8
+ const COLORS = ['#dbeafe', '#dcfce7', '#fef9c3', '#fee2e2', '#f3e8ff', '#ffedd5', '#e0e7ff', '#ecfccb'];
9
+ const stringToColor = (str: string) => {
10
+ let hash = 0;
11
+ for (let i = 0; i < str.length; i++) hash = str.charCodeAt(i) + ((hash << 5) - hash);
12
+ return COLORS[Math.abs(hash) % COLORS.length];
13
+ };
14
+
15
+ export const TeacherDashboard: React.FC = () => {
16
+ const [loading, setLoading] = useState(true);
17
+ const [schedules, setSchedules] = useState<Schedule[]>([]);
18
+ const [students, setStudents] = useState<Student[]>([]);
19
+ const [attendance, setAttendance] = useState<Attendance[]>([]);
20
+
21
+ // Schedule State
22
+ const [showSchedule, setShowSchedule] = useState(false);
23
+ const [weekType, setWeekType] = useState<'ALL' | 'ODD' | 'EVEN'>('ALL'); // Currently displaying
24
+ const [editingCell, setEditingCell] = useState<{day: number, period: number} | null>(null);
25
+ const [editForm, setEditForm] = useState({ subject: '', teacherName: '', weekType: 'ALL' });
26
+ const [periodCount, setPeriodCount] = useState(8); // Default, could be config
27
+
28
+ const currentUser = api.auth.getCurrentUser();
29
+ const homeroomClass = currentUser?.homeroomClass;
30
+
31
+ useEffect(() => {
32
+ loadData();
33
+ }, []);
34
+
35
+ const loadData = async () => {
36
+ setLoading(true);
37
+ try {
38
+ const todayStr = new Date().toISOString().split('T')[0];
39
+
40
+ const [stus, scheds, atts] = await Promise.all([
41
+ api.students.getAll(),
42
+ homeroomClass ? api.schedules.get({ className: homeroomClass }) : Promise.resolve([]),
43
+ homeroomClass ? api.attendance.get({ className: homeroomClass, date: todayStr }) : Promise.resolve([])
44
+ ]);
45
+
46
+ if (homeroomClass) {
47
+ setStudents(stus.filter((s: Student) => s.className === homeroomClass));
48
+ setSchedules(scheds);
49
+ setAttendance(atts);
50
+ } else {
51
+ // Non-homeroom teacher view logic could be added here
52
+ }
53
+ } catch (e) { console.error(e); }
54
+ finally { setLoading(false); }
55
+ };
56
+
57
+ const handleSaveSchedule = async () => {
58
+ if (!homeroomClass || !editingCell) return;
59
+ if (!editForm.subject) return alert('请输入科目');
60
+ try {
61
+ await api.schedules.save({
62
+ className: homeroomClass,
63
+ dayOfWeek: editingCell.day,
64
+ period: editingCell.period,
65
+ subject: editForm.subject,
66
+ teacherName: editForm.teacherName,
67
+ weekType: editForm.weekType as any
68
+ });
69
+ setEditingCell(null);
70
+ // Refresh
71
+ const updated = await api.schedules.get({ className: homeroomClass });
72
+ setSchedules(updated);
73
+ } catch(e) { alert('保存失败'); }
74
+ };
75
+
76
+ const handleDeleteSchedule = async (s: Schedule) => {
77
+ if(!confirm('确定删除此课程?')) return;
78
+ await api.schedules.delete({ className: s.className, dayOfWeek: s.dayOfWeek, period: s.period });
79
+ const updated = await api.schedules.get({ className: homeroomClass });
80
+ setSchedules(updated);
81
+ };
82
+
83
+ // Stats
84
+ const absentCount = attendance.filter(a => a.status === 'Absent').length;
85
+ const leaveCount = attendance.filter(a => a.status === 'Leave').length;
86
+ const presentCount = attendance.filter(a => a.status === 'Present').length;
87
+ const unknownCount = students.length - presentCount - leaveCount - absentCount;
88
+
89
+ const absentNames = attendance.filter(a => a.status === 'Absent').map(a => a.studentName);
90
+ const leaveNames = attendance.filter(a => a.status === 'Leave').map(a => a.studentName);
91
+
92
+ return (
93
+ <div className="space-y-6">
94
+ <div className="flex flex-col md:flex-row justify-between items-center bg-white p-6 rounded-xl shadow-sm border border-gray-100">
95
+ <div>
96
+ <h1 className="text-2xl font-bold text-gray-800">你好,{currentUser?.trueName || currentUser?.username} 老师</h1>
97
+ <p className="text-gray-500 mt-1">{homeroomClass ? `${homeroomClass} 班主任` : '科任教师'}</p>
98
+ </div>
99
+ {homeroomClass && (
100
+ <button onClick={() => setShowSchedule(true)} className="mt-4 md:mt-0 flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 shadow-md transition-colors">
101
+ <Calendar className="mr-2" size={18}/> 班级课表管理
102
+ </button>
103
+ )}
104
+ </div>
105
+
106
+ <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
107
+ {/* Left Column: Stats & Todo */}
108
+ <div className="lg:col-span-2 space-y-6">
109
+ {/* Attendance Stats */}
110
+ {homeroomClass && (
111
+ <div className="bg-white p-6 rounded-xl border border-gray-100 shadow-sm">
112
+ <h3 className="font-bold text-gray-800 mb-4 flex items-center"><Activity className="mr-2 text-blue-500"/> 今日考勤概览</h3>
113
+ <div className="grid grid-cols-4 gap-4 text-center">
114
+ <div className="bg-green-50 p-3 rounded-lg border border-green-100">
115
+ <div className="text-2xl font-black text-green-600">{presentCount}</div>
116
+ <div className="text-xs text-green-700">出勤</div>
117
+ </div>
118
+ <div className="bg-orange-50 p-3 rounded-lg border border-orange-100">
119
+ <div className="text-2xl font-black text-orange-500">{leaveCount}</div>
120
+ <div className="text-xs text-orange-700">请假</div>
121
+ </div>
122
+ <div className="bg-red-50 p-3 rounded-lg border border-red-100">
123
+ <div className="text-2xl font-black text-red-500">{absentCount}</div>
124
+ <div className="text-xs text-red-700">旷课</div>
125
+ </div>
126
+ <div className="bg-gray-50 p-3 rounded-lg border border-gray-200">
127
+ <div className="text-2xl font-black text-gray-400">{unknownCount}</div>
128
+ <div className="text-xs text-gray-500">未打卡</div>
129
+ </div>
130
+ </div>
131
+ </div>
132
+ )}
133
+
134
+ {/* Warnings */}
135
+ {homeroomClass && (absentCount > 0 || leaveCount > 0) && (
136
+ <div className="bg-white p-6 rounded-xl border border-gray-100 shadow-sm">
137
+ <h3 className="font-bold text-gray-800 mb-4 flex items-center"><AlertTriangle className="mr-2 text-amber-500"/> 缺勤/请假名单</h3>
138
+ <div className="space-y-3">
139
+ {absentCount > 0 && (
140
+ <div className="flex gap-2 items-start bg-red-50 p-3 rounded-lg border border-red-100">
141
+ <div className="font-bold text-red-600 text-sm whitespace-nowrap">旷课人员:</div>
142
+ <div className="text-sm text-red-800 flex flex-wrap gap-2">
143
+ {absentNames.map(n => <span key={n} className="bg-white px-2 py-0.5 rounded border border-red-200">{n}</span>)}
144
+ </div>
145
+ </div>
146
+ )}
147
+ {leaveCount > 0 && (
148
+ <div className="flex gap-2 items-start bg-orange-50 p-3 rounded-lg border border-orange-100">
149
+ <div className="font-bold text-orange-600 text-sm whitespace-nowrap">请假人员:</div>
150
+ <div className="text-sm text-orange-800 flex flex-wrap gap-2">
151
+ {leaveNames.map(n => <span key={n} className="bg-white px-2 py-0.5 rounded border border-orange-200">{n}</span>)}
152
+ </div>
153
+ </div>
154
+ )}
155
+ </div>
156
+ </div>
157
+ )}
158
+ </div>
159
+
160
+ {/* Right Column: Todo List */}
161
+ <div className="h-[500px]">
162
+ <TodoList />
163
+ </div>
164
+ </div>
165
+
166
+ {/* Schedule Modal */}
167
+ {showSchedule && homeroomClass && (
168
+ <div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
169
+ <div className="bg-white rounded-xl w-full max-w-6xl h-[90vh] flex flex-col shadow-2xl animate-in zoom-in-95">
170
+ <div className="p-4 border-b flex justify-between items-center">
171
+ <div className="flex items-center gap-4">
172
+ <h3 className="text-xl font-bold flex items-center"><Calendar className="mr-2 text-blue-600"/> 智能课表 ({homeroomClass})</h3>
173
+ <div className="flex bg-gray-100 p-1 rounded-lg text-xs font-bold">
174
+ {['ALL','ODD','EVEN'].map(t => (
175
+ <button
176
+ key={t}
177
+ onClick={() => setWeekType(t as any)}
178
+ className={`px-3 py-1.5 rounded transition-all ${weekType === t ? 'bg-white shadow text-blue-600' : 'text-gray-500'}`}
179
+ >
180
+ {t === 'ALL' ? '全周' : t === 'ODD' ? '单周' : '双周'}
181
+ </button>
182
+ ))}
183
+ </div>
184
+ </div>
185
+ <button onClick={() => setShowSchedule(false)}><X size={24} className="text-gray-400 hover:text-gray-600"/></button>
186
+ </div>
187
+
188
+ <div className="flex-1 overflow-auto bg-gray-50 p-4">
189
+ <table className="w-full border-collapse bg-white shadow-sm rounded-lg overflow-hidden">
190
+ <thead>
191
+ <tr className="bg-gray-100 text-gray-500 text-sm">
192
+ <th className="p-3 border w-20">节次</th>
193
+ {['周一','周二','周三','周四','周五'].map(d=><th key={d} className="p-3 border w-[18%]">{d}</th>)}
194
+ </tr>
195
+ </thead>
196
+ <tbody>
197
+ {Array.from({length: periodCount}).map((_, idx) => {
198
+ const period = idx + 1;
199
+ return (
200
+ <tr key={period}>
201
+ <td className="border p-3 text-center text-sm font-bold text-gray-600 bg-gray-50">第{period}节</td>
202
+ {[1,2,3,4,5].map(day => {
203
+ // Filter relevant schedules based on week type
204
+ const slotItems = schedules.filter(s =>
205
+ s.dayOfWeek === day &&
206
+ s.period === period &&
207
+ (s.weekType === 'ALL' || s.weekType === weekType || weekType === 'ALL')
208
+ );
209
+
210
+ return (
211
+ <td key={day} className="border p-1 align-top h-24 relative group hover:bg-blue-50 transition-colors" onClick={() => {
212
+ setEditingCell({day, period});
213
+ setEditForm({subject:'', teacherName:'', weekType: weekType === 'ALL' ? 'ALL' : weekType});
214
+ }}>
215
+ <div className="flex flex-col gap-1 h-full">
216
+ {slotItems.map(item => (
217
+ <div
218
+ key={item._id}
219
+ className="p-1 rounded text-xs border relative group/item"
220
+ style={{backgroundColor: stringToColor(item.subject), borderColor: 'rgba(0,0,0,0.1)'}}
221
+ onClick={(e) => { e.stopPropagation(); }}
222
+ >
223
+ <div className="font-bold text-gray-800">{item.subject}</div>
224
+ <div className="flex justify-between items-center text-[10px] text-gray-600 mt-0.5">
225
+ <span>{item.teacherName}</span>
226
+ {item.weekType !== 'ALL' && <span className="bg-white/50 px-1 rounded">{item.weekType==='ODD'?'单':'双'}</span>}
227
+ </div>
228
+ <button
229
+ onClick={(e) => { e.stopPropagation(); handleDeleteSchedule(item); }}
230
+ className="absolute top-0 right-0 p-0.5 text-red-500 opacity-0 group-hover/item:opacity-100"
231
+ >
232
+ <X size={12}/>
233
+ </button>
234
+ </div>
235
+ ))}
236
+ {slotItems.length === 0 && <div className="h-full flex items-center justify-center opacity-0 group-hover:opacity-50"><Plus size={20} className="text-blue-300"/></div>}
237
+ </div>
238
+ </td>
239
+ );
240
+ })}
241
+ </tr>
242
+ );
243
+ })}
244
+ </tbody>
245
+ </table>
246
+ </div>
247
+ </div>
248
+ </div>
249
+ )}
250
+
251
+ {/* Edit Modal */}
252
+ {editingCell && (
253
+ <div className="fixed inset-0 bg-black/20 z-[100] flex items-center justify-center p-4 backdrop-blur-sm">
254
+ <div className="bg-white p-6 rounded-xl shadow-2xl w-80 animate-in zoom-in-95">
255
+ <h4 className="font-bold mb-4 text-gray-800">编辑课程</h4>
256
+ <div className="space-y-3">
257
+ <div>
258
+ <label className="text-xs font-bold text-gray-500">科目</label>
259
+ <input className="w-full border rounded p-2 text-sm" value={editForm.subject} onChange={e=>setEditForm({...editForm, subject: e.target.value})} autoFocus/>
260
+ </div>
261
+ <div>
262
+ <label className="text-xs font-bold text-gray-500">教师 (可选)</label>
263
+ <input className="w-full border rounded p-2 text-sm" value={editForm.teacherName} onChange={e=>setEditForm({...editForm, teacherName: e.target.value})}/>
264
+ </div>
265
+ <div>
266
+ <label className="text-xs font-bold text-gray-500">周次类型</label>
267
+ <select className="w-full border rounded p-2 text-sm" value={editForm.weekType} onChange={e=>setEditForm({...editForm, weekType: e.target.value})}>
268
+ <option value="ALL">每周</option>
269
+ <option value="ODD">单周</option>
270
+ <option value="EVEN">双周</option>
271
+ </select>
272
+ </div>
273
+ <div className="flex gap-2 pt-2">
274
+ <button onClick={handleSaveSchedule} className="flex-1 bg-blue-600 text-white py-2 rounded font-bold">保存</button>
275
+ <button onClick={()=>setEditingCell(null)} className="flex-1 bg-gray-100 text-gray-700 py-2 rounded">取消</button>
276
+ </div>
277
+ </div>
278
+ </div>
279
+ </div>
280
+ )}
281
+ </div>
282
+ );
283
+ };
pages/WishesAndFeedback.tsx CHANGED
@@ -3,27 +3,18 @@ import React, { useState, useEffect } from 'react';
3
  import { api } from '../services/api';
4
  import { Student, User, Wish, Feedback } from '../types';
5
  import { Loader2, Send, Heart, MessageSquare, Check, X, Filter, Trash2, Shuffle, CheckCircle, Mail } from 'lucide-react';
6
- import { Emoji } from '../components/Emoji';
7
 
8
- // --- SUB-COMPONENTS ---
9
-
10
- // 1. Wish Note Card
11
  const WishNote = ({ wish, onFulfill }: { wish: Wish, onFulfill?: (id: string) => void }) => {
12
  return (
13
  <div className={`relative p-4 w-40 h-40 md:w-48 md:h-48 shadow-lg flex flex-col justify-between transition-transform hover:scale-105 hover:z-10 rotate-${Math.floor(Math.random()*6)-3} ${wish.status === 'FULFILLED' ? 'bg-gray-100 grayscale opacity-70' : 'bg-yellow-100'}`} style={{fontFamily: '"Comic Sans MS", cursive, sans-serif'}}>
14
- {/* Pin */}
15
  <div className="absolute -top-3 left-1/2 -translate-x-1/2 w-4 h-4 rounded-full bg-red-500 shadow-sm border-2 border-red-700 z-20"></div>
16
-
17
  <div className="text-xs text-gray-500 font-bold mb-1">{new Date(wish.createTime).toLocaleDateString()}</div>
18
- <div className="flex-1 overflow-hidden text-sm text-gray-800 leading-relaxed font-medium break-words">
19
- {wish.content}
20
- </div>
21
  <div className="mt-2 pt-2 border-t border-black/10 flex justify-between items-end">
22
  <div className="text-xs font-bold text-gray-600 truncate max-w-[80px]">{wish.studentName}</div>
23
  {onFulfill && wish.status === 'PENDING' && (
24
- <button onClick={() => onFulfill(wish._id!)} className="bg-green-500 text-white p-1 rounded-full hover:bg-green-600 shadow-sm" title="实现愿望">
25
- <Check size={14}/>
26
- </button>
27
  )}
28
  {wish.status === 'FULFILLED' && <CheckCircle size={16} className="text-green-600"/>}
29
  </div>
@@ -31,79 +22,20 @@ const WishNote = ({ wish, onFulfill }: { wish: Wish, onFulfill?: (id: string) =>
31
  );
32
  };
33
 
34
- // 2. Feedback Item
35
  const FeedbackItem = ({ fb, onStatusChange }: { fb: Feedback, onStatusChange?: (id: string, status: string, reply?: string) => void }) => {
36
  const [replyText, setReplyText] = useState(fb.reply || '');
37
  const [isReplying, setIsReplying] = useState(false);
38
-
39
- const handleAction = (status: string) => {
40
- if (onStatusChange) {
41
- onStatusChange(fb._id!, status, isReplying ? replyText : undefined);
42
- setIsReplying(false);
43
- }
44
- };
45
-
46
  return (
47
  <div className="bg-white p-4 rounded-xl border border-gray-100 shadow-sm hover:shadow-md transition-shadow">
48
  <div className="flex justify-between items-start mb-2">
49
- <div className="flex items-center gap-2">
50
- <div className="w-8 h-8 rounded-full bg-blue-100 text-blue-600 flex items-center justify-center font-bold text-xs">
51
- {fb.creatorName[0]}
52
- </div>
53
- <div>
54
- <div className="font-bold text-sm text-gray-800">{fb.creatorName} <span className="text-xs font-normal text-gray-400">({fb.creatorRole === 'STUDENT' ? '学生' : '老师'})</span></div>
55
- <div className="text-[10px] text-gray-400">{new Date(fb.createTime).toLocaleString()}</div>
56
- </div>
57
- </div>
58
- <div className={`px-2 py-1 rounded text-xs font-bold ${
59
- fb.status === 'PENDING' ? 'bg-gray-100 text-gray-500' :
60
- fb.status === 'ACCEPTED' ? 'bg-blue-100 text-blue-600' :
61
- fb.status === 'PROCESSED' ? 'bg-green-100 text-green-600' : 'bg-red-50 text-red-400'
62
- }`}>
63
- {fb.status === 'PENDING' ? '待处理' : fb.status === 'ACCEPTED' ? '已接受' : fb.status === 'PROCESSED' ? '已处理' : '已忽略'}
64
- </div>
65
- </div>
66
-
67
- <div className="bg-gray-50 p-3 rounded-lg text-sm text-gray-700 mb-3 whitespace-pre-wrap border border-gray-100">
68
- {fb.content}
69
  </div>
70
-
71
- {fb.reply && !isReplying && (
72
- <div className="bg-blue-50 p-3 rounded-lg text-sm text-blue-800 mb-3 border border-blue-100">
73
- <span className="font-bold mr-1">回复:</span> {fb.reply}
74
- </div>
75
- )}
76
-
77
- {isReplying && (
78
- <div className="mb-3">
79
- <textarea
80
- className="w-full border rounded p-2 text-sm focus:ring-2 focus:ring-blue-500 outline-none"
81
- placeholder="请输入回复内容..."
82
- value={replyText}
83
- onChange={e => setReplyText(e.target.value)}
84
- />
85
- </div>
86
- )}
87
-
88
- {onStatusChange && (
89
- <div className="flex gap-2 justify-end">
90
- {fb.status === 'PENDING' && (
91
- <>
92
- <button onClick={() => handleAction('IGNORED')} className="text-xs text-gray-400 hover:text-red-500 px-2 py-1">忽略</button>
93
- <button onClick={() => handleAction('ACCEPTED')} className="text-xs bg-blue-100 text-blue-600 px-3 py-1 rounded hover:bg-blue-200 font-bold">接受</button>
94
- <button onClick={() => { setIsReplying(true); if(isReplying && replyText) handleAction('PROCESSED'); }} className="text-xs bg-green-100 text-green-600 px-3 py-1 rounded hover:bg-green-200 font-bold">
95
- {isReplying ? '提交回复并完成' : '回复并处理'}
96
- </button>
97
- </>
98
- )}
99
- {/* Allow replying to accepted feedback */}
100
- {fb.status === 'ACCEPTED' && (
101
- <button onClick={() => { setIsReplying(true); if(isReplying && replyText) handleAction('PROCESSED'); }} className="text-xs bg-green-100 text-green-600 px-3 py-1 rounded hover:bg-green-200 font-bold">
102
- {isReplying ? '提交回复并完成' : '处理'}
103
- </button>
104
- )}
105
- </div>
106
- )}
107
  </div>
108
  );
109
  };
@@ -113,369 +45,141 @@ export const WishesAndFeedback: React.FC = () => {
113
  const [loading, setLoading] = useState(true);
114
  const [wishes, setWishes] = useState<Wish[]>([]);
115
  const [feedbackList, setFeedbackList] = useState<Feedback[]>([]);
116
-
117
- // Config / Data
118
  const [teachers, setTeachers] = useState<User[]>([]);
119
  const [studentInfo, setStudentInfo] = useState<Student | null>(null);
120
  const [myPendingWish, setMyPendingWish] = useState<Wish | null>(null);
121
-
122
- // Filter States
123
- const [filterStatus, setFilterStatus] = useState<string>('PENDING,ACCEPTED,PROCESSED'); // Default hide ignored
124
-
125
- // Forms
126
  const [wishContent, setWishContent] = useState('');
127
  const [feedbackContent, setFeedbackContent] = useState('');
128
  const [selectedTeacherId, setSelectedTeacherId] = useState('');
 
129
 
130
  const currentUser = api.auth.getCurrentUser();
131
  const isStudent = currentUser?.role === 'STUDENT';
132
  const isTeacher = currentUser?.role === 'TEACHER';
133
- const isPrincipal = currentUser?.role === 'PRINCIPAL';
134
  const isAdmin = currentUser?.role === 'ADMIN';
135
 
136
- useEffect(() => {
137
- loadData();
138
- }, [activeTab, filterStatus]);
139
 
140
  const loadData = async () => {
141
  setLoading(true);
142
  try {
143
- // Common: Load Teachers for selection
144
  if (teachers.length === 0 && !isAdmin) {
145
- // If student, get class teachers. If teacher, get colleagues or just use list for principal
146
  if (isStudent) {
147
  const stus = await api.students.getAll();
148
  const me = stus.find((s: Student) => s.name === (currentUser?.trueName || currentUser?.username));
149
- if (me) {
150
- setStudentInfo(me);
151
- const tList = await api.users.getTeachersForClass(me.className);
152
- setTeachers(tList);
153
- // Default teacher is homeroom if possible, or first in list
154
- // Logic for finding homeroom teacher ID is complex on frontend without extra calls,
155
- // so we might just default to first one.
156
- if (tList.length > 0) setSelectedTeacherId(tList[0]._id!);
157
- }
158
- } else {
159
- const allTeachers = await api.users.getAll({ role: 'TEACHER' });
160
- setTeachers(allTeachers);
161
- }
162
  }
163
-
164
  if (activeTab === 'wishes') {
165
- if (isStudent && studentInfo) {
166
- // Load MY wishes
167
- const myWishes = await api.wishes.getAll({ studentId: studentInfo._id || String(studentInfo.id) });
168
- setWishes(myWishes);
169
- setMyPendingWish(myWishes.find((w: Wish) => w.status === 'PENDING') || null);
170
- } else if (isTeacher) {
171
- // Load wishes FOR ME
172
- const myWishes = await api.wishes.getAll({ teacherId: currentUser?._id });
173
- setWishes(myWishes);
174
- }
175
  } else if (activeTab === 'feedback') {
176
- if (isStudent && studentInfo) {
177
- const myFb = await api.feedback.getAll({ creatorId: studentInfo._id || String(studentInfo.id), type: 'ACADEMIC' });
178
- setFeedbackList(myFb);
179
- } else if (isTeacher) {
180
- const fbForMe = await api.feedback.getAll({ targetId: currentUser?._id, type: 'ACADEMIC', status: filterStatus });
181
- setFeedbackList(fbForMe);
182
- }
183
  } else if (activeTab === 'system') {
184
- if (isAdmin) {
185
- const sysFb = await api.feedback.getAll({ type: 'SYSTEM' });
186
- setFeedbackList(sysFb);
187
- } else {
188
- // Teacher/Principal view their own system feedback
189
- const mySysFb = await api.feedback.getAll({ creatorId: currentUser?._id, type: 'SYSTEM' });
190
- setFeedbackList(mySysFb);
191
- }
192
  }
193
-
194
- } catch (e) { console.error(e); }
195
- finally { setLoading(false); }
196
  };
197
 
198
- // --- Actions ---
199
-
200
  const submitWish = async () => {
201
  if (!wishContent.trim()) return alert('请输入愿望内容');
202
  if (!selectedTeacherId) return alert('请选择许愿对象');
203
-
204
  try {
205
  const targetTeacher = teachers.find(t => t._id === selectedTeacherId);
206
- await api.wishes.create({
207
- studentId: studentInfo?._id || String(studentInfo?.id),
208
- studentName: studentInfo?.name,
209
- className: studentInfo?.className,
210
- teacherId: selectedTeacherId,
211
- teacherName: targetTeacher?.trueName || targetTeacher?.username,
212
- content: wishContent,
213
- status: 'PENDING'
214
- });
215
- alert('许愿成功!愿望已挂上许愿树。');
216
- setWishContent('');
217
- loadData();
218
- } catch (e: any) {
219
- alert(e.message || '许愿失败');
220
- }
221
  };
222
 
223
  const fulfillWish = async (id: string) => {
224
- if (!confirm('确定要实现这个愿望吗?')) return;
225
- await api.wishes.fulfill(id);
226
- loadData();
 
 
 
 
 
 
227
  };
228
 
229
  const randomFulfill = async () => {
230
- if (!confirm('系统将随机选择一个待实现的愿望,确定吗?')) return;
231
- try {
232
- const res = await api.wishes.randomFulfill(currentUser?._id!);
233
- alert(`🎉 命运之:${res.wish.studentName} 的愿望 "${res.wish.content}" 已被选中实现!`);
234
- loadData();
235
- } catch (e: any) { alert(e.message); }
 
 
 
 
 
 
236
  };
237
 
238
  const submitFeedback = async (type: 'ACADEMIC' | 'SYSTEM') => {
239
- const content = type === 'ACADEMIC' ? feedbackContent : wishContent; // Reuse state for simplicity or separate
240
  if (!content.trim()) return alert('请输入内容');
241
-
242
  try {
243
- const payload: Partial<Feedback> = {
244
- creatorId: type === 'ACADEMIC' ? (studentInfo?._id || String(studentInfo?.id)) : currentUser?._id,
245
- creatorName: type === 'ACADEMIC' ? studentInfo?.name : (currentUser?.trueName || currentUser?.username),
246
- creatorRole: currentUser?.role,
247
- content: content,
248
- type: type,
249
- status: 'PENDING'
250
- };
251
-
252
- if (type === 'ACADEMIC') {
253
- if (!selectedTeacherId) return alert('请选择反馈对象');
254
- const targetTeacher = teachers.find(t => t._id === selectedTeacherId);
255
- payload.targetId = selectedTeacherId;
256
- payload.targetName = targetTeacher?.trueName || targetTeacher?.username;
257
- } else {
258
- payload.targetId = 'ADMIN';
259
- payload.targetName = '系统管理员';
260
- }
261
-
262
- await api.feedback.create(payload);
263
- alert('提交成功');
264
- if (type === 'ACADEMIC') setFeedbackContent(''); else setWishContent('');
265
- loadData();
266
  } catch(e) { alert('提交失败'); }
267
  };
268
 
269
- const handleFeedbackStatus = async (id: string, status: string, reply?: string) => {
270
- await api.feedback.update(id, { status, reply });
271
- loadData();
272
- };
273
-
274
  const handleIgnoreAll = async () => {
275
- if (!confirm('确定要忽略所有待处理的反馈吗?')) return;
276
- await api.feedback.ignoreAll(currentUser?._id!);
277
- loadData();
 
 
 
 
 
 
278
  };
279
 
280
- // --- RENDER ---
281
-
282
  return (
283
  <div className="h-full flex flex-col bg-slate-50 overflow-hidden">
284
- {/* Header Tabs */}
285
  <div className="bg-white border-b border-gray-200 px-6 pt-4 flex gap-6 shrink-0 shadow-sm z-10">
286
- <button onClick={() => setActiveTab('wishes')} className={`pb-3 text-sm font-bold border-b-2 transition-colors flex items-center gap-2 ${activeTab === 'wishes' ? 'border-pink-500 text-pink-600' : 'border-transparent text-gray-500 hover:text-gray-700'}`}>
287
- <Heart size={18} className={activeTab === 'wishes' ? 'fill-pink-500' : ''}/> 许愿树
288
- </button>
289
- {/* Feedback Tab logic: Students see "My Feedback", Teachers see "Inbox" */}
290
- <button onClick={() => setActiveTab('feedback')} className={`pb-3 text-sm font-bold border-b-2 transition-colors flex items-center gap-2 ${activeTab === 'feedback' ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700'}`}>
291
- <MessageSquare size={18} className={activeTab === 'feedback' ? 'fill-blue-500' : ''}/> {isStudent ? '我的意见箱' : '学生反馈'}
292
- </button>
293
- {/* System Feedback: Admin sees inbox, others see submission form */}
294
- {!isStudent && (
295
- <button onClick={() => setActiveTab('system')} className={`pb-3 text-sm font-bold border-b-2 transition-colors flex items-center gap-2 ${activeTab === 'system' ? 'border-purple-500 text-purple-600' : 'border-transparent text-gray-500 hover:text-gray-700'}`}>
296
- <Mail size={18} className={activeTab === 'system' ? 'fill-purple-500' : ''}/> 系统建议
297
- </button>
298
- )}
299
  </div>
300
-
301
  <div className="flex-1 overflow-hidden relative">
302
  {loading && <div className="absolute inset-0 bg-white/50 z-50 flex items-center justify-center"><Loader2 className="animate-spin text-blue-600"/></div>}
303
-
304
- {/* --- TAB: WISHES --- */}
305
  {activeTab === 'wishes' && (
306
  <div className="h-full flex flex-col">
307
- {/* Tree Background Area */}
308
  <div className="flex-1 bg-green-50 overflow-y-auto p-6 relative custom-scrollbar">
309
- <div className="absolute inset-0 opacity-10 pointer-events-none flex justify-center items-center">
310
- {/* Simple CSS Tree representation or SVG */}
311
- <svg viewBox="0 0 200 200" className="w-full h-full text-green-800" fill="currentColor">
312
- <path d="M100 20 L140 100 H120 L150 160 H50 L80 100 H60 Z" />
313
- <rect x="90" y="160" width="20" height="40" />
314
- </svg>
315
- </div>
316
-
317
  <div className="relative z-10 flex flex-wrap gap-6 justify-center content-start min-h-[300px]">
318
- {wishes.length === 0 ? (
319
- <div className="text-gray-400 mt-20 font-bold bg-white/80 p-4 rounded-xl shadow-sm">
320
- 🌲 许愿树上还空荡荡的,快来挂上第一个愿望吧!
321
- </div>
322
- ) : (
323
- wishes.map(w => (
324
- <WishNote key={w._id} wish={w} onFulfill={isTeacher ? fulfillWish : undefined} />
325
- ))
326
- )}
327
  </div>
328
  </div>
329
-
330
- {/* Bottom Control Panel */}
331
  <div className="bg-white border-t border-gray-200 p-4 shrink-0 shadow-[0_-4px_10px_-1px_rgba(0,0,0,0.05)]">
332
- {isStudent ? (
333
- myPendingWish ? (
334
- <div className="text-center p-4 bg-yellow-50 border border-yellow-200 rounded-xl">
335
- <p className="text-yellow-800 font-bold mb-1">您许下的愿望正在等待实现中...</p>
336
- <p className="text-sm text-yellow-600">"{myPendingWish.content}"</p>
337
- <p className="text-xs text-gray-400 mt-2">当老师实现此愿望后,您才可以许下新的愿望。</p>
338
- </div>
339
- ) : (
340
- <div className="flex flex-col md:flex-row gap-4 items-end max-w-4xl mx-auto">
341
- <div className="flex-1 w-full">
342
- <label className="text-xs font-bold text-gray-500 mb-1 block uppercase">许愿内容</label>
343
- <input
344
- className="w-full border border-gray-300 rounded-lg p-3 focus:ring-2 focus:ring-pink-500 outline-none"
345
- placeholder="我希望..."
346
- value={wishContent}
347
- onChange={e => setWishContent(e.target.value)}
348
- />
349
- </div>
350
- <div className="w-full md:w-48">
351
- <label className="text-xs font-bold text-gray-500 mb-1 block uppercase">许愿对象</label>
352
- <select
353
- className="w-full border border-gray-300 rounded-lg p-3 bg-white outline-none"
354
- value={selectedTeacherId}
355
- onChange={e => setSelectedTeacherId(e.target.value)}
356
- >
357
- {teachers.map(t => <option key={t._id} value={t._id}>{t.teachingSubject ? `${t.teachingSubject}-${t.trueName||t.username}` : t.trueName||t.username}</option>)}
358
- </select>
359
- </div>
360
- <button onClick={submitWish} className="w-full md:w-auto bg-pink-500 text-white px-6 py-3 rounded-lg font-bold hover:bg-pink-600 flex items-center justify-center gap-2 shadow-md">
361
- <Send size={18}/> 挂上愿望
362
- </button>
363
- </div>
364
- )
365
- ) : isTeacher ? (
366
- <div className="flex justify-between items-center max-w-4xl mx-auto">
367
- <div className="text-sm text-gray-500">
368
- <span className="font-bold text-gray-800">{wishes.filter(w=>w.status==='PENDING').length}</span> 个待实现愿望
369
- </div>
370
- <button onClick={randomFulfill} className="bg-gradient-to-r from-purple-500 to-indigo-500 text-white px-6 py-3 rounded-lg font-bold hover:shadow-lg transition-all flex items-center gap-2">
371
- <Shuffle size={18}/> 随机实现一个愿望
372
- </button>
373
- </div>
374
- ) : <div className="text-center text-gray-400">只读模式</div>}
375
  </div>
376
  </div>
377
  )}
378
-
379
- {/* --- TAB: FEEDBACK --- */}
380
  {activeTab === 'feedback' && (
381
  <div className="h-full flex flex-col md:flex-row">
382
- {/* List Area */}
383
  <div className="flex-1 overflow-y-auto p-6 bg-gray-50">
384
- {isTeacher && (
385
- <div className="mb-4 flex justify-between items-center bg-white p-3 rounded-xl border border-gray-100 shadow-sm">
386
- <div className="flex items-center gap-2 text-sm text-gray-600">
387
- <Filter size={16}/>
388
- <label className="flex items-center gap-1 cursor-pointer">
389
- <input type="checkbox" checked={filterStatus.includes('IGNORED')} onChange={e => {
390
- if(e.target.checked) setFilterStatus('PENDING,ACCEPTED,PROCESSED,IGNORED');
391
- else setFilterStatus('PENDING,ACCEPTED,PROCESSED');
392
- }} />
393
- 显示已忽略
394
- </label>
395
- </div>
396
- <button onClick={handleIgnoreAll} className="text-xs text-red-500 hover:text-red-600 hover:bg-red-50 px-3 py-1.5 rounded flex items-center transition-colors">
397
- <Trash2 size={14} className="mr-1"/> 一键忽略所有待处理
398
- </button>
399
- </div>
400
- )}
401
-
402
- <div className="space-y-4 max-w-3xl mx-auto">
403
- {feedbackList.length === 0 ? (
404
- <div className="text-center text-gray-400 py-10">暂无反馈记录</div>
405
- ) : (
406
- feedbackList.map(fb => (
407
- <FeedbackItem key={fb._id} fb={fb} onStatusChange={isTeacher ? handleFeedbackStatus : undefined} />
408
- ))
409
- )}
410
- </div>
411
  </div>
412
-
413
- {/* Input Area (Student Only) */}
414
- {isStudent && (
415
- <div className="w-full md:w-80 bg-white border-l border-gray-200 p-6 flex flex-col shadow-xl z-20">
416
- <h3 className="font-bold text-gray-800 mb-4 flex items-center"><MessageSquare size={18} className="mr-2 text-blue-500"/> 提交反馈</h3>
417
- <div className="space-y-4 flex-1">
418
- <div>
419
- <label className="text-xs font-bold text-gray-500 mb-1 block uppercase">反馈对象</label>
420
- <select
421
- className="w-full border border-gray-300 rounded-lg p-2 text-sm bg-white"
422
- value={selectedTeacherId}
423
- onChange={e => setSelectedTeacherId(e.target.value)}
424
- >
425
- {teachers.map(t => <option key={t._id} value={t._id}>{t.teachingSubject ? `${t.teachingSubject}-${t.trueName||t.username}` : t.trueName||t.username}</option>)}
426
- </select>
427
- </div>
428
- <div className="flex-1 flex flex-col">
429
- <label className="text-xs font-bold text-gray-500 mb-1 block uppercase">内容</label>
430
- <textarea
431
- className="w-full border border-gray-300 rounded-lg p-3 text-sm flex-1 resize-none focus:ring-2 focus:ring-blue-500 outline-none"
432
- placeholder="老师,我对课程有建议..."
433
- value={feedbackContent}
434
- onChange={e => setFeedbackContent(e.target.value)}
435
- />
436
- </div>
437
- <button onClick={() => submitFeedback('ACADEMIC')} className="w-full bg-blue-600 text-white py-3 rounded-lg font-bold hover:bg-blue-700 shadow-md">
438
- 提交反馈
439
- </button>
440
- </div>
441
- </div>
442
- )}
443
  </div>
444
  )}
445
-
446
- {/* --- TAB: SYSTEM FEEDBACK --- */}
447
  {activeTab === 'system' && (
448
  <div className="h-full flex flex-col md:flex-row">
449
- <div className="flex-1 overflow-y-auto p-6 bg-gray-50">
450
- <div className="max-w-3xl mx-auto space-y-4">
451
- {feedbackList.length === 0 ? (
452
- <div className="text-center text-gray-400 py-10">暂无系统反馈</div>
453
- ) : (
454
- feedbackList.map(fb => (
455
- <FeedbackItem key={fb._id} fb={fb} onStatusChange={isAdmin ? handleFeedbackStatus : undefined} />
456
- ))
457
- )}
458
- </div>
459
- </div>
460
-
461
- {!isAdmin && (
462
- <div className="w-full md:w-80 bg-white border-l border-gray-200 p-6 flex flex-col shadow-xl z-20">
463
- <h3 className="font-bold text-gray-800 mb-4 flex items-center"><Mail size={18} className="mr-2 text-purple-500"/> 联系管理员</h3>
464
- <p className="text-xs text-gray-500 mb-4">如果您在使用系统中遇到问题或有优化建议,请在此反馈。</p>
465
- <textarea
466
- className="w-full border border-gray-300 rounded-lg p-3 text-sm h-40 resize-none focus:ring-2 focus:ring-purple-500 outline-none mb-4"
467
- placeholder="描述您的问题或建议..."
468
- value={wishContent} // Reusing state var for simplicity
469
- onChange={e => setWishContent(e.target.value)}
470
- />
471
- <button onClick={() => submitFeedback('SYSTEM')} className="w-full bg-purple-600 text-white py-3 rounded-lg font-bold hover:bg-purple-700 shadow-md">
472
- 发送反馈
473
- </button>
474
- </div>
475
- )}
476
  </div>
477
  )}
478
  </div>
479
  </div>
480
  );
481
- };
 
3
  import { api } from '../services/api';
4
  import { Student, User, Wish, Feedback } from '../types';
5
  import { Loader2, Send, Heart, MessageSquare, Check, X, Filter, Trash2, Shuffle, CheckCircle, Mail } from 'lucide-react';
6
+ import { ConfirmModal } from '../components/ConfirmModal';
7
 
 
 
 
8
  const WishNote = ({ wish, onFulfill }: { wish: Wish, onFulfill?: (id: string) => void }) => {
9
  return (
10
  <div className={`relative p-4 w-40 h-40 md:w-48 md:h-48 shadow-lg flex flex-col justify-between transition-transform hover:scale-105 hover:z-10 rotate-${Math.floor(Math.random()*6)-3} ${wish.status === 'FULFILLED' ? 'bg-gray-100 grayscale opacity-70' : 'bg-yellow-100'}`} style={{fontFamily: '"Comic Sans MS", cursive, sans-serif'}}>
 
11
  <div className="absolute -top-3 left-1/2 -translate-x-1/2 w-4 h-4 rounded-full bg-red-500 shadow-sm border-2 border-red-700 z-20"></div>
 
12
  <div className="text-xs text-gray-500 font-bold mb-1">{new Date(wish.createTime).toLocaleDateString()}</div>
13
+ <div className="flex-1 overflow-hidden text-sm text-gray-800 leading-relaxed font-medium break-words">{wish.content}</div>
 
 
14
  <div className="mt-2 pt-2 border-t border-black/10 flex justify-between items-end">
15
  <div className="text-xs font-bold text-gray-600 truncate max-w-[80px]">{wish.studentName}</div>
16
  {onFulfill && wish.status === 'PENDING' && (
17
+ <button onClick={() => onFulfill(wish._id!)} className="bg-green-500 text-white p-1 rounded-full hover:bg-green-600 shadow-sm" title="实现愿望"><Check size={14}/></button>
 
 
18
  )}
19
  {wish.status === 'FULFILLED' && <CheckCircle size={16} className="text-green-600"/>}
20
  </div>
 
22
  );
23
  };
24
 
 
25
  const FeedbackItem = ({ fb, onStatusChange }: { fb: Feedback, onStatusChange?: (id: string, status: string, reply?: string) => void }) => {
26
  const [replyText, setReplyText] = useState(fb.reply || '');
27
  const [isReplying, setIsReplying] = useState(false);
28
+ const handleAction = (status: string) => { if (onStatusChange) { onStatusChange(fb._id!, status, isReplying ? replyText : undefined); setIsReplying(false); } };
 
 
 
 
 
 
 
29
  return (
30
  <div className="bg-white p-4 rounded-xl border border-gray-100 shadow-sm hover:shadow-md transition-shadow">
31
  <div className="flex justify-between items-start mb-2">
32
+ <div className="flex items-center gap-2"><div className="w-8 h-8 rounded-full bg-blue-100 text-blue-600 flex items-center justify-center font-bold text-xs">{fb.creatorName[0]}</div><div><div className="font-bold text-sm text-gray-800">{fb.creatorName} <span className="text-xs font-normal text-gray-400">({fb.creatorRole === 'STUDENT' ? '学生' : '老师'})</span></div><div className="text-[10px] text-gray-400">{new Date(fb.createTime).toLocaleString()}</div></div></div>
33
+ <div className={`px-2 py-1 rounded text-xs font-bold ${fb.status === 'PENDING' ? 'bg-gray-100 text-gray-500' : fb.status === 'ACCEPTED' ? 'bg-blue-100 text-blue-600' : fb.status === 'PROCESSED' ? 'bg-green-100 text-green-600' : 'bg-red-50 text-red-400'}`}>{fb.status === 'PENDING' ? '待处理' : fb.status === 'ACCEPTED' ? '已接受' : fb.status === 'PROCESSED' ? '已处理' : '已忽略'}</div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  </div>
35
+ <div className="bg-gray-50 p-3 rounded-lg text-sm text-gray-700 mb-3 whitespace-pre-wrap border border-gray-100">{fb.content}</div>
36
+ {fb.reply && !isReplying && <div className="bg-blue-50 p-3 rounded-lg text-sm text-blue-800 mb-3 border border-blue-100"><span className="font-bold mr-1">回复:</span> {fb.reply}</div>}
37
+ {isReplying && <div className="mb-3"><textarea className="w-full border rounded p-2 text-sm focus:ring-2 focus:ring-blue-500 outline-none" placeholder="请输入回复内容..." value={replyText} onChange={e => setReplyText(e.target.value)}/></div>}
38
+ {onStatusChange && (<div className="flex gap-2 justify-end">{fb.status === 'PENDING' && (<><button onClick={() => handleAction('IGNORED')} className="text-xs text-gray-400 hover:text-red-500 px-2 py-1">忽略</button><button onClick={() => handleAction('ACCEPTED')} className="text-xs bg-blue-100 text-blue-600 px-3 py-1 rounded hover:bg-blue-200 font-bold">接受</button><button onClick={() => { setIsReplying(true); if(isReplying && replyText) handleAction('PROCESSED'); }} className="text-xs bg-green-100 text-green-600 px-3 py-1 rounded hover:bg-green-200 font-bold">{isReplying ? '提交回复并完成' : '回复并处理'}</button></>)}{fb.status === 'ACCEPTED' && (<button onClick={() => { setIsReplying(true); if(isReplying && replyText) handleAction('PROCESSED'); }} className="text-xs bg-green-100 text-green-600 px-3 py-1 rounded hover:bg-green-200 font-bold">{isReplying ? '提交回复并完成' : '处理'}</button>)}</div>)}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
  </div>
40
  );
41
  };
 
45
  const [loading, setLoading] = useState(true);
46
  const [wishes, setWishes] = useState<Wish[]>([]);
47
  const [feedbackList, setFeedbackList] = useState<Feedback[]>([]);
 
 
48
  const [teachers, setTeachers] = useState<User[]>([]);
49
  const [studentInfo, setStudentInfo] = useState<Student | null>(null);
50
  const [myPendingWish, setMyPendingWish] = useState<Wish | null>(null);
51
+ const [filterStatus, setFilterStatus] = useState<string>('PENDING,ACCEPTED,PROCESSED');
 
 
 
 
52
  const [wishContent, setWishContent] = useState('');
53
  const [feedbackContent, setFeedbackContent] = useState('');
54
  const [selectedTeacherId, setSelectedTeacherId] = useState('');
55
+ const [confirmModal, setConfirmModal] = useState<{isOpen: boolean, title: string, message: string, onConfirm: () => void}>({ isOpen: false, title: '', message: '', onConfirm: () => {} });
56
 
57
  const currentUser = api.auth.getCurrentUser();
58
  const isStudent = currentUser?.role === 'STUDENT';
59
  const isTeacher = currentUser?.role === 'TEACHER';
 
60
  const isAdmin = currentUser?.role === 'ADMIN';
61
 
62
+ useEffect(() => { loadData(); }, [activeTab, filterStatus]);
 
 
63
 
64
  const loadData = async () => {
65
  setLoading(true);
66
  try {
 
67
  if (teachers.length === 0 && !isAdmin) {
 
68
  if (isStudent) {
69
  const stus = await api.students.getAll();
70
  const me = stus.find((s: Student) => s.name === (currentUser?.trueName || currentUser?.username));
71
+ if (me) { setStudentInfo(me); const tList = await api.users.getTeachersForClass(me.className); setTeachers(tList); if (tList.length > 0) setSelectedTeacherId(tList[0]._id!); }
72
+ } else { const allTeachers = await api.users.getAll({ role: 'TEACHER' }); setTeachers(allTeachers); }
 
 
 
 
 
 
 
 
 
 
 
73
  }
 
74
  if (activeTab === 'wishes') {
75
+ if (isStudent && studentInfo) { const myWishes = await api.wishes.getAll({ studentId: studentInfo._id || String(studentInfo.id) }); setWishes(myWishes); setMyPendingWish(myWishes.find((w: Wish) => w.status === 'PENDING') || null); } else if (isTeacher) { const myWishes = await api.wishes.getAll({ teacherId: currentUser?._id }); setWishes(myWishes); }
 
 
 
 
 
 
 
 
 
76
  } else if (activeTab === 'feedback') {
77
+ if (isStudent && studentInfo) { const myFb = await api.feedback.getAll({ creatorId: studentInfo._id || String(studentInfo.id), type: 'ACADEMIC' }); setFeedbackList(myFb); } else if (isTeacher) { const fbForMe = await api.feedback.getAll({ targetId: currentUser?._id, type: 'ACADEMIC', status: filterStatus }); setFeedbackList(fbForMe); }
 
 
 
 
 
 
78
  } else if (activeTab === 'system') {
79
+ if (isAdmin) { const sysFb = await api.feedback.getAll({ type: 'SYSTEM' }); setFeedbackList(sysFb); } else { const mySysFb = await api.feedback.getAll({ creatorId: currentUser?._id, type: 'SYSTEM' }); setFeedbackList(mySysFb); }
 
 
 
 
 
 
 
80
  }
81
+ } catch (e) { console.error(e); } finally { setLoading(false); }
 
 
82
  };
83
 
 
 
84
  const submitWish = async () => {
85
  if (!wishContent.trim()) return alert('请输入愿望内容');
86
  if (!selectedTeacherId) return alert('请选择许愿对象');
 
87
  try {
88
  const targetTeacher = teachers.find(t => t._id === selectedTeacherId);
89
+ await api.wishes.create({ studentId: studentInfo?._id || String(studentInfo?.id), studentName: studentInfo?.name, className: studentInfo?.className, teacherId: selectedTeacherId, teacherName: targetTeacher?.trueName || targetTeacher?.username, content: wishContent, status: 'PENDING' });
90
+ alert('许愿成功!愿望已挂上许愿树。'); setWishContent(''); loadData();
91
+ } catch (e: any) { alert(e.message || '许愿失败'); }
 
 
 
 
 
 
 
 
 
 
 
 
92
  };
93
 
94
  const fulfillWish = async (id: string) => {
95
+ setConfirmModal({
96
+ isOpen: true,
97
+ title: '实现愿望',
98
+ message: '确定要实现这个愿望吗?',
99
+ onConfirm: async () => {
100
+ await api.wishes.fulfill(id);
101
+ loadData();
102
+ }
103
+ });
104
  };
105
 
106
  const randomFulfill = async () => {
107
+ setConfirmModal({
108
+ isOpen: true,
109
+ title: '随机抽取',
110
+ message: '系统将随机择一个待实现的愿望,确定吗?',
111
+ onConfirm: async () => {
112
+ try {
113
+ const res = await api.wishes.randomFulfill(currentUser?._id!);
114
+ alert(`🎉 命运之选:${res.wish.studentName} 的愿望 "${res.wish.content}" 已被选中实现!`);
115
+ loadData();
116
+ } catch (e: any) { alert(e.message); }
117
+ }
118
+ });
119
  };
120
 
121
  const submitFeedback = async (type: 'ACADEMIC' | 'SYSTEM') => {
122
+ const content = type === 'ACADEMIC' ? feedbackContent : wishContent;
123
  if (!content.trim()) return alert('请输入内容');
 
124
  try {
125
+ const payload: Partial<Feedback> = { creatorId: type === 'ACADEMIC' ? (studentInfo?._id || String(studentInfo?.id)) : currentUser?._id, creatorName: type === 'ACADEMIC' ? studentInfo?.name : (currentUser?.trueName || currentUser?.username), creatorRole: currentUser?.role, content: content, type: type, status: 'PENDING' };
126
+ if (type === 'ACADEMIC') { if (!selectedTeacherId) return alert('请选择反馈对象'); const targetTeacher = teachers.find(t => t._id === selectedTeacherId); payload.targetId = selectedTeacherId; payload.targetName = targetTeacher?.trueName || targetTeacher?.username; } else { payload.targetId = 'ADMIN'; payload.targetName = '系统管理员'; }
127
+ await api.feedback.create(payload); alert('提交成功'); if (type === 'ACADEMIC') setFeedbackContent(''); else setWishContent(''); loadData();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
  } catch(e) { alert('提交失败'); }
129
  };
130
 
131
+ const handleFeedbackStatus = async (id: string, status: string, reply?: string) => { await api.feedback.update(id, { status, reply }); loadData(); };
 
 
 
 
132
  const handleIgnoreAll = async () => {
133
+ setConfirmModal({
134
+ isOpen: true,
135
+ title: '全部忽略',
136
+ message: '确定要忽略所有待处理的反馈吗?',
137
+ onConfirm: async () => {
138
+ await api.feedback.ignoreAll(currentUser?._id!);
139
+ loadData();
140
+ }
141
+ });
142
  };
143
 
 
 
144
  return (
145
  <div className="h-full flex flex-col bg-slate-50 overflow-hidden">
146
+ <ConfirmModal isOpen={confirmModal.isOpen} title={confirmModal.title} message={confirmModal.message} onClose={()=>setConfirmModal({...confirmModal, isOpen: false})} onConfirm={confirmModal.onConfirm}/>
147
  <div className="bg-white border-b border-gray-200 px-6 pt-4 flex gap-6 shrink-0 shadow-sm z-10">
148
+ <button onClick={() => setActiveTab('wishes')} className={`pb-3 text-sm font-bold border-b-2 transition-colors flex items-center gap-2 ${activeTab === 'wishes' ? 'border-pink-500 text-pink-600' : 'border-transparent text-gray-500 hover:text-gray-700'}`}><Heart size={18} className={activeTab === 'wishes' ? 'fill-pink-500' : ''}/> 许愿树</button>
149
+ <button onClick={() => setActiveTab('feedback')} className={`pb-3 text-sm font-bold border-b-2 transition-colors flex items-center gap-2 ${activeTab === 'feedback' ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700'}`}><MessageSquare size={18} className={activeTab === 'feedback' ? 'fill-blue-500' : ''}/> {isStudent ? '我的意见箱' : '学生反馈'}</button>
150
+ {!isStudent && <button onClick={() => setActiveTab('system')} className={`pb-3 text-sm font-bold border-b-2 transition-colors flex items-center gap-2 ${activeTab === 'system' ? 'border-purple-500 text-purple-600' : 'border-transparent text-gray-500 hover:text-gray-700'}`}><Mail size={18} className={activeTab === 'system' ? 'fill-purple-500' : ''}/> 系统建议</button>}
 
 
 
 
 
 
 
 
 
 
151
  </div>
 
152
  <div className="flex-1 overflow-hidden relative">
153
  {loading && <div className="absolute inset-0 bg-white/50 z-50 flex items-center justify-center"><Loader2 className="animate-spin text-blue-600"/></div>}
 
 
154
  {activeTab === 'wishes' && (
155
  <div className="h-full flex flex-col">
 
156
  <div className="flex-1 bg-green-50 overflow-y-auto p-6 relative custom-scrollbar">
157
+ <div className="absolute inset-0 opacity-10 pointer-events-none flex justify-center items-center"><svg viewBox="0 0 200 200" className="w-full h-full text-green-800" fill="currentColor"><path d="M100 20 L140 100 H120 L150 160 H50 L80 100 H60 Z" /><rect x="90" y="160" width="20" height="40" /></svg></div>
 
 
 
 
 
 
 
158
  <div className="relative z-10 flex flex-wrap gap-6 justify-center content-start min-h-[300px]">
159
+ {wishes.length === 0 ? <div className="text-gray-400 mt-20 font-bold bg-white/80 p-4 rounded-xl shadow-sm">🌲 许愿树上还空荡荡的,快来挂上第一个愿望吧!</div> : wishes.map(w => (<WishNote key={w._id} wish={w} onFulfill={isTeacher ? fulfillWish : undefined} />))}
 
 
 
 
 
 
 
 
160
  </div>
161
  </div>
 
 
162
  <div className="bg-white border-t border-gray-200 p-4 shrink-0 shadow-[0_-4px_10px_-1px_rgba(0,0,0,0.05)]">
163
+ {isStudent ? (myPendingWish ? (<div className="text-center p-4 bg-yellow-50 border border-yellow-200 rounded-xl"><p className="text-yellow-800 font-bold mb-1">您许下的愿望正在等待实现中...</p><p className="text-sm text-yellow-600">"{myPendingWish.content}"</p><p className="text-xs text-gray-400 mt-2">当老师实现此愿望后,您才可以许下新的愿望。</p></div>) : (<div className="flex flex-col md:flex-row gap-4 items-end max-w-4xl mx-auto"><div className="flex-1 w-full"><label className="text-xs font-bold text-gray-500 mb-1 block uppercase">许愿内容</label><input className="w-full border border-gray-300 rounded-lg p-3 focus:ring-2 focus:ring-pink-500 outline-none" placeholder="我希望..." value={wishContent} onChange={e => setWishContent(e.target.value)}/></div><div className="w-full md:w-48"><label className="text-xs font-bold text-gray-500 mb-1 block uppercase">许愿对象</label><select className="w-full border border-gray-300 rounded-lg p-3 bg-white outline-none" value={selectedTeacherId} onChange={e => setSelectedTeacherId(e.target.value)}>{teachers.map(t => <option key={t._id} value={t._id}>{t.teachingSubject ? `${t.teachingSubject}-${t.trueName||t.username}` : t.trueName||t.username}</option>)}</select></div><button onClick={submitWish} className="w-full md:w-auto bg-pink-500 text-white px-6 py-3 rounded-lg font-bold hover:bg-pink-600 flex items-center justify-center gap-2 shadow-md"><Send size={18}/> 挂上愿望</button></div>)) : isTeacher ? (<div className="flex justify-between items-center max-w-4xl mx-auto"><div className="text-sm text-gray-500"><span className="font-bold text-gray-800">{wishes.filter(w=>w.status==='PENDING').length}</span> 个待实现愿望</div><button onClick={randomFulfill} className="bg-gradient-to-r from-purple-500 to-indigo-500 text-white px-6 py-3 rounded-lg font-bold hover:shadow-lg transition-all flex items-center gap-2"><Shuffle size={18}/> 随机实现一个愿望</button></div>) : <div className="text-center text-gray-400">只读模式</div>}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
164
  </div>
165
  </div>
166
  )}
 
 
167
  {activeTab === 'feedback' && (
168
  <div className="h-full flex flex-col md:flex-row">
 
169
  <div className="flex-1 overflow-y-auto p-6 bg-gray-50">
170
+ {isTeacher && (<div className="mb-4 flex justify-between items-center bg-white p-3 rounded-xl border border-gray-100 shadow-sm"><div className="flex items-center gap-2 text-sm text-gray-600"><Filter size={16}/><label className="flex items-center gap-1 cursor-pointer"><input type="checkbox" checked={filterStatus.includes('IGNORED')} onChange={e => { if(e.target.checked) setFilterStatus('PENDING,ACCEPTED,PROCESSED,IGNORED'); else setFilterStatus('PENDING,ACCEPTED,PROCESSED'); }} /> 显示已忽略</label></div><button onClick={handleIgnoreAll} className="text-xs text-red-500 hover:text-red-600 hover:bg-red-50 px-3 py-1.5 rounded flex items-center transition-colors"><Trash2 size={14} className="mr-1"/> 一键忽略所有待处理</button></div>)}
171
+ <div className="space-y-4 max-w-3xl mx-auto">{feedbackList.length === 0 ? <div className="text-center text-gray-400 py-10">暂无反馈记录</div> : feedbackList.map(fb => (<FeedbackItem key={fb._id} fb={fb} onStatusChange={isTeacher ? handleFeedbackStatus : undefined} />))}</div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
172
  </div>
173
+ {isStudent && (<div className="w-full md:w-80 bg-white border-l border-gray-200 p-6 flex flex-col shadow-xl z-20"><h3 className="font-bold text-gray-800 mb-4 flex items-center"><MessageSquare size={18} className="mr-2 text-blue-500"/> 提交反馈</h3><div className="space-y-4 flex-1"><div><label className="text-xs font-bold text-gray-500 mb-1 block uppercase">反馈对象</label><select className="w-full border border-gray-300 rounded-lg p-2 text-sm bg-white" value={selectedTeacherId} onChange={e => setSelectedTeacherId(e.target.value)}>{teachers.map(t => <option key={t._id} value={t._id}>{t.teachingSubject ? `${t.teachingSubject}-${t.trueName||t.username}` : t.trueName||t.username}</option>)}</select></div><div className="flex-1 flex flex-col"><label className="text-xs font-bold text-gray-500 mb-1 block uppercase">内容</label><textarea className="w-full border border-gray-300 rounded-lg p-3 text-sm flex-1 resize-none focus:ring-2 focus:ring-blue-500 outline-none" placeholder="老师,我对课程有建议..." value={feedbackContent} onChange={e => setFeedbackContent(e.target.value)}/></div><button onClick={() => submitFeedback('ACADEMIC')} className="w-full bg-blue-600 text-white py-3 rounded-lg font-bold hover:bg-blue-700 shadow-md">提交反馈</button></div></div>)}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
174
  </div>
175
  )}
 
 
176
  {activeTab === 'system' && (
177
  <div className="h-full flex flex-col md:flex-row">
178
+ <div className="flex-1 overflow-y-auto p-6 bg-gray-50"><div className="max-w-3xl mx-auto space-y-4">{feedbackList.length === 0 ? <div className="text-center text-gray-400 py-10">暂无系统反馈</div> : feedbackList.map(fb => (<FeedbackItem key={fb._id} fb={fb} onStatusChange={isAdmin ? handleFeedbackStatus : undefined} />))}</div></div>
179
+ {!isAdmin && (<div className="w-full md:w-80 bg-white border-l border-gray-200 p-6 flex flex-col shadow-xl z-20"><h3 className="font-bold text-gray-800 mb-4 flex items-center"><Mail size={18} className="mr-2 text-purple-500"/> 联系管理员</h3><p className="text-xs text-gray-500 mb-4">如果您在使用系统中遇到问题或有优化建议,请在此反馈。</p><textarea className="w-full border border-gray-300 rounded-lg p-3 text-sm h-40 resize-none focus:ring-2 focus:ring-purple-500 outline-none mb-4" placeholder="描述您的问题或建议..." value={wishContent} onChange={e => setWishContent(e.target.value)}/><button onClick={() => submitFeedback('SYSTEM')} className="w-full bg-purple-600 text-white py-3 rounded-lg font-bold hover:bg-purple-700 shadow-md">发送反馈</button></div>)}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
180
  </div>
181
  )}
182
  </div>
183
  </div>
184
  );
185
+ };
server.js CHANGED
@@ -3,7 +3,7 @@ const {
3
  School, User, Student, Course, Score, ClassModel, SubjectModel, ExamModel, ScheduleModel,
4
  ConfigModel, NotificationModel, GameSessionModel, StudentRewardModel, LuckyDrawConfigModel, GameMonsterConfigModel, GameZenConfigModel,
5
  AchievementConfigModel, TeacherExchangeConfigModel, StudentAchievementModel, AttendanceModel, LeaveRequestModel, SchoolCalendarModel,
6
- WishModel, FeedbackModel
7
  } = require('./models');
8
 
9
  // Import AI Routes
@@ -16,25 +16,22 @@ const bodyParser = require('body-parser');
16
  const path = require('path');
17
  const compression = require('compression');
18
 
19
- // ... constants
20
  const PORT = 7860;
21
  const MONGO_URI = 'mongodb+srv://dv890a:db8822723@chatpro.gw3v0v7.mongodb.net/chatpro?retryWrites=true&w=majority&appName=chatpro&authSource=admin';
22
 
23
  const app = express();
24
 
25
- // FIX: Disable compression for AI Chat SSE endpoint to allow real-time streaming
26
- // Using req.originalUrl to match the full path regardless of mounting point
27
  app.use(compression({
28
  filter: (req, res) => {
29
  if (req.originalUrl && req.originalUrl.includes('/api/ai/chat')) {
30
- return false; // Don't compress SSE streams
31
  }
32
  return compression.filter(req, res);
33
  }
34
  }));
35
 
36
  app.use(cors());
37
- app.use(bodyParser.json({ limit: '50mb' })); // Increased limit for audio
38
  app.use(express.static(path.join(__dirname, 'dist'), {
39
  setHeaders: (res, filePath) => {
40
  if (filePath.endsWith('.html')) {
@@ -45,7 +42,6 @@ app.use(express.static(path.join(__dirname, 'dist'), {
45
  }
46
  }));
47
 
48
- // ... (DB Connection and helpers remain the same) ...
49
  const InMemoryDB = { schools: [], users: [], isFallback: false };
50
  const connectDB = async () => {
51
  try {
@@ -76,7 +72,6 @@ const getQueryFilter = (req) => {
76
  };
77
  const injectSchoolId = (req, b) => ({ ...b, schoolId: req.headers['x-school-id'] });
78
 
79
- // ... (existing helper functions) ...
80
  const getGameOwnerFilter = async (req) => {
81
  const role = req.headers['x-user-role'];
82
  const username = req.headers['x-user-username'];
@@ -110,8 +105,63 @@ const generateStudentNo = async () => {
110
  app.use('/api/ai', aiRoutes);
111
 
112
  // ... (Rest of Existing Routes) ...
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
113
  app.get('/api/classes/:className/teachers', async (req, res) => {
114
- // ... existing code ...
115
  const { className } = req.params;
116
  const schoolId = req.headers['x-school-id'];
117
  const normalize = (s) => (s || '').replace(/\s+/g, '');
@@ -148,78 +198,44 @@ app.get('/api/classes/:className/teachers', async (req, res) => {
148
  } catch (e) { res.json([]); }
149
  });
150
 
151
- // --- WISH TREE ENDPOINTS ---
152
  app.get('/api/wishes', async (req, res) => {
153
  const { teacherId, studentId, status } = req.query;
154
  const filter = getQueryFilter(req);
155
  if (teacherId) filter.teacherId = teacherId;
156
  if (studentId) filter.studentId = studentId;
157
  if (status) filter.status = status;
158
-
159
- // Sort pending first, then by createTime
160
  res.json(await WishModel.find(filter).sort({ status: -1, createTime: -1 }));
161
  });
162
 
163
  app.post('/api/wishes', async (req, res) => {
164
  const data = injectSchoolId(req, req.body);
165
- // Validation: Check if student already has a pending wish
166
- const existing = await WishModel.findOne({
167
- studentId: data.studentId,
168
- status: 'PENDING'
169
- });
170
-
171
- if (existing) {
172
- return res.status(400).json({ error: 'LIMIT_REACHED', message: '您还有一个未实现的愿望,请等待实现后再许愿!' });
173
- }
174
-
175
  await WishModel.create(data);
176
  res.json({ success: true });
177
  });
178
 
179
  app.post('/api/wishes/:id/fulfill', async (req, res) => {
180
- const { id } = req.params;
181
- await WishModel.findByIdAndUpdate(id, {
182
- status: 'FULFILLED',
183
- fulfillTime: new Date()
184
- });
185
  res.json({ success: true });
186
  });
187
 
188
  app.post('/api/wishes/random-fulfill', async (req, res) => {
189
  const { teacherId } = req.body;
190
  const pendingWishes = await WishModel.find({ teacherId, status: 'PENDING' });
191
-
192
- if (pendingWishes.length === 0) {
193
- return res.status(404).json({ error: 'NO_WISHES', message: '暂无待实现的愿望' });
194
- }
195
-
196
  const randomWish = pendingWishes[Math.floor(Math.random() * pendingWishes.length)];
197
- await WishModel.findByIdAndUpdate(randomWish._id, {
198
- status: 'FULFILLED',
199
- fulfillTime: new Date()
200
- });
201
-
202
  res.json({ success: true, wish: randomWish });
203
  });
204
 
205
- // --- FEEDBACK WALL ENDPOINTS ---
206
  app.get('/api/feedback', async (req, res) => {
207
  const { targetId, creatorId, type, status } = req.query;
208
  const filter = getQueryFilter(req);
209
  if (targetId) filter.targetId = targetId;
210
  if (creatorId) filter.creatorId = creatorId;
211
  if (type) filter.type = type;
212
-
213
- // Filter logic for status (comma separated allowed)
214
- if (status) {
215
- const statuses = status.split(',');
216
- if (statuses.length > 1) {
217
- filter.status = { $in: statuses };
218
- } else {
219
- filter.status = status;
220
- }
221
- }
222
-
223
  res.json(await FeedbackModel.find(filter).sort({ createTime: -1 }));
224
  });
225
 
@@ -230,27 +246,20 @@ app.post('/api/feedback', async (req, res) => {
230
  });
231
 
232
  app.put('/api/feedback/:id', async (req, res) => {
233
- const { id } = req.params;
234
- const { status, reply } = req.body;
235
  const updateData = { updateTime: new Date() };
236
  if (status) updateData.status = status;
237
  if (reply !== undefined) updateData.reply = reply;
238
-
239
  await FeedbackModel.findByIdAndUpdate(id, updateData);
240
  res.json({ success: true });
241
  });
242
 
243
  app.post('/api/feedback/ignore-all', async (req, res) => {
244
  const { targetId } = req.body;
245
- await FeedbackModel.updateMany(
246
- { targetId, status: 'PENDING' },
247
- { status: 'IGNORED', updateTime: new Date() }
248
- );
249
  res.json({ success: true });
250
  });
251
 
252
- // ... (Rest of existing routes from server.js) ...
253
- // ...
254
  app.get('/api/games/lucky-config', async (req, res) => {
255
  const filter = getQueryFilter(req);
256
  if (req.query.ownerId) { filter.ownerId = req.query.ownerId; } else { const ownerFilter = await getGameOwnerFilter(req); Object.assign(filter, ownerFilter); }
@@ -428,7 +437,7 @@ app.post('/api/auth/login', async (req, res) => { const { username, password } =
428
  app.get('/api/schools', async (req, res) => { res.json(await School.find()); });
429
  app.post('/api/schools', async (req, res) => { res.json(await School.create(req.body)); });
430
  app.put('/api/schools/:id', async (req, res) => { await School.findByIdAndUpdate(req.params.id, req.body); res.json({}); });
431
- app.delete('/api/schools/:id', async (req, res) => { const schoolId = req.params.id; 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 }); await WishModel.deleteMany({ schoolId }); await FeedbackModel.deleteMany({ schoolId }); res.json({ success: true }); } catch (e) { res.status(500).json({ error: e.message }); } });
432
  app.delete('/api/users/:id', async (req, res) => { const requesterRole = req.headers['x-user-role']; if (requesterRole === 'PRINCIPAL') { const user = await User.findById(req.params.id); if (!user || user.schoolId !== req.headers['x-school-id']) return res.status(403).json({error: 'Permission denied'}); if (user.role === 'ADMIN') return res.status(403).json({error: 'Cannot delete admin'}); } await User.findByIdAndDelete(req.params.id); res.json({}); });
433
  app.get('/api/students', async (req, res) => { res.json(await Student.find(getQueryFilter(req))); });
434
  app.post('/api/students', async (req, res) => { const data = injectSchoolId(req, req.body); if (data.studentNo === '') delete data.studentNo; try { const existing = await Student.findOne({ schoolId: data.schoolId, name: data.name, className: data.className }); 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); } res.json({ success: true }); } catch (e) { if (e.code === 11000) return res.status(409).json({ error: 'DUPLICATE', message: '保存失败:存在重复的系统ID' }); res.status(500).json({ error: e.message }); } });
@@ -448,9 +457,6 @@ app.put('/api/scores/:id', async (req, res) => { await Score.findByIdAndUpdate(r
448
  app.delete('/api/scores/:id', async (req, res) => { await Score.findByIdAndDelete(req.params.id); res.json({}); });
449
  app.get('/api/exams', async (req, res) => { res.json(await ExamModel.find(getQueryFilter(req))); });
450
  app.post('/api/exams', async (req, res) => { await ExamModel.findOneAndUpdate({name:req.body.name}, injectSchoolId(req, req.body), {upsert:true}); res.json({}); });
451
- app.get('/api/schedules', async (req, res) => { const query = { ...getQueryFilter(req), ...req.query }; if (query.grade) { query.className = { $regex: '^' + query.grade }; delete query.grade; } res.json(await ScheduleModel.find(query)); });
452
- app.post('/api/schedules', async (req, res) => { const filter = { className:req.body.className, dayOfWeek:req.body.dayOfWeek, period:req.body.period }; const sId = req.headers['x-school-id']; if(sId) filter.schoolId = sId; await ScheduleModel.findOneAndUpdate(filter, injectSchoolId(req, req.body), {upsert:true}); res.json({}); });
453
- app.delete('/api/schedules', async (req, res) => { await ScheduleModel.deleteOne({...getQueryFilter(req), ...req.query}); res.json({}); });
454
  app.get('/api/stats', async (req, res) => { const filter = getQueryFilter(req); const studentCount = await Student.countDocuments(filter); const courseCount = await Course.countDocuments(filter); const scores = await Score.find({...filter, status: 'Normal'}); let avgScore = 0; let excellentRate = '0%'; if (scores.length > 0) { const total = scores.reduce((sum, s) => sum + s.score, 0); avgScore = parseFloat((total / scores.length).toFixed(1)); const excellent = scores.filter(s => s.score >= 90).length; excellentRate = Math.round((excellent / scores.length) * 100) + '%'; } res.json({ studentCount, courseCount, avgScore, excellentRate }); });
455
  app.get('/api/config', async (req, res) => { const currentSem = getAutoSemester(); let config = await ConfigModel.findOne({key:'main'}); 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] }; } res.json(config); });
456
  app.post('/api/config', async (req, res) => { await ConfigModel.findOneAndUpdate({key:'main'}, req.body, {upsert:true}); res.json({}); });
 
3
  School, User, Student, Course, Score, ClassModel, SubjectModel, ExamModel, ScheduleModel,
4
  ConfigModel, NotificationModel, GameSessionModel, StudentRewardModel, LuckyDrawConfigModel, GameMonsterConfigModel, GameZenConfigModel,
5
  AchievementConfigModel, TeacherExchangeConfigModel, StudentAchievementModel, AttendanceModel, LeaveRequestModel, SchoolCalendarModel,
6
+ WishModel, FeedbackModel, TodoModel
7
  } = require('./models');
8
 
9
  // Import AI Routes
 
16
  const path = require('path');
17
  const compression = require('compression');
18
 
 
19
  const PORT = 7860;
20
  const MONGO_URI = 'mongodb+srv://dv890a:db8822723@chatpro.gw3v0v7.mongodb.net/chatpro?retryWrites=true&w=majority&appName=chatpro&authSource=admin';
21
 
22
  const app = express();
23
 
 
 
24
  app.use(compression({
25
  filter: (req, res) => {
26
  if (req.originalUrl && req.originalUrl.includes('/api/ai/chat')) {
27
+ return false;
28
  }
29
  return compression.filter(req, res);
30
  }
31
  }));
32
 
33
  app.use(cors());
34
+ app.use(bodyParser.json({ limit: '50mb' }));
35
  app.use(express.static(path.join(__dirname, 'dist'), {
36
  setHeaders: (res, filePath) => {
37
  if (filePath.endsWith('.html')) {
 
42
  }
43
  }));
44
 
 
45
  const InMemoryDB = { schools: [], users: [], isFallback: false };
46
  const connectDB = async () => {
47
  try {
 
72
  };
73
  const injectSchoolId = (req, b) => ({ ...b, schoolId: req.headers['x-school-id'] });
74
 
 
75
  const getGameOwnerFilter = async (req) => {
76
  const role = req.headers['x-user-role'];
77
  const username = req.headers['x-user-username'];
 
105
  app.use('/api/ai', aiRoutes);
106
 
107
  // ... (Rest of Existing Routes) ...
108
+
109
+ // --- TODO LIST ENDPOINTS ---
110
+ app.get('/api/todos', async (req, res) => {
111
+ const username = req.headers['x-user-username'];
112
+ if (!username) return res.status(401).json({ error: 'Unauthorized' });
113
+ const user = await User.findOne({ username });
114
+ if (!user) return res.status(404).json({ error: 'User not found' });
115
+ res.json(await TodoModel.find({ userId: user._id.toString() }).sort({ isCompleted: 1, createTime: -1 }));
116
+ });
117
+
118
+ app.post('/api/todos', async (req, res) => {
119
+ const username = req.headers['x-user-username'];
120
+ const user = await User.findOne({ username });
121
+ if (!user) return res.status(404).json({ error: 'User not found' });
122
+ await TodoModel.create({ ...req.body, userId: user._id.toString() });
123
+ res.json({ success: true });
124
+ });
125
+
126
+ app.put('/api/todos/:id', async (req, res) => {
127
+ await TodoModel.findByIdAndUpdate(req.params.id, req.body);
128
+ res.json({ success: true });
129
+ });
130
+
131
+ app.delete('/api/todos/:id', async (req, res) => {
132
+ await TodoModel.findByIdAndDelete(req.params.id);
133
+ res.json({ success: true });
134
+ });
135
+
136
+ // --- UPDATED SCHEDULE ENDPOINTS ---
137
+ app.get('/api/schedules', async (req, res) => {
138
+ const query = { ...getQueryFilter(req), ...req.query };
139
+ if (query.grade) { query.className = { $regex: '^' + query.grade }; delete query.grade; }
140
+ res.json(await ScheduleModel.find(query));
141
+ });
142
+
143
+ app.post('/api/schedules', async (req, res) => {
144
+ const filter = { className:req.body.className, dayOfWeek:req.body.dayOfWeek, period:req.body.period, weekType: req.body.weekType || 'ALL' };
145
+ const sId = req.headers['x-school-id'];
146
+ if(sId) filter.schoolId = sId;
147
+ await ScheduleModel.findOneAndUpdate(filter, injectSchoolId(req, req.body), {upsert:true});
148
+ res.json({});
149
+ });
150
+
151
+ app.delete('/api/schedules', async (req, res) => {
152
+ await ScheduleModel.deleteOne({...getQueryFilter(req), ...req.query});
153
+ res.json({});
154
+ });
155
+
156
+ // --- USER MENU ORDER ---
157
+ app.put('/api/users/:id/menu-order', async (req, res) => {
158
+ const { menuOrder } = req.body;
159
+ await User.findByIdAndUpdate(req.params.id, { menuOrder });
160
+ res.json({ success: true });
161
+ });
162
+
163
+ // --- Existing Routes (Minimally Modified) ---
164
  app.get('/api/classes/:className/teachers', async (req, res) => {
 
165
  const { className } = req.params;
166
  const schoolId = req.headers['x-school-id'];
167
  const normalize = (s) => (s || '').replace(/\s+/g, '');
 
198
  } catch (e) { res.json([]); }
199
  });
200
 
 
201
  app.get('/api/wishes', async (req, res) => {
202
  const { teacherId, studentId, status } = req.query;
203
  const filter = getQueryFilter(req);
204
  if (teacherId) filter.teacherId = teacherId;
205
  if (studentId) filter.studentId = studentId;
206
  if (status) filter.status = status;
 
 
207
  res.json(await WishModel.find(filter).sort({ status: -1, createTime: -1 }));
208
  });
209
 
210
  app.post('/api/wishes', async (req, res) => {
211
  const data = injectSchoolId(req, req.body);
212
+ const existing = await WishModel.findOne({ studentId: data.studentId, status: 'PENDING' });
213
+ if (existing) { return res.status(400).json({ error: 'LIMIT_REACHED', message: '您还有一个未实现的愿望,请等待实现后再许愿!' }); }
 
 
 
 
 
 
 
 
214
  await WishModel.create(data);
215
  res.json({ success: true });
216
  });
217
 
218
  app.post('/api/wishes/:id/fulfill', async (req, res) => {
219
+ await WishModel.findByIdAndUpdate(req.params.id, { status: 'FULFILLED', fulfillTime: new Date() });
 
 
 
 
220
  res.json({ success: true });
221
  });
222
 
223
  app.post('/api/wishes/random-fulfill', async (req, res) => {
224
  const { teacherId } = req.body;
225
  const pendingWishes = await WishModel.find({ teacherId, status: 'PENDING' });
226
+ if (pendingWishes.length === 0) { return res.status(404).json({ error: 'NO_WISHES', message: '暂无待实现的愿望' }); }
 
 
 
 
227
  const randomWish = pendingWishes[Math.floor(Math.random() * pendingWishes.length)];
228
+ await WishModel.findByIdAndUpdate(randomWish._id, { status: 'FULFILLED', fulfillTime: new Date() });
 
 
 
 
229
  res.json({ success: true, wish: randomWish });
230
  });
231
 
 
232
  app.get('/api/feedback', async (req, res) => {
233
  const { targetId, creatorId, type, status } = req.query;
234
  const filter = getQueryFilter(req);
235
  if (targetId) filter.targetId = targetId;
236
  if (creatorId) filter.creatorId = creatorId;
237
  if (type) filter.type = type;
238
+ if (status) { const statuses = status.split(','); if (statuses.length > 1) { filter.status = { $in: statuses }; } else { filter.status = status; } }
 
 
 
 
 
 
 
 
 
 
239
  res.json(await FeedbackModel.find(filter).sort({ createTime: -1 }));
240
  });
241
 
 
246
  });
247
 
248
  app.put('/api/feedback/:id', async (req, res) => {
249
+ const { id } = req.params; const { status, reply } = req.body;
 
250
  const updateData = { updateTime: new Date() };
251
  if (status) updateData.status = status;
252
  if (reply !== undefined) updateData.reply = reply;
 
253
  await FeedbackModel.findByIdAndUpdate(id, updateData);
254
  res.json({ success: true });
255
  });
256
 
257
  app.post('/api/feedback/ignore-all', async (req, res) => {
258
  const { targetId } = req.body;
259
+ await FeedbackModel.updateMany( { targetId, status: 'PENDING' }, { status: 'IGNORED', updateTime: new Date() } );
 
 
 
260
  res.json({ success: true });
261
  });
262
 
 
 
263
  app.get('/api/games/lucky-config', async (req, res) => {
264
  const filter = getQueryFilter(req);
265
  if (req.query.ownerId) { filter.ownerId = req.query.ownerId; } else { const ownerFilter = await getGameOwnerFilter(req); Object.assign(filter, ownerFilter); }
 
437
  app.get('/api/schools', async (req, res) => { res.json(await School.find()); });
438
  app.post('/api/schools', async (req, res) => { res.json(await School.create(req.body)); });
439
  app.put('/api/schools/:id', async (req, res) => { await School.findByIdAndUpdate(req.params.id, req.body); res.json({}); });
440
+ app.delete('/api/schools/:id', async (req, res) => { const schoolId = req.params.id; 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 }); await WishModel.deleteMany({ schoolId }); await FeedbackModel.deleteMany({ schoolId }); await TodoModel.deleteMany({}); res.json({ success: true }); } catch (e) { res.status(500).json({ error: e.message }); } });
441
  app.delete('/api/users/:id', async (req, res) => { const requesterRole = req.headers['x-user-role']; if (requesterRole === 'PRINCIPAL') { const user = await User.findById(req.params.id); if (!user || user.schoolId !== req.headers['x-school-id']) return res.status(403).json({error: 'Permission denied'}); if (user.role === 'ADMIN') return res.status(403).json({error: 'Cannot delete admin'}); } await User.findByIdAndDelete(req.params.id); res.json({}); });
442
  app.get('/api/students', async (req, res) => { res.json(await Student.find(getQueryFilter(req))); });
443
  app.post('/api/students', async (req, res) => { const data = injectSchoolId(req, req.body); if (data.studentNo === '') delete data.studentNo; try { const existing = await Student.findOne({ schoolId: data.schoolId, name: data.name, className: data.className }); 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); } res.json({ success: true }); } catch (e) { if (e.code === 11000) return res.status(409).json({ error: 'DUPLICATE', message: '保存失败:存在重复的系统ID' }); res.status(500).json({ error: e.message }); } });
 
457
  app.delete('/api/scores/:id', async (req, res) => { await Score.findByIdAndDelete(req.params.id); res.json({}); });
458
  app.get('/api/exams', async (req, res) => { res.json(await ExamModel.find(getQueryFilter(req))); });
459
  app.post('/api/exams', async (req, res) => { await ExamModel.findOneAndUpdate({name:req.body.name}, injectSchoolId(req, req.body), {upsert:true}); res.json({}); });
 
 
 
460
  app.get('/api/stats', async (req, res) => { const filter = getQueryFilter(req); const studentCount = await Student.countDocuments(filter); const courseCount = await Course.countDocuments(filter); const scores = await Score.find({...filter, status: 'Normal'}); let avgScore = 0; let excellentRate = '0%'; if (scores.length > 0) { const total = scores.reduce((sum, s) => sum + s.score, 0); avgScore = parseFloat((total / scores.length).toFixed(1)); const excellent = scores.filter(s => s.score >= 90).length; excellentRate = Math.round((excellent / scores.length) * 100) + '%'; } res.json({ studentCount, courseCount, avgScore, excellentRate }); });
461
  app.get('/api/config', async (req, res) => { const currentSem = getAutoSemester(); let config = await ConfigModel.findOne({key:'main'}); 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] }; } res.json(config); });
462
  app.post('/api/config', async (req, res) => { await ConfigModel.findOneAndUpdate({key:'main'}, req.body, {upsert:true}); res.json({}); });
services/api.ts CHANGED
@@ -1,6 +1,8 @@
 
1
  // ... existing imports
2
- import { User, ClassInfo, SystemConfig, Subject, School, Schedule, GameSession, StudentReward, LuckyDrawConfig, Attendance, LeaveRequest, AchievementConfig, SchoolCalendarEntry, TeacherExchangeConfig, Wish, Feedback, AIChatMessage } from '../types';
3
 
 
4
  const getBaseUrl = () => {
5
  let isProd = false;
6
  try {
@@ -31,7 +33,6 @@ async function request(endpoint: string, options: RequestInit = {}) {
31
  headers['x-school-id'] = currentUser.schoolId;
32
  }
33
 
34
- // Inject User Role for backend logic (e.g., bypassing draw limits)
35
  if (currentUser?.role) {
36
  headers['x-user-role'] = currentUser.role;
37
  headers['x-user-username'] = currentUser.username;
@@ -104,7 +105,7 @@ export const api = {
104
  getAll: () => request('/schools'),
105
  add: (data: School) => request('/schools', { method: 'POST', body: JSON.stringify(data) }),
106
  update: (id: string, data: Partial<School>) => request(`/schools/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
107
- delete: (id: string) => request(`/schools/${id}`, { method: 'DELETE' }) // NEW
108
  },
109
 
110
  users: {
@@ -119,6 +120,7 @@ export const api = {
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/${encodeURIComponent(className)}/teachers`),
 
122
  },
123
 
124
  students: {
@@ -126,7 +128,6 @@ export const api = {
126
  add: (data: any) => request('/students', { method: 'POST', body: JSON.stringify(data) }),
127
  update: (id: string, data: any) => request(`/students/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
128
  delete: (id: string | number) => request(`/students/${id}`, { method: 'DELETE' }),
129
- // NEW: Promote and Transfer
130
  promote: (data: { teacherFollows: boolean }) => request('/students/promote', { method: 'POST', body: JSON.stringify(data) }),
131
  transfer: (data: { studentId: string, targetClass: string }) => request('/students/transfer', { method: 'POST', body: JSON.stringify(data) })
132
  },
@@ -225,7 +226,6 @@ export const api = {
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(',')}`),
@@ -248,7 +248,6 @@ export const api = {
248
  return request('/batch-delete', { method: 'POST', body: JSON.stringify({ type, ids }) });
249
  },
250
 
251
- // NEW: Wish and Feedback API
252
  wishes: {
253
  getAll: (params: { teacherId?: string, studentId?: string, status?: string }) => {
254
  const qs = new URLSearchParams(params as any).toString();
@@ -269,9 +268,15 @@ export const api = {
269
  ignoreAll: (targetId: string) => request('/feedback/ignore-all', { method: 'POST', body: JSON.stringify({ targetId }) }),
270
  },
271
 
272
- // NEW: AI Endpoints
273
  ai: {
274
  chat: (data: { text?: string, audio?: string, history?: { role: string, text?: string }[] }) => request('/ai/chat', { method: 'POST', body: JSON.stringify(data) }),
275
  evaluate: (data: { question: string, audio?: string, image?: string }) => request('/ai/evaluate', { method: 'POST', body: JSON.stringify(data) }),
 
 
 
 
 
 
 
276
  }
277
- };
 
1
+
2
  // ... existing imports
3
+ import { User, ClassInfo, SystemConfig, Subject, School, Schedule, GameSession, StudentReward, LuckyDrawConfig, Attendance, LeaveRequest, AchievementConfig, SchoolCalendarEntry, TeacherExchangeConfig, Wish, Feedback, AIChatMessage, Todo } from '../types';
4
 
5
+ // ... existing getBaseUrl ...
6
  const getBaseUrl = () => {
7
  let isProd = false;
8
  try {
 
33
  headers['x-school-id'] = currentUser.schoolId;
34
  }
35
 
 
36
  if (currentUser?.role) {
37
  headers['x-user-role'] = currentUser.role;
38
  headers['x-user-username'] = currentUser.username;
 
105
  getAll: () => request('/schools'),
106
  add: (data: School) => request('/schools', { method: 'POST', body: JSON.stringify(data) }),
107
  update: (id: string, data: Partial<School>) => request(`/schools/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
108
+ delete: (id: string) => request(`/schools/${id}`, { method: 'DELETE' })
109
  },
110
 
111
  users: {
 
120
  applyClass: (data: { userId: string, type: 'CLAIM'|'RESIGN', targetClass?: string, action: 'APPLY'|'APPROVE'|'REJECT' }) =>
121
  request('/users/class-application', { method: 'POST', body: JSON.stringify(data) }),
122
  getTeachersForClass: (className: string) => request(`/classes/${encodeURIComponent(className)}/teachers`),
123
+ saveMenuOrder: (userId: string, order: string[]) => request(`/users/${userId}/menu-order`, { method: 'PUT', body: JSON.stringify({ menuOrder: order }) }), // NEW
124
  },
125
 
126
  students: {
 
128
  add: (data: any) => request('/students', { method: 'POST', body: JSON.stringify(data) }),
129
  update: (id: string, data: any) => request(`/students/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
130
  delete: (id: string | number) => request(`/students/${id}`, { method: 'DELETE' }),
 
131
  promote: (data: { teacherFollows: boolean }) => request('/students/promote', { method: 'POST', body: JSON.stringify(data) }),
132
  transfer: (data: { studentId: string, targetClass: string }) => request('/students/transfer', { method: 'POST', body: JSON.stringify(data) })
133
  },
 
226
  getStudentAchievements: (studentId: string, semester?: string) => request(`/achievements/student?studentId=${studentId}${semester ? `&semester=${semester}` : ''}`),
227
  grant: (data: { studentId: string, achievementId: string, semester: string }) => request('/achievements/grant', { method: 'POST', body: JSON.stringify(data) }),
228
  exchange: (data: { studentId: string, ruleId: string, teacherId?: string }) => request('/achievements/exchange', { method: 'POST', body: JSON.stringify(data) }),
 
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(',')}`),
 
248
  return request('/batch-delete', { method: 'POST', body: JSON.stringify({ type, ids }) });
249
  },
250
 
 
251
  wishes: {
252
  getAll: (params: { teacherId?: string, studentId?: string, status?: string }) => {
253
  const qs = new URLSearchParams(params as any).toString();
 
268
  ignoreAll: (targetId: string) => request('/feedback/ignore-all', { method: 'POST', body: JSON.stringify({ targetId }) }),
269
  },
270
 
 
271
  ai: {
272
  chat: (data: { text?: string, audio?: string, history?: { role: string, text?: string }[] }) => request('/ai/chat', { method: 'POST', body: JSON.stringify(data) }),
273
  evaluate: (data: { question: string, audio?: string, image?: string }) => request('/ai/evaluate', { method: 'POST', body: JSON.stringify(data) }),
274
+ },
275
+
276
+ todos: { // NEW
277
+ getAll: () => request('/todos'),
278
+ add: (content: string) => request('/todos', { method: 'POST', body: JSON.stringify({ content }) }),
279
+ update: (id: string, data: Partial<Todo>) => request(`/todos/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
280
+ delete: (id: string) => request(`/todos/${id}`, { method: 'DELETE' }),
281
  }
282
+ };
types.ts CHANGED
@@ -1,70 +1,7 @@
1
 
2
- // ... existing imports
3
-
4
- // ... existing types
5
-
6
- // AI Assistant Types
7
-
8
- export interface AIChatMessage {
9
- id: string;
10
- role: 'user' | 'model';
11
- text?: string;
12
- audio?: string; // Base64 encoded audio for playback
13
- isAudioMessage?: boolean; // Was the input/output audio based?
14
- timestamp: number;
15
- }
16
-
17
- export interface OralAssessment {
18
- id: string;
19
- topic: string;
20
- difficulty: string;
21
- studentAudio?: string;
22
- score?: number;
23
- feedback?: string;
24
- transcription?: string; // What the AI heard
25
- status: 'IDLE' | 'RECORDING' | 'ANALYZING' | 'COMPLETED';
26
- }
27
-
28
- // ... rest of the file
29
- export type WishStatus = 'PENDING' | 'FULFILLED';
30
-
31
- export interface Wish {
32
- _id?: string;
33
- schoolId: string;
34
- studentId: string;
35
- studentName: string;
36
- className: string;
37
- teacherId: string; // Target Teacher
38
- teacherName: string;
39
- content: string;
40
- status: WishStatus;
41
- createTime: string;
42
- fulfillTime?: string;
43
- }
44
-
45
- export type FeedbackStatus = 'PENDING' | 'ACCEPTED' | 'PROCESSED' | 'IGNORED';
46
- export type FeedbackType = 'ACADEMIC' | 'SYSTEM'; // ACADEMIC: Student->Teacher, SYSTEM: Staff->Admin
47
-
48
- export interface Feedback {
49
- _id?: string;
50
- schoolId: string;
51
- creatorId: string;
52
- creatorName: string;
53
- creatorRole: string;
54
- targetId: string; // Teacher ID or 'ADMIN'
55
- targetName: string;
56
- content: string;
57
- type: FeedbackType;
58
- status: FeedbackStatus;
59
- reply?: string; // Teacher/Admin reply
60
- createTime: string;
61
- updateTime?: string;
62
- }
63
-
64
- // ... rest of the file (UserRole, etc.)
65
  export enum UserRole {
66
  ADMIN = 'ADMIN',
67
- PRINCIPAL = 'PRINCIPAL', // 新增校长角色
68
  TEACHER = 'TEACHER',
69
  STUDENT = 'STUDENT',
70
  USER = 'USER'
@@ -76,119 +13,82 @@ export enum UserStatus {
76
  BANNED = 'banned'
77
  }
78
 
79
- export interface School {
80
- id?: number;
81
- _id?: string;
82
- name: string;
83
- code: string;
84
- }
85
-
86
  export interface User {
87
  id?: number;
88
  _id?: string;
89
  username: string;
 
90
  trueName?: string;
91
  phone?: string;
92
  email?: string;
93
  schoolId?: string;
94
- role: UserRole;
95
- status: UserStatus;
96
  avatar?: string;
97
  createTime?: string;
98
  teachingSubject?: string;
99
  homeroomClass?: string;
100
- // NEW: Feature Flag
101
  aiAccess?: boolean;
102
- // Class Application
103
  classApplication?: {
104
  type: 'CLAIM' | 'RESIGN';
105
  targetClass?: string;
106
  status: 'PENDING' | 'REJECTED';
107
  };
108
- // Student Registration Temp Fields
109
  studentNo?: string;
110
  parentName?: string;
111
  parentPhone?: string;
112
  address?: string;
113
  gender?: 'Male' | 'Female';
114
  seatNo?: string;
115
- idCard?: string; // NEW
116
- }
117
-
118
- export interface ClassInfo {
119
- // ... (rest remains same)
120
- id?: number;
121
- _id?: string;
122
- schoolId?: string;
123
- grade: string;
124
- className: string;
125
- teacherName?: string; // Display string (e.g., "张三, 李四")
126
- homeroomTeacherIds?: string[]; // Actual IDs for logic
127
- studentCount?: number;
128
- }
129
-
130
- export interface Subject {
131
- id?: number;
132
- _id?: string;
133
- schoolId?: string;
134
- name: string;
135
- code: string;
136
- color: string;
137
- excellenceThreshold?: number;
138
- thresholds?: Record<string, number>; // Grade specific overrides e.g. {'一年级': 95}
139
- }
140
-
141
- export interface SystemConfig {
142
- systemName: string;
143
- semester: string;
144
- semesters?: string[];
145
- allowRegister: boolean;
146
- allowAdminRegister: boolean;
147
- allowPrincipalRegister?: boolean; // 新增:是否允许校长注册
148
- allowStudentRegister?: boolean;
149
- maintenanceMode: boolean;
150
- emailNotify: boolean;
151
- // NEW AI CONFIG
152
- enableAI?: boolean;
153
- aiTotalCalls?: number;
154
  }
155
 
156
  export interface Student {
157
  id?: number;
158
  _id?: string;
159
  schoolId?: string;
160
- studentNo: string; // System ID (Login ID)
161
- seatNo?: string; // Class Seat Number (Optional, for sorting/display)
162
  name: string;
163
- gender: 'Male' | 'Female' | 'Other';
164
- birthday: string;
165
- idCard: string;
166
- phone: string;
167
  className: string;
168
  status: 'Enrolled' | 'Graduated' | 'Suspended';
169
  parentName?: string;
170
  parentPhone?: string;
171
  address?: string;
172
- // Game related
173
- teamId?: string;
174
- drawAttempts?: number;
175
- dailyDrawLog?: { date: string; count: number };
176
- // Achievement related
177
- flowerBalance?: number;
 
 
 
 
 
 
 
 
 
178
  }
179
 
180
  export interface Course {
181
  id?: number;
182
  _id?: string;
183
  schoolId?: string;
184
- courseCode: string;
185
- courseName: string; // Subject Name
186
- className: string; // Target Class (e.g. "一年级(1)班")
187
  teacherName: string;
188
- teacherId?: string; // Optional linkage
189
- credits: number;
190
- capacity: number;
191
- enrolled: number;
 
192
  }
193
 
194
  export type ExamStatus = 'Normal' | 'Absent' | 'Leave' | 'Cheat';
@@ -201,20 +101,30 @@ export interface Score {
201
  studentNo: string;
202
  courseName: string;
203
  score: number;
204
- semester: string;
205
- type: 'Midterm' | 'Final' | 'Quiz';
206
  examName?: string;
207
  status?: ExamStatus;
208
  }
209
 
210
- export interface Exam {
211
  id?: number;
 
 
 
 
 
 
 
 
 
 
212
  _id?: string;
213
  schoolId?: string;
214
  name: string;
215
  date: string;
 
216
  semester?: string;
217
- type?: 'Midterm' | 'Final' | 'Quiz' | string;
218
  }
219
 
220
  export interface Schedule {
@@ -226,75 +136,106 @@ export interface Schedule {
226
  subject: string;
227
  dayOfWeek: number;
228
  period: number;
 
229
  }
230
 
231
- export interface Notification {
232
  id?: number;
 
 
 
 
 
 
233
  _id?: string;
234
  schoolId?: string;
235
- targetRole?: UserRole;
236
  targetUserId?: string;
237
  title: string;
238
  content: string;
239
  type: 'info' | 'success' | 'warning' | 'error';
240
- isRead?: boolean;
241
  createTime: string;
242
  }
243
 
244
- export interface ApiResponse<T> {
245
- code: number;
246
- message: string;
247
- data: T;
248
- timestamp: number;
249
  }
250
 
251
- // --- Game Types ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
252
 
 
 
 
 
 
 
 
 
 
253
  export interface GameTeam {
254
  id: string;
255
  name: string;
256
  score: number;
257
- avatar: string; // Emoji or URL
258
  color: string;
259
- members: string[]; // Student IDs
260
  }
261
 
262
  export interface GameRewardConfig {
263
- scoreThreshold: number;
264
- rewardType: 'ITEM' | 'DRAW_COUNT' | 'ACHIEVEMENT';
265
- rewardName: string;
266
- rewardValue: number;
267
- achievementId?: string;
268
  }
269
 
270
  export interface GameSession {
271
  _id?: string;
272
  schoolId: string;
273
- className: string;
274
- subject: string;
275
  isEnabled: boolean;
 
276
  teams: GameTeam[];
277
  rewardsConfig: GameRewardConfig[];
278
- maxSteps: number;
279
- }
280
-
281
- export enum RewardType {
282
- ITEM = 'ITEM',
283
- DRAW_COUNT = 'DRAW_COUNT',
284
- CONSOLATION = 'CONSOLATION',
285
- ACHIEVEMENT = 'ACHIEVEMENT'
286
  }
287
 
288
  export interface StudentReward {
289
  _id?: string;
290
- schoolId?: string;
291
- studentId: string;
292
  studentName: string;
293
- rewardType: RewardType | string;
294
  name: string;
295
- count?: number;
296
  status: 'PENDING' | 'REDEEMED';
297
- source: string;
298
  createTime: string;
299
  ownerId?: string;
300
  }
@@ -302,70 +243,49 @@ export interface StudentReward {
302
  export interface LuckyPrize {
303
  id: string;
304
  name: string;
305
- probability: number; // Treated as Weight
306
- count: number; // Inventory
307
  icon?: string;
308
  }
309
 
310
  export interface LuckyDrawConfig {
311
- _id?: string;
312
- schoolId: string;
313
- className?: string;
314
- ownerId?: string; // Isolated by teacher
315
- prizes: LuckyPrize[];
316
- dailyLimit: number;
317
- cardCount?: number;
318
- defaultPrize: string; // "再接再厉"
319
- consolationWeight?: number; // Weight for NOT winning a main prize
320
- }
321
-
322
- export interface GameMonsterConfig {
323
  _id?: string;
324
  schoolId: string;
325
  className: string;
326
- ownerId?: string; // Isolated by teacher
327
- duration: number;
328
- sensitivity: number;
329
- difficulty: number;
330
- useKeyboardMode: boolean;
331
- rewardConfig: {
332
- enabled: boolean;
333
- type: 'DRAW_COUNT' | 'ITEM' | 'ACHIEVEMENT';
334
- val: string;
335
- count: number;
336
- };
337
  }
338
 
339
- // --- Achievement System Types ---
340
  export interface AchievementItem {
341
  id: string;
342
  name: string;
343
- icon: string;
344
- points: number;
345
  description?: string;
346
- addedBy?: string; // Teacher ID
347
- addedByName?: string; // Teacher Name
348
  }
349
 
350
  export interface ExchangeRule {
351
  id: string;
352
- cost: number;
353
- rewardType: 'ITEM' | 'DRAW_COUNT';
354
  rewardName: string;
355
- rewardValue: number;
356
  }
357
 
358
- // Class-based Config (Shared Achievement Library)
359
  export interface AchievementConfig {
360
  _id?: string;
361
  schoolId: string;
362
  className: string;
363
  achievements: AchievementItem[];
364
- // exchangeRules is deprecated here, moving to TeacherExchangeConfig
365
- exchangeRules?: ExchangeRule[];
366
  }
367
 
368
- // New: Teacher-based Config (Global Rules for a teacher)
369
  export interface TeacherExchangeConfig {
370
  _id?: string;
371
  schoolId: string;
@@ -379,24 +299,21 @@ export interface StudentAchievement {
379
  schoolId: string;
380
  studentId: string;
381
  studentName: string;
382
- achievementId: string;
383
- achievementName: string;
384
  achievementIcon: string;
385
  semester: string;
386
  createTime: string;
387
  }
388
 
389
- // --- Attendance Types ---
390
- export type AttendanceStatus = 'Present' | 'Absent' | 'Leave';
391
-
392
  export interface Attendance {
393
  _id?: string;
394
  schoolId: string;
395
  studentId: string;
396
  studentName: string;
397
  className: string;
398
- date: string; // YYYY-MM-DD
399
- status: AttendanceStatus;
400
  checkInTime?: string;
401
  }
402
 
@@ -413,12 +330,41 @@ export interface LeaveRequest {
413
  createTime: string;
414
  }
415
 
416
- export interface SchoolCalendarEntry {
417
  _id?: string;
418
  schoolId: string;
419
- className?: string; // Optional, if set applies to specific class only
420
- type: 'HOLIDAY' | 'BREAK' | 'OFF';
421
- startDate: string; // YYYY-MM-DD
422
- endDate: string; // YYYY-MM-DD
423
- name: string;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
424
  }
 
1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  export enum UserRole {
3
  ADMIN = 'ADMIN',
4
+ PRINCIPAL = 'PRINCIPAL',
5
  TEACHER = 'TEACHER',
6
  STUDENT = 'STUDENT',
7
  USER = 'USER'
 
13
  BANNED = 'banned'
14
  }
15
 
 
 
 
 
 
 
 
16
  export interface User {
17
  id?: number;
18
  _id?: string;
19
  username: string;
20
+ password?: string;
21
  trueName?: string;
22
  phone?: string;
23
  email?: string;
24
  schoolId?: string;
25
+ role: UserRole | string;
26
+ status: UserStatus | string;
27
  avatar?: string;
28
  createTime?: string;
29
  teachingSubject?: string;
30
  homeroomClass?: string;
 
31
  aiAccess?: boolean;
32
+ menuOrder?: string[];
33
  classApplication?: {
34
  type: 'CLAIM' | 'RESIGN';
35
  targetClass?: string;
36
  status: 'PENDING' | 'REJECTED';
37
  };
 
38
  studentNo?: string;
39
  parentName?: string;
40
  parentPhone?: string;
41
  address?: string;
42
  gender?: 'Male' | 'Female';
43
  seatNo?: string;
44
+ idCard?: string;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  }
46
 
47
  export interface Student {
48
  id?: number;
49
  _id?: string;
50
  schoolId?: string;
51
+ studentNo: string;
52
+ seatNo?: string;
53
  name: string;
54
+ gender: 'Male' | 'Female';
55
+ birthday?: string;
56
+ idCard?: string;
57
+ phone?: string;
58
  className: string;
59
  status: 'Enrolled' | 'Graduated' | 'Suspended';
60
  parentName?: string;
61
  parentPhone?: string;
62
  address?: string;
63
+ teamId?: string;
64
+ drawAttempts?: number;
65
+ dailyDrawLog?: { date: string, count: number };
66
+ flowerBalance?: number;
67
+ }
68
+
69
+ export interface ClassInfo {
70
+ id?: number;
71
+ _id?: string;
72
+ schoolId?: string;
73
+ grade: string;
74
+ className: string;
75
+ teacherName?: string;
76
+ homeroomTeacherIds?: string[];
77
+ studentCount?: number;
78
  }
79
 
80
  export interface Course {
81
  id?: number;
82
  _id?: string;
83
  schoolId?: string;
84
+ courseCode?: string;
85
+ courseName: string;
 
86
  teacherName: string;
87
+ teacherId?: string;
88
+ className: string;
89
+ credits?: number;
90
+ capacity?: number;
91
+ enrolled?: number;
92
  }
93
 
94
  export type ExamStatus = 'Normal' | 'Absent' | 'Leave' | 'Cheat';
 
101
  studentNo: string;
102
  courseName: string;
103
  score: number;
104
+ semester?: string;
105
+ type: string;
106
  examName?: string;
107
  status?: ExamStatus;
108
  }
109
 
110
+ export interface Subject {
111
  id?: number;
112
+ _id?: string;
113
+ schoolId?: string;
114
+ name: string;
115
+ code?: string;
116
+ color?: string;
117
+ excellenceThreshold?: number;
118
+ thresholds?: Record<string, number>;
119
+ }
120
+
121
+ export interface Exam {
122
  _id?: string;
123
  schoolId?: string;
124
  name: string;
125
  date: string;
126
+ type: string; // 'Midterm', 'Final', 'Quiz'
127
  semester?: string;
 
128
  }
129
 
130
  export interface Schedule {
 
136
  subject: string;
137
  dayOfWeek: number;
138
  period: number;
139
+ weekType?: 'ALL' | 'ODD' | 'EVEN';
140
  }
141
 
142
+ export interface School {
143
  id?: number;
144
+ _id?: string;
145
+ name: string;
146
+ code: string;
147
+ }
148
+
149
+ export interface Notification {
150
  _id?: string;
151
  schoolId?: string;
152
+ targetRole?: string;
153
  targetUserId?: string;
154
  title: string;
155
  content: string;
156
  type: 'info' | 'success' | 'warning' | 'error';
 
157
  createTime: string;
158
  }
159
 
160
+ export interface PeriodConfig {
161
+ period: number;
162
+ name: string;
163
+ startTime?: string;
164
+ endTime?: string;
165
  }
166
 
167
+ export interface SystemConfig {
168
+ systemName: string;
169
+ semester: string;
170
+ semesters?: string[];
171
+ allowRegister: boolean;
172
+ allowAdminRegister: boolean;
173
+ allowPrincipalRegister?: boolean;
174
+ allowStudentRegister?: boolean;
175
+ maintenanceMode: boolean;
176
+ emailNotify: boolean;
177
+ enableAI?: boolean;
178
+ aiTotalCalls?: number;
179
+ periodConfig?: PeriodConfig[];
180
+ }
181
+
182
+ export interface SchoolCalendarEntry {
183
+ _id?: string;
184
+ schoolId: string;
185
+ className?: string;
186
+ type: 'HOLIDAY' | 'BREAK' | 'OFF' | 'WORKDAY';
187
+ startDate: string;
188
+ endDate: string;
189
+ name: string;
190
+ }
191
 
192
+ export interface Todo {
193
+ _id?: string;
194
+ userId: string;
195
+ content: string;
196
+ isCompleted: boolean;
197
+ createTime: string;
198
+ }
199
+
200
+ // Game & Rewards
201
  export interface GameTeam {
202
  id: string;
203
  name: string;
204
  score: number;
205
+ avatar: string;
206
  color: string;
207
+ members: string[];
208
  }
209
 
210
  export interface GameRewardConfig {
211
+ scoreThreshold: number;
212
+ rewardType: 'DRAW_COUNT' | 'ITEM' | 'ACHIEVEMENT';
213
+ rewardName: string;
214
+ rewardValue: number;
215
+ achievementId?: string;
216
  }
217
 
218
  export interface GameSession {
219
  _id?: string;
220
  schoolId: string;
221
+ className: string;
222
+ subject: string;
223
  isEnabled: boolean;
224
+ maxSteps: number;
225
  teams: GameTeam[];
226
  rewardsConfig: GameRewardConfig[];
 
 
 
 
 
 
 
 
227
  }
228
 
229
  export interface StudentReward {
230
  _id?: string;
231
+ schoolId: string;
232
+ studentId: string;
233
  studentName: string;
234
+ rewardType: 'DRAW_COUNT' | 'ITEM' | 'CONSOLATION' | 'ACHIEVEMENT';
235
  name: string;
236
+ count: number;
237
  status: 'PENDING' | 'REDEEMED';
238
+ source: string;
239
  createTime: string;
240
  ownerId?: string;
241
  }
 
243
  export interface LuckyPrize {
244
  id: string;
245
  name: string;
246
+ probability: number;
247
+ count: number;
248
  icon?: string;
249
  }
250
 
251
  export interface LuckyDrawConfig {
 
 
 
 
 
 
 
 
 
 
 
 
252
  _id?: string;
253
  schoolId: string;
254
  className: string;
255
+ ownerId?: string;
256
+ prizes: LuckyPrize[];
257
+ dailyLimit: number;
258
+ cardCount: number;
259
+ defaultPrize: string;
260
+ consolationWeight: number;
 
 
 
 
 
261
  }
262
 
 
263
  export interface AchievementItem {
264
  id: string;
265
  name: string;
266
+ icon: string;
267
+ points: number;
268
  description?: string;
269
+ addedBy?: string;
270
+ addedByName?: string;
271
  }
272
 
273
  export interface ExchangeRule {
274
  id: string;
275
+ cost: number;
276
+ rewardType: 'DRAW_COUNT' | 'ITEM';
277
  rewardName: string;
278
+ rewardValue: number;
279
  }
280
 
 
281
  export interface AchievementConfig {
282
  _id?: string;
283
  schoolId: string;
284
  className: string;
285
  achievements: AchievementItem[];
286
+ exchangeRules: ExchangeRule[];
 
287
  }
288
 
 
289
  export interface TeacherExchangeConfig {
290
  _id?: string;
291
  schoolId: string;
 
299
  schoolId: string;
300
  studentId: string;
301
  studentName: string;
302
+ achievementId: string;
303
+ achievementName: string;
304
  achievementIcon: string;
305
  semester: string;
306
  createTime: string;
307
  }
308
 
 
 
 
309
  export interface Attendance {
310
  _id?: string;
311
  schoolId: string;
312
  studentId: string;
313
  studentName: string;
314
  className: string;
315
+ date: string;
316
+ status: 'Present' | 'Absent' | 'Leave';
317
  checkInTime?: string;
318
  }
319
 
 
330
  createTime: string;
331
  }
332
 
333
+ export interface Wish {
334
  _id?: string;
335
  schoolId: string;
336
+ studentId: string;
337
+ studentName: string;
338
+ className?: string;
339
+ teacherId: string;
340
+ teacherName: string;
341
+ content: string;
342
+ status: 'PENDING' | 'FULFILLED';
343
+ createTime: string;
344
+ fulfillTime?: string;
345
+ }
346
+
347
+ export interface Feedback {
348
+ _id?: string;
349
+ schoolId: string;
350
+ creatorId: string;
351
+ creatorName: string;
352
+ creatorRole: string;
353
+ targetId?: string;
354
+ targetName?: string;
355
+ content: string;
356
+ type: 'ACADEMIC' | 'SYSTEM';
357
+ status: 'PENDING' | 'ACCEPTED' | 'PROCESSED' | 'IGNORED';
358
+ reply?: string;
359
+ createTime: string;
360
+ updateTime?: string;
361
+ }
362
+
363
+ export interface AIChatMessage {
364
+ id: string;
365
+ role: 'user' | 'model';
366
+ text?: string;
367
+ audio?: string;
368
+ isAudioMessage?: boolean;
369
+ timestamp: number;
370
  }