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;
|
||||
Reference in New Issue
Block a user