Spaces:
Sleeping
Sleeping
| import React, { useState, useEffect } from 'react'; | |
| import { api } from '../services/api'; | |
| import { AchievementConfig, AchievementItem, Student, StudentAchievement, SystemConfig, TeacherExchangeConfig, User } from '../types'; | |
| import { Award, ShoppingBag, Loader2, Calendar, Lock } from 'lucide-react'; | |
| import { Emoji } from '../components/Emoji'; | |
| export const AchievementStudent: React.FC = () => { | |
| const [loading, setLoading] = useState(true); | |
| const [student, setStudent] = useState<Student | null>(null); | |
| const [config, setConfig] = useState<AchievementConfig | null>(null); | |
| const [teacherRules, setTeacherRules] = useState<TeacherExchangeConfig[]>([]); | |
| const [myAchievements, setMyAchievements] = useState<StudentAchievement[]>([]); | |
| const [availableTeachers, setAvailableTeachers] = useState<User[]>([]); // To resolve teacher names | |
| // UI State | |
| const [activeTab, setActiveTab] = useState<'wall' | 'shop'>('wall'); | |
| const [semesters, setSemesters] = useState<string[]>([]); | |
| const [selectedSemester, setSelectedSemester] = useState(''); | |
| const currentUser = api.auth.getCurrentUser(); | |
| // Helper to format polite teacher name | |
| const formatTeacherName = (t: User | undefined) => { | |
| if (!t) return '未知老师'; | |
| const name = t.trueName || t.username; | |
| const surname = name.charAt(0); | |
| // Use teachingSubject if available, otherwise just Surname + Teacher. | |
| // Do not default to "科任". | |
| const subject = t.teachingSubject; | |
| return subject ? `${subject}-${surname}老师` : `${surname}老师`; | |
| }; | |
| useEffect(() => { | |
| loadData(); | |
| }, [selectedSemester]); | |
| const loadData = async () => { | |
| setLoading(true); | |
| try { | |
| const [stus, sysCfg] = await Promise.all([ | |
| api.students.getAll(), | |
| api.config.getPublic() as Promise<SystemConfig> | |
| ]); | |
| const me = stus.find((s: Student) => s.name === (currentUser?.trueName || currentUser?.username)); | |
| if (me) { | |
| setStudent(me); | |
| const cfg = await api.achievements.getConfig(me.className); | |
| setConfig(cfg); | |
| // Semesters Logic | |
| const sems = sysCfg.semesters || ['当前学期']; | |
| setSemesters(sems); | |
| if (!selectedSemester && sysCfg.semester) { | |
| setSelectedSemester(sysCfg.semester); | |
| return; // Will re-trigger useEffect | |
| } | |
| // Fetch Records | |
| const records = await api.achievements.getStudentAchievements(me._id || String(me.id), selectedSemester); | |
| setMyAchievements(records); | |
| // Fetch Teacher Rules | |
| // Get ALL associated teachers (Homeroom + Course teachers) | |
| const teachers = await api.users.getTeachersForClass(me.className); | |
| setAvailableTeachers(teachers); // Save for formatting | |
| const teacherIds = teachers.map((t: any) => t._id); | |
| if (teacherIds.length > 0) { | |
| const rules = await api.achievements.getRulesByTeachers(teacherIds); | |
| // Filter out rulesets that have no rules | |
| setTeacherRules(rules.filter((r: TeacherExchangeConfig) => r.rules && r.rules.length > 0)); | |
| } | |
| } | |
| } catch (e) { console.error(e); } | |
| finally { setLoading(false); } | |
| }; | |
| const handleExchange = async (ruleId: string, teacherId: string) => { | |
| if (!student) return; | |
| if (!confirm('确认消耗小红花进行兑换吗?')) return; | |
| try { | |
| await api.achievements.exchange({ studentId: student._id || String(student.id), ruleId, teacherId }); | |
| alert('兑换成功!请到“奖励管理”查看。'); | |
| loadData(); // Refresh balance | |
| } catch (e: any) { alert(e.message || '兑换失败'); } | |
| }; | |
| // Calculate Counts for Wall | |
| const getAchievementCount = (achId: string) => { | |
| return myAchievements.filter(a => a.achievementId === achId).length; | |
| }; | |
| // Helper to merge config achievements and orphaned historical achievements | |
| const getDisplayAchievements = () => { | |
| if (!config) return []; | |
| // 1. Start with current config achievements (The "Main Grid") | |
| const displayList = [...config.achievements]; | |
| // 2. Find "Orphaned" achievements (Earned in past, but not in current config) | |
| const configIds = new Set(config.achievements.map(a => a.id)); | |
| const uniqueOrphans = new Map<string, StudentAchievement>(); | |
| myAchievements.forEach(record => { | |
| if (!configIds.has(record.achievementId)) { | |
| if (!uniqueOrphans.has(record.achievementId)) { | |
| uniqueOrphans.set(record.achievementId, record); | |
| } | |
| } | |
| }); | |
| uniqueOrphans.forEach((record) => { | |
| displayList.push({ | |
| id: record.achievementId, | |
| name: record.achievementName, | |
| icon: record.achievementIcon, | |
| points: 0, | |
| description: '历史荣誉' | |
| }); | |
| }); | |
| return displayList; | |
| }; | |
| if (loading && !student) return <div className="flex justify-center p-10"><Loader2 className="animate-spin text-blue-600"/></div>; | |
| if (!student || !config) return <div className="text-center p-10 text-gray-400">暂无数据,请联系班主任开启成就系统。</div>; | |
| const displayItems = getDisplayAchievements(); | |
| return ( | |
| <div className="h-full flex flex-col bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden"> | |
| {/* Header / Info */} | |
| <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"> | |
| <div className="flex items-center gap-4"> | |
| <div className="bg-amber-100 p-3 rounded-full border border-amber-200"> | |
| <Award size={32} className="text-amber-600"/> | |
| </div> | |
| <div> | |
| <h2 className="text-xl font-bold text-gray-800">我的成就中心</h2> | |
| <p className="text-sm text-gray-500">努力学习,收获荣誉与奖励!</p> | |
| </div> | |
| </div> | |
| <div className="flex items-center gap-4 bg-white p-3 rounded-xl shadow-sm border border-orange-100"> | |
| <div className="text-right"> | |
| <div className="text-xs text-gray-500 font-bold uppercase">当前小红花</div> | |
| <div className="text-2xl font-black text-amber-600">{student.flowerBalance || 0} <Emoji symbol="🌺" size={20}/></div> | |
| </div> | |
| </div> | |
| </div> | |
| {/* Tabs */} | |
| <div className="flex border-b border-gray-100 px-6"> | |
| <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'}`}> | |
| 🏆 荣誉成就墙 | |
| </button> | |
| <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'}`}> | |
| 🛍️ 积分兑换 | |
| </button> | |
| </div> | |
| <div className="flex-1 overflow-y-auto p-6 bg-gray-50/30"> | |
| {/* 1. Wall */} | |
| {activeTab === 'wall' && ( | |
| <div className="space-y-6"> | |
| <div className="flex justify-end"> | |
| <div className="flex items-center bg-white border rounded-lg px-2 py-1 shadow-sm"> | |
| <Calendar size={14} className="text-gray-400 mr-2"/> | |
| <select className="text-sm bg-transparent outline-none text-gray-600" value={selectedSemester} onChange={e => setSelectedSemester(e.target.value)}> | |
| <option value="">-- 全部时间 --</option> | |
| {semesters.map(s => <option key={s} value={s}>{s}</option>)} | |
| </select> | |
| </div> | |
| </div> | |
| <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4"> | |
| {displayItems.map(ach => { | |
| const count = getAchievementCount(ach.id); | |
| const isUnlocked = count > 0; | |
| const isHistoric = ach.description === '历史荣誉'; | |
| return ( | |
| <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'}`}> | |
| <div className={`text-5xl mb-3 transition-transform ${isUnlocked ? 'scale-110 drop-shadow-md' : 'scale-90 opacity-50'}`}> | |
| <Emoji symbol={ach.icon} size={48} /> | |
| </div> | |
| <div className={`font-bold text-center ${isUnlocked ? 'text-gray-800' : 'text-gray-400'}`}> | |
| {ach.name} | |
| </div> | |
| {isUnlocked ? ( | |
| <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"> | |
| x{count} | |
| </div> | |
| ) : ( | |
| <div className="absolute top-2 right-2 text-gray-300"> | |
| <Lock size={16}/> | |
| </div> | |
| )} | |
| <div className={`mt-1 text-xs font-medium px-2 rounded ${isHistoric ? 'text-gray-500 bg-gray-100' : 'text-amber-600 bg-amber-50'}`}> | |
| {isHistoric ? '历史记录' : <span>{ach.points} <Emoji symbol="🌺" size={10}/></span>} | |
| </div> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| </div> | |
| )} | |
| {/* 2. Shop */} | |
| {activeTab === 'shop' && ( | |
| <div className="space-y-8"> | |
| {teacherRules.length === 0 ? ( | |
| <div className="text-center py-20 text-gray-400 flex flex-col items-center"> | |
| <ShoppingBag size={48} className="mb-4 opacity-20"/> | |
| <p>暂时没有老师开启兑换商店</p> | |
| <p className="text-xs mt-2">请提醒你的任课老师在“成就管理-兑换规则”中添加奖品哦!</p> | |
| </div> | |
| ) : ( | |
| teacherRules.map(teacherConfig => { | |
| // Resolve teacher info to get subject | |
| const teacherObj = availableTeachers.find(t => t._id === teacherConfig.teacherId); | |
| const displayName = teacherObj ? formatTeacherName(teacherObj) : teacherConfig.teacherName; | |
| return ( | |
| teacherConfig.rules.length > 0 && ( | |
| <div key={teacherConfig.teacherId} className="bg-white rounded-xl border border-gray-200 p-6 shadow-sm"> | |
| <h3 className="font-bold text-lg text-gray-800 mb-4 border-b pb-2 flex items-center"> | |
| 🛍️ {displayName} 的兑换店 | |
| </h3> | |
| <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> | |
| {teacherConfig.rules.map(rule => { | |
| const canAfford = (student.flowerBalance || 0) >= rule.cost; | |
| return ( | |
| <div key={rule.id} className="bg-gray-50 rounded-xl border border-gray-100 p-4 flex flex-col items-center text-center hover:shadow-md transition-shadow relative overflow-hidden"> | |
| <div className="w-14 h-14 bg-gradient-to-br from-green-100 to-emerald-100 rounded-full flex items-center justify-center mb-3 text-2xl shadow-inner"> | |
| <Emoji symbol={rule.rewardType === 'DRAW_COUNT' ? '🎲' : '🎁'} size={28} /> | |
| </div> | |
| <h3 className="font-bold text-gray-800 mb-1">{rule.rewardName}</h3> | |
| <p className="text-xs text-gray-500 mb-3">包含数量: x{rule.rewardValue}</p> | |
| <button | |
| onClick={() => handleExchange(rule.id, teacherConfig.teacherId)} | |
| disabled={!canAfford} | |
| className={`mt-auto w-full py-2 rounded-lg font-bold flex items-center justify-center gap-2 text-sm transition-all ${canAfford ? 'bg-amber-500 text-white hover:bg-amber-600 shadow-sm active:scale-95' : 'bg-gray-200 text-gray-400 cursor-not-allowed'}`} | |
| > | |
| <ShoppingBag size={14}/> | |
| {rule.cost} <Emoji symbol="🌺" size={12} /> 兑换 | |
| </button> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| </div> | |
| ) | |
| )}) | |
| )} | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| ); | |
| }; | |