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