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