後端
- 需要一個
Review
的 endpoint 來處理產品評論的新增邏輯
- 需要客製化
Product
的 serializer
在產品的 JSON 格式中加入該產品的評論
先回顧一下 Review
的 model,每個評論都有一個 product
來指向該評論的產品,並有一個 user
來指向該評論的使用者,並有一個 rating
來表示評分,comment
來表示評論內容,createdAt
來表示評論的時間。
1 2 3 4 5 6 7 8 9 10
| class Review(models.Model): product = models.ForeignKey(Product,on_delete=models.CASCADE,null=True) user = models.ForeignKey(User,on_delete=models.SET_NULL,null=True) name = models.CharField(max_length=50,null=True,blank=True) rating = models.IntegerField(null=True, blank=True,default=0) comment = models.TextField(null=True, blank=True) createdAt = models.DateTimeField(auto_now_add=True)
def __str__(self) -> str: return str(self.rating)
|
views
這邊會有 3 種情況需要去撰寫程式邏輯:
- 評論已經存在,避免同一個使用者多次重複評論
- 評論沒有選擇星數(rating)或星數為 0
- 成功新增評論
成品大致會如下:

另外也要在新增評論後,重新計算該產品的評分,rating 的計算方式為 總(rating)評分數 / 評論數量
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
| @api_view(['POST']) def createProductReview(request, pk): user = request.user product = Product.objects.get(id=pk) data = request.data print("data:", data)
alreadyExists = product.review_set.filter(user=user).exists() if alreadyExists: content = {'detail': '您已經評論過該產品'} return Response(content, status=400) elif data['rating'] == 0: content = {'detail': '請選擇評分'} return Response(content, status=400) else: review = Review.objects.create( user=user, product=product, name=user.first_name, rating=data['rating'], comment=data['comment'], ) reviews = product.review_set.all() product.numReviews = len(reviews) total = 0 for i in reviews: total += i.rating product.rating = total / len(reviews) product.save() return Response('評論發表成功!')
|
urls
1
| path('<str:pk>/reviews/', views.createProductReview, name="create-review"),
|
Postman 測試
基本上直接對 api/products/1/reviews/
請求會看到以下錯誤,因為沒有帶 rating
及 comment
的值:

所以在 Postman -> Body -> raw
這設定 rating
的值:
1 2 3 4
| { "rating": 3, "comment": "超讚的產品!!!" }
|

然後就能看到後端回傳新增評論成功的 JSON 訊息:

此時到 Django 後台就可以看到新增的評論:

所以接下來目標就是要將這個評論顯示在產品頁面上,
目前 api/products/1
的回傳:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| { "id": 1, "name": "Airpods Wireless Bluetooth Headphones", "image": "/images/airpods.jpg", "brand": "Apple", "category": "Electronics", "description": "超酷藍芽耳機,價格不親民,請三思。", "rating": "3.00", "numReviews": 1, "price": 7790, "countInStock": 6, "createdAt": "2023-01-10T09:28:44.460000Z", "user": 1 }
|
所以必需要建立一個 serializer
來取得該產品的評論,這個 serializer
會用到 Review
的 serializer
,所以先建立一個 ReviewSerializer
:
serializers
- 先寫一個
ReviewSerializer
的 class 來將取得 Review
的所有欄位序列化為 JSON 格式
get_reviews
命名是 DRF SerializerMethodField 的設計規範,get_
後面接欄位名稱,這樣 SerializerMethodField
才能正確的將這個欄位序列化為 JSON 格式
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| from .models import Product,..., Review
class ReviewSerializer(serializers.ModelSerializer): class Meta: model = Review fields = '__all__'
class ProductSerializer(serializers.ModelSerializer): reviews = serializers.SerializerMethodField(read_only=True) class Meta: model = Product fields = '__all__'
def get_reviews(self, obj): reviews = obj.review_set.all() serializer = ReviewSerializer(reviews, many=True) return serializer.data
|
順便複習一下 SerializerMethodField
的用法及使用場景:SerializerMethodField
可以在不用真的去動到資料庫的情況下,動態生成暫時性的欄位,如果不用 SerializerMethodField
,就必須要在 Product
的 serializer
中加入 reviews = ReviewSerializer(many=True)
:
1 2 3 4 5 6
| class ProductSerializer(serializers.ModelSerializer): reviews = ReviewSerializer(many=True, read_only=True)
class Meta: model = Product fields = '__all__'
|
這樣就會在取得 Product
的時候,一併取得該產品的評論,會增加資料庫的查詢次數,所以使用 SerializerMethodField
來動態生成暫時性的欄位,可以減少資料庫的查詢次數,增加效能。
在 ProductSerializer
新增完 reviews
後的 api/products/1
回傳:
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
| { "id": 1, "reviews": [ { "id": 1, "name": "管理員1號", "rating": 3, "comment": "超讚的產品!!!", "createdAt": "2025-01-24T08:05:28.281674Z", "product": 1, "user": 1 } ], "name": "Airpods Wireless Bluetooth Headphones", "image": "/images/airpods.jpg", "brand": "Apple", "category": "Electronics", "description": "超酷藍芽耳機,價格不親民,請三思。", "rating": "3.00", "numReviews": 1, "price": 7790, "countInStock": 6, "createdAt": "2023-01-10T09:28:44.460000Z", "user": 1 }
|
前端
constants(宣告常數)
1 2 3 4 5
| export const PRODUCT_CREATE_REVIEW_REQUEST = "PRODUCT_CREATE_REVIEW_REQUEST"; export const PRODUCT_CREATE_REVIEW_SUCCESS = "PRODUCT_CREATE_REVIEW_SUCCESS"; export const PRODUCT_CREATE_REVIEW_FAIL = "PRODUCT_CREATE_REVIEW_FAIL"; export const PRODUCT_CREATE_REVIEW_RESET = "PRODUCT_CREATE_REVIEW_RESET";
|
reducers(狀態管理)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| export const productCreateReviewReducer = (state = {}, action) => { switch (action.type) { case PRODUCT_CREATE_REVIEW_REQUEST: return { loading: true }; case PRODUCT_CREATE_REVIEW_SUCCESS: return { loading: false, success: true }; case PRODUCT_CREATE_REVIEW_FAIL: return { loading: false, error: action.payload }; case PRODUCT_CREATE_REVIEW_RESET: return {}; default: return state; } };
|
actions(發送請求)
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
|
export const createProductReview = (productId, review) => async (dispatch, getState) => { try { dispatch({ type: PRODUCT_CREATE_REVIEW_REQUEST, });
const { userLogin: { userInfo }, } = getState();
const config = { headers: { "Content-type": "application/json", Authorization: `Bearer ${userInfo.token}`, }, };
const { data } = await axios.post(`/api/products/${productId}/reviews/`, review, config);
dispatch({ type: PRODUCT_CREATE_REVIEW_SUCCESS, payload: data, }); } catch (error) { dispatch({ type: PRODUCT_CREATE_REVIEW_FAIL, payload: error.response && error.response.data.detail ? error.response.data.detail : error.message, }); } };
|
詳細產品前端頁面
- 要登入才能評論,否則就顯示登入連結,所以這邊一樣將
userInfo
從 state
中取出
- 用戶評論完畢後,重新刷新產品詳細資訊,所以在
useEffect
中的依賴陣列加入 successCreateReview
- 用戶評論完畢後,清空
rating
及 comment
的值,並且重置 PRODUCT_CREATE_REVIEW
的狀態
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| ... const [rating, setRating] = useState(1); const [comment, setComment] = useState("");
const { userInfo } = useSelector((state) => state.userLogin);
const productCreateReview = useSelector((state) => state.productCreateReview); const { loading: loadingCreateReview, error: errorCreateReview, success: successCreateReview } = productCreateReview;
useEffect(() => { if (successCreateReview) { setRating(1); setComment(""); dispatch({ type: PRODUCT_CREATE_REVIEW_RESET }); } dispatch(listProductDetail(id)); }, [dispatch, id, successCreateReview]);
... const submitReviewHandler = (e) => { e.preventDefault(); dispatch(createProductReview(product.id, { rating, comment })); };
|
評論表單部分
判斷若尚未有任何評論,就顯示沒有評論的訊息
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| <Row className='my-3'> <Col md={6}> <h2>商品評價</h2> <ListGroup> {product.reviews.length === 0 && <Message variant='info'>目前還沒有評論~</Message>} {product.reviews.map((review) => ( <ListGroup.Item key={review.id}> <div className='my-2 text-bold'>用戶名稱:{review.name}</div> <div className='my-2'> <Rate value={review.rating} color={`#f8e825`} /> </div> <div className='my-2'>{review.createdAt.substring(0, 10)}</div> <div className='my-2'>{review.comment}</div> </ListGroup.Item> ))} ... </ListGroup> </Col> </Row>
|
判斷若未登入,則顯示登入連結,否則顯示評論表單,未登入的情況應顯示:

- 避免重複送出評論,所以在送出評論的按鈕的
disabled
屬性加入 loadingCreateReview
的狀態判斷
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
| <Row className='my-3'> <Col md={6}> <h2>商品評價</h2> <ListGroup> ... <ListGroup.Item> <h2 className='my-3'>撰寫評論</h2> {loadingCreateReview && <Loader />} {errorCreateReview && <Message variant='danger'>{errorCreateReview}</Message>} {successCreateReview && <Message variant='success'>評論已送出!</Message>} {userInfo ? ( <Form onSubmit={submitReviewHandler}> <Form.Group controlId='rating' className='my-2'> <Form.Label>評價</Form.Label> <Form.Control as='select' value={rating} onChange={(e) => setRating(e.target.value)}> <option value=''>選擇評價</option> <option value='1'>1星 - 非常差</option> <option value='2'>2星 - 差</option> <option value='3'>3星 - 普通</option> <option value='4'>4星 - 好</option> <option value='5'>5星 - 超讚</option> </Form.Control> </Form.Group>
<Form.Group controlId='comment'> <Form.Label>評論</Form.Label> <Form.Control as='textarea' rows={5} value={comment} onChange={(e) => setComment(e.target.value)} placeholder='輸入您對此商品的評論...' /> </Form.Group>
<Button type='submit' disabled={loadingCreateReview} variant='primary' className='my-2'> 送出評論 </Button> </Form> ) : ( <Message variant='info'> 請先 <Link to='/login'>登入</Link> 再來撰寫評論 </Message> )} </ListGroup.Item> </ListGroup> </Col> </Row>
|
成品:

另外觀察到,當新增評論後,產品的星星數也隨之改變:

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