feat(app): 实现完整的财务管家记账聊天与统计功能

- 新增App主组件,实现聊天、账本、统计三大功能模块切换
- 支持语音及文本输入,自动解析记账信息并生成交易记录
- 交易记录支持编辑、删除、提醒等操作,数据本地持久化
- 管家性格预设和自定义设定,包含头像上传和语气调整
- 会话展示支持关联交易显示,样式区分收入与支出
- 账本页添加日期分组交易列表及饼图统计展示
- 消息列表实现渐变背景和交互动画,提升用户体验
- 支持图片上传并同步发送给AI进行解析记账
- 底部导航实现三标签切换并同步显示对应内容
- 全局事务编辑弹窗支持直接调用和删除操作
- 增加环境变量示例和.gitignore规则,完善工程配置
This commit is contained in:
2026-01-19 20:20:54 +08:00
commit c06e74bd01
24 changed files with 7081 additions and 0 deletions

205
LedgerPage.tsx Normal file
View File

@@ -0,0 +1,205 @@
import React, { useMemo, useState } from 'react';
import { Transaction, TransactionType, ExpenseCategory, IncomeCategory } from '../types';
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend } from 'recharts';
import EditTransactionModal from './EditTransactionModal';
interface LedgerPageProps {
transactions: Transaction[];
onUpdateTransaction: (t: Transaction) => void;
onDeleteTransaction: (id: string) => void;
}
const COLORS = ['#6366f1', '#ec4899', '#10b981', '#f59e0b', '#8b5cf6', '#ef4444', '#6b7280'];
const LedgerPage: React.FC<LedgerPageProps> = ({ transactions, onUpdateTransaction, onDeleteTransaction }) => {
const [editingTransaction, setEditingTransaction] = useState<Transaction | null>(null);
// Group by date
const groupedTransactions = useMemo(() => {
const groups: { [key: string]: Transaction[] } = {};
transactions.forEach(t => {
if (!groups[t.date]) groups[t.date] = [];
groups[t.date].push(t);
});
// Sort dates descending
return Object.entries(groups).sort((a, b) => new Date(b[0]).getTime() - new Date(a[0]).getTime());
}, [transactions]);
const totalExpense = useMemo(() =>
transactions.filter(t => t.type === TransactionType.EXPENSE).reduce((sum, t) => sum + t.amount, 0),
[transactions]);
const totalIncome = useMemo(() =>
transactions.filter(t => t.type === TransactionType.INCOME).reduce((sum, t) => sum + t.amount, 0),
[transactions]);
const balance = totalIncome - totalExpense;
// Category Data for Pie Chart (Expenses Only)
const expenseCategoryData = useMemo(() => {
const map = new Map<string, number>();
transactions.filter(t => t.type === TransactionType.EXPENSE).forEach(t => {
map.set(t.category, (map.get(t.category) || 0) + t.amount);
});
return Array.from(map.entries())
.map(([name, value], index) => ({ name, value, fill: COLORS[index % COLORS.length] }))
.sort((a, b) => b.value - a.value);
}, [transactions]);
const getIcon = (t: Transaction) => {
if (t.type === TransactionType.INCOME) {
switch (t.category) {
case IncomeCategory.SALARY: return '💰';
case IncomeCategory.BONUS: return '🧧';
case IncomeCategory.INVESTMENT: return '📈';
default: return '💵';
}
}
switch (t.category) {
case ExpenseCategory.FOOD: return '🍔';
case ExpenseCategory.SHOPPING: return '🛍️';
case ExpenseCategory.TRANSPORT: return '🚗';
case ExpenseCategory.ENTERTAINMENT: return '🎬';
case ExpenseCategory.HOUSING: return '🏠';
case ExpenseCategory.MEDICAL: return '💊';
default: return '🧾';
}
};
const handleSaveEdit = (updated: Transaction) => {
onUpdateTransaction(updated);
setEditingTransaction(null);
};
const handleDelete = (id: string) => {
onDeleteTransaction(id);
setEditingTransaction(null);
};
return (
<div className="h-full flex flex-col bg-gray-50 overflow-y-auto no-scrollbar pb-24 md:pb-4 relative">
{/* Header Summary Cards */}
<div className="bg-indigo-600 text-white p-6 rounded-b-3xl shadow-lg shrink-0">
<div className="grid grid-cols-3 gap-4 text-center">
<div className="flex flex-col items-center">
<span className="text-xs opacity-70 mb-1">收入 (Income)</span>
<span className="text-lg font-bold text-emerald-200">+¥{totalIncome.toFixed(0)}</span>
</div>
<div className="flex flex-col items-center border-x border-indigo-500/50">
<span className="text-xs opacity-70 mb-1">结余 (Balance)</span>
<span className="text-2xl font-bold">¥{balance.toFixed(0)}</span>
</div>
<div className="flex flex-col items-center">
<span className="text-xs opacity-70 mb-1">支出 (Expense)</span>
<span className="text-lg font-bold text-pink-200">-¥{totalExpense.toFixed(0)}</span>
</div>
</div>
</div>
<div className="p-4 space-y-6 flex-1 max-w-3xl mx-auto w-full">
{/* Charts Section (Expenses) */}
{expenseCategoryData.length > 0 && (
<div className="bg-white p-5 rounded-2xl shadow-sm border border-gray-100">
<h3 className="text-gray-700 font-bold mb-4 flex items-center gap-2 text-sm">
<svg className="w-4 h-4 text-indigo-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 3.055A9.001 9.001 0 1020.945 13H11V3.055z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.488 9H15V3.512A9.025 9.025 0 0120.488 9z" /></svg>
支出构成
</h3>
<div className="h-48 w-full">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={expenseCategoryData}
cx="50%"
cy="50%"
innerRadius={45}
outerRadius={65}
paddingAngle={5}
dataKey="value"
>
{expenseCategoryData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.fill} />
))}
</Pie>
<Tooltip formatter={(value: number) => `¥${value.toFixed(2)}`} />
<Legend iconSize={8} wrapperStyle={{ fontSize: '12px' }} />
</PieChart>
</ResponsiveContainer>
</div>
</div>
)}
{/* Transactions List */}
<div className="space-y-4">
<h3 className="text-gray-700 font-bold px-2 flex items-center gap-2 text-sm">
<svg className="w-4 h-4 text-indigo-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" /></svg>
收支明细
</h3>
{groupedTransactions.map(([date, items]) => {
const dayIncome = items.filter(t => t.type === TransactionType.INCOME).reduce((s,t)=>s+t.amount,0);
const dayExpense = items.filter(t => t.type === TransactionType.EXPENSE).reduce((s,t)=>s+t.amount,0);
return (
<div key={date} className="bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden">
<div className="bg-gray-50/80 px-4 py-2 border-b border-gray-100 flex justify-between items-center backdrop-blur-sm sticky top-0 text-xs">
<span className="font-semibold text-gray-500">{date}</span>
<div className="flex gap-2">
{dayIncome > 0 && <span className="text-emerald-600">收 ¥{dayIncome}</span>}
{dayExpense > 0 && <span className="text-gray-600">支 ¥{dayExpense}</span>}
</div>
</div>
<div>
{items.slice().reverse().map((t, idx) => (
<div
key={t.id}
onClick={() => setEditingTransaction(t)}
className={`flex items-center justify-between p-4 hover:bg-gray-50 active:bg-gray-100 cursor-pointer transition-colors ${idx !== items.length - 1 ? 'border-b border-gray-50' : ''}`}
>
<div className="flex items-center gap-3 overflow-hidden">
<div className={`w-10 h-10 rounded-full flex items-center justify-center text-lg shrink-0 shadow-sm
${t.type === TransactionType.INCOME
? 'bg-emerald-100 text-emerald-600'
: (t.category === ExpenseCategory.FOOD ? 'bg-orange-100 text-orange-500' :
t.category === ExpenseCategory.SHOPPING ? 'bg-pink-100 text-pink-500' :
t.category === ExpenseCategory.TRANSPORT ? 'bg-blue-100 text-blue-500' :
'bg-gray-100 text-gray-500')
}`}
>
{getIcon(t)}
</div>
<div className="min-w-0">
<p className="font-medium text-gray-800 truncate">{t.item}</p>
<p className="text-xs text-gray-500">{t.category}</p>
</div>
</div>
<div className={`font-bold whitespace-nowrap pl-2 ${t.type === TransactionType.INCOME ? 'text-emerald-600' : 'text-gray-900'}`}>
{t.type === TransactionType.INCOME ? '+' : '-'} {t.amount.toFixed(2)}
</div>
</div>
))}
</div>
</div>
)})}
{transactions.length === 0 && (
<div className="text-center text-gray-400 py-12 bg-white rounded-2xl border border-dashed border-gray-200">
<p className="text-4xl mb-2">💸</p>
<p>还没有账目</p>
<p className="text-sm mt-1">告诉崔经理你赚了多少或花了多少!</p>
</div>
)}
</div>
</div>
{/* Edit Modal */}
{editingTransaction && (
<EditTransactionModal
transaction={editingTransaction}
onSave={handleSaveEdit}
onDelete={handleDelete}
onClose={() => setEditingTransaction(null)}
/>
)}
</div>
);
};
export default LedgerPage;