commit c06e74bd019306473766bd83ccf1cd9efdc455f9 Author: karlcarlo Date: Mon Jan 19 20:20:54 2026 +0800 feat(app): 实现完整的财务管家记账聊天与统计功能 - 新增App主组件,实现聊天、账本、统计三大功能模块切换 - 支持语音及文本输入,自动解析记账信息并生成交易记录 - 交易记录支持编辑、删除、提醒等操作,数据本地持久化 - 管家性格预设和自定义设定,包含头像上传和语气调整 - 会话展示支持关联交易显示,样式区分收入与支出 - 账本页添加日期分组交易列表及饼图统计展示 - 消息列表实现渐变背景和交互动画,提升用户体验 - 支持图片上传并同步发送给AI进行解析记账 - 底部导航实现三标签切换并同步显示对应内容 - 全局事务编辑弹窗支持直接调用和删除操作 - 增加环境变量示例和.gitignore规则,完善工程配置 diff --git a/.env.sample b/.env.sample new file mode 100644 index 0000000..0255572 --- /dev/null +++ b/.env.sample @@ -0,0 +1,6 @@ +# GEMINI_API_KEY=AIzaSyCoF3vLfa3Pw4dXMz75wnoUzVdoGOvg1fc +GLM_API=https://api.chataiapi.com +API_KEY=sk-C5fv7W1sE1x2kncXeDrB1kscMM0JjrhIpiknjVOp8q4NtCmk + +# API_URL=http://localhost:8000 +API_URL=https://mp.yizhezhe.com \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fe3ae21 --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +.qoder +.trae \ No newline at end of file diff --git a/App.tsx b/App.tsx new file mode 100644 index 0000000..c5a6243 --- /dev/null +++ b/App.tsx @@ -0,0 +1,535 @@ +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('chat'); + const [messages, setMessages] = useState([]); + const [transactions, setTransactions] = useState([]); + const [budgets, setBudgets] = useState>({ '总预算': 3000 }); + const [reminders, setReminders] = useState([]); + const [inputText, setInputText] = useState(''); + const [isProcessing, setIsProcessing] = useState(false); + const [editingTransaction, setEditingTransaction] = useState(null); + const [isEditingFromChat, setIsEditingFromChat] = useState(false); + const [inputMode, setInputMode] = useState<'voice' | 'keyboard'>('voice'); + + const [aiSettings, setAiSettings] = useState(DEFAULT_AI_SETTINGS); + const [isSettingsOpen, setIsSettingsOpen] = useState(false); + const [customPreset, setCustomPreset] = useState({ name: '自定义', description: '可以根据你喜欢的方式设定助手的语气和风格' }); + + const fileInputRef = useRef(null); + const avatarInputRef = useRef(null); + const messagesEndRef = useRef(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) => { + 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) => { + 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 ( +
+
+ {/* Chat Tab */} +
+
+
setIsSettingsOpen(true)}> +
+ {aiSettings.avatar ? avatar : aiSettings.name.charAt(0)} +
+
+

+ {aiSettings.name} + +

+ + {aiSettings.persona.split(/[。!]/)[0] + '...'} + +
+
+
+ 今日支出 + ¥{todaySpending.toFixed(0)} +
+
+ + {/* AI Settings Modal */} + {isSettingsOpen && ( +
+
+
+

管家设定

+ +
+
+
+
+
+ {aiSettings.avatar ? avatar : aiSettings.name.charAt(0)} +
+ + +
+ 更改管家形象 +
+ +
+ + 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" /> +
+
+ +
+ {personaPresets.map(p => ( + + ))} +
+
+
+ +