stud-manager / pages /AchievementStudent.tsx
dvc890's picture
Upload 45 files
c728eff verified
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>
);
};