feat(app): 实现完整的财务管家记账聊天与统计功能
- 新增App主组件,实现聊天、账本、统计三大功能模块切换 - 支持语音及文本输入,自动解析记账信息并生成交易记录 - 交易记录支持编辑、删除、提醒等操作,数据本地持久化 - 管家性格预设和自定义设定,包含头像上传和语气调整 - 会话展示支持关联交易显示,样式区分收入与支出 - 账本页添加日期分组交易列表及饼图统计展示 - 消息列表实现渐变背景和交互动画,提升用户体验 - 支持图片上传并同步发送给AI进行解析记账 - 底部导航实现三标签切换并同步显示对应内容 - 全局事务编辑弹窗支持直接调用和删除操作 - 增加环境变量示例和.gitignore规则,完善工程配置
This commit is contained in:
247
components/BillReminders.tsx
Normal file
247
components/BillReminders.tsx
Normal file
@@ -0,0 +1,247 @@
|
||||
import React, { useState } from 'react';
|
||||
import { BillReminder } from '../types';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
interface BillRemindersProps {
|
||||
reminders: BillReminder[];
|
||||
onAdd: (reminder: BillReminder) => void;
|
||||
onUpdate: (reminder: BillReminder) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onToggle: (id: string) => void;
|
||||
}
|
||||
|
||||
// Helper to calculate days remaining
|
||||
export const getDaysRemaining = (r: BillReminder): number => {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
let targetDate = new Date();
|
||||
targetDate.setHours(0, 0, 0, 0);
|
||||
|
||||
if (r.frequency === 'monthly') {
|
||||
const day = parseInt(r.targetDate);
|
||||
targetDate.setDate(day);
|
||||
|
||||
// If the day has passed this month, move to next month
|
||||
if (targetDate < today) {
|
||||
targetDate.setMonth(targetDate.getMonth() + 1);
|
||||
}
|
||||
} else {
|
||||
// Once
|
||||
targetDate = new Date(r.targetDate);
|
||||
targetDate.setHours(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
const diffTime = targetDate.getTime() - today.getTime();
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
return diffDays;
|
||||
};
|
||||
|
||||
const BillReminders: React.FC<BillRemindersProps> = ({ reminders, onAdd, onUpdate, onDelete, onToggle }) => {
|
||||
const [isFormOpen, setIsFormOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
|
||||
const [title, setTitle] = useState('');
|
||||
const [amount, setAmount] = useState('');
|
||||
const [frequency, setFrequency] = useState<'monthly' | 'once'>('monthly');
|
||||
const [targetDate, setTargetDate] = useState(''); // '15' or '2023-10-15'
|
||||
|
||||
const resetForm = () => {
|
||||
setTitle('');
|
||||
setAmount('');
|
||||
setFrequency('monthly');
|
||||
setTargetDate('');
|
||||
setEditingId(null);
|
||||
setIsFormOpen(false);
|
||||
};
|
||||
|
||||
const startEdit = (r: BillReminder) => {
|
||||
setEditingId(r.id);
|
||||
setTitle(r.title);
|
||||
setAmount(r.amount ? r.amount.toString() : '');
|
||||
setFrequency(r.frequency);
|
||||
setTargetDate(r.targetDate);
|
||||
setIsFormOpen(true);
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
// Amount is now optional, check title and targetDate only
|
||||
if (!title || !targetDate) return;
|
||||
|
||||
const parsedAmount = parseFloat(amount);
|
||||
|
||||
const reminderData: BillReminder = {
|
||||
id: editingId || uuidv4(),
|
||||
title,
|
||||
amount: isNaN(parsedAmount) ? undefined : parsedAmount,
|
||||
frequency,
|
||||
targetDate,
|
||||
isCompleted: editingId ? reminders.find(r => r.id === editingId)?.isCompleted || false : false
|
||||
};
|
||||
|
||||
if (editingId) {
|
||||
onUpdate(reminderData);
|
||||
} else {
|
||||
onAdd(reminderData);
|
||||
}
|
||||
resetForm();
|
||||
};
|
||||
|
||||
const getDueText = (r: BillReminder) => {
|
||||
if (r.frequency === 'monthly') {
|
||||
return `每月 ${r.targetDate} 日`;
|
||||
}
|
||||
return r.targetDate; // YYYY-MM-DD
|
||||
};
|
||||
|
||||
const renderCountdown = (r: BillReminder) => {
|
||||
const days = getDaysRemaining(r);
|
||||
if (r.isCompleted) return <span className="text-emerald-600 font-bold text-[10px] bg-emerald-50 px-2 py-0.5 rounded-full border border-emerald-100">已完成</span>;
|
||||
if (days < 0) return <span className="text-rose-600 font-bold text-[10px] bg-rose-50 px-2 py-0.5 rounded-full border border-rose-100">逾期 {Math.abs(days)} 天</span>;
|
||||
if (days === 0) return <span className="text-rose-600 font-bold text-[10px] bg-rose-50 px-2 py-0.5 rounded-full border border-rose-100 animate-pulse">今天到期</span>;
|
||||
if (days <= 3) return <span className="text-orange-600 font-bold text-[10px] bg-orange-50 px-2 py-0.5 rounded-full border border-orange-100">剩 {days} 天</span>;
|
||||
return <span className="text-indigo-600 font-bold text-[10px] bg-indigo-50 px-2 py-0.5 rounded-full border border-indigo-100">剩 {days} 天</span>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between px-2">
|
||||
<h3 className="text-gray-700 font-bold flex items-center gap-2 text-sm">
|
||||
<svg className="w-5 h-5 text-indigo-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
||||
待办 & 还款提醒
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (isFormOpen) resetForm();
|
||||
else setIsFormOpen(true);
|
||||
}}
|
||||
className="text-indigo-600 text-xs font-semibold hover:bg-indigo-50 px-2 py-1 rounded-lg transition-colors bg-white shadow-sm border border-indigo-100"
|
||||
>
|
||||
{isFormOpen ? '取消' : '+ 新增'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isFormOpen && (
|
||||
<form onSubmit={handleSubmit} className="bg-white p-4 rounded-2xl shadow-lg border border-indigo-50 animate-float-in relative z-20">
|
||||
<h4 className="text-sm font-bold text-gray-800 mb-3 flex items-center gap-2">
|
||||
<div className="w-1 h-4 bg-indigo-500 rounded-full"></div>
|
||||
{editingId ? '编辑提醒' : '新提醒'}
|
||||
</h4>
|
||||
<div className="space-y-3">
|
||||
<input
|
||||
placeholder="项目名称 (如: 房贷、信用卡)"
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
className="w-full p-2 bg-gray-50 border-none rounded-xl text-sm focus:ring-2 focus:ring-indigo-500 outline-none transition-all"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="number"
|
||||
placeholder="金额 (选填)"
|
||||
value={amount}
|
||||
onChange={e => setAmount(e.target.value)}
|
||||
className="flex-1 p-2 bg-gray-50 border-none rounded-xl text-sm focus:ring-2 focus:ring-indigo-500 outline-none transition-all"
|
||||
/>
|
||||
<select
|
||||
value={frequency}
|
||||
onChange={e => setFrequency(e.target.value as any)}
|
||||
className="p-2 bg-gray-50 border-none rounded-xl text-sm outline-none"
|
||||
>
|
||||
<option value="monthly">每月</option>
|
||||
<option value="once">单次</option>
|
||||
</select>
|
||||
</div>
|
||||
{frequency === 'monthly' ? (
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600 bg-gray-50 p-2 rounded-xl">
|
||||
<span>每月</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1" max="31"
|
||||
placeholder="日"
|
||||
value={targetDate}
|
||||
onChange={e => setTargetDate(e.target.value)}
|
||||
className="w-12 p-1 border-b border-indigo-200 text-center bg-transparent focus:border-indigo-500 outline-none"
|
||||
/>
|
||||
<span>号还款</span>
|
||||
</div>
|
||||
) : (
|
||||
<input
|
||||
type="date"
|
||||
value={targetDate}
|
||||
onChange={e => setTargetDate(e.target.value)}
|
||||
className="w-full p-2 bg-gray-50 border-none rounded-xl text-sm text-gray-600 outline-none"
|
||||
/>
|
||||
)}
|
||||
<button type="submit" className="w-full py-2 bg-indigo-600 text-white rounded-xl text-sm font-bold shadow-md shadow-indigo-200 hover:bg-indigo-700 transition-transform active:scale-95">
|
||||
{editingId ? '保存' : '添加'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{reminders.length === 0 && !isFormOpen && (
|
||||
<div className="text-center py-6 text-gray-400 text-xs bg-white rounded-2xl border border-dashed border-gray-200">
|
||||
<p className="text-xl mb-1">📅</p>
|
||||
暂无待办事项
|
||||
</div>
|
||||
)}
|
||||
{reminders.map(r => (
|
||||
<div key={r.id} className={`relative overflow-hidden p-3 rounded-2xl border transition-all duration-300 ${r.isCompleted ? 'bg-gray-50 border-gray-100 opacity-60' : 'bg-white border-gray-100 shadow-sm'}`}>
|
||||
{/* Decorative side bar */}
|
||||
<div className={`absolute left-0 top-0 bottom-0 w-1 ${r.isCompleted ? 'bg-gray-300' : (getDaysRemaining(r) <= 3 ? 'bg-rose-500' : 'bg-indigo-500')}`}></div>
|
||||
|
||||
<div className="flex items-center justify-between pl-3 pr-1">
|
||||
{/* Left: Info */}
|
||||
<div className="flex-1 min-w-0 mr-2">
|
||||
{/* Row 1: Checkbox + Title + Amount */}
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<button
|
||||
onClick={() => onToggle(r.id)}
|
||||
className={`w-5 h-5 rounded-full border-2 flex items-center justify-center transition-colors shrink-0 ${r.isCompleted ? 'bg-emerald-500 border-emerald-500' : 'border-gray-300 hover:border-indigo-500'}`}
|
||||
>
|
||||
{r.isCompleted && <svg className="w-3 h-3 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" /></svg>}
|
||||
</button>
|
||||
<span className={`font-bold text-sm truncate ${r.isCompleted ? 'line-through text-gray-400' : 'text-gray-800'}`}>{r.title}</span>
|
||||
{r.amount !== undefined && (
|
||||
<span className="font-mono text-gray-500 font-bold text-[10px] bg-gray-50 px-1 rounded">¥{r.amount}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Row 2: Date + Countdown */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1 text-[10px] text-gray-400">
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
|
||||
<span>{getDueText(r)}</span>
|
||||
</div>
|
||||
{renderCountdown(r)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Actions (Separated) */}
|
||||
<div className="flex items-center gap-6 border-l border-gray-100 pl-4 py-1">
|
||||
<button
|
||||
onClick={() => startEdit(r)}
|
||||
className="text-gray-400 hover:text-indigo-600 transition-colors p-1"
|
||||
title="编辑"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" /></svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDelete(r.id)}
|
||||
className="text-gray-400 hover:text-rose-600 transition-colors p-1"
|
||||
title="删除"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BillReminders;
|
||||
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;
|
||||
114
components/Dashboard.tsx
Normal file
114
components/Dashboard.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Transaction, TransactionType, ExpenseCategory } from '../types';
|
||||
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend, BarChart, Bar, XAxis, YAxis, CartesianGrid } from 'recharts';
|
||||
|
||||
interface DashboardProps {
|
||||
expenses: Transaction[]; // Kept prop name as expenses for compatibility but type is Transaction
|
||||
}
|
||||
|
||||
const COLORS = [
|
||||
'#6366f1', // Indigo
|
||||
'#ec4899', // Pink
|
||||
'#10b981', // Emerald
|
||||
'#f59e0b', // Amber
|
||||
'#8b5cf6', // Violet
|
||||
'#ef4444', // Red
|
||||
'#3b82f6', // Blue
|
||||
'#14b8a6', // Teal
|
||||
'#f97316', // Orange
|
||||
'#84cc16', // Lime
|
||||
'#06b6d4', // Cyan
|
||||
'#d946ef', // Fuchsia
|
||||
'#64748b', // Slate
|
||||
'#a855f7', // Purple
|
||||
'#fbbf24' // Yellow
|
||||
];
|
||||
|
||||
const Dashboard: React.FC<DashboardProps> = ({ expenses }) => {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
|
||||
// Filter only expenses for the dashboard visuals
|
||||
const expenseTransactions = useMemo(() => expenses.filter(e => e.type === TransactionType.EXPENSE), [expenses]);
|
||||
|
||||
const todaysExpenses = useMemo(() => {
|
||||
return expenseTransactions.filter(e => e.date === today);
|
||||
}, [expenseTransactions, today]);
|
||||
|
||||
const totalToday = useMemo(() => {
|
||||
return todaysExpenses.reduce((sum, e) => sum + e.amount, 0);
|
||||
}, [todaysExpenses]);
|
||||
|
||||
const categoryData = useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
todaysExpenses.forEach(e => {
|
||||
map.set(e.category, (map.get(e.category) || 0) + e.amount);
|
||||
});
|
||||
|
||||
return Array.from(map.entries()).map(([name, value], index) => ({
|
||||
name,
|
||||
value,
|
||||
fill: COLORS[index % COLORS.length]
|
||||
}));
|
||||
}, [todaysExpenses]);
|
||||
|
||||
const weeklyData = useMemo(() => {
|
||||
const data: { date: string; total: number }[] = [];
|
||||
for (let i = 6; i >= 0; i--) {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - i);
|
||||
const dateStr = d.toISOString().split('T')[0];
|
||||
const dailySum = expenseTransactions
|
||||
.filter(e => e.date === dateStr)
|
||||
.reduce((sum, e) => sum + e.amount, 0);
|
||||
data.push({ date: dateStr.slice(5), total: dailySum });
|
||||
}
|
||||
return data;
|
||||
}, [expenseTransactions]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
|
||||
<div className="p-6 bg-gradient-to-r from-indigo-500 to-purple-600 text-white">
|
||||
<h2 className="text-xl font-bold mb-1">今日消费统计</h2>
|
||||
<div className="flex items-end gap-2">
|
||||
<span className="text-4xl font-extrabold tracking-tight">¥{totalToday.toFixed(2)}</span>
|
||||
<span className="text-indigo-100 mb-1 text-sm">{today}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-6 no-scrollbar">
|
||||
<div className="bg-gray-50 p-4 rounded-xl">
|
||||
<h3 className="text-gray-700 font-semibold mb-4 text-sm uppercase tracking-wider">今日分类占比</h3>
|
||||
{categoryData.length > 0 ? (
|
||||
<div className="h-64 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={categoryData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={60}
|
||||
outerRadius={80}
|
||||
paddingAngle={5}
|
||||
dataKey="value"
|
||||
>
|
||||
{categoryData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.fill} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip formatter={(value: number) => `¥${value.toFixed(2)}`} />
|
||||
<Legend />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-32 flex items-center justify-center text-gray-400 text-sm">
|
||||
今天还没有消费哦,小王表现不错!
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
116
components/EditTransactionModal.tsx
Normal file
116
components/EditTransactionModal.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Transaction, TransactionType, ExpenseCategory, IncomeCategory } from '../types';
|
||||
|
||||
interface EditTransactionModalProps {
|
||||
transaction: Transaction;
|
||||
onSave: (updated: Transaction) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onClose: () => void;
|
||||
hideDelete?: boolean;
|
||||
}
|
||||
|
||||
const EditTransactionModal: React.FC<EditTransactionModalProps> = ({ transaction, onSave, onDelete, onClose, hideDelete = false }) => {
|
||||
const [draft, setDraft] = useState<Transaction>(transaction);
|
||||
|
||||
useEffect(() => {
|
||||
setDraft(transaction);
|
||||
}, [transaction]);
|
||||
|
||||
const handleDelete = () => {
|
||||
if (confirm('确定要删除这条记录吗?崔经理会盯着你的。')) {
|
||||
onDelete(draft.id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] flex items-end md:items-center justify-center bg-black/50 backdrop-blur-sm p-4">
|
||||
<div className="bg-white w-full max-w-md rounded-2xl p-6 shadow-2xl animate-float md:animate-none">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg font-bold text-gray-900">编辑账单</h3>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">金额</label>
|
||||
<input
|
||||
type="number"
|
||||
value={draft.amount}
|
||||
onChange={(e) => setDraft({...draft, amount: parseFloat(e.target.value) || 0})}
|
||||
className="w-full p-3 border border-gray-200 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none font-bold text-lg bg-white shadow-sm transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">内容</label>
|
||||
<input
|
||||
type="text"
|
||||
value={draft.item}
|
||||
onChange={(e) => setDraft({...draft, item: e.target.value})}
|
||||
className="w-full p-3 border border-gray-200 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none bg-white shadow-sm transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">类型</label>
|
||||
<select
|
||||
value={draft.type}
|
||||
onChange={(e) => setDraft({
|
||||
...draft,
|
||||
type: e.target.value as TransactionType,
|
||||
category: e.target.value === TransactionType.EXPENSE ? ExpenseCategory.MISC : IncomeCategory.OTHER
|
||||
})}
|
||||
className="w-full p-3 border border-gray-200 rounded-xl focus:ring-2 focus:ring-indigo-500 outline-none bg-white shadow-sm transition-all"
|
||||
>
|
||||
<option value={TransactionType.EXPENSE}>支出</option>
|
||||
<option value={TransactionType.INCOME}>收入</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">分类</label>
|
||||
<select
|
||||
value={draft.category}
|
||||
onChange={(e) => setDraft({...draft, category: e.target.value})}
|
||||
className="w-full p-3 border border-gray-200 rounded-xl focus:ring-2 focus:ring-indigo-500 outline-none bg-white shadow-sm transition-all"
|
||||
>
|
||||
{draft.type === TransactionType.EXPENSE
|
||||
? Object.values(ExpenseCategory).map(c => <option key={c} value={c}>{c}</option>)
|
||||
: Object.values(IncomeCategory).map(c => <option key={c} value={c}>{c}</option>)
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">日期</label>
|
||||
<input
|
||||
type="date"
|
||||
value={draft.date}
|
||||
onChange={(e) => setDraft({...draft, date: e.target.value})}
|
||||
className="w-full p-3 border border-gray-200 rounded-xl focus:ring-2 focus:ring-indigo-500 outline-none bg-white shadow-sm transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 flex gap-3">
|
||||
{!hideDelete && (
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="flex-1 py-3 px-4 bg-red-50 text-red-600 rounded-xl font-semibold hover:bg-red-100 transition-colors"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => onSave(draft)}
|
||||
className={`py-3 px-4 bg-indigo-600 text-white rounded-xl font-semibold hover:bg-indigo-700 transition-all shadow-md shadow-indigo-200 ${hideDelete ? 'w-full' : 'flex-[2]'}`}
|
||||
>
|
||||
保存修改
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditTransactionModal;
|
||||
208
components/LedgerPage.tsx
Normal file
208
components/LedgerPage.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
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;
|
||||
274
components/StatisticsPage.tsx
Normal file
274
components/StatisticsPage.tsx
Normal file
@@ -0,0 +1,274 @@
|
||||
import React, { useMemo, useState, useEffect } from 'react';
|
||||
import { Transaction, TransactionType, ExpenseCategory } from '../types';
|
||||
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell, LineChart, Line, CartesianGrid, ReferenceLine } from 'recharts';
|
||||
|
||||
interface StatisticsPageProps {
|
||||
transactions: Transaction[];
|
||||
}
|
||||
|
||||
const COLORS = [
|
||||
'#6366f1', // Indigo
|
||||
'#ec4899', // Pink
|
||||
'#10b981', // Emerald
|
||||
'#f59e0b', // Amber
|
||||
'#8b5cf6', // Violet
|
||||
'#ef4444', // Red
|
||||
'#3b82f6', // Blue
|
||||
'#14b8a6', // Teal
|
||||
'#f97316', // Orange
|
||||
'#84cc16', // Lime
|
||||
'#06b6d4', // Cyan
|
||||
'#d946ef', // Fuchsia
|
||||
'#64748b', // Slate
|
||||
'#a855f7', // Purple
|
||||
'#fbbf24' // Yellow
|
||||
];
|
||||
|
||||
const StatisticsPage: React.FC<StatisticsPageProps> = ({ transactions }) => {
|
||||
const [selectedMonth, setSelectedMonth] = useState(new Date().toISOString().slice(0, 7));
|
||||
const [trendCategory, setTrendCategory] = useState<string>(ExpenseCategory.FOOD);
|
||||
|
||||
const monthlyTransactions = useMemo(() => {
|
||||
return transactions.filter(t => t.date.startsWith(selectedMonth));
|
||||
}, [transactions, selectedMonth]);
|
||||
|
||||
const totalIncome = useMemo(() =>
|
||||
monthlyTransactions.filter(t => t.type === TransactionType.INCOME).reduce((sum, t) => sum + t.amount, 0),
|
||||
[monthlyTransactions]);
|
||||
|
||||
const totalExpense = useMemo(() =>
|
||||
monthlyTransactions.filter(t => t.type === TransactionType.EXPENSE).reduce((sum, t) => sum + t.amount, 0),
|
||||
[monthlyTransactions]);
|
||||
|
||||
const monthlyBalance = useMemo(() => totalIncome - totalExpense, [totalIncome, totalExpense]);
|
||||
|
||||
// Extract month number for display
|
||||
const monthLabel = useMemo(() => {
|
||||
const month = parseInt(selectedMonth.split('-')[1]);
|
||||
return `${month}月`;
|
||||
}, [selectedMonth]);
|
||||
|
||||
const barData = [
|
||||
{ name: '收入', amount: totalIncome, fill: '#10b981' },
|
||||
{ name: '支出', amount: totalExpense, fill: '#ec4899' },
|
||||
{ name: '结余', amount: monthlyBalance, fill: '#6366f1' }
|
||||
];
|
||||
|
||||
// Category Stats (Used for Ranking and Detail)
|
||||
const categoryStats = useMemo(() => {
|
||||
const map = new Map<string, { count: number; amount: number }>();
|
||||
monthlyTransactions
|
||||
.filter(t => t.type === TransactionType.EXPENSE)
|
||||
.forEach(t => {
|
||||
const existing = map.get(t.category) || { count: 0, amount: 0 };
|
||||
existing.count += 1;
|
||||
existing.amount += t.amount;
|
||||
map.set(t.category, existing);
|
||||
});
|
||||
|
||||
return Array.from(map.entries())
|
||||
.map(([name, stat], index) => ({
|
||||
name,
|
||||
...stat,
|
||||
color: COLORS[index % COLORS.length]
|
||||
}))
|
||||
.sort((a, b) => b.amount - a.amount);
|
||||
}, [monthlyTransactions]);
|
||||
|
||||
// Set default trend category to top expense of current month if available
|
||||
useEffect(() => {
|
||||
if (categoryStats.length > 0) {
|
||||
setTrendCategory(categoryStats[0].name);
|
||||
}
|
||||
}, [categoryStats.length > 0 ? categoryStats[0].name : null]);
|
||||
|
||||
// Top 5 Ranking (Now based on Categories)
|
||||
const rankingData = useMemo(() => {
|
||||
return categoryStats.slice(0, 5);
|
||||
}, [categoryStats]);
|
||||
|
||||
// Calculate 6-month trend for the selected category
|
||||
const trendData = useMemo(() => {
|
||||
const data = [];
|
||||
const today = new Date();
|
||||
// Look back 6 months
|
||||
for (let i = 5; i >= 0; i--) {
|
||||
const d = new Date(today.getFullYear(), today.getMonth() - i, 1);
|
||||
const monthStr = d.toISOString().slice(0, 7); // YYYY-MM
|
||||
const displayMonth = `${d.getMonth() + 1}月`;
|
||||
|
||||
const total = transactions
|
||||
.filter(t =>
|
||||
t.type === TransactionType.EXPENSE &&
|
||||
t.category === trendCategory &&
|
||||
t.date.startsWith(monthStr)
|
||||
)
|
||||
.reduce((sum, t) => sum + t.amount, 0);
|
||||
|
||||
data.push({
|
||||
name: displayMonth,
|
||||
amount: total,
|
||||
fullDate: monthStr
|
||||
});
|
||||
}
|
||||
return data;
|
||||
}, [transactions, trendCategory]);
|
||||
|
||||
return (
|
||||
<div className="h-full bg-gray-50 overflow-y-auto no-scrollbar pb-24 font-sans">
|
||||
{/* Header */}
|
||||
<div className="bg-white p-5 sticky top-0 z-10 shadow-sm">
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-xl font-black text-gray-800">收支统计</h1>
|
||||
<input
|
||||
type="month"
|
||||
value={selectedMonth}
|
||||
onChange={e => setSelectedMonth(e.target.value)}
|
||||
className="bg-gray-100 rounded-lg px-3 py-1 text-xs font-bold text-gray-500 outline-none border-none shadow-sm focus:ring-1 focus:ring-indigo-300 transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-5 space-y-6">
|
||||
|
||||
{/* Monthly Overview Card */}
|
||||
<div className="bg-white rounded-2xl p-5 shadow-sm border border-gray-100">
|
||||
<h2 className="text-sm font-bold text-gray-700 mb-6 flex items-center gap-2">
|
||||
<span className="w-1.5 h-4 bg-indigo-500 rounded-full"></span>
|
||||
{monthLabel}收支总览
|
||||
</h2>
|
||||
<div className="grid grid-cols-3 gap-2 mb-6 px-1">
|
||||
<div className="text-center border-r border-gray-50">
|
||||
<div className="text-[10px] text-gray-400 font-bold mb-1 uppercase tracking-tight">总收入</div>
|
||||
<div className="text-sm font-black text-emerald-500 font-mono truncate">+¥{totalIncome.toFixed(0)}</div>
|
||||
</div>
|
||||
<div className="text-center border-r border-gray-50">
|
||||
<div className="text-[10px] text-gray-400 font-bold mb-1 uppercase tracking-tight">总支出</div>
|
||||
<div className="text-sm font-black text-rose-500 font-mono truncate">-¥{totalExpense.toFixed(0)}</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-[10px] text-gray-400 font-bold mb-1 uppercase tracking-tight">本月结余</div>
|
||||
<div className={`text-sm font-black font-mono truncate ${monthlyBalance >= 0 ? 'text-indigo-600' : 'text-orange-500'}`}>
|
||||
{monthlyBalance < 0 ? '-' : ''}¥{Math.abs(monthlyBalance).toFixed(0)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-56 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={barData} layout="vertical" margin={{top:0, left:0, right:30, bottom:0}}>
|
||||
<XAxis type="number" hide domain={['auto', 'auto']} />
|
||||
<YAxis dataKey="name" type="category" width={40} tick={{fontSize: 12, fontWeight: 'bold'}} axisLine={false} tickLine={false} />
|
||||
<Tooltip formatter={(val: number) => `¥${val.toFixed(2)}`} cursor={{fill: 'transparent'}} contentStyle={{borderRadius: '8px'}} />
|
||||
<ReferenceLine x={0} stroke="#f3f4f6" />
|
||||
<Bar dataKey="amount" barSize={24} radius={[0, 4, 4, 0]}>
|
||||
{barData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.fill} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top Expenses Ranking (By Category) */}
|
||||
<div className="bg-white rounded-2xl p-5 shadow-sm border border-gray-100">
|
||||
<h2 className="text-sm font-bold text-gray-700 mb-4 flex items-center gap-2">
|
||||
<span className="w-1.5 h-4 bg-orange-500 rounded-full"></span>
|
||||
{monthLabel}分类消费排行榜 (Top 5)
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
{rankingData.length > 0 ? rankingData.map((cat, idx) => (
|
||||
<div key={cat.name} className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3 overflow-hidden">
|
||||
<div className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold text-white shrink-0
|
||||
${idx === 0 ? 'bg-yellow-400' : (idx === 1 ? 'bg-gray-400' : (idx === 2 ? 'bg-orange-400' : 'bg-indigo-100 text-indigo-500'))}`}>
|
||||
{idx + 1}
|
||||
</div>
|
||||
<span className="text-sm text-gray-700 font-medium truncate">{cat.name}</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-end">
|
||||
<span className="font-mono font-bold text-gray-900 text-sm">¥{cat.amount.toFixed(0)}</span>
|
||||
<span className="text-[10px] text-gray-400">{cat.count}笔</span>
|
||||
</div>
|
||||
</div>
|
||||
)) : (
|
||||
<div className="text-center text-gray-400 text-xs py-4">暂无支出</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category Breakdown Details */}
|
||||
<div className="bg-white rounded-2xl p-5 shadow-sm border border-gray-100">
|
||||
<h2 className="text-sm font-bold text-gray-700 mb-4 flex items-center gap-2">
|
||||
<span className="w-1.5 h-4 bg-purple-500 rounded-full"></span>
|
||||
{monthLabel}分类统计详情
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
{categoryStats.length > 0 ? categoryStats.map((cat) => (
|
||||
<div key={cat.name} className="relative">
|
||||
<div className="flex justify-between items-center mb-1 text-sm relative z-10">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold text-gray-700">{cat.name}</span>
|
||||
<span className="text-xs text-gray-400 bg-gray-100 px-1.5 py-0.5 rounded-md">{cat.count}笔</span>
|
||||
</div>
|
||||
<span className="font-mono font-bold text-gray-900">¥{cat.amount.toFixed(0)}</span>
|
||||
</div>
|
||||
<div className="h-1.5 w-full bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full"
|
||||
style={{ width: `${(cat.amount / Math.max(totalExpense, 1)) * 100}%`, backgroundColor: cat.color }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
)) : (
|
||||
<div className="text-center text-gray-400 text-xs py-4">暂无数据</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Trend Analysis Chart */}
|
||||
<div className="bg-white rounded-2xl p-5 shadow-sm border border-gray-100">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-sm font-bold text-gray-700 flex items-center gap-2">
|
||||
<span className="w-1.5 h-4 bg-blue-500 rounded-full"></span>
|
||||
分类趋势 (近6个月)
|
||||
</h2>
|
||||
<select
|
||||
value={trendCategory}
|
||||
onChange={(e) => setTrendCategory(e.target.value)}
|
||||
className="text-xs bg-gray-50 border-none rounded-lg py-1 px-2 text-gray-600 font-medium outline-none"
|
||||
>
|
||||
{Object.values(ExpenseCategory).map(cat => (
|
||||
<option key={cat} value={cat}>{cat}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="h-48 w-full -ml-2">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={trendData}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f3f4f6" />
|
||||
<XAxis dataKey="name" axisLine={false} tickLine={false} tick={{fontSize: 10, fill: '#9ca3af'}} dy={10} />
|
||||
<YAxis axisLine={false} tickLine={false} tick={{fontSize: 10, fill: '#9ca3af'}} />
|
||||
<Tooltip
|
||||
contentStyle={{borderRadius: '12px', border: 'none', boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)'}}
|
||||
itemStyle={{color: '#4f46e5', fontWeight: 'bold'}}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="amount"
|
||||
stroke="#6366f1"
|
||||
strokeWidth={3}
|
||||
dot={{fill: '#6366f1', strokeWidth: 2, r: 4, stroke: '#fff'}}
|
||||
activeDot={{r: 6}}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StatisticsPage;
|
||||
104
components/VoiceInput.tsx
Normal file
104
components/VoiceInput.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
|
||||
interface VoiceInputProps {
|
||||
onTranscript: (text: string) => void;
|
||||
isProcessing: boolean;
|
||||
isFullWidth?: boolean;
|
||||
}
|
||||
|
||||
const VoiceInput: React.FC<VoiceInputProps> = ({ onTranscript, isProcessing, isFullWidth = false }) => {
|
||||
const [isListening, setIsListening] = useState(false);
|
||||
const recognitionRef = useRef<any>(null);
|
||||
const timerRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if ('SpeechRecognition' in window || 'webkitSpeechRecognition' in window) {
|
||||
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||
recognitionRef.current = new SpeechRecognition();
|
||||
recognitionRef.current.continuous = false;
|
||||
recognitionRef.current.lang = 'zh-CN';
|
||||
recognitionRef.current.interimResults = false;
|
||||
|
||||
recognitionRef.current.onresult = (event: any) => {
|
||||
const transcript = event.results[0][0].transcript;
|
||||
if (transcript) onTranscript(transcript);
|
||||
setIsListening(false);
|
||||
};
|
||||
|
||||
recognitionRef.current.onerror = (event: any) => {
|
||||
console.error('Speech recognition error', event.error);
|
||||
setIsListening(false);
|
||||
};
|
||||
|
||||
recognitionRef.current.onend = () => {
|
||||
setIsListening(false);
|
||||
};
|
||||
}
|
||||
}, [onTranscript]);
|
||||
|
||||
const startListening = (e: React.MouseEvent | React.TouchEvent) => {
|
||||
e.preventDefault();
|
||||
if (isProcessing || isListening) return;
|
||||
|
||||
if (recognitionRef.current) {
|
||||
try {
|
||||
recognitionRef.current.start();
|
||||
setIsListening(true);
|
||||
} catch (e) {
|
||||
console.error("Failed to start recognition", e);
|
||||
}
|
||||
} else {
|
||||
alert("您的浏览器不支持语音识别,请使用 Chrome 或 Edge。");
|
||||
}
|
||||
};
|
||||
|
||||
const stopListening = () => {
|
||||
if (isListening) {
|
||||
recognitionRef.current?.stop();
|
||||
setIsListening(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`${isFullWidth ? 'w-full' : ''} relative select-none touch-none`}>
|
||||
<button
|
||||
onMouseDown={startListening}
|
||||
onMouseUp={stopListening}
|
||||
onMouseLeave={stopListening}
|
||||
onTouchStart={startListening}
|
||||
onTouchEnd={stopListening}
|
||||
disabled={isProcessing}
|
||||
className={`relative flex items-center justify-center transition-all duration-300 active:scale-95 shadow-lg ${
|
||||
isFullWidth
|
||||
? 'w-full py-4 rounded-2xl font-bold text-sm tracking-widest'
|
||||
: 'w-14 h-14 rounded-full'
|
||||
} ${
|
||||
isListening
|
||||
? 'bg-rose-500 text-white shadow-rose-200'
|
||||
: 'bg-indigo-600 text-white shadow-indigo-100'
|
||||
} ${isProcessing ? 'opacity-50 grayscale cursor-not-allowed' : ''}`}
|
||||
>
|
||||
{isListening && (
|
||||
<div className="absolute inset-0 rounded-2xl animate-ping bg-rose-400 opacity-20"></div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 z-10">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className={`${isFullWidth ? 'h-5 w-5' : 'h-6 w-6'}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z" />
|
||||
</svg>
|
||||
{isFullWidth && <span>{isListening ? '松开 结束' : '按住 说话'}</span>}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{isListening && isFullWidth && (
|
||||
<div className="absolute -top-16 left-0 right-0 flex justify-center animate-bounce">
|
||||
<div className="bg-rose-500 text-white px-4 py-2 rounded-full text-xs font-bold shadow-xl border-2 border-white">
|
||||
正在录音...
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VoiceInput;
|
||||
Reference in New Issue
Block a user