dvc890 commited on
Commit
101d298
·
verified ·
1 Parent(s): 58175ca

Upload 37 files

Browse files
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('rewards')} className={`px-6 py-2.5 rounded-full font-bold flex items-center transition-all ${activeTab === 'rewards' ? 'bg-amber-500 text-white shadow-lg shadow-amber-200 scale-105' : 'bg-white text-gray-500 hover:bg-gray-50 border border-transparent'}`}>
 
 
 
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
+ }