Files
app_02_bill/App.tsx
karlcarlo 5367a4107e feat(voice): 优化语音识别功能及增强可用性提示
- 新增语音服务可用性状态管理,动态检测浏览器支持情况
- 调整初始输入模式为键盘输入,避免误触启动语音识别
- 合并消息列表时保留已有调试消息,避免信息丢失
- VoiceInput组件新增调试日志回调,增强调试信息输出
- 细化语音识别状态机,区分idle、starting和listening状态
- 优化权限拒绝错误处理,针对iOS给出具体操作提示
- 改进语音识别启动流程,针对iOS增加软启动延迟避免冲突
- 支持多种指针和触摸事件管理,防止冲突导致的识别异常
- 按钮UI根据状态动态调整,显示不同提示文本和样式
- 禁用语音不可用时的按钮操作,避免误操作
- 增加语音识别启动超时保护,防止长时间挂起
- 优化用户交互体验,加入振动反馈及事件防抖处理
2026-01-23 14:00:39 +08:00

904 lines
52 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 {
deleteMessage, deleteTransaction, updateTransaction, createTransaction, createMessage, getMessages, getTransactions, loginOrRegister, getUserProfile,
createReminder, getReminders, updateReminder, toggleReminder, deleteReminder
} from './services/api';
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[]>(() => {
try {
const saved = localStorage.getItem('cui_messages');
return saved ? JSON.parse(saved) : [];
} catch (e) {
return [];
}
});
const [transactions, setTransactions] = useState<Transaction[]>(() => {
try {
const saved = localStorage.getItem('cui_transactions');
return saved ? JSON.parse(saved) : [];
} catch (e) {
return [];
}
});
const [budgets, setBudgets] = useState<Record<string, number>>(() => {
try {
const saved = localStorage.getItem('cui_budget');
if (saved) {
const parsed = JSON.parse(saved);
return typeof parsed === 'number' ? { '总预算': parsed } : parsed;
}
} catch (e) {}
return { '总预算': 3000 };
});
const [reminders, setReminders] = useState<BillReminder[]>(() => {
try {
const saved = localStorage.getItem('cui_reminders');
return saved ? JSON.parse(saved) : [];
} catch (e) {
return [];
}
});
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'>('keyboard');
const [isVoiceServiceAvailable, setIsVoiceServiceAvailable] = useState(true);
const [token, setToken] = useState<string | null>(null);
const [userInfo, setUserInfo] = useState<any>(null);
const [aiSettings, setAiSettings] = useState<AISettings>(() => {
try {
const saved = localStorage.getItem('cui_ai_settings');
return saved ? JSON.parse(saved) : DEFAULT_AI_SETTINGS;
} catch (e) {
return DEFAULT_AI_SETTINGS;
}
});
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const [customPreset, setCustomPreset] = useState(() => {
try {
const saved = localStorage.getItem('cui_custom_preset_info');
return saved ? JSON.parse(saved) : { name: '自定义', description: '可以根据你喜欢的方式设定助手的语气和风格' };
} catch (e) {
return { name: '自定义', description: '可以根据你喜欢的方式设定助手的语气和风格' };
}
});
const fileInputRef = useRef<HTMLInputElement>(null);
const avatarInputRef = useRef<HTMLInputElement>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
// 在首页加载后打印当前URL并检查token
useEffect(() => {
console.log('当前URL:', window.location.href);
// 检查浏览器是否支持语音识别
const SpeechRecognition = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
if (!SpeechRecognition) {
setIsVoiceServiceAvailable(false);
}
// 检查是否存在token,如果不存在则调用登录接口
checkAndLogin().then((userInfo) => {
// 登录成功后加载数据
if (userInfo || localStorage.getItem('auth_token')) {
loadInitialData();
}
});
}, []);
// 检查token并登录的函数
const checkAndLogin = async (): Promise<any | null> => {
// 从localStorage检查是否存在token
const storedToken = localStorage.getItem('auth_token');
if (storedToken) {
setToken(storedToken);
// 如果有token,携带token访问 GET /api/wx/profile 获取用户信息
try {
const userInfo = await getUserProfile();
// 更新本地缓存
localStorage.setItem('user_info', JSON.stringify(userInfo));
setUserInfo(userInfo);
console.log('通过token获取用户信息成功:', userInfo);
return userInfo;
} catch (error) {
console.error('获取用户信息失败:', error);
// 如果token失效,清除本地存储
localStorage.removeItem('auth_token');
localStorage.removeItem('user_info');
setToken(null);
setUserInfo(null);
// 如果请求失败,尝试使用本地缓存
try {
const cachedUserInfo = JSON.parse(localStorage.getItem('user_info') || '{}');
if (cachedUserInfo && Object.keys(cachedUserInfo).length > 0) {
setUserInfo(cachedUserInfo);
return cachedUserInfo;
}
} catch (e) {
console.error('解析本地用户信息失败:', e);
}
}
return null;
}
// 如果没有token,则检查URL参数并调用登录接口
const urlParams = new URLSearchParams(window.location.search);
const openid = urlParams.get('openid');
const mobile = urlParams.get('mobile');
if (openid && mobile) {
try {
const result = await loginOrRegister(openid, mobile);
// 存储token和用户信息
localStorage.setItem('user_info', JSON.stringify(result.user));
setToken(result.token);
setUserInfo(result.user);
console.log('登录成功:', result.user);
return result.user;
} catch (error) {
console.error('登录请求出错:', error);
return null;
}
} else {
console.log('缺少必需的URL参数: openid 或 mobile');
return null;
}
};
const loadInitialData = async () => {
// 从数据库获取交易列表
try {
const response = await getTransactions();
if (response.items && response.items.length > 0) {
// 将数据库返回的交易转换为本地格式
const transactionsFromDB: Transaction[] = response.items.map(tx => ({
id: tx.id,
item: tx.item,
amount: tx.amount,
type: tx.type,
category: tx.category,
date: tx.date,
timestamp: tx.timestamp
}));
// 更新本地缓存
setTransactions(transactionsFromDB);
console.log('交易列表已从数据库加载:', transactionsFromDB.length);
} else {
console.log('数据库中暂无交易记录');
}
} catch (error) {
console.error('加载交易列表失败:', error);
// 失败时尝试从本地存储加载
const storedTransactions = localStorage.getItem('cui_transactions');
if (storedTransactions) {
setTransactions(JSON.parse(storedTransactions));
console.log('已从本地缓存加载交易记录');
}
}
// 从数据库获取消息列表
try {
const response = await getMessages();
if (response.items && response.items.length > 0) {
// 将数据库返回的消息转换为本地格式
const messagesFromDB: Message[] = response.items.map((msg: any) => ({
id: msg.id,
role: msg.role,
content: msg.content,
timestamp: msg.timestamp,
// API返回的是transaction_id,需要映射为relatedTransactionId
relatedTransactionId: msg.transaction_id || msg.relatedTransactionId
}));
// 更新本地缓存
setMessages(prev => {
// 如果已经有消息了(比如调试消息),保留它们并合并
const existingDebugMessages = prev.filter(m => m.id.startsWith('debug-'));
return [...messagesFromDB, ...existingDebugMessages];
});
console.log('消息列表已从数据库加载:', messagesFromDB.length);
} else {
// 如果数据库中没有消息,显示初始欢迎消息
const aiName = aiSettings.name;
setMessages(prev => {
const existingDebugMessages = prev.filter(m => m.id.startsWith('debug-'));
return [{
id: 'init',
role: 'assistant',
content: `我是${aiName}。✨\n想记账?快说!🎤`,
timestamp: Date.now()
}, ...existingDebugMessages];
});
}
} catch (error) {
console.error('加载消息列表失败:', error);
// 失败时尝试从本地存储加载
const storedMessages = localStorage.getItem('cui_messages');
if (storedMessages) {
setMessages(prev => {
const existingDebugMessages = prev.filter(m => m.id.startsWith('debug-'));
return [...JSON.parse(storedMessages), ...existingDebugMessages];
});
} else {
// 显示初始欢迎消息
const aiName = aiSettings.name;
setMessages(prev => {
const existingDebugMessages = prev.filter(m => m.id.startsWith('debug-'));
return [{
id: 'init',
role: 'assistant',
content: `我是${aiName}。✨\n想记账?快说!🎤`,
timestamp: Date.now()
}, ...existingDebugMessages];
});
}
}
// 从数据库获取待办提醒
try {
const response = await getReminders();
if (response.items && response.items.length > 0) {
const remindersFromDB: BillReminder[] = response.items.map((r: any) => ({
id: r.id,
title: r.title,
amount: r.amount,
frequency: r.frequency,
targetDate: r.target_date || r.targetDate,
isCompleted: r.is_completed ?? r.isCompleted ?? false
}));
setReminders(remindersFromDB);
console.log('待办提醒已从数据库加载:', remindersFromDB.length);
}
} catch (error) {
console.error('加载待办提醒失败:', error);
}
};
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 = async (updated: Transaction) => {
try {
// 调用 API 更新交易记录
const result = await updateTransaction(updated.id, {
item: updated.item,
amount: updated.amount,
type: updated.type,
category: updated.category,
date: updated.date,
timestamp: updated.timestamp
});
console.log('交易记录已在数据库中更新:', result);
// 更新成功后,更新本地缓存
setTransactions(prev => prev.map(t => t.id === updated.id ? result : t));
setEditingTransaction(null);
setIsEditingFromChat(false);
} catch (error) {
console.error('更新交易记录失败:', error);
alert('更新失败,请检查网络连接后重试。');
throw error;
}
};
const handleDeleteTransaction = async (id: string) => {
if (confirm('确定要删除这条记录吗?')) {
try {
// 先尝试查找关联的消息(卡片)
const associatedMessage = messages.find(m => m.relatedTransactionId === id);
// 调用 API 删除交易记录
await deleteTransaction(id);
console.log('交易记录已从数据库中删除:', id);
// 如果有对应消息,也从数据库删除
if (associatedMessage) {
try {
await deleteMessage(associatedMessage.id);
console.log('关联消息已从数据库中删除:', associatedMessage.id);
} catch (msgErr) {
console.warn('删除关联消息失败:', msgErr);
}
}
// 删除成功后,更新本地缓存
setTransactions(prev => prev.filter(t => t.id !== id));
setMessages(prev => prev.filter(m => m.relatedTransactionId !== id));
setEditingTransaction(null);
setIsEditingFromChat(false);
} catch (error) {
console.error('删除交易记录失败:', error);
alert('删除失败,请检查网络连接后重试。');
}
}
};
const handleAddReminder = async (reminder: BillReminder) => {
try {
const result: any = await createReminder({
title: reminder.title,
amount: reminder.amount,
frequency: reminder.frequency,
target_date: reminder.targetDate
});
const newReminder: BillReminder = {
id: result.id,
title: result.title,
amount: result.amount,
frequency: result.frequency,
targetDate: result.target_date || result.targetDate,
isCompleted: result.is_completed ?? result.isCompleted ?? false
};
setReminders(prev => [...prev, newReminder]);
} catch (error) {
console.error('创建待办提醒失败:', error);
alert('创建失败,请稍后重试');
}
};
const handleUpdateReminder = async (updated: BillReminder) => {
try {
const result: any = await updateReminder(updated.id, {
title: updated.title,
amount: updated.amount,
frequency: updated.frequency,
target_date: updated.targetDate
});
const mappedResult: BillReminder = {
id: result.id,
title: result.title,
amount: result.amount,
frequency: result.frequency,
targetDate: result.target_date || result.targetDate,
isCompleted: result.is_completed ?? result.isCompleted ?? false
};
setReminders(prev => prev.map(r => r.id === updated.id ? mappedResult : r));
} catch (error) {
console.error('更新待办提醒失败:', error);
alert('更新失败,请稍后重试');
}
};
const handleDeleteReminder = async (id: string) => {
if (confirm('确定要删除这个提醒吗?')) {
try {
await deleteReminder(id);
setReminders(prev => prev.filter(r => r.id !== id));
} catch (error) {
console.error('删除待办提醒失败:', error);
alert('删除失败,请稍后重试');
}
}
};
const handleToggleReminder = async (id: string) => {
try {
const result = await toggleReminder(id);
setReminders(prev => prev.map(r => r.id === id ? { ...r, isCompleted: result.is_completed } : r));
} catch (error) {
console.error('切换待办状态失败:', error);
alert('操作失败,请稍后重试');
}
};
// 辅助函数:先保存消息到数据库,然后更新本地
const addMessage = async (messageData: Omit<Message, 'id'> & { id?: string }): Promise<Message | null> => {
try {
const savedMessage = await createMessage({
role: messageData.role,
content: messageData.content,
timestamp: messageData.timestamp,
transaction_id: messageData.relatedTransactionId
});
console.log('消息已保存到数据库:', savedMessage);
// 数据库保存成功后,更新本地缓存
// API返回的是transaction_id,需要映射为relatedTransactionId
const newMessage: Message = {
id: savedMessage.id,
role: savedMessage.role,
content: savedMessage.content,
timestamp: savedMessage.timestamp,
relatedTransactionId: (savedMessage as any).transaction_id || savedMessage.relatedTransactionId
};
setMessages(prev => [...prev, newMessage]);
return newMessage;
} catch (error) {
console.error('保存消息到数据库失败:', error);
return null;
}
};
const handleDebugLog = React.useCallback((content: string) => {
// 调试信息仅打印到控制台,不再添加到消息列表
console.log(`🔧 [调试信息] ${content}`);
}, []);
const handleSendMessage = React.useCallback(async (text: string, imageBase64?: string, mimeType?: string) => {
if (!text.trim() && !imageBase64) return;
// 先保存用户消息到数据库
const userMessage = await addMessage({
role: 'user',
content: imageBase64 ? '已上传图片' : text,
timestamp: Date.now()
});
if (!userMessage) {
// 如果保存失败,显示错误提示
console.error('用户消息保存失败');
return;
}
setInputText('');
setIsProcessing(true);
try {
const result = await processUserMessage(text, aiSettings.name, aiSettings.persona, imageBase64, mimeType);
if (result.transaction) {
const transactionData = {
item: result.transaction.item,
amount: result.transaction.amount,
type: result.transaction.type,
category: result.transaction.category,
date: getLocalDateString(),
timestamp: Date.now()
};
// 先提交到数据库
try {
const savedTransaction = await createTransaction(transactionData);
console.log('交易记录已保存到数据库:', savedTransaction);
// 数据库保存成功后,更新本地缓存
const newTransaction: Transaction = {
id: savedTransaction.id, // 使用数据库返回的ID
...transactionData
};
setTransactions(prev => [...prev, newTransaction]);
// 显示交易卡片和回复消息
await addMessage({
role: 'assistant',
content: 'RECEIPT',
timestamp: Date.now(),
relatedTransactionId: savedTransaction.id
});
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));
await addMessage({
role: 'assistant',
content: replySegments[i].trim(),
timestamp: Date.now()
});
}
} catch (dbError) {
console.error('保存交易记录到数据库失败:', dbError);
// 数据库保存失败时,显示错误提示
await addMessage({
role: 'assistant',
content: '抱歉,保存交易记录失败,请检查网络连接或稍后重试。',
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));
await addMessage({
role: 'assistant',
content: replySegments[i].trim(),
timestamp: Date.now()
});
}
}
} catch (error) {
await addMessage({
role: 'assistant',
content: '系统走神了... 🫥\n等会儿再说。',
timestamp: Date.now()
});
} finally {
setIsProcessing(false);
}
}, [aiSettings, transactions, messages]);
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-40 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
onDebug={handleDebugLog}
onAvailabilityChange={(isAvailable) => setIsVoiceServiceAvailable(isAvailable)}
isServiceAvailable={isVoiceServiceAvailable}
/>
)}
<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;