Spaces:
Sleeping
Sleeping
Upload 29 files
Browse files- index.css +27 -0
- pages/Games.tsx +319 -123
- server.js +78 -3
- services/api.ts +2 -3
- types.ts +10 -2
index.css
CHANGED
|
@@ -12,3 +12,30 @@ body {
|
|
| 12 |
-moz-osx-font-smoothing: grayscale;
|
| 13 |
background-color: #f9fafb;
|
| 14 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
-moz-osx-font-smoothing: grayscale;
|
| 13 |
background-color: #f9fafb;
|
| 14 |
}
|
| 15 |
+
|
| 16 |
+
/* Custom Scrollbar */
|
| 17 |
+
.custom-scrollbar::-webkit-scrollbar {
|
| 18 |
+
width: 6px;
|
| 19 |
+
height: 6px;
|
| 20 |
+
}
|
| 21 |
+
.custom-scrollbar::-webkit-scrollbar-track {
|
| 22 |
+
background: transparent;
|
| 23 |
+
}
|
| 24 |
+
.custom-scrollbar::-webkit-scrollbar-thumb {
|
| 25 |
+
background-color: rgba(156, 163, 175, 0.5);
|
| 26 |
+
border-radius: 3px;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
/* Card Flip Animation */
|
| 30 |
+
.perspective-1000 {
|
| 31 |
+
perspective: 1000px;
|
| 32 |
+
}
|
| 33 |
+
.transform-style-3d {
|
| 34 |
+
transform-style: preserve-3d;
|
| 35 |
+
}
|
| 36 |
+
.backface-hidden {
|
| 37 |
+
backface-visibility: hidden;
|
| 38 |
+
}
|
| 39 |
+
.rotate-y-180 {
|
| 40 |
+
transform: rotateY(180deg);
|
| 41 |
+
}
|
pages/Games.tsx
CHANGED
|
@@ -1,19 +1,40 @@
|
|
| 1 |
|
| 2 |
import React, { useState, useEffect } from 'react';
|
| 3 |
import { api } from '../services/api';
|
| 4 |
-
import { GameSession, GameTeam, Student, StudentReward, LuckyDrawConfig } from '../types';
|
| 5 |
-
import { Trophy, Gift, Lock, Settings, Plus, Minus, Users, RefreshCw, Star, ArrowRight, Loader2 } from 'lucide-react';
|
| 6 |
|
| 7 |
-
// ---
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
const MountainStage = ({ team, index, rewardsConfig, maxSteps }: { team: GameTeam, index: number, rewardsConfig: any[], maxSteps: number }) => {
|
| 10 |
const percentage = Math.min(Math.max(team.score, 0), maxSteps) / maxSteps;
|
| 11 |
const bottomPos = 5 + (percentage * 85);
|
| 12 |
|
| 13 |
return (
|
| 14 |
-
<div className="relative flex flex-col items-center justify-end h-[400px] w-48 mx-4 flex-shrink-0 select-none group">
|
| 15 |
-
<div className="absolute -top-12 text-center w-[140%] z-20">
|
| 16 |
-
<h3 className="text-lg font-black text-slate-800 bg-white/90 px-3 py-1 rounded-xl shadow-sm border border-white/60">
|
| 17 |
{team.name}
|
| 18 |
</h3>
|
| 19 |
</div>
|
|
@@ -35,7 +56,7 @@ const MountainStage = ({ team, index, rewardsConfig, maxSteps }: { team: GameTea
|
|
| 35 |
<div key={i} className="relative w-full h-full flex items-center justify-center">
|
| 36 |
<div className="w-full h-1 bg-amber-700/50 rounded-sm"></div>
|
| 37 |
{reward && (
|
| 38 |
-
<div className={`absolute left-full ml-2 px-2 py-1 rounded text-[10px] font-bold whitespace-nowrap border ${isUnlocked ? 'bg-yellow-100 border-yellow-300 text-yellow-700' : 'bg-gray-100 border-gray-200 text-gray-400'}`}>
|
| 39 |
{reward.rewardType === 'DRAW_COUNT' ? '🎲' : '🎁'} {reward.rewardName}
|
| 40 |
</div>
|
| 41 |
)}
|
|
@@ -46,8 +67,8 @@ const MountainStage = ({ team, index, rewardsConfig, maxSteps }: { team: GameTea
|
|
| 46 |
|
| 47 |
{/* Climber */}
|
| 48 |
<div className="absolute z-30 transition-all duration-700 ease-out flex flex-col items-center" style={{ bottom: `${bottomPos}%` }}>
|
| 49 |
-
<div className="w-12 h-12 bg-white rounded-full border-4 shadow-lg flex items-center justify-center transform hover:scale-110 transition-transform" style={{ borderColor: team.color }}>
|
| 50 |
-
<span className="text-2xl">{team.avatar || '🚩'}</span>
|
| 51 |
<div className="absolute -top-2 -right-2 bg-red-500 text-white text-[10px] font-bold w-5 h-5 rounded-full flex items-center justify-center border border-white">
|
| 52 |
{team.score}
|
| 53 |
</div>
|
|
@@ -57,38 +78,6 @@ const MountainStage = ({ team, index, rewardsConfig, maxSteps }: { team: GameTea
|
|
| 57 |
);
|
| 58 |
};
|
| 59 |
|
| 60 |
-
// --- Lucky Wheel Sub-Components ---
|
| 61 |
-
|
| 62 |
-
const LuckyGrid = ({ prizes, onDraw, remaining, isSpinning }: { prizes: string[], onDraw: () => void, remaining: number, isSpinning: boolean }) => {
|
| 63 |
-
return (
|
| 64 |
-
<div className="relative w-full max-w-md mx-auto aspect-square bg-red-600 rounded-3xl p-4 shadow-2xl border-4 border-yellow-400">
|
| 65 |
-
<div className="grid grid-cols-3 gap-2 h-full">
|
| 66 |
-
{/* 8 items around, center is button */}
|
| 67 |
-
{[0, 1, 2, 7, -1, 3, 6, 5, 4].map((idx, pos) => {
|
| 68 |
-
if (idx === -1) {
|
| 69 |
-
return (
|
| 70 |
-
<button
|
| 71 |
-
key="btn"
|
| 72 |
-
onClick={onDraw}
|
| 73 |
-
disabled={isSpinning || remaining <= 0}
|
| 74 |
-
className="bg-yellow-400 hover:bg-yellow-300 active:scale-95 disabled:opacity-50 disabled:scale-100 transition-all rounded-xl flex flex-col items-center justify-center shadow-inner border-b-4 border-yellow-600"
|
| 75 |
-
>
|
| 76 |
-
<span className="text-2xl font-black text-red-700">抽奖</span>
|
| 77 |
-
<span className="text-xs font-bold text-red-800">剩 {remaining} 次</span>
|
| 78 |
-
</button>
|
| 79 |
-
);
|
| 80 |
-
}
|
| 81 |
-
return (
|
| 82 |
-
<div key={pos} className="bg-red-50 rounded-xl flex items-center justify-center text-center p-1 shadow-sm border border-red-100">
|
| 83 |
-
<span className="text-sm font-bold text-red-800 break-words w-full">{prizes[idx] || '谢谢参与'}</span>
|
| 84 |
-
</div>
|
| 85 |
-
);
|
| 86 |
-
})}
|
| 87 |
-
</div>
|
| 88 |
-
</div>
|
| 89 |
-
);
|
| 90 |
-
};
|
| 91 |
-
|
| 92 |
// --- Main Page ---
|
| 93 |
|
| 94 |
export const Games: React.FC = () => {
|
|
@@ -99,9 +88,15 @@ export const Games: React.FC = () => {
|
|
| 99 |
const [studentInfo, setStudentInfo] = useState<Student | null>(null);
|
| 100 |
const [loading, setLoading] = useState(true);
|
| 101 |
|
| 102 |
-
// Teacher Controls
|
| 103 |
-
const [
|
|
|
|
| 104 |
const [students, setStudents] = useState<Student[]>([]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
|
| 106 |
const currentUser = api.auth.getCurrentUser();
|
| 107 |
const isTeacher = currentUser?.role === 'TEACHER';
|
|
@@ -115,7 +110,6 @@ export const Games: React.FC = () => {
|
|
| 115 |
try {
|
| 116 |
if (!currentUser) return;
|
| 117 |
|
| 118 |
-
// Load context based on user
|
| 119 |
let targetClass = '';
|
| 120 |
if (isTeacher && currentUser.homeroomClass) targetClass = currentUser.homeroomClass;
|
| 121 |
else if (currentUser.role === 'STUDENT') {
|
|
@@ -132,7 +126,7 @@ export const Games: React.FC = () => {
|
|
| 132 |
const sess = await api.games.getMountainSession(targetClass);
|
| 133 |
if (sess) setSession(sess);
|
| 134 |
else if (isTeacher && currentUser?.schoolId) {
|
| 135 |
-
// Init default session
|
| 136 |
const newSess: GameSession = {
|
| 137 |
schoolId: currentUser.schoolId,
|
| 138 |
className: targetClass,
|
|
@@ -140,11 +134,11 @@ export const Games: React.FC = () => {
|
|
| 140 |
isEnabled: true,
|
| 141 |
maxSteps: 10,
|
| 142 |
teams: [
|
| 143 |
-
{ id: '1', name: '
|
| 144 |
-
{ id: '2', name: '
|
| 145 |
],
|
| 146 |
rewardsConfig: [
|
| 147 |
-
{ scoreThreshold: 5, rewardType: 'DRAW_COUNT', rewardName: '抽奖券', rewardValue: 1 },
|
| 148 |
{ scoreThreshold: 10, rewardType: 'ITEM', rewardName: '神秘大奖', rewardValue: 1 }
|
| 149 |
]
|
| 150 |
};
|
|
@@ -156,7 +150,6 @@ export const Games: React.FC = () => {
|
|
| 156 |
const lCfg = await api.games.getLuckyConfig();
|
| 157 |
setLuckyConfig(lCfg);
|
| 158 |
|
| 159 |
-
// Load Rewards if Student
|
| 160 |
if (currentUser.role === 'STUDENT' && studentInfo && studentInfo._id) {
|
| 161 |
const rews = await api.rewards.getMyRewards(studentInfo._id);
|
| 162 |
setMyRewards(rews);
|
|
@@ -170,17 +163,17 @@ export const Games: React.FC = () => {
|
|
| 170 |
finally { setLoading(false); }
|
| 171 |
};
|
| 172 |
|
|
|
|
|
|
|
| 173 |
const handleScoreChange = async (teamId: string, delta: number) => {
|
| 174 |
if (!session || !isTeacher) return;
|
| 175 |
const newTeams = session.teams.map(t => {
|
| 176 |
if (t.id !== teamId) return t;
|
| 177 |
const newScore = Math.max(0, Math.min(session.maxSteps, t.score + delta));
|
| 178 |
|
| 179 |
-
// Check for Reward Trigger (Scaling up)
|
| 180 |
if (delta > 0) {
|
| 181 |
const reward = session.rewardsConfig.find(r => r.scoreThreshold === newScore);
|
| 182 |
if (reward) {
|
| 183 |
-
// Distribute Reward to all members
|
| 184 |
t.members.forEach(stuId => {
|
| 185 |
const stu = students.find(s => (s._id || s.id) == stuId);
|
| 186 |
if (stu) {
|
|
@@ -206,81 +199,89 @@ export const Games: React.FC = () => {
|
|
| 206 |
await api.games.saveMountainSession(newSession);
|
| 207 |
};
|
| 208 |
|
| 209 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 210 |
const handleDraw = async () => {
|
| 211 |
-
if (!studentInfo || !luckyConfig) return;
|
| 212 |
if ((studentInfo.drawAttempts || 0) <= 0) return alert('抽奖次数不足!请通过“群岳争锋”游戏获取。');
|
| 213 |
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
status: 'PENDING',
|
| 231 |
-
source: '幸运大抽奖'
|
| 232 |
-
});
|
| 233 |
|
| 234 |
-
|
| 235 |
-
|
|
|
|
| 236 |
};
|
| 237 |
|
| 238 |
if (loading) return <div className="p-10 text-center"><Loader2 className="animate-spin inline"/></div>;
|
| 239 |
|
| 240 |
-
if (!isTeacher && !session?.isEnabled)
|
| 241 |
-
return <div className="p-10 text-center text-gray-400">老师尚未开启互动教学功能</div>;
|
| 242 |
-
}
|
| 243 |
|
| 244 |
return (
|
| 245 |
<div className="space-y-6">
|
| 246 |
{/* Game Switcher */}
|
| 247 |
<div className="flex justify-center space-x-4 mb-6">
|
| 248 |
-
<button
|
| 249 |
-
onClick={() => setActiveGame('mountain')}
|
| 250 |
-
className={`px-6 py-3 rounded-2xl font-bold flex items-center transition-all ${activeGame === 'mountain' ? 'bg-gradient-to-r from-green-500 to-emerald-600 text-white shadow-lg scale-105' : 'bg-white text-gray-500 hover:bg-gray-50'}`}
|
| 251 |
-
>
|
| 252 |
<Trophy className="mr-2"/> 群岳争锋
|
| 253 |
</button>
|
| 254 |
-
<button
|
| 255 |
-
onClick={() => setActiveGame('lucky')}
|
| 256 |
-
className={`px-6 py-3 rounded-2xl font-bold flex items-center transition-all ${activeGame === 'lucky' ? 'bg-gradient-to-r from-red-500 to-pink-600 text-white shadow-lg scale-105' : 'bg-white text-gray-500 hover:bg-gray-50'}`}
|
| 257 |
-
>
|
| 258 |
<Gift className="mr-2"/> 幸运红包
|
| 259 |
</button>
|
| 260 |
</div>
|
| 261 |
|
| 262 |
{/* --- MOUNTAIN GAME --- */}
|
| 263 |
{activeGame === 'mountain' && session && (
|
| 264 |
-
<div className="animate-in fade-in">
|
| 265 |
{isTeacher && (
|
| 266 |
<div className="flex justify-end mb-4">
|
| 267 |
-
<button onClick={() =>
|
|
|
|
|
|
|
| 268 |
</div>
|
| 269 |
)}
|
| 270 |
|
| 271 |
<div className="bg-gradient-to-b from-sky-200 to-white rounded-3xl p-8 overflow-x-auto min-h-[500px] border border-sky-100 shadow-inner relative">
|
| 272 |
-
<div className="flex items-end min-w-max mx-auto justify-center gap-
|
| 273 |
{session.teams.map((team, idx) => (
|
| 274 |
<div key={team.id} className="relative group">
|
| 275 |
-
<MountainStage
|
| 276 |
-
team={team}
|
| 277 |
-
index={idx}
|
| 278 |
-
rewardsConfig={session.rewardsConfig}
|
| 279 |
-
maxSteps={session.maxSteps}
|
| 280 |
-
/>
|
| 281 |
-
{/* Teacher Controls */}
|
| 282 |
{isTeacher && (
|
| 283 |
-
<div className="absolute -bottom-12 left-1/2 -translate-x-1/2 flex items-center bg-white rounded-full shadow-lg p-1 border border-gray-100 opacity-0 group-hover:opacity-100 transition-opacity">
|
| 284 |
<button onClick={() => handleScoreChange(team.id, -1)} className="p-2 hover:bg-gray-100 rounded-full text-gray-500"><Minus size={16}/></button>
|
| 285 |
<span className="w-8 text-center font-bold text-gray-700">{team.score}</span>
|
| 286 |
<button onClick={() => handleScoreChange(team.id, 1)} className="p-2 hover:bg-blue-50 rounded-full text-blue-600"><Plus size={16}/></button>
|
|
@@ -295,37 +296,230 @@ export const Games: React.FC = () => {
|
|
| 295 |
|
| 296 |
{/* --- LUCKY DRAW GAME --- */}
|
| 297 |
{activeGame === 'lucky' && luckyConfig && (
|
| 298 |
-
<div className="animate-in fade-in
|
| 299 |
-
|
| 300 |
-
<div className="
|
| 301 |
-
<
|
| 302 |
-
|
| 303 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 304 |
</div>
|
| 305 |
-
<LuckyGrid
|
| 306 |
-
prizes={luckyConfig.prizes}
|
| 307 |
-
onDraw={handleDraw}
|
| 308 |
-
remaining={studentInfo?.drawAttempts || 0}
|
| 309 |
-
isSpinning={false}
|
| 310 |
-
/>
|
| 311 |
</div>
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 322 |
</div>
|
| 323 |
-
{r.status === 'REDEEMED'
|
| 324 |
-
? <span className="text-xs bg-gray-200 text-gray-500 px-2 py-1 rounded">已兑换</span>
|
| 325 |
-
: <span className="text-xs bg-green-100 text-green-700 px-2 py-1 rounded cursor-pointer">未兑换</span>
|
| 326 |
-
}
|
| 327 |
</div>
|
| 328 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 329 |
</div>
|
| 330 |
</div>
|
| 331 |
</div>
|
|
@@ -333,3 +527,5 @@ export const Games: React.FC = () => {
|
|
| 333 |
</div>
|
| 334 |
);
|
| 335 |
};
|
|
|
|
|
|
|
|
|
| 1 |
|
| 2 |
import React, { useState, useEffect } from 'react';
|
| 3 |
import { api } from '../services/api';
|
| 4 |
+
import { GameSession, GameTeam, Student, StudentReward, LuckyDrawConfig, LuckyPrize } from '../types';
|
| 5 |
+
import { Trophy, Gift, Lock, Settings, Plus, Minus, Users, RefreshCw, Star, ArrowRight, Loader2, Save, Trash2, X, AlertCircle } from 'lucide-react';
|
| 6 |
|
| 7 |
+
// --- Components ---
|
| 8 |
+
|
| 9 |
+
const FlipCard = ({ prize, onFlip, isRevealed }: { prize: string, onFlip: () => void, isRevealed: boolean }) => {
|
| 10 |
+
return (
|
| 11 |
+
<div className="relative w-full aspect-[3/4] cursor-pointer perspective-1000 group" onClick={!isRevealed ? onFlip : undefined}>
|
| 12 |
+
<div className={`relative w-full h-full text-center transition-transform duration-700 transform-style-3d shadow-xl rounded-xl ${isRevealed ? 'rotate-y-180' : 'group-hover:-translate-y-2'}`}>
|
| 13 |
+
{/* Front (Red Packet) */}
|
| 14 |
+
<div className="absolute w-full h-full backface-hidden bg-gradient-to-br from-red-500 to-red-700 rounded-xl flex flex-col items-center justify-center border-4 border-yellow-300 shadow-inner">
|
| 15 |
+
<div className="w-12 h-12 bg-yellow-200 rounded-full flex items-center justify-center mb-2 shadow-md border-2 border-yellow-400">
|
| 16 |
+
<span className="text-xl">🧧</span>
|
| 17 |
+
</div>
|
| 18 |
+
<span className="text-yellow-100 font-bold text-lg tracking-widest">開</span>
|
| 19 |
+
</div>
|
| 20 |
+
{/* Back (Prize) */}
|
| 21 |
+
<div className="absolute w-full h-full backface-hidden bg-white rounded-xl flex flex-col items-center justify-center border-2 border-red-200 rotate-y-180 shadow-inner p-2">
|
| 22 |
+
<span className="text-3xl mb-2">🎁</span>
|
| 23 |
+
<span className="text-red-600 font-bold text-sm break-words leading-tight">{prize}</span>
|
| 24 |
+
</div>
|
| 25 |
+
</div>
|
| 26 |
+
</div>
|
| 27 |
+
);
|
| 28 |
+
};
|
| 29 |
|
| 30 |
const MountainStage = ({ team, index, rewardsConfig, maxSteps }: { team: GameTeam, index: number, rewardsConfig: any[], maxSteps: number }) => {
|
| 31 |
const percentage = Math.min(Math.max(team.score, 0), maxSteps) / maxSteps;
|
| 32 |
const bottomPos = 5 + (percentage * 85);
|
| 33 |
|
| 34 |
return (
|
| 35 |
+
<div className="relative flex flex-col items-center justify-end h-[400px] w-32 md:w-48 mx-2 md:mx-4 flex-shrink-0 select-none group">
|
| 36 |
+
<div className="absolute -top-12 text-center w-[140%] z-20 transition-transform hover:-translate-y-1">
|
| 37 |
+
<h3 className="text-base md:text-lg font-black text-slate-800 bg-white/90 px-3 py-1 rounded-xl shadow-sm border border-white/60 truncate max-w-full">
|
| 38 |
{team.name}
|
| 39 |
</h3>
|
| 40 |
</div>
|
|
|
|
| 56 |
<div key={i} className="relative w-full h-full flex items-center justify-center">
|
| 57 |
<div className="w-full h-1 bg-amber-700/50 rounded-sm"></div>
|
| 58 |
{reward && (
|
| 59 |
+
<div className={`absolute left-full ml-2 px-2 py-1 rounded text-[10px] font-bold whitespace-nowrap border z-20 ${isUnlocked ? 'bg-yellow-100 border-yellow-300 text-yellow-700' : 'bg-gray-100 border-gray-200 text-gray-400'}`}>
|
| 60 |
{reward.rewardType === 'DRAW_COUNT' ? '🎲' : '🎁'} {reward.rewardName}
|
| 61 |
</div>
|
| 62 |
)}
|
|
|
|
| 67 |
|
| 68 |
{/* Climber */}
|
| 69 |
<div className="absolute z-30 transition-all duration-700 ease-out flex flex-col items-center" style={{ bottom: `${bottomPos}%` }}>
|
| 70 |
+
<div className="w-10 h-10 md:w-12 md:h-12 bg-white rounded-full border-4 shadow-lg flex items-center justify-center transform hover:scale-110 transition-transform" style={{ borderColor: team.color }}>
|
| 71 |
+
<span className="text-xl md:text-2xl">{team.avatar || '🚩'}</span>
|
| 72 |
<div className="absolute -top-2 -right-2 bg-red-500 text-white text-[10px] font-bold w-5 h-5 rounded-full flex items-center justify-center border border-white">
|
| 73 |
{team.score}
|
| 74 |
</div>
|
|
|
|
| 78 |
);
|
| 79 |
};
|
| 80 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
// --- Main Page ---
|
| 82 |
|
| 83 |
export const Games: React.FC = () => {
|
|
|
|
| 88 |
const [studentInfo, setStudentInfo] = useState<Student | null>(null);
|
| 89 |
const [loading, setLoading] = useState(true);
|
| 90 |
|
| 91 |
+
// Teacher Controls State
|
| 92 |
+
const [isMtSettingsOpen, setIsMtSettingsOpen] = useState(false);
|
| 93 |
+
const [isLuckySettingsOpen, setIsLuckySettingsOpen] = useState(false);
|
| 94 |
const [students, setStudents] = useState<Student[]>([]);
|
| 95 |
+
const [selectedTeamId, setSelectedTeamId] = useState<string | null>(null); // For member management
|
| 96 |
+
|
| 97 |
+
// Lucky Draw State
|
| 98 |
+
const [drawResult, setDrawResult] = useState<{prize: string} | null>(null);
|
| 99 |
+
const [isFlipping, setIsFlipping] = useState(false);
|
| 100 |
|
| 101 |
const currentUser = api.auth.getCurrentUser();
|
| 102 |
const isTeacher = currentUser?.role === 'TEACHER';
|
|
|
|
| 110 |
try {
|
| 111 |
if (!currentUser) return;
|
| 112 |
|
|
|
|
| 113 |
let targetClass = '';
|
| 114 |
if (isTeacher && currentUser.homeroomClass) targetClass = currentUser.homeroomClass;
|
| 115 |
else if (currentUser.role === 'STUDENT') {
|
|
|
|
| 126 |
const sess = await api.games.getMountainSession(targetClass);
|
| 127 |
if (sess) setSession(sess);
|
| 128 |
else if (isTeacher && currentUser?.schoolId) {
|
| 129 |
+
// Init default session
|
| 130 |
const newSess: GameSession = {
|
| 131 |
schoolId: currentUser.schoolId,
|
| 132 |
className: targetClass,
|
|
|
|
| 134 |
isEnabled: true,
|
| 135 |
maxSteps: 10,
|
| 136 |
teams: [
|
| 137 |
+
{ id: '1', name: '红队', score: 0, avatar: '🚀', color: '#ef4444', members: [] },
|
| 138 |
+
{ id: '2', name: '蓝队', score: 0, avatar: '🦅', color: '#3b82f6', members: [] }
|
| 139 |
],
|
| 140 |
rewardsConfig: [
|
| 141 |
+
{ scoreThreshold: 5, rewardType: 'DRAW_COUNT', rewardName: '抽奖券x1', rewardValue: 1 },
|
| 142 |
{ scoreThreshold: 10, rewardType: 'ITEM', rewardName: '神秘大奖', rewardValue: 1 }
|
| 143 |
]
|
| 144 |
};
|
|
|
|
| 150 |
const lCfg = await api.games.getLuckyConfig();
|
| 151 |
setLuckyConfig(lCfg);
|
| 152 |
|
|
|
|
| 153 |
if (currentUser.role === 'STUDENT' && studentInfo && studentInfo._id) {
|
| 154 |
const rews = await api.rewards.getMyRewards(studentInfo._id);
|
| 155 |
setMyRewards(rews);
|
|
|
|
| 163 |
finally { setLoading(false); }
|
| 164 |
};
|
| 165 |
|
| 166 |
+
// --- Mountain Logic ---
|
| 167 |
+
|
| 168 |
const handleScoreChange = async (teamId: string, delta: number) => {
|
| 169 |
if (!session || !isTeacher) return;
|
| 170 |
const newTeams = session.teams.map(t => {
|
| 171 |
if (t.id !== teamId) return t;
|
| 172 |
const newScore = Math.max(0, Math.min(session.maxSteps, t.score + delta));
|
| 173 |
|
|
|
|
| 174 |
if (delta > 0) {
|
| 175 |
const reward = session.rewardsConfig.find(r => r.scoreThreshold === newScore);
|
| 176 |
if (reward) {
|
|
|
|
| 177 |
t.members.forEach(stuId => {
|
| 178 |
const stu = students.find(s => (s._id || s.id) == stuId);
|
| 179 |
if (stu) {
|
|
|
|
| 199 |
await api.games.saveMountainSession(newSession);
|
| 200 |
};
|
| 201 |
|
| 202 |
+
const saveMountainSettings = async () => {
|
| 203 |
+
if (session) await api.games.saveMountainSession(session);
|
| 204 |
+
setIsMtSettingsOpen(false);
|
| 205 |
+
};
|
| 206 |
+
|
| 207 |
+
const toggleTeamMember = (studentId: string, teamId: string) => {
|
| 208 |
+
if (!session) return;
|
| 209 |
+
const newTeams = session.teams.map(t => {
|
| 210 |
+
const members = new Set(t.members);
|
| 211 |
+
if (t.id === teamId) {
|
| 212 |
+
// Add to target team
|
| 213 |
+
members.add(studentId);
|
| 214 |
+
} else {
|
| 215 |
+
// Remove from other teams (exclusive membership)
|
| 216 |
+
members.delete(studentId);
|
| 217 |
+
}
|
| 218 |
+
return { ...t, members: Array.from(members) };
|
| 219 |
+
});
|
| 220 |
+
setSession({ ...session, teams: newTeams });
|
| 221 |
+
};
|
| 222 |
+
|
| 223 |
+
// --- Lucky Draw Logic ---
|
| 224 |
+
|
| 225 |
const handleDraw = async () => {
|
| 226 |
+
if (!studentInfo || !luckyConfig || isFlipping) return;
|
| 227 |
if ((studentInfo.drawAttempts || 0) <= 0) return alert('抽奖次数不足!请通过“群岳争锋”游戏获取。');
|
| 228 |
|
| 229 |
+
setIsFlipping(true);
|
| 230 |
+
try {
|
| 231 |
+
const res = await api.games.drawLucky(studentInfo._id!);
|
| 232 |
+
setDrawResult(res);
|
| 233 |
+
setStudentInfo({ ...studentInfo, drawAttempts: (studentInfo.drawAttempts || 0) - 1 });
|
| 234 |
+
setTimeout(() => {
|
| 235 |
+
alert(`🎁 恭喜!你抽中了:${res.prize}`);
|
| 236 |
+
setIsFlipping(false);
|
| 237 |
+
setDrawResult(null);
|
| 238 |
+
loadData(); // Reload rewards list
|
| 239 |
+
}, 1500);
|
| 240 |
+
} catch (e) {
|
| 241 |
+
alert('抽奖失败,请稍后重试');
|
| 242 |
+
setIsFlipping(false);
|
| 243 |
+
}
|
| 244 |
+
};
|
|
|
|
|
|
|
|
|
|
| 245 |
|
| 246 |
+
const saveLuckySettings = async () => {
|
| 247 |
+
if (luckyConfig) await api.games.saveLuckyConfig(luckyConfig);
|
| 248 |
+
setIsLuckySettingsOpen(false);
|
| 249 |
};
|
| 250 |
|
| 251 |
if (loading) return <div className="p-10 text-center"><Loader2 className="animate-spin inline"/></div>;
|
| 252 |
|
| 253 |
+
if (!isTeacher && !session?.isEnabled) return <div className="p-10 text-center text-gray-400">老师尚未开启互动教学功能</div>;
|
|
|
|
|
|
|
| 254 |
|
| 255 |
return (
|
| 256 |
<div className="space-y-6">
|
| 257 |
{/* Game Switcher */}
|
| 258 |
<div className="flex justify-center space-x-4 mb-6">
|
| 259 |
+
<button onClick={() => setActiveGame('mountain')} className={`px-6 py-3 rounded-2xl font-bold flex items-center transition-all ${activeGame === 'mountain' ? 'bg-gradient-to-r from-green-500 to-emerald-600 text-white shadow-lg scale-105' : 'bg-white text-gray-500 hover:bg-gray-50'}`}>
|
|
|
|
|
|
|
|
|
|
| 260 |
<Trophy className="mr-2"/> 群岳争锋
|
| 261 |
</button>
|
| 262 |
+
<button onClick={() => setActiveGame('lucky')} className={`px-6 py-3 rounded-2xl font-bold flex items-center transition-all ${activeGame === 'lucky' ? 'bg-gradient-to-r from-red-500 to-pink-600 text-white shadow-lg scale-105' : 'bg-white text-gray-500 hover:bg-gray-50'}`}>
|
|
|
|
|
|
|
|
|
|
| 263 |
<Gift className="mr-2"/> 幸运红包
|
| 264 |
</button>
|
| 265 |
</div>
|
| 266 |
|
| 267 |
{/* --- MOUNTAIN GAME --- */}
|
| 268 |
{activeGame === 'mountain' && session && (
|
| 269 |
+
<div className="animate-in fade-in relative">
|
| 270 |
{isTeacher && (
|
| 271 |
<div className="flex justify-end mb-4">
|
| 272 |
+
<button onClick={() => setIsMtSettingsOpen(true)} className="flex items-center text-sm font-bold text-blue-600 bg-blue-50 px-4 py-2 rounded-lg border border-blue-200 hover:bg-blue-100 shadow-sm z-10">
|
| 273 |
+
<Settings size={16} className="mr-2"/> 游戏设置 & 成员管理
|
| 274 |
+
</button>
|
| 275 |
</div>
|
| 276 |
)}
|
| 277 |
|
| 278 |
<div className="bg-gradient-to-b from-sky-200 to-white rounded-3xl p-8 overflow-x-auto min-h-[500px] border border-sky-100 shadow-inner relative">
|
| 279 |
+
<div className="flex items-end min-w-max mx-auto justify-center gap-4 pb-10">
|
| 280 |
{session.teams.map((team, idx) => (
|
| 281 |
<div key={team.id} className="relative group">
|
| 282 |
+
<MountainStage team={team} index={idx} rewardsConfig={session.rewardsConfig} maxSteps={session.maxSteps} />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 283 |
{isTeacher && (
|
| 284 |
+
<div className="absolute -bottom-12 left-1/2 -translate-x-1/2 flex items-center bg-white rounded-full shadow-lg p-1 border border-gray-100 opacity-0 group-hover:opacity-100 transition-opacity z-20">
|
| 285 |
<button onClick={() => handleScoreChange(team.id, -1)} className="p-2 hover:bg-gray-100 rounded-full text-gray-500"><Minus size={16}/></button>
|
| 286 |
<span className="w-8 text-center font-bold text-gray-700">{team.score}</span>
|
| 287 |
<button onClick={() => handleScoreChange(team.id, 1)} className="p-2 hover:bg-blue-50 rounded-full text-blue-600"><Plus size={16}/></button>
|
|
|
|
| 296 |
|
| 297 |
{/* --- LUCKY DRAW GAME --- */}
|
| 298 |
{activeGame === 'lucky' && luckyConfig && (
|
| 299 |
+
<div className="animate-in fade-in">
|
| 300 |
+
{isTeacher && (
|
| 301 |
+
<div className="flex justify-end mb-4">
|
| 302 |
+
<button onClick={() => setIsLuckySettingsOpen(true)} className="flex items-center text-sm font-bold text-blue-600 bg-blue-50 px-4 py-2 rounded-lg border border-blue-200 hover:bg-blue-100 shadow-sm">
|
| 303 |
+
<Settings size={16} className="mr-2"/> 奖池配置
|
| 304 |
+
</button>
|
| 305 |
+
</div>
|
| 306 |
+
)}
|
| 307 |
+
|
| 308 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
| 309 |
+
<div className="flex flex-col items-center">
|
| 310 |
+
<div className="bg-white p-4 rounded-2xl shadow-sm border w-full max-w-md mb-6 text-center">
|
| 311 |
+
<h3 className="text-xl font-bold text-gray-800 mb-2">我的抽奖券</h3>
|
| 312 |
+
<div className="text-4xl font-black text-amber-500 mb-2">{studentInfo?.drawAttempts || 0}</div>
|
| 313 |
+
<p className="text-xs text-gray-400">每日上限 {luckyConfig.dailyLimit} 次 | 每次消耗 1 券</p>
|
| 314 |
+
</div>
|
| 315 |
+
|
| 316 |
+
{/* Grid Layout for Cards */}
|
| 317 |
+
<div className="grid grid-cols-3 gap-4 w-full max-w-md">
|
| 318 |
+
{Array.from({ length: 9 }).map((_, i) => (
|
| 319 |
+
<FlipCard
|
| 320 |
+
key={i}
|
| 321 |
+
prize={drawResult ? drawResult.prize : '???'}
|
| 322 |
+
onFlip={handleDraw}
|
| 323 |
+
isRevealed={!!drawResult}
|
| 324 |
+
/>
|
| 325 |
+
))}
|
| 326 |
+
</div>
|
| 327 |
+
</div>
|
| 328 |
+
|
| 329 |
+
{/* Rewards List */}
|
| 330 |
+
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 p-6 h-fit">
|
| 331 |
+
<h3 className="font-bold text-gray-800 mb-4 flex items-center"><Star className="text-yellow-400 mr-2"/> 我的战利品</h3>
|
| 332 |
+
<div className="space-y-3 max-h-[500px] overflow-y-auto custom-scrollbar pr-2">
|
| 333 |
+
{myRewards.length > 0 ? myRewards.map(r => (
|
| 334 |
+
<div key={r._id} className="flex justify-between items-center p-3 bg-gray-50 rounded-lg border border-gray-100">
|
| 335 |
+
<div>
|
| 336 |
+
<div className="font-bold text-gray-800 text-sm">{r.name}</div>
|
| 337 |
+
<div className="text-xs text-gray-400">{new Date(r.createTime).toLocaleDateString()} · {r.source}</div>
|
| 338 |
+
</div>
|
| 339 |
+
{r.status === 'REDEEMED'
|
| 340 |
+
? <span className="text-xs bg-gray-200 text-gray-500 px-2 py-1 rounded">已兑换</span>
|
| 341 |
+
: <span className="text-xs bg-green-100 text-green-700 px-2 py-1 rounded cursor-pointer border border-green-200">未兑换</span>
|
| 342 |
+
}
|
| 343 |
+
</div>
|
| 344 |
+
)) : <div className="text-center text-gray-400 py-10">暂无奖品,去登山吧!</div>}
|
| 345 |
+
</div>
|
| 346 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 347 |
</div>
|
| 348 |
+
</div>
|
| 349 |
+
)}
|
| 350 |
+
|
| 351 |
+
{/* --- MOUNTAIN SETTINGS MODAL --- */}
|
| 352 |
+
{isMtSettingsOpen && session && (
|
| 353 |
+
<div className="fixed inset-0 bg-black/60 z-[100] flex items-center justify-center p-4 backdrop-blur-sm">
|
| 354 |
+
<div className="bg-white rounded-2xl w-full max-w-4xl h-[85vh] flex flex-col shadow-2xl">
|
| 355 |
+
<div className="p-6 border-b border-gray-100 flex justify-between items-center">
|
| 356 |
+
<h3 className="text-xl font-bold text-gray-800">群岳争锋 - 游戏设置</h3>
|
| 357 |
+
<button onClick={() => setIsMtSettingsOpen(false)}><X size={24} className="text-gray-400 hover:text-gray-600"/></button>
|
| 358 |
+
</div>
|
| 359 |
+
|
| 360 |
+
<div className="flex-1 overflow-y-auto p-6 space-y-8">
|
| 361 |
+
{/* Basic Settings */}
|
| 362 |
+
<section>
|
| 363 |
+
<h4 className="font-bold text-gray-700 mb-3 flex items-center"><Settings size={16} className="mr-2"/> 基础参数</h4>
|
| 364 |
+
<div className="flex gap-4 items-center bg-gray-50 p-4 rounded-xl border border-gray-200">
|
| 365 |
+
<label className="text-sm font-medium">山峰总高度 (步数):</label>
|
| 366 |
+
<input type="number" className="border rounded px-2 py-1 w-20 text-center" value={session.maxSteps} onChange={e => setSession({...session, maxSteps: Number(e.target.value)})} min={5} max={50}/>
|
| 367 |
+
<div className="w-px h-6 bg-gray-300 mx-2"></div>
|
| 368 |
+
<label className="flex items-center text-sm cursor-pointer">
|
| 369 |
+
<input type="checkbox" checked={session.isEnabled} onChange={e => setSession({...session, isEnabled: e.target.checked})} className="mr-2"/> 启用游戏
|
| 370 |
+
</label>
|
| 371 |
+
</div>
|
| 372 |
+
</section>
|
| 373 |
+
|
| 374 |
+
{/* Team & Member Management */}
|
| 375 |
+
<section>
|
| 376 |
+
<div className="flex justify-between items-center mb-3">
|
| 377 |
+
<h4 className="font-bold text-gray-700 flex items-center"><Users size={16} className="mr-2"/> 队伍与成员管理</h4>
|
| 378 |
+
<button onClick={() => {
|
| 379 |
+
const newTeam: GameTeam = { id: Date.now().toString(), name: '新队伍', color: '#6366f1', avatar: '🚩', score: 0, members: [] };
|
| 380 |
+
setSession({ ...session, teams: [...session.teams, newTeam] });
|
| 381 |
+
}} className="text-xs bg-blue-100 text-blue-600 px-2 py-1 rounded hover:bg-blue-200">+ 新建队伍</button>
|
| 382 |
+
</div>
|
| 383 |
+
|
| 384 |
+
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
| 385 |
+
{/* Left: Team List */}
|
| 386 |
+
<div className="space-y-2 max-h-64 overflow-y-auto pr-2">
|
| 387 |
+
{session.teams.map(t => (
|
| 388 |
+
<div key={t.id}
|
| 389 |
+
className={`p-3 rounded-lg border cursor-pointer transition-all ${selectedTeamId === t.id ? 'border-blue-500 bg-blue-50 shadow-sm' : 'border-gray-200 hover:bg-gray-50'}`}
|
| 390 |
+
onClick={() => setSelectedTeamId(t.id)}
|
| 391 |
+
>
|
| 392 |
+
<div className="flex justify-between items-center mb-2">
|
| 393 |
+
<input value={t.name} onChange={e => {
|
| 394 |
+
const updated = session.teams.map(tm => tm.id === t.id ? {...tm, name: e.target.value} : tm);
|
| 395 |
+
setSession({...session, teams: updated});
|
| 396 |
+
}} className="bg-transparent font-bold text-sm w-24 outline-none border-b border-transparent focus:border-blue-300" />
|
| 397 |
+
<button onClick={(e) => {
|
| 398 |
+
e.stopPropagation();
|
| 399 |
+
if(confirm('删除队伍?')) setSession({...session, teams: session.teams.filter(tm => tm.id !== t.id)});
|
| 400 |
+
}} className="text-gray-300 hover:text-red-500"><Trash2 size={14}/></button>
|
| 401 |
+
</div>
|
| 402 |
+
<div className="flex gap-2">
|
| 403 |
+
<input type="color" value={t.color} onChange={e => {
|
| 404 |
+
const updated = session.teams.map(tm => tm.id === t.id ? {...tm, color: e.target.value} : tm);
|
| 405 |
+
setSession({...session, teams: updated});
|
| 406 |
+
}} className="w-6 h-6 p-0 border-0 rounded overflow-hidden" />
|
| 407 |
+
<input value={t.avatar} onChange={e => {
|
| 408 |
+
const updated = session.teams.map(tm => tm.id === t.id ? {...tm, avatar: e.target.value} : tm);
|
| 409 |
+
setSession({...session, teams: updated});
|
| 410 |
+
}} className="w-8 border rounded text-center text-sm" />
|
| 411 |
+
<span className="text-xs text-gray-400 self-center ml-auto">{t.members.length} 人</span>
|
| 412 |
+
</div>
|
| 413 |
+
</div>
|
| 414 |
+
))}
|
| 415 |
+
</div>
|
| 416 |
+
|
| 417 |
+
{/* Right: Member Shuttle */}
|
| 418 |
+
<div className="col-span-2 bg-gray-50 rounded-xl border border-gray-200 p-4 flex flex-col h-80">
|
| 419 |
+
{selectedTeamId ? (
|
| 420 |
+
<>
|
| 421 |
+
<div className="text-sm font-bold text-gray-700 mb-2 border-b pb-2">
|
| 422 |
+
配置 [{session.teams.find(t => t.id === selectedTeamId)?.name}] 的成员
|
| 423 |
+
</div>
|
| 424 |
+
<div className="flex-1 overflow-y-auto grid grid-cols-3 gap-2 content-start">
|
| 425 |
+
{students.map(s => {
|
| 426 |
+
const currentTeamId = session.teams.find(t => t.members.includes(s._id || String(s.id)))?.id;
|
| 427 |
+
const isInCurrent = currentTeamId === selectedTeamId;
|
| 428 |
+
const isInOther = currentTeamId && !isInCurrent;
|
| 429 |
+
|
| 430 |
+
return (
|
| 431 |
+
<div key={s._id}
|
| 432 |
+
onClick={() => toggleTeamMember(s._id || String(s.id), selectedTeamId)}
|
| 433 |
+
className={`text-xs p-2 rounded border cursor-pointer flex items-center justify-between ${
|
| 434 |
+
isInCurrent ? 'bg-green-100 border-green-300 text-green-800' :
|
| 435 |
+
isInOther ? 'bg-gray-100 border-gray-200 text-gray-400 opacity-60' : 'bg-white border-gray-300 hover:border-blue-400'
|
| 436 |
+
}`}
|
| 437 |
+
>
|
| 438 |
+
<span className="truncate">{s.name}</span>
|
| 439 |
+
{isInCurrent && <CheckIcon />}
|
| 440 |
+
</div>
|
| 441 |
+
);
|
| 442 |
+
})}
|
| 443 |
+
</div>
|
| 444 |
+
</>
|
| 445 |
+
) : (
|
| 446 |
+
<div className="flex items-center justify-center h-full text-gray-400">请先在左侧选择一个队伍</div>
|
| 447 |
+
)}
|
| 448 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 449 |
</div>
|
| 450 |
+
</section>
|
| 451 |
+
</div>
|
| 452 |
+
|
| 453 |
+
<div className="p-4 border-t border-gray-100 bg-gray-50 rounded-b-2xl flex justify-end gap-2">
|
| 454 |
+
<button onClick={() => setIsMtSettingsOpen(false)} className="px-4 py-2 text-gray-600 hover:bg-gray-200 rounded-lg">取消</button>
|
| 455 |
+
<button onClick={saveMountainSettings} className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-bold">保存设置</button>
|
| 456 |
+
</div>
|
| 457 |
+
</div>
|
| 458 |
+
</div>
|
| 459 |
+
)}
|
| 460 |
+
|
| 461 |
+
{/* --- LUCKY SETTINGS MODAL --- */}
|
| 462 |
+
{isLuckySettingsOpen && luckyConfig && (
|
| 463 |
+
<div className="fixed inset-0 bg-black/60 z-[100] flex items-center justify-center p-4 backdrop-blur-sm">
|
| 464 |
+
<div className="bg-white rounded-2xl w-full max-w-2xl h-[80vh] flex flex-col shadow-2xl">
|
| 465 |
+
<div className="p-6 border-b border-gray-100 flex justify-between items-center">
|
| 466 |
+
<h3 className="text-xl font-bold text-gray-800">奖池配置</h3>
|
| 467 |
+
<button onClick={() => setIsLuckySettingsOpen(false)}><X size={24} className="text-gray-400 hover:text-gray-600"/></button>
|
| 468 |
+
</div>
|
| 469 |
+
|
| 470 |
+
<div className="flex-1 overflow-y-auto p-6">
|
| 471 |
+
<div className="mb-6 flex gap-4 bg-yellow-50 p-4 rounded-xl border border-yellow-100">
|
| 472 |
+
<div>
|
| 473 |
+
<label className="text-xs font-bold text-yellow-700 uppercase block mb-1">每日抽奖上限</label>
|
| 474 |
+
<input type="number" className="border rounded px-2 py-1 w-20 text-center" value={luckyConfig.dailyLimit} onChange={e => setLuckyConfig({...luckyConfig, dailyLimit: Number(e.target.value)})}/>
|
| 475 |
+
</div>
|
| 476 |
+
<div>
|
| 477 |
+
<label className="text-xs font-bold text-yellow-700 uppercase block mb-1">默认安慰奖</label>
|
| 478 |
+
<input className="border rounded px-2 py-1" value={luckyConfig.defaultPrize} onChange={e => setLuckyConfig({...luckyConfig, defaultPrize: e.target.value})}/>
|
| 479 |
+
</div>
|
| 480 |
+
</div>
|
| 481 |
+
|
| 482 |
+
<table className="w-full text-sm text-left">
|
| 483 |
+
<thead className="bg-gray-100 text-gray-500 uppercase text-xs">
|
| 484 |
+
<tr>
|
| 485 |
+
<th className="p-3 rounded-tl-lg">奖品名称</th>
|
| 486 |
+
<th className="p-3">概率 (%)</th>
|
| 487 |
+
<th className="p-3">库存</th>
|
| 488 |
+
<th className="p-3 rounded-tr-lg text-right">操作</th>
|
| 489 |
+
</tr>
|
| 490 |
+
</thead>
|
| 491 |
+
<tbody className="divide-y divide-gray-100">
|
| 492 |
+
{luckyConfig.prizes.map((p, idx) => (
|
| 493 |
+
<tr key={idx} className="group hover:bg-gray-50">
|
| 494 |
+
<td className="p-2"><input value={p.name} onChange={e => {
|
| 495 |
+
const np = [...luckyConfig.prizes]; np[idx].name = e.target.value; setLuckyConfig({...luckyConfig, prizes: np});
|
| 496 |
+
}} className="w-full border p-1 rounded"/></td>
|
| 497 |
+
<td className="p-2"><input type="number" value={p.probability} onChange={e => {
|
| 498 |
+
const np = [...luckyConfig.prizes]; np[idx].probability = Number(e.target.value); setLuckyConfig({...luckyConfig, prizes: np});
|
| 499 |
+
}} className="w-16 border p-1 rounded text-center"/></td>
|
| 500 |
+
<td className="p-2"><input type="number" value={p.count} onChange={e => {
|
| 501 |
+
const np = [...luckyConfig.prizes]; np[idx].count = Number(e.target.value); setLuckyConfig({...luckyConfig, prizes: np});
|
| 502 |
+
}} className="w-16 border p-1 rounded text-center"/></td>
|
| 503 |
+
<td className="p-2 text-right">
|
| 504 |
+
<button onClick={() => {
|
| 505 |
+
const np = luckyConfig.prizes.filter((_, i) => i !== idx);
|
| 506 |
+
setLuckyConfig({...luckyConfig, prizes: np});
|
| 507 |
+
}} className="text-gray-300 hover:text-red-500"><Trash2 size={16}/></button>
|
| 508 |
+
</td>
|
| 509 |
+
</tr>
|
| 510 |
+
))}
|
| 511 |
+
</tbody>
|
| 512 |
+
</table>
|
| 513 |
+
|
| 514 |
+
<button onClick={() => {
|
| 515 |
+
const newPrize: LuckyPrize = { id: Date.now().toString(), name: '新奖品', probability: 10, count: 10 };
|
| 516 |
+
setLuckyConfig({...luckyConfig, prizes: [...luckyConfig.prizes, newPrize]});
|
| 517 |
+
}} className="mt-4 w-full py-2 border-2 border-dashed border-gray-300 rounded-lg text-gray-500 hover:border-blue-400 hover:text-blue-500 transition-colors">+ 添加奖品</button>
|
| 518 |
+
</div>
|
| 519 |
+
|
| 520 |
+
<div className="p-4 border-t border-gray-100 bg-gray-50 rounded-b-2xl flex justify-end gap-2">
|
| 521 |
+
<button onClick={() => setIsLuckySettingsOpen(false)} className="px-4 py-2 text-gray-600 hover:bg-gray-200 rounded-lg">取消</button>
|
| 522 |
+
<button onClick={saveLuckySettings} className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-bold">保存配置</button>
|
| 523 |
</div>
|
| 524 |
</div>
|
| 525 |
</div>
|
|
|
|
| 527 |
</div>
|
| 528 |
);
|
| 529 |
};
|
| 530 |
+
|
| 531 |
+
const CheckIcon = () => <svg className="w-4 h-4 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /></svg>;
|
server.js
CHANGED
|
@@ -229,11 +229,18 @@ const StudentRewardSchema = new mongoose.Schema({
|
|
| 229 |
});
|
| 230 |
const StudentRewardModel = mongoose.model('StudentReward', StudentRewardSchema);
|
| 231 |
|
|
|
|
| 232 |
const LuckyDrawConfigSchema = new mongoose.Schema({
|
| 233 |
schoolId: String,
|
| 234 |
-
prizes: [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 235 |
dailyLimit: { type: Number, default: 3 },
|
| 236 |
-
defaultPrize: { type: String, default: '
|
| 237 |
});
|
| 238 |
const LuckyDrawConfigModel = mongoose.model('LuckyDrawConfig', LuckyDrawConfigSchema);
|
| 239 |
|
|
@@ -702,7 +709,7 @@ app.post('/api/games/mountain', async (req, res) => {
|
|
| 702 |
app.get('/api/games/lucky-config', async (req, res) => {
|
| 703 |
const filter = getQueryFilter(req);
|
| 704 |
if (InMemoryDB.isFallback) return res.json(InMemoryDB.luckyConfig);
|
| 705 |
-
res.json(await LuckyDrawConfigModel.findOne(filter) || { prizes: [
|
| 706 |
});
|
| 707 |
app.post('/api/games/lucky-config', async (req, res) => {
|
| 708 |
const data = injectSchoolId(req, req.body);
|
|
@@ -711,6 +718,74 @@ app.post('/api/games/lucky-config', async (req, res) => {
|
|
| 711 |
res.json({ success: true });
|
| 712 |
});
|
| 713 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 714 |
app.get('/api/rewards', async (req, res) => {
|
| 715 |
const { studentId } = req.query;
|
| 716 |
const filter = { ...getQueryFilter(req), studentId };
|
|
|
|
| 229 |
});
|
| 230 |
const StudentRewardModel = mongoose.model('StudentReward', StudentRewardSchema);
|
| 231 |
|
| 232 |
+
// Updated Lucky Draw Config Schema to support probability
|
| 233 |
const LuckyDrawConfigSchema = new mongoose.Schema({
|
| 234 |
schoolId: String,
|
| 235 |
+
prizes: [{
|
| 236 |
+
id: String,
|
| 237 |
+
name: String,
|
| 238 |
+
probability: Number, // 0-100
|
| 239 |
+
count: Number, // Inventory
|
| 240 |
+
icon: String
|
| 241 |
+
}],
|
| 242 |
dailyLimit: { type: Number, default: 3 },
|
| 243 |
+
defaultPrize: { type: String, default: '再接再厉' }
|
| 244 |
});
|
| 245 |
const LuckyDrawConfigModel = mongoose.model('LuckyDrawConfig', LuckyDrawConfigSchema);
|
| 246 |
|
|
|
|
| 709 |
app.get('/api/games/lucky-config', async (req, res) => {
|
| 710 |
const filter = getQueryFilter(req);
|
| 711 |
if (InMemoryDB.isFallback) return res.json(InMemoryDB.luckyConfig);
|
| 712 |
+
res.json(await LuckyDrawConfigModel.findOne(filter) || { prizes: [], dailyLimit: 3 });
|
| 713 |
});
|
| 714 |
app.post('/api/games/lucky-config', async (req, res) => {
|
| 715 |
const data = injectSchoolId(req, req.body);
|
|
|
|
| 718 |
res.json({ success: true });
|
| 719 |
});
|
| 720 |
|
| 721 |
+
// Secure Lucky Draw Endpoint
|
| 722 |
+
app.post('/api/games/lucky-draw', async (req, res) => {
|
| 723 |
+
const { studentId } = req.body;
|
| 724 |
+
const schoolId = req.headers['x-school-id'];
|
| 725 |
+
|
| 726 |
+
try {
|
| 727 |
+
if (InMemoryDB.isFallback) return res.json({ prize: '模拟奖品' });
|
| 728 |
+
|
| 729 |
+
// 1. Get Student & Check Attempts
|
| 730 |
+
const student = await Student.findById(studentId);
|
| 731 |
+
if (!student || student.drawAttempts <= 0) return res.status(403).json({ error: '次数不足' });
|
| 732 |
+
|
| 733 |
+
// 2. Get Config
|
| 734 |
+
const config = await LuckyDrawConfigModel.findOne({ schoolId });
|
| 735 |
+
const prizes = config?.prizes || [];
|
| 736 |
+
const defaultPrize = config?.defaultPrize || '再接再厉';
|
| 737 |
+
|
| 738 |
+
// 3. Weighted Random Logic
|
| 739 |
+
let selectedPrize = defaultPrize;
|
| 740 |
+
|
| 741 |
+
const availablePrizes = prizes.filter(p => (p.count === undefined || p.count > 0));
|
| 742 |
+
const totalWeight = availablePrizes.reduce((sum, p) => sum + (p.probability || 0), 0);
|
| 743 |
+
const random = Math.random() * Math.max(100, totalWeight); // Normalized to 100 or total
|
| 744 |
+
|
| 745 |
+
let currentWeight = 0;
|
| 746 |
+
let matchedPrize = null;
|
| 747 |
+
|
| 748 |
+
for (const p of availablePrizes) {
|
| 749 |
+
currentWeight += p.probability || 0;
|
| 750 |
+
if (random <= currentWeight) {
|
| 751 |
+
matchedPrize = p;
|
| 752 |
+
break;
|
| 753 |
+
}
|
| 754 |
+
}
|
| 755 |
+
|
| 756 |
+
if (matchedPrize) {
|
| 757 |
+
selectedPrize = matchedPrize.name;
|
| 758 |
+
// Deduct inventory
|
| 759 |
+
if (matchedPrize.count !== undefined && matchedPrize.count > 0) {
|
| 760 |
+
await LuckyDrawConfigModel.updateOne(
|
| 761 |
+
{ schoolId, "prizes.id": matchedPrize.id },
|
| 762 |
+
{ $inc: { "prizes.$.count": -1 } }
|
| 763 |
+
);
|
| 764 |
+
}
|
| 765 |
+
}
|
| 766 |
+
|
| 767 |
+
// 4. Consume Attempt
|
| 768 |
+
await Student.findByIdAndUpdate(studentId, { $inc: { drawAttempts: -1 } });
|
| 769 |
+
|
| 770 |
+
// 5. Record Reward
|
| 771 |
+
await StudentRewardModel.create({
|
| 772 |
+
schoolId,
|
| 773 |
+
studentId,
|
| 774 |
+
studentName: student.name,
|
| 775 |
+
rewardType: 'ITEM',
|
| 776 |
+
name: selectedPrize,
|
| 777 |
+
status: 'PENDING',
|
| 778 |
+
source: '幸运大抽奖'
|
| 779 |
+
});
|
| 780 |
+
|
| 781 |
+
res.json({ prize: selectedPrize });
|
| 782 |
+
|
| 783 |
+
} catch (e) {
|
| 784 |
+
console.error(e);
|
| 785 |
+
res.status(500).json({ error: e.message });
|
| 786 |
+
}
|
| 787 |
+
});
|
| 788 |
+
|
| 789 |
app.get('/api/rewards', async (req, res) => {
|
| 790 |
const { studentId } = req.query;
|
| 791 |
const filter = { ...getQueryFilter(req), studentId };
|
services/api.ts
CHANGED
|
@@ -1,5 +1,3 @@
|
|
| 1 |
-
|
| 2 |
-
/// <reference types="vite/client" />
|
| 3 |
import { User, ClassInfo, SystemConfig, Subject, School, Schedule, GameSession, StudentReward, LuckyDrawConfig } from '../types';
|
| 4 |
|
| 5 |
const getBaseUrl = () => {
|
|
@@ -170,6 +168,7 @@ export const api = {
|
|
| 170 |
saveMountainSession: (data: GameSession) => request('/games/mountain', { method: 'POST', body: JSON.stringify(data) }),
|
| 171 |
getLuckyConfig: () => request('/games/lucky-config'),
|
| 172 |
saveLuckyConfig: (data: LuckyDrawConfig) => request('/games/lucky-config', { method: 'POST', body: JSON.stringify(data) }),
|
|
|
|
| 173 |
},
|
| 174 |
rewards: {
|
| 175 |
getMyRewards: (studentId: string) => request(`/rewards?studentId=${studentId}`),
|
|
@@ -181,4 +180,4 @@ export const api = {
|
|
| 181 |
batchDelete: (type: 'student' | 'score' | 'user', ids: string[]) => {
|
| 182 |
return request('/batch-delete', { method: 'POST', body: JSON.stringify({ type, ids }) });
|
| 183 |
}
|
| 184 |
-
};
|
|
|
|
|
|
|
|
|
|
| 1 |
import { User, ClassInfo, SystemConfig, Subject, School, Schedule, GameSession, StudentReward, LuckyDrawConfig } from '../types';
|
| 2 |
|
| 3 |
const getBaseUrl = () => {
|
|
|
|
| 168 |
saveMountainSession: (data: GameSession) => request('/games/mountain', { method: 'POST', body: JSON.stringify(data) }),
|
| 169 |
getLuckyConfig: () => request('/games/lucky-config'),
|
| 170 |
saveLuckyConfig: (data: LuckyDrawConfig) => request('/games/lucky-config', { method: 'POST', body: JSON.stringify(data) }),
|
| 171 |
+
drawLucky: (studentId: string) => request('/games/lucky-draw', { method: 'POST', body: JSON.stringify({ studentId }) }),
|
| 172 |
},
|
| 173 |
rewards: {
|
| 174 |
getMyRewards: (studentId: string) => request(`/rewards?studentId=${studentId}`),
|
|
|
|
| 180 |
batchDelete: (type: 'student' | 'score' | 'user', ids: string[]) => {
|
| 181 |
return request('/batch-delete', { method: 'POST', body: JSON.stringify({ type, ids }) });
|
| 182 |
}
|
| 183 |
+
};
|
types.ts
CHANGED
|
@@ -201,10 +201,18 @@ export interface StudentReward {
|
|
| 201 |
createTime: string;
|
| 202 |
}
|
| 203 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 204 |
export interface LuckyDrawConfig {
|
| 205 |
_id?: string;
|
| 206 |
schoolId: string;
|
| 207 |
-
prizes:
|
| 208 |
dailyLimit: number;
|
| 209 |
-
defaultPrize: string; // "
|
| 210 |
}
|
|
|
|
| 201 |
createTime: string;
|
| 202 |
}
|
| 203 |
|
| 204 |
+
export interface LuckyPrize {
|
| 205 |
+
id: string;
|
| 206 |
+
name: string;
|
| 207 |
+
probability: number; // 0-100
|
| 208 |
+
count: number; // Inventory
|
| 209 |
+
icon?: string;
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
export interface LuckyDrawConfig {
|
| 213 |
_id?: string;
|
| 214 |
schoolId: string;
|
| 215 |
+
prizes: LuckyPrize[];
|
| 216 |
dailyLimit: number;
|
| 217 |
+
defaultPrize: string; // "再接再厉"
|
| 218 |
}
|