Spaces:
Sleeping
Sleeping
Upload 31 files
Browse files- pages/Games.tsx +238 -115
- pages/Login.tsx +0 -1
- pages/Reports.tsx +7 -377
- pages/StudentReports.tsx +111 -0
- pages/TeacherReports.tsx +385 -0
- server.js +35 -3
- services/api.ts +5 -1
pages/Games.tsx
CHANGED
|
@@ -2,7 +2,7 @@
|
|
| 2 |
import React, { useState, useEffect } from 'react';
|
| 3 |
import { api } from '../services/api';
|
| 4 |
import { GameSession, GameTeam, Student, StudentReward, LuckyDrawConfig, LuckyPrize } from '../types';
|
| 5 |
-
import { Trophy, Gift,
|
| 6 |
|
| 7 |
// --- Components ---
|
| 8 |
|
|
@@ -10,14 +10,14 @@ const FlipCard = ({ prize, onFlip, isRevealed }: { prize: string, onFlip: () =>
|
|
| 10 |
return (
|
| 11 |
<div className="relative w-full aspect-[3/4] cursor-pointer perspective-1000 group" onClick={!isRevealed ? onFlip : undefined}>
|
| 12 |
<div className={`relative w-full h-full text-center transition-transform duration-700 transform-style-3d shadow-xl rounded-xl ${isRevealed ? 'rotate-y-180' : 'group-hover:-translate-y-2'}`}>
|
| 13 |
-
{/* Front
|
| 14 |
<div className="absolute w-full h-full backface-hidden bg-gradient-to-br from-red-500 to-red-700 rounded-xl flex flex-col items-center justify-center border-4 border-yellow-300 shadow-inner">
|
| 15 |
<div className="w-12 h-12 bg-yellow-200 rounded-full flex items-center justify-center mb-2 shadow-md border-2 border-yellow-400">
|
| 16 |
<span className="text-xl">🧧</span>
|
| 17 |
</div>
|
| 18 |
<span className="text-yellow-100 font-bold text-lg tracking-widest">開</span>
|
| 19 |
</div>
|
| 20 |
-
{/* Back
|
| 21 |
<div className="absolute w-full h-full backface-hidden bg-white rounded-xl flex flex-col items-center justify-center border-2 border-red-200 rotate-y-180 shadow-inner p-2">
|
| 22 |
<span className="text-3xl mb-2">🎁</span>
|
| 23 |
<span className="text-red-600 font-bold text-sm break-words leading-tight">{prize}</span>
|
|
@@ -32,14 +32,14 @@ const MountainStage = ({ team, index, rewardsConfig, maxSteps }: { team: GameTea
|
|
| 32 |
const bottomPos = 5 + (percentage * 85);
|
| 33 |
|
| 34 |
return (
|
| 35 |
-
<div className="relative flex flex-col items-center justify-end h-[
|
| 36 |
<div className="absolute -top-12 text-center w-[140%] z-20 transition-transform hover:-translate-y-1">
|
| 37 |
-
<h3 className="text-
|
| 38 |
{team.name}
|
| 39 |
</h3>
|
| 40 |
</div>
|
| 41 |
|
| 42 |
-
{/* Mountain SVG
|
| 43 |
<div className="absolute bottom-0 left-0 w-full h-full z-0 overflow-visible filter drop-shadow-md">
|
| 44 |
<svg viewBox="0 0 200 500" preserveAspectRatio="none" className="w-full h-full">
|
| 45 |
<path d="M100 20 L 190 500 L 10 500 Z" fill={index % 2 === 0 ? '#4ade80' : '#22c55e'} stroke="#15803d" strokeWidth="2" />
|
|
@@ -47,16 +47,16 @@ const MountainStage = ({ team, index, rewardsConfig, maxSteps }: { team: GameTea
|
|
| 47 |
</svg>
|
| 48 |
</div>
|
| 49 |
|
| 50 |
-
{/* Ladder
|
| 51 |
-
<div className="absolute bottom-0 w-
|
| 52 |
{Array.from({ length: maxSteps + 1 }).map((_, i) => {
|
| 53 |
const reward = rewardsConfig.find(r => r.scoreThreshold === i);
|
| 54 |
const isUnlocked = team.score >= i;
|
| 55 |
return (
|
| 56 |
<div key={i} className="relative w-full h-full flex items-center justify-center">
|
| 57 |
-
<div className="w-full h-
|
| 58 |
{reward && (
|
| 59 |
-
<div className={`absolute left-full ml-2 px-
|
| 60 |
{reward.rewardType === 'DRAW_COUNT' ? '🎲' : '🎁'} {reward.rewardName}
|
| 61 |
</div>
|
| 62 |
)}
|
|
@@ -67,9 +67,9 @@ const MountainStage = ({ team, index, rewardsConfig, maxSteps }: { team: GameTea
|
|
| 67 |
|
| 68 |
{/* Climber */}
|
| 69 |
<div className="absolute z-30 transition-all duration-700 ease-out flex flex-col items-center" style={{ bottom: `${bottomPos}%` }}>
|
| 70 |
-
<div className="w-10 h-10
|
| 71 |
-
<span className="text-xl
|
| 72 |
-
<div className="absolute -top-
|
| 73 |
{team.score}
|
| 74 |
</div>
|
| 75 |
</div>
|
|
@@ -81,18 +81,23 @@ const MountainStage = ({ team, index, rewardsConfig, maxSteps }: { team: GameTea
|
|
| 81 |
// --- Main Page ---
|
| 82 |
|
| 83 |
export const Games: React.FC = () => {
|
|
|
|
| 84 |
const [activeGame, setActiveGame] = useState<'mountain' | 'lucky'>('mountain');
|
|
|
|
| 85 |
const [session, setSession] = useState<GameSession | null>(null);
|
| 86 |
const [luckyConfig, setLuckyConfig] = useState<LuckyDrawConfig | null>(null);
|
| 87 |
const [myRewards, setMyRewards] = useState<StudentReward[]>([]);
|
|
|
|
| 88 |
const [studentInfo, setStudentInfo] = useState<Student | null>(null);
|
| 89 |
const [loading, setLoading] = useState(true);
|
| 90 |
|
| 91 |
// Teacher Controls State
|
| 92 |
const [isMtSettingsOpen, setIsMtSettingsOpen] = useState(false);
|
| 93 |
const [isLuckySettingsOpen, setIsLuckySettingsOpen] = useState(false);
|
|
|
|
| 94 |
const [students, setStudents] = useState<Student[]>([]);
|
| 95 |
const [selectedTeamId, setSelectedTeamId] = useState<string | null>(null); // For member management
|
|
|
|
| 96 |
|
| 97 |
// Lucky Draw State
|
| 98 |
const [drawResult, setDrawResult] = useState<{prize: string} | null>(null);
|
|
@@ -100,25 +105,32 @@ export const Games: React.FC = () => {
|
|
| 100 |
|
| 101 |
const currentUser = api.auth.getCurrentUser();
|
| 102 |
const isTeacher = currentUser?.role === 'TEACHER';
|
|
|
|
| 103 |
|
| 104 |
useEffect(() => {
|
| 105 |
loadData();
|
| 106 |
-
}, [activeGame]);
|
| 107 |
|
| 108 |
const loadData = async () => {
|
| 109 |
setLoading(true);
|
| 110 |
try {
|
| 111 |
if (!currentUser) return;
|
| 112 |
|
|
|
|
|
|
|
| 113 |
let targetClass = '';
|
| 114 |
-
if (isTeacher && currentUser.homeroomClass)
|
| 115 |
-
|
| 116 |
-
|
|
|
|
|
|
|
| 117 |
const me = stus.find((s: Student) => s.name === (currentUser.trueName || currentUser.username));
|
| 118 |
if (me) {
|
| 119 |
setStudentInfo(me);
|
| 120 |
targetClass = me.className;
|
| 121 |
}
|
|
|
|
|
|
|
| 122 |
}
|
| 123 |
|
| 124 |
if (targetClass) {
|
|
@@ -150,13 +162,22 @@ export const Games: React.FC = () => {
|
|
| 150 |
const lCfg = await api.games.getLuckyConfig();
|
| 151 |
setLuckyConfig(lCfg);
|
| 152 |
|
| 153 |
-
|
|
|
|
| 154 |
const rews = await api.rewards.getMyRewards(studentInfo._id);
|
| 155 |
setMyRewards(rews);
|
| 156 |
-
}
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
}
|
| 161 |
|
| 162 |
} catch (e) { console.error(e); }
|
|
@@ -174,7 +195,9 @@ export const Games: React.FC = () => {
|
|
| 174 |
if (delta > 0) {
|
| 175 |
const reward = session.rewardsConfig.find(r => r.scoreThreshold === newScore);
|
| 176 |
if (reward) {
|
|
|
|
| 177 |
t.members.forEach(stuId => {
|
|
|
|
| 178 |
const stu = students.find(s => (s._id || s.id) == stuId);
|
| 179 |
if (stu) {
|
| 180 |
api.rewards.addReward({
|
|
@@ -184,11 +207,11 @@ export const Games: React.FC = () => {
|
|
| 184 |
rewardType: reward.rewardType as any,
|
| 185 |
name: reward.rewardName,
|
| 186 |
status: 'PENDING',
|
| 187 |
-
source: `群岳争锋 - ${t.name} ${newScore}分
|
| 188 |
});
|
| 189 |
}
|
| 190 |
});
|
| 191 |
-
alert(`🎉 ${t.name} 达到 ${newScore} 分!已发放 [${reward.rewardName}]
|
| 192 |
}
|
| 193 |
}
|
| 194 |
return { ...t, score: newScore };
|
|
@@ -208,12 +231,19 @@ export const Games: React.FC = () => {
|
|
| 208 |
if (!session) return;
|
| 209 |
const newTeams = session.teams.map(t => {
|
| 210 |
const members = new Set(t.members);
|
|
|
|
| 211 |
if (t.id === teamId) {
|
| 212 |
-
//
|
| 213 |
-
members.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 214 |
} else {
|
| 215 |
-
//
|
| 216 |
-
members.
|
|
|
|
|
|
|
| 217 |
}
|
| 218 |
return { ...t, members: Array.from(members) };
|
| 219 |
});
|
|
@@ -224,7 +254,7 @@ export const Games: React.FC = () => {
|
|
| 224 |
|
| 225 |
const handleDraw = async () => {
|
| 226 |
if (!studentInfo || !luckyConfig || isFlipping) return;
|
| 227 |
-
if ((studentInfo.drawAttempts || 0) <= 0) return alert('抽奖次数不足!请通过“群岳争锋”游戏获取。');
|
| 228 |
|
| 229 |
setIsFlipping(true);
|
| 230 |
try {
|
|
@@ -248,72 +278,158 @@ export const Games: React.FC = () => {
|
|
| 248 |
setIsLuckySettingsOpen(false);
|
| 249 |
};
|
| 250 |
|
| 251 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 252 |
|
| 253 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 254 |
|
| 255 |
return (
|
| 256 |
-
<div className="
|
| 257 |
-
{/*
|
| 258 |
-
<div className="flex justify-center space-x-4 mb-
|
| 259 |
-
<button onClick={() =>
|
| 260 |
-
<Trophy className="mr-2"/>
|
| 261 |
</button>
|
| 262 |
-
<button onClick={() =>
|
| 263 |
-
<
|
| 264 |
</button>
|
| 265 |
</div>
|
| 266 |
|
| 267 |
-
{/* ---
|
| 268 |
-
{
|
| 269 |
-
<div className="animate-in fade-in
|
| 270 |
-
|
| 271 |
-
<
|
| 272 |
-
|
| 273 |
-
|
|
|
|
|
|
|
|
|
|
| 274 |
</button>
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
<div className="
|
| 279 |
-
<
|
| 280 |
-
|
| 281 |
-
<
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 293 |
</div>
|
| 294 |
</div>
|
| 295 |
)}
|
| 296 |
|
| 297 |
-
{/* ---
|
| 298 |
-
{
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 305 |
</div>
|
| 306 |
)}
|
| 307 |
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
<div className="
|
| 313 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 314 |
</div>
|
| 315 |
|
| 316 |
-
{/* Grid Layout for Cards */}
|
| 317 |
<div className="grid grid-cols-3 gap-4 w-full max-w-md">
|
| 318 |
{Array.from({ length: 9 }).map((_, i) => (
|
| 319 |
<FlipCard
|
|
@@ -325,35 +441,17 @@ export const Games: React.FC = () => {
|
|
| 325 |
))}
|
| 326 |
</div>
|
| 327 |
</div>
|
| 328 |
-
|
| 329 |
-
{/* Rewards List */}
|
| 330 |
-
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 p-6 h-fit">
|
| 331 |
-
<h3 className="font-bold text-gray-800 mb-4 flex items-center"><Star className="text-yellow-400 mr-2"/> 我的战利品</h3>
|
| 332 |
-
<div className="space-y-3 max-h-[500px] overflow-y-auto custom-scrollbar pr-2">
|
| 333 |
-
{myRewards.length > 0 ? myRewards.map(r => (
|
| 334 |
-
<div key={r._id} className="flex justify-between items-center p-3 bg-gray-50 rounded-lg border border-gray-100">
|
| 335 |
-
<div>
|
| 336 |
-
<div className="font-bold text-gray-800 text-sm">{r.name}</div>
|
| 337 |
-
<div className="text-xs text-gray-400">{new Date(r.createTime).toLocaleDateString()} · {r.source}</div>
|
| 338 |
-
</div>
|
| 339 |
-
{r.status === 'REDEEMED'
|
| 340 |
-
? <span className="text-xs bg-gray-200 text-gray-500 px-2 py-1 rounded">已兑换</span>
|
| 341 |
-
: <span className="text-xs bg-green-100 text-green-700 px-2 py-1 rounded cursor-pointer border border-green-200">未兑换</span>
|
| 342 |
-
}
|
| 343 |
-
</div>
|
| 344 |
-
)) : <div className="text-center text-gray-400 py-10">暂无奖品,去登山吧!</div>}
|
| 345 |
-
</div>
|
| 346 |
-
</div>
|
| 347 |
-
</div>
|
| 348 |
</div>
|
|
|
|
| 349 |
)}
|
| 350 |
|
| 351 |
{/* --- MOUNTAIN SETTINGS MODAL --- */}
|
| 352 |
{isMtSettingsOpen && session && (
|
| 353 |
<div className="fixed inset-0 bg-black/60 z-[100] flex items-center justify-center p-4 backdrop-blur-sm">
|
| 354 |
-
<div className="bg-white rounded-2xl w-full max-w-4xl h-[85vh] flex flex-col shadow-2xl">
|
| 355 |
<div className="p-6 border-b border-gray-100 flex justify-between items-center">
|
| 356 |
-
<h3 className="text-xl font-bold text-gray-800">群岳争锋 -
|
| 357 |
<button onClick={() => setIsMtSettingsOpen(false)}><X size={24} className="text-gray-400 hover:text-gray-600"/></button>
|
| 358 |
</div>
|
| 359 |
|
|
@@ -381,9 +479,9 @@ export const Games: React.FC = () => {
|
|
| 381 |
}} className="text-xs bg-blue-100 text-blue-600 px-2 py-1 rounded hover:bg-blue-200">+ 新建队伍</button>
|
| 382 |
</div>
|
| 383 |
|
| 384 |
-
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
| 385 |
{/* Left: Team List */}
|
| 386 |
-
<div className="space-y-2
|
| 387 |
{session.teams.map(t => (
|
| 388 |
<div key={t.id}
|
| 389 |
className={`p-3 rounded-lg border cursor-pointer transition-all ${selectedTeamId === t.id ? 'border-blue-500 bg-blue-50 shadow-sm' : 'border-gray-200 hover:bg-gray-50'}`}
|
|
@@ -393,7 +491,7 @@ export const Games: React.FC = () => {
|
|
| 393 |
<input value={t.name} onChange={e => {
|
| 394 |
const updated = session.teams.map(tm => tm.id === t.id ? {...tm, name: e.target.value} : tm);
|
| 395 |
setSession({...session, teams: updated});
|
| 396 |
-
}} className="bg-transparent font-bold text-sm w-24 outline-none border-b border-transparent focus:border-blue-300" />
|
| 397 |
<button onClick={(e) => {
|
| 398 |
e.stopPropagation();
|
| 399 |
if(confirm('删除队伍?')) setSession({...session, teams: session.teams.filter(tm => tm.id !== t.id)});
|
|
@@ -403,11 +501,11 @@ export const Games: React.FC = () => {
|
|
| 403 |
<input type="color" value={t.color} onChange={e => {
|
| 404 |
const updated = session.teams.map(tm => tm.id === t.id ? {...tm, color: e.target.value} : tm);
|
| 405 |
setSession({...session, teams: updated});
|
| 406 |
-
}} className="w-6 h-6 p-0 border-0 rounded overflow-hidden" />
|
| 407 |
<input value={t.avatar} onChange={e => {
|
| 408 |
const updated = session.teams.map(tm => tm.id === t.id ? {...tm, avatar: e.target.value} : tm);
|
| 409 |
setSession({...session, teams: updated});
|
| 410 |
-
}} className="w-8 border rounded text-center text-sm" />
|
| 411 |
<span className="text-xs text-gray-400 self-center ml-auto">{t.members.length} 人</span>
|
| 412 |
</div>
|
| 413 |
</div>
|
|
@@ -415,11 +513,12 @@ export const Games: React.FC = () => {
|
|
| 415 |
</div>
|
| 416 |
|
| 417 |
{/* Right: Member Shuttle */}
|
| 418 |
-
<div className="col-span-2 bg-gray-50 rounded-xl border border-gray-200 p-4 flex flex-col
|
| 419 |
{selectedTeamId ? (
|
| 420 |
<>
|
| 421 |
-
<div className="text-sm font-bold text-gray-700 mb-2 border-b pb-2">
|
| 422 |
-
配置 [{session.teams.find(t => t.id === selectedTeamId)?.name}]
|
|
|
|
| 423 |
</div>
|
| 424 |
<div className="flex-1 overflow-y-auto grid grid-cols-3 gap-2 content-start">
|
| 425 |
{students.map(s => {
|
|
@@ -429,14 +528,14 @@ export const Games: React.FC = () => {
|
|
| 429 |
|
| 430 |
return (
|
| 431 |
<div key={s._id}
|
| 432 |
-
onClick={() => toggleTeamMember(s._id || String(s.id), selectedTeamId)}
|
| 433 |
-
className={`text-xs p-2 rounded border cursor-pointer flex items-center justify-between ${
|
| 434 |
isInCurrent ? 'bg-green-100 border-green-300 text-green-800' :
|
| 435 |
-
isInOther ? 'bg-gray-100 border-gray-200 text-gray-400 opacity-
|
| 436 |
}`}
|
| 437 |
>
|
| 438 |
<span className="truncate">{s.name}</span>
|
| 439 |
-
{isInCurrent && <
|
| 440 |
</div>
|
| 441 |
);
|
| 442 |
})}
|
|
@@ -461,7 +560,7 @@ export const Games: React.FC = () => {
|
|
| 461 |
{/* --- LUCKY SETTINGS MODAL --- */}
|
| 462 |
{isLuckySettingsOpen && luckyConfig && (
|
| 463 |
<div className="fixed inset-0 bg-black/60 z-[100] flex items-center justify-center p-4 backdrop-blur-sm">
|
| 464 |
-
<div className="bg-white rounded-2xl w-full max-w-2xl h-[80vh] flex flex-col shadow-2xl">
|
| 465 |
<div className="p-6 border-b border-gray-100 flex justify-between items-center">
|
| 466 |
<h3 className="text-xl font-bold text-gray-800">奖池配置</h3>
|
| 467 |
<button onClick={() => setIsLuckySettingsOpen(false)}><X size={24} className="text-gray-400 hover:text-gray-600"/></button>
|
|
@@ -524,8 +623,32 @@ export const Games: React.FC = () => {
|
|
| 524 |
</div>
|
| 525 |
</div>
|
| 526 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 527 |
</div>
|
| 528 |
);
|
| 529 |
};
|
| 530 |
-
|
| 531 |
-
const CheckIcon = () => <svg className="w-4 h-4 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /></svg>;
|
|
|
|
| 2 |
import React, { useState, useEffect } from 'react';
|
| 3 |
import { api } from '../services/api';
|
| 4 |
import { GameSession, GameTeam, Student, StudentReward, LuckyDrawConfig, LuckyPrize } from '../types';
|
| 5 |
+
import { Trophy, Gift, Settings, Plus, Minus, Users, CheckSquare, Loader2, Save, Trash2, X, Star, CreditCard } from 'lucide-react';
|
| 6 |
|
| 7 |
// --- Components ---
|
| 8 |
|
|
|
|
| 10 |
return (
|
| 11 |
<div className="relative w-full aspect-[3/4] cursor-pointer perspective-1000 group" onClick={!isRevealed ? onFlip : undefined}>
|
| 12 |
<div className={`relative w-full h-full text-center transition-transform duration-700 transform-style-3d shadow-xl rounded-xl ${isRevealed ? 'rotate-y-180' : 'group-hover:-translate-y-2'}`}>
|
| 13 |
+
{/* Front */}
|
| 14 |
<div className="absolute w-full h-full backface-hidden bg-gradient-to-br from-red-500 to-red-700 rounded-xl flex flex-col items-center justify-center border-4 border-yellow-300 shadow-inner">
|
| 15 |
<div className="w-12 h-12 bg-yellow-200 rounded-full flex items-center justify-center mb-2 shadow-md border-2 border-yellow-400">
|
| 16 |
<span className="text-xl">🧧</span>
|
| 17 |
</div>
|
| 18 |
<span className="text-yellow-100 font-bold text-lg tracking-widest">開</span>
|
| 19 |
</div>
|
| 20 |
+
{/* Back */}
|
| 21 |
<div className="absolute w-full h-full backface-hidden bg-white rounded-xl flex flex-col items-center justify-center border-2 border-red-200 rotate-y-180 shadow-inner p-2">
|
| 22 |
<span className="text-3xl mb-2">🎁</span>
|
| 23 |
<span className="text-red-600 font-bold text-sm break-words leading-tight">{prize}</span>
|
|
|
|
| 32 |
const bottomPos = 5 + (percentage * 85);
|
| 33 |
|
| 34 |
return (
|
| 35 |
+
<div className="relative flex flex-col items-center justify-end h-[350px] w-32 md:w-40 mx-2 flex-shrink-0 select-none group">
|
| 36 |
<div className="absolute -top-12 text-center w-[140%] z-20 transition-transform hover:-translate-y-1">
|
| 37 |
+
<h3 className="text-sm font-black text-slate-800 bg-white/90 px-2 py-1 rounded-lg shadow-sm border border-white/60 truncate max-w-full">
|
| 38 |
{team.name}
|
| 39 |
</h3>
|
| 40 |
</div>
|
| 41 |
|
| 42 |
+
{/* Mountain SVG */}
|
| 43 |
<div className="absolute bottom-0 left-0 w-full h-full z-0 overflow-visible filter drop-shadow-md">
|
| 44 |
<svg viewBox="0 0 200 500" preserveAspectRatio="none" className="w-full h-full">
|
| 45 |
<path d="M100 20 L 190 500 L 10 500 Z" fill={index % 2 === 0 ? '#4ade80' : '#22c55e'} stroke="#15803d" strokeWidth="2" />
|
|
|
|
| 47 |
</svg>
|
| 48 |
</div>
|
| 49 |
|
| 50 |
+
{/* Ladder */}
|
| 51 |
+
<div className="absolute bottom-0 w-8 h-[90%] z-10 flex flex-col-reverse justify-between items-center py-4">
|
| 52 |
{Array.from({ length: maxSteps + 1 }).map((_, i) => {
|
| 53 |
const reward = rewardsConfig.find(r => r.scoreThreshold === i);
|
| 54 |
const isUnlocked = team.score >= i;
|
| 55 |
return (
|
| 56 |
<div key={i} className="relative w-full h-full flex items-center justify-center">
|
| 57 |
+
<div className="w-full h-0.5 bg-amber-700/30 rounded-sm"></div>
|
| 58 |
{reward && (
|
| 59 |
+
<div className={`absolute left-full ml-2 px-1.5 py-0.5 rounded text-[9px] font-bold whitespace-nowrap border z-20 ${isUnlocked ? 'bg-yellow-100 border-yellow-300 text-yellow-700' : 'bg-gray-100 border-gray-200 text-gray-400'}`}>
|
| 60 |
{reward.rewardType === 'DRAW_COUNT' ? '🎲' : '🎁'} {reward.rewardName}
|
| 61 |
</div>
|
| 62 |
)}
|
|
|
|
| 67 |
|
| 68 |
{/* Climber */}
|
| 69 |
<div className="absolute z-30 transition-all duration-700 ease-out flex flex-col items-center" style={{ bottom: `${bottomPos}%` }}>
|
| 70 |
+
<div className="w-10 h-10 bg-white rounded-full border-2 shadow-lg flex items-center justify-center transform hover:scale-110 transition-transform" style={{ borderColor: team.color }}>
|
| 71 |
+
<span className="text-xl">{team.avatar || '🚩'}</span>
|
| 72 |
+
<div className="absolute -top-1 -right-1 bg-red-500 text-white text-[9px] font-bold w-4 h-4 rounded-full flex items-center justify-center border border-white">
|
| 73 |
{team.score}
|
| 74 |
</div>
|
| 75 |
</div>
|
|
|
|
| 81 |
// --- Main Page ---
|
| 82 |
|
| 83 |
export const Games: React.FC = () => {
|
| 84 |
+
const [activeTab, setActiveTab] = useState<'games' | 'rewards'>('games');
|
| 85 |
const [activeGame, setActiveGame] = useState<'mountain' | 'lucky'>('mountain');
|
| 86 |
+
|
| 87 |
const [session, setSession] = useState<GameSession | null>(null);
|
| 88 |
const [luckyConfig, setLuckyConfig] = useState<LuckyDrawConfig | null>(null);
|
| 89 |
const [myRewards, setMyRewards] = useState<StudentReward[]>([]);
|
| 90 |
+
const [allRewards, setAllRewards] = useState<StudentReward[]>([]);
|
| 91 |
const [studentInfo, setStudentInfo] = useState<Student | null>(null);
|
| 92 |
const [loading, setLoading] = useState(true);
|
| 93 |
|
| 94 |
// Teacher Controls State
|
| 95 |
const [isMtSettingsOpen, setIsMtSettingsOpen] = useState(false);
|
| 96 |
const [isLuckySettingsOpen, setIsLuckySettingsOpen] = useState(false);
|
| 97 |
+
const [isGrantModalOpen, setIsGrantModalOpen] = useState(false);
|
| 98 |
const [students, setStudents] = useState<Student[]>([]);
|
| 99 |
const [selectedTeamId, setSelectedTeamId] = useState<string | null>(null); // For member management
|
| 100 |
+
const [grantForm, setGrantForm] = useState({ studentId: '', count: 1 });
|
| 101 |
|
| 102 |
// Lucky Draw State
|
| 103 |
const [drawResult, setDrawResult] = useState<{prize: string} | null>(null);
|
|
|
|
| 105 |
|
| 106 |
const currentUser = api.auth.getCurrentUser();
|
| 107 |
const isTeacher = currentUser?.role === 'TEACHER';
|
| 108 |
+
const isStudent = currentUser?.role === 'STUDENT';
|
| 109 |
|
| 110 |
useEffect(() => {
|
| 111 |
loadData();
|
| 112 |
+
}, [activeGame, activeTab]);
|
| 113 |
|
| 114 |
const loadData = async () => {
|
| 115 |
setLoading(true);
|
| 116 |
try {
|
| 117 |
if (!currentUser) return;
|
| 118 |
|
| 119 |
+
const stus = await api.students.getAll();
|
| 120 |
+
|
| 121 |
let targetClass = '';
|
| 122 |
+
if (isTeacher && currentUser.homeroomClass) {
|
| 123 |
+
targetClass = currentUser.homeroomClass;
|
| 124 |
+
// Filter students for Teacher View (Only their class)
|
| 125 |
+
setStudents(stus.filter((s: Student) => s.className === targetClass));
|
| 126 |
+
} else if (isStudent) {
|
| 127 |
const me = stus.find((s: Student) => s.name === (currentUser.trueName || currentUser.username));
|
| 128 |
if (me) {
|
| 129 |
setStudentInfo(me);
|
| 130 |
targetClass = me.className;
|
| 131 |
}
|
| 132 |
+
} else if (currentUser.role === 'ADMIN') {
|
| 133 |
+
setStudents(stus); // Admin sees all
|
| 134 |
}
|
| 135 |
|
| 136 |
if (targetClass) {
|
|
|
|
| 162 |
const lCfg = await api.games.getLuckyConfig();
|
| 163 |
setLuckyConfig(lCfg);
|
| 164 |
|
| 165 |
+
// Load Rewards
|
| 166 |
+
if (isStudent && studentInfo && studentInfo._id) {
|
| 167 |
const rews = await api.rewards.getMyRewards(studentInfo._id);
|
| 168 |
setMyRewards(rews);
|
| 169 |
+
} else if (isTeacher || currentUser.role === 'ADMIN') {
|
| 170 |
+
// Teacher View: Get class rewards (Backend allows filtering or we filter here)
|
| 171 |
+
// Assuming api.rewards.getAll returns all for admin/teacher scope
|
| 172 |
+
const all = await api.rewards.getMyRewards(''); // Hack: empty returns all based on logic or need new endpoint
|
| 173 |
+
// Filter rewards for students in this class
|
| 174 |
+
if (isTeacher && currentUser.homeroomClass) {
|
| 175 |
+
// We need to fetch all rewards first then filter
|
| 176 |
+
// Better to use dedicated endpoint in real app
|
| 177 |
+
setAllRewards(all); // Temporary showing all for prototype or implementation detail
|
| 178 |
+
} else {
|
| 179 |
+
setAllRewards(all);
|
| 180 |
+
}
|
| 181 |
}
|
| 182 |
|
| 183 |
} catch (e) { console.error(e); }
|
|
|
|
| 195 |
if (delta > 0) {
|
| 196 |
const reward = session.rewardsConfig.find(r => r.scoreThreshold === newScore);
|
| 197 |
if (reward) {
|
| 198 |
+
// Grant rewards
|
| 199 |
t.members.forEach(stuId => {
|
| 200 |
+
// Find student detail from the full list loaded
|
| 201 |
const stu = students.find(s => (s._id || s.id) == stuId);
|
| 202 |
if (stu) {
|
| 203 |
api.rewards.addReward({
|
|
|
|
| 207 |
rewardType: reward.rewardType as any,
|
| 208 |
name: reward.rewardName,
|
| 209 |
status: 'PENDING',
|
| 210 |
+
source: `群岳争锋 - ${t.name} ${newScore}分`
|
| 211 |
});
|
| 212 |
}
|
| 213 |
});
|
| 214 |
+
alert(`🎉 ${t.name} 达到 ${newScore} 分!已为组员发放 [${reward.rewardName}]!`);
|
| 215 |
}
|
| 216 |
}
|
| 217 |
return { ...t, score: newScore };
|
|
|
|
| 231 |
if (!session) return;
|
| 232 |
const newTeams = session.teams.map(t => {
|
| 233 |
const members = new Set(t.members);
|
| 234 |
+
|
| 235 |
if (t.id === teamId) {
|
| 236 |
+
// Toggle Logic: If exists, remove. If not, add.
|
| 237 |
+
if (members.has(studentId)) {
|
| 238 |
+
members.delete(studentId);
|
| 239 |
+
} else {
|
| 240 |
+
members.add(studentId);
|
| 241 |
+
}
|
| 242 |
} else {
|
| 243 |
+
// Ensure student is removed from other teams (Exclusive)
|
| 244 |
+
if (members.has(studentId)) {
|
| 245 |
+
members.delete(studentId);
|
| 246 |
+
}
|
| 247 |
}
|
| 248 |
return { ...t, members: Array.from(members) };
|
| 249 |
});
|
|
|
|
| 254 |
|
| 255 |
const handleDraw = async () => {
|
| 256 |
if (!studentInfo || !luckyConfig || isFlipping) return;
|
| 257 |
+
if ((studentInfo.drawAttempts || 0) <= 0) return alert('抽奖次数不足!请通过“群岳争锋”游戏或老师奖励获取。');
|
| 258 |
|
| 259 |
setIsFlipping(true);
|
| 260 |
try {
|
|
|
|
| 278 |
setIsLuckySettingsOpen(false);
|
| 279 |
};
|
| 280 |
|
| 281 |
+
const handleGrantDraw = async () => {
|
| 282 |
+
if(!grantForm.studentId) return alert('请选择学生');
|
| 283 |
+
try {
|
| 284 |
+
await api.games.grantDrawCount(grantForm.studentId, grantForm.count);
|
| 285 |
+
setIsGrantModalOpen(false);
|
| 286 |
+
alert('发放成功');
|
| 287 |
+
loadData();
|
| 288 |
+
} catch(e) { alert('发放失败'); }
|
| 289 |
+
};
|
| 290 |
|
| 291 |
+
const handleRedeem = async (id: string) => {
|
| 292 |
+
if(confirm('确认标记为已核销/已兑换?')) {
|
| 293 |
+
await api.rewards.redeem(id);
|
| 294 |
+
loadData();
|
| 295 |
+
}
|
| 296 |
+
};
|
| 297 |
+
|
| 298 |
+
if (loading) return <div className="p-10 text-center"><Loader2 className="animate-spin inline"/></div>;
|
| 299 |
|
| 300 |
return (
|
| 301 |
+
<div className="flex flex-col h-[calc(100vh-120px)]">
|
| 302 |
+
{/* Top Tabs */}
|
| 303 |
+
<div className="flex justify-center space-x-4 mb-4 shrink-0">
|
| 304 |
+
<button onClick={() => setActiveTab('games')} className={`px-6 py-2 rounded-full font-bold flex items-center transition-all ${activeTab === 'games' ? 'bg-blue-600 text-white shadow-md' : 'bg-white text-gray-500 hover:bg-gray-50'}`}>
|
| 305 |
+
<Trophy className="mr-2" size={18}/> 互动游戏
|
| 306 |
</button>
|
| 307 |
+
<button onClick={() => setActiveTab('rewards')} className={`px-6 py-2 rounded-full font-bold flex items-center transition-all ${activeTab === 'rewards' ? 'bg-amber-500 text-white shadow-md' : 'bg-white text-gray-500 hover:bg-gray-50'}`}>
|
| 308 |
+
<Star className="mr-2" size={18}/> 奖励管理
|
| 309 |
</button>
|
| 310 |
</div>
|
| 311 |
|
| 312 |
+
{/* --- REWARDS MANAGEMENT TAB --- */}
|
| 313 |
+
{activeTab === 'rewards' && (
|
| 314 |
+
<div className="flex-1 overflow-y-auto bg-white rounded-2xl shadow-sm border border-gray-100 p-6 animate-in fade-in">
|
| 315 |
+
<div className="flex justify-between items-center mb-6">
|
| 316 |
+
<h3 className="text-xl font-bold text-gray-800">
|
| 317 |
+
{isStudent ? '我的战利品清单' : '班级奖励核销台'}
|
| 318 |
+
</h3>
|
| 319 |
+
{!isStudent && (
|
| 320 |
+
<button onClick={() => setIsGrantModalOpen(true)} className="flex items-center px-4 py-2 bg-amber-100 text-amber-700 rounded-lg font-bold hover:bg-amber-200">
|
| 321 |
+
<Gift size={18} className="mr-2"/> 手动发放抽奖券
|
| 322 |
</button>
|
| 323 |
+
)}
|
| 324 |
+
</div>
|
| 325 |
+
|
| 326 |
+
<div className="overflow-x-auto">
|
| 327 |
+
<table className="w-full text-left">
|
| 328 |
+
<thead className="bg-gray-50 text-gray-500 text-xs uppercase">
|
| 329 |
+
<tr>
|
| 330 |
+
{!isStudent && <th className="p-4">学生姓名</th>}
|
| 331 |
+
<th className="p-4">奖品名称</th>
|
| 332 |
+
<th className="p-4">类型</th>
|
| 333 |
+
<th className="p-4">来源</th>
|
| 334 |
+
<th className="p-4">获得时间</th>
|
| 335 |
+
<th className="p-4">状态</th>
|
| 336 |
+
{!isStudent && <th className="p-4 text-right">操作</th>}
|
| 337 |
+
</tr>
|
| 338 |
+
</thead>
|
| 339 |
+
<tbody className="divide-y divide-gray-100">
|
| 340 |
+
{(isStudent ? myRewards : allRewards).map(r => (
|
| 341 |
+
<tr key={r._id} className="hover:bg-gray-50">
|
| 342 |
+
{!isStudent && <td className="p-4 font-bold text-gray-700">{r.studentName}</td>}
|
| 343 |
+
<td className="p-4 font-medium text-gray-800">{r.name}</td>
|
| 344 |
+
<td className="p-4"><span className={`text-xs px-2 py-1 rounded ${r.rewardType === 'DRAW_COUNT' ? 'bg-purple-100 text-purple-700' : 'bg-blue-100 text-blue-700'}`}>{r.rewardType==='DRAW_COUNT' ? '抽奖券' : '实物'}</span></td>
|
| 345 |
+
<td className="p-4 text-gray-500 text-sm">{r.source}</td>
|
| 346 |
+
<td className="p-4 text-gray-500 text-sm">{new Date(r.createTime).toLocaleDateString()}</td>
|
| 347 |
+
<td className="p-4">
|
| 348 |
+
{r.status === 'REDEEMED'
|
| 349 |
+
? <span className="text-xs bg-gray-200 text-gray-500 px-2 py-1 rounded">已兑换</span>
|
| 350 |
+
: <span className="text-xs bg-green-100 text-green-700 px-2 py-1 rounded">未兑换</span>
|
| 351 |
+
}
|
| 352 |
+
</td>
|
| 353 |
+
{!isStudent && (
|
| 354 |
+
<td className="p-4 text-right">
|
| 355 |
+
{r.status !== 'REDEEMED' && (
|
| 356 |
+
<button onClick={() => handleRedeem(r._id!)} className="text-xs bg-green-600 text-white px-3 py-1.5 rounded hover:bg-green-700">核销</button>
|
| 357 |
+
)}
|
| 358 |
+
</td>
|
| 359 |
+
)}
|
| 360 |
+
</tr>
|
| 361 |
+
))}
|
| 362 |
+
{(isStudent ? myRewards : allRewards).length === 0 && (
|
| 363 |
+
<tr><td colSpan={7} className="text-center py-10 text-gray-400">暂无记录</td></tr>
|
| 364 |
+
)}
|
| 365 |
+
</tbody>
|
| 366 |
+
</table>
|
| 367 |
</div>
|
| 368 |
</div>
|
| 369 |
)}
|
| 370 |
|
| 371 |
+
{/* --- GAMES TAB --- */}
|
| 372 |
+
{activeTab === 'games' && (
|
| 373 |
+
<div className="flex-1 flex flex-col min-h-0 animate-in fade-in">
|
| 374 |
+
{/* Sub Switcher */}
|
| 375 |
+
<div className="flex justify-center space-x-2 mb-4 shrink-0">
|
| 376 |
+
<button onClick={() => setActiveGame('mountain')} className={`px-4 py-1.5 rounded-lg text-sm font-bold transition-all ${activeGame === 'mountain' ? 'bg-sky-100 text-sky-700 border border-sky-200' : 'text-gray-500 hover:bg-gray-100'}`}>
|
| 377 |
+
群岳争锋
|
| 378 |
+
</button>
|
| 379 |
+
<button onClick={() => setActiveGame('lucky')} className={`px-4 py-1.5 rounded-lg text-sm font-bold transition-all ${activeGame === 'lucky' ? 'bg-pink-100 text-pink-700 border border-pink-200' : 'text-gray-500 hover:bg-gray-100'}`}>
|
| 380 |
+
幸运红包
|
| 381 |
+
</button>
|
| 382 |
+
</div>
|
| 383 |
+
|
| 384 |
+
<div className="flex-1 bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden relative">
|
| 385 |
+
{/* Mountain Game */}
|
| 386 |
+
{activeGame === 'mountain' && session && (
|
| 387 |
+
<div className="h-full flex flex-col">
|
| 388 |
+
{isTeacher && (
|
| 389 |
+
<div className="absolute top-4 right-4 z-10">
|
| 390 |
+
<button onClick={() => setIsMtSettingsOpen(true)} className="flex items-center text-xs font-bold text-slate-600 bg-white/80 backdrop-blur px-3 py-1.5 rounded-lg border border-slate-200 hover:bg-slate-50 shadow-sm">
|
| 391 |
+
<Settings size={14} className="mr-1"/> 设置/管理
|
| 392 |
+
</button>
|
| 393 |
+
</div>
|
| 394 |
+
)}
|
| 395 |
+
|
| 396 |
+
<div className="flex-1 overflow-x-auto overflow-y-hidden bg-gradient-to-b from-sky-200 to-white relative">
|
| 397 |
+
<div className="h-full flex items-end min-w-max px-10 pb-10 gap-6 mx-auto">
|
| 398 |
+
{session.teams.map((team, idx) => (
|
| 399 |
+
<div key={team.id} className="relative group">
|
| 400 |
+
<MountainStage team={team} index={idx} rewardsConfig={session.rewardsConfig} maxSteps={session.maxSteps} />
|
| 401 |
+
{isTeacher && (
|
| 402 |
+
<div className="absolute -bottom-8 left-1/2 -translate-x-1/2 flex items-center bg-white rounded-full shadow-lg p-1 border border-gray-100 opacity-0 group-hover:opacity-100 transition-opacity z-20">
|
| 403 |
+
<button onClick={() => handleScoreChange(team.id, -1)} className="p-1 hover:bg-gray-100 rounded-full text-gray-500"><Minus size={14}/></button>
|
| 404 |
+
<span className="w-6 text-center font-bold text-gray-700 text-xs">{team.score}</span>
|
| 405 |
+
<button onClick={() => handleScoreChange(team.id, 1)} className="p-1 hover:bg-blue-50 rounded-full text-blue-600"><Plus size={14}/></button>
|
| 406 |
+
</div>
|
| 407 |
+
)}
|
| 408 |
+
</div>
|
| 409 |
+
))}
|
| 410 |
+
</div>
|
| 411 |
+
</div>
|
| 412 |
</div>
|
| 413 |
)}
|
| 414 |
|
| 415 |
+
{/* Lucky Game */}
|
| 416 |
+
{activeGame === 'lucky' && luckyConfig && (
|
| 417 |
+
<div className="h-full overflow-y-auto p-6 md:p-10 flex flex-col items-center">
|
| 418 |
+
{isTeacher && (
|
| 419 |
+
<div className="absolute top-4 right-4">
|
| 420 |
+
<button onClick={() => setIsLuckySettingsOpen(true)} className="flex items-center text-xs font-bold text-slate-600 bg-gray-100 px-3 py-1.5 rounded-lg border hover:bg-gray-200">
|
| 421 |
+
<Settings size={14} className="mr-1"/> 奖池配置
|
| 422 |
+
</button>
|
| 423 |
+
</div>
|
| 424 |
+
)}
|
| 425 |
+
|
| 426 |
+
<div className="bg-yellow-50 p-4 rounded-2xl shadow-sm border border-yellow-100 w-full max-w-md mb-8 text-center relative overflow-hidden">
|
| 427 |
+
<div className="absolute top-0 right-0 p-2 opacity-10"><Gift size={64}/></div>
|
| 428 |
+
<h3 className="text-xl font-bold text-yellow-800 mb-1">我的抽奖券</h3>
|
| 429 |
+
<div className="text-4xl font-black text-amber-500 mb-1">{studentInfo?.drawAttempts || 0}</div>
|
| 430 |
+
<p className="text-[10px] text-yellow-600 opacity-70">每日上限 {luckyConfig.dailyLimit} 次 | 每次消耗 1 张</p>
|
| 431 |
</div>
|
| 432 |
|
|
|
|
| 433 |
<div className="grid grid-cols-3 gap-4 w-full max-w-md">
|
| 434 |
{Array.from({ length: 9 }).map((_, i) => (
|
| 435 |
<FlipCard
|
|
|
|
| 441 |
))}
|
| 442 |
</div>
|
| 443 |
</div>
|
| 444 |
+
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 445 |
</div>
|
| 446 |
+
</div>
|
| 447 |
)}
|
| 448 |
|
| 449 |
{/* --- MOUNTAIN SETTINGS MODAL --- */}
|
| 450 |
{isMtSettingsOpen && session && (
|
| 451 |
<div className="fixed inset-0 bg-black/60 z-[100] flex items-center justify-center p-4 backdrop-blur-sm">
|
| 452 |
+
<div className="bg-white rounded-2xl w-full max-w-4xl h-[85vh] flex flex-col shadow-2xl animate-in zoom-in-95">
|
| 453 |
<div className="p-6 border-b border-gray-100 flex justify-between items-center">
|
| 454 |
+
<h3 className="text-xl font-bold text-gray-800">群岳争锋 - 设置与管理</h3>
|
| 455 |
<button onClick={() => setIsMtSettingsOpen(false)}><X size={24} className="text-gray-400 hover:text-gray-600"/></button>
|
| 456 |
</div>
|
| 457 |
|
|
|
|
| 479 |
}} className="text-xs bg-blue-100 text-blue-600 px-2 py-1 rounded hover:bg-blue-200">+ 新建队伍</button>
|
| 480 |
</div>
|
| 481 |
|
| 482 |
+
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 h-96">
|
| 483 |
{/* Left: Team List */}
|
| 484 |
+
<div className="space-y-2 overflow-y-auto pr-2 border rounded-lg p-2">
|
| 485 |
{session.teams.map(t => (
|
| 486 |
<div key={t.id}
|
| 487 |
className={`p-3 rounded-lg border cursor-pointer transition-all ${selectedTeamId === t.id ? 'border-blue-500 bg-blue-50 shadow-sm' : 'border-gray-200 hover:bg-gray-50'}`}
|
|
|
|
| 491 |
<input value={t.name} onChange={e => {
|
| 492 |
const updated = session.teams.map(tm => tm.id === t.id ? {...tm, name: e.target.value} : tm);
|
| 493 |
setSession({...session, teams: updated});
|
| 494 |
+
}} className="bg-transparent font-bold text-sm w-24 outline-none border-b border-transparent focus:border-blue-300" onClick={e=>e.stopPropagation()}/>
|
| 495 |
<button onClick={(e) => {
|
| 496 |
e.stopPropagation();
|
| 497 |
if(confirm('删除队伍?')) setSession({...session, teams: session.teams.filter(tm => tm.id !== t.id)});
|
|
|
|
| 501 |
<input type="color" value={t.color} onChange={e => {
|
| 502 |
const updated = session.teams.map(tm => tm.id === t.id ? {...tm, color: e.target.value} : tm);
|
| 503 |
setSession({...session, teams: updated});
|
| 504 |
+
}} className="w-6 h-6 p-0 border-0 rounded overflow-hidden" onClick={e=>e.stopPropagation()}/>
|
| 505 |
<input value={t.avatar} onChange={e => {
|
| 506 |
const updated = session.teams.map(tm => tm.id === t.id ? {...tm, avatar: e.target.value} : tm);
|
| 507 |
setSession({...session, teams: updated});
|
| 508 |
+
}} className="w-8 border rounded text-center text-sm" onClick={e=>e.stopPropagation()}/>
|
| 509 |
<span className="text-xs text-gray-400 self-center ml-auto">{t.members.length} 人</span>
|
| 510 |
</div>
|
| 511 |
</div>
|
|
|
|
| 513 |
</div>
|
| 514 |
|
| 515 |
{/* Right: Member Shuttle */}
|
| 516 |
+
<div className="col-span-2 bg-gray-50 rounded-xl border border-gray-200 p-4 flex flex-col">
|
| 517 |
{selectedTeamId ? (
|
| 518 |
<>
|
| 519 |
+
<div className="text-sm font-bold text-gray-700 mb-2 border-b pb-2 flex justify-between">
|
| 520 |
+
<span>配置 [{session.teams.find(t => t.id === selectedTeamId)?.name}] 成员</span>
|
| 521 |
+
<span className="text-xs text-gray-400">点击勾选/取消</span>
|
| 522 |
</div>
|
| 523 |
<div className="flex-1 overflow-y-auto grid grid-cols-3 gap-2 content-start">
|
| 524 |
{students.map(s => {
|
|
|
|
| 528 |
|
| 529 |
return (
|
| 530 |
<div key={s._id}
|
| 531 |
+
onClick={() => !isInOther && toggleTeamMember(s._id || String(s.id), selectedTeamId)}
|
| 532 |
+
className={`text-xs p-2 rounded border cursor-pointer flex items-center justify-between transition-colors ${
|
| 533 |
isInCurrent ? 'bg-green-100 border-green-300 text-green-800' :
|
| 534 |
+
isInOther ? 'bg-gray-100 border-gray-200 text-gray-400 opacity-50 cursor-not-allowed' : 'bg-white border-gray-300 hover:border-blue-400'
|
| 535 |
}`}
|
| 536 |
>
|
| 537 |
<span className="truncate">{s.name}</span>
|
| 538 |
+
{isInCurrent && <CheckSquare size={12}/>}
|
| 539 |
</div>
|
| 540 |
);
|
| 541 |
})}
|
|
|
|
| 560 |
{/* --- LUCKY SETTINGS MODAL --- */}
|
| 561 |
{isLuckySettingsOpen && luckyConfig && (
|
| 562 |
<div className="fixed inset-0 bg-black/60 z-[100] flex items-center justify-center p-4 backdrop-blur-sm">
|
| 563 |
+
<div className="bg-white rounded-2xl w-full max-w-2xl h-[80vh] flex flex-col shadow-2xl animate-in zoom-in-95">
|
| 564 |
<div className="p-6 border-b border-gray-100 flex justify-between items-center">
|
| 565 |
<h3 className="text-xl font-bold text-gray-800">奖池配置</h3>
|
| 566 |
<button onClick={() => setIsLuckySettingsOpen(false)}><X size={24} className="text-gray-400 hover:text-gray-600"/></button>
|
|
|
|
| 623 |
</div>
|
| 624 |
</div>
|
| 625 |
)}
|
| 626 |
+
|
| 627 |
+
{/* --- MANUAL GRANT MODAL --- */}
|
| 628 |
+
{isGrantModalOpen && (
|
| 629 |
+
<div className="fixed inset-0 bg-black/50 z-[100] flex items-center justify-center p-4">
|
| 630 |
+
<div className="bg-white rounded-xl p-6 w-full max-w-sm animate-in fade-in">
|
| 631 |
+
<h3 className="font-bold text-lg mb-4">手动发放抽奖券</h3>
|
| 632 |
+
<div className="space-y-4">
|
| 633 |
+
<div>
|
| 634 |
+
<label className="block text-sm font-medium text-gray-700">选择学生</label>
|
| 635 |
+
<select className="w-full border p-2 rounded mt-1" value={grantForm.studentId} onChange={e=>setGrantForm({...grantForm, studentId: e.target.value})}>
|
| 636 |
+
<option value="">-- 请选择 --</option>
|
| 637 |
+
{students.map(s => <option key={s._id} value={s._id}>{s.name} ({s.studentNo})</option>)}
|
| 638 |
+
</select>
|
| 639 |
+
</div>
|
| 640 |
+
<div>
|
| 641 |
+
<label className="block text-sm font-medium text-gray-700">数量</label>
|
| 642 |
+
<input type="number" min={1} className="w-full border p-2 rounded mt-1" value={grantForm.count} onChange={e=>setGrantForm({...grantForm, count: Number(e.target.value)})}/>
|
| 643 |
+
</div>
|
| 644 |
+
<div className="flex gap-2 pt-2">
|
| 645 |
+
<button onClick={handleGrantDraw} className="flex-1 bg-amber-500 text-white py-2 rounded font-bold">确认发放</button>
|
| 646 |
+
<button onClick={() => setIsGrantModalOpen(false)} className="flex-1 bg-gray-100 text-gray-600 py-2 rounded">取消</button>
|
| 647 |
+
</div>
|
| 648 |
+
</div>
|
| 649 |
+
</div>
|
| 650 |
+
</div>
|
| 651 |
+
)}
|
| 652 |
</div>
|
| 653 |
);
|
| 654 |
};
|
|
|
|
|
|
pages/Login.tsx
CHANGED
|
@@ -331,7 +331,6 @@ export const Login: React.FC<LoginProps> = ({ onLogin }) => {
|
|
| 331 |
<button type="submit" disabled={loading} className="w-full py-3 bg-blue-600 text-white rounded-lg font-bold hover:bg-blue-700 transition-colors">
|
| 332 |
{loading ? <Loader2 className="animate-spin inline"/> : '登 录'}
|
| 333 |
</button>
|
| 334 |
-
<div className="text-center text-xs text-gray-400 mt-4">演示账号: admin / admin</div>
|
| 335 |
</form>
|
| 336 |
) : (
|
| 337 |
<div className="min-h-[300px]">
|
|
|
|
| 331 |
<button type="submit" disabled={loading} className="w-full py-3 bg-blue-600 text-white rounded-lg font-bold hover:bg-blue-700 transition-colors">
|
| 332 |
{loading ? <Loader2 className="animate-spin inline"/> : '登 录'}
|
| 333 |
</button>
|
|
|
|
| 334 |
</form>
|
| 335 |
) : (
|
| 336 |
<div className="min-h-[300px]">
|
pages/Reports.tsx
CHANGED
|
@@ -1,386 +1,16 @@
|
|
| 1 |
|
| 2 |
-
import React
|
| 3 |
-
import {
|
| 4 |
-
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer,
|
| 5 |
-
LineChart, Line, AreaChart, Area, RadarChart, Radar, PolarGrid, PolarAngleAxis, PolarRadiusAxis
|
| 6 |
-
} from 'recharts';
|
| 7 |
import { api } from '../services/api';
|
| 8 |
-
import {
|
| 9 |
-
import {
|
| 10 |
-
|
| 11 |
-
const localSortGrades = (a: string, b: string) => {
|
| 12 |
-
const order: Record<string, number> = { '一年级': 1, '二年级': 2, '三年级': 3, '四年级': 4, '五年级': 5, '六年级': 6, '初一': 7, '七年级': 7, '初二': 8, '八年级': 8, '初三': 9, '九年级': 9, '高一': 10, '高二': 11, '高三': 12 };
|
| 13 |
-
return (order[a] || 99) - (order[b] || 99);
|
| 14 |
-
};
|
| 15 |
|
| 16 |
export const Reports: React.FC = () => {
|
| 17 |
-
const [loading, setLoading] = useState(true);
|
| 18 |
const currentUser = api.auth.getCurrentUser();
|
| 19 |
const isStudent = currentUser?.role === 'STUDENT';
|
| 20 |
|
| 21 |
-
|
| 22 |
-
|
|
|
|
| 23 |
|
| 24 |
-
|
| 25 |
-
const [students, setStudents] = useState<Student[]>([]);
|
| 26 |
-
const [classes, setClasses] = useState<ClassInfo[]>([]);
|
| 27 |
-
const [subjects, setSubjects] = useState<Subject[]>([]);
|
| 28 |
-
const [exams, setExams] = useState<Exam[]>([]);
|
| 29 |
-
|
| 30 |
-
const [selectedGrade, setSelectedGrade] = useState<string>('六年级');
|
| 31 |
-
const [selectedClass, setSelectedClass] = useState<string>('');
|
| 32 |
-
const [selectedSubject, setSelectedSubject] = useState<string>('');
|
| 33 |
-
|
| 34 |
-
const [gradeAnalysisData, setGradeAnalysisData] = useState<any[]>([]);
|
| 35 |
-
const [trendData, setTrendData] = useState<any[]>([]);
|
| 36 |
-
const [matrixData, setMatrixData] = useState<any[]>([]);
|
| 37 |
-
const [overviewData, setOverviewData] = useState<any>({});
|
| 38 |
-
|
| 39 |
-
const [selectedStudent, setSelectedStudent] = useState<Student | null>(null);
|
| 40 |
-
|
| 41 |
-
useEffect(() => {
|
| 42 |
-
const loadData = async () => {
|
| 43 |
-
setLoading(true);
|
| 44 |
-
try {
|
| 45 |
-
const [scs, stus, cls, subs, exs] = await Promise.all([
|
| 46 |
-
api.scores.getAll(),
|
| 47 |
-
api.students.getAll(),
|
| 48 |
-
api.classes.getAll(),
|
| 49 |
-
api.subjects.getAll(),
|
| 50 |
-
api.exams.getAll()
|
| 51 |
-
]);
|
| 52 |
-
setScores(scs);
|
| 53 |
-
setStudents(stus);
|
| 54 |
-
setClasses(cls);
|
| 55 |
-
setSubjects(subs);
|
| 56 |
-
setExams(exs);
|
| 57 |
-
|
| 58 |
-
if (cls.length > 0) setSelectedClass(cls[0].grade + cls[0].className);
|
| 59 |
-
if (subs.length > 0) setSelectedSubject(subs[0].name);
|
| 60 |
-
|
| 61 |
-
// If Student, auto select self
|
| 62 |
-
if (isStudent && currentUser) {
|
| 63 |
-
const me = stus.find((s: Student) => s.name === (currentUser.trueName || currentUser.username));
|
| 64 |
-
if (me) {
|
| 65 |
-
setSelectedClass(me.className);
|
| 66 |
-
setSelectedStudent(me);
|
| 67 |
-
}
|
| 68 |
-
}
|
| 69 |
-
} catch (e) {
|
| 70 |
-
console.error(e);
|
| 71 |
-
} finally {
|
| 72 |
-
setLoading(false);
|
| 73 |
-
}
|
| 74 |
-
};
|
| 75 |
-
loadData();
|
| 76 |
-
}, []);
|
| 77 |
-
|
| 78 |
-
useEffect(() => {
|
| 79 |
-
if (scores.length === 0 || students.length === 0) return;
|
| 80 |
-
|
| 81 |
-
// --- Overview ---
|
| 82 |
-
if (!isStudent) {
|
| 83 |
-
const normalScores = scores.filter(s => s.status === 'Normal');
|
| 84 |
-
const totalAvg = normalScores.length ? normalScores.reduce((a,b)=>a+b.score,0)/normalScores.length : 0;
|
| 85 |
-
const totalPass = normalScores.filter(s => s.score >= 60).length;
|
| 86 |
-
const passRate = normalScores.length ? (totalPass / normalScores.length)*100 : 0;
|
| 87 |
-
|
| 88 |
-
const uniqueGradesList = Array.from(new Set(classes.map(c => c.grade))).sort(localSortGrades);
|
| 89 |
-
const ladderData = uniqueGradesList.map(g => {
|
| 90 |
-
const gradeClasses = classes.filter(c => c.grade === g).map(c => c.grade+c.className);
|
| 91 |
-
const gradeStus = students.filter(s => gradeClasses.includes(s.className)).map(s => s.studentNo);
|
| 92 |
-
const gradeScores = normalScores.filter(s => gradeStus.includes(s.studentNo));
|
| 93 |
-
const gAvg = gradeScores.length ? gradeScores.reduce((a,b)=>a+b.score,0)/gradeScores.length : 0;
|
| 94 |
-
return { name: g, 平均分: Number(gAvg.toFixed(1)) };
|
| 95 |
-
});
|
| 96 |
-
|
| 97 |
-
const subDist = subjects.map(sub => {
|
| 98 |
-
const subScores = normalScores.filter(s => s.courseName === sub.name);
|
| 99 |
-
const sAvg = subScores.length ? subScores.reduce((a,b)=>a+b.score,0)/subScores.length : 0;
|
| 100 |
-
return { name: sub.name, value: Number(sAvg.toFixed(1)), color: sub.color };
|
| 101 |
-
});
|
| 102 |
-
|
| 103 |
-
setOverviewData({
|
| 104 |
-
totalStudents: students.length,
|
| 105 |
-
avgScore: Number(totalAvg.toFixed(1)),
|
| 106 |
-
passRate: Number(passRate.toFixed(1)),
|
| 107 |
-
gradeLadder: ladderData,
|
| 108 |
-
subjectDist: subDist
|
| 109 |
-
});
|
| 110 |
-
|
| 111 |
-
// --- Grade Analysis ---
|
| 112 |
-
const gradeClasses = classes.filter(c => c.grade === selectedGrade);
|
| 113 |
-
const gaData = gradeClasses.map(cls => {
|
| 114 |
-
const fullClassName = cls.grade + cls.className;
|
| 115 |
-
const classStudentIds = students.filter(s => s.className === fullClassName).map(s => s.studentNo);
|
| 116 |
-
const classScores = scores.filter(s => classStudentIds.includes(s.studentNo) && s.status === 'Normal');
|
| 117 |
-
const totalScore = classScores.reduce((sum, s) => sum + s.score, 0);
|
| 118 |
-
const avg = classScores.length ? (totalScore / classScores.length) : 0;
|
| 119 |
-
const passed = classScores.filter(s => s.score >= 60).length;
|
| 120 |
-
const passRate = classScores.length ? (passed / classScores.length) * 100 : 0;
|
| 121 |
-
const excellent = classScores.filter(s => {
|
| 122 |
-
const sub = subjects.find(sub => sub.name === s.courseName);
|
| 123 |
-
return s.score >= (sub?.excellenceThreshold || 90);
|
| 124 |
-
}).length;
|
| 125 |
-
const excellentRate = classScores.length ? (excellent / classScores.length) * 100 : 0;
|
| 126 |
-
return { name: cls.className, fullName: fullClassName, 平均分: Number(avg.toFixed(1)), 及格率: Number(passRate.toFixed(1)), 优秀率: Number(excellentRate.toFixed(1)) };
|
| 127 |
-
});
|
| 128 |
-
setGradeAnalysisData(gaData);
|
| 129 |
-
}
|
| 130 |
-
|
| 131 |
-
// --- Trend Analysis ---
|
| 132 |
-
if (selectedClass && selectedSubject && !isStudent) {
|
| 133 |
-
const classStudentIds = students.filter(s => s.className === selectedClass).map(s => s.studentNo);
|
| 134 |
-
const uniqueExamNames = Array.from(new Set(scores.map(s => s.examName || s.type)));
|
| 135 |
-
uniqueExamNames.sort((a, b) => {
|
| 136 |
-
const dateA = exams.find(e => e.name === a)?.date || '9999-99-99';
|
| 137 |
-
const dateB = exams.find(e => e.name === b)?.date || '9999-99-99';
|
| 138 |
-
return dateA.localeCompare(dateB);
|
| 139 |
-
});
|
| 140 |
-
const tData = uniqueExamNames.map(exam => {
|
| 141 |
-
const examScores = scores.filter(s => (s.examName === exam || s.type === exam) && s.courseName === selectedSubject && s.status === 'Normal');
|
| 142 |
-
const classExamScores = examScores.filter(s => classStudentIds.includes(s.studentNo));
|
| 143 |
-
const classAvg = classExamScores.length ? classExamScores.reduce((a,b)=>a+b.score,0) / classExamScores.length : 0;
|
| 144 |
-
const currentGrade = classes.find(c => c.grade + c.className === selectedClass)?.grade || '';
|
| 145 |
-
const gradeStudentIds = students.filter(s => s.className.startsWith(currentGrade)).map(s => s.studentNo);
|
| 146 |
-
const gradeExamScores = examScores.filter(s => gradeStudentIds.includes(s.studentNo));
|
| 147 |
-
const gradeAvg = gradeExamScores.length ? gradeExamScores.reduce((a,b)=>a+b.score,0) / gradeExamScores.length : 0;
|
| 148 |
-
return { name: exam, 班级平均: Number(classAvg.toFixed(1)), 年级平均: Number(gradeAvg.toFixed(1)) };
|
| 149 |
-
});
|
| 150 |
-
setTrendData(tData);
|
| 151 |
-
}
|
| 152 |
-
}, [scores, students, classes, subjects, exams, selectedGrade, selectedClass, selectedSubject]);
|
| 153 |
-
|
| 154 |
-
const getStudentTrend = (studentNo: string) => {
|
| 155 |
-
const stuScores = scores.filter(s => s.studentNo === studentNo && s.status === 'Normal');
|
| 156 |
-
const uniqueExamNames = Array.from(new Set(stuScores.map(s => s.examName || s.type)));
|
| 157 |
-
uniqueExamNames.sort((a, b) => {
|
| 158 |
-
const dateA = exams.find(e => e.name === a)?.date || '9999-99-99';
|
| 159 |
-
const dateB = exams.find(e => e.name === b)?.date || '9999-99-99';
|
| 160 |
-
return dateA.localeCompare(dateB);
|
| 161 |
-
});
|
| 162 |
-
return uniqueExamNames.map(exam => {
|
| 163 |
-
const s = stuScores.find(s => (s.examName || s.type) === exam);
|
| 164 |
-
return { name: exam, score: s ? s.score : 0 };
|
| 165 |
-
});
|
| 166 |
-
};
|
| 167 |
-
|
| 168 |
-
const getStudentRadar = (studentNo: string) => {
|
| 169 |
-
const stuScores = scores.filter(s => s.studentNo === studentNo && s.status === 'Normal');
|
| 170 |
-
return subjects.map(sub => {
|
| 171 |
-
const subScores = stuScores.filter(s => s.courseName === sub.name);
|
| 172 |
-
const avg = subScores.length ? subScores.reduce((a,b)=>a+b.score,0)/subScores.length : 0;
|
| 173 |
-
return { subject: sub.name, score: Number(avg.toFixed(1)), fullMark: 100 };
|
| 174 |
-
});
|
| 175 |
-
};
|
| 176 |
-
|
| 177 |
-
const uniqueGrades = Array.from(new Set(classes.map(c => c.grade))).sort(localSortGrades);
|
| 178 |
-
const allClasses = classes.map(c => c.grade + c.className);
|
| 179 |
-
|
| 180 |
-
// Student Focus List
|
| 181 |
-
// If Student Role, only show SELF.
|
| 182 |
-
const focusStudents = isStudent
|
| 183 |
-
? (selectedStudent ? [selectedStudent] : [])
|
| 184 |
-
: students.filter(s => selectedClass ? s.className === selectedClass : true);
|
| 185 |
-
|
| 186 |
-
if (loading) return <div className="flex justify-center py-20"><Loader2 className="animate-spin text-blue-600"/></div>;
|
| 187 |
-
|
| 188 |
-
return (
|
| 189 |
-
<div className="space-y-6">
|
| 190 |
-
<div className="flex justify-between items-center">
|
| 191 |
-
<div>
|
| 192 |
-
<h2 className="text-xl font-bold text-gray-800">
|
| 193 |
-
{isStudent ? '我的成绩分析' : '教务数据分析中心'}
|
| 194 |
-
</h2>
|
| 195 |
-
</div>
|
| 196 |
-
</div>
|
| 197 |
-
|
| 198 |
-
{/* Tabs - Hidden for Students */}
|
| 199 |
-
{!isStudent && (
|
| 200 |
-
<div className="flex space-x-1 bg-gray-100 p-1 rounded-xl w-full md:w-auto overflow-x-auto">
|
| 201 |
-
{[
|
| 202 |
-
{ id: 'overview', label: '全校概览', icon: PieChartIcon },
|
| 203 |
-
{ id: 'grade', label: '年级横向分析', icon: BarChart2 },
|
| 204 |
-
{ id: 'trend', label: '教学成长轨迹', icon: TrendingUp },
|
| 205 |
-
{ id: 'matrix', label: '学科质量透视', icon: Grid },
|
| 206 |
-
{ id: 'student', label: '学生个像', icon: User },
|
| 207 |
-
].map(tab => (
|
| 208 |
-
<button
|
| 209 |
-
key={tab.id}
|
| 210 |
-
onClick={() => setActiveTab(tab.id as any)}
|
| 211 |
-
className={`flex items-center space-x-2 px-4 py-2 rounded-lg text-sm font-medium transition-all whitespace-nowrap ${
|
| 212 |
-
activeTab === tab.id ? 'bg-white text-blue-600 shadow-sm' : 'text-gray-500 hover:text-gray-700'
|
| 213 |
-
}`}
|
| 214 |
-
>
|
| 215 |
-
<tab.icon size={16}/>
|
| 216 |
-
<span>{tab.label}</span>
|
| 217 |
-
</button>
|
| 218 |
-
))}
|
| 219 |
-
</div>
|
| 220 |
-
)}
|
| 221 |
-
|
| 222 |
-
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6 min-h-[500px]">
|
| 223 |
-
{/* Filters */}
|
| 224 |
-
{!isStudent && activeTab !== 'overview' && (
|
| 225 |
-
<div className="flex flex-wrap gap-4 mb-8 items-center bg-gray-50 p-4 rounded-lg">
|
| 226 |
-
<div className="flex items-center text-sm font-bold text-gray-500"><Filter size={16} className="mr-2"/> 筛选维度:</div>
|
| 227 |
-
{(activeTab === 'grade' || activeTab === 'matrix') && (
|
| 228 |
-
<select className="border border-gray-300 p-2 rounded text-sm text-gray-800" value={selectedGrade} onChange={e => setSelectedGrade(e.target.value)}>
|
| 229 |
-
{uniqueGrades.map(g => <option key={g} value={g}>{g}</option>)}
|
| 230 |
-
</select>
|
| 231 |
-
)}
|
| 232 |
-
{(activeTab === 'trend' || activeTab === 'student') && (
|
| 233 |
-
<select className="border border-gray-300 p-2 rounded text-sm text-gray-800" value={selectedClass} onChange={e => setSelectedClass(e.target.value)}>
|
| 234 |
-
{allClasses.map(c => <option key={c} value={c}>{c}</option>)}
|
| 235 |
-
</select>
|
| 236 |
-
)}
|
| 237 |
-
{activeTab === 'trend' && (
|
| 238 |
-
<select className="border border-gray-300 p-2 rounded text-sm text-gray-800" value={selectedSubject} onChange={e => setSelectedSubject(e.target.value)}>
|
| 239 |
-
{subjects.map(s => <option key={s._id} value={s.name}>{s.name}</option>)}
|
| 240 |
-
</select>
|
| 241 |
-
)}
|
| 242 |
-
</div>
|
| 243 |
-
)}
|
| 244 |
-
|
| 245 |
-
{/* Overview Tab */}
|
| 246 |
-
{activeTab === 'overview' && !isStudent && (
|
| 247 |
-
<div className="animate-in fade-in space-y-8">
|
| 248 |
-
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
| 249 |
-
<div className="bg-blue-50 p-6 rounded-xl border border-blue-100">
|
| 250 |
-
<p className="text-gray-500 text-sm font-medium mb-1">全校总人数</p>
|
| 251 |
-
<h3 className="text-3xl font-bold text-blue-700">{overviewData.totalStudents}</h3>
|
| 252 |
-
</div>
|
| 253 |
-
<div className="bg-violet-50 p-6 rounded-xl border border-violet-100">
|
| 254 |
-
<p className="text-gray-500 text-sm font-medium mb-1">全校综合平均分</p>
|
| 255 |
-
<h3 className="text-3xl font-bold text-violet-700">{overviewData.avgScore}</h3>
|
| 256 |
-
</div>
|
| 257 |
-
<div className="bg-emerald-50 p-6 rounded-xl border border-emerald-100">
|
| 258 |
-
<p className="text-gray-500 text-sm font-medium mb-1">综合及格率</p>
|
| 259 |
-
<h3 className="text-3xl font-bold text-emerald-700">{overviewData.passRate}%</h3>
|
| 260 |
-
</div>
|
| 261 |
-
</div>
|
| 262 |
-
<div className="bg-white p-4 rounded-lg border border-gray-100 shadow-sm">
|
| 263 |
-
<h3 className="font-bold text-gray-800 mb-4 text-center">各年级平均分阶梯</h3>
|
| 264 |
-
<div className="h-72">
|
| 265 |
-
<ResponsiveContainer width="100%" height="100%">
|
| 266 |
-
<AreaChart data={overviewData.gradeLadder}>
|
| 267 |
-
<defs><linearGradient id="colorAvg" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="#8884d8" stopOpacity={0.8}/><stop offset="95%" stopColor="#8884d8" stopOpacity={0}/></linearGradient></defs>
|
| 268 |
-
<XAxis dataKey="name" /><YAxis domain={[0, 100]}/>
|
| 269 |
-
<CartesianGrid strokeDasharray="3 3" vertical={false} /><Tooltip />
|
| 270 |
-
<Area type="monotone" dataKey="平均分" stroke="#8884d8" fillOpacity={1} fill="url(#colorAvg)" />
|
| 271 |
-
</AreaChart>
|
| 272 |
-
</ResponsiveContainer>
|
| 273 |
-
</div>
|
| 274 |
-
</div>
|
| 275 |
-
</div>
|
| 276 |
-
)}
|
| 277 |
-
|
| 278 |
-
{/* Grade Tab */}
|
| 279 |
-
{activeTab === 'grade' && !isStudent && (
|
| 280 |
-
<div className="h-80">
|
| 281 |
-
<ResponsiveContainer width="100%" height="100%">
|
| 282 |
-
<BarChart data={gradeAnalysisData} layout="vertical">
|
| 283 |
-
<CartesianGrid strokeDasharray="3 3" horizontal={true} vertical={false}/>
|
| 284 |
-
<XAxis type="number" domain={[0, 100]} hide/>
|
| 285 |
-
<YAxis dataKey="name" type="category" width={80}/>
|
| 286 |
-
<Tooltip/>
|
| 287 |
-
<Bar dataKey="平均分" fill="#3b82f6" radius={[0, 4, 4, 0]} barSize={20}/>
|
| 288 |
-
</BarChart>
|
| 289 |
-
</ResponsiveContainer>
|
| 290 |
-
</div>
|
| 291 |
-
)}
|
| 292 |
-
|
| 293 |
-
{/* Student Focus / Student View */}
|
| 294 |
-
{activeTab === 'student' && (
|
| 295 |
-
<div className="animate-in fade-in">
|
| 296 |
-
{isStudent && <div className="mb-6 bg-blue-50 text-blue-700 p-4 rounded-lg flex items-center"><Lock className="mr-2" size={16}/> 您正在查看自己的成绩分析档案。</div>}
|
| 297 |
-
|
| 298 |
-
{isStudent && selectedStudent ? (
|
| 299 |
-
// Expanded Single Student View for Student Role
|
| 300 |
-
<div className="space-y-8">
|
| 301 |
-
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
| 302 |
-
<div className="bg-white border border-gray-100 rounded-xl p-4 shadow-sm">
|
| 303 |
-
<h3 className="font-bold text-gray-800 mb-4 text-center">我的学科能力模型</h3>
|
| 304 |
-
<div className="h-64">
|
| 305 |
-
<ResponsiveContainer width="100%" height="100%">
|
| 306 |
-
<RadarChart cx="50%" cy="50%" outerRadius="70%" data={getStudentRadar(selectedStudent.studentNo)}>
|
| 307 |
-
<PolarGrid />
|
| 308 |
-
<PolarAngleAxis dataKey="subject" />
|
| 309 |
-
<PolarRadiusAxis angle={30} domain={[0, 100]} />
|
| 310 |
-
<Radar name={selectedStudent.name} dataKey="score" stroke="#8884d8" fill="#8884d8" fillOpacity={0.6} />
|
| 311 |
-
<Tooltip />
|
| 312 |
-
</RadarChart>
|
| 313 |
-
</ResponsiveContainer>
|
| 314 |
-
</div>
|
| 315 |
-
</div>
|
| 316 |
-
<div className="bg-white border border-gray-100 rounded-xl p-4 shadow-sm">
|
| 317 |
-
<h3 className="font-bold text-gray-800 mb-4 text-center">我的综合成绩走势</h3>
|
| 318 |
-
<div className="h-64">
|
| 319 |
-
<ResponsiveContainer width="100%" height="100%">
|
| 320 |
-
<LineChart data={getStudentTrend(selectedStudent.studentNo)}>
|
| 321 |
-
<CartesianGrid strokeDasharray="3 3" vertical={false}/>
|
| 322 |
-
<XAxis dataKey="name" />
|
| 323 |
-
<YAxis domain={[0, 100]}/>
|
| 324 |
-
<Tooltip />
|
| 325 |
-
<Line type="monotone" dataKey="score" stroke="#2563eb" strokeWidth={3} />
|
| 326 |
-
</LineChart>
|
| 327 |
-
</ResponsiveContainer>
|
| 328 |
-
</div>
|
| 329 |
-
</div>
|
| 330 |
-
</div>
|
| 331 |
-
</div>
|
| 332 |
-
) : (
|
| 333 |
-
// List for Admin/Teacher
|
| 334 |
-
<div className="grid grid-cols-1 md:grid-cols-3 xl:grid-cols-4 gap-4">
|
| 335 |
-
{focusStudents.map((s: Student) => (
|
| 336 |
-
<div
|
| 337 |
-
key={s._id}
|
| 338 |
-
onClick={() => setSelectedStudent(s)}
|
| 339 |
-
className="bg-white border border-gray-200 p-4 rounded-xl cursor-pointer hover:shadow-lg hover:border-blue-400 transition-all group"
|
| 340 |
-
>
|
| 341 |
-
<div className="flex items-center space-x-3 mb-3">
|
| 342 |
-
<div className={`w-10 h-10 rounded-full flex items-center justify-center font-bold text-white ${s.gender === 'Female' ? 'bg-pink-400' : 'bg-blue-400'}`}>
|
| 343 |
-
{s.name[0]}
|
| 344 |
-
</div>
|
| 345 |
-
<div>
|
| 346 |
-
<h4 className="font-bold text-gray-800 group-hover:text-blue-600">{s.name}</h4>
|
| 347 |
-
<p className="text-xs text-gray-500 font-mono">{s.studentNo}</p>
|
| 348 |
-
</div>
|
| 349 |
-
</div>
|
| 350 |
-
</div>
|
| 351 |
-
))}
|
| 352 |
-
</div>
|
| 353 |
-
)}
|
| 354 |
-
</div>
|
| 355 |
-
)}
|
| 356 |
-
</div>
|
| 357 |
-
|
| 358 |
-
{/* Admin/Teacher Modal for Detail */}
|
| 359 |
-
{!isStudent && selectedStudent && (
|
| 360 |
-
<div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4 backdrop-blur-sm animate-in fade-in">
|
| 361 |
-
<div className="bg-white rounded-2xl p-6 w-full max-w-4xl max-h-[90vh] overflow-y-auto shadow-2xl">
|
| 362 |
-
<div className="flex justify-between items-start mb-6">
|
| 363 |
-
<div className="flex items-center space-x-4">
|
| 364 |
-
<div className={`w-16 h-16 rounded-full flex items-center justify-center text-2xl font-bold text-white ${selectedStudent.gender === 'Female' ? 'bg-pink-500' : 'bg-blue-500'}`}>
|
| 365 |
-
{selectedStudent.name[0]}
|
| 366 |
-
</div>
|
| 367 |
-
<div>
|
| 368 |
-
<h2 className="text-2xl font-bold text-gray-900">{selectedStudent.name}</h2>
|
| 369 |
-
<div className="flex items-center space-x-3 text-sm text-gray-500 mt-1">
|
| 370 |
-
<span className="bg-gray-100 px-2 py-0.5 rounded text-gray-700">{selectedStudent.className}</span>
|
| 371 |
-
</div>
|
| 372 |
-
</div>
|
| 373 |
-
</div>
|
| 374 |
-
<button onClick={() => setSelectedStudent(null)} className="p-2 hover:bg-gray-100 rounded-full transition-colors"><Grid size={20}/></button>
|
| 375 |
-
</div>
|
| 376 |
-
{/* Re-use charts logic for modal */}
|
| 377 |
-
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
| 378 |
-
<div className="h-64"><ResponsiveContainer><RadarChart data={getStudentRadar(selectedStudent.studentNo)}><PolarGrid/><PolarAngleAxis dataKey="subject"/><PolarRadiusAxis angle={30} domain={[0,100]}/><Radar dataKey="score" stroke="#8884d8" fill="#8884d8" fillOpacity={0.6}/><Tooltip/></RadarChart></ResponsiveContainer></div>
|
| 379 |
-
<div className="h-64"><ResponsiveContainer><LineChart data={getStudentTrend(selectedStudent.studentNo)}><CartesianGrid vertical={false}/><XAxis dataKey="name"/><YAxis domain={[0,100]}/><Tooltip/><Line type="monotone" dataKey="score" stroke="#2563eb" strokeWidth={3}/></LineChart></ResponsiveContainer></div>
|
| 380 |
-
</div>
|
| 381 |
-
</div>
|
| 382 |
-
</div>
|
| 383 |
-
)}
|
| 384 |
-
</div>
|
| 385 |
-
);
|
| 386 |
};
|
|
|
|
| 1 |
|
| 2 |
+
import React from 'react';
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
import { api } from '../services/api';
|
| 4 |
+
import { TeacherReports } from './TeacherReports';
|
| 5 |
+
import { StudentReports } from './StudentReports';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
export const Reports: React.FC = () => {
|
|
|
|
| 8 |
const currentUser = api.auth.getCurrentUser();
|
| 9 |
const isStudent = currentUser?.role === 'STUDENT';
|
| 10 |
|
| 11 |
+
if (isStudent) {
|
| 12 |
+
return <StudentReports />;
|
| 13 |
+
}
|
| 14 |
|
| 15 |
+
return <TeacherReports />;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
};
|
pages/StudentReports.tsx
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import React, { useState, useEffect } from 'react';
|
| 3 |
+
import {
|
| 4 |
+
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
|
| 5 |
+
RadarChart, Radar, PolarGrid, PolarAngleAxis, PolarRadiusAxis
|
| 6 |
+
} from 'recharts';
|
| 7 |
+
import { api } from '../services/api';
|
| 8 |
+
import { Loader2, Lock } from 'lucide-react';
|
| 9 |
+
import { Score, Student, Subject, Exam } from '../types';
|
| 10 |
+
|
| 11 |
+
export const StudentReports: React.FC = () => {
|
| 12 |
+
const [loading, setLoading] = useState(true);
|
| 13 |
+
const [student, setStudent] = useState<Student | null>(null);
|
| 14 |
+
const [scores, setScores] = useState<Score[]>([]);
|
| 15 |
+
const [subjects, setSubjects] = useState<Subject[]>([]);
|
| 16 |
+
const [exams, setExams] = useState<Exam[]>([]);
|
| 17 |
+
|
| 18 |
+
const currentUser = api.auth.getCurrentUser();
|
| 19 |
+
|
| 20 |
+
useEffect(() => {
|
| 21 |
+
const loadData = async () => {
|
| 22 |
+
setLoading(true);
|
| 23 |
+
try {
|
| 24 |
+
const [stus, scs, subs, exs] = await Promise.all([
|
| 25 |
+
api.students.getAll(),
|
| 26 |
+
api.scores.getAll(),
|
| 27 |
+
api.subjects.getAll(),
|
| 28 |
+
api.exams.getAll()
|
| 29 |
+
]);
|
| 30 |
+
const me = stus.find((s: Student) => s.name === (currentUser?.trueName || currentUser?.username));
|
| 31 |
+
setStudent(me || null);
|
| 32 |
+
setScores(scs);
|
| 33 |
+
setSubjects(subs);
|
| 34 |
+
setExams(exs);
|
| 35 |
+
} catch (e) {
|
| 36 |
+
console.error(e);
|
| 37 |
+
} finally {
|
| 38 |
+
setLoading(false);
|
| 39 |
+
}
|
| 40 |
+
};
|
| 41 |
+
loadData();
|
| 42 |
+
}, []);
|
| 43 |
+
|
| 44 |
+
const getStudentRadar = () => {
|
| 45 |
+
if (!student) return [];
|
| 46 |
+
const stuScores = scores.filter(s => s.studentNo === student.studentNo && s.status === 'Normal');
|
| 47 |
+
return subjects.map(sub => {
|
| 48 |
+
const subScores = stuScores.filter(s => s.courseName === sub.name);
|
| 49 |
+
const avg = subScores.length ? subScores.reduce((a,b)=>a+b.score,0)/subScores.length : 0;
|
| 50 |
+
return { subject: sub.name, score: Number(avg.toFixed(1)), fullMark: 100 };
|
| 51 |
+
});
|
| 52 |
+
};
|
| 53 |
+
|
| 54 |
+
const getStudentTrend = () => {
|
| 55 |
+
if (!student) return [];
|
| 56 |
+
const stuScores = scores.filter(s => s.studentNo === student.studentNo && s.status === 'Normal');
|
| 57 |
+
const uniqueExamNames = Array.from(new Set(stuScores.map(s => s.examName || s.type)));
|
| 58 |
+
uniqueExamNames.sort((a, b) => {
|
| 59 |
+
const dateA = exams.find(e => e.name === a)?.date || '9999-99-99';
|
| 60 |
+
const dateB = exams.find(e => e.name === b)?.date || '9999-99-99';
|
| 61 |
+
return dateA.localeCompare(dateB);
|
| 62 |
+
});
|
| 63 |
+
return uniqueExamNames.map(exam => {
|
| 64 |
+
const s = stuScores.find(s => (s.examName || s.type) === exam);
|
| 65 |
+
return { name: exam, score: s ? s.score : 0 };
|
| 66 |
+
});
|
| 67 |
+
};
|
| 68 |
+
|
| 69 |
+
if (loading) return <div className="flex justify-center py-20"><Loader2 className="animate-spin text-blue-600"/></div>;
|
| 70 |
+
if (!student) return <div className="text-center py-20 text-gray-500">暂无学生档案信息,请联系老师关联账号。</div>;
|
| 71 |
+
|
| 72 |
+
return (
|
| 73 |
+
<div className="space-y-6">
|
| 74 |
+
<div className="bg-blue-600 rounded-xl p-6 text-white shadow-lg">
|
| 75 |
+
<h2 className="text-2xl font-bold mb-1">我的成绩分析</h2>
|
| 76 |
+
<p className="opacity-90 flex items-center"><Lock size={14} className="mr-1"/> 仅自己可见</p>
|
| 77 |
+
</div>
|
| 78 |
+
|
| 79 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
| 80 |
+
<div className="bg-white border border-gray-100 rounded-xl p-6 shadow-sm">
|
| 81 |
+
<h3 className="font-bold text-gray-800 mb-4 text-center">学科能力模型</h3>
|
| 82 |
+
<div className="h-72">
|
| 83 |
+
<ResponsiveContainer width="100%" height="100%">
|
| 84 |
+
<RadarChart cx="50%" cy="50%" outerRadius="70%" data={getStudentRadar()}>
|
| 85 |
+
<PolarGrid />
|
| 86 |
+
<PolarAngleAxis dataKey="subject" />
|
| 87 |
+
<PolarRadiusAxis angle={30} domain={[0, 100]} />
|
| 88 |
+
<Radar name={student.name} dataKey="score" stroke="#8884d8" fill="#8884d8" fillOpacity={0.6} />
|
| 89 |
+
<Tooltip />
|
| 90 |
+
</RadarChart>
|
| 91 |
+
</ResponsiveContainer>
|
| 92 |
+
</div>
|
| 93 |
+
</div>
|
| 94 |
+
<div className="bg-white border border-gray-100 rounded-xl p-6 shadow-sm">
|
| 95 |
+
<h3 className="font-bold text-gray-800 mb-4 text-center">综合成绩走势</h3>
|
| 96 |
+
<div className="h-72">
|
| 97 |
+
<ResponsiveContainer width="100%" height="100%">
|
| 98 |
+
<LineChart data={getStudentTrend()}>
|
| 99 |
+
<CartesianGrid strokeDasharray="3 3" vertical={false}/>
|
| 100 |
+
<XAxis dataKey="name" />
|
| 101 |
+
<YAxis domain={[0, 100]}/>
|
| 102 |
+
<Tooltip />
|
| 103 |
+
<Line type="monotone" dataKey="score" stroke="#2563eb" strokeWidth={3} activeDot={{r:6}} />
|
| 104 |
+
</LineChart>
|
| 105 |
+
</ResponsiveContainer>
|
| 106 |
+
</div>
|
| 107 |
+
</div>
|
| 108 |
+
</div>
|
| 109 |
+
</div>
|
| 110 |
+
);
|
| 111 |
+
};
|
pages/TeacherReports.tsx
ADDED
|
@@ -0,0 +1,385 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import React, { useState, useEffect } from 'react';
|
| 3 |
+
import {
|
| 4 |
+
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer,
|
| 5 |
+
LineChart, Line, AreaChart, Area, RadarChart, Radar, PolarGrid, PolarAngleAxis, PolarRadiusAxis, ScatterChart, Scatter, ZAxis
|
| 6 |
+
} from 'recharts';
|
| 7 |
+
import { api } from '../services/api';
|
| 8 |
+
import { Loader2, Filter, TrendingUp, Grid, BarChart2, User, PieChart as PieChartIcon } from 'lucide-react';
|
| 9 |
+
import { Score, Student, ClassInfo, Subject, Exam } from '../types';
|
| 10 |
+
|
| 11 |
+
const localSortGrades = (a: string, b: string) => {
|
| 12 |
+
const order: Record<string, number> = { '一年级': 1, '二年级': 2, '三年级': 3, '四年级': 4, '五年级': 5, '六年级': 6, '初一': 7, '七年级': 7, '初二': 8, '八年级': 8, '初三': 9, '九年级': 9, '高一': 10, '高二': 11, '高三': 12 };
|
| 13 |
+
return (order[a] || 99) - (order[b] || 99);
|
| 14 |
+
};
|
| 15 |
+
|
| 16 |
+
export const TeacherReports: React.FC = () => {
|
| 17 |
+
const [loading, setLoading] = useState(true);
|
| 18 |
+
const [activeTab, setActiveTab] = useState<'overview' | 'grade' | 'trend' | 'matrix' | 'student'>('overview');
|
| 19 |
+
|
| 20 |
+
const [scores, setScores] = useState<Score[]>([]);
|
| 21 |
+
const [students, setStudents] = useState<Student[]>([]);
|
| 22 |
+
const [classes, setClasses] = useState<ClassInfo[]>([]);
|
| 23 |
+
const [subjects, setSubjects] = useState<Subject[]>([]);
|
| 24 |
+
const [exams, setExams] = useState<Exam[]>([]);
|
| 25 |
+
|
| 26 |
+
const [selectedGrade, setSelectedGrade] = useState<string>('六年级');
|
| 27 |
+
const [selectedClass, setSelectedClass] = useState<string>('');
|
| 28 |
+
const [selectedSubject, setSelectedSubject] = useState<string>('');
|
| 29 |
+
|
| 30 |
+
const [gradeAnalysisData, setGradeAnalysisData] = useState<any[]>([]);
|
| 31 |
+
const [trendData, setTrendData] = useState<any[]>([]);
|
| 32 |
+
const [matrixData, setMatrixData] = useState<any[]>([]);
|
| 33 |
+
const [overviewData, setOverviewData] = useState<any>({});
|
| 34 |
+
|
| 35 |
+
const [selectedStudent, setSelectedStudent] = useState<Student | null>(null);
|
| 36 |
+
|
| 37 |
+
useEffect(() => {
|
| 38 |
+
const loadData = async () => {
|
| 39 |
+
setLoading(true);
|
| 40 |
+
try {
|
| 41 |
+
const [scs, stus, cls, subs, exs] = await Promise.all([
|
| 42 |
+
api.scores.getAll(),
|
| 43 |
+
api.students.getAll(),
|
| 44 |
+
api.classes.getAll(),
|
| 45 |
+
api.subjects.getAll(),
|
| 46 |
+
api.exams.getAll()
|
| 47 |
+
]) as [Score[], Student[], ClassInfo[], Subject[], Exam[]];
|
| 48 |
+
|
| 49 |
+
setScores(scs);
|
| 50 |
+
setStudents(stus);
|
| 51 |
+
setClasses(cls);
|
| 52 |
+
setSubjects(subs);
|
| 53 |
+
setExams(exs);
|
| 54 |
+
|
| 55 |
+
if (cls.length > 0) setSelectedClass(cls[0].grade + cls[0].className);
|
| 56 |
+
if (subs.length > 0) setSelectedSubject(subs[0].name);
|
| 57 |
+
|
| 58 |
+
// Init view grade
|
| 59 |
+
const grades = Array.from(new Set(cls.map((c: ClassInfo) => c.grade))).sort(localSortGrades);
|
| 60 |
+
if (grades.length > 0) setSelectedGrade(grades[0]);
|
| 61 |
+
|
| 62 |
+
} catch (e) { console.error(e); }
|
| 63 |
+
finally { setLoading(false); }
|
| 64 |
+
};
|
| 65 |
+
loadData();
|
| 66 |
+
}, []);
|
| 67 |
+
|
| 68 |
+
useEffect(() => {
|
| 69 |
+
if (scores.length === 0 || students.length === 0) return;
|
| 70 |
+
|
| 71 |
+
// --- Overview Logic ---
|
| 72 |
+
const normalScores = scores.filter(s => s.status === 'Normal');
|
| 73 |
+
const totalAvg = normalScores.length ? normalScores.reduce((a,b)=>a+b.score,0)/normalScores.length : 0;
|
| 74 |
+
const totalPass = normalScores.filter(s => s.score >= 60).length;
|
| 75 |
+
const passRate = normalScores.length ? (totalPass / normalScores.length)*100 : 0;
|
| 76 |
+
|
| 77 |
+
const uniqueGradesList = Array.from(new Set(classes.map(c => c.grade))).sort(localSortGrades);
|
| 78 |
+
const ladderData = uniqueGradesList.map(g => {
|
| 79 |
+
const gradeClasses = classes.filter(c => c.grade === g).map(c => c.grade+c.className);
|
| 80 |
+
const gradeStus = students.filter(s => gradeClasses.includes(s.className)).map(s => s.studentNo);
|
| 81 |
+
const gradeScores = normalScores.filter(s => gradeStus.includes(s.studentNo));
|
| 82 |
+
const gAvg = gradeScores.length ? gradeScores.reduce((a,b)=>a+b.score,0)/gradeScores.length : 0;
|
| 83 |
+
return { name: g, 平均分: Number(gAvg.toFixed(1)) };
|
| 84 |
+
});
|
| 85 |
+
|
| 86 |
+
const subDist = subjects.map(sub => {
|
| 87 |
+
const subScores = normalScores.filter(s => s.courseName === sub.name);
|
| 88 |
+
const sAvg = subScores.length ? subScores.reduce((a,b)=>a+b.score,0)/subScores.length : 0;
|
| 89 |
+
return { name: sub.name, value: Number(sAvg.toFixed(1)), color: sub.color };
|
| 90 |
+
});
|
| 91 |
+
|
| 92 |
+
setOverviewData({
|
| 93 |
+
totalStudents: students.length,
|
| 94 |
+
avgScore: Number(totalAvg.toFixed(1)),
|
| 95 |
+
passRate: Number(passRate.toFixed(1)),
|
| 96 |
+
gradeLadder: ladderData,
|
| 97 |
+
subjectDist: subDist
|
| 98 |
+
});
|
| 99 |
+
|
| 100 |
+
// --- Grade Analysis ---
|
| 101 |
+
const gradeClasses = classes.filter(c => c.grade === selectedGrade);
|
| 102 |
+
const gaData = gradeClasses.map(cls => {
|
| 103 |
+
const fullClassName = cls.grade + cls.className;
|
| 104 |
+
const classStudentIds = students.filter(s => s.className === fullClassName).map(s => s.studentNo);
|
| 105 |
+
const classScores = scores.filter(s => classStudentIds.includes(s.studentNo) && s.status === 'Normal');
|
| 106 |
+
const totalScore = classScores.reduce((sum, s) => sum + s.score, 0);
|
| 107 |
+
const avg = classScores.length ? (totalScore / classScores.length) : 0;
|
| 108 |
+
const passed = classScores.filter(s => s.score >= 60).length;
|
| 109 |
+
const passRate = classScores.length ? (passed / classScores.length) * 100 : 0;
|
| 110 |
+
const excellent = classScores.filter(s => {
|
| 111 |
+
const sub = subjects.find(sub => sub.name === s.courseName);
|
| 112 |
+
return s.score >= (sub?.excellenceThreshold || 90);
|
| 113 |
+
}).length;
|
| 114 |
+
const excellentRate = classScores.length ? (excellent / classScores.length) * 100 : 0;
|
| 115 |
+
return { name: cls.className, fullName: fullClassName, 平均分: Number(avg.toFixed(1)), 及格率: Number(passRate.toFixed(1)), 优秀率: Number(excellentRate.toFixed(1)) };
|
| 116 |
+
});
|
| 117 |
+
setGradeAnalysisData(gaData);
|
| 118 |
+
|
| 119 |
+
// --- Trend Analysis ---
|
| 120 |
+
if (selectedClass && selectedSubject) {
|
| 121 |
+
const classStudentIds = students.filter(s => s.className === selectedClass).map(s => s.studentNo);
|
| 122 |
+
const uniqueExamNames = Array.from(new Set(scores.map(s => s.examName || s.type)));
|
| 123 |
+
uniqueExamNames.sort((a, b) => {
|
| 124 |
+
const dateA = exams.find(e => e.name === a)?.date || '9999-99-99';
|
| 125 |
+
const dateB = exams.find(e => e.name === b)?.date || '9999-99-99';
|
| 126 |
+
return dateA.localeCompare(dateB);
|
| 127 |
+
});
|
| 128 |
+
const tData = uniqueExamNames.map(exam => {
|
| 129 |
+
const examScores = scores.filter(s => (s.examName === exam || s.type === exam) && s.courseName === selectedSubject && s.status === 'Normal');
|
| 130 |
+
const classExamScores = examScores.filter(s => classStudentIds.includes(s.studentNo));
|
| 131 |
+
const classAvg = classExamScores.length ? classExamScores.reduce((a,b)=>a+b.score,0) / classExamScores.length : 0;
|
| 132 |
+
const currentGrade = classes.find(c => c.grade + c.className === selectedClass)?.grade || '';
|
| 133 |
+
const gradeStudentIds = students.filter(s => s.className.startsWith(currentGrade)).map(s => s.studentNo);
|
| 134 |
+
const gradeExamScores = examScores.filter(s => gradeStudentIds.includes(s.studentNo));
|
| 135 |
+
const gradeAvg = gradeExamScores.length ? gradeExamScores.reduce((a,b)=>a+b.score,0) / gradeExamScores.length : 0;
|
| 136 |
+
return { name: exam, 班级平均: Number(classAvg.toFixed(1)), 年级平均: Number(gradeAvg.toFixed(1)) };
|
| 137 |
+
});
|
| 138 |
+
setTrendData(tData);
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
// --- Matrix (Scatter) ---
|
| 142 |
+
// Show distribution of scores for selected Grade + Subject
|
| 143 |
+
if (selectedGrade) {
|
| 144 |
+
const relevantClasses = classes.filter(c => c.grade === selectedGrade).map(c => c.grade+c.className);
|
| 145 |
+
// We will average score per class per subject
|
| 146 |
+
// Just mocking a complex chart: Average Score vs Variance? Or Pass Rate vs Excellent Rate
|
| 147 |
+
const mData = relevantClasses.map(cName => {
|
| 148 |
+
const sIds = students.filter(s => s.className === cName).map(s => s.studentNo);
|
| 149 |
+
const cScores = normalScores.filter(s => sIds.includes(s.studentNo));
|
| 150 |
+
if (cScores.length === 0) return { name: cName, x: 0, y: 0, z: 0 };
|
| 151 |
+
|
| 152 |
+
const avg = cScores.reduce((a,b)=>a+b.score,0)/cScores.length;
|
| 153 |
+
const pass = cScores.filter(s=>s.score>=60).length / cScores.length * 100;
|
| 154 |
+
const excellent = cScores.filter(s=>s.score>=90).length / cScores.length * 100;
|
| 155 |
+
|
| 156 |
+
return { name: cName.replace(selectedGrade,''), x: Number(avg.toFixed(1)), y: Number(pass.toFixed(1)), z: Number(excellent.toFixed(1)) };
|
| 157 |
+
});
|
| 158 |
+
setMatrixData(mData);
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
}, [scores, students, classes, subjects, exams, selectedGrade, selectedClass, selectedSubject]);
|
| 162 |
+
|
| 163 |
+
const getStudentRadar = (studentNo: string) => {
|
| 164 |
+
const stuScores = scores.filter(s => s.studentNo === studentNo && s.status === 'Normal');
|
| 165 |
+
return subjects.map(sub => {
|
| 166 |
+
const subScores = stuScores.filter(s => s.courseName === sub.name);
|
| 167 |
+
const avg = subScores.length ? subScores.reduce((a,b)=>a+b.score,0)/subScores.length : 0;
|
| 168 |
+
return { subject: sub.name, score: Number(avg.toFixed(1)), fullMark: 100 };
|
| 169 |
+
});
|
| 170 |
+
};
|
| 171 |
+
|
| 172 |
+
const getStudentTrend = (studentNo: string) => {
|
| 173 |
+
const stuScores = scores.filter(s => s.studentNo === studentNo && s.status === 'Normal');
|
| 174 |
+
const uniqueExamNames = Array.from(new Set(stuScores.map(s => s.examName || s.type)));
|
| 175 |
+
uniqueExamNames.sort((a, b) => {
|
| 176 |
+
const dateA = exams.find(e => e.name === a)?.date || '9999-99-99';
|
| 177 |
+
const dateB = exams.find(e => e.name === b)?.date || '9999-99-99';
|
| 178 |
+
return dateA.localeCompare(dateB);
|
| 179 |
+
});
|
| 180 |
+
return uniqueExamNames.map(exam => {
|
| 181 |
+
const s = stuScores.find(s => (s.examName || s.type) === exam);
|
| 182 |
+
return { name: exam, score: s ? s.score : 0 };
|
| 183 |
+
});
|
| 184 |
+
};
|
| 185 |
+
|
| 186 |
+
const uniqueGrades = Array.from(new Set(classes.map(c => c.grade))).sort(localSortGrades);
|
| 187 |
+
const allClasses = classes.map(c => c.grade + c.className);
|
| 188 |
+
|
| 189 |
+
// Filter students for "Student Focus" tab
|
| 190 |
+
const focusStudents = students.filter(s => selectedClass ? s.className === selectedClass : true);
|
| 191 |
+
|
| 192 |
+
if (loading) return <div className="flex justify-center py-20"><Loader2 className="animate-spin text-blue-600"/></div>;
|
| 193 |
+
|
| 194 |
+
return (
|
| 195 |
+
<div className="space-y-6">
|
| 196 |
+
<div className="flex justify-between items-center">
|
| 197 |
+
<h2 className="text-xl font-bold text-gray-800">教务数据分析中心</h2>
|
| 198 |
+
</div>
|
| 199 |
+
|
| 200 |
+
<div className="flex space-x-1 bg-gray-100 p-1 rounded-xl w-full md:w-auto overflow-x-auto">
|
| 201 |
+
{[
|
| 202 |
+
{ id: 'overview', label: '全校概览', icon: PieChartIcon },
|
| 203 |
+
{ id: 'grade', label: '年级横向分析', icon: BarChart2 },
|
| 204 |
+
{ id: 'trend', label: '教学成长轨迹', icon: TrendingUp },
|
| 205 |
+
{ id: 'matrix', label: '学科质量透视', icon: Grid },
|
| 206 |
+
{ id: 'student', label: '学生个像', icon: User },
|
| 207 |
+
].map(tab => (
|
| 208 |
+
<button
|
| 209 |
+
key={tab.id}
|
| 210 |
+
onClick={() => setActiveTab(tab.id as any)}
|
| 211 |
+
className={`flex items-center space-x-2 px-4 py-2 rounded-lg text-sm font-medium transition-all whitespace-nowrap ${
|
| 212 |
+
activeTab === tab.id ? 'bg-white text-blue-600 shadow-sm' : 'text-gray-500 hover:text-gray-700'
|
| 213 |
+
}`}
|
| 214 |
+
>
|
| 215 |
+
<tab.icon size={16}/>
|
| 216 |
+
<span>{tab.label}</span>
|
| 217 |
+
</button>
|
| 218 |
+
))}
|
| 219 |
+
</div>
|
| 220 |
+
|
| 221 |
+
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6 min-h-[500px]">
|
| 222 |
+
{/* Filters */}
|
| 223 |
+
{activeTab !== 'overview' && (
|
| 224 |
+
<div className="flex flex-wrap gap-4 mb-8 items-center bg-gray-50 p-4 rounded-lg">
|
| 225 |
+
<div className="flex items-center text-sm font-bold text-gray-500"><Filter size={16} className="mr-2"/> 筛选维度:</div>
|
| 226 |
+
{(activeTab === 'grade' || activeTab === 'matrix') && (
|
| 227 |
+
<select className="border border-gray-300 p-2 rounded text-sm text-gray-800" value={selectedGrade} onChange={e => setSelectedGrade(e.target.value)}>
|
| 228 |
+
{uniqueGrades.map(g => <option key={g} value={g}>{g}</option>)}
|
| 229 |
+
</select>
|
| 230 |
+
)}
|
| 231 |
+
{(activeTab === 'trend' || activeTab === 'student') && (
|
| 232 |
+
<select className="border border-gray-300 p-2 rounded text-sm text-gray-800" value={selectedClass} onChange={e => setSelectedClass(e.target.value)}>
|
| 233 |
+
<option value="">-- 选择班级 --</option>
|
| 234 |
+
{allClasses.map(c => <option key={c} value={c}>{c}</option>)}
|
| 235 |
+
</select>
|
| 236 |
+
)}
|
| 237 |
+
{activeTab === 'trend' && (
|
| 238 |
+
<select className="border border-gray-300 p-2 rounded text-sm text-gray-800" value={selectedSubject} onChange={e => setSelectedSubject(e.target.value)}>
|
| 239 |
+
{subjects.map(s => <option key={s._id} value={s.name}>{s.name}</option>)}
|
| 240 |
+
</select>
|
| 241 |
+
)}
|
| 242 |
+
</div>
|
| 243 |
+
)}
|
| 244 |
+
|
| 245 |
+
{/* 1. Overview */}
|
| 246 |
+
{activeTab === 'overview' && (
|
| 247 |
+
<div className="animate-in fade-in space-y-8">
|
| 248 |
+
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
| 249 |
+
<div className="bg-blue-50 p-6 rounded-xl border border-blue-100">
|
| 250 |
+
<p className="text-gray-500 text-sm font-medium mb-1">全校总人数</p>
|
| 251 |
+
<h3 className="text-3xl font-bold text-blue-700">{overviewData.totalStudents}</h3>
|
| 252 |
+
</div>
|
| 253 |
+
<div className="bg-violet-50 p-6 rounded-xl border border-violet-100">
|
| 254 |
+
<p className="text-gray-500 text-sm font-medium mb-1">全校综合平均分</p>
|
| 255 |
+
<h3 className="text-3xl font-bold text-violet-700">{overviewData.avgScore}</h3>
|
| 256 |
+
</div>
|
| 257 |
+
<div className="bg-emerald-50 p-6 rounded-xl border border-emerald-100">
|
| 258 |
+
<p className="text-gray-500 text-sm font-medium mb-1">综合及格率</p>
|
| 259 |
+
<h3 className="text-3xl font-bold text-emerald-700">{overviewData.passRate}%</h3>
|
| 260 |
+
</div>
|
| 261 |
+
</div>
|
| 262 |
+
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
| 263 |
+
<div className="bg-white p-4 rounded-lg border border-gray-100 shadow-sm">
|
| 264 |
+
<h3 className="font-bold text-gray-800 mb-4 text-center">各年级平均分阶梯</h3>
|
| 265 |
+
<div className="h-72">
|
| 266 |
+
<ResponsiveContainer width="100%" height="100%">
|
| 267 |
+
<AreaChart data={overviewData.gradeLadder}>
|
| 268 |
+
<defs><linearGradient id="colorAvg" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="#8884d8" stopOpacity={0.8}/><stop offset="95%" stopColor="#8884d8" stopOpacity={0}/></linearGradient></defs>
|
| 269 |
+
<XAxis dataKey="name" /><YAxis domain={[0, 100]}/>
|
| 270 |
+
<CartesianGrid strokeDasharray="3 3" vertical={false} /><Tooltip />
|
| 271 |
+
<Area type="monotone" dataKey="平均分" stroke="#8884d8" fillOpacity={1} fill="url(#colorAvg)" />
|
| 272 |
+
</AreaChart>
|
| 273 |
+
</ResponsiveContainer>
|
| 274 |
+
</div>
|
| 275 |
+
</div>
|
| 276 |
+
{/* Add more overview charts if needed */}
|
| 277 |
+
</div>
|
| 278 |
+
</div>
|
| 279 |
+
)}
|
| 280 |
+
|
| 281 |
+
{/* 2. Grade Analysis */}
|
| 282 |
+
{activeTab === 'grade' && (
|
| 283 |
+
<div className="h-96 animate-in fade-in">
|
| 284 |
+
<ResponsiveContainer width="100%" height="100%">
|
| 285 |
+
<BarChart data={gradeAnalysisData} layout="vertical" margin={{left: 40}}>
|
| 286 |
+
<CartesianGrid strokeDasharray="3 3" horizontal={true} vertical={false}/>
|
| 287 |
+
<XAxis type="number" domain={[0, 100]} />
|
| 288 |
+
<YAxis dataKey="name" type="category" width={80}/>
|
| 289 |
+
<Tooltip cursor={{fill: 'transparent'}}/>
|
| 290 |
+
<Legend />
|
| 291 |
+
<Bar dataKey="平均分" fill="#3b82f6" radius={[0, 4, 4, 0]} barSize={20} name="平均分"/>
|
| 292 |
+
<Bar dataKey="及格率" fill="#10b981" radius={[0, 4, 4, 0]} barSize={20} name="及格率%"/>
|
| 293 |
+
<Bar dataKey="优秀率" fill="#f59e0b" radius={[0, 4, 4, 0]} barSize={20} name="优秀率%"/>
|
| 294 |
+
</BarChart>
|
| 295 |
+
</ResponsiveContainer>
|
| 296 |
+
</div>
|
| 297 |
+
)}
|
| 298 |
+
|
| 299 |
+
{/* 3. Trend */}
|
| 300 |
+
{activeTab === 'trend' && (
|
| 301 |
+
<div className="h-96 animate-in fade-in">
|
| 302 |
+
{trendData.length > 0 ? (
|
| 303 |
+
<ResponsiveContainer width="100%" height="100%">
|
| 304 |
+
<LineChart data={trendData}>
|
| 305 |
+
<CartesianGrid strokeDasharray="3 3" vertical={false}/>
|
| 306 |
+
<XAxis dataKey="name" />
|
| 307 |
+
<YAxis domain={[0, 100]} />
|
| 308 |
+
<Tooltip />
|
| 309 |
+
<Legend />
|
| 310 |
+
<Line type="monotone" dataKey="班级平均" stroke="#2563eb" strokeWidth={3} activeDot={{ r: 8 }} />
|
| 311 |
+
<Line type="monotone" dataKey="年级平均" stroke="#9ca3af" strokeWidth={2} strokeDasharray="5 5" />
|
| 312 |
+
</LineChart>
|
| 313 |
+
</ResponsiveContainer>
|
| 314 |
+
) : <div className="flex h-full items-center justify-center text-gray-400">请选择班级和学科以查看趋势</div>}
|
| 315 |
+
</div>
|
| 316 |
+
)}
|
| 317 |
+
|
| 318 |
+
{/* 4. Matrix (Scatter) - Restored */}
|
| 319 |
+
{activeTab === 'matrix' && (
|
| 320 |
+
<div className="h-96 animate-in fade-in">
|
| 321 |
+
<div className="text-center text-sm text-gray-500 mb-2">X轴: 平均分 | Y轴: 及格率 | 气泡大小: 优秀率</div>
|
| 322 |
+
<ResponsiveContainer width="100%" height="100%">
|
| 323 |
+
<ScatterChart margin={{ top: 20, right: 20, bottom: 20, left: 20 }}>
|
| 324 |
+
<CartesianGrid />
|
| 325 |
+
<XAxis type="number" dataKey="x" name="平均分" unit="分" domain={[0, 100]} />
|
| 326 |
+
<YAxis type="number" dataKey="y" name="及格率" unit="%" domain={[0, 100]} />
|
| 327 |
+
<ZAxis type="number" dataKey="z" range={[50, 400]} name="优秀率" unit="%" />
|
| 328 |
+
<Tooltip cursor={{ strokeDasharray: '3 3' }} />
|
| 329 |
+
<Legend />
|
| 330 |
+
<Scatter name="各班级表现" data={matrixData} fill="#8884d8" />
|
| 331 |
+
</ScatterChart>
|
| 332 |
+
</ResponsiveContainer>
|
| 333 |
+
</div>
|
| 334 |
+
)}
|
| 335 |
+
|
| 336 |
+
{/* 5. Student Individual */}
|
| 337 |
+
{activeTab === 'student' && (
|
| 338 |
+
<div className="animate-in fade-in">
|
| 339 |
+
{!selectedStudent ? (
|
| 340 |
+
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
| 341 |
+
{focusStudents.map((s: Student) => (
|
| 342 |
+
<div
|
| 343 |
+
key={s._id}
|
| 344 |
+
onClick={() => setSelectedStudent(s)}
|
| 345 |
+
className="bg-white border border-gray-200 p-4 rounded-xl cursor-pointer hover:shadow-lg hover:border-blue-400 transition-all group text-center"
|
| 346 |
+
>
|
| 347 |
+
<div className={`w-12 h-12 mx-auto rounded-full flex items-center justify-center font-bold text-white text-lg mb-2 ${s.gender === 'Female' ? 'bg-pink-400' : 'bg-blue-400'}`}>
|
| 348 |
+
{s.name[0]}
|
| 349 |
+
</div>
|
| 350 |
+
<h4 className="font-bold text-gray-800 group-hover:text-blue-600 truncate">{s.name}</h4>
|
| 351 |
+
<p className="text-xs text-gray-500 font-mono truncate">{s.studentNo}</p>
|
| 352 |
+
</div>
|
| 353 |
+
))}
|
| 354 |
+
{focusStudents.length === 0 && <div className="col-span-full text-center py-10 text-gray-400">请先选择班级</div>}
|
| 355 |
+
</div>
|
| 356 |
+
) : (
|
| 357 |
+
<div className="space-y-6">
|
| 358 |
+
<button onClick={() => setSelectedStudent(null)} className="text-sm text-blue-600 hover:underline mb-4">← 返回学生列表</button>
|
| 359 |
+
<div className="bg-gray-50 p-4 rounded-xl border border-gray-200 mb-6 flex items-center gap-4">
|
| 360 |
+
<div className={`w-16 h-16 rounded-full flex items-center justify-center text-2xl font-bold text-white ${selectedStudent.gender === 'Female' ? 'bg-pink-500' : 'bg-blue-500'}`}>
|
| 361 |
+
{selectedStudent.name[0]}
|
| 362 |
+
</div>
|
| 363 |
+
<div>
|
| 364 |
+
<h2 className="text-2xl font-bold text-gray-900">{selectedStudent.name}</h2>
|
| 365 |
+
<p className="text-gray-500">{selectedStudent.className} | {selectedStudent.studentNo}</p>
|
| 366 |
+
</div>
|
| 367 |
+
</div>
|
| 368 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
| 369 |
+
<div className="h-72 bg-white p-4 rounded-xl border shadow-sm">
|
| 370 |
+
<h3 className="text-center font-bold text-gray-700 mb-2">学科能力雷达</h3>
|
| 371 |
+
<ResponsiveContainer><RadarChart data={getStudentRadar(selectedStudent.studentNo)}><PolarGrid/><PolarAngleAxis dataKey="subject"/><PolarRadiusAxis angle={30} domain={[0,100]}/><Radar dataKey="score" stroke="#8884d8" fill="#8884d8" fillOpacity={0.6}/><Tooltip/></RadarChart></ResponsiveContainer>
|
| 372 |
+
</div>
|
| 373 |
+
<div className="h-72 bg-white p-4 rounded-xl border shadow-sm">
|
| 374 |
+
<h3 className="text-center font-bold text-gray-700 mb-2">成绩走势</h3>
|
| 375 |
+
<ResponsiveContainer><LineChart data={getStudentTrend(selectedStudent.studentNo)}><CartesianGrid vertical={false}/><XAxis dataKey="name"/><YAxis domain={[0,100]}/><Tooltip/><Line type="monotone" dataKey="score" stroke="#2563eb" strokeWidth={3}/></LineChart></ResponsiveContainer>
|
| 376 |
+
</div>
|
| 377 |
+
</div>
|
| 378 |
+
</div>
|
| 379 |
+
)}
|
| 380 |
+
</div>
|
| 381 |
+
)}
|
| 382 |
+
</div>
|
| 383 |
+
</div>
|
| 384 |
+
);
|
| 385 |
+
};
|
server.js
CHANGED
|
@@ -786,10 +786,42 @@ app.post('/api/games/lucky-draw', async (req, res) => {
|
|
| 786 |
}
|
| 787 |
});
|
| 788 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 789 |
app.get('/api/rewards', async (req, res) => {
|
| 790 |
-
const { studentId } = req.query;
|
| 791 |
-
const filter =
|
| 792 |
-
if (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 793 |
res.json(await StudentRewardModel.find(filter).sort({ createTime: -1 }));
|
| 794 |
});
|
| 795 |
app.post('/api/rewards', async (req, res) => {
|
|
|
|
| 786 |
}
|
| 787 |
});
|
| 788 |
|
| 789 |
+
// Grant Draw Count Manually
|
| 790 |
+
app.post('/api/games/grant-draw', async (req, res) => {
|
| 791 |
+
const { studentId, count } = req.body;
|
| 792 |
+
const schoolId = req.headers['x-school-id'];
|
| 793 |
+
try {
|
| 794 |
+
if(InMemoryDB.isFallback) return res.json({success:true});
|
| 795 |
+
|
| 796 |
+
const student = await Student.findById(studentId);
|
| 797 |
+
if(!student) return res.status(404).json({error: '学生未找到'});
|
| 798 |
+
|
| 799 |
+
await Student.findByIdAndUpdate(studentId, { $inc: { drawAttempts: count } });
|
| 800 |
+
|
| 801 |
+
// Log as reward
|
| 802 |
+
await StudentRewardModel.create({
|
| 803 |
+
schoolId,
|
| 804 |
+
studentId,
|
| 805 |
+
studentName: student.name,
|
| 806 |
+
rewardType: 'DRAW_COUNT',
|
| 807 |
+
name: `老师发放 ${count} 次`,
|
| 808 |
+
status: 'REDEEMED',
|
| 809 |
+
source: '教师发放'
|
| 810 |
+
});
|
| 811 |
+
|
| 812 |
+
res.json({ success: true });
|
| 813 |
+
} catch(e) { res.status(500).json({error: e.message}); }
|
| 814 |
+
});
|
| 815 |
+
|
| 816 |
app.get('/api/rewards', async (req, res) => {
|
| 817 |
+
const { studentId, scope } = req.query;
|
| 818 |
+
const filter = getQueryFilter(req);
|
| 819 |
+
if (studentId) filter.studentId = studentId;
|
| 820 |
+
|
| 821 |
+
// If teacher requests class scope (in frontend we usually filter, but backend support helps)
|
| 822 |
+
// NOTE: Simple implementation: return all school rewards for teacher role, let frontend filter by class for now, or improve query later.
|
| 823 |
+
|
| 824 |
+
if (InMemoryDB.isFallback) return res.json(InMemoryDB.rewards.filter(r => !studentId || r.studentId === studentId));
|
| 825 |
res.json(await StudentRewardModel.find(filter).sort({ createTime: -1 }));
|
| 826 |
});
|
| 827 |
app.post('/api/rewards', async (req, res) => {
|
services/api.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
|
|
| 1 |
import { User, ClassInfo, SystemConfig, Subject, School, Schedule, GameSession, StudentReward, LuckyDrawConfig } from '../types';
|
| 2 |
|
| 3 |
const getBaseUrl = () => {
|
|
@@ -169,9 +170,12 @@ export const api = {
|
|
| 169 |
getLuckyConfig: () => request('/games/lucky-config'),
|
| 170 |
saveLuckyConfig: (data: LuckyDrawConfig) => request('/games/lucky-config', { method: 'POST', body: JSON.stringify(data) }),
|
| 171 |
drawLucky: (studentId: string) => request('/games/lucky-draw', { method: 'POST', body: JSON.stringify({ studentId }) }),
|
|
|
|
| 172 |
},
|
| 173 |
rewards: {
|
| 174 |
getMyRewards: (studentId: string) => request(`/rewards?studentId=${studentId}`),
|
|
|
|
|
|
|
| 175 |
addReward: (data: Partial<StudentReward>) => request('/rewards', { method: 'POST', body: JSON.stringify(data) }),
|
| 176 |
redeem: (id: string) => request(`/rewards/${id}/redeem`, { method: 'POST' }),
|
| 177 |
consumeDraw: (studentId: string) => request(`/rewards/consume-draw`, { method: 'POST', body: JSON.stringify({ studentId }) })
|
|
@@ -180,4 +184,4 @@ export const api = {
|
|
| 180 |
batchDelete: (type: 'student' | 'score' | 'user', ids: string[]) => {
|
| 181 |
return request('/batch-delete', { method: 'POST', body: JSON.stringify({ type, ids }) });
|
| 182 |
}
|
| 183 |
-
};
|
|
|
|
| 1 |
+
|
| 2 |
import { User, ClassInfo, SystemConfig, Subject, School, Schedule, GameSession, StudentReward, LuckyDrawConfig } from '../types';
|
| 3 |
|
| 4 |
const getBaseUrl = () => {
|
|
|
|
| 170 |
getLuckyConfig: () => request('/games/lucky-config'),
|
| 171 |
saveLuckyConfig: (data: LuckyDrawConfig) => request('/games/lucky-config', { method: 'POST', body: JSON.stringify(data) }),
|
| 172 |
drawLucky: (studentId: string) => request('/games/lucky-draw', { method: 'POST', body: JSON.stringify({ studentId }) }),
|
| 173 |
+
grantDrawCount: (studentId: string, count: number) => request('/games/grant-draw', { method: 'POST', body: JSON.stringify({ studentId, count }) }),
|
| 174 |
},
|
| 175 |
rewards: {
|
| 176 |
getMyRewards: (studentId: string) => request(`/rewards?studentId=${studentId}`),
|
| 177 |
+
// For teachers to get class rewards, we can use the same endpoint but passing a filter in backend or allow listing all
|
| 178 |
+
getClassRewards: () => request('/rewards?scope=class'),
|
| 179 |
addReward: (data: Partial<StudentReward>) => request('/rewards', { method: 'POST', body: JSON.stringify(data) }),
|
| 180 |
redeem: (id: string) => request(`/rewards/${id}/redeem`, { method: 'POST' }),
|
| 181 |
consumeDraw: (studentId: string) => request(`/rewards/consume-draw`, { method: 'POST', body: JSON.stringify({ studentId }) })
|
|
|
|
| 184 |
batchDelete: (type: 'student' | 'score' | 'user', ids: string[]) => {
|
| 185 |
return request('/batch-delete', { method: 'POST', body: JSON.stringify({ type, ids }) });
|
| 186 |
}
|
| 187 |
+
};
|