fix(stats): Optimize chart UI, dateparser timezone, and pandas apply performance. Improve font download stability.

This commit is contained in:
Xiaolan Bot
2026-02-22 23:43:34 +08:00
parent 54d46d7e6b
commit 31b1235d20

View File

@@ -64,13 +64,21 @@ 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'
}
for url in urls:
try:
response = requests.get(url, stream=True, headers=headers, timeout=10)
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):
@@ -79,7 +87,10 @@ def get_chinese_font():
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}")
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)
# 顶部信息栏
ax_header.set_facecolor('#ffffff')
ax_header.set_xticks([])
ax_header.set_yticks([])
for spine in ax_header.spines.values():
spine.set_visible(False)
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]
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):