Spaces:
Sleeping
Sleeping
Upload 44 files
Browse files- models.js +16 -1
- pages/GameMonster.tsx +66 -6
- pages/GameRandom.tsx +72 -7
- pages/GameZen.tsx +549 -0
- pages/Games.tsx +9 -4
- server.js +17 -4
- services/api.ts +2 -0
models.js
CHANGED
|
@@ -112,6 +112,21 @@ const GameMonsterConfigSchema = new mongoose.Schema({
|
|
| 112 |
});
|
| 113 |
const GameMonsterConfigModel = mongoose.model('GameMonsterConfig', GameMonsterConfigSchema);
|
| 114 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
const AchievementConfigSchema = new mongoose.Schema({ schoolId: String, className: String, achievements: [{ id: String, name: String, icon: String, points: Number, description: String }], exchangeRules: [{ id: String, cost: Number, rewardType: String, rewardName: String, rewardValue: Number }] });
|
| 116 |
const AchievementConfigModel = mongoose.model('AchievementConfig', AchievementConfigSchema);
|
| 117 |
|
|
@@ -126,6 +141,6 @@ const LeaveRequestModel = mongoose.model('LeaveRequest', LeaveRequestSchema);
|
|
| 126 |
|
| 127 |
module.exports = {
|
| 128 |
School, User, Student, Course, Score, ClassModel, SubjectModel, ExamModel, ScheduleModel,
|
| 129 |
-
ConfigModel, NotificationModel, GameSessionModel, StudentRewardModel, LuckyDrawConfigModel, GameMonsterConfigModel,
|
| 130 |
AchievementConfigModel, StudentAchievementModel, AttendanceModel, LeaveRequestModel
|
| 131 |
};
|
|
|
|
| 112 |
});
|
| 113 |
const GameMonsterConfigModel = mongoose.model('GameMonsterConfig', GameMonsterConfigSchema);
|
| 114 |
|
| 115 |
+
const GameZenConfigSchema = new mongoose.Schema({
|
| 116 |
+
schoolId: String,
|
| 117 |
+
className: String,
|
| 118 |
+
durationMinutes: { type: Number, default: 40 },
|
| 119 |
+
threshold: { type: Number, default: 30 },
|
| 120 |
+
passRate: { type: Number, default: 90 },
|
| 121 |
+
rewardConfig: {
|
| 122 |
+
enabled: Boolean,
|
| 123 |
+
type: { type: String },
|
| 124 |
+
val: String,
|
| 125 |
+
count: Number
|
| 126 |
+
}
|
| 127 |
+
});
|
| 128 |
+
const GameZenConfigModel = mongoose.model('GameZenConfig', GameZenConfigSchema);
|
| 129 |
+
|
| 130 |
const AchievementConfigSchema = new mongoose.Schema({ schoolId: String, className: String, achievements: [{ id: String, name: String, icon: String, points: Number, description: String }], exchangeRules: [{ id: String, cost: Number, rewardType: String, rewardName: String, rewardValue: Number }] });
|
| 131 |
const AchievementConfigModel = mongoose.model('AchievementConfig', AchievementConfigSchema);
|
| 132 |
|
|
|
|
| 141 |
|
| 142 |
module.exports = {
|
| 143 |
School, User, Student, Course, Score, ClassModel, SubjectModel, ExamModel, ScheduleModel,
|
| 144 |
+
ConfigModel, NotificationModel, GameSessionModel, StudentRewardModel, LuckyDrawConfigModel, GameMonsterConfigModel, GameZenConfigModel,
|
| 145 |
AchievementConfigModel, StudentAchievementModel, AttendanceModel, LeaveRequestModel
|
| 146 |
};
|
pages/GameMonster.tsx
CHANGED
|
@@ -2,7 +2,7 @@ import React, { useState, useEffect, useRef, useMemo } from 'react';
|
|
| 2 |
import { createPortal } from 'react-dom';
|
| 3 |
import { api } from '../services/api';
|
| 4 |
import { AchievementConfig, Student } from '../types';
|
| 5 |
-
import { Play, Pause, Settings, Maximize, Minimize, Gift, Trophy, Package, Volume2, Keyboard, Award, RotateCcw } from 'lucide-react';
|
| 6 |
|
| 7 |
// Monster visual assets
|
| 8 |
const MONSTER_TYPES = ['👾', '👹', '👺', '👻', '💀', '👽'];
|
|
@@ -24,6 +24,8 @@ export const GameMonster: React.FC = () => {
|
|
| 24 |
// Config State
|
| 25 |
const [achConfig, setAchConfig] = useState<AchievementConfig | null>(null);
|
| 26 |
const [students, setStudents] = useState<Student[]>([]);
|
|
|
|
|
|
|
| 27 |
|
| 28 |
const [duration, setDuration] = useState(300);
|
| 29 |
const [sensitivity, setSensitivity] = useState(40);
|
|
@@ -102,7 +104,15 @@ export const GameMonster: React.FC = () => {
|
|
| 102 |
api.games.getMonsterConfig(homeroomClass)
|
| 103 |
]);
|
| 104 |
setAchConfig(ac);
|
| 105 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
|
| 107 |
if (savedConfig && savedConfig.duration) {
|
| 108 |
setDuration(savedConfig.duration);
|
|
@@ -280,11 +290,12 @@ export const GameMonster: React.FC = () => {
|
|
| 280 |
};
|
| 281 |
|
| 282 |
const handleBatchGrant = async () => {
|
| 283 |
-
|
| 284 |
-
if (!
|
|
|
|
| 285 |
|
| 286 |
try {
|
| 287 |
-
const promises =
|
| 288 |
if (rewardConfig.type === 'ACHIEVEMENT') {
|
| 289 |
return api.achievements.grant({
|
| 290 |
studentId: s._id || String(s.id),
|
|
@@ -471,6 +482,17 @@ export const GameMonster: React.FC = () => {
|
|
| 471 |
</label>
|
| 472 |
</div>
|
| 473 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 474 |
{/* Extended Reward Config */}
|
| 475 |
<div className="bg-slate-900 p-4 rounded-xl border border-slate-700 space-y-3">
|
| 476 |
<div className="flex items-center justify-between">
|
|
@@ -546,6 +568,44 @@ export const GameMonster: React.FC = () => {
|
|
| 546 |
</div>
|
| 547 |
)}
|
| 548 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 549 |
{/* Result Modal */}
|
| 550 |
{gameState !== 'IDLE' && gameState !== 'PLAYING' && !isConfigOpen && (
|
| 551 |
<div className="absolute inset-0 bg-black/90 z-[110] flex items-center justify-center p-4 backdrop-blur-lg animate-in zoom-in">
|
|
@@ -561,7 +621,7 @@ export const GameMonster: React.FC = () => {
|
|
| 561 |
<div className="bg-white/10 p-6 rounded-2xl mb-10 border border-white/10 backdrop-blur-sm mt-8">
|
| 562 |
<p className="text-yellow-200 font-bold mb-2 flex items-center justify-center"><Gift size={20} className="mr-2"/> 奖励已解锁</p>
|
| 563 |
<p className="text-sm text-gray-300 mb-4">
|
| 564 |
-
|
| 565 |
<span className="text-white font-bold mx-1 border-b border-white/30">
|
| 566 |
{rewardConfig.type==='ACHIEVEMENT' ? '成就奖状' : rewardConfig.type==='ITEM' ? rewardConfig.val : '抽奖券'}
|
| 567 |
</span>
|
|
|
|
| 2 |
import { createPortal } from 'react-dom';
|
| 3 |
import { api } from '../services/api';
|
| 4 |
import { AchievementConfig, Student } from '../types';
|
| 5 |
+
import { Play, Pause, Settings, Maximize, Minimize, Gift, Trophy, Package, Volume2, Keyboard, Award, RotateCcw, UserX } from 'lucide-react';
|
| 6 |
|
| 7 |
// Monster visual assets
|
| 8 |
const MONSTER_TYPES = ['👾', '👹', '👺', '👻', '💀', '👽'];
|
|
|
|
| 24 |
// Config State
|
| 25 |
const [achConfig, setAchConfig] = useState<AchievementConfig | null>(null);
|
| 26 |
const [students, setStudents] = useState<Student[]>([]);
|
| 27 |
+
const [excludedStudentIds, setExcludedStudentIds] = useState<Set<string>>(new Set());
|
| 28 |
+
const [isFilterOpen, setIsFilterOpen] = useState(false);
|
| 29 |
|
| 30 |
const [duration, setDuration] = useState(300);
|
| 31 |
const [sensitivity, setSensitivity] = useState(40);
|
|
|
|
| 104 |
api.games.getMonsterConfig(homeroomClass)
|
| 105 |
]);
|
| 106 |
setAchConfig(ac);
|
| 107 |
+
const classStudents = (stus as Student[]).filter((s: Student) => s.className === homeroomClass);
|
| 108 |
+
// Sort by Seat No
|
| 109 |
+
classStudents.sort((a, b) => {
|
| 110 |
+
const seatA = parseInt(a.seatNo || '99999');
|
| 111 |
+
const seatB = parseInt(b.seatNo || '99999');
|
| 112 |
+
if (seatA !== seatB) return seatA - seatB;
|
| 113 |
+
return a.name.localeCompare(b.name, 'zh-CN');
|
| 114 |
+
});
|
| 115 |
+
setStudents(classStudents);
|
| 116 |
|
| 117 |
if (savedConfig && savedConfig.duration) {
|
| 118 |
setDuration(savedConfig.duration);
|
|
|
|
| 290 |
};
|
| 291 |
|
| 292 |
const handleBatchGrant = async () => {
|
| 293 |
+
const targets = students.filter(s => !excludedStudentIds.has(s._id || String(s.id)));
|
| 294 |
+
if (!rewardConfig.enabled || targets.length === 0) return;
|
| 295 |
+
if (!confirm(`确定为全班 ${targets.length} 名学生发放奖励吗?\n(已排除 ${excludedStudentIds.size} 名请假学生)`)) return;
|
| 296 |
|
| 297 |
try {
|
| 298 |
+
const promises = targets.map(s => {
|
| 299 |
if (rewardConfig.type === 'ACHIEVEMENT') {
|
| 300 |
return api.achievements.grant({
|
| 301 |
studentId: s._id || String(s.id),
|
|
|
|
| 482 |
</label>
|
| 483 |
</div>
|
| 484 |
|
| 485 |
+
{/* Person Filter */}
|
| 486 |
+
<div className="bg-slate-900 p-4 rounded-xl border border-slate-700">
|
| 487 |
+
<div className="flex justify-between items-center mb-2">
|
| 488 |
+
<h4 className="text-xs text-gray-400 font-bold uppercase">参与人员</h4>
|
| 489 |
+
<button onClick={() => setIsFilterOpen(true)} className="text-xs text-blue-400 hover:text-blue-300 flex items-center">
|
| 490 |
+
<UserX size={14} className="mr-1"/> 排除请假学生 ({excludedStudentIds.size})
|
| 491 |
+
</button>
|
| 492 |
+
</div>
|
| 493 |
+
<p className="text-xs text-gray-500">共 {students.length - excludedStudentIds.size} 人参与奖励结算</p>
|
| 494 |
+
</div>
|
| 495 |
+
|
| 496 |
{/* Extended Reward Config */}
|
| 497 |
<div className="bg-slate-900 p-4 rounded-xl border border-slate-700 space-y-3">
|
| 498 |
<div className="flex items-center justify-between">
|
|
|
|
| 568 |
</div>
|
| 569 |
)}
|
| 570 |
|
| 571 |
+
{/* Student Filter Modal */}
|
| 572 |
+
{isFilterOpen && (
|
| 573 |
+
<div className="absolute inset-0 bg-black/50 z-[1000000] flex items-center justify-center p-4">
|
| 574 |
+
<div className="bg-white rounded-xl w-full max-w-3xl h-[80vh] flex flex-col shadow-2xl animate-in zoom-in-95 text-gray-800">
|
| 575 |
+
<div className="p-4 border-b flex justify-between items-center">
|
| 576 |
+
<h3 className="font-bold text-lg">排除请假/缺勤学生</h3>
|
| 577 |
+
<div className="flex gap-2">
|
| 578 |
+
<button onClick={() => setExcludedStudentIds(new Set())} className="text-xs text-blue-600 hover:underline">清空选择</button>
|
| 579 |
+
<button onClick={() => setIsFilterOpen(false)} className="px-4 py-1 bg-blue-600 text-white rounded text-sm">确定</button>
|
| 580 |
+
</div>
|
| 581 |
+
</div>
|
| 582 |
+
<div className="flex-1 overflow-y-auto p-4 bg-gray-50">
|
| 583 |
+
<div className="grid grid-cols-4 sm:grid-cols-5 md:grid-cols-6 gap-2">
|
| 584 |
+
{students.map(s => {
|
| 585 |
+
const isExcluded = excludedStudentIds.has(s._id || String(s.id));
|
| 586 |
+
return (
|
| 587 |
+
<div
|
| 588 |
+
key={s._id}
|
| 589 |
+
onClick={() => {
|
| 590 |
+
const newSet = new Set(excludedStudentIds);
|
| 591 |
+
if (isExcluded) newSet.delete(s._id || String(s.id));
|
| 592 |
+
else newSet.add(s._id || String(s.id));
|
| 593 |
+
setExcludedStudentIds(newSet);
|
| 594 |
+
}}
|
| 595 |
+
className={`p-2 rounded border cursor-pointer text-center text-sm transition-all select-none ${isExcluded ? 'bg-red-50 border-red-300 text-red-500 opacity-60' : 'bg-white border-gray-200 hover:border-blue-300'}`}
|
| 596 |
+
>
|
| 597 |
+
<div className="font-bold">{s.name}</div>
|
| 598 |
+
<div className="text-xs opacity-70">{s.seatNo}</div>
|
| 599 |
+
{isExcluded && <div className="text-[10px] font-bold mt-1">已排除</div>}
|
| 600 |
+
</div>
|
| 601 |
+
)
|
| 602 |
+
})}
|
| 603 |
+
</div>
|
| 604 |
+
</div>
|
| 605 |
+
</div>
|
| 606 |
+
</div>
|
| 607 |
+
)}
|
| 608 |
+
|
| 609 |
{/* Result Modal */}
|
| 610 |
{gameState !== 'IDLE' && gameState !== 'PLAYING' && !isConfigOpen && (
|
| 611 |
<div className="absolute inset-0 bg-black/90 z-[110] flex items-center justify-center p-4 backdrop-blur-lg animate-in zoom-in">
|
|
|
|
| 621 |
<div className="bg-white/10 p-6 rounded-2xl mb-10 border border-white/10 backdrop-blur-sm mt-8">
|
| 622 |
<p className="text-yellow-200 font-bold mb-2 flex items-center justify-center"><Gift size={20} className="mr-2"/> 奖励已解锁</p>
|
| 623 |
<p className="text-sm text-gray-300 mb-4">
|
| 624 |
+
为 <span className="text-white font-bold">{students.length - excludedStudentIds.size}</span> 名学生发放:
|
| 625 |
<span className="text-white font-bold mx-1 border-b border-white/30">
|
| 626 |
{rewardConfig.type==='ACHIEVEMENT' ? '成就奖状' : rewardConfig.type==='ITEM' ? rewardConfig.val : '抽奖券'}
|
| 627 |
</span>
|
pages/GameRandom.tsx
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
|
|
| 1 |
import React, { useState, useEffect, useRef } from 'react';
|
| 2 |
import { createPortal } from 'react-dom';
|
| 3 |
import { api } from '../services/api';
|
| 4 |
import { Student, GameSession, GameTeam, AchievementConfig } from '../types';
|
| 5 |
-
import { Loader2, User, Users, Play, Pause, Gift, CheckCircle, XCircle, Award, Volume2, Settings, Maximize, Minimize } from 'lucide-react';
|
| 6 |
|
| 7 |
export const GameRandom: React.FC = () => {
|
| 8 |
const [loading, setLoading] = useState(true);
|
|
@@ -19,6 +20,10 @@ export const GameRandom: React.FC = () => {
|
|
| 19 |
const [showResultModal, setShowResultModal] = useState(false);
|
| 20 |
const [isFullscreen, setIsFullscreen] = useState(false);
|
| 21 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
// Reward State
|
| 23 |
const [rewardType, setRewardType] = useState<'DRAW_COUNT' | 'ITEM' | 'ACHIEVEMENT'>('DRAW_COUNT');
|
| 24 |
const [rewardId, setRewardId] = useState(''); // Achievement ID or Item Name
|
|
@@ -63,10 +68,17 @@ export const GameRandom: React.FC = () => {
|
|
| 63 |
|
| 64 |
const getTargetList = () => {
|
| 65 |
if (mode === 'TEAM') return teams;
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
if (!
|
| 69 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
};
|
| 71 |
|
| 72 |
const startRandom = () => {
|
|
@@ -125,7 +137,14 @@ export const GameRandom: React.FC = () => {
|
|
| 125 |
} else {
|
| 126 |
// Team mode: find all students in team
|
| 127 |
const team = selectedResult as GameTeam;
|
| 128 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
}
|
| 130 |
|
| 131 |
const rewardName = rewardType === 'ACHIEVEMENT'
|
|
@@ -194,6 +213,14 @@ export const GameRandom: React.FC = () => {
|
|
| 194 |
{teams.map(t => <option key={t.id} value={t.id}>{t.name} (组)</option>)}
|
| 195 |
</select>
|
| 196 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 197 |
<div className="flex items-center gap-2 bg-amber-50 px-3 py-2 rounded-lg border border-amber-100">
|
| 198 |
<Gift size={16} className="text-amber-500"/>
|
| 199 |
<label className="text-sm font-bold text-gray-700">奖励:</label>
|
|
@@ -260,6 +287,44 @@ export const GameRandom: React.FC = () => {
|
|
| 260 |
</button>
|
| 261 |
</div>
|
| 262 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 263 |
{/* Result Modal */}
|
| 264 |
{showResultModal && selectedResult && (
|
| 265 |
<div className="fixed inset-0 bg-black/60 z-[110] flex items-center justify-center p-4 backdrop-blur-sm animate-in fade-in">
|
|
@@ -298,4 +363,4 @@ export const GameRandom: React.FC = () => {
|
|
| 298 |
return createPortal(GameContent, document.body);
|
| 299 |
}
|
| 300 |
return GameContent;
|
| 301 |
-
};
|
|
|
|
| 1 |
+
|
| 2 |
import React, { useState, useEffect, useRef } from 'react';
|
| 3 |
import { createPortal } from 'react-dom';
|
| 4 |
import { api } from '../services/api';
|
| 5 |
import { Student, GameSession, GameTeam, AchievementConfig } from '../types';
|
| 6 |
+
import { Loader2, User, Users, Play, Pause, Gift, CheckCircle, XCircle, Award, Volume2, Settings, Maximize, Minimize, UserX } from 'lucide-react';
|
| 7 |
|
| 8 |
export const GameRandom: React.FC = () => {
|
| 9 |
const [loading, setLoading] = useState(true);
|
|
|
|
| 20 |
const [showResultModal, setShowResultModal] = useState(false);
|
| 21 |
const [isFullscreen, setIsFullscreen] = useState(false);
|
| 22 |
|
| 23 |
+
// Filter State
|
| 24 |
+
const [excludedStudentIds, setExcludedStudentIds] = useState<Set<string>>(new Set());
|
| 25 |
+
const [isFilterOpen, setIsFilterOpen] = useState(false);
|
| 26 |
+
|
| 27 |
// Reward State
|
| 28 |
const [rewardType, setRewardType] = useState<'DRAW_COUNT' | 'ITEM' | 'ACHIEVEMENT'>('DRAW_COUNT');
|
| 29 |
const [rewardId, setRewardId] = useState(''); // Achievement ID or Item Name
|
|
|
|
| 68 |
|
| 69 |
const getTargetList = () => {
|
| 70 |
if (mode === 'TEAM') return teams;
|
| 71 |
+
|
| 72 |
+
let baseList = students;
|
| 73 |
+
if (scopeTeamId !== 'ALL') {
|
| 74 |
+
const team = teams.find(t => t.id === scopeTeamId);
|
| 75 |
+
if (team) {
|
| 76 |
+
baseList = students.filter(s => team.members.includes(s._id || String(s.id)));
|
| 77 |
+
}
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
// Filter out excluded students
|
| 81 |
+
return baseList.filter(s => !excludedStudentIds.has(s._id || String(s.id)));
|
| 82 |
};
|
| 83 |
|
| 84 |
const startRandom = () => {
|
|
|
|
| 137 |
} else {
|
| 138 |
// Team mode: find all students in team
|
| 139 |
const team = selectedResult as GameTeam;
|
| 140 |
+
// IMPORTANT: Exclude absent students from team reward
|
| 141 |
+
targets = students.filter(s => team.members.includes(s._id || String(s.id)) && !excludedStudentIds.has(s._id || String(s.id)));
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
if (targets.length === 0) {
|
| 145 |
+
alert('没有符合条件的学生可发放奖励');
|
| 146 |
+
setShowResultModal(false);
|
| 147 |
+
return;
|
| 148 |
}
|
| 149 |
|
| 150 |
const rewardName = rewardType === 'ACHIEVEMENT'
|
|
|
|
| 213 |
{teams.map(t => <option key={t.id} value={t.id}>{t.name} (组)</option>)}
|
| 214 |
</select>
|
| 215 |
)}
|
| 216 |
+
|
| 217 |
+
<button
|
| 218 |
+
onClick={() => setIsFilterOpen(true)}
|
| 219 |
+
className={`flex items-center gap-1 px-3 py-2 rounded-lg text-sm border font-medium ${excludedStudentIds.size > 0 ? 'bg-red-50 text-red-600 border-red-200' : 'bg-gray-50 text-gray-600 border-gray-200'}`}
|
| 220 |
+
>
|
| 221 |
+
<UserX size={16}/> 排除 ({excludedStudentIds.size})
|
| 222 |
+
</button>
|
| 223 |
+
|
| 224 |
<div className="flex items-center gap-2 bg-amber-50 px-3 py-2 rounded-lg border border-amber-100">
|
| 225 |
<Gift size={16} className="text-amber-500"/>
|
| 226 |
<label className="text-sm font-bold text-gray-700">奖励:</label>
|
|
|
|
| 287 |
</button>
|
| 288 |
</div>
|
| 289 |
|
| 290 |
+
{/* Student Filter Modal */}
|
| 291 |
+
{isFilterOpen && (
|
| 292 |
+
<div className="absolute inset-0 bg-black/50 z-[1000000] flex items-center justify-center p-4">
|
| 293 |
+
<div className="bg-white rounded-xl w-full max-w-3xl h-[80vh] flex flex-col shadow-2xl animate-in zoom-in-95 text-gray-800">
|
| 294 |
+
<div className="p-4 border-b flex justify-between items-center">
|
| 295 |
+
<h3 className="font-bold text-lg">排除请假/缺勤学生</h3>
|
| 296 |
+
<div className="flex gap-2">
|
| 297 |
+
<button onClick={() => setExcludedStudentIds(new Set())} className="text-xs text-blue-600 hover:underline">清空选择</button>
|
| 298 |
+
<button onClick={() => setIsFilterOpen(false)} className="px-4 py-1 bg-blue-600 text-white rounded text-sm">确定</button>
|
| 299 |
+
</div>
|
| 300 |
+
</div>
|
| 301 |
+
<div className="flex-1 overflow-y-auto p-4 bg-gray-50">
|
| 302 |
+
<div className="grid grid-cols-4 sm:grid-cols-5 md:grid-cols-6 gap-2">
|
| 303 |
+
{students.map(s => {
|
| 304 |
+
const isExcluded = excludedStudentIds.has(s._id || String(s.id));
|
| 305 |
+
return (
|
| 306 |
+
<div
|
| 307 |
+
key={s._id}
|
| 308 |
+
onClick={() => {
|
| 309 |
+
const newSet = new Set(excludedStudentIds);
|
| 310 |
+
if (isExcluded) newSet.delete(s._id || String(s.id));
|
| 311 |
+
else newSet.add(s._id || String(s.id));
|
| 312 |
+
setExcludedStudentIds(newSet);
|
| 313 |
+
}}
|
| 314 |
+
className={`p-2 rounded border cursor-pointer text-center text-sm transition-all select-none ${isExcluded ? 'bg-red-50 border-red-300 text-red-500 opacity-60' : 'bg-white border-gray-200 hover:border-blue-300'}`}
|
| 315 |
+
>
|
| 316 |
+
<div className="font-bold">{s.name}</div>
|
| 317 |
+
<div className="text-xs opacity-70">{s.seatNo}</div>
|
| 318 |
+
{isExcluded && <div className="text-[10px] font-bold mt-1">已排除</div>}
|
| 319 |
+
</div>
|
| 320 |
+
)
|
| 321 |
+
})}
|
| 322 |
+
</div>
|
| 323 |
+
</div>
|
| 324 |
+
</div>
|
| 325 |
+
</div>
|
| 326 |
+
)}
|
| 327 |
+
|
| 328 |
{/* Result Modal */}
|
| 329 |
{showResultModal && selectedResult && (
|
| 330 |
<div className="fixed inset-0 bg-black/60 z-[110] flex items-center justify-center p-4 backdrop-blur-sm animate-in fade-in">
|
|
|
|
| 363 |
return createPortal(GameContent, document.body);
|
| 364 |
}
|
| 365 |
return GameContent;
|
| 366 |
+
};
|
pages/GameZen.tsx
ADDED
|
@@ -0,0 +1,549 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import React, { useState, useEffect, useRef } from 'react';
|
| 3 |
+
import { createPortal } from 'react-dom';
|
| 4 |
+
import { api } from '../services/api';
|
| 5 |
+
import { AchievementConfig, Student } from '../types';
|
| 6 |
+
import { Play, Pause, Settings, Maximize, Minimize, Gift, Trophy, Package, Moon, UserX, CheckCircle, Volume2 } from 'lucide-react';
|
| 7 |
+
|
| 8 |
+
export const GameZen: React.FC = () => {
|
| 9 |
+
const currentUser = api.auth.getCurrentUser();
|
| 10 |
+
const homeroomClass = currentUser?.homeroomClass;
|
| 11 |
+
|
| 12 |
+
// React State
|
| 13 |
+
const [isPlaying, setIsPlaying] = useState(false);
|
| 14 |
+
const [gameState, setGameState] = useState<'IDLE' | 'PLAYING' | 'FINISHED'>('IDLE');
|
| 15 |
+
const [timeLeft, setTimeLeft] = useState(2400); // Seconds
|
| 16 |
+
const [currentVolume, setCurrentVolume] = useState(0);
|
| 17 |
+
const [isFullscreen, setIsFullscreen] = useState(false);
|
| 18 |
+
|
| 19 |
+
// Game Logic State
|
| 20 |
+
const [score, setScore] = useState(100);
|
| 21 |
+
const [quietSeconds, setQuietSeconds] = useState(0);
|
| 22 |
+
const [totalSeconds, setTotalSeconds] = useState(0);
|
| 23 |
+
|
| 24 |
+
// Config State
|
| 25 |
+
const [achConfig, setAchConfig] = useState<AchievementConfig | null>(null);
|
| 26 |
+
const [students, setStudents] = useState<Student[]>([]);
|
| 27 |
+
const [excludedStudentIds, setExcludedStudentIds] = useState<Set<string>>(new Set());
|
| 28 |
+
const [isFilterOpen, setIsFilterOpen] = useState(false);
|
| 29 |
+
|
| 30 |
+
const [durationMinutes, setDurationMinutes] = useState(40);
|
| 31 |
+
const [threshold, setThreshold] = useState(30);
|
| 32 |
+
const [passRate, setPassRate] = useState(90);
|
| 33 |
+
|
| 34 |
+
const [rewardConfig, setRewardConfig] = useState<{
|
| 35 |
+
enabled: boolean;
|
| 36 |
+
type: 'DRAW_COUNT' | 'ITEM' | 'ACHIEVEMENT';
|
| 37 |
+
val: string;
|
| 38 |
+
count: number;
|
| 39 |
+
}>({ enabled: true, type: 'DRAW_COUNT', val: '自习奖励', count: 1 });
|
| 40 |
+
|
| 41 |
+
const [isConfigOpen, setIsConfigOpen] = useState(true);
|
| 42 |
+
|
| 43 |
+
// REFS
|
| 44 |
+
const isPlayingRef = useRef(false);
|
| 45 |
+
const gameStateRef = useRef('IDLE');
|
| 46 |
+
const lastTimeRef = useRef(0);
|
| 47 |
+
const audioContextRef = useRef<AudioContext | null>(null);
|
| 48 |
+
const analyserRef = useRef<AnalyserNode | null>(null);
|
| 49 |
+
const dataArrayRef = useRef<Uint8Array | null>(null);
|
| 50 |
+
const sourceRef = useRef<MediaStreamAudioSourceNode | null>(null);
|
| 51 |
+
const reqRef = useRef<number>(0);
|
| 52 |
+
|
| 53 |
+
// Sync Refs
|
| 54 |
+
useEffect(() => { isPlayingRef.current = isPlaying; }, [isPlaying]);
|
| 55 |
+
useEffect(() => { gameStateRef.current = gameState; }, [gameState]);
|
| 56 |
+
|
| 57 |
+
useEffect(() => {
|
| 58 |
+
loadData();
|
| 59 |
+
return () => {
|
| 60 |
+
stopAudio();
|
| 61 |
+
if(reqRef.current) cancelAnimationFrame(reqRef.current);
|
| 62 |
+
};
|
| 63 |
+
}, []);
|
| 64 |
+
|
| 65 |
+
const loadData = async () => {
|
| 66 |
+
if (!homeroomClass) return;
|
| 67 |
+
try {
|
| 68 |
+
const [ac, stus, savedConfig] = await Promise.all([
|
| 69 |
+
api.achievements.getConfig(homeroomClass),
|
| 70 |
+
api.students.getAll(),
|
| 71 |
+
api.games.getZenConfig(homeroomClass)
|
| 72 |
+
]);
|
| 73 |
+
setAchConfig(ac);
|
| 74 |
+
const classStudents = (stus as Student[]).filter((s: Student) => s.className === homeroomClass);
|
| 75 |
+
// Sort by Seat No
|
| 76 |
+
classStudents.sort((a, b) => {
|
| 77 |
+
const seatA = parseInt(a.seatNo || '99999');
|
| 78 |
+
const seatB = parseInt(b.seatNo || '99999');
|
| 79 |
+
if (seatA !== seatB) return seatA - seatB;
|
| 80 |
+
return a.name.localeCompare(b.name, 'zh-CN');
|
| 81 |
+
});
|
| 82 |
+
setStudents(classStudents);
|
| 83 |
+
|
| 84 |
+
if (savedConfig && savedConfig.durationMinutes) {
|
| 85 |
+
setDurationMinutes(savedConfig.durationMinutes);
|
| 86 |
+
setThreshold(savedConfig.threshold);
|
| 87 |
+
setPassRate(savedConfig.passRate);
|
| 88 |
+
if (savedConfig.rewardConfig) setRewardConfig(savedConfig.rewardConfig);
|
| 89 |
+
}
|
| 90 |
+
} catch (e) { console.error(e); }
|
| 91 |
+
};
|
| 92 |
+
|
| 93 |
+
const startAudio = async () => {
|
| 94 |
+
try {
|
| 95 |
+
if (audioContextRef.current && audioContextRef.current.state === 'running') return;
|
| 96 |
+
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
| 97 |
+
// @ts-ignore
|
| 98 |
+
const AudioContext = window.AudioContext || window.webkitAudioContext;
|
| 99 |
+
const ctx = new AudioContext();
|
| 100 |
+
const analyser = ctx.createAnalyser();
|
| 101 |
+
analyser.fftSize = 256;
|
| 102 |
+
const source = ctx.createMediaStreamSource(stream);
|
| 103 |
+
source.connect(analyser);
|
| 104 |
+
|
| 105 |
+
audioContextRef.current = ctx;
|
| 106 |
+
analyserRef.current = analyser;
|
| 107 |
+
sourceRef.current = source;
|
| 108 |
+
dataArrayRef.current = new Uint8Array(analyser.frequencyBinCount);
|
| 109 |
+
} catch (e) {
|
| 110 |
+
console.error("Mic error", e);
|
| 111 |
+
alert('无法访问麦克风,请检查权限');
|
| 112 |
+
}
|
| 113 |
+
};
|
| 114 |
+
|
| 115 |
+
const stopAudio = () => {
|
| 116 |
+
if (sourceRef.current) sourceRef.current.disconnect();
|
| 117 |
+
if (audioContextRef.current) audioContextRef.current.close();
|
| 118 |
+
audioContextRef.current = null;
|
| 119 |
+
};
|
| 120 |
+
|
| 121 |
+
const saveConfig = async () => {
|
| 122 |
+
if (!homeroomClass) return;
|
| 123 |
+
try {
|
| 124 |
+
await api.games.saveZenConfig({
|
| 125 |
+
className: homeroomClass,
|
| 126 |
+
durationMinutes,
|
| 127 |
+
threshold,
|
| 128 |
+
passRate,
|
| 129 |
+
rewardConfig
|
| 130 |
+
});
|
| 131 |
+
} catch (e) { console.error('Auto-save config failed', e); }
|
| 132 |
+
};
|
| 133 |
+
|
| 134 |
+
const startGame = async () => {
|
| 135 |
+
saveConfig();
|
| 136 |
+
setIsConfigOpen(false);
|
| 137 |
+
setGameState('PLAYING');
|
| 138 |
+
setTimeLeft(durationMinutes * 60);
|
| 139 |
+
setQuietSeconds(0);
|
| 140 |
+
setTotalSeconds(0);
|
| 141 |
+
setScore(100);
|
| 142 |
+
|
| 143 |
+
await startAudio();
|
| 144 |
+
setIsPlaying(true);
|
| 145 |
+
|
| 146 |
+
lastTimeRef.current = performance.now();
|
| 147 |
+
if (reqRef.current) cancelAnimationFrame(reqRef.current);
|
| 148 |
+
reqRef.current = requestAnimationFrame(loop);
|
| 149 |
+
};
|
| 150 |
+
|
| 151 |
+
const endGame = () => {
|
| 152 |
+
setIsPlaying(false);
|
| 153 |
+
setGameState('FINISHED');
|
| 154 |
+
if(reqRef.current) cancelAnimationFrame(reqRef.current);
|
| 155 |
+
stopAudio();
|
| 156 |
+
};
|
| 157 |
+
|
| 158 |
+
const loop = (time: number) => {
|
| 159 |
+
reqRef.current = requestAnimationFrame(loop);
|
| 160 |
+
|
| 161 |
+
if (!isPlayingRef.current || gameStateRef.current !== 'PLAYING') {
|
| 162 |
+
lastTimeRef.current = time;
|
| 163 |
+
return;
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
const delta = (time - lastTimeRef.current) / 1000;
|
| 167 |
+
lastTimeRef.current = time;
|
| 168 |
+
|
| 169 |
+
// Audio Processing
|
| 170 |
+
if (analyserRef.current && dataArrayRef.current) {
|
| 171 |
+
analyserRef.current.getByteFrequencyData(dataArrayRef.current as any);
|
| 172 |
+
const avg = dataArrayRef.current.reduce((a,b)=>a+b) / dataArrayRef.current.length;
|
| 173 |
+
const vol = Math.min(100, Math.floor(avg / 2));
|
| 174 |
+
setCurrentVolume(vol);
|
| 175 |
+
|
| 176 |
+
// Game Logic
|
| 177 |
+
if (delta > 0) {
|
| 178 |
+
setTotalSeconds(prev => prev + delta);
|
| 179 |
+
if (vol < threshold) {
|
| 180 |
+
setQuietSeconds(prev => prev + delta);
|
| 181 |
+
}
|
| 182 |
+
}
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
setTimeLeft(prev => {
|
| 186 |
+
const next = prev - delta;
|
| 187 |
+
if (next <= 0) {
|
| 188 |
+
endGame();
|
| 189 |
+
return 0;
|
| 190 |
+
}
|
| 191 |
+
return next;
|
| 192 |
+
});
|
| 193 |
+
};
|
| 194 |
+
|
| 195 |
+
// Score Calculation
|
| 196 |
+
const calculatedScore = totalSeconds > 0 ? Math.round((quietSeconds / totalSeconds) * 100) : 100;
|
| 197 |
+
const isQuiet = currentVolume < threshold;
|
| 198 |
+
|
| 199 |
+
const handleBatchGrant = async () => {
|
| 200 |
+
const targets = students.filter(s => !excludedStudentIds.has(s._id || String(s.id)));
|
| 201 |
+
if (!rewardConfig.enabled || targets.length === 0) return;
|
| 202 |
+
if (!confirm(`确定为 ${targets.length} 名学生发放奖励吗?\n(已排除 ${excludedStudentIds.size} 名请假学生)`)) return;
|
| 203 |
+
|
| 204 |
+
try {
|
| 205 |
+
const promises = targets.map(s => {
|
| 206 |
+
if (rewardConfig.type === 'ACHIEVEMENT') {
|
| 207 |
+
return api.achievements.grant({
|
| 208 |
+
studentId: s._id || String(s.id),
|
| 209 |
+
achievementId: rewardConfig.val,
|
| 210 |
+
semester: '当前学期'
|
| 211 |
+
});
|
| 212 |
+
} else {
|
| 213 |
+
return api.games.grantReward({
|
| 214 |
+
studentId: s._id || String(s.id),
|
| 215 |
+
count: rewardConfig.count,
|
| 216 |
+
rewardType: rewardConfig.type,
|
| 217 |
+
name: rewardConfig.type === 'DRAW_COUNT' ? '禅道修行奖励' : rewardConfig.val
|
| 218 |
+
});
|
| 219 |
+
}
|
| 220 |
+
});
|
| 221 |
+
await Promise.all(promises);
|
| 222 |
+
alert('奖励发放成功!');
|
| 223 |
+
setIsConfigOpen(true);
|
| 224 |
+
setGameState('IDLE');
|
| 225 |
+
} catch (e) { alert('发放失败'); }
|
| 226 |
+
};
|
| 227 |
+
|
| 228 |
+
const containerStyle = isFullscreen ? {
|
| 229 |
+
position: 'fixed' as 'fixed',
|
| 230 |
+
top: 0,
|
| 231 |
+
left: 0,
|
| 232 |
+
width: '100vw',
|
| 233 |
+
height: '100vh',
|
| 234 |
+
zIndex: 999999,
|
| 235 |
+
backgroundColor: '#0f766e',
|
| 236 |
+
} : {};
|
| 237 |
+
|
| 238 |
+
// Zen Visuals
|
| 239 |
+
const monkY = isQuiet ? -20 : 0;
|
| 240 |
+
const monkScale = isQuiet ? 1.1 : 1;
|
| 241 |
+
const glowOpacity = isQuiet ? 0.6 : 0;
|
| 242 |
+
|
| 243 |
+
const GameContent = (
|
| 244 |
+
<div
|
| 245 |
+
className={`${isFullscreen ? '' : 'h-full w-full relative'} flex flex-col bg-teal-800 overflow-hidden select-none transition-all duration-300 font-sans`}
|
| 246 |
+
style={containerStyle}
|
| 247 |
+
>
|
| 248 |
+
{/* Background */}
|
| 249 |
+
<div className="absolute inset-0 bg-gradient-to-b from-teal-900 to-teal-700 opacity-90 pointer-events-none"></div>
|
| 250 |
+
<div className="absolute inset-0 flex items-center justify-center pointer-events-none opacity-10">
|
| 251 |
+
<div className={`w-[800px] h-[800px] border-[50px] rounded-full border-white transition-all duration-1000 ${isQuiet ? 'scale-100' : 'scale-90 border-red-500'}`}></div>
|
| 252 |
+
</div>
|
| 253 |
+
|
| 254 |
+
{/* HUD */}
|
| 255 |
+
<div className="absolute top-4 left-4 z-50 flex gap-3">
|
| 256 |
+
<div className="bg-black/30 border border-teal-300/30 rounded-xl p-3 min-w-[120px] text-center backdrop-blur-md">
|
| 257 |
+
<span className="text-[10px] text-teal-200 font-bold uppercase block">倒计时</span>
|
| 258 |
+
<span className="text-3xl font-mono font-black text-white">
|
| 259 |
+
{Math.floor(timeLeft / 60)}:{String(Math.floor(timeLeft % 60)).padStart(2, '0')}
|
| 260 |
+
</span>
|
| 261 |
+
</div>
|
| 262 |
+
<div className="bg-black/30 border border-teal-300/30 rounded-xl p-3 min-w-[120px] text-center backdrop-blur-md">
|
| 263 |
+
<span className="text-[10px] text-teal-200 font-bold uppercase block">专注评分</span>
|
| 264 |
+
<span className={`text-3xl font-mono font-black ${calculatedScore >= passRate ? 'text-green-400' : 'text-red-400'}`}>{calculatedScore}</span>
|
| 265 |
+
</div>
|
| 266 |
+
</div>
|
| 267 |
+
|
| 268 |
+
{/* Status Indicator */}
|
| 269 |
+
<div className="absolute top-4 right-4 z-50">
|
| 270 |
+
<div className={`px-4 py-2 rounded-full font-bold shadow-lg backdrop-blur-md transition-colors ${isQuiet ? 'bg-green-500/20 text-green-300 border border-green-500/50' : 'bg-red-500/20 text-red-300 border border-red-500/50'}`}>
|
| 271 |
+
{isQuiet ? '🌿 极静 · 专注' : '⚠️ 喧哗 · 浮躁'}
|
| 272 |
+
</div>
|
| 273 |
+
</div>
|
| 274 |
+
|
| 275 |
+
{/* Main Controls */}
|
| 276 |
+
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 z-50 flex gap-4 items-center bg-black/40 p-2 rounded-2xl backdrop-blur-md border border-white/10 shadow-2xl">
|
| 277 |
+
<button
|
| 278 |
+
onClick={() => setIsConfigOpen(true)}
|
| 279 |
+
className="p-3 bg-white/10 hover:bg-white/20 text-white rounded-xl transition-all"
|
| 280 |
+
title="设置"
|
| 281 |
+
>
|
| 282 |
+
<Settings size={20}/>
|
| 283 |
+
</button>
|
| 284 |
+
|
| 285 |
+
{gameState === 'PLAYING' ? (
|
| 286 |
+
<button
|
| 287 |
+
onClick={endGame}
|
| 288 |
+
className="px-6 py-3 rounded-xl shadow-lg bg-red-600 hover:bg-red-500 text-white font-bold transition-all"
|
| 289 |
+
>
|
| 290 |
+
提前结束
|
| 291 |
+
</button>
|
| 292 |
+
) : (
|
| 293 |
+
<button
|
| 294 |
+
onClick={startGame}
|
| 295 |
+
className="p-4 rounded-xl shadow-lg bg-green-600 hover:bg-green-500 text-white transition-all w-16 h-16 flex items-center justify-center animate-pulse"
|
| 296 |
+
>
|
| 297 |
+
<Play fill="currentColor" size={28}/>
|
| 298 |
+
</button>
|
| 299 |
+
)}
|
| 300 |
+
|
| 301 |
+
<button
|
| 302 |
+
onClick={() => setIsFullscreen(!isFullscreen)}
|
| 303 |
+
className="p-3 bg-white/10 hover:bg-white/20 text-white rounded-xl transition-all"
|
| 304 |
+
title="全屏"
|
| 305 |
+
>
|
| 306 |
+
{isFullscreen ? <Minimize size={20}/> : <Maximize size={20}/>}
|
| 307 |
+
</button>
|
| 308 |
+
</div>
|
| 309 |
+
|
| 310 |
+
{/* Visuals */}
|
| 311 |
+
<div className="flex-1 relative z-10 flex flex-col items-center justify-center pb-20">
|
| 312 |
+
{/* Aura */}
|
| 313 |
+
<div
|
| 314 |
+
className="absolute w-64 h-64 bg-yellow-200 rounded-full blur-[80px] transition-all duration-1000"
|
| 315 |
+
style={{ opacity: glowOpacity, transform: `scale(${isQuiet ? 1.5 : 0.5})` }}
|
| 316 |
+
></div>
|
| 317 |
+
|
| 318 |
+
{/* Monk/Visual */}
|
| 319 |
+
<div
|
| 320 |
+
className="text-[150px] transition-all duration-1000 ease-in-out relative z-20 drop-shadow-2xl"
|
| 321 |
+
style={{ transform: `translateY(${monkY}px) scale(${monkScale})` }}
|
| 322 |
+
>
|
| 323 |
+
{isQuiet ? '🧘' : '😖'}
|
| 324 |
+
</div>
|
| 325 |
+
|
| 326 |
+
{/* Levitation Base */}
|
| 327 |
+
<div className={`w-40 h-10 bg-black/20 rounded-[100%] blur-md transition-all duration-1000 ${isQuiet ? 'scale-75 opacity-50' : 'scale-100 opacity-80'}`}></div>
|
| 328 |
+
</div>
|
| 329 |
+
|
| 330 |
+
{/* Config Modal */}
|
| 331 |
+
{isConfigOpen && (
|
| 332 |
+
<div className="absolute inset-0 bg-black/80 z-[999999] flex items-center justify-center p-4 backdrop-blur-md">
|
| 333 |
+
<div className="bg-slate-800 text-white rounded-2xl w-full max-w-2xl max-h-[90vh] flex flex-col shadow-2xl border border-slate-700 animate-in zoom-in-95">
|
| 334 |
+
<div className="p-5 border-b border-slate-700 flex justify-between items-center bg-slate-900/50">
|
| 335 |
+
<h2 className="text-xl font-bold flex items-center"><Moon className="mr-2 text-teal-400"/> 禅道修行设置</h2>
|
| 336 |
+
</div>
|
| 337 |
+
|
| 338 |
+
<div className="p-6 space-y-6 overflow-y-auto flex-1 custom-scrollbar">
|
| 339 |
+
<div className="grid grid-cols-2 gap-6">
|
| 340 |
+
<div className="col-span-2 bg-slate-900 p-4 rounded-xl border border-slate-700">
|
| 341 |
+
<h4 className="text-xs text-teal-400 font-bold uppercase mb-4">麦克风校准 (Calibration)</h4>
|
| 342 |
+
<div className="flex items-center gap-4">
|
| 343 |
+
<div className="flex-1">
|
| 344 |
+
<div className="flex justify-between text-xs text-gray-400 mb-1">
|
| 345 |
+
<span>当前环境噪音: {currentVolume}</span>
|
| 346 |
+
<span>阈值设定: {threshold}</span>
|
| 347 |
+
</div>
|
| 348 |
+
<div className="w-full h-6 bg-gray-800 rounded-full overflow-hidden relative border border-gray-600">
|
| 349 |
+
<div className="absolute top-0 bottom-0 w-0.5 bg-yellow-500 z-10 shadow-[0_0_5px_yellow]" style={{left: `${threshold}%`}}></div>
|
| 350 |
+
<div
|
| 351 |
+
className={`h-full transition-all duration-75 ${currentVolume < threshold ? 'bg-green-500' : 'bg-red-500'}`}
|
| 352 |
+
style={{width: `${Math.min(currentVolume, 100)}%`}}
|
| 353 |
+
></div>
|
| 354 |
+
</div>
|
| 355 |
+
<div className="mt-2">
|
| 356 |
+
<input type="range" min="1" max="100" className="w-full h-2 bg-slate-700 rounded-lg appearance-none cursor-pointer" value={threshold} onChange={e=>setThreshold(Number(e.target.value))}/>
|
| 357 |
+
</div>
|
| 358 |
+
</div>
|
| 359 |
+
<button
|
| 360 |
+
onClick={() => { startAudio(); setThreshold(currentVolume + 5); }}
|
| 361 |
+
className="px-4 py-2 bg-slate-700 hover:bg-slate-600 rounded-lg text-xs font-bold whitespace-nowrap border border-slate-600"
|
| 362 |
+
>
|
| 363 |
+
<Volume2 size={14} className="inline mr-1"/> 自动设定
|
| 364 |
+
</button>
|
| 365 |
+
</div>
|
| 366 |
+
<p className="text-[10px] text-gray-500 mt-2">* 点击“自动设定”前请让教室保持您期望的安静状态,系统将自动设置合适的阈值。</p>
|
| 367 |
+
</div>
|
| 368 |
+
|
| 369 |
+
<div>
|
| 370 |
+
<label className="text-xs text-gray-400 font-bold uppercase block mb-1">修行时长 (分钟)</label>
|
| 371 |
+
<input type="number" className="w-full bg-slate-900 border border-slate-600 rounded p-3 text-white focus:border-teal-500 outline-none" value={durationMinutes} onChange={e=>setDurationMinutes(Number(e.target.value))}/>
|
| 372 |
+
</div>
|
| 373 |
+
|
| 374 |
+
<div>
|
| 375 |
+
<label className="text-xs text-gray-400 font-bold uppercase block mb-1">及格评分 (%)</label>
|
| 376 |
+
<input type="number" min="1" max="100" className="w-full bg-slate-900 border border-slate-600 rounded p-3 text-white focus:border-teal-500 outline-none" value={passRate} onChange={e=>setPassRate(Number(e.target.value))}/>
|
| 377 |
+
</div>
|
| 378 |
+
</div>
|
| 379 |
+
|
| 380 |
+
{/* Person Filter */}
|
| 381 |
+
<div className="bg-slate-900 p-4 rounded-xl border border-slate-700">
|
| 382 |
+
<div className="flex justify-between items-center mb-2">
|
| 383 |
+
<h4 className="text-xs text-gray-400 font-bold uppercase">参与人员</h4>
|
| 384 |
+
<button onClick={() => setIsFilterOpen(true)} className="text-xs text-blue-400 hover:text-blue-300 flex items-center">
|
| 385 |
+
<UserX size={14} className="mr-1"/> 排除请假学生 ({excludedStudentIds.size})
|
| 386 |
+
</button>
|
| 387 |
+
</div>
|
| 388 |
+
<p className="text-xs text-gray-500">共 {students.length - excludedStudentIds.size} 人参与奖励结算</p>
|
| 389 |
+
</div>
|
| 390 |
+
|
| 391 |
+
{/* Reward Config */}
|
| 392 |
+
<div className="bg-slate-900 p-4 rounded-xl border border-slate-700 space-y-3">
|
| 393 |
+
<div className="flex items-center justify-between">
|
| 394 |
+
<span className="text-sm font-bold flex items-center text-amber-400"><Gift size={16} className="mr-2"/> 达成奖励</span>
|
| 395 |
+
<label className="relative inline-flex items-center cursor-pointer">
|
| 396 |
+
<input type="checkbox" checked={rewardConfig.enabled} onChange={e=>setRewardConfig({...rewardConfig, enabled: e.target.checked})} className="sr-only peer"/>
|
| 397 |
+
<div className="w-9 h-5 bg-slate-700 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-amber-500"></div>
|
| 398 |
+
</label>
|
| 399 |
+
</div>
|
| 400 |
+
|
| 401 |
+
{rewardConfig.enabled && (
|
| 402 |
+
<div className="space-y-3 animate-in slide-in-from-top-2">
|
| 403 |
+
<div className="flex gap-2">
|
| 404 |
+
{[
|
| 405 |
+
{id: 'DRAW_COUNT', label: '抽奖券', icon: <Gift size={14}/>},
|
| 406 |
+
{id: 'ITEM', label: '实物', icon: <Package size={14}/>},
|
| 407 |
+
{id: 'ACHIEVEMENT', label: '成就', icon: <Trophy size={14}/>}
|
| 408 |
+
].map(t => (
|
| 409 |
+
<button
|
| 410 |
+
key={t.id}
|
| 411 |
+
onClick={() => setRewardConfig({...rewardConfig, type: t.id as any})}
|
| 412 |
+
className={`flex-1 py-2 text-xs rounded-lg border transition-all flex items-center justify-center gap-1 ${rewardConfig.type === t.id ? 'bg-amber-500/20 border-amber-500 text-amber-400' : 'bg-slate-800 border-slate-600 text-gray-400 hover:bg-slate-700'}`}
|
| 413 |
+
>
|
| 414 |
+
{t.icon} {t.label}
|
| 415 |
+
</button>
|
| 416 |
+
))}
|
| 417 |
+
</div>
|
| 418 |
+
|
| 419 |
+
<div className="flex gap-2">
|
| 420 |
+
{rewardConfig.type === 'ACHIEVEMENT' ? (
|
| 421 |
+
<select
|
| 422 |
+
className="flex-1 bg-slate-800 border border-slate-600 rounded-lg p-2 text-xs text-white outline-none"
|
| 423 |
+
value={rewardConfig.val}
|
| 424 |
+
onChange={e=>setRewardConfig({...rewardConfig, val: e.target.value})}
|
| 425 |
+
>
|
| 426 |
+
<option value="">-- 选择成就 --</option>
|
| 427 |
+
{achConfig?.achievements.map(a => <option key={a.id} value={a.id}>{a.icon} {a.name}</option>)}
|
| 428 |
+
</select>
|
| 429 |
+
) : rewardConfig.type === 'ITEM' ? (
|
| 430 |
+
<input
|
| 431 |
+
className="flex-1 bg-slate-800 border border-slate-600 rounded-lg p-2 text-xs text-white outline-none placeholder-gray-500"
|
| 432 |
+
placeholder="奖品名称"
|
| 433 |
+
value={rewardConfig.val}
|
| 434 |
+
onChange={e=>setRewardConfig({...rewardConfig, val: e.target.value})}
|
| 435 |
+
/>
|
| 436 |
+
) : (
|
| 437 |
+
<div className="flex-1 bg-slate-800 border border-slate-600 rounded-lg p-2 text-xs text-gray-400">
|
| 438 |
+
自动发放系统抽奖券
|
| 439 |
+
</div>
|
| 440 |
+
)}
|
| 441 |
+
<div className="flex items-center bg-slate-800 border border-slate-600 rounded-lg px-2">
|
| 442 |
+
<span className="text-xs text-gray-500 mr-2">x</span>
|
| 443 |
+
<input
|
| 444 |
+
type="number"
|
| 445 |
+
min={1}
|
| 446 |
+
className="bg-transparent w-8 text-center text-white text-xs outline-none"
|
| 447 |
+
value={rewardConfig.count}
|
| 448 |
+
onChange={e=>setRewardConfig({...rewardConfig, count: Number(e.target.value)})}
|
| 449 |
+
/>
|
| 450 |
+
</div>
|
| 451 |
+
</div>
|
| 452 |
+
</div>
|
| 453 |
+
)}
|
| 454 |
+
</div>
|
| 455 |
+
</div>
|
| 456 |
+
|
| 457 |
+
<div className="p-5 border-t border-slate-700 bg-slate-900/50 shrink-0">
|
| 458 |
+
<button onClick={startGame} className="w-full py-3 bg-gradient-to-r from-teal-600 to-emerald-600 hover:from-teal-500 hover:to-emerald-500 rounded-xl font-bold text-lg shadow-lg shadow-teal-900/50 transition-all transform hover:scale-[1.02] flex items-center justify-center">
|
| 459 |
+
<Play size={20} className="mr-2" fill="white"/> 开始修行
|
| 460 |
+
</button>
|
| 461 |
+
</div>
|
| 462 |
+
</div>
|
| 463 |
+
</div>
|
| 464 |
+
)}
|
| 465 |
+
|
| 466 |
+
{/* Student Filter Modal */}
|
| 467 |
+
{isFilterOpen && (
|
| 468 |
+
<div className="absolute inset-0 bg-black/80 z-[1000000] flex items-center justify-center p-4">
|
| 469 |
+
<div className="bg-white rounded-xl w-full max-w-3xl h-[80vh] flex flex-col shadow-2xl animate-in zoom-in-95 text-gray-800">
|
| 470 |
+
<div className="p-4 border-b flex justify-between items-center">
|
| 471 |
+
<h3 className="font-bold text-lg">排除请假/缺勤学生</h3>
|
| 472 |
+
<div className="flex gap-2">
|
| 473 |
+
<button onClick={() => setExcludedStudentIds(new Set())} className="text-xs text-blue-600 hover:underline">清空选择</button>
|
| 474 |
+
<button onClick={() => setIsFilterOpen(false)} className="px-4 py-1 bg-blue-600 text-white rounded text-sm">确定</button>
|
| 475 |
+
</div>
|
| 476 |
+
</div>
|
| 477 |
+
<div className="flex-1 overflow-y-auto p-4 bg-gray-50">
|
| 478 |
+
<div className="grid grid-cols-4 sm:grid-cols-5 md:grid-cols-6 gap-2">
|
| 479 |
+
{students.map(s => {
|
| 480 |
+
const isExcluded = excludedStudentIds.has(s._id || String(s.id));
|
| 481 |
+
return (
|
| 482 |
+
<div
|
| 483 |
+
key={s._id}
|
| 484 |
+
onClick={() => {
|
| 485 |
+
const newSet = new Set(excludedStudentIds);
|
| 486 |
+
if (isExcluded) newSet.delete(s._id || String(s.id));
|
| 487 |
+
else newSet.add(s._id || String(s.id));
|
| 488 |
+
setExcludedStudentIds(newSet);
|
| 489 |
+
}}
|
| 490 |
+
className={`p-2 rounded border cursor-pointer text-center text-sm transition-all select-none ${isExcluded ? 'bg-red-50 border-red-300 text-red-500 opacity-60' : 'bg-white border-gray-200 hover:border-blue-300'}`}
|
| 491 |
+
>
|
| 492 |
+
<div className="font-bold">{s.name}</div>
|
| 493 |
+
<div className="text-xs opacity-70">{s.seatNo}</div>
|
| 494 |
+
{isExcluded && <div className="text-[10px] font-bold mt-1">已排除</div>}
|
| 495 |
+
</div>
|
| 496 |
+
)
|
| 497 |
+
})}
|
| 498 |
+
</div>
|
| 499 |
+
</div>
|
| 500 |
+
</div>
|
| 501 |
+
</div>
|
| 502 |
+
)}
|
| 503 |
+
|
| 504 |
+
{/* Result Modal */}
|
| 505 |
+
{gameState === 'FINISHED' && !isConfigOpen && (
|
| 506 |
+
<div className="absolute inset-0 bg-black/90 z-[110] flex items-center justify-center p-4 backdrop-blur-lg animate-in zoom-in">
|
| 507 |
+
<div className="text-center w-full max-w-lg">
|
| 508 |
+
<div className="text-9xl mb-6 animate-bounce drop-shadow-[0_0_25px_rgba(255,255,255,0.5)]">
|
| 509 |
+
{calculatedScore >= passRate ? '🌸' : '🍂'}
|
| 510 |
+
</div>
|
| 511 |
+
<h2 className={`text-5xl font-black mb-2 tracking-tight ${calculatedScore >= passRate ? 'text-transparent bg-clip-text bg-gradient-to-b from-green-300 to-emerald-600' : 'text-gray-400'}`}>
|
| 512 |
+
{calculatedScore >= passRate ? '修行圆满' : '心浮气躁'}
|
| 513 |
+
</h2>
|
| 514 |
+
<p className="text-xl text-gray-300 mb-8">
|
| 515 |
+
最终评分: <span className="font-mono font-bold text-white text-3xl">{calculatedScore}</span>
|
| 516 |
+
</p>
|
| 517 |
+
|
| 518 |
+
{calculatedScore >= passRate && rewardConfig.enabled && (
|
| 519 |
+
<div className="bg-white/10 p-6 rounded-2xl mb-10 border border-white/10 backdrop-blur-sm">
|
| 520 |
+
<p className="text-teal-200 font-bold mb-2 flex items-center justify-center"><Gift size={20} className="mr-2"/> 奖励已解锁</p>
|
| 521 |
+
<p className="text-sm text-gray-300 mb-4">
|
| 522 |
+
为 <span className="font-bold text-white">{students.length - excludedStudentIds.size}</span> 名学生发放:
|
| 523 |
+
<span className="text-white font-bold mx-1 border-b border-white/30">
|
| 524 |
+
{rewardConfig.type==='ACHIEVEMENT' ? '成就奖状' : rewardConfig.type==='ITEM' ? rewardConfig.val : '抽奖券'}
|
| 525 |
+
</span>
|
| 526 |
+
x{rewardConfig.count}
|
| 527 |
+
</p>
|
| 528 |
+
<button onClick={handleBatchGrant} className="bg-amber-500 hover:bg-amber-600 text-white px-6 py-2 rounded-lg font-bold text-sm shadow-lg transition-colors">
|
| 529 |
+
立即发放
|
| 530 |
+
</button>
|
| 531 |
+
</div>
|
| 532 |
+
)}
|
| 533 |
+
|
| 534 |
+
<div className="flex gap-4 justify-center mt-8">
|
| 535 |
+
<button onClick={() => setIsConfigOpen(true)} className="px-8 py-3 bg-white/10 hover:bg-white/20 text-white rounded-full font-bold flex items-center transition-colors border border-white/10">
|
| 536 |
+
<Settings size={18} className="mr-2"/> 再来一次
|
| 537 |
+
</button>
|
| 538 |
+
</div>
|
| 539 |
+
</div>
|
| 540 |
+
</div>
|
| 541 |
+
)}
|
| 542 |
+
</div>
|
| 543 |
+
);
|
| 544 |
+
|
| 545 |
+
if (isFullscreen) {
|
| 546 |
+
return createPortal(GameContent, document.body);
|
| 547 |
+
}
|
| 548 |
+
return GameContent;
|
| 549 |
+
};
|
pages/Games.tsx
CHANGED
|
@@ -1,10 +1,11 @@
|
|
| 1 |
|
| 2 |
import React, { useState } from 'react';
|
| 3 |
-
import { Trophy, Star, Award, Zap, Mic } from 'lucide-react';
|
| 4 |
import { GameMountain } from './GameMountain';
|
| 5 |
import { GameLucky } from './GameLucky';
|
| 6 |
import { GameRandom } from './GameRandom';
|
| 7 |
import { GameMonster } from './GameMonster';
|
|
|
|
| 8 |
import { GameRewards } from './GameRewards';
|
| 9 |
import { AchievementTeacher } from './AchievementTeacher';
|
| 10 |
import { AchievementStudent } from './AchievementStudent';
|
|
@@ -12,7 +13,7 @@ import { api } from '../services/api';
|
|
| 12 |
|
| 13 |
export const Games: React.FC = () => {
|
| 14 |
const [activeTab, setActiveTab] = useState<'games' | 'achievements' | 'rewards'>('games');
|
| 15 |
-
const [activeGame, setActiveGame] = useState<'mountain' | 'lucky' | 'random' | 'monster'>('mountain');
|
| 16 |
|
| 17 |
const currentUser = api.auth.getCurrentUser();
|
| 18 |
const isStudent = currentUser?.role === 'STUDENT';
|
|
@@ -50,12 +51,16 @@ export const Games: React.FC = () => {
|
|
| 50 |
<button onClick={() => setActiveGame('monster')} className={`px-4 py-1.5 rounded-xl text-sm font-bold transition-all whitespace-nowrap ${activeGame === 'monster' ? 'bg-purple-100 text-purple-700 border-2 border-purple-200 shadow-sm' : 'text-gray-500 hover:bg-white hover:shadow-sm border-2 border-transparent'}`}>
|
| 51 |
<Mic size={14} className="inline mr-1"/> 早读战怪兽
|
| 52 |
</button>
|
|
|
|
|
|
|
|
|
|
| 53 |
</div>
|
| 54 |
|
| 55 |
<div className="flex-1 bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden relative">
|
| 56 |
{activeGame === 'mountain' ? <GameMountain /> :
|
| 57 |
activeGame === 'lucky' ? <GameLucky /> :
|
| 58 |
-
activeGame === 'random' ? <GameRandom /> :
|
|
|
|
| 59 |
</div>
|
| 60 |
</div>
|
| 61 |
)}
|
|
@@ -70,4 +75,4 @@ export const Games: React.FC = () => {
|
|
| 70 |
</div>
|
| 71 |
</div>
|
| 72 |
);
|
| 73 |
-
};
|
|
|
|
| 1 |
|
| 2 |
import React, { useState } from 'react';
|
| 3 |
+
import { Trophy, Star, Award, Zap, Mic, Moon } from 'lucide-react';
|
| 4 |
import { GameMountain } from './GameMountain';
|
| 5 |
import { GameLucky } from './GameLucky';
|
| 6 |
import { GameRandom } from './GameRandom';
|
| 7 |
import { GameMonster } from './GameMonster';
|
| 8 |
+
import { GameZen } from './GameZen';
|
| 9 |
import { GameRewards } from './GameRewards';
|
| 10 |
import { AchievementTeacher } from './AchievementTeacher';
|
| 11 |
import { AchievementStudent } from './AchievementStudent';
|
|
|
|
| 13 |
|
| 14 |
export const Games: React.FC = () => {
|
| 15 |
const [activeTab, setActiveTab] = useState<'games' | 'achievements' | 'rewards'>('games');
|
| 16 |
+
const [activeGame, setActiveGame] = useState<'mountain' | 'lucky' | 'random' | 'monster' | 'zen'>('mountain');
|
| 17 |
|
| 18 |
const currentUser = api.auth.getCurrentUser();
|
| 19 |
const isStudent = currentUser?.role === 'STUDENT';
|
|
|
|
| 51 |
<button onClick={() => setActiveGame('monster')} className={`px-4 py-1.5 rounded-xl text-sm font-bold transition-all whitespace-nowrap ${activeGame === 'monster' ? 'bg-purple-100 text-purple-700 border-2 border-purple-200 shadow-sm' : 'text-gray-500 hover:bg-white hover:shadow-sm border-2 border-transparent'}`}>
|
| 52 |
<Mic size={14} className="inline mr-1"/> 早读战怪兽
|
| 53 |
</button>
|
| 54 |
+
<button onClick={() => setActiveGame('zen')} className={`px-4 py-1.5 rounded-xl text-sm font-bold transition-all whitespace-nowrap ${activeGame === 'zen' ? 'bg-teal-100 text-teal-700 border-2 border-teal-200 shadow-sm' : 'text-gray-500 hover:bg-white hover:shadow-sm border-2 border-transparent'}`}>
|
| 55 |
+
<Moon size={14} className="inline mr-1"/> 禅道修行
|
| 56 |
+
</button>
|
| 57 |
</div>
|
| 58 |
|
| 59 |
<div className="flex-1 bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden relative">
|
| 60 |
{activeGame === 'mountain' ? <GameMountain /> :
|
| 61 |
activeGame === 'lucky' ? <GameLucky /> :
|
| 62 |
+
activeGame === 'random' ? <GameRandom /> :
|
| 63 |
+
activeGame === 'zen' ? <GameZen /> : <GameMonster />}
|
| 64 |
</div>
|
| 65 |
</div>
|
| 66 |
)}
|
|
|
|
| 75 |
</div>
|
| 76 |
</div>
|
| 77 |
);
|
| 78 |
+
};
|
server.js
CHANGED
|
@@ -7,7 +7,7 @@ const path = require('path');
|
|
| 7 |
const compression = require('compression');
|
| 8 |
const {
|
| 9 |
School, User, Student, Course, Score, ClassModel, SubjectModel, ExamModel, ScheduleModel,
|
| 10 |
-
ConfigModel, NotificationModel, GameSessionModel, StudentRewardModel, LuckyDrawConfigModel, GameMonsterConfigModel,
|
| 11 |
AchievementConfigModel, StudentAchievementModel, AttendanceModel, LeaveRequestModel
|
| 12 |
} = require('./models');
|
| 13 |
|
|
@@ -509,7 +509,7 @@ app.post('/api/games/lucky-draw', async (req, res) => {
|
|
| 509 |
} catch (e) { res.status(500).json({ error: e.message }); }
|
| 510 |
});
|
| 511 |
|
| 512 |
-
// ---
|
| 513 |
app.get('/api/games/monster-config', async (req, res) => {
|
| 514 |
const filter = getQueryFilter(req);
|
| 515 |
if (req.query.className) filter.className = req.query.className;
|
|
@@ -522,6 +522,18 @@ app.post('/api/games/monster-config', async (req, res) => {
|
|
| 522 |
res.json({ success: true });
|
| 523 |
});
|
| 524 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 525 |
app.get('/api/notifications', async (req, res) => {
|
| 526 |
const { role, userId } = req.query;
|
| 527 |
const query = { $and: [ getQueryFilter(req), { $or: [ { targetRole: null, targetUserId: null }, { targetRole: role }, { targetUserId: userId } ] } ] };
|
|
@@ -573,7 +585,8 @@ app.delete('/api/schools/:id', async (req, res) => {
|
|
| 573 |
await GameSessionModel.deleteMany({ schoolId });
|
| 574 |
await StudentRewardModel.deleteMany({ schoolId });
|
| 575 |
await LuckyDrawConfigModel.deleteMany({ schoolId });
|
| 576 |
-
await GameMonsterConfigModel.deleteMany({ schoolId });
|
|
|
|
| 577 |
await AchievementConfigModel.deleteMany({ schoolId });
|
| 578 |
await StudentAchievementModel.deleteMany({ schoolId });
|
| 579 |
res.json({ success: true });
|
|
@@ -800,4 +813,4 @@ app.post('/api/batch-delete', async (req, res) => {
|
|
| 800 |
});
|
| 801 |
|
| 802 |
app.get('*', (req, res) => { res.sendFile(path.join(__dirname, 'dist', 'index.html')); });
|
| 803 |
-
app.listen(PORT, () => console.log(`🚀 Server running on port ${PORT}`));
|
|
|
|
| 7 |
const compression = require('compression');
|
| 8 |
const {
|
| 9 |
School, User, Student, Course, Score, ClassModel, SubjectModel, ExamModel, ScheduleModel,
|
| 10 |
+
ConfigModel, NotificationModel, GameSessionModel, StudentRewardModel, LuckyDrawConfigModel, GameMonsterConfigModel, GameZenConfigModel,
|
| 11 |
AchievementConfigModel, StudentAchievementModel, AttendanceModel, LeaveRequestModel
|
| 12 |
} = require('./models');
|
| 13 |
|
|
|
|
| 509 |
} catch (e) { res.status(500).json({ error: e.message }); }
|
| 510 |
});
|
| 511 |
|
| 512 |
+
// --- GAME CONFIG ROUTES ---
|
| 513 |
app.get('/api/games/monster-config', async (req, res) => {
|
| 514 |
const filter = getQueryFilter(req);
|
| 515 |
if (req.query.className) filter.className = req.query.className;
|
|
|
|
| 522 |
res.json({ success: true });
|
| 523 |
});
|
| 524 |
|
| 525 |
+
app.get('/api/games/zen-config', async (req, res) => {
|
| 526 |
+
const filter = getQueryFilter(req);
|
| 527 |
+
if (req.query.className) filter.className = req.query.className;
|
| 528 |
+
const config = await GameZenConfigModel.findOne(filter);
|
| 529 |
+
res.json(config || {});
|
| 530 |
+
});
|
| 531 |
+
app.post('/api/games/zen-config', async (req, res) => {
|
| 532 |
+
const data = injectSchoolId(req, req.body);
|
| 533 |
+
await GameZenConfigModel.findOneAndUpdate({ className: data.className, ...getQueryFilter(req) }, data, { upsert: true });
|
| 534 |
+
res.json({ success: true });
|
| 535 |
+
});
|
| 536 |
+
|
| 537 |
app.get('/api/notifications', async (req, res) => {
|
| 538 |
const { role, userId } = req.query;
|
| 539 |
const query = { $and: [ getQueryFilter(req), { $or: [ { targetRole: null, targetUserId: null }, { targetRole: role }, { targetUserId: userId } ] } ] };
|
|
|
|
| 585 |
await GameSessionModel.deleteMany({ schoolId });
|
| 586 |
await StudentRewardModel.deleteMany({ schoolId });
|
| 587 |
await LuckyDrawConfigModel.deleteMany({ schoolId });
|
| 588 |
+
await GameMonsterConfigModel.deleteMany({ schoolId });
|
| 589 |
+
await GameZenConfigModel.deleteMany({ schoolId });
|
| 590 |
await AchievementConfigModel.deleteMany({ schoolId });
|
| 591 |
await StudentAchievementModel.deleteMany({ schoolId });
|
| 592 |
res.json({ success: true });
|
|
|
|
| 813 |
});
|
| 814 |
|
| 815 |
app.get('*', (req, res) => { res.sendFile(path.join(__dirname, 'dist', 'index.html')); });
|
| 816 |
+
app.listen(PORT, () => console.log(`🚀 Server running on port ${PORT}`));
|
services/api.ts
CHANGED
|
@@ -207,6 +207,8 @@ export const api = {
|
|
| 207 |
grantReward: (data: { studentId: string, count: number, rewardType: string, name?: string }) => request('/games/grant-reward', { method: 'POST', body: JSON.stringify(data) }),
|
| 208 |
getMonsterConfig: (className: string) => request(`/games/monster-config?className=${className}`),
|
| 209 |
saveMonsterConfig: (data: any) => request('/games/monster-config', { method: 'POST', body: JSON.stringify(data) }),
|
|
|
|
|
|
|
| 210 |
},
|
| 211 |
|
| 212 |
achievements: {
|
|
|
|
| 207 |
grantReward: (data: { studentId: string, count: number, rewardType: string, name?: string }) => request('/games/grant-reward', { method: 'POST', body: JSON.stringify(data) }),
|
| 208 |
getMonsterConfig: (className: string) => request(`/games/monster-config?className=${className}`),
|
| 209 |
saveMonsterConfig: (data: any) => request('/games/monster-config', { method: 'POST', body: JSON.stringify(data) }),
|
| 210 |
+
getZenConfig: (className: string) => request(`/games/zen-config?className=${className}`),
|
| 211 |
+
saveZenConfig: (data: any) => request('/games/zen-config', { method: 'POST', body: JSON.stringify(data) }),
|
| 212 |
},
|
| 213 |
|
| 214 |
achievements: {
|