產品管理列表前端頁面
建立一個新的頁面,用來管理產品,包含新增、編輯、刪除、查看產品的功能
- import 先前寫好的
listProduct
的 Action
- 調用
ProductList
的 state,取得所有產品
- 用
map
迭代遍歷 products
陣列,並顯於產品列表上加上新增、刪除按鈕
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
| import { listProducts } from "../actions/productActions"; ... const ProductList = useSelector((state) => state.ProductList); const { loading, error, products } = ProductList; ... return( ... <Row className='align-items-center'> <Col className='text-right'> <Button className='my-3' variant="success" onClick={createProductHandler}> <i className='fas fa-plus'></i> 新增產品 </Button> </Col> <Col className='text-right'> <Button className='my-3' variant="danger" onClick={deleteProductHandler}> <i className='fas fa-plus'></i> 刪除產品 </Button> </Col> </Row> ... <Table striped hover responsive className='table-sm table-light'> ... {products.map((product) => { ... })} </Table> )
|
後端實作 CRUD API - 刪除
一樣先建立對應的 views
實作功能,再建立對應的 urls
給 前端的 axios
調用
views
1 2 3 4 5 6 7
| @api_view(['DELETE']) @permission_classes([IsAdminUser]) def deleteProduct(request,id): product = Product.objects.get(id=id) product.delete() return Response('產品刪除成功!')
|
urls
1 2 3
| ... path('delete/<str:id>', views.deleteProduct,name="deleteProduct"),
|
前端實作刪除功能
constants
1 2 3
| export const PRODUCT_DELETE_REQUEST = "PRODUCT_DELETE_REQUEST"; export const PRODUCT_DELETE_SUCCESS = "PRODUCT_DELETE_SUCCESS"; export const PRODUCT_DELETE_FAIL = "PRODUCT_DELETE_FAIL";
|
reducers
所有刪除相關動作就不需要回傳 product: action.payload
了,只需回傳 loading
和 success
狀態即可
1 2 3 4 5 6 7 8 9 10 11 12 13
| export const ProductDeleteReducer = (state = {}, action) => { switch (action.type) { case PRODUCT_DELETE_REQUEST: return { loading: true }; case PRODUCT_DELETE_SUCCESS: return { loading: false, success: true }; case PRODUCT_DELETE_FAIL: return { loading: false, error: action.payload }; default: return state; } };
|
actions
由於刪除產品需要 admin
權限,所以要在 actions
中加入 token
來驗證
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 deleteProduct = (id) => async (dispatch, getState) => {
try { dispatch({ type: PRODUCT_DELETE_REQUEST, });
const { userLogin: { userInfo }, } = getState();
const config = { headers: { "Content-type": "application/json", Authorization: `Bearer ${userInfo.token}`, }, };
const { data } = await axios.delete(`/api/products/delete/${id}`, config);
dispatch({ type: PRODUCT_DELETE_SUCCESS, payload: data, }); } catch (error) { dispatch({ type: PRODUCT_DELETE_FAIL, payload: error.response && error.response.data.detail ? error.response.data.detail : error.message, }); } };
|
產品管理清單頁面(刪除按鈕)前端實作
每當刪除完產品時,應自動重新載入產品列表,所以這邊將 useEffect
加入 successDelete
來監聽是否刪除成功,若成功就「重新載入產品列表」。
1 2 3 4 5 6
| const productDelete = useSelector((state) => state.productDelete); const { loading: loadingDelete, error: errorDelete, success: successDelete } = productDelete;
useEffect(() => { dispatch(listProducts()); }, [dispatch, successDelete]);
|
後端實作 CRUD API - 新增/更新
此專案新增產品的邏輯與一般網站較不同的是,與其導向到一個新的 Form 頁面,再逐一填寫產品資料,這邊直接創建一個有預設資料的產品,並導向至該產品的「編輯頁面」,讓使用者直接在編輯頁面進行產品資料的編輯,可以達到更簡潔、高效的操作流程,所以這邊會同時製作 CREATE、UPDATE 功能
也就是說新增以及編輯產品會導向同一個頁面
views
在createProduct
的 view 部分,有個很愚蠢的邏輯謬誤讓我腦袋卡了一下,在創建產品時壓根不會有 data['name']、data['price]...
這些資料,因為根本還「不存在」!
導致我原本寫 data['name']
,按下創建商品一直報 500 ERROR
,後來才發現本來就應該先預設 sample
值帶入 product
,這樣才能有資料可以編輯
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(['PUT']) @permission_classes([IsAdminUser]) def updateProduct(request, pk): data = request.data product = Product.objects.get(id=pk) product.name = data['name'] product.price = data['price'] product.brand = data['brand'] product.countInStock = data['countInStock'] product.category = data['category'] product.description = data['description']
product.save() serializer = ProductSerializer(product, many=False) return Response(serializer.data)
@api_view(['POST']) @permission_classes([IsAdminUser]) def createProduct(request): user = request.user product = Product.objects.create( user=user, name='Sample Name', price=0, brand='Sample Brand', countInStock=0, category='Sample Category', description='Sample Description', )
serializer = ProductSerializer(product, many=False) return Response(serializer.data)
|
再創建商品時要有預設圖片的路徑,解決產品一開始沒有圖片的問題,這邊就把產品預設圖放到 /backend/static/images/
資料夾下,並在 models.py
中設定該 image
預設路徑
1 2 3 4
| class Product(models.Model): ... image = models.ImageField(null=True,blank=True,default='/placeholder.png')
|
這樣就可以每次在創建商品時,都能先有一張預設圖片

以及查看 Postman
的回傳結果也能看到預設的產品圖片資料 "image": "/images/placeholder.png",

urls
如果將 path('create/...
放在 path('<str:id>', views.getProduct_detail
之下,可能會發生「路由衝突」問題
比如當使用者訪問 http://127.0.0.1:8000/api/products/create/
時, id=create
會被傳遞到 getProduct_detail
中做處理,此時就會發生找不到 id=create
的產品的錯誤,所以要將 create
放在 getProduct_detail
之上
1 2 3 4 5 6
| ... path('create/', views.createProduct,name="product_create"), path('<str:pk>', views.getProduct_detail,name="product_detail"), ... path('update/<str:pk>', views.updateProduct,name="product_update"),
|
當按下新增產品的按鈕,就會馬上觸發 createProduct
這個 Action,並將使用者導向到編輯產品的頁面,所以其實當我們再創建產品時,同時就是在 「編輯產品」,只是這次是新增一個全新的產品而已
前端實作新增/更新產品功能
constants
1 2 3 4 5 6 7 8
| export const PRODUCT_CREATE_REQUEST = "PRODUCT_CREATE_REQUEST"; export const PRODUCT_CREATE_SUCCESS = "PRODUCT_CREATE_SUCCESS"; export const PRODUCT_CREATE_FAIL = "PRODUCT_CREATE_FAIL";
export const PRODUCT_UPDATE_REQUEST = "PRODUCT_UPDATE_REQUEST"; export const PRODUCT_UPDATE_SUCCESS = "PRODUCT_UPDATE_SUCCESS"; export const PRODUCT_UPDATE_FAIL = "PRODUCT_UPDATE_FAIL"; export const PRODUCT_UPDATE_RESET = "PRODUCT_UPDATE_RESET";
|
reducers
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
| export const productCreateReducer = (state = {}, action) => { switch (action.type) { case PRODUCT_CREATE_REQUEST: return { loading: true }; case PRODUCT_CREATE_SUCCESS: return { loading: false, success: true, product: action.payload }; case PRODUCT_CREATE_FAIL: return { loading: false, error: action.payload }; case PRODUCT_CREATE_RESET: return {}; default: return state; } };
export const productUpdateReducer = (state = { product: [] }, action) => { switch (action.type) { case PRODUCT_UPDATE_REQUEST: return { loading: true }; case PRODUCT_UPDATE_SUCCESS: return { loading: false, success: true, product: action.payload }; case PRODUCT_UPDATE_FAIL: return { loading: false, error: action.payload }; case PRODUCT_UPDATE_RESET: return { product: [] }; default: return state; } };
|
actions
在 axios.post
的部分多傳遞了一個空物件 {}
,確保其符合後端格式要求,若沒有加入 {}
,目前其實也能正確運作,但為了確保程式的穩定性,還是加上比較好
在 updateProduct
的 action 中,在更新完產品資訊後,一樣要能夠自動載入
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
| export const createProduct = () => async (dispatch, getState) => { try { dispatch({ type: PRODUCT_CREATE_REQUEST, });
const { userLogin: { userInfo }, } = getState();
const config = { headers: { "Content-type": "application/json", Authorization: `Bearer ${userInfo.token}`, }, }; const { data } = await axios.post( `/api/products/create/`, {}, config );
dispatch({ type: PRODUCT_CREATE_SUCCESS, payload: data, }); } catch (error) { dispatch({ type: PRODUCT_CREATE_FAIL, payload: error.response && error.response.data.detail ? error.response.data.detail : error.message, }); } };
|
產品管理清單頁面(新增/編輯按鈕)前端實作
- 在
useEffect
開頭直接先 dispatch PRODUCT_CREATE_RESET
;,清空之前的產品創建狀態,避免頁面載入時因殘留的 Redux 狀態導致誤判或錯誤行為
- 在
useEffect
中加入 successCreate
,當成功創建產品時,導向到該產品的編輯頁面
- 從後端
createProduct
view 返回序列化後的產品資料,透過 productActions
中的 createProduct
的 Axios
POST 請求,來請求新增產品,並在 productCreateReducer
的 PRODUCT_CREATE_SUCCESS
將後端回傳的產品資料儲存到 state.product
中,這就是 navigate
的 createProduct.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 26 27 28 29 30 31 32 33
| ... const navigate = useNavigate(); const dispatch = useDispatch();
const productList = useSelector((state) => state.productList); const { loading, error, products } = productList;
const productDelete = useSelector((state) => state.productDelete); const { loading: loadingDelete, error: errorDelete, success: successDelete } = productDelete;
const productCreate = useSelector((state) => state.productCreate); const { loading: loadingCreate, error: errorCreate, success: successCreate, product: createdProduct } = productCreate;
useEffect(() => { dispatch({ type: PRODUCT_CREATE_RESET }); if (successCreate) { navigate(`/admin/product/${createdProduct.id}/edit`); } else { dispatch(listProducts()); } }, [dispatch, navigate, successDelete, successCreate, createdProduct]);
const createProductHandler = () => { dispatch(createProduct()); };
const deleteProductHandler = (id) => { if (window.confirm("確定要刪除此產品嗎?")) { dispatch(deleteProduct(id)); } };
|
目前進度應是按下新增產品後,能夠成功創建一個新的產品,但由於編輯頁面還沒製作所以會導向至先前寫好的 404 頁面
產品(新增/編輯頁面)頁面前端實作
直接複製用戶編輯頁面 UserEditPage.js
來進行微調
- 稍後會來修改可直接上傳更新產品圖片的功能,而不是現在只有輸入圖片路徑的功能
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 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88
| const ProductEditPage = () => { const navigate = useNavigate(); const { productID } = useParams();
const [name, setName] = useState(""); const [price, setPrice] = useState(0); const [image, setImage] = useState(""); const [brand, setBrand] = useState(""); const [category, setCategory] = useState(""); const [countInStock, setCountInStock] = useState(0); const [description, setDescription] = useState("");
const dispatch = useDispatch();
const productDetail = useSelector((state) => state.productDetail); const { loading, error, product } = productDetail;
const productUpdate = useSelector((state) => state.productUpdate); const { loading: loadingUpdate, error: errorUpdate, success: successUpdate } = productUpdate;
const submitHandler = (e) => { e.preventDefault(); dispatch( updateProduct({ id: productID, name, price, image, brand, category, countInStock, description, }) ); }; useEffect(() => { if (successUpdate) { dispatch({ type: PRODUCT_UPDATE_RESET }); navigate("/admin/productlist"); } else { if (!product || !product.name || product.id !== Number(productID)) { dispatch(listProductDetail(productID)); } else { setName(product.name); setPrice(product.price); setImage(product.image); setBrand(product.brand); setCategory(product.category); setCountInStock(product.countInStock); setDescription(product.description); } } }, [dispatch, product, productID, navigate, successUpdate]);
return ( <> <Link to='/admin/productlist' className='btn btn-light my-3'> 回到產品列表 </Link>
<h1>編輯產品資料</h1>
{loading ? ( <Loader /> ) : error ? ( <Message variant='danger'>{error}</Message> ) : ( <FormContainer> <Form onSubmit={submitHandler}> <Form.Group controlId='name' className='mb-3'> <Form.Label>產品名稱</Form.Label> <Form.Control required type='name' placeholder='請輸入您的ID' value={name} onChange={(e) => setName(e.target.value)}></Form.Control> </Form.Group> ... <Form.Group controlId='image' className='mb-3'> <Form.Label>產品圖片</Form.Label> <Form.Control required type='text' placeholder='請輸入產品圖片' value={image} onChange={(e) => setImage(e.target.value)}></Form.Control> </Form.Group> <Button type='submit' variant='primary' className='mt-3'> 更新產品資料 </Button> </Form> </FormContainer> )} </> ); };
|
至 App.js
中添加此路由,記得要將 :productID
與 ProductEditPage
的 const { productID } = useParams();
對應,否則會找不到 ID 造成 product 變 undefined
1 2 3
| <Route path='/admin/productlist' element={<AdminRoute><ProductListPage /></AdminRoute>} /> <Route path='/admin/product/:productID/edit' element={<AdminRoute><ProductEditPage /></AdminRoute>} />
|
最後當按下更新產品按鈕時,應會觸發 submitHandler
函式,並將更新後的產品資料透過 dispatch(updateProduct({id, name, price, image, brand, category, countInStock, description}))
這個 action 傳遞給後端的 updateProduct
view 進行更新,然後執行 navigate("/admin/productlist");
導回產品列表頁面

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