diff --git a/SubMind.py b/SubMind.py index 3fc3c94..dd4a3de 100644 --- a/SubMind.py +++ b/SubMind.py @@ -64,23 +64,34 @@ def get_chinese_font(): logger.info(f"Font '{font_name}' not found. Attempting to download...") os.makedirs('fonts', exist_ok=True) - url = 'https://github.com/wweir/source-han-sans-sc/raw/refs/heads/master/SourceHanSansSC-Regular.otf' + + urls = [ + 'https://github.com/wweir/source-han-sans-sc/raw/refs/heads/master/SourceHanSansSC-Regular.otf', + 'https://cdn.jsdelivr.net/gh/wweir/source-han-sans-sc@master/SourceHanSansSC-Regular.otf', + 'https://fastly.jsdelivr.net/gh/wweir/source-han-sans-sc@master/SourceHanSansSC-Regular.otf' + ] + headers = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' } - try: - response = requests.get(url, stream=True, headers=headers, timeout=10) - response.raise_for_status() - with open(font_path, 'wb') as f: - for chunk in response.iter_content(chunk_size=8192): - f.write(chunk) - logger.info(f"Font '{font_name}' downloaded successfully to '{font_path}'.") - fm._load_fontmanager(try_read_cache=False) - return fm.FontProperties(fname=font_path) - except requests.exceptions.RequestException as e: - logger.error(f"Failed to download font. Error: {e}") - return fm.FontProperties(family='sans-serif') + for url in urls: + try: + logger.info(f"Trying to download font from: {url}") + response = requests.get(url, stream=True, headers=headers, timeout=15) + response.raise_for_status() + with open(font_path, 'wb') as f: + for chunk in response.iter_content(chunk_size=8192): + f.write(chunk) + logger.info(f"Font '{font_name}' downloaded successfully to '{font_path}'.") + fm._load_fontmanager(try_read_cache=False) + return fm.FontProperties(fname=font_path) + except requests.exceptions.RequestException as e: + logger.warning(f"Failed to download font from {url}. Error: {e}") + continue + + logger.error("All font download attempts failed. Falling back to default sans-serif.") + return fm.FontProperties(family='sans-serif') # --- 数据库初始化与迁移 --- @@ -211,7 +222,7 @@ def convert_currency(amount, from_curr, to_curr): def parse_date(date_string: str) -> str: today = datetime.datetime.now() try: - dt = dateparser.parse(date_string, languages=['en', 'zh']) + dt = dateparser.parse(date_string, languages=['en', 'zh'], settings={'TIMEZONE': 'Asia/Shanghai', 'RETURN_AS_TIMEZONE_AWARE': False}) if not dt: return None has_year_info = any(c in date_string for c in ['年', '/']) or (re.search(r'\d{4}', date_string) is not None) @@ -425,13 +436,12 @@ 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 @@ -454,7 +464,7 @@ async def stats(update: Update, context: CallbackContext): await update.message.reply_text("您的订阅没有有效的费用信息。") return - max_categories = 7 + max_categories = 8 if len(category_costs) > max_categories: top = category_costs.iloc[:max_categories] others_sum = category_costs.iloc[max_categories:].sum() @@ -464,139 +474,128 @@ 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 format_money(value): - return f"{symbol}{value:,.2f}" - def autopct_if_large(pct): - if pct < 5: + if pct < 4: return '' - return f'{pct:.1f}%' + value = total_monthly * pct / 100 + return f"{pct:.1f}%\\n{symbol}{value:.2f}" - 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]) + # Setup figure with a clean, modern background + fig = plt.figure(figsize=(15, 8.5), facecolor='#FAFAFA') + gs = fig.add_gridspec(1, 2, width_ratios=[1.1, 1], wspace=0.15) + ax_pie = fig.add_subplot(gs[0, 0]) + ax_bar = fig.add_subplot(gs[0, 1]) image_path = None try: - base_colors = ['#4f46e5', '#0ea5e9', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#14b8a6', '#f97316'] - colors = base_colors[:len(category_costs)] + # Modern color palette (Tailwind-inspired) + theme_colors = ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#14B8A6', '#F97316', '#6366F1', '#84CC16'] + if len(category_costs) > len(theme_colors): + import matplotlib.pyplot as plt + extra_colors = [matplotlib.colors.to_hex(c) for c in plt.get_cmap('tab20').colors] + theme_colors.extend(extra_colors) + + color_map = {cat: theme_colors[i] for i, cat in enumerate(category_costs.index)} + pie_colors = [color_map[cat] for cat in category_costs.index] - # 顶部信息栏 - 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( + # Enhanced Donut Chart + wedges, texts, autotexts = ax_pie.pie( category_costs.values, - labels=None, + labels=category_costs.index, autopct=autopct_if_large, - startangle=100, + startangle=140, counterclock=False, - pctdistance=0.80, - colors=colors, - wedgeprops={'width': 0.42, 'edgecolor': 'white', 'linewidth': 1.5} + pctdistance=0.75, + labeldistance=1.1, + colors=pie_colors, + wedgeprops={'width': 0.35, 'edgecolor': '#FAFAFA', 'linewidth': 2.5} ) + for t in texts: + t.set_fontproperties(font_prop) + t.set_fontsize(13) + t.set_color('#374151') for t in autotexts: t.set_fontproperties(font_prop) t.set_fontsize(10) - t.set_color('#0f172a') + t.set_color('#FFFFFF') + t.set_weight('bold') + # Center text for donut ax_pie.text( 0, 0, - f"月总支出\n{format_money(total_monthly)}", + f"月总支出\n{symbol}{total_monthly:.2f}", ha='center', va='center', fontproperties=font_prop, - fontsize=15, - color='#111827', + fontsize=18, + color='#1F2937', weight='bold' ) - ax_pie.set_title('支出结构(类别占比)', fontproperties=font_prop, fontsize=16, pad=14, color='#0f172a') + ax_pie.set_title('支出占比结构', fontproperties=font_prop, fontsize=18, pad=20, color='#111827', weight='bold') 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) - - # 横向柱状图:展示对比 + # Enhanced Bar Chart 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.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') + bar_colors = [color_map[cat] for cat in bar_series.index] + + bars = ax_bar.barh(bar_series.index, bar_series.values, color=bar_colors, height=0.6, alpha=0.95, edgecolor='none') + + ax_bar.set_title('各类别月支出对比', fontproperties=font_prop, fontsize=18, pad=20, color='#111827', weight='bold') + ax_bar.set_xlabel(f'金额({main_currency.upper()})', fontproperties=font_prop, fontsize=12, color='#6B7280', labelpad=10) + + # Clean up axes + ax_bar.spines['top'].set_visible(False) + ax_bar.spines['right'].set_visible(False) + ax_bar.spines['left'].set_visible(False) + ax_bar.spines['bottom'].set_color('#E5E7EB') + + ax_bar.tick_params(axis='x', colors='#6B7280', labelsize=11) + ax_bar.tick_params(axis='y', length=0, pad=10) # Hide y ticks but keep labels + + ax_bar.grid(axis='x', color='#F3F4F6', linestyle='-', linewidth=1.5, alpha=1) ax_bar.set_axisbelow(True) for label in ax_bar.get_yticklabels(): label.set_fontproperties(font_prop) - label.set_fontsize(11) + label.set_fontsize(13) + label.set_color('#374151') + # Bar value labels max_val = bar_series.max() if len(bar_series) else 0 - offset = max(max_val * 0.015, 0.08) - ax_bar.set_xlim(0, max_val * 1.22 if max_val > 0 else 1) - + offset = max_val * 0.02 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, - format_money(value), + f"{symbol}{value:.2f}", va='center', ha='left', fontproperties=font_prop, - fontsize=10, - color='#1e293b' + fontsize=11, + color='#4B5563', + weight='bold' ) - fig.tight_layout(rect=[0.01, 0.01, 0.99, 0.98]) + fig.suptitle('📊 您的订阅支出洞察', fontproperties=font_prop, fontsize=24, color='#0F172A', y=1.02, weight='bold') + fig.tight_layout(rect=[0, 0, 1, 0.95]) with tempfile.NamedTemporaryFile(prefix=f'stats_{user_id}_', suffix='.png', delete=False) as tmp: image_path = tmp.name - plt.savefig(image_path, dpi=240, bbox_inches='tight', facecolor=fig.get_facecolor()) + plt.savefig(image_path, dpi=250, 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): os.remove(image_path) + # --- Import/Export Commands --- async def export_command(update: Update, context: CallbackContext): user_id = update.effective_user.id @@ -1562,6 +1561,7 @@ async def set_currency(update: Update, context: CallbackContext): conn.commit() await update.message.reply_text(f"您的主货币已设为 {escape_markdown(new_currency, version=2)}。", parse_mode='MarkdownV2') + return ConversationHandler.END async def cancel(update: Update, context: CallbackContext):