# API 進階整合應用,Axios 攔截器設定與處理認證及授權等 ## 專案啟動 ### 專案技術棧介紹 #### 核心框架 - **Next.js 16.0.0**: 全端 React 框架 - 檔案系統路由 - 內建 API 路由 - 靜態生成 (SSG) 和伺服器端渲染 (SSR) #### 型別安全 - **TypeScript 5**: 靜態型別檢查 - 編譯時錯誤檢測 - 更好的 IDE 支援 - 重構安全性 #### 狀態管理 - **TanStack React Query 5.90.5**: 伺服器狀態管理 - 自動快取和背景更新 - 錯誤處理和重試 #### 表單處理 - **React Hook Form 7.65.0**: 高效能表單庫 - 非受控組件 - 內建驗證 - 最小重新渲染 #### HTTP 客戶端 - **Axios 1.12.2**: Promise 基礎的 HTTP 客戶端 - 請求/回應攔截器 - 自動 JSON 轉換 - 錯誤處理 --- ### 安裝方式 (課程上請下載範例專案) ```bash # 建立新專案 npx create-next-app@latest my-app --yes cd my-app npm run dev ``` [範例專案](https://github.com/IffyArt/2025-fullstack-course-forntend/tree/feature/base-api-project) #### 課程使用配置 ``` ✔ What is your project named? … my-app ✔ Would you like to use the recommended Next.js defaults? › No, customize settings ✔ Would you like to use TypeScript? … Yes ✔ Which linter would you like to use? › ESLint ✔ Would you like to use React Compiler? … Yes ✔ Would you like to use Tailwind CSS? … No ✔ Would you like your code inside a `src/` directory? … Yes ✔ Would you like to use App Router? (recommended) … No ✔ Would you like to use Turbopack? (recommended) … No ✔ Would you like to customize the import alias (`@/*` by default)? … Yes ✔ What import alias would you like configured? … @/* ``` --- ### 專案結構解析 ``` src/ ├── pages/ # Next.js 頁面路由 │ ├── _app.tsx # 應用程式根組件 │ ├── _document.tsx # HTML 文檔結構 │ └── index.tsx # 首頁 (/) ├── helper/ # 工具函數和配置 │ └── react-query/ # React Query 配置 │ └── index.ts # QueryClient 設定 └── ... # 其他源碼目錄 ``` #### 關鍵檔案說明 **`src/pages/_app.tsx`** - 應用程式根組件 ```typescript import queryClient from '@/helper/react-query'; import { QueryClientProvider } from '@tanstack/react-query'; import type { AppProps } from 'next/app'; export default function App({ Component, pageProps }: AppProps) { return ( <QueryClientProvider client={queryClient}> <Component {...pageProps} /> </QueryClientProvider> ); } ``` **`src/helper/react-query/index.ts`** - React Query 配置 ```typescript import { QueryClient } from '@tanstack/react-query'; const queryClient = new QueryClient(); export default queryClient; ``` --- ### Next.js 配置 **`next.config.ts`** - Next.js 設定檔 ```typescript import type { NextConfig } from 'next'; const nextConfig: NextConfig = { reactCompiler: true, // 啟用 React Compiler (實驗性) reactStrictMode: true, // 啟用嚴格模式 }; export default nextConfig; ``` #### 配置說明 - **reactCompiler**: 啟用實驗性的 React 編譯器,自動優化組件 - **reactStrictMode**: 啟用嚴格模式,幫助發現潛在問題 --- ### 依賴套件分析 #### 生產依賴 (dependencies) ```json { "@tanstack/react-query": "^5.90.5", // 伺服器狀態管理 "axios": "^1.12.2", // HTTP 客戶端 "next": "16.0.0", // Next.js 框架 "react": "19.2.0", // React 核心 "react-dom": "19.2.0", // React DOM "react-hook-form": "^7.65.0" // 表單處理 } ``` #### 開發依賴 (devDependencies) ```json { "@types/node": "^20", // Node.js 型別定義 "@types/react": "^19", // React 型別定義 "@types/react-dom": "^19", // React DOM 型別定義 "babel-plugin-react-compiler": "1.0.0", // React 編譯器 "eslint": "^9", // 程式碼檢查 "eslint-config-next": "16.0.0", // Next.js ESLint 配置 "typescript": "^5" // TypeScript 編譯器 } ``` --- ### 開發環境設定 #### 必要工具 - **Node.js 18+**: JavaScript 執行環境 - **VS Code**: 推薦編輯器 - **Git**: 版本控制 #### 推薦 VS Code 擴充功能 - TypeScript Importer - ES7+ React/Redux/React-Native snippets - Prettier - Code formatter - ESLint ### 3.2 專案啟動流程 ```bash # 1. 安裝依賴 npm install # 2. 啟動開發伺服器 npm run dev # 3. 開啟瀏覽器 # http://localhost:3000 ``` #### 可用指令 ```json { "scripts": { "dev": "next dev --webpack", // 開發模式 "build": "next build --webpack", // 建置專案 "start": "next start", // 生產模式 "lint": "eslint" // 程式碼檢查 } } ``` <br/> ## Context Hook — 全域狀態與共用邏輯管理 ### 1. 為什麼需要 Context? 在 React 應用中,當組件層級不斷變深時,**props 傳遞**會造成代碼維護困難。 #### ❌ 問題示例:Props drilling(層層傳遞) ```tsx // App → Layout → Toolbar → UserInfo function App() { const user = { name: '江星誌', role: '系統分析師' }; return <Layout user={user} />; } function Layout({ user }: { user: any }) { return <Toolbar user={user} />; } function Toolbar({ user }: { user: any }) { return <UserInfo user={user} />; } function UserInfo({ user }: { user: any }) { return <p>使用者:{user.name}</p>; } ``` > 缺點: > > * 所有中間層都要傳遞 `user` > * 當結構變動時,維護成本高 --- ### 2. 使用 Context 解決全域資料共用 React 提供 `createContext` 與 `useContext`,讓你在組件樹中共享狀態,而不需層層傳遞。 #### ✅ 範例:建立 UserContext **`src/context/UserContext.tsx`** ```tsx import { createContext, useContext, useState, ReactNode } from 'react'; // 定義型別 type User = { name: string; role: string }; type UserContextType = { user: User | null; setUser: (user: User | null) => void; }; // 建立 Context const UserContext = createContext<UserContextType | undefined>(undefined); // 提供者組件 export const UserProvider = ({ children }: { children: ReactNode }) => { const [user, setUser] = useState<User | null>({ name: '江星誌', role: '系統分析師', }); return ( <UserContext.Provider value={{ user, setUser }}> {children} </UserContext.Provider> ); }; // 封裝成自訂 Hook export const useUser = () => { const context = useContext(UserContext); if (!context) { throw new Error('useUser 必須在 UserProvider 內使用'); } return context; }; ``` --- ### 3. 使用 Context 在應用中共用狀態 **`src/pages/_app.tsx`** ```tsx import { QueryClientProvider } from '@tanstack/react-query'; import queryClient from '@/helper/react-query'; import { UserProvider } from '@/context/UserContext'; import type { AppProps } from 'next/app'; export default function App({ Component, pageProps }: AppProps) { return ( <QueryClientProvider client={queryClient}> <UserProvider> <Component {...pageProps} /> </UserProvider> </QueryClientProvider> ); } ``` **`src/pages/index.tsx`** ```tsx import { useUser } from '@/context/UserContext'; export default function Home() { const { user, setUser } = useUser(); return ( <main style={{ padding: '2rem' }}> <h1>🎉 Context Hook 範例</h1> <p>目前使用者:{user?.name}</p> <button onClick={() => setUser({ name: '其他人', role: '角色' })} > 切換使用者 </button> </main> ); } ``` ✅ **成果**: * 所有頁面都能共用 `user` * 不需再層層傳遞 props [當前專案範本](https://github.com/IffyArt/2025-fullstack-course-forntend/tree/feature/context-hook) --- ### 4. 自訂 Hook 的設計邏輯 自訂 Hook 通常負責: * 封裝重複邏輯 * 整合外部工具(如 React Query、Axios) * 提供型別安全與可重用的 API #### 🎯 範例:封裝登入狀態管理 **`src/hooks/useAuth.ts`** ```tsx import axios from 'axios'; import { useState } from 'react'; export const useAuth = () => { const [access, setAccess] = useState<string | null>(null); const [refresh, setRefresh] = useState<string | null>(null); const login = async (username: string, password: string) => { const res = await axios.post('http://localhost:8000/api/auth/jwt/create', { username, password, }); setAccess(res.data.access); setRefresh(res.data.refresh); }; const logout = () => { setAccess(null); setRefresh(null); }; return { access, refresh, login, logout }; }; ``` 接著可將此 Hook 與 Context 整合: **`src/context/AuthContext.tsx`** ```tsx import { createContext, useContext, ReactNode } from 'react'; import { useAuth } from '@/hooks/useAuth'; const AuthContext = createContext<ReturnType<typeof useAuth> | undefined>( undefined ); export const AuthProvider = ({ children }: { children: ReactNode }) => { const auth = useAuth(); return ( <AuthContext.Provider value={auth}>{children}</AuthContext.Provider> ); }; export const useAuthContext = () => { const ctx = useContext(AuthContext); if (!ctx) throw new Error('useAuthContext 必須在 AuthProvider 中使用'); return ctx; }; ``` 然後將 Provider 也設置到 _app 的檔案上: **`src/pages/_app.tsx`** ```tsx import { AuthProvider } from '@/context/AuthContext'; import { UserProvider } from '@/context/UserContext'; import queryClient from '@/helper/react-query'; import { QueryClientProvider } from '@tanstack/react-query'; import type { AppProps } from 'next/app'; export default function App({ Component, pageProps }: AppProps) { return ( <QueryClientProvider client={queryClient}> <UserProvider> <AuthProvider> <Component {...pageProps} /> </AuthProvider> </UserProvider> </QueryClientProvider> ); } ``` 最後將新的 Hook 操作方式引用到首頁上: **`src/pages/index.tsx`** ```tsx import { useAuth } from '@/hooks/useAuth'; export default function Home() { const { login, access, refresh, logout } = useAuth(); return ( <main style={{ padding: '2rem' }}> {access ? <p>Logged in</p> : <p>Logged out</p>} <button onClick={() => login('admin', 'admin')}>Login</button> <button onClick={() => logout()}>Logout</button> <p>Access: {access}</p> <p>Refresh: {refresh}</p> </main> ); } ``` [當前專案範本](https://github.com/IffyArt/2025-fullstack-course-forntend/tree/feature/custom-hook) --- ### 5. Context + React Query 實務整合範例 許多應用會在登入後需要帶 Token 進行 API 請求。 這時我們可以透過 Context 結合 **Axios 攔截器** 來達成。 **`src/helper/api-client/index.ts`** ```tsx import axios from 'axios'; export const apiClient = axios.create({ baseURL: 'http://localhost:8000/api', }); // 攔截器設定 apiClient.interceptors.request.use((config) => { const access = localStorage.getItem('access'); if (access) config.headers.Authorization = `Bearer ${access}`; return config; }); ``` 建立使用 react query 的 api 敲打方式: **`src/servers/auth.ts`** ```tsx import { apiClient } from '@/helper/api-client'; import { useMutation } from '@tanstack/react-query'; type AuthJwtCreateParams = { username: string; password: string; }; export const useAuthJwtCreate = () => { return useMutation({ mutationFn: async ({ username, password }: AuthJwtCreateParams) => { const { data } = await apiClient.post('/auth/jwt/create', { username, password, }); return data; }, }); }; ``` 更新原先的 useAuth hook 功能 **`src/hooks/useAuth.ts`** ```tsx import { useAuthJwtCreate } from '@/servers/auth'; import { useState } from 'react'; export const useAuth = () => { const { mutate: authJwtCreate } = useAuthJwtCreate(); const [access, setAccess] = useState<string | null>(null); const [refresh, setRefresh] = useState<string | null>(null); const login = async (username: string, password: string) => { authJwtCreate( { username, password }, { onSuccess: (data) => { setAccess(data.access); setRefresh(data.refresh); localStorage.setItem('access', data.access); }, onError: (error) => { console.error(error); }, }, ); }; const logout = () => { setAccess(null); setRefresh(null); }; return { access, refresh, login, logout }; }; ``` [當前專案範本](https://github.com/IffyArt/2025-fullstack-course-forntend/tree/feature/axios-client) --- ### 6. 小結 | 概念 | 說明 | | --------------------------- | ----------------------- | | **Context** | 全域狀態共用,取代 props 傳遞 | | **自訂 Hook** | 封裝邏輯與狀態,提升重用性 | | **React Query + Axios 攔截器** | 實現 Token 驗證與自動帶入 Header | | **Provider 嵌套** | 可在 _app.tsx 中統一註冊全域狀態 | <br/> ## TODO LIST 範例製作 [範例專案](https://github.com/IffyArt/2025-fullstack-course-forntend/tree/feature/todo-list) ### 1. 設置表單相關組件 本章節的目標是建立一個可重用的表單組件,利用 `react-hook-form` 處理驗證、控制輸入,並支援多種欄位型別(text、number、select、checkbox、textarea 等)。 #### 1.1 設置表單欄位型別 📄 檔案位置:`src/models/form-field.ts` 這個型別用來定義**表單欄位的結構**,以便在不同表單中共用。 每個欄位都會有: * `label`:欄位標題 * `name`:欄位名稱(對應到資料模型中的 key) * `type`:欄位類型 * `required`:是否必填 * `options`:若為下拉選單,則提供可選項目 ```tsx export type FormField = { label: string; name: string; type: 'text' | 'number' | 'email' | 'boolean' | 'select' | 'textarea'; required?: boolean; options?: { label: string; value: string }[]; }; ``` #### 1.2 建立表單欄位切換 📄 檔案位置:`src/components/Fields/index.tsx` 這個組件用於根據 `FormField.type` 自動渲染不同輸入元件。 我們透過 `switch` 判斷欄位型別後,返回對應的 `<input>`、`<select>` 或 `<textarea>`。 每個欄位也會整合 `react-hook-form` 的 `register()` 與錯誤訊息顯示。 ```tsx import { FormField } from '@/models/form-field'; import { UseFormReturn } from 'react-hook-form'; type Props = { field: FormField; methods: UseFormReturn<Record<string, unknown>>; }; export const TextField = ({ field, methods }: Props) => { const { label, name, type } = field; const { register, formState: { errors }, } = methods; switch (type) { case 'text': return ( <section> <label htmlFor={name}>{label}</label> <input type='text' placeholder={`請輸入 ${label}`} {...register(name, { required: field.required })} /> {errors[name] && <p>{errors[name]?.message}</p>} </section> ); case 'number': return ( <section> <label htmlFor={name}>{label}</label> <input type='number' placeholder={`請輸入 ${label}`} {...register(name, { required: field.required })} /> {errors[name] && <p>{errors[name]?.message}</p>} </section> ); case 'email': return ( <section> <label htmlFor={name}>{label}</label> <input type='email' placeholder={`請輸入 ${label}`} {...register(name, { required: field.required })} /> {errors[name] && <p>{errors[name]?.message}</p>} </section> ); case 'boolean': return ( <section> <label htmlFor={name}> <input type='checkbox' {...register(name, { required: field.required })} /> {label} </label> {errors[name] && <p>{errors[name]?.message}</p>} </section> ); case 'select': return ( <section> <label htmlFor={name}>{label}</label> <select {...register(name, { required: field.required })}> <option value=''>請選擇 {label}</option> {field.options?.map((option) => ( <option key={option.value} value={option.value}> {option.label} </option> ))} </select> {errors[name] && <p>{errors[name]?.message}</p>} </section> ); case 'textarea': return ( <section> <label htmlFor={name}>{label}</label> <textarea placeholder={`請輸入 ${label}`} {...register(name, { required: field.required })} /> </section> ); default: return null; } }; ``` 💡 **補充說明:** * 使用 `errors[name]` 即可即時顯示錯誤訊息。 * `placeholder` 透過模板字串自動生成「請輸入 + 欄位名」。 * 使用 `<section>` 包裝欄位,方便後續 CSS 版面控制。 #### 1.3 建立表單組件 📄 檔案位置:`src/components/Form/index.tsx` 這裡是整個表單的外層容器, 負責: * 初始化 `useForm()` * 提供上下文給子欄位 (`FormProvider`) * 負責表單送出事件 `onSubmit` * 支援資料重設(例如編輯時) ```tsx import { FormField } from '@/models/form-field'; import { useEffect } from 'react'; import { FormProvider, useForm } from 'react-hook-form'; import { TextField } from '../Fields'; type Props = { fields: FormField[]; onSubmit: (data: Record<string, unknown>) => void; resetData?: Record<string, unknown>; }; export const Form = ({ fields, onSubmit, resetData }: Props) => { const methods = useForm<Record<string, unknown>>(); useEffect(() => { if (resetData) { fields.forEach((field) => { methods.setValue(field.name, resetData[field.name]); }); } }, [fields, resetData, methods]); const handleSubmit = (data: Record<string, unknown>) => { onSubmit(data); }; return ( <FormProvider {...methods}> <form onSubmit={methods.handleSubmit(handleSubmit)} style={{ border: '1px solid #000', padding: '1rem' }} > <article> {fields.map((field) => ( <TextField key={field.name} field={field} methods={methods} /> ))} </article> <footer> <button type='submit'>Submit</button> </footer> </form> </FormProvider> ); }; ``` 🧠 **補充重點:** * `FormProvider` 提供上下文,使所有子元件都能使用同一個表單狀態。 * `useEffect` 內的 `resetData` 可以實現「編輯時預填資料」。 * `methods.handleSubmit` 自動包含驗證與資料處理。 --- ### 2. 設置其餘相關 Model 這部分建立專案需要的**基本資料結構**,例如 API 回傳格式、Todo 任務欄位、使用者登入參數等。 #### 2.1 建立 api 相關 Model 📄 檔案位置:`src/models/api.ts` 這裡是後端通用回傳格式定義。 `ApiPaginatedResponse` 對應 Django REST Framework 預設分頁結構。 ```ts= export type ApiPaginatedResponse<T> = { results: T[]; count: number; next: string | null; previous: string | null; }; export type BaseApiResponse<T extends object> = T & { created_at: string; id: number; updated_at: string; owner: { email: string; id: number; username: string; }; }; ``` #### 2.2 建立 todo 相關 Model 📄 檔案位置:`src/models/todo.ts` 定義任務(task)、專案(project)、標籤(tag)等資料結構。 ```ts= export enum Priority { LOW = 'Low', MEDIUM = 'Medium', HIGH = 'High', URGENT = 'Urgent', } export type TodoProjectType = { name: string; is_public: boolean; }; export type TodoTagType = { name: string; }; export type TodoTaskType = { title: string; description: string; priority: Priority; is_completed: boolean; due_date: Date; project_id: number; tag_ids: number[]; }; ``` 💬 **補充:** * `Priority` 使用 `enum` 管理優先順序。 * `TodoTaskType` 與 `project_id`、`tag_ids` 維持資料關聯性。 #### 2.3 建立 Auth 相關 Model 📄 檔案位置:`src/models/auth.ts` 簡單定義 JWT 登入使用的 request/response 型別。 ```ts= export type AuthJwtCreateParams = { username: string; password: string; }; export type AuthJwtCreateResponse = { access: string; refresh: string; }; ``` #### 2.4 建立表單資料 📄 檔案位置:`src/fixtures/todo-field-config.ts` ```tsx= import { FormField } from '@/models/form-field'; import { Priority } from '@/models/todo'; export const todoProjectFieldConfig: FormField[] = [ { label: '名稱', name: 'name', type: 'text', required: true, }, { label: '是否公開', name: 'is_public', type: 'boolean', }, ]; export const todoTagFieldConfig: FormField[] = [ { label: '名稱', name: 'name', type: 'text', required: true, }, ]; export const todoTaskFieldConfig: FormField[] = [ { label: '標題', name: 'title', type: 'text', required: true, }, { label: '描述', name: 'description', type: 'textarea', required: true, }, { label: '優先級', name: 'priority', type: 'select', required: true, options: [ { label: 'Low', value: Priority.LOW }, { label: 'Medium', value: Priority.MEDIUM }, { label: 'High', value: Priority.HIGH }, { label: 'Urgent', value: Priority.URGENT }, ], }, { label: '是否完成', name: 'is_completed', type: 'boolean', }, { label: '截止日期', name: 'due_date', type: 'text', }, { label: '專案ID', name: 'project_id', type: 'number', required: true, }, ]; ``` --- ### 3. 撰寫 servers API 操作函式 這裡是使用 **React Query + Axios** 的核心: 將每個資料模型的 CRUD 操作包裝成可重用的 hook。 #### 3.1 設置 Auth 相關 API 📄 檔案位置:`src/servers/auth.ts` 登入時使用 `/auth/jwt/create`,獲取 access/refresh token。 ```ts= import { apiClient } from '@/helper/api-client'; import { AuthJwtCreateParams, AuthJwtCreateResponse } from '@/models/auth'; import { useMutation } from '@tanstack/react-query'; export const useAuthJwtCreate = () => { return useMutation({ mutationFn: async ({ username, password }: AuthJwtCreateParams) => { const { data } = await apiClient.post('/auth/jwt/create', { username, password, }); return data as AuthJwtCreateResponse; }, }); }; ``` #### 3.2 設置 todo project 相關 API 📄 檔案位置:`src/servers/todo-project.ts` 提供對「專案」的 CRUD 操作: * List:取得所有專案 * Retrieve:查看單筆 * Create / Update / Delete ```ts= import { apiClient } from '@/helper/api-client'; import { ApiPaginatedResponse, BaseApiResponse } from '@/models/api'; import { TodoProjectType } from '@/models/todo'; import { useMutation, useQuery } from '@tanstack/react-query'; const todoProjectListQueryKey = 'todoProjectList'; export const useTodoProjectList = () => { return useQuery({ queryKey: [todoProjectListQueryKey], queryFn: async () => { const { data } = await apiClient.get('/v1/todo/projects'); return data as ApiPaginatedResponse<BaseApiResponse<TodoProjectType>>; }, }); }; export const useTodoProjectRetrieve = (projectId: string) => { return useQuery({ queryKey: [todoProjectListQueryKey, projectId], queryFn: async () => { const { data } = await apiClient.get(`/v1/todo/projects/${projectId}`); return data as BaseApiResponse<TodoProjectType>; }, enabled: !!projectId, }); }; export const useTodoProjectCreate = () => { return useMutation({ mutationFn: async (project: TodoProjectType) => { const { data } = await apiClient.post('/v1/todo/projects', project); return data; }, }); }; export const useTodoProjectUpdate = () => { return useMutation({ mutationFn: async (project: TodoProjectType & { id: string }) => { const { data } = await apiClient.put( `/v1/todo/projects/${project.id}`, project, ); return data; }, }); }; export const useTodoProjectDelete = () => { return useMutation({ mutationFn: async (projectId: string) => { const { data } = await apiClient.delete(`/v1/todo/projects/${projectId}`); return data; }, }); }; ``` 🧩 **補充:** * 所有 API 均透過 React Query 管理快取。 * `queryKey` 用於自動重新整理(refetch)。 --- ### 4. 修改 useAuth 內部函式 📄 檔案位置:`src/hooks/useAuth.ts` 這個 Hook 封裝登入/登出邏輯,統一管理 JWT Token。 ```ts= import { useAuthJwtCreate } from '@/servers/auth'; import { useState } from 'react'; export const useAuth = () => { const { mutate: authJwtCreate } = useAuthJwtCreate(); const [access, setAccess] = useState<string | null>(null); const [refresh, setRefresh] = useState<string | null>(null); const login = async (username: string, password: string) => { authJwtCreate( { username, password }, { onSuccess: (data) => { setAccess(data.access); setRefresh(data.refresh); localStorage.setItem('access', data.access); }, onError: (error) => { console.error(error); }, }, ); }; const logout = () => { setAccess(null); setRefresh(null); }; return { access, refresh, login, logout }; }; ``` 💡 **重點補充:** * 使用 React Query 的 `onSuccess` 事件接收登入回傳。 * Token 存在 `localStorage`,刷新頁面後仍可保留。 --- ### 5. 製作 Todo 這裡整合前面所有模組,完成「專案管理」功能。 #### 5.1 製作 Todo 組件 📄 檔案位置:`src/components/Todo/index.tsx` 此組件負責: * 顯示所有 Todo Project * 新增、編輯、刪除功能 * 切換「新增表單」與「編輯表單」 ```tsx= import { todoProjectFieldConfig } from '@/fixtures/todo-field-config'; import { TodoProjectType } from '@/models/todo'; import { useTodoProjectCreate, useTodoProjectDelete, useTodoProjectList, useTodoProjectRetrieve, useTodoProjectUpdate, } from '@/servers/todo-project'; import { useState } from 'react'; import { Form } from '../Form'; const TodoProject = () => { const [currentId, setCurrentId] = useState<number | null>(null); const { data: todoProjectList, refetch: refetchTodoProjectList, isFetching: isFetchingTodoProjectList, isPending: isPendingTodoProjectList, } = useTodoProjectList(); const { data: todoProjectData } = useTodoProjectRetrieve( currentId?.toString() ?? '', ); const { mutate: createTodoProject } = useTodoProjectCreate(); const { mutate: updateTodoProject } = useTodoProjectUpdate(); const { mutate: deleteTodoProject } = useTodoProjectDelete(); const handleCreateTodoProject = (data: TodoProjectType) => { createTodoProject(data, { onSuccess: () => { refetchTodoProjectList(); }, }); }; const handleUpdateTodoProject = (data: TodoProjectType) => { updateTodoProject( { ...data, id: currentId?.toString() ?? '' }, { onSuccess: () => { refetchTodoProjectList(); setCurrentId(null); }, }, ); }; const handleDeleteTodoProject = (id: number) => { deleteTodoProject(id.toString(), { onSuccess: () => { refetchTodoProjectList(); }, }); }; return ( <div> {isPendingTodoProjectList || isFetchingTodoProjectList ? ( <div>Loading...</div> ) : ( <> <h1>Todo Project 新增表單</h1> <Form fields={todoProjectFieldConfig} onSubmit={(data) => handleCreateTodoProject(data as TodoProjectType) } /> {currentId && ( <> <h1> Todo Project 編輯表單{' '} <button onClick={() => setCurrentId(null)}>取消編輯</button> </h1> <Form fields={todoProjectFieldConfig} onSubmit={(data) => handleUpdateTodoProject(data as TodoProjectType) } resetData={todoProjectData} /> </> )} </> )} {todoProjectList && ( <ul> {todoProjectList?.results?.map((project) => ( <li key={project.id}> <div> {project.name} {project.is_public ? '公開' : '私密'} </div> <button onClick={() => setCurrentId(project.id)}>編輯欄位</button> <button onClick={() => handleDeleteTodoProject(project.id)}> 刪除項目 </button> </li> ))} </ul> )} </div> ); }; export default TodoProject; ``` 🧩 **補充教學:** * `currentId` 用來判斷目前是否為編輯狀態。 * `resetData` 傳入 `Form` 組件以顯示預設值。 * 刪除或更新後透過 `refetchTodoProjectList()` 即時更新 UI。 #### 5.2 設置首頁畫面 📄 檔案位置:`src/pages/index.tsx` 此頁面示範登入流程與主畫面展示。 登入成功後會顯示 “Logged in”,並可操作 Todo 專案。 ```tsx= import TodoProject from '@/components/Todo'; import { useAuth } from '@/hooks/useAuth'; export default function Home() { const { login, access, logout } = useAuth(); return ( <main style={{ padding: '2rem' }}> {access ? <p>Logged in</p> : <p>Logged out</p>} <button onClick={() => login('admin', 'admin')}>Login</button> <button onClick={() => logout()}>Logout</button> <TodoProject /> </main> ); } ``` 💡 **補充:** * 測試時可以直接使用 `login('admin', 'admin')` 模擬登入。 * 頁面簡單但具備完整 CRUD 與登入流程範例,非常適合教材示範。 <br/> ## TODO LIST TASK 設計 [範例專案](https://github.com/IffyArt/2025-fullstack-course-forntend/tree/feature/todo-list-task) ### servers 📄 檔案位置:`src/servers/todo-tags.ts` ```tsx= import { apiClient } from '@/helper/api-client'; import { ApiPaginatedResponse, BaseApiResponse } from '@/models/api'; import { TodoTagType } from '@/models/todo'; import { useMutation, useQuery } from '@tanstack/react-query'; const todoTagsListQueryKey = 'todoTagsList'; export const useTodoTagsList = () => { return useQuery({ queryKey: [todoTagsListQueryKey], queryFn: async () => { const { data } = await apiClient.get('/v1/todo/tags'); return data as ApiPaginatedResponse<BaseApiResponse<TodoTagType>>; }, }); }; export const useTodoTagsRetrieve = (tagId: string) => { return useQuery({ queryKey: [todoTagsListQueryKey, tagId], queryFn: async () => { const { data } = await apiClient.get(`/v1/todo/tags/${tagId}`); return data as BaseApiResponse<TodoTagType>; }, enabled: !!tagId, }); }; export const useTodoTagsCreate = () => { return useMutation({ mutationFn: async (tag: TodoTagType) => { const { data } = await apiClient.post('/v1/todo/tags', tag); return data; }, }); }; export const useTodoTagUpdate = () => { return useMutation({ mutationFn: async (tag: TodoTagType & { id: string }) => { const { data } = await apiClient.put(`/v1/todo/tags/${tag.id}`, tag); return data; }, }); }; export const useTodoTagsDelete = () => { return useMutation({ mutationFn: async (tagId: string) => { const { data } = await apiClient.delete(`/v1/todo/tags/${tagId}`); return data; }, }); }; ``` 📄 檔案位置:`src/servers/todo-task.ts` ```tsx= import { apiClient } from '@/helper/api-client'; import { ApiPaginatedResponse, BaseApiResponse } from '@/models/api'; import { TodoTaskType } from '@/models/todo'; import { useMutation, useQuery } from '@tanstack/react-query'; const todoTaskListQueryKey = 'todoTaskList'; export const useTodoTaskList = () => { return useQuery({ queryKey: [todoTaskListQueryKey], queryFn: async () => { const { data } = await apiClient.get('/v1/todo/tasks'); return data as ApiPaginatedResponse<BaseApiResponse<TodoTaskType>>; }, }); }; export const useTodoTaskRetrieve = (taskId: string) => { return useQuery({ queryKey: [todoTaskListQueryKey, taskId], queryFn: async () => { const { data } = await apiClient.get(`/v1/todo/tasks/${taskId}`); return data as BaseApiResponse<TodoTaskType>; }, enabled: !!taskId, }); }; export const useTodoTaskCreate = () => { return useMutation({ mutationFn: async (task: TodoTaskType) => { const { data } = await apiClient.post('/v1/todo/tasks', task); return data; }, }); }; export const useTodoTaskUpdate = () => { return useMutation({ mutationFn: async (task: TodoTaskType & { id: string }) => { const { data } = await apiClient.put(`/v1/todo/tasks/${task.id}`, task); return data; }, }); }; export const useTodoTaskDelete = () => { return useMutation({ mutationFn: async (taskId: string) => { const { data } = await apiClient.delete(`/v1/todo/tasks/${taskId}`); return data; }, }); }; ``` --- ### models 📄 檔案位置:`src/models/form-field.ts` ```tsx=import { UseQueryResult } from '@tanstack/react-query'; import { ApiPaginatedResponse, BaseApiResponse } from './api'; export type FormField = { label: string; name: string; type: | 'text' | 'number' | 'email' | 'boolean' | 'select' | 'date' | 'textarea' | 'query-select' | 'query-checkboxes'; required?: boolean; options?: { label: string; value: string }[]; query?: () => UseQueryResult< ApiPaginatedResponse<BaseApiResponse<object>>, Error >; }; ``` 📄 檔案位置:`src/models/todo.ts` ```tsx=import { UseQueryResult } from '@tanstack/react-query'; export enum Priority { LOW = 'low', MEDIUM = 'medium', HIGH = 'high', URGENT = 'urgent', } export type TodoProjectType = { name: string; is_public: boolean; }; export type TodoTagType = { name: string; }; export type TodoTaskType = { title: string; description: string; priority: Priority; is_completed: boolean; due_date: Date; project_id: number; tag_ids: number[]; }; ``` ### fixtures 📄 檔案位置:`src/fixtures/todo-field-config.ts` ```tsx= import { FormField } from '@/models/form-field'; import { Priority } from '@/models/todo'; import { useTodoProjectList } from '@/servers/todo-project'; import { useTodoTagsList } from '@/servers/todo-tags'; export const todoProjectFieldConfig: FormField[] = [ { label: '名稱', name: 'name', type: 'text', required: true, }, { label: '是否公開', name: 'is_public', type: 'boolean', }, ]; export const todoTagFieldConfig: FormField[] = [ { label: '名稱', name: 'name', type: 'text', required: true, }, ]; export const todoTaskFieldConfig: FormField[] = [ { label: '標題', name: 'title', type: 'text', required: true, }, { label: '描述', name: 'description', type: 'textarea', required: true, }, { label: '優先級', name: 'priority', type: 'select', required: true, options: [ { label: 'Low', value: Priority.LOW }, { label: 'Medium', value: Priority.MEDIUM }, { label: 'High', value: Priority.HIGH }, { label: 'Urgent', value: Priority.URGENT }, ], }, { label: '是否完成', name: 'is_completed', type: 'boolean', }, { label: '截止日期', name: 'due_date', type: 'date', }, { label: '專案名稱', name: 'project_id', type: 'query-select', required: true, query: useTodoProjectList, }, { label: '標籤項目', name: 'tag_ids', type: 'query-checkboxes', required: true, query: useTodoTagsList, }, ]; ``` ### components 📄 檔案位置:`src/components/Fields/index.tsx` ```tsx= import { FormField } from '@/models/form-field'; import { UseFormReturn } from 'react-hook-form'; import { QueryCheckboxes } from './src/QueryCheckboxes'; import { QuerySelect } from './src/QuerySelect'; export type FieldProps = { field: FormField; methods: UseFormReturn<Record<string, unknown>>; }; export const TextField = ({ field, methods }: FieldProps) => { const { label, name, type } = field; const { register, formState: { errors }, } = methods; switch (type) { case 'text': return ( <section> <label htmlFor={name}>{label}</label> <input type='text' placeholder={`請輸入 ${label}`} {...register(name, { required: field.required })} /> {errors[name] && <p>{errors[name]?.message}</p>} </section> ); case 'number': return ( <section> <label htmlFor={name}>{label}</label> <input type='number' placeholder={`請輸入 ${label}`} {...register(name, { required: field.required })} /> {errors[name] && <p>{errors[name]?.message}</p>} </section> ); case 'email': return ( <section> <label htmlFor={name}>{label}</label> <input type='email' placeholder={`請輸入 ${label}`} {...register(name, { required: field.required })} /> {errors[name] && <p>{errors[name]?.message}</p>} </section> ); case 'boolean': return ( <section> <label htmlFor={name}> <input type='checkbox' {...register(name, { required: field.required })} /> {label} </label> {errors[name] && <p>{errors[name]?.message}</p>} </section> ); case 'date': return ( <section> <label htmlFor={name}>{label}</label> <input type='date' {...register(name, { required: field.required })} /> {errors[name] && <p>{errors[name]?.message}</p>} </section> ); case 'select': return ( <section> <label htmlFor={name}>{label}</label> <select {...register(name, { required: field.required })}> <option value=''>請選擇 {label}</option> {field.options?.map((option) => ( <option key={option.value} value={option.value}> {option.label} </option> ))} </select> {errors[name] && <p>{errors[name]?.message}</p>} </section> ); case 'textarea': return ( <section> <label htmlFor={name}>{label}</label> <textarea placeholder={`請輸入 ${label}`} {...register(name, { required: field.required })} /> </section> ); case 'query-select': return <QuerySelect field={field} methods={methods} />; case 'query-checkboxes': return <QueryCheckboxes field={field} methods={methods} />; default: return null; } }; ``` 📄 檔案位置:`src/components/Fields/src/QuerySelect.tsx` ```tsx= /* eslint-disable @typescript-eslint/no-explicit-any */ import { FieldProps } from '..'; export const QuerySelect = ({ field, methods }: FieldProps) => { const { query } = field; const { label, name } = field; const { register, formState: { errors }, } = methods; const queryResult = query?.(); const data = queryResult && 'data' in queryResult ? (queryResult.data as any) : undefined; return ( <section> <label htmlFor={name}>{label}</label> <select {...register(name, { required: field.required })}> <option value=''>請選擇 {label}</option> {data?.results?.map((item: any) => ( <option key={item.id} value={item.id}> {item.name} </option> ))} </select> {errors[name] && <p>{errors[name]?.message}</p>} </section> ); }; ``` 📄 檔案位置:`src/components/Fields/src/QueryCheckboxes.tsx` ```tsx= /* eslint-disable @typescript-eslint/no-explicit-any */ import { FieldProps } from '..'; export const QueryCheckboxes = ({ field, methods }: FieldProps) => { const { query } = field; const { label, name } = field; const { register, formState: { errors }, } = methods; const queryResult = query?.(); const data = queryResult && 'data' in queryResult ? (queryResult.data as any) : undefined; return ( <section> <label htmlFor={name}>{label}</label> {data?.results?.map((item: any) => ( <label key={item.id} htmlFor={`${name}-${item.id}`}> <input type='checkbox' value={item.id} id={`${name}-${item.id}`} {...register(name, { required: field.required })} /> {item.name} </label> ))} {errors[name] && <p>{errors[name]?.message}</p>} </section> ); }; ``` 📄 檔案位置:`src/components/TodoTag/index.tsx` ```tsx= import { todoTagFieldConfig } from '@/fixtures/todo-field-config'; import { TodoTagType } from '@/models/todo'; import { useTodoTagsCreate, useTodoTagsDelete, useTodoTagsList, useTodoTagsRetrieve, useTodoTagUpdate, } from '@/servers/todo-tags'; import { useState } from 'react'; import { Form } from '../Form'; const TodoTag = () => { const [currentId, setCurrentId] = useState<number | null>(null); const { data: todoTagList, refetch: refetchTodoTagList, isFetching: isFetchingTodoTagList, isPending: isPendingTodoTagList, } = useTodoTagsList(); const { data: todoTagData } = useTodoTagsRetrieve( currentId?.toString() ?? '', ); const { mutate: createTodoTag } = useTodoTagsCreate(); const { mutate: updateTodoTag } = useTodoTagUpdate(); const { mutate: deleteTodoTag } = useTodoTagsDelete(); const handleCreateTodoTag = (data: TodoTagType) => { createTodoTag(data, { onSuccess: () => { refetchTodoTagList(); }, }); }; const handleUpdateTodoTag = (data: TodoTagType) => { updateTodoTag( { ...data, id: currentId?.toString() ?? '' }, { onSuccess: () => { refetchTodoTagList(); setCurrentId(null); }, }, ); }; const handleDeleteTodoTag = (id: number) => { deleteTodoTag(id.toString(), { onSuccess: () => { refetchTodoTagList(); }, }); }; return ( <div> {isPendingTodoTagList || isFetchingTodoTagList ? ( <div>Loading...</div> ) : ( <> <h1>Todo Tag 新增表單</h1> <Form fields={todoTagFieldConfig} onSubmit={(data) => handleCreateTodoTag(data as TodoTagType)} /> {currentId && ( <> <h1> Todo Tag 編輯表單{' '} <button onClick={() => setCurrentId(null)}>取消編輯</button> </h1> <Form fields={todoTagFieldConfig} onSubmit={(data) => handleUpdateTodoTag(data as TodoTagType)} resetData={todoTagData} /> </> )} </> )} {todoTagList && ( <ul> {todoTagList?.results?.map((tag) => ( <li key={tag.id}> <div>{tag.name}</div> <button onClick={() => setCurrentId(tag.id)}>編輯欄位</button> <button onClick={() => handleDeleteTodoTag(tag.id)}> 刪除項目 </button> </li> ))} </ul> )} </div> ); }; export default TodoTag; ``` 📄 檔案位置:`src/components/TodoTask/index.tsx` ```tsx= import { todoTaskFieldConfig } from '@/fixtures/todo-field-config'; import { TodoTaskType } from '@/models/todo'; import { useTodoTaskCreate, useTodoTaskDelete, useTodoTaskList, useTodoTaskRetrieve, useTodoTaskUpdate, } from '@/servers/todo-task'; import { useState } from 'react'; import { Form } from '../Form'; const TodoTask = () => { const [currentId, setCurrentId] = useState<number | null>(null); const { data: todoTaskList, refetch: refetchTodoTaskList, isFetching: isFetchingTodoTaskList, isPending: isPendingTodoTaskList, } = useTodoTaskList(); const { data: todoTaskData } = useTodoTaskRetrieve( currentId?.toString() ?? '', ); console.log(todoTaskList); const { mutate: createTodoTask } = useTodoTaskCreate(); const { mutate: updateTodoTask } = useTodoTaskUpdate(); const { mutate: deleteTodoTask } = useTodoTaskDelete(); const handleCreateTodoTask = (data: TodoTaskType) => { createTodoTask(data, { onSuccess: () => { refetchTodoTaskList(); }, }); }; const handleUpdateTodoTask = (data: TodoTaskType) => { updateTodoTask( { ...data, id: currentId?.toString() ?? '' }, { onSuccess: () => { refetchTodoTaskList(); setCurrentId(null); }, }, ); }; const handleDeleteTodoTask = (id: number) => { deleteTodoTask(id.toString(), { onSuccess: () => { refetchTodoTaskList(); }, }); }; return ( <div> {isPendingTodoTaskList || isFetchingTodoTaskList ? ( <div>Loading...</div> ) : ( <> <h1>Todo Task 新增表單</h1> <Form fields={todoTaskFieldConfig} onSubmit={(data) => handleCreateTodoTask(data as TodoTaskType)} /> {currentId && ( <> <h1> Todo Task 編輯表單{' '} <button onClick={() => setCurrentId(null)}>取消編輯</button> </h1> <Form fields={todoTaskFieldConfig} onSubmit={(data) => handleUpdateTodoTask(data as TodoTaskType)} resetData={todoTaskData} /> </> )} </> )} {todoTaskList && ( <ul> {todoTaskList?.results?.map((task) => ( <li key={task.id}> <div> {task.title} <br /> {task.description} <br /> {task.priority} <br /> {task.project_id} <br /> {task.is_completed ? '完成' : '未完成'} </div> <button onClick={() => setCurrentId(task.id)}>編輯欄位</button> <button onClick={() => handleDeleteTodoTask(task.id)}> 刪除項目 </button> </li> ))} </ul> )} </div> ); }; export default TodoTask; ``` <!-- ### servers 📄 檔案位置:`src/servers/todo-tags.ts` ```tsx= ``` 📄 檔案位置:`src/servers/todo-task.ts` ```tsx= ``` --> ### 處理表單設置初始值機制 📄 檔案位置:`src/components/Form/index.tsx` ```tsx= import { FormField } from '@/models/form-field'; import { useEffect } from 'react'; import { FormProvider, useForm } from 'react-hook-form'; import { TextField } from '../Fields'; type Props = { fields: FormField[]; onSubmit: (data: Record<string, unknown>) => void; resetData?: Record<string, unknown>; }; export const Form = ({ fields, onSubmit, resetData }: Props) => { const methods = useForm<Record<string, unknown>>(); useEffect(() => { if (resetData) { fields.forEach((field) => { const fieldValue = resetData[field.name]; switch (field.type) { case 'date': methods.setValue( field.name, new Date(fieldValue as string).toISOString().split('T')[0], ); break; case 'query-select': const queryKey = field.name.replace('_id', ''); const targetValue = ( resetData[queryKey] as { id: number } ).id.toString(); methods.setValue(field.name, targetValue); break; default: methods.setValue(field.name, fieldValue); break; } }); } }, [fields, resetData, methods]); const handleSubmit = (data: Record<string, unknown>) => { onSubmit(data); }; return ( <FormProvider {...methods}> <form onSubmit={methods.handleSubmit(handleSubmit)} style={{ border: '1px solid #000', padding: '1rem' }} > <article> {fields.map((field) => ( <TextField key={field.name} field={field} methods={methods} /> ))} </article> <footer> <button type='submit'>Submit</button> </footer> </form> </FormProvider> ); }; ```