# 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>
);
};
```