# 取得 Admin Token

由於在 user_views.py 中,使用 IsAdminUser 來限制只有 Admin 才能取得 User 資料,所以需要先登入 Admin 帳號,取得 Admin 使用者 Token,才能透過 Postman 取得 User 資料。

@api_view(['GET']) # 取所有 User
@permission_classes([IsAdminUser]) # 只有Admin才能 access
def getUsers(request):
    users = User.objects.all()
    serializer = UserSerializer(users,many=True)
    return Response(serializer.data)

若套用一般使用者的 Token 會出現以下錯誤:

no_permission

因此先登入 Admin 帳號,取得 Admin 使用者 Token,才能透過 Postman 取得所有 User 的資料。

admin_user_token

# 用戶列表前端實作

# Constants 定義

frontend/src/constants/userConstants.js 定義以下常數:

....
export const USER_LIST_REQUEST = "USER_LIST_REQUEST";
export const USER_LIST_SUCCESS = "USER_LIST_SUCCESS";
export const USER_LIST_FAIL = "USER_LIST_FAIL";
export const USER_LIST_RESET = "USER_LIST_RESET";

# Reducers 實作

這邊 state 會需要多一個 users 陣列來存放所有 User 資料,然後在 success 終究不在需要 success: true了,因為這邊只是取得 User 資料,不需要判斷是否成功更新。然後 userInfo 改成 user,一樣都是儲存 action 傳遞過來的 payload

export const userListReducer = (state = { users: [] }, action) => {
 switch (action.type) {
  case USER_LIST_REQUEST:
   return { loading: true };
  case USER_LIST_SUCCESS:
   return { loading: false, user: action.payload }; // success 是為了在 ProfilePage.js output 判斷是否成功更新
  case USER_LIST_FAIL:
   return { loading: false, error: action.payload };
  case USER_LIST_RESET: // 這邊是為了在 ProfilePage.js 中的 useEffect 中,當 user 更新成功後,清空 state
   return { users: [] };
  default:
   return state;
 }
};

一樣到 store.js 中加入 userListReducer

import { ...,userListReducer } from "./reducers/userReducers";

const store = configureStore({
  reducer: {
    // 儲存多個state的地方,到時會再各個component用useSelector指定要使用哪一個

    // 用戶相關
    ...
    userList: userListReducer,
  },
  preloadedState: initialState,
  middleware: middleware,
});

此時去網頁開啟 Redux DevTools,應能看到多了一個 userList 的 state:

redux_tool

# Actions 實作

這邊直接複製 updateUserProfile 的 actio 來修改,因為都是取得 User 資料,只是 endpoint 不同而已。

  • 將 put 改成 get
  • /api/users/profile/ 改成 /api/users/
  • 拿掉 localStorage 的更新,因為這邊只是取得 User 資料,不需要更新 localStorage
// userActions.js
// 更新使用者資料
export const listUsers = (user) => async (dispatch, getState) => {
 // getState 大多都是在需要訪問 store 的裡的數據以做一些操作時使用
 try {
  dispatch({
   type: USER_LIST_REQUEST,
  });

  const {
   userLogin: { userInfo },
  } = getState(); // 確保 User 是登入的狀態

  const config = {
   headers: {
    "Content-Type": "application/json",
    Authorization: `Bearer ${userInfo.token}`, // 需要有 token 才能取得資料,因為後端有設 permission_classes
   },
  };
  const { data } = await axios.get(
   `/api/users/`,
   user, // 這邊的 user 是從 ProfilePage.js 傳過來的
   config
  );

  dispatch({
   type: USER_LIST_SUCCESS,
   payload: data, // payload 是後端回傳的資料
  });
 } catch (error) {
  dispatch({
   type: USER_LIST_FAIL,
   payload: error.response && error.response.data.message ? error.response.data.message : error.message,
  });
 }
};

為了登出後清空 User 資料,所以在同檔案中的 logout 函式加上 USER_LIST_RESET

// userActions.js
export const logout = () => (dispatch) => {
 localStorage.removeItem("userInfo");
 dispatch({ type: USER_LOGOUT });
 dispatch({ type: USER_DETAILS_RESET }); // 登出後要清空 user Object,否則會造成登出後還是會顯示上一個使用者的資料
 dispatch({ type: ORDER_LIST_MY_RESET }); // 登出後清空訂單資料
 dispatch({ type: USER_LIST_RESET }); // 登出後清空訂單資料
};

# 前端頁面實作

建立一個新的名為 UserListPage.js 的 component,這邊會使用到 useEffect 來取得 User 資料,並且使用 useDispatch 來 dispatch action 來取得 User 資料。

    useEffect(() => {
        dispatch(listUsers());
    }, [dispatch]); // 當 dispatch有變動就觸發 useEffect
    ...
    return(
        ...
    )

回到 App.js 中註冊 UserListPage 的 Route,測試看頁面是否有成功顯示:

// App.js
import UserListPage from "./Pages/UserListPage";
...
<Routes>
 <Route path="/" element={<HomePage/>} />
 ....
 <Route path="/admin/userlist" element={<UserListPage/>} />
</Routes>4

# 非 Admin 使用者重新導向至登入頁面

判斷若非 Admin 使用者就直接不顯示這個下拉選項以及不能訪問該頁面 參考這篇 -> 管理員專屬後台頁面-路由結構最佳化寫法

# 製作顯示所有使用者資料的表格

user_table

# 在 Header 新增相應的下拉選項

這邊不直接使用 NavDropdown.Itemhref,而是使用 LinkContainer 來導向到 /admin/userlist,是因為要保有 React SPA 的優勢,不要重新整理頁面。

// Header.js
userInfo && userInfo.isAdmin && (
 <NavDropdown title='後台管理專用' id='admin-menu'>
  <LinkContainer to='/admin/userlist'>
   <NavDropdown.Item>用戶管理</NavDropdown.Item>
  </LinkContainer>
  <NavDropdown.Item>Another action</NavDropdown.Item>

  <NavDropdown.Item>Something</NavDropdown.Item>
  <NavDropdown.Divider />
  <NavDropdown.Item>Separated link</NavDropdown.Item>
 </NavDropdown>
);

# 刪除使用者資料實作

在前端簡單測試按鈕是否有成功觸發事件,並取得 User 的 id 後,就可以開始撰寫後端 API 來處理編輯、刪除使用者資料。

frontend_delete_user_test

const deleteUserHandler = (id) => {
 console.log("delete", id);
};
...
<Button variant='danger' className='btn-sm' onClick={() => deleteUserHandler(user.id)}>
 <i className='fas fa-trash'></i>
</Button>

# 後端 API 實作

一樣先從

  • user_views.py 中新增一個 deleteUser 的 API
  • urls.py 中新增一個 path('user/<str:pk>/', views.deleteUser, name='user-delete') 的路由

# views(刪除使用者視圖)

@api_view(['DELETE']) # 刪除 User
@permission_classes([IsAdminUser]) # 只有Admin才能 access
def deleteUser(request,pk):
    userForDeletion = User.objects.get(id=pk)
    userForDeletion.delete()
    return Response('用戶已成功刪除')

# urls(更新路由)

path('delete/<str:pk>/',views.deleteUser , name='user_delete'),

# 前端實作

# Constants 定義

export const USER_DELETE_REQUEST = "USER_DELETE_REQUEST";
export const USER_DELETE_SUCCESS = "USER_DELETE_SUCCESS";
export const USER_DELETE_FAIL = "USER_DELETE_FAIL";

# Reducers 實作

直接複製 userListReducer 函式來修改,這邊重點修改處:

  • state 初始值改為空物件,因為已不需要 users 陣列來存放 User 資料
  • USER_LIST_XXX 改為 USER_DELETE_XXX
  • 在 case USER_DELETE_SUCCESS 中,不再需要 users: action.payload
  • 去除 USER_LIST_RESET,因為這邊不需要清空 state
export const userDeleteReducer = (state = {}, action) => {
 switch (action.type) {
  case USER_DELETE_REQUEST:
   return { loading: true };
  case USER_DELETE_SUCCESS:
   return { loading: false, success: true };
  case USER_DELETE_FAIL:
   return { loading: false, error: action.payload };
  default:
   return state;
 }
};

store.jsconfigureStore 中加入 userDeleteReducer

const store = configureStore({
  reducer: {
    // 用戶相關
 ...
    userDelete: userDeleteReducer,
  },
  preloadedState: initialState,
  middleware: middleware,
});

註冊完畢後,在前端開啟 Redux DevTools,應能看到多了一個 userDelete 的 state,此時裡面應該空物件的狀態。

user_delete_state

# Actions 實作

一樣複製 listUsers 函式來修改,重點修改處:

  • 需要在 deleteUser 函式中傳入 id 來刪除指定 user
  • get 改為 delete
  • HTTP 請求路由改為 /api/users/delete/${id}/,這邊 ${id} 是從 UserListPage.js 傳過來的
// 刪除使用者資料
export const deleteUser = (id) => async (dispatch, getState) => {
 // getState 大多都是在需要訪問 store 的裡的數據以做一些操作時使用
 try {
  dispatch({
   type: USER_DELETE_REQUEST,
  });

  const {
   userLogin: { userInfo },
  } = getState(); // 確保 User 是登入的狀態

  const config = {
   headers: {
    "Content-Type": "application/json",
    Authorization: `Bearer ${userInfo.token}`, // 需要有 token 才能取得資料,因為後端有設 permission_classes
   },
  };
  const { data } = await axios.delete(`/api/users/delete/${id}/`, config);

  dispatch({
   type: USER_DELETE_SUCCESS,
   payload: data, // payload 是後端回傳的資料
  });
 } catch (error) {
  dispatch({
   type: USER_DELETE_FAIL,
   payload: error.response && error.response.data.message ? error.response.data.message : error.message,
  });
 }
};

# 前端頁面中的按鈕實現刪除使用者資料功能

引入先前寫好的 deleteUser action,並從 Redux store 中讀取 userDelete 狀態,然後解構 successsuccessDelete(提取刪除操作是否成功的標誌,並重命名為 successDelete),最後再透過 useEffect 來監聽刪除結果,若成功刪除使用者後,自動重新載入頁面。

  • listUsers: 用來取得所有使用者的列表,並更新到 Redux store 中。
  • deleteUser: 用來發送刪除使用者的請求,並更新刪除的結果到 Redux store。
import { listUsers,deleteUser } from "../actions/userActions";
...

const userDelete = useSelector((state) => state.userDelete);
const { success: successDelete } = userDelete;

useEffect(() => {
 dispatch(listUsers());

}, [dispatch,successDelete]); // 這邊放入 successDelete,因為要在刪除使用者後自動重新載入頁面

const deleteUserHandler = (id) => {
 // console.log("delete",id);
 if(window.confirm("確定要刪除此使用者嗎?")){
  dispatch(deleteUser(id));
 }
}

這一段 useEffect 的白話文解析: 每次 successDelete 發生變化時(即刪除操作成功後),重新觸發 listUsers,從後端更新用戶列表。

# 觀念補充

至於為何不直接如以下這樣調用 userDelete.success,還需要解構 userDelete,再取出 success,再重新命名為 successDelete

const userDelete = useSelector((state) => state.userDelete);
// const { success: successDelete } = userDelete;
useEffect(() => {
 dispatch(listUsers());
}, [dispatch, userDelete.success]);

是為了防止不必要的多次觸發,也是培養一個良好的寫程式習慣,一般在 useEffect 中,盡量不直接與 Redux store 中的整個 userDelete 物件進行比較,而是只比較 success 屬性,這樣可以避免不必要的重新渲染。

例如以上情況就是,只有當 userDelete.success 的值改變(例如從 false 變為 true)時,successDelete 才會更新,進而觸發 useEffect。

# 編輯使用者資料實作

先前只有做取得用戶個人資訊的功能,現在需要取得「所有用戶」個別的資訊,所以需先取得各個不同使用者的資料才能去進行編輯、更新,所以會需創建兩個後端的 Route,

# 後端 API 實作

# views

與前面不同的是這邊的 user 都從 單單 request.user 改為 User.objects.get(id=pk),這樣才能取得特定 User 的資料,因為不再是取得登入用戶自己的資料,而是要取得指定 User 的資料。

@api_view(['GET']) # 後台用戶管理,取得單個用戶
@permission_classes([IsAdminUser]) # 只有Admin才能 access
def adminGetUserById(request,pk):
    user = User.objects.get(id=pk) # 根據提供的 id 手動查詢特定用戶資料。
    serializer = UserSerializer(user,many=False)
    return Response(serializer.data)
  • 這邊要用 user.is_staff 來判斷是否為 Admin,而不是 user.isAdminis_staff 才是實際在資料庫中的使用的欄位名稱。
# serializers.py
class UserSerializer(serializers.ModelSerializer):
    first_name = serializers.SerializerMethodField(
        read_only=True)  # 這個欄位不會存到資料庫,只是用來顯示
    isAdmin = serializers.SerializerMethodField(read_only=True)

    class Meta:
        model = User
        fields = ['id', 'isAdmin', 'first_name', 'last_name', 'username', 'email']

    def get_isAdmin(self, obj):  # obj 就是user
        return obj.is_staff  # django 預設欄位
...
@api_view(['PUT']) # 更新用戶的 profile
@permission_classes([IsAuthenticated]) # 只有登入的人才能 access
def adminUpdateUser(request,pk):
    user = User.objects.get(id=pk)
    serializer = UserSerializer(user,many=False)

    data = request.data

    user.first_name = data['first_name']
    user.username = data['email']
    user.email = data['email']
    # user.isAdmin = data['isAdmin']
    user.is_staff = data['isAdmin']

    user.save()
    return Response(serializer.data)

# 更新 urls

    # Admin
    path('<str:pk>/', views.adminGetUserById, name='user_get_by_id'),
    path('update/<str:pk>/', views.adminUpdateUser, name='user_update'),

寫完後去 Postman 測試看看是否有成功取得特定 User 的資料,一樣應要代入 Admin Token 才能取得 User 資料。

postman_get_user

# 前端實作

# 編輯使用者頁面

建立一個名為 UserEditPage.js 的頁面,功能部分類似於註冊頁面

  • 透過 useParams 取得 URL 中用戶的 id,記得在App.js中若是定義:userID,那用 useParams 取時也一樣要用
    userID,而不是 id
// App.js
...
<Route path='/admin/user/:userID/edit' element={<AdminRoute><UserEditPage /></AdminRoute>} />
  • 透過 useDispatch 來 dispatch getUserDetails action 來取得特定 User 的資料
  • 透過 useSelector 來取得 userDetails 的狀態,並解構出 loadingerroruser 三個屬性
// UserEditPage.js
const UserEditPage = () => {
 const { userID } = useParams(); // React v6以後要用這種方式來取得id
};
...
const userDetails = useSelector((state) => state.userDetails);
const { loading, error, user } = userDetails;

const submitHandler = (e) => {
 e.preventDefault();
};

如果沒用 NumberuserID轉換成數字,user.id !== userID 就會始終為 true,dispatch(getUserDetails(userID)) 就會不停被觸發,Redux 狀態一直被重置為 loading: true,從而導致頁面不斷進入加載狀態。

這邊目的要能判斷當前用戶是否存在,並且用戶 id 是否等於當前用戶 id,如果是,就自動載入用戶資料,否則就發送取得用戶資料的請求。

useEffect(() => {
 if (!user.first_name || user.id !== Number(userID)) {
  dispatch(getUserDetails(userID)); // 如果用戶不存在或者用戶id不等於當前用戶id,就發送請求
 } else {
  // 如果用戶存在,就自動載入用戶資料
  setFirst_name(user.first_name);
  setEmail(user.email);
  setIsAdmin(user.isAdmin);
 }
}, [dispatch, user, userID]);

return (
 <>
  <Link to='/admin/userlist' className='btn btn-light my-3'>
   回到用戶列表
  </Link>

  <h1>編輯用戶資料</h1>
  {loading ? (
   <Loader />
  ) : error ? (
   <Message variant='danger'>{error}</Message>
  ) : (
   <FormContainer>
    <Form onSubmit={submitHandler}>
     <Form.Group controlId='first_name' className='mb-3'>
      <Form.Label>用戶姓名</Form.Label>
      <Form.Control required type='name' placeholder='請輸入您的ID' value={first_name} onChange={(e) => setFirst_name(e.target.value)}></Form.Control>
     </Form.Group>

     <Form.Group controlId='email' className='mb-3'>
      <Form.Label>電子郵件</Form.Label>
      <Form.Control required type='email' placeholder='請輸入電子郵件' value={email} onChange={(e) => setEmail(e.target.value)} autoComplete='username'></Form.Control>
     </Form.Group>

     <Form.Group controlId='isAdmin'>
      <Form.Check type='checkbox' label='是否為管理員' checked={isAdmin} onChange={(e) => setIsAdmin(e.target.checked)}></Form.Check>
     </Form.Group>

     <p>{isAdmin ? "User is an Admin" : "User is not an Admin"}</p>

     <Button type='submit' variant='primary' className='mt-3'>
      更新
     </Button>
    </Form>
   </FormContainer>
  )}
 </>
);

# 解決從編輯用戶資料跳至個人資料頁面,資料顯示不正確的問題

目前若從用戶編輯頁面

edit_user_page

跳至用戶個人資料頁面,會發現資料載入不正確,必須要重新整理頁面才能正確載入。

profile_page

那這是由於在 useEffect 中,當 user.id !== Number(userID) 時,才會發送 getUserDetails(userID) 請求,但是當從編輯頁面跳至個人資料頁面時,user.id 並不會改變,所以就不會發送請求,這就是為什麼會出現資料不正確的問題。

解決方是就是回到個人資料頁面 ProfilePage.js 時,多檢查 userInfo.id 若不等於 user.id 就再發送一次getUserDetails(userInfo.id) 請求,重新獲取正確的用戶個人資料。

// ProfilePage.js
useEffect(() => {
...
 if (!user || !user.first_name || success || userInfo.id !== user.id) {
  ...
 }
...
}, [dispatch, userInfo, user, success]);

# Constants 定義

// userConstants.js
export const USER_UPDATE_REQUEST = "USER_UPDATE_REQUEST";
export const USER_UPDATE_SUCCESS = "USER_UPDATE_SUCCESS";
export const USER_UPDATE_FAIL = "USER_UPDATE_FAIL";

# Reducers 實作

// userReducers.js
export const userUpdateReducer = (state = { user: [] }, action) => {
 switch (action.type) {
  case USER_UPDATE_REQUEST:
   return { loading: true };
  case USER_UPDATE_SUCCESS:
   return { loading: false, success: true };
  case USER_UPDATE_FAIL:
   return { loading: false, error: action.payload };
  case USER_UPDATE_RESET:
   return { user: [] }; // 重置 user 狀態,清空舊資料,避免影響下一次操作或導致錯誤的資料顯示
  default:
   return state;
 }
};

然後一樣至 store.jsconfigureStore 中加入 userUpdateReducer,並回瀏覽器的 Redux DevTools 中檢查是否有 userUpdate 的 state。

# Actions 實作

「更新操作的重點在於需要修改的資料,而不是指定哪個用戶」

  • updateUser 函式改傳入 user 而不是 id,因為更新需要多個欄位(例如名字、電子郵件、角色、狀態等),所以傳入整個 user 物件
  • axios 使用的是 put 方法,並帶入 user 物件,路由所帶入的 iduser.id
  • 不會在 UPDATE_SUCCESS 中帶入 payload:data,而是在更新成功用戶資料後,再次發送 USER_DETAILS_SUCCESS 來重新獲取用戶資料
export const updateUser = (user) => async (dispatch, getState) => {
 // getState 大多都是在需要訪問 store 的裡的數據以做一些操作時使用
 try {
  dispatch({
   type: USER_UPDATE_REQUEST,
  });

  const {
   userLogin: { userInfo },
  } = getState(); // 確保 User 是登入的狀態

  const config = {
   headers: {
    "Content-Type": "application/json",
    Authorization: `Bearer ${userInfo.token}`, // 需要有 token 才能取得資料,因為後端有設 permission_classes
   },
  };
  const { data } = await axios.put(`/api/users/update/${user.id}/`, user, config);

  dispatch({
   type: USER_UPDATE_SUCCESS,
  });

  dispatch({
   // 更新成功後自動重新獲取用戶列表
   type: USER_DETAILS_SUCCESS,
   payload: data,
  });
 } catch (error) {
  dispatch({
   type: USER_UPDATE_FAIL,
   payload: error.response && error.response.data.message ? error.response.data.message : error.message,
  });
 }
};

# 前端頁面功能實作

回到用戶編輯頁面

  • import 在 userActions.js 中沒用到的 USER_UPDATE_RESET 常數,負責在更新成功後清空 user 狀態,避免影響下一次操作或導致錯誤的資料顯示
  • 更新完成後,自動導向到用戶列表頁面
  • 顯示更新操作的 loading 狀態,以及錯誤信息
  • 透過 useEffect 依賴陣列監聽 successUpdate 狀態,若更新成功,則清空 user 狀態,並自動導向到用戶列表頁面
// UserEditPage.js
import { ..., updateUser } from "../actions/userActions"; // 自動載入用戶資料
import { USER_UPDATE_RESET } from "../constants/userConstants";

const userUpdate = useSelector((state) => state.userUpdate);
// console.log("Redux state userDetails:", userDetails);
const { error: errorUpdate, loading: loadingUpdate, suecces: successUpdate } = userUpdate;

const submitHandler = (e) => {
 e.preventDefault();
 dispatch(updateUser({ id: userID, first_name, email, isAdmin }));
};

useEffect(() => {
 if (successUpdate) {
  dispatch({ type: USER_UPDATE_RESET });
  navigate("/admin/userlist");
 } else {
  if (!user.first_name || user.id !== Number(userID)) {
   dispatch(getUserDetails(userID)); // 如果用戶不存在或者用戶id不等於當前用戶id,就發送請求
  } else {
   // 如果用戶存在,就自動載入用戶資料
   setFirst_name(user.first_name);
   setEmail(user.email);
   setIsAdmin(user.isAdmin);
  }
 }
}, [dispatch, user, userID, successUpdate, navigate]);

return(
 ...
 <h1>編輯用戶資料</h1>
 {loadingUpdate && <Loader />}
 {errorUpdate && <Message variant='danger'>{errorUpdate}</Message>}
)

此時回到網頁,進行用戶資料的編輯,應能正常更新用戶資料,並自動導向到用戶列表頁面:

update_success