前言

账单的操作部分在之前的章节已经结束了,本章节我们学习如何将账单列表,以可视化数据的新形势展示,本章节我们会通过 Echart 插件,对数据进行可视化展示。

页面布局和分析如下所示:

img

知识点

  • Echart 引入和使用。
  • 进度条组件 Progress 的使用。

正文

头部筛选和数据实现

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

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
import React, { useEffect, useRef, useState } from "react";
import { Icon, Progress } from "zarm";
import cx from "classnames";
import dayjs from "dayjs";
import { get, typeMap } from "@/utils";
import CustomIcon from "@/components/CustomIcon";
import PopupDate from "@/components/PopupDate";
import s from "./style.module.less";

const Data = () => {
return (
<div className={s.data}>
<div className={s.total}>
<div className={s.time}>
<span>2021-06</span>
<Icon className={s.date} type="date" />
</div>
<div className={s.title}>共支出</div>
<div className={s.expense}>¥1000</div>
<div className={s.income}>共收入¥200</div>
</div>
</div>
);
};

export default Data;

头部的一些引入是为后面的代码所用,在这里事先引入,避免后面重复出现。

上述代码为头部统计的页面布局,同样采用的 flex 布局,样式部分如下:

文末已为同学们提供下本章节 demo 代码,样式部分不再详细说明。

样式部分有一个小技巧需要注意,日期后面的小竖线,如下所示:

img

在业务中,类似这样的需求非常多,这里我们可以使用伪类 ::before::after 去实现,减少在页面中再添加一些多余的标签。上述代码实现的逻辑是在日期的 span 上加上 ::after,如下所示:

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

给一个空的 content,再给上 1px 的宽度,颜色和上下距离可以根据需求调整。

苦口婆心的我再次强调,flex 布局的重要性,一定要把它吃透,至少在布局的时候,你可以灵活的运用横竖布局。

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

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
const Data = () => {
const monthRef = useRef();
const [currentMonth, setCurrentMonth] = useState(dayjs().format("YYYY-MM"));

// 月份弹窗开关
const monthShow = () => {
monthRef.current && monthRef.current.show();
};

const selectMonth = (item) => {
setCurrentMonth(item);
};

return (
<div className={s.data}>
<div className={s.total}>
<div className={s.time} onClick={monthShow}>
<span>{currentMonth}</span>
<Icon className={s.date} type="date" />
</div>
<div className={s.title}>共支出</div>
<div className={s.expense}>¥1000</div>
<div className={s.income}>共收入¥200</div>
</div>
<PopupDate ref={monthRef} mode="month" onSelect={selectMonth} />
</div>
);
};

给日期按钮添加 monthShow 点击事件,调出 PopupDate 弹窗。并且,通过 selectMonth 方法,设置好选择的月份,展示于页面之上。效果如下所示:

img

账单单项排名制作

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

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
const Data = () => {
...
const [totalType, setTotalType] = useState('expense'); // 收入或支出类型
const [totalExpense, setTotalExpense] = useState(0); // 总支出
const [totalIncome, setTotalIncome] = useState(0); // 总收入
const [expenseData, setExpenseData] = useState([]); // 支出数据
const [incomeData, setIncomeData] = useState([]); // 收入数据

useEffect(() => {
getData()
}, [currentMonth]);

// 获取数据详情
const getData = async () => {
const { data } = await get(`/api/bill/data?date=${currentMonth}`);

// 总收支
setTotalExpense(data.total_expense);
setTotalIncome(data.total_income);

// 过滤支出和收入
const expense_data = data.total_data.filter(item => item.pay_type == 1).sort((a, b) => b.number - a.number); // 过滤出账单类型为支出的项
const income_data = data.total_data.filter(item => item.pay_type == 2).sort((a, b) => b.number - a.number); // 过滤出账单类型为收入的项
setExpenseData(expense_data);
setIncomeData(income_data);
};

return <div className={s.data}>
...
<div className={s.structure}>
<div className={s.head}>
<span className={s.title}>收支构成</span>
<div className={s.tab}>
<span onClick={() => changeTotalType('expense')} className={cx({ [s.expense]: true, [s.active]: totalType == 'expense' })}>支出</span>
<span onClick={() => changeTotalType('income')} className={cx({ [s.income]: true, [s.active]: totalType == 'income' })}>收入</span>
</div>
</div>
<div className={s.content}>
{
(totalType == 'expense' ? expenseData : incomeData).map(item => <div key={item.type_id} className={s.item}>
<div className={s.left}>
<div className={s.type}>
<span className={cx({ [s.expense]: totalType == 'expense', [s.income]: totalType == 'income' })}>
<CustomIcon
type={item.type_id ? typeMap[item.type_id].icon : 1}
/>
</span>
<span className={s.name}>{ item.type_name }</span>
</div>
<div className={s.progress}>¥{ Number(item.number).toFixed(2) || 0 }</div>
</div>
<div className={s.right}>
<div className={s.percent}>
<Progress
shape="line"
percent={Number((item.number / Number(totalType == 'expense' ? totalExpense : totalIncome)) * 100).toFixed(2)}
theme='primary'
/>
</div>
</div>
</div>)
}
</div>
</div>
...
</div>
}

上述是账单排名部分的代码部分,通过 getData 方法获取账单数据,接口字段分析:

img

首先我们需要传递日期参数 date,才能正常获取该月份的数据。

  • number: 账单金额。
  • pay_type:账单类型,1 为支出,2 为收入。
  • type_id:账单种类 id。
  • type_name:账单种类名称,如购物、交通、医疗等。

并将数据进行二次处理,将「收入」和「支出」分成两个数组保存。

通过 changeTotalType 方法,切换展示「收入」或「支出」。

通过对 Progress 组件的样式二次修改,样式代码如下:

文末已为同学们提供下本章节 demo 代码,样式部分不再详细说明。

将组件展示效果改成如下所示:

img

饼图制作

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<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">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>掘掘手札</title>
</head>
<body>
<div id="root"></div>
<script src="//s.yezgea02.com/1609305532675/echarts.js"></script>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

这种引入方式,不会将 echart 打包到最终的入口脚本里。有同学会说可以按需引入,但是就算是按需引入,脚本也会变得很大,本身 echart 这类可视化工具库就非常大,因为内部使用了大量绘制图形的代码。

完成上述操作之后,我们尝试在 /Data/index.jsx 添加如下代码:

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
let proportionChart = null; // 用于存放 echart 初始化返回的实例

const Data = () => {
...
const [pieType, setPieType] = useState('expense'); // 饼图的「收入」和「支出」控制

useEffect(() => {
getData();
return () => {
// 每次组件卸载的时候,需要释放图表实例。clear 只是将其清空不会释放。
proportionChart.dispose();
};
}, [currentMonth]);

// 绘制饼图方法
const setPieChart = (data) => {
if (window.echarts) {
// 初始化饼图,返回实例。
proportionChart = echarts.init(document.getElementById('proportion'));
proportionChart.setOption({
tooltip: {
trigger: 'item',
formatter: '{a} <br/>{b} : {c} ({d}%)'
},
// 图例
legend: {
data: data.map(item => item.type_name)
},
series: [
{
name: '支出',
type: 'pie',
radius: '55%',
data: data.map(item => {
return {
value: item.number,
name: item.type_name
}
}),
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.5)'
}
}
}
]
})
};
};

// 获取数据详情
const getData = async () => {
// ...
// 绘制饼图
setPieChart(pieType == 'expense' ? expense_data : income_data);
};

// 切换饼图收支类型
const changePieType = (type) => {
setPieType(type);
// 重绘饼图
setPieChart(type == 'expense' ? expenseData : incomeData);
}

return <div className={s.data}>
...
<div className={s.structure}>
<div className={s.proportion}>
<div className={s.head}>
<span className={s.title}>收支构成</span>
<div className={s.tab}>
<span onClick={() => changePieType('expense')} className={cx({ [s.expense]: true, [s.active]: pieType == 'expense' })}>支出</span>
<span onClick={() => changePieType('income')} className={cx({ [s.income]: true, [s.active]: pieType == 'income' })}>收入</span>
</div>
</div>
{/* 这是用于放置饼图的 DOM 节点 */}
<div id="proportion"></div>
</div>
</div>
</div>
}

切换饼图「收入」和「支出」这里,我使用了一个小技巧,每次调用 setPieChart 的时候,会将数据重新传入,此时的数据是经过 changePieType 接收的参数进行筛选的,如果形参 type 的值为 expense,那么给 setPieChart 传的参数为 expenseData,反之则为 incomeData

注意,在页面销毁前,需要将实例清除。在 useEffectreturn 一个函数,该函数就是在组件销毁时执行,在函数内部执行 proportionChart.dispose(); 对实例进行销毁操作。

最后,我们将头部的数据补上,如下所示:

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

浏览器展示如下:

img

总结

可视化的形式还有很多,相关的可视化库有 three.js、d3.js、highchart.js 等等,这些内容都值得你去深入,但是在此之前,希望同学们能明确自己希望深入那一方面的知识,进行深度学习。比如张鑫旭大神,对 CSS 的研究,入骨三分,我相信业务无人出其右。所以找准方向,往前冲。

[嘿哈]