Browse Source

feat: update metrics data and remove mock API

- Enhanced metrics data by adding new sub-metrics for personal task completion and team communication capabilities.
- Removed the mock API implementation and related documentation to streamline the codebase.
- Introduced a new authentication module to handle user login and session verification.
- Added a new hook for fetching D4 metrics from the backend.
- Implemented runtime metric handling to process and display metrics data effectively.
- Updated project documentation to reflect changes in API integration and local development setup.
- Cleaned up middleware and proxy handling for authentication redirection.
Insouciant21 4 months ago
parent
commit
e614ae9201

+ 1 - 9
.env.example

@@ -1,9 +1 @@
-# GEMINI_API_KEY: Required for Gemini AI API calls.
-# AI Studio automatically injects this at runtime from user secrets.
-# Users configure this via the Secrets panel in the AI Studio UI.
-GEMINI_API_KEY="MY_GEMINI_API_KEY"
-
-# APP_URL: The URL where this applet is hosted.
-# AI Studio automatically injects this at runtime with the Cloud Run service URL.
-# Used for self-referential links, OAuth callbacks, and API endpoints.
-APP_URL="MY_APP_URL"
+NEXT_PUBLIC_SEEC_ANALYSIS_API_BASE_URL=http://localhost:8080

+ 2 - 0
.gitignore

@@ -1,6 +1,8 @@
 node_modules/
 .next/
 coverage/
+backend/
+backend*
 .DS_Store
 *.log
 .env*

+ 37 - 0
AGENTS.md

@@ -0,0 +1,37 @@
+# AGENTS.md
+
+## Project assumptions
+- This is a pnpm-based Next.js project.
+- Prefer existing patterns and conventions already present in the repo.
+- If a task is ambiguous, inspect nearby similar implementations before proposing new patterns.
+- This is NOT the Next.js you know:This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
+- This project is managed by pnpm.
+
+
+## Working style
+- For feature work touching more than 3 files, start by proposing a plan.
+- Prefer minimal, high-confidence diffs over broad refactors.
+- Reuse existing utilities, hooks, components, and service patterns when possible.
+- Do not introduce new dependencies unless necessary and explicitly justified.
+- If you are uncertain about something (requirement), you should ask instead of assuming.
+- Test your solutions before implementing it.
+
+## Environment
+- Ubuntu 25.01 in WSL2
+
+## Run
+- `pnpm run dev` to run as debug
+
+## Clean
+- After code changes, you should check whether there are codes no more functions and clear them.
+
+## Validation
+- After clean, run the relevant checks:
+  - `pnpm lint`
+  - `pnpm typecheck`
+- If one check fails, explain the likely cause before making another edit.
+
+## Output expectations
+- Summarize changed files
+- Explain the implementation approach briefly
+- List residual risks or follow-up work

+ 62 - 0
README.md

@@ -0,0 +1,62 @@
+# SEEC Analysis
+
+Next.js 前端项目,用于展示 SeeCoder 能力分析指标。当前已接入后端 D4「态度与团队协作」指标接口,其它指标保留目录与页面结构,等待后端 API 补齐。
+
+## 环境要求
+
+- Node.js / pnpm
+- 后端服务:`backend/` 中的 Spring Boot 服务
+
+## 本地启动
+
+```bash
+pnpm install
+pnpm run dev
+```
+
+默认前端地址为 Next.js 输出的本地地址,后端默认地址为:
+
+```bash
+NEXT_PUBLIC_SEEC_ANALYSIS_API_BASE_URL=http://localhost:8080
+```
+
+如需修改后端地址,编辑根目录 `.env`。
+
+## 已接入接口
+
+前端调用:
+
+```http
+GET /api/d4/metrics
+```
+
+完整后端地址由 `.env` 中的 `NEXT_PUBLIC_SEEC_ANALYSIS_API_BASE_URL` 与 `/api/d4/metrics` 拼接得到。
+
+支持的查询参数与后端一致:
+
+- `from`: 起始时间,ISO-8601
+- `to`: 结束时间,ISO-8601
+- `projectId`: 可选项目过滤
+- `taskType`: 可选任务类型过滤
+- `userId`: 可选用户过滤
+- `slaMs`: 可选 SLA 毫秒数
+
+## 登录账号
+
+账号登录仍保留为前端本地测试账号:
+
+- 学生:`stu` / `passwd`
+- 教师:`teacher` / `passwd`
+
+## 验证
+
+```bash
+pnpm lint
+pnpm typecheck
+```
+
+## 说明
+
+- `backend/` 是后端实现,本前端改动不修改该目录。
+- D4 数据来自真实后端接口。
+- K/A/S 与 D1-D3 暂无后端接口,页面中显示为「未接入」状态。

+ 118 - 122
app/(dashboard)/analysis/AnalysisDashboard.tsx

@@ -4,196 +4,192 @@ 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, getMockClassAverageMetrics, getMockClassMacroAverages } from "@/lib/mockApi";
 import { useAuth } from "@/components/AuthProvider";
+import { useD4Metrics } from "@/hooks/useD4Metrics";
+import { toD4UserId } from "@/lib/d4Metrics";
+import { buildRuntimeMetrics, type MetricDataState } from "@/lib/metricRuntime";
 
 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;
-  const classAverages = useMemo(() => getMockClassAverageMetrics(), []);
-  const classMacros = useMemo(() => getMockClassMacroAverages(), []);
-
-  // 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";
+
+  const studentId = searchParams.get("student") || (user?.role === "stu" ? user.id : null);
+  const d4UserId = toD4UserId(studentId);
+  const d4Query = useMemo(() => (d4UserId ? { userId: d4UserId } : {}), [d4UserId]);
+  const d4Metrics = useD4Metrics(d4Query);
+
+  const displayData = useMemo(
+    () => buildRuntimeMetrics({ data: d4Metrics.data, error: d4Metrics.error, isLoading: d4Metrics.isLoading }),
+    [d4Metrics.data, d4Metrics.error, d4Metrics.isLoading],
+  );
+
+  const getHeatmapClass = (score: number | null, state: MetricDataState) => {
+    if (state === "loading") return "bg-primary/10 text-primary border-primary/30";
+    if (state === "error") return "bg-metric-low/10 text-metric-low border-metric-low/30";
+    if (state === "unavailable") return "bg-surface-container-high text-on-surface-variant border-outline-variant";
+    if (score == null) return "bg-primary/10 text-primary border-primary/30";
     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(() => {
-    const personalizedData = JSON.parse(JSON.stringify(metricsData)) as Record<string, any[]>;
-    
-    Object.keys(personalizedData).forEach((catKey) => {
-       personalizedData[catKey].forEach(item => {
-          const score = student ? student.metricScores?.[item.id] : classAverages[item.id];
-          item.avgScore = score !== undefined ? score.toString() : "0";
-       });
-    });
-
-    return personalizedData;
-  }, [student, classAverages]);
+  const getStateLabel = (score: number | null, state: MetricDataState) => {
+    if (state === "loading") return "加载中";
+    if (state === "error") return "异常";
+    if (state === "unavailable") return "未接入";
+    if (score == null) return "已同步";
+    if (score >= 85) return "优秀";
+    if (score >= 75) return "良好";
+    return "需改进";
+  };
 
-  // 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: use macro averages
-        stats[cat] = {
-           avg: classMacros[cat] || 0,
-           total: items.length
-        };
-      }
+    const stats: Record<string, { avg: number | null; total: number; synced: number; state: MetricDataState }> = {};
+
+    Object.entries(displayData).forEach(([cat, items]) => {
+      const scores = items
+        .map((item) => item.score)
+        .filter((score): score is number => typeof score === "number");
+      const synced = items.filter((item) => item.state === "ready").length;
+      const hasLoading = items.some((item) => item.state === "loading");
+      const hasError = items.some((item) => item.state === "error");
+
+      stats[cat] = {
+        avg: scores.length > 0 ? Math.round(scores.reduce((sum, score) => sum + score, 0) / scores.length) : null,
+        total: items.length,
+        synced,
+        state: hasLoading ? "loading" : hasError ? "error" : synced > 0 ? "ready" : "unavailable",
+      };
     });
+
     return stats;
-  }, [displayData, student, classMacros]);
+  }, [displayData]);
 
   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" />
+    D: <ShieldCheck className="w-10 h-10 mb-3 opacity-80" />,
   };
 
   const titles = {
     K: "知识掌握",
     A: "AI 辅助",
     S: "软件工程",
-    D: "态度与协作"
+    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" />}
+            能力分析雷达 {studentId && <User className="inline-block ml-3 w-6 h-6 text-primary" />}
           </h1>
           <p className="text-secondary text-sm mt-1">
-            {student ? "个体学生多维度能力结构深度诊断" : "多维度能力结构诊断热力图 (大盘基准)"}
+            {studentId ? "按学生维度过滤 D4 指标数据" : "多维度能力结构诊断热力图"}
           </p>
         </div>
-        
-        {/* Dynamic Context Banner if Drilling Down */}
-        {student && (
+
+        {studentId && (
           <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]}
+              {studentId.slice(0, 1)}
             </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>
+                学员 <span className="text-outline font-mono ml-1">{studentId}</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>
+          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">
+              <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.synced} / {stat.total} 项 API 指标
+                  </p>
+                </div>
+                <div className="flex items-end justify-between mt-6 gap-3">
+                  <span className="text-5xl font-display font-bold tracking-tight text-on-surface">
+                    {stat.avg ?? "--"}<span className="text-xl text-secondary ml-1">{stat.avg == null ? "" : "%"}</span>
+                  </span>
+                  <span className={`px-2 py-1 rounded text-xs font-bold border ${getHeatmapClass(stat.avg, stat.state)}`}>
+                    {getStateLabel(stat.avg, stat.state)}
+                  </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>
+        <h2 className="text-2xl font-medium text-on-surface">{studentId ? "学生指标接口覆盖情况" : "能力全景分布"}</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>
-           ))}
-         </div>
+              <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.score, metric.state)}`}
+                  >
+                    <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 gap-2">
+                      <span className="font-black text-lg tracking-tight opacity-90">{metric.id}</span>
+                      <span className="font-bold text-sm bg-black/10 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 line-clamp-2 drop-shadow-sm">{metric.name}</p>
+                      <p className="text-[10px] mt-1 opacity-80">{getStateLabel(metric.score, metric.state)}</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>
+        <span className="font-bold animate-pulse">正在提取分析数据...</span>
       </div>
     }>
       <AnalysisDashboardContent />

+ 27 - 114
app/(dashboard)/students/page.tsx

@@ -1,37 +1,15 @@
 "use client";
 
 import { useAuth } from "@/components/AuthProvider";
-import { Loader2, Search, ArrowUpDown, ChevronRight } from "lucide-react";
-import Image from "next/image";
+import { Loader2, Search, Database, ArrowRight } from "lucide-react";
 import Link from "next/link";
-import { useState, useEffect } from "react";
-import { mockApi, MockStudent } from "@/lib/mockApi";
+import { useState } from "react";
 
 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) {
+  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" />
@@ -49,111 +27,46 @@ export default function StudentsPage() {
     );
   }
 
-  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>
+          <p className="text-secondary text-sm mt-1">学生列表接口尚未接入,已移除本地模拟数据。</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="搜索姓名或学号..." 
+          <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"
+            onChange={(event) => setSearchTerm(event.target.value)}
+            disabled
+            className="w-full bg-surface-container border border-outline-variant/50 rounded-full py-2 pl-9 pr-4 text-sm outline-none transition-all text-outline disabled:opacity-70"
           />
         </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 className="p-8 lg:p-12 flex flex-col items-center justify-center text-center min-h-[360px]">
+          <div className="w-16 h-16 rounded-2xl bg-primary/10 text-primary flex items-center justify-center mb-5">
+            <Database className="w-8 h-8" />
+          </div>
+          <h2 className="text-2xl font-display font-bold text-on-surface mb-3">暂无学生列表数据源</h2>
+          <p className="text-secondary text-sm leading-relaxed max-w-2xl">
+            当前后端只提供 D4 指标汇总接口,尚未提供学生名单、个人画像列表或排行榜接口。此页面保留入口,避免继续展示旧的 mock 学生数据。
+          </p>
+          <Link
+            href="/analysis"
+            className="mt-8 inline-flex items-center px-5 py-2.5 rounded-full bg-primary text-on-primary text-sm font-bold shadow hover:bg-primary/90 transition-colors"
+          >
+            查看 D4 能力分析
+            <ArrowRight className="w-4 h-4 ml-2" />
+          </Link>
         </div>
       </div>
-      
+
       <div className="h-10"></div>
     </div>
   );

+ 6 - 6
app/login/page.tsx

@@ -3,7 +3,7 @@
 import { useState } from "react";
 import { GraduationCap, ArrowRight, Loader2 } from "lucide-react";
 import { useAuth } from "@/components/AuthProvider";
-import { mockApi } from "@/lib/mockApi";
+import { authApi } from "@/lib/auth";
 import Image from "next/image";
 
 export default function LoginPage() {
@@ -19,10 +19,10 @@ export default function LoginPage() {
     setLoading(true);
 
     try {
-      const { token, user } = await mockApi.login(username, password);
+      const { token, user } = await authApi.login(username, password);
       login(token, user);
-    } catch (err: any) {
-      setError(err.message || "登录失败,请检查账号密码");
+    } catch (err: unknown) {
+      setError(err instanceof Error ? err.message : "登录失败,请检查账号密码");
     } finally {
       setLoading(false);
     }
@@ -101,9 +101,9 @@ export default function LoginPage() {
             </button>
           </form>
 
-          {/* Quick Mock Login Helpers */}
+          {/* Quick 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>
+             <p className="text-xs text-outline mb-3">测试账号</p>
              <div className="flex gap-3 justify-center">
                 <button 
                   type="button" 

+ 2 - 2
components/AuthProvider.tsx

@@ -1,7 +1,7 @@
 "use client";
 
 import React, { createContext, useContext, useState, useEffect } from "react";
-import { User, mockApi } from "@/lib/mockApi";
+import { User, authApi } from "@/lib/auth";
 import { useRouter, usePathname } from "next/navigation";
 
 // Utility functions for client-side cookie management
@@ -47,7 +47,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
       const token = getCookie("auth_token");
       if (token) {
         try {
-          const validUser = await mockApi.verifySession(token);
+          const validUser = await authApi.verifySession(token);
           if (validUser) {
             setUser(validUser);
             // Middleware handles redirect, but we can dual-enforce client side

+ 180 - 107
components/DashboardContent.tsx

@@ -1,46 +1,80 @@
 "use client";
 
-import { useState, useMemo } from "react";
+import { useMemo, useState } from "react";
 import { Download, ChevronDown, CheckCircle2 } from "lucide-react";
-import Link from "next/link";
-import { metricsData, MetricCategoryData } from "./metricsData";
 import { useAuth } from "@/components/AuthProvider";
-import { getMockStudentById, getMockClassAverageMetrics } from "@/lib/mockApi";
+import { useD4Metrics } from "@/hooks/useD4Metrics";
+import { toD4UserId } from "@/lib/d4Metrics";
+import { buildRuntimeMetrics, type RuntimeMetric, type RuntimeMetricValue } from "@/lib/metricRuntime";
 
 export default function DashboardContent() {
   const { user } = useAuth();
   const [expandedCard, setExpandedCard] = useState<string | null>(null);
-  const [activeTab, setActiveTab] = useState<"K" | "A" | "S" | "D">("K");
+  const [activeTab, setActiveTab] = useState<"K" | "A" | "S" | "D">("D");
 
-  const student = user?.role === 'stu' ? getMockStudentById(user.id) : null;
-  const classAverages = useMemo(() => getMockClassAverageMetrics(), []);
+  const d4Query = useMemo(() => {
+    const userId = user?.role === "stu" ? toD4UserId(user.id) : undefined;
+    return userId ? { userId } : {};
+  }, [user]);
 
-  const displayData = useMemo(() => {
-    const personalizedData = JSON.parse(JSON.stringify(metricsData)) as Record<string, any[]>;
-    
-    Object.keys(personalizedData).forEach((catKey) => {
-       personalizedData[catKey].forEach(item => {
-          const score = student ? student.metricScores?.[item.id] : classAverages[item.id];
-          item.avgScore = score !== undefined ? score.toString() : "0";
-       });
-    });
+  const d4Metrics = useD4Metrics(d4Query);
 
-    return personalizedData as Record<string, (MetricCategoryData & { avgScore: string })[]>;
-  }, [student, classAverages]);
+  const displayData = useMemo(
+    () => buildRuntimeMetrics({ data: d4Metrics.data, error: d4Metrics.error, isLoading: d4Metrics.isLoading }),
+    [d4Metrics.data, d4Metrics.error, d4Metrics.isLoading],
+  );
 
   const toggleCard = (id: string) => {
     setExpandedCard(expandedCard === id ? null : id);
   };
 
-  const getMetricStatus = (score: string) => {
-    const num = parseInt(score);
-    if (isNaN(num)) return { label: "未知", class: "bg-surface-variant text-on-surface-variant" };
-    if (num >= 90) return { label: "优秀", class: "bg-metric-high/10 text-metric-high border-metric-high/30" };
-    if (num >= 80) return { label: "良好", class: "bg-metric-mid/10 text-metric-mid border-metric-mid/30" };
-    if (num >= 70) return { label: "一般", class: "bg-metric-warn/10 text-metric-warn border-metric-warn/30" };
+  const getScoreStatus = (score: number | null) => {
+    if (score == null) {
+      return { label: "暂无", class: "bg-surface-variant text-on-surface-variant border-outline-variant" };
+    }
+
+    if (score >= 90) return { label: "优秀", class: "bg-metric-high/10 text-metric-high border-metric-high/30" };
+    if (score >= 80) return { label: "良好", class: "bg-metric-mid/10 text-metric-mid border-metric-mid/30" };
+    if (score >= 70) return { label: "一般", class: "bg-metric-warn/10 text-metric-warn border-metric-warn/30" };
     return { label: "需改进", class: "bg-metric-low/10 text-metric-low border-metric-low/30" };
   };
 
+  const getMetricStatus = (metric: RuntimeMetric) => {
+    if (metric.state === "loading") {
+      return { label: "加载中", class: "bg-primary/10 text-primary border-primary/30" };
+    }
+
+    if (metric.state === "error") {
+      return { label: "接口异常", class: "bg-metric-low/10 text-metric-low border-metric-low/30" };
+    }
+
+    if (metric.state === "unavailable") {
+      return { label: "未接入", class: "bg-surface-variant text-on-surface-variant border-outline-variant" };
+    }
+
+    return getScoreStatus(metric.score);
+  };
+
+  const getValueStatus = (value: RuntimeMetricValue) => {
+    if (value.state === "ready" && value.score == null) {
+      return { label: "已同步", class: "bg-primary/10 text-primary border-primary/30" };
+    }
+
+    if (value.state === "loading") {
+      return { label: "加载中", class: "bg-primary/10 text-primary border-primary/30" };
+    }
+
+    if (value.state === "error") {
+      return { label: "异常", class: "bg-metric-low/10 text-metric-low border-metric-low/30" };
+    }
+
+    if (value.state === "unavailable") {
+      return { label: "未接入", class: "bg-surface-variant text-on-surface-variant border-outline-variant" };
+    }
+
+    return getScoreStatus(value.score);
+  };
+
   return (
     <div className="flex-1 overflow-y-auto p-4 lg:p-8 space-y-6">
       <div className="flex flex-col md:flex-row justify-between items-start md:items-end mb-2">
@@ -49,7 +83,7 @@ export default function DashboardContent() {
             综合能力总览
           </h1>
           <p className="text-secondary text-sm lg:text-base">
-            基于核心能力指标的数据分析与追踪
+            基于后端指标接口的数据分析与追踪
           </p>
         </div>
         <div className="mt-4 md:mt-0 flex gap-3">
@@ -65,27 +99,27 @@ export default function DashboardContent() {
           aria-label="Tabs"
           className="flex space-x-6 overflow-x-auto pb-1 no-scrollbar"
         >
-          <button 
+          <button
             onClick={() => setActiveTab("K")}
-            className={`whitespace-nowrap py-3 px-2 border-b-2 font-medium text-sm transition-colors cursor-pointer ${activeTab === 'K' ? 'border-primary text-primary' : 'border-transparent text-secondary hover:text-on-surface hover:border-outline'}`}
+            className={`whitespace-nowrap py-3 px-2 border-b-2 font-medium text-sm transition-colors cursor-pointer ${activeTab === "K" ? "border-primary text-primary" : "border-transparent text-secondary hover:text-on-surface hover:border-outline"}`}
           >
             知识掌握情况 (K)
           </button>
-          <button 
+          <button
             onClick={() => setActiveTab("A")}
-            className={`whitespace-nowrap py-3 px-2 border-b-2 font-medium text-sm transition-colors cursor-pointer ${activeTab === 'A' ? 'border-primary text-primary' : 'border-transparent text-secondary hover:text-on-surface hover:border-outline'}`}
+            className={`whitespace-nowrap py-3 px-2 border-b-2 font-medium text-sm transition-colors cursor-pointer ${activeTab === "A" ? "border-primary text-primary" : "border-transparent text-secondary hover:text-on-surface hover:border-outline"}`}
           >
             AI 辅助能力 (A)
           </button>
-          <button 
+          <button
             onClick={() => setActiveTab("S")}
-            className={`whitespace-nowrap py-3 px-2 border-b-2 font-medium text-sm transition-colors cursor-pointer ${activeTab === 'S' ? 'border-primary text-primary' : 'border-transparent text-secondary hover:text-on-surface hover:border-outline'}`}
+            className={`whitespace-nowrap py-3 px-2 border-b-2 font-medium text-sm transition-colors cursor-pointer ${activeTab === "S" ? "border-primary text-primary" : "border-transparent text-secondary hover:text-on-surface hover:border-outline"}`}
           >
             软件工程能力 (S)
           </button>
-          <button 
+          <button
             onClick={() => setActiveTab("D")}
-            className={`whitespace-nowrap py-3 px-2 border-b-2 font-medium text-sm transition-colors cursor-pointer ${activeTab === 'D' ? 'border-primary text-primary' : 'border-transparent text-secondary hover:text-on-surface hover:border-outline'}`}
+            className={`whitespace-nowrap py-3 px-2 border-b-2 font-medium text-sm transition-colors cursor-pointer ${activeTab === "D" ? "border-primary text-primary" : "border-transparent text-secondary hover:text-on-surface hover:border-outline"}`}
           >
             态度与团队协作 (D)
           </button>
@@ -93,83 +127,119 @@ export default function DashboardContent() {
       </div>
 
       <div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
-        {displayData[activeTab].map((metric) => (
-          <div
-            key={metric.id}
-            className={`bg-surface-container rounded-3xl p-6 shadow-elevation-1 hover:shadow-elevation-2 transition-all border border-outline-variant/50 group cursor-pointer ${expandedCard === metric.id ? "expanded" : ""}`}
-            onClick={() => toggleCard(metric.id)}
-          >
-            <div className="flex justify-between items-start">
-              <div className="flex items-center gap-4">
-                <div className={`h-14 w-14 rounded-2xl flex items-center justify-center ${metric.bgClass}`}>
-                  <span className="font-bold text-xl">
-                    {metric.id}
-                  </span>
+        {displayData[activeTab].map((metric) => {
+          const status = getMetricStatus(metric);
+          const progress = metric.score ?? 0;
+
+          return (
+            <div
+              key={metric.id}
+              className={`bg-surface-container rounded-3xl p-6 shadow-elevation-1 hover:shadow-elevation-2 transition-all border border-outline-variant/50 group cursor-pointer ${expandedCard === metric.id ? "expanded" : ""}`}
+              onClick={() => toggleCard(metric.id)}
+            >
+              <div className="flex justify-between items-start gap-4">
+                <div className="flex items-center gap-4 min-w-0">
+                  <div className={`h-14 w-14 rounded-2xl flex items-center justify-center shrink-0 ${metric.bgClass}`}>
+                    <span className="font-bold text-xl">
+                      {metric.id}
+                    </span>
+                  </div>
+                  <div className="min-w-0">
+                    <h3 className={`text-lg font-medium text-on-surface transition-colors group-hover:${metric.textClass}`}>
+                      {metric.name}
+                    </h3>
+                    <p className="text-sm text-secondary">
+                      {metric.summary}
+                    </p>
+                  </div>
                 </div>
-                <div>
-                  <h3 className={`text-lg font-medium text-on-surface transition-colors group-hover:${metric.textClass}`}>
-                    {metric.name}
-                  </h3>
-                  <p className="text-sm text-secondary">
-                    {metric.description}
-                  </p>
+                <div className="flex items-center gap-4 shrink-0">
+                  <div className="text-right hidden sm:block">
+                    <div className={`text-2xl font-bold ${metric.score == null ? "text-outline" : `text-${metric.colorClass}`}`}>
+                      {metric.avgScore}
+                    </div>
+                    <div className="text-xs text-secondary">指标值</div>
+                  </div>
+                  <ChevronDown
+                    className={`w-6 h-6 text-secondary transition-transform duration-300 ${expandedCard === metric.id ? "rotate-180" : ""}`}
+                  />
                 </div>
               </div>
-              <div className="flex items-center gap-4">
-                <div className="text-right hidden sm:block">
-                  <div className={`text-2xl font-bold text-${metric.colorClass}`}>{metric.avgScore}</div>
-                  <div className="text-xs text-secondary">平均分</div>
-                </div>
-                <ChevronDown
-                  className={`w-6 h-6 text-secondary transition-transform duration-300 ${expandedCard === metric.id ? "rotate-180" : ""}`}
-                />
+              <div className="mt-5 w-full bg-surface-container-high rounded-full h-2 overflow-hidden">
+                <div
+                  className="h-2 rounded-full transition-all duration-500"
+                  style={{
+                    width: `${progress}%`,
+                    backgroundColor: metric.score == null ? "var(--color-outline-variant)" : `var(--color-${metric.colorClass})`,
+                  }}
+                ></div>
               </div>
-            </div>
-            <div className="mt-5 w-full bg-surface-container-high rounded-full h-2 overflow-hidden">
-              <div
-                className={`h-2 rounded-full`}
-                style={{ width: metric.avgScore, backgroundColor: `var(--color-${metric.colorClass})` }}
-              ></div>
-            </div>
-            <div className={`mt-0 border-t border-outline-variant/30 pt-0 ${expandedCard === metric.id ? "animate-in slide-in-from-top-2 block" : "hidden"}`}>
-              <div className="pt-6 space-y-6">
-                {metric.subMetrics.map((subMetric) => {
-                  // Make a fake consistent status based on submetric ID string length and char codes for visual diversity, in a real app this is data driven
-                  const fakeScoreVal = 70 + (subMetric.id.charCodeAt(subMetric.id.length - 1) * 3) % 25; 
-                  const status = getMetricStatus(fakeScoreVal.toString());
-                  
-                  return (
-                    <div key={subMetric.id}>
-                      <div className="flex justify-between items-center mb-3">
-                        <h4 className="font-medium text-sm text-on-surface">
-                          {subMetric.id} {subMetric.name}
-                        </h4>
-                        <span className={`text-xs font-bold border px-2 py-0.5 rounded-full ${status.class}`}>
-                          {status.label}
-                        </span>
-                      </div>
-                      <div className="grid gap-2">
-                        {subMetric.values.map((val, idx) => (
-                           <div key={idx} className="flex items-center justify-between text-sm p-3 rounded-lg bg-surface-container-high border border-outline-variant/20 transition-colors">
-                              <div className="flex items-center text-secondary">
-                                <CheckCircle2 className={`w-4 h-4 mr-3 text-${metric.colorClass}`} />
-                                {val.name}
+              <div className={`mt-0 border-t border-outline-variant/30 pt-0 ${expandedCard === metric.id ? "animate-in slide-in-from-top-2 block" : "hidden"}`}>
+                <div className="pt-6 space-y-6">
+                  {metric.subMetrics.map((subMetric) => {
+                    const subStatus = subMetric.state === "ready" ? getScoreStatus(subMetric.score) : status;
+
+                    return (
+                      <div key={subMetric.id}>
+                        <div className="flex justify-between items-center mb-3 gap-3">
+                          <h4 className="font-medium text-sm text-on-surface">
+                            {subMetric.id} {subMetric.name}
+                          </h4>
+                          <span className={`text-xs font-bold border px-2 py-0.5 rounded-full whitespace-nowrap ${subStatus.class}`}>
+                            {subStatus.label}
+                          </span>
+                        </div>
+                        <div className="grid gap-2">
+                          {subMetric.values.map((val, idx) => {
+                            const valueStatus = getValueStatus(val);
+
+                            return (
+                              <div key={idx} className="text-sm p-3 rounded-lg bg-surface-container-high border border-outline-variant/20 transition-colors">
+                                <div className="flex items-center justify-between gap-3">
+                                  <div className="flex items-center text-secondary min-w-0">
+                                    <CheckCircle2 className={`w-4 h-4 mr-3 shrink-0 ${val.state === "ready" ? `text-${metric.colorClass}` : "text-outline"}`} />
+                                    <span className="truncate">{val.name}</span>
+                                  </div>
+                                  <div className="flex items-center gap-3 shrink-0">
+                                    <span className="font-mono text-on-surface">{val.displayValue}</span>
+                                    <span className={`text-[10px] font-bold border px-2 py-0.5 rounded-full ${valueStatus.class}`}>
+                                      {valueStatus.label}
+                                    </span>
+                                  </div>
+                                </div>
+                                {val.helperText && (
+                                  <p className="text-xs text-outline mt-2 pl-7">
+                                    {val.helperText}
+                                  </p>
+                                )}
+                                {val.progress != null && (
+                                  <div className="mt-3 ml-7 h-1.5 rounded-full bg-surface-variant overflow-hidden">
+                                    <div
+                                      className="h-full rounded-full"
+                                      style={{
+                                        width: `${val.progress}%`,
+                                        backgroundColor: `var(--color-${metric.colorClass})`,
+                                      }}
+                                    ></div>
+                                  </div>
+                                )}
                               </div>
-                           </div>
-                        ))}
+                            );
+                          })}
+                        </div>
                       </div>
-                    </div>
-                  );
-                })}
+                    );
+                  })}
+                </div>
               </div>
             </div>
-          </div>
-        ))}
+          );
+        })}
       </div>
 
       <div className="mt-8 mb-4">
         <h2 className="text-xl font-display font-medium text-on-secondary-container">
-          近期活动追踪
+          指标数据同步
         </h2>
       </div>
 
@@ -182,10 +252,10 @@ export default function DashboardContent() {
                   能力指标
                 </th>
                 <th className="p-4 font-medium text-sm text-secondary uppercase tracking-wider">
-                  测验/活动
+                  数据来源
                 </th>
                 <th className="p-4 font-medium text-sm text-secondary uppercase tracking-wider">
-                  得分
+                  指标值
                 </th>
                 <th className="p-4 font-medium text-sm text-secondary uppercase tracking-wider">
                   状态
@@ -193,11 +263,12 @@ export default function DashboardContent() {
               </tr>
             </thead>
             <tbody className="divide-y divide-outline-variant/20">
-              {displayData[activeTab].slice(0, 4).map((metric, i) => {
-                 const status = getMetricStatus(metric.avgScore);
-                 return (
+              {displayData[activeTab].map((metric) => {
+                const status = getMetricStatus(metric);
+
+                return (
                   <tr
-                    key={i}
+                    key={metric.id}
                     className="hover:bg-white/5 transition-colors cursor-pointer"
                     onClick={() => (window.location.href = `/metrics/${metric.id.toLowerCase()}`)}
                   >
@@ -208,16 +279,18 @@ export default function DashboardContent() {
                       <span className="text-on-surface">{metric.name}</span>
                     </td>
                     <td className="p-4 text-sm text-secondary">
-                      {metric.subMetrics[0]?.values[0]?.name || "综合能力评估"}
+                      {metric.id === "D4" ? "后端 D4 指标接口" : "等待后端接口"}
+                    </td>
+                    <td className={`p-4 font-mono text-sm ${metric.score == null ? "text-outline" : `text-${metric.colorClass}`}`}>
+                      {metric.avgScore}
                     </td>
-                    <td className={`p-4 font-mono text-sm text-${metric.colorClass}`}>{metric.avgScore}</td>
                     <td className="p-4 whitespace-nowrap">
                       <span className={`text-xs font-bold px-3 py-1 rounded-full border whitespace-nowrap ${status.class}`}>
                         {status.label}
                       </span>
                     </td>
                   </tr>
-                 )
+                );
               })}
             </tbody>
           </table>

+ 106 - 74
components/MetricsContent.tsx

@@ -6,45 +6,55 @@ import {
   Info,
   CheckCircle2,
   AlertTriangle,
-  User,
+  RefreshCw,
 } from "lucide-react";
+import { useMemo } from "react";
 import { useSearchParams } from "next/navigation";
-import { metricsData } from "./metricsData";
-import { getMockStudentById, getMockClassAverageMetrics } from "@/lib/mockApi";
 import { useAuth } from "@/components/AuthProvider";
+import { useD4Metrics } from "@/hooks/useD4Metrics";
+import { toD4UserId } from "@/lib/d4Metrics";
+import { buildRuntimeMetrics, getRuntimeMetric, type MetricDataState } from "@/lib/metricRuntime";
 
 export default function MetricsContent({ metricId }: { metricId: string }) {
   const searchParams = useSearchParams();
   const { user } = useAuth();
-  
-  const studentId = searchParams.get("student") || (user?.role === 'stu' ? user.id : null);
-  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());
+  const normalizedMetricId = metricId.toUpperCase();
+  const isD4Metric = normalizedMetricId === "D4";
+  const selectedStudentId = searchParams.get("student") || (user?.role === "stu" ? user.id : null);
+  const selectedD4UserId = toD4UserId(selectedStudentId);
+
+  const d4Query = useMemo(() => (selectedD4UserId ? { userId: selectedD4UserId } : {}), [selectedD4UserId]);
+  const d4Metrics = useD4Metrics(d4Query, { enabled: isD4Metric });
+
+  const runtimeMetrics = useMemo(
+    () => buildRuntimeMetrics({ data: d4Metrics.data, error: d4Metrics.error, isLoading: d4Metrics.isLoading }),
+    [d4Metrics.data, d4Metrics.error, d4Metrics.isLoading],
+  );
+
+  const metricObj = getRuntimeMetric(runtimeMetrics, normalizedMetricId);
 
   if (!metricObj) {
     return (
       <div className="flex-grow flex items-center justify-center p-8">
-        <h2 className="text-xl text-secondary">未找到指标 {metricId.toUpperCase()} 的详情</h2>
+        <h2 className="text-xl text-secondary">未找到指标 {normalizedMetricId} 的详情</h2>
       </div>
     );
   }
 
-  let scoreNum = 85.0; // fallback
-  if (student) {
-    const storedScore = student.metricScores?.[metricId.toUpperCase()];
-    if (storedScore !== undefined) {
-      scoreNum = storedScore;
-    }
-  } else {
-    const classAvgs = getMockClassAverageMetrics();
-    const storedScore = classAvgs[metricId.toUpperCase()];
-    if (storedScore !== undefined) {
-       scoreNum = storedScore;
-    }
-  }
+  const getStateLabel = (state: MetricDataState, score: number | null) => {
+    if (state === "loading") return { label: "加载中", class: "bg-primary/10 text-primary border-primary/30" };
+    if (state === "error") return { label: "接口异常", class: "bg-metric-low/10 text-metric-low border-metric-low/30" };
+    if (state === "unavailable") return { label: "未接入", class: "bg-surface-variant text-on-surface-variant border-outline-variant" };
+    if (score == null) return { label: "已同步", class: "bg-primary/10 text-primary border-primary/30" };
+    if (score >= 80) return { label: "良好", class: "bg-metric-high/10 text-metric-high border-metric-high/30" };
+    if (score >= 70) return { label: "一般", class: "bg-metric-warn/10 text-metric-warn border-metric-warn/30" };
+    return { label: "需改进", class: "bg-metric-low/10 text-metric-low border-metric-low/30" };
+  };
+
+  const scoreNum = metricObj.score;
+  const chartScore = scoreNum ?? 0;
+  const metricStatus = getStateLabel(metricObj.state, metricObj.score);
 
   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">
@@ -57,18 +67,16 @@ export default function MetricsContent({ metricId }: { metricId: string }) {
             </h2>
           </div>
           <div className="flex flex-col items-center justify-center py-8 relative">
-            {/* Ambient Background Glow */}
-            <div 
+            <div
               className="absolute w-40 h-40 rounded-full opacity-20 blur-2xl"
               style={{ backgroundColor: `var(--color-${metricObj.colorClass})` }}
             ></div>
-            
+
             <div className="relative w-40 h-40 flex items-center justify-center rounded-full bg-surface shadow-elevation-2 border border-outline-variant/30">
               <svg
                 className="absolute w-full h-full transform -rotate-90 drop-shadow-md"
                 viewBox="0 0 100 100"
               >
-                {/* Track Circle */}
                 <circle
                   cx="50"
                   cy="50"
@@ -77,14 +85,13 @@ export default function MetricsContent({ metricId }: { metricId: string }) {
                   className="stroke-surface-variant flex-shrink-0"
                   strokeWidth="6"
                 />
-                {/* Progress Circle */}
                 <circle
                   cx="50"
                   cy="50"
                   fill="transparent"
                   r="44"
-                  style={{ stroke: `var(--color-${metricObj.colorClass})` }}
-                  strokeDasharray={`${(scoreNum / 100) * 276.46} 276.46`}
+                  style={{ stroke: scoreNum == null ? "var(--color-outline-variant)" : `var(--color-${metricObj.colorClass})` }}
+                  strokeDasharray={`${(chartScore / 100) * 276.46} 276.46`}
                   strokeDashoffset="0"
                   strokeLinecap="round"
                   strokeWidth="8"
@@ -92,14 +99,14 @@ export default function MetricsContent({ metricId }: { metricId: string }) {
                 ></circle>
               </svg>
               <div className="text-center z-10 flex flex-col items-center justify-center bg-surface w-28 h-28 rounded-full shadow-inner border border-outline-variant/10">
-                <span 
+                <span
                   className="text-4xl font-display font-bold tracking-tight"
-                  style={{ color: `var(--color-${metricObj.colorClass})` }}
+                  style={{ color: scoreNum == null ? "var(--color-outline)" : `var(--color-${metricObj.colorClass})` }}
                 >
-                  {scoreNum}
+                  {scoreNum ?? "--"}
                 </span>
                 <span className="block text-[0.65rem] text-on-surface-variant uppercase tracking-widest mt-1 font-medium">
-                  得分
+                  指标值
                 </span>
               </div>
             </div>
@@ -107,14 +114,26 @@ export default function MetricsContent({ metricId }: { metricId: string }) {
           <div className="space-y-4 mt-2">
             <div className="flex justify-between items-center text-sm">
               <span className="text-on-surface-variant">
-                趋势 (对比上月)
+                数据状态
               </span>
-              <span className="flex items-center text-metric-high font-medium bg-metric-high/10 px-2 py-0.5 rounded-md">
-                <TrendingUp className="w-4 h-4 mr-1" /> +2.1%
+              <span className={`flex items-center font-medium px-2 py-0.5 rounded-md border ${metricStatus.class}`}>
+                <TrendingUp className="w-4 h-4 mr-1" /> {metricStatus.label}
               </span>
             </div>
+            <p className="text-xs text-outline leading-relaxed">
+              {metricObj.summary}
+            </p>
           </div>
-          <div className="mt-6 pt-6 border-t border-outline-variant">
+          <div className="mt-6 pt-6 border-t border-outline-variant space-y-3">
+            {isD4Metric && (
+              <button
+                type="button"
+                onClick={d4Metrics.refetch}
+                className="w-full border border-outline-variant text-on-surface h-10 rounded-full font-medium text-sm hover:bg-surface-container-high transition-all flex items-center justify-center gap-2 cursor-pointer"
+              >
+                <RefreshCw className="w-4 h-4" /> 刷新数据
+              </button>
+            )}
             <button className="w-full bg-primary text-on-primary h-10 rounded-full font-medium text-sm shadow-md hover:shadow-lg transition-all flex items-center justify-center gap-2 cursor-pointer">
               <Download className="w-5 h-5" /> 导出报告
             </button>
@@ -140,26 +159,17 @@ export default function MetricsContent({ metricId }: { metricId: string }) {
               关于 {metricObj.id} 的详细数据维度拆解
             </p>
           </div>
-          <div className="flex gap-2 mt-4 md:mt-0">
-            <button className="h-10 px-4 rounded-full border border-outline text-on-surface-variant text-sm font-medium hover:bg-surface-variant hover:text-on-surface focus:ring-2 focus:ring-primary focus:outline-none transition-colors cursor-pointer">
-              筛选
-            </button>
-            <button className="h-10 px-4 rounded-full border border-outline text-on-surface-variant text-sm font-medium hover:bg-surface-variant hover:text-on-surface focus:ring-2 focus:ring-primary focus:outline-none transition-colors cursor-pointer">
-              日期范围
-            </button>
-          </div>
         </div>
 
         <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
-          {metricObj.subMetrics.map((sub, i) => {
-            // Generating some mock variation to make UI look realistic
-            const subScore = Math.min(100, scoreNum + ((i % 3 === 0 ? 1 : -1) * (i * 2 + 3)));
-            const isWarn = subScore < 80;
-            
+          {metricObj.subMetrics.map((sub) => {
+            const subStatus = getStateLabel(sub.state, sub.score);
+            const isWarn = sub.state === "error" || (sub.score != null && sub.score < 70);
+
             return (
               <article key={sub.id} className="bg-surface rounded-xl p-0 shadow-elevation-1 border border-outline-variant overflow-hidden flex flex-col h-full hover:shadow-elevation-2 hover:bg-surface-container transition-all duration-300 group">
                 <div className="p-5 border-b border-outline-variant bg-surface-variant/20">
-                  <div className="flex justify-between items-start">
+                  <div className="flex justify-between items-start gap-4">
                     <div>
                       <span className={`text-xs font-bold tracking-wider uppercase text-${metricObj.colorClass}`}>
                         {sub.id}
@@ -168,40 +178,62 @@ export default function MetricsContent({ metricId }: { metricId: string }) {
                         {sub.name}
                       </h3>
                     </div>
-                    <span className={`w-8 h-8 rounded-full flex items-center justify-center ${isWarn ? 'bg-metric-warn/10 text-metric-warn' : 'bg-metric-high/10 text-metric-high'}`}>
+                    <span className={`w-8 h-8 rounded-full flex items-center justify-center shrink-0 ${isWarn ? "bg-metric-warn/10 text-metric-warn" : "bg-metric-high/10 text-metric-high"}`}>
                       {isWarn ? <AlertTriangle className="w-5 h-5" /> : <CheckCircle2 className="w-5 h-5" />}
                     </span>
                   </div>
                 </div>
                 <div className="p-5 flex-grow space-y-5">
-                  {sub.values.map((val, idx) => (
-                    <div key={idx} className="flex flex-col gap-1">
-                      <div className="flex items-center justify-between">
-                         <div className="flex items-start gap-3">
-                           <CheckCircle2 className="text-on-surface-variant w-5 h-5 mt-0.5" />
-                           <p className="text-sm font-medium text-on-surface leading-tight">
-                             {val.name}
-                           </p>
-                         </div>
-                         <div className="text-right whitespace-nowrap ml-2">
-                           <span className={`block text-xl font-bold ${isWarn ? 'text-metric-warn' : 'text-metric-high'}`}>
-                             {idx === 0 ? subScore + '%' : (isWarn ? '一般' : '优秀')}
-                           </span>
-                         </div>
+                  {sub.values.map((val, idx) => {
+                    const valueStatus = getStateLabel(val.state, val.score);
+
+                    return (
+                      <div key={idx} className="flex flex-col gap-2">
+                        <div className="flex items-start justify-between gap-3">
+                          <div className="flex items-start gap-3 min-w-0">
+                            <CheckCircle2 className="text-on-surface-variant w-5 h-5 mt-0.5 shrink-0" />
+                            <div className="min-w-0">
+                              <p className="text-sm font-medium text-on-surface leading-tight">
+                                {val.name}
+                              </p>
+                              {val.helperText && (
+                                <p className="text-xs text-outline mt-1 leading-relaxed">
+                                  {val.helperText}
+                                </p>
+                              )}
+                            </div>
+                          </div>
+                          <div className="text-right whitespace-nowrap ml-2">
+                            <span className={`block text-xl font-bold ${val.score == null ? "text-on-surface" : val.score < 70 ? "text-metric-warn" : "text-metric-high"}`}>
+                              {val.displayValue}
+                            </span>
+                            <span className={`inline-flex text-[10px] font-bold border px-2 py-0.5 rounded-full mt-1 ${valueStatus.class}`}>
+                              {valueStatus.label}
+                            </span>
+                          </div>
+                        </div>
+                        {val.progress != null && (
+                          <div className="w-full bg-surface-variant rounded-full h-1.5 mt-1">
+                            <div
+                              className={`h-1.5 rounded-full ${val.progress < 70 ? "bg-metric-warn" : "bg-metric-high"}`}
+                              style={{ width: `${val.progress}%` }}
+                            ></div>
+                          </div>
+                        )}
                       </div>
-                    </div>
-                  ))}
+                    );
+                  })}
                   <div className="w-full bg-surface-variant rounded-full h-1.5 mt-2">
                     <div
-                      className={`h-1.5 rounded-full ${isWarn ? 'bg-metric-warn' : 'bg-metric-high'}`}
-                      style={{ width: `${subScore}%` }}
+                      className={`h-1.5 rounded-full ${sub.score != null && sub.score < 70 ? "bg-metric-warn" : "bg-metric-high"}`}
+                      style={{ width: `${sub.score ?? 0}%` }}
                     ></div>
                   </div>
                 </div>
                 <div className="px-5 py-3 bg-surface-variant/10 flex justify-end border-t border-outline-variant/50">
-                  <button className="text-primary text-sm font-medium hover:bg-primary/10 px-3 py-1.5 rounded-lg transition-colors cursor-pointer">
-                    查看详情
-                  </button>
+                  <span className={`text-xs font-bold px-3 py-1 rounded-full border ${subStatus.class}`}>
+                    {subStatus.label}
+                  </span>
                 </div>
               </article>
             );

+ 186 - 137
components/TeacherDashboard.tsx

@@ -1,68 +1,136 @@
 "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 { useMemo } from "react";
+import { Download, Users, TrendingUp, Award, UserCheck, Loader2, RefreshCw, AlertTriangle } from "lucide-react";
 import Link from "next/link";
+import { useD4Metrics } from "@/hooks/useD4Metrics";
+import { D4_EVENT_LABELS, type D4EventName } from "@/lib/d4Metrics";
+import { buildRuntimeMetrics, getRuntimeMetric, type RuntimeMetricValue } from "@/lib/metricRuntime";
 
 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) {
+  const d4Metrics = useD4Metrics();
+
+  const runtimeMetrics = useMemo(
+    () => buildRuntimeMetrics({ data: d4Metrics.data, error: d4Metrics.error, isLoading: d4Metrics.isLoading }),
+    [d4Metrics.data, d4Metrics.error, d4Metrics.isLoading],
+  );
+
+  const d4Metric = getRuntimeMetric(runtimeMetrics, "D4");
+  const d4Values = d4Metric?.subMetrics.flatMap((subMetric) => subMetric.values) ?? [];
+
+  const findValue = (name: string): RuntimeMetricValue | undefined => d4Values.find((value) => value.name === name);
+
+  const completionRate = findValue("协作任务完成率");
+  const onTimeRate = findValue("个人任务按时完成率");
+  const averageResponse = findValue("团队沟通平均响应耗时");
+  const effectiveCount = findValue("有效协作次数");
+  const resolvedCount = findValue("协作冲突解决次数");
+  const conflictRate = findValue("协作冲突解决率");
+  const detailValues = [completionRate, onTimeRate, averageResponse, effectiveCount, resolvedCount, conflictRate]
+    .filter((value): value is RuntimeMetricValue => Boolean(value));
+
+  const overviewCards = [
+    {
+      key: "score",
+      label: "D4 综合指数",
+      value: d4Metric?.avgScore ?? "--",
+      helper: d4Metric?.summary ?? "正在同步 D4 指标",
+      color: "text-chart-1",
+      bg: "bg-secondary-container",
+      icon: <UserCheck className="w-6 h-6 text-chart-1" />,
+    },
+    {
+      key: "completion",
+      label: "协作任务完成率",
+      value: completionRate?.displayValue ?? "--",
+      helper: completionRate?.helperText ?? "D4_TASK_COMPLETED / D4_TASK_ASSIGNED",
+      color: "text-chart-3",
+      bg: "bg-primary-container",
+      icon: <TrendingUp className="w-6 h-6 text-primary" />,
+    },
+    {
+      key: "onTime",
+      label: "个人任务按时完成率",
+      value: onTimeRate?.displayValue ?? "--",
+      helper: onTimeRate?.helperText ?? "按后端 SLA 统计",
+      color: "text-chart-4",
+      bg: "bg-chart-5-container",
+      icon: <Award className="w-6 h-6 text-chart-4" />,
+    },
+    {
+      key: "conflict",
+      label: "协作冲突解决率",
+      value: conflictRate?.displayValue ?? "--",
+      helper: conflictRate?.helperText ?? "D4_COLLAB_CONFLICT_RESOLVED / CREATED",
+      color: "text-tertiary",
+      bg: "bg-tertiary-container",
+      icon: <Users className="w-6 h-6 text-tertiary" />,
+    },
+  ];
+
+  const eventCounts = Object.entries(d4Metrics.data?.eventCounts ?? {})
+    .sort(([eventA], [eventB]) => eventA.localeCompare(eventB))
+    .map(([event, count]) => ({
+      event,
+      label: D4_EVENT_LABELS[event as D4EventName] ?? event,
+      count,
+    }));
+
+  if (d4Metrics.isLoading && !d4Metrics.data) {
     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>
+        <span className="font-bold animate-pulse">正在加载 D4 教学大盘数据...</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;
+  if (d4Metrics.error && !d4Metrics.data) {
+    return (
+      <div className="flex-1 overflow-y-auto p-4 lg:p-8 animate-in fade-in duration-500">
+        <div className="bg-surface rounded-3xl p-8 shadow-elevation-1 border border-metric-low/30 max-w-3xl">
+          <div className="flex items-start gap-4">
+            <div className="w-12 h-12 rounded-2xl bg-metric-low/10 text-metric-low flex items-center justify-center shrink-0">
+              <AlertTriangle className="w-6 h-6" />
+            </div>
+            <div>
+              <h1 className="text-2xl font-display font-bold text-on-surface mb-2">D4 指标接口暂不可用</h1>
+              <p className="text-secondary text-sm leading-relaxed">{d4Metrics.error}</p>
+              <button
+                type="button"
+                onClick={d4Metrics.refetch}
+                className="mt-6 inline-flex items-center px-4 py-2 rounded-full bg-primary text-on-primary text-sm font-bold shadow hover:bg-primary/90 transition-colors"
+              >
+                <RefreshCw className="w-4 h-4 mr-2" />
+                重试
+              </button>
+            </div>
+          </div>
+        </div>
+      </div>
+    );
+  }
 
   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">
-            全体学生多维度能力评估核心统计基准板
+            当前接入 D4 态度与团队协作指标接口
           </p>
         </div>
         <div className="mt-4 md:mt-0 flex gap-3">
+          <button
+            type="button"
+            onClick={d4Metrics.refetch}
+            className="flex items-center px-5 py-2.5 border border-outline-variant text-on-surface rounded-full hover:bg-surface-container-high transition-all font-medium text-sm cursor-pointer"
+          >
+            <RefreshCw className="w-4 h-4 mr-2" />
+            刷新数据
+          </button>
           <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" />
             导出班级报告
@@ -70,116 +138,97 @@ export default function TeacherDashboard() {
         </div>
       </div>
 
-      {/* Aggregate Score Cards */}
       <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
-        {classAverages.map((data) => (
+        {overviewCards.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 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 gap-4">
+              <div className="min-w-0">
+                <p className="text-sm font-bold text-outline uppercase tracking-wider mb-1">D4 指标</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 shrink-0`}>
+                {data.icon}
+              </div>
+            </div>
+
+            <div className="mt-6 relative z-10">
+              <span className={`text-5xl font-display font-black tracking-tighter ${data.color}`}>{data.value}</span>
+              <p className="text-xs text-secondary mt-3 leading-relaxed min-h-8">{data.helper}</p>
+            </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 className="flex justify-between items-center mb-6 gap-4">
+            <h3 className="text-xl font-display font-medium text-on-surface">D4 子指标明细</h3>
+            <span className="text-xs font-bold text-primary bg-primary/10 border border-primary/20 px-3 py-1 rounded-full">
+              后端实时汇总
+            </span>
+          </div>
+
+          <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+            {detailValues.map((value) => (
+              <div key={value.name} className="p-4 rounded-2xl bg-surface border border-outline-variant/30">
+                <div className="flex items-start justify-between gap-3">
+                  <div>
+                    <p className="text-sm font-bold text-on-surface">{value.name}</p>
+                    <p className="text-xs text-secondary mt-1 leading-relaxed">{value.helperText}</p>
+                  </div>
+                  <span className="text-xl font-black text-primary whitespace-nowrap">{value.displayValue}</span>
+                </div>
+                {value.progress != null && (
+                  <div className="mt-4 h-2 rounded-full bg-surface-container-high overflow-hidden">
+                    <div className="h-full rounded-full bg-primary" style={{ width: `${value.progress}%` }}></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>
+          <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" />
+            D4 事件分布
+          </h3>
+
+          <div className="space-y-4 flex-1">
+            {eventCounts.map((item) => (
+              <div key={item.event} className="p-3 rounded-2xl bg-surface/70 border border-outline-variant/30">
+                <div className="flex items-center justify-between gap-3">
+                  <div className="min-w-0">
+                    <h4 className="font-bold text-on-surface text-sm truncate">{item.label}</h4>
+                    <p className="text-xs text-outline font-mono truncate">{item.event}</p>
+                  </div>
+                  <div className="text-primary font-black text-xl">{item.count}</div>
+                </div>
+              </div>
+            ))}
+            {eventCounts.length === 0 && (
+              <div className="text-sm text-secondary p-4 rounded-2xl bg-surface/70 border border-outline-variant/30">
+                当前时间范围内暂无 D4 事件。
+              </div>
+            )}
+          </div>
+
+          {d4Metrics.data && (
+            <div className="mt-6 pt-5 border-t border-outline-variant/40 text-xs text-secondary space-y-1">
+              <p>索引:{d4Metrics.data.scan.indexPattern}</p>
+              <p>扫描事件:{d4Metrics.data.scan.scannedEvents} / {d4Metrics.data.scan.maxEventsToScan}</p>
+              <p>范围:{new Date(d4Metrics.data.from).toLocaleString()} - {new Date(d4Metrics.data.to).toLocaleString()}</p>
+            </div>
+          )}
 
+          <Link href="/analysis" 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>
   );

+ 17 - 12
components/metricsData.ts

@@ -502,17 +502,22 @@ export const metricsData: Record<string, MetricCategoryData[]> = {
       colorClass: "chart-1",
       bgClass: "bg-secondary-container text-on-secondary-container",
       textClass: "text-chart-1",
      subMetrics: [
-        {
-          id: "D4-1",
-          name: "个人分工任务按时完成能力",
-          values: [{ name: "协作任务完成率" }],
-        },
-        {
-          id: "D4-2",
-          name: "团队内部沟通能力",
-          values: [{ name: "团队沟通平均响应耗时" }, { name: "有效协作次数" }, { name: "协作冲突解决次数" }],
-        },
-      ],
-    },
+        {
+          id: "D4-1",
+          name: "个人分工任务按时完成能力",
+          values: [{ name: "协作任务完成率" }, { name: "个人任务按时完成率" }],
+        },
+        {
+          id: "D4-2",
+          name: "团队内部沟通能力",
+          values: [
+            { name: "团队沟通平均响应耗时" },
+            { name: "有效协作次数" },
+            { name: "协作冲突解决次数" },
+            { name: "协作冲突解决率" },
+          ],
+        },
+      ],
+    },
   ],
 };

+ 70 - 0
hooks/useD4Metrics.ts

@@ -0,0 +1,70 @@
+"use client";
+
+import { useCallback, useEffect, useMemo, useState } from "react";
+import { fetchD4Metrics, type D4MetricsQuery, type D4MetricsResponse } from "@/lib/d4Metrics";
+
+export interface UseD4MetricsOptions {
+  enabled?: boolean;
+}
+
+export function useD4Metrics(query: D4MetricsQuery = {}, options: UseD4MetricsOptions = {}) {
+  const enabled = options.enabled ?? true;
+  const queryKey = useMemo(() => JSON.stringify(query), [query]);
+  const [refreshIndex, setRefreshIndex] = useState(0);
+  const requestKey = `${queryKey}:${refreshIndex}`;
+  const [result, setResult] = useState<{
+    key: string | null;
+    data: D4MetricsResponse | null;
+    error: string | null;
+  }>({
+    key: null,
+    data: null,
+    error: null,
+  });
+
+  const refetch = useCallback(() => {
+    setRefreshIndex((current) => current + 1);
+  }, []);
+
+  useEffect(() => {
+    if (!enabled) {
+      return;
+    }
+
+    const abortController = new AbortController();
+    const parsedQuery = JSON.parse(queryKey) as D4MetricsQuery;
+
+    fetchD4Metrics(parsedQuery, abortController.signal)
+      .then((response) => {
+        setResult({
+          key: requestKey,
+          data: response,
+          error: null,
+        });
+      })
+      .catch((requestError: unknown) => {
+        if (abortController.signal.aborted) {
+          return;
+        }
+
+        setResult({
+          key: requestKey,
+          data: null,
+          error: requestError instanceof Error ? requestError.message : "D4 指标请求失败",
+        });
+      });
+
+    return () => {
+      abortController.abort();
+    };
+  }, [enabled, queryKey, requestKey]);
+
+  const isCurrentResult = result.key === requestKey;
+
+  return {
+    data: isCurrentResult ? result.data : null,
+    error: isCurrentResult ? result.error : null,
+    isLoading: enabled && !isCurrentResult,
+    refetch,
+  };
+}

+ 63 - 0
lib/auth.ts

@@ -0,0 +1,63 @@
+export type UserRole = "stu" | "teacher";
+
+export interface User {
+  id: string;
+  username: string;
+  role: UserRole;
+  name: string;
+  avatar: string;
+}
+
+export interface AuthResponse {
+  token: string;
+  user: User;
+}
+
+const AUTH_USERS: Record<string, User> = {
+  stu: {
+    id: "231250001",
+    username: "stu",
+    role: "stu",
+    name: "李子涵",
+    avatar: "/images/profile-avatar.png",
+  },
+  teacher: {
+    id: "tea_001",
+    username: "teacher",
+    role: "teacher",
+    name: "刘钦",
+    avatar: "/images/profile-avatar.png",
+  },
+};
+
+const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
+
+export const authApi = {
+  login: async (username: string, password: string): Promise<AuthResponse> => {
+    await delay(300);
+
+    if (password !== "passwd") {
+      throw new Error("密码错误");
+    }
+
+    const user = AUTH_USERS[username];
+    if (!user) {
+      throw new Error("用户不存在");
+    }
+
+    return {
+      token: `local_auth_${user.username}_${user.id}_${Date.now()}`,
+      user,
+    };
+  },
+
+  verifySession: async (token: string | null): Promise<User | null> => {
+    await delay(120);
+
+    if (!token) {
+      return null;
+    }
+
+    return Object.values(AUTH_USERS).find((user) => token.includes(user.id)) ?? null;
+  },
+};

+ 119 - 0
lib/d4Metrics.ts

@@ -0,0 +1,119 @@
+const DEFAULT_API_BASE_URL = "http://localhost:8080";
+
+export const D4_METRICS_PATH = "/api/d4/metrics";
+
+export type D4EventName =
+  | "D4_COLLAB_CONFLICT_CREATED"
+  | "D4_COLLAB_CONFLICT_RESOLVED"
+  | "D4_COLLAB_EFFECTIVE"
+  | "D4_COMM_MESSAGE_RESPONSE"
+  | "D4_COMM_MESSAGE_SENT"
+  | "D4_TASK_ASSIGNED"
+  | "D4_TASK_COMPLETED";
+
+export interface D4MetricsQuery {
+  from?: string;
+  to?: string;
+  projectId?: number | string;
+  taskType?: string;
+  userId?: number | string;
+  slaMs?: number | string;
+}
+
+export interface D4MetricsResponse {
+  from: string;
+  to: string;
+  filter: {
+    projectId: number | null;
+    taskType: string | null;
+    userId: number | null;
+    slaMs: number | null;
+  };
+  taskCompletion: {
+    assignedCount: number;
+    completedCount: number;
+    completionRate: number | null;
+    onTimeCompletedCount: number | null;
+    onTimeCompletionRate: number | null;
+  };
+  communication: {
+    sentMessageCount: number;
+    respondedMessageCount: number;
+    averageResponseMs: number | null;
+  };
+  collaboration: {
+    effectiveCollaborationCount: number;
+  };
+  conflict: {
+    createdCount: number;
+    resolvedCount: number;
+    resolutionRate: number | null;
+  };
+  eventCounts: Record<D4EventName | string, number>;
+  scan: {
+    indexPattern: string;
+    scannedEvents: number;
+    maxEventsToScan: number;
+    truncated: boolean;
+  };
+}
+
+export const D4_EVENT_LABELS: Record<D4EventName, string> = {
+  D4_COLLAB_CONFLICT_CREATED: "协作冲突创建",
+  D4_COLLAB_CONFLICT_RESOLVED: "协作冲突解决",
+  D4_COLLAB_EFFECTIVE: "有效协作",
+  D4_COMM_MESSAGE_RESPONSE: "沟通响应",
+  D4_COMM_MESSAGE_SENT: "沟通发送",
+  D4_TASK_ASSIGNED: "任务分配",
+  D4_TASK_COMPLETED: "任务完成",
+};
+
+export function getApiBaseUrl() {
+  return (process.env.NEXT_PUBLIC_SEEC_ANALYSIS_API_BASE_URL || DEFAULT_API_BASE_URL).replace(/\/$/, "");
+}
+
+export function toD4UserId(userId: string | null | undefined) {
+  if (!userId) {
+    return undefined;
+  }
+
+  const parsed = Number(userId);
+  return Number.isSafeInteger(parsed) ? parsed : undefined;
+}
+
+export function buildD4MetricsUrl(query: D4MetricsQuery = {}) {
+  const url = new URL(`${getApiBaseUrl()}${D4_METRICS_PATH}`);
+
+  Object.entries(query).forEach(([key, value]) => {
+    if (value !== undefined && value !== null && `${value}`.trim() !== "") {
+      url.searchParams.set(key, `${value}`);
+    }
+  });
+
+  return url.toString();
+}
+
+export async function fetchD4Metrics(query: D4MetricsQuery = {}, signal?: AbortSignal) {
+  const response = await fetch(buildD4MetricsUrl(query), {
+    method: "GET",
+    headers: {
+      Accept: "application/json",
+    },
+    signal,
+  });
+
+  if (!response.ok) {
+    let message = `D4 指标请求失败 (${response.status})`;
+
+    try {
+      const body = (await response.json()) as { message?: string; error?: string };
+      message = body.message || body.error || message;
+    } catch {
+      // Keep the status based message when the response body is not JSON.
+    }
+
+    throw new Error(message);
+  }
+
+  return (await response.json()) as D4MetricsResponse;
+}

+ 251 - 0
lib/metricRuntime.ts

@@ -0,0 +1,251 @@
+import { metricsData, type MetricCategoryData, type MetricValue, type SubMetric } from "@/components/metricsData";
+import type { D4MetricsResponse } from "@/lib/d4Metrics";
+
+export type MetricDataState = "ready" | "loading" | "error" | "unavailable";
+
+export type RuntimeMetricValue = Omit<MetricValue, "score"> & {
+  displayValue: string;
+  helperText?: string;
+  progress: number | null;
+  score: number | null;
+  state: MetricDataState;
+};
+
+export type RuntimeSubMetric = Omit<SubMetric, "values"> & {
+  values: RuntimeMetricValue[];
+  score: number | null;
+  state: MetricDataState;
+};
+
+export type RuntimeMetric = Omit<MetricCategoryData, "subMetrics"> & {
+  subMetrics: RuntimeSubMetric[];
+  avgScore: string;
+  score: number | null;
+  state: MetricDataState;
+  summary: string;
+};
+
+export type RuntimeMetricsByCategory = Record<string, RuntimeMetric[]>;
+
+export interface D4MetricsLoadState {
+  data: D4MetricsResponse | null;
+  isLoading: boolean;
+  error: string | null;
+}
+
+const countFormatter = new Intl.NumberFormat("zh-CN");
+
+function clampScore(value: number) {
+  return Math.max(0, Math.min(100, Math.round(value)));
+}
+
+function rateToScore(rate: number | null | undefined) {
+  return rate == null ? null : clampScore(rate * 100);
+}
+
+function averageScore(values: Array<number | null | undefined>) {
+  const validValues = values.filter((value): value is number => typeof value === "number" && !Number.isNaN(value));
+
+  if (validValues.length === 0) {
+    return null;
+  }
+
+  return clampScore(validValues.reduce((sum, value) => sum + value, 0) / validValues.length);
+}
+
+function formatPercent(rate: number | null | undefined) {
+  const score = rateToScore(rate);
+  return score == null ? "暂无数据" : `${score}%`;
+}
+
+function formatCount(value: number | null | undefined) {
+  return value == null ? "暂无数据" : countFormatter.format(value);
+}
+
+function formatDuration(ms: number | null | undefined) {
+  if (ms == null) {
+    return "暂无数据";
+  }
+
+  const totalSeconds = Math.max(0, Math.round(ms / 1000));
+  if (totalSeconds < 60) {
+    return `${totalSeconds} 秒`;
+  }
+
+  const totalMinutes = Math.round(totalSeconds / 60);
+  if (totalMinutes < 60) {
+    return `${totalMinutes} 分钟`;
+  }
+
+  const hours = Math.floor(totalMinutes / 60);
+  const minutes = totalMinutes % 60;
+  if (hours < 24) {
+    return minutes > 0 ? `${hours} 小时 ${minutes} 分钟` : `${hours} 小时`;
+  }
+
+  const days = Math.floor(hours / 24);
+  const restHours = hours % 24;
+  return restHours > 0 ? `${days} 天 ${restHours} 小时` : `${days} 天`;
+}
+
+function buildPendingValue(value: MetricValue, state: MetricDataState, message: string, helperText?: string): RuntimeMetricValue {
+  return {
+    ...value,
+    displayValue: message,
+    helperText,
+    progress: null,
+    score: null,
+    state,
+  };
+}
+
+function buildPendingMetric(metric: MetricCategoryData, state: MetricDataState, message: string, summary: string): RuntimeMetric {
+  return {
+    ...metric,
+    avgScore: state === "loading" ? "..." : "--",
+    score: null,
+    state,
+    summary,
+    subMetrics: metric.subMetrics.map((subMetric) => ({
+      ...subMetric,
+      score: null,
+      state,
+      values: subMetric.values.map((value) => buildPendingValue(value, state, message, summary)),
+    })),
+  };
+}
+
+function buildD4ReadyMetric(metric: MetricCategoryData, data: D4MetricsResponse): RuntimeMetric {
+  const completionScore = rateToScore(data.taskCompletion.completionRate);
+  const onTimeScore = rateToScore(data.taskCompletion.onTimeCompletionRate);
+  const conflictScore = rateToScore(data.conflict.resolutionRate);
+  const score = averageScore([completionScore, onTimeScore, conflictScore]);
+
+  const subMetrics: RuntimeSubMetric[] = metric.subMetrics.map((subMetric) => {
+    if (subMetric.id === "D4-1") {
+      const values: RuntimeMetricValue[] = [
+        {
+          name: "协作任务完成率",
+          displayValue: formatPercent(data.taskCompletion.completionRate),
+          helperText: `${formatCount(data.taskCompletion.completedCount)} / ${formatCount(data.taskCompletion.assignedCount)} 项任务完成`,
+          progress: completionScore,
+          score: completionScore,
+          state: "ready",
+        },
+        {
+          name: "个人任务按时完成率",
+          displayValue: formatPercent(data.taskCompletion.onTimeCompletionRate),
+          helperText: `${formatCount(data.taskCompletion.onTimeCompletedCount)} / ${formatCount(data.taskCompletion.completedCount)} 项完成任务满足 SLA`,
+          progress: onTimeScore,
+          score: onTimeScore,
+          state: "ready",
+        },
+      ];
+
+      return {
+        ...subMetric,
+        values,
+        score: averageScore(values.map((value) => value.score)),
+        state: "ready",
+      };
+    }
+
+    if (subMetric.id === "D4-2") {
+      const values: RuntimeMetricValue[] = [
+        {
+          name: "团队沟通平均响应耗时",
+          displayValue: formatDuration(data.communication.averageResponseMs),
+          helperText: `${formatCount(data.communication.respondedMessageCount)} / ${formatCount(data.communication.sentMessageCount)} 条消息形成响应`,
+          progress: null,
+          score: null,
+          state: "ready",
+        },
+        {
+          name: "有效协作次数",
+          displayValue: formatCount(data.collaboration.effectiveCollaborationCount),
+          helperText: "D4_COLLAB_EFFECTIVE 事件计数",
+          progress: null,
+          score: null,
+          state: "ready",
+        },
+        {
+          name: "协作冲突解决次数",
+          displayValue: formatCount(data.conflict.resolvedCount),
+          helperText: `${formatCount(data.conflict.createdCount)} 次冲突创建`,
+          progress: null,
+          score: null,
+          state: "ready",
+        },
+        {
+          name: "协作冲突解决率",
+          displayValue: formatPercent(data.conflict.resolutionRate),
+          helperText: `${formatCount(data.conflict.resolvedCount)} / ${formatCount(data.conflict.createdCount)} 次冲突已解决`,
+          progress: conflictScore,
+          score: conflictScore,
+          state: "ready",
+        },
+      ];
+
+      return {
+        ...subMetric,
+        values,
+        score: averageScore(values.map((value) => value.score)),
+        state: "ready",
+      };
+    }
+
+    return {
+      ...subMetric,
+      score: null,
+      state: "ready",
+      values: subMetric.values.map((value) => buildPendingValue(value, "ready", "暂无数据")),
+    };
+  });
+
+  return {
+    ...metric,
+    avgScore: score == null ? "--" : `${score}`,
+    score,
+    state: "ready",
+    summary: `已同步 ${formatCount(data.scan.scannedEvents)} 条 D4 日志事件`,
+    subMetrics,
+  };
+}
+
+function buildD4Metric(metric: MetricCategoryData, loadState: D4MetricsLoadState): RuntimeMetric {
+  if (loadState.isLoading) {
+    return buildPendingMetric(metric, "loading", "加载中", "正在从 D4 指标接口同步数据");
+  }
+
+  if (loadState.error) {
+    return buildPendingMetric(metric, "error", "接口异常", loadState.error);
+  }
+
+  if (!loadState.data) {
+    return buildPendingMetric(metric, "unavailable", "暂无数据", "D4 接口尚未返回数据");
+  }
+
+  return buildD4ReadyMetric(metric, loadState.data);
+}
+
+export function buildRuntimeMetrics(d4LoadState: D4MetricsLoadState): RuntimeMetricsByCategory {
+  return Object.fromEntries(
+    Object.entries(metricsData).map(([category, metrics]) => [
+      category,
+      metrics.map((metric) => {
+        if (metric.id === "D4") {
+          return buildD4Metric(metric, d4LoadState);
+        }
+
+        return buildPendingMetric(metric, "unavailable", "未接入", "后端暂未提供该指标接口");
+      }),
+    ]),
+  );
+}
+
+export function getRuntimeMetric(metrics: RuntimeMetricsByCategory, metricId: string) {
+  const normalizedMetricId = metricId.toUpperCase();
+  const category = normalizedMetricId.charAt(0);
+
+  return metrics[category]?.find((metric) => metric.id === normalizedMetricId) ?? null;
+}

+ 0 - 337
lib/mockApi.ts

@@ -1,337 +0,0 @@
-// ============================================================================
-// 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);
-};
-
-export const getMockClassAverageMetrics = (): MetricScores => {
-  const students = initMockData();
-  if (students.length === 0) return {};
-  const totals: Record<string, number> = {};
-  students.forEach(s => {
-    Object.entries(s.metricScores).forEach(([k, v]) => {
-      totals[k] = (totals[k] || 0) + v;
-    });
-  });
-  const avgs: MetricScores = {};
-  Object.keys(totals).forEach(k => {
-    avgs[k] = Math.round(totals[k] / students.length);
-  });
-  return avgs;
-};
-
-export const getMockClassMacroAverages = (): Record<string, number> => {
-  const students = initMockData();
-  if (students.length === 0) return { K: 0, A: 0, S: 0, D: 0 };
-  const totals = { K: 0, A: 0, S: 0, D: 0 };
-  students.forEach(s => {
-    totals.K += s.K;
-    totals.A += s.A;
-    totals.S += s.S;
-    totals.D += s.D;
-  });
-  return {
-    K: Math.round(totals.K / students.length),
-    A: Math.round(totals.A / students.length),
-    S: Math.round(totals.S / students.length),
-    D: Math.round(totals.D / students.length),
-  };
-};

+ 0 - 38
middleware.ts

@@ -1,38 +0,0 @@
-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).*)',
-  ],
-};

+ 0 - 189
mock-api-docs.md

@@ -1,189 +0,0 @@
-# 模拟接口 API 文档 (基于 `mockApi`)
-
-本文档提供了当前前端中使用的 `mockApi` 相应的接口定义。通过此文档可以了解各个接口的请求参数格式及对应返回的数据格式,以方便后期后端的开发和前端的对应接口替换。
-
----
-
-## 基础数据实体
-
-在所有接口中复用的核心数据结构定义:
-
-### User (用户信息)
-```typescript
-{
-  "id": "string",         // 用户唯一ID
-  "username": "string",   // 登录用户名
-  "role": "stu" | "teacher", // 用户角色
-  "name": "string",       // 用户姓名
-  "avatar": "string"      // 头像路径/URL
-}
-```
-
-### MockStudent (学生详细信息)
-```typescript
-{
-  "id": "string",         // 学生学号/ID
-  "name": "string",       // 学生姓名
-  "overall": "number",    // 总分
-  "K": "number",          // 知识覆盖维度大分 (0-100)
-  "A": "number",          // AI交互维度大分 (0-100)
-  "S": "number",          // 代码产出维度大分 (0-100)
-  "D": "number",          // 团队协作维度大分 (0-100)
-  "avatar": "string",     // 头像URL
-  "metricScores": {       // 具体子指标分数映射,如 "K1": 88
-    "[metricId: string]": "number"
-  }
-}
-```
-
----
-
-## 接口列表
-
-### 1. 登录 (Login)
-* **功能**: 用户使用用户名和密码登录
-* **路径**: `POST /api/auth/login` (建议)
-* **请求格式**: `application/json`
-  ```json
-  {
-    "username": "stu",      // 必填
-    "password": "passwd"    // 必填 (模拟数据密码固定为 passwd)
-  }
-  ```
-* **返回格式**: `200 OK`
-  ```json
-  {
-    "token": "string",      // 签发的 JWT Token 
-    "user": {
-      // User 结构
-      "id": "231250001",
-      "username": "stu",
-      "role": "stu",
-      "name": "李子涵",
-      "avatar": "/images/profile-avatar.png"
-    }
-  }
-  ```
-* **错误响应**: `401 Unauthorized` (密码错误或用户不存在)
-
----
-
-### 2. 校验会话 (Verify Session)
-* **功能**: 利用现有的 Token 验证用户并获取信息
-* **路径**: `GET /api/auth/verify` (建议)
-* **请求头**: 
-  * `Authorization: Bearer <token>`
-* **返回格式**: `200 OK`
-  ```json
-  {
-    // User 结构 
-  }
-  ```
-  *(注: 在 mock 中如果 token 为空或无效则返回 `null` / `401 Unauthorized`)*
-
----
-
-### 3. 获取所有学生列表 (Get Students)
-* **功能**: 获取全班学生的简要/详细得分数据集合
-* **路径**: `GET /api/students`
-* **返回格式**: `200 OK`
-  ```json
-  [
-    {
-      // MockStudent 结构
-    },
-    // ...
-  ]
-  ```
-
----
-
-### 4. 获取指定学生详情 (Get Student By Id)
-* **功能**: 根据学号/ID请求单个具体学生的数据
-* **路径**: `GET /api/students/:id`
-* **参数**: 
-  * 路径参数 `id`: 目标学生ID
-* **返回格式**: `200 OK`
-  ```json
-  {
-    // MockStudent 结构
-  }
-  ```
-* **错误响应**: `404 Not Found` (未找到对应学生)
-
----
-
-### 5. 获取指定学生子指标分数 (Get Student Metrics)
-* **功能**: 仅获取某个学生的详细二级指标分数(如果不想拉取全量学生信息的话,用来绘制雷达图等使用)
-* **路径**: `GET /api/students/:id/metrics`
-* **返回格式**: `200 OK`
-  ```json
-  {
-    "K1": 85,
-    "K2": 90,
-    "A1": 77
-    // [metricId: string]: number
-  }
-  ```
-
----
-
-### 6. 获取班级统计信息 (Get Class Stats)
-* **功能**: 获取当前班级的整体统计报表(如平均分、四大指标对应的分布区间)
-* **路径**: `GET /api/class/stats`
-* **返回格式**: `200 OK`
-  ```json
-  {
-    "averages": {
-      "K": 82,
-      "A": 81,
-      "S": 82,
-      "D": 82
-    },
-    "distributions": {
-      "K": [
-        { "tier": "优秀 (90+)", "count": 10, "percent": "25%" },
-        { "tier": "良好 (80-89)", "count": 20, "percent": "50%" },
-        { "tier": "一般 (70-79)", "count": 5, "percent": "12%" },
-        { "tier": "需改进 (<70)", "count": 5, "percent": "12%" }
-      ],
-      "A": [ /* ... */ ],
-      "S": [ /* ... */ ],
-      "D": [ /* ... */ ]
-    }
-  }
-  ```
-
----
-
-### 7. 获取排行榜 (Get Leaderboard)
-* **功能**: 返回学生总分排行榜数据
-* **路径**: `GET /api/leaderboard`
-* **请求参数 (Query)**:
-  * `limit` (选填): 默认值为 5,控制请求返回前N名
-* **返回格式**: `200 OK`
-  ```json
-  [
-    {
-      "id": "231250005",
-      "name": "刘星雨",
-      "score": 92,
-      "avatar": "/images/profile-avatar.png",
-      "bestAt": "K (知识覆盖)" // 计算所得的最高维度提示
-    }
-    // ...共 limit 条
-  ]
-  ```
-
----
-
-### 8. 获取"我的"个人数据 (Get My Profile)
-* **功能**: 面向学生端使用的快捷接口,获取当前登录用户的档案(无需手动传ID参数)
-* **路径**: `GET /api/me`
-* **请求头**: 需要携带凭据 (如 `Authorization: Bearer <token>`)
-* **返回数据格式**: `200 OK`
-  ```json
-  {
-    // MockStudent 结构
-  }
-  ```

+ 2 - 1
package.json

@@ -7,6 +7,7 @@
     "build": "next build",
     "start": "next start",
     "lint": "eslint .",
+    "typecheck": "tsc --noEmit --incremental false",
     "clean": "next clean"
   },
   "dependencies": {
@@ -37,4 +38,4 @@
     "tw-animate-css": "^1.4.0",
     "typescript": "5.9.3"
   }
-}
+}

+ 6 - 0
pnpm-workspace.yaml

@@ -0,0 +1,6 @@
+allowBuilds:
+  '@tailwindcss/oxide': false
+  protobufjs: false
+  re2: false
+  sharp: false
+  unrs-resolver: false

+ 25 - 0
proxy.ts

@@ -0,0 +1,25 @@
+import { NextResponse } from "next/server";
+import type { NextRequest } from "next/server";
+
+export function proxy(request: NextRequest) {
+  const token = request.cookies.get("auth_token")?.value;
+  const isLoginPage = request.nextUrl.pathname === "/login";
+
+  if (!token && !isLoginPage) {
+    const loginUrl = new URL("/login", request.url);
+    return NextResponse.redirect(loginUrl);
+  }
+
+  if (token && isLoginPage) {
+    const dashboardUrl = new URL("/", request.url);
+    return NextResponse.redirect(dashboardUrl);
+  }
+
+  return NextResponse.next();
+}
+
+export const config = {
+  matcher: [
+    "/((?!api|_next/static|_next/image|favicon.ico|images|banner).*)",
+  ],
+};