import os
import json
import base64
import io
import sys
from datetime import datetime, timedelta
from flask import Flask, render_template_string, request, jsonify, send_file
import pandas as pd
import matplotlib
if sys.stdout.encoding != 'UTF-8':
try:
sys.stdout.reconfigure(encoding='utf-8')
except AttributeError:
pass
matplotlib.use('Agg')
import matplotlib.pyplot as plt
from matplotlib import font_manager
def setup_chinese_font():
"""设置中文字体支持"""
try:
font_names = [
'Microsoft YaHei', 'SimHei', 'SimSun', 'KaiTi', 'STKaiti', 'STSong',
'LiHei Pro', 'WenQuanYi Micro Hei', 'DejaVu Sans'
]
for font_name in font_names:
if font_manager.findfont(font_name):
plt.rcParams['font.family'] = font_name
plt.rcParams['axes.unicode_minus'] = False
print(f"使用字体:{font_name}")
return True
plt.rcParams['font.family'] = ['DejaVu Sans', 'Arial']
plt.rcParams['axes.unicode_minus'] = False
print("使用默认字体")
return True
except Exception as e:
print(f"字体设置失败:{e}")
return False
setup_chinese_font()
HTML_TEMPLATE = '''<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>个人记账本 - Web 版</title>
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<style>
body { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); min-height: 100vh; font-family: 'Microsoft YaHei', sans-serif; }
.header-bg { background: linear-gradient(135deg, #2c3e50 0%, #3498db 100%); border-radius: 0 0 20px 20px; box-shadow: 0 4px 20px rgba(0,0,0,0.1); }
.stat-card { border: none; border-radius: 15px; box-shadow: 0 4px 15px rgba(0,0,0,0.1); transition: transform 0.3s ease; }
.stat-card:hover { transform: translateY(-5px); }
.card { border: none; border-radius: 15px; box-shadow: 0 4px 20px rgba(0,0,0,0.1); margin-bottom: 20px; }
.btn { border-radius: 10px; padding: 10px 20px; font-weight: 500; transition: all 0.3s ease; }
.btn-primary { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border: none; }
.table th { border: none; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; font-weight: 500; }
.table td { border: none; padding: 15px 10px; }
.table tbody tr:hover { background-color: rgba(102, 126, 234, 0.1); transform: translateX(5px); }
.category-badge { padding: 5px 12px; border-radius: 20px; font-size: 0.85em; font-weight: 500; color: white; }
.amount-cell { font-weight: 600; color: #e74c3c; }
.delete-btn { background: linear-gradient(135deg, #e74c3c 0%, #c0392b 100%); color: white; border: none; border-radius: 8px; padding: 5px 12px; }
.chinese-font { font-family: 'Microsoft YaHei', 'PingFang SC', sans-serif; }
</style>
</head>
<body>
<div class="container-fluid">
<div class="header-bg p-4 mb-4 text-white">
<h1><i class="fas fa-wallet"></i> 个人记账本</h1>
<p id="current-date"></p>
</div>
<!-- 统计卡片 -->
<div class="row mb-4">
<div class="col-md-4">
<div class="card stat-card p-3">
<div class="d-flex justify-content-between align-items-center">
<div>
<h4 id="total-amount">¥0.00</h4>
<p class="mb-0">本月总支出</p>
</div>
<i class="fas fa-coins fa-2x text-warning"></i>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card stat-card p-3">
<div class="d-flex justify-content-between align-items-center">
<div>
<h4 id="total-count">0</h4>
<p class="mb-0">本月记录数</p>
</div>
<i class="fas fa-list fa-2x text-info"></i>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card stat-card p-3">
<div class="d-flex justify-content-between align-items-center">
<div>
<h4 id="top-category">-</h4>
<p class="mb-0">最大支出分类</p>
</div>
<i class="fas fa-chart-pie fa-2x text-success"></i>
</div>
</div>
</div>
</div>
<!-- 输入表单 -->
<div class="card p-4 mb-4">
<h5><i class="fas fa-plus-circle"></i> 记录新支出</h5>
<form onsubmit="event.preventDefault(); addExpense();">
<div class="row g-3">
<div class="col-md-3">
<label>金额</label>
<input type="number" id="amount" placeholder="0.00" step="0.01" class="form-control">
</div>
<div class="col-md-3">
<label>分类</label>
<select id="category" class="form-select">
<option value="餐饮">餐饮</option>
<option value="交通">交通</option>
<option value="购物">购物</option>
<option value="娱乐">娱乐</option>
<option value="学习">学习</option>
<option value="医疗">医疗</option>
<option value="住房">住房</option>
<option value="其他">其他</option>
</select>
</div>
<div class="col-md-3">
<label>日期</label>
<input type="date" id="date" class="form-control">
</div>
<div class="col-md-3">
<label>备注</label>
<input type="text" id="note" placeholder="可选备注" class="form-control">
</div>
</div>
<button type="submit" class="btn btn-primary mt-3"><i class="fas fa-save"></i> 添加记录</button>
</form>
</div>
<!-- 功能按钮 -->
<div class="card p-3 mb-4">
<button onclick="refreshData()" class="btn btn-outline-secondary me-2"><i class="fas fa-sync"></i> 刷新数据</button>
<button onclick="showChart()" class="btn btn-outline-secondary me-2"><i class="fas fa-chart-bar"></i> 查看图表</button>
<button onclick="exportData()" class="btn btn-outline-secondary"><i class="fas fa-file-excel"></i> 导出 Excel</button>
</div>
<!-- 支出列表 -->
<div class="card p-4">
<h5><i class="fas fa-table"></i> 支出记录</h5>
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>日期</th>
<th>分类</th>
<th>金额</th>
<th>备注</th>
<th>操作</th>
</tr>
</thead>
<tbody id="expenses-table">
<!-- 数据通过 JavaScript 动态加载 -->
</tbody>
</table>
</div>
</div>
</div>
<!-- 图表模态框 -->
<div class="modal fade" id="chartModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">支出统计图表</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body text-center">
<img id="chart-image" style="max-width: 100%;">
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"></script>
<script>
document.getElementById('date').valueAsDate = new Date();
document.getElementById('current-date').textContent = new Date().toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric', weekday: 'long' });
function getCategoryColor(category) {
const colors = { '餐饮': '#e74c3c', '交通': '#3498db', '购物': '#9b59b6', '娱乐': '#f39c12', '学习': '#2ecc71', '医疗': '#e67e22', '住房': '#34495e', '其他': '#95a5a6' };
return colors[category] || '#95a5a6';
}
async function loadData() {
try {
const response = await fetch('/api/expenses');
const expenses = await response.json();
renderExpenses(expenses);
updateStatistics();
} catch (error) { showAlert('加载数据失败:' + error, 'danger'); }
}
function renderExpenses(expenses) {
const tbody = document.getElementById('expenses-table');
tbody.innerHTML = '';
if (expenses.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" class="text-center"><i class="fas fa-inbox"></i><br>还没有支出记录</td></tr>';
return;
}
expenses.forEach((expense, index) => {
const row = document.createElement('tr');
row.innerHTML = `
<td>${expense.date}</td>
<td><span class="badge" style="background-color: ${getCategoryColor(expense.category)}">${expense.category}</span></td>
<td class="amount-cell">¥${parseFloat(expense.amount).toFixed(2)}</td>
<td>${expense.note || '-'}</td>
<td><button onclick="deleteExpense(${index})" class="btn btn-sm delete-btn"><i class="fas fa-trash"></i> 删除</button></td>
`;
tbody.appendChild(row);
});
}
async function addExpense() {
const amount = document.getElementById('amount').value;
const category = document.getElementById('category').value;
const date = document.getElementById('date').value;
const note = document.getElementById('note').value;
if (!amount || !category || !date) { showAlert('请填写完整信息', 'warning'); return; }
try {
const response = await fetch('/api/expenses', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ amount, category, date, note })
});
const result = await response.json();
if (result.success) {
showAlert(result.message, 'success');
document.getElementById('amount').value = '';
document.getElementById('note').value = '';
loadData();
} else { showAlert(result.message, 'danger'); }
} catch (error) { showAlert('添加失败:' + error, 'danger'); }
}
async function deleteExpense(index) {
if (!confirm('确定要删除这条记录吗?')) return;
try {
const response = await fetch(`/api/expenses/${index}`, {method: 'DELETE'});
const result = await response.json();
if (result.success) { showAlert(result.message, 'success'); loadData(); }
else { showAlert(result.message, 'danger'); }
} catch (error) { showAlert('删除失败:' + error, 'danger'); }
}
async function updateStatistics() {
try {
const response = await fetch('/api/statistics');
const stats = await response.json();
document.getElementById('total-amount').textContent = '¥' + stats.total.toFixed(2);
document.getElementById('total-count').textContent = stats.count;
if (stats.category_totals && Object.keys(stats.category_totals).length > 0) {
const topCategory = Object.entries(stats.category_totals).reduce((a, b) => a[1] > b[1] ? a : b);
document.getElementById('top-category').textContent = topCategory[0];
}
} catch (error) { console.error('更新统计失败:', error); }
}
async function showChart() {
try {
const response = await fetch('/api/chart');
const result = await response.json();
if (result.success) {
document.getElementById('chart-image').src = 'data:image/png;base64,' + result.chart;
new bootstrap.Modal(document.getElementById('chartModal')).show();
} else { showAlert(result.message, 'warning'); }
} catch (error) { showAlert('生成图表失败:' + error, 'danger'); }
}
async function exportData() {
try {
const response = await fetch('/api/export');
if (response.ok) {
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `记账数据_${new Date().toISOString().slice(0,19).replace(/:/g,'')}.xlsx`;
a.click();
window.URL.revokeObjectURL(url);
showAlert('导出成功', 'success');
} else { showAlert('导出失败', 'danger'); }
} catch (error) { showAlert('导出失败:' + error, 'danger'); }
}
function refreshData() { loadData(); showAlert('数据已刷新', 'info'); }
function showAlert(message, type) {
const alertDiv = document.createElement('div');
alertDiv.className = `alert alert-${type} alert-dismissible fade show position-fixed`;
alertDiv.style.cssText = 'top: 20px; right: 20px; z-index: 9999; min-width: 300px;';
alertDiv.innerHTML = `${message}<button type="button" class="btn-close" data-bs-dismiss="alert"></button>`;
document.body.appendChild(alertDiv);
setTimeout(() => { if (alertDiv.parentNode) alertDiv.parentNode.removeChild(alertDiv); }, 3000);
}
document.addEventListener('DOMContentLoaded', loadData);
</script>
</body>
</html>'''
class Expense:
def __init__(self, amount, category, date, note=''):
self.amount = float(amount)
self.category = category
self.date = date
self.note = note
@classmethod
def from_dict(cls, data):
return cls(
data['amount'],
data['category'],
datetime.strptime(data['date'], '%Y-%m-%d'),
data.get('note', '')
)
def to_dict(self):
return {
'amount': self.amount,
'category': self.category,
'date': self.date.strftime('%Y-%m-%d'),
'note': self.note
}
class AccountBook:
def __init__(self):
self.expenses = []
self.filename = "account_data.json"
self.categories = ["餐饮", "交通", "购物", "娱乐", "学习", "医疗", "住房", "其他"]
self.load_data()
def load_data(self):
if os.path.exists(self.filename):
try:
with open(self.filename, 'r', encoding='utf-8') as f:
data = json.load(f)
self.expenses = [Expense.from_dict(item) for item in data]
print(f"成功加载 {len(self.expenses)} 条记录")
except Exception as e:
print(f"加载数据失败:{e}")
def save_data(self):
try:
data = [expense.to_dict() for expense in self.expenses]
with open(self.filename, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
print(f"成功保存 {len(data)} 条记录")
return True
except Exception as e:
print(f"保存数据失败:{e}")
return False
def add_expense(self, amount, category, date, note=''):
try:
expense = Expense(amount, category, date, note)
self.expenses.append(expense)
success = self.save_data()
return success
except Exception as e:
print(f"添加失败:{e}")
return False
def delete_expense(self, index):
if 0 <= index < len(self.expenses):
self.expenses.pop(index)
success = self.save_data()
return success
return False
def get_monthly_statistics(self, year=None, month=None):
if not year or not month:
now = datetime.now()
year, month = now.year, now.month
monthly_expenses = [
exp for exp in self.expenses
if exp.date.year == year and exp.date.month == month
]
category_totals = {}
for exp in monthly_expenses:
category_totals[exp.category] = category_totals.get(exp.category, 0) + exp.amount
return {
'total': sum(exp.amount for exp in monthly_expenses),
'category_totals': category_totals,
'count': len(monthly_expenses)
}
app = Flask(__name__)
account_book = AccountBook()
@app.route('/')
def index():
return render_template_string(HTML_TEMPLATE)
@app.route('/api/expenses', methods=['GET'])
def get_expenses():
sorted_expenses = sorted(account_book.expenses, key=lambda x: x.date, reverse=True)
return jsonify([expense.to_dict() for expense in sorted_expenses])
@app.route('/api/expenses', methods=['POST'])
def add_expense_route():
data = request.json
amount = data.get('amount')
category = data.get('category')
date_str = data.get('date')
note = data.get('note', '')
if not all([amount, category, date_str]):
return jsonify({'success': False, 'message': '请填写完整信息'})
try:
amount_val = float(amount)
if amount_val <= 0:
return jsonify({'success': False, 'message': '金额必须大于 0'})
date = datetime.strptime(date_str, '%Y-%m-%d')
except (ValueError, TypeError) as e:
return jsonify({'success': False, 'message': f'数据格式错误:{e}'})
if account_book.add_expense(amount_val, category, date, note):
return jsonify({'success': True, 'message': '添加成功'})
return jsonify({'success': False, 'message': '添加失败'})
@app.route('/api/expenses/<int:index>', methods=['DELETE'])
def delete_expense_route(index):
if account_book.delete_expense(index):
return jsonify({'success': True, 'message': '删除成功'})
return jsonify({'success': False, 'message': '删除失败'})
@app.route('/api/statistics')
def get_statistics():
return jsonify(account_book.get_monthly_statistics())
@app.route('/api/chart')
def get_chart():
stats = account_book.get_monthly_statistics()
if not stats['category_totals']:
return jsonify({'success': False, 'message': '没有数据'})
try:
plt.figure(figsize=(10, 8))
categories = list(stats['category_totals'].keys())
amounts = list(stats['category_totals'].values())
plt.subplot(1, 2, 1)
plt.pie(amounts, labels=categories, autopct='%1.1f%%', startangle=90)
plt.title('本月支出分类比例')
plt.subplot(1, 2, 2)
colors = ['#e74c3c', '#3498db', '#9b59b6', '#f39c12', '#2ecc71', '#e67e22', '#34495e', '#95a5a6']
plt.bar(categories, amounts, color=colors[:len(categories)])
plt.title('各分类支出金额')
plt.xticks(rotation=45)
plt.tight_layout()
img = io.BytesIO()
plt.savefig(img, format='png', bbox_inches='tight', dpi=100)
img.seek(0)
chart_url = base64.b64encode(img.getvalue()).decode()
plt.close()
return jsonify({'success': True, 'chart': chart_url})
except Exception as e:
print(f"生成图表失败:{e}")
return jsonify({'success': False, 'message': f'生成图表失败:{e}'})
@app.route('/api/export')
def export_data():
if not account_book.expenses:
return jsonify({'success': False, 'message': '没有数据可以导出'})
try:
data = [expense.to_dict() for expense in account_book.expenses]
df = pd.DataFrame(data)
filename = f"记账数据_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
df.to_excel(filename, index=False, encoding='utf-8')
return send_file(filename, as_attachment=True)
except Exception as e:
return jsonify({'success': False, 'message': f'导出失败:{e}'})
def main():
print("=" * 50)
print("个人记账本 Web 版")
print("=" * 50)
print("正在启动服务器...")
print("请在浏览器中访问:http://127.0.0.1:5000")
print("按 Ctrl+C 停止服务器")
print("=" * 50)
try:
import webbrowser
webbrowser.open('http://127.0.0.1:5000')
except Exception as e:
print(f"自动打开浏览器失败:{e}")
app.run(debug=False, host='127.0.0.1', port=5000)
if __name__ == '__main__':
main()