dvc890 commited on
Commit
ea97403
·
verified ·
1 Parent(s): 842b4cd

Upload 53 files

Browse files
components/ConfirmModal.tsx CHANGED
@@ -1,6 +1,6 @@
1
 
2
  import React from 'react';
3
- import { X, AlertTriangle, Info } from 'lucide-react';
4
 
5
  interface ConfirmModalProps {
6
  isOpen: boolean;
@@ -11,7 +11,7 @@ interface ConfirmModalProps {
11
  confirmText?: string;
12
  cancelText?: string;
13
  isDanger?: boolean;
14
- type?: 'confirm' | 'alert'; // Added type
15
  }
16
 
17
  export const ConfirmModal: React.FC<ConfirmModalProps> = ({
@@ -20,6 +20,11 @@ export const ConfirmModal: React.FC<ConfirmModalProps> = ({
20
  }) => {
21
  if (!isOpen) return null;
22
 
 
 
 
 
 
23
  return (
24
  <div className="fixed inset-0 bg-black/60 z-[99999] flex items-center justify-center p-4 backdrop-blur-sm animate-in fade-in">
25
  <div className="bg-white rounded-2xl shadow-2xl max-w-sm w-full overflow-hidden animate-in zoom-in-95 border border-gray-100">
@@ -28,7 +33,7 @@ export const ConfirmModal: React.FC<ConfirmModalProps> = ({
28
  {isDanger ? <AlertTriangle size={24} /> : <Info size={24} />}
29
  </div>
30
  <h3 className="text-lg font-bold text-gray-900 mb-2">{title}</h3>
31
- <p className="text-sm text-gray-500 leading-relaxed">{message}</p>
32
  </div>
33
  <div className="bg-gray-50 px-6 py-4 flex gap-3">
34
  {type === 'confirm' && (
@@ -40,7 +45,7 @@ export const ConfirmModal: React.FC<ConfirmModalProps> = ({
40
  </button>
41
  )}
42
  <button
43
- onClick={() => { onConfirm(); if(type==='alert') onClose(); }} // Close automatically for alert, parent handles confirm for logic
44
  className={`flex-1 py-2.5 rounded-xl text-sm font-bold text-white shadow-md transition-colors ${isDanger ? 'bg-red-600 hover:bg-red-700' : 'bg-blue-600 hover:bg-blue-700'}`}
45
  >
46
  {confirmText}
 
1
 
2
  import React from 'react';
3
+ import { AlertTriangle, Info } from 'lucide-react';
4
 
5
  interface ConfirmModalProps {
6
  isOpen: boolean;
 
11
  confirmText?: string;
12
  cancelText?: string;
13
  isDanger?: boolean;
14
+ type?: 'confirm' | 'alert';
15
  }
16
 
17
  export const ConfirmModal: React.FC<ConfirmModalProps> = ({
 
20
  }) => {
21
  if (!isOpen) return null;
22
 
23
+ const handleConfirm = () => {
24
+ onConfirm();
25
+ onClose(); // Automatically close modal after confirmation
26
+ };
27
+
28
  return (
29
  <div className="fixed inset-0 bg-black/60 z-[99999] flex items-center justify-center p-4 backdrop-blur-sm animate-in fade-in">
30
  <div className="bg-white rounded-2xl shadow-2xl max-w-sm w-full overflow-hidden animate-in zoom-in-95 border border-gray-100">
 
33
  {isDanger ? <AlertTriangle size={24} /> : <Info size={24} />}
34
  </div>
35
  <h3 className="text-lg font-bold text-gray-900 mb-2">{title}</h3>
36
+ <p className="text-sm text-gray-500 leading-relaxed whitespace-pre-wrap">{message}</p>
37
  </div>
38
  <div className="bg-gray-50 px-6 py-4 flex gap-3">
39
  {type === 'confirm' && (
 
45
  </button>
46
  )}
47
  <button
48
+ onClick={handleConfirm}
49
  className={`flex-1 py-2.5 rounded-xl text-sm font-bold text-white shadow-md transition-colors ${isDanger ? 'bg-red-600 hover:bg-red-700' : 'bg-blue-600 hover:bg-blue-700'}`}
50
  >
51
  {confirmText}
pages/Attendance.tsx CHANGED
@@ -3,6 +3,8 @@ 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[]>([]);
@@ -18,6 +20,11 @@ export const AttendancePage: React.FC = () => {
18
  const [excludeWeekends, setExcludeWeekends] = useState(true);
19
  const [newHoliday, setNewHoliday] = useState({ name: '', startDate: '', endDate: '' });
20
 
 
 
 
 
 
21
  const currentUser = api.auth.getCurrentUser();
22
  const targetClass = currentUser?.homeroomClass;
23
 
@@ -85,10 +92,20 @@ export const AttendancePage: React.FC = () => {
85
  useEffect(() => { checkIsHoliday(date, calendarEntries); }, [excludeWeekends]);
86
 
87
  const handleBatchCheckIn = async () => {
88
- if (isHoliday) return alert('当前是非考勤日,无法执行全勤操作');
89
- if (!confirm(`确定要将所有未打卡学生标记为“出”吗?`)) return;
90
- await api.attendance.batch({ className: targetClass!, date });
91
- loadData();
 
 
 
 
 
 
 
 
 
 
92
  };
93
 
94
  const toggleStatus = async (studentId: string) => {
@@ -104,7 +121,10 @@ export const AttendancePage: React.FC = () => {
104
  };
105
 
106
  const handleAddHoliday = async () => {
107
- if (!newHoliday.name || !newHoliday.startDate || !newHoliday.endDate) return alert('请填写完整');
 
 
 
108
  await api.calendar.add({
109
  schoolId: currentUser?.schoolId!,
110
  className: targetClass,
@@ -112,42 +132,62 @@ export const AttendancePage: React.FC = () => {
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,6 +202,16 @@ export const AttendancePage: React.FC = () => {
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>
@@ -181,10 +231,10 @@ export const AttendancePage: React.FC = () => {
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 && (
 
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
+ import { ConfirmModal } from '../components/ConfirmModal';
7
+ import { Toast, ToastState } from '../components/Toast';
8
 
9
  export const AttendancePage: React.FC = () => {
10
  const [students, setStudents] = useState<Student[]>([]);
 
20
  const [excludeWeekends, setExcludeWeekends] = useState(true);
21
  const [newHoliday, setNewHoliday] = useState({ name: '', startDate: '', endDate: '' });
22
 
23
+ const [toast, setToast] = useState<ToastState>({ show: false, message: '', type: 'success' });
24
+ const [confirmModal, setConfirmModal] = useState<{isOpen: boolean, title: string, message: string, onConfirm: () => void, isDanger?: boolean}>({
25
+ isOpen: false, title: '', message: '', onConfirm: () => {}
26
+ });
27
+
28
  const currentUser = api.auth.getCurrentUser();
29
  const targetClass = currentUser?.homeroomClass;
30
 
 
92
  useEffect(() => { checkIsHoliday(date, calendarEntries); }, [excludeWeekends]);
93
 
94
  const handleBatchCheckIn = async () => {
95
+ if (isHoliday) {
96
+ setToast({ show: true, message: '当前是非考日,无法执行全勤操作', type: 'error' });
97
+ return;
98
+ }
99
+ setConfirmModal({
100
+ isOpen: true,
101
+ title: '一键全勤',
102
+ message: '确定要将所有未打卡学生标记为“出勤”吗?',
103
+ onConfirm: async () => {
104
+ await api.attendance.batch({ className: targetClass!, date });
105
+ setToast({ show: true, message: '操作成功', type: 'success' });
106
+ loadData();
107
+ }
108
+ });
109
  };
110
 
111
  const toggleStatus = async (studentId: string) => {
 
121
  };
122
 
123
  const handleAddHoliday = async () => {
124
+ if (!newHoliday.name || !newHoliday.startDate || !newHoliday.endDate) {
125
+ setToast({ show: true, message: '请填写完整假期信息', type: 'error' });
126
+ return;
127
+ }
128
  await api.calendar.add({
129
  schoolId: currentUser?.schoolId!,
130
  className: targetClass,
 
132
  ...newHoliday
133
  });
134
  setNewHoliday({ name: '', startDate: '', endDate: '' });
135
+ setToast({ show: true, message: '日程已添加', type: 'success' });
136
  loadData();
137
  };
138
 
139
  const handleDeleteHoliday = async (id: string) => {
140
+ setConfirmModal({
141
+ isOpen: true,
142
+ title: '删除日程',
143
+ message: '确定删除这条日程设置吗?',
144
+ isDanger: true,
145
+ onConfirm: async () => {
146
+ await api.calendar.delete(id);
147
+ loadData();
148
+ }
149
+ });
150
  };
151
 
152
  // Toggle Day Type (Workday <-> Holiday/Off)
153
  const toggleDayType = async () => {
154
  if (isHoliday) {
155
  // Current is Holiday -> Set to Workday
156
+ setConfirmModal({
157
+ isOpen: true,
158
+ title: '开启考勤',
159
+ message: `确定将 ${date} 设为“工作日”并开启考勤吗?\n(此操作将覆盖周末或假期的免打卡状态)`,
160
+ onConfirm: async () => {
161
+ await api.calendar.add({
162
+ schoolId: currentUser?.schoolId!,
163
+ className: targetClass,
164
+ type: 'WORKDAY',
165
+ startDate: date,
166
+ endDate: date,
167
+ name: '补班/工作日'
168
+ });
169
+ loadData();
170
+ }
171
  });
172
  } else {
173
  // Current is Workday -> Set to Off
174
+ setConfirmModal({
175
+ isOpen: true,
176
+ title: '关闭考勤',
177
+ message: `确定将 ${date} 标记为“休息日”并免除考勤吗?`,
178
+ onConfirm: async () => {
179
+ await api.calendar.add({
180
+ schoolId: currentUser?.schoolId!,
181
+ className: targetClass,
182
+ type: 'OFF',
183
+ startDate: date,
184
+ endDate: date,
185
+ name: '休息/停课'
186
+ });
187
+ loadData();
188
+ }
189
  });
190
  }
 
191
  };
192
 
193
  if (!targetClass) return <div className="p-10 text-center text-gray-500">您不是班主任,无法管理考勤。</div>;
 
202
 
203
  return (
204
  <div className="space-y-6">
205
+ {toast.show && <Toast message={toast.message} type={toast.type} onClose={()=>setToast({...toast, show: false})}/>}
206
+ <ConfirmModal
207
+ isOpen={confirmModal.isOpen}
208
+ title={confirmModal.title}
209
+ message={confirmModal.message}
210
+ onClose={()=>setConfirmModal({...confirmModal, isOpen: false})}
211
+ onConfirm={confirmModal.onConfirm}
212
+ isDanger={confirmModal.isDanger}
213
+ />
214
+
215
  <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">
216
  <div className="flex items-center gap-4 w-full md:w-auto">
217
  <h2 className="text-xl font-bold text-gray-800">考勤管理</h2>
 
231
  {/* Dynamic Toggle Button */}
232
  <button
233
  onClick={toggleDayType}
234
+ className={`px-3 py-2 rounded-lg text-sm whitespace-nowrap flex items-center shadow-sm font-bold ${isHoliday ? 'bg-green-100 text-green-700 hover:bg-green-200' : 'bg-orange-100 text-orange-700 hover:bg-orange-200'}`}
235
  title={isHoliday ? "设为工作日 (开启考勤)" : "设为休息日 (免打卡)"}
236
  >
237
+ {isHoliday ? <><Briefcase size={18} className="mr-1"/> 开启考勤</> : <><CalendarOff size={18} className="mr-1"/> 设为休息</>}
238
  </button>
239
 
240
  {!isHoliday && (
pages/GameLucky.tsx CHANGED
@@ -4,8 +4,10 @@ import { api } from '../services/api';
4
  import { LuckyDrawConfig, Student, LuckyPrize, User } from '../types';
5
  import { Gift, Settings, Loader2, Save, Trash2, X, UserCircle, RefreshCcw, HelpCircle, Maximize, Minimize, Disc, LayoutGrid, ArrowDown, ChevronDown } from 'lucide-react';
6
  import { Emoji } from '../components/Emoji';
 
 
7
 
8
- // ... (Keep FlipCard and LuckyWheel Components Unchanged - omitted for brevity) ...
9
  const FlipCard = ({ index, prize, onFlip, isRevealed, activeIndex }: { index: number, prize: string, onFlip: (idx: number) => void, isRevealed: boolean, activeIndex: number | null }) => {
10
  const showBack = isRevealed && activeIndex === index;
11
  return (
@@ -79,6 +81,8 @@ export const GameLucky: React.FC<{className?: string}> = ({ className }) => {
79
 
80
  const [activeCardIndex, setActiveCardIndex] = useState<number | null>(null);
81
  const [isWheelSpinning, setIsWheelSpinning] = useState(false);
 
 
82
 
83
  const currentUser = api.auth.getCurrentUser();
84
  const isTeacher = currentUser?.role === 'TEACHER';
@@ -91,8 +95,6 @@ export const GameLucky: React.FC<{className?: string}> = ({ className }) => {
91
  if (!t) return '未知老师';
92
  const name = t.trueName || t.username;
93
  const surname = name.charAt(0);
94
- // Use teachingSubject if available, otherwise just Surname + Teacher.
95
- // Do not default to "科任".
96
  const subject = t.teachingSubject;
97
  return subject ? `${subject}-${surname}老师` : `${surname}老师`;
98
  };
@@ -176,8 +178,8 @@ export const GameLucky: React.FC<{className?: string}> = ({ className }) => {
176
  if (isBusy) return;
177
 
178
  const targetId = isTeacher ? proxyStudentId : (studentInfo?._id || String(studentInfo?.id));
179
- if (!targetId) return alert(isTeacher ? '请先在右侧选择要代抽的学生' : '学生信息未加载');
180
- if (!studentInfo || (studentInfo.drawAttempts || 0) <= 0) return alert('抽奖次数不足!');
181
 
182
  if (viewMode === 'CARD' && triggerIndex !== undefined) setActiveCardIndex(triggerIndex);
183
  if (viewMode === 'WHEEL') setIsWheelSpinning(true);
@@ -191,14 +193,18 @@ export const GameLucky: React.FC<{className?: string}> = ({ className }) => {
191
 
192
  setTimeout(() => {
193
  const msg = res.rewardType !== 'CONSOLATION' ? `🎁 恭喜!抽中了:${res.prize}` : `💪 ${res.prize}`;
194
- alert(msg);
195
- setDrawResult(null);
196
- setActiveCardIndex(null);
197
- setIsWheelSpinning(false);
 
 
 
 
198
  }, delay);
199
 
200
  } catch (e: any) {
201
- alert(e.message || '抽奖失败,请稍后重试');
202
  setActiveCardIndex(null);
203
  setIsWheelSpinning(false);
204
  }
@@ -207,6 +213,7 @@ export const GameLucky: React.FC<{className?: string}> = ({ className }) => {
207
  const saveSettings = async () => {
208
  if (luckyConfig) await api.games.saveLuckyConfig(luckyConfig);
209
  setIsSettingsOpen(false);
 
210
  };
211
 
212
  if (loading && availableTeachers.length === 0 && !hasFetchedTeachers) return <div className="h-full flex items-center justify-center"><Loader2 className="animate-spin text-blue-600"/></div>;
@@ -218,6 +225,8 @@ export const GameLucky: React.FC<{className?: string}> = ({ className }) => {
218
 
219
  return (
220
  <div className={`${isFullscreen ? 'fixed inset-0 z-[9999]' : 'h-full relative'} flex flex-col md:flex-row bg-gradient-to-br from-red-50 to-orange-50 overflow-hidden`}>
 
 
221
  <button onClick={() => setIsFullscreen(!isFullscreen)} className="absolute top-4 right-4 z-50 p-2 rounded-full bg-white/50 hover:bg-white backdrop-blur shadow-sm border transition-colors md:top-4 md:left-4 md:right-auto">
222
  {isFullscreen ? <Minimize size={20} className="text-gray-700"/> : <Maximize size={20} className="text-gray-700"/>}
223
  </button>
@@ -354,11 +363,11 @@ export const GameLucky: React.FC<{className?: string}> = ({ className }) => {
354
  <div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
355
  <div className="bg-white p-4 rounded-xl border shadow-sm">
356
  <label className="text-xs font-bold text-gray-500 uppercase block mb-2">每日抽奖上限</label>
357
- <input type="number" className="w-full border rounded-lg px-3 py-2 font-bold text-lg text-center focus:ring-2 focus:ring-blue-500 outline-none" value={config.dailyLimit} onChange={e => setLuckyConfig({...config, dailyLimit: Number(e.target.value)})}/>
358
  </div>
359
  <div className="bg-white p-4 rounded-xl border shadow-sm">
360
  <label className="text-xs font-bold text-gray-500 uppercase block mb-2">红包显示数量</label>
361
- <input type="number" min={4} max={20} className="w-full border rounded-lg px-3 py-2 font-bold text-lg text-center focus:ring-2 focus:ring-blue-500 outline-none" value={config.cardCount || 9} onChange={e => setLuckyConfig({...config, cardCount: Number(e.target.value)})}/>
362
  </div>
363
  <div className="bg-white p-4 rounded-xl border shadow-sm">
364
  <label className="text-xs font-bold text-gray-500 uppercase block mb-2">安慰奖文案</label>
@@ -366,7 +375,7 @@ export const GameLucky: React.FC<{className?: string}> = ({ className }) => {
366
  </div>
367
  <div className="bg-white p-4 rounded-xl border shadow-sm">
368
  <label className="text-xs font-bold text-gray-500 uppercase block mb-2">安慰奖权重</label>
369
- <input type="number" min={0} className="w-full border rounded-lg px-3 py-2 font-bold text-lg text-center focus:ring-2 focus:ring-blue-500 outline-none" value={config.consolationWeight || 0} onChange={e => setLuckyConfig({...config, consolationWeight: Number(e.target.value)})}/>
370
  <p className="text-[10px] text-gray-400 mt-1 text-center">数值越大越难中奖</p>
371
  </div>
372
  </div>
@@ -390,15 +399,17 @@ export const GameLucky: React.FC<{className?: string}> = ({ className }) => {
390
  const np = [...config.prizes]; np[idx].name = e.target.value; setLuckyConfig({...config, prizes: np});
391
  }} className="w-full border-b border-transparent focus:border-blue-500 bg-transparent outline-none py-1"/>
392
  </td>
393
- <td className="p-3">
394
- <input type="number" value={p.probability} onChange={e => {
395
- const np = [...config.prizes]; np[idx].probability = Number(e.target.value); setLuckyConfig({...config, prizes: np});
396
- }} className="w-full text-center border rounded bg-gray-50 py-1 focus:bg-white font-mono"/>
397
  </td>
398
  <td className="p-3">
399
- <input type="number" value={p.count} onChange={e => {
400
- const np = [...config.prizes]; np[idx].count = Number(e.target.value); setLuckyConfig({...config, prizes: np});
401
- }} className="w-full text-center border rounded bg-gray-50 py-1 focus:bg-white"/>
 
 
402
  </td>
403
  <td className="p-3 text-right">
404
  <button onClick={() => {
 
4
  import { LuckyDrawConfig, Student, LuckyPrize, User } from '../types';
5
  import { Gift, Settings, Loader2, Save, Trash2, X, UserCircle, RefreshCcw, HelpCircle, Maximize, Minimize, Disc, LayoutGrid, ArrowDown, ChevronDown } from 'lucide-react';
6
  import { Emoji } from '../components/Emoji';
7
+ import { NumberInput } from '../components/NumberInput';
8
+ import { Toast, ToastState } from '../components/Toast';
9
 
10
+ // ... (Keep FlipCard and LuckyWheel Components Unchanged)
11
  const FlipCard = ({ index, prize, onFlip, isRevealed, activeIndex }: { index: number, prize: string, onFlip: (idx: number) => void, isRevealed: boolean, activeIndex: number | null }) => {
12
  const showBack = isRevealed && activeIndex === index;
13
  return (
 
81
 
82
  const [activeCardIndex, setActiveCardIndex] = useState<number | null>(null);
83
  const [isWheelSpinning, setIsWheelSpinning] = useState(false);
84
+
85
+ const [toast, setToast] = useState<ToastState>({ show: false, message: '', type: 'success' });
86
 
87
  const currentUser = api.auth.getCurrentUser();
88
  const isTeacher = currentUser?.role === 'TEACHER';
 
95
  if (!t) return '未知老师';
96
  const name = t.trueName || t.username;
97
  const surname = name.charAt(0);
 
 
98
  const subject = t.teachingSubject;
99
  return subject ? `${subject}-${surname}老师` : `${surname}老师`;
100
  };
 
178
  if (isBusy) return;
179
 
180
  const targetId = isTeacher ? proxyStudentId : (studentInfo?._id || String(studentInfo?.id));
181
+ if (!targetId) return setToast({ show: true, message: isTeacher ? '请先在右侧选择要代抽的学生' : '学生信息未加载', type: 'error' });
182
+ if (!studentInfo || (studentInfo.drawAttempts || 0) <= 0) return setToast({ show: true, message: '抽奖次数不足!', type: 'error' });
183
 
184
  if (viewMode === 'CARD' && triggerIndex !== undefined) setActiveCardIndex(triggerIndex);
185
  if (viewMode === 'WHEEL') setIsWheelSpinning(true);
 
193
 
194
  setTimeout(() => {
195
  const msg = res.rewardType !== 'CONSOLATION' ? `🎁 恭喜!抽中了:${res.prize}` : `💪 ${res.prize}`;
196
+ // Use Toast for quick feedback instead of blocking alert
197
+ setToast({ show: true, message: msg, type: res.rewardType !== 'CONSOLATION' ? 'success' : 'error' });
198
+ // Delay clearing result slightly longer for user to see
199
+ setTimeout(() => {
200
+ setDrawResult(null);
201
+ setActiveCardIndex(null);
202
+ setIsWheelSpinning(false);
203
+ }, 1000);
204
  }, delay);
205
 
206
  } catch (e: any) {
207
+ setToast({ show: true, message: e.message || '抽奖失败,请稍后重试', type: 'error' });
208
  setActiveCardIndex(null);
209
  setIsWheelSpinning(false);
210
  }
 
213
  const saveSettings = async () => {
214
  if (luckyConfig) await api.games.saveLuckyConfig(luckyConfig);
215
  setIsSettingsOpen(false);
216
+ setToast({ show: true, message: '配置已保存', type: 'success' });
217
  };
218
 
219
  if (loading && availableTeachers.length === 0 && !hasFetchedTeachers) return <div className="h-full flex items-center justify-center"><Loader2 className="animate-spin text-blue-600"/></div>;
 
225
 
226
  return (
227
  <div className={`${isFullscreen ? 'fixed inset-0 z-[9999]' : 'h-full relative'} flex flex-col md:flex-row bg-gradient-to-br from-red-50 to-orange-50 overflow-hidden`}>
228
+ {toast.show && <Toast message={toast.message} type={toast.type} onClose={()=>setToast({...toast, show: false})}/>}
229
+
230
  <button onClick={() => setIsFullscreen(!isFullscreen)} className="absolute top-4 right-4 z-50 p-2 rounded-full bg-white/50 hover:bg-white backdrop-blur shadow-sm border transition-colors md:top-4 md:left-4 md:right-auto">
231
  {isFullscreen ? <Minimize size={20} className="text-gray-700"/> : <Maximize size={20} className="text-gray-700"/>}
232
  </button>
 
363
  <div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
364
  <div className="bg-white p-4 rounded-xl border shadow-sm">
365
  <label className="text-xs font-bold text-gray-500 uppercase block mb-2">每日抽奖上限</label>
366
+ <NumberInput value={config.dailyLimit} onChange={v => setLuckyConfig({...config, dailyLimit: v})} min={1} max={99}/>
367
  </div>
368
  <div className="bg-white p-4 rounded-xl border shadow-sm">
369
  <label className="text-xs font-bold text-gray-500 uppercase block mb-2">红包显示数量</label>
370
+ <NumberInput value={config.cardCount || 9} onChange={v => setLuckyConfig({...config, cardCount: v})} min={4} max={20}/>
371
  </div>
372
  <div className="bg-white p-4 rounded-xl border shadow-sm">
373
  <label className="text-xs font-bold text-gray-500 uppercase block mb-2">安慰奖文案</label>
 
375
  </div>
376
  <div className="bg-white p-4 rounded-xl border shadow-sm">
377
  <label className="text-xs font-bold text-gray-500 uppercase block mb-2">安慰奖权重</label>
378
+ <NumberInput value={config.consolationWeight || 0} onChange={v => setLuckyConfig({...config, consolationWeight: v})} min={0} max={100}/>
379
  <p className="text-[10px] text-gray-400 mt-1 text-center">数值越大越难中奖</p>
380
  </div>
381
  </div>
 
399
  const np = [...config.prizes]; np[idx].name = e.target.value; setLuckyConfig({...config, prizes: np});
400
  }} className="w-full border-b border-transparent focus:border-blue-500 bg-transparent outline-none py-1"/>
401
  </td>
402
+ <td className="p-3 flex justify-center">
403
+ <NumberInput className="w-32" value={p.probability} onChange={v => {
404
+ const np = [...config.prizes]; np[idx].probability = v; setLuckyConfig({...config, prizes: np});
405
+ }} min={1}/>
406
  </td>
407
  <td className="p-3">
408
+ <div className="flex justify-center">
409
+ <NumberInput className="w-24" value={p.count} onChange={v => {
410
+ const np = [...config.prizes]; np[idx].count = v; setLuckyConfig({...config, prizes: np});
411
+ }} min={0}/>
412
+ </div>
413
  </td>
414
  <td className="p-3 text-right">
415
  <button onClick={() => {
pages/Profile.tsx CHANGED
@@ -3,6 +3,7 @@ import React, { useState, useEffect } from 'react';
3
  import { api } from '../services/api';
4
  import { User } from '../types';
5
  import { Save, Lock, User as UserIcon, Camera, Loader2, Link } from 'lucide-react';
 
6
 
7
  const AVATAR_PRESETS = [
8
  'https://api.dicebear.com/7.x/avataaars/svg?seed=Felix',
@@ -48,6 +49,8 @@ export const Profile: React.FC = () => {
48
  const [loading, setLoading] = useState(true);
49
  const [saving, setSaving] = useState(false);
50
 
 
 
51
  // Forms
52
  const [profileForm, setProfileForm] = useState({ trueName: '', phone: '', avatar: '' });
53
  const [passwordForm, setPasswordForm] = useState({ currentPassword: '', newPassword: '', confirmPassword: '' });
@@ -83,16 +86,18 @@ export const Profile: React.FC = () => {
83
  userId: user?._id || user?.id,
84
  ...profileForm
85
  });
86
- alert('个人资料更新成功!');
87
  loadUser();
88
- } catch(e: any) { alert('更新失败: ' + e.message); }
 
 
89
  finally { setSaving(false); }
90
  };
91
 
92
  const handlePasswordUpdate = async () => {
93
- if (!passwordForm.currentPassword || !passwordForm.newPassword) return alert('请填写完整');
94
- if (passwordForm.newPassword !== passwordForm.confirmPassword) return alert('两次输入的新密码不一致');
95
- if (passwordForm.newPassword.length < 6) return alert('新密码长度不能少于6位');
96
 
97
  setSaving(true);
98
  try {
@@ -101,11 +106,11 @@ export const Profile: React.FC = () => {
101
  currentPassword: passwordForm.currentPassword,
102
  newPassword: passwordForm.newPassword
103
  });
104
- alert('密码修改成功!下次登录请使用新密码。');
105
  setPasswordForm({ currentPassword: '', newPassword: '', confirmPassword: '' });
106
  } catch(e: any) {
107
- if (e.message === 'INVALID_PASSWORD') alert('当前密码错误,请重试');
108
- else alert('修改失败: ' + e.message);
109
  } finally { setSaving(false); }
110
  };
111
 
@@ -113,6 +118,8 @@ export const Profile: React.FC = () => {
113
 
114
  return (
115
  <div className="max-w-4xl mx-auto space-y-6 animate-in fade-in">
 
 
116
  {/* Header Card */}
117
  <div className="bg-white rounded-xl p-6 shadow-sm border border-gray-100 flex flex-col md:flex-row items-center gap-6">
118
  <div className="relative group">
 
3
  import { api } from '../services/api';
4
  import { User } from '../types';
5
  import { Save, Lock, User as UserIcon, Camera, Loader2, Link } from 'lucide-react';
6
+ import { Toast, ToastState } from '../components/Toast';
7
 
8
  const AVATAR_PRESETS = [
9
  'https://api.dicebear.com/7.x/avataaars/svg?seed=Felix',
 
49
  const [loading, setLoading] = useState(true);
50
  const [saving, setSaving] = useState(false);
51
 
52
+ const [toast, setToast] = useState<ToastState>({ show: false, message: '', type: 'success' });
53
+
54
  // Forms
55
  const [profileForm, setProfileForm] = useState({ trueName: '', phone: '', avatar: '' });
56
  const [passwordForm, setPasswordForm] = useState({ currentPassword: '', newPassword: '', confirmPassword: '' });
 
86
  userId: user?._id || user?.id,
87
  ...profileForm
88
  });
89
+ setToast({ show: true, message: '个人资料更新成功!', type: 'success' });
90
  loadUser();
91
+ } catch(e: any) {
92
+ setToast({ show: true, message: '更新失败: ' + e.message, type: 'error' });
93
+ }
94
  finally { setSaving(false); }
95
  };
96
 
97
  const handlePasswordUpdate = async () => {
98
+ if (!passwordForm.currentPassword || !passwordForm.newPassword) return setToast({ show: true, message: '请填写完整', type: 'error' });
99
+ if (passwordForm.newPassword !== passwordForm.confirmPassword) return setToast({ show: true, message: '两次输入的新密码不一致', type: 'error' });
100
+ if (passwordForm.newPassword.length < 6) return setToast({ show: true, message: '新密码长度不能少于6位', type: 'error' });
101
 
102
  setSaving(true);
103
  try {
 
106
  currentPassword: passwordForm.currentPassword,
107
  newPassword: passwordForm.newPassword
108
  });
109
+ setToast({ show: true, message: '密码修改成功!下次登录请使用新密码。', type: 'success' });
110
  setPasswordForm({ currentPassword: '', newPassword: '', confirmPassword: '' });
111
  } catch(e: any) {
112
+ if (e.message === 'INVALID_PASSWORD') setToast({ show: true, message: '当前密码错误,请重试', type: 'error' });
113
+ else setToast({ show: true, message: '修改失败: ' + e.message, type: 'error' });
114
  } finally { setSaving(false); }
115
  };
116
 
 
118
 
119
  return (
120
  <div className="max-w-4xl mx-auto space-y-6 animate-in fade-in">
121
+ {toast.show && <Toast message={toast.message} type={toast.type} onClose={()=>setToast({...toast, show: false})}/>}
122
+
123
  {/* Header Card */}
124
  <div className="bg-white rounded-xl p-6 shadow-sm border border-gray-100 flex flex-col md:flex-row items-center gap-6">
125
  <div className="relative group">
pages/SubjectList.tsx CHANGED
@@ -3,6 +3,7 @@ import React, { useState, useEffect } from 'react';
3
  import { Subject } from '../types';
4
  import { api } from '../services/api';
5
  import { Loader2, Plus, Trash2, Palette, Edit, Save, X, Lock, Settings } from 'lucide-react';
 
6
 
7
  const ALL_GRADES = [
8
  '一年级', '二年级', '三年级', '四年级', '五年级', '六年级',
@@ -23,6 +24,9 @@ export const SubjectList: React.FC = () => {
23
  const [editForm, setEditForm] = useState<Subject>({ name: '', code: '', color: '', excellenceThreshold: 90, thresholds: {} });
24
 
25
  const [showThresholdModal, setShowThresholdModal] = useState(false);
 
 
 
26
 
27
  const loadSubjects = async () => {
28
  setLoading(true);
@@ -60,10 +64,16 @@ export const SubjectList: React.FC = () => {
60
  };
61
 
62
  const handleDelete = async (id: string) => {
63
- if(confirm('删除学科可能会影响相关成绩数据,确定继续吗?')) {
64
- await api.subjects.delete(id);
65
- loadSubjects();
66
- }
 
 
 
 
 
 
67
  };
68
 
69
  // Permission Check for Teachers
@@ -85,6 +95,15 @@ export const SubjectList: React.FC = () => {
85
 
86
  return (
87
  <div className="space-y-6">
 
 
 
 
 
 
 
 
 
88
  <div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6">
89
  <h2 className="text-xl font-bold text-gray-800 mb-6 flex items-center">
90
  <Palette className="mr-2 text-purple-600" />
@@ -251,4 +270,4 @@ export const SubjectList: React.FC = () => {
251
  )}
252
  </div>
253
  );
254
- };
 
3
  import { Subject } from '../types';
4
  import { api } from '../services/api';
5
  import { Loader2, Plus, Trash2, Palette, Edit, Save, X, Lock, Settings } from 'lucide-react';
6
+ import { ConfirmModal } from '../components/ConfirmModal';
7
 
8
  const ALL_GRADES = [
9
  '一年级', '二年级', '三年级', '四年级', '五年级', '六年级',
 
24
  const [editForm, setEditForm] = useState<Subject>({ name: '', code: '', color: '', excellenceThreshold: 90, thresholds: {} });
25
 
26
  const [showThresholdModal, setShowThresholdModal] = useState(false);
27
+ const [confirmModal, setConfirmModal] = useState<{isOpen: boolean, title: string, message: string, onConfirm: () => void, isDanger?: boolean}>({
28
+ isOpen: false, title: '', message: '', onConfirm: () => {}
29
+ });
30
 
31
  const loadSubjects = async () => {
32
  setLoading(true);
 
64
  };
65
 
66
  const handleDelete = async (id: string) => {
67
+ setConfirmModal({
68
+ isOpen: true,
69
+ title: '删除学科',
70
+ message: '删除学科可能会影响相关成绩数据,确定继续吗?',
71
+ isDanger: true,
72
+ onConfirm: async () => {
73
+ await api.subjects.delete(id);
74
+ loadSubjects();
75
+ }
76
+ });
77
  };
78
 
79
  // Permission Check for Teachers
 
95
 
96
  return (
97
  <div className="space-y-6">
98
+ <ConfirmModal
99
+ isOpen={confirmModal.isOpen}
100
+ title={confirmModal.title}
101
+ message={confirmModal.message}
102
+ onClose={()=>setConfirmModal({...confirmModal, isOpen: false})}
103
+ onConfirm={confirmModal.onConfirm}
104
+ isDanger={confirmModal.isDanger}
105
+ />
106
+
107
  <div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6">
108
  <h2 className="text-xl font-bold text-gray-800 mb-6 flex items-center">
109
  <Palette className="mr-2 text-purple-600" />
 
270
  )}
271
  </div>
272
  );
273
+ };
pages/UserList.tsx CHANGED
@@ -3,6 +3,8 @@ import React, { useState, useEffect } from 'react';
3
  import { User, UserRole, UserStatus, School, ClassInfo } from '../types';
4
  import { api } from '../services/api';
5
  import { Loader2, Check, X, Trash2, Edit, Briefcase, GraduationCap, AlertCircle, Bot } from 'lucide-react';
 
 
6
 
7
  export const UserList: React.FC = () => {
8
  const [users, setUsers] = useState<User[]>([]);
@@ -14,10 +16,15 @@ export const UserList: React.FC = () => {
14
  const [isApplyOpen, setIsApplyOpen] = useState(false);
15
  const [applyTarget, setApplyTarget] = useState('');
16
 
 
 
 
 
 
17
  const currentUser = api.auth.getCurrentUser();
18
  const isAdmin = currentUser?.role === UserRole.ADMIN;
19
  const isPrincipal = currentUser?.role === UserRole.PRINCIPAL;
20
- const isTeacher = currentUser?.role === UserRole.TEACHER;
21
 
22
  const loadData = async () => {
23
  setLoading(true);
@@ -37,23 +44,37 @@ export const UserList: React.FC = () => {
37
  useEffect(() => { loadData(); }, []);
38
 
39
  const handleApprove = async (user: User) => {
40
- if (confirm(`确认批准 ${user.trueName || user.username} 的注册申请?`)) {
41
- await api.users.update(user._id || String(user.id), { status: UserStatus.ACTIVE });
42
- loadData();
43
- }
 
 
 
 
 
 
44
  };
45
 
46
  const handleDelete = async (user: User) => {
47
- if (user.role === UserRole.ADMIN && user.username === 'admin') return alert('无法删除超级管理员');
48
- if (confirm(`警告:确定要删除用户 ${user.username} 吗?`)) {
49
- await api.users.delete(user._id || String(user.id));
50
- loadData();
51
- }
 
 
 
 
 
 
 
 
52
  };
53
 
54
  const handleRoleChange = async (user: User, newRole: UserRole) => {
55
- if (user.username === 'admin') return alert('无法修改超级管理员');
56
- if (isPrincipal && newRole === UserRole.ADMIN) return alert('权限不足:校长无法将用户提升为超级管理员');
57
 
58
  await api.users.update(user._id || String(user.id), { role: newRole });
59
  loadData();
@@ -67,10 +88,16 @@ export const UserList: React.FC = () => {
67
 
68
  const handleAIToggle = async (user: User) => {
69
  const newVal = !user.aiAccess;
70
- if (confirm(`确认${newVal ? '开通' : '关闭'} ${user.trueName || user.username} 的 AI 助教权限?`)) {
71
- await api.users.update(user._id || String(user.id), { aiAccess: newVal });
72
- loadData();
73
- }
 
 
 
 
 
 
74
  };
75
 
76
  const submitClassApply = async () => {
@@ -83,21 +110,27 @@ export const UserList: React.FC = () => {
83
  action: 'APPLY'
84
  });
85
  setIsApplyOpen(false);
86
- alert('申请已提交,请等待管理员审核');
87
  loadData();
88
- } catch(e) { alert('申请失败'); }
89
  };
90
 
91
  const submitResign = async () => {
92
- if(!confirm('确定申请卸任班主任吗?')) return;
93
- try {
94
- await api.users.applyClass({
95
- userId: currentUser?._id!,
96
- type: 'RESIGN',
97
- action: 'APPLY'
98
- });
99
- alert('卸任申请已提交');
100
- } catch(e) { alert('提交失败'); }
 
 
 
 
 
 
101
  };
102
 
103
  const getSchoolName = (id?: string) => schools.find(s => s._id === id)?.name || '未分配';
@@ -128,6 +161,16 @@ export const UserList: React.FC = () => {
128
 
129
  return (
130
  <div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6">
 
 
 
 
 
 
 
 
 
 
131
  <div className="flex justify-between items-center mb-4">
132
  <div className="flex items-center gap-4">
133
  <h2 className="text-xl font-bold text-gray-800">用户权限与账号审核</h2>
 
3
  import { User, UserRole, UserStatus, School, ClassInfo } from '../types';
4
  import { api } from '../services/api';
5
  import { Loader2, Check, X, Trash2, Edit, Briefcase, GraduationCap, AlertCircle, Bot } from 'lucide-react';
6
+ import { ConfirmModal } from '../components/ConfirmModal';
7
+ import { Toast, ToastState } from '../components/Toast';
8
 
9
  export const UserList: React.FC = () => {
10
  const [users, setUsers] = useState<User[]>([]);
 
16
  const [isApplyOpen, setIsApplyOpen] = useState(false);
17
  const [applyTarget, setApplyTarget] = useState('');
18
 
19
+ const [toast, setToast] = useState<ToastState>({ show: false, message: '', type: 'success' });
20
+ const [confirmModal, setConfirmModal] = useState<{isOpen: boolean, title: string, message: string, onConfirm: () => void, isDanger?: boolean}>({
21
+ isOpen: false, title: '', message: '', onConfirm: () => {}
22
+ });
23
+
24
  const currentUser = api.auth.getCurrentUser();
25
  const isAdmin = currentUser?.role === UserRole.ADMIN;
26
  const isPrincipal = currentUser?.role === UserRole.PRINCIPAL;
27
+ const isTeacher = currentUser?.role === 'TEACHER';
28
 
29
  const loadData = async () => {
30
  setLoading(true);
 
44
  useEffect(() => { loadData(); }, []);
45
 
46
  const handleApprove = async (user: User) => {
47
+ setConfirmModal({
48
+ isOpen: true,
49
+ title: '批准注册',
50
+ message: `确认批准 ${user.trueName || user.username} 的注册申请?`,
51
+ onConfirm: async () => {
52
+ await api.users.update(user._id || String(user.id), { status: UserStatus.ACTIVE });
53
+ setToast({ show: true, message: '已批准', type: 'success' });
54
+ loadData();
55
+ }
56
+ });
57
  };
58
 
59
  const handleDelete = async (user: User) => {
60
+ if (user.role === UserRole.ADMIN && user.username === 'admin') return setToast({ show: true, message: '无法删除超级管理员', type: 'error' });
61
+
62
+ setConfirmModal({
63
+ isOpen: true,
64
+ title: '删除用户',
65
+ message: `警告:确定要删除用户 ${user.username} 吗?此操作不可恢复。`,
66
+ isDanger: true,
67
+ onConfirm: async () => {
68
+ await api.users.delete(user._id || String(user.id));
69
+ setToast({ show: true, message: '用户已删除', type: 'success' });
70
+ loadData();
71
+ }
72
+ });
73
  };
74
 
75
  const handleRoleChange = async (user: User, newRole: UserRole) => {
76
+ if (user.username === 'admin') return setToast({ show: true, message: '无法修改超级管理员', type: 'error' });
77
+ if (isPrincipal && newRole === UserRole.ADMIN) return setToast({ show: true, message: '权限不足:校长无法将用户提升为超级管理员', type: 'error' });
78
 
79
  await api.users.update(user._id || String(user.id), { role: newRole });
80
  loadData();
 
88
 
89
  const handleAIToggle = async (user: User) => {
90
  const newVal = !user.aiAccess;
91
+ setConfirmModal({
92
+ isOpen: true,
93
+ title: 'AI 权限变更',
94
+ message: `确认${newVal ? '开通' : '关闭'} ${user.trueName || user.username} 的 AI 助教权限?`,
95
+ onConfirm: async () => {
96
+ await api.users.update(user._id || String(user.id), { aiAccess: newVal });
97
+ setToast({ show: true, message: '权限已更新', type: 'success' });
98
+ loadData();
99
+ }
100
+ });
101
  };
102
 
103
  const submitClassApply = async () => {
 
110
  action: 'APPLY'
111
  });
112
  setIsApplyOpen(false);
113
+ setToast({ show: true, message: '申请已提交,请等待管理员审核', type: 'success' });
114
  loadData();
115
+ } catch(e) { setToast({ show: true, message: '申请失败', type: 'error' }); }
116
  };
117
 
118
  const submitResign = async () => {
119
+ setConfirmModal({
120
+ isOpen: true,
121
+ title: '申请卸任',
122
+ message: '确定申请卸任班主任吗?',
123
+ onConfirm: async () => {
124
+ try {
125
+ await api.users.applyClass({
126
+ userId: currentUser?._id!,
127
+ type: 'RESIGN',
128
+ action: 'APPLY'
129
+ });
130
+ setToast({ show: true, message: '卸任申请已提交', type: 'success' });
131
+ } catch(e) { setToast({ show: true, message: '提交失败', type: 'error' }); }
132
+ }
133
+ });
134
  };
135
 
136
  const getSchoolName = (id?: string) => schools.find(s => s._id === id)?.name || '未分配';
 
161
 
162
  return (
163
  <div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6">
164
+ {toast.show && <Toast message={toast.message} type={toast.type} onClose={()=>setToast({...toast, show: false})}/>}
165
+ <ConfirmModal
166
+ isOpen={confirmModal.isOpen}
167
+ title={confirmModal.title}
168
+ message={confirmModal.message}
169
+ onClose={()=>setConfirmModal({...confirmModal, isOpen: false})}
170
+ onConfirm={confirmModal.onConfirm}
171
+ isDanger={confirmModal.isDanger}
172
+ />
173
+
174
  <div className="flex justify-between items-center mb-4">
175
  <div className="flex items-center gap-4">
176
  <h2 className="text-xl font-bold text-gray-800">用户权限与账号审核</h2>