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