2025 OEM 網站開發筆記 [100] - 後端文章管理系統 & 列表

ER Model 設計、ORM 模型設計,建立關聯資料表、資料庫遷移、文章列表 API 設計

Posted by Young on 2025-03-20
Estimated Reading Time 10 Minutes
Words 2.1k In Total

後端

整體後端大致工作流程為

  1. ORM 模型設計
  2. serializer 開發
  3. View 開發
  4. URL 路由配置
  5. API 測試與驗證(Postman / DRF)

ER Model

article-er-model

因為已經有文章標籤(TAG)的關係,這邊將文章(POST)和分類(CATEGORY)之間的關係從多對多改為一對多,更好的去區別

ORM 模型

程式碼就不全部列出來,先只顯示 PostPostTag 兩個表重點說明

  • 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
# api/blog/models.py

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
# api/blog/admin.py
from django.contrib import admin
from .models import *

admin.site.register(Post)
...

admin-display

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
# api/blog/serializers.py
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
# api/blog/urls.py
urlpatterns = [
...
# 文章相關 URLs
path('posts/', PostListCreateView.as_view(), name='post-list'),
]

在專案總路由管理也導入 blog 文章相關路由

1
2
3
4
5
6
7
# backend/urls.py
urlpatterns = [
path('admin/', admin.site.urls),
...
path('api/products/', include('api.products.urls')), # 產品 API
path('api/blog/', include('api.blog.urls')), # 部落格 API
]

前端工作流程

  1. 建構發送 API 請求
  2. 使用 Tanstack-query 構建自定義的 hook
  3. 在文章列表元件中調用 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 });

// 這裡需要檢查 response.data 的結構
// 處理分頁響應
if (response.data.results) {
return response.data.results;
}
return response.data;
};

hook

定義一個自定義 Hook usePostsQuery,封裝 React Query 的 useQuery Hook 來管理文章數據的獲取

  1. queryKey:設置為 [“posts”, filters] 數組,當 filters 變化時會觸發重新獲取
  2. queryFn:設置為返回 fetchPosts(filters) 的函數,作為數據獲取的實際執行者
  3. 返回 useQuery 的結果,包含數據、加載狀態、錯誤等信息
1
2
3
4
5
6
7
// 創建自定義 Hook 來處理文章查詢
export const usePostsQuery = (filters?: PostFilters) => {
return useQuery({
queryKey: ["posts", filters],
queryFn: () => fetchPosts(filters),
});
};

前端頁面更新

  1. 在前端元件中調用自定義 Hook usePostsQuery 並傳入過濾條件,解構獲取文章數據、加載狀態和錯誤訊息
  2. 定義 handlePostClick 函數處理文章點擊事件,導到文章內頁
  3. 在渲染部分,根據是否有文章數據顯示不同的內容:
    1. 當沒有文章時,顯示無結果提示和重置過濾器的按鈕
    2. 當有文章時,使用網格佈局展示文章卡片列表

為每篇文章渲染 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
// components/blog/blog-post-list.tsx

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

文章卡片則會接收 postonClick 兩個 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
// components/blog/blog-post-card.tsx
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>
);
}

若您覺得這篇文章對您有幫助,歡迎分享出去讓更多人看到⊂◉‿◉つ~


留言版