dvc890 commited on
Commit
7aec442
·
verified ·
1 Parent(s): 1a8352d

Upload 35 files

Browse files
App.tsx CHANGED
@@ -14,6 +14,7 @@ const SubjectList = React.lazy(() => import('./pages/SubjectList').then(module =
14
  const UserList = React.lazy(() => import('./pages/UserList').then(module => ({ default: module.UserList })));
15
  const SchoolList = React.lazy(() => import('./pages/SchoolList').then(module => ({ default: module.SchoolList })));
16
  const Games = React.lazy(() => import('./pages/Games').then(module => ({ default: module.Games })));
 
17
 
18
  import { Login } from './pages/Login';
19
  import { User, UserRole } from './types';
@@ -108,6 +109,7 @@ const AppContent: React.FC = () => {
108
  case 'users': return <UserList />;
109
  case 'schools': return <SchoolList />;
110
  case 'games': return <Games />;
 
111
  default: return <Dashboard onNavigate={(view) => setCurrentView(view)} />;
112
  }
113
  };
@@ -123,7 +125,8 @@ const AppContent: React.FC = () => {
123
  subjects: '学科设置',
124
  users: '用户权限管理',
125
  schools: '学校维度管理',
126
- games: '互动教学中心'
 
127
  };
128
 
129
  if (loading) return <div className="min-h-screen flex items-center justify-center bg-gray-50 text-blue-600">加载中...</div>;
@@ -161,4 +164,4 @@ const App: React.FC = () => {
161
  );
162
  };
163
 
164
- export default App;
 
14
  const UserList = React.lazy(() => import('./pages/UserList').then(module => ({ default: module.UserList })));
15
  const SchoolList = React.lazy(() => import('./pages/SchoolList').then(module => ({ default: module.SchoolList })));
16
  const Games = React.lazy(() => import('./pages/Games').then(module => ({ default: module.Games })));
17
+ const AttendancePage = React.lazy(() => import('./pages/Attendance').then(module => ({ default: module.AttendancePage })));
18
 
19
  import { Login } from './pages/Login';
20
  import { User, UserRole } from './types';
 
109
  case 'users': return <UserList />;
110
  case 'schools': return <SchoolList />;
111
  case 'games': return <Games />;
112
+ case 'attendance': return <AttendancePage />;
113
  default: return <Dashboard onNavigate={(view) => setCurrentView(view)} />;
114
  }
115
  };
 
125
  subjects: '学科设置',
126
  users: '用户权限管理',
127
  schools: '学校维度管理',
128
+ games: '互动教学中心',
129
+ attendance: '考勤管理'
130
  };
131
 
132
  if (loading) return <div className="min-h-screen flex items-center justify-center bg-gray-50 text-blue-600">加载中...</div>;
 
164
  );
165
  };
166
 
167
+ export default App;
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 } from 'lucide-react';
4
  import { UserRole } from '../types';
5
 
6
  interface SidebarProps {
@@ -16,7 +16,8 @@ export const Sidebar: React.FC<SidebarProps> = ({ currentView, onChangeView, use
16
  // Define menu items with explicit roles
17
  const menuItems = [
18
  { id: 'dashboard', label: '工作台', icon: LayoutDashboard, roles: [UserRole.ADMIN, UserRole.TEACHER, UserRole.STUDENT] },
19
- { id: 'games', label: '互动教学', icon: Gamepad2, roles: [UserRole.TEACHER, UserRole.STUDENT] }, // NEW
 
20
  { id: 'students', label: '学生管理', icon: Users, roles: [UserRole.ADMIN, UserRole.TEACHER] },
21
  { id: 'classes', label: '班级管理', icon: School, roles: [UserRole.ADMIN] },
22
  { id: 'schools', label: '学校管理', icon: Building, roles: [UserRole.ADMIN] },
@@ -24,7 +25,7 @@ export const Sidebar: React.FC<SidebarProps> = ({ currentView, onChangeView, use
24
  { id: 'grades', label: '成绩管理', icon: GraduationCap, roles: [UserRole.ADMIN, UserRole.TEACHER] },
25
  { id: 'reports', label: '报表统计', icon: FileText, roles: [UserRole.ADMIN, UserRole.TEACHER, UserRole.STUDENT] },
26
  { id: 'subjects', label: '学科设置', icon: Palette, roles: [UserRole.ADMIN, UserRole.TEACHER] },
27
- { id: 'users', label: '用户管理', icon: UserCog, roles: [UserRole.ADMIN, UserRole.TEACHER] }, // Teachers can see user list to approve students
28
  { id: 'settings', label: '系统设置', icon: Settings, roles: [UserRole.ADMIN] },
29
  ];
30
 
@@ -82,4 +83,4 @@ export const Sidebar: React.FC<SidebarProps> = ({ currentView, onChangeView, use
82
  </div>
83
  </>
84
  );
85
- };
 
1
 
2
  import React from 'react';
3
+ import { LayoutDashboard, Users, BookOpen, GraduationCap, Settings, LogOut, FileText, School, UserCog, Palette, X, Building, Gamepad2, CalendarCheck } from 'lucide-react';
4
  import { UserRole } from '../types';
5
 
6
  interface SidebarProps {
 
16
  // Define menu items with explicit roles
17
  const menuItems = [
18
  { id: 'dashboard', label: '工作台', icon: LayoutDashboard, roles: [UserRole.ADMIN, UserRole.TEACHER, UserRole.STUDENT] },
19
+ { id: 'attendance', label: '考勤管理', icon: CalendarCheck, roles: [UserRole.TEACHER] }, // NEW
20
+ { id: 'games', label: '互动教学', icon: Gamepad2, roles: [UserRole.TEACHER, UserRole.STUDENT] },
21
  { id: 'students', label: '学生管理', icon: Users, roles: [UserRole.ADMIN, UserRole.TEACHER] },
22
  { id: 'classes', label: '班级管理', icon: School, roles: [UserRole.ADMIN] },
23
  { id: 'schools', label: '学校管理', icon: Building, roles: [UserRole.ADMIN] },
 
25
  { id: 'grades', label: '成绩管理', icon: GraduationCap, roles: [UserRole.ADMIN, UserRole.TEACHER] },
26
  { id: 'reports', label: '报表统计', icon: FileText, roles: [UserRole.ADMIN, UserRole.TEACHER, UserRole.STUDENT] },
27
  { id: 'subjects', label: '学科设置', icon: Palette, roles: [UserRole.ADMIN, UserRole.TEACHER] },
28
+ { id: 'users', label: '用户管理', icon: UserCog, roles: [UserRole.ADMIN, UserRole.TEACHER] },
29
  { id: 'settings', label: '系统设置', icon: Settings, roles: [UserRole.ADMIN] },
30
  ];
31
 
 
83
  </div>
84
  </>
85
  );
86
+ };
pages/Attendance.tsx ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import React, { useState, useEffect } from 'react';
3
+ import { api } from '../services/api';
4
+ import { Student, Attendance } from '../types';
5
+ import { Calendar, CheckCircle, Clock, AlertCircle, Loader2, UserX } from 'lucide-react';
6
+
7
+ export const AttendancePage: React.FC = () => {
8
+ const [students, setStudents] = useState<Student[]>([]);
9
+ const [attendanceMap, setAttendanceMap] = useState<Record<string, Attendance>>({});
10
+ const [loading, setLoading] = useState(true);
11
+ const [date, setDate] = useState(new Date().toISOString().split('T')[0]);
12
+
13
+ const currentUser = api.auth.getCurrentUser();
14
+ const targetClass = currentUser?.homeroomClass;
15
+
16
+ const loadData = async () => {
17
+ if (!targetClass) return setLoading(false);
18
+ setLoading(true);
19
+ try {
20
+ const [stus, atts] = await Promise.all([
21
+ api.students.getAll(),
22
+ api.attendance.get({ className: targetClass, date })
23
+ ]);
24
+ const classStudents = stus.filter((s: Student) => s.className === targetClass);
25
+ setStudents(classStudents);
26
+
27
+ const map: Record<string, Attendance> = {};
28
+ atts.forEach((a: Attendance) => { map[a.studentId] = a; });
29
+ setAttendanceMap(map);
30
+ } catch (e) { console.error(e); }
31
+ finally { setLoading(false); }
32
+ };
33
+
34
+ useEffect(() => {
35
+ loadData();
36
+ }, [date]);
37
+
38
+ const handleBatchCheckIn = async () => {
39
+ if (!confirm(`确定要将所有未打卡学生标记为“出勤”吗?`)) return;
40
+ await api.attendance.batch({ className: targetClass!, date });
41
+ loadData();
42
+ };
43
+
44
+ const toggleStatus = async (studentId: string) => {
45
+ const current = attendanceMap[studentId]?.status;
46
+ let nextStatus = 'Present';
47
+ if (current === 'Present') nextStatus = 'Leave';
48
+ else if (current === 'Leave') nextStatus = 'Absent';
49
+ else if (current === 'Absent') nextStatus = 'Present';
50
+
51
+ // If no record, create as Present first
52
+ if (!current) nextStatus = 'Present';
53
+
54
+ await api.attendance.update({ studentId, date, status: nextStatus });
55
+ loadData();
56
+ };
57
+
58
+ if (!targetClass) return <div className="p-10 text-center text-gray-500">您不是班主任,无法管理考勤。</div>;
59
+
60
+ const stats = {
61
+ present: Object.values(attendanceMap).filter(a => a.status === 'Present').length,
62
+ leave: Object.values(attendanceMap).filter(a => a.status === 'Leave').length,
63
+ absent: Object.values(attendanceMap).filter(a => a.status === 'Absent').length,
64
+ total: students.length
65
+ };
66
+ const missing = stats.total - stats.present - stats.leave - stats.absent;
67
+
68
+ return (
69
+ <div className="space-y-6">
70
+ <div className="flex flex-col md:flex-row justify-between items-center bg-white p-4 rounded-xl shadow-sm border border-gray-100">
71
+ <div className="flex items-center gap-4 mb-4 md:mb-0">
72
+ <h2 className="text-xl font-bold text-gray-800">考勤管理</h2>
73
+ <span className="bg-blue-100 text-blue-700 px-3 py-1 rounded-full text-sm font-medium">{targetClass}</span>
74
+ </div>
75
+ <div className="flex gap-4 items-center">
76
+ <input type="date" className="border rounded-lg px-3 py-2 text-sm" value={date} onChange={e => setDate(e.target.value)} max={new Date().toISOString().split('T')[0]}/>
77
+ <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">
78
+ 一键全勤
79
+ </button>
80
+ </div>
81
+ </div>
82
+
83
+ <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
84
+ <div className="bg-green-50 p-4 rounded-lg border border-green-100 flex flex-col items-center">
85
+ <span className="text-green-600 font-bold text-2xl">{stats.present}</span>
86
+ <span className="text-xs text-green-700">出勤</span>
87
+ </div>
88
+ <div className="bg-orange-50 p-4 rounded-lg border border-orange-100 flex flex-col items-center">
89
+ <span className="text-orange-500 font-bold text-2xl">{stats.leave}</span>
90
+ <span className="text-xs text-orange-700">请假</span>
91
+ </div>
92
+ <div className="bg-red-50 p-4 rounded-lg border border-red-100 flex flex-col items-center">
93
+ <span className="text-red-500 font-bold text-2xl">{stats.absent}</span>
94
+ <span className="text-xs text-red-700">缺勤</span>
95
+ </div>
96
+ <div className="bg-gray-50 p-4 rounded-lg border border-gray-200 flex flex-col items-center">
97
+ <span className="text-gray-500 font-bold text-2xl">{missing}</span>
98
+ <span className="text-xs text-gray-600">未记录</span>
99
+ </div>
100
+ </div>
101
+
102
+ {loading ? <div className="p-10 flex justify-center"><Loader2 className="animate-spin text-blue-600"/></div> : (
103
+ <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
104
+ {students.map(s => {
105
+ const record = attendanceMap[s._id || String(s.id)];
106
+ const status = record?.status || 'None';
107
+
108
+ let bg = 'bg-white border-gray-200';
109
+ let icon = <Clock size={20} className="text-gray-300"/>;
110
+ let text = '未打卡';
111
+ let textColor = 'text-gray-400';
112
+
113
+ if (status === 'Present') {
114
+ bg = 'bg-green-50 border-green-200 ring-1 ring-green-200';
115
+ icon = <CheckCircle size={20} className="text-green-600"/>;
116
+ text = '出勤';
117
+ textColor = 'text-green-700';
118
+ } else if (status === 'Leave') {
119
+ bg = 'bg-orange-50 border-orange-200 ring-1 ring-orange-200';
120
+ icon = <AlertCircle size={20} className="text-orange-500"/>;
121
+ text = '请假';
122
+ textColor = 'text-orange-700';
123
+ } else if (status === 'Absent') {
124
+ bg = 'bg-red-50 border-red-200 ring-1 ring-red-200';
125
+ icon = <UserX size={20} className="text-red-500"/>;
126
+ text = '缺勤';
127
+ textColor = 'text-red-700';
128
+ }
129
+
130
+ return (
131
+ <div
132
+ key={s._id}
133
+ onClick={() => toggleStatus(s._id || String(s.id))}
134
+ 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}`}
135
+ >
136
+ <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'}`}>
137
+ {s.name[0]}
138
+ </div>
139
+ <div className="text-center">
140
+ <p className="font-bold text-gray-800 text-sm">{s.name}</p>
141
+ <p className="text-[10px] text-gray-400">{s.studentNo}</p>
142
+ </div>
143
+ <div className={`flex items-center gap-1 text-xs font-bold ${textColor}`}>
144
+ {icon} {text}
145
+ </div>
146
+ <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">
147
+ 点击切换状态
148
+ </div>
149
+ </div>
150
+ );
151
+ })}
152
+ </div>
153
+ )}
154
+ </div>
155
+ );
156
+ };
pages/GameLucky.tsx CHANGED
@@ -38,7 +38,7 @@ export const GameLucky: React.FC = () => {
38
  const [isSettingsOpen, setIsSettingsOpen] = useState(false);
39
 
40
  // Game State
41
- const [drawResult, setDrawResult] = useState<{prize: string} | null>(null);
42
  const [activeCardIndex, setActiveCardIndex] = useState<number | null>(null);
43
  const [isFlipping, setIsFlipping] = useState(false);
44
 
@@ -94,8 +94,12 @@ export const GameLucky: React.FC = () => {
94
  setDrawResult(res);
95
  setStudentInfo(prev => prev ? ({ ...prev, drawAttempts: (prev.drawAttempts || 0) - 1 }) : null);
96
 
 
97
  setTimeout(() => {
98
- alert(`🎁 恭喜!抽中了:${res.prize}`);
 
 
 
99
  setIsFlipping(false);
100
  setDrawResult(null);
101
  setActiveCardIndex(null);
@@ -276,4 +280,4 @@ export const GameLucky: React.FC = () => {
276
  )}
277
  </div>
278
  );
279
- };
 
38
  const [isSettingsOpen, setIsSettingsOpen] = useState(false);
39
 
40
  // Game State
41
+ const [drawResult, setDrawResult] = useState<{prize: string, rewardType?: string} | null>(null);
42
  const [activeCardIndex, setActiveCardIndex] = useState<number | null>(null);
43
  const [isFlipping, setIsFlipping] = useState(false);
44
 
 
94
  setDrawResult(res);
95
  setStudentInfo(prev => prev ? ({ ...prev, drawAttempts: (prev.drawAttempts || 0) - 1 }) : null);
96
 
97
+ // Wait for flip animation
98
  setTimeout(() => {
99
+ // ONLY alert if it's NOT a consolation prize
100
+ if (res.rewardType !== 'CONSOLATION') {
101
+ alert(`🎁 恭喜!抽中了:${res.prize}`);
102
+ }
103
  setIsFlipping(false);
104
  setDrawResult(null);
105
  setActiveCardIndex(null);
 
280
  )}
281
  </div>
282
  );
283
+ };
pages/GameRewards.tsx CHANGED
@@ -194,17 +194,17 @@ export const GameRewards: React.FC = () => {
194
  <td className="p-4">
195
  <span className={`text-xs px-2 py-1 rounded border ${
196
  r.rewardType === 'DRAW_COUNT' ? 'bg-purple-50 text-purple-700 border-purple-100' :
197
- r.rewardType === 'CONSOLATION' ? 'bg-gray-50 text-gray-500 border-gray-100' :
198
  'bg-blue-50 text-blue-700 border-blue-100'
199
  }`}>
200
- {r.rewardType==='DRAW_COUNT' ? '抽奖券' : r.rewardType==='CONSOLATION' ? '安慰奖' : '实物'}
201
  </span>
202
  </td>
203
  <td className="p-4 text-gray-500 text-xs">{r.source}</td>
204
  <td className="p-4 text-gray-500 text-xs">{new Date(r.createTime).toLocaleDateString()}</td>
205
  <td className="p-4">
206
  {r.rewardType === 'DRAW_COUNT' || r.rewardType === 'CONSOLATION' ? (
207
- <span className="text-xs bg-gray-100 text-gray-500 px-2 py-1 rounded">无需核销</span>
208
  ) : (
209
  r.status === 'REDEEMED'
210
  ? <span className="text-xs bg-green-100 text-green-700 px-2 py-1 rounded border border-green-200">已兑换</span>
@@ -284,4 +284,4 @@ export const GameRewards: React.FC = () => {
284
  )}
285
  </div>
286
  );
287
- };
 
194
  <td className="p-4">
195
  <span className={`text-xs px-2 py-1 rounded border ${
196
  r.rewardType === 'DRAW_COUNT' ? 'bg-purple-50 text-purple-700 border-purple-100' :
197
+ r.rewardType === 'CONSOLATION' ? 'bg-gray-100 text-gray-500 border-gray-200' :
198
  'bg-blue-50 text-blue-700 border-blue-100'
199
  }`}>
200
+ {r.rewardType==='DRAW_COUNT' ? '抽奖券' : r.rewardType==='CONSOLATION' ? '未中奖' : '实物'}
201
  </span>
202
  </td>
203
  <td className="p-4 text-gray-500 text-xs">{r.source}</td>
204
  <td className="p-4 text-gray-500 text-xs">{new Date(r.createTime).toLocaleDateString()}</td>
205
  <td className="p-4">
206
  {r.rewardType === 'DRAW_COUNT' || r.rewardType === 'CONSOLATION' ? (
207
+ <span className="text-xs text-gray-400">-</span>
208
  ) : (
209
  r.status === 'REDEEMED'
210
  ? <span className="text-xs bg-green-100 text-green-700 px-2 py-1 rounded border border-green-200">已兑换</span>
 
284
  )}
285
  </div>
286
  );
287
+ };
pages/StudentDashboard.tsx CHANGED
@@ -1,14 +1,19 @@
1
 
2
  import React, { useEffect, useState } from 'react';
3
  import { api } from '../services/api';
4
- import { Schedule, Student } from '../types';
5
- import { Calendar, CheckCircle, Clock, Coffee, FileText, MapPin } from 'lucide-react';
6
 
7
  export const StudentDashboard: React.FC = () => {
8
  const [student, setStudent] = useState<Student | null>(null);
9
  const [schedules, setSchedules] = useState<Schedule[]>([]);
10
- const [checkedIn, setCheckedIn] = useState(false);
11
  const [currentTime, setCurrentTime] = useState(new Date());
 
 
 
 
 
12
 
13
  const currentUser = api.auth.getCurrentUser();
14
 
@@ -26,13 +31,45 @@ export const StudentDashboard: React.FC = () => {
26
  setStudent(me);
27
  const sched = await api.schedules.get({ className: me.className });
28
  setSchedules(sched);
 
 
 
 
 
29
  }
30
  } catch (e) { console.error(e); }
31
  };
32
 
33
- const handleCheckIn = () => {
34
- setCheckedIn(true);
35
- alert('打卡成功!已记录考勤。');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
  };
37
 
38
  const today = new Date().getDay(); // 0-6
@@ -48,18 +85,29 @@ export const StudentDashboard: React.FC = () => {
48
 
49
  <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
50
  {/* Attendance Card */}
51
- <div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100 flex flex-col items-center justify-center text-center">
52
- <div className="mb-4 bg-blue-50 p-4 rounded-full">
53
- <MapPin size={32} className="text-blue-600" />
54
  </div>
55
- <h3 className="text-lg font-bold text-gray-800 mb-2">每日考勤</h3>
56
- <p className="text-sm text-gray-500 mb-6">记录你的在校状态</p>
 
 
 
 
 
 
 
 
 
 
 
57
  <button
58
  onClick={handleCheckIn}
59
- disabled={checkedIn}
60
- className={`w-full py-3 rounded-xl font-bold transition-all ${checkedIn ? 'bg-green-100 text-green-700 cursor-default' : 'bg-blue-600 text-white hover:bg-blue-700 shadow-md'}`}
61
  >
62
- {checkedIn ? '已签到' : '立即打卡'}
63
  </button>
64
  </div>
65
 
@@ -69,7 +117,7 @@ export const StudentDashboard: React.FC = () => {
69
  <h3 className="font-bold text-gray-800 flex items-center"><Calendar className="mr-2 text-indigo-600"/> 今日课表</h3>
70
  <span className="text-xs bg-indigo-50 text-indigo-600 px-2 py-1 rounded-full">{todaySchedules.length} 节课</span>
71
  </div>
72
- <div className="space-y-3">
73
  {todaySchedules.length > 0 ? todaySchedules.map(s => (
74
  <div key={s._id} className="flex items-center p-3 bg-gray-50 rounded-lg border border-gray-100">
75
  <div className="w-12 text-center font-bold text-gray-400 text-sm">第{s.period}节</div>
@@ -78,7 +126,6 @@ export const StudentDashboard: React.FC = () => {
78
  <div className="font-bold text-gray-800">{s.subject}</div>
79
  <div className="text-xs text-gray-500">{s.teacherName}</div>
80
  </div>
81
- {/* Highlight if current time matches period (Mock logic) */}
82
  <Clock size={16} className="text-gray-300"/>
83
  </div>
84
  )) : (
@@ -95,13 +142,39 @@ export const StudentDashboard: React.FC = () => {
95
  <div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100">
96
  <h3 className="font-bold text-gray-800 mb-4">常用功能</h3>
97
  <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
98
- <button className="p-4 bg-orange-50 rounded-xl text-orange-700 flex flex-col items-center hover:bg-orange-100 transition-colors">
99
  <FileText className="mb-2"/>
100
  <span className="text-sm font-medium">请假申请</span>
101
  </button>
102
- {/* Add more student actions here */}
103
  </div>
104
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
  </div>
106
  );
107
- };
 
1
 
2
  import React, { useEffect, useState } from 'react';
3
  import { api } from '../services/api';
4
+ import { Schedule, Student, Attendance } from '../types';
5
+ import { Calendar, CheckCircle, Clock, Coffee, FileText, MapPin, X } from 'lucide-react';
6
 
7
  export const StudentDashboard: React.FC = () => {
8
  const [student, setStudent] = useState<Student | null>(null);
9
  const [schedules, setSchedules] = useState<Schedule[]>([]);
10
+ const [todayAttendance, setTodayAttendance] = useState<Attendance | null>(null);
11
  const [currentTime, setCurrentTime] = useState(new Date());
12
+
13
+ // Leave Modal
14
+ const [isLeaveOpen, setIsLeaveOpen] = useState(false);
15
+ const [leaveReason, setLeaveReason] = useState('');
16
+ const [leaveDates, setLeaveDates] = useState({ start: '', end: '' });
17
 
18
  const currentUser = api.auth.getCurrentUser();
19
 
 
31
  setStudent(me);
32
  const sched = await api.schedules.get({ className: me.className });
33
  setSchedules(sched);
34
+
35
+ // Check today's attendance
36
+ const todayStr = new Date().toISOString().split('T')[0];
37
+ const att = await api.attendance.get({ studentId: me._id || String(me.id), date: todayStr });
38
+ if (att && att.length > 0) setTodayAttendance(att[0]);
39
  }
40
  } catch (e) { console.error(e); }
41
  };
42
 
43
+ const handleCheckIn = async () => {
44
+ if(!student) return;
45
+ try {
46
+ const todayStr = new Date().toISOString().split('T')[0];
47
+ await api.attendance.checkIn({
48
+ studentId: student._id || String(student.id),
49
+ date: todayStr
50
+ });
51
+ alert('打卡成功!已记录考勤。');
52
+ loadData();
53
+ } catch(e: any) {
54
+ alert('打卡失败: ' + (e.message || '未知错误'));
55
+ }
56
+ };
57
+
58
+ const handleSubmitLeave = async () => {
59
+ if(!student || !leaveReason || !leaveDates.start) return alert('请填写完整');
60
+ try {
61
+ await api.attendance.applyLeave({
62
+ studentId: student._id || String(student.id),
63
+ studentName: student.name,
64
+ className: student.className,
65
+ reason: leaveReason,
66
+ startDate: leaveDates.start,
67
+ endDate: leaveDates.end || leaveDates.start
68
+ });
69
+ alert('请假申请已提交');
70
+ setIsLeaveOpen(false);
71
+ loadData(); // Update status if leave is for today
72
+ } catch(e) { alert('提交失败'); }
73
  };
74
 
75
  const today = new Date().getDay(); // 0-6
 
85
 
86
  <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
87
  {/* Attendance Card */}
88
+ <div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100 flex flex-col items-center justify-center text-center relative overflow-hidden">
89
+ <div className="mb-4 bg-blue-50 p-4 rounded-full z-10">
90
+ {todayAttendance?.status === 'Leave' ? <FileText size={32} className="text-orange-500"/> : <MapPin size={32} className="text-blue-600" />}
91
  </div>
92
+ <h3 className="text-lg font-bold text-gray-800 mb-2 z-10">每日考勤</h3>
93
+
94
+ {todayAttendance ? (
95
+ <div className="mb-6 z-10">
96
+ <p className={`text-lg font-bold ${todayAttendance.status==='Present'?'text-green-600':todayAttendance.status==='Leave'?'text-orange-500':'text-red-500'}`}>
97
+ {todayAttendance.status==='Present'?'已签到':todayAttendance.status==='Leave'?'请假中':'缺勤'}
98
+ </p>
99
+ <p className="text-xs text-gray-400">{new Date(todayAttendance.checkInTime || new Date()).toLocaleTimeString()}</p>
100
+ </div>
101
+ ) : (
102
+ <p className="text-sm text-gray-500 mb-6 z-10">记录你的在校状态</p>
103
+ )}
104
+
105
  <button
106
  onClick={handleCheckIn}
107
+ disabled={!!todayAttendance}
108
+ className={`w-full py-3 rounded-xl font-bold transition-all z-10 ${todayAttendance ? 'bg-gray-100 text-gray-400 cursor-not-allowed' : 'bg-blue-600 text-white hover:bg-blue-700 shadow-md'}`}
109
  >
110
+ {todayAttendance ? '今日已记录' : '立即打卡'}
111
  </button>
112
  </div>
113
 
 
117
  <h3 className="font-bold text-gray-800 flex items-center"><Calendar className="mr-2 text-indigo-600"/> 今日课表</h3>
118
  <span className="text-xs bg-indigo-50 text-indigo-600 px-2 py-1 rounded-full">{todaySchedules.length} 节课</span>
119
  </div>
120
+ <div className="space-y-3 max-h-64 overflow-y-auto custom-scrollbar">
121
  {todaySchedules.length > 0 ? todaySchedules.map(s => (
122
  <div key={s._id} className="flex items-center p-3 bg-gray-50 rounded-lg border border-gray-100">
123
  <div className="w-12 text-center font-bold text-gray-400 text-sm">第{s.period}节</div>
 
126
  <div className="font-bold text-gray-800">{s.subject}</div>
127
  <div className="text-xs text-gray-500">{s.teacherName}</div>
128
  </div>
 
129
  <Clock size={16} className="text-gray-300"/>
130
  </div>
131
  )) : (
 
142
  <div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100">
143
  <h3 className="font-bold text-gray-800 mb-4">常用功能</h3>
144
  <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
145
+ <button onClick={() => setIsLeaveOpen(true)} className="p-4 bg-orange-50 rounded-xl text-orange-700 flex flex-col items-center hover:bg-orange-100 transition-colors">
146
  <FileText className="mb-2"/>
147
  <span className="text-sm font-medium">请假申请</span>
148
  </button>
 
149
  </div>
150
  </div>
151
+
152
+ {/* Leave Modal */}
153
+ {isLeaveOpen && (
154
+ <div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
155
+ <div className="bg-white rounded-xl p-6 w-full max-w-sm animate-in zoom-in-95">
156
+ <div className="flex justify-between items-center mb-4">
157
+ <h3 className="font-bold text-lg">请假申请</h3>
158
+ <button onClick={()=>setIsLeaveOpen(false)}><X size={20} className="text-gray-400"/></button>
159
+ </div>
160
+ <div className="space-y-3">
161
+ <div>
162
+ <label className="text-xs text-gray-500 block mb-1">开始日期</label>
163
+ <input type="date" className="w-full border rounded p-2 text-sm" value={leaveDates.start} onChange={e=>setLeaveDates({...leaveDates, start:e.target.value})}/>
164
+ </div>
165
+ <div>
166
+ <label className="text-xs text-gray-500 block mb-1">结束日期 (可选)</label>
167
+ <input type="date" className="w-full border rounded p-2 text-sm" value={leaveDates.end} onChange={e=>setLeaveDates({...leaveDates, end:e.target.value})}/>
168
+ </div>
169
+ <div>
170
+ <label className="text-xs text-gray-500 block mb-1">请假事由</label>
171
+ <textarea className="w-full border rounded p-2 text-sm h-24" placeholder="请输入请假原因..." value={leaveReason} onChange={e=>setLeaveReason(e.target.value)}/>
172
+ </div>
173
+ <button onClick={handleSubmitLeave} className="w-full bg-orange-500 text-white py-2 rounded-lg font-bold hover:bg-orange-600 mt-2">提交申请</button>
174
+ </div>
175
+ </div>
176
+ </div>
177
+ )}
178
  </div>
179
  );
180
+ };
server.js CHANGED
@@ -32,6 +32,7 @@ const InMemoryDB = {
32
  rewards: [],
33
  luckyConfig: {},
34
  config: {},
 
35
  isFallback: false
36
  };
37
 
@@ -85,6 +86,12 @@ const StudentRewardModel = mongoose.model('StudentReward', StudentRewardSchema);
85
  const LuckyDrawConfigSchema = new mongoose.Schema({ schoolId: String, prizes: [{ id: String, name: String, probability: Number, count: Number, icon: String }], dailyLimit: Number, cardCount: Number, defaultPrize: String });
86
  const LuckyDrawConfigModel = mongoose.model('LuckyDrawConfig', LuckyDrawConfigSchema);
87
 
 
 
 
 
 
 
88
  // ... Helpers ...
89
  // IMPORTANT FIX: Allow fetching legacy data that doesn't have a schoolId yet
90
  const getQueryFilter = (req) => {
@@ -111,8 +118,6 @@ app.get('/api/games/lucky-config', async (req, res) => {
111
  app.post('/api/games/lucky-config', async (req, res) => {
112
  const data = injectSchoolId(req, req.body);
113
  if (InMemoryDB.isFallback) { InMemoryDB.luckyConfig = data; return res.json({}); }
114
- // Use updateOne with upsert to handle legacy data without schoolId more gracefully if needed,
115
- // but findOneAndUpdate is fine. We force schoolId on save.
116
  await LuckyDrawConfigModel.findOneAndUpdate({ schoolId: data.schoolId }, data, { upsert: true });
117
  res.json({ success: true });
118
  });
@@ -131,7 +136,6 @@ app.post('/api/games/lucky-draw', async (req, res) => {
131
  if (student.drawAttempts <= 0) return res.status(403).json({ error: '次数不足' });
132
 
133
  // 2. Get Config
134
- // Use permissive filter for reading config
135
  const filter = schoolId ? { $or: [{ schoolId }, { schoolId: { $exists: false } }] } : {};
136
  const config = await LuckyDrawConfigModel.findOne(filter);
137
  const prizes = config?.prizes || [];
@@ -188,7 +192,7 @@ app.post('/api/games/lucky-draw', async (req, res) => {
188
  source: '幸运大抽奖'
189
  });
190
 
191
- res.json({ prize: selectedPrize });
192
 
193
  } catch (e) {
194
  console.error(e);
@@ -196,6 +200,112 @@ app.post('/api/games/lucky-draw', async (req, res) => {
196
  }
197
  });
198
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
199
  // Standard Routes with permissive filter
200
  app.get('/api/notifications', async (req, res) => {
201
  const schoolId = req.headers['x-school-id'];
@@ -388,4 +498,4 @@ app.get('*', (req, res) => {
388
  res.sendFile(path.join(__dirname, 'dist', 'index.html'));
389
  });
390
 
391
- app.listen(PORT, () => console.log(`🚀 Server running on port ${PORT}`));
 
32
  rewards: [],
33
  luckyConfig: {},
34
  config: {},
35
+ attendance: [],
36
  isFallback: false
37
  };
38
 
 
86
  const LuckyDrawConfigSchema = new mongoose.Schema({ schoolId: String, prizes: [{ id: String, name: String, probability: Number, count: Number, icon: String }], dailyLimit: Number, cardCount: Number, defaultPrize: String });
87
  const LuckyDrawConfigModel = mongoose.model('LuckyDrawConfig', LuckyDrawConfigSchema);
88
 
89
+ // Attendance Schemas
90
+ const AttendanceSchema = new mongoose.Schema({ schoolId: String, studentId: String, studentName: String, className: String, date: String, status: String, checkInTime: Date });
91
+ const AttendanceModel = mongoose.model('Attendance', AttendanceSchema);
92
+ const LeaveRequestSchema = new mongoose.Schema({ schoolId: String, studentId: String, studentName: String, className: String, reason: String, startDate: String, endDate: String, status: { type: String, default: 'Pending' }, createTime: { type: Date, default: Date.now } });
93
+ const LeaveRequestModel = mongoose.model('LeaveRequest', LeaveRequestSchema);
94
+
95
  // ... Helpers ...
96
  // IMPORTANT FIX: Allow fetching legacy data that doesn't have a schoolId yet
97
  const getQueryFilter = (req) => {
 
118
  app.post('/api/games/lucky-config', async (req, res) => {
119
  const data = injectSchoolId(req, req.body);
120
  if (InMemoryDB.isFallback) { InMemoryDB.luckyConfig = data; return res.json({}); }
 
 
121
  await LuckyDrawConfigModel.findOneAndUpdate({ schoolId: data.schoolId }, data, { upsert: true });
122
  res.json({ success: true });
123
  });
 
136
  if (student.drawAttempts <= 0) return res.status(403).json({ error: '次数不足' });
137
 
138
  // 2. Get Config
 
139
  const filter = schoolId ? { $or: [{ schoolId }, { schoolId: { $exists: false } }] } : {};
140
  const config = await LuckyDrawConfigModel.findOne(filter);
141
  const prizes = config?.prizes || [];
 
192
  source: '幸运大抽奖'
193
  });
194
 
195
+ res.json({ prize: selectedPrize, rewardType });
196
 
197
  } catch (e) {
198
  console.error(e);
 
200
  }
201
  });
202
 
203
+ // --- ATTENDANCE ROUTES ---
204
+
205
+ app.get('/api/attendance', async (req, res) => {
206
+ const { className, date, studentId } = req.query;
207
+ const filter = getQueryFilter(req);
208
+ if(className) filter.className = className;
209
+ if(date) filter.date = date;
210
+ if(studentId) filter.studentId = studentId;
211
+ res.json(await AttendanceModel.find(filter));
212
+ });
213
+
214
+ app.post('/api/attendance/check-in', async (req, res) => {
215
+ const { studentId, date, status } = req.body;
216
+ const sId = req.headers['x-school-id'];
217
+
218
+ // Check existing
219
+ const exists = await AttendanceModel.findOne({ studentId, date });
220
+ if (exists) return res.status(400).json({ error: 'ALREADY_CHECKED_IN', message: '今日已打卡' });
221
+
222
+ const student = await Student.findById(studentId);
223
+ if (!student) return res.status(404).json({error: 'Student not found'});
224
+
225
+ await AttendanceModel.create({
226
+ schoolId: sId,
227
+ studentId,
228
+ studentName: student.name,
229
+ className: student.className,
230
+ date,
231
+ status: status || 'Present',
232
+ checkInTime: new Date()
233
+ });
234
+ res.json({ success: true });
235
+ });
236
+
237
+ app.post('/api/attendance/batch', async (req, res) => {
238
+ const { className, date, status } = req.body;
239
+ const sId = req.headers['x-school-id'];
240
+
241
+ const students = await Student.find({ className, ...getQueryFilter(req) });
242
+ const ops = students.map(s => ({
243
+ updateOne: {
244
+ filter: { studentId: s._id, date },
245
+ update: {
246
+ $setOnInsert: {
247
+ schoolId: sId,
248
+ studentId: s._id,
249
+ studentName: s.name,
250
+ className: s.className,
251
+ date,
252
+ status: status || 'Present',
253
+ checkInTime: new Date()
254
+ }
255
+ },
256
+ upsert: true
257
+ }
258
+ }));
259
+
260
+ if (ops.length > 0) await AttendanceModel.bulkWrite(ops);
261
+ res.json({ success: true, count: ops.length });
262
+ });
263
+
264
+ app.put('/api/attendance/update', async (req, res) => {
265
+ const { studentId, date, status } = req.body;
266
+ const sId = req.headers['x-school-id'];
267
+ const student = await Student.findById(studentId);
268
+
269
+ await AttendanceModel.findOneAndUpdate(
270
+ { studentId, date },
271
+ {
272
+ schoolId: sId,
273
+ studentId,
274
+ studentName: student.name,
275
+ className: student.className,
276
+ date,
277
+ status,
278
+ checkInTime: new Date()
279
+ },
280
+ { upsert: true }
281
+ );
282
+ res.json({ success: true });
283
+ });
284
+
285
+ app.post('/api/leave', async (req, res) => {
286
+ await LeaveRequestModel.create(injectSchoolId(req, req.body));
287
+ // Auto mark attendance as Leave for the start date
288
+ const { studentId, startDate } = req.body;
289
+ const student = await Student.findById(studentId);
290
+ if (student) {
291
+ await AttendanceModel.findOneAndUpdate(
292
+ { studentId, date: startDate },
293
+ {
294
+ schoolId: req.headers['x-school-id'],
295
+ studentId,
296
+ studentName: student.name,
297
+ className: student.className,
298
+ date: startDate,
299
+ status: 'Leave',
300
+ checkInTime: new Date()
301
+ },
302
+ { upsert: true }
303
+ );
304
+ }
305
+ res.json({ success: true });
306
+ });
307
+
308
+
309
  // Standard Routes with permissive filter
310
  app.get('/api/notifications', async (req, res) => {
311
  const schoolId = req.headers['x-school-id'];
 
498
  res.sendFile(path.join(__dirname, 'dist', 'index.html'));
499
  });
500
 
501
+ app.listen(PORT, () => console.log(`🚀 Server running on port ${PORT}`));
services/api.ts CHANGED
@@ -1,5 +1,5 @@
1
 
2
- import { User, ClassInfo, SystemConfig, Subject, School, Schedule, GameSession, StudentReward, LuckyDrawConfig } from '../types';
3
 
4
  const getBaseUrl = () => {
5
  let isProd = false;
@@ -149,6 +149,17 @@ export const api = {
149
  }
150
  },
151
 
 
 
 
 
 
 
 
 
 
 
 
152
  stats: {
153
  getSummary: () => request('/stats')
154
  },
@@ -184,4 +195,4 @@ export const api = {
184
  batchDelete: (type: 'student' | 'score' | 'user', ids: string[]) => {
185
  return request('/batch-delete', { method: 'POST', body: JSON.stringify({ type, ids }) });
186
  }
187
- };
 
1
 
2
+ import { User, ClassInfo, SystemConfig, Subject, School, Schedule, GameSession, StudentReward, LuckyDrawConfig, Attendance, LeaveRequest } from '../types';
3
 
4
  const getBaseUrl = () => {
5
  let isProd = false;
 
149
  }
150
  },
151
 
152
+ attendance: {
153
+ checkIn: (data: { studentId: string, date: string, status?: string }) => request('/attendance/check-in', { method: 'POST', body: JSON.stringify(data) }),
154
+ get: (params: { className?: string, date?: string, studentId?: string }) => {
155
+ const qs = new URLSearchParams(params as any).toString();
156
+ return request(`/attendance?${qs}`);
157
+ },
158
+ batch: (data: { className: string, date: string, status?: string }) => request('/attendance/batch', { method: 'POST', body: JSON.stringify(data) }),
159
+ update: (data: { studentId: string, date: string, status: string }) => request('/attendance/update', { method: 'PUT', body: JSON.stringify(data) }),
160
+ applyLeave: (data: { studentId: string, studentName: string, className: string, reason: string, startDate: string, endDate: string }) => request('/leave', { method: 'POST', body: JSON.stringify(data) }),
161
+ },
162
+
163
  stats: {
164
  getSummary: () => request('/stats')
165
  },
 
195
  batchDelete: (type: 'student' | 'score' | 'user', ids: string[]) => {
196
  return request('/batch-delete', { method: 'POST', body: JSON.stringify({ type, ids }) });
197
  }
198
+ };
types.ts CHANGED
@@ -218,3 +218,30 @@ export interface LuckyDrawConfig {
218
  cardCount?: number; // Number of cards displayed (e.g., 9, 12)
219
  defaultPrize: string; // "再接再厉"
220
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
218
  cardCount?: number; // Number of cards displayed (e.g., 9, 12)
219
  defaultPrize: string; // "再接再厉"
220
  }
221
+
222
+ // --- Attendance Types ---
223
+ export type AttendanceStatus = 'Present' | 'Absent' | 'Leave';
224
+
225
+ export interface Attendance {
226
+ _id?: string;
227
+ schoolId: string;
228
+ studentId: string;
229
+ studentName: string;
230
+ className: string;
231
+ date: string; // YYYY-MM-DD
232
+ status: AttendanceStatus;
233
+ checkInTime?: string;
234
+ }
235
+
236
+ export interface LeaveRequest {
237
+ _id?: string;
238
+ schoolId: string;
239
+ studentId: string;
240
+ studentName: string;
241
+ className: string;
242
+ reason: string;
243
+ startDate: string;
244
+ endDate: string;
245
+ status: 'Pending' | 'Approved' | 'Rejected';
246
+ createTime: string;
247
+ }