Spaces:
Running
Running
Upload 37 files
Browse files- pages/AchievementStudent.tsx +183 -0
- pages/AchievementTeacher.tsx +338 -0
- pages/Games.tsx +20 -6
- server.js +120 -3
- services/api.ts +11 -2
- types.ts +41 -1
pages/AchievementStudent.tsx
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import React, { useState, useEffect } from 'react';
|
| 3 |
+
import { api } from '../services/api';
|
| 4 |
+
import { AchievementConfig, AchievementItem, Student, StudentAchievement, SystemConfig } from '../types';
|
| 5 |
+
import { Award, ShoppingBag, Loader2, Calendar, Lock } from 'lucide-react';
|
| 6 |
+
|
| 7 |
+
export const AchievementStudent: React.FC = () => {
|
| 8 |
+
const [loading, setLoading] = useState(true);
|
| 9 |
+
const [student, setStudent] = useState<Student | null>(null);
|
| 10 |
+
const [config, setConfig] = useState<AchievementConfig | null>(null);
|
| 11 |
+
const [myAchievements, setMyAchievements] = useState<StudentAchievement[]>([]);
|
| 12 |
+
|
| 13 |
+
// UI State
|
| 14 |
+
const [activeTab, setActiveTab] = useState<'wall' | 'shop'>('wall');
|
| 15 |
+
const [semesters, setSemesters] = useState<string[]>([]);
|
| 16 |
+
const [selectedSemester, setSelectedSemester] = useState('');
|
| 17 |
+
|
| 18 |
+
const currentUser = api.auth.getCurrentUser();
|
| 19 |
+
|
| 20 |
+
useEffect(() => {
|
| 21 |
+
loadData();
|
| 22 |
+
}, [selectedSemester]);
|
| 23 |
+
|
| 24 |
+
const loadData = async () => {
|
| 25 |
+
setLoading(true);
|
| 26 |
+
try {
|
| 27 |
+
const [stus, sysCfg] = await Promise.all([
|
| 28 |
+
api.students.getAll(),
|
| 29 |
+
api.config.getPublic() as Promise<SystemConfig>
|
| 30 |
+
]);
|
| 31 |
+
|
| 32 |
+
const me = stus.find((s: Student) => s.name === (currentUser?.trueName || currentUser?.username));
|
| 33 |
+
if (me) {
|
| 34 |
+
setStudent(me);
|
| 35 |
+
const cfg = await api.achievements.getConfig(me.className);
|
| 36 |
+
setConfig(cfg);
|
| 37 |
+
|
| 38 |
+
// Semesters Logic
|
| 39 |
+
const sems = sysCfg.semesters || ['当前学期'];
|
| 40 |
+
setSemesters(sems);
|
| 41 |
+
if (!selectedSemester && sysCfg.semester) {
|
| 42 |
+
setSelectedSemester(sysCfg.semester);
|
| 43 |
+
return; // Will re-trigger useEffect
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
// Fetch Records
|
| 47 |
+
const records = await api.achievements.getStudentAchievements(me._id || String(me.id), selectedSemester);
|
| 48 |
+
setMyAchievements(records);
|
| 49 |
+
}
|
| 50 |
+
} catch (e) { console.error(e); }
|
| 51 |
+
finally { setLoading(false); }
|
| 52 |
+
};
|
| 53 |
+
|
| 54 |
+
const handleExchange = async (ruleId: string) => {
|
| 55 |
+
if (!student) return;
|
| 56 |
+
if (!confirm('确认消耗小红花进行兑换吗?')) return;
|
| 57 |
+
try {
|
| 58 |
+
await api.achievements.exchange({ studentId: student._id || String(student.id), ruleId });
|
| 59 |
+
alert('兑换成功!请到“奖励管理”查看。');
|
| 60 |
+
loadData(); // Refresh balance
|
| 61 |
+
} catch (e: any) { alert(e.message || '兑换失败'); }
|
| 62 |
+
};
|
| 63 |
+
|
| 64 |
+
// Calculate Counts for Wall
|
| 65 |
+
const getAchievementCount = (achId: string) => {
|
| 66 |
+
return myAchievements.filter(a => a.achievementId === achId).length;
|
| 67 |
+
};
|
| 68 |
+
|
| 69 |
+
if (loading && !student) return <div className="flex justify-center p-10"><Loader2 className="animate-spin text-blue-600"/></div>;
|
| 70 |
+
if (!student || !config) return <div className="text-center p-10 text-gray-400">暂无数据,请联系班主任开启成就系统。</div>;
|
| 71 |
+
|
| 72 |
+
return (
|
| 73 |
+
<div className="h-full flex flex-col bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
|
| 74 |
+
{/* Header / Info */}
|
| 75 |
+
<div className="p-6 bg-gradient-to-r from-amber-50 to-orange-50 border-b border-orange-100 flex flex-col md:flex-row justify-between items-center gap-4">
|
| 76 |
+
<div className="flex items-center gap-4">
|
| 77 |
+
<div className="bg-amber-100 p-3 rounded-full border border-amber-200">
|
| 78 |
+
<Award size={32} className="text-amber-600"/>
|
| 79 |
+
</div>
|
| 80 |
+
<div>
|
| 81 |
+
<h2 className="text-xl font-bold text-gray-800">我的成就中心</h2>
|
| 82 |
+
<p className="text-sm text-gray-500">努力学习,收获荣誉与奖励!</p>
|
| 83 |
+
</div>
|
| 84 |
+
</div>
|
| 85 |
+
<div className="flex items-center gap-4 bg-white p-3 rounded-xl shadow-sm border border-orange-100">
|
| 86 |
+
<div className="text-right">
|
| 87 |
+
<div className="text-xs text-gray-500 font-bold uppercase">当前小红花</div>
|
| 88 |
+
<div className="text-2xl font-black text-amber-600">{student.flowerBalance || 0} 🌺</div>
|
| 89 |
+
</div>
|
| 90 |
+
</div>
|
| 91 |
+
</div>
|
| 92 |
+
|
| 93 |
+
{/* Tabs */}
|
| 94 |
+
<div className="flex border-b border-gray-100 px-6">
|
| 95 |
+
<button onClick={() => setActiveTab('wall')} className={`px-4 py-3 text-sm font-bold border-b-2 transition-colors ${activeTab === 'wall' ? 'border-amber-500 text-amber-600' : 'border-transparent text-gray-500 hover:text-gray-700'}`}>
|
| 96 |
+
🏆 荣誉成就墙
|
| 97 |
+
</button>
|
| 98 |
+
<button onClick={() => setActiveTab('shop')} className={`px-4 py-3 text-sm font-bold border-b-2 transition-colors ${activeTab === 'shop' ? 'border-amber-500 text-amber-600' : 'border-transparent text-gray-500 hover:text-gray-700'}`}>
|
| 99 |
+
🛍️ 积分兑换
|
| 100 |
+
</button>
|
| 101 |
+
</div>
|
| 102 |
+
|
| 103 |
+
<div className="flex-1 overflow-y-auto p-6 bg-gray-50/30">
|
| 104 |
+
{/* 1. Wall */}
|
| 105 |
+
{activeTab === 'wall' && (
|
| 106 |
+
<div className="space-y-6">
|
| 107 |
+
<div className="flex justify-end">
|
| 108 |
+
<div className="flex items-center bg-white border rounded-lg px-2 py-1 shadow-sm">
|
| 109 |
+
<Calendar size={14} className="text-gray-400 mr-2"/>
|
| 110 |
+
<select className="text-sm bg-transparent outline-none text-gray-600" value={selectedSemester} onChange={e => setSelectedSemester(e.target.value)}>
|
| 111 |
+
<option value="">-- 全部时间 --</option>
|
| 112 |
+
{semesters.map(s => <option key={s} value={s}>{s}</option>)}
|
| 113 |
+
</select>
|
| 114 |
+
</div>
|
| 115 |
+
</div>
|
| 116 |
+
|
| 117 |
+
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
| 118 |
+
{config.achievements.map(ach => {
|
| 119 |
+
const count = getAchievementCount(ach.id);
|
| 120 |
+
const isUnlocked = count > 0;
|
| 121 |
+
|
| 122 |
+
return (
|
| 123 |
+
<div key={ach.id} className={`relative aspect-square rounded-xl border flex flex-col items-center justify-center p-4 transition-all ${isUnlocked ? 'bg-white border-amber-200 shadow-sm' : 'bg-gray-50 border-gray-200 grayscale opacity-70'}`}>
|
| 124 |
+
<div className={`text-5xl mb-3 transition-transform ${isUnlocked ? 'scale-110 drop-shadow-md' : 'scale-90 opacity-50'}`}>
|
| 125 |
+
{ach.icon}
|
| 126 |
+
</div>
|
| 127 |
+
<div className={`font-bold text-center ${isUnlocked ? 'text-gray-800' : 'text-gray-400'}`}>
|
| 128 |
+
{ach.name}
|
| 129 |
+
</div>
|
| 130 |
+
{isUnlocked ? (
|
| 131 |
+
<div className="absolute top-2 right-2 bg-red-500 text-white text-xs font-bold px-1.5 rounded-full border border-white shadow-sm">
|
| 132 |
+
x{count}
|
| 133 |
+
</div>
|
| 134 |
+
) : (
|
| 135 |
+
<div className="absolute top-2 right-2 text-gray-300">
|
| 136 |
+
<Lock size={16}/>
|
| 137 |
+
</div>
|
| 138 |
+
)}
|
| 139 |
+
<div className="mt-1 text-xs text-amber-600 font-medium bg-amber-50 px-2 rounded">
|
| 140 |
+
{ach.points} 🌺
|
| 141 |
+
</div>
|
| 142 |
+
</div>
|
| 143 |
+
);
|
| 144 |
+
})}
|
| 145 |
+
</div>
|
| 146 |
+
</div>
|
| 147 |
+
)}
|
| 148 |
+
|
| 149 |
+
{/* 2. Shop */}
|
| 150 |
+
{activeTab === 'shop' && (
|
| 151 |
+
<div className="max-w-4xl mx-auto grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
| 152 |
+
{config.exchangeRules.map(rule => {
|
| 153 |
+
const canAfford = (student.flowerBalance || 0) >= rule.cost;
|
| 154 |
+
return (
|
| 155 |
+
<div key={rule.id} className="bg-white rounded-xl border border-gray-200 shadow-sm p-5 flex flex-col items-center text-center hover:shadow-md transition-shadow relative overflow-hidden">
|
| 156 |
+
<div className="w-16 h-16 bg-gradient-to-br from-green-100 to-emerald-100 rounded-full flex items-center justify-center mb-3 text-3xl shadow-inner">
|
| 157 |
+
{rule.rewardType === 'DRAW_COUNT' ? '🎲' : '🎁'}
|
| 158 |
+
</div>
|
| 159 |
+
<h3 className="font-bold text-gray-800 text-lg mb-1">{rule.rewardName}</h3>
|
| 160 |
+
<p className="text-sm text-gray-500 mb-4">包含数量: x{rule.rewardValue}</p>
|
| 161 |
+
|
| 162 |
+
<button
|
| 163 |
+
onClick={() => handleExchange(rule.id)}
|
| 164 |
+
disabled={!canAfford}
|
| 165 |
+
className={`mt-auto w-full py-2.5 rounded-lg font-bold flex items-center justify-center gap-2 transition-all ${canAfford ? 'bg-amber-500 text-white hover:bg-amber-600 shadow-md hover:shadow-lg active:scale-95' : 'bg-gray-100 text-gray-400 cursor-not-allowed'}`}
|
| 166 |
+
>
|
| 167 |
+
<ShoppingBag size={16}/>
|
| 168 |
+
{rule.cost} 🌺 兑换
|
| 169 |
+
</button>
|
| 170 |
+
</div>
|
| 171 |
+
);
|
| 172 |
+
})}
|
| 173 |
+
{config.exchangeRules.length === 0 && (
|
| 174 |
+
<div className="col-span-full text-center py-20 text-gray-400">
|
| 175 |
+
老师暂时没有设置兑换商品哦~
|
| 176 |
+
</div>
|
| 177 |
+
)}
|
| 178 |
+
</div>
|
| 179 |
+
)}
|
| 180 |
+
</div>
|
| 181 |
+
</div>
|
| 182 |
+
);
|
| 183 |
+
};
|
pages/AchievementTeacher.tsx
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import React, { useState, useEffect } from 'react';
|
| 3 |
+
import { api } from '../services/api';
|
| 4 |
+
import { AchievementConfig, AchievementItem, ExchangeRule, Student, SystemConfig } from '../types';
|
| 5 |
+
import { Plus, Trash2, Edit, Save, Gift, Award, Coins, Users, Search, Loader2, CheckCircle } from 'lucide-react';
|
| 6 |
+
|
| 7 |
+
const PRESET_ICONS = [
|
| 8 |
+
{ icon: '🏆', label: '冠军杯' }, { icon: '🥇', label: '金牌' }, { icon: '🥈', label: '银牌' }, { icon: '🥉', label: '铜牌' },
|
| 9 |
+
{ icon: '⭐', label: '星星' }, { icon: '🌟', label: '闪亮' }, { icon: '🔥', label: '热血' }, { icon: '💡', label: '智慧' },
|
| 10 |
+
{ icon: '📚', label: '博学' }, { icon: '✏️', label: '勤奋' }, { icon: '🎨', label: '艺术' }, { icon: '🎵', label: '音乐' },
|
| 11 |
+
{ icon: '⚽', label: '运动' }, { icon: '🏃', label: '进步' }, { icon: '🤝', label: '助人' }, { icon: '🧹', label: '劳动' },
|
| 12 |
+
{ icon: '⏰', label: '守时' }, { icon: '📝', label: '写作' }, { icon: '🧮', label: '计算' }, { icon: '🔬', label: '科学' }
|
| 13 |
+
];
|
| 14 |
+
|
| 15 |
+
export const AchievementTeacher: React.FC = () => {
|
| 16 |
+
const [loading, setLoading] = useState(true);
|
| 17 |
+
const [config, setConfig] = useState<AchievementConfig | null>(null);
|
| 18 |
+
const [students, setStudents] = useState<Student[]>([]);
|
| 19 |
+
const [semester, setSemester] = useState('');
|
| 20 |
+
|
| 21 |
+
// Tab State
|
| 22 |
+
const [activeTab, setActiveTab] = useState<'manage' | 'grant' | 'exchange' | 'balance'>('manage');
|
| 23 |
+
|
| 24 |
+
// Forms
|
| 25 |
+
const [newAchieve, setNewAchieve] = useState<AchievementItem>({ id: '', name: '', icon: '🏆', points: 1 });
|
| 26 |
+
const [newRule, setNewRule] = useState<ExchangeRule>({ id: '', cost: 10, rewardType: 'DRAW_COUNT', rewardName: '抽奖券x1', rewardValue: 1 });
|
| 27 |
+
|
| 28 |
+
// Grant State
|
| 29 |
+
const [selectedStudents, setSelectedStudents] = useState<Set<string>>(new Set());
|
| 30 |
+
const [selectedAchieveId, setSelectedAchieveId] = useState('');
|
| 31 |
+
|
| 32 |
+
const currentUser = api.auth.getCurrentUser();
|
| 33 |
+
const homeroomClass = currentUser?.homeroomClass;
|
| 34 |
+
|
| 35 |
+
useEffect(() => {
|
| 36 |
+
loadData();
|
| 37 |
+
}, []);
|
| 38 |
+
|
| 39 |
+
const loadData = async () => {
|
| 40 |
+
if (!homeroomClass) return;
|
| 41 |
+
setLoading(true);
|
| 42 |
+
try {
|
| 43 |
+
const [cfg, stus, sysCfg] = await Promise.all([
|
| 44 |
+
api.achievements.getConfig(homeroomClass),
|
| 45 |
+
api.students.getAll(),
|
| 46 |
+
api.config.getPublic() as Promise<SystemConfig>
|
| 47 |
+
]);
|
| 48 |
+
|
| 49 |
+
// Filter students for homeroom
|
| 50 |
+
setStudents(stus.filter((s: Student) => s.className === homeroomClass));
|
| 51 |
+
|
| 52 |
+
setConfig(cfg || {
|
| 53 |
+
schoolId: currentUser?.schoolId || '',
|
| 54 |
+
className: homeroomClass,
|
| 55 |
+
achievements: [],
|
| 56 |
+
exchangeRules: []
|
| 57 |
+
});
|
| 58 |
+
|
| 59 |
+
setSemester(sysCfg.semester || '当前学期');
|
| 60 |
+
|
| 61 |
+
} catch (e) { console.error(e); }
|
| 62 |
+
finally { setLoading(false); }
|
| 63 |
+
};
|
| 64 |
+
|
| 65 |
+
const handleSaveConfig = async (newConfig: AchievementConfig) => {
|
| 66 |
+
try {
|
| 67 |
+
await api.achievements.saveConfig(newConfig);
|
| 68 |
+
setConfig(newConfig);
|
| 69 |
+
} catch (e) { alert('保存失败'); }
|
| 70 |
+
};
|
| 71 |
+
|
| 72 |
+
// --- Tab 1: Manage Achievements ---
|
| 73 |
+
const addAchievement = () => {
|
| 74 |
+
if (!config || !newAchieve.name) return;
|
| 75 |
+
const newItem = { ...newAchieve, id: Date.now().toString() };
|
| 76 |
+
const updated = { ...config, achievements: [...config.achievements, newItem] };
|
| 77 |
+
handleSaveConfig(updated);
|
| 78 |
+
setNewAchieve({ id: '', name: '', icon: '🏆', points: 1 });
|
| 79 |
+
};
|
| 80 |
+
|
| 81 |
+
const deleteAchievement = (id: string) => {
|
| 82 |
+
if (!config || !confirm('确认删除?')) return;
|
| 83 |
+
const updated = { ...config, achievements: config.achievements.filter(a => a.id !== id) };
|
| 84 |
+
handleSaveConfig(updated);
|
| 85 |
+
};
|
| 86 |
+
|
| 87 |
+
// --- Tab 2: Grant ---
|
| 88 |
+
const handleGrant = async () => {
|
| 89 |
+
if (selectedStudents.size === 0 || !selectedAchieveId) return alert('请选择学生和奖状');
|
| 90 |
+
try {
|
| 91 |
+
const promises = Array.from(selectedStudents).map(sid =>
|
| 92 |
+
api.achievements.grant({ studentId: sid, achievementId: selectedAchieveId, semester })
|
| 93 |
+
);
|
| 94 |
+
await Promise.all(promises);
|
| 95 |
+
alert(`成功发放给 ${selectedStudents.size} 位学生`);
|
| 96 |
+
setSelectedStudents(new Set());
|
| 97 |
+
loadData(); // Refresh flower balances
|
| 98 |
+
} catch (e) { alert('部分发放失败'); }
|
| 99 |
+
};
|
| 100 |
+
|
| 101 |
+
// --- Tab 3: Exchange Rules ---
|
| 102 |
+
const addRule = () => {
|
| 103 |
+
if (!config || !newRule.rewardName) return;
|
| 104 |
+
const newItem = { ...newRule, id: Date.now().toString() };
|
| 105 |
+
const updated = { ...config, exchangeRules: [...config.exchangeRules, newItem] };
|
| 106 |
+
handleSaveConfig(updated);
|
| 107 |
+
setNewRule({ id: '', cost: 10, rewardType: 'DRAW_COUNT', rewardName: '抽奖券x1', rewardValue: 1 });
|
| 108 |
+
};
|
| 109 |
+
|
| 110 |
+
const deleteRule = (id: string) => {
|
| 111 |
+
if (!config || !confirm('确认删除?')) return;
|
| 112 |
+
const updated = { ...config, exchangeRules: config.exchangeRules.filter(r => r.id !== id) };
|
| 113 |
+
handleSaveConfig(updated);
|
| 114 |
+
};
|
| 115 |
+
|
| 116 |
+
// Proxy Exchange (Teacher helps student exchange)
|
| 117 |
+
const handleProxyExchange = async (studentId: string, ruleId: string) => {
|
| 118 |
+
if (!confirm('确认代学生兑换?将扣除对应小红花并记录。')) return;
|
| 119 |
+
try {
|
| 120 |
+
await api.achievements.exchange({ studentId, ruleId });
|
| 121 |
+
alert('兑换成功');
|
| 122 |
+
loadData(); // Refresh balance
|
| 123 |
+
} catch (e: any) { alert(e.message || '兑换失败,余额不足?'); }
|
| 124 |
+
};
|
| 125 |
+
|
| 126 |
+
if (!homeroomClass) return <div className="p-10 text-center text-gray-500">您不是班主任,无法使用成就管理功能。</div>;
|
| 127 |
+
if (loading) return <div className="flex justify-center p-10"><Loader2 className="animate-spin text-blue-600"/></div>;
|
| 128 |
+
|
| 129 |
+
return (
|
| 130 |
+
<div className="h-full flex flex-col bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
|
| 131 |
+
{/* Header / Tabs */}
|
| 132 |
+
<div className="flex border-b border-gray-100 bg-gray-50/50">
|
| 133 |
+
<button onClick={() => setActiveTab('manage')} className={`px-6 py-4 text-sm font-bold border-b-2 transition-colors flex items-center gap-2 ${activeTab === 'manage' ? 'border-blue-500 text-blue-600 bg-white' : 'border-transparent text-gray-500 hover:text-gray-700'}`}>
|
| 134 |
+
<Award size={18}/> 成就库管理
|
| 135 |
+
</button>
|
| 136 |
+
<button onClick={() => setActiveTab('grant')} className={`px-6 py-4 text-sm font-bold border-b-2 transition-colors flex items-center gap-2 ${activeTab === 'grant' ? 'border-blue-500 text-blue-600 bg-white' : 'border-transparent text-gray-500 hover:text-gray-700'}`}>
|
| 137 |
+
<Gift size={18}/> 发放成就
|
| 138 |
+
</button>
|
| 139 |
+
<button onClick={() => setActiveTab('exchange')} className={`px-6 py-4 text-sm font-bold border-b-2 transition-colors flex items-center gap-2 ${activeTab === 'exchange' ? 'border-blue-500 text-blue-600 bg-white' : 'border-transparent text-gray-500 hover:text-gray-700'}`}>
|
| 140 |
+
<Coins size={18}/> 兑换规则
|
| 141 |
+
</button>
|
| 142 |
+
<button onClick={() => setActiveTab('balance')} className={`px-6 py-4 text-sm font-bold border-b-2 transition-colors flex items-center gap-2 ${activeTab === 'balance' ? 'border-blue-500 text-blue-600 bg-white' : 'border-transparent text-gray-500 hover:text-gray-700'}`}>
|
| 143 |
+
<Users size={18}/> 学生积分
|
| 144 |
+
</button>
|
| 145 |
+
</div>
|
| 146 |
+
|
| 147 |
+
<div className="flex-1 overflow-y-auto p-6">
|
| 148 |
+
{/* 1. Manage Achievements */}
|
| 149 |
+
{activeTab === 'manage' && (
|
| 150 |
+
<div className="space-y-6 max-w-4xl mx-auto">
|
| 151 |
+
<div className="bg-blue-50 p-4 rounded-xl border border-blue-100 flex flex-col md:flex-row gap-4 items-end">
|
| 152 |
+
<div className="flex-1 w-full">
|
| 153 |
+
<label className="text-xs font-bold text-gray-500 uppercase mb-1 block">成就名称</label>
|
| 154 |
+
<input className="w-full border rounded p-2" placeholder="如: 劳动小能手" value={newAchieve.name} onChange={e => setNewAchieve({...newAchieve, name: e.target.value})} />
|
| 155 |
+
</div>
|
| 156 |
+
<div className="w-full md:w-32">
|
| 157 |
+
<label className="text-xs font-bold text-gray-500 uppercase mb-1 block">分值 (小红花)</label>
|
| 158 |
+
<input type="number" min={1} className="w-full border rounded p-2" value={newAchieve.points} onChange={e => setNewAchieve({...newAchieve, points: Number(e.target.value)})} />
|
| 159 |
+
</div>
|
| 160 |
+
<div className="w-full md:w-auto">
|
| 161 |
+
<label className="text-xs font-bold text-gray-500 uppercase mb-1 block">图标预设</label>
|
| 162 |
+
<select className="w-full border rounded p-2 min-w-[100px]" value={newAchieve.icon} onChange={e => setNewAchieve({...newAchieve, icon: e.target.value})}>
|
| 163 |
+
{PRESET_ICONS.map(p => <option key={p.label} value={p.icon}>{p.icon} {p.label}</option>)}
|
| 164 |
+
</select>
|
| 165 |
+
</div>
|
| 166 |
+
<button onClick={addAchievement} className="bg-blue-600 text-white px-4 py-2 rounded-lg font-bold hover:bg-blue-700 whitespace-nowrap">添加成就</button>
|
| 167 |
+
</div>
|
| 168 |
+
|
| 169 |
+
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
| 170 |
+
{config?.achievements.map(ach => (
|
| 171 |
+
<div key={ach.id} className="border border-gray-200 rounded-xl p-4 flex flex-col items-center relative group hover:shadow-md transition-all bg-white">
|
| 172 |
+
<div className="text-4xl mb-2">{ach.icon}</div>
|
| 173 |
+
<div className="font-bold text-gray-800 text-center">{ach.name}</div>
|
| 174 |
+
<div className="text-xs text-amber-600 font-bold bg-amber-50 px-2 py-0.5 rounded-full mt-1 border border-amber-100">
|
| 175 |
+
{ach.points} 🌺
|
| 176 |
+
</div>
|
| 177 |
+
<button onClick={() => deleteAchievement(ach.id)} className="absolute top-2 right-2 text-gray-300 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-opacity">
|
| 178 |
+
<Trash2 size={16}/>
|
| 179 |
+
</button>
|
| 180 |
+
</div>
|
| 181 |
+
))}
|
| 182 |
+
</div>
|
| 183 |
+
</div>
|
| 184 |
+
)}
|
| 185 |
+
|
| 186 |
+
{/* 2. Grant Achievements */}
|
| 187 |
+
{activeTab === 'grant' && (
|
| 188 |
+
<div className="flex flex-col md:flex-row gap-6 h-full">
|
| 189 |
+
{/* Student Selector */}
|
| 190 |
+
<div className="w-full md:w-1/3 border-r border-gray-100 pr-4 flex flex-col min-h-[400px]">
|
| 191 |
+
<h3 className="font-bold text-gray-700 mb-2">1. 选择学生 ({selectedStudents.size})</h3>
|
| 192 |
+
<button onClick={() => setSelectedStudents(new Set(selectedStudents.size === students.length ? [] : students.map(s => s._id || String(s.id))))} className="text-xs text-blue-600 mb-2 text-left hover:underline">
|
| 193 |
+
{selectedStudents.size === students.length ? '取消全选' : '全选所有'}
|
| 194 |
+
</button>
|
| 195 |
+
<div className="flex-1 overflow-y-auto space-y-2 pr-2 custom-scrollbar max-h-[500px]">
|
| 196 |
+
{students.map(s => (
|
| 197 |
+
<div
|
| 198 |
+
key={s._id}
|
| 199 |
+
onClick={() => {
|
| 200 |
+
const newSet = new Set(selectedStudents);
|
| 201 |
+
const sid = s._id || String(s.id);
|
| 202 |
+
if (newSet.has(sid)) newSet.delete(sid);
|
| 203 |
+
else newSet.add(sid);
|
| 204 |
+
setSelectedStudents(newSet);
|
| 205 |
+
}}
|
| 206 |
+
className={`p-2 rounded border cursor-pointer flex items-center justify-between ${selectedStudents.has(s._id || String(s.id)) ? 'bg-blue-50 border-blue-400' : 'bg-white border-gray-200 hover:bg-gray-50'}`}
|
| 207 |
+
>
|
| 208 |
+
<span className="text-sm font-medium">{s.name}</span>
|
| 209 |
+
{selectedStudents.has(s._id || String(s.id)) && <CheckCircle size={16} className="text-blue-500"/>}
|
| 210 |
+
</div>
|
| 211 |
+
))}
|
| 212 |
+
</div>
|
| 213 |
+
</div>
|
| 214 |
+
|
| 215 |
+
{/* Achievement Selector */}
|
| 216 |
+
<div className="flex-1 flex flex-col">
|
| 217 |
+
<h3 className="font-bold text-gray-700 mb-4">2. 选择要颁发的奖状</h3>
|
| 218 |
+
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 mb-6">
|
| 219 |
+
{config?.achievements.map(ach => (
|
| 220 |
+
<div
|
| 221 |
+
key={ach.id}
|
| 222 |
+
onClick={() => setSelectedAchieveId(ach.id)}
|
| 223 |
+
className={`p-4 rounded-xl border cursor-pointer text-center transition-all ${selectedAchieveId === ach.id ? 'bg-amber-50 border-amber-400 ring-2 ring-amber-200' : 'bg-white border-gray-200 hover:bg-gray-50'}`}
|
| 224 |
+
>
|
| 225 |
+
<div className="text-3xl mb-1">{ach.icon}</div>
|
| 226 |
+
<div className="font-bold text-sm text-gray-800">{ach.name}</div>
|
| 227 |
+
<div className="text-xs text-gray-500">+{ach.points} 🌺</div>
|
| 228 |
+
</div>
|
| 229 |
+
))}
|
| 230 |
+
</div>
|
| 231 |
+
|
| 232 |
+
<div className="mt-auto bg-gray-50 p-4 rounded-xl border border-gray-200">
|
| 233 |
+
<div className="flex justify-between items-center mb-2">
|
| 234 |
+
<span className="text-sm text-gray-600">当前学期: <b>{semester}</b></span>
|
| 235 |
+
<span className="text-sm text-gray-600">已选: <b>{selectedStudents.size}</b> 人</span>
|
| 236 |
+
</div>
|
| 237 |
+
<button onClick={handleGrant} disabled={!selectedAchieveId || selectedStudents.size===0} className="w-full bg-blue-600 text-white py-3 rounded-lg font-bold hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed shadow-md">
|
| 238 |
+
确认发放
|
| 239 |
+
</button>
|
| 240 |
+
</div>
|
| 241 |
+
</div>
|
| 242 |
+
</div>
|
| 243 |
+
)}
|
| 244 |
+
|
| 245 |
+
{/* 3. Exchange Rules */}
|
| 246 |
+
{activeTab === 'exchange' && (
|
| 247 |
+
<div className="space-y-6 max-w-4xl mx-auto">
|
| 248 |
+
<div className="bg-green-50 p-4 rounded-xl border border-green-100 flex flex-col md:flex-row gap-4 items-end">
|
| 249 |
+
<div className="w-full md:w-32">
|
| 250 |
+
<label className="text-xs font-bold text-gray-500 uppercase mb-1 block">消耗小红花</label>
|
| 251 |
+
<input type="number" className="w-full border rounded p-2" value={newRule.cost} onChange={e => setNewRule({...newRule, cost: Number(e.target.value)})}/>
|
| 252 |
+
</div>
|
| 253 |
+
<div className="w-full md:w-32">
|
| 254 |
+
<label className="text-xs font-bold text-gray-500 uppercase mb-1 block">奖励类型</label>
|
| 255 |
+
<select className="w-full border rounded p-2" value={newRule.rewardType} onChange={e => setNewRule({...newRule, rewardType: e.target.value as any})}>
|
| 256 |
+
<option value="DRAW_COUNT">🎲 抽奖券</option>
|
| 257 |
+
<option value="ITEM">🎁 实物/特权</option>
|
| 258 |
+
</select>
|
| 259 |
+
</div>
|
| 260 |
+
<div className="flex-1 w-full">
|
| 261 |
+
<label className="text-xs font-bold text-gray-500 uppercase mb-1 block">奖励名称</label>
|
| 262 |
+
<input className="w-full border rounded p-2" placeholder="如: 免作业券" value={newRule.rewardName} onChange={e => setNewRule({...newRule, rewardName: e.target.value})}/>
|
| 263 |
+
</div>
|
| 264 |
+
<div className="w-24">
|
| 265 |
+
<label className="text-xs font-bold text-gray-500 uppercase mb-1 block">数量</label>
|
| 266 |
+
<input type="number" min={1} className="w-full border rounded p-2" value={newRule.rewardValue} onChange={e => setNewRule({...newRule, rewardValue: Number(e.target.value)})}/>
|
| 267 |
+
</div>
|
| 268 |
+
<button onClick={addRule} className="bg-green-600 text-white px-4 py-2 rounded-lg font-bold hover:bg-green-700 whitespace-nowrap">添加规则</button>
|
| 269 |
+
</div>
|
| 270 |
+
|
| 271 |
+
<div className="space-y-3">
|
| 272 |
+
{config?.exchangeRules.map(rule => (
|
| 273 |
+
<div key={rule.id} className="flex items-center justify-between p-4 bg-white border border-gray-200 rounded-xl hover:shadow-sm">
|
| 274 |
+
<div className="flex items-center gap-4">
|
| 275 |
+
<div className="bg-amber-100 text-amber-700 font-bold px-3 py-1 rounded-lg border border-amber-200">
|
| 276 |
+
{rule.cost} 🌺
|
| 277 |
+
</div>
|
| 278 |
+
<div className="text-gray-400">➡️</div>
|
| 279 |
+
<div className="flex items-center gap-2">
|
| 280 |
+
<span className="text-2xl">{rule.rewardType === 'DRAW_COUNT' ? '🎲' : '🎁'}</span>
|
| 281 |
+
<div>
|
| 282 |
+
<div className="font-bold text-gray-800">{rule.rewardName}</div>
|
| 283 |
+
<div className="text-xs text-gray-500">x{rule.rewardValue}</div>
|
| 284 |
+
</div>
|
| 285 |
+
</div>
|
| 286 |
+
</div>
|
| 287 |
+
<button onClick={() => deleteRule(rule.id)} className="text-gray-300 hover:text-red-500 p-2"><Trash2 size={18}/></button>
|
| 288 |
+
</div>
|
| 289 |
+
))}
|
| 290 |
+
</div>
|
| 291 |
+
</div>
|
| 292 |
+
)}
|
| 293 |
+
|
| 294 |
+
{/* 4. Balance List (with Proxy Exchange) */}
|
| 295 |
+
{activeTab === 'balance' && (
|
| 296 |
+
<div className="overflow-x-auto">
|
| 297 |
+
<table className="w-full text-left border-collapse">
|
| 298 |
+
<thead className="bg-gray-50 text-gray-500 text-xs uppercase">
|
| 299 |
+
<tr>
|
| 300 |
+
<th className="p-4">学生姓名</th>
|
| 301 |
+
<th className="p-4">小红花余额</th>
|
| 302 |
+
<th className="p-4 text-right">操作 (代兑换)</th>
|
| 303 |
+
</tr>
|
| 304 |
+
</thead>
|
| 305 |
+
<tbody className="divide-y divide-gray-100">
|
| 306 |
+
{students.map(s => (
|
| 307 |
+
<tr key={s._id} className="hover:bg-gray-50">
|
| 308 |
+
<td className="p-4 font-bold text-gray-700">{s.name}</td>
|
| 309 |
+
<td className="p-4">
|
| 310 |
+
<span className="text-amber-600 font-bold bg-amber-50 px-2 py-1 rounded border border-amber-100">
|
| 311 |
+
{s.flowerBalance || 0} 🌺
|
| 312 |
+
</span>
|
| 313 |
+
</td>
|
| 314 |
+
<td className="p-4 text-right">
|
| 315 |
+
<div className="flex justify-end gap-2">
|
| 316 |
+
{config?.exchangeRules.map(r => (
|
| 317 |
+
<button
|
| 318 |
+
key={r.id}
|
| 319 |
+
disabled={(s.flowerBalance || 0) < r.cost}
|
| 320 |
+
onClick={() => handleProxyExchange(s._id || String(s.id), r.id)}
|
| 321 |
+
className="text-xs border border-gray-200 px-2 py-1 rounded hover:bg-blue-50 hover:text-blue-600 hover:border-blue-200 disabled:opacity-30 disabled:cursor-not-allowed"
|
| 322 |
+
title={`消耗 ${r.cost} 花兑换 ${r.rewardName}`}
|
| 323 |
+
>
|
| 324 |
+
兑换 {r.rewardName} (-{r.cost})
|
| 325 |
+
</button>
|
| 326 |
+
))}
|
| 327 |
+
</div>
|
| 328 |
+
</td>
|
| 329 |
+
</tr>
|
| 330 |
+
))}
|
| 331 |
+
</tbody>
|
| 332 |
+
</table>
|
| 333 |
+
</div>
|
| 334 |
+
)}
|
| 335 |
+
</div>
|
| 336 |
+
</div>
|
| 337 |
+
);
|
| 338 |
+
};
|
pages/Games.tsx
CHANGED
|
@@ -1,22 +1,31 @@
|
|
| 1 |
|
| 2 |
import React, { useState } from 'react';
|
| 3 |
-
import { Trophy, Star } from 'lucide-react';
|
| 4 |
import { GameMountain } from './GameMountain';
|
| 5 |
import { GameLucky } from './GameLucky';
|
| 6 |
import { GameRewards } from './GameRewards';
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
export const Games: React.FC = () => {
|
| 9 |
-
const [activeTab, setActiveTab] = useState<'games' | 'rewards'>('games');
|
| 10 |
const [activeGame, setActiveGame] = useState<'mountain' | 'lucky'>('mountain');
|
| 11 |
|
|
|
|
|
|
|
|
|
|
| 12 |
return (
|
| 13 |
<div className="flex flex-col h-[calc(100vh-120px)] w-full max-w-full overflow-hidden">
|
| 14 |
{/* Top Tabs Switcher */}
|
| 15 |
-
<div className="flex justify-center space-x-4 mb-4 shrink-0">
|
| 16 |
-
<button onClick={() => setActiveTab('games')} className={`px-6 py-2.5 rounded-full font-bold flex items-center transition-all ${activeTab === 'games' ? 'bg-blue-600 text-white shadow-lg shadow-blue-200 scale-105' : 'bg-white text-gray-500 hover:bg-gray-50 border border-transparent'}`}>
|
| 17 |
<Trophy className="mr-2" size={18}/> 互动游戏
|
| 18 |
</button>
|
| 19 |
-
<button onClick={() => setActiveTab('
|
|
|
|
|
|
|
|
|
|
| 20 |
<Star className="mr-2" size={18}/> 奖励管理
|
| 21 |
</button>
|
| 22 |
</div>
|
|
@@ -41,9 +50,14 @@ export const Games: React.FC = () => {
|
|
| 41 |
</div>
|
| 42 |
)}
|
| 43 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
{/* REWARD VIEW */}
|
| 45 |
{activeTab === 'rewards' && <GameRewards />}
|
| 46 |
</div>
|
| 47 |
</div>
|
| 48 |
);
|
| 49 |
-
};
|
|
|
|
| 1 |
|
| 2 |
import React, { useState } from 'react';
|
| 3 |
+
import { Trophy, Star, Award } from 'lucide-react';
|
| 4 |
import { GameMountain } from './GameMountain';
|
| 5 |
import { GameLucky } from './GameLucky';
|
| 6 |
import { GameRewards } from './GameRewards';
|
| 7 |
+
import { AchievementTeacher } from './AchievementTeacher';
|
| 8 |
+
import { AchievementStudent } from './AchievementStudent';
|
| 9 |
+
import { api } from '../services/api';
|
| 10 |
|
| 11 |
export const Games: React.FC = () => {
|
| 12 |
+
const [activeTab, setActiveTab] = useState<'games' | 'achievements' | 'rewards'>('games');
|
| 13 |
const [activeGame, setActiveGame] = useState<'mountain' | 'lucky'>('mountain');
|
| 14 |
|
| 15 |
+
const currentUser = api.auth.getCurrentUser();
|
| 16 |
+
const isStudent = currentUser?.role === 'STUDENT';
|
| 17 |
+
|
| 18 |
return (
|
| 19 |
<div className="flex flex-col h-[calc(100vh-120px)] w-full max-w-full overflow-hidden">
|
| 20 |
{/* Top Tabs Switcher */}
|
| 21 |
+
<div className="flex justify-center space-x-4 mb-4 shrink-0 overflow-x-auto px-4 py-1">
|
| 22 |
+
<button onClick={() => setActiveTab('games')} className={`px-6 py-2.5 rounded-full font-bold flex items-center transition-all whitespace-nowrap ${activeTab === 'games' ? 'bg-blue-600 text-white shadow-lg shadow-blue-200 scale-105' : 'bg-white text-gray-500 hover:bg-gray-50 border border-transparent'}`}>
|
| 23 |
<Trophy className="mr-2" size={18}/> 互动游戏
|
| 24 |
</button>
|
| 25 |
+
<button onClick={() => setActiveTab('achievements')} className={`px-6 py-2.5 rounded-full font-bold flex items-center transition-all whitespace-nowrap ${activeTab === 'achievements' ? 'bg-amber-500 text-white shadow-lg shadow-amber-200 scale-105' : 'bg-white text-gray-500 hover:bg-gray-50 border border-transparent'}`}>
|
| 26 |
+
<Award className="mr-2" size={18}/> 成就系统
|
| 27 |
+
</button>
|
| 28 |
+
<button onClick={() => setActiveTab('rewards')} className={`px-6 py-2.5 rounded-full font-bold flex items-center transition-all whitespace-nowrap ${activeTab === 'rewards' ? 'bg-emerald-500 text-white shadow-lg shadow-emerald-200 scale-105' : 'bg-white text-gray-500 hover:bg-gray-50 border border-transparent'}`}>
|
| 29 |
<Star className="mr-2" size={18}/> 奖励管理
|
| 30 |
</button>
|
| 31 |
</div>
|
|
|
|
| 50 |
</div>
|
| 51 |
)}
|
| 52 |
|
| 53 |
+
{/* ACHIEVEMENTS VIEW */}
|
| 54 |
+
{activeTab === 'achievements' && (
|
| 55 |
+
isStudent ? <AchievementStudent /> : <AchievementTeacher />
|
| 56 |
+
)}
|
| 57 |
+
|
| 58 |
{/* REWARD VIEW */}
|
| 59 |
{activeTab === 'rewards' && <GameRewards />}
|
| 60 |
</div>
|
| 61 |
</div>
|
| 62 |
);
|
| 63 |
+
};
|
server.js
CHANGED
|
@@ -33,6 +33,8 @@ const InMemoryDB = {
|
|
| 33 |
luckyConfig: {},
|
| 34 |
config: {},
|
| 35 |
attendance: [],
|
|
|
|
|
|
|
| 36 |
isFallback: false
|
| 37 |
};
|
| 38 |
|
|
@@ -59,8 +61,8 @@ const SchoolSchema = new mongoose.Schema({ name: String, code: String });
|
|
| 59 |
const School = mongoose.model('School', SchoolSchema);
|
| 60 |
const UserSchema = new mongoose.Schema({ username: String, password: String, trueName: String, phone: String, email: String, schoolId: String, role: String, status: String, avatar: String, createTime: Date, teachingSubject: String, homeroomClass: String, studentNo: String, parentName: String, parentPhone: String, address: String, gender: String });
|
| 61 |
const User = mongoose.model('User', UserSchema);
|
| 62 |
-
// Updated Student Schema with dailyDrawLog
|
| 63 |
-
const StudentSchema = new mongoose.Schema({ schoolId: String, studentNo: String, name: String, gender: String, birthday: String, idCard: String, phone: String, className: String, status: String, parentName: String, parentPhone: String, address: String, teamId: String, drawAttempts: { type: Number, default: 0 }, dailyDrawLog: { date: String, count: { type: Number, default: 0 } } });
|
| 64 |
const Student = mongoose.model('Student', StudentSchema);
|
| 65 |
const CourseSchema = new mongoose.Schema({ schoolId: String, courseCode: String, courseName: String, teacherName: String, credits: Number, capacity: Number, enrolled: Number });
|
| 66 |
const Course = mongoose.model('Course', CourseSchema);
|
|
@@ -87,6 +89,13 @@ const StudentRewardModel = mongoose.model('StudentReward', StudentRewardSchema);
|
|
| 87 |
const LuckyDrawConfigSchema = new mongoose.Schema({ schoolId: String, prizes: [{ id: String, name: String, probability: Number, count: Number, icon: String }], dailyLimit: Number, cardCount: Number, defaultPrize: String });
|
| 88 |
const LuckyDrawConfigModel = mongoose.model('LuckyDrawConfig', LuckyDrawConfigSchema);
|
| 89 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
// Attendance Schemas
|
| 91 |
const AttendanceSchema = new mongoose.Schema({ schoolId: String, studentId: String, studentName: String, className: String, date: String, status: String, checkInTime: Date });
|
| 92 |
const AttendanceModel = mongoose.model('Attendance', AttendanceSchema);
|
|
@@ -111,6 +120,114 @@ const injectSchoolId = (req, b) => ({ ...b, schoolId: req.headers['x-school-id']
|
|
| 111 |
|
| 112 |
// ... Routes ...
|
| 113 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
app.get('/api/games/lucky-config', async (req, res) => {
|
| 115 |
const filter = getQueryFilter(req);
|
| 116 |
if (InMemoryDB.isFallback) return res.json(InMemoryDB.luckyConfig);
|
|
@@ -557,4 +674,4 @@ app.get('*', (req, res) => {
|
|
| 557 |
res.sendFile(path.join(__dirname, 'dist', 'index.html'));
|
| 558 |
});
|
| 559 |
|
| 560 |
-
app.listen(PORT, () => console.log(`🚀 Server running on port ${PORT}`));
|
|
|
|
| 33 |
luckyConfig: {},
|
| 34 |
config: {},
|
| 35 |
attendance: [],
|
| 36 |
+
achievementsConfig: [], // NEW
|
| 37 |
+
studentAchievements: [], // NEW
|
| 38 |
isFallback: false
|
| 39 |
};
|
| 40 |
|
|
|
|
| 61 |
const School = mongoose.model('School', SchoolSchema);
|
| 62 |
const UserSchema = new mongoose.Schema({ username: String, password: String, trueName: String, phone: String, email: String, schoolId: String, role: String, status: String, avatar: String, createTime: Date, teachingSubject: String, homeroomClass: String, studentNo: String, parentName: String, parentPhone: String, address: String, gender: String });
|
| 63 |
const User = mongoose.model('User', UserSchema);
|
| 64 |
+
// Updated Student Schema with dailyDrawLog AND flowerBalance
|
| 65 |
+
const StudentSchema = new mongoose.Schema({ schoolId: String, studentNo: String, name: String, gender: String, birthday: String, idCard: String, phone: String, className: String, status: String, parentName: String, parentPhone: String, address: String, teamId: String, drawAttempts: { type: Number, default: 0 }, dailyDrawLog: { date: String, count: { type: Number, default: 0 } }, flowerBalance: { type: Number, default: 0 } });
|
| 66 |
const Student = mongoose.model('Student', StudentSchema);
|
| 67 |
const CourseSchema = new mongoose.Schema({ schoolId: String, courseCode: String, courseName: String, teacherName: String, credits: Number, capacity: Number, enrolled: Number });
|
| 68 |
const Course = mongoose.model('Course', CourseSchema);
|
|
|
|
| 89 |
const LuckyDrawConfigSchema = new mongoose.Schema({ schoolId: String, prizes: [{ id: String, name: String, probability: Number, count: Number, icon: String }], dailyLimit: Number, cardCount: Number, defaultPrize: String });
|
| 90 |
const LuckyDrawConfigModel = mongoose.model('LuckyDrawConfig', LuckyDrawConfigSchema);
|
| 91 |
|
| 92 |
+
// NEW: Achievement Schemas
|
| 93 |
+
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 }] });
|
| 94 |
+
const AchievementConfigModel = mongoose.model('AchievementConfig', AchievementConfigSchema);
|
| 95 |
+
const StudentAchievementSchema = new mongoose.Schema({ schoolId: String, studentId: String, studentName: String, achievementId: String, achievementName: String, achievementIcon: String, semester: String, createTime: { type: Date, default: Date.now } });
|
| 96 |
+
const StudentAchievementModel = mongoose.model('StudentAchievement', StudentAchievementSchema);
|
| 97 |
+
|
| 98 |
+
|
| 99 |
// Attendance Schemas
|
| 100 |
const AttendanceSchema = new mongoose.Schema({ schoolId: String, studentId: String, studentName: String, className: String, date: String, status: String, checkInTime: Date });
|
| 101 |
const AttendanceModel = mongoose.model('Attendance', AttendanceSchema);
|
|
|
|
| 120 |
|
| 121 |
// ... Routes ...
|
| 122 |
|
| 123 |
+
// --- ACHIEVEMENT ROUTES ---
|
| 124 |
+
|
| 125 |
+
// Get Achievement Config for a class
|
| 126 |
+
app.get('/api/achievements/config', async (req, res) => {
|
| 127 |
+
const { className } = req.query;
|
| 128 |
+
if (!className) return res.status(400).json({ error: 'Class name required' });
|
| 129 |
+
const config = await AchievementConfigModel.findOne({ ...getQueryFilter(req), className });
|
| 130 |
+
res.json(config || { className, achievements: [], exchangeRules: [] });
|
| 131 |
+
});
|
| 132 |
+
|
| 133 |
+
// Save Achievement Config
|
| 134 |
+
app.post('/api/achievements/config', async (req, res) => {
|
| 135 |
+
const { className } = req.body;
|
| 136 |
+
if (!className) return res.status(400).json({ error: 'Class name required' });
|
| 137 |
+
const sId = req.headers['x-school-id'];
|
| 138 |
+
const filter = { className };
|
| 139 |
+
if (sId) filter.schoolId = sId;
|
| 140 |
+
|
| 141 |
+
await AchievementConfigModel.findOneAndUpdate(filter, injectSchoolId(req, req.body), { upsert: true });
|
| 142 |
+
res.json({ success: true });
|
| 143 |
+
});
|
| 144 |
+
|
| 145 |
+
// Get My/Student Achievements (with Semester Filter)
|
| 146 |
+
app.get('/api/achievements/student', async (req, res) => {
|
| 147 |
+
const { studentId, semester } = req.query;
|
| 148 |
+
const filter = getQueryFilter(req);
|
| 149 |
+
if (studentId) filter.studentId = studentId;
|
| 150 |
+
if (semester) filter.semester = semester;
|
| 151 |
+
const list = await StudentAchievementModel.find(filter).sort({ createTime: -1 });
|
| 152 |
+
res.json(list);
|
| 153 |
+
});
|
| 154 |
+
|
| 155 |
+
// Grant Achievement (Adds Record + Adds Flowers)
|
| 156 |
+
app.post('/api/achievements/grant', async (req, res) => {
|
| 157 |
+
const { studentId, achievementId, semester } = req.body;
|
| 158 |
+
const sId = req.headers['x-school-id'];
|
| 159 |
+
|
| 160 |
+
const student = await Student.findById(studentId);
|
| 161 |
+
if (!student) return res.status(404).json({ error: 'Student not found' });
|
| 162 |
+
|
| 163 |
+
// Find config to get details
|
| 164 |
+
const config = await AchievementConfigModel.findOne({ className: student.className, ...getQueryFilter(req) });
|
| 165 |
+
const ach = config?.achievements.find(a => a.id === achievementId);
|
| 166 |
+
|
| 167 |
+
if (!ach) return res.status(404).json({ error: 'Achievement definition not found' });
|
| 168 |
+
|
| 169 |
+
// 1. Create Record
|
| 170 |
+
await StudentAchievementModel.create({
|
| 171 |
+
schoolId: sId,
|
| 172 |
+
studentId,
|
| 173 |
+
studentName: student.name,
|
| 174 |
+
achievementId: ach.id,
|
| 175 |
+
achievementName: ach.name,
|
| 176 |
+
achievementIcon: ach.icon,
|
| 177 |
+
semester: semester || '当前学期',
|
| 178 |
+
});
|
| 179 |
+
|
| 180 |
+
// 2. Add Flowers to Student Balance
|
| 181 |
+
await Student.findByIdAndUpdate(studentId, { $inc: { flowerBalance: ach.points } });
|
| 182 |
+
|
| 183 |
+
res.json({ success: true });
|
| 184 |
+
});
|
| 185 |
+
|
| 186 |
+
// Exchange Flowers (Deduct Flowers + Add Reward Record)
|
| 187 |
+
app.post('/api/achievements/exchange', async (req, res) => {
|
| 188 |
+
const { studentId, ruleId } = req.body;
|
| 189 |
+
const sId = req.headers['x-school-id'];
|
| 190 |
+
|
| 191 |
+
const student = await Student.findById(studentId);
|
| 192 |
+
if (!student) return res.status(404).json({ error: 'Student not found' });
|
| 193 |
+
|
| 194 |
+
const config = await AchievementConfigModel.findOne({ className: student.className, ...getQueryFilter(req) });
|
| 195 |
+
const rule = config?.exchangeRules.find(r => r.id === ruleId);
|
| 196 |
+
|
| 197 |
+
if (!rule) return res.status(404).json({ error: 'Exchange rule not found' });
|
| 198 |
+
|
| 199 |
+
if ((student.flowerBalance || 0) < rule.cost) {
|
| 200 |
+
return res.status(400).json({ error: 'INSUFFICIENT_FLOWERS', message: '小红花余额不足' });
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
// 1. Deduct Flowers
|
| 204 |
+
await Student.findByIdAndUpdate(studentId, { $inc: { flowerBalance: -rule.cost } });
|
| 205 |
+
|
| 206 |
+
// 2. Grant Reward logic (similar to grant-reward endpoint)
|
| 207 |
+
// If it's DRAW_COUNT, we immediately credit attempts. If ITEM, it's pending.
|
| 208 |
+
let status = 'PENDING';
|
| 209 |
+
if (rule.rewardType === 'DRAW_COUNT') {
|
| 210 |
+
status = 'REDEEMED';
|
| 211 |
+
await Student.findByIdAndUpdate(studentId, { $inc: { drawAttempts: rule.rewardValue } });
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
await StudentRewardModel.create({
|
| 215 |
+
schoolId: sId,
|
| 216 |
+
studentId,
|
| 217 |
+
studentName: student.name,
|
| 218 |
+
rewardType: rule.rewardType,
|
| 219 |
+
name: rule.rewardName,
|
| 220 |
+
count: rule.rewardValue,
|
| 221 |
+
status: status,
|
| 222 |
+
source: `积分兑换`
|
| 223 |
+
});
|
| 224 |
+
|
| 225 |
+
res.json({ success: true });
|
| 226 |
+
});
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
// ... Previous Routes ...
|
| 230 |
+
|
| 231 |
app.get('/api/games/lucky-config', async (req, res) => {
|
| 232 |
const filter = getQueryFilter(req);
|
| 233 |
if (InMemoryDB.isFallback) return res.json(InMemoryDB.luckyConfig);
|
|
|
|
| 674 |
res.sendFile(path.join(__dirname, 'dist', 'index.html'));
|
| 675 |
});
|
| 676 |
|
| 677 |
+
app.listen(PORT, () => console.log(`🚀 Server running on port ${PORT}`));
|
services/api.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
|
| 2 |
-
import { User, ClassInfo, SystemConfig, Subject, School, Schedule, GameSession, StudentReward, LuckyDrawConfig, Attendance, LeaveRequest } from '../types';
|
| 3 |
|
| 4 |
const getBaseUrl = () => {
|
| 5 |
let isProd = false;
|
|
@@ -188,6 +188,15 @@ export const api = {
|
|
| 188 |
drawLucky: (studentId: string) => request('/games/lucky-draw', { method: 'POST', body: JSON.stringify({ studentId }) }),
|
| 189 |
grantReward: (data: { studentId: string, count: number, rewardType: string, name?: string }) => request('/games/grant-reward', { method: 'POST', body: JSON.stringify(data) }),
|
| 190 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 191 |
rewards: {
|
| 192 |
// Pagination support
|
| 193 |
getMyRewards: (studentId: string, page = 1, limit = 20) => request(`/rewards?studentId=${studentId}&page=${page}&limit=${limit}`),
|
|
@@ -201,4 +210,4 @@ export const api = {
|
|
| 201 |
batchDelete: (type: 'student' | 'score' | 'user', ids: string[]) => {
|
| 202 |
return request('/batch-delete', { method: 'POST', body: JSON.stringify({ type, ids }) });
|
| 203 |
}
|
| 204 |
-
};
|
|
|
|
| 1 |
|
| 2 |
+
import { User, ClassInfo, SystemConfig, Subject, School, Schedule, GameSession, StudentReward, LuckyDrawConfig, Attendance, LeaveRequest, AchievementConfig } from '../types';
|
| 3 |
|
| 4 |
const getBaseUrl = () => {
|
| 5 |
let isProd = false;
|
|
|
|
| 188 |
drawLucky: (studentId: string) => request('/games/lucky-draw', { method: 'POST', body: JSON.stringify({ studentId }) }),
|
| 189 |
grantReward: (data: { studentId: string, count: number, rewardType: string, name?: string }) => request('/games/grant-reward', { method: 'POST', body: JSON.stringify(data) }),
|
| 190 |
},
|
| 191 |
+
|
| 192 |
+
achievements: {
|
| 193 |
+
getConfig: (className: string) => request(`/achievements/config?className=${className}`),
|
| 194 |
+
saveConfig: (data: AchievementConfig) => request('/achievements/config', { method: 'POST', body: JSON.stringify(data) }),
|
| 195 |
+
getStudentAchievements: (studentId: string, semester?: string) => request(`/achievements/student?studentId=${studentId}${semester ? `&semester=${semester}` : ''}`),
|
| 196 |
+
grant: (data: { studentId: string, achievementId: string, semester: string }) => request('/achievements/grant', { method: 'POST', body: JSON.stringify(data) }),
|
| 197 |
+
exchange: (data: { studentId: string, ruleId: string }) => request('/achievements/exchange', { method: 'POST', body: JSON.stringify(data) }),
|
| 198 |
+
},
|
| 199 |
+
|
| 200 |
rewards: {
|
| 201 |
// Pagination support
|
| 202 |
getMyRewards: (studentId: string, page = 1, limit = 20) => request(`/rewards?studentId=${studentId}&page=${page}&limit=${limit}`),
|
|
|
|
| 210 |
batchDelete: (type: 'student' | 'score' | 'user', ids: string[]) => {
|
| 211 |
return request('/batch-delete', { method: 'POST', body: JSON.stringify({ type, ids }) });
|
| 212 |
}
|
| 213 |
+
};
|
types.ts
CHANGED
|
@@ -91,6 +91,8 @@ export interface Student {
|
|
| 91 |
teamId?: string;
|
| 92 |
drawAttempts?: number;
|
| 93 |
dailyDrawLog?: { date: string; count: number }; // Track daily usage
|
|
|
|
|
|
|
| 94 |
}
|
| 95 |
|
| 96 |
export interface Course {
|
|
@@ -226,6 +228,44 @@ export interface LuckyDrawConfig {
|
|
| 226 |
defaultPrize: string; // "再接再厉"
|
| 227 |
}
|
| 228 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 229 |
// --- Attendance Types ---
|
| 230 |
export type AttendanceStatus = 'Present' | 'Absent' | 'Leave';
|
| 231 |
|
|
@@ -251,4 +291,4 @@ export interface LeaveRequest {
|
|
| 251 |
endDate: string;
|
| 252 |
status: 'Pending' | 'Approved' | 'Rejected';
|
| 253 |
createTime: string;
|
| 254 |
-
}
|
|
|
|
| 91 |
teamId?: string;
|
| 92 |
drawAttempts?: number;
|
| 93 |
dailyDrawLog?: { date: string; count: number }; // Track daily usage
|
| 94 |
+
// Achievement related
|
| 95 |
+
flowerBalance?: number; // 小红花余额
|
| 96 |
}
|
| 97 |
|
| 98 |
export interface Course {
|
|
|
|
| 228 |
defaultPrize: string; // "再接再厉"
|
| 229 |
}
|
| 230 |
|
| 231 |
+
// --- Achievement System Types ---
|
| 232 |
+
|
| 233 |
+
export interface AchievementItem {
|
| 234 |
+
id: string;
|
| 235 |
+
name: string;
|
| 236 |
+
icon: string; // Emoji or URL
|
| 237 |
+
points: number; // Worth how many flowers
|
| 238 |
+
description?: string;
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
export interface ExchangeRule {
|
| 242 |
+
id: string;
|
| 243 |
+
cost: number; // Cost in flowers
|
| 244 |
+
rewardType: 'ITEM' | 'DRAW_COUNT';
|
| 245 |
+
rewardName: string;
|
| 246 |
+
rewardValue: number; // Quantity (e.g. 1 draw count)
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
export interface AchievementConfig {
|
| 250 |
+
_id?: string;
|
| 251 |
+
schoolId: string;
|
| 252 |
+
className: string;
|
| 253 |
+
achievements: AchievementItem[];
|
| 254 |
+
exchangeRules: ExchangeRule[];
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
export interface StudentAchievement {
|
| 258 |
+
_id?: string;
|
| 259 |
+
schoolId: string;
|
| 260 |
+
studentId: string;
|
| 261 |
+
studentName: string;
|
| 262 |
+
achievementId: string; // Refers to AchievementItem.id
|
| 263 |
+
achievementName: string; // Denormalized for display
|
| 264 |
+
achievementIcon: string;
|
| 265 |
+
semester: string;
|
| 266 |
+
createTime: string;
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
// --- Attendance Types ---
|
| 270 |
export type AttendanceStatus = 'Present' | 'Absent' | 'Leave';
|
| 271 |
|
|
|
|
| 291 |
endDate: string;
|
| 292 |
status: 'Pending' | 'Approved' | 'Rejected';
|
| 293 |
createTime: string;
|
| 294 |
+
}
|