歡迎來到 Day24!若你是每天看的朋友,那表示有個好消息!再撐一天又是一個連假啦!當然,如果你不幸身體微恙導致明天不克上班,那從今晚開始就是你的假期了:D 因應即將放假的現實,今天的進度我特別~弄得相當輕鬆,畢竟我們前兩天就把這部分最麻煩的事情先搞定了,肯定不是我想偷懶!
前兩天我們完成了練習紀錄的資料表,也按照 Supabase 官方教學的方式將我們的 Next 專案增加了使用者認證系統,最後在昨天則是將寫入資料表的邏輯也加了上去,還順帶處理了之前 prompt 無法處理追問情境的問題,離成品完成越來越接近了。 今天我們要做的事情相當的單純,既然我們已經有真正的練習紀錄了,那我們在練習紀錄與練習紀錄詳情頁面的資料就可以不再依賴mockPracticeHistory
了,可以實際使用我們存在 Supabase 的資料囉!
app/(main)/history/page.tsx
,將 mockPracticeHistory
替換為從 Supabase 即時抓取的真實練習列表。app/(main)/history/[recordId]/page.tsx
,同樣換成對應的練習紀錄。question_id
, evaluation
) 與現有 UI 元件所需的資料格式進行對應。我們的列表頁面結構和樣式都非常完善,因此我們完全不需要改動 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>
);
}
practice_records
表中尚未儲存 duration
(練習時長)欄位,我們暫時將其從 UI 中移除,這也是未來可以迭代優化的方向。錯誤排解:怎麼我明明有練習卻完全看不到紀錄?
如果你發現你目前登入的帳號已經有做過幾次練習,資料庫中也確實有看到該 ID 的練習紀錄,但套上我們的程式碼後卻發現一筆資料都沒在頁面中顯示出來,那並不是你的錯,而是我們過去的文章少做了一件事情。
還記得我們前幾天設定的「資料列級安全性 (Row Level Security, RLS)」嗎?,我們雖然啟用 (Enable)了它,但沒有為它設定允許讀取的「策略 (Policy)」。
你可以把 RLS 想像成兩個部分:
你現在遇到的狀況是:保全已經上崗了,但他手上沒有任何訪客名單。所以他的預設行為就是「誰都不准進」,即使你是已登入的使用者,也會被擋在門外,導致查詢結果為空。
要做的事情也很簡單,打開 Supabase 的頁面,左側欄位點擊 Authentication => Policies,右側則會有我們的pratice_records
資料表,你會發現目前這邊完全是空的,點擊 Create Policy 就可以新增我們的規則了。
![]() |
---|
圖1 :新增Policy頁面 |
點擊開啟新的編輯視窗後,照著圖片的內容輸入資訊,大致上這邊你可以選你要允許什麼樣的操作(Select、Update之類的資料表操作),另外還可以選定限定怎麼樣的使用者,以圖片中的範例就是限定被認證的使用者才允許,其中最重要的就是寫在 using
內的句子 (auth.uid()) = user_id
。
auth.uid()
:這是一個 Supabase 的特殊函式,它會自動取得當前登入使用者的 UUID。user_id
:這是我們 practice_records
資料表中用來儲存使用者 UUID 的欄位名稱。整個 using
條件的意思就是:「允許這次的 SELECT 操作,但僅限於那些 user_id
欄位的值等於當前登入者 UUID 的資料列。」
![]() |
---|
圖2 :Policy內容 |
完成後再次刷新頁面,你應該就會看到練習紀錄被順利的渲染出來了。
![]() |
---|
圖3 :練習紀錄頁面 |
接下來,我們以同樣的模式改造詳情頁。這個頁面將根據 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>
);
}
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),並探討如何讓題庫的管理更具擴展性。