AnalysisDashboard.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212
  1. "use client";
  2. import { useMemo, Suspense } from "react";
  3. import Link from "next/link";
  4. import { useSearchParams } from "next/navigation";
  5. import { Target, ShieldCheck, Cpu, Code2, User } from "lucide-react";
  6. import { metricsData } from "@/components/metricsData";
  7. import { getMockStudentById } from "@/lib/mockApi";
  8. import { useAuth } from "@/components/AuthProvider";
  9. function AnalysisDashboardContent() {
  10. const searchParams = useSearchParams();
  11. const { user } = useAuth();
  12. // If there's a ?student= param in the URL (from Teacher Dashboard drill-down), use it.
  13. // Otherwise, if we are logged in as a student, use our own ID.
  14. const studentId = searchParams.get("student") || (user?.role === 'stu' ? user.id : null);
  15. const student = studentId ? getMockStudentById(studentId) : null;
  16. // Helper function to colorize cells like a thermal heatmap based on parsed average score
  17. const getHeatmapClass = (scoreStr: string | number) => {
  18. const score = typeof scoreStr === 'string' ? parseInt(scoreStr) : scoreStr;
  19. if (isNaN(score)) return "bg-surface-variant text-on-surface-variant border-outline-variant";
  20. 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";
  21. if (score >= 80) return "bg-metric-mid dark:bg-[#01579b] text-white border-metric-mid/30 dark:border-white/10";
  22. if (score >= 70) return "bg-metric-warn dark:bg-[#d84315] text-white border-metric-warn/30 dark:border-white/10";
  23. return "bg-metric-low dark:bg-[#b71c1c] text-white border-metric-low/30 dark:border-white/10";
  24. };
  25. // When viewing a specific student, override each metric's avgScore
  26. // with the pre-generated score from the database. No random jitter.
  27. const displayData = useMemo(() => {
  28. if (!student) return metricsData; // Default class average view
  29. const personalizedData = JSON.parse(JSON.stringify(metricsData)) as typeof metricsData;
  30. Object.keys(personalizedData).forEach((catKey) => {
  31. personalizedData[catKey].forEach(item => {
  32. // Read the pre-generated score from the student's metricScores
  33. const storedScore = student.metricScores?.[item.id];
  34. if (storedScore !== undefined) {
  35. item.avgScore = storedScore.toString();
  36. }
  37. });
  38. });
  39. return personalizedData;
  40. }, [student]);
  41. // Pre-calculate dimensional averages for the top macro view
  42. // When viewing a specific student, use their EXACT scores from the database
  43. // to ensure consistency with the roster table.
  44. const categoryStats = useMemo(() => {
  45. const stats: Record<string, { avg: number; total: number }> = {};
  46. Object.keys(displayData).forEach((cat) => {
  47. const items = displayData[cat];
  48. if (student) {
  49. // Use the student's EXACT macro score (same number shown in the roster)
  50. stats[cat] = {
  51. avg: student[cat as keyof typeof student] as number,
  52. total: items.length
  53. };
  54. } else {
  55. // Class average view: compute from metricsData sub-items
  56. let sum = 0;
  57. let count = 0;
  58. items.forEach((item) => {
  59. const itemScore = parseInt(item.avgScore) || 0;
  60. sum += itemScore;
  61. count++;
  62. });
  63. stats[cat] = {
  64. avg: count > 0 ? Math.round(sum / count) : 0,
  65. total: count
  66. };
  67. }
  68. });
  69. return stats;
  70. }, [displayData, student]);
  71. const icons = {
  72. K: <Target className="w-10 h-10 mb-3 opacity-80" />,
  73. A: <Cpu className="w-10 h-10 mb-3 opacity-80" />,
  74. S: <Code2 className="w-10 h-10 mb-3 opacity-80" />,
  75. D: <ShieldCheck className="w-10 h-10 mb-3 opacity-80" />
  76. };
  77. const titles = {
  78. K: "知识掌握",
  79. A: "AI 辅助",
  80. S: "软件工程",
  81. D: "态度与协作"
  82. };
  83. return (
  84. <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">
  85. {/* Header Container */}
  86. <div className="flex flex-col md:flex-row md:items-center justify-between gap-4 pb-6">
  87. <div>
  88. <h1 className="text-3xl font-display font-medium text-on-surface flex items-center">
  89. 能力分析雷达 {student && <User className="inline-block ml-3 w-6 h-6 text-primary" />}
  90. </h1>
  91. <p className="text-secondary text-sm mt-1">
  92. {student ? "个体学生多维度能力结构深度诊断" : "多维度能力结构诊断热力图 (大盘基准)"}
  93. </p>
  94. </div>
  95. {/* Dynamic Context Banner if Drilling Down */}
  96. {student && (
  97. <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">
  98. <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">
  99. {student.name[0]}
  100. </div>
  101. <div>
  102. <div className="text-xs text-primary font-bold tracking-wider uppercase">正在深入诊断</div>
  103. <div className="text-on-surface font-medium text-sm lg:text-base">
  104. {student.name} <span className="text-outline font-mono ml-1">{student.id}</span>
  105. </div>
  106. </div>
  107. </div>
  108. )}
  109. </div>
  110. {/* Top Row: Macro View (4 Big Cards) */}
  111. <section className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
  112. {Object.keys(displayData).map((catKey) => {
  113. const firstItem = displayData[catKey][0];
  114. const stat = categoryStats[catKey];
  115. return (
  116. <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">
  117. {/* Ambient Background Gradient */}
  118. <div className="absolute -right-10 -top-10 w-32 h-32 rounded-full opacity-10 blur-2xl transition-all group-hover:opacity-20"
  119. style={{ backgroundColor: `var(--color-${firstItem.colorClass})` }}></div>
  120. <div className="relative z-10 flex flex-col h-full justify-between">
  121. <div>
  122. <div style={{ color: `var(--color-${firstItem.colorClass})` }}>
  123. {icons[catKey as keyof typeof icons]}
  124. </div>
  125. <h3 className="text-xl font-bold text-on-surface">{titles[catKey as keyof typeof titles]}</h3>
  126. <p className="text-xs text-secondary mt-1 tracking-widest uppercase">{stat.total} 项考核指标</p>
  127. </div>
  128. <div className="flex items-end justify-between mt-6">
  129. <span className="text-5xl font-display font-bold tracking-tight text-on-surface">
  130. {stat.avg}<span className="text-xl text-secondary ml-1">%</span>
  131. </span>
  132. <span className={`px-2 py-1 rounded text-xs font-bold ${getHeatmapClass(stat.avg.toString())}`}>
  133. {stat.avg >= 85 ? '优秀' : stat.avg >= 75 ? '良好' : '需改进'}
  134. </span>
  135. </div>
  136. </div>
  137. </div>
  138. );
  139. })}
  140. </section>
  141. {/* Main Area: The Bento Box Heatmap Grid */}
  142. <section className="space-y-6">
  143. <h2 className="text-2xl font-medium text-on-surface">{student ? `${student.name} 的全景弱点追踪` : "能力全景分布"}</h2>
  144. <div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
  145. {Object.keys(displayData).map((catKey) => (
  146. <div key={`bento-${catKey}`} className="bg-surface rounded-3xl p-6 shadow-elevation-1 border border-outline-variant/30 flex flex-col">
  147. <div className="flex items-center justify-between mb-6">
  148. <h3 className="text-lg font-bold text-on-surface flex items-center gap-2">
  149. <span className="w-8 h-8 rounded-lg bg-surface-variant flex items-center justify-center text-sm font-black">{catKey}</span>
  150. {titles[catKey as keyof typeof titles]} 拆解项
  151. </h3>
  152. </div>
  153. {/* The Micro-Tiles Grid Layer */}
  154. <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
  155. {displayData[catKey].map((metric) => (
  156. <Link
  157. key={metric.id}
  158. href={`/metrics/${metric.id.toLowerCase()}${studentId ? `?student=${studentId}` : ''}`}
  159. 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)}`}
  160. >
  161. {/* Glossy Overlay for that Glassmorphism feel */}
  162. <div className="absolute inset-0 bg-white/10 opacity-0 group-hover:opacity-100 transition-opacity"></div>
  163. <div className="flex justify-between items-start z-10">
  164. <span className="font-black text-lg tracking-tight mix-blend-overlay opacity-90">{metric.id}</span>
  165. <span className="font-bold text-sm bg-black/20 px-2 py-0.5 rounded backdrop-blur-sm">{metric.avgScore}</span>
  166. </div>
  167. <div className="z-10 mt-auto pt-4 leading-tight">
  168. <p className="text-xs font-medium text-white/90 line-clamp-2 drop-shadow-sm">{metric.name}</p>
  169. </div>
  170. </Link>
  171. ))}
  172. </div>
  173. </div>
  174. ))}
  175. </div>
  176. </section>
  177. <div className="h-10"></div>
  178. </div>
  179. );
  180. }
  181. // Wrap in suspense to handle useSearchParams appropriately in Next.js 14+ client components
  182. export default function AnalysisDashboard() {
  183. return (
  184. <Suspense fallback={
  185. <div className="flex-1 h-full w-full flex items-center justify-center p-8 text-secondary">
  186. <div className="w-8 h-8 animate-spin border-4 border-primary border-t-transparent rounded-full mr-3"></div>
  187. <span className="font-bold animate-pulse">正在提取分析晶元数据...</span>
  188. </div>
  189. }>
  190. <AnalysisDashboardContent />
  191. </Suspense>
  192. );
  193. }