# 後端

整體後端大致工作流程為

  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 用於後台管理介面的顯示

那文章對

# 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

# 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 處理文章的創建和更新操作,特別處理了多對多關係
# 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 請求,創建新資源(這裡是新文章)
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 檔案,將所有與產品相關的路由都放在這個檔案中

# api/blog/urls.py
urlpatterns = [
    ...
    # 文章相關 URLs
    path('posts/', PostListCreateView.as_view(), name='post-list'),
]

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

# 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 請求路由更新

這部分在先前已提過不再重複,就是改基底路由

# 型別定義

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 請求路由

// src/api/blog.ts
// 獲取文章列表
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 的結果,包含數據、加載狀態、錯誤等信息
// 創建自定義 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 參數為文章卡片顯示文章的詳細資訊

// 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 函數,導向到對應的文章內頁

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

請我喝[茶]~( ̄▽ ̄)~*

Young 微信支付

微信支付

Young 支付寶

支付寶