""" 个人记账助手 v3.0 新增:预算进度条、记录搜索筛选、CSV导出、饼图统计 """ import tkinter as tk from tkinter import ttk, messagebox, filedialog import json, os, sys, csv, math, tempfile from datetime import date, timedelta from collections import defaultdict DATA_FILE = os.path.join(os.path.dirname(os.path.abspath(sys.argv[0])), 'ledger_data.json') CONFIG_FILE = os.path.join(os.path.dirname(os.path.abspath(sys.argv[0])), 'config.json') CATEGORIES_INCOME = [ ('工资', '💰'), ('兼职', '💼'), ('红包', '🧧'), ('投资收益', '📈'), ('其他收入', '💵'), ] CATEGORIES_EXPENSE = [ ('餐饮', '🍽️'), ('交通', '🚗'), ('购物', '🛒'), ('娱乐', '🎮'), ('居住', '🏠'), ('医疗', '💊'), ('教育', '📚'), ('通讯', '📱'), ('其他', '📦'), ] COLORS = ['#58a6ff', '#3fb950', '#d29922', '#f78166', '#a371f7', '#db61a2', '#58a6ff', '#3fb950', '#8b949e'] def load_json(path, default): if os.path.exists(path): try: with open(path, 'r', encoding='utf-8') as f: return json.load(f) except (OSError, json.JSONDecodeError): return default return default def save_json(path, data): directory = os.path.dirname(path) or '.' fd, temp_path = tempfile.mkstemp(prefix='.ledger-', suffix='.tmp', dir=directory) try: with os.fdopen(fd, 'w', encoding='utf-8') as f: json.dump(data, f, ensure_ascii=False, indent=2) f.flush() os.fsync(f.fileno()) os.replace(temp_path, path) finally: if os.path.exists(temp_path): os.remove(temp_path) class LedgerApp: def __init__(self, root): self.root = root self.root.title('个人记账助手') self.root.geometry('460x780') self.root.minsize(400, 650) self.root.configure(bg='#0d1117') self.records = load_json(DATA_FILE, []) self.config = load_json(CONFIG_FILE, {'budget': 3000}) s = ttk.Style() s.theme_use('clam') s.configure('TNotebook', background='#0d1117', borderwidth=0) s.configure('TNotebook.Tab', padding=[18, 8], font=('Microsoft YaHei', 10)) s.map('TNotebook.Tab', background=[('selected', '#161b22')], foreground=[('selected', '#58a6ff')]) # Bind enter key root.bind('', lambda e: self.add_record()) self.build_ui() self.refresh_all() # ═══════════════════ UI BUILDERS ═══════════════════ def build_ui(self): # ── Balance Card ── self.balance_var = tk.StringVar() self.income_var = tk.StringVar() self.expense_var = tk.StringVar() self.budget_var = tk.StringVar() card = tk.Frame(self.root, bg='#161b22', highlightbackground='#30363d', highlightthickness=1) card.pack(fill='x', padx=12, pady=(12, 4)) tk.Label(card, text='当前余额', font=('Microsoft YaHei', 9), fg='#8b949e', bg='#161b22').pack(anchor='w', padx=14, pady=(10, 0)) tk.Label(card, textvariable=self.balance_var, font=('Microsoft YaHei', 26, 'bold'), fg='#58a6ff', bg='#161b22').pack(anchor='w', padx=14) row = tk.Frame(card, bg='#161b22') row.pack(fill='x', padx=14, pady=(0, 0)) tk.Label(row, textvariable=self.income_var, font=('Microsoft YaHei', 10), fg='#3fb950', bg='#161b22').pack(side='left') tk.Label(row, textvariable=self.expense_var, font=('Microsoft YaHei', 10), fg='#f78166', bg='#161b22').pack(side='left', padx=(14, 0)) tk.Label(row, textvariable=self.budget_var, font=('Microsoft YaHei', 10), fg='#d29922', bg='#161b22').pack(side='right') # Budget progress bar self.budget_bar = tk.Canvas(card, height=6, bg='#161b22', highlightthickness=0) self.budget_bar.pack(fill='x', padx=14, pady=(4, 10)) # ── Notebook ── nb = ttk.Notebook(self.root) nb.pack(fill='both', expand=True, padx=12, pady=(0, 10)) nb.bind('<>', self.on_tab_change) self.tab1 = tk.Frame(nb, bg='#0d1117'); nb.add(self.tab1, text=' 📝 记账 ') self.tab2 = tk.Frame(nb, bg='#0d1117'); nb.add(self.tab2, text=' 📋 记录 ') self.tab3 = tk.Frame(nb, bg='#0d1117'); nb.add(self.tab3, text=' 📊 统计 ') self.tab4 = tk.Frame(nb, bg='#0d1117'); nb.add(self.tab4, text=' ⚙️ 设置 ') self.build_add_tab() self.build_records_tab() self.build_stats_tab() self.build_settings_tab() def build_add_tab(self): t = self.tab1 form = tk.Frame(t, bg='#161b22', highlightbackground='#30363d', highlightthickness=1) form.pack(fill='x', pady=10) tk.Label(form, text='新增记录', font=('Microsoft YaHei', 14, 'bold'), fg='#e6edf3', bg='#161b22').pack(anchor='w', padx=16, pady=(12, 8)) # Type toggle self.type_var = tk.StringVar(value='支出') tr = tk.Frame(form, bg='#161b22'); tr.pack(fill='x', padx=16, pady=4) tk.Label(tr, text='类型', font=('Microsoft YaHei', 10), fg='#8b949e', bg='#161b22').pack(side='left') for v, c, txt in [('支出', '#f78166', '💸'), ('收入', '#3fb950', '📈')]: tk.Radiobutton(tr, text=txt, variable=self.type_var, value=v, font=('Microsoft YaHei', 11), bg='#161b22', fg='#e6edf3', activebackground='#161b22', activeforeground='#e6edf3', selectcolor='#161b22', command=self.on_type_change).pack(side='left', padx=(10, 0)) # Amount self.amount_var = tk.StringVar() ar = tk.Frame(form, bg='#161b22'); ar.pack(fill='x', padx=16, pady=6) tk.Label(ar, text='金额 ¥', font=('Microsoft YaHei', 10), fg='#8b949e', bg='#161b22').pack(side='left') self.amount_entry = tk.Entry(ar, textvariable=self.amount_var, font=('Microsoft YaHei', 14), bg='#0d1117', fg='#e6edf3', insertbackground='#58a6ff', relief='solid', bd=1, width=14) self.amount_entry.pack(side='right', ipady=2) self.amount_entry.bind('', lambda e: self.amount_entry.configure(bg='#1a2332')) self.amount_entry.bind('', lambda e: self.amount_entry.configure(bg='#0d1117')) # Category grid (replaces dropdown) self.cat_var = tk.StringVar() cat_frame = tk.Frame(form, bg='#161b22'); cat_frame.pack(fill='x', padx=16, pady=4) tk.Label(cat_frame, text='分类', font=('Microsoft YaHei', 10), fg='#8b949e', bg='#161b22').pack(anchor='w') self.cat_grid = tk.Frame(cat_frame, bg='#161b22'); self.cat_grid.pack(fill='x', pady=(6, 0)) self.refresh_category_grid() # Date self.date_var = tk.StringVar(value=date.today().isoformat()) dr = tk.Frame(form, bg='#161b22'); dr.pack(fill='x', padx=16, pady=6) tk.Label(dr, text='日期', font=('Microsoft YaHei', 10), fg='#8b949e', bg='#161b22').pack(side='left') date_row = tk.Frame(dr, bg='#161b22'); date_row.pack(side='right') tk.Button(date_row, text='◀', font=('Microsoft YaHei', 9), bg='#0d1117', fg='#8b949e', relief='flat', command=lambda: self.shift_date(-1)).pack(side='left') tk.Entry(date_row, textvariable=self.date_var, font=('Microsoft YaHei', 10), bg='#0d1117', fg='#e6edf3', insertbackground='#e6edf3', relief='solid', bd=1, width=12, justify='center').pack(side='left', ipady=1) tk.Button(date_row, text='▶', font=('Microsoft YaHei', 9), bg='#0d1117', fg='#8b949e', relief='flat', command=lambda: self.shift_date(1)).pack(side='left') # Note self.note_var = tk.StringVar() nr = tk.Frame(form, bg='#161b22'); nr.pack(fill='x', padx=16, pady=6) tk.Label(nr, text='备注', font=('Microsoft YaHei', 10), fg='#8b949e', bg='#161b22').pack(side='left') note_entry = tk.Entry(nr, textvariable=self.note_var, font=('Microsoft YaHei', 10), bg='#0d1117', fg='#e6edf3', insertbackground='#58a6ff', relief='solid', bd=1, width=22) note_entry.pack(side='right', ipady=1) note_entry.bind('', lambda e: note_entry.configure(bg='#1a2332')) note_entry.bind('', lambda e: note_entry.configure(bg='#0d1117')) # Add button self.add_btn = tk.Button(form, text='✓ 添加记录', font=('Microsoft YaHei', 12, 'bold'), bg='#58a6ff', fg='white', activebackground='#4090e0', relief='flat', padx=20, pady=10, command=self.add_record) self.add_btn.pack(pady=(14, 14)) def refresh_category_grid(self): for w in self.cat_grid.winfo_children(): w.destroy() cats = CATEGORIES_INCOME if self.type_var.get() == '收入' else CATEGORIES_EXPENSE self.cat_var.set(cats[0][0]) for i, (name, icon) in enumerate(cats): btn = tk.Button(self.cat_grid, text=f'{icon} {name}', font=('Microsoft YaHei', 9), bg='#0d1117', fg='#8b949e', activebackground='#58a6ff', relief='flat', padx=8, pady=4, command=lambda n=name: self.cat_var.set(n)) btn.grid(row=i // 3, column=i % 3, padx=2, pady=2, sticky='ew') col_weight = {0: 1, 1: 1, 2: 1} self.cat_grid.grid_columnconfigure(i % 3, weight=col_weight[i % 3]) def shift_date(self, days): try: d = date.fromisoformat(self.date_var.get()) + timedelta(days=days) self.date_var.set(d.isoformat()) except ValueError: pass def build_records_tab(self): t = self.tab2 toolbar = tk.Frame(t, bg='#0d1117'); toolbar.pack(fill='x', pady=4) self.month_filter = tk.StringVar(value=date.today().strftime('%Y-%m')) tk.Button(toolbar, text='◀', font=('Microsoft YaHei', 9), bg='#161b22', fg='#8b949e', relief='flat', padx=6, command=lambda: self.filter_month(-1)).pack(side='left') tk.Label(toolbar, textvariable=self.month_filter, font=('Microsoft YaHei', 11, 'bold'), fg='#e6edf3', bg='#0d1117').pack(side='left', padx=8) tk.Button(toolbar, text='▶', font=('Microsoft YaHei', 9), bg='#161b22', fg='#8b949e', relief='flat', padx=6, command=lambda: self.filter_month(1)).pack(side='left') btn_frame = tk.Frame(t, bg='#0d1117'); btn_frame.pack(fill='x', pady=4) self.search_var = tk.StringVar() self.search_var.trace('w', lambda *a: self.update_records_list()) search_entry = tk.Entry(btn_frame, textvariable=self.search_var, font=('Microsoft YaHei', 9), bg='#161b22', fg='#e6edf3', insertbackground='#58a6ff', relief='solid', bd=1, width=14) search_entry.pack(side='left', padx=(0, 4), ipady=2) search_entry.insert(0, '') search_entry.bind('', lambda e: search_entry.delete(0, 'end') if search_entry.get() == '' else None) tk.Button(btn_frame, text='🔄 刷新', font=('Microsoft YaHei', 9), bg='#161b22', fg='#58a6ff', relief='flat', command=self.refresh_all).pack(side='right') self.records_frame = tk.Frame(t, bg='#0d1117'); self.records_frame.pack(fill='both', expand=True) self.records_canvas = tk.Canvas(self.records_frame, bg='#0d1117', highlightthickness=0) scrollbar = ttk.Scrollbar(self.records_frame, orient='vertical', command=self.records_canvas.yview) self.records_inner = tk.Frame(self.records_canvas, bg='#0d1117') self.records_inner.bind('', lambda e: self.records_canvas.configure(scrollregion=self.records_canvas.bbox('all'))) self.records_canvas.create_window((0, 0), window=self.records_inner, anchor='nw') self.records_canvas.configure(yscrollcommand=scrollbar.set) self.records_canvas.pack(side='left', fill='both', expand=True) scrollbar.pack(side='right', fill='y') def filter_month(self, delta): try: d = date.fromisoformat(self.month_filter.get() + '-01') + timedelta(days=delta * 31) d = d.replace(day=1) self.month_filter.set(d.strftime('%Y-%m')) self.refresh_all() except ValueError: pass def build_stats_tab(self): t = self.tab3 toolbar = tk.Frame(t, bg='#0d1117'); toolbar.pack(fill='x', pady=4) self.stats_month = tk.StringVar(value=date.today().strftime('%Y-%m')) tk.Button(toolbar, text='◀', font=('Microsoft YaHei', 9), bg='#161b22', fg='#8b949e', relief='flat', padx=6, command=lambda: self.stats_shift(-1)).pack(side='left') tk.Label(toolbar, textvariable=self.stats_month, font=('Microsoft YaHei', 11, 'bold'), fg='#e6edf3', bg='#0d1117').pack(side='left', padx=8) tk.Button(toolbar, text='▶', font=('Microsoft YaHei', 9), bg='#161b22', fg='#8b949e', relief='flat', padx=6, command=lambda: self.stats_shift(1)).pack(side='left') self.stats_canvas = tk.Canvas(t, bg='#0d1117', highlightthickness=0) self.stats_inner = tk.Frame(self.stats_canvas, bg='#0d1117') self.stats_canvas.create_window((0, 0), window=self.stats_inner, anchor='nw') self.stats_inner.bind('', lambda e: self.stats_canvas.configure(scrollregion=self.stats_canvas.bbox('all'))) self.stats_canvas.pack(fill='both', expand=True) def stats_shift(self, delta): try: d = date.fromisoformat(self.stats_month.get() + '-01') + timedelta(days=delta * 31) d = d.replace(day=1) self.stats_month.set(d.strftime('%Y-%m')) self.update_summary() except ValueError: pass def build_settings_tab(self): t = self.tab4 form = tk.Frame(t, bg='#161b22', highlightbackground='#30363d', highlightthickness=1) form.pack(fill='x', pady=10) tk.Label(form, text='设置', font=('Microsoft YaHei', 14, 'bold'), fg='#e6edf3', bg='#161b22').pack(anchor='w', padx=16, pady=(12, 10)) tk.Label(form, text='每月预算 ¥', font=('Microsoft YaHei', 10), fg='#8b949e', bg='#161b22').pack(anchor='w', padx=16) budget_frame = tk.Frame(form, bg='#161b22'); budget_frame.pack(fill='x', padx=16, pady=(4, 12)) self.budget_entry_var = tk.StringVar(value=str(self.config.get('budget', 3000))) tk.Entry(budget_frame, textvariable=self.budget_entry_var, font=('Microsoft YaHei', 14), width=12, bg='#0d1117', fg='#e6edf3', insertbackground='#58a6ff', relief='solid', bd=1).pack(side='left') tk.Button(budget_frame, text='保存', font=('Microsoft YaHei', 10), bg='#58a6ff', fg='white', relief='flat', padx=16, pady=6, command=self.save_budget).pack(side='left', padx=8) # Export section sep = tk.Frame(form, bg='#30363d', height=1); sep.pack(fill='x', padx=16, pady=10) tk.Label(form, text='数据导出', font=('Microsoft YaHei', 10, 'bold'), fg='#e6edf3', bg='#161b22').pack(anchor='w', padx=16, pady=(4, 6)) tk.Button(form, text='📥 导出 CSV', font=('Microsoft YaHei', 10), bg='#0d1117', fg='#3fb950', activebackground='#1a2332', relief='flat', padx=16, pady=8, command=self.export_csv).pack(padx=16, pady=(0, 12)) # ═══════════════════ ACTIONS ═══════════════════ def on_type_change(self): self.refresh_category_grid() def add_record(self): try: amount = float(self.amount_var.get()) except ValueError: self.flash_button('请输入金额', '#f78166') return if amount <= 0: self.flash_button('金额>0', '#f78166') return if not math.isfinite(amount): self.flash_button('金额格式不合法', '#f78166') return try: date.fromisoformat(self.date_var.get()) except ValueError: self.flash_button('日期格式应为 YYYY-MM-DD', '#f78166') return if not self.cat_var.get(): self.flash_button('请选择分类', '#f78166') return rec = { 'type': self.type_var.get(), 'amount': amount, 'category': self.cat_var.get(), 'note': self.note_var.get(), 'date': self.date_var.get(), } # Check editing state editing_index = getattr(self, '_editing_index', None) if editing_index is not None: self.records[editing_index] = rec self._editing_index = None self.add_btn.configure(text='✓ 添加记录') else: self.records.append(rec) save_json(DATA_FILE, self.records) self.amount_var.set('') self.note_var.set('') self.date_var.set(date.today().isoformat()) self.refresh_all() self.flash_button('✓ 已保存', '#3fb950') def flash_button(self, text, color): old_text = self.add_btn.cget('text') old_bg = self.add_btn.cget('bg') self.add_btn.configure(text=text, bg=color) self.root.after(1500, lambda: self.add_btn.configure(text=old_text, bg=old_bg)) def edit_record(self, index): r = self.records[index] self.type_var.set(r['type']) self.amount_var.set(str(r['amount'])) self.cat_var.set(r['category']) self.note_var.set(r.get('note', '')) self.date_var.set(r['date']) self._editing_index = index self.add_btn.configure(text='💾 保存修改', bg='#d29922') # Switch to add tab nb = self.root.winfo_children()[2] # Notebook nb.select(0) self.on_type_change() def delete_record(self, index): if messagebox.askyesno('确认', '确定要删除这条记录吗?'): self.records.pop(index) save_json(DATA_FILE, self.records) self.refresh_all() def save_budget(self): try: b = int(self.budget_entry_var.get()) if b < 0: raise ValueError self.config['budget'] = b save_json(CONFIG_FILE, self.config) self.update_balance() messagebox.showinfo('成功', f'每月预算已设为 ¥{b:,}') except ValueError: messagebox.showerror('错误', '请输入有效数字') def export_csv(self): filepath = filedialog.asksaveasfilename(defaultextension='.csv', filetypes=[('CSV文件', '*.csv')], initialfile=f'账本导出_{date.today().isoformat()}.csv') if not filepath: return with open(filepath, 'w', newline='', encoding='utf-8-sig') as f: writer = csv.writer(f) writer.writerow(['日期', '类型', '金额', '分类', '备注']) for r in self.records: writer.writerow([r['date'], r['type'], r['amount'], r['category'], r.get('note', '')]) messagebox.showinfo('导出成功', f'已导出 {len(self.records)} 条记录到:\n{filepath}') def draw_pie(self, canvas, data): """Draw a simple pie chart on tkinter Canvas""" total = sum(data.values()) if total == 0: return cx, cy, r = 80, 100, 70 # center and radius start_angle = -90 sorted_items = sorted(data.items(), key=lambda x: x[1], reverse=True) for i, (label, val) in enumerate(sorted_items[:6]): extent = val / total * 360 color = COLORS[i % len(COLORS)] # Draw arc canvas.create_arc(cx - r, cy - r, cx + r, cy + r, start=start_angle, extent=extent, fill=color, outline='#161b22', width=2) # Label position mid_angle = math.radians(start_angle + extent / 2) lx = cx + (r + 40) * math.cos(mid_angle) ly = cy + (r + 40) * math.sin(mid_angle) pct = val / total * 100 canvas.create_text(lx, ly, text=f'{label}\n{pct:.0f}%', fill='#8b949e', font=('Microsoft YaHei', 8), anchor='center') start_angle += extent def on_tab_change(self, event): idx = event.widget.index('current') if idx == 2: # Stats tab self.update_summary() # ═══════════════════ REFRESH ═══════════════════ def refresh_all(self): self.records = load_json(DATA_FILE, []) self.update_balance() self.update_records_list() def update_balance(self): month = self.month_filter.get() mr = [r for r in self.records if r['date'].startswith(month)] ti = sum(r['amount'] for r in mr if r['type'] == '收入') te = sum(r['amount'] for r in mr if r['type'] == '支出') balance = ti - te self.balance_var.set(f'¥{balance:,.2f}') self.income_var.set(f'收入 ¥{ti:,.0f}') self.expense_var.set(f'支出 ¥{te:,.0f}') budget = self.config.get('budget', 3000) left = budget - te color = '#3fb950' if left > 0 else '#f78166' self.budget_var.set(f'预算剩余 ¥{left:,.0f}') # Flash balance card if over budget if left < 0: self.budget_var.set(f'⚠️ 超预算 ¥{abs(left):,.0f}') # Draw budget progress bar if hasattr(self, 'budget_bar'): self.budget_bar.delete('all') w = self.budget_bar.winfo_width() or 400 pct = min(te / budget, 1.0) if budget > 0 else 1.0 fill_w = int(w * pct) color = '#3fb950' if pct < 0.8 else ('#d29922' if pct <= 1.0 else '#f78166') self.budget_bar.create_rectangle(0, 0, fill_w, 6, fill=color, outline='') self.budget_bar.create_text(w // 2, 3, text=f'{pct*100:.0f}%', fill='#8b949e', font=('Microsoft YaHei', 7)) def update_records_list(self): for w in self.records_inner.winfo_children(): w.destroy() month = self.month_filter.get() search = getattr(self, 'search_var', tk.StringVar()).get().lower() filtered = [(i, r) for i, r in enumerate(self.records) if r['date'].startswith(month)] if search: filtered = [(i, r) for i, r in filtered if search in r.get('note', '').lower() or search in r['category'].lower() or search in r['type'].lower() or search in str(r['amount'])] filtered.sort(key=lambda x: x[1]['date'], reverse=True) if not filtered: tk.Label(self.records_inner, text='本月暂无记录', font=('Microsoft YaHei', 11), fg='#8b949e', bg='#0d1117').pack(pady=30) else: for idx, r in filtered[:60]: icon = '📈' if r['type'] == '收入' else '💸' clr = '#3fb950' if r['type'] == '收入' else '#f78166' sign = '+' if r['type'] == '收入' else '-' frame = tk.Frame(self.records_inner, bg='#161b22', highlightbackground='#30363d', highlightthickness=1) frame.pack(fill='x', pady=2, padx=2) # Left info left = tk.Frame(frame, bg='#161b22'); left.pack(side='left', padx=10, pady=8, fill='x', expand=True) tk.Label(left, text=f'{icon} {r["category"]} {sign}¥{r["amount"]:.2f}', font=('Microsoft YaHei', 11, 'bold'), fg=clr, bg='#161b22').pack(anchor='w') sub = r['date'] if r.get('note'): sub += ' · ' + r['note'] tk.Label(left, text=sub, font=('Microsoft YaHei', 8), fg='#8b949e', bg='#161b22').pack(anchor='w') # Right buttons right = tk.Frame(frame, bg='#161b22'); right.pack(side='right', padx=6) tk.Button(right, text='✎', font=('Microsoft YaHei', 12), bg='#161b22', fg='#58a6ff', activebackground='#161b22', relief='flat', bd=0, padx=4, command=lambda i=idx: self.edit_record(i)).pack(side='left') tk.Button(right, text='✕', font=('Microsoft YaHei', 12), bg='#161b22', fg='#8b949e', activebackground='#161b22', relief='flat', bd=0, padx=4, command=lambda i=idx: self.delete_record(i)).pack(side='left') self.records_inner.update_idletasks() self.records_canvas.configure(scrollregion=self.records_canvas.bbox('all')) def update_summary(self): for w in self.stats_inner.winfo_children(): w.destroy() month = self.stats_month.get() mr = [r for r in self.records if r['date'].startswith(month)] mi = sum(r['amount'] for r in mr if r['type'] == '收入') me = sum(r['amount'] for r in mr if r['type'] == '支出') # ── Overview card ── card = tk.Frame(self.stats_inner, bg='#161b22', highlightbackground='#30363d', highlightthickness=1) card.pack(fill='x', pady=4) tk.Label(card, text=f'📊 {month} 统计', font=('Microsoft YaHei', 13, 'bold'), fg='#e6edf3', bg='#161b22').pack(anchor='w', padx=14, pady=(10, 6)) row = tk.Frame(card, bg='#161b22'); row.pack(fill='x', padx=14, pady=(0, 6)) budget = self.config.get('budget', 3000) over = me > budget tk.Label(row, text=f'收入 ¥{mi:,.0f}', font=('Microsoft YaHei', 12), fg='#3fb950', bg='#161b22').pack(side='left') tk.Label(row, text=f'支出 ¥{me:,.0f}', font=('Microsoft YaHei', 12), fg='#f78166' if over else '#e6edf3', bg='#161b22').pack(side='left', padx=14) tk.Label(row, text=f'预算 ¥{budget:,}', font=('Microsoft YaHei', 10), fg='#8b949e', bg='#161b22').pack(side='left') if over: tk.Label(card, text=f'⚠️ 本月超预算 ¥{me - budget:,.0f}', font=('Microsoft YaHei', 10, 'bold'), fg='#f78166', bg='#161b22').pack(anchor='w', padx=14, pady=(0, 8)) # ── Category breakdown ── ce = defaultdict(float) for r in mr: if r['type'] == '支出': ce[r['category']] += r['amount'] if ce: tk.Label(card, text='支出构成', font=('Microsoft YaHei', 10, 'bold'), fg='#e6edf3', bg='#161b22').pack(anchor='w', padx=14, pady=(8, 4)) total_exp = sum(ce.values()) or 1 for i, (cat, amt) in enumerate(sorted(ce.items(), key=lambda x: x[1], reverse=True)[:8]): pct = amt / total_exp * 100 bar = tk.Frame(card, bg='#161b22'); bar.pack(fill='x', padx=14, pady=2) tk.Label(bar, text=cat, font=('Microsoft YaHei', 9), fg='#e6edf3', bg='#161b22', width=8, anchor='w').pack(side='left') w = max(2, int(pct * 2)) tk.Frame(bar, bg=COLORS[i % len(COLORS)], width=w, height=12).pack(side='left', padx=(4, 6)) tk.Label(bar, text=f'¥{amt:.0f} ({pct:.0f}%)', font=('Microsoft YaHei', 9), fg='#8b949e', bg='#161b22').pack(side='left') tk.Frame(card, bg='#161b22', height=10).pack() if not mr: tk.Label(card, text='本月暂无数据', font=('Microsoft YaHei', 10), fg='#8b949e', bg='#161b22').pack(pady=14) # ── Pie chart on Canvas ── if ce: pie_card = tk.Frame(self.stats_inner, bg='#161b22', highlightbackground='#30363d', highlightthickness=1) pie_card.pack(fill='x', pady=6) tk.Label(pie_card, text='支出构成(饼图)', font=('Microsoft YaHei', 10, 'bold'), fg='#e6edf3', bg='#161b22').pack(anchor='w', padx=14, pady=(10, 6)) canvas = tk.Canvas(pie_card, width=300, height=220, bg='#161b22', highlightthickness=0) canvas.pack(pady=(0, 8)) self.draw_pie(canvas, ce) self.stats_inner.update_idletasks() self.stats_canvas.configure(scrollregion=self.stats_canvas.bbox('all')) if __name__ == '__main__': root = tk.Tk() app = LedgerApp(root) root.mainloop()