dvc890 commited on
Commit
9783eaf
·
verified ·
1 Parent(s): b56ea18

Upload 45 files

Browse files
Files changed (2) hide show
  1. pages/GameLucky.tsx +212 -38
  2. pages/Games.tsx +2 -2
pages/GameLucky.tsx CHANGED
@@ -1,11 +1,12 @@
1
 
2
- import React, { useState, useEffect } from 'react';
3
  import { createPortal } from 'react-dom';
4
  import { api } from '../services/api';
5
  import { LuckyDrawConfig, Student, LuckyPrize } from '../types';
6
- import { Gift, Settings, Loader2, Save, Trash2, X, UserCircle, RefreshCcw, HelpCircle, Maximize, Minimize } from 'lucide-react';
7
  import { Emoji } from '../components/Emoji';
8
 
 
9
  const FlipCard = ({ index, prize, onFlip, isRevealed, activeIndex }: { index: number, prize: string, onFlip: (idx: number) => void, isRevealed: boolean, activeIndex: number | null }) => {
10
  const showBack = isRevealed && activeIndex === index;
11
 
@@ -29,6 +30,142 @@ const FlipCard = ({ index, prize, onFlip, isRevealed, activeIndex }: { index: nu
29
  );
30
  };
31
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  export const GameLucky: React.FC = () => {
33
  const [loading, setLoading] = useState(true);
34
  const [luckyConfig, setLuckyConfig] = useState<LuckyDrawConfig | null>(null);
@@ -39,9 +176,14 @@ export const GameLucky: React.FC = () => {
39
  const [isSettingsOpen, setIsSettingsOpen] = useState(false);
40
  const [isFullscreen, setIsFullscreen] = useState(false);
41
 
 
 
42
  const [drawResult, setDrawResult] = useState<{prize: string, rewardType?: string} | null>(null);
 
 
43
  const [activeCardIndex, setActiveCardIndex] = useState<number | null>(null);
44
- const [isFlipping, setIsFlipping] = useState(false);
 
45
 
46
  const currentUser = api.auth.getCurrentUser();
47
  const isTeacher = currentUser?.role === 'TEACHER';
@@ -98,33 +240,42 @@ export const GameLucky: React.FC = () => {
98
  finally { setLoading(false); }
99
  };
100
 
101
- const handleDraw = async (index: number) => {
102
- if (isFlipping) return;
 
 
 
103
  const targetId = isTeacher ? proxyStudentId : (studentInfo?._id || String(studentInfo?.id));
104
-
105
  if (!targetId) return alert(isTeacher ? '请先在右侧选择要代抽的学生' : '学生信息未加载');
106
  if (!studentInfo || (studentInfo.drawAttempts || 0) <= 0) return alert('抽奖次数不足!');
107
 
108
- setIsFlipping(true);
109
- setActiveCardIndex(index);
 
110
 
111
  try {
112
  const res = await api.games.drawLucky(targetId);
113
- setDrawResult(res);
114
  setStudentInfo(prev => prev ? ({ ...prev, drawAttempts: (prev.drawAttempts || 0) - 1 }) : null);
115
 
 
 
 
116
  setTimeout(() => {
117
- if (res.rewardType !== 'CONSOLATION') {
118
- alert(`🎁 恭喜!抽中了:${res.prize}`);
119
- }
120
- setIsFlipping(false);
 
121
  setDrawResult(null);
122
  setActiveCardIndex(null);
123
- }, 2000);
 
 
124
  } catch (e: any) {
125
  alert(e.message || '抽奖失败,请稍后重试');
126
- setIsFlipping(false);
127
  setActiveCardIndex(null);
 
128
  }
129
  };
130
 
@@ -147,27 +298,34 @@ export const GameLucky: React.FC = () => {
147
  {isFullscreen ? <Minimize size={20} className="text-gray-700"/> : <Maximize size={20} className="text-gray-700"/>}
148
  </button>
149
 
150
- {/* Left: Red Packet Grid */}
151
- <div className="flex-1 overflow-y-auto p-4 md:p-8 custom-scrollbar order-1 md:order-1">
152
- <div className={`grid gap-2 md:gap-6 w-full max-w-5xl mx-auto transition-all place-content-center ${
153
- // Mobile: Use 3 cols if count > 4 to save space
154
- ((luckyConfig.cardCount || 9) <= 4 ? 'grid-cols-2 ' : 'grid-cols-3 ') +
155
- // Desktop: Keep existing logic
156
- ((luckyConfig.cardCount || 9) <= 4 ? 'md:grid-cols-3' :
157
- (luckyConfig.cardCount || 9) <= 6 ? 'md:grid-cols-3 lg:grid-cols-4' :
158
- 'md:grid-cols-4 lg:grid-cols-5')
159
- }`}>
160
- {Array.from({ length: luckyConfig.cardCount || 9 }).map((_, i) => (
161
- <FlipCard
162
- key={i}
163
- index={i}
164
- prize={drawResult ? drawResult.prize : '???'}
165
- onFlip={handleDraw}
166
- isRevealed={activeCardIndex === i && !!drawResult}
167
- activeIndex={activeCardIndex}
168
- />
169
- ))}
170
- </div>
 
 
 
 
 
 
 
171
  </div>
172
 
173
  {/* Right/Bottom: Controls */}
@@ -201,6 +359,22 @@ export const GameLucky: React.FC = () => {
201
 
202
  {isTeacher && (
203
  <div className="space-y-3 md:space-y-4">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
204
  <div>
205
  <label className="text-xs font-bold text-gray-500 uppercase block mb-1">选择代抽学生</label>
206
  <div className="relative">
@@ -231,7 +405,7 @@ export const GameLucky: React.FC = () => {
231
  <div className="fixed inset-0 bg-black/60 z-[1000] flex items-center justify-center p-4 backdrop-blur-sm">
232
  <div className="bg-white rounded-2xl w-full max-w-4xl h-[90vh] flex flex-col shadow-2xl animate-in zoom-in-95">
233
  <div className="p-6 border-b border-gray-100 flex justify-between items-center">
234
- <h3 className="text-xl font-bold text-gray-800">红包奖池配置 - {currentClassName}</h3>
235
  <button onClick={() => setIsSettingsOpen(false)}><X size={24} className="text-gray-400 hover:text-gray-600"/></button>
236
  </div>
237
 
@@ -262,7 +436,7 @@ export const GameLucky: React.FC = () => {
262
  value={luckyConfig.consolationWeight || 0}
263
  onChange={e => setLuckyConfig({...luckyConfig, consolationWeight: Number(e.target.value)})}
264
  />
265
- <span className="text-xs text-gray-500">设为0则表示百分百中奖(只要有库存)</span>
266
  </div>
267
  </div>
268
 
 
1
 
2
+ import React, { useState, useEffect, useRef, useMemo } from 'react';
3
  import { createPortal } from 'react-dom';
4
  import { api } from '../services/api';
5
  import { LuckyDrawConfig, Student, LuckyPrize } from '../types';
6
+ import { Gift, Settings, Loader2, Save, Trash2, X, UserCircle, RefreshCcw, HelpCircle, Maximize, Minimize, Disc, LayoutGrid, ArrowDown } from 'lucide-react';
7
  import { Emoji } from '../components/Emoji';
8
 
9
+ // --- Card Component ---
10
  const FlipCard = ({ index, prize, onFlip, isRevealed, activeIndex }: { index: number, prize: string, onFlip: (idx: number) => void, isRevealed: boolean, activeIndex: number | null }) => {
11
  const showBack = isRevealed && activeIndex === index;
12
 
 
30
  );
31
  };
32
 
33
+ // --- Wheel Component ---
34
+ const WHEEL_COLORS = ['#ef4444', '#f97316', '#eab308', '#84cc16', '#22c55e', '#3b82f6', '#8b5cf6', '#ec4899'];
35
+
36
+ const LuckyWheel = ({ config, isSpinning, result, onSpin }: {
37
+ config: LuckyDrawConfig,
38
+ isSpinning: boolean,
39
+ result: {prize: string} | null,
40
+ onSpin: () => void
41
+ }) => {
42
+ const [rotation, setRotation] = useState(0);
43
+
44
+ // Construct Segments (Config Prizes + Consolation)
45
+ const segments = useMemo(() => {
46
+ const list = config.prizes.map((p, i) => ({
47
+ name: p.name,
48
+ weight: p.probability,
49
+ color: WHEEL_COLORS[i % WHEEL_COLORS.length],
50
+ isConsolation: false
51
+ }));
52
+
53
+ // Add Consolation Segment
54
+ if ((config.consolationWeight || 0) > 0 || list.length === 0) {
55
+ list.push({
56
+ name: config.defaultPrize || '再接再厉',
57
+ weight: config.consolationWeight || 10, // Default to 10 if 0 but needed
58
+ color: '#94a3b8', // Grey for consolation
59
+ isConsolation: true
60
+ });
61
+ }
62
+ return list;
63
+ }, [config]);
64
+
65
+ const totalWeight = segments.reduce((acc, s) => acc + s.weight, 0);
66
+
67
+ // Calculate Slices
68
+ let currentAngle = 0;
69
+ const slices = segments.map(seg => {
70
+ const angle = (seg.weight / totalWeight) * 360;
71
+ const start = currentAngle;
72
+ currentAngle += angle;
73
+ return { ...seg, startAngle: start, endAngle: currentAngle, angle };
74
+ });
75
+
76
+ useEffect(() => {
77
+ if (result && isSpinning) {
78
+ // Find target slice index
79
+ const targetIndex = slices.findIndex(s => s.name === result.prize);
80
+ // Default to first if not found (shouldn't happen if config syncs)
81
+ const safeIndex = targetIndex >= 0 ? targetIndex : slices.length - 1;
82
+ const targetSlice = slices[safeIndex];
83
+
84
+ // Calculate center of target slice
85
+ const centerAngle = targetSlice.startAngle + (targetSlice.angle / 2);
86
+
87
+ // We want this centerAngle to align with 0 (top) after rotation
88
+ // Logic: Current rotation -> Add 5 full spins (1800) -> Add offset to align
89
+ // To align X at top (0 deg), we need to rotate -(X).
90
+ // SVG coordinate system usually 0 is right (3 o'clock).
91
+ // We rotate wheel -90deg initially so 0 is top.
92
+ // Target rotation = (360 - centerAngle).
93
+
94
+ const spinRounds = 5 * 360;
95
+ const finalRotation = rotation + spinRounds + (360 - centerAngle);
96
+
97
+ setRotation(finalRotation);
98
+ }
99
+ }, [result, isSpinning]);
100
+
101
+ // SVG Path Generator for Slice
102
+ const getCoordinatesForPercent = (percent: number) => {
103
+ const x = Math.cos(2 * Math.PI * percent);
104
+ const y = Math.sin(2 * Math.PI * percent);
105
+ return [x, y];
106
+ };
107
+
108
+ return (
109
+ <div className="flex flex-col items-center justify-center h-full w-full max-w-lg mx-auto p-4">
110
+ <div className="relative w-full aspect-square max-w-[400px]">
111
+ {/* Pointer */}
112
+ <div className="absolute -top-6 left-1/2 -translate-x-1/2 z-20 text-red-600 drop-shadow-lg animate-bounce">
113
+ <ArrowDown size={40} fill="currentColor" strokeWidth={2} stroke="white"/>
114
+ </div>
115
+
116
+ {/* Wheel */}
117
+ <div
118
+ className="w-full h-full rounded-full border-8 border-yellow-400 shadow-2xl overflow-hidden relative transition-transform duration-[4000ms] cubic-bezier(0.2, 0.8, 0.2, 1)"
119
+ style={{ transform: `rotate(${rotation}deg)` }}
120
+ >
121
+ <svg viewBox="-1 -1 2 2" style={{ transform: 'rotate(-90deg)' }} className="w-full h-full">
122
+ {slices.map((slice, i) => {
123
+ // Calculate SVG path
124
+ const start = slice.startAngle / 360;
125
+ const end = slice.endAngle / 360;
126
+ const [startX, startY] = getCoordinatesForPercent(end); // Clockwise
127
+ const [endX, endY] = getCoordinatesForPercent(start);
128
+ const largeArcFlag = slice.angle > 180 ? 1 : 0;
129
+ const pathData = `M 0 0 L ${startX} ${startY} A 1 1 0 ${largeArcFlag} 0 ${endX} ${endY} Z`;
130
+
131
+ return (
132
+ <g key={i}>
133
+ <path d={pathData} fill={slice.color} stroke="white" strokeWidth="0.01" />
134
+ {/* Text Label - Midpoint */}
135
+ <g transform={`rotate(${(slice.startAngle + slice.endAngle)/2}) translate(0.6)`}>
136
+ <text
137
+ x="0" y="0"
138
+ fill="white"
139
+ fontSize="0.1"
140
+ fontWeight="bold"
141
+ textAnchor="middle"
142
+ dominantBaseline="middle"
143
+ transform="rotate(90)"
144
+ >
145
+ {slice.name.length > 5 ? slice.name.substring(0,4)+'..' : slice.name}
146
+ </text>
147
+ </g>
148
+ </g>
149
+ );
150
+ })}
151
+ </svg>
152
+ </div>
153
+
154
+ {/* Center Button */}
155
+ <div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-10">
156
+ <button
157
+ onClick={onSpin}
158
+ disabled={isSpinning}
159
+ className="w-16 h-16 md:w-20 md:h-20 bg-gradient-to-b from-yellow-300 to-yellow-500 rounded-full border-4 border-white shadow-xl flex items-center justify-center font-black text-red-600 text-lg md:text-xl hover:scale-105 active:scale-95 disabled:grayscale transition-all"
160
+ >
161
+ {isSpinning ? '...' : '抽奖'}
162
+ </button>
163
+ </div>
164
+ </div>
165
+ </div>
166
+ );
167
+ };
168
+
169
  export const GameLucky: React.FC = () => {
170
  const [loading, setLoading] = useState(true);
171
  const [luckyConfig, setLuckyConfig] = useState<LuckyDrawConfig | null>(null);
 
176
  const [isSettingsOpen, setIsSettingsOpen] = useState(false);
177
  const [isFullscreen, setIsFullscreen] = useState(false);
178
 
179
+ // Game State
180
+ const [viewMode, setViewMode] = useState<'CARD' | 'WHEEL'>('CARD'); // New: Switch modes
181
  const [drawResult, setDrawResult] = useState<{prize: string, rewardType?: string} | null>(null);
182
+
183
+ // Card Mode State
184
  const [activeCardIndex, setActiveCardIndex] = useState<number | null>(null);
185
+ // Wheel Mode State
186
+ const [isWheelSpinning, setIsWheelSpinning] = useState(false);
187
 
188
  const currentUser = api.auth.getCurrentUser();
189
  const isTeacher = currentUser?.role === 'TEACHER';
 
240
  finally { setLoading(false); }
241
  };
242
 
243
+ // Unified Draw Handler
244
+ const executeDraw = async (triggerIndex?: number) => {
245
+ const isBusy = viewMode === 'CARD' ? activeCardIndex !== null : isWheelSpinning;
246
+ if (isBusy) return;
247
+
248
  const targetId = isTeacher ? proxyStudentId : (studentInfo?._id || String(studentInfo?.id));
 
249
  if (!targetId) return alert(isTeacher ? '请先在右侧选择要代抽的学生' : '学生信息未加载');
250
  if (!studentInfo || (studentInfo.drawAttempts || 0) <= 0) return alert('抽奖次数不足!');
251
 
252
+ // Set Loading State
253
+ if (viewMode === 'CARD' && triggerIndex !== undefined) setActiveCardIndex(triggerIndex);
254
+ if (viewMode === 'WHEEL') setIsWheelSpinning(true);
255
 
256
  try {
257
  const res = await api.games.drawLucky(targetId);
258
+ setDrawResult(res); // Wheel will react to this change
259
  setStudentInfo(prev => prev ? ({ ...prev, drawAttempts: (prev.drawAttempts || 0) - 1 }) : null);
260
 
261
+ // Wait for animation to finish
262
+ const delay = viewMode === 'WHEEL' ? 4500 : 1500; // Wheel spins for 4s, Cards flip for 1s
263
+
264
  setTimeout(() => {
265
+ // Show Result
266
+ const msg = res.rewardType !== 'CONSOLATION' ? `🎁 恭喜!抽中了:${res.prize}` : `💪 ${res.prize}`;
267
+ alert(msg);
268
+
269
+ // Reset State
270
  setDrawResult(null);
271
  setActiveCardIndex(null);
272
+ setIsWheelSpinning(false);
273
+ }, delay);
274
+
275
  } catch (e: any) {
276
  alert(e.message || '抽奖失败,请稍后重试');
 
277
  setActiveCardIndex(null);
278
+ setIsWheelSpinning(false);
279
  }
280
  };
281
 
 
298
  {isFullscreen ? <Minimize size={20} className="text-gray-700"/> : <Maximize size={20} className="text-gray-700"/>}
299
  </button>
300
 
301
+ {/* LEFT: GAME AREA (Card Grid OR Wheel) */}
302
+ <div className="flex-1 overflow-y-auto p-4 md:p-8 custom-scrollbar order-1 md:order-1 flex flex-col justify-center">
303
+ {viewMode === 'CARD' ? (
304
+ <div className={`grid gap-2 md:gap-6 w-full max-w-5xl mx-auto transition-all place-content-center ${
305
+ ((luckyConfig.cardCount || 9) <= 4 ? 'grid-cols-2 ' : 'grid-cols-3 ') +
306
+ ((luckyConfig.cardCount || 9) <= 4 ? 'md:grid-cols-3' :
307
+ (luckyConfig.cardCount || 9) <= 6 ? 'md:grid-cols-3 lg:grid-cols-4' :
308
+ 'md:grid-cols-4 lg:grid-cols-5')
309
+ }`}>
310
+ {Array.from({ length: luckyConfig.cardCount || 9 }).map((_, i) => (
311
+ <FlipCard
312
+ key={i}
313
+ index={i}
314
+ prize={drawResult ? drawResult.prize : '???'}
315
+ onFlip={executeDraw}
316
+ isRevealed={activeCardIndex === i && !!drawResult}
317
+ activeIndex={activeCardIndex}
318
+ />
319
+ ))}
320
+ </div>
321
+ ) : (
322
+ <LuckyWheel
323
+ config={luckyConfig}
324
+ isSpinning={isWheelSpinning}
325
+ result={drawResult}
326
+ onSpin={() => executeDraw()}
327
+ />
328
+ )}
329
  </div>
330
 
331
  {/* Right/Bottom: Controls */}
 
359
 
360
  {isTeacher && (
361
  <div className="space-y-3 md:space-y-4">
362
+ {/* Mode Switcher (Teacher Only) */}
363
+ <div className="bg-gray-100 p-1 rounded-lg flex text-xs font-bold">
364
+ <button
365
+ onClick={() => setViewMode('CARD')}
366
+ className={`flex-1 py-2 rounded flex items-center justify-center transition-all ${viewMode==='CARD' ? 'bg-white shadow text-red-600' : 'text-gray-500'}`}
367
+ >
368
+ <LayoutGrid size={14} className="mr-1"/> 翻红包
369
+ </button>
370
+ <button
371
+ onClick={() => setViewMode('WHEEL')}
372
+ className={`flex-1 py-2 rounded flex items-center justify-center transition-all ${viewMode==='WHEEL' ? 'bg-white shadow text-red-600' : 'text-gray-500'}`}
373
+ >
374
+ <Disc size={14} className="mr-1"/> 大转盘
375
+ </button>
376
+ </div>
377
+
378
  <div>
379
  <label className="text-xs font-bold text-gray-500 uppercase block mb-1">选择代抽学生</label>
380
  <div className="relative">
 
405
  <div className="fixed inset-0 bg-black/60 z-[1000] flex items-center justify-center p-4 backdrop-blur-sm">
406
  <div className="bg-white rounded-2xl w-full max-w-4xl h-[90vh] flex flex-col shadow-2xl animate-in zoom-in-95">
407
  <div className="p-6 border-b border-gray-100 flex justify-between items-center">
408
+ <h3 className="text-xl font-bold text-gray-800">奖池配置 - {currentClassName}</h3>
409
  <button onClick={() => setIsSettingsOpen(false)}><X size={24} className="text-gray-400 hover:text-gray-600"/></button>
410
  </div>
411
 
 
436
  value={luckyConfig.consolationWeight || 0}
437
  onChange={e => setLuckyConfig({...luckyConfig, consolationWeight: Number(e.target.value)})}
438
  />
439
+ <span className="text-xs text-gray-500">此权重将作为大转盘中的灰色区域占比</span>
440
  </div>
441
  </div>
442
 
pages/Games.tsx CHANGED
@@ -43,7 +43,7 @@ export const Games: React.FC = () => {
43
  🏔️ 群岳争锋
44
  </button>
45
  <button onClick={() => setActiveGame('lucky')} className={`px-4 py-1.5 rounded-xl text-sm font-bold transition-all whitespace-nowrap ${activeGame === 'lucky' ? 'bg-red-100 text-red-700 border-2 border-red-200 shadow-sm' : 'text-gray-500 hover:bg-white hover:shadow-sm border-2 border-transparent'}`}>
46
- 🧧 幸运红包
47
  </button>
48
  <button onClick={() => setActiveGame('random')} className={`px-4 py-1.5 rounded-xl text-sm font-bold transition-all whitespace-nowrap ${activeGame === 'random' ? 'bg-yellow-100 text-yellow-700 border-2 border-yellow-200 shadow-sm' : 'text-gray-500 hover:bg-white hover:shadow-sm border-2 border-transparent'}`}>
49
  <Zap size={14} className="inline mr-1"/> 随机点名
@@ -75,4 +75,4 @@ export const Games: React.FC = () => {
75
  </div>
76
  </div>
77
  );
78
- };
 
43
  🏔️ 群岳争锋
44
  </button>
45
  <button onClick={() => setActiveGame('lucky')} className={`px-4 py-1.5 rounded-xl text-sm font-bold transition-all whitespace-nowrap ${activeGame === 'lucky' ? 'bg-red-100 text-red-700 border-2 border-red-200 shadow-sm' : 'text-gray-500 hover:bg-white hover:shadow-sm border-2 border-transparent'}`}>
46
+ 🧧 幸运抽奖
47
  </button>
48
  <button onClick={() => setActiveGame('random')} className={`px-4 py-1.5 rounded-xl text-sm font-bold transition-all whitespace-nowrap ${activeGame === 'random' ? 'bg-yellow-100 text-yellow-700 border-2 border-yellow-200 shadow-sm' : 'text-gray-500 hover:bg-white hover:shadow-sm border-2 border-transparent'}`}>
49
  <Zap size={14} className="inline mr-1"/> 随机点名
 
75
  </div>
76
  </div>
77
  );
78
+ };