在Day19中,我們完成全站UI/UX體驗升級,加入了SonnerToast通知與Modal/ConfirmDialog對話框。
今天(Day20),我們將邁入VibePulse的核心核心功能之一:單一技能詳細頁面/skills/[id]。我們將深入Next.jsAppRouter的靈魂——ServerComponent(RSC)與ClientComponent的混合渲染架構,讓使用者點擊特定技能卡片後,能進入專屬的技能儀表板,切換檢視技能概覽、學習筆記與練習日誌。
1.架構設計:ServerComponent+ClientTabs混合架構
為了兼顧SEO/首屏渲染速度(FCP)與前端豐富的頁籤互動,我們採用分層設計:
2.實戰步驟1:建立單一技能API端點(app/api/skills/[id]/route.ts)
在建置前端頁面前,先確保後端提供完整的單一技能查詢API:
// app/api/skills/[id]/route.ts
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
export async function GET(
request: Request,
{ params }: { params: { id: string } }
) {
try {
const { id } = params;
const skill = await prisma.skill.findUnique({
where: { id },
include: {
notes: { orderBy: { createdAt: 'desc' } },
logs: { orderBy: { date: 'desc' } },
},
});
if (!skill) {
return NextResponse.json({ error: '找不到該技能' }, { status: 404 });
}
return NextResponse.json(skill);
} catch (error) {
return NextResponse.json(
{ error: '伺服器取得技能資料失敗' },
{ status: 500 }
);
}
}
3.實戰步驟2:封裝Client端頁籤互動組件(components/skills/SkillDetailView.tsx)
這個ClientComponent負責處理使用者介面的互動,包括Tabs切換、動態進度條顯示以及刪除/編輯Modal的觸發。
// components/skills/SkillDetailView.tsx
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { BookOpen, Calendar, ArrowLeft, Trash2, Trophy } from 'lucide-react';
import { ConfirmDialog } from '@/components/ui/ConfirmDialog';
import { useDeleteSkill } from '@/hooks/useSkills';
interface SkillDetailViewProps {
skill: {
id: string;
title: string;
category: string;
proficiency: number;
description: string | null;
createdAt: string;
};
}
export function SkillDetailView({ skill }: SkillDetailViewProps) {
const router = useRouter();
const [activeTab, setActiveTab] = useState<'overview' | 'notes' | 'logs'>('overview');
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const deleteSkillMutation = useDeleteSkill();
const handleDelete = () => {
deleteSkillMutation.mutate(skill.id, {
onSuccess: () => {
setIsDeleteOpen(false);
router.push('/skills');
},
});
};
return (
<div className="max-w-5xl mx-auto px-4 py-8 space-y-8">
{/* 頂部導覽列 */}
<div className="flex items-center justify-between">
<button
onClick={() => router.back()}
className="flex items-center gap-2 text-sm text-slate-400 hover:text-slate-100 transition"
>
<ArrowLeft className="h-4 w-4" /> 返回技能列表
</button>
<button
onClick={() => setIsDeleteOpen(true)}
className="flex items-center gap-2 px-3 py-1.5 text-sm text-rose-400 border border-rose-500/20 bg-rose-500/10 rounded-lg hover:bg-rose-500/20 transition"
>
<Trash2 className="h-4 w-4" /> 刪除技能
</button>
</div>
{/* Hero 技能資訊面板 */}
<div className="rounded-2xl border border-slate-800 bg-slate-900/60 p-6 md:p-8 backdrop-blur-md space-y-6">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<span className="inline-block px-3 py-1 text-xs font-semibold rounded-full bg-cyan-500/10 text-cyan-400 border border-cyan-500/20">
{skill.category}
</span>
<h1 className="text-3xl font-extrabold text-slate-100 mt-2">{skill.title}</h1>
</div>
<div className="flex items-center gap-3 bg-slate-800/80 px-4 py-3 rounded-xl border border-slate-700/60">
<Trophy className="h-6 w-6 text-amber-400" />
<div>
<div className="text-xs text-slate-400">當前熟練度</div>
<div className="text-xl font-bold text-slate-100">{skill.proficiency} %</div>
</div>
</div>
</div>
{/* 熟練度進度條 */}
<div className="w-full bg-slate-800 h-3 rounded-full overflow-hidden">
<div
className="bg-gradient-to-r from-cyan-500 to-blue-500 h-full transition-all duration-500"
style={{ width: `${skill.proficiency}%` }}
/>
</div>
<p className="text-slate-300 text-sm leading-relaxed">
{skill.description || '尚無描述內容...'}
</p>
</div>
{/* 頁籤導覽 (Tabs) */}
<div className="border-b border-slate-800 flex gap-6">
{[
{ key: 'overview', label: '總覽', icon: BookOpen },
{ key: 'notes', label: '學習筆記', icon: BookOpen },
{ key: 'logs', label: '練習紀錄', icon: Calendar },
].map((tab) => {
const Icon = tab.icon;
const isActive = activeTab === tab.key;
return (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key as any)}
className={`flex items-center gap-2 pb-3 font-medium text-sm border-b-2 transition ${
isActive
? 'border-cyan-500 text-cyan-400'
: 'border-transparent text-slate-400 hover:text-slate-200'
}`}
>
<Icon className="h-4 w-4" />
{tab.label}
</button>
);
})}
</div>
{/* 頁籤內容展現區域 */}
<div className="pt-2">
{activeTab === 'overview' && (
<div className="rounded-xl border border-slate-800 bg-slate-900/40 p-6 text-slate-300 text-sm">
這裡展示技能的總體統計數據與近期的練習趨勢圖表。
</div>
)}
{activeTab === 'notes' && (
<div className="text-slate-400 text-sm">學習筆記模組(將於 Day 21 實作)</div>
)}
{activeTab === 'logs' && (
<div className="text-slate-400 text-sm">練習日誌模組(將於 Day 22 實作)</div>
)}
</div>
{/* 刪除確認 Modal */}
<ConfirmDialog
isOpen={isDeleteOpen}
onClose={() => setIsDeleteOpen(false)}
onConfirm={handleDelete}
title={`確定要刪除「${skill.title}」嗎?`}
message="此操作無法復原,與該技能相關的所有筆記與練習日誌將一併清除。"
isLoading={deleteSkillMutation.isPending}
/>
</div>
);
}
4.實戰步驟3:撰寫ServerComponent主頁面(app/skills/[id]/page.tsx)
現在我們可以在page.tsx中使用Next.js的原生Server-Side擷取機制,預先將數據載入完成,若技能不存在則自動呼叫notFound():
// app/skills/[id]/page.tsx
import { notFound } from 'next/navigation';
import { prisma } from '@/lib/prisma';
import { SkillDetailView } from '@/components/skills/SkillDetailView';
interface SkillPageProps {
params: {
id: string;
};
}
export async function generateMetadata({ params }: SkillPageProps) {
const skill = await prisma.skill.findUnique({ where: { id: params.id } });
if (!skill) return { title: '技能不存在 | VibePulse' };
return { title: `${skill.title} | VibePulse 技能儀表板` };
}
export default async function SkillDetailPage({ params }: SkillPageProps) {
const skill = await prisma.skill.findUnique({
where: { id: params.id },
});
if (!skill) {
notFound();
}
// 序列化 Date 物件傳遞給 Client Component
const formattedSkill = {
...skill,
createdAt: skill.createdAt.toISOString(),
};
return <SkillDetailView skill={formattedSkill} />;
}
今天我們完成了VibePulse的動態路由頁面建置:
/skills/[id]動態路由:靈活運用Next.jsAppRouter的動態參數結構。
RSC+ClientComponent混合架構:Server端預先處理404與資料庫SSR,Client端負責頁籤互動與Modal防護。
極致介面排版:建立充滿科技質感的Hero區域、Progress進度條與風格一致的Tabs切換器。