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

179
components/CalendarView.tsx Normal file
View File

@@ -0,0 +1,179 @@
import React, { useState, useMemo, useEffect } from 'react';
import { Transaction, TransactionType, BillReminder } from '../types';
import { Solar } from 'lunar-javascript';
interface CalendarViewProps {
transactions: Transaction[];
reminders?: BillReminder[];
viewDate?: string; // Format: YYYY-MM
}
const formatAmount = (num: number): string => {
// 如果金额超过 10 万(十万单位),显示为 X.Y万,最多显示小数点后一位
if (num >= 100000) {
return (num / 10000).toFixed(1).replace(/\.0$/, '') + '万';
}
// 否则显示完整数字(四舍五入)
return Math.round(num).toString();
};
const CalendarView: React.FC<CalendarViewProps> = ({ transactions, reminders = [], viewDate }) => {
// Initialize from prop or current date
const [currentDate, setCurrentDate] = useState(() => {
if (viewDate) {
const [y, m] = viewDate.split('-').map(Number);
return new Date(y, m - 1, 1);
}
return new Date();
});
// Sync internal state when prop changes
useEffect(() => {
if (viewDate) {
const [y, m] = viewDate.split('-').map(Number);
setCurrentDate(new Date(y, m - 1, 1));
}
}, [viewDate]);
const year = currentDate.getFullYear();
const month = currentDate.getMonth();
const daysInMonth = new Date(year, month + 1, 0).getDate();
const firstDayOfMonth = new Date(year, month, 1).getDay(); // 0 is Sunday
const handlePrevMonth = () => setCurrentDate(new Date(year, month - 1, 1));
const handleNextMonth = () => setCurrentDate(new Date(year, month + 1, 1));
// Helper to get Lunar Date text
const getLunarText = (y: number, m: number, d: number) => {
try {
const solar = Solar.fromYmd(y, m + 1, d);
const lunar = solar.getLunar();
const day = lunar.getDayInChinese();
if (day === '初一') {
return lunar.getMonthInChinese() + '月';
}
return day;
} catch (e) {
return '';
}
};
// Aggregate stats by date
const dailyStats = useMemo(() => {
const map = new Map<string, { income: number; expense: number; reminderTitle?: string }>();
// Process Transactions
transactions.forEach(t => {
const existing = map.get(t.date) || { income: 0, expense: 0 };
if (t.type === TransactionType.EXPENSE) {
existing.expense += t.amount;
} else {
existing.income += t.amount;
}
map.set(t.date, existing);
});
// Process Reminders
reminders.forEach(r => {
let dateKey = '';
if (r.frequency === 'monthly') {
const day = parseInt(r.targetDate);
if (day <= daysInMonth) {
dateKey = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
}
} else {
dateKey = r.targetDate;
}
if (dateKey) {
const existing = map.get(dateKey) || { income: 0, expense: 0 };
if (!r.isCompleted && !existing.reminderTitle) {
existing.reminderTitle = r.title;
}
map.set(dateKey, existing);
}
});
return map;
}, [transactions, reminders, year, month, daysInMonth]);
const renderDays = () => {
const days = [];
for (let i = 0; i < firstDayOfMonth; i++) {
days.push(<div key={`empty-${i}`} className="h-24 bg-gray-50/20 border border-gray-100/30"></div>);
}
for (let d = 1; d <= daysInMonth; d++) {
const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
const stat = dailyStats.get(dateStr) || { income: 0, expense: 0 };
const isToday = new Date().toISOString().split('T')[0] === dateStr;
const lunarText = getLunarText(year, month, d);
days.push(
<div key={d} className={`h-24 border border-gray-100 p-1 flex flex-col justify-between relative overflow-hidden transition-colors ${isToday ? 'bg-indigo-50/40 ring-1 ring-inset ring-indigo-100' : 'bg-white'}`}>
<div className="flex flex-col w-full items-center pt-1">
<div className="flex flex-col items-center leading-none">
<span className={`text-[11px] font-black w-5 h-5 flex items-center justify-center rounded-full mb-0.5 ${isToday ? 'bg-indigo-600 text-white' : 'text-gray-700'}`}>
{d}
</span>
<span className="text-[8px] text-gray-300 font-bold scale-90">{lunarText}</span>
</div>
{stat.reminderTitle && (
<div className="w-full px-0.5 mt-1 text-center">
<span className="block text-[8px] font-black text-rose-500 bg-rose-50 border border-rose-100 rounded px-0.5 py-0.5 whitespace-nowrap overflow-hidden leading-none uppercase tracking-tighter">
{stat.reminderTitle.slice(0, 4)}
</span>
</div>
)}
</div>
<div className="flex flex-col items-end w-full px-0.5 gap-0.5 pb-1">
{stat.income > 0 && (
<div className="flex items-center gap-0.5 w-full justify-end">
<span className="text-[9px] font-black text-emerald-500 leading-none whitespace-nowrap overflow-visible">
¥{formatAmount(stat.income)}
</span>
</div>
)}
{stat.expense > 0 && (
<div className="flex items-center gap-0.5 w-full justify-end">
<span className="text-[9px] font-black text-rose-400 leading-none whitespace-nowrap overflow-visible">
¥{formatAmount(stat.expense)}
</span>
</div>
)}
</div>
</div>
);
}
return days;
};
return (
<div className="bg-white rounded-[2rem] shadow-sm border border-gray-100 overflow-hidden">
<div className="flex items-center justify-between p-4 border-b border-gray-100 bg-white">
<button onClick={handlePrevMonth} className="p-2 hover:bg-gray-50 rounded-xl text-gray-300 hover:text-indigo-600 transition-colors">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" /></svg>
</button>
<h3 className="font-black text-gray-800 text-sm tracking-widest uppercase">
{year} / {String(month + 1).padStart(2, '0')}
</h3>
<button onClick={handleNextMonth} className="p-2 hover:bg-gray-50 rounded-xl text-gray-300 hover:text-indigo-600 transition-colors">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /></svg>
</button>
</div>
<div className="grid grid-cols-7 bg-gray-50/30">
{['日', '一', '二', '三', '四', '五', '六'].map(day => (
<div key={day} className="py-2.5 text-center text-[9px] font-black text-gray-400 uppercase tracking-widest">
{day}
</div>
))}
</div>
<div className="grid grid-cols-7 border-t border-gray-50">
{renderDays()}
</div>
</div>
);
};
export default CalendarView;