From 54d46d7e6ba28959fa62cd217f0d14d25c00c823 Mon Sep 17 00:00:00 2001 From: Xiaolan Bot Date: Sun, 22 Feb 2026 22:18:43 +0800 Subject: [PATCH] Polish stats chart layout and readability --- SubMind.py | 120 ++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 81 insertions(+), 39 deletions(-) diff --git a/SubMind.py b/SubMind.py index 2cf1fb6..3fc3c94 100644 --- a/SubMind.py +++ b/SubMind.py @@ -425,12 +425,13 @@ def make_autopct(values, currency_code): async def stats(update: Update, context: CallbackContext): user_id = update.effective_user.id - await update.message.reply_text("正在为您生成更美观的统计图,请稍候...") + await update.message.reply_text("正在生成更清晰、更好看的统计图,请稍候...") font_prop = get_chinese_font() main_currency = get_user_main_currency(user_id) with get_db_connection() as conn: df = pd.read_sql_query("SELECT * FROM subscriptions WHERE user_id = ?", conn, params=(user_id,)) + if df.empty: await update.message.reply_text("您还没有任何订阅数据。") return @@ -453,7 +454,7 @@ async def stats(update: Update, context: CallbackContext): await update.message.reply_text("您的订阅没有有效的费用信息。") return - max_categories = 8 + max_categories = 7 if len(category_costs) > max_categories: top = category_costs.iloc[:max_categories] others_sum = category_costs.iloc[max_categories:].sum() @@ -463,92 +464,133 @@ async def stats(update: Update, context: CallbackContext): category_costs = top total_monthly = category_costs.sum() + active_subs_count = int((df['monthly_cost'] > 0).sum()) + top_category = category_costs.index[0] + top_ratio = (category_costs.iloc[0] / total_monthly * 100) if total_monthly else 0 + 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}' + def format_money(value): + return f"{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]) + def autopct_if_large(pct): + if pct < 5: + return '' + return f'{pct:.1f}%' + + fig = plt.figure(figsize=(15, 9), facecolor='#f8fafc') + gs = fig.add_gridspec(2, 2, height_ratios=[0.22, 1], width_ratios=[1.05, 1], hspace=0.08, wspace=0.16) + + ax_header = fig.add_subplot(gs[0, :]) + ax_pie = fig.add_subplot(gs[1, 0]) + ax_bar = fig.add_subplot(gs[1, 1]) image_path = None try: - colors = list(plt.get_cmap('tab20').colors)[:len(category_costs)] + base_colors = ['#4f46e5', '#0ea5e9', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#14b8a6', '#f97316'] + colors = base_colors[:len(category_costs)] - _, texts, autotexts = ax_pie.pie( + # 顶部信息栏 + ax_header.set_facecolor('#ffffff') + ax_header.set_xticks([]) + ax_header.set_yticks([]) + for spine in ax_header.spines.values(): + spine.set_visible(False) + + header_title = '每月订阅支出概览' + header_desc = ( + f"总月支出:{format_money(total_monthly)} " + f"活跃订阅:{active_subs_count} 个 " + f"最大类别:{top_category}({top_ratio:.1f}%)" + ) + ax_header.text(0.01, 0.66, header_title, fontproperties=font_prop, fontsize=21, color='#0f172a', weight='bold') + ax_header.text(0.01, 0.20, header_desc, fontproperties=font_prop, fontsize=12, color='#334155') + + # 环形图:展示结构 + wedges, _, autotexts = ax_pie.pie( category_costs.values, - labels=category_costs.index, + labels=None, autopct=autopct_if_large, - startangle=120, + startangle=100, counterclock=False, - pctdistance=0.78, - labeldistance=1.08, + pctdistance=0.80, colors=colors, - wedgeprops={'width': 0.42, 'edgecolor': 'white', 'linewidth': 1.2} + wedgeprops={'width': 0.42, 'edgecolor': 'white', 'linewidth': 1.5} ) - 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') + t.set_color('#0f172a') ax_pie.text( 0, 0, - f"月总支出\n{symbol}{total_monthly:.2f}", + f"月总支出\n{format_money(total_monthly)}", ha='center', va='center', fontproperties=font_prop, - fontsize=16, + fontsize=15, color='#111827', weight='bold' ) - ax_pie.set_title('订阅支出结构(按类别)', fontproperties=font_prop, fontsize=16, pad=14, color='#111827') + ax_pie.set_title('支出结构(类别占比)', fontproperties=font_prop, fontsize=16, pad=14, color='#0f172a') ax_pie.axis('equal') + legend_labels = [f"{name} · {format_money(value)}" for name, value in category_costs.items()] + legend = ax_pie.legend( + wedges, + legend_labels, + title='类别 / 月支出', + loc='center left', + bbox_to_anchor=(0.95, 0.5), + frameon=False, + fontsize=10 + ) + if legend and legend.get_title(): + legend.get_title().set_fontproperties(font_prop) + legend.get_title().set_fontsize(11) + for txt in legend.get_texts(): + txt.set_fontproperties(font_prop) + + # 横向柱状图:展示对比 bar_series = category_costs.sort_values(ascending=True) - bars = ax_bar.barh(bar_series.index, bar_series.values, color=colors[:len(bar_series)], alpha=0.9) - ax_bar.set_title('类别月支出对比', fontproperties=font_prop, fontsize=16, pad=14, color='#111827') - 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) + bars = ax_bar.barh(bar_series.index, bar_series.values, color=colors[:len(bar_series)], alpha=0.95) + ax_bar.set_title('类别月支出对比', fontproperties=font_prop, fontsize=16, pad=14, color='#0f172a') + ax_bar.set_xlabel(f'金额({main_currency.upper()})', fontproperties=font_prop, fontsize=11, color='#475569') + ax_bar.tick_params(axis='x', colors='#64748b') + ax_bar.tick_params(axis='y', colors='#0f172a') + ax_bar.grid(axis='x', linestyle='--', alpha=0.22, color='#94a3b8') + ax_bar.set_axisbelow(True) + for label in ax_bar.get_yticklabels(): label.set_fontproperties(font_prop) label.set_fontsize(11) max_val = bar_series.max() if len(bar_series) else 0 - offset = max_val * 0.015 if max_val > 0 else 0.1 + offset = max(max_val * 0.015, 0.08) + ax_bar.set_xlim(0, max_val * 1.22 if max_val > 0 else 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}", + format_money(value), va='center', ha='left', fontproperties=font_prop, fontsize=10, - color='#1f2937' + color='#1e293b' ) - fig.suptitle('每月订阅支出统计', fontproperties=font_prop, fontsize=20, color='#0f172a', y=0.98) - fig.tight_layout(rect=[0, 0, 1, 0.96]) + fig.tight_layout(rect=[0.01, 0.01, 0.99, 0.98]) with tempfile.NamedTemporaryFile(prefix=f'stats_{user_id}_', suffix='.png', delete=False) as tmp: image_path = tmp.name - plt.savefig(image_path, dpi=220, bbox_inches='tight', facecolor=fig.get_facecolor()) + plt.savefig(image_path, dpi=240, bbox_inches='tight', facecolor=fig.get_facecolor()) with open(image_path, 'rb') as photo: - await update.message.reply_photo(photo, caption="这是优化后的订阅月支出统计图(结构 + 对比)。") + await update.message.reply_photo(photo, caption="已为你优化统计图样式(概览 + 结构 + 对比)。") finally: plt.close(fig) if image_path and os.path.exists(image_path):