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

205 lines
11 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;