Spaces:
Sleeping
Sleeping
Upload 35 files
Browse files- pages/GameRewards.tsx +50 -25
- pages/StudentReports.tsx +53 -3
- pages/TeacherReports.tsx +41 -6
- server.js +45 -7
- services/api.ts +9 -3
- types.ts +9 -2
pages/GameRewards.tsx
CHANGED
|
@@ -2,11 +2,12 @@
|
|
| 2 |
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 } from 'lucide-react';
|
| 6 |
|
| 7 |
export const GameRewards: React.FC = () => {
|
| 8 |
-
const [
|
| 9 |
-
const [
|
|
|
|
| 10 |
const [loading, setLoading] = useState(true);
|
| 11 |
|
| 12 |
// Grant Modal
|
|
@@ -32,43 +33,52 @@ export const GameRewards: React.FC = () => {
|
|
| 32 |
const isStudent = currentUser?.role === 'STUDENT';
|
| 33 |
const isTeacher = currentUser?.role === 'TEACHER';
|
| 34 |
|
|
|
|
|
|
|
| 35 |
const loadData = async () => {
|
| 36 |
setLoading(true);
|
| 37 |
try {
|
| 38 |
if (isStudent) {
|
|
|
|
| 39 |
const stus = await api.students.getAll();
|
| 40 |
const me = stus.find((s: Student) => s.name === (currentUser.trueName || currentUser.username));
|
| 41 |
if (me) {
|
| 42 |
-
const
|
| 43 |
-
|
|
|
|
| 44 |
}
|
| 45 |
} else {
|
| 46 |
-
|
| 47 |
-
|
|
|
|
| 48 |
api.students.getAll()
|
| 49 |
]);
|
| 50 |
|
| 51 |
-
|
| 52 |
-
|
|
|
|
| 53 |
let filteredStudents = allStus;
|
| 54 |
-
|
| 55 |
if (isTeacher && currentUser.homeroomClass) {
|
| 56 |
filteredStudents = allStus.filter((s: Student) => s.className === currentUser.homeroomClass);
|
| 57 |
const studentIds = filteredStudents.map((s: Student) => s._id || String(s.id));
|
| 58 |
-
|
|
|
|
|
|
|
|
|
|
| 59 |
}
|
| 60 |
|
| 61 |
-
// Hide Consolation
|
| 62 |
-
|
| 63 |
|
| 64 |
-
|
|
|
|
| 65 |
setStudents(filteredStudents);
|
| 66 |
}
|
| 67 |
} catch (e) { console.error(e); }
|
| 68 |
finally { setLoading(false); }
|
| 69 |
};
|
| 70 |
|
| 71 |
-
useEffect(() => { loadData(); }, []);
|
| 72 |
|
| 73 |
const handleGrant = async () => {
|
| 74 |
if(!grantForm.studentId) return alert('请选择学生');
|
|
@@ -78,7 +88,6 @@ export const GameRewards: React.FC = () => {
|
|
| 78 |
await api.games.grantReward(grantForm);
|
| 79 |
setIsGrantModalOpen(false);
|
| 80 |
alert('发放成功');
|
| 81 |
-
// Reset form defaults
|
| 82 |
setGrantForm({ studentId: '', count: 1, rewardType: 'DRAW_COUNT', name: '' });
|
| 83 |
loadData();
|
| 84 |
} catch(e) { alert('发放失败'); }
|
|
@@ -112,8 +121,9 @@ export const GameRewards: React.FC = () => {
|
|
| 112 |
setEditForm({ name: r.name, count: r.count || 1 });
|
| 113 |
};
|
| 114 |
|
| 115 |
-
//
|
| 116 |
-
|
|
|
|
| 117 |
if (filterType !== 'ALL' && r.rewardType !== filterType) return false;
|
| 118 |
if (filterStatus !== 'ALL' && r.status !== filterStatus) return false;
|
| 119 |
if (searchText) {
|
|
@@ -123,11 +133,13 @@ export const GameRewards: React.FC = () => {
|
|
| 123 |
return true;
|
| 124 |
});
|
| 125 |
|
| 126 |
-
|
|
|
|
|
|
|
| 127 |
|
| 128 |
return (
|
| 129 |
-
<div className="flex
|
| 130 |
-
<div className="p-6 border-b border-gray-100 flex flex-col md:flex-row justify-between items-start md:items-center gap-4 shrink-0">
|
| 131 |
<h3 className="text-xl font-bold text-gray-800">
|
| 132 |
{isStudent ? '我的战利品清单' : '班级奖励核销台'}
|
| 133 |
</h3>
|
|
@@ -161,7 +173,8 @@ export const GameRewards: React.FC = () => {
|
|
| 161 |
</div>
|
| 162 |
</div>
|
| 163 |
|
| 164 |
-
|
|
|
|
| 165 |
<table className="w-full text-left border-collapse">
|
| 166 |
<thead className="bg-gray-50 text-gray-500 text-xs uppercase sticky top-0 z-10 shadow-sm">
|
| 167 |
<tr>
|
|
@@ -203,8 +216,10 @@ export const GameRewards: React.FC = () => {
|
|
| 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'
|
| 207 |
-
<span className="text-xs text-
|
|
|
|
|
|
|
| 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>
|
|
@@ -241,6 +256,16 @@ export const GameRewards: React.FC = () => {
|
|
| 241 |
</table>
|
| 242 |
</div>
|
| 243 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 244 |
{/* Grant Modal */}
|
| 245 |
{isGrantModalOpen && (
|
| 246 |
<div className="fixed inset-0 bg-black/50 z-[100] flex items-center justify-center p-4">
|
|
@@ -284,4 +309,4 @@ export const GameRewards: React.FC = () => {
|
|
| 284 |
)}
|
| 285 |
</div>
|
| 286 |
);
|
| 287 |
-
};
|
|
|
|
| 2 |
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 = () => {
|
| 8 |
+
const [rewards, setRewards] = useState<StudentReward[]>([]);
|
| 9 |
+
const [total, setTotal] = useState(0);
|
| 10 |
+
const [page, setPage] = useState(1);
|
| 11 |
const [loading, setLoading] = useState(true);
|
| 12 |
|
| 13 |
// Grant Modal
|
|
|
|
| 33 |
const isStudent = currentUser?.role === 'STUDENT';
|
| 34 |
const isTeacher = currentUser?.role === 'TEACHER';
|
| 35 |
|
| 36 |
+
const PAGE_SIZE = 15;
|
| 37 |
+
|
| 38 |
const loadData = async () => {
|
| 39 |
setLoading(true);
|
| 40 |
try {
|
| 41 |
if (isStudent) {
|
| 42 |
+
// Student View
|
| 43 |
const stus = await api.students.getAll();
|
| 44 |
const me = stus.find((s: Student) => s.name === (currentUser.trueName || currentUser.username));
|
| 45 |
if (me) {
|
| 46 |
+
const res = await api.rewards.getMyRewards(me._id || String(me.id), page, PAGE_SIZE);
|
| 47 |
+
setRewards(res.list || res); // Handle both old array and new object return
|
| 48 |
+
setTotal(res.total || (Array.isArray(res) ? res.length : 0));
|
| 49 |
}
|
| 50 |
} else {
|
| 51 |
+
// Teacher View
|
| 52 |
+
const [res, allStus] = await Promise.all([
|
| 53 |
+
api.rewards.getClassRewards(page, PAGE_SIZE), // Fetch paginated
|
| 54 |
api.students.getAll()
|
| 55 |
]);
|
| 56 |
|
| 57 |
+
let list = res.list || res;
|
| 58 |
+
|
| 59 |
+
// Client-side filtering for class (simplified for this demo, ideal would be server-side filter)
|
| 60 |
let filteredStudents = allStus;
|
|
|
|
| 61 |
if (isTeacher && currentUser.homeroomClass) {
|
| 62 |
filteredStudents = allStus.filter((s: Student) => s.className === currentUser.homeroomClass);
|
| 63 |
const studentIds = filteredStudents.map((s: Student) => s._id || String(s.id));
|
| 64 |
+
// Note: If using real pagination, this filter should be on backend.
|
| 65 |
+
// Here we assume the backend returns class-scoped rewards if the token allows,
|
| 66 |
+
// or we filter what we got.
|
| 67 |
+
list = list.filter((r: StudentReward) => studentIds.includes(r.studentId));
|
| 68 |
}
|
| 69 |
|
| 70 |
+
// Hide Consolation
|
| 71 |
+
list = list.filter((r: StudentReward) => r.rewardType !== 'CONSOLATION');
|
| 72 |
|
| 73 |
+
setRewards(list);
|
| 74 |
+
setTotal(res.total || 0); // Note: Total might be inaccurate if we filter client-side, but okay for MVP
|
| 75 |
setStudents(filteredStudents);
|
| 76 |
}
|
| 77 |
} catch (e) { console.error(e); }
|
| 78 |
finally { setLoading(false); }
|
| 79 |
};
|
| 80 |
|
| 81 |
+
useEffect(() => { loadData(); }, [page]);
|
| 82 |
|
| 83 |
const handleGrant = async () => {
|
| 84 |
if(!grantForm.studentId) return alert('请选择学生');
|
|
|
|
| 88 |
await api.games.grantReward(grantForm);
|
| 89 |
setIsGrantModalOpen(false);
|
| 90 |
alert('发放成功');
|
|
|
|
| 91 |
setGrantForm({ studentId: '', count: 1, rewardType: 'DRAW_COUNT', name: '' });
|
| 92 |
loadData();
|
| 93 |
} catch(e) { alert('发放失败'); }
|
|
|
|
| 121 |
setEditForm({ name: r.name, count: r.count || 1 });
|
| 122 |
};
|
| 123 |
|
| 124 |
+
// Client-side Filtering for Type/Status/Search on the current page data
|
| 125 |
+
// (In a full prod app, these would be API params)
|
| 126 |
+
const displayRewards = rewards.filter(r => {
|
| 127 |
if (filterType !== 'ALL' && r.rewardType !== filterType) return false;
|
| 128 |
if (filterStatus !== 'ALL' && r.status !== filterStatus) return false;
|
| 129 |
if (searchText) {
|
|
|
|
| 133 |
return true;
|
| 134 |
});
|
| 135 |
|
| 136 |
+
const totalPages = Math.ceil(total / PAGE_SIZE);
|
| 137 |
+
|
| 138 |
+
if (loading && page === 1) return <div className="h-full flex items-center justify-center"><Loader2 className="animate-spin text-blue-600"/></div>;
|
| 139 |
|
| 140 |
return (
|
| 141 |
+
<div className="flex flex-col h-full bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden">
|
| 142 |
+
<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">
|
| 143 |
<h3 className="text-xl font-bold text-gray-800">
|
| 144 |
{isStudent ? '我的战利品清单' : '班级奖励核销台'}
|
| 145 |
</h3>
|
|
|
|
| 173 |
</div>
|
| 174 |
</div>
|
| 175 |
|
| 176 |
+
{/* Scrollable List Container */}
|
| 177 |
+
<div className="flex-1 overflow-y-auto p-0 min-h-0">
|
| 178 |
<table className="w-full text-left border-collapse">
|
| 179 |
<thead className="bg-gray-50 text-gray-500 text-xs uppercase sticky top-0 z-10 shadow-sm">
|
| 180 |
<tr>
|
|
|
|
| 216 |
<td className="p-4 text-gray-500 text-xs">{r.source}</td>
|
| 217 |
<td className="p-4 text-gray-500 text-xs">{new Date(r.createTime).toLocaleDateString()}</td>
|
| 218 |
<td className="p-4">
|
| 219 |
+
{r.rewardType === 'DRAW_COUNT' ? (
|
| 220 |
+
<span className="text-xs bg-purple-100 text-purple-700 px-2 py-1 rounded border border-purple-200">系统入账</span>
|
| 221 |
+
) : r.rewardType === 'CONSOLATION' ? (
|
| 222 |
+
<span className="text-xs text-gray-400">已结束</span>
|
| 223 |
) : (
|
| 224 |
r.status === 'REDEEMED'
|
| 225 |
? <span className="text-xs bg-green-100 text-green-700 px-2 py-1 rounded border border-green-200">已兑换</span>
|
|
|
|
| 256 |
</table>
|
| 257 |
</div>
|
| 258 |
|
| 259 |
+
{/* Pagination Footer */}
|
| 260 |
+
<div className="p-3 border-t border-gray-100 flex items-center justify-between shrink-0 bg-gray-50">
|
| 261 |
+
<span className="text-xs text-gray-500">共 {total} 条记录</span>
|
| 262 |
+
<div className="flex items-center gap-2">
|
| 263 |
+
<button onClick={()=>setPage(Math.max(1, page-1))} disabled={page===1} className="p-1 rounded hover:bg-gray-200 disabled:opacity-30"><ChevronLeft size={16}/></button>
|
| 264 |
+
<span className="text-xs font-medium text-gray-700">第 {page} / {Math.max(1, totalPages)} 页</span>
|
| 265 |
+
<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>
|
| 266 |
+
</div>
|
| 267 |
+
</div>
|
| 268 |
+
|
| 269 |
{/* Grant Modal */}
|
| 270 |
{isGrantModalOpen && (
|
| 271 |
<div className="fixed inset-0 bg-black/50 z-[100] flex items-center justify-center p-4">
|
|
|
|
| 309 |
)}
|
| 310 |
</div>
|
| 311 |
);
|
| 312 |
+
};
|
pages/StudentReports.tsx
CHANGED
|
@@ -2,11 +2,11 @@
|
|
| 2 |
import React, { useState, useEffect } from 'react';
|
| 3 |
import {
|
| 4 |
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
|
| 5 |
-
RadarChart, Radar, PolarGrid, PolarAngleAxis, PolarRadiusAxis
|
| 6 |
} from 'recharts';
|
| 7 |
import { api } from '../services/api';
|
| 8 |
-
import { Loader2, Lock } from 'lucide-react';
|
| 9 |
-
import { Score, Student, Subject, Exam } from '../types';
|
| 10 |
|
| 11 |
export const StudentReports: React.FC = () => {
|
| 12 |
const [loading, setLoading] = useState(true);
|
|
@@ -14,6 +14,7 @@ export const StudentReports: React.FC = () => {
|
|
| 14 |
const [scores, setScores] = useState<Score[]>([]);
|
| 15 |
const [subjects, setSubjects] = useState<Subject[]>([]);
|
| 16 |
const [exams, setExams] = useState<Exam[]>([]);
|
|
|
|
| 17 |
|
| 18 |
const currentUser = api.auth.getCurrentUser();
|
| 19 |
|
|
@@ -32,6 +33,11 @@ export const StudentReports: React.FC = () => {
|
|
| 32 |
setScores(scs);
|
| 33 |
setSubjects(subs);
|
| 34 |
setExams(exs);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
} catch (e) {
|
| 36 |
console.error(e);
|
| 37 |
} finally {
|
|
@@ -66,6 +72,17 @@ export const StudentReports: React.FC = () => {
|
|
| 66 |
});
|
| 67 |
};
|
| 68 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
if (loading) return <div className="flex justify-center py-20"><Loader2 className="animate-spin text-blue-600"/></div>;
|
| 70 |
if (!student) return <div className="text-center py-20 text-gray-500">暂无学生档案信息,请联系老师关联账号。</div>;
|
| 71 |
|
|
@@ -105,6 +122,39 @@ export const StudentReports: React.FC = () => {
|
|
| 105 |
</ResponsiveContainer>
|
| 106 |
</div>
|
| 107 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
</div>
|
| 109 |
</div>
|
| 110 |
);
|
|
|
|
| 2 |
import React, { useState, useEffect } from 'react';
|
| 3 |
import {
|
| 4 |
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
|
| 5 |
+
RadarChart, Radar, PolarGrid, PolarAngleAxis, PolarRadiusAxis, PieChart, Pie, Cell
|
| 6 |
} from 'recharts';
|
| 7 |
import { api } from '../services/api';
|
| 8 |
+
import { Loader2, Lock, Clock } from 'lucide-react';
|
| 9 |
+
import { Score, Student, Subject, Exam, Attendance } from '../types';
|
| 10 |
|
| 11 |
export const StudentReports: React.FC = () => {
|
| 12 |
const [loading, setLoading] = useState(true);
|
|
|
|
| 14 |
const [scores, setScores] = useState<Score[]>([]);
|
| 15 |
const [subjects, setSubjects] = useState<Subject[]>([]);
|
| 16 |
const [exams, setExams] = useState<Exam[]>([]);
|
| 17 |
+
const [attendance, setAttendance] = useState<Attendance[]>([]);
|
| 18 |
|
| 19 |
const currentUser = api.auth.getCurrentUser();
|
| 20 |
|
|
|
|
| 33 |
setScores(scs);
|
| 34 |
setSubjects(subs);
|
| 35 |
setExams(exs);
|
| 36 |
+
|
| 37 |
+
if (me) {
|
| 38 |
+
const att = await api.attendance.get({ studentId: me._id || String(me.id) });
|
| 39 |
+
setAttendance(att);
|
| 40 |
+
}
|
| 41 |
} catch (e) {
|
| 42 |
console.error(e);
|
| 43 |
} finally {
|
|
|
|
| 72 |
});
|
| 73 |
};
|
| 74 |
|
| 75 |
+
const getAttendanceStats = () => {
|
| 76 |
+
const present = attendance.filter(a => a.status === 'Present').length;
|
| 77 |
+
const leave = attendance.filter(a => a.status === 'Leave').length;
|
| 78 |
+
const absent = attendance.filter(a => a.status === 'Absent').length;
|
| 79 |
+
return [
|
| 80 |
+
{ name: '出勤', value: present, color: '#22c55e' },
|
| 81 |
+
{ name: '请假', value: leave, color: '#f97316' },
|
| 82 |
+
{ name: '缺勤', value: absent, color: '#ef4444' }
|
| 83 |
+
].filter(d => d.value > 0);
|
| 84 |
+
};
|
| 85 |
+
|
| 86 |
if (loading) return <div className="flex justify-center py-20"><Loader2 className="animate-spin text-blue-600"/></div>;
|
| 87 |
if (!student) return <div className="text-center py-20 text-gray-500">暂无学生档案信息,请联系老师关联账号。</div>;
|
| 88 |
|
|
|
|
| 122 |
</ResponsiveContainer>
|
| 123 |
</div>
|
| 124 |
</div>
|
| 125 |
+
|
| 126 |
+
{/* Attendance Chart */}
|
| 127 |
+
<div className="bg-white border border-gray-100 rounded-xl p-6 shadow-sm">
|
| 128 |
+
<h3 className="font-bold text-gray-800 mb-4 text-center flex items-center justify-center"><Clock size={18} className="mr-2 text-indigo-500"/>考勤统计</h3>
|
| 129 |
+
<div className="h-64 flex justify-center">
|
| 130 |
+
{attendance.length > 0 ? (
|
| 131 |
+
<ResponsiveContainer width="100%" height="100%">
|
| 132 |
+
<PieChart>
|
| 133 |
+
<Pie data={getAttendanceStats()} innerRadius={60} outerRadius={80} paddingAngle={5} dataKey="value">
|
| 134 |
+
{getAttendanceStats().map((entry, index) => (
|
| 135 |
+
<Cell key={`cell-${index}`} fill={entry.color} />
|
| 136 |
+
))}
|
| 137 |
+
</Pie>
|
| 138 |
+
<Tooltip />
|
| 139 |
+
<text x="50%" y="50%" textAnchor="middle" dominantBaseline="middle" className="text-2xl font-bold fill-gray-700">
|
| 140 |
+
{attendance.length}
|
| 141 |
+
</text>
|
| 142 |
+
<text x="50%" y="60%" textAnchor="middle" dominantBaseline="middle" className="text-xs fill-gray-400">
|
| 143 |
+
总记录
|
| 144 |
+
</text>
|
| 145 |
+
</PieChart>
|
| 146 |
+
</ResponsiveContainer>
|
| 147 |
+
) : <div className="flex items-center justify-center h-full text-gray-400 text-sm">暂无考勤记录</div>}
|
| 148 |
+
</div>
|
| 149 |
+
<div className="flex justify-center gap-4 mt-2">
|
| 150 |
+
{getAttendanceStats().map(d => (
|
| 151 |
+
<div key={d.name} className="flex items-center text-xs text-gray-600">
|
| 152 |
+
<span className="w-2 h-2 rounded-full mr-1" style={{backgroundColor: d.color}}></span>
|
| 153 |
+
{d.name} {d.value}
|
| 154 |
+
</div>
|
| 155 |
+
))}
|
| 156 |
+
</div>
|
| 157 |
+
</div>
|
| 158 |
</div>
|
| 159 |
</div>
|
| 160 |
);
|
pages/TeacherReports.tsx
CHANGED
|
@@ -2,11 +2,11 @@
|
|
| 2 |
import React, { useState, useEffect } from 'react';
|
| 3 |
import {
|
| 4 |
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer,
|
| 5 |
-
LineChart, Line, AreaChart, Area, RadarChart, Radar, PolarGrid, PolarAngleAxis, PolarRadiusAxis, ScatterChart, Scatter, ZAxis
|
| 6 |
} from 'recharts';
|
| 7 |
import { api } from '../services/api';
|
| 8 |
-
import { Loader2, Filter, TrendingUp, Grid, BarChart2, User, PieChart as PieChartIcon } from 'lucide-react';
|
| 9 |
-
import { Score, Student, ClassInfo, Subject, Exam } from '../types';
|
| 10 |
|
| 11 |
const localSortGrades = (a: string, b: string) => {
|
| 12 |
const order: Record<string, number> = { '一年级': 1, '二年级': 2, '三年级': 3, '四年级': 4, '五年级': 5, '六年级': 6, '初一': 7, '七年级': 7, '初二': 8, '八年级': 8, '初三': 9, '九年级': 9, '高一': 10, '高二': 11, '高三': 12 };
|
|
@@ -33,6 +33,7 @@ export const TeacherReports: React.FC = () => {
|
|
| 33 |
const [overviewData, setOverviewData] = useState<any>({});
|
| 34 |
|
| 35 |
const [selectedStudent, setSelectedStudent] = useState<Student | null>(null);
|
|
|
|
| 36 |
|
| 37 |
useEffect(() => {
|
| 38 |
const loadData = async () => {
|
|
@@ -142,8 +143,6 @@ export const TeacherReports: React.FC = () => {
|
|
| 142 |
// Show distribution of scores for selected Grade + Subject
|
| 143 |
if (selectedGrade) {
|
| 144 |
const relevantClasses = classes.filter(c => c.grade === selectedGrade).map(c => c.grade+c.className);
|
| 145 |
-
// We will average score per class per subject
|
| 146 |
-
// Just mocking a complex chart: Average Score vs Variance? Or Pass Rate vs Excellent Rate
|
| 147 |
const mData = relevantClasses.map(cName => {
|
| 148 |
const sIds = students.filter(s => s.className === cName).map(s => s.studentNo);
|
| 149 |
const cScores = normalScores.filter(s => sIds.includes(s.studentNo));
|
|
@@ -160,6 +159,12 @@ export const TeacherReports: React.FC = () => {
|
|
| 160 |
|
| 161 |
}, [scores, students, classes, subjects, exams, selectedGrade, selectedClass, selectedSubject]);
|
| 162 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 163 |
const getStudentRadar = (studentNo: string) => {
|
| 164 |
const stuScores = scores.filter(s => s.studentNo === studentNo && s.status === 'Normal');
|
| 165 |
return subjects.map(sub => {
|
|
@@ -183,6 +188,18 @@ export const TeacherReports: React.FC = () => {
|
|
| 183 |
});
|
| 184 |
};
|
| 185 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
const uniqueGrades = Array.from(new Set(classes.map(c => c.grade))).sort(localSortGrades);
|
| 187 |
const allClasses = classes.map(c => c.grade + c.className);
|
| 188 |
|
|
@@ -341,7 +358,7 @@ export const TeacherReports: React.FC = () => {
|
|
| 341 |
{focusStudents.map((s: Student) => (
|
| 342 |
<div
|
| 343 |
key={s._id}
|
| 344 |
-
onClick={() =>
|
| 345 |
className="bg-white border border-gray-200 p-4 rounded-xl cursor-pointer hover:shadow-lg hover:border-blue-400 transition-all group text-center"
|
| 346 |
>
|
| 347 |
<div className={`w-12 h-12 mx-auto rounded-full flex items-center justify-center font-bold text-white text-lg mb-2 ${s.gender === 'Female' ? 'bg-pink-400' : 'bg-blue-400'}`}>
|
|
@@ -374,6 +391,24 @@ export const TeacherReports: React.FC = () => {
|
|
| 374 |
<h3 className="text-center font-bold text-gray-700 mb-2">成绩走势</h3>
|
| 375 |
<ResponsiveContainer><LineChart data={getStudentTrend(selectedStudent.studentNo)}><CartesianGrid vertical={false}/><XAxis dataKey="name"/><YAxis domain={[0,100]}/><Tooltip/><Line type="monotone" dataKey="score" stroke="#2563eb" strokeWidth={3}/></LineChart></ResponsiveContainer>
|
| 376 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 377 |
</div>
|
| 378 |
</div>
|
| 379 |
)}
|
|
|
|
| 2 |
import React, { useState, useEffect } from 'react';
|
| 3 |
import {
|
| 4 |
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer,
|
| 5 |
+
LineChart, Line, AreaChart, Area, RadarChart, Radar, PolarGrid, PolarAngleAxis, PolarRadiusAxis, ScatterChart, Scatter, ZAxis, PieChart, Pie, Cell
|
| 6 |
} from 'recharts';
|
| 7 |
import { api } from '../services/api';
|
| 8 |
+
import { Loader2, Filter, TrendingUp, Grid, BarChart2, User, PieChart as PieChartIcon, Clock } from 'lucide-react';
|
| 9 |
+
import { Score, Student, ClassInfo, Subject, Exam, Attendance } from '../types';
|
| 10 |
|
| 11 |
const localSortGrades = (a: string, b: string) => {
|
| 12 |
const order: Record<string, number> = { '一年级': 1, '二年级': 2, '三年级': 3, '四年级': 4, '五年级': 5, '六年级': 6, '初一': 7, '七年级': 7, '初二': 8, '八年级': 8, '初三': 9, '九年级': 9, '高一': 10, '高二': 11, '高三': 12 };
|
|
|
|
| 33 |
const [overviewData, setOverviewData] = useState<any>({});
|
| 34 |
|
| 35 |
const [selectedStudent, setSelectedStudent] = useState<Student | null>(null);
|
| 36 |
+
const [selectedStudentAttendance, setSelectedStudentAttendance] = useState<Attendance[]>([]);
|
| 37 |
|
| 38 |
useEffect(() => {
|
| 39 |
const loadData = async () => {
|
|
|
|
| 143 |
// Show distribution of scores for selected Grade + Subject
|
| 144 |
if (selectedGrade) {
|
| 145 |
const relevantClasses = classes.filter(c => c.grade === selectedGrade).map(c => c.grade+c.className);
|
|
|
|
|
|
|
| 146 |
const mData = relevantClasses.map(cName => {
|
| 147 |
const sIds = students.filter(s => s.className === cName).map(s => s.studentNo);
|
| 148 |
const cScores = normalScores.filter(s => sIds.includes(s.studentNo));
|
|
|
|
| 159 |
|
| 160 |
}, [scores, students, classes, subjects, exams, selectedGrade, selectedClass, selectedSubject]);
|
| 161 |
|
| 162 |
+
const handleStudentSelect = async (s: Student) => {
|
| 163 |
+
setSelectedStudent(s);
|
| 164 |
+
const att = await api.attendance.get({ studentId: s._id || String(s.id) });
|
| 165 |
+
setSelectedStudentAttendance(att);
|
| 166 |
+
};
|
| 167 |
+
|
| 168 |
const getStudentRadar = (studentNo: string) => {
|
| 169 |
const stuScores = scores.filter(s => s.studentNo === studentNo && s.status === 'Normal');
|
| 170 |
return subjects.map(sub => {
|
|
|
|
| 188 |
});
|
| 189 |
};
|
| 190 |
|
| 191 |
+
const getAttendanceStats = () => {
|
| 192 |
+
if(!selectedStudentAttendance) return [];
|
| 193 |
+
const present = selectedStudentAttendance.filter(a => a.status === 'Present').length;
|
| 194 |
+
const leave = selectedStudentAttendance.filter(a => a.status === 'Leave').length;
|
| 195 |
+
const absent = selectedStudentAttendance.filter(a => a.status === 'Absent').length;
|
| 196 |
+
return [
|
| 197 |
+
{ name: '出勤', value: present, color: '#22c55e' },
|
| 198 |
+
{ name: '请假', value: leave, color: '#f97316' },
|
| 199 |
+
{ name: '缺勤', value: absent, color: '#ef4444' }
|
| 200 |
+
].filter(d => d.value > 0);
|
| 201 |
+
};
|
| 202 |
+
|
| 203 |
const uniqueGrades = Array.from(new Set(classes.map(c => c.grade))).sort(localSortGrades);
|
| 204 |
const allClasses = classes.map(c => c.grade + c.className);
|
| 205 |
|
|
|
|
| 358 |
{focusStudents.map((s: Student) => (
|
| 359 |
<div
|
| 360 |
key={s._id}
|
| 361 |
+
onClick={() => handleStudentSelect(s)}
|
| 362 |
className="bg-white border border-gray-200 p-4 rounded-xl cursor-pointer hover:shadow-lg hover:border-blue-400 transition-all group text-center"
|
| 363 |
>
|
| 364 |
<div className={`w-12 h-12 mx-auto rounded-full flex items-center justify-center font-bold text-white text-lg mb-2 ${s.gender === 'Female' ? 'bg-pink-400' : 'bg-blue-400'}`}>
|
|
|
|
| 391 |
<h3 className="text-center font-bold text-gray-700 mb-2">成绩走势</h3>
|
| 392 |
<ResponsiveContainer><LineChart data={getStudentTrend(selectedStudent.studentNo)}><CartesianGrid vertical={false}/><XAxis dataKey="name"/><YAxis domain={[0,100]}/><Tooltip/><Line type="monotone" dataKey="score" stroke="#2563eb" strokeWidth={3}/></LineChart></ResponsiveContainer>
|
| 393 |
</div>
|
| 394 |
+
|
| 395 |
+
{/* Attendance Chart for Teacher View */}
|
| 396 |
+
<div className="h-64 bg-white p-4 rounded-xl border shadow-sm">
|
| 397 |
+
<h3 className="text-center font-bold text-gray-700 mb-2 flex items-center justify-center"><Clock size={16} className="mr-2"/>考勤概览</h3>
|
| 398 |
+
{selectedStudentAttendance.length > 0 ? (
|
| 399 |
+
<ResponsiveContainer width="100%" height="100%">
|
| 400 |
+
<PieChart>
|
| 401 |
+
<Pie data={getAttendanceStats()} innerRadius={50} outerRadius={70} paddingAngle={5} dataKey="value">
|
| 402 |
+
{getAttendanceStats().map((entry, index) => (
|
| 403 |
+
<Cell key={`cell-${index}`} fill={entry.color} />
|
| 404 |
+
))}
|
| 405 |
+
</Pie>
|
| 406 |
+
<Tooltip />
|
| 407 |
+
<Legend verticalAlign="bottom"/>
|
| 408 |
+
</PieChart>
|
| 409 |
+
</ResponsiveContainer>
|
| 410 |
+
) : <div className="h-full flex items-center justify-center text-gray-400 text-sm">暂无考勤记录</div>}
|
| 411 |
+
</div>
|
| 412 |
</div>
|
| 413 |
</div>
|
| 414 |
)}
|
server.js
CHANGED
|
@@ -59,7 +59,8 @@ const SchoolSchema = new mongoose.Schema({ name: String, code: String });
|
|
| 59 |
const School = mongoose.model('School', SchoolSchema);
|
| 60 |
const UserSchema = new mongoose.Schema({ username: String, password: String, trueName: String, phone: String, email: String, schoolId: String, role: String, status: String, avatar: String, createTime: Date, teachingSubject: String, homeroomClass: String, studentNo: String, parentName: String, parentPhone: String, address: String, gender: String });
|
| 61 |
const User = mongoose.model('User', UserSchema);
|
| 62 |
-
|
|
|
|
| 63 |
const Student = mongoose.model('Student', StudentSchema);
|
| 64 |
const CourseSchema = new mongoose.Schema({ schoolId: String, courseCode: String, courseName: String, teacherName: String, credits: Number, capacity: Number, enrolled: Number });
|
| 65 |
const Course = mongoose.model('Course', CourseSchema);
|
|
@@ -122,10 +123,11 @@ app.post('/api/games/lucky-config', async (req, res) => {
|
|
| 122 |
res.json({ success: true });
|
| 123 |
});
|
| 124 |
|
| 125 |
-
// Secure Lucky Draw Endpoint
|
| 126 |
app.post('/api/games/lucky-draw', async (req, res) => {
|
| 127 |
const { studentId } = req.body;
|
| 128 |
const schoolId = req.headers['x-school-id'];
|
|
|
|
| 129 |
|
| 130 |
try {
|
| 131 |
if (InMemoryDB.isFallback) return res.json({ prize: '模拟奖品' });
|
|
@@ -133,13 +135,34 @@ app.post('/api/games/lucky-draw', async (req, res) => {
|
|
| 133 |
// 1. Get Student
|
| 134 |
const student = await Student.findById(studentId);
|
| 135 |
if (!student) return res.status(404).json({ error: 'Student not found' });
|
| 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 || [];
|
| 142 |
const defaultPrize = config?.defaultPrize || '再接再厉';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
|
| 144 |
// 3. Global Inventory Check
|
| 145 |
const availablePrizes = prizes.filter(p => (p.count === undefined || p.count > 0));
|
|
@@ -176,8 +199,9 @@ app.post('/api/games/lucky-draw', async (req, res) => {
|
|
| 176 |
}
|
| 177 |
}
|
| 178 |
|
| 179 |
-
// 5. Consume Attempt
|
| 180 |
-
|
|
|
|
| 181 |
|
| 182 |
// 6. Record Reward
|
| 183 |
// Note: Consolation prizes are recorded but handled differently in UI
|
|
@@ -469,11 +493,25 @@ app.delete('/api/rewards/:id', async (req, res) => {
|
|
| 469 |
res.json({});
|
| 470 |
});
|
| 471 |
|
|
|
|
| 472 |
app.get('/api/rewards', async (req, res) => {
|
| 473 |
const filter = getQueryFilter(req);
|
| 474 |
if(req.query.studentId) filter.studentId = req.query.studentId;
|
| 475 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 476 |
});
|
|
|
|
| 477 |
app.post('/api/rewards', async (req, res) => {
|
| 478 |
const data = injectSchoolId(req, req.body);
|
| 479 |
// Ensure count default
|
|
@@ -498,4 +536,4 @@ app.get('*', (req, res) => {
|
|
| 498 |
res.sendFile(path.join(__dirname, 'dist', 'index.html'));
|
| 499 |
});
|
| 500 |
|
| 501 |
-
app.listen(PORT, () => console.log(`🚀 Server running on port ${PORT}`));
|
|
|
|
| 59 |
const School = mongoose.model('School', SchoolSchema);
|
| 60 |
const UserSchema = new mongoose.Schema({ username: String, password: String, trueName: String, phone: String, email: String, schoolId: String, role: String, status: String, avatar: String, createTime: Date, teachingSubject: String, homeroomClass: String, studentNo: String, parentName: String, parentPhone: String, address: String, gender: String });
|
| 61 |
const User = mongoose.model('User', UserSchema);
|
| 62 |
+
// Updated Student Schema with dailyDrawLog
|
| 63 |
+
const StudentSchema = new mongoose.Schema({ schoolId: String, studentNo: String, name: String, gender: String, birthday: String, idCard: String, phone: String, className: String, status: String, parentName: String, parentPhone: String, address: String, teamId: String, drawAttempts: { type: Number, default: 0 }, dailyDrawLog: { date: String, count: { type: Number, default: 0 } } });
|
| 64 |
const Student = mongoose.model('Student', StudentSchema);
|
| 65 |
const CourseSchema = new mongoose.Schema({ schoolId: String, courseCode: String, courseName: String, teacherName: String, credits: Number, capacity: Number, enrolled: Number });
|
| 66 |
const Course = mongoose.model('Course', CourseSchema);
|
|
|
|
| 123 |
res.json({ success: true });
|
| 124 |
});
|
| 125 |
|
| 126 |
+
// Secure Lucky Draw Endpoint with DAILY LIMIT
|
| 127 |
app.post('/api/games/lucky-draw', async (req, res) => {
|
| 128 |
const { studentId } = req.body;
|
| 129 |
const schoolId = req.headers['x-school-id'];
|
| 130 |
+
const userRole = req.headers['x-user-role'];
|
| 131 |
|
| 132 |
try {
|
| 133 |
if (InMemoryDB.isFallback) return res.json({ prize: '模拟奖品' });
|
|
|
|
| 135 |
// 1. Get Student
|
| 136 |
const student = await Student.findById(studentId);
|
| 137 |
if (!student) return res.status(404).json({ error: 'Student not found' });
|
| 138 |
+
if (student.drawAttempts <= 0) return res.status(403).json({ error: '次数不足', message: '您的抽奖次数已用完' });
|
| 139 |
|
| 140 |
// 2. Get Config
|
| 141 |
const filter = schoolId ? { $or: [{ schoolId }, { schoolId: { $exists: false } }] } : {};
|
| 142 |
const config = await LuckyDrawConfigModel.findOne(filter);
|
| 143 |
const prizes = config?.prizes || [];
|
| 144 |
const defaultPrize = config?.defaultPrize || '再接再厉';
|
| 145 |
+
const dailyLimit = config?.dailyLimit || 3;
|
| 146 |
+
|
| 147 |
+
// 2.5 Daily Limit Check
|
| 148 |
+
// Only limit if it's a STUDENT drawing for themselves. Teachers/Admins bypass limits.
|
| 149 |
+
if (userRole === 'STUDENT') {
|
| 150 |
+
const today = new Date().toISOString().split('T')[0];
|
| 151 |
+
let dailyLog = student.dailyDrawLog || { date: today, count: 0 };
|
| 152 |
+
|
| 153 |
+
if (dailyLog.date !== today) {
|
| 154 |
+
dailyLog = { date: today, count: 0 }; // Reset for new day
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
if (dailyLog.count >= dailyLimit) {
|
| 158 |
+
return res.status(403).json({ error: 'DAILY_LIMIT_REACHED', message: `今日抽奖次数已达上限 (${dailyLimit}次)` });
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
// Increment daily count
|
| 162 |
+
dailyLog.count += 1;
|
| 163 |
+
// Note: We'll save this along with drawAttempts deduction
|
| 164 |
+
student.dailyDrawLog = dailyLog;
|
| 165 |
+
}
|
| 166 |
|
| 167 |
// 3. Global Inventory Check
|
| 168 |
const availablePrizes = prizes.filter(p => (p.count === undefined || p.count > 0));
|
|
|
|
| 199 |
}
|
| 200 |
}
|
| 201 |
|
| 202 |
+
// 5. Consume Attempt & Save Daily Log
|
| 203 |
+
student.drawAttempts -= 1;
|
| 204 |
+
await student.save();
|
| 205 |
|
| 206 |
// 6. Record Reward
|
| 207 |
// Note: Consolation prizes are recorded but handled differently in UI
|
|
|
|
| 493 |
res.json({});
|
| 494 |
});
|
| 495 |
|
| 496 |
+
// REWARDS PAGINATION
|
| 497 |
app.get('/api/rewards', async (req, res) => {
|
| 498 |
const filter = getQueryFilter(req);
|
| 499 |
if(req.query.studentId) filter.studentId = req.query.studentId;
|
| 500 |
+
|
| 501 |
+
const page = parseInt(req.query.page) || 1;
|
| 502 |
+
const limit = parseInt(req.query.limit) || 20;
|
| 503 |
+
const skip = (page - 1) * limit;
|
| 504 |
+
|
| 505 |
+
const total = await StudentRewardModel.countDocuments(filter);
|
| 506 |
+
const list = await StudentRewardModel.find(filter)
|
| 507 |
+
.sort({createTime:-1})
|
| 508 |
+
.skip(skip)
|
| 509 |
+
.limit(limit);
|
| 510 |
+
|
| 511 |
+
// Return object format for pagination
|
| 512 |
+
res.json({ list, total });
|
| 513 |
});
|
| 514 |
+
|
| 515 |
app.post('/api/rewards', async (req, res) => {
|
| 516 |
const data = injectSchoolId(req, req.body);
|
| 517 |
// Ensure count default
|
|
|
|
| 536 |
res.sendFile(path.join(__dirname, 'dist', 'index.html'));
|
| 537 |
});
|
| 538 |
|
| 539 |
+
app.listen(PORT, () => console.log(`🚀 Server running on port ${PORT}`));
|
services/api.ts
CHANGED
|
@@ -30,6 +30,11 @@ async function request(endpoint: string, options: RequestInit = {}) {
|
|
| 30 |
} else if (currentUser?.schoolId) {
|
| 31 |
headers['x-school-id'] = currentUser.schoolId;
|
| 32 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
}
|
| 34 |
|
| 35 |
const res = await fetch(`${API_BASE_URL}${endpoint}`, { ...options, headers });
|
|
@@ -184,8 +189,9 @@ export const api = {
|
|
| 184 |
grantReward: (data: { studentId: string, count: number, rewardType: string, name?: string }) => request('/games/grant-reward', { method: 'POST', body: JSON.stringify(data) }),
|
| 185 |
},
|
| 186 |
rewards: {
|
| 187 |
-
|
| 188 |
-
|
|
|
|
| 189 |
addReward: (data: Partial<StudentReward>) => request('/rewards', { method: 'POST', body: JSON.stringify(data) }),
|
| 190 |
update: (id: string, data: Partial<StudentReward>) => request(`/rewards/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
| 191 |
delete: (id: string) => request(`/rewards/${id}`, { method: 'DELETE' }),
|
|
@@ -195,4 +201,4 @@ export const api = {
|
|
| 195 |
batchDelete: (type: 'student' | 'score' | 'user', ids: string[]) => {
|
| 196 |
return request('/batch-delete', { method: 'POST', body: JSON.stringify({ type, ids }) });
|
| 197 |
}
|
| 198 |
-
};
|
|
|
|
| 30 |
} else if (currentUser?.schoolId) {
|
| 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 |
+
}
|
| 38 |
}
|
| 39 |
|
| 40 |
const res = await fetch(`${API_BASE_URL}${endpoint}`, { ...options, headers });
|
|
|
|
| 189 |
grantReward: (data: { studentId: string, count: number, rewardType: string, name?: string }) => request('/games/grant-reward', { method: 'POST', body: JSON.stringify(data) }),
|
| 190 |
},
|
| 191 |
rewards: {
|
| 192 |
+
// Pagination support
|
| 193 |
+
getMyRewards: (studentId: string, page = 1, limit = 20) => request(`/rewards?studentId=${studentId}&page=${page}&limit=${limit}`),
|
| 194 |
+
getClassRewards: (page = 1, limit = 20) => request(`/rewards?scope=class&page=${page}&limit=${limit}`),
|
| 195 |
addReward: (data: Partial<StudentReward>) => request('/rewards', { method: 'POST', body: JSON.stringify(data) }),
|
| 196 |
update: (id: string, data: Partial<StudentReward>) => request(`/rewards/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
| 197 |
delete: (id: string) => request(`/rewards/${id}`, { method: 'DELETE' }),
|
|
|
|
| 201 |
batchDelete: (type: 'student' | 'score' | 'user', ids: string[]) => {
|
| 202 |
return request('/batch-delete', { method: 'POST', body: JSON.stringify({ type, ids }) });
|
| 203 |
}
|
| 204 |
+
};
|
types.ts
CHANGED
|
@@ -90,6 +90,7 @@ export interface Student {
|
|
| 90 |
// Game related
|
| 91 |
teamId?: string;
|
| 92 |
drawAttempts?: number;
|
|
|
|
| 93 |
}
|
| 94 |
|
| 95 |
export interface Course {
|
|
@@ -189,12 +190,18 @@ export interface GameSession {
|
|
| 189 |
maxSteps: number; // For mountain height
|
| 190 |
}
|
| 191 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
export interface StudentReward {
|
| 193 |
_id?: string;
|
| 194 |
schoolId?: string;
|
| 195 |
studentId: string; // Link to Student
|
| 196 |
studentName: string;
|
| 197 |
-
rewardType:
|
| 198 |
name: string;
|
| 199 |
count?: number; // Quantity
|
| 200 |
status: 'PENDING' | 'REDEEMED';
|
|
@@ -244,4 +251,4 @@ export interface LeaveRequest {
|
|
| 244 |
endDate: string;
|
| 245 |
status: 'Pending' | 'Approved' | 'Rejected';
|
| 246 |
createTime: string;
|
| 247 |
-
}
|
|
|
|
| 90 |
// Game related
|
| 91 |
teamId?: string;
|
| 92 |
drawAttempts?: number;
|
| 93 |
+
dailyDrawLog?: { date: string; count: number }; // Track daily usage
|
| 94 |
}
|
| 95 |
|
| 96 |
export interface Course {
|
|
|
|
| 190 |
maxSteps: number; // For mountain height
|
| 191 |
}
|
| 192 |
|
| 193 |
+
export enum RewardType {
|
| 194 |
+
ITEM = 'ITEM',
|
| 195 |
+
DRAW_COUNT = 'DRAW_COUNT',
|
| 196 |
+
CONSOLATION = 'CONSOLATION'
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
export interface StudentReward {
|
| 200 |
_id?: string;
|
| 201 |
schoolId?: string;
|
| 202 |
studentId: string; // Link to Student
|
| 203 |
studentName: string;
|
| 204 |
+
rewardType: RewardType | string; // Updated
|
| 205 |
name: string;
|
| 206 |
count?: number; // Quantity
|
| 207 |
status: 'PENDING' | 'REDEEMED';
|
|
|
|
| 251 |
endDate: string;
|
| 252 |
status: 'Pending' | 'Approved' | 'Rejected';
|
| 253 |
createTime: string;
|
| 254 |
+
}
|