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