iT邦幫忙

2026 iThome 鐵人賽

DAY 22
1
Vibe Coding

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

Day 22|練習日誌與數據視覺化整合Recharts圖表與打卡機制

  • 分享至 

  • xImage
  •  

在Day21中,我們成功為單一技能詳細頁(/skills/[id])加入了Markdown學習筆記系統,並實作了語法高亮與雙欄即時預覽。

今天(Day22),我們將填補詳細頁中的最後一塊核心拼圖—練習日誌(PracticeLogs)與數據視覺化!

對於任何技能的學習,持續練習與數據回饋是突破瓶頸期的不二法門。今天我們將實作:

練習日誌打卡模組:記錄每次練習的時數(Minutes)、心流程度(FocusLevel)與練習備註。

Prisma數據連動:打卡時同步自動更新Skill的lastPracticedAt(最後練習時間)與proficiency(熟練度)。

Recharts視覺化圖表:繪製每週練習時數趨勢圖(BarChart)與熟練度成長曲線(AreaChart),用數據量化你的學習成長!

1.系統架構與資料庫關聯(PracticeLogModel)
我們首先擴充prisma/schema.prisma,建立PracticeLog模型,並建立與Skill的1:N關聯
2.實戰步驟1:擴充PrismaSchema與Migration
開啟@prisma/schema.prisma,加入PracticeLog模型:

// prisma/schema.prisma

model Skill {
  id              String        @id @default(uuid())
  title           String
  category        Category
  proficiency     Int           @default(0)
  tags            String[]      @default([])
  status          Status        @default(LEARNING)
  notesCount      Int           @default(0)
  lastPracticedAt DateTime      @default(now())
  createdAt       DateTime      @default(now())
  updatedAt       DateTime      @updatedAt

  notes           Note[]
  logs            PracticeLog[] // 練習日誌關聯

  @@index([category])
  @@index([status])
  @@map("skills")
}

model PracticeLog {
  id         String   @id @default(uuid())
  skillId    String
  duration   Int      // 練習時間(分鐘)
  focusLevel Int      @default(3) // 專注度 rating (1-5)
  notes      String?  @db.Text
  date       DateTime @default(now())
  createdAt  DateTime @default(now())

  skill      Skill    @relation(fields: [skillId], references: [id], onDelete: Cascade)

  @@index([skillId])
  @@map("practice_logs")
}

在Terminal執行Migration:

npx prisma migrate dev --name add_practice_log_model

3.實戰步驟2:安裝Recharts可視化圖表庫
在Terminal執行:

npm install recharts

4.實戰步驟3:建立練習打卡與統計API(app/api/skills/[id]/logs/route.ts)
當使用者提交一次練習時,我們使用PrismaTransaction同時執行三件事:

建立PracticeLog紀錄。

自動更新Skill的lastPracticedAt為當前時間。

根據練習時數按比例微幅提升Skill的proficiency熟練度(最高上限100)。

// app/api/skills/[id]/logs/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { z } from 'zod';

const createLogSchema = z.object({
  duration: z.number().min(1, '練習時間必須大於 0 分鐘'),
  focusLevel: z.number().min(1).max(5),
  notes: z.string().optional(),
  date: z.string().optional(),
});

// GET: 取得該技能的所有練習紀錄
export async function GET(
  _request: NextRequest,
  { params }: { params: { id: string } }
) {
  try {
    const logs = await prisma.practiceLog.findMany({
      where: { skillId: params.id },
      orderBy: { date: 'desc' },
    });
    return NextResponse.json({ success: true, data: logs });
  } catch (error) {
    return NextResponse.json({ success: false, error: '讀取練習紀錄失敗' }, { status: 500 });
  }
}

// POST: 新增練習打卡紀錄
export async function POST(
  request: NextRequest,
  { params }: { params: { id: string } }
) {
  try {
    const body = await request.json();
    const validation = createLogSchema.safeParse(body);

    if (!validation.success) {
      return NextResponse.json({ success: false, error: '資料驗證失敗' }, { status: 400 });
    }

    const { duration, focusLevel, notes, date } = validation.data;
    const logDate = date ? new Date(date) : new Date();

    // 取得當前技能以計算熟練度增量
    const currentSkill = await prisma.skill.findUnique({
      where: { id: params.id },
    });

    if (!currentSkill) {
      return NextResponse.json({ success: false, error: '找不到該技能' }, { status: 404 });
    }

    // 計算新熟練度:每練習 30 分鐘微幅增加 2%,上限 100%
    const addedProficiency = Math.floor(duration / 30) * 2;
    const newProficiency = Math.min(100, currentSkill.proficiency + addedProficiency);

    // 事務操作
    const [newLog] = await prisma.$transaction([
      prisma.practiceLog.create({
        data: {
          skillId: params.id,
          duration,
          focusLevel,
          notes,
          date: logDate,
        },
      }),
      prisma.skill.update({
        where: { id: params.id },
        data: {
          lastPracticedAt: new Date(),
          proficiency: newProficiency,
        },
      }),
    ]);

    return NextResponse.json({ success: true, data: newLog }, { status: 201 });
  } catch (error) {
    return NextResponse.json({ success: false, error: '新增打卡失敗' }, { status: 500 });
  }
}

5.實戰步驟4:打造Recharts統計圖表組件(components/skills/PracticeChart.tsx)
我們建立一個動態響應式的Recharts圖表組件,繪製近7日練習時數長條圖:

// components/skills/PracticeChart.tsx
'use client';

import {
  BarChart,
  Bar,
  XAxis,
  YAxis,
  Tooltip,
  ResponsiveContainer,
  CartesianGrid,
} from 'recharts';

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

export function PracticeChart({ data }: PracticeChartProps) {
  if (!data || data.length === 0) {
    return (
      <div className="flex items-center justify-center h-48 border border-dashed border-slate-800 rounded-xl text-slate-500 text-sm">
        尚無足夠的練習紀錄可供繪製圖表
      </div>
    );
  }

  return (
    <div className="w-full h-64 bg-slate-900/60 border border-slate-800 p-4 rounded-xl">
      <h4 className="text-sm font-semibold text-slate-300 mb-4">近 7 次練習時數統計 (分鐘)</h4>
      <ResponsiveContainer width="100%" height="80%">
        <BarChart data={data} margin={{ top: 10, right: 10, left: -20, bottom: 0 }}>
          <CartesianGrid strokeDasharray="3 3" stroke="#334155" opacity={0.5} />
          <XAxis dataKey="date" stroke="#94a3b8" fontSize={12} tickLine={false} />
          <YAxis stroke="#94a3b8" fontSize={12} tickLine={false} />
          <Tooltip
            contentStyle={{
              backgroundColor: '#0f172a',
              borderColor: '#334155',
              borderRadius: '0.5rem',
              color: '#f8fafc',
            }}
          />
          <Bar dataKey="duration" fill="#06b6d4" radius={[6, 6, 0, 0]} name="練習分鐘" />
        </BarChart>
      </ResponsiveContainer>
    </div>
  );
}

6.實戰步驟5:建立練習打卡Modal與清單視圖(components/skills/PracticeLogSection.tsx)
現在我們將打卡Modal、歷史清單與Recharts圖表整合在同一個介面中:

// components/skills/PracticeLogSection.tsx
'use client';

import { useState } from 'react';
import { Plus, Clock, Flame, Calendar } from 'lucide-react';
import { Modal } from '@/components/ui/Modal';
import { PracticeChart } from './PracticeChart';
import { toast } from 'sonner';

interface Log {
  id: string;
  duration: number;
  focusLevel: number;
  notes: string | null;
  date: string;
}

interface PracticeLogSectionProps {
  skillId: string;
  initialLogs: Log[];
}

export function PracticeLogSection({ skillId, initialLogs }: PracticeLogSectionProps) {
  const [logs, setLogs] = useState<Log[]>(initialLogs);
  const [isModalOpen, setIsModalOpen] = useState(false);
  const [duration, setDuration] = useState(30);
  const [focusLevel, setFocusLevel] = useState(3);
  const [notes, setNotes] = useState('');
  const [isSubmitting, setIsSubmitting] = useState(false);

  // 轉換圖表數據 (取最近 7 筆)
  const chartData = [...logs]
    .reverse()
    .slice(-7)
    .map((log) => ({
      date: new Date(log.date).toLocaleDateString('zh-TW', { month: 'numeric', day: 'numeric' }),
      duration: log.duration,
    }));

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setIsSubmitting(true);

    try {
      const res = await fetch(`/api/skills/${skillId}/logs`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ duration, focusLevel, notes }),
      });

      const json = await res.json();

      if (json.success) {
        toast.success('打卡成功!熟練度已同步提升!');
        setLogs([json.data, ...logs]);
        setIsModalOpen(false);
        setNotes('');
      } else {
        toast.error('打卡失敗:' + json.error);
      }
    } catch {
      toast.error('網路連線異常');
    } finally {
      setIsSubmitting(false);
    }
  };

  return (
    <div className="space-y-6">
      {/* 頂部操作與統計區域 */}
      <div className="flex items-center justify-between">
        <h3 className="text-lg font-bold text-slate-100 flex items-center gap-2">
          <Calendar className="h-5 w-5 text-cyan-400" /> 練習歷程與打卡
        </h3>
        <button
          onClick={() => setIsModalOpen(true)}
          className="flex items-center gap-2 px-4 py-2 bg-cyan-600 hover:bg-cyan-500 text-white text-sm font-semibold rounded-xl shadow-lg shadow-cyan-600/20 transition"
        >
          <Plus className="h-4 w-4" /> 練習打卡
        </button>
      </div>

      {/* 圖表展現 */}
      <PracticeChart data={chartData} />

      {/* 歷史打卡列表 */}
      <div className="space-y-3">
        {logs.map((log) => (
          <div
            key={log.id}
            className="flex flex-col sm:flex-row sm:items-center justify-between p-4 bg-slate-900/40 border border-slate-800/80 rounded-xl gap-3"
          >
            <div className="space-y-1">
              <div className="flex items-center gap-3 text-xs text-slate-400">
                <span>{new Date(log.date).toLocaleString('zh-TW')}</span>
                <span className="flex items-center gap-1 text-amber-400">
                  <Flame className="h-3.5 w-3.5" /> 專注度 Lvl {log.focusLevel}
                </span>
              </div>
              <p className="text-sm text-slate-200">{log.notes || '(無附加筆記)'}</p>
            </div>

            <div className="flex items-center gap-1 text-cyan-400 font-mono font-bold text-sm bg-cyan-500/10 px-3 py-1.5 rounded-lg border border-cyan-500/20 w-fit">
              <Clock className="h-4 w-4" /> {log.duration} 分鐘
            </div>
          </div>
        ))}
      </div>

      {/* 打卡 Modal */}
      <Modal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)} title="新增練習紀錄">
        <form onSubmit={handleSubmit} className="space-y-4">
          <div>
            <label className="block text-xs font-semibold text-slate-300 mb-1">
              本次練習時數 (分鐘)
            </label>
            <input
              type="number"
              min="1"
              max="600"
              value={duration}
              onChange={(e) => setDuration(Number(e.target.value))}
              className="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-slate-100 text-sm focus:border-cyan-500 focus:outline-none"
            />
          </div>

          <div>
            <label className="block text-xs font-semibold text-slate-300 mb-1">
              專注狀態 (1-5 顆星)
            </label>
            <input
              type="range"
              min="1"
              max="5"
              value={focusLevel}
              onChange={(e) => setFocusLevel(Number(e.target.value))}
              className="w-full accent-cyan-500"
            />
            <div className="text-center text-xs text-cyan-400 font-bold mt-1">
              {focusLevel} / 5
            </div>
          </div>

          <div>
            <label className="block text-xs font-semibold text-slate-300 mb-1">
              練習心得 / 突破瓶頸備註
            </label>
            <textarea
              rows={3}
              value={notes}
              onChange={(e) => setNotes(e.target.value)}
              placeholder="今天完成了哪些重點?遇到什麼問題?"
              className="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-slate-100 text-sm focus:border-cyan-500 focus:outline-none"
            />
          </div>

          <div className="flex justify-end gap-3 pt-2">
            <button
              type="button"
              onClick={() => setIsModalOpen(false)}
              className="px-4 py-2 bg-slate-800 text-slate-300 text-sm rounded-lg hover:bg-slate-700 transition"
            >
              取消
            </button>
            <button
              type="submit"
              disabled={isSubmitting}
              className="px-4 py-2 bg-cyan-600 text-white text-sm font-semibold rounded-lg hover:bg-cyan-500 transition disabled:opacity-50"
            >
              {isSubmitting ? '儲存中...' : '確認打卡'}
            </button>
          </div>
        </form>
      </Modal>
    </div>
  );
}

今天我們成功完成VibePulse數據打卡與視覺化模組:

Prisma連動機制:打卡時透過Transaction同步紀錄時間,並自動計算熟練度增量與更新lastPracticedAt。

Recharts圖表整合:繪製流暢響應的練習時數長條圖,讓數據會說話。

完整的單一技能儀表板:結合Day20的頁籤、Day21的Markdown筆記與Day22的練習歷程,完成整個技能詳細頁的全功能閉環!


上一篇
Day 21|Markdown學習筆記系統實作CRUD與富文本即時預覽
下一篇
Day 23|全站Dashboard數據儀表板總結統計KPI與Recharts綜合圖表
系列文
Vibe Coding的30天,自然語言與AI共舞,從Prompt到高品質原型落地23
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言