feat: redesign stats chart with donut + comparison bars

This commit is contained in:
Xiaolan Bot
2026-02-22 22:01:06 +08:00
parent f064f751f0
commit ee1a5b59b0

View File

@@ -425,7 +425,7 @@ def make_autopct(values, currency_code):
async def stats(update: Update, context: CallbackContext): async def stats(update: Update, context: CallbackContext):
user_id = update.effective_user.id user_id = update.effective_user.id
await update.message.reply_text("正在为您生成订阅统计图,请稍候...") await update.message.reply_text("正在为您生成更美观的统计图,请稍候...")
font_prop = get_chinese_font() font_prop = get_chinese_font()
main_currency = get_user_main_currency(user_id) main_currency = get_user_main_currency(user_id)
@@ -453,41 +453,102 @@ async def stats(update: Update, context: CallbackContext):
await update.message.reply_text("您的订阅没有有效的费用信息。") await update.message.reply_text("您的订阅没有有效的费用信息。")
return return
plt.style.use('seaborn-v0_8-darkgrid') max_categories = 8
fig, ax = plt.subplots(figsize=(12, 12)) if len(category_costs) > max_categories:
top = category_costs.iloc[:max_categories]
others_sum = category_costs.iloc[max_categories:].sum()
if others_sum > 0:
category_costs = pd.concat([top, pd.Series({'其他': others_sum})])
else:
category_costs = top
total_monthly = category_costs.sum()
currency_symbols = {'USD': '$', 'CNY': '¥', 'EUR': '', 'GBP': '£', 'JPY': '¥'}
symbol = currency_symbols.get(main_currency.upper(), f'{main_currency.upper()} ')
def autopct_if_large(pct):
if pct < 4:
return ''
value = total_monthly * pct / 100
return f'{pct:.1f}%\n{symbol}{value:.2f}'
fig = plt.figure(figsize=(14, 8), facecolor='#f8fafc')
gs = fig.add_gridspec(1, 2, width_ratios=[1.2, 1], wspace=0.12)
ax_pie = fig.add_subplot(gs[0, 0])
ax_bar = fig.add_subplot(gs[0, 1])
image_path = None image_path = None
try: try:
autopct_function = make_autopct(category_costs.values, main_currency) colors = list(plt.get_cmap('tab20').colors)[:len(category_costs)]
wedges, texts, autotexts = ax.pie(category_costs.values, _, texts, autotexts = ax_pie.pie(
category_costs.values,
labels=category_costs.index, labels=category_costs.index,
autopct=autopct_function, autopct=autopct_if_large,
startangle=140, startangle=120,
pctdistance=0.7, counterclock=False,
labeldistance=1.05) pctdistance=0.78,
labeldistance=1.08,
colors=colors,
wedgeprops={'width': 0.42, 'edgecolor': 'white', 'linewidth': 1.2}
)
ax.set_title('每月订阅支出分类统计', fontproperties=font_prop, fontsize=32, pad=20) for t in texts:
t.set_fontproperties(font_prop)
t.set_fontsize(12)
t.set_color('#1f2937')
for t in autotexts:
t.set_fontproperties(font_prop)
t.set_fontsize(10)
t.set_color('#111827')
for text in texts: ax_pie.text(
text.set_fontproperties(font_prop) 0, 0,
text.set_fontsize(22) f"月总支出\n{symbol}{total_monthly:.2f}",
ha='center', va='center',
fontproperties=font_prop,
fontsize=16,
color='#111827',
weight='bold'
)
ax_pie.set_title('订阅支出结构(按类别)', fontproperties=font_prop, fontsize=16, pad=14, color='#111827')
ax_pie.axis('equal')
for autotext in autotexts: bar_series = category_costs.sort_values(ascending=True)
autotext.set_fontproperties(font_prop) bars = ax_bar.barh(bar_series.index, bar_series.values, color=colors[:len(bar_series)], alpha=0.9)
autotext.set_fontsize(20) ax_bar.set_title('类别月支出对比', fontproperties=font_prop, fontsize=16, pad=14, color='#111827')
autotext.set_color('white') ax_bar.set_xlabel(f'金额({main_currency.upper()}', fontproperties=font_prop, fontsize=11, color='#374151')
ax_bar.tick_params(axis='x', colors='#6b7280')
ax_bar.tick_params(axis='y', colors='#111827')
ax_bar.grid(axis='x', linestyle='--', alpha=0.25)
for label in ax_bar.get_yticklabels():
label.set_fontproperties(font_prop)
label.set_fontsize(11)
ax.axis('equal') max_val = bar_series.max() if len(bar_series) else 0
fig.tight_layout() offset = max_val * 0.015 if max_val > 0 else 0.1
for rect, value in zip(bars, bar_series.values):
ax_bar.text(
rect.get_width() + offset,
rect.get_y() + rect.get_height() / 2,
f"{symbol}{value:.2f}",
va='center',
ha='left',
fontproperties=font_prop,
fontsize=10,
color='#1f2937'
)
fig.suptitle('每月订阅支出统计', fontproperties=font_prop, fontsize=20, color='#0f172a', y=0.98)
fig.tight_layout(rect=[0, 0, 1, 0.96])
with tempfile.NamedTemporaryFile(prefix=f'stats_{user_id}_', suffix='.png', delete=False) as tmp: with tempfile.NamedTemporaryFile(prefix=f'stats_{user_id}_', suffix='.png', delete=False) as tmp:
image_path = tmp.name image_path = tmp.name
plt.savefig(image_path) plt.savefig(image_path, dpi=220, bbox_inches='tight', facecolor=fig.get_facecolor())
with open(image_path, 'rb') as photo: with open(image_path, 'rb') as photo:
await update.message.reply_photo(photo, caption="这是您按类别统计的每月订阅支出。") await update.message.reply_photo(photo, caption="这是优化后的订阅支出统计图(结构 + 对比)")
finally: finally:
plt.close(fig) plt.close(fig)
if image_path and os.path.exists(image_path): if image_path and os.path.exists(image_path):