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

179 lines
7.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;