+
+ {name}
-
-
-
+
+
+ {team} · {position}
+
-
- );
- }
-}
+
+
+
+
+
+
+ );
+};
PlayerListItem.propTypes = {
id: PropTypes.number.isRequired,
@@ -47,7 +61,9 @@ PlayerListItem.propTypes = {
team: PropTypes.string.isRequired,
position: PropTypes.string.isRequired,
starred: PropTypes.bool,
- starPlayer: PropTypes.func.isRequired,
+ handleDeletePlayer: PropTypes.func.isRequired,
+ handleStarPlayer: PropTypes.func.isRequired,
+ successCallback: PropTypes.func.isRequired,
};
export default PlayerListItem;
diff --git a/src/components/PlayerListItem.css b/src/components/PlayerListItem.module.css
similarity index 96%
rename from src/components/PlayerListItem.css
rename to src/components/PlayerListItem.module.css
index 4ed1654..726bf67 100755
--- a/src/components/PlayerListItem.css
+++ b/src/components/PlayerListItem.module.css
@@ -16,6 +16,7 @@
:local(.playerActions) {
flex: 0 0 90px;
+ display: flex;
}
:local(.btnAction),
diff --git a/src/components/SearchContent.js b/src/components/SearchContent.js
new file mode 100644
index 0000000..4cf279b
--- /dev/null
+++ b/src/components/SearchContent.js
@@ -0,0 +1,75 @@
+/**
+ * 搜索区域
+ */
+
+import React from "react";
+import classnames from "classnames";
+
+import { PLAYER_POSITION, PLAYER_TEAM } from "../constants";
+
+import styles from "./AddPlayerInput.module.css";
+
+const SearchContent = ({ filterParams, setFilterParams }) => {
+ return (
+
+
search player:
+
+ 球员姓名:
+
+ setFilterParams({
+ ...filterParams,
+ keyword: e.target.value.trim(),
+ })
+ }
+ />
+ 球员位置:
+
+ 所属团队:
+
+
+
+ );
+};
+
+export default SearchContent;
diff --git a/src/components/index.js b/src/components/index.js
index 12ce117..8b15b6e 100755
--- a/src/components/index.js
+++ b/src/components/index.js
@@ -1,3 +1,5 @@
-export { default as AddPlayerInput } from './AddPlayerInput';
-export { default as PlayerList } from './PlayerList';
-export { default as PlayerListItem } from './PlayerListItem';
+export { default as AddPlayerInput } from "./AddPlayerInput";
+export { default as PlayerList } from "./PlayerList";
+export { default as PlayerListItem } from "./PlayerListItem";
+export { default as Pagination } from "./Pagination";
+export { default as SearchContent } from "./SearchContent";
diff --git a/src/constants/ActionTypes.js b/src/constants/ActionTypes.js
index b796fae..ed0e851 100755
--- a/src/constants/ActionTypes.js
+++ b/src/constants/ActionTypes.js
@@ -1,3 +1,4 @@
-export const ADD_PLAYER = 'ADD_PLAYER';
-export const STAR_PLAYER = 'STAR_PLAYER';
-export const DELETE_PLAYER = 'DELETE_PLAYER';
+export const ADD_PLAYER = "ADD_PLAYER";
+export const STAR_PLAYER = "STAR_PLAYER";
+export const DELETE_PLAYER = "DELETE_PLAYER";
+export const GET_LATEST_PLAYER_LIST = "GET_LATEST_PLAYER_LIST";
diff --git a/src/constants/index.js b/src/constants/index.js
new file mode 100644
index 0000000..da37c15
--- /dev/null
+++ b/src/constants/index.js
@@ -0,0 +1,45 @@
+// 定义球员位置
+
+export const PLAYER_POSITION = [
+ {
+ label: "SF",
+ value: "SF",
+ },
+ {
+ label: "PF",
+ value: "PF",
+ },
+ {
+ label: "PG",
+ value: "PG",
+ },
+ {
+ label: "SG",
+ value: "SG",
+ },
+];
+// 定义球队
+export const PLAYER_TEAM = [
+ {
+ label: "LOS ANGELES LAKERS",
+ value: "LOS ANGELES LAKERS",
+ },
+ {
+ label: "GOLDEN STATE WARRIORS",
+ value: "GOLDEN STATE WARRIORS",
+ },
+ {
+ label: "NEW ORLEANS PELICANS",
+ value: "NEW ORLEANS PELICANS",
+ },
+ {
+ label: "HOUSTON ROCKETS",
+ value: "HOUSTON ROCKETS",
+ },
+ {
+ label: "TORONTO RAPTORS",
+ value: "TORONTO RAPTORS",
+ },
+];
+// 每页显示的条目数
+export const PAGE_SIZE = 5;
diff --git a/src/containers/PlayerListApp.js b/src/containers/PlayerListApp.js
index 0e4bfa5..2e04738 100755
--- a/src/containers/PlayerListApp.js
+++ b/src/containers/PlayerListApp.js
@@ -1,41 +1,151 @@
-import React, { Component } from 'react';
-import styles from './PlayerListApp.css';
-import { connect } from 'react-redux';
-
-import { addPlayer, deletePlayer, starPlayer } from '../actions/PlayersActions';
-import { PlayerList, AddPlayerInput } from '../components';
-
-class PlayerListApp extends Component {
- render() {
- const {
- playerlist: { playersById },
- } = this.props;
-
- const actions = {
- addPlayer: this.props.addPlayer,
- deletePlayer: this.props.deletePlayer,
- starPlayer: this.props.starPlayer,
- };
-
- return (
-
- );
- }
-}
-
-function mapStateToProps(state) {
- return state;
-}
-
-export default connect(
- mapStateToProps,
- {
- addPlayer,
- deletePlayer,
- starPlayer,
- },
-)(PlayerListApp);
+import React from "react";
+import { useSelector, useDispatch } from "react-redux";
+
+import {
+ useMount,
+ useUpdateDebounceEffect,
+ useUpdateEffect,
+ useStorageState,
+} from "../hooks";
+
+import {
+ PlayerList,
+ AddPlayerInput,
+ Pagination,
+ SearchContent,
+} from "../components";
+
+import {
+ addPlayer,
+ deletePlayer,
+ starPlayer,
+ getLatestPlayerData,
+} from "../actions/PlayersActions";
+
+import { PAGE_SIZE } from "../constants";
+
+import styles from "./PlayerListApp.module.css";
+
+const PlayerListApp = () => {
+ const dispatch = useDispatch();
+
+ const { showPlayersById, total } = useSelector((state) => state?.playerlist);
+
+ // 当前的筛选参数,这里可以防止刷新搜索条件丢失,后期甚至可以做到通过 url 中的参数来定位搜索条件
+ const [filterParams, setFilterParams] = useStorageState("filterParams", {
+ page: 1,
+ pageSize: PAGE_SIZE,
+ keyword: "",
+ position: "",
+ team: "",
+ });
+
+ // 获取最新的球员列表
+ const getPlayerDataList = (params = filterParams) => {
+ dispatch(getLatestPlayerData(params));
+ };
+
+ // 添加球员
+ const handleAddPlayer = (playerInfo) => {
+ dispatch(addPlayer(playerInfo));
+ };
+
+ // 删除球员
+ const handleDeletePlayer = (playerId) => {
+ dispatch(deletePlayer(playerId));
+ };
+
+ // 收藏球员
+ const handleStarPlayer = (playerId) => {
+ dispatch(starPlayer(playerId));
+ };
+
+ // 初始渲染时,请求列表数据
+ useMount(() => {
+ getPlayerDataList(filterParams);
+ });
+
+ // 这里关注点分离,当改变条件时,各自做好各自的 setFilterParams就行,
+ // 忽略首次渲染,当改变这些条件时,需要重置分页
+ useUpdateEffect(() => {
+ setFilterParams({ ...filterParams, page: 1 });
+ }, [
+ filterParams?.pageSize,
+ filterParams?.keyword,
+ filterParams?.position,
+ filterParams?.team,
+ ]);
+
+ // 忽略首次渲染,当这些参数改变时,重新请求数据(该 hook 有防抖的功能)
+ useUpdateDebounceEffect(
+ () => {
+ getPlayerDataList(filterParams);
+ },
+ [
+ filterParams?.page,
+ filterParams?.pageSize,
+ filterParams?.keyword,
+ filterParams?.position,
+ filterParams?.team,
+ ],
+ 300
+ );
+
+ // TODO:这里由于没有安装redux-thunk,导致所有的action都是同步的,无法模拟异步的情况
+ // 因此在这里做了特殊处理,对删除场景时,如果点击删除了该页的最后一项数据,那么需要重置到前一页
+ // useEffect(() => {
+ // if (showPlayersById?.length === 0 && filterParams?.page > 1) {
+ // setFilterParams({ ...filterParams, page: filterParams?.page - 1 });
+ // }
+ // }, [showPlayersById]);
+
+ return (
+
+
NBA Players
+
+
getPlayerDataList(filterParams)}
+ />
+
+
+
+ getPlayerDataList(filterParams)}
+ />
+
+ setFilterParams({ ...filterParams, page: newPage })
+ }
+ />
+
+ );
+};
+
+export default PlayerListApp;
diff --git a/src/containers/PlayerListApp.css b/src/containers/PlayerListApp.module.css
similarity index 64%
rename from src/containers/PlayerListApp.css
rename to src/containers/PlayerListApp.module.css
index f438bf1..64ea1e7 100755
--- a/src/containers/PlayerListApp.css
+++ b/src/containers/PlayerListApp.module.css
@@ -5,20 +5,18 @@ body {
justify-content: center;
}
-:local(.playerListApp) {
- width: 540px;
- margin-top: 10px;
- padding-top: 18px;
+.playerListApp {
+ width: 1000px;
+ padding: 20px;
background-color: #5c75b0;
border: 1px solid #e3e3e3;
}
-:local(.playerListApp h1) {
+.playerListApp h1 {
color: white;
- font-size: 16px;
+ font-size: 20px;
line-height: 20px;
margin-bottom: 10px;
margin-top: 0;
- padding-left: 10px;
font-family: Helvetica;
}
diff --git a/src/hooks/index.js b/src/hooks/index.js
new file mode 100644
index 0000000..73aa38f
--- /dev/null
+++ b/src/hooks/index.js
@@ -0,0 +1,5 @@
+export { useMount } from "./useMount";
+export { useUpdateDebounceEffect } from "./useUpdateDebounceEffect";
+export { useStorageState } from "./useStorageState";
+export { useFirstMount } from "./useFirstMount";
+export { useUpdateEffect } from "./useUpdateEffect";
diff --git a/src/hooks/useFirstMount.js b/src/hooks/useFirstMount.js
new file mode 100644
index 0000000..113cbf5
--- /dev/null
+++ b/src/hooks/useFirstMount.js
@@ -0,0 +1,18 @@
+/**
+ * 判断是否是第一次渲染
+ */
+
+import { useRef } from "react";
+
+export function useFirstMount() {
+ const isFirst = useRef(true);
+
+ //如果是初次渲染
+ if (isFirst.current) {
+ isFirst.current = false;
+
+ return true;
+ }
+
+ return isFirst.current;
+}
diff --git a/src/hooks/useFirstMount.test.js b/src/hooks/useFirstMount.test.js
new file mode 100644
index 0000000..8b3db2e
--- /dev/null
+++ b/src/hooks/useFirstMount.test.js
@@ -0,0 +1,26 @@
+import { render, screen } from "@testing-library/react";
+import { useFirstMount } from "./useFirstMount";
+
+// 在这里定义一个简单的组件,用于测试 useFirstMount
+function ComponentToTest() {
+ const isFirstMount = useFirstMount();
+
+ // 渲染时,将 isFirstMount 值显示在页面上
+ return
{isFirstMount.toString()}
;
+}
+
+test("useFirstMount returns true on first render", () => {
+ // 渲染组件
+ render(
);
+
+ // 在初次渲染时,useFirstMount 应该返回 true
+ expect(useFirstMount()).toBe(true);
+});
+
+test("useFirstMount returns false on subsequent renders", () => {
+ // 渲染组件
+ render(
);
+
+ // 初次渲染后,useFirstMount 应该返回 false
+ expect(useFirstMount()).toBe(false);
+});
diff --git a/src/hooks/useMount.js b/src/hooks/useMount.js
new file mode 100644
index 0000000..875e704
--- /dev/null
+++ b/src/hooks/useMount.js
@@ -0,0 +1,5 @@
+import { useEffect } from "react";
+
+export const useMount = (callback) => {
+ useEffect(callback, []);
+};
diff --git a/src/hooks/useStorageState.js b/src/hooks/useStorageState.js
new file mode 100644
index 0000000..48cafac
--- /dev/null
+++ b/src/hooks/useStorageState.js
@@ -0,0 +1,86 @@
+/**
+ * 用为缓存的hook,可以像setState一样使用
+ */
+import { useState } from "react";
+
+// 保存值到本地存储或会话存储
+const valueToStorageFun = ({ type, key, value }) => {
+ if (typeof window !== "undefined") {
+ switch (type) {
+ case "localStorage":
+ window.localStorage.setItem(key, JSON.stringify(value));
+ break;
+ case "sessionStorage":
+ window.sessionStorage.setItem(key, JSON.stringify(value));
+ break;
+ default:
+ window.localStorage.setItem(key, JSON.stringify(value));
+ }
+ }
+};
+
+// 自定义 Hook,用于在组件中处理具有本地存储/会话存储支持的状态
+export function useStorageState(key, initialValue, options) {
+ const { priority = "local", type = "localStorage" } = {
+ priority: "local",
+ type: "localStorage",
+ ...options,
+ };
+
+ // 使用 useState 初始化状态值
+ const [storedValue, setStoredValue] = useState(() => {
+ if (typeof window === "undefined") {
+ return initialValue;
+ }
+ try {
+ // 根据类型判断从何处读取数据
+ let item;
+ switch (type) {
+ case "localStorage":
+ item = window.localStorage.getItem(key);
+ break;
+ case "sessionStorage":
+ item = window.sessionStorage.getItem(key);
+ break;
+ default:
+ item = window.localStorage.getItem(key);
+ }
+
+ // 解析 state 并按优先级进行处理
+ if (item) {
+ switch (priority) {
+ case "local":
+ return JSON.parse(item);
+ case "initialValue":
+ valueToStorageFun({ key, type, value: initialValue });
+ return initialValue;
+ default:
+ return JSON.parse(item);
+ }
+ } else {
+ return initialValue;
+ }
+ } catch (error) {
+ console.error(error);
+ return initialValue;
+ }
+ });
+
+ // 定义 setValue 函数,用于更新状态值并同步到存储中
+ const setValue = (value) => {
+ try {
+ // 和 useState 保持相同用法,支持函数和默认值
+ const valueToStore =
+ value instanceof Function ? value(storedValue) : value;
+ // 保存 state
+ setStoredValue(valueToStore);
+ // 同步到 storage 中
+ valueToStorageFun({ key, type, value: valueToStore });
+ } catch (error) {
+ console.log(error);
+ }
+ };
+
+ // 返回状态值和更新函数
+ return [storedValue, setValue];
+}
diff --git a/src/hooks/useUpdateDebounceEffect.js b/src/hooks/useUpdateDebounceEffect.js
new file mode 100644
index 0000000..f19357d
--- /dev/null
+++ b/src/hooks/useUpdateDebounceEffect.js
@@ -0,0 +1,31 @@
+/**
+ * 忽略首次渲染、防抖
+ */
+
+import { useEffect, useRef, useCallback } from "react";
+
+export const useUpdateDebounceEffect = (callback, dependencies, delay) => {
+ const isFirstRender = useRef(true);
+
+ // 使用 useCallback 确保在依赖项变化时返回新的回调
+ const debouncedCallback = useCallback(callback, dependencies);
+
+ useEffect(() => {
+ // 首次渲染时不执行回调
+ if (isFirstRender.current) {
+ isFirstRender.current = false;
+ return;
+ }
+
+ // 设置一个定时器
+ const timer = setTimeout(() => {
+ // 当定时器触发时执行回调
+ debouncedCallback();
+ }, delay);
+
+ // 在每次 effect 运行之前清除上一个定时器
+ return () => clearTimeout(timer);
+
+ // 在依赖项或延迟改变时重新运行 effect
+ }, [debouncedCallback, delay]);
+};
diff --git a/src/hooks/useUpdateEffect.js b/src/hooks/useUpdateEffect.js
new file mode 100644
index 0000000..aef1494
--- /dev/null
+++ b/src/hooks/useUpdateEffect.js
@@ -0,0 +1,12 @@
+import { useEffect } from "react";
+import { useFirstMount } from "./useFirstMount";
+
+export const useUpdateEffect = (effect, deps) => {
+ const isFirstMount = useFirstMount(); //判断是否是初次渲染
+
+ useEffect(() => {
+ if (!isFirstMount) {
+ return effect(); //二次渲染才执行
+ }
+ }, deps);
+};
diff --git a/src/reducers/playerlist.js b/src/reducers/playerlist.js
index 1bc7457..a6b8283 100755
--- a/src/reducers/playerlist.js
+++ b/src/reducers/playerlist.js
@@ -1,74 +1,127 @@
-import * as types from '../constants/ActionTypes';
+import * as types from "../constants/ActionTypes";
const initialState = {
+ // 这里相当于是数据库
playersById: [
{
- name: 'LeBron James',
- team: 'LOS ANGELES LAKERS',
- position: 'SF',
+ id: 1,
+ name: "LeBron James",
+ team: "LOS ANGELES LAKERS",
+ position: "SF",
starred: true,
},
{
- name: 'Kevin Duran',
- team: 'GOLDEN STATE WARRIORS',
- position: 'SF',
+ id: 2,
+ name: "Kevin Duran",
+ team: "GOLDEN STATE WARRIORS",
+ position: "SF",
starred: false,
},
{
- name: 'Anthony Davis',
- team: 'NEW ORLEANS PELICANS',
- position: 'PF',
+ id: 3,
+ name: "Anthony Davis",
+ team: "NEW ORLEANS PELICANS",
+ position: "PF",
starred: false,
},
{
- name: 'Stephen Curry',
- team: 'GOLDEN STATE WARRIORS',
- position: 'PG',
+ id: 4,
+ name: "Stephen Curry",
+ team: "GOLDEN STATE WARRIORS",
+ position: "PG",
starred: false,
},
{
- name: 'James Harden',
- team: 'HOUSTON ROCKETS',
- position: 'SG',
+ id: 5,
+ name: "James Harden",
+ team: "HOUSTON ROCKETS",
+ position: "SG",
starred: false,
},
{
- name: 'Kawhi Leonard',
- team: 'TORONTO RAPTORS',
- position: 'SF',
+ id: 6,
+ name: "Kawhi Leonard",
+ team: "TORONTO RAPTORS",
+ position: "SF",
starred: false,
},
],
+ total: 6,
+ // 这里是给前端显示的数据
+ showPlayersById: [],
};
export default function players(state = initialState, action) {
switch (action.type) {
+ // 添加
case types.ADD_PLAYER:
+ const { playerInfo } = action;
+
+ // 将时间戳作为默认id
+ var id = new Date().getTime();
+
return {
...state,
playersById: [
- ...state.playersById,
{
- name: action.name,
- team: 'LOS ANGELES LAKERS',
- position: 'SF',
+ id,
+ name: playerInfo.name,
+ team: playerInfo.team,
+ position: playerInfo.position,
+ starred: false,
},
+ ...state.playersById,
],
};
+
+ // 删除
case types.DELETE_PLAYER:
return {
...state,
playersById: state.playersById.filter(
- (item, index) => index !== action.id,
+ (item) => item?.id !== action?.id
),
};
+
+ // 收藏
case types.STAR_PLAYER:
- let players = [...state.playersById];
- let player = players.find((item, index) => index === action.id);
- player.starred = !player.starred;
+ try {
+ const { id } = action;
+ let clonePlayers = JSON.parse(JSON.stringify(state.playersById));
+ let player = clonePlayers.find((item) => item?.id === id);
+ player.starred = !player.starred;
+ return {
+ ...state,
+ playersById: clonePlayers,
+ };
+ } catch (error) {
+ console.error(error);
+ }
+
+ // 获取数据列表
+ case types.GET_LATEST_PLAYER_LIST:
+ const { page, pageSize, keyword, position, team } = action;
+ // 先根据查询条件去过滤数据
+ const data =
+ state.playersById.filter(
+ (player) =>
+ player?.name?.toLowerCase()?.includes(keyword?.toLowerCase()) &&
+ player?.position?.includes(position) &&
+ player?.team?.includes(team)
+ ) || [];
+
+ // 计算切片的起始和结束索引
+ const startIndex = (page - 1) * pageSize;
+ const endIndex = startIndex + pageSize;
+
+ // 获取对应页码的数据切片
+ const updatedPlayers = data.slice(startIndex, endIndex);
+
+ // 这里是给前端返回的数据
return {
...state,
- playersById: players,
+ showPlayersById: updatedPlayers,
+ total: data?.length || 0,
};
default: