前面的登录注册是整个项目的根基,没有拿到 token,将无法进行后续的各种操作,如账单的增删改查。所以务必将上一章节好好地阅读与揣摩,为后面的学习做好铺垫。我们直接进入本次前端实战项目的主题,账单的增删改查之列表页。

知识点

  • 单项组件抽离
  • 列表页无限滚动
  • 下拉刷新列表
  • 弹窗组件封装

页面效果预览
image.png

列表页编写

按照正常的开发流程,我们先将静态页面切出来,再填入数据使其动态化。在此之前,我们已经新建好了 Home 目录,该目录便是用于放置账单列表,所以我们直接在 Home/index.jsx 新增代码。

头部统计实现

列表的头部展示的内容为当月的收入和支出汇总,并且有两个列表条件过滤项,分别是类型过滤和时间过滤。
我们新增代码如下:

  1. import React from 'react'
  2. import { Icon } from 'zarm'
  3. import s from './style.module.less'
  4. const Home = () => {
  5. return <div className={s.home}>
  6. <div className={s.header}>
  7. <div className={s.dataWrap}>
  8. <span className={s.expense}>总支出:<b 200</b></span>
  9. <span className={s.income}>总收入:<b 500</b></span>
  10. </div>
  11. <div className={s.typeWrap}>
  12. <div className={s.left}>
  13. <span className={s.title}>类型 <Icon className={s.arrow} type="arrow-bottom" /></span>
  14. </div>
  15. <div className={s.right}>
  16. <span className={s.time}>2022-06<Icon className={s.arrow} type="arrow-bottom" /></span>
  17. </div>
  18. </div>
  19. </div>
  20. </div>
  21. }
  22. export default Home

header 采用 fixed 固定定位,将整个汇总信息固定在页面的顶部位置,这样后续列表滚动的时候,你可以方便查看当月的收入汇总,以及筛选当月消费类型和时间段的筛选。每个列表展示的是当月的收入与支出明细,比如 2022-10 的收入明细。
本次项目全程采用的是 Flex 弹性布局,这种布局形式在当下的开发生产环境已经非常成熟,同学们如果还有不熟悉的,请实现对 Flex 布局做一个简单的学习,这边推荐一个学习网站:

flexboxfroggy.com/#zh-cn

列表页面实现

列表页面会用到 Zarm 组件库为我们提供的 Pull 组件,来实现下拉刷新以及无限滚动,我们先来将基础布局实现,如下所示:

  1. // Home/index.jsx
  2. const Home = () => {
  3. const [list, setList] = useState([
  4. {
  5. bills: [
  6. {
  7. amount: "25.00",
  8. date: "1623390740000",
  9. id: 911,
  10. pay_type: 1,
  11. remark: "",
  12. type_id: 1,
  13. type_name: "餐饮"
  14. }
  15. ],
  16. date: '2021-06-11'
  17. }
  18. ]); // 账单列表
  19. return <div className={s.home}>
  20. <div className={s.header}>
  21. ...
  22. </div>
  23. <div className={s.contentWrap}>
  24. {
  25. list.map((item, index) => <BillItem />)
  26. }
  27. </div>
  28. </div>
  29. }

上述我们添加 list 为列表假数据,BillItem 组件为账单单项组件,我们将其抽离到 components 组件库,如下:

  1. // components/BillItem/index.jsx
  2. import React, { useEffect, useState } from 'react';
  3. import PropTypes from 'prop-types';
  4. import dayjs from 'dayjs';
  5. import { Cell } from 'zarm';
  6. import { useHistory } from 'react-router-dom'
  7. import CustomIcon from '../CustomIcon';
  8. import { typeMap } from '@/utils';
  9. import s from './style.module.less';
  10. const BillItem = ({ bill }) => {
  11. const [income, setIncome] = useState(0); // 收入
  12. const [expense, setExpense] = useState(0); // 支出
  13. const history = useHistory(); // 路由实例
  14. // 当添加账单是,bill.bills 长度变化,触发当日收支总和计算。
  15. useEffect(() => {
  16. // 初始化将传入的 bill 内的 bills 数组内数据项,过滤出支出和收入。
  17. // pay_type:1 为支出;2 为收入
  18. // 通过 reduce 累加
  19. const _income = bill.bills.filter(i => i.pay_type == 2).reduce((curr, item) => {
  20. curr += Number(item.amount);
  21. return curr;
  22. }, 0);
  23. setIncome(_income);
  24. const _expense = bill.bills.filter(i => i.pay_type == 1).reduce((curr, item) => {
  25. curr += Number(item.amount);
  26. return curr;
  27. }, 0);
  28. setExpense(_expense);
  29. }, [bill.bills]);
  30. // 前往账单详情
  31. const goToDetail = (item) => {
  32. history.push(`/detail?id=${item.id}`)
  33. };
  34. return <div className={s.item}>
  35. <div className={s.headerDate}>
  36. <div className={s.date}>{bill.date}</div>
  37. <div className={s.money}>
  38. <span>
  39. <img src="//s.yezgea02.com/1615953405599/zhi%402x.png" alt='支' />
  40. <span>¥{ expense.toFixed(2) }</span>
  41. </span>
  42. <span>
  43. <img src="//s.yezgea02.com/1615953405599/shou%402x.png" alt="收" />
  44. <span>¥{ income.toFixed(2) }</span>
  45. </span>
  46. </div>
  47. </div>
  48. {
  49. bill && bill.bills.map(item => <Cell
  50. className={s.bill}
  51. key={item.id}
  52. onClick={() => goToDetail(item)}
  53. title={
  54. <>
  55. <CustomIcon
  56. className={s.itemIcon}
  57. type={item.type_id ? typeMap[item.type_id].icon : 1}
  58. />
  59. <span>{ item.type_name }</span>
  60. </>
  61. }
  62. description={<span style={{ color: item.pay_type == 2 ? 'red' : '#39be77' }}>{`${item.pay_type == 1 ? '-' : '+'}${item.amount}`}</span>}
  63. help={<div>{dayjs(Number(item.date)).format('HH:mm')} {item.remark ? `| ${item.remark}` : ''}</div>}
  64. >
  65. </Cell>)
  66. }
  67. </div>
  68. };
  69. BillItem.propTypes = {
  70. bill: PropTypes.object
  71. };
  72. export default BillItem;

通过 npm i dayjs -S 添加日期操作工具,移动端建议使用 dayjs,因为它相比 moment,体积小很多。
上述代码中,typeMap 为我们自定义的属性,它是一个简直对,key 为消费类型 icon 的 id,value 为消费类型的 iconfont 的值,如下所示:

  1. // utils/index.js
  2. ...
  3. export const typeMap = {
  4. 1: {
  5. icon: 'canyin'
  6. },
  7. 2: {
  8. icon: 'fushi'
  9. },
  10. 3: {
  11. icon: 'jiaotong'
  12. },
  13. 4: {
  14. icon: 'riyong'
  15. },
  16. 5: {
  17. icon: 'gouwu'
  18. },
  19. 6: {
  20. icon: 'xuexi'
  21. },
  22. 7: {
  23. icon: 'yiliao'
  24. },
  25. 8: {
  26. icon: 'lvxing'
  27. },
  28. 9: {
  29. icon: 'renqing'
  30. },
  31. 10: {
  32. icon: 'qita'
  33. },
  34. 11: {
  35. icon: 'gongzi'
  36. },
  37. 12: {
  38. icon: 'jiangjin'
  39. },
  40. 13: {
  41. icon: 'zhuanzhang'
  42. },
  43. 14: {
  44. icon: 'licai'
  45. },
  46. 15: {
  47. icon: 'tuikuang'
  48. },
  49. 16: {
  50. icon: 'qita'
  51. }
  52. }

完成上述操作之后,我们重启浏览器,如下所示:
image.png

下拉刷新、上滑无限加载

我们修改 Home/index.jsx 如下所示:

  1. import React, { useState, useEffect } from 'react'
  2. import { Icon, Pull } from 'zarm'
  3. import dayjs from 'dayjs'
  4. import BillItem from '@/components/BillItem'
  5. import { get, REFRESH_STATE, LOAD_STATE } from '@/utils' // Pull 组件需要的一些常量
  6. import s from './style.module.less'
  7. const Home = () => {
  8. const [currentTime, setCurrentTime] = useState(dayjs().format('YYYY-MM')); // 当前筛选时间
  9. const [page, setPage] = useState(1); // 分页
  10. const [list, setList] = useState([]); // 账单列表
  11. const [totalPage, setTotalPage] = useState(0); // 分页总数
  12. const [refreshing, setRefreshing] = useState(REFRESH_STATE.normal); // 下拉刷新状态
  13. const [loading, setLoading] = useState(LOAD_STATE.normal); // 上拉加载状态
  14. useEffect(() => {
  15. getBillList() // 初始化
  16. }, [page])
  17. // 获取账单方法
  18. const getBillList = async () => {
  19. const { data } = await get(`/api/bill/list?page=${page}&page_size=5&date=${currentTime}`);
  20. // 下拉刷新,重制数据
  21. if (page == 1) {
  22. setList(data.list);
  23. } else {
  24. setList(list.concat(data.list));
  25. }
  26. setTotalPage(data.totalPage);
  27. // 上滑加载状态
  28. setLoading(LOAD_STATE.success);
  29. setRefreshing(REFRESH_STATE.success);
  30. }
  31. // 请求列表数据
  32. const refreshData = () => {
  33. setRefreshing(REFRESH_STATE.loading);
  34. if (page != 1) {
  35. setPage(1);
  36. } else {
  37. getBillList();
  38. };
  39. };
  40. const loadData = () => {
  41. if (page < totalPage) {
  42. setLoading(LOAD_STATE.loading);
  43. setPage(page + 1);
  44. }
  45. }
  46. return <div className={s.home}>
  47. <div className={s.header}>
  48. <div className={s.dataWrap}>
  49. <span className={s.expense}>总支出:<b 200</b></span>
  50. <span className={s.income}>总收入:<b 500</b></span>
  51. </div>
  52. <div className={s.typeWrap}>
  53. <div className={s.left}>
  54. <span className={s.title}>类型 <Icon className={s.arrow} type="arrow-bottom" /></span>
  55. </div>
  56. <div className={s.right}>
  57. <span className={s.time}>2022-06<Icon className={s.arrow} type="arrow-bottom" /></span>
  58. </div>
  59. </div>
  60. </div>
  61. <div className={s.contentWrap}>
  62. {
  63. list.length ? <Pull
  64. animationDuration={200}
  65. stayTime={400}
  66. refresh={{
  67. state: refreshing,
  68. handler: refreshData
  69. }}
  70. load={{
  71. state: loading,
  72. distance: 200,
  73. handler: loadData
  74. }}
  75. >
  76. {
  77. list.map((item, index) => <BillItem
  78. bill={item}
  79. key={index}
  80. />)
  81. }
  82. </Pull> : null
  83. }
  84. </div>
  85. </div>
  86. }
  87. export default Home
  88. }

在 utils/index.js 中添加一些 Pull 组件需要用到的常量,如下:

  1. // utils/index.js
  2. export const REFRESH_STATE = {
  3. normal: 0, // 普通
  4. pull: 1, // 下拉刷新(未满足刷新条件)
  5. drop: 2, // 释放立即刷新(满足刷新条件)
  6. loading: 3, // 加载中
  7. success: 4, // 加载成功
  8. failure: 5, // 加载失败
  9. };
  10. export const LOAD_STATE = {
  11. normal: 0, // 普通
  12. abort: 1, // 中止
  13. loading: 2, // 加载中
  14. success: 3, // 加载成功
  15. failure: 4, // 加载失败
  16. complete: 5, // 加载完成(无新数据)
  17. };

bug修复:滑到底部的时候,有一部分内容被遮挡住了,此时我们需要添加下列样式,进行修复:

  1. .home {
  2. ...
  3. .content-wrap {
  4. height: calc(~"(100% - 50px)");
  5. overflow: hidden;
  6. overflow-y: scroll;
  7. background-color: #f5f5f5;
  8. padding: 10px;
  9. :global {
  10. .za-pull {
  11. overflow: unset;
  12. }
  13. }
  14. }
  15. }

给 content-wrap 对应的标签一个高度,并且减去 50px 的高度,这样就不会被遮挡住下面一点的部分。
还有一个很关键的步骤,给 src 目录下的的 index.css 添加初始化高度和样式:

  1. body {
  2. margin: 0;
  3. font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
  4. 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
  5. sans-serif;
  6. -webkit-font-smoothing: antialiased;
  7. -moz-osx-font-smoothing: grayscale;
  8. }
  9. body, html, p {
  10. height: 100%;
  11. margin: 0;
  12. padding: 0;
  13. }
  14. * {
  15. box-sizing: border-box;
  16. }
  17. #root {
  18. height: 100%;
  19. }
  20. .text-deep {
  21. color: rgba(0, 0, 0, 0.9)
  22. }
  23. .text-light {
  24. color: rgba(0, 0, 0, 0.6)
  25. }

至此,滚动加载基本上就完成了。

添加筛选条件

最后我们需要添加两个筛选条件,类型选择和日期选择。
我们先来实现类型选择弹窗,我们采用的形式如下,底部弹出的弹窗形式,大致如下:
image.png
想要实现上述形式,我们需要借助 Zarm 组件库为我们提供的 Popup 组件,它的作用就是从不同方向弹出一个脱离文档流的弹出层。同样,我们使用组件的形式将其放置于 components 文件夹内实现,这样便于后续其他地方的使用。
新建 components/PopupType,在其内部新建 index.jsx 和 style.module.less 内容如下:

  1. // PopupType/index.jsx
  2. import React, { forwardRef, useEffect, useState } from 'react'
  3. import PropTypes from 'prop-types'
  4. import { Popup, Icon } from 'zarm'
  5. import cx from 'classnames'
  6. import { get } from '@/utils'
  7. import s from './style.module.less'
  8. // forwardRef 用于拿到父组件传入的 ref 属性,这样在父组件便能通过 ref 控制子组件。
  9. const PopupType = forwardRef(({ onSelect }, ref) => {
  10. const [show, setShow] = useState(false); // 组件的显示和隐藏
  11. const [active, setActive] = useState('all'); // 激活的 type
  12. const [expense, setExpense] = useState([]); // 支出类型标签
  13. const [income, setIncome] = useState([]); // 收入类型标签
  14. useEffect(async () => {
  15. // 请求标签接口放在弹窗内,这个弹窗可能会被复用,所以请求如果放在外面,会造成代码冗余。
  16. const { data: { list } } = await get('/api/type/list')
  17. setExpense(list.filter(i => i.type == 1))
  18. setIncome(list.filter(i => i.type == 2))
  19. }, [])
  20. if (ref) {
  21. ref.current = {
  22. // 外部可以通过 ref.current.show 来控制组件的显示
  23. show: () => {
  24. setShow(true)
  25. },
  26. // 外部可以通过 ref.current.close 来控制组件的显示
  27. close: () => {
  28. setShow(false)
  29. }
  30. }
  31. };
  32. // 选择类型回调
  33. const choseType = (item) => {
  34. setActive(item.id)
  35. setShow(false)
  36. // 父组件传入的 onSelect,为了获取类型
  37. onSelect(item)
  38. };
  39. return <Popup
  40. visible={show}
  41. direction="bottom"
  42. onMaskClick={() => setShow(false)}
  43. destroy={false}
  44. mountContainer={() => document.body}
  45. >
  46. <div className={s.popupType}>
  47. <div className={s.header}>
  48. 请选择类型
  49. <Icon type="wrong" className={s.cross} onClick={() => setShow(false)} />
  50. </div>
  51. <div className={s.content}>
  52. <div onClick={() => choseType({ id: 'all' })} className={cx({ [s.all]: true, [s.active]: active == 'all' })}>全部类型</div>
  53. <div className={s.title}>支出</div>
  54. <div className={s.expenseWrap}>
  55. {
  56. expense.map((item, index) => <p key={index} onClick={() => choseType(item)} className={cx({[s.active]: active == item.id})} >{ item.name }</p>)
  57. }
  58. </div>
  59. <div className={s.title}>收入</div>
  60. <div className={s.incomeWrap}>
  61. {
  62. income.map((item, index) => <p key={index} onClick={() => choseType(item)} className={cx({[s.active]: active == item.id})} >{ item.name }</p>)
  63. }
  64. </div>
  65. </div>
  66. </div>
  67. </Popup>
  68. });
  69. PopupType.propTypes = {
  70. onSelect: PropTypes.func
  71. }
  72. export default PopupType;

类型弹窗组件写完之后,我们在 Home/index.jsx 内尝试调用它,如下所示

  1. ...
  2. import PopupType from '@/components/PopupType'
  3. const Home = () => {
  4. const typeRef = useRef(); // 账单类型 ref
  5. const [currentSelect, setCurrentSelect] = useState({}); // 当前筛选类型
  6. ...
  7. useEffect(() => {
  8. getBillList() // 初始化
  9. }, [page, currentSelect])
  10. const getBillList = async () => {
  11. const { data } = await get(`/api/bill/list?page=${page}&page_size=5&date=${currentTime}&type_id=${currentSelect.id || 'all'}`);
  12. // 下拉刷新,重制数据
  13. if (page == 1) {
  14. setList(data.list);
  15. } else {
  16. setList(list.concat(data.list));
  17. }
  18. setTotalPage(data.totalPage);
  19. // 上滑加载状态
  20. setLoading(LOAD_STATE.success);
  21. setRefreshing(REFRESH_STATE.success);
  22. }
  23. ...
  24. // 添加账单弹窗
  25. const toggle = () => {
  26. typeRef.current && typeRef.current.show()
  27. };
  28. // 筛选类型
  29. const select = (item) => {
  30. setRefreshing(REFRESH_STATE.loading);
  31. // 触发刷新列表,将分页重制为 1
  32. setPage(1);
  33. setCurrentSelect(item)
  34. }
  35. return <div className={s.home}>
  36. <div className={s.header}>
  37. <div className={s.dataWrap}>
  38. <span className={s.expense}>总支出:<b 200</b></span>
  39. <span className={s.income}>总收入:<b 500</b></span>
  40. </div>
  41. <div className={s.typeWrap}>
  42. <div className={s.left} onClick={toggle}>
  43. <span className={s.title}>{ currentSelect.name || '全部类型' } <Icon className={s.arrow} type="arrow-bottom" /></span>
  44. </div>
  45. <div className={s.right}>
  46. <span className={s.time}>2022-06<Icon className={s.arrow} type="arrow-bottom" /></span>
  47. </div>
  48. </div>
  49. </div>
  50. <div className={s.contentWrap}>
  51. {
  52. list.length ? <Pull
  53. animationDuration={200}
  54. stayTime={400}
  55. refresh={{
  56. state: refreshing,
  57. handler: refreshData
  58. }}
  59. load={{
  60. state: loading,
  61. distance: 200,
  62. handler: loadData
  63. }}
  64. >
  65. {
  66. list.map((item, index) => <BillItem
  67. bill={item}
  68. key={index}
  69. />)
  70. }
  71. </Pull> : null
  72. }
  73. </div>
  74. <PopupType ref={typeRef} onSelect={select} />
  75. </div>
  76. }

添加类型选择弹窗注意几个点:
1、使用 useState 声明好类型字段。
2、通过 useRef 声明的 ref 给到 PopupType 组件,便于控制内部的方法。
3、传递 onSelect 方法,获取到弹窗内部选择的类型。
4、useEffect 第二个参数,添加一个 currentSelect 以来,便于修改的时候,触发列表的重新渲染。

加完类型筛选之后,我们再将时间筛选加上,同样将时间筛选添加至 components 目录下,便于后续数据页面的时间筛选。

  1. // PopupDate/index.jsx
  2. import React, { forwardRef, useState } from 'react'
  3. import PropTypes from 'prop-types'
  4. import { Popup, DatePicker } from 'zarm'
  5. import dayjs from 'dayjs'
  6. const PopupDate = forwardRef(({ onSelect, mode = 'date' }, ref) => {
  7. const [show, setShow] = useState(false)
  8. const [now, setNow] = useState(new Date())
  9. const choseMonth = (item) => {
  10. setNow(item)
  11. setShow(false)
  12. if (mode == 'month') {
  13. onSelect(dayjs(item).format('YYYY-MM'))
  14. } else if (mode == 'date') {
  15. onSelect(dayjs(item).format('YYYY-MM-DD'))
  16. }
  17. }
  18. if (ref) {
  19. ref.current = {
  20. show: () => {
  21. setShow(true)
  22. },
  23. close: () => {
  24. setShow(false)
  25. }
  26. }
  27. };
  28. return <Popup
  29. visible={show}
  30. direction="bottom"
  31. onMaskClick={() => setShow(false)}
  32. destroy={false}
  33. mountContainer={() => document.body}
  34. >
  35. <div>
  36. <DatePicker
  37. visible={show}
  38. value={now}
  39. mode={mode}
  40. onOk={choseMonth}
  41. onCancel={() => setShow(false)}
  42. />
  43. </div>
  44. </Popup>
  45. });
  46. PopupDate.propTypes = {
  47. mode: PropTypes.string, // 日期模式
  48. onSelect: PropTypes.func, // 选择后的回调
  49. }
  50. export default PopupDate;

底部时间弹窗逻辑和类型选择的逻辑相似,这里不做赘述,直接在 Home/index.jsx 中引入时间筛选框:

  1. // Home/index.jsx
  2. ...
  3. import PopupDate from '@/components/PopupDate'
  4. const Home = () => {
  5. ...
  6. const monthRef = useRef(); // 月份筛选 ref
  7. useEffect(() => {
  8. getBillList() // 初始化
  9. }, [page, currentSelect, currentTime])
  10. ...
  11. // 选择月份弹窗
  12. const monthToggle = () => {
  13. monthRef.current && monthRef.current.show()
  14. };
  15. // 筛选月份
  16. const selectMonth = (item) => {
  17. setRefreshing(REFRESH_STATE.loading);
  18. setPage(1);
  19. setCurrentTime(item)
  20. }
  21. return <div className={s.home}>
  22. ...
  23. <div className={s.right}>
  24. <span className={s.time} onClick={monthToggle}>{ currentTime }<Icon className={s.arrow} type="arrow-bottom" /></span>
  25. </div>
  26. ...
  27. <PopupDate ref={monthRef} mode="month" onSelect={selectMonth} />
  28. </div>
  29. }

刷新浏览器如下所示:
image.png
最后不要忘记计算当前月份的收入和支出汇总数据,放置于头部,修改 Home/index.jsx 内的代码如下:

  1. ...
  2. const Home = () => {
  3. ...
  4. const [totalExpense, setTotalExpense] = useState(0); // 总支出
  5. const [totalIncome, setTotalIncome] = useState(0); // 总收入
  6. const getBillList = async () => {
  7. const { data } = await get(`/api/bill/list?page=${page}&page_size=5&date=${currentTime}&type_id=${currentSelect.id || 'all'}`);
  8. // 下拉刷新,重制数据
  9. if (page == 1) {
  10. setList(data.list);
  11. } else {
  12. setList(list.concat(data.list));
  13. }
  14. setTotalExpense(data.totalExpense.toFixed(2));
  15. setTotalIncome(data.totalIncome.toFixed(2));
  16. setTotalPage(data.totalPage);
  17. // 上滑加载状态
  18. setLoading(LOAD_STATE.success);
  19. setRefreshing(REFRESH_STATE.success);
  20. }
  21. return <div className={s.home}>
  22. ...
  23. <div className={s.dataWrap}>
  24. <span className={s.expense}>总支出:<b { totalExpense }</b></span>
  25. <span className={s.income}>总收入:<b { totalIncome }</b></span>
  26. </div>
  27. ...
  28. <div>
  29. }

总结

本章节的内容,偏向实战,而实战部分代码在文章的重复率不可避免,这里大家把握好本章节两个重要知识点:
1、无限加载、下拉刷新。
2、公用组件提取,如弹窗组件、账单组件。