本章节我们学习如何将账单列表,以可视化数据的新形势展示,本章节我们会通过 Echart 插件,对数据进行可视化展示。
页面布局如下所示:
image.pngimage.png

头部筛选和数据实现

当你看到顶部的时间筛选项的时候,你会再一次体会到当初把时间筛选功能封装成公用组件的好处,于是我们打开 Data/index.jsx,添加如下代码:

  1. import React, { useEffect, useRef, useState } from 'react';
  2. import { Icon, Progress } from 'zarm';
  3. import cx from 'classnames';
  4. import dayjs from 'dayjs';
  5. import { get, typeMap } from '@/utils'
  6. import CustomIcon from '@/components/CustomIcon'
  7. import PopupDate from '@/components/PopupDate'
  8. import s from './style.module.less';
  9. const Data = () => {
  10. return <div className={s.data}>
  11. <div className={s.total}>
  12. <div className={s.time}>
  13. <span>2021-06</span>
  14. <Icon className={s.date} type="date" />
  15. </div>
  16. <div className={s.title}>共支出</div>
  17. <div className={s.expense}>¥1000</div>
  18. <div className={s.income}>共收入¥200</div>
  19. </div>
  20. </div>
  21. }
  22. export default Data


样式部分有一个小技巧需要注意,日期后面的小竖线,如下所示:
image.png
在业务中,类似这样的需求非常多,这里我们可以使用伪类 ::before 或 ::after 去实现,减少在页面中再添加一些多余的标签。上述代码实现的逻辑是在日期的 span 上加上 ::after,如下所示:

  1. span:nth-of-type(1)::after {
  2. content: '';
  3. position: absolute;
  4. top: 9px;
  5. bottom: 8px;
  6. right: 28px;
  7. width: 1px;
  8. background-color: rgba(0, 0, 0, .5);
  9. }

点击如期按钮,弹出底部弹窗,这里使用到了之前写好的 PopupDate 组件,代码如下:

  1. const Data = () => {
  2. const monthRef = useRef();
  3. const [currentMonth, setCurrentMonth] = useState(dayjs().format('YYYY-MM'));
  4. // 月份弹窗开关
  5. const monthShow = () => {
  6. monthRef.current && monthRef.current.show();
  7. };
  8. const selectMonth = (item) => {
  9. setCurrentMonth(item);
  10. };
  11. return <div className={s.data}>
  12. <div className={s.total}>
  13. <div className={s.time} onClick={monthShow}>
  14. <span>{currentMonth}</span>
  15. <Icon className={s.date} type="date" />
  16. </div>
  17. <div className={s.title}>共支出</div>
  18. <div className={s.expense}>¥1000</div>
  19. <div className={s.income}>共收入¥200</div>
  20. </div>
  21. <PopupDate ref={monthRef} mode="month" onSelect={selectMonth} />
  22. </div>
  23. }

账单单项排名制作

我们将账单排名部分的结构搭建出来,通过请求数据接口,将数据展示在页面上,代码如下:

  1. const Data = () => {
  2. ...
  3. const [totalType, setTotalType] = useState('expense'); // 收入或支出类型
  4. const [totalExpense, setTotalExpense] = useState(0); // 总支出
  5. const [totalIncome, setTotalIncome] = useState(0); // 总收入
  6. const [expenseData, setExpenseData] = useState([]); // 支出数据
  7. const [incomeData, setIncomeData] = useState([]); // 收入数据
  8. useEffect(() => {
  9. getData()
  10. }, [currentMonth]);
  11. // 获取数据详情
  12. const getData = async () => {
  13. const { data } = await get(`/api/bill/data?date=${currentMonth}`);
  14. // 总收支
  15. setTotalExpense(data.total_expense);
  16. setTotalIncome(data.total_income);
  17. // 过滤支出和收入
  18. const expense_data = data.total_data.filter(item => item.pay_type == 1).sort((a, b) => b.number - a.number); // 过滤出账单类型为支出的项
  19. const income_data = data.total_data.filter(item => item.pay_type == 2).sort((a, b) => b.number - a.number); // 过滤出账单类型为收入的项
  20. setExpenseData(expense_data);
  21. setIncomeData(income_data);
  22. };
  23. return <div className={s.data}>
  24. ...
  25. <div className={s.structure}>
  26. <div className={s.head}>
  27. <span className={s.title}>收支构成</span>
  28. <div className={s.tab}>
  29. <span onClick={() => changeTotalType('expense')} className={cx({ [s.expense]: true, [s.active]: totalType == 'expense' })}>支出</span>
  30. <span onClick={() => changeTotalType('income')} className={cx({ [s.income]: true, [s.active]: totalType == 'income' })}>收入</span>
  31. </div>
  32. </div>
  33. <div className={s.content}>
  34. {
  35. (totalType == 'expense' ? expenseData : incomeData).map(item => <div key={item.type_id} className={s.item}>
  36. <div className={s.left}>
  37. <div className={s.type}>
  38. <span className={cx({ [s.expense]: totalType == 'expense', [s.income]: totalType == 'income' })}>
  39. <CustomIcon
  40. type={item.type_id ? typeMap[item.type_id].icon : 1}
  41. />
  42. </span>
  43. <span className={s.name}>{ item.type_name }</span>
  44. </div>
  45. <div className={s.progress}>¥{ Number(item.number).toFixed(2) || 0 }</div>
  46. </div>
  47. <div className={s.right}>
  48. <div className={s.percent}>
  49. <Progress
  50. shape="line"
  51. percent={Number((item.number / Number(totalType == 'expense' ? totalExpense : totalIncome)) * 100).toFixed(2)}
  52. theme='primary'
  53. />
  54. </div>
  55. </div>
  56. </div>)
  57. }
  58. </div>
  59. </div>
  60. ...
  61. </div>
  62. }

上述是账单排名部分的代码部分,通过 getData 方法获取账单数据,接口字段分析:
image.png
首先我们需要传递日期参数 date,才能正常获取该月份的数据。
并将数据进行二次处理,将「收入」和「支出」分成两个数组保存。
通过 changeTotalType 方法,切换展示「收入」或「支出」。
通过对 Progress 组件的样式二次修改,样式代码如下:
image.png

饼图制作

接下来我们尝试引入 Echart,我们不通过 npm 引入它,我们尝试引入它的静态资源,找到根目录下的 index.html,添加如下代码:

  1. <!DOCTYPE html>
  2. <html lang="en">
  3. <head>
  4. <meta charset="UTF-8" />
  5. <link rel="icon" sizes="32x32" href="https://p1-jj.byteimg.com/tos-cn-i-t2oaga2asx/gold-assets/favicons/v2/favicon-32x32.png~tplv-t2oaga2asx-image.image">
  6. <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  7. <title>掘掘手札</title>
  8. </head>
  9. <body>
  10. <div id="root"></div>
  11. <script src="//s.yezgea02.com/1609305532675/echarts.js"></script>
  12. <script type="module" src="/src/main.jsx"></script>
  13. </body>
  14. </html>

这种引入方式,不会将 echart 打包到最终的入口脚本里。有同学会说可以按需引入,但是就算是按需引入,脚本也会变得很大,本身 echart 这类可视化工具库就非常大,因为内部使用了大量绘制图形的代码。
完成上述操作之后,我们尝试在 /Data/index.jsx 添加如下代码:

  1. let proportionChart = null; // 用于存放 echart 初始化返回的实例
  2. const Data = () => {
  3. ...
  4. const [pieType, setPieType] = useState('expense'); // 饼图的「收入」和「支出」控制
  5. useEffect(() => {
  6. getData();
  7. return () => {
  8. // 每次组件卸载的时候,需要释放图表实例。clear 只是将其清空不会释放。
  9. proportionChart.dispose();
  10. };
  11. }, [currentMonth]);
  12. // 绘制饼图方法
  13. const setPieChart = (data) => {
  14. if (window.echarts) {
  15. // 初始化饼图,返回实例。
  16. proportionChart = echarts.init(document.getElementById('proportion'));
  17. proportionChart.setOption({
  18. tooltip: {
  19. trigger: 'item',
  20. formatter: '{a} <br/>{b} : {c} ({d}%)'
  21. },
  22. // 图例
  23. legend: {
  24. data: data.map(item => item.type_name)
  25. },
  26. series: [
  27. {
  28. name: '支出',
  29. type: 'pie',
  30. radius: '55%',
  31. data: data.map(item => {
  32. return {
  33. value: item.number,
  34. name: item.type_name
  35. }
  36. }),
  37. emphasis: {
  38. itemStyle: {
  39. shadowBlur: 10,
  40. shadowOffsetX: 0,
  41. shadowColor: 'rgba(0, 0, 0, 0.5)'
  42. }
  43. }
  44. }
  45. ]
  46. })
  47. };
  48. };
  49. // 获取数据详情
  50. const getData = async () => {
  51. // ...
  52. // 绘制饼图
  53. setPieChart(pieType == 'expense' ? expense_data : income_data);
  54. };
  55. // 切换饼图收支类型
  56. const changePieType = (type) => {
  57. setPieType(type);
  58. // 重绘饼图
  59. setPieChart(type == 'expense' ? expenseData : incomeData);
  60. }
  61. return <div className={s.data}>
  62. ...
  63. <div className={s.structure}>
  64. <div className={s.proportion}>
  65. <div className={s.head}>
  66. <span className={s.title}>收支构成</span>
  67. <div className={s.tab}>
  68. <span onClick={() => changePieType('expense')} className={cx({ [s.expense]: true, [s.active]: pieType == 'expense' })}>支出</span>
  69. <span onClick={() => changePieType('income')} className={cx({ [s.income]: true, [s.active]: pieType == 'income' })}>收入</span>
  70. </div>
  71. </div>
  72. {/* 这是用于放置饼图的 DOM 节点 */}
  73. <div id="proportion"></div>
  74. </div>
  75. </div>
  76. </div>
  77. }

切换饼图「收入」和「支出」这里,使用了一个小技巧,每次调用 setPieChart 的时候,会将数据重新传入,此时的数据是经过 changePieType 接收的参数进行筛选的,如果形参 type 的值为 expense,那么给 setPieChart 传的参数为 expenseData,反之则为 incomeData。
注意,在页面销毁前,需要将实例清除。在 useEffect 内 return 一个函数,该函数就是在组件销毁时执行,在函数内部执行 proportionChart.dispose(); 对实例进行销毁操作。
最后,我们将头部的数据补上,如下所示:

  1. <div className={s.expense}>¥{ totalExpense }</div>
  2. <div className={s.income}>共收入¥{ totalIncome }</div>

以上就是账单统计的全部实现