代码改变世界

【日常收支账本】【Day06】设计可视化账本界面——用Dataframe存放各动账记录,并用QChart展示数据

2023-11-13 22:56  林风冰翼  阅读(193)  评论(0编辑  收藏  举报

一、项目地址

https://github.com/LinFeng-BingYi/DailyAccountBook

二、新增

1. 完成可视化账本界面设计

1.1 功能详述

可视化账本的需求:

  • 一段时间内支出总额、变化趋势、支出结构;
  • 一段时间内收入总额、变化趋势、收入结构;
  • 一段时间内净收入总额、变化趋势;
  • 总资产,分为可用资产和固定资产,忽略非本人名下资产(特指代管存款,如家庭基金);
  • 各存款账户的余额,以及各账户在一段时间内收支总额;
  • 某支出/收入类型在一段时间内的变化趋势、所有记录。

界面设计上,分为两个tab页——“总额统计”、“收支结构”。
总额统计tab页中,以数字形式展示支出总额、收入总额、净收入总额、总资产,以表格形式展示各存款账户的余额,以及各账户在一段时间内收支总额,以条形图形式展示支出、收入、净收入的变化趋势。
总额统计tab页

收支结构tab页中,以饼图形式展示支出结构、收入结构,点击饼图中某收支类型的切片,再弹窗展示该类型在一段时间内的变化趋势、所有记录。(开发中)
收支结构tab页

1.2 代码实现

通过QDesigner绘制。

2. 自定义QChartView,用于展示收支情况

2.1 功能详述

为便于展示收支趋势,自定义继承于 QChartView 的 StatisticBarChartView 类。

选择继承 QChartView 而不是 QChart 的原因:QChartView 是 QGraphicsView 的子类,可以传入QLayout.addWidget(),方便自定义布局,而 QChart 无法实现。

2.2 代码实现

class StatisticBarChartView(QChartView):
    def __init__(self, parent=None):
        super().__init__()
        self.parent = parent

        self.chart = QChart()
        self.chart.setAnimationOptions(QChart.AnimationOption.SeriesAnimations)
        self.setChart(self.chart)
        # 坐标轴
        self._axisX = None
        self._axisY = None

        # 存入条形图数据集合set的列表(由于更新条形图所用的QBarSeries.clear()方法会销毁QBarSet对象,故此处通过创建list避免被销毁)
        self.bar_set_expense_list = []
        self.bar_set_income_list = []
        self.bar_set_net_list = []
        # 条形图序列series
        self.bar_series = QBarSeries()
        # x轴时间尺度
        self.time_scale_now = "month"
        self.time_scale = {"year": "yyyy", "month": "yyyy/MM", "week": "", "day": "MM/dd"}

        self.initWidget()
        self.bindSignal()
代码过长,展开查看剩余代码:

    def initWidget(self):
        # 标题和标签
        self.chart.setTitle("总额统计")
        # self.chart.legend().hide()

        # 初始化图表
        this_year = QDate.currentDate().year()
        df_init_expense = pd.DataFrame(
            {"date": [datetime.strptime(f"{this_year}{i:0>2}01", "%Y%m%d") for i in range(1, 13)], "value": [1500, 1800] * 6})
        df_init_income = pd.DataFrame(
            {"date": [datetime.strptime(f"{this_year}{i:0>2}01", "%Y%m%d") for i in range(1, 13)], "value": [6800, 6500] * 6})
        df_init_net = pd.DataFrame(
            {"date": [datetime.strptime(f"{this_year}{i:0>2}01", "%Y%m%d") for i in range(1, 13)], "value": [5300, 4300] * 6})
        self.updateBarSeries(df_init_expense, df_init_income, df_init_net)
        # 将series添加到chart中
        self.chart.addSeries(self.bar_series)

    def bindSignal(self):
        self.bar_series.hovered.connect(self.onSeriesHovered)

    def onSeriesHovered(self, state, index, bar_set: QBarSet):
        """
        Describe: 鼠标悬停series事件处理函数

        Args:
            state: bool
                表示鼠标是否悬停在series上。鼠标悬停时为True,离开后变为False
            index: int
                表示鼠标当前所悬停的条形,在条形集合中的编号
            bar_set: PySide6.QtCharts.QBarSet
                表示鼠标当前所悬停的条形集合类别
        """
        # print("悬停series的状态:", state)
        if state:
            QToolTip.showText(QCursor.pos(), "%s\n%s\n%s" %
                              (bar_set.label(),
                               self._axisX.categories()[index],
                               bar_set.at(index)))

    def createStatisticBarChartAxes(self, x_label_list, y_range):
        """
        Describe: 创建统计条形图坐标轴

        Args:
            x_label_list: list
                x轴标签列表
            y_range: tuple
                y轴范围
        """
        # 先删除旧坐标轴
        self.chart.removeAxis(self._axisX)
        self.chart.removeAxis(self._axisY)
        # 创建坐标轴
        self._axisX = QBarCategoryAxis()
        self._axisX.append(x_label_list)
        self._axisY = QValueAxis()
        self._axisY.setRange(y_range[0], y_range[1])
        # 加入坐标轴并绑定
        self.chart.addAxis(self._axisX, Qt.AlignmentFlag.AlignBottom)
        self.chart.addAxis(self._axisY, Qt.AlignmentFlag.AlignLeft)
        # 绑定series到坐标轴
        self.bar_series.attachAxis(self._axisX)
        self.bar_series.attachAxis(self._axisY)

    def updateBarSeries(self, df_expense, df_income, df_net):
        """
        Describe: 用新数据更新series

        Args:
            df_expense: pandas.DataFrame
                支出数据
            df_income: pandas.DataFrame
                收入数据
            df_net: pandas.DataFrame
                净收入数据
        """
        # 清除series
        self.bar_series.clear()
        # 创建QBarSet
        bar_set_expense = QBarSet("支出", self.chart)
        bar_set_expense.append(df_expense['value'].tolist())
        bar_set_income = QBarSet("收入", self.chart)
        bar_set_income.append(df_income['value'].tolist())
        bar_set_net = QBarSet("净收入", self.chart)
        bar_set_net.append(df_net['value'].tolist())
        # print("条形图净收入的数据:", df_net['value'].tolist())
        # 将QBarSet加入series
        self.bar_series.append(bar_set_expense)
        self.bar_series.append(bar_set_income)
        self.bar_series.append(bar_set_net)

    def displayAllBarChart(self, df_expense, df_income, df_net):
        """
        Describe: 展示新条形图

        Args:
            df_expense: pandas.DataFrame
                支出数据
            df_income: pandas.DataFrame
                收入数据
            df_net: pandas.DataFrame
                净收入数据
        """
        self.updateBarSeries(df_expense, df_income, df_net)

        # x轴labels
        date_str_list = [convertPandasToQDateTime(date).toString(self.time_scale[self.time_scale_now])
                         for date in df_net['date']]
        # y轴范围
        axis_y_range = (min(0, df_net['value'].min()), max(df_expense['value'].max(), df_income['value'].max()))
        self.createStatisticBarChartAxes(date_str_list, axis_y_range)

    def scalingDfAndDisplay(self, df_expense, df_income, df_net, time_scale):
        """
        Describe: 根据时间尺度调整数据,并展示结果

        Args:
            df_expense: pandas.DataFrame
                支出数据
            df_income: pandas.DataFrame
                收入数据
            df_net: pandas.DataFrame
                净收入数据
            time_scale: str['year', 'month', 'day']
                时间尺度
        """
        if time_scale not in ['year', 'month', 'day']:
            print("不支持的时间尺度!!")
            raise AttributeError("不支持的时间尺度!!")
        # print("传入的时间尺度为:", time_scale)
        self.time_scale_now = time_scale
        #  如果是天数尺度,直接展示
        if time_scale == 'day':
            self.displayAllBarChart(df_expense, df_income, df_net)
            return
        # 根据时间尺度调整数据
        # 默认为月尺度
        group_by_pattern = '%Y-%m'
        if time_scale == 'year':
            group_by_pattern = '%Y'
        df_expense = scalingDfByTime(df_expense, group_by_pattern)
        df_income = scalingDfByTime(df_income, group_by_pattern)
        df_net = scalingDfByTime(df_net, group_by_pattern)
        # print("根据时间尺度调整后的数据:")
        # print(df_expense)
        # print(df_net)
        self.displayAllBarChart(df_expense, df_income, df_net)

三、修改

1. XML文件中存款账户增加信息

1.1 修改内容

XMl文件中,每个存款账户元素节点增加了"ignore"、"disposable"两个子元素:
image

目的是方便识别、管理。额度类(如会员卡内余额、手机话费余额)、不动产类(如黄金、商品房)将“可支配”设为False,代理存款将“不计入”设为True。

对于额度类存款,用户也可以认为该类账务属于已被消费的资产,一旦充值,便不属于自有资产,此时,该笔充值的动账记录应算作“支出”类别;否则,若用户认为该类账务的充值属于一种资产的转移,其性质仍然属于自有资产,那么该笔充值的动作记录应算作“转移”类别。一切都依据自身认知而定。

四、开发总结

1. Dataframe操作

当需要展示数据时,需要进行各种数据切片(保留某几行/列数据)、按字段值筛选(查看某时间段内数据)、列之间运算(计算净收入)等复杂操作。此时,选择将XML中的记录存储到pandas的DataFrame数据类型中,因为其提供了许多方便的函数可以实现以上需求。

1.1 数据切片

需求描述

现有表示支出的DataFrame对象df_expense,其中有字段"date"、"value"、"category"。当展示支出总额时,只需保留"date"、"value"两个字段,并计算"value"字段值总和。

所用方法

使用DateFrame.iloc[ ]DateFrame.loc[ ]来实现。

代码实现

用法请参考,简单易懂:Pandas入门-2-loc & iloc

df_expense.loc[:, ["date", "value"]]

1.2 按字段值筛选

需求描述

现有表示支出的DataFrame对象df_expense,其中有字段"date"、"value"、"category"。此时,需要得到start_date到end_date之间的所有非不动产(category不在ignore_list中)的动账记录数据。

所用方法

loc[ ]方法功能性足够强大,也可以根据多个字段值内容筛选数据,参阅:DataFrame按条件筛选、修改数据:df.loc[]拓展

代码实现

df_expense.loc[(df_expense['date'].between(start_date, end_date)) &
               (action_df['category'].isin(ignore_category) == False)]

1.2 列之间运算

需求描述

现有表示支出的DataFrame对象df_expense、表示收入的DataFrame对象df_income,均含有字段"date"、"value"。此时,需要通过df_income中某日期的收入总额减去df_expense中对应日期的支出总额,以得到表示净收入的DataFrame对象df_net。

⚠ 注意:
在df_expense与df_income中,同一日期可以包含多条记录,因此,需要按日期分组得到每个日的总额。此外,也可能不存在某日期的数据,例如,2023/11/13日,df_expense中有value=10的记录,而df_income中该日没有记录,那么计算时,应先为df_income创建改日的数据,并设置value=0,再执行运算。

所用方法

而我们可以通过groupby()join()方法,像操作数据库表那样操作DateFrame:对df_expense与df_income按date字段分组,再将分组后的DateFrame按date字段连接,之后再新增value字段,存放计算结果,最后提取出date和value字段,得到最终结果。

代码实现

# 按日分组收支记录,得到每日总的收入与支出,以便计算每日净收入
df_expense_grouped = df_expense.groupby('date').sum().reset_index()
df_income_grouped = df_income.groupby('date').sum().reset_index()
# 根据支出Dataframe与收入Dataframe进行join操作,以便得到净收入收入Dataframe
df_net = df_expense_grouped.set_index('date').join(df_income_grouped.set_index('date'),
                                                   rsuffix='_income', how='outer')
# 新增一列df_net.value,其值为df_income.value减去df_expense.value
df_net['value'] = df_net['value_income'].sub(df_net['value'], fill_value=0)
# 按date字段join后,date则变成了index,此时只需提取value字段
df_net = df_net[['value']]
# 重命名index为date,并将其从index设为column
df_net.index.name = 'date'
df_net = df_net.reset_index()

2. PySide6界面区域隐藏和显示

通过按钮控制界面某区域隐藏和显示:

    def changeStatisticChartExpand(self):
        """
        Describe: 控制总额统计tab的图表配置区域是否展开
        """
        self.flag_expand_config_area = not self.flag_expand_config_area
        self.widget_config_panel.setVisible(self.flag_expand_config_area)
        if self.flag_expand_config_area:    # 展开
            self.pushButton_expand_config_area.setText(">")
            self.widget_chart_config.setMinimumWidth(150)
            self.pushButton_expand_config_area.setMinimumHeight(30)
        else:                               # 隐藏
            self.pushButton_expand_config_area.setText("<")
            self.widget_chart_config.setMinimumWidth(35)
            self.pushButton_expand_config_area.setMinimumHeight(350)

代码解释:初始化本窗口时,用于判断总额统计tab的图表配置区域是否展开的属性self.flag_expand_config_area = True,将按钮self.pushButton_expand_config_area与该方法关联。按下按钮后,根据当前状态设置各控件属性,达到展开和隐藏的效果。
核心代码是self.widget_config_panel.setVisible(self.flag_expand_config_area)方法。

QWidget对象self.widget_config_panel表示被控制的区块,它所在区域层级如下:
horizontalLayout_chart_area是整个区域的一个水平布局QHLayout对象,它左边是QWidget对象widget_chart_display用于显示图表,右边是QWidget对象widget_chart_config,用于控制图表;
verticalLayout_chart_config是widget_chart_config的内部垂直布局QVLayout对象,它上边是主角self.widget_config_panel,下边是控制主角收放的按钮pushButton_expand_config_area。

展开时的样子:
image

收起时的样子:
image

由于self.widget_config_panel被隐藏,且设置了按钮pushButton_expand_config_area的大小,因此,布局对象verticalLayout_chart_config会自动收缩以适应按钮的大小,同时右边的控件widget_chart_display也会伸展,最终达到隐藏目标区块的效果。

3. 自定义QChartView

通过自定义QChartView以满足展示数据的需求。

选择继承 QChartView 而不是 QChart 的原因:QChartView 是 QGraphicsView 的子类,可以传入QLayout.addWidget(),方便自定义布局,而 QChart 无法实现。

QChart模块的使用参阅合集: 【PySide6】QChart笔记