Spaces:
Running
Running
Upload 34 files
Browse files- pages/Dashboard.tsx +7 -7
- pages/GameRewards.tsx +167 -51
- server.js +69 -12
- services/api.ts +3 -3
- types.ts +2 -1
pages/Dashboard.tsx
CHANGED
|
@@ -173,10 +173,10 @@ export const Dashboard: React.FC<DashboardProps> = ({ onNavigate }) => {
|
|
| 173 |
: classList.map(c => c.grade + c.className).sort(sortClasses);
|
| 174 |
|
| 175 |
const cards = [
|
| 176 |
-
{ label: '在校学生', value: stats.studentCount, icon: Users, color: 'bg-blue-500', trend: '
|
| 177 |
-
{ label: '开设课程', value: stats.courseCount, icon: BookOpen, color: 'bg-emerald-500', trend: '
|
| 178 |
-
{ label: '平均成绩', value: stats.avgScore, icon: GraduationCap, color: 'bg-violet-500', trend: '
|
| 179 |
-
{ label: '优秀率', value: stats.excellentRate, icon: TrendingUp, color: 'bg-orange-500', trend: '
|
| 180 |
];
|
| 181 |
|
| 182 |
return (
|
|
@@ -202,9 +202,9 @@ export const Dashboard: React.FC<DashboardProps> = ({ onNavigate }) => {
|
|
| 202 |
<div className="relative z-10">
|
| 203 |
<p className="text-sm font-medium text-gray-500 mb-1">{card.label}</p>
|
| 204 |
<h3 className="text-3xl font-bold text-gray-800">{card.value}</h3>
|
| 205 |
-
<div className={`flex items-center mt-2 text-xs font-medium
|
| 206 |
-
<span className={`px-1.5 py-0.5 rounded
|
| 207 |
-
|
| 208 |
</div>
|
| 209 |
</div>
|
| 210 |
<div className={`p-3 rounded-xl ${card.color} bg-opacity-10 text-white shadow-sm group-hover:scale-110 transition-transform`}>
|
|
|
|
| 173 |
: classList.map(c => c.grade + c.className).sort(sortClasses);
|
| 174 |
|
| 175 |
const cards = [
|
| 176 |
+
{ label: '在校学生', value: stats.studentCount, icon: Users, color: 'bg-blue-500', trend: '实时' },
|
| 177 |
+
{ label: '开设课程', value: stats.courseCount, icon: BookOpen, color: 'bg-emerald-500', trend: '实时' },
|
| 178 |
+
{ label: '平均成绩', value: stats.avgScore, icon: GraduationCap, color: 'bg-violet-500', trend: '全校' },
|
| 179 |
+
{ label: '优秀率', value: stats.excellentRate, icon: TrendingUp, color: 'bg-orange-500', trend: '>=90分' },
|
| 180 |
];
|
| 181 |
|
| 182 |
return (
|
|
|
|
| 202 |
<div className="relative z-10">
|
| 203 |
<p className="text-sm font-medium text-gray-500 mb-1">{card.label}</p>
|
| 204 |
<h3 className="text-3xl font-bold text-gray-800">{card.value}</h3>
|
| 205 |
+
<div className={`flex items-center mt-2 text-xs font-medium text-gray-400`}>
|
| 206 |
+
<span className={`px-1.5 py-0.5 rounded bg-gray-100 text-gray-600 mr-2`}>{card.trend}</span>
|
| 207 |
+
统计数据
|
| 208 |
</div>
|
| 209 |
</div>
|
| 210 |
<div className={`p-3 rounded-xl ${card.color} bg-opacity-10 text-white shadow-sm group-hover:scale-110 transition-transform`}>
|
pages/GameRewards.tsx
CHANGED
|
@@ -2,7 +2,7 @@
|
|
| 2 |
import React, { useState, useEffect } from 'react';
|
| 3 |
import { api } from '../services/api';
|
| 4 |
import { StudentReward, Student } from '../types';
|
| 5 |
-
import { Gift, Loader2, Search } from 'lucide-react';
|
| 6 |
|
| 7 |
export const GameRewards: React.FC = () => {
|
| 8 |
const [myRewards, setMyRewards] = useState<StudentReward[]>([]);
|
|
@@ -12,18 +12,30 @@ export const GameRewards: React.FC = () => {
|
|
| 12 |
// Grant Modal
|
| 13 |
const [isGrantModalOpen, setIsGrantModalOpen] = useState(false);
|
| 14 |
const [students, setStudents] = useState<Student[]>([]);
|
| 15 |
-
const [grantForm, setGrantForm] = useState({
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
const currentUser = api.auth.getCurrentUser();
|
| 18 |
const isStudent = currentUser?.role === 'STUDENT';
|
| 19 |
const isTeacher = currentUser?.role === 'TEACHER';
|
| 20 |
-
const isAdmin = currentUser?.role === 'ADMIN';
|
| 21 |
|
| 22 |
const loadData = async () => {
|
| 23 |
setLoading(true);
|
| 24 |
try {
|
| 25 |
if (isStudent) {
|
| 26 |
-
// Need to find my student ID first
|
| 27 |
const stus = await api.students.getAll();
|
| 28 |
const me = stus.find((s: Student) => s.name === (currentUser.trueName || currentUser.username));
|
| 29 |
if (me) {
|
|
@@ -41,12 +53,13 @@ export const GameRewards: React.FC = () => {
|
|
| 41 |
let filteredStudents = allStus;
|
| 42 |
|
| 43 |
if (isTeacher && currentUser.homeroomClass) {
|
| 44 |
-
// Filter rewards by student names belonging to class (Backend link is better, but frontend filtering works for mock)
|
| 45 |
-
// Or better: filter students first, then filter rewards by student IDs
|
| 46 |
filteredStudents = allStus.filter((s: Student) => s.className === currentUser.homeroomClass);
|
| 47 |
const studentIds = filteredStudents.map((s: Student) => s._id || String(s.id));
|
| 48 |
filteredRewards = allRews.filter((r: StudentReward) => studentIds.includes(r.studentId));
|
| 49 |
}
|
|
|
|
|
|
|
|
|
|
| 50 |
|
| 51 |
setAllRewards(filteredRewards);
|
| 52 |
setStudents(filteredStudents);
|
|
@@ -57,12 +70,16 @@ export const GameRewards: React.FC = () => {
|
|
| 57 |
|
| 58 |
useEffect(() => { loadData(); }, []);
|
| 59 |
|
| 60 |
-
const
|
| 61 |
if(!grantForm.studentId) return alert('请选择学生');
|
|
|
|
|
|
|
| 62 |
try {
|
| 63 |
-
await api.games.
|
| 64 |
setIsGrantModalOpen(false);
|
| 65 |
alert('发放成功');
|
|
|
|
|
|
|
| 66 |
loadData();
|
| 67 |
} catch(e) { alert('发放失败'); }
|
| 68 |
};
|
|
@@ -74,19 +91,74 @@ export const GameRewards: React.FC = () => {
|
|
| 74 |
}
|
| 75 |
};
|
| 76 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
if (loading) return <div className="h-full flex items-center justify-center"><Loader2 className="animate-spin text-blue-600"/></div>;
|
| 78 |
|
| 79 |
return (
|
| 80 |
<div className="flex-1 flex flex-col min-h-0 bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden">
|
| 81 |
-
<div className="p-6 border-b border-gray-100 flex justify-between items-center shrink-0">
|
| 82 |
<h3 className="text-xl font-bold text-gray-800">
|
| 83 |
{isStudent ? '我的战利品清单' : '班级奖励核销台'}
|
| 84 |
</h3>
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
<
|
| 89 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
</div>
|
| 91 |
|
| 92 |
<div className="flex-1 overflow-auto p-0">
|
|
@@ -94,7 +166,7 @@ export const GameRewards: React.FC = () => {
|
|
| 94 |
<thead className="bg-gray-50 text-gray-500 text-xs uppercase sticky top-0 z-10 shadow-sm">
|
| 95 |
<tr>
|
| 96 |
{!isStudent && <th className="p-4 font-semibold">学生姓名</th>}
|
| 97 |
-
<th className="p-4 font-semibold">奖品
|
| 98 |
<th className="p-4 font-semibold">类型</th>
|
| 99 |
<th className="p-4 font-semibold">来源</th>
|
| 100 |
<th className="p-4 font-semibold">获得时间</th>
|
|
@@ -103,38 +175,66 @@ export const GameRewards: React.FC = () => {
|
|
| 103 |
</tr>
|
| 104 |
</thead>
|
| 105 |
<tbody className="divide-y divide-gray-100 text-sm">
|
| 106 |
-
{
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
<
|
| 110 |
-
|
| 111 |
-
<
|
| 112 |
-
{
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
) : (
|
| 121 |
-
r.status === 'REDEEMED'
|
| 122 |
-
? <span className="text-xs bg-gray-100 text-gray-500 px-2 py-1 rounded border border-gray-200">已兑换</span>
|
| 123 |
-
: <span className="text-xs bg-green-100 text-green-700 px-2 py-1 rounded border border-green-200 animate-pulse">未兑换</span>
|
| 124 |
-
)}
|
| 125 |
-
</td>
|
| 126 |
-
{!isStudent && (
|
| 127 |
-
<td className="p-4 text-right">
|
| 128 |
-
{r.status !== 'REDEEMED' && r.rewardType !== 'DRAW_COUNT' && (
|
| 129 |
-
<button onClick={() => handleRedeem(r._id!)} className="text-xs bg-green-600 text-white px-3 py-1.5 rounded hover:bg-green-700 shadow-sm transition-colors">
|
| 130 |
-
核销
|
| 131 |
-
</button>
|
| 132 |
)}
|
| 133 |
</td>
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
<tr><td colSpan={7} className="text-center py-10 text-gray-400">暂无记录</td></tr>
|
| 139 |
)}
|
| 140 |
</tbody>
|
|
@@ -145,22 +245,38 @@ export const GameRewards: React.FC = () => {
|
|
| 145 |
{isGrantModalOpen && (
|
| 146 |
<div className="fixed inset-0 bg-black/50 z-[100] flex items-center justify-center p-4">
|
| 147 |
<div className="bg-white rounded-xl p-6 w-full max-w-sm animate-in fade-in zoom-in-95 shadow-2xl">
|
| 148 |
-
<h3 className="font-bold text-lg mb-4 text-gray-800">
|
| 149 |
<div className="space-y-4">
|
| 150 |
<div>
|
| 151 |
-
<label className="block text-
|
| 152 |
-
<select className="w-full border border-gray-300 p-2 rounded-lg bg-gray-50 focus:bg-white
|
| 153 |
<option value="">-- 请选择 --</option>
|
| 154 |
{students.map(s => <option key={s._id} value={s._id || String(s.id)}>{s.name} ({s.studentNo})</option>)}
|
| 155 |
</select>
|
| 156 |
</div>
|
| 157 |
<div>
|
| 158 |
-
<label className="block text-
|
| 159 |
-
<
|
|
|
|
|
|
|
|
|
|
| 160 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
<div className="flex gap-2 pt-2">
|
| 162 |
<button onClick={() => setIsGrantModalOpen(false)} className="flex-1 bg-gray-100 text-gray-700 py-2 rounded-lg hover:bg-gray-200 transition-colors">取消</button>
|
| 163 |
-
<button onClick={
|
| 164 |
</div>
|
| 165 |
</div>
|
| 166 |
</div>
|
|
|
|
| 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 [myRewards, setMyRewards] = useState<StudentReward[]>([]);
|
|
|
|
| 12 |
// Grant Modal
|
| 13 |
const [isGrantModalOpen, setIsGrantModalOpen] = useState(false);
|
| 14 |
const [students, setStudents] = useState<Student[]>([]);
|
| 15 |
+
const [grantForm, setGrantForm] = useState({
|
| 16 |
+
studentId: '',
|
| 17 |
+
count: 1,
|
| 18 |
+
rewardType: 'DRAW_COUNT' as 'DRAW_COUNT' | 'ITEM',
|
| 19 |
+
name: ''
|
| 20 |
+
});
|
| 21 |
+
|
| 22 |
+
// Filters
|
| 23 |
+
const [filterType, setFilterType] = useState('ALL'); // ALL, ITEM, DRAW_COUNT
|
| 24 |
+
const [filterStatus, setFilterStatus] = useState('ALL'); // ALL, PENDING, REDEEMED
|
| 25 |
+
const [searchText, setSearchText] = useState('');
|
| 26 |
+
|
| 27 |
+
// Edit State
|
| 28 |
+
const [editingId, setEditingId] = useState<string | null>(null);
|
| 29 |
+
const [editForm, setEditForm] = useState({ name: '', count: 1 });
|
| 30 |
|
| 31 |
const currentUser = api.auth.getCurrentUser();
|
| 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) {
|
|
|
|
| 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 |
filteredRewards = allRews.filter((r: StudentReward) => studentIds.includes(r.studentId));
|
| 59 |
}
|
| 60 |
+
|
| 61 |
+
// Hide Consolation prizes from teacher view to keep it clean
|
| 62 |
+
filteredRewards = filteredRewards.filter((r: StudentReward) => r.rewardType !== 'CONSOLATION');
|
| 63 |
|
| 64 |
setAllRewards(filteredRewards);
|
| 65 |
setStudents(filteredStudents);
|
|
|
|
| 70 |
|
| 71 |
useEffect(() => { loadData(); }, []);
|
| 72 |
|
| 73 |
+
const handleGrant = async () => {
|
| 74 |
if(!grantForm.studentId) return alert('请选择学生');
|
| 75 |
+
if(grantForm.rewardType === 'ITEM' && !grantForm.name) return alert('请输入奖品名称');
|
| 76 |
+
|
| 77 |
try {
|
| 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('发放失败'); }
|
| 85 |
};
|
|
|
|
| 91 |
}
|
| 92 |
};
|
| 93 |
|
| 94 |
+
const handleDelete = async (r: StudentReward) => {
|
| 95 |
+
if (!confirm(`确定要撤回这条奖励吗?\n如果学生已经使用了抽奖券,撤回将失败。`)) return;
|
| 96 |
+
try {
|
| 97 |
+
await api.rewards.delete(r._id!);
|
| 98 |
+
loadData();
|
| 99 |
+
} catch (e: any) {
|
| 100 |
+
alert(e.message || '撤回失败,可能学生已使用部分次数');
|
| 101 |
+
}
|
| 102 |
+
};
|
| 103 |
+
|
| 104 |
+
const handleUpdate = async (id: string) => {
|
| 105 |
+
await api.rewards.update(id, editForm);
|
| 106 |
+
setEditingId(null);
|
| 107 |
+
loadData();
|
| 108 |
+
};
|
| 109 |
+
|
| 110 |
+
const startEdit = (r: StudentReward) => {
|
| 111 |
+
setEditingId(r._id!);
|
| 112 |
+
setEditForm({ name: r.name, count: r.count || 1 });
|
| 113 |
+
};
|
| 114 |
+
|
| 115 |
+
// Filter Logic
|
| 116 |
+
const displayRewards = (isStudent ? myRewards : allRewards).filter(r => {
|
| 117 |
+
if (filterType !== 'ALL' && r.rewardType !== filterType) return false;
|
| 118 |
+
if (filterStatus !== 'ALL' && r.status !== filterStatus) return false;
|
| 119 |
+
if (searchText) {
|
| 120 |
+
const lower = searchText.toLowerCase();
|
| 121 |
+
return r.studentName.toLowerCase().includes(lower) || r.name.toLowerCase().includes(lower);
|
| 122 |
+
}
|
| 123 |
+
return true;
|
| 124 |
+
});
|
| 125 |
+
|
| 126 |
if (loading) return <div className="h-full flex items-center justify-center"><Loader2 className="animate-spin text-blue-600"/></div>;
|
| 127 |
|
| 128 |
return (
|
| 129 |
<div className="flex-1 flex flex-col min-h-0 bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden">
|
| 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>
|
| 134 |
+
|
| 135 |
+
<div className="flex flex-wrap items-center gap-3 w-full md:w-auto">
|
| 136 |
+
{/* Filters */}
|
| 137 |
+
<div className="flex items-center bg-gray-100 rounded-lg p-1">
|
| 138 |
+
<select className="bg-transparent text-xs p-1.5 rounded outline-none text-gray-600" value={filterType} onChange={e=>setFilterType(e.target.value)}>
|
| 139 |
+
<option value="ALL">全部类型</option>
|
| 140 |
+
<option value="ITEM">实物</option>
|
| 141 |
+
<option value="DRAW_COUNT">抽奖券</option>
|
| 142 |
+
</select>
|
| 143 |
+
</div>
|
| 144 |
+
<div className="flex items-center bg-gray-100 rounded-lg p-1">
|
| 145 |
+
<select className="bg-transparent text-xs p-1.5 rounded outline-none text-gray-600" value={filterStatus} onChange={e=>setFilterStatus(e.target.value)}>
|
| 146 |
+
<option value="ALL">全部状态</option>
|
| 147 |
+
<option value="PENDING">未核销</option>
|
| 148 |
+
<option value="REDEEMED">已核销</option>
|
| 149 |
+
</select>
|
| 150 |
+
</div>
|
| 151 |
+
<div className="relative">
|
| 152 |
+
<input className="pl-8 pr-3 py-1.5 text-xs border rounded-lg bg-gray-50 focus:bg-white transition-colors outline-none w-32" placeholder="搜索..." value={searchText} onChange={e=>setSearchText(e.target.value)}/>
|
| 153 |
+
<Search className="absolute left-2.5 top-2 text-gray-400" size={12}/>
|
| 154 |
+
</div>
|
| 155 |
+
|
| 156 |
+
{!isStudent && (
|
| 157 |
+
<button onClick={() => setIsGrantModalOpen(true)} className="flex items-center px-4 py-2 bg-amber-100 text-amber-700 rounded-lg font-bold hover:bg-amber-200 text-sm ml-auto md:ml-0">
|
| 158 |
+
<Gift size={16} className="mr-2"/> 发放奖励
|
| 159 |
+
</button>
|
| 160 |
+
)}
|
| 161 |
+
</div>
|
| 162 |
</div>
|
| 163 |
|
| 164 |
<div className="flex-1 overflow-auto p-0">
|
|
|
|
| 166 |
<thead className="bg-gray-50 text-gray-500 text-xs uppercase sticky top-0 z-10 shadow-sm">
|
| 167 |
<tr>
|
| 168 |
{!isStudent && <th className="p-4 font-semibold">学生姓名</th>}
|
| 169 |
+
<th className="p-4 font-semibold">奖品内容</th>
|
| 170 |
<th className="p-4 font-semibold">类型</th>
|
| 171 |
<th className="p-4 font-semibold">来源</th>
|
| 172 |
<th className="p-4 font-semibold">获得时间</th>
|
|
|
|
| 175 |
</tr>
|
| 176 |
</thead>
|
| 177 |
<tbody className="divide-y divide-gray-100 text-sm">
|
| 178 |
+
{displayRewards.map(r => {
|
| 179 |
+
const isEditing = editingId === r._id;
|
| 180 |
+
return (
|
| 181 |
+
<tr key={r._id} className="hover:bg-blue-50/30 transition-colors">
|
| 182 |
+
{!isStudent && <td className="p-4 font-bold text-gray-700">{r.studentName}</td>}
|
| 183 |
+
<td className="p-4 font-medium text-gray-900">
|
| 184 |
+
{isEditing ? (
|
| 185 |
+
<div className="flex gap-1 items-center">
|
| 186 |
+
<input className="border rounded px-1 py-0.5 text-xs w-24" value={editForm.name} onChange={e=>setEditForm({...editForm, name:e.target.value})} placeholder="名称"/>
|
| 187 |
+
<span className="text-xs text-gray-400">x</span>
|
| 188 |
+
<input type="number" min={1} className="border rounded px-1 py-0.5 text-xs w-12" value={editForm.count} onChange={e=>setEditForm({...editForm, count:Number(e.target.value)})}/>
|
| 189 |
+
</div>
|
| 190 |
+
) : (
|
| 191 |
+
<span>{r.name} <span className="text-gray-400 text-xs ml-1">x{r.count || 1}</span></span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
)}
|
| 193 |
</td>
|
| 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>
|
| 211 |
+
: <span className="text-xs bg-amber-100 text-amber-700 px-2 py-1 rounded border border-amber-200 animate-pulse">待核销</span>
|
| 212 |
+
)}
|
| 213 |
+
</td>
|
| 214 |
+
{!isStudent && (
|
| 215 |
+
<td className="p-4 text-right flex justify-end gap-2">
|
| 216 |
+
{isEditing ? (
|
| 217 |
+
<>
|
| 218 |
+
<button onClick={()=>handleUpdate(r._id!)} className="text-green-600 hover:bg-green-50 p-1 rounded"><Save size={16}/></button>
|
| 219 |
+
<button onClick={()=>setEditingId(null)} className="text-gray-400 hover:bg-gray-50 p-1 rounded"><X size={16}/></button>
|
| 220 |
+
</>
|
| 221 |
+
) : (
|
| 222 |
+
<>
|
| 223 |
+
{r.status !== 'REDEEMED' && r.rewardType === 'ITEM' && (
|
| 224 |
+
<button onClick={() => handleRedeem(r._id!)} className="text-xs bg-green-600 text-white px-3 py-1 rounded hover:bg-green-700 shadow-sm transition-colors mr-2">
|
| 225 |
+
核销
|
| 226 |
+
</button>
|
| 227 |
+
)}
|
| 228 |
+
<button onClick={()=>startEdit(r)} className="text-blue-400 hover:text-blue-600 p-1" title="编辑"><Edit size={16}/></button>
|
| 229 |
+
<button onClick={()=>handleDelete(r)} className="text-gray-400 hover:text-red-500 p-1" title="撤回"><Trash2 size={16}/></button>
|
| 230 |
+
</>
|
| 231 |
+
)}
|
| 232 |
+
</td>
|
| 233 |
+
)}
|
| 234 |
+
</tr>
|
| 235 |
+
);
|
| 236 |
+
})}
|
| 237 |
+
{displayRewards.length === 0 && (
|
| 238 |
<tr><td colSpan={7} className="text-center py-10 text-gray-400">暂无记录</td></tr>
|
| 239 |
)}
|
| 240 |
</tbody>
|
|
|
|
| 245 |
{isGrantModalOpen && (
|
| 246 |
<div className="fixed inset-0 bg-black/50 z-[100] flex items-center justify-center p-4">
|
| 247 |
<div className="bg-white rounded-xl p-6 w-full max-w-sm animate-in fade-in zoom-in-95 shadow-2xl">
|
| 248 |
+
<h3 className="font-bold text-lg mb-4 text-gray-800">发放奖励</h3>
|
| 249 |
<div className="space-y-4">
|
| 250 |
<div>
|
| 251 |
+
<label className="block text-xs font-bold text-gray-500 uppercase mb-1">选择学生</label>
|
| 252 |
+
<select className="w-full border border-gray-300 p-2 rounded-lg bg-gray-50 focus:bg-white text-sm" value={grantForm.studentId} onChange={e=>setGrantForm({...grantForm, studentId: e.target.value})}>
|
| 253 |
<option value="">-- 请选择 --</option>
|
| 254 |
{students.map(s => <option key={s._id} value={s._id || String(s.id)}>{s.name} ({s.studentNo})</option>)}
|
| 255 |
</select>
|
| 256 |
</div>
|
| 257 |
<div>
|
| 258 |
+
<label className="block text-xs font-bold text-gray-500 uppercase mb-1">奖励类型</label>
|
| 259 |
+
<div className="flex gap-2">
|
| 260 |
+
<button onClick={()=>setGrantForm({...grantForm, rewardType: 'DRAW_COUNT'})} className={`flex-1 py-2 text-sm rounded border ${grantForm.rewardType==='DRAW_COUNT' ? 'bg-purple-50 border-purple-500 text-purple-700 font-bold' : 'border-gray-200 text-gray-600'}`}>抽奖券</button>
|
| 261 |
+
<button onClick={()=>setGrantForm({...grantForm, rewardType: 'ITEM'})} className={`flex-1 py-2 text-sm rounded border ${grantForm.rewardType==='ITEM' ? 'bg-blue-50 border-blue-500 text-blue-700 font-bold' : 'border-gray-200 text-gray-600'}`}>实物奖品</button>
|
| 262 |
+
</div>
|
| 263 |
</div>
|
| 264 |
+
|
| 265 |
+
{grantForm.rewardType === 'ITEM' && (
|
| 266 |
+
<div>
|
| 267 |
+
<label className="block text-xs font-bold text-gray-500 uppercase mb-1">奖品名称</label>
|
| 268 |
+
<input className="w-full border border-gray-300 p-2 rounded-lg text-sm" placeholder="如: 笔记本、铅笔" value={grantForm.name} onChange={e=>setGrantForm({...grantForm, name: e.target.value})}/>
|
| 269 |
+
</div>
|
| 270 |
+
)}
|
| 271 |
+
|
| 272 |
+
<div>
|
| 273 |
+
<label className="block text-xs font-bold text-gray-500 uppercase mb-1">发放数量</label>
|
| 274 |
+
<input type="number" min={1} className="w-full border border-gray-300 p-2 rounded-lg outline-none" value={grantForm.count} onChange={e=>setGrantForm({...grantForm, count: Number(e.target.value)})}/>
|
| 275 |
+
</div>
|
| 276 |
+
|
| 277 |
<div className="flex gap-2 pt-2">
|
| 278 |
<button onClick={() => setIsGrantModalOpen(false)} className="flex-1 bg-gray-100 text-gray-700 py-2 rounded-lg hover:bg-gray-200 transition-colors">取消</button>
|
| 279 |
+
<button onClick={handleGrant} className="flex-1 bg-amber-500 text-white py-2 rounded-lg font-bold hover:bg-amber-600 shadow-md transition-colors">确认发放</button>
|
| 280 |
</div>
|
| 281 |
</div>
|
| 282 |
</div>
|
server.js
CHANGED
|
@@ -80,7 +80,7 @@ const NotificationModel = mongoose.model('Notification', NotificationSchema);
|
|
| 80 |
// Game Schemas
|
| 81 |
const GameSessionSchema = new mongoose.Schema({ schoolId: String, className: String, isEnabled: Boolean, maxSteps: Number, teams: [{ id: String, name: String, score: Number, avatar: String, color: String, members: [String] }], rewardsConfig: [{ scoreThreshold: Number, rewardType: String, rewardName: String, rewardValue: Number }] });
|
| 82 |
const GameSessionModel = mongoose.model('GameSession', GameSessionSchema);
|
| 83 |
-
const StudentRewardSchema = new mongoose.Schema({ schoolId: String, studentId: String, studentName: String, rewardType: String, name: String, status: String, source: String, createTime: { type: Date, default: Date.now } });
|
| 84 |
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);
|
|
@@ -145,6 +145,7 @@ app.post('/api/games/lucky-draw', async (req, res) => {
|
|
| 145 |
|
| 146 |
// 4. Weighted Random Logic
|
| 147 |
let selectedPrize = defaultPrize;
|
|
|
|
| 148 |
const random = Math.random() * 100;
|
| 149 |
let currentWeight = 0;
|
| 150 |
let matchedPrize = null;
|
|
@@ -159,10 +160,9 @@ app.post('/api/games/lucky-draw', async (req, res) => {
|
|
| 159 |
|
| 160 |
if (matchedPrize) {
|
| 161 |
selectedPrize = matchedPrize.name;
|
|
|
|
| 162 |
// Only decrease count if it's not infinite (undefined)
|
| 163 |
if (matchedPrize.count !== undefined && matchedPrize.count > 0) {
|
| 164 |
-
// Need to update the specific array element.
|
| 165 |
-
// If config has no ID, we might have trouble updating. Assuming config has _id.
|
| 166 |
if (config._id) {
|
| 167 |
await LuckyDrawConfigModel.updateOne(
|
| 168 |
{ _id: config._id, "prizes.id": matchedPrize.id },
|
|
@@ -176,12 +176,14 @@ app.post('/api/games/lucky-draw', async (req, res) => {
|
|
| 176 |
await Student.findByIdAndUpdate(studentId, { $inc: { drawAttempts: -1 } });
|
| 177 |
|
| 178 |
// 6. Record Reward
|
|
|
|
| 179 |
await StudentRewardModel.create({
|
| 180 |
schoolId,
|
| 181 |
studentId,
|
| 182 |
studentName: student.name,
|
| 183 |
-
rewardType
|
| 184 |
name: selectedPrize,
|
|
|
|
| 185 |
status: 'PENDING',
|
| 186 |
source: '幸运大抽奖'
|
| 187 |
});
|
|
@@ -275,7 +277,25 @@ app.post('/api/schedules', async (req, res) => {
|
|
| 275 |
});
|
| 276 |
app.delete('/api/schedules', async (req, res) => { await ScheduleModel.deleteOne({...getQueryFilter(req), ...req.query}); res.json({}); });
|
| 277 |
|
| 278 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 279 |
|
| 280 |
app.get('/api/config', async (req, res) => { res.json(await ConfigModel.findOne({key:'main'})); });
|
| 281 |
app.post('/api/config', async (req, res) => { await ConfigModel.findOneAndUpdate({key:'main'}, req.body, {upsert:true}); res.json({}); });
|
|
@@ -290,21 +310,55 @@ app.post('/api/games/mountain', async (req, res) => {
|
|
| 290 |
res.json({});
|
| 291 |
});
|
| 292 |
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 296 |
await StudentRewardModel.create({
|
| 297 |
schoolId: req.headers['x-school-id'],
|
| 298 |
studentId,
|
| 299 |
studentName: (await Student.findById(studentId)).name,
|
| 300 |
-
rewardType
|
| 301 |
-
name:
|
| 302 |
-
|
|
|
|
| 303 |
source: '教师发放'
|
| 304 |
});
|
| 305 |
res.json({});
|
| 306 |
});
|
| 307 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 308 |
app.get('/api/rewards', async (req, res) => {
|
| 309 |
const filter = getQueryFilter(req);
|
| 310 |
if(req.query.studentId) filter.studentId = req.query.studentId;
|
|
@@ -312,9 +366,12 @@ app.get('/api/rewards', async (req, res) => {
|
|
| 312 |
});
|
| 313 |
app.post('/api/rewards', async (req, res) => {
|
| 314 |
const data = injectSchoolId(req, req.body);
|
|
|
|
|
|
|
|
|
|
| 315 |
if(data.rewardType==='DRAW_COUNT') {
|
| 316 |
data.status='REDEEMED';
|
| 317 |
-
await Student.findByIdAndUpdate(data.studentId, {$inc:{drawAttempts:
|
| 318 |
}
|
| 319 |
await StudentRewardModel.create(data);
|
| 320 |
res.json({});
|
|
|
|
| 80 |
// Game Schemas
|
| 81 |
const GameSessionSchema = new mongoose.Schema({ schoolId: String, className: String, isEnabled: Boolean, maxSteps: Number, teams: [{ id: String, name: String, score: Number, avatar: String, color: String, members: [String] }], rewardsConfig: [{ scoreThreshold: Number, rewardType: String, rewardName: String, rewardValue: Number }] });
|
| 82 |
const GameSessionModel = mongoose.model('GameSession', GameSessionSchema);
|
| 83 |
+
const StudentRewardSchema = new mongoose.Schema({ schoolId: String, studentId: String, studentName: String, rewardType: String, name: String, count: { type: Number, default: 1 }, status: String, source: String, createTime: { type: Date, default: Date.now } });
|
| 84 |
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);
|
|
|
|
| 145 |
|
| 146 |
// 4. Weighted Random Logic
|
| 147 |
let selectedPrize = defaultPrize;
|
| 148 |
+
let rewardType = 'CONSOLATION'; // Default to consolation
|
| 149 |
const random = Math.random() * 100;
|
| 150 |
let currentWeight = 0;
|
| 151 |
let matchedPrize = null;
|
|
|
|
| 160 |
|
| 161 |
if (matchedPrize) {
|
| 162 |
selectedPrize = matchedPrize.name;
|
| 163 |
+
rewardType = 'ITEM'; // It's a real prize
|
| 164 |
// Only decrease count if it's not infinite (undefined)
|
| 165 |
if (matchedPrize.count !== undefined && matchedPrize.count > 0) {
|
|
|
|
|
|
|
| 166 |
if (config._id) {
|
| 167 |
await LuckyDrawConfigModel.updateOne(
|
| 168 |
{ _id: config._id, "prizes.id": matchedPrize.id },
|
|
|
|
| 176 |
await Student.findByIdAndUpdate(studentId, { $inc: { drawAttempts: -1 } });
|
| 177 |
|
| 178 |
// 6. Record Reward
|
| 179 |
+
// Note: Consolation prizes are recorded but handled differently in UI
|
| 180 |
await StudentRewardModel.create({
|
| 181 |
schoolId,
|
| 182 |
studentId,
|
| 183 |
studentName: student.name,
|
| 184 |
+
rewardType,
|
| 185 |
name: selectedPrize,
|
| 186 |
+
count: 1,
|
| 187 |
status: 'PENDING',
|
| 188 |
source: '幸运大抽奖'
|
| 189 |
});
|
|
|
|
| 277 |
});
|
| 278 |
app.delete('/api/schedules', async (req, res) => { await ScheduleModel.deleteOne({...getQueryFilter(req), ...req.query}); res.json({}); });
|
| 279 |
|
| 280 |
+
// REAL STATS API (No more mock)
|
| 281 |
+
app.get('/api/stats', async (req, res) => {
|
| 282 |
+
const filter = getQueryFilter(req);
|
| 283 |
+
const studentCount = await Student.countDocuments(filter);
|
| 284 |
+
const courseCount = await Course.countDocuments(filter);
|
| 285 |
+
|
| 286 |
+
const scores = await Score.find({...filter, status: 'Normal'});
|
| 287 |
+
let avgScore = 0;
|
| 288 |
+
let excellentRate = '0%';
|
| 289 |
+
|
| 290 |
+
if (scores.length > 0) {
|
| 291 |
+
const total = scores.reduce((sum, s) => sum + s.score, 0);
|
| 292 |
+
avgScore = parseFloat((total / scores.length).toFixed(1));
|
| 293 |
+
const excellent = scores.filter(s => s.score >= 90).length;
|
| 294 |
+
excellentRate = Math.round((excellent / scores.length) * 100) + '%';
|
| 295 |
+
}
|
| 296 |
+
|
| 297 |
+
res.json({ studentCount, courseCount, avgScore, excellentRate });
|
| 298 |
+
});
|
| 299 |
|
| 300 |
app.get('/api/config', async (req, res) => { res.json(await ConfigModel.findOne({key:'main'})); });
|
| 301 |
app.post('/api/config', async (req, res) => { await ConfigModel.findOneAndUpdate({key:'main'}, req.body, {upsert:true}); res.json({}); });
|
|
|
|
| 310 |
res.json({});
|
| 311 |
});
|
| 312 |
|
| 313 |
+
// Grant Reward (Flexible)
|
| 314 |
+
app.post('/api/games/grant-reward', async (req, res) => {
|
| 315 |
+
const { studentId, count, rewardType, name } = req.body;
|
| 316 |
+
const finalCount = count || 1;
|
| 317 |
+
const finalName = name || (rewardType === 'DRAW_COUNT' ? '抽奖券' : '奖品');
|
| 318 |
+
|
| 319 |
+
if (rewardType === 'DRAW_COUNT') {
|
| 320 |
+
await Student.findByIdAndUpdate(studentId, { $inc: { drawAttempts: finalCount } });
|
| 321 |
+
}
|
| 322 |
+
|
| 323 |
await StudentRewardModel.create({
|
| 324 |
schoolId: req.headers['x-school-id'],
|
| 325 |
studentId,
|
| 326 |
studentName: (await Student.findById(studentId)).name,
|
| 327 |
+
rewardType,
|
| 328 |
+
name: finalName,
|
| 329 |
+
count: finalCount,
|
| 330 |
+
status: rewardType === 'DRAW_COUNT' ? 'REDEEMED' : 'PENDING',
|
| 331 |
source: '教师发放'
|
| 332 |
});
|
| 333 |
res.json({});
|
| 334 |
});
|
| 335 |
|
| 336 |
+
// Update Reward
|
| 337 |
+
app.put('/api/rewards/:id', async (req, res) => {
|
| 338 |
+
await StudentRewardModel.findByIdAndUpdate(req.params.id, req.body);
|
| 339 |
+
res.json({});
|
| 340 |
+
});
|
| 341 |
+
|
| 342 |
+
// Delete Reward (Smart Revoke)
|
| 343 |
+
app.delete('/api/rewards/:id', async (req, res) => {
|
| 344 |
+
const reward = await StudentRewardModel.findById(req.params.id);
|
| 345 |
+
if (!reward) return res.status(404).json({error: 'Not found'});
|
| 346 |
+
|
| 347 |
+
// If we revoke draw counts, ensure student has enough
|
| 348 |
+
if (reward.rewardType === 'DRAW_COUNT') {
|
| 349 |
+
const student = await Student.findById(reward.studentId);
|
| 350 |
+
if (!student) return res.status(404).json({error: 'Student missing'});
|
| 351 |
+
|
| 352 |
+
if (student.drawAttempts < reward.count) {
|
| 353 |
+
return res.status(400).json({ error: 'FAILED_REVOKE', message: '修改失败,次数已被使用' });
|
| 354 |
+
}
|
| 355 |
+
await Student.findByIdAndUpdate(reward.studentId, { $inc: { drawAttempts: -reward.count } });
|
| 356 |
+
}
|
| 357 |
+
|
| 358 |
+
await StudentRewardModel.findByIdAndDelete(req.params.id);
|
| 359 |
+
res.json({});
|
| 360 |
+
});
|
| 361 |
+
|
| 362 |
app.get('/api/rewards', async (req, res) => {
|
| 363 |
const filter = getQueryFilter(req);
|
| 364 |
if(req.query.studentId) filter.studentId = req.query.studentId;
|
|
|
|
| 366 |
});
|
| 367 |
app.post('/api/rewards', async (req, res) => {
|
| 368 |
const data = injectSchoolId(req, req.body);
|
| 369 |
+
// Ensure count default
|
| 370 |
+
if (!data.count) data.count = 1;
|
| 371 |
+
|
| 372 |
if(data.rewardType==='DRAW_COUNT') {
|
| 373 |
data.status='REDEEMED';
|
| 374 |
+
await Student.findByIdAndUpdate(data.studentId, {$inc:{drawAttempts:data.count}});
|
| 375 |
}
|
| 376 |
await StudentRewardModel.create(data);
|
| 377 |
res.json({});
|
services/api.ts
CHANGED
|
@@ -170,15 +170,15 @@ export const api = {
|
|
| 170 |
getLuckyConfig: () => request('/games/lucky-config'),
|
| 171 |
saveLuckyConfig: (data: LuckyDrawConfig) => request('/games/lucky-config', { method: 'POST', body: JSON.stringify(data) }),
|
| 172 |
drawLucky: (studentId: string) => request('/games/lucky-draw', { method: 'POST', body: JSON.stringify({ studentId }) }),
|
| 173 |
-
|
| 174 |
},
|
| 175 |
rewards: {
|
| 176 |
getMyRewards: (studentId: string) => request(`/rewards?studentId=${studentId}`),
|
| 177 |
-
// For teachers to get class rewards, we can use the same endpoint but passing a filter in backend or allow listing all
|
| 178 |
getClassRewards: () => request('/rewards?scope=class'),
|
| 179 |
addReward: (data: Partial<StudentReward>) => request('/rewards', { method: 'POST', body: JSON.stringify(data) }),
|
|
|
|
|
|
|
| 180 |
redeem: (id: string) => request(`/rewards/${id}/redeem`, { method: 'POST' }),
|
| 181 |
-
consumeDraw: (studentId: string) => request(`/rewards/consume-draw`, { method: 'POST', body: JSON.stringify({ studentId }) })
|
| 182 |
},
|
| 183 |
|
| 184 |
batchDelete: (type: 'student' | 'score' | 'user', ids: string[]) => {
|
|
|
|
| 170 |
getLuckyConfig: () => request('/games/lucky-config'),
|
| 171 |
saveLuckyConfig: (data: LuckyDrawConfig) => request('/games/lucky-config', { method: 'POST', body: JSON.stringify(data) }),
|
| 172 |
drawLucky: (studentId: string) => request('/games/lucky-draw', { method: 'POST', body: JSON.stringify({ studentId }) }),
|
| 173 |
+
grantReward: (data: { studentId: string, count: number, rewardType: string, name?: string }) => request('/games/grant-reward', { method: 'POST', body: JSON.stringify(data) }),
|
| 174 |
},
|
| 175 |
rewards: {
|
| 176 |
getMyRewards: (studentId: string) => request(`/rewards?studentId=${studentId}`),
|
|
|
|
| 177 |
getClassRewards: () => request('/rewards?scope=class'),
|
| 178 |
addReward: (data: Partial<StudentReward>) => request('/rewards', { method: 'POST', body: JSON.stringify(data) }),
|
| 179 |
+
update: (id: string, data: Partial<StudentReward>) => request(`/rewards/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
| 180 |
+
delete: (id: string) => request(`/rewards/${id}`, { method: 'DELETE' }),
|
| 181 |
redeem: (id: string) => request(`/rewards/${id}/redeem`, { method: 'POST' }),
|
|
|
|
| 182 |
},
|
| 183 |
|
| 184 |
batchDelete: (type: 'student' | 'score' | 'user', ids: string[]) => {
|
types.ts
CHANGED
|
@@ -194,8 +194,9 @@ export interface StudentReward {
|
|
| 194 |
schoolId?: string;
|
| 195 |
studentId: string; // Link to Student
|
| 196 |
studentName: string;
|
| 197 |
-
rewardType: 'ITEM' | 'DRAW_COUNT';
|
| 198 |
name: string;
|
|
|
|
| 199 |
status: 'PENDING' | 'REDEEMED';
|
| 200 |
source: string; // "Mountain Game"
|
| 201 |
createTime: string;
|
|
|
|
| 194 |
schoolId?: string;
|
| 195 |
studentId: string; // Link to Student
|
| 196 |
studentName: string;
|
| 197 |
+
rewardType: 'ITEM' | 'DRAW_COUNT' | 'CONSOLATION'; // Updated
|
| 198 |
name: string;
|
| 199 |
+
count?: number; // Quantity
|
| 200 |
status: 'PENDING' | 'REDEEMED';
|
| 201 |
source: string; // "Mountain Game"
|
| 202 |
createTime: string;
|