iT邦幫忙

2026 iThome 鐵人賽

DAY 23
0
AI Engineering

30天用 Claude Code + LangGraph 實作個人化 AI 學習教練系列 第 23 篇

Day 23:簡化版前端架構 - 三個核心頁面

  • 分享至 

  • xImage
  •  

前 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,不檢查密碼。

為什麼要同時用 React Context 又存 localStorage

使用者輸入 user_id 後,今日任務頁與對話頁都需要讀取目前身分,適合放進 Context,避免逐層傳遞。Context 只存在於分頁記憶體,重新整理後會重置;因此初始值會從 localStorage 讀回,寫入 user_id 時也同步保存。Context 負責元件共享與重新渲染,localStorage 負責保留重新整理後的身分。

今天選 Next.js App Router,跟 Day 3 畫的草圖不一樣

Day 3 規劃專案結構時,frontend/ 底下畫的是 src/pages/login.tsx 這種寫法,那是 Next.js 比較舊的 Pages Router 慣例。但 30 天計畫本身在今天的主題裡明確寫著要用 App Router,這是 Next.js 目前官方建議的做法。Day 3 當時也提醒過「資料夾結構不用完全一樣,這是建議的結構」,所以今天直接照目前官方建議走,資料夾長得跟 Day 3 的草圖不同是預期中的事,不是走錯路。


實作步驟

步驟1:用 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 預設的歡迎頁面就算初始化成功,等一下的步驟會把這個預設頁面整個換掉。

步驟2:設定後端 API 網址

檔案位置: frontend/.env.local
狀態: 新增檔案
用途: 設定前端要呼叫的後端網址,之後部署到不同環境時只需要改這個檔案
依賴: 無

NEXT_PUBLIC_API_BASE_URL=http://127.0.0.1:8000

Next.js 規定要讓瀏覽器端的程式碼讀到環境變數,變數名稱必須以 NEXT_PUBLIC_ 開頭,沒有這個前綴的變數只有伺服器端看得到。

步驟3:寫一個共用的 API 呼叫函式

檔案位置: 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」的情況(例如伺服器整個掛掉回傳空白內容),這種時候不希望連解析錯誤訊息這一步都拋出新的例外,蓋掉原本真正的錯誤。

步驟4:寫身分狀態的 Context

檔案位置: 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 重新讀取。

步驟5:把 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/。

步驟6:寫一個簡單的導覽列

檔案位置: 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>
  );
}

步驟7:寫登入頁

檔案位置: 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>
  );
}

步驟8:寫今日任務頁

檔案位置: 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>
  );
}

步驟9:寫對話頁

檔案位置: 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>
  );
}

步驟10:根路徑自動導向

檔案位置: 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;
}

步驟11:測試

先確認後端服務已啟動(記得 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 真的撐過了重新整理。


常見問題

畫面整個空白,Console 顯示 CORS 錯誤

檢查 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.json

create-next-app 偵測到 frontend/ 裡已經有 public/、src/,直接中止,沒有產生任何檔案,所以 package.json 不存在。照步驟 1 先刪除空的 frontend/ 再重跑即可。如果你在裡面放了自己的檔案,先備份到別的地方。


今天的對話頁其實已經能用了,但每次送出訊息都要等模型把整句話一次生成完才會顯示,使用者盯著畫面空白等好幾秒,體驗不太好。明天要用 Server-Sent Events(SSE)把 /chat 改成串流回應,讓教練的回覆像 ChatGPT 那樣一個字一個字跑出來,不用整句等完才看得到。


上一篇
Day 22:複習系統 - 遺忘曲線與間隔重複
下一篇
Day 24:簡化的聊天功能 - 串流回應
系列文
30天用 Claude Code + LangGraph 實作個人化 AI 學習教練 共 25 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言