前 22 天全部都在後端打轉。系統已經有對話、計畫、任務、進度、測驗、複習六種能力,但驗證方式全部靠 Swagger 的 /docs 頁面手動點來點去,沒有一個真正的使用者介面。今天要開始蓋前端,讓這些 API 第一次被包進真正能點、能看的網頁裡。
今天只做三個頁面:登入頁、今日任務頁、對話頁,分別對應 Day 17 的 /coach/today 和 Day 19 的 /chat。三個頁面之間要能正常切換,而且都要能成功呼叫到後端 API,這是今天唯一的目標,畫面好不好看留到 Day 25 儀表板再處理。
Day 27 才會加入 JWT 帳號密碼登入。在此之前,後端沿用呼叫端提供 user_id 的簡化驗證方式,_get_owned_plan 與 _get_owned_task 都只比對這個值。今天的登入頁讓使用者輸入編號,後續 API 都使用該 user_id,不檢查密碼。
使用者輸入 user_id 後,今日任務頁與對話頁都需要讀取目前身分,適合放進 Context,避免逐層傳遞。Context 只存在於分頁記憶體,重新整理後會重置;因此初始值會從 localStorage 讀回,寫入 user_id 時也同步保存。Context 負責元件共享與重新渲染,localStorage 負責保留重新整理後的身分。
Day 3 規劃專案結構時,frontend/ 底下畫的是 src/pages/login.tsx 這種寫法,那是 Next.js 比較舊的 Pages Router 慣例。但 30 天計畫本身在今天的主題裡明確寫著要用 App Router,這是 Next.js 目前官方建議的做法。Day 3 當時也提醒過「資料夾結構不用完全一樣,這是建議的結構」,所以今天直接照目前官方建議走,資料夾長得跟 Day 3 的草圖不同是預期中的事,不是走錯路。
create-next-app 初始化前端專案Day 3 已經建立了一個空的 frontend/ 資料夾(裡面只有 src/pages、public 這些空的子資料夾)。create-next-app 發現目標資料夾裡有 public/ 或 src/ 就會拒絕執行,即使它們是空的,所以要先刪掉這個空資料夾,再讓 create-next-app 重建。
回到專案根目錄(跟 backend/ 同一層),先確認 frontend/ 裡沒有你自己加的檔案,再執行:
Remove-Item -Recurse -Force frontend
npx create-next-app@latest frontend --typescript --app --src-dir --import-alias "@/*" --no-tailwind --eslint
如果你用的是 Windows PowerShell,第一行改成 Remove-Item -Recurse -Force frontend。
這串參數依序代表:用 TypeScript、用 App Router、程式碼放進 src/ 底下、匯入路徑用 @/ 開頭、不裝 Tailwind(今天先專注在頁面邏輯,樣式留到 Day 25)、保留 ESLint 檢查。
驗證:
cd frontend
npm run dev
打開瀏覽器進入 http://localhost:3000,看到 Next.js 預設的歡迎頁面就算初始化成功,等一下的步驟會把這個預設頁面整個換掉。
檔案位置: frontend/.env.local
狀態: 新增檔案
用途: 設定前端要呼叫的後端網址,之後部署到不同環境時只需要改這個檔案
依賴: 無
NEXT_PUBLIC_API_BASE_URL=http://127.0.0.1:8000
Next.js 規定要讓瀏覽器端的程式碼讀到環境變數,變數名稱必須以 NEXT_PUBLIC_ 開頭,沒有這個前綴的變數只有伺服器端看得到。
檔案位置: frontend/src/lib/api.ts
狀態: 新增檔案
用途: 統一處理呼叫後端API、共用的錯誤處理
依賴: 無
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://127.0.0.1:8000";
export async function apiFetch<T>(
path: string,
options?: RequestInit
): Promise<T> {
const response = await fetch(`${API_BASE_URL}${path}`, {
headers: { "Content-Type": "application/json" },
...options,
});
if (!response.ok) {
const body = await response.json().catch(() => null);
throw new Error(body?.detail ?? `API錯誤:${response.status}`);
}
return response.json();
}
response.json().catch(() => null) 是為了應付「後端根本沒回傳 JSON」的情況(例如伺服器整個掛掉回傳空白內容),這種時候不希望連解析錯誤訊息這一步都拋出新的例外,蓋掉原本真正的錯誤。
檔案位置: frontend/src/context/AuthContext.tsx
狀態: 新增檔案
用途: 讓所有頁面共用目前登入的user_id,並讓它撐過重新整理頁面
依賴: react
"use client";
import { createContext, useContext, useSyncExternalStore, ReactNode } from "react";
interface AuthContextValue {
userId: number | null;
setUserId: (id: number) => void;
logout: () => void;
}
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
const STORAGE_KEY = "userId";
const listeners = new Set<() => void>();
// 讓React在localStorage改變時重新讀取(同一個分頁用listeners,其他分頁用storage事件)
function subscribe(callback: () => void) {
listeners.add(callback);
window.addEventListener("storage", callback);
return () => {
listeners.delete(callback);
window.removeEventListener("storage", callback);
};
}
function getSnapshot() {
return localStorage.getItem(STORAGE_KEY);
}
// 伺服器端沒有localStorage,第一次渲染一律當作還沒登入
function getServerSnapshot() {
return null;
}
function notify() {
listeners.forEach((listener) => listener());
}
export function AuthProvider({ children }: { children: ReactNode }) {
const stored = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
const userId = stored ? Number(stored) : null;
const setUserId = (id: number) => {
localStorage.setItem(STORAGE_KEY, String(id));
notify();
};
const logout = () => {
localStorage.removeItem(STORAGE_KEY);
notify();
};
return (
<AuthContext.Provider value={{ userId, setUserId, logout }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth(): AuthContextValue {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth必須在AuthProvider底下使用");
}
return context;
}
"use client" 這一行是 App Router 的規則:預設所有元件都在伺服器端渲染,但 useSyncExternalStore、localStorage 都只能在瀏覽器裡執行,加上這行告訴 Next.js「這支元件要送到瀏覽器上執行」。
這裡不用常見的 useEffect + setState 讀 localStorage,因為 Next.js 16 預設的 ESLint 規則 react-hooks/set-state-in-effect 會把它標成錯誤。useSyncExternalStore 是 React 專門用來讀取外部資料(這裡是 localStorage)的 hook:伺服器端與第一次渲染回傳 getServerSnapshot() 的 null,到瀏覽器後再讀真正的值,所以重新整理後仍能保持登入。setUserId 與 logout 寫完 localStorage 後呼叫 notify(),通知 React 重新讀取。
AuthProvider 包進整個網站的最外層檔案位置: frontend/src/app/layout.tsx
狀態: 修改檔案(取代 create-next-app 產生的預設內容)
用途: 讓每個頁面都能用useAuth()拿到目前的user_id
依賴: context
import { AuthProvider } from "@/context/AuthContext";
import { NavBar } from "@/components/NavBar";
export const metadata = {
title: "AI 學習教練",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="zh-TW">
<body>
<AuthProvider>
<NavBar />
{children}
</AuthProvider>
</body>
</html>
);
}
存檔後 @/components/NavBar 會先出現紅色波浪線,這是正常的:NavBar 要到下一步才建立。做完步驟 6 紅線就會消失。如果做完步驟 6 還有紅線,檢查 context/、components/、lib/ 是不是和 app/ 同層,都放在 src/ 底下,而不是放進 src/app/。
檔案位置: frontend/src/components/NavBar.tsx
狀態: 新增檔案
用途: 尚未登入時不顯示,登入後提供頁面間的導覽連結
依賴: context
"use client";
import Link from "next/link";
import { useAuth } from "@/context/AuthContext";
export function NavBar() {
const { userId, logout } = useAuth();
if (userId === null) {
return null;
}
return (
<nav>
<span>使用者 {userId}</span>
<Link href="/today">今日任務</Link>
<Link href="/chat">對話</Link>
<button onClick={logout}>登出</button>
</nav>
);
}
檔案位置: frontend/src/app/login/page.tsx
狀態: 新增檔案
用途: 讓使用者輸入user_id,決定接下來以誰的身分呼叫API
依賴: context
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/context/AuthContext";
export default function LoginPage() {
const [inputId, setInputId] = useState("1");
const { setUserId } = useAuth();
const router = useRouter();
const handleContinue = () => {
const id = Number(inputId);
if (!Number.isInteger(id) || id <= 0) {
alert("請輸入有效的使用者編號");
return;
}
setUserId(id);
router.push("/today");
};
return (
<main>
<h1>登入</h1>
<p>
系統目前還沒有真正的帳號密碼登入(Day 27 才會加上),先輸入使用者編號代表你是誰。
</p>
<input
type="number"
value={inputId}
onChange={(event) => setInputId(event.target.value)}
/>
<button onClick={handleContinue}>繼續</button>
</main>
);
}
檔案位置: frontend/src/app/today/page.tsx
狀態: 新增檔案
用途: 呼叫Day17的/coach/today,展示今天的任務、激勵訊息、診斷
依賴: context, lib/api
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/context/AuthContext";
import { apiFetch } from "@/lib/api";
interface TaskOut {
id: number;
day: number;
title: string;
estimated_hours: number;
status: string;
deadline: string;
}
interface CoachTodayResponse {
date: string;
tasks: TaskOut[];
motivation: string;
diagnosis: string | null;
}
export default function TodayPage() {
const { userId } = useAuth();
const router = useRouter();
const [data, setData] = useState<CoachTodayResponse | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (userId === null) {
router.push("/login");
return;
}
apiFetch<CoachTodayResponse>(`/coach/today?user_id=${userId}`)
.then(setData)
.catch((err: Error) => setError(err.message));
}, [userId, router]);
if (error) {
return (
<main>
<p>發生錯誤:{error}</p>
</main>
);
}
if (!data) {
return (
<main>
<p>載入中...</p>
</main>
);
}
return (
<main>
<h1>{data.date} 今日任務</h1>
<p>{data.motivation}</p>
{data.diagnosis && <p>提醒:{data.diagnosis}</p>}
<ul>
{data.tasks.map((task) => (
<li key={task.id}>
{task.title}(預估 {task.estimated_hours} 小時,狀態:{task.status})
</li>
))}
</ul>
</main>
);
}
檔案位置: frontend/src/app/chat/page.tsx
狀態: 新增檔案
用途: 呼叫Day19的/chat,讓使用者能跟教練對話
依賴: context, lib/api
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/context/AuthContext";
import { apiFetch } from "@/lib/api";
interface ChatResponse {
intent: string;
reply: string;
}
interface ChatMessage {
role: "user" | "coach";
content: string;
}
export default function ChatPage() {
const { userId } = useAuth();
const router = useRouter();
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState("");
const [sending, setSending] = useState(false);
useEffect(() => {
if (userId === null) {
router.push("/login");
}
}, [userId, router]);
const handleSend = async () => {
if (!input.trim() || userId === null) {
return;
}
const userMessage: ChatMessage = { role: "user", content: input };
setMessages((prev) => [...prev, userMessage]);
setInput("");
setSending(true);
try {
const result = await apiFetch<ChatResponse>("/chat", {
method: "POST",
body: JSON.stringify({ user_id: userId, message: userMessage.content }),
});
setMessages((prev) => [...prev, { role: "coach", content: result.reply }]);
} catch {
setMessages((prev) => [
...prev,
{ role: "coach", content: "抱歉,剛剛出了點問題" },
]);
} finally {
setSending(false);
}
};
return (
<main>
<h1>跟教練聊聊</h1>
<div>
{messages.map((msg, index) => (
<p key={index}>
<strong>{msg.role === "user" ? "你" : "教練"}:</strong>
{msg.content}
</p>
))}
</div>
<input
value={input}
onChange={(event) => setInput(event.target.value)}
onKeyDown={(event) => event.key === "Enter" && handleSend()}
/>
<button onClick={handleSend} disabled={sending}>
送出
</button>
</main>
);
}
檔案位置: frontend/src/app/page.tsx
狀態: 修改檔案(取代 create-next-app 產生的預設首頁)
用途: 已登入導向今日任務頁,未登入導向登入頁
依賴: context
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/context/AuthContext";
export default function HomePage() {
const { userId } = useAuth();
const router = useRouter();
useEffect(() => {
router.push(userId ? "/today" : "/login");
}, [userId, router]);
return null;
}
先確認後端服務已啟動(記得 CORS 設定裡的 allow_origins 要包含 http://localhost:3000,Day 6 已經設好):
cd backend
uvicorn main:app --reload
開另一個終端機視窗啟動前端:
cd frontend
npm run dev
進入 http://localhost:3000,應該自動跳轉到 /login。輸入一個已經有已核准計畫的 user_id(例如 1),按「繼續」,應該跳轉到 /today,看到今天的任務清單和激勵訊息。點導覽列的「對話」,進到 /chat,輸入一句話按送出,應該收到教練的回應。
重新整理任一頁面:導覽列應該還是顯示「使用者 1」,不會被踢回登入頁,證明 localStorage 真的撐過了重新整理。
檢查 Day 6 寫的 allow_origins 是不是真的包含 http://localhost:3000,網址要完全一致(包含 port)。如果前端跑起來不是 3000 port(例如被佔用改成 3001),要跟著改後端的 allow_origins。
/today 頁面一直停在「載入中」打開瀏覽器開發者工具的 Network 分頁,看 /coach/today 這支請求實際回了什麼。最常見的原因是這個 user_id 底下沒有已核准的計畫(Day 17 會回 404),今天的錯誤處理會把錯誤訊息顯示在畫面上而不是無限轉圈,如果畫面卡住沒反應,代表發生了程式碼本身的問題,不是後端錯誤。
正常。今天畫面上的 messages 只是這個分頁自己記的暫存資料,重新整理就會清空,這是前端記憶體裡的狀態,不是持久化的資料。Day 19 做的 Checkpoint 機制記的是後端那份真正的對話歷史:重新整理後再問教練同樣的問題,教練還是答得出來(因為後端記得),只是畫面上舊的訊息泡泡不會自動補回來。如果想讓畫面重新整理後也能看到歷史訊息,需要後端另外提供一支「查詢對話歷史」的 API,今天沒有做這件事。
沒錯,今天的登入頁完全沒有身分驗證,只要知道別人的 user_id 就能冒充。這跟 Day 15 開始「呼叫端自己回報 user_id」是同一個簡化,一路要撐到 Day 27 加上 JWT 認證,屆時這一頁才會變成真正檢查密碼的登入頁,現在的重點只是先把「使用者身分要在前端傳給後端」這條路走通。
npx create-next-app 出現互動式問句,該怎麼選如果指令參數沒有完全涵蓋所有選項,命令列會跳出來問你(例如是否要用 Turbopack),照預設值按 Enter 通常就沒問題,今天用到的功能都不依賴那些額外選項。
create-next-app 顯示 The directory frontend contains files that could conflict,之後 npm run dev 找不到 package.jsoncreate-next-app 偵測到 frontend/ 裡已經有 public/、src/,直接中止,沒有產生任何檔案,所以 package.json 不存在。照步驟 1 先刪除空的 frontend/ 再重跑即可。如果你在裡面放了自己的檔案,先備份到別的地方。
今天的對話頁其實已經能用了,但每次送出訊息都要等模型把整句話一次生成完才會顯示,使用者盯著畫面空白等好幾秒,體驗不太好。明天要用 Server-Sent Events(SSE)把 /chat 改成串流回應,讓教練的回覆像 ChatGPT 那樣一個字一個字跑出來,不用整句等完才看得到。