2025 OEM 網站開發筆記 [23] - 前端點擊分類,篩選並只顯示出該分類的文章列表功能

Posted by Young on 2025-05-29
Estimated Reading Time 4 Minutes
Words 923 In Total

後端

後端工作流程:

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

Serializer

使用前篇用來獲取所有文章列表的相同 PostListSerializer

1
2
class PostListSerializer(serializers.ModelSerializer):
...

views

調用的 serializer 與獲取文章列表的是同一個 PostListSerializer,但這邊會根據前端傳入的 category_id 參數來篩選文章

1
2
3
4
5
6
7
8
9
10
11
12
13
class PostsByCategoryView(ListAPIView):
serializer_class = PostListSerializer

def get_queryset(self):
category_id = self.request.query_params.get('category_id')
if not category_id:
return Response(
{"error": "需要提供 category_id 參數"},
status=status.HTTP_400_BAD_REQUEST
)
if self.request.user.is_staff:
return Post.objects.filter(category_id=category_id)
return Post.objects.filter(category_id=category_id, status='published')

urls

一樣記得把這三行

1
2
3
4
...
path('posts/published/', PublishedPostsView.as_view(), name='published-posts'),
path('posts/by-category/', PostsByCategoryView.as_view(), name='posts-by-category'),
path('posts/by-tag/', PostsByTagView.as_view(), name='posts-by-tag'),

移到這兩行之前:

1
2
3
4
5
urlpatterns = [
# 文章相關 URLs
path('posts/', PostListCreateView.as_view(), name='post-list'),
path('posts/<slug:slug>/', PostDetailView.as_view(), name='post-detail'),
]

避免 Django 匹配到 'posts/''posts/<slug:slug>/' 時就停止了。

前端

前端工作流程:

  1. api
  2. hooks
  3. 前端文章列表元件頁面

api

1
2
3
4
5
6
7
8
// src/api/blog.ts
// 按分類獲取文章
export const fetchPostsByCategory = async (categoryId: number): Promise<Post[]> => {
const response = await api.get("/api/blog/posts/by-category/", {
params: { category_id: categoryId },
});
return response.data;
};

hooks

一樣用 TanStack Query 的 useQuery 來獲取文章列表,但在 queryFn 中根據 categoryId 來決定是「獲取所有已發布的文章」還是「按分類獲取文章」。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 專門用於按分類獲取文章的 Hook
export const usePostsByCategoryQuery = (categoryId: number | null) => {
return useQuery({
queryKey: ["posts", "category", categoryId],
queryFn: () => {
if (categoryId === null) {
// 如果沒有選擇分類,返回所有已發布的文章
return fetchPosts({ status: "published" });
}
return fetchPostsByCategory(categoryId);
},
enabled: true, // 總是啟用
});
};

前端頁面

在前端頁面中,因為多了需要根據選擇的分類來獲取文章,所以需要一個狀態來存儲當前選擇的分類 ID,並根據這個 ID 來決定使用哪個 Hook 獲取文章數據

這邊也會修改到原本獲取所有文章的 usePostsQuery,讓它可以根據是否有選擇分類來決定是獲取所有已發布的文章還是按分類獲取文章。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { ..., usePostsByCategoryQuery } from "@/hooks/useBlogQuery";
...

const [selectedCategoryId, setSelectedCategoryId] = useState<number | null>(null);
const [otherFilters, setOtherFilters] = useState<Omit<PostFilters, "category">>({});

const { data: postsByCategory = [], isLoading: isLoadingByCategory } = usePostsByCategoryQuery(selectedCategoryId);
const { data: postsGeneral = [], isLoading: isLoadingGeneral, isError, error } = usePostsQuery(selectedCategoryId === null ? otherFilters : undefined);
const { data: categories = [] } = useCategoriesQuery();

// 如果沒有選擇分類,則顯示所有已發布的文章
const posts = selectedCategoryId !== null ? postsByCategory : postsGeneral;
const isLoading = selectedCategoryId !== null ? isLoadingByCategory : isLoadingGeneral;

const handleCategoryChange = (categoryId: number | null) => {
setSelectedCategoryId(categoryId);
};

const resetFilters = () => {
// 清除分類篩選
setSelectedCategoryId(null);
setOtherFilters({});
// setSearchTerm("");
};

前端頁面中就直接在按鈕 onClick 事件中調用 handleCategoryChange 函數來更新選擇的分類 ID,並根據這個 ID 來更新獲取文章列表

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
return (
...
<div ref={filterRef} className='mb-8'>
<div className='flex items-center mb-4'>
<Filter className='w-4 h-4 mr-2' />
<span>分類篩選</span>
</div>
<div className='flex flex-wrap gap-2'>
<Button variant={selectedCategoryId === null ? "default" : "outline"} onClick={() => handleCategoryChange(null)} size='sm' className='rounded-full'>
全部
</Button>
{categories.map((category) => (
<Button key={category.id} variant={selectedCategoryId === category.id ? "default" : "outline"} onClick={() => handleCategoryChange(category.id)} size='sm' className='rounded-full'>
{category.name}
</Button>
))}
</div>
{selectedCategoryId !== null && (
<Button variant='ghost' onClick={resetFilters} size='sm' className='mt-4'>
<RefreshCw className='w-4 h-4 mr-2' />
重設篩選
</Button>
)}
</div>
)

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


留言版