| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212 |
- "use client";
- import { useMemo, Suspense } from "react";
- import Link from "next/link";
- import { useSearchParams } from "next/navigation";
- import { Target, ShieldCheck, Cpu, Code2, User } from "lucide-react";
- import { metricsData } from "@/components/metricsData";
- import { getMockStudentById } from "@/lib/mockApi";
- import { useAuth } from "@/components/AuthProvider";
- function AnalysisDashboardContent() {
- const searchParams = useSearchParams();
- const { user } = useAuth();
-
- // If there's a ?student= param in the URL (from Teacher Dashboard drill-down), use it.
- // Otherwise, if we are logged in as a student, use our own ID.
- const studentId = searchParams.get("student") || (user?.role === 'stu' ? user.id : null);
- const student = studentId ? getMockStudentById(studentId) : null;
- // Helper function to colorize cells like a thermal heatmap based on parsed average score
- const getHeatmapClass = (scoreStr: string | number) => {
- const score = typeof scoreStr === 'string' ? parseInt(scoreStr) : scoreStr;
- if (isNaN(score)) return "bg-surface-variant text-on-surface-variant border-outline-variant";
- if (score >= 90) return "bg-metric-high dark:bg-[#004d40] text-white border-metric-high/30 dark:border-white/10 shadow-[0_0_15px_rgba(var(--color-metric-high),0.3)] dark:shadow-none";
- if (score >= 80) return "bg-metric-mid dark:bg-[#01579b] text-white border-metric-mid/30 dark:border-white/10";
- if (score >= 70) return "bg-metric-warn dark:bg-[#d84315] text-white border-metric-warn/30 dark:border-white/10";
- return "bg-metric-low dark:bg-[#b71c1c] text-white border-metric-low/30 dark:border-white/10";
- };
- // When viewing a specific student, override each metric's avgScore
- // with the pre-generated score from the database. No random jitter.
- const displayData = useMemo(() => {
- if (!student) return metricsData; // Default class average view
- const personalizedData = JSON.parse(JSON.stringify(metricsData)) as typeof metricsData;
-
- Object.keys(personalizedData).forEach((catKey) => {
- personalizedData[catKey].forEach(item => {
- // Read the pre-generated score from the student's metricScores
- const storedScore = student.metricScores?.[item.id];
- if (storedScore !== undefined) {
- item.avgScore = storedScore.toString();
- }
- });
- });
- return personalizedData;
- }, [student]);
- // Pre-calculate dimensional averages for the top macro view
- // When viewing a specific student, use their EXACT scores from the database
- // to ensure consistency with the roster table.
- const categoryStats = useMemo(() => {
- const stats: Record<string, { avg: number; total: number }> = {};
- Object.keys(displayData).forEach((cat) => {
- const items = displayData[cat];
- if (student) {
- // Use the student's EXACT macro score (same number shown in the roster)
- stats[cat] = {
- avg: student[cat as keyof typeof student] as number,
- total: items.length
- };
- } else {
- // Class average view: compute from metricsData sub-items
- let sum = 0;
- let count = 0;
- items.forEach((item) => {
- const itemScore = parseInt(item.avgScore) || 0;
- sum += itemScore;
- count++;
- });
- stats[cat] = {
- avg: count > 0 ? Math.round(sum / count) : 0,
- total: count
- };
- }
- });
- return stats;
- }, [displayData, student]);
- const icons = {
- K: <Target className="w-10 h-10 mb-3 opacity-80" />,
- A: <Cpu className="w-10 h-10 mb-3 opacity-80" />,
- S: <Code2 className="w-10 h-10 mb-3 opacity-80" />,
- D: <ShieldCheck className="w-10 h-10 mb-3 opacity-80" />
- };
- const titles = {
- K: "知识掌握",
- A: "AI 辅助",
- S: "软件工程",
- D: "态度与协作"
- };
- return (
- <div className="flex-1 overflow-y-auto p-4 md:p-8 max-w-[1400px] mx-auto w-full space-y-8 animate-in fade-in">
- {/* Header Container */}
- <div className="flex flex-col md:flex-row md:items-center justify-between gap-4 pb-6">
- <div>
- <h1 className="text-3xl font-display font-medium text-on-surface flex items-center">
- 能力分析雷达 {student && <User className="inline-block ml-3 w-6 h-6 text-primary" />}
- </h1>
- <p className="text-secondary text-sm mt-1">
- {student ? "个体学生多维度能力结构深度诊断" : "多维度能力结构诊断热力图 (大盘基准)"}
- </p>
- </div>
-
- {/* Dynamic Context Banner if Drilling Down */}
- {student && (
- <div className="bg-primary/10 border border-primary/20 p-3 lg:px-6 rounded-2xl flex items-center animate-in slide-in-from-right-4">
- <div className="w-10 h-10 rounded-full bg-primary/20 flex items-center justify-center text-primary font-bold text-lg mr-3 shadow-inner">
- {student.name[0]}
- </div>
- <div>
- <div className="text-xs text-primary font-bold tracking-wider uppercase">正在深入诊断</div>
- <div className="text-on-surface font-medium text-sm lg:text-base">
- {student.name} <span className="text-outline font-mono ml-1">{student.id}</span>
- </div>
- </div>
- </div>
- )}
- </div>
- {/* Top Row: Macro View (4 Big Cards) */}
- <section className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
- {Object.keys(displayData).map((catKey) => {
- const firstItem = displayData[catKey][0];
- const stat = categoryStats[catKey];
- return (
- <div key={catKey} className="relative overflow-hidden bg-surface-container rounded-3xl p-6 shadow-elevation-1 border border-outline-variant/50 hover:shadow-elevation-2 transition-all group">
- {/* Ambient Background Gradient */}
- <div className="absolute -right-10 -top-10 w-32 h-32 rounded-full opacity-10 blur-2xl transition-all group-hover:opacity-20"
- style={{ backgroundColor: `var(--color-${firstItem.colorClass})` }}></div>
-
- <div className="relative z-10 flex flex-col h-full justify-between">
- <div>
- <div style={{ color: `var(--color-${firstItem.colorClass})` }}>
- {icons[catKey as keyof typeof icons]}
- </div>
- <h3 className="text-xl font-bold text-on-surface">{titles[catKey as keyof typeof titles]}</h3>
- <p className="text-xs text-secondary mt-1 tracking-widest uppercase">{stat.total} 项考核指标</p>
- </div>
- <div className="flex items-end justify-between mt-6">
- <span className="text-5xl font-display font-bold tracking-tight text-on-surface">
- {stat.avg}<span className="text-xl text-secondary ml-1">%</span>
- </span>
- <span className={`px-2 py-1 rounded text-xs font-bold ${getHeatmapClass(stat.avg.toString())}`}>
- {stat.avg >= 85 ? '优秀' : stat.avg >= 75 ? '良好' : '需改进'}
- </span>
- </div>
- </div>
- </div>
- );
- })}
- </section>
- {/* Main Area: The Bento Box Heatmap Grid */}
- <section className="space-y-6">
- <h2 className="text-2xl font-medium text-on-surface">{student ? `${student.name} 的全景弱点追踪` : "能力全景分布"}</h2>
- <div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
- {Object.keys(displayData).map((catKey) => (
- <div key={`bento-${catKey}`} className="bg-surface rounded-3xl p-6 shadow-elevation-1 border border-outline-variant/30 flex flex-col">
- <div className="flex items-center justify-between mb-6">
- <h3 className="text-lg font-bold text-on-surface flex items-center gap-2">
- <span className="w-8 h-8 rounded-lg bg-surface-variant flex items-center justify-center text-sm font-black">{catKey}</span>
- {titles[catKey as keyof typeof titles]} 拆解项
- </h3>
- </div>
- {/* The Micro-Tiles Grid Layer */}
- <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
- {displayData[catKey].map((metric) => (
- <Link
- key={metric.id}
- href={`/metrics/${metric.id.toLowerCase()}${studentId ? `?student=${studentId}` : ''}`}
- className={`relative group flex flex-col justify-between p-4 min-h-[110px] rounded-2xl border transition-all hover:-translate-y-1 hover:shadow-lg cursor-pointer overflow-hidden ${getHeatmapClass(metric.avgScore)}`}
- >
- {/* Glossy Overlay for that Glassmorphism feel */}
- <div className="absolute inset-0 bg-white/10 opacity-0 group-hover:opacity-100 transition-opacity"></div>
-
- <div className="flex justify-between items-start z-10">
- <span className="font-black text-lg tracking-tight mix-blend-overlay opacity-90">{metric.id}</span>
- <span className="font-bold text-sm bg-black/20 px-2 py-0.5 rounded backdrop-blur-sm">{metric.avgScore}</span>
- </div>
- <div className="z-10 mt-auto pt-4 leading-tight">
- <p className="text-xs font-medium text-white/90 line-clamp-2 drop-shadow-sm">{metric.name}</p>
- </div>
- </Link>
- ))}
- </div>
- </div>
- ))}
- </div>
- </section>
-
- <div className="h-10"></div>
- </div>
- );
- }
- // Wrap in suspense to handle useSearchParams appropriately in Next.js 14+ client components
- export default function AnalysisDashboard() {
- return (
- <Suspense fallback={
- <div className="flex-1 h-full w-full flex items-center justify-center p-8 text-secondary">
- <div className="w-8 h-8 animate-spin border-4 border-primary border-t-transparent rounded-full mr-3"></div>
- <span className="font-bold animate-pulse">正在提取分析晶元数据...</span>
- </div>
- }>
- <AnalysisDashboardContent />
- </Suspense>
- );
- }
|