# 後端

  • 需要一個 Review 的 endpoint 來處理產品評論的新增邏輯
  • 需要客製化 Productserializer 在產品的 JSON 格式中加入該產品的評論

先回顧一下 Review 的 model,每個評論都有一個 product 來指向該評論的產品,並有一個 user 來指向該評論的使用者,並有一個 rating 來表示評分,comment 來表示評論內容,createdAt 來表示評論的時間。

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:  # 讓後台能顯示產品名稱,否則只會顯示 Product Object(1)
        return str(self.rating)

# views

這邊會有 3 種情況需要去撰寫程式邏輯:

  1. 評論已經存在,避免同一個使用者多次重複評論
  2. 評論沒有選擇星數(rating)或星數為 0
  3. 成功新增評論

成品大致會如下:

3_scenario

另外也要在新增評論後,重新計算該產品的評分,rating 的計算方式為 總(rating)評分數 / 評論數量

# 用戶發表產品評論
@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)
    # 檢查評論的 Rating 是否存在或為 0
    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

path('<str:pk>/reviews/', views.createProductReview, name="create-review"),

# Postman 測試

基本上直接對 api/products/1/reviews/ 請求會看到以下錯誤,因為沒有帶 ratingcomment 的值:

keyError_rating

所以在 Postman -> Body -> raw 這設定 rating 的值:

{
 "rating": 3,
 "comment": "超讚的產品!!!"
}

postman_test

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

send_success

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

admin_review

所以接下來目標就是要將這個評論顯示在產品頁面上,

目前 api/products/1 的回傳:

{
 "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 會用到 Reviewserializer,所以先建立一個 ReviewSerializer

# serializers

  • 先寫一個 ReviewSerializer 的 class 來將取得 Review 的所有欄位序列化為 JSON 格式
  • get_reviews 命名是 DRF SerializerMethodField 的設計規範,get_ 後面接欄位名稱,這樣 SerializerMethodField 才能正確的將這個欄位序列化為 JSON 格式
# serializers.py
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,就必須要在 Productserializer 中加入 reviews = ReviewSerializer(many=True)

class ProductSerializer(serializers.ModelSerializer):
    reviews = ReviewSerializer(many=True, read_only=True)  # 直接指定關聯序列化器

    class Meta:
        model = Product
        fields = '__all__'

這樣就會在取得 Product 的時候,一併取得該產品的評論,會增加資料庫的查詢次數,所以使用 SerializerMethodField 來動態生成暫時性的欄位,可以減少資料庫的查詢次數,增加效能。

ProductSerializer 新增完 reviews後的 api/products/1 回傳:

{
    "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(宣告常數)

// constants/productConstants.js
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(狀態管理)

// reducers/productReducers.js
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(發送請求)

// actions/productActions.js
// 用戶新增產品評論
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,
  });
 }
};

# 詳細產品前端頁面

  • 要登入才能評論,否則就顯示登入連結,所以這邊一樣將 userInfostate 中取出
  • 用戶評論完畢後,重新刷新產品詳細資訊,所以在 useEffect 中的依賴陣列加入 successCreateReview
  • 用戶評論完畢後,清空 ratingcomment 的值,並且重置 PRODUCT_CREATE_REVIEW 的狀態
...
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 }));
};

# 評論表單部分

判斷若尚未有任何評論,就顯示沒有評論的訊息

<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>

判斷若未登入,則顯示登入連結,否則顯示評論表單,未登入的情況應顯示:

login_first

  • 避免重複送出評論,所以在送出評論的按鈕的 disabled 屬性加入 loadingCreateReview 的狀態判斷
<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>

成品:

done_comment_section

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

review_changed