iT邦幫忙

2026 iThome 鐵人賽

DAY 23
0
Vibe Coding

Vibe Coding的30天,自然語言與AI共舞,從Prompt到高品質原型落地系列 第 23

Day 23|全站Dashboard數據儀表板總結統計KPI與Recharts綜合圖表

  • 分享至 

  • xImage
  •  

在Day22中,我們完成了單一技能詳細頁(/skills/[id])中的練習日誌打卡與Recharts圖表,讓使用者能夠量化單一技能的累積進度。

今天(Day23),我們將把視角拉回至全站最高維度——全站Dashboard數據儀表板(/dashboard)!

我們將聚合資料庫中所有技能、筆記與練習日誌的數據,打造一個具備科技感、視覺衝擊力且商業級別的全站總覽頁面:

全站KPI指標卡片:即時計算總技能數、平均熟練度、累積練習總時數與本週打卡天數。

Recharts綜合數據圖表:

技能類別分佈雷達圖(RadarChart):視覺化評估各領域技能的均衡度。

全站練習時數趨勢圖(AreaChart):展示近30天的學習熱度與時間投入。

Prisma高效能數據聚合(Aggregation&GroupBy):在Server端完成複雜運算,確保載入速度極致流暢!

1.架構設計:Server-Side數據聚合與Client視覺化
為了確保首屏載入速度(FCP)並降低Client端的運算負擔,我們利用Prisma的aggregate與groupBy在ServerComponent中預先計算數據,再傳遞給可互動的Recharts視圖組件:

2.實戰步驟1:建立Dashboard數據聚合API/Server函式(lib/dashboard.ts)
我們在@lib/dashboard.ts中封裝Server端的數據計算邏輯:

// lib/dashboard.ts
import { prisma } from '@/lib/prisma';

export async function getDashboardStats() {
  // 1. 基礎統計數據 (Aggregate)
  const skillStats = await prisma.skill.aggregate({
    _count: { id: true },
    _avg: { proficiency: true },
  });

  const logStats = await prisma.practiceLog.aggregate({
    _sum: { duration: true },
    _count: { id: true },
  });

  const noteCount = await prisma.note.count();

  // 2. 按分類 (Category) 分組統計 (GroupBy)
  const categoryStats = await prisma.skill.groupBy({
    by: ['category'],
    _count: { id: true },
    _avg: { proficiency: true },
  });

  // 3. 取得近 30 天的練習日誌進行時間軸聚合
  const thirtyDaysAgo = new Date();
  thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);

  const recentLogs = await prisma.practiceLog.findMany({
    where: {
      date: { gte: thirtyDaysAgo },
    },
    select: {
      date: true,
      duration: true,
    },
    orderBy: { date: 'asc' },
  });

  // 整理近 30 天每日累積時數
  const dailyLogsMap: Record<string, number> = {};
  recentLogs.forEach((log) => {
    const dateStr = new Date(log.date).toISOString().split('T')[0];
    dailyLogsMap[dateStr] = (dailyLogsMap[dateStr] || 0) + log.duration;
  });

  return {
    totalSkills: skillStats._count.id || 0,
    avgProficiency: Math.round(skillStats._avg.proficiency || 0),
    totalPracticeMinutes: logStats._sum.duration || 0,
    totalNotes: noteCount,
    categoryBreakdown: categoryStats.map((item) => ({
      category: item.category,
      count: item._count.id,
      avgProficiency: Math.round(item._avg.proficiency || 0),
    })),
    dailyPracticeTrend: Object.entries(dailyLogsMap).map(([date, duration]) => ({
      date,
      duration,
    })),
  };
}

3.實戰步驟2:打造Recharts綜合圖表組件
我們封裝兩個現代深色風格的圖表組件:
A.技能領域分佈雷達圖(components/dashboard/CategoryRadarChart.tsx)

// components/dashboard/CategoryRadarChart.tsx
'use client';

import {
  Radar,
  RadarChart,
  PolarGrid,
  PolarAngleAxis,
  PolarRadiusAxis,
  ResponsiveContainer,
  Tooltip,
} from 'recharts';

interface CategoryRadarChartProps {
  data: {
    category: string;
    avgProficiency: number;
  }[];
}

export function CategoryRadarChart({ data }: CategoryRadarChartProps) {
  return (
    <div className="w-full h-80 bg-slate-900/60 border border-slate-800 p-5 rounded-2xl backdrop-blur-md flex flex-col">
      <h3 className="text-base font-bold text-slate-100 mb-2">技能領域均衡度 (雷達圖)</h3>
      <div className="flex-1 w-full">
        <ResponsiveContainer width="100%" height="100%">
          <RadarChart cx="50%" cy="50%" outerRadius="70%" data={data}>
            <PolarGrid stroke="#334155" />
            <PolarAngleAxis dataKey="category" stroke="#94a3b8" fontSize={12} />
            <PolarRadiusAxis angle={30} domain={[0, 100]} stroke="#475569" fontSize={10} />
            <Tooltip
              contentStyle={{
                backgroundColor: '#0f172a',
                borderColor: '#334155',
                borderRadius: '0.75rem',
                color: '#f8fafc',
              }}
            />
            <Radar
              name="平均熟練度 (%)"
              dataKey="avgProficiency"
              stroke="#06b6d4"
              fill="#06b6d4"
              fillOpacity={0.4}
            />
          </RadarChart>
        </ResponsiveContainer>
      </div>
    </div>
  );
}

B. 月度練習時數趨勢圖(components/dashboard/PracticeTrendChart.tsx)

// components/dashboard/PracticeTrendChart.tsx
'use client';

import {
  AreaChart,
  Area,
  XAxis,
  YAxis,
  CartesianGrid,
  Tooltip,
  ResponsiveContainer,
} from 'recharts';

interface PracticeTrendChartProps {
  data: {
    date: string;
    duration: number;
  }[];
}

export function PracticeTrendChart({ data }: PracticeTrendChartProps) {
  return (
    <div className="w-full h-80 bg-slate-900/60 border border-slate-800 p-5 rounded-2xl backdrop-blur-md flex flex-col">
      <h3 className="text-base font-bold text-slate-100 mb-2">近 30 天練習時數趨勢 (分鐘)</h3>
      <div className="flex-1 w-full">
        <ResponsiveContainer width="100%" height="100%">
          <AreaChart data={data} margin={{ top: 10, right: 10, left: -20, bottom: 0 }}>
            <defs>
              <linearGradient id="colorDuration" x1="0" y1="0" x2="0" y2="1">
                <stop offset="5%" stopColor="#3b82f6" stopOpacity={0.8} />
                <stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
              </linearGradient>
            </defs>
            <CartesianGrid strokeDasharray="3 3" stroke="#334155" opacity={0.5} />
            <XAxis dataKey="date" stroke="#94a3b8" fontSize={11} tickLine={false} />
            <YAxis stroke="#94a3b8" fontSize={11} tickLine={false} />
            <Tooltip
              contentStyle={{
                backgroundColor: '#0f172a',
                borderColor: '#334155',
                borderRadius: '0.75rem',
                color: '#f8fafc',
              }}
            />
            <Area
              type="monotone"
              dataKey="duration"
              stroke="#3b82f6"
              strokeWidth={2}
              fillOpacity={1}
              fill="url(#colorDuration)"
              name="練習時數 (分)"
            />
          </AreaChart>
        </ResponsiveContainer>
      </div>
    </div>
  );
}

4.實戰步驟3:組合全站Dashboard頁面(app/dashboard/page.tsx)
現在我們將4大KPI卡片與Recharts視圖組合至儀表板主頁面:

// app/dashboard/page.tsx
import { getDashboardStats } from '@/lib/dashboard';
import { CategoryRadarChart } from '@/components/dashboard/CategoryRadarChart';
import { PracticeTrendChart } from '@/components/dashboard/PracticeTrendChart';
import { BookOpen, Trophy, Clock, FileText } from 'lucide-react';

export const revalidate = 60; // 每 60 秒重新快取 ISR

export default async function DashboardPage() {
  const stats = await getDashboardStats();

  const totalHours = Math.round((stats.totalPracticeMinutes / 60) * 10) / 10;

  return (
    <div className="max-w-7xl mx-auto px-4 py-8 space-y-8">
      {/* 頁頭 */}
      <div>
        <h1 className="text-3xl font-extrabold text-slate-100">全站數據儀表板</h1>
        <p className="text-slate-400 text-sm mt-1">
          量化你的學習歷程,即時掌握技能累積與練習熱度
        </p>
      </div>

      {/* 1. 4 大 KPI 指標卡片 */}
      <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
        {[
          {
            title: '總追蹤技能',
            value: `${stats.totalSkills} 項`,
            icon: BookOpen,
            color: 'text-cyan-400',
            bgColor: 'bg-cyan-500/10',
            borderColor: 'border-cyan-500/20',
          },
          {
            title: '平均熟練度',
            value: `${stats.avgProficiency} %`,
            icon: Trophy,
            color: 'text-amber-400',
            bgColor: 'bg-amber-500/10',
            borderColor: 'border-amber-500/20',
          },
          {
            title: '累積練習時數',
            value: `${totalHours} 小時`,
            icon: Clock,
            color: 'text-blue-400',
            bgColor: 'bg-blue-500/10',
            borderColor: 'border-blue-500/20',
          },
          {
            title: '學習筆記總數',
            value: `${stats.totalNotes} 篇`,
            icon: FileText,
            color: 'text-emerald-400',
            bgColor: 'bg-emerald-500/10',
            borderColor: 'border-emerald-500/20',
          },
        ].map((kpi, idx) => {
          const Icon = kpi.icon;
          return (
            <div
              key={idx}
              className="rounded-2xl border border-slate-800 bg-slate-900/60 p-5 backdrop-blur-md flex items-center justify-between"
            >
              <div>
                <span className="text-xs text-slate-400 font-medium">{kpi.title}</span>
                <div className="text-2xl font-black text-slate-100 mt-1">{kpi.value}</div>
              </div>
              <div className={`p-3 rounded-xl border ${kpi.bgColor} ${kpi.borderColor} ${kpi.color}`}>
                <Icon className="h-6 w-6" />
              </div>
            </div>
          );
        })}
      </div>

      {/* 2. Recharts 綜合視覺化圖表雙欄 */}
      <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
        <CategoryRadarChart data={stats.categoryBreakdown} />
        <PracticeTrendChart data={stats.dailyPracticeTrend} />
      </div>
    </div>
  );
}

今天我們成功構建了VibePulse的全站Dashboard儀表板:

Prisma效能優化:運用aggregate與groupBy在Server端完成多表數據聚合,減輕前端負載。

Recharts雙維度圖表:以雷達圖(RadarChart)評估跨領域能力均衡度,以漸層面積圖(AreaChart)展現30天練習熱度。

商業級UI配置:搭配響應式KPI卡片與深色玻璃擬物風格,打造專業的高顏值數據面板!


上一篇
Day 22|練習日誌與數據視覺化整合Recharts圖表與打卡機制
系列文
Vibe Coding的30天,自然語言與AI共舞,從Prompt到高品質原型落地23
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言