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

247 lines
12 KiB
TypeScript

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;