iT邦幫忙

2025 iThome 鐵人賽

DAY 24
0

前言

歡迎來到 Day24!若你是每天看的朋友,那表示有個好消息!再撐一天又是一個連假啦!當然,如果你不幸身體微恙導致明天不克上班,那從今晚開始就是你的假期了:D 因應即將放假的現實,今天的進度我特別~弄得相當輕鬆,畢竟我們前兩天就把這部分最麻煩的事情先搞定了,肯定不是我想偷懶!
前兩天我們完成了練習紀錄的資料表,也按照 Supabase 官方教學的方式將我們的 Next 專案增加了使用者認證系統,最後在昨天則是將寫入資料表的邏輯也加了上去,還順帶處理了之前 prompt 無法處理追問情境的問題,離成品完成越來越接近了。 今天我們要做的事情相當的單純,既然我們已經有真正的練習紀錄了,那我們在練習紀錄與練習紀錄詳情頁面的資料就可以不再依賴mockPracticeHistory了,可以實際使用我們存在 Supabase 的資料囉!

今日目標

  • 整合練習列表:修改 app/(main)/history/page.tsx,將 mockPracticeHistory 替換為從 Supabase 即時抓取的真實練習列表。
  • 整合練習紀錄詳情頁:修改 app/(main)/history/[recordId]/page.tsx,同樣換成對應的練習紀錄。
  • 資料對應:將資料庫欄位 (如 question_id, evaluation) 與現有 UI 元件所需的資料格式進行對應。

Step 1: 改造練習紀錄列表 (app/(main)/history/page.tsx)

我們的列表頁面結構和樣式都非常完善,因此我們完全不需要改動 UI,只需要將資料來源從靜態檔案換成 Supabase 即可。

請將 app/(main)/history/page.tsx檔案的內容更新為以下程式碼:

import { Code, MessageSquare, BarChart2 } from 'lucide-react';
import Link from 'next/link';
import { createAuthClient } from '@/app/lib/supabase/server';
import questions from '@/data/questions.json';

export default async function PracticeHistoryPage() {
  const supabase = await createAuthClient();
  const {
    data: { user },
  } = await supabase.auth.getUser();

  if (!user) {
    return <p className="p-8">Please log in to view your history.</p>;
  }

  const { data: records, error } = await supabase
    .from('practice_records')
    .select('id, created_at, question_id, score')
    .eq('user_id', user.id)
    .order('created_at', { ascending: false });

  if (error) {
    console.error('Error fetching records:', error);
    return (
      <p className="p-8">
        Sorry, something went wrong while fetching your records.
      </p>
    );
  }

  return (
    <div className="p-8">
      <h2 className="text-3xl font-bold mb-6">練習紀錄</h2>
      <div className="space-y-4">
        {records.length === 0 ? (
          <p>
            還沒有任何練習紀錄,
            <Link href="/" className="text-blue-400 hover:underline">
              現在就開始吧!
            </Link>
          </p>
        ) : (
          records.map((item) => {
            const questionInfo = questions.find(
              (q) => q.id === item.question_id
            );
            const title = questionInfo
              ? `${questionInfo.type}: ${questionInfo.question}`
              : '未知題目';
            const type = questionInfo ? questionInfo.type : '概念問答';

            const formattedDate = new Date(item.created_at).toLocaleString(
              'zh-TW',
              {
                year: 'numeric',
                month: '2-digit',
                day: '2-digit',
                hour: '2-digit',
                minute: '2-digit',
              }
            );

            return (
              <Link
                href={`/history/${item.id}`}
                key={item.id}
                className="bg-gray-800 p-4 rounded-lg flex items-center justify-between hover:bg-gray-700/50 transition cursor-pointer"
              >
                <div className="flex items-center gap-4">
                  {type === '程式實作' ? (
                    <Code className="text-purple-400" />
                  ) : (
                    <MessageSquare className="text-blue-400" />
                  )}
                  <div>
                    <h3 className="font-semibold">{title}</h3>
                    <p className="text-sm text-gray-400">{formattedDate}</p>
                  </div>
                </div>
                <div className="flex items-center gap-6">
                  {/* 注意:duration 目前資料庫沒有,暫時移除 */}
                  <div className="flex items-center gap-2 text-sm">
                    <BarChart2 size={16} /> 評分:{' '}
                    <span className="font-bold text-lg ml-1">{item.score}</span>
                  </div>
                  <span className="bg-gray-600 hover:bg-gray-500 text-white font-bold py-2 px-4 rounded-lg transition-colors text-sm">
                    查看詳情
                  </span>
                </div>
              </Link>
            );
          })
        )}
      </div>
    </div>
  );
}

程式碼摘要說明:

  • 替換資料源:我們移除了對假資料的依賴,並將組件改為 Server component 在伺服器端直接、安全地從 Supabase 抓取資料,由於這個頁面並不會有太多的互動性,轉為Server component可以避免我們還要再開新的 API 讓前端串接。
  • 處理資料差異:由於目前 practice_records 表中尚未儲存 duration(練習時長)欄位,我們暫時將其從 UI 中移除,這也是未來可以迭代優化的方向。

錯誤排解:怎麼我明明有練習卻完全看不到紀錄?

如果你發現你目前登入的帳號已經有做過幾次練習,資料庫中也確實有看到該 ID 的練習紀錄,但套上我們的程式碼後卻發現一筆資料都沒在頁面中顯示出來,那並不是你的錯,而是我們過去的文章少做了一件事情。
還記得我們前幾天設定的「資料列級安全性 (Row Level Security, RLS)」嗎?,我們雖然啟用 (Enable)了它,但沒有為它設定允許讀取的「策略 (Policy)」。

你可以把 RLS 想像成兩個部分:

  • 啟用 RLS (Enable RLS):這就像是你在大樓門口派駐了一位保全。只要這個開關打開,所有人都不能隨意進出,一律會被保全攔下。我們在 Day 21 的 SQL 腳本中,已經執行了 ALTER TABLE public.practice_records ENABLE ROW LEVEL SECURITY;,所以保全已經上崗了。
  • 設定策略 (Policy):這就像是你交給保全一份「訪客名單」。你必須明確告訴他:「符合什麼條件的人,可以做什麼事」。例如,「只有本人 (user_id) 才能讀取 (SELECT) 自己的紀錄」。

你現在遇到的狀況是:保全已經上崗了,但他手上沒有任何訪客名單。所以他的預設行為就是「誰都不准進」,即使你是已登入的使用者,也會被擋在門外,導致查詢結果為空。

要做的事情也很簡單,打開 Supabase 的頁面,左側欄位點擊 Authentication => Policies,右側則會有我們的pratice_records 資料表,你會發現目前這邊完全是空的,點擊 Create Policy 就可以新增我們的規則了。

圖1
圖1 :新增Policy頁面

點擊開啟新的編輯視窗後,照著圖片的內容輸入資訊,大致上這邊你可以選你要允許什麼樣的操作(Select、Update之類的資料表操作),另外還可以選定限定怎麼樣的使用者,以圖片中的範例就是限定被認證的使用者才允許,其中最重要的就是寫在 using 內的句子 (auth.uid()) = user_id

  • auth.uid():這是一個 Supabase 的特殊函式,它會自動取得當前登入使用者的 UUID。
  • user_id:這是我們 practice_records 資料表中用來儲存使用者 UUID 的欄位名稱。

整個 using 條件的意思就是:「允許這次的 SELECT 操作,但僅限於那些 user_id 欄位的值等於當前登入者 UUID 的資料列。」

圖2
圖2 :Policy內容

完成後再次刷新頁面,你應該就會看到練習紀錄被順利的渲染出來了。

圖3
圖3 :練習紀錄頁面

Step 2: 動態化練習詳情頁 (app/history/[recordId]/page.tsx)

接下來,我們以同樣的模式改造詳情頁。這個頁面將根據 URL 傳入的 recordId 來抓取特定的單筆紀錄。

請將 app/(main)/history/[recordId]/page.tsx 的內容更新為:

// app/history/[recordId]/page.tsx
import { createAuthClient } from '@/app/lib/supabase/server';
import { notFound } from 'next/navigation';
import questions from '@/data/questions.json';
import AiMessage from '@/app/components/interview/AIMessage';
import Link from 'next/link';
import { ArrowLeft, BarChart2, CheckCircle, XCircle } from 'lucide-react';

type PageProps = {
  params: { recordId: string };
};

export default async function RecordDetailPage({ params }: PageProps) {
  const { recordId } = await params;
  const supabase = await createAuthClient();
  const {
    data: { user },
  } = await supabase.auth.getUser();

  if (!user) {
    notFound();
  }

  // 【核心修改】根據 recordId 抓取單筆資料,並驗證 user_id
  const { data: record, error } = await supabase
    .from('practice_records')
    .select('*')
    .eq('id', recordId)
    .eq('user_id', user.id) // 確保只能抓到自己的紀錄
    .single(); // .single() 期望只回傳一筆資料,否則會報錯

  // 如果查詢出錯或找不到紀錄,顯示 404 頁面
  if (error || !record) {
    notFound();
  }

  const questionInfo = questions.find((q) => q.id === record.question_id);
  const evaluation = record.evaluation as any; // 方便起見,暫時使用 any
  const groundedEvidence = evaluation?.grounded_evidence;
  const formattedDate = new Date(record.created_at).toLocaleString('zh-TW', {
    year: 'numeric',
    month: '2-digit',
    day: '2-digit',
    hour: '2-digit',
    minute: '2-digit',
  });

  return (
    <div className="p-8 max-w-5xl mx-auto">
      {/* UI 結構完全沿用你的設計 */}
      <Link
        href="/history"
        className="flex items-center gap-2 text-sm text-gray-400 hover:text-white mb-6"
      >
        <ArrowLeft size={16} /> 返回練習紀錄
      </Link>
      <div className="bg-gray-800 rounded-xl border border-gray-700 p-6 mb-8">
        <h2 className="text-2xl font-bold">
          {questionInfo?.question || '未知題目'}
        </h2>
        <div className="flex items-center gap-6 text-sm text-gray-400 mt-2">
          <span>{formattedDate}</span>
          {/* 練習時長 (duration) 目前未儲存,暫時移除 */}
          <span className="flex items-center gap-1.5">
            <BarChart2 size={14} />
            最終評分:{' '}
            <strong className="text-lg text-white ml-1">{record.score}</strong>
          </span>
        </div>
      </div>
      <div className="space-y-8">
        {record.user_answer && (
          <div>
            <h3 className="text-xl font-bold mb-4">你提交的答案</h3>
            <div className="bg-[#0d1117] rounded-xl border border-gray-700">
              {/* 檔名是 mock data 才有的,真實答案不一定有檔名,故移除 */}
              <div className="p-4 overflow-auto">
                <pre className="text-sm">
                  <code>{record.user_answer}</code>
                </pre>
              </div>
            </div>
          </div>
        )}

        {/* 【修改】測試案例結果,從 evaluation.grounded_evidence 取得 */}
        {groundedEvidence && (
          <div>
            <h3 className="text-xl font-bold mb-4">測試案例結果</h3>
            <div className="bg-gray-800 rounded-xl border border-gray-700 p-4 space-y-3">
              {groundedEvidence.tests_passed > 0 && (
                <div className="flex items-center gap-3">
                  <CheckCircle
                    size={20}
                    className="text-green-500 flex-shrink-0"
                  />
                  <span className="text-sm">
                    {groundedEvidence.tests_passed} 個測試案例通過
                  </span>
                </div>
              )}
              {groundedEvidence.tests_failed > 0 && (
                <div className="flex items-center gap-3">
                  <XCircle size={20} className="text-red-500 flex-shrink-0" />
                  <span className="text-sm">
                    {groundedEvidence.tests_failed} 個測試案例失敗
                  </span>
                </div>
              )}
              {groundedEvidence.stderr_excerpt && (
                <div className="text-sm text-red-400 mt-2">
                  <p className="font-semibold">錯誤摘要:</p>
                  <pre className="bg-black/20 p-2 rounded-md mt-1 whitespace-pre-wrap">
                    {groundedEvidence.stderr_excerpt}
                  </pre>
                </div>
              )}
            </div>
          </div>
        )}

        {/* AI 回饋直接傳入 evaluation 物件,與 AIMessage 元件無縫接軌 */}
        {evaluation && (
          <div>
            <h3 className="text-xl font-bold mb-4">AI 回饋重點</h3>
            <AiMessage message={{ evaluation: evaluation }} />
          </div>
        )}
      </div>
    </div>
  );
}

程式碼摘要說明:

  • 抓取單筆紀錄:我們使用 params.recordId 搭配 .eq('id', recordId) 和 .single() 方法來精準地抓取使用者想查看的那一筆紀錄。
  • 無縫整合元件:抓取到的 record.evaluation 物件被直接傳遞給你現有的 元件,實現了完美的資料整合。

現在,啟動你的開發伺服器,你的練習紀錄頁面已經完全由後端資料庫驅動了!外觀不變,但靈魂已經換成了真實的數據。

今日回顧

今天我們完成了資料閉環中最重要的一哩路:將既有前端介面與後端資料庫串接。這不僅讓應用程式變得真實可用,也驗證了我們之前的架構設計是穩健且易於擴展的。

✅ 練習列表動態化:成功將 app/history/page.tsx 從 Mock Data 遷移至 Supabase 真實資料。
✅ 詳情頁動態化:app/history/[recordId]/page.tsx 現在能根據 URL 顯示任何一筆練習的詳細資訊。
✅ 實踐安全抓取:所有資料抓取都在 Server Component 中完成,並透過 .eq('user_id', user.id) 確保了資料的安全性。
✅ 數據與 UI 結合:成功將資料庫中的欄位(包括複雜的 JSONB)對應到既有的 UI 元件上。

明日預告

核心的面試與回顧流程已經完整了!但一個好的面試工具,題庫的廣度與深度是關鍵。目前我們的 questions.json 還很陽春。明天 (Day 25),我們將會擴充我們的題庫,加入更多元的前端題目類型(例如 React 和 CSS),並探討如何讓題庫的管理更具擴展性。


上一篇
留下學習的足跡 - 將 AI 評估結果寫入練習紀錄
系列文
前端工程師的AI應用開發實戰:30天從Prompt到Production - 以打造AI前端面試官為例24
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言