feat(app): 实现完整的财务管家记账聊天与统计功能
- 新增App主组件,实现聊天、账本、统计三大功能模块切换 - 支持语音及文本输入,自动解析记账信息并生成交易记录 - 交易记录支持编辑、删除、提醒等操作,数据本地持久化 - 管家性格预设和自定义设定,包含头像上传和语气调整 - 会话展示支持关联交易显示,样式区分收入与支出 - 账本页添加日期分组交易列表及饼图统计展示 - 消息列表实现渐变背景和交互动画,提升用户体验 - 支持图片上传并同步发送给AI进行解析记账 - 底部导航实现三标签切换并同步显示对应内容 - 全局事务编辑弹窗支持直接调用和删除操作 - 增加环境变量示例和.gitignore规则,完善工程配置
This commit is contained in:
205
LedgerPage.tsx
Normal file
205
LedgerPage.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user