1
0
Переглянути джерело

feat: Implement teacher dashboard, authentication, and metric analysis features with a new application structure.

Insouciant21 6 місяців тому
батько
коміт
37f31cab3d

+ 99 - 30
app/analysis/AnalysisDashboard.tsx → app/(dashboard)/analysis/AnalysisDashboard.tsx

@@ -1,15 +1,20 @@
 "use client";
 
-import { useMemo } from "react";
+import { useMemo, Suspense } from "react";
 import Link from "next/link";
-import { Target, ShieldCheck, Cpu, Code2 } from "lucide-react";
+import { useSearchParams } from "next/navigation";
+import { Target, ShieldCheck, Cpu, Code2, User } from "lucide-react";
 import { metricsData } from "@/components/metricsData";
+import { getMockStudentById } from "@/lib/mockApi";
 
-export default function AnalysisDashboard() {
+function AnalysisDashboardContent() {
+  const searchParams = useSearchParams();
+  const studentId = searchParams.get("student");
+  const student = studentId ? getMockStudentById(studentId) : null;
 
   // Helper function to colorize cells like a thermal heatmap based on parsed average score
-  const getHeatmapClass = (scoreStr: string) => {
-    const score = parseInt(scoreStr);
+  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";
@@ -17,25 +22,56 @@ export default function AnalysisDashboard() {
     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(metricsData).forEach((cat) => {
-      const items = metricsData[cat];
-      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
-      };
+    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" />,
@@ -52,19 +88,38 @@ export default function AnalysisDashboard() {
   };
 
   return (
-    <div className="flex-1 overflow-y-auto p-4 md:p-8 max-w-[1400px] mx-auto w-full space-y-8">
-      {/* Header */}
-      <div className="flex items-center gap-4 pb-6">
+    <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">能力分析雷达</h1>
-          <p className="text-secondary text-sm mt-1">多维度能力结构诊断热力图</p>
+          <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(metricsData).map((catKey) => {
-            const firstItem = metricsData[catKey][0];
+        {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">
@@ -96,9 +151,9 @@ export default function AnalysisDashboard() {
 
       {/* Main Area: The Bento Box Heatmap Grid */}
       <section className="space-y-6">
-         <h2 className="text-2xl font-medium text-on-surface">能力全景分布</h2>
+         <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(metricsData).map((catKey) => (
+           {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">
@@ -108,10 +163,10 @@ export default function AnalysisDashboard() {
                  </div>
                  {/* The Micro-Tiles Grid Layer */}
                  <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
-                    {metricsData[catKey].map((metric) => (
+                    {displayData[catKey].map((metric) => (
                        <Link 
                           key={metric.id}
-                          href={`/metrics/${metric.id.toLowerCase()}`}
+                          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 */}
@@ -136,3 +191,17 @@ export default function AnalysisDashboard() {
     </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>
+  );
+}

+ 5 - 0
app/(dashboard)/analysis/page.tsx

@@ -0,0 +1,5 @@
+import AnalysisDashboard from "./AnalysisDashboard";
+
+export default function AnalysisPage() {
+  return <AnalysisDashboard />;
+}

+ 6 - 3
app/analysis/page.tsx → app/(dashboard)/layout.tsx

@@ -1,14 +1,17 @@
 import Sidebar from "@/components/Sidebar";
 import Header from "@/components/Header";
-import AnalysisDashboard from "./AnalysisDashboard";
 
-export default function AnalysisPage() {
+export default function DashboardLayout({
+  children,
+}: {
+  children: React.ReactNode;
+}) {
   return (
     <div className="flex h-screen w-full bg-background text-on-background font-sans antialiased overflow-hidden selection:bg-primary selection:text-on-primary">
       <Sidebar />
       <main className="flex-1 flex flex-col h-screen overflow-hidden relative bg-background">
         <Header />
-        <AnalysisDashboard />
+        {children}
       </main>
     </div>
   );

+ 18 - 0
app/(dashboard)/metrics/[id]/page.tsx

@@ -0,0 +1,18 @@
+import { Suspense } from "react";
+import MetricsContent from "@/components/MetricsContent";
+
+type Props = {
+  params: Promise<{ id: string }>;
+};
+
+export default async function MetricsDetail({ params }: Props) {
+  const { id } = await params;
+  
+  return (
+    <div className="flex-1 overflow-y-auto bg-background text-on-background transition-colors duration-200">
+      <Suspense>
+        <MetricsContent metricId={id.toUpperCase()} />
+      </Suspense>
+    </div>
+  );
+}

+ 27 - 0
app/(dashboard)/page.tsx

@@ -0,0 +1,27 @@
+"use client";
+
+import DashboardContent from "@/components/DashboardContent";
+import TeacherDashboard from "@/components/TeacherDashboard";
+import { useAuth } from "@/components/AuthProvider";
+import { Loader2 } from "lucide-react";
+
+export default function Home() {
+  const { user, isLoading } = useAuth();
+
+  if (isLoading) {
+    return (
+      <div className="flex-1 h-full w-full flex items-center justify-center p-8 text-secondary">
+        <Loader2 className="w-10 h-10 animate-spin mr-3 text-primary" />
+        <span className="font-bold text-lg animate-pulse">正在验证身份与加载数据门户...</span>
+      </div>
+    );
+  }
+
+  // Teacher specific view spanning aggregate stats
+  if (user?.role === "teacher") {
+    return <TeacherDashboard />;
+  }
+
+  // Fallback to default granular student view
+  return <DashboardContent />;
+}

+ 0 - 0
app/profile/page.tsx → app/(dashboard)/profile/page.tsx


+ 0 - 0
app/settings/page.tsx → app/(dashboard)/settings/page.tsx


+ 160 - 0
app/(dashboard)/students/page.tsx

@@ -0,0 +1,160 @@
+"use client";
+
+import { useAuth } from "@/components/AuthProvider";
+import { Loader2, Search, ArrowUpDown, ChevronRight } from "lucide-react";
+import Image from "next/image";
+import Link from "next/link";
+import { useState, useEffect } from "react";
+import { mockApi, MockStudent } from "@/lib/mockApi";
+
+export default function StudentsPage() {
+  const { user, isLoading } = useAuth();
+  const [searchTerm, setSearchTerm] = useState("");
+  const [students, setStudents] = useState<MockStudent[]>([]);
+  const [dataLoading, setDataLoading] = useState(true);
+  const [sortKey, setSortKey] = useState<'overall' | 'K' | 'A' | 'S' | 'D'>('overall');
+  const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc');
+
+  const toggleSort = (key: typeof sortKey) => {
+    if (sortKey === key) {
+      setSortDir(prev => prev === 'desc' ? 'asc' : 'desc');
+    } else {
+      setSortKey(key);
+      setSortDir('desc');
+    }
+  };
+
+  useEffect(() => {
+    mockApi.getStudents().then(data => {
+      setStudents(data);
+      setDataLoading(false);
+    });
+  }, []);
+
+  if (isLoading || dataLoading) {
+    return (
+      <div className="flex-1 h-full w-full flex items-center justify-center p-8 text-secondary">
+        <Loader2 className="w-10 h-10 animate-spin mr-3 text-primary" />
+        <span className="font-bold animate-pulse">加载学生档案中...</span>
+      </div>
+    );
+  }
+
+  if (user?.role !== "teacher") {
+    return (
+      <div className="flex-1 p-8 text-center text-metric-low mt-20">
+        <h2 className="text-2xl font-bold mb-2">访问受限 (403)</h2>
+        <p>您的账号权限级别({user?.role})无法访问学生管理面板。</p>
+      </div>
+    );
+  }
+
+  const filteredStudents = students
+    .filter(s => 
+      s.name.includes(searchTerm) || s.id.toLowerCase().includes(searchTerm.toLowerCase())
+    )
+    .sort((a, b) => {
+      const diff = a[sortKey] - b[sortKey];
+      return sortDir === 'desc' ? -diff : diff;
+    });
+
+  const getScoreBadge = (score: number) => {
+    if (score >= 90) return "bg-metric-high/10 text-metric-high border-metric-high/30";
+    if (score >= 80) return "bg-metric-mid/10 text-metric-mid border-metric-mid/30";
+    if (score >= 70) return "bg-metric-warn/10 text-metric-warn border-metric-warn/30";
+    return "bg-metric-low/10 text-metric-low border-metric-low/30";
+  };
+
+  return (
+    <div className="flex-1 overflow-y-auto p-4 lg:p-8 w-full bg-background animate-in fade-in">
+      
+      <div className="flex flex-col md:flex-row justify-between items-start md:items-center mb-8 gap-4">
+        <div>
+          <h1 className="text-3xl font-display font-bold text-on-surface">班级学生档案</h1>
+          <p className="text-secondary text-sm mt-1">软件工程2023级 (共 {students.length} 人)</p>
+        </div>
+        
+        <div className="relative w-full md:w-72">
+          <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-outline" />
+          <input 
+            type="text" 
+            placeholder="搜索姓名或学号..." 
+            value={searchTerm}
+            onChange={e => setSearchTerm(e.target.value)}
+            className="w-full bg-surface-container border border-outline-variant/50 rounded-full py-2 pl-9 pr-4 text-sm focus:ring-2 focus:ring-primary outline-none transition-all"
+          />
+        </div>
+      </div>
+
+      <div className="bg-surface rounded-3xl shadow-elevation-1 border border-outline-variant/30 overflow-hidden">
+        <div className="overflow-x-auto">
+          <table className="w-full text-left border-collapse">
+            <thead>
+              <tr className="bg-surface-container h-12 border-b border-outline-variant/30 text-secondary text-xs font-bold uppercase tracking-wider">
+                <th className="px-6 py-3 rounded-tl-3xl">学生基本信息</th>
+                <th className="px-6 py-3 cursor-pointer hover:text-primary transition-colors select-none" onClick={() => toggleSort('overall')}>
+                  <div className="flex items-center">综合评分 <ArrowUpDown className={`w-3 h-3 ml-1 ${sortKey === 'overall' ? 'text-primary' : ''}`} /></div>
+                </th>
+                <th className="px-4 py-3 text-center cursor-pointer hover:text-primary transition-colors select-none" onClick={() => toggleSort('K')}>
+                  <div className="flex items-center justify-center">知识掌握 (K) <ArrowUpDown className={`w-3 h-3 ml-1 ${sortKey === 'K' ? 'text-primary' : ''}`} /></div>
+                </th>
+                <th className="px-4 py-3 text-center cursor-pointer hover:text-primary transition-colors select-none" onClick={() => toggleSort('A')}>
+                  <div className="flex items-center justify-center">AI辅助 (A) <ArrowUpDown className={`w-3 h-3 ml-1 ${sortKey === 'A' ? 'text-primary' : ''}`} /></div>
+                </th>
+                <th className="px-4 py-3 text-center cursor-pointer hover:text-primary transition-colors select-none" onClick={() => toggleSort('S')}>
+                  <div className="flex items-center justify-center">工程能力 (S) <ArrowUpDown className={`w-3 h-3 ml-1 ${sortKey === 'S' ? 'text-primary' : ''}`} /></div>
+                </th>
+                <th className="px-4 py-3 text-center cursor-pointer hover:text-primary transition-colors select-none" onClick={() => toggleSort('D')}>
+                  <div className="flex items-center justify-center">态度协作 (D) <ArrowUpDown className={`w-3 h-3 ml-1 ${sortKey === 'D' ? 'text-primary' : ''}`} /></div>
+                </th>
+                <th className="px-6 py-3 rounded-tr-3xl text-right">操作</th>
+              </tr>
+            </thead>
+            <tbody className="divide-y divide-outline-variant/20">
+              {filteredStudents.map((stu) => (
+                <tr key={stu.id} className="hover:bg-surface-container-high/50 transition-colors group">
+                  <td className="px-6 py-4">
+                    <div className="flex items-center">
+                      <Image src={stu.avatar} alt="Avatar" width={36} height={36} className="rounded-lg object-contain bg-white border border-outline-variant/20 mr-3 shrink-0" />
+                      <div>
+                        <div className="font-bold text-on-surface text-sm">{stu.name}</div>
+                        <div className="text-xs text-outline font-mono">{stu.id}</div>
+                      </div>
+                    </div>
+                  </td>
+                  <td className="px-6 py-4">
+                    <span className={`px-2.5 py-1 rounded-full text-xs font-bold border ${getScoreBadge(stu.overall)}`}>
+                      {stu.overall} 分
+                    </span>
+                  </td>
+                  <td className="px-4 py-4 text-center font-mono text-sm text-secondary">{stu.K}</td>
+                  <td className="px-4 py-4 text-center font-mono text-sm text-secondary">{stu.A}</td>
+                  <td className="px-4 py-4 text-center font-mono text-sm text-secondary">{stu.S}</td>
+                  <td className="px-4 py-4 text-center font-mono text-sm text-secondary">{stu.D}</td>
+                  <td className="px-6 py-4 text-right">
+                    <Link 
+                      href={`/analysis?student=${stu.id}`} 
+                      className="inline-flex items-center justify-center p-2 text-primary hover:bg-primary-container rounded-lg transition-colors"
+                      title="查看能力雷达画像"
+                    >
+                      <ChevronRight className="w-5 h-5" />
+                    </Link>
+                  </td>
+                </tr>
+              ))}
+              {filteredStudents.length === 0 && (
+                <tr>
+                   <td colSpan={7} className="px-6 py-12 text-center text-secondary">
+                      无匹配结果,请尝试其他搜索词。
+                   </td>
+                </tr>
+              )}
+            </tbody>
+          </table>
+        </div>
+      </div>
+      
+      <div className="h-10"></div>
+    </div>
+  );
+}

+ 4 - 1
app/layout.tsx

@@ -2,6 +2,7 @@ import type { Metadata } from "next";
 import "./globals.css"; // Global styles
 import { ThemeProvider } from "@/components/ThemeProvider";
 import { LayoutProvider } from "@/components/LayoutProvider";
+import { AuthProvider } from "@/components/AuthProvider";
 
 export const metadata: Metadata = {
   title: "SEEC-Metrics 学生评价平台",
@@ -25,7 +26,9 @@ export default function RootLayout({
           enableSystem
           disableTransitionOnChange
         >
-          <LayoutProvider>{children}</LayoutProvider>
+          <AuthProvider>
+            <LayoutProvider>{children}</LayoutProvider>
+          </AuthProvider>
         </ThemeProvider>
       </body>
     </html>

+ 139 - 0
app/login/page.tsx

@@ -0,0 +1,139 @@
+"use client";
+
+import { useState } from "react";
+import { GraduationCap, ArrowRight, Loader2 } from "lucide-react";
+import { useAuth } from "@/components/AuthProvider";
+import { mockApi } from "@/lib/mockApi";
+import Image from "next/image";
+
+export default function LoginPage() {
+  const [username, setUsername] = useState("");
+  const [password, setPassword] = useState("");
+  const [error, setError] = useState("");
+  const [loading, setLoading] = useState(false);
+  const { login } = useAuth();
+
+  const handleLogin = async (e: React.FormEvent) => {
+    e.preventDefault();
+    setError("");
+    setLoading(true);
+
+    try {
+      const { token, user } = await mockApi.login(username, password);
+      login(token, user);
+    } catch (err: any) {
+      setError(err.message || "登录失败,请检查账号密码");
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  const autofill = (user: string) => {
+    setUsername(user);
+    setPassword("passwd");
+  };
+
+  return (
+    <div className="min-h-screen w-full flex items-center justify-center bg-background relative overflow-hidden selection:bg-primary selection:text-on-primary">
+      {/* Dynamic Background Elements */}
+      <div className="absolute top-[-10%] left-[-10%] w-[40%] h-[40%] bg-primary/20 rounded-full blur-[120px] mix-blend-multiply dark:mix-blend-lighten animate-blob"></div>
+      <div className="absolute bottom-[-10%] right-[-5%] w-[35%] h-[35%] bg-chart-4/20 rounded-full blur-[100px] mix-blend-multiply dark:mix-blend-lighten animate-blob animation-delay-2000"></div>
+      <div className="absolute top-[20%] right-[10%] w-[25%] h-[25%] bg-chart-1/20 rounded-full blur-[90px] mix-blend-multiply dark:mix-blend-lighten animate-blob animation-delay-4000"></div>
+
+      <div className="relative z-10 w-full max-w-md mx-4">
+        {/* Glassmorphic Card */}
+        <div className="bg-surface/60 dark:bg-surface/80 backdrop-blur-xl border border-white/20 dark:border-white/10 p-8 md:p-10 rounded-3xl shadow-elevation-3">
+          
+          <div className="flex flex-col items-center justify-center text-center mb-10">
+            <div className="w-16 h-16 bg-primary-container text-primary rounded-2xl flex items-center justify-center mb-4 shadow-inner ring-1 ring-white/50">
+              <GraduationCap className="w-8 h-8" />
+            </div>
+            <h1 className="text-3xl font-display font-bold text-on-surface tracking-tight">
+               欢迎登录
+            </h1>
+            <p className="text-secondary mt-2 text-sm">SEEC-Metrics 学生评价平台</p>
+          </div>
+
+          <form onSubmit={handleLogin} className="space-y-5">
+            <div>
+              <label className="block text-sm font-medium text-on-surface mb-1.5 ml-1">用户名</label>
+              <input
+                type="text"
+                value={username}
+                onChange={(e) => setUsername(e.target.value)}
+                className="w-full px-5 py-3.5 bg-surface-container-high/50 border border-outline-variant/50 rounded-xl focus:ring-2 focus:ring-primary focus:border-transparent outline-none transition-all placeholder:text-outline text-on-surface shadow-inner"
+                placeholder="请输入学号/工号 (stu 或 teacher)"
+                required
+              />
+            </div>
+
+            <div>
+              <label className="block text-sm font-medium text-on-surface mb-1.5 ml-1">密码</label>
+              <input
+                type="password"
+                value={password}
+                onChange={(e) => setPassword(e.target.value)}
+                className="w-full px-5 py-3.5 bg-surface-container-high/50 border border-outline-variant/50 rounded-xl focus:ring-2 focus:ring-primary focus:border-transparent outline-none transition-all placeholder:text-outline text-on-surface shadow-inner"
+                placeholder="请输入密码 (passwd)"
+                required
+              />
+            </div>
+
+            {error && (
+              <div className="text-metric-low text-sm font-medium bg-metric-low/10 px-4 py-3 rounded-xl flex items-center border border-metric-low/20">
+                <span className="mr-2">⚠️</span> {error}
+              </div>
+            )}
+
+            <button
+              type="submit"
+              disabled={loading}
+              className="w-full mt-2 py-4 px-4 bg-primary hover:bg-primary/90 text-on-primary font-bold rounded-xl shadow-lg hover:shadow-primary/30 transition-all flex justify-center items-center group disabled:opacity-70 disabled:cursor-not-allowed"
+            >
+              {loading ? (
+                <Loader2 className="w-5 h-5 animate-spin" />
+              ) : (
+                <>
+                  登录系统
+                  <ArrowRight className="w-5 h-5 ml-2 group-hover:translate-x-1 transition-transform" />
+                </>
+              )}
+            </button>
+          </form>
+
+          {/* Quick Mock Login Helpers */}
+          <div className="mt-8 pt-6 border-t border-outline-variant/30 text-center">
+             <p className="text-xs text-outline mb-3">Mock 账号</p>
+             <div className="flex gap-3 justify-center">
+                <button 
+                  type="button" 
+                  onClick={() => autofill('stu')}
+                  className="px-4 py-2 bg-surface-container text-xs font-medium text-on-surface rounded-lg hover:bg-surface-container-high transition-colors border border-outline-variant/50"
+                >
+                  学生账号 (stu)
+                </button>
+                <button 
+                  type="button" 
+                  onClick={() => autofill('teacher')}
+                  className="px-4 py-2 bg-surface-container text-xs font-medium text-on-surface rounded-lg hover:bg-surface-container-high transition-colors border border-outline-variant/50"
+                >
+                  教师账号 (teacher)
+                </button>
+             </div>
+          </div>
+        </div>
+        
+        {/* Footer Brand Banner */}
+        <div className="mt-8 flex justify-center opacity-50 contrast-125 saturate-0 group-hover:opacity-100 transition-opacity">
+            <Image 
+              src="/banner.png" 
+              alt="SEEC Metrics" 
+              width={140} 
+              height={35} 
+              className="object-contain"
+            />
+        </div>
+      </div>
+    </div>
+  );
+}

+ 0 - 17
app/metrics/[id]/page.tsx

@@ -1,17 +0,0 @@
-import MetricsHeader from "@/components/MetricsHeader";
-import MetricsContent from "@/components/MetricsContent";
-
-type Props = {
-  params: Promise<{ id: string }>;
-};
-
-export default async function MetricsDetail({ params }: Props) {
-  const { id } = await params;
-  
-  return (
-    <div className="bg-background text-on-background transition-colors duration-200 min-h-screen flex flex-col">
-      <MetricsHeader metricId={id.toUpperCase()} />
-      <MetricsContent metricId={id.toUpperCase()} />
-    </div>
-  );
-}

+ 0 - 15
app/page.tsx

@@ -1,15 +0,0 @@
-import Sidebar from "@/components/Sidebar";
-import Header from "@/components/Header";
-import DashboardContent from "@/components/DashboardContent";
-
-export default function Home() {
-  return (
-    <div className="flex h-screen w-full bg-background text-on-background font-sans antialiased overflow-hidden selection:bg-primary selection:text-on-primary">
-      <Sidebar />
-      <main className="flex-1 flex flex-col h-screen overflow-hidden relative bg-background">
-        <Header />
-        <DashboardContent />
-      </main>
-    </div>
-  );
-}

+ 0 - 5
app/students/page.tsx

@@ -1,5 +0,0 @@
-import Placeholder from "@/components/Placeholder";
-
-export default function StudentsPage() {
-  return <Placeholder title="学生管理" />;
-}

+ 100 - 0
components/AuthProvider.tsx

@@ -0,0 +1,100 @@
+"use client";
+
+import React, { createContext, useContext, useState, useEffect } from "react";
+import { User, mockApi } from "@/lib/mockApi";
+import { useRouter, usePathname } from "next/navigation";
+
+// Utility functions for client-side cookie management
+const getCookie = (name: string) => {
+  if (typeof document === 'undefined') return null;
+  const value = `; ${document.cookie}`;
+  const parts = value.split(`; ${name}=`);
+  if (parts.length === 2) return parts.pop()?.split(';').shift() || null;
+  return null;
+};
+
+const setCookie = (name: string, value: string, days = 7) => {
+  if (typeof document === 'undefined') return;
+  const date = new Date();
+  date.setTime(date.getTime() + (days * 24 * 60 * 60 * 1000));
+  const expires = `expires=${date.toUTCString()}`;
+  document.cookie = `${name}=${value};${expires};path=/`;
+};
+
+const deleteCookie = (name: string) => {
+  if (typeof document === 'undefined') return;
+  document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 UTC;path=/;`;
+};
+
+interface AuthContextType {
+  user: User | null;
+  isLoading: boolean;
+  login: (token: string, userData: User) => void;
+  logout: () => void;
+}
+
+const AuthContext = createContext<AuthContextType | undefined>(undefined);
+
+export function AuthProvider({ children }: { children: React.ReactNode }) {
+  const [user, setUser] = useState<User | null>(null);
+  const [isLoading, setIsLoading] = useState(true);
+  const router = useRouter();
+  const pathname = usePathname();
+
+  useEffect(() => {
+    // Check for existing session via cookie
+    const initAuth = async () => {
+      const token = getCookie("auth_token");
+      if (token) {
+        try {
+          const validUser = await mockApi.verifySession(token);
+          if (validUser) {
+            setUser(validUser);
+            // Middleware handles redirect, but we can dual-enforce client side
+            if (pathname === '/login') {
+               router.replace('/');
+            }
+          } else {
+            deleteCookie("auth_token");
+            if (pathname !== '/login') router.replace('/login');
+          }
+        } catch (error) {
+          deleteCookie("auth_token");
+          if (pathname !== '/login') router.replace('/login');
+        }
+      } else {
+        // No token
+        if (pathname !== '/login') router.replace('/login');
+      }
+      setIsLoading(false);
+    };
+
+    initAuth();
+  }, [pathname, router]);
+
+  const login = (token: string, userData: User) => {
+    setCookie("auth_token", token);
+    setUser(userData);
+    router.replace('/');
+  };
+
+  const logout = () => {
+    deleteCookie("auth_token");
+    setUser(null);
+    router.replace('/login');
+  };
+
+  return (
+    <AuthContext.Provider value={{ user, isLoading, login, logout }}>
+      {children}
+    </AuthContext.Provider>
+  );
+}
+
+export function useAuth() {
+  const context = useContext(AuthContext);
+  if (context === undefined) {
+    throw new Error("useAuth must be used within an AuthProvider");
+  }
+  return context;
+}

+ 23 - 4
components/Header.tsx

@@ -1,24 +1,43 @@
 "use client";
 
-import { Menu, Search, Bell, HelpCircle, X } from "lucide-react";
+import { useState } from "react";
+import { Menu, Search, Bell, HelpCircle, X, ArrowLeft } from "lucide-react";
 import { ThemeToggle } from "@/components/ThemeToggle";
 import { useLayout } from "@/components/LayoutProvider";
-import { useState } from "react";
+import { usePathname, useRouter } from "next/navigation";
 import Image from "next/image";
 
 export default function Header() {
   const { setMobileMenuOpen } = useLayout();
   const [isSearchOpen, setIsSearchOpen] = useState(false);
+  const pathname = usePathname();
+  const router = useRouter();
+
+  // Only show the back button on pages NOT directly reachable via the sidebar
+  const sidebarPaths = ['/', '/analysis', '/students', '/settings'];
+  const showBack = !sidebarPaths.includes(pathname);
+
   return (
     <header className="h-20 flex items-center justify-between px-6 lg:px-8 border-b border-outline-variant/30 z-10 bg-background/95 backdrop-blur-sm sticky top-0">
-      <div className="flex items-center w-full max-w-xl">
+      <div className="flex items-center w-full max-w-xl gap-2">
         <button 
-          className="lg:hidden mr-4 shrink-0 text-secondary hover:text-on-surface transition-colors"
+          className="lg:hidden mr-2 shrink-0 text-secondary hover:text-on-surface transition-colors"
           onClick={() => setMobileMenuOpen(true)}
         >
           <Menu className="w-6 h-6" />
         </button>
 
+        {/* Back Button — visible on sub-pages */}
+        {showBack && (
+          <button
+            onClick={() => router.back()}
+            className="shrink-0 w-9 h-9 rounded-full flex items-center justify-center hover:bg-surface-container-high text-secondary hover:text-on-surface transition-colors cursor-pointer"
+            title="返回上一页"
+          >
+            <ArrowLeft className="w-5 h-5" />
+          </button>
+        )}
+
         {/* Mobile Banner */}
         <div className="lg:hidden flex-1 flex items-center">
           <Image 

+ 16 - 2
components/MetricsContent.tsx

@@ -6,10 +6,17 @@ import {
   Info,
   CheckCircle2,
   AlertTriangle,
+  User,
 } from "lucide-react";
+import { useSearchParams } from "next/navigation";
 import { metricsData } from "./metricsData";
+import { getMockStudentById } from "@/lib/mockApi";
 
 export default function MetricsContent({ metricId }: { metricId: string }) {
+  const searchParams = useSearchParams();
+  const studentId = searchParams.get("student");
+  const student = studentId ? getMockStudentById(studentId) : null;
+
   const categoryStr = metricId.charAt(0).toUpperCase();
   const metricCategoryList = metricsData[categoryStr] || [];
   const metricObj = metricCategoryList.find(m => m.id === metricId.toUpperCase());
@@ -22,8 +29,15 @@ export default function MetricsContent({ metricId }: { metricId: string }) {
     );
   }
 
-  // Parse average score out of "92%" string into a Number
-  const scoreNum = parseFloat(metricObj.avgScore) || 85.0;
+  // When viewing a specific student, use their pre-generated score for this metric.
+  // Otherwise use the static class average from metricsData.
+  let scoreNum = parseFloat(metricObj.avgScore) || 85.0;
+  if (student) {
+    const storedScore = student.metricScores?.[metricId.toUpperCase()];
+    if (storedScore !== undefined) {
+      scoreNum = storedScore;
+    }
+  }
 
   return (
     <main className="flex-grow p-4 md:p-8 max-w-7xl mx-auto w-full grid grid-cols-1 lg:grid-cols-12 gap-6">

+ 0 - 47
components/MetricsHeader.tsx

@@ -1,47 +0,0 @@
-"use client";
-
-import { ArrowLeft } from "lucide-react";
-import { useRouter } from "next/navigation";
-
-export default function MetricsHeader({ metricId }: { metricId: string }) {
-  const router = useRouter();
-
-  const getMetricTitle = (id: string) => {
-    const category = id.charAt(0).toUpperCase();
-    switch (category) {
-      case "K":
-        return "知识掌握指标详情";
-      case "A":
-        return "AI 辅助指标详情";
-      case "S":
-        return "软件工程指标详情";
-      case "D":
-        return "态度与协作指标详情";
-      default:
-        return "能力指标详情";
-    }
-  };
-
-  return (
-    <header className="h-16 px-4 md:px-6 flex items-center justify-between sticky top-0 z-50 bg-surface/90 backdrop-blur-md border-b border-outline-variant">
-      <div className="flex items-center gap-4">
-        <button
-          onClick={() => router.back()}
-          className="w-10 h-10 rounded-full flex items-center justify-center hover:bg-surface-variant transition-colors text-on-surface cursor-pointer"
-        >
-          <ArrowLeft className="w-6 h-6" />
-        </button>
-        <h1 className="text-xl font-medium tracking-tight text-on-surface">
-          {getMetricTitle(metricId)}
-        </h1>
-      </div>
-      <div className="flex items-center gap-3">
-        <div className="hidden md:flex items-center gap-2">
-          <span className="w-8 h-8 rounded-full bg-primary-container text-on-primary-container flex items-center justify-center text-sm font-bold">
-            67
-          </span>
-        </div>
-      </div>
-    </header>
-  );
-}

+ 1 - 9
components/Placeholder.tsx

@@ -1,7 +1,5 @@
 import { FileQuestion, Home } from "lucide-react";
 import Link from "next/link";
-import Header from "@/components/Header";
-import Sidebar from "@/components/Sidebar";
 
 interface PlaceholderProps {
   title: string;
@@ -9,11 +7,7 @@ interface PlaceholderProps {
 
 export default function Placeholder({ title }: PlaceholderProps) {
   return (
-    <div className="flex h-screen w-full bg-background text-on-background font-sans antialiased overflow-hidden">
-      <Sidebar />
-      <main className="flex-1 flex flex-col h-screen overflow-hidden relative bg-background">
-        <Header />
-        <div className="flex-1 flex flex-col items-center justify-center p-8 text-center animate-in fade-in duration-500">
+    <div className="flex-1 flex flex-col items-center justify-center p-8 text-center animate-in fade-in duration-500">
           <div className="w-24 h-24 bg-surface-container rounded-full flex items-center justify-center mb-6 shadow-elevation-1">
             <FileQuestion className="w-12 h-12 text-primary" />
           </div>
@@ -30,8 +24,6 @@ export default function Placeholder({ title }: PlaceholderProps) {
             <Home className="w-5 h-5" />
             返回总览面板
           </Link>
-        </div>
-      </main>
     </div>
   );
 }

+ 44 - 27
components/Sidebar.tsx

@@ -6,15 +6,25 @@ import {
   BarChart2,
   Users,
   Settings,
+  LogOut
 } from "lucide-react";
 import Image from "next/image";
 import Link from "next/link";
 import { usePathname } from "next/navigation";
 import { useLayout } from "@/components/LayoutProvider";
+import { useAuth } from "@/components/AuthProvider";
 
 export default function Sidebar() {
   const pathname = usePathname();
   const { mobileMenuOpen, setMobileMenuOpen } = useLayout();
+  const { user, logout } = useAuth();
+
+  const navItems = [
+    { name: "总览面板", href: "/", icon: LayoutDashboard, roles: ["stu", "teacher"] },
+    { name: "能力分析", href: "/analysis", icon: BarChart2, roles: ["stu"] },
+    { name: "学生管理", href: "/students", icon: Users, roles: ["teacher"] },
+    { name: "系统设置", href: "/settings", icon: Settings, roles: ["teacher"] },
+  ].filter(item => item.roles.includes(user?.role || "stu"));
   return (
     <>
       {/* Mobile backdrop */}
@@ -44,12 +54,7 @@ export default function Sidebar() {
           </Link>
         </div>
         <nav className="mt-4 px-3 space-y-2">
-          {[
-            { name: "总览面板", href: "/", icon: LayoutDashboard },
-            { name: "能力分析", href: "/analysis", icon: BarChart2 },
-            { name: "学生管理", href: "/students", icon: Users },
-            { name: "系统设置", href: "/settings", icon: Settings },
-          ].map((item) => {
+          {navItems.map((item) => {
             const isActive = pathname === item.href || (pathname?.startsWith("/metrics") && item.href === "/analysis");
             const activeClass = isActive 
               ? "bg-primary-container text-on-primary-container" 
@@ -71,27 +76,39 @@ export default function Sidebar() {
         </nav>
       </div>
       <div className="p-4 lg:p-6 mt-auto">
-        <Link 
-          href="/profile" 
-          className="flex items-center justify-start p-3 rounded-xl bg-surface-container border border-outline-variant/30 hover:bg-surface-container-high cursor-pointer transition-colors group"
-          onClick={() => setMobileMenuOpen(false)}
-        >
-          <div className="relative shrink-0">
-            <Image
-              alt="User Profile"
-              className="h-10 w-10 rounded-lg object-contain ring-2 ring-surface group-hover:ring-primary/50 transition-all bg-white"
-              src="/images/profile-avatar.png"
-              width={40}
-              height={40}
-              referrerPolicy="no-referrer"
-            />
-            <span className="absolute bottom-0 right-0 h-2.5 w-2.5 bg-green-400 rounded-full ring-2 ring-surface-container group-hover:ring-surface-container-high transition-colors"></span>
-          </div>
-          <div className="ml-3">
-            <p className="text-sm font-medium text-on-surface group-hover:text-primary transition-colors">刘钦</p>
-            <p className="text-xs text-outline group-hover:text-secondary transition-colors">指导教师</p>
-          </div>
-        </Link>
+        <div className="flex items-center justify-between p-3 rounded-xl bg-surface-container border border-outline-variant/30 hover:bg-surface-container-high transition-colors group">
+          <Link 
+            href="/profile" 
+            className="flex items-center flex-1 cursor-pointer"
+            onClick={() => setMobileMenuOpen(false)}
+          >
+            <div className="relative shrink-0">
+              <Image
+                alt="User Profile"
+                className="h-10 w-10 rounded-lg object-contain ring-2 ring-surface group-hover:ring-primary/50 transition-all bg-white"
+                src={user?.avatar || "/images/profile-avatar.png"}
+                width={40}
+                height={40}
+                referrerPolicy="no-referrer"
+              />
+              <span className="absolute bottom-0 right-0 h-2.5 w-2.5 bg-green-400 rounded-full ring-2 ring-surface-container group-hover:ring-surface-container-high transition-colors"></span>
+            </div>
+            <div className="ml-3">
+              <p className="text-sm font-medium text-on-surface group-hover:text-primary transition-colors">{user?.name || "未知用户"}</p>
+              <p className="text-xs text-outline group-hover:text-secondary transition-colors">{user?.role === 'teacher' ? '指导教师' : '学生'}</p>
+            </div>
+          </Link>
+          <button 
+            onClick={() => {
+              setMobileMenuOpen(false);
+              logout();
+            }}
+            className="p-2 text-outline hover:text-metric-low hover:bg-metric-low/10 rounded-lg transition-colors ml-2"
+            title="退出登录"
+          >
+            <LogOut className="w-5 h-5" />
+          </button>
+        </div>
       </div>
     </aside>
     </>

+ 186 - 0
components/TeacherDashboard.tsx

@@ -0,0 +1,186 @@
+"use client";
+
+import { useState, useEffect } from "react";
+import { Download, Users, TrendingUp, Award, UserCheck, Loader2 } from "lucide-react";
+import { mockApi, ClassStats, LeaderboardEntry } from "@/lib/mockApi";
+import Link from "next/link";
+
+export default function TeacherDashboard() {
+  const [activeTab, setActiveTab] = useState<"K" | "A" | "S" | "D">("K");
+  const [stats, setStats] = useState<ClassStats | null>(null);
+  const [topPerformers, setTopPerformers] = useState<LeaderboardEntry[]>([]);
+  const [loading, setLoading] = useState(true);
+
+  useEffect(() => {
+    const fetchData = async () => {
+      const [classStats, leaderboard] = await Promise.all([
+        mockApi.getClassStats(),
+        mockApi.getLeaderboard(5),
+      ]);
+      setStats(classStats);
+      setTopPerformers(leaderboard);
+      setLoading(false);
+    };
+    fetchData();
+  }, []);
+
+  // Visual config for each dimension
+  const dimConfig = {
+    K: { trend: "+2.1%", label: "软件工程知识", color: "text-chart-3", bg: "bg-primary-container", icon: <UserCheck className="w-6 h-6 text-primary" /> },
+    A: { trend: "+4.5%", label: "AI输出获取", color: "text-chart-4", bg: "bg-chart-5-container", icon: <TrendingUp className="w-6 h-6 text-chart-4" /> },
+    S: { trend: "-1.2%", label: "文档编程实现", color: "text-tertiary", bg: "bg-tertiary-container", icon: <Award className="w-6 h-6 text-tertiary" /> },
+    D: { trend: "+0.8%", label: "协作态度考核", color: "text-chart-1", bg: "bg-secondary-container", icon: <Users className="w-6 h-6 text-chart-1" /> }
+  };
+
+  if (loading || !stats) {
+    return (
+      <div className="flex-1 h-full w-full flex items-center justify-center p-8 text-secondary">
+        <Loader2 className="w-10 h-10 animate-spin mr-3 text-primary" />
+        <span className="font-bold animate-pulse">正在加载教学大盘数据...</span>
+      </div>
+    );
+  }
+
+  const classAverages = Object.entries(dimConfig).map(([key, cfg]) => ({
+    key,
+    score: stats.averages[key as keyof typeof stats.averages],
+    ...cfg,
+  }));
+
+  const distributions = stats.distributions;
+
+  return (
+    <div className="flex-1 overflow-y-auto p-4 lg:p-8 space-y-8 animate-in fade-in duration-500">
+      
+      {/* Header */}
+      <div className="flex flex-col md:flex-row justify-between items-start md:items-end mb-4">
+        <div>
+          <h1 className="text-3xl lg:text-4xl font-display font-bold text-on-surface mb-2">
+            教学大盘数据总览
+          </h1>
+          <p className="text-secondary text-sm lg:text-base">
+            全体学生多维度能力评估核心统计基准板
+          </p>
+        </div>
+        <div className="mt-4 md:mt-0 flex gap-3">
+          <button className="flex items-center px-5 py-2.5 bg-primary text-on-primary rounded-full shadow-lg hover:shadow-primary/20 hover:bg-white transition-all font-medium text-sm cursor-pointer border border-primary/20">
+            <Download className="w-5 h-5 mr-2" />
+            导出班级报告
+          </button>
+        </div>
+      </div>
+
+      {/* Aggregate Score Cards */}
+      <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
+        {classAverages.map((data) => (
+          <div key={data.key} className="bg-surface rounded-3xl p-6 shadow-elevation-1 border border-outline-variant/30 hover:shadow-elevation-2 transition-all flex flex-col relative overflow-hidden group">
+             {/* Gradient splash */}
+             <div className={`absolute -right-8 -top-8 w-32 h-32 rounded-full opacity-20 blur-3xl ${data.bg} transition-transform group-hover:scale-110 duration-500`}></div>
+             
+             <div className="flex justify-between items-start relative z-10">
+               <div>
+                 <p className="text-sm font-bold text-outline uppercase tracking-wider mb-1">大类平均维度 {data.key}</p>
+                 <h3 className="text-lg font-medium text-on-surface">{data.label}</h3>
+               </div>
+               <div className={`w-12 h-12 rounded-2xl flex items-center justify-center ${data.bg} shadow-inner`}>
+                  {data.icon}
+               </div>
+             </div>
+             
+             <div className="flex items-baseline mt-6 relative z-10 gap-3">
+                <span className={`text-5xl font-display font-black tracking-tighter ${data.color}`}>{data.score}</span>
+                <div className={`flex items-center text-sm font-bold ${data.trend.startsWith('+') ? 'text-metric-high' : 'text-metric-low'}`}>
+                  {data.trend}
+                  {data.trend.startsWith('+') ? ' ↑' : ' ↓'}
+                </div>
+             </div>
+          </div>
+        ))}
+      </div>
+
+      <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
+        
+        {/* Disbribution Section (2 cols) */}
+        <div className="lg:col-span-2 bg-surface-container rounded-3xl p-6 lg:p-8 shadow-elevation-1 border border-outline-variant/30 flex flex-col">
+           <div className="flex justify-between items-center mb-6">
+             <h3 className="text-xl font-display font-medium text-on-surface">能力等级分布图</h3>
+             <nav className="flex space-x-2 bg-background p-1 rounded-lg border border-outline-variant/20">
+                {(["K", "A", "S", "D"] as const).map(tab => (
+                   <button 
+                     key={tab}
+                     onClick={() => setActiveTab(tab)}
+                     className={`px-4 py-1.5 rounded-md text-sm font-bold transition-all ${activeTab === tab ? 'bg-primary text-on-primary shadow' : 'text-secondary hover:text-on-surface'}`}
+                   >
+                     {tab} 维度
+                   </button>
+                ))}
+             </nav>
+           </div>
+
+           <div className="flex-1 flex flex-col justify-center space-y-6 mt-4">
+              {distributions[activeTab].map((item, idx) => {
+                 // Dynamic styling based on tier
+                 let barColor = "bg-metric-high";
+                 if (idx === 1) barColor = "bg-metric-mid";
+                 else if (idx === 2) barColor = "bg-metric-warn";
+                 else if (idx === 3) barColor = "bg-metric-low";
+
+                 return (
+                  <div key={idx} className="group">
+                    <div className="flex justify-between text-sm mb-2">
+                       <span className="font-bold text-on-surface">{item.tier}</span>
+                       <span className="text-secondary font-medium">{item.count} 人 ({item.percent})</span>
+                    </div>
+                    <div className="w-full bg-surface-variant rounded-full h-3.5 overflow-hidden shadow-inner flex">
+                       <div 
+                         className={`h-full ${barColor} rounded-full transition-all duration-1000 ease-in-out relative`} 
+                         style={{ width: item.percent }}
+                       >
+                          <div className="absolute inset-0 bg-white/20 w-full h-full skew-x-12 -translate-x-full group-hover:animate-[shimmer_1.5s_infinite]"></div>
+                       </div>
+                    </div>
+                  </div>
+                 )
+              })}
+           </div>
+        </div>
+
+        {/* Top Performers (1 col) */}
+        <div className="bg-gradient-to-br from-surface to-surface-container-high rounded-3xl p-6 lg:p-8 shadow-elevation-2 border border-outline-variant/30 flex flex-col">
+           <h3 className="text-xl font-display font-medium text-on-surface flex items-center mb-6">
+             <Award className="w-5 h-5 text-metric-warn mr-2" fill="currentColor" />
+             班级领军学霸榜
+           </h3>
+           
+           <div className="space-y-4 flex-1">
+              {topPerformers.map((stu, i) => (
+                 <div key={stu.id} className="flex items-center p-3 rounded-2xl hover:bg-white/50 dark:hover:bg-black/20 transition-all border border-transparent hover:border-outline-variant/30 group">
+                    <div className="relative shrink-0 w-12 h-12 rounded-xl bg-background border border-outline-variant/30 overflow-hidden flex items-center justify-center mr-4">
+                       <span className="absolute top-0 left-0 text-[10px] font-black tracking-tighter bg-surface px-1.5 opacity-80 rounded-br-lg text-primary">{i+1}</span>
+                       <span className="text-lg font-bold text-secondary object-contain">{stu.name[0]}</span>
+                    </div>
+                    <div className="flex-1 min-w-0">
+                       <h4 className="font-bold text-on-surface text-sm truncate">{stu.name}</h4>
+                       <p className="text-xs text-outline font-mono truncate">{stu.id}</p>
+                    </div>
+                    <div className="text-right ml-3">
+                       <div className="text-primary font-black text-xl">{stu.score}</div>
+                       <div className="text-[10px] font-bold text-secondary bg-surface-variant px-1.5 rounded-full mt-1">
+                         强项: {stu.bestAt}
+                       </div>
+                    </div>
+                 </div>
+              ))}
+           </div>
+           
+           <Link href="/students" className="w-full mt-6 py-3 border border-outline-variant/50 rounded-xl text-sm font-bold text-primary hover:bg-primary-container/50 transition-colors block text-center">
+              查看全体排名及明细 →
+           </Link>
+        </div>
+
+      </div>
+      
+      <div className="h-10"></div>
+    </div>
+  );
+}

+ 303 - 0
lib/mockApi.ts

@@ -0,0 +1,303 @@
+// ============================================================================
+// Mock API Layer — Single Source of Truth with localStorage persistence
+// All scores (macro + sub-metric) are pre-generated and stored deterministically
+// ============================================================================
+
+export interface User {
+  id: string;
+  username: string;
+  role: 'stu' | 'teacher';
+  name: string;
+  avatar: string;
+}
+
+export interface AuthResponse {
+  token: string;
+  user: User;
+}
+
+// Per-metric score record: metricId → score (0-100)
+export type MetricScores = Record<string, number>;
+
+export interface MockStudent {
+  id: string;
+  name: string;
+  overall: number;
+  K: number;
+  A: number;
+  S: number;
+  D: number;
+  avatar: string;
+  // Pre-generated sub-metric scores, e.g. { "K1": 88, "K2": 82, "K3": 90, "A1": 85, ... }
+  metricScores: MetricScores;
+}
+
+export interface ClassStats {
+  averages: { K: number; A: number; S: number; D: number };
+  distributions: Record<string, { tier: string; count: number; percent: string }[]>;
+}
+
+export interface LeaderboardEntry {
+  id: string;
+  name: string;
+  score: number;
+  avatar: string;
+  bestAt: string;
+}
+
+// ── All metric IDs grouped by dimension ────────────────────────────────────────
+// This is the structural truth — must match metricsData.ts
+const METRIC_IDS: Record<string, string[]> = {
+  K: ['K1', 'K2', 'K3'],
+  A: ['A1', 'A2', 'A3', 'A4', 'A5', 'A6', 'A7'],
+  S: ['S1', 'S2', 'S3', 'S4', 'S5', 'S6', 'S7'],
+  D: ['D1', 'D2', 'D3', 'D4'],
+};
+
+// ── localStorage Keys ──────────────────────────────────────────────────────────
+const STORAGE_KEY = 'edumetric_students';
+
+// ── Deterministic seeded RNG ───────────────────────────────────────────────────
+function createSeededRng(seed: number) {
+  let s = seed;
+  return () => {
+    const x = Math.sin(s++) * 10000;
+    return x - Math.floor(x);
+  };
+}
+
+// ── Data Generation (runs once, then persisted) ────────────────────────────────
+function generateStudents(): MockStudent[] {
+  return Array.from({ length: 40 }).map((_, i) => {
+    const isExcellent = i % 5 === 0;
+    const isStruggling = i % 7 === 0;
+
+    let baseScore = 80;
+    if (isExcellent) baseScore = 92;
+    else if (isStruggling) baseScore = 65;
+    else baseScore = 75 + (i % 15);
+
+    const majorCode = i % 2 === 0 ? '125' : '188';
+    const indexCode = (i + 1).toString().padStart(3, '0');
+    const studentId = `23${majorCode}0${indexCode}`;
+
+    // Macro dimension scores
+    const K = baseScore + (i % 4) - 2;
+    const A = baseScore + (i % 5) - 1;
+    const S = baseScore - (i % 3);
+    const D = baseScore + (i % 6);
+    const macros: Record<string, number> = { K, A, S, D };
+
+    // Generate deterministic sub-metric scores per student
+    const rng = createSeededRng(i * 1000 + 42);
+    const metricScores: MetricScores = {};
+    for (const [dim, ids] of Object.entries(METRIC_IDS)) {
+      const dimScore = macros[dim];
+      for (const metricId of ids) {
+        const offset = Math.floor(rng() * 9) - 4;
+        metricScores[metricId] = Math.max(0, Math.min(100, dimScore + offset));
+      }
+    }
+
+    const surnames = [
+      '李', '王', '赵', '陈', '刘', '张', '周', '吴', '黄', '孙',
+      '徐', '马', '朱', '胡', '林', '郭', '何', '高', '罗', '郑',
+      '梁', '谢', '宋', '唐', '韩', '冯', '董', '程', '蔡', '袁',
+      '许', '叶', '余', '彭', '苏', '潘', '杜', '曹', '戴', '魏',
+    ];
+    const givenNames = [
+      '子涵', '佳怡', '宇航', '梓轩', '星雨', '梦瑶', '浩然', '思源', '雨萱', '天翔',
+      '若琳', '明哲', '语嫣', '博文', '诗涵', '晨阳', '雅静', '俊杰', '欣怡', '泽宇',
+      '芷若', '翰林', '婉清', '凌峰', '乐天', '安琪', '鸿飞', '嘉懿', '瑾瑜', '睿渊',
+      '文昊', '修洁', '黎昕', '烨磊', '晟睿', '靖琪', '致远', '逸飞', '昊然', '皓轩',
+    ];
+
+    return {
+      id: studentId,
+      name: surnames[i] + givenNames[i],
+      overall: baseScore,
+      K, A, S, D,
+      avatar: '/images/profile-avatar.png',
+      metricScores,
+    };
+  });
+}
+
+// ── Init / Read from localStorage ──────────────────────────────────────────────
+function initMockData(): MockStudent[] {
+  if (typeof window === 'undefined') {
+    return generateStudents();
+  }
+
+  const stored = localStorage.getItem(STORAGE_KEY);
+  if (stored) {
+    try {
+      const parsed = JSON.parse(stored) as MockStudent[];
+      // Validate that sub-metric scores exist (handle old format)
+      if (parsed.length > 0 && parsed[0].metricScores) {
+        return parsed;
+      }
+    } catch {
+      // Corrupted — regenerate
+    }
+  }
+
+  const data = generateStudents();
+  localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
+  return data;
+}
+
+// ── User Accounts ──────────────────────────────────────────────────────────────
+function getUsers(): Record<string, User> {
+  const students = initMockData();
+  return {
+    stu: {
+      id: students[0].id,
+      username: 'stu',
+      role: 'stu',
+      name: students[0].name,
+      avatar: students[0].avatar,
+    },
+    teacher: {
+      id: 'tea_001',
+      username: 'teacher',
+      role: 'teacher',
+      name: '刘钦',
+      avatar: '/images/profile-avatar.png',
+    },
+  };
+}
+
+// ── Helpers ─────────────────────────────────────────────────────────────────────
+const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
+
+// ── Public Mock API ────────────────────────────────────────────────────────────
+export const mockApi = {
+  // ── Auth ────────────────────────────────────────────────────────────────────
+  login: async (username: string, password: string): Promise<AuthResponse> => {
+    await delay(600);
+    if (password !== 'passwd') throw new Error('密码错误');
+    const users = getUsers();
+    const user = users[username];
+    if (!user) throw new Error('用户不存在');
+    return {
+      token: `mock_jwt_token_${user.id}_${Date.now()}`,
+      user,
+    };
+  },
+
+  verifySession: async (token: string | null): Promise<User | null> => {
+    await delay(300);
+    if (!token) return null;
+    const users = getUsers();
+    if (token.includes(users.stu.id)) return users.stu;
+    if (token.includes('tea_001')) return users.teacher;
+    return null;
+  },
+
+  // ── Students ────────────────────────────────────────────────────────────────
+  getStudents: async (): Promise<MockStudent[]> => {
+    await delay(400);
+    return initMockData();
+  },
+
+  getStudentById: async (id: string): Promise<MockStudent | undefined> => {
+    await delay(200);
+    return initMockData().find(s => s.id === id);
+  },
+
+  // ── Per-Student Metric Scores ───────────────────────────────────────────────
+  // Returns the pre-generated sub-metric scores for a specific student
+  getStudentMetrics: async (studentId: string): Promise<MetricScores | null> => {
+    await delay(200);
+    const student = initMockData().find(s => s.id === studentId);
+    return student?.metricScores ?? null;
+  },
+
+  // ── Class Statistics (Teacher Dashboard) ────────────────────────────────────
+  getClassStats: async (): Promise<ClassStats> => {
+    await delay(500);
+    const students = initMockData();
+    const total = students.length || 1;
+
+    const sums = students.reduce(
+      (acc, stu) => {
+        acc.K += stu.K;
+        acc.A += stu.A;
+        acc.S += stu.S;
+        acc.D += stu.D;
+        return acc;
+      },
+      { K: 0, A: 0, S: 0, D: 0 },
+    );
+
+    const averages = {
+      K: Math.round(sums.K / total),
+      A: Math.round(sums.A / total),
+      S: Math.round(sums.S / total),
+      D: Math.round(sums.D / total),
+    };
+
+    const bucket = (key: 'K' | 'A' | 'S' | 'D') => {
+      let excellent = 0, good = 0, fair = 0, poor = 0;
+      students.forEach(stu => {
+        const score = stu[key];
+        if (score >= 90) excellent++;
+        else if (score >= 80) good++;
+        else if (score >= 70) fair++;
+        else poor++;
+      });
+      return [
+        { tier: '优秀 (90+)', count: excellent, percent: `${Math.round((excellent / total) * 100)}%` },
+        { tier: '良好 (80-89)', count: good, percent: `${Math.round((good / total) * 100)}%` },
+        { tier: '一般 (70-79)', count: fair, percent: `${Math.round((fair / total) * 100)}%` },
+        { tier: '需改进 (<70)', count: poor, percent: `${Math.round((poor / total) * 100)}%` },
+      ];
+    };
+
+    const distributions: ClassStats['distributions'] = {
+      K: bucket('K'),
+      A: bucket('A'),
+      S: bucket('S'),
+      D: bucket('D'),
+    };
+
+    return { averages, distributions };
+  },
+
+  // ── Leaderboard ─────────────────────────────────────────────────────────────
+  getLeaderboard: async (limit = 5): Promise<LeaderboardEntry[]> => {
+    await delay(300);
+    const students = initMockData();
+    return [...students]
+      .sort((a, b) => b.overall - a.overall)
+      .slice(0, limit)
+      .map(stu => {
+        const scores = [
+          { k: 'K (知识覆盖)', v: stu.K },
+          { k: 'A (AI交互)', v: stu.A },
+          { k: 'S (代码产出)', v: stu.S },
+          { k: 'D (团队协作)', v: stu.D },
+        ];
+        scores.sort((a, b) => b.v - a.v);
+        return {
+          id: stu.id,
+          name: stu.name,
+          score: stu.overall,
+          avatar: stu.avatar,
+          bestAt: scores[0].k,
+        };
+      });
+  },
+
+  // ── Student Self Profile ────────────────────────────────────────────────────
+  getMyProfile: async (): Promise<MockStudent> => {
+    await delay(200);
+    return initMockData()[0];
+  },
+};
+
+// ── Sync helpers (for components that can't use async easily) ─────────────────
+export const getMockStudentById = (id: string): MockStudent | undefined => {
+  return initMockData().find(s => s.id === id);
+};

+ 38 - 0
middleware.ts

@@ -0,0 +1,38 @@
+import { NextResponse } from 'next/server';
+import type { NextRequest } from 'next/server';
+
+export function middleware(request: NextRequest) {
+  const token = request.cookies.get('auth_token')?.value;
+  const isLoginPage = request.nextUrl.pathname === '/login';
+
+  // If no token exists and user is trying to access a protected route
+  // Kick them to the login page immediately at the edge.
+  if (!token && !isLoginPage) {
+    const loginUrl = new URL('/login', request.url);
+    return NextResponse.redirect(loginUrl);
+  }
+
+  // If token exists and they are hitting the login page, redirect to dashboard
+  if (token && isLoginPage) {
+    const dashboardUrl = new URL('/', request.url);
+    return NextResponse.redirect(dashboardUrl);
+  }
+
+  return NextResponse.next();
+}
+
+// See "Matching Paths" below to learn more
+export const config = {
+  matcher: [
+    /*
+     * Match all request paths except for the ones starting with:
+     * - api (API routes)
+     * - _next/static (static files)
+     * - _next/image (image optimization files)
+     * - favicon.ico (favicon file)
+     * - images/ (public assets)
+     * - banner.png (public assets)
+     */
+    '/((?!api|_next/static|_next/image|favicon.ico|images|banner).*)',
+  ],
+};