在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卡片與深色玻璃擬物風格,打造專業的高顏值數據面板!