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

535 lines
35 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, useEffect, useRef } from 'react';
import { processUserMessage } from './services/geminiService';
import VoiceInput from './components/VoiceInput';
import LedgerPage from './components/LedgerPage';
import StatisticsPage from './components/StatisticsPage';
import EditTransactionModal from './components/EditTransactionModal';
import { Transaction, TransactionType, Message, ExpenseCategory, IncomeCategory, BillReminder } from './types';
import { v4 as uuidv4 } from 'uuid';
type Tab = 'chat' | 'ledger' | 'stats';
interface AISettings {
name: string;
persona: string;
avatar?: string;
}
const DEFAULT_AI_SETTINGS: AISettings = {
name: "崔经理",
persona: "一个精明、毒舌但实际上很负责任的财务经理。说话喜欢带刺,吐槽用户乱花钱,但关键时刻会给出靠谱建议。"
};
const INITIAL_PERSONA_PRESETS = [
{
id: 'toxic',
name: '崔经理 (默认)',
description: '精明、毒舌、傲娇。喜欢吐槽你乱花钱,"恨铁不成钢",但内心其实是为你好。'
},
{
id: 'gentle',
name: '温柔管家',
description: '温柔、体贴、治愈。无论你怎么花钱都会温柔记录,并在超支时委婉地提醒你注意身体和钱包。'
},
{
id: 'strict',
name: '严厉会计',
description: '一丝不苟、冷酷无情。只关心数字,对任何非必要支出都会严厉批评,没有任何废话。'
},
{
id: 'custom',
name: '自定义',
description: '可以根据你喜欢的方式设定助手的语气和风格'
}
];
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 getCategoryIcon = (category: string, type: TransactionType) => {
if (type === TransactionType.INCOME) {
switch (category) {
case IncomeCategory.SALARY: return '💰';
case IncomeCategory.BONUS: return '🧧';
case IncomeCategory.INVESTMENT: return '📈';
default: return '💵';
}
}
switch (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 '🧾';
}
};
const App: React.FC = () => {
const [activeTab, setActiveTab] = useState<Tab>('chat');
const [messages, setMessages] = useState<Message[]>([]);
const [transactions, setTransactions] = useState<Transaction[]>([]);
const [budgets, setBudgets] = useState<Record<string, number>>({ '总预算': 3000 });
const [reminders, setReminders] = useState<BillReminder[]>([]);
const [inputText, setInputText] = useState('');
const [isProcessing, setIsProcessing] = useState(false);
const [editingTransaction, setEditingTransaction] = useState<Transaction | null>(null);
const [isEditingFromChat, setIsEditingFromChat] = useState(false);
const [inputMode, setInputMode] = useState<'voice' | 'keyboard'>('voice');
const [aiSettings, setAiSettings] = useState<AISettings>(DEFAULT_AI_SETTINGS);
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const [customPreset, setCustomPreset] = useState({ name: '自定义', description: '可以根据你喜欢的方式设定助手的语气和风格' });
const fileInputRef = useRef<HTMLInputElement>(null);
const avatarInputRef = useRef<HTMLInputElement>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const storedTransactions = localStorage.getItem('cui_transactions');
const storedMessages = localStorage.getItem('cui_messages');
const storedBudget = localStorage.getItem('cui_budget');
const storedReminders = localStorage.getItem('cui_reminders');
const storedAiSettings = localStorage.getItem('cui_ai_settings');
const storedCustom = localStorage.getItem('cui_custom_preset_info');
if (storedTransactions) setTransactions(JSON.parse(storedTransactions));
if (storedMessages) setMessages(JSON.parse(storedMessages));
if (storedBudget) {
const parsed = JSON.parse(storedBudget);
setBudgets(typeof parsed === 'number' ? { '总预算': parsed } : parsed);
}
if (storedReminders) setReminders(JSON.parse(storedReminders));
if (storedAiSettings) setAiSettings(JSON.parse(storedAiSettings));
if (storedCustom) setCustomPreset(JSON.parse(storedCustom));
if (!storedMessages) {
setMessages([{
id: 'init',
role: 'assistant',
content: `我是${storedAiSettings ? JSON.parse(storedAiSettings).name : '崔经理'}。✨\n想记账?快说!🎤`,
timestamp: Date.now()
}]);
}
}, []);
useEffect(() => { localStorage.setItem('cui_transactions', JSON.stringify(transactions)); }, [transactions]);
useEffect(() => { localStorage.setItem('cui_budget', JSON.stringify(budgets)); }, [budgets]);
useEffect(() => { localStorage.setItem('cui_reminders', JSON.stringify(reminders)); }, [reminders]);
useEffect(() => { localStorage.setItem('cui_ai_settings', JSON.stringify(aiSettings)); }, [aiSettings]);
useEffect(() => { localStorage.setItem('cui_custom_preset_info', JSON.stringify(customPreset)); }, [customPreset]);
useEffect(() => {
localStorage.setItem('cui_messages', JSON.stringify(messages));
if (activeTab === 'chat') scrollToBottom();
}, [messages, activeTab]);
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
};
const handleUpdateTransaction = (updated: Transaction) => {
setTransactions(prev => prev.map(t => t.id === updated.id ? updated : t));
setEditingTransaction(null);
setIsEditingFromChat(false);
};
const handleDeleteTransaction = (id: string) => {
if (confirm('确定要删除这条记录吗?')) {
setTransactions(prev => prev.filter(t => t.id !== id));
setEditingTransaction(null);
setIsEditingFromChat(false);
}
};
const handleAddReminder = (reminder: BillReminder) => {
setReminders(prev => [...prev, reminder]);
};
const handleUpdateReminder = (updated: BillReminder) => {
setReminders(prev => prev.map(r => r.id === updated.id ? updated : r));
};
const handleDeleteReminder = (id: string) => {
if (confirm('确定要删除这个提醒吗?')) {
setReminders(prev => prev.filter(r => r.id !== id));
}
};
const handleToggleReminder = (id: string) => {
setReminders(prev => prev.map(r => r.id === id ? { ...r, isCompleted: !r.isCompleted } : r));
};
const handleSendMessage = async (text: string, imageBase64?: string, mimeType?: string) => {
if (!text.trim() && !imageBase64) return;
const userMsg: Message = {
id: uuidv4(),
role: 'user',
content: imageBase64 ? '已上传图片' : text,
timestamp: Date.now()
};
setMessages(prev => [...prev, userMsg]);
setInputText('');
setIsProcessing(true);
try {
const result = await processUserMessage(text, aiSettings.name, aiSettings.persona, imageBase64, mimeType);
if (result.transaction) {
const newTransactionId = uuidv4();
const newTransaction: Transaction = {
id: newTransactionId,
item: result.transaction.item,
amount: result.transaction.amount,
type: result.transaction.type,
category: result.transaction.category,
date: getLocalDateString(),
timestamp: Date.now()
};
setTransactions(prev => [...prev, newTransaction]);
setMessages(prev => [...prev, {
id: uuidv4(),
role: 'assistant',
content: 'RECEIPT',
timestamp: Date.now(),
relatedTransactionId: newTransactionId
}]);
const replySegments = result.reply.split('\n').filter(s => s.trim()).slice(0, 2);
for (let i = 0; i < replySegments.length; i++) {
if (i > 0) await new Promise(r => setTimeout(r, 450));
setMessages(prev => [...prev, {
id: uuidv4(),
role: 'assistant',
content: replySegments[i].trim(),
timestamp: Date.now()
}]);
}
} else {
const replySegments = result.reply.split('\n').filter(s => s.trim()).slice(0, 2);
for (let i = 0; i < replySegments.length; i++) {
if (i > 0) await new Promise(r => setTimeout(r, 450));
setMessages(prev => [...prev, {
id: uuidv4(),
role: 'assistant',
content: replySegments[i].trim(),
timestamp: Date.now()
}]);
}
}
} catch (error) {
setMessages(prev => [...prev, { id: uuidv4(), role: 'assistant', content: '系统走神了... 🫥\n等会儿再说。', timestamp: Date.now() }]);
} finally {
setIsProcessing(false);
}
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSendMessage(inputText);
}
};
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onloadend = () => {
const base64Data = (reader.result as string).split(',')[1];
handleSendMessage("", base64Data, file.type);
};
reader.readAsDataURL(file);
if (fileInputRef.current) fileInputRef.current.value = '';
}
const handleAvatarUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onloadend = () => {
const base64Data = reader.result as string;
setAiSettings(prev => ({ ...prev, avatar: base64Data }));
};
reader.readAsDataURL(file);
if (avatarInputRef.current) avatarInputRef.current.value = '';
}
const todayStr = getLocalDateString();
const todaySpending = transactions
.filter(t => t.date === todayStr && t.type === TransactionType.EXPENSE)
.reduce((a, b) => a + b.amount, 0);
const personaPresets = INITIAL_PERSONA_PRESETS.map(p => {
if (p.id === 'custom') {
return { ...p, name: customPreset.name, description: customPreset.description };
}
return p;
});
const handleSaveSettings = () => {
const currentPreset = INITIAL_PERSONA_PRESETS.find(p => p.description === aiSettings.persona && p.id !== 'custom');
if (!currentPreset) {
setCustomPreset({ name: aiSettings.name, description: aiSettings.persona });
}
setIsSettingsOpen(false);
};
return (
<div className="flex flex-col h-screen w-full bg-gray-100 font-sans">
<div className="flex-1 overflow-hidden relative">
{/* Chat Tab */}
<div className={`absolute inset-0 flex flex-col transition-transform duration-300 ${activeTab === 'chat' ? 'translate-x-0' : '-translate-x-full'}`}>
<div className="bg-white/90 backdrop-blur-lg border-b border-gray-100 px-6 py-3 flex items-center justify-between shadow-sm z-10 relative">
<div className="flex items-center gap-2.5 cursor-pointer group" onClick={() => setIsSettingsOpen(true)}>
<div className="w-9 h-9 rounded-full bg-indigo-900 text-white flex items-center justify-center font-bold text-lg border-2 border-yellow-400 shadow-sm transition-transform group-hover:scale-105 active:scale-95 overflow-hidden">
{aiSettings.avatar ? <img src={aiSettings.avatar} alt="avatar" className="w-full h-full object-cover" /> : aiSettings.name.charAt(0)}
</div>
<div className="min-w-0">
<h1 className="font-bold text-gray-800 text-sm md:text-base flex items-center gap-1">
{aiSettings.name}
<svg className="w-3 h-3 text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg>
</h1>
<span className="text-[9px] text-gray-400 font-medium tracking-tight truncate block max-w-[140px]">
{aiSettings.persona.split(/[。!]/)[0] + '...'}
</span>
</div>
</div>
<div className="flex items-center gap-1">
<span className="text-[10px] text-gray-400/50 font-bold uppercase tracking-widest mr-1">今日支出</span>
<span className="text-lg font-black text-indigo-600 font-mono tracking-tighter">¥{todaySpending.toFixed(0)}</span>
</div>
</div>
{/* AI Settings Modal */}
{isSettingsOpen && (
<div className="fixed inset-0 z-[100] bg-black/40 backdrop-blur-sm flex items-end md:items-center justify-center p-4">
<div className="bg-white w-full max-w-md rounded-3xl p-6 shadow-2xl animate-float-up md:animate-zoom-in max-h-[85vh] flex flex-col mb-32 md:mb-0">
<div className="flex justify-between items-center mb-5 shrink-0">
<h3 className="text-xl font-bold text-gray-900">管家设定</h3>
<button onClick={() => setIsSettingsOpen(false)} className="p-2 bg-gray-100 rounded-full hover:bg-gray-200 transition-colors">
<svg className="w-5 h-5 text-gray-500" 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="flex-1 overflow-y-auto pr-1 space-y-6 pb-2 no-scrollbar">
<div className="flex flex-col items-center mb-2">
<div className="relative group">
<div className="w-20 h-20 rounded-full bg-indigo-900 border-4 border-white shadow-lg flex items-center justify-center text-3xl font-bold text-white overflow-hidden">
{aiSettings.avatar ? <img src={aiSettings.avatar} alt="avatar" className="w-full h-full object-cover" /> : aiSettings.name.charAt(0)}
</div>
<button
onClick={() => avatarInputRef.current?.click()}
className="absolute bottom-0 right-0 p-1.5 bg-indigo-600 text-white rounded-full border-2 border-white shadow-md hover:bg-indigo-700 transition-colors"
>
<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="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" /></svg>
</button>
<input type="file" ref={avatarInputRef} onChange={handleAvatarUpload} accept="image/*" className="hidden" />
</div>
<span className="text-[10px] text-gray-400 font-bold mt-2 uppercase tracking-widest">更改管家形象</span>
</div>
<div>
<label className="block text-xs font-bold text-gray-400 mb-2 uppercase tracking-wider">管家名字</label>
<input type="text" value={aiSettings.name} onChange={(e) => setAiSettings({...aiSettings, name: e.target.value})} className="w-full p-3.5 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none font-bold text-gray-800" />
</div>
<div>
<label className="block text-xs font-bold text-gray-400 mb-3 uppercase tracking-wider">性格预设</label>
<div className="grid grid-cols-2 gap-3">
{personaPresets.map(p => (
<button key={p.id} onClick={() => setAiSettings({...aiSettings, name: p.name.split(' ')[0], persona: p.description})} className={`p-4 rounded-2xl border text-left transition-all ${aiSettings.persona === p.description ? 'bg-indigo-600 border-indigo-600 text-white shadow-lg shadow-indigo-100' : 'bg-white border-gray-100 hover:bg-gray-50 text-gray-600'}`}>
<div className={`font-bold text-sm mb-1 ${aiSettings.persona === p.description ? 'text-white' : 'text-gray-900'}`}>{p.name}</div>
<div className={`text-[10px] leading-tight ${aiSettings.persona === p.description ? 'text-indigo-100' : 'text-gray-400'}`}>{p.description.slice(0, 24)}...</div>
</button>
))}
</div>
</div>
<div>
<label className="block text-xs font-bold text-gray-400 mb-2 uppercase tracking-wider">详细性格定义</label>
<textarea value={aiSettings.persona} onChange={(e) => setAiSettings({...aiSettings, persona: e.target.value})} className="w-full p-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none text-sm text-gray-600 h-28 resize-none" />
</div>
</div>
<div className="pt-5 shrink-0 border-t border-gray-100 mt-2">
<button onClick={handleSaveSettings} className="w-full py-4 bg-indigo-600 text-white rounded-2xl font-bold shadow-xl shadow-indigo-100 hover:bg-indigo-700 transition-all active:scale-95">保存设定</button>
</div>
</div>
</div>
)}
{/* Messages - Added Gradient Background */}
<div className="flex-1 overflow-y-auto p-4 pb-32 space-y-6 bg-gradient-to-b from-indigo-50/40 via-white to-gray-50">
{messages.map((msg) => {
const isUser = msg.role === 'user';
const relatedTx = msg.relatedTransactionId ? transactions.find(t => t.id === msg.relatedTransactionId) : null;
if (msg.relatedTransactionId && !relatedTx) return null;
const isExpense = relatedTx?.type === TransactionType.EXPENSE;
return (
<div key={msg.id} className={`flex w-full ${isUser ? 'justify-end' : 'justify-start'}`}>
<div className={`flex max-w-[90%] md:max-w-[70%] gap-2 ${isUser ? 'flex-row-reverse' : 'flex-row'}`}>
<div className="flex-shrink-0 mt-1">
{isUser ? <div className="w-8 h-8 rounded-full bg-indigo-100 flex items-center justify-center text-indigo-600 border border-indigo-200 shadow-sm"><svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" /></svg></div> : <div className="w-8 h-8 rounded-full bg-indigo-900 text-white text-[10px] flex items-center justify-center border border-yellow-500 shadow-sm font-bold overflow-hidden">{aiSettings.avatar ? <img src={aiSettings.avatar} alt="avatar" className="w-full h-full object-cover" /> : aiSettings.name.charAt(0)}</div>}
</div>
<div className="flex flex-col">
{!isUser && !relatedTx && <span className="text-[9px] text-gray-400 font-bold mb-1 ml-1 tracking-tight">{aiSettings.name}</span>}
{relatedTx ? (
/* Redesigned Receipt Card with visual differentiation */
<div className={`
relative min-w-[240px] md:min-w-[280px] rounded-3xl overflow-hidden shadow-xl animate-float-in border
${isExpense
? 'bg-gradient-to-br from-rose-50/30 to-white border-rose-100/50 shadow-rose-200/20'
: 'bg-gradient-to-br from-emerald-50/30 to-white border-emerald-100/50 shadow-emerald-200/20'}
`}>
{/* Top Accents */}
<div className={`h-1.5 w-full ${isExpense ? 'bg-rose-400' : 'bg-emerald-400'}`}></div>
<div className="p-5">
<div className="flex justify-between items-start mb-4">
<div className="flex flex-col">
<div className="flex items-center gap-1.5 mb-0.5">
<span className={`w-1.5 h-1.5 rounded-full ${isExpense ? 'bg-rose-400 animate-pulse' : 'bg-emerald-400 animate-pulse'}`}></span>
<span className={`text-[10px] font-black uppercase tracking-wider ${isExpense ? 'text-rose-400' : 'text-emerald-500'}`}>
{isExpense ? '支出成功' : '收入成功'}
</span>
</div>
<h4 className="text-gray-800 font-black text-base line-clamp-1">{relatedTx.item}</h4>
</div>
<div className={`w-11 h-11 rounded-2xl flex items-center justify-center text-2xl shrink-0 border shadow-sm
${isExpense ? 'bg-rose-50 border-rose-100' : 'bg-emerald-50 border-emerald-100'}
`}>
{getCategoryIcon(relatedTx.category, relatedTx.type)}
</div>
</div>
<div className="space-y-3">
<div className="flex flex-col">
<span className="text-[9px] text-gray-400 font-bold uppercase tracking-tight mb-1">交易详情</span>
<div className="flex items-center gap-2">
<span className={`px-2 py-0.5 rounded-lg text-[10px] font-black
${isExpense ? 'bg-rose-100 text-rose-600' : 'bg-emerald-100 text-emerald-600'}
`}>
{relatedTx.category}
</span>
<span className="text-[10px] text-gray-300 font-mono">{relatedTx.date}</span>
</div>
</div>
<div className="pt-3 border-t border-dashed border-gray-100 flex items-baseline justify-between">
<span className="text-gray-400 text-[10px] font-black uppercase tracking-tighter">Amount</span>
<span className={`text-2xl font-black font-mono tracking-tighter ${isExpense ? 'text-rose-500' : 'text-emerald-500'}`}>
{isExpense ? '-' : '+'}¥{relatedTx.amount.toFixed(2)}
</span>
</div>
</div>
</div>
{/* Quick Actions Bar */}
<div className="bg-gray-50/50 px-3 py-2 border-t border-gray-100 flex justify-end gap-3">
<button onClick={() => { setEditingTransaction(relatedTx); setIsEditingFromChat(true); }} className="p-2 text-gray-300 hover:text-indigo-600 transition-all">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} 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={() => handleDeleteTransaction(relatedTx.id)} className="p-2 text-gray-300 hover:text-rose-500 transition-all">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} 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 className={`p-4 rounded-3xl shadow-sm text-sm md:text-base leading-relaxed whitespace-pre-wrap ${isUser ? 'bg-indigo-600 text-white rounded-tr-none' : 'bg-white text-gray-800 border border-gray-100 rounded-tl-none animate-float-in'}`}>{msg.content}</div>
)}
</div>
</div>
</div>
);
})}
{isProcessing && <div className="flex w-full justify-start"><div className="flex max-w-[70%] gap-2"><div className="w-8 h-8 rounded-full bg-indigo-900 text-white text-[10px] flex items-center justify-center border border-yellow-500 font-bold overflow-hidden">{aiSettings.avatar ? <img src={aiSettings.avatar} alt="avatar" className="w-full h-full object-cover" /> : aiSettings.name.charAt(0)}</div><div className="bg-white p-3 rounded-2xl rounded-tl-none border border-gray-200 shadow-sm flex items-center gap-1.5"><span className="w-1.5 h-1.5 bg-indigo-400 rounded-full animate-bounce"></span><span className="w-1.5 h-1.5 bg-indigo-400 rounded-full animate-bounce delay-75"></span><span className="w-1.5 h-1.5 bg-indigo-400 rounded-full animate-bounce delay-150"></span></div></div></div>}
<div ref={messagesEndRef} />
</div>
{/* Input Area */}
<div className="absolute bottom-20 left-0 right-0 p-4 bg-gradient-to-t from-gray-100 via-gray-100 to-transparent z-40">
<div className="max-w-4xl mx-auto flex flex-col gap-3">
<div className="flex items-center gap-3">
<button onClick={() => setInputMode(inputMode === 'voice' ? 'keyboard' : 'voice')} className="p-3 bg-white text-gray-400 rounded-full shadow-lg hover:bg-gray-50 transition-all active:scale-90">
{inputMode === 'voice' ? (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /></svg>
) : (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} 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>
)}
</button>
{inputMode === 'keyboard' ? (
<div className="flex-1 bg-white rounded-2xl flex items-center px-4 py-1.5 focus-within:ring-2 focus-within:ring-indigo-500 shadow-lg border border-gray-100 transition-all">
<input type="text" value={inputText} onChange={(e) => setInputText(e.target.value)} onKeyDown={handleKeyPress} placeholder="刚才花了还是赚了?" className="flex-1 bg-transparent border-none focus:outline-none text-gray-700 py-2.5 text-sm" disabled={isProcessing} />
{inputText.trim() && (
<button onClick={() => handleSendMessage(inputText)} className="ml-2 p-2 text-indigo-600 font-bold text-sm">发送</button>
)}
</div>
) : (
<VoiceInput onTranscript={(text) => handleSendMessage(text)} isProcessing={isProcessing} isFullWidth />
)}
<input type="file" accept="image/*" className="hidden" ref={fileInputRef} onChange={handleFileUpload} />
<button onClick={() => fileInputRef.current?.click()} disabled={isProcessing} className="p-3 bg-white text-gray-400 rounded-full shadow-lg hover:bg-gray-50 transition-all active:scale-90">
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
</button>
</div>
</div>
</div>
</div>
{/* Other Tabs */}
<div className={`absolute inset-0 bg-gray-50 transition-transform duration-300 ${activeTab === 'ledger' ? 'translate-x-0' : (activeTab === 'stats' ? '-translate-x-full' : 'translate-x-full')}`}>
<LedgerPage transactions={transactions} onUpdateTransaction={handleUpdateTransaction} onDeleteTransaction={handleDeleteTransaction} budgets={budgets} setBudgets={setBudgets} reminders={reminders} onAddReminder={handleAddReminder} onUpdateReminder={handleUpdateReminder} onDeleteReminder={handleDeleteReminder} onToggleReminder={handleToggleReminder} />
</div>
<div className={`absolute inset-0 bg-gray-50 transition-transform duration-300 ${activeTab === 'stats' ? 'translate-x-0' : 'translate-x-full'}`}>
<StatisticsPage transactions={transactions} />
</div>
</div>
{/* Bottom Nav */}
<div className="absolute bottom-2 left-4 right-4 bg-white/95 backdrop-blur-xl rounded-2xl shadow-2xl border border-gray-100 p-1 flex justify-around md:w-96 md:mx-auto z-50">
<button onClick={() => setActiveTab('ledger')} className={`flex flex-col items-center justify-center w-full py-2 rounded-xl transition-all duration-300 ${activeTab === 'ledger' ? 'bg-indigo-50 text-indigo-600' : 'text-gray-400'}`}>
<svg className="w-5 h-5 mb-0.5" viewBox="0 0 24 24" fill={activeTab === 'ledger' ? 'currentColor' : 'none'} stroke="currentColor"><path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-2 10h-4v4h4v-4zm0-6h-4v4h4V7zM9 17H5v-4h4v4zm0-6H5V7h4v4z" strokeWidth={activeTab === 'ledger' ? 0 : 2} /></svg>
<span className="text-[10px] font-bold">账本</span>
</button>
<button onClick={() => setActiveTab('chat')} className={`flex flex-col items-center justify-center w-full py-2 rounded-xl transition-all duration-300 ${activeTab === 'chat' ? 'bg-indigo-50 text-indigo-600' : 'text-gray-400'}`}>
<svg className="w-5 h-5 mb-0.5" viewBox="0 0 24 24" fill={activeTab === 'chat' ? 'currentColor' : 'none'} stroke="currentColor"><path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2z" strokeWidth={activeTab === 'chat' ? 0 : 2} /></svg>
<span className="text-[10px] font-bold">记一笔</span>
</button>
<button onClick={() => setActiveTab('stats')} className={`flex flex-col items-center justify-center w-full py-2 rounded-xl transition-all duration-300 ${activeTab === 'stats' ? 'bg-indigo-50 text-indigo-600' : 'text-gray-400'}`}>
<svg className="w-5 h-5 mb-0.5" viewBox="0 0 24 24" fill={activeTab === 'stats' ? 'currentColor' : 'none'} stroke="currentColor"><path d="M11 3.055A9.001 9.001 0 1020.945 13H11V3.055z" strokeWidth={activeTab === 'stats' ? 0 : 2} /><path d="M20.488 9H15V3.512A9.025 9.025 0 0120.488 9z" strokeWidth={activeTab === 'stats' ? 0 : 2} /></svg>
<span className="text-[10px] font-bold">统计</span>
</button>
</div>
{/* Global Edit Modal */}
{editingTransaction && (
<EditTransactionModal
transaction={editingTransaction}
onSave={handleUpdateTransaction}
onDelete={() => handleDeleteTransaction(editingTransaction.id)}
onClose={() => { setEditingTransaction(null); setIsEditingFromChat(false); }}
hideDelete={isEditingFromChat}
/>
)}
</div>
);
};
export default App;