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

208 lines
15 KiB
TypeScript

import React, { useMemo, useState, useEffect } from 'react';
import { Transaction, TransactionType, ExpenseCategory, IncomeCategory, BillReminder } from '../types';
import EditTransactionModal from './EditTransactionModal';
import CalendarView from './CalendarView';
import BillReminders, { getDaysRemaining } from './BillReminders';
interface LedgerPageProps {
transactions: Transaction[];
onUpdateTransaction: (t: Transaction) => void;
onDeleteTransaction: (id: string) => void;
budgets: Record<string, number>;
setBudgets: (val: Record<string, number>) => void;
reminders: BillReminder[];
onAddReminder: (r: BillReminder) => void;
onUpdateReminder: (r: BillReminder) => void;
onDeleteReminder: (id: string) => void;
onToggleReminder: (id: string) => void;
}
const CARD_THEMES = [
{ id: 'dark-indigo', class: 'from-gray-900 via-indigo-950 to-black', themeColor: 'bg-indigo-600', name: '暗夜霓虹' },
{ id: 'sunset', class: 'from-orange-500 via-rose-600 to-pink-700', themeColor: 'bg-rose-600', name: '落日熔金' },
{ id: 'emerald', class: 'from-emerald-600 via-teal-700 to-cyan-900', themeColor: 'bg-emerald-600', name: '薄荷森林' },
{ id: 'royal', class: 'from-blue-700 via-indigo-800 to-indigo-950', themeColor: 'bg-blue-700', name: '皇家礼赞' },
{ id: 'rose', class: 'from-rose-500 via-pink-600 to-purple-800', themeColor: 'bg-purple-600', name: '浪漫巴黎' },
];
const getLocalDateString = (date = new Date()) => {
const offset = date.getTimezoneOffset();
const localDate = new Date(date.getTime() - (offset * 60 * 1000));
return localDate.toISOString().split('T')[0];
};
const LedgerPage: React.FC<LedgerPageProps> = ({
transactions, onUpdateTransaction, onDeleteTransaction, budgets, reminders,
onAddReminder, onUpdateReminder, onDeleteReminder, onToggleReminder
}) => {
const [editingTransaction, setEditingTransaction] = useState<Transaction | null>(null);
const [subTab, setSubTab] = useState<'details' | 'calendar' | 'bills'>('details');
const [selectedMonth, setSelectedMonth] = useState(getLocalDateString().slice(0, 7));
const [themeIndex, setThemeIndex] = useState(() => Number(localStorage.getItem('cui_card_theme')) || 0);
useEffect(() => {
localStorage.setItem('cui_card_theme', themeIndex.toString());
}, [themeIndex]);
const currentTheme = CARD_THEMES[themeIndex];
// Monthly stats for the top cards
const monthlyTransactions = useMemo(() => transactions.filter(t => t.date.startsWith(selectedMonth)), [transactions, selectedMonth]);
const currentMonthExpense = useMemo(() => monthlyTransactions.filter(t => t.type === TransactionType.EXPENSE).reduce((sum, t) => sum + t.amount, 0), [monthlyTransactions]);
const currentMonthIncome = useMemo(() => monthlyTransactions.filter(t => t.type === TransactionType.INCOME).reduce((sum, t) => sum + t.amount, 0), [monthlyTransactions]);
const todayExpense = useMemo(() => transactions.filter(t => t.date === getLocalDateString() && t.type === TransactionType.EXPENSE).reduce((sum, t) => sum + t.amount, 0), [transactions]);
const currentMonthBalance = currentMonthIncome - currentMonthExpense;
const cycleTheme = () => setThemeIndex((prev) => (prev + 1) % CARD_THEMES.length);
const getIcon = (t: Transaction) => {
if (t.type === TransactionType.INCOME) return '💰';
switch (t.category) {
case ExpenseCategory.FOOD: return '🍔';
case ExpenseCategory.SHOPPING: return '🛍️';
case ExpenseCategory.TRANSPORT: return '🚗';
case ExpenseCategory.HOUSING: return '🏠';
case ExpenseCategory.ENTERTAINMENT: return '🎬';
case ExpenseCategory.MEDICAL: return '💊';
case ExpenseCategory.SOCIAL: return '🤝';
case ExpenseCategory.EDUCATION: return '📚';
case ExpenseCategory.FINANCE: return '🏦';
case ExpenseCategory.CHILD: return '👶';
case ExpenseCategory.PET: return '🐱';
case ExpenseCategory.CAR: return '🚘';
case ExpenseCategory.BEAUTY: return '💄';
case ExpenseCategory.BUSINESS: return '💼';
case ExpenseCategory.MISC: return '🧺';
default: return '🧾';
}
};
// Grouped transactions for "Details" - Filtered by selectedMonth
const allGroupedTransactions = useMemo(() => {
const grouped = monthlyTransactions.reduce((acc, t) => {
if (!acc[t.date]) acc[t.date] = [];
acc[t.date].push(t);
return acc;
}, {} as Record<string, Transaction[]>);
return Object.entries(grouped).sort((a, b) => new Date(b[0]).getTime() - new Date(a[0]).getTime());
}, [monthlyTransactions]);
return (
<div className="h-full flex flex-col bg-gray-50 overflow-y-auto no-scrollbar pb-24 md:pb-4 relative font-sans text-gray-900 antialiased">
<div className="px-5 pt-8 pb-4">
<div className="flex justify-between items-center mb-8">
<div>
<h1 className="text-2xl font-black text-gray-800 tracking-tight">我的账本</h1>
<p className="text-[10px] text-gray-400 font-bold uppercase tracking-widest">Financial Ledger</p>
</div>
<input
type="month"
value={selectedMonth}
onChange={e => setSelectedMonth(e.target.value)}
className="bg-gray-100 rounded-lg px-3 py-1.5 text-sm font-bold text-gray-600 outline-none border-none shadow-sm focus:ring-1 focus:ring-indigo-300 transition-all"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className={`col-span-2 bg-gradient-to-br ${currentTheme.class} rounded-[2.5rem] p-8 text-white shadow-2xl shadow-indigo-200/40 relative overflow-hidden group transition-all duration-700`}>
<button
onClick={cycleTheme}
className="absolute bottom-4 right-5 p-1 bg-white/10 rounded-full hover:bg-white/20 transition-all z-20 opacity-40 backdrop-blur-sm border border-white/5"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /></svg>
</button>
<div className="relative z-10 pointer-events-none">
<div className="flex justify-between items-start gap-4">
<div className="flex-1 min-w-0">
<div className="text-white/80 text-sm font-black mb-2 flex items-center gap-2 uppercase tracking-widest"><span className="w-1.5 h-1.5 bg-white/60 rounded-full"></span>本月总支出</div>
<div className="text-3xl sm:text-4xl font-black tracking-tighter font-mono whitespace-nowrap overflow-hidden">-¥{currentMonthExpense.toFixed(0)}</div>
</div>
<div className="text-right shrink-0">
<div className="text-white/50 text-xs font-black mb-1.5 uppercase tracking-widest">今日支出</div>
<div className="text-xl sm:text-2xl font-black font-mono text-white/90">-¥{todayExpense.toFixed(0)}</div>
</div>
</div>
<div className="mt-10 flex gap-8 pt-6 border-t border-white/10">
<div className="shrink-0">
<div className="text-xs text-white/60 font-black uppercase tracking-widest mb-1">本月总收入</div>
<div className="font-mono font-black text-xl sm:text-2xl text-white/90">+¥{currentMonthIncome.toFixed(0)}</div>
</div>
<div className="shrink-0">
<div className="text-xs text-white/60 font-black uppercase tracking-widest mb-1">本月结余</div>
<div className="font-mono font-black text-xl sm:text-2xl text-white/90">
{currentMonthBalance < 0 ? '-' : ''}¥{Math.abs(currentMonthBalance).toFixed(0)}
</div>
</div>
</div>
</div>
<div className="absolute -right-16 -top-24 w-80 h-80 bg-white/5 rounded-full blur-[90px]"></div>
</div>
</div>
</div>
<div className="px-5 mb-4">
<div className="bg-gray-100/50 p-1 rounded-2xl flex font-bold text-[13px] text-gray-400 border border-gray-200">
<button onClick={() => setSubTab('details')} className={`flex-1 py-2.5 rounded-xl transition-all duration-300 ${subTab === 'details' ? `${currentTheme.themeColor} text-white shadow-lg` : 'hover:text-gray-600'}`}>明细</button>
<button onClick={() => setSubTab('calendar')} className={`flex-1 py-2.5 rounded-xl transition-all duration-300 ${subTab === 'calendar' ? `${currentTheme.themeColor} text-white shadow-lg` : 'hover:text-gray-600'}`}>开销日历</button>
<button onClick={() => setSubTab('bills')} className={`flex-1 py-2.5 rounded-xl transition-all duration-300 relative ${subTab === 'bills' ? `${currentTheme.themeColor} text-white shadow-lg` : 'hover:text-gray-600'}`}>待办 {reminders.some(r => !r.isCompleted && getDaysRemaining(r) <= 1) && <span className={`absolute top-3 right-4 w-1.5 h-1.5 rounded-full animate-pulse ${subTab === 'bills' ? 'bg-white' : 'bg-rose-500'}`}></span>}</button>
</div>
</div>
<div className="flex-1 px-5 pb-20 space-y-4">
{subTab === 'details' && (
<div className="space-y-4">
{allGroupedTransactions.map(([date, items]) => {
const dayIncome = items.filter((t:any) => t.type === TransactionType.INCOME).reduce((s:any,t:any)=>s+t.amount,0);
const dayExpense = items.filter((t:any) => t.type === TransactionType.EXPENSE).reduce((s:any,t:any)=>s+t.amount,0);
const dObj = new Date(date);
const isToday = getLocalDateString() === date;
return (
<div key={date} className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden animate-float-in">
<div className="bg-gray-50/50 px-4 py-2.5 border-b border-gray-100 flex justify-between items-center text-[11px] font-bold text-gray-500">
<div className="flex items-center gap-2">
<span className={isToday ? 'text-indigo-600' : ''}>
{isToday ? '今天' : `${dObj.getMonth()+1}月${dObj.getDate()}日`}
</span>
<span className="text-gray-300 opacity-50">|</span>
<span className="text-gray-400 uppercase tracking-tighter">
{['周日','周一','周二','周三','周四','周五','周六'][dObj.getDay()]}
</span>
</div>
<div className="flex items-center gap-3">
{dayIncome > 0 && <span className="font-mono text-emerald-500">收 +¥{dayIncome.toFixed(0)}</span>}
{dayExpense > 0 && <span className="font-mono text-red-500">支 -¥{dayExpense.toFixed(0)}</span>}
</div>
</div>
<div className="divide-y divide-gray-50">
{items.slice().reverse().map((t:any) => (
<div key={t.id} onClick={() => setEditingTransaction(t)} className="flex items-center justify-between py-4 px-4 hover:bg-gray-50 active:bg-gray-100 cursor-pointer transition-colors">
<div className="flex items-center gap-3 overflow-hidden">
<div className="w-10 h-10 rounded-xl flex items-center justify-center text-lg shrink-0 bg-gray-50 border border-gray-100 shadow-inner">{getIcon(t)}</div>
<div className="min-w-0">
<p className="font-bold text-gray-800 truncate text-[14px] mb-0.5 tracking-tight">{t.item}</p>
<p className="text-[10px] text-gray-300 font-bold uppercase tracking-wider">{t.category}</p>
</div>
</div>
<div className={`font-black font-mono whitespace-nowrap pl-2 text-[15px] ${t.type === TransactionType.INCOME ? 'text-emerald-500' : 'text-red-500'}`}>
{t.type === TransactionType.INCOME ? '+' : '-'}¥{t.amount.toFixed(0)}
</div>
</div>
))}
</div>
</div>
)})}
{allGroupedTransactions.length === 0 && <div className="text-center text-gray-300 py-12 bg-white rounded-2xl border border-dashed border-gray-200"><div className="w-16 h-16 bg-gray-50 rounded-full flex items-center justify-center mx-auto mb-3 opacity-50 text-2xl">📓</div><p className="text-[11px] font-bold uppercase tracking-widest">本月暂无明细</p></div>}
</div>
)}
{subTab === 'calendar' && <CalendarView transactions={transactions} reminders={reminders} viewDate={selectedMonth} />}
{subTab === 'bills' && <BillReminders reminders={reminders.filter(r => r.frequency === 'monthly' || r.targetDate.startsWith(selectedMonth))} onAdd={onAddReminder} onUpdate={onUpdateReminder} onDelete={onDeleteReminder} onToggle={onToggleReminder} />}
</div>
{editingTransaction && <EditTransactionModal transaction={editingTransaction} onSave={(updated) => { onUpdateTransaction(updated); setEditingTransaction(null); }} onDelete={(id) => { onDeleteTransaction(id); setEditingTransaction(null); }} onClose={() => setEditingTransaction(null)} />}
</div>
);
};
export default LedgerPage;