本文設計了可視化賬本界面,將其分為總額統計、收支結構兩個標簽頁。總額統計中主要展示選定時間段內的收支總額、各存款賬戶的收支情況,還通過柱狀圖的形式展示變化趨勢;收支結構中通過餅圖的形式展示選定時間段內各收入/支出類型所占比例。本界面通過多個Dataframe類的私有屬性存放各動賬記錄,方便應對各種場... ...
一、項目地址
https://github.com/LinFeng-BingYi/DailyAccountBook
二、新增
1. 完成可視化賬本界面設計
1.1 功能詳述
可視化賬本的需求:
- 一段時間內支出總額、變化趨勢、支出結構;
- 一段時間內收入總額、變化趨勢、收入結構;
- 一段時間內凈收入總額、變化趨勢;
- 總資產,分為可用資產和固定資產,忽略非本人名下資產(特指代管存款,如家庭基金);
- 各存款賬戶的餘額,以及各賬戶在一段時間內收支總額;
- 某支出/收入類型在一段時間內的變化趨勢、所有記錄。
界面設計上,分為兩個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"兩個子元素:
目的是方便識別、管理。額度類(如會員卡內餘額、手機話費餘額)、不動產類(如黃金、商品房)將“可支配”設為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。
展開時的樣子:
收起時的樣子:
由於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筆記
本文來自博客園,作者:林風冰翼,轉載請註明原文鏈接:https://www.cnblogs.com/LinfengBingyi/p/17785720.html