後端
整體後端大致工作流程為
- ORM 模型設計
- serializer 開發
- View 開發
- URL 路由配置
- API 測試與驗證(Postman / DRF)
ER Model

因為已經有文章標籤(TAG)的關係,這邊將文章(POST)和分類(CATEGORY)之間的關係從多對多改為一對多,更好的去區別
ORM 模型
程式碼就不全部列出來,先只顯示 Post
跟 PostTag
兩個表重點說明
- 在
tag
欄位中用 Django 的 through
參數明確定義了多對多關係的中間表模型
- 添加了 unique_together 確保一篇文章不會重複關聯同一個分類或標籤
- 在 Post 添加了
save
方法,自動設置文章第一次發布時的發布時間
- 添加
verbose_name
用於後台管理介面的顯示
那文章對
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43
|
class Post(models.Model): """文章模型""" STATUS_CHOICES = ( ('draft', '草稿'), ('published', '已發布'), ('archived', '已歸檔'), )
title = models.CharField(max_length=200, verbose_name="標題") slug = models.SlugField(max_length=200, unique=True, verbose_name="URL別名") content = models.TextField(verbose_name="內容") ...
category = models.ForeignKey(Category, on_delete=models.CASCADE, related_name='posts', verbose_name="分類") tags = models.ManyToManyField(Tag, through='PostTag', related_name='posts', verbose_name="標籤")
class Meta: verbose_name = "文章" verbose_name_plural = "文章" ordering = ['-published_at', '-created_at']
def __str__(self): return self. def save(self, *args, **kwargs): if self.status == 'published' and not self.published_at: self.published_at = timezone.now() super().save(*args, **kwargs) ... class PostTag(models.Model): """文章-標籤 關聯模型""" post = models.ForeignKey(Post, on_delete=models.CASCADE, verbose_name="文章") tag = models.ForeignKey(Tag, on_delete=models.CASCADE, verbose_name="標籤")
class Meta: verbose_name = "文章標籤關聯" verbose_name_plural = "文章標籤關聯" unique_together = ('post', 'tag') ...
|
Django Admin Panel 註冊
要在 Django Admin Panel 中顯示這些模型就必須在 admin.py
1 2 3 4 5 6
| from django.contrib import admin from .models import *
admin.site.register(Post) ...
|

serializer 設計
一樣只顯示部分程式碼,將文章、分類和標籤模型轉換成 RESTful API
PostListSerializer
提供文章列表時使用,包含摘要資訊
CategorySerializer
和 TagSerializer 處理分類和標籤的序列化
PostDetailSerializer
提供單篇文章詳情時使用,包含所有欄位
PostCreateUpdateSerializer
處理文章的創建和更新操作,特別處理了多對多關係
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| class CategorySerializer(serializers.ModelSerializer): class Meta: model = Category fields = ['id', 'name', 'description', 'sort_order']
class PostListSerializer(serializers.ModelSerializer): category_name = serializers.CharField(source='category.name', read_only=True) tags = TagSerializer(many=True, read_only=True)
class Meta: model = Post fields = [ 'id', 'title', 'slug', 'summary', 'thumbnail_url', 'published_at', 'status', 'view_count', 'category', 'category_name', 'tags' ] ... class PostDetailSerializer(serializers.ModelSerializer): category = CategorySerializer(read_only=True) tags = TagSerializer(many=True, read_only=True)
class Meta: model = Post fields = [ 'id', 'title', 'slug', 'content', 'summary', 'thumbnail_url', 'meta_title', 'meta_description', 'created_at', 'updated_at', 'published_at', 'status', 'view_count', 'category', 'tags' ]
|
在 PostListSerializer
中,使用 source='category.name'
參數來表示這個字段的值來源於 Post 模型的 category 關聯對象的 name 屬性,也就是再獲取文章列表時,同時返回該文章的分類名稱及標籤
view 設計
這邊使用 DRF 的 ListAPIView
來實作產品列表的 API,並且使用 ProductSerializer
來序列化資料,這邊使用 get_queryset
方法會返回所有狀態為 published
的文章
此 PostListCreateView
是繼承ListCreateAPIView
,而 ListCreateAPIView
是 Django REST Framework 的組合視圖
融合列表查詢和創建的功能,這樣寫可以減少重複性程式碼
- List:處理 GET 請求,返回資源列表(這裡是文章列表)
- Create:處理 POST 請求,創建新資源(這裡是新文章)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| class PostListCreateView(ListCreateAPIView): queryset = Post.objects.all() filterset_fields = ['category', 'status', 'tags'] search_fields = ['title', 'content', 'summary'] ordering_fields = ['published_at', 'created_at', 'view_count', 'title']
def get_serializer_class(self): if self.request.method == 'POST': return PostCreateUpdateSerializer return PostListSerializer
def get_queryset(self): if self.request.user.is_staff: return Post.objects.all() return Post.objects.filter(status='published')
|
url 路由
這邊將路由分開管理,建立一個 urls 資料夾,並且在裡面建立 product_urls
.py 檔案,將所有與產品相關的路由都放在這個檔案中
1 2 3 4 5 6
| urlpatterns = [ ... path('posts/', PostListCreateView.as_view(), name='post-list'), ]
|
在專案總路由管理也導入 blog 文章相關路由
1 2 3 4 5 6 7
| urlpatterns = [ path('admin/', admin.site.urls), ... path('api/products/', include('api.products.urls')), path('api/blog/', include('api.blog.urls')), ]
|
前端工作流程
- 建構發送 API 請求
- 使用 Tanstack-query 構建自定義的 hook
- 在文章列表元件中調用 hook
axiosInstance API 請求路由更新
這部分在先前已提過不再重複,就是改基底路由
型別定義
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| export interface Tag { id: number; name: string; description: string; }
...
export interface Post { id: number; title: string; slug: string; summary: string; thumbnail_url: string | null; published_at: string; status: "draft" | "published" | "archived"; view_count: number; category: number; category_name: string; tags: Tag[]; } ...
|
更新產品 API 請求路由
1 2 3 4 5 6 7 8 9 10 11 12
| export const fetchPosts = async (filters?: PostFilters): Promise<Post[]> => { const params = filters || {}; const response = await api.get("/api/blog/posts/", { params });
if (response.data.results) { return response.data.results; } return response.data; };
|
hook
定義一個自定義 Hook usePostsQuery
,封裝 React Query 的 useQuery Hook 來管理文章數據的獲取
queryKey
:設置為 [“posts”, filters] 數組,當 filters 變化時會觸發重新獲取
queryFn
:設置為返回 fetchPosts(filters) 的函數,作為數據獲取的實際執行者
- 返回 useQuery 的結果,包含數據、加載狀態、錯誤等信息
1 2 3 4 5 6 7
| export const usePostsQuery = (filters?: PostFilters) => { return useQuery({ queryKey: ["posts", filters], queryFn: () => fetchPosts(filters), }); };
|
前端頁面更新
- 在前端元件中調用自定義 Hook usePostsQuery 並傳入過濾條件,解構獲取文章數據、加載狀態和錯誤訊息
- 定義
handlePostClick
函數處理文章點擊事件,導到文章內頁
- 在渲染部分,根據是否有文章數據顯示不同的內容:
- 當沒有文章時,顯示無結果提示和重置過濾器的按鈕
- 當有文章時,使用網格佈局展示文章卡片列表
為每篇文章渲染 BlogPostCard 組件,並通過 onClick 屬性綁定點擊事件處理函數,並透過傳遞 post 跟 onclick 參數為文章卡片顯示文章的詳細資訊
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
|
const navigate = useNavigate(); const { data: posts = [], isLoading, isError, error } = usePostsQuery(filters);
const handlePostClick = (slug: string) => { navigate(`/articles/${slug}`); };
export const BlogPostList: React.FC = () => { return ( <div className='container mx-auto px-4 py-12'> ... {posts.length === 0 ? ( <div className='text-center py-16 text-gray-500'> <p className='text-xl mb-4'>沒有找到符合條件的文章</p> <Button variant='outline' onClick={resetFilters}> 清除篩選條件 </Button> </div> ) : ( <div className='grid grid-cols-1 md:grid-cols-2 gap-8'> {posts.map((post) => ( <BlogPostCard key={post.id} post={post} onClick={() => handlePostClick(post.slug)} /> ))} </div> )} </div> ); };
|
文章卡片則會接收 post
和 onClick
兩個 props,並在點擊時觸發 onClick
函數,導向到對應的文章內頁
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| export const BlogPostCard: React.FC<PostCardProps> = ({ post, onClick }) => { return ( <div ref={cardRef} className='h-full rounded-lg overflow-hidden shadow-md cursor-pointer bg-white' onClick={onClick}> {/* 圖片容器 */} {post.thumbnail_url && ( ... )}
{/* 內容區域 */} <div className='p-4'> <h3 className='text-xl font-semibold group-hover:text-blue-600 transition-colors'>{post.title}</h3> ... {/* 標籤部分 */} {post.tags.length > 0 && ( <div className='flex flex-wrap gap-1 w-full mt-auto pt-2 border-t border-gray-100'> {post.tags.slice(0, 3).map((tag) => ( <Badge key={tag.id} variant='outline' className='text-xs flex items-center gap-1 bg-gray-50 hover:bg-gray-100 transition-colors'> <Tag className='w-3 h-3' /> {tag.name} </Badge> ))} ... </div> )} </div> </div> ); }
|
若您覺得這篇文章對您有幫助,歡迎分享出去讓更多人看到⊂◉‿◉つ~
留言版