feat(app): 实现完整的财务管家记账聊天与统计功能

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

6
.env.sample Normal file
View File

@@ -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

27
.gitignore vendored Normal file
View File

@@ -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

535
App.tsx Normal file
View File

@@ -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<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;

205
LedgerPage.tsx Normal file
View File

@@ -0,0 +1,205 @@
import React, { useMemo, useState } from 'react';
import { Transaction, TransactionType, ExpenseCategory, IncomeCategory } from '../types';
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend } from 'recharts';
import EditTransactionModal from './EditTransactionModal';
interface LedgerPageProps {
transactions: Transaction[];
onUpdateTransaction: (t: Transaction) => void;
onDeleteTransaction: (id: string) => void;
}
const COLORS = ['#6366f1', '#ec4899', '#10b981', '#f59e0b', '#8b5cf6', '#ef4444', '#6b7280'];
const LedgerPage: React.FC<LedgerPageProps> = ({ transactions, onUpdateTransaction, onDeleteTransaction }) => {
const [editingTransaction, setEditingTransaction] = useState<Transaction | null>(null);
// Group by date
const groupedTransactions = useMemo(() => {
const groups: { [key: string]: Transaction[] } = {};
transactions.forEach(t => {
if (!groups[t.date]) groups[t.date] = [];
groups[t.date].push(t);
});
// Sort dates descending
return Object.entries(groups).sort((a, b) => new Date(b[0]).getTime() - new Date(a[0]).getTime());
}, [transactions]);
const totalExpense = useMemo(() =>
transactions.filter(t => t.type === TransactionType.EXPENSE).reduce((sum, t) => sum + t.amount, 0),
[transactions]);
const totalIncome = useMemo(() =>
transactions.filter(t => t.type === TransactionType.INCOME).reduce((sum, t) => sum + t.amount, 0),
[transactions]);
const balance = totalIncome - totalExpense;
// Category Data for Pie Chart (Expenses Only)
const expenseCategoryData = useMemo(() => {
const map = new Map<string, number>();
transactions.filter(t => t.type === TransactionType.EXPENSE).forEach(t => {
map.set(t.category, (map.get(t.category) || 0) + t.amount);
});
return Array.from(map.entries())
.map(([name, value], index) => ({ name, value, fill: COLORS[index % COLORS.length] }))
.sort((a, b) => b.value - a.value);
}, [transactions]);
const getIcon = (t: Transaction) => {
if (t.type === TransactionType.INCOME) {
switch (t.category) {
case IncomeCategory.SALARY: return '💰';
case IncomeCategory.BONUS: return '🧧';
case IncomeCategory.INVESTMENT: return '📈';
default: return '💵';
}
}
switch (t.category) {
case ExpenseCategory.FOOD: return '🍔';
case ExpenseCategory.SHOPPING: return '🛍️';
case ExpenseCategory.TRANSPORT: return '🚗';
case ExpenseCategory.ENTERTAINMENT: return '🎬';
case ExpenseCategory.HOUSING: return '🏠';
case ExpenseCategory.MEDICAL: return '💊';
default: return '🧾';
}
};
const handleSaveEdit = (updated: Transaction) => {
onUpdateTransaction(updated);
setEditingTransaction(null);
};
const handleDelete = (id: string) => {
onDeleteTransaction(id);
setEditingTransaction(null);
};
return (
<div className="h-full flex flex-col bg-gray-50 overflow-y-auto no-scrollbar pb-24 md:pb-4 relative">
{/* Header Summary Cards */}
<div className="bg-indigo-600 text-white p-6 rounded-b-3xl shadow-lg shrink-0">
<div className="grid grid-cols-3 gap-4 text-center">
<div className="flex flex-col items-center">
<span className="text-xs opacity-70 mb-1">收入 (Income)</span>
<span className="text-lg font-bold text-emerald-200">+¥{totalIncome.toFixed(0)}</span>
</div>
<div className="flex flex-col items-center border-x border-indigo-500/50">
<span className="text-xs opacity-70 mb-1">结余 (Balance)</span>
<span className="text-2xl font-bold">¥{balance.toFixed(0)}</span>
</div>
<div className="flex flex-col items-center">
<span className="text-xs opacity-70 mb-1">支出 (Expense)</span>
<span className="text-lg font-bold text-pink-200">-¥{totalExpense.toFixed(0)}</span>
</div>
</div>
</div>
<div className="p-4 space-y-6 flex-1 max-w-3xl mx-auto w-full">
{/* Charts Section (Expenses) */}
{expenseCategoryData.length > 0 && (
<div className="bg-white p-5 rounded-2xl shadow-sm border border-gray-100">
<h3 className="text-gray-700 font-bold mb-4 flex items-center gap-2 text-sm">
<svg className="w-4 h-4 text-indigo-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 3.055A9.001 9.001 0 1020.945 13H11V3.055z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.488 9H15V3.512A9.025 9.025 0 0120.488 9z" /></svg>
支出构成
</h3>
<div className="h-48 w-full">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={expenseCategoryData}
cx="50%"
cy="50%"
innerRadius={45}
outerRadius={65}
paddingAngle={5}
dataKey="value"
>
{expenseCategoryData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.fill} />
))}
</Pie>
<Tooltip formatter={(value: number) => `¥${value.toFixed(2)}`} />
<Legend iconSize={8} wrapperStyle={{ fontSize: '12px' }} />
</PieChart>
</ResponsiveContainer>
</div>
</div>
)}
{/* Transactions List */}
<div className="space-y-4">
<h3 className="text-gray-700 font-bold px-2 flex items-center gap-2 text-sm">
<svg className="w-4 h-4 text-indigo-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" /></svg>
收支明细
</h3>
{groupedTransactions.map(([date, items]) => {
const dayIncome = items.filter(t => t.type === TransactionType.INCOME).reduce((s,t)=>s+t.amount,0);
const dayExpense = items.filter(t => t.type === TransactionType.EXPENSE).reduce((s,t)=>s+t.amount,0);
return (
<div key={date} className="bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden">
<div className="bg-gray-50/80 px-4 py-2 border-b border-gray-100 flex justify-between items-center backdrop-blur-sm sticky top-0 text-xs">
<span className="font-semibold text-gray-500">{date}</span>
<div className="flex gap-2">
{dayIncome > 0 && <span className="text-emerald-600">收 ¥{dayIncome}</span>}
{dayExpense > 0 && <span className="text-gray-600">支 ¥{dayExpense}</span>}
</div>
</div>
<div>
{items.slice().reverse().map((t, idx) => (
<div
key={t.id}
onClick={() => setEditingTransaction(t)}
className={`flex items-center justify-between p-4 hover:bg-gray-50 active:bg-gray-100 cursor-pointer transition-colors ${idx !== items.length - 1 ? 'border-b border-gray-50' : ''}`}
>
<div className="flex items-center gap-3 overflow-hidden">
<div className={`w-10 h-10 rounded-full flex items-center justify-center text-lg shrink-0 shadow-sm
${t.type === TransactionType.INCOME
? 'bg-emerald-100 text-emerald-600'
: (t.category === ExpenseCategory.FOOD ? 'bg-orange-100 text-orange-500' :
t.category === ExpenseCategory.SHOPPING ? 'bg-pink-100 text-pink-500' :
t.category === ExpenseCategory.TRANSPORT ? 'bg-blue-100 text-blue-500' :
'bg-gray-100 text-gray-500')
}`}
>
{getIcon(t)}
</div>
<div className="min-w-0">
<p className="font-medium text-gray-800 truncate">{t.item}</p>
<p className="text-xs text-gray-500">{t.category}</p>
</div>
</div>
<div className={`font-bold whitespace-nowrap pl-2 ${t.type === TransactionType.INCOME ? 'text-emerald-600' : 'text-gray-900'}`}>
{t.type === TransactionType.INCOME ? '+' : '-'} {t.amount.toFixed(2)}
</div>
</div>
))}
</div>
</div>
)})}
{transactions.length === 0 && (
<div className="text-center text-gray-400 py-12 bg-white rounded-2xl border border-dashed border-gray-200">
<p className="text-4xl mb-2">💸</p>
<p>还没有账目</p>
<p className="text-sm mt-1">告诉崔经理你赚了多少或花了多少!</p>
</div>
)}
</div>
</div>
{/* Edit Modal */}
{editingTransaction && (
<EditTransactionModal
transaction={editingTransaction}
onSave={handleSaveEdit}
onDelete={handleDelete}
onClose={() => setEditingTransaction(null)}
/>
)}
</div>
);
};
export default LedgerPage;

20
README.md Normal file
View File

@@ -0,0 +1,20 @@
<div align="center">
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
</div>
# Run and deploy your AI Studio app
This contains everything you need to run your app locally.
View your app in AI Studio: https://ai.studio/apps/drive/1DCZp-lBT-6ncjuvPV3JSFJjmsYxSuqTd
## Run Locally
**Prerequisites:** Node.js
1. Install dependencies:
`npm install`
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
3. Run the app:
`npm run dev`

View File

@@ -0,0 +1,247 @@
import React, { useState } from 'react';
import { BillReminder } from '../types';
import { v4 as uuidv4 } from 'uuid';
interface BillRemindersProps {
reminders: BillReminder[];
onAdd: (reminder: BillReminder) => void;
onUpdate: (reminder: BillReminder) => void;
onDelete: (id: string) => void;
onToggle: (id: string) => void;
}
// Helper to calculate days remaining
export const getDaysRemaining = (r: BillReminder): number => {
const today = new Date();
today.setHours(0, 0, 0, 0);
let targetDate = new Date();
targetDate.setHours(0, 0, 0, 0);
if (r.frequency === 'monthly') {
const day = parseInt(r.targetDate);
targetDate.setDate(day);
// If the day has passed this month, move to next month
if (targetDate < today) {
targetDate.setMonth(targetDate.getMonth() + 1);
}
} else {
// Once
targetDate = new Date(r.targetDate);
targetDate.setHours(0, 0, 0, 0);
}
const diffTime = targetDate.getTime() - today.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
return diffDays;
};
const BillReminders: React.FC<BillRemindersProps> = ({ reminders, onAdd, onUpdate, onDelete, onToggle }) => {
const [isFormOpen, setIsFormOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [title, setTitle] = useState('');
const [amount, setAmount] = useState('');
const [frequency, setFrequency] = useState<'monthly' | 'once'>('monthly');
const [targetDate, setTargetDate] = useState(''); // '15' or '2023-10-15'
const resetForm = () => {
setTitle('');
setAmount('');
setFrequency('monthly');
setTargetDate('');
setEditingId(null);
setIsFormOpen(false);
};
const startEdit = (r: BillReminder) => {
setEditingId(r.id);
setTitle(r.title);
setAmount(r.amount ? r.amount.toString() : '');
setFrequency(r.frequency);
setTargetDate(r.targetDate);
setIsFormOpen(true);
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
// Amount is now optional, check title and targetDate only
if (!title || !targetDate) return;
const parsedAmount = parseFloat(amount);
const reminderData: BillReminder = {
id: editingId || uuidv4(),
title,
amount: isNaN(parsedAmount) ? undefined : parsedAmount,
frequency,
targetDate,
isCompleted: editingId ? reminders.find(r => r.id === editingId)?.isCompleted || false : false
};
if (editingId) {
onUpdate(reminderData);
} else {
onAdd(reminderData);
}
resetForm();
};
const getDueText = (r: BillReminder) => {
if (r.frequency === 'monthly') {
return `每月 ${r.targetDate} 日`;
}
return r.targetDate; // YYYY-MM-DD
};
const renderCountdown = (r: BillReminder) => {
const days = getDaysRemaining(r);
if (r.isCompleted) return <span className="text-emerald-600 font-bold text-[10px] bg-emerald-50 px-2 py-0.5 rounded-full border border-emerald-100">已完成</span>;
if (days < 0) return <span className="text-rose-600 font-bold text-[10px] bg-rose-50 px-2 py-0.5 rounded-full border border-rose-100">逾期 {Math.abs(days)} 天</span>;
if (days === 0) return <span className="text-rose-600 font-bold text-[10px] bg-rose-50 px-2 py-0.5 rounded-full border border-rose-100 animate-pulse">今天到期</span>;
if (days <= 3) return <span className="text-orange-600 font-bold text-[10px] bg-orange-50 px-2 py-0.5 rounded-full border border-orange-100">剩 {days} 天</span>;
return <span className="text-indigo-600 font-bold text-[10px] bg-indigo-50 px-2 py-0.5 rounded-full border border-indigo-100">剩 {days} 天</span>;
};
return (
<div className="space-y-3">
<div className="flex items-center justify-between px-2">
<h3 className="text-gray-700 font-bold flex items-center gap-2 text-sm">
<svg className="w-5 h-5 text-indigo-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
待办 & 还款提醒
</h3>
<button
onClick={() => {
if (isFormOpen) resetForm();
else setIsFormOpen(true);
}}
className="text-indigo-600 text-xs font-semibold hover:bg-indigo-50 px-2 py-1 rounded-lg transition-colors bg-white shadow-sm border border-indigo-100"
>
{isFormOpen ? '取消' : '+ 新增'}
</button>
</div>
{isFormOpen && (
<form onSubmit={handleSubmit} className="bg-white p-4 rounded-2xl shadow-lg border border-indigo-50 animate-float-in relative z-20">
<h4 className="text-sm font-bold text-gray-800 mb-3 flex items-center gap-2">
<div className="w-1 h-4 bg-indigo-500 rounded-full"></div>
{editingId ? '编辑提醒' : '新提醒'}
</h4>
<div className="space-y-3">
<input
placeholder="项目名称 (如: 房贷、信用卡)"
value={title}
onChange={e => setTitle(e.target.value)}
className="w-full p-2 bg-gray-50 border-none rounded-xl text-sm focus:ring-2 focus:ring-indigo-500 outline-none transition-all"
/>
<div className="flex gap-2">
<input
type="number"
placeholder="金额 (选填)"
value={amount}
onChange={e => setAmount(e.target.value)}
className="flex-1 p-2 bg-gray-50 border-none rounded-xl text-sm focus:ring-2 focus:ring-indigo-500 outline-none transition-all"
/>
<select
value={frequency}
onChange={e => setFrequency(e.target.value as any)}
className="p-2 bg-gray-50 border-none rounded-xl text-sm outline-none"
>
<option value="monthly">每月</option>
<option value="once">单次</option>
</select>
</div>
{frequency === 'monthly' ? (
<div className="flex items-center gap-2 text-sm text-gray-600 bg-gray-50 p-2 rounded-xl">
<span>每月</span>
<input
type="number"
min="1" max="31"
placeholder="日"
value={targetDate}
onChange={e => setTargetDate(e.target.value)}
className="w-12 p-1 border-b border-indigo-200 text-center bg-transparent focus:border-indigo-500 outline-none"
/>
<span>号还款</span>
</div>
) : (
<input
type="date"
value={targetDate}
onChange={e => setTargetDate(e.target.value)}
className="w-full p-2 bg-gray-50 border-none rounded-xl text-sm text-gray-600 outline-none"
/>
)}
<button type="submit" className="w-full py-2 bg-indigo-600 text-white rounded-xl text-sm font-bold shadow-md shadow-indigo-200 hover:bg-indigo-700 transition-transform active:scale-95">
{editingId ? '保存' : '添加'}
</button>
</div>
</form>
)}
<div className="space-y-2">
{reminders.length === 0 && !isFormOpen && (
<div className="text-center py-6 text-gray-400 text-xs bg-white rounded-2xl border border-dashed border-gray-200">
<p className="text-xl mb-1">📅</p>
暂无待办事项
</div>
)}
{reminders.map(r => (
<div key={r.id} className={`relative overflow-hidden p-3 rounded-2xl border transition-all duration-300 ${r.isCompleted ? 'bg-gray-50 border-gray-100 opacity-60' : 'bg-white border-gray-100 shadow-sm'}`}>
{/* Decorative side bar */}
<div className={`absolute left-0 top-0 bottom-0 w-1 ${r.isCompleted ? 'bg-gray-300' : (getDaysRemaining(r) <= 3 ? 'bg-rose-500' : 'bg-indigo-500')}`}></div>
<div className="flex items-center justify-between pl-3 pr-1">
{/* Left: Info */}
<div className="flex-1 min-w-0 mr-2">
{/* Row 1: Checkbox + Title + Amount */}
<div className="flex items-center gap-2 mb-1.5">
<button
onClick={() => onToggle(r.id)}
className={`w-5 h-5 rounded-full border-2 flex items-center justify-center transition-colors shrink-0 ${r.isCompleted ? 'bg-emerald-500 border-emerald-500' : 'border-gray-300 hover:border-indigo-500'}`}
>
{r.isCompleted && <svg className="w-3 h-3 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" /></svg>}
</button>
<span className={`font-bold text-sm truncate ${r.isCompleted ? 'line-through text-gray-400' : 'text-gray-800'}`}>{r.title}</span>
{r.amount !== undefined && (
<span className="font-mono text-gray-500 font-bold text-[10px] bg-gray-50 px-1 rounded">¥{r.amount}</span>
)}
</div>
{/* Row 2: Date + Countdown */}
<div className="flex items-center gap-2">
<div className="flex items-center gap-1 text-[10px] text-gray-400">
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
<span>{getDueText(r)}</span>
</div>
{renderCountdown(r)}
</div>
</div>
{/* Right: Actions (Separated) */}
<div className="flex items-center gap-6 border-l border-gray-100 pl-4 py-1">
<button
onClick={() => startEdit(r)}
className="text-gray-400 hover:text-indigo-600 transition-colors p-1"
title="编辑"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" /></svg>
</button>
<button
onClick={() => onDelete(r.id)}
className="text-gray-400 hover:text-rose-600 transition-colors p-1"
title="删除"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
</button>
</div>
</div>
</div>
))}
</div>
</div>
);
};
export default BillReminders;

179
components/CalendarView.tsx Normal file
View File

@@ -0,0 +1,179 @@
import React, { useState, useMemo, useEffect } from 'react';
import { Transaction, TransactionType, BillReminder } from '../types';
import { Solar } from 'lunar-javascript';
interface CalendarViewProps {
transactions: Transaction[];
reminders?: BillReminder[];
viewDate?: string; // Format: YYYY-MM
}
const formatAmount = (num: number): string => {
// 如果金额超过 10 万(十万单位),显示为 X.Y万,最多显示小数点后一位
if (num >= 100000) {
return (num / 10000).toFixed(1).replace(/\.0$/, '') + '万';
}
// 否则显示完整数字(四舍五入)
return Math.round(num).toString();
};
const CalendarView: React.FC<CalendarViewProps> = ({ transactions, reminders = [], viewDate }) => {
// Initialize from prop or current date
const [currentDate, setCurrentDate] = useState(() => {
if (viewDate) {
const [y, m] = viewDate.split('-').map(Number);
return new Date(y, m - 1, 1);
}
return new Date();
});
// Sync internal state when prop changes
useEffect(() => {
if (viewDate) {
const [y, m] = viewDate.split('-').map(Number);
setCurrentDate(new Date(y, m - 1, 1));
}
}, [viewDate]);
const year = currentDate.getFullYear();
const month = currentDate.getMonth();
const daysInMonth = new Date(year, month + 1, 0).getDate();
const firstDayOfMonth = new Date(year, month, 1).getDay(); // 0 is Sunday
const handlePrevMonth = () => setCurrentDate(new Date(year, month - 1, 1));
const handleNextMonth = () => setCurrentDate(new Date(year, month + 1, 1));
// Helper to get Lunar Date text
const getLunarText = (y: number, m: number, d: number) => {
try {
const solar = Solar.fromYmd(y, m + 1, d);
const lunar = solar.getLunar();
const day = lunar.getDayInChinese();
if (day === '初一') {
return lunar.getMonthInChinese() + '月';
}
return day;
} catch (e) {
return '';
}
};
// Aggregate stats by date
const dailyStats = useMemo(() => {
const map = new Map<string, { income: number; expense: number; reminderTitle?: string }>();
// Process Transactions
transactions.forEach(t => {
const existing = map.get(t.date) || { income: 0, expense: 0 };
if (t.type === TransactionType.EXPENSE) {
existing.expense += t.amount;
} else {
existing.income += t.amount;
}
map.set(t.date, existing);
});
// Process Reminders
reminders.forEach(r => {
let dateKey = '';
if (r.frequency === 'monthly') {
const day = parseInt(r.targetDate);
if (day <= daysInMonth) {
dateKey = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
}
} else {
dateKey = r.targetDate;
}
if (dateKey) {
const existing = map.get(dateKey) || { income: 0, expense: 0 };
if (!r.isCompleted && !existing.reminderTitle) {
existing.reminderTitle = r.title;
}
map.set(dateKey, existing);
}
});
return map;
}, [transactions, reminders, year, month, daysInMonth]);
const renderDays = () => {
const days = [];
for (let i = 0; i < firstDayOfMonth; i++) {
days.push(<div key={`empty-${i}`} className="h-24 bg-gray-50/20 border border-gray-100/30"></div>);
}
for (let d = 1; d <= daysInMonth; d++) {
const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
const stat = dailyStats.get(dateStr) || { income: 0, expense: 0 };
const isToday = new Date().toISOString().split('T')[0] === dateStr;
const lunarText = getLunarText(year, month, d);
days.push(
<div key={d} className={`h-24 border border-gray-100 p-1 flex flex-col justify-between relative overflow-hidden transition-colors ${isToday ? 'bg-indigo-50/40 ring-1 ring-inset ring-indigo-100' : 'bg-white'}`}>
<div className="flex flex-col w-full items-center pt-1">
<div className="flex flex-col items-center leading-none">
<span className={`text-[11px] font-black w-5 h-5 flex items-center justify-center rounded-full mb-0.5 ${isToday ? 'bg-indigo-600 text-white' : 'text-gray-700'}`}>
{d}
</span>
<span className="text-[8px] text-gray-300 font-bold scale-90">{lunarText}</span>
</div>
{stat.reminderTitle && (
<div className="w-full px-0.5 mt-1 text-center">
<span className="block text-[8px] font-black text-rose-500 bg-rose-50 border border-rose-100 rounded px-0.5 py-0.5 whitespace-nowrap overflow-hidden leading-none uppercase tracking-tighter">
{stat.reminderTitle.slice(0, 4)}
</span>
</div>
)}
</div>
<div className="flex flex-col items-end w-full px-0.5 gap-0.5 pb-1">
{stat.income > 0 && (
<div className="flex items-center gap-0.5 w-full justify-end">
<span className="text-[9px] font-black text-emerald-500 leading-none whitespace-nowrap overflow-visible">
¥{formatAmount(stat.income)}
</span>
</div>
)}
{stat.expense > 0 && (
<div className="flex items-center gap-0.5 w-full justify-end">
<span className="text-[9px] font-black text-rose-400 leading-none whitespace-nowrap overflow-visible">
¥{formatAmount(stat.expense)}
</span>
</div>
)}
</div>
</div>
);
}
return days;
};
return (
<div className="bg-white rounded-[2rem] shadow-sm border border-gray-100 overflow-hidden">
<div className="flex items-center justify-between p-4 border-b border-gray-100 bg-white">
<button onClick={handlePrevMonth} className="p-2 hover:bg-gray-50 rounded-xl text-gray-300 hover:text-indigo-600 transition-colors">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" /></svg>
</button>
<h3 className="font-black text-gray-800 text-sm tracking-widest uppercase">
{year} / {String(month + 1).padStart(2, '0')}
</h3>
<button onClick={handleNextMonth} className="p-2 hover:bg-gray-50 rounded-xl text-gray-300 hover:text-indigo-600 transition-colors">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /></svg>
</button>
</div>
<div className="grid grid-cols-7 bg-gray-50/30">
{['日', '一', '二', '三', '四', '五', '六'].map(day => (
<div key={day} className="py-2.5 text-center text-[9px] font-black text-gray-400 uppercase tracking-widest">
{day}
</div>
))}
</div>
<div className="grid grid-cols-7 border-t border-gray-50">
{renderDays()}
</div>
</div>
);
};
export default CalendarView;

114
components/Dashboard.tsx Normal file
View File

@@ -0,0 +1,114 @@
import React, { useMemo } from 'react';
import { Transaction, TransactionType, ExpenseCategory } from '../types';
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend, BarChart, Bar, XAxis, YAxis, CartesianGrid } from 'recharts';
interface DashboardProps {
expenses: Transaction[]; // Kept prop name as expenses for compatibility but type is Transaction
}
const COLORS = [
'#6366f1', // Indigo
'#ec4899', // Pink
'#10b981', // Emerald
'#f59e0b', // Amber
'#8b5cf6', // Violet
'#ef4444', // Red
'#3b82f6', // Blue
'#14b8a6', // Teal
'#f97316', // Orange
'#84cc16', // Lime
'#06b6d4', // Cyan
'#d946ef', // Fuchsia
'#64748b', // Slate
'#a855f7', // Purple
'#fbbf24' // Yellow
];
const Dashboard: React.FC<DashboardProps> = ({ expenses }) => {
const today = new Date().toISOString().split('T')[0];
// Filter only expenses for the dashboard visuals
const expenseTransactions = useMemo(() => expenses.filter(e => e.type === TransactionType.EXPENSE), [expenses]);
const todaysExpenses = useMemo(() => {
return expenseTransactions.filter(e => e.date === today);
}, [expenseTransactions, today]);
const totalToday = useMemo(() => {
return todaysExpenses.reduce((sum, e) => sum + e.amount, 0);
}, [todaysExpenses]);
const categoryData = useMemo(() => {
const map = new Map<string, number>();
todaysExpenses.forEach(e => {
map.set(e.category, (map.get(e.category) || 0) + e.amount);
});
return Array.from(map.entries()).map(([name, value], index) => ({
name,
value,
fill: COLORS[index % COLORS.length]
}));
}, [todaysExpenses]);
const weeklyData = useMemo(() => {
const data: { date: string; total: number }[] = [];
for (let i = 6; i >= 0; i--) {
const d = new Date();
d.setDate(d.getDate() - i);
const dateStr = d.toISOString().split('T')[0];
const dailySum = expenseTransactions
.filter(e => e.date === dateStr)
.reduce((sum, e) => sum + e.amount, 0);
data.push({ date: dateStr.slice(5), total: dailySum });
}
return data;
}, [expenseTransactions]);
return (
<div className="flex flex-col h-full bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
<div className="p-6 bg-gradient-to-r from-indigo-500 to-purple-600 text-white">
<h2 className="text-xl font-bold mb-1">今日消费统计</h2>
<div className="flex items-end gap-2">
<span className="text-4xl font-extrabold tracking-tight">¥{totalToday.toFixed(2)}</span>
<span className="text-indigo-100 mb-1 text-sm">{today}</span>
</div>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-6 no-scrollbar">
<div className="bg-gray-50 p-4 rounded-xl">
<h3 className="text-gray-700 font-semibold mb-4 text-sm uppercase tracking-wider">今日分类占比</h3>
{categoryData.length > 0 ? (
<div className="h-64 w-full">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={categoryData}
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={80}
paddingAngle={5}
dataKey="value"
>
{categoryData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.fill} />
))}
</Pie>
<Tooltip formatter={(value: number) => `¥${value.toFixed(2)}`} />
<Legend />
</PieChart>
</ResponsiveContainer>
</div>
) : (
<div className="h-32 flex items-center justify-center text-gray-400 text-sm">
今天还没有消费哦,小王表现不错!
</div>
)}
</div>
</div>
</div>
);
};
export default Dashboard;

View File

@@ -0,0 +1,116 @@
import React, { useState, useEffect } from 'react';
import { Transaction, TransactionType, ExpenseCategory, IncomeCategory } from '../types';
interface EditTransactionModalProps {
transaction: Transaction;
onSave: (updated: Transaction) => void;
onDelete: (id: string) => void;
onClose: () => void;
hideDelete?: boolean;
}
const EditTransactionModal: React.FC<EditTransactionModalProps> = ({ transaction, onSave, onDelete, onClose, hideDelete = false }) => {
const [draft, setDraft] = useState<Transaction>(transaction);
useEffect(() => {
setDraft(transaction);
}, [transaction]);
const handleDelete = () => {
if (confirm('确定要删除这条记录吗?崔经理会盯着你的。')) {
onDelete(draft.id);
}
};
return (
<div className="fixed inset-0 z-[60] flex items-end md:items-center justify-center bg-black/50 backdrop-blur-sm p-4">
<div className="bg-white w-full max-w-md rounded-2xl p-6 shadow-2xl animate-float md:animate-none">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-bold text-gray-900">编辑账单</h3>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
<svg className="w-6 h-6" 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="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">金额</label>
<input
type="number"
value={draft.amount}
onChange={(e) => setDraft({...draft, amount: parseFloat(e.target.value) || 0})}
className="w-full p-3 border border-gray-200 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none font-bold text-lg bg-white shadow-sm transition-all"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">内容</label>
<input
type="text"
value={draft.item}
onChange={(e) => setDraft({...draft, item: e.target.value})}
className="w-full p-3 border border-gray-200 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none bg-white shadow-sm transition-all"
/>
</div>
<div className="flex gap-4">
<div className="flex-1">
<label className="block text-sm font-medium text-gray-700 mb-1">类型</label>
<select
value={draft.type}
onChange={(e) => setDraft({
...draft,
type: e.target.value as TransactionType,
category: e.target.value === TransactionType.EXPENSE ? ExpenseCategory.MISC : IncomeCategory.OTHER
})}
className="w-full p-3 border border-gray-200 rounded-xl focus:ring-2 focus:ring-indigo-500 outline-none bg-white shadow-sm transition-all"
>
<option value={TransactionType.EXPENSE}>支出</option>
<option value={TransactionType.INCOME}>收入</option>
</select>
</div>
<div className="flex-1">
<label className="block text-sm font-medium text-gray-700 mb-1">分类</label>
<select
value={draft.category}
onChange={(e) => setDraft({...draft, category: e.target.value})}
className="w-full p-3 border border-gray-200 rounded-xl focus:ring-2 focus:ring-indigo-500 outline-none bg-white shadow-sm transition-all"
>
{draft.type === TransactionType.EXPENSE
? Object.values(ExpenseCategory).map(c => <option key={c} value={c}>{c}</option>)
: Object.values(IncomeCategory).map(c => <option key={c} value={c}>{c}</option>)
}
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">日期</label>
<input
type="date"
value={draft.date}
onChange={(e) => setDraft({...draft, date: e.target.value})}
className="w-full p-3 border border-gray-200 rounded-xl focus:ring-2 focus:ring-indigo-500 outline-none bg-white shadow-sm transition-all"
/>
</div>
<div className="pt-4 flex gap-3">
{!hideDelete && (
<button
onClick={handleDelete}
className="flex-1 py-3 px-4 bg-red-50 text-red-600 rounded-xl font-semibold hover:bg-red-100 transition-colors"
>
删除
</button>
)}
<button
onClick={() => onSave(draft)}
className={`py-3 px-4 bg-indigo-600 text-white rounded-xl font-semibold hover:bg-indigo-700 transition-all shadow-md shadow-indigo-200 ${hideDelete ? 'w-full' : 'flex-[2]'}`}
>
保存修改
</button>
</div>
</div>
</div>
</div>
);
};
export default EditTransactionModal;

208
components/LedgerPage.tsx Normal file
View File

@@ -0,0 +1,208 @@
import React, { useMemo, useState, useEffect } from 'react';
import { Transaction, TransactionType, ExpenseCategory, IncomeCategory, BillReminder } from '../types';
import EditTransactionModal from './EditTransactionModal';
import CalendarView from './CalendarView';
import BillReminders, { getDaysRemaining } from './BillReminders';
interface LedgerPageProps {
transactions: Transaction[];
onUpdateTransaction: (t: Transaction) => void;
onDeleteTransaction: (id: string) => void;
budgets: Record<string, number>;
setBudgets: (val: Record<string, number>) => void;
reminders: BillReminder[];
onAddReminder: (r: BillReminder) => void;
onUpdateReminder: (r: BillReminder) => void;
onDeleteReminder: (id: string) => void;
onToggleReminder: (id: string) => void;
}
const CARD_THEMES = [
{ id: 'dark-indigo', class: 'from-gray-900 via-indigo-950 to-black', themeColor: 'bg-indigo-600', name: '暗夜霓虹' },
{ id: 'sunset', class: 'from-orange-500 via-rose-600 to-pink-700', themeColor: 'bg-rose-600', name: '落日熔金' },
{ id: 'emerald', class: 'from-emerald-600 via-teal-700 to-cyan-900', themeColor: 'bg-emerald-600', name: '薄荷森林' },
{ id: 'royal', class: 'from-blue-700 via-indigo-800 to-indigo-950', themeColor: 'bg-blue-700', name: '皇家礼赞' },
{ id: 'rose', class: 'from-rose-500 via-pink-600 to-purple-800', themeColor: 'bg-purple-600', name: '浪漫巴黎' },
];
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 LedgerPage: React.FC<LedgerPageProps> = ({
transactions, onUpdateTransaction, onDeleteTransaction, budgets, reminders,
onAddReminder, onUpdateReminder, onDeleteReminder, onToggleReminder
}) => {
const [editingTransaction, setEditingTransaction] = useState<Transaction | null>(null);
const [subTab, setSubTab] = useState<'details' | 'calendar' | 'bills'>('details');
const [selectedMonth, setSelectedMonth] = useState(getLocalDateString().slice(0, 7));
const [themeIndex, setThemeIndex] = useState(() => Number(localStorage.getItem('cui_card_theme')) || 0);
useEffect(() => {
localStorage.setItem('cui_card_theme', themeIndex.toString());
}, [themeIndex]);
const currentTheme = CARD_THEMES[themeIndex];
// Monthly stats for the top cards
const monthlyTransactions = useMemo(() => transactions.filter(t => t.date.startsWith(selectedMonth)), [transactions, selectedMonth]);
const currentMonthExpense = useMemo(() => monthlyTransactions.filter(t => t.type === TransactionType.EXPENSE).reduce((sum, t) => sum + t.amount, 0), [monthlyTransactions]);
const currentMonthIncome = useMemo(() => monthlyTransactions.filter(t => t.type === TransactionType.INCOME).reduce((sum, t) => sum + t.amount, 0), [monthlyTransactions]);
const todayExpense = useMemo(() => transactions.filter(t => t.date === getLocalDateString() && t.type === TransactionType.EXPENSE).reduce((sum, t) => sum + t.amount, 0), [transactions]);
const currentMonthBalance = currentMonthIncome - currentMonthExpense;
const cycleTheme = () => setThemeIndex((prev) => (prev + 1) % CARD_THEMES.length);
const getIcon = (t: Transaction) => {
if (t.type === TransactionType.INCOME) return '💰';
switch (t.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 '🧾';
}
};
// Grouped transactions for "Details" - Filtered by selectedMonth
const allGroupedTransactions = useMemo(() => {
const grouped = monthlyTransactions.reduce((acc, t) => {
if (!acc[t.date]) acc[t.date] = [];
acc[t.date].push(t);
return acc;
}, {} as Record<string, Transaction[]>);
return Object.entries(grouped).sort((a, b) => new Date(b[0]).getTime() - new Date(a[0]).getTime());
}, [monthlyTransactions]);
return (
<div className="h-full flex flex-col bg-gray-50 overflow-y-auto no-scrollbar pb-24 md:pb-4 relative font-sans text-gray-900 antialiased">
<div className="px-5 pt-8 pb-4">
<div className="flex justify-between items-center mb-8">
<div>
<h1 className="text-2xl font-black text-gray-800 tracking-tight">我的账本</h1>
<p className="text-[10px] text-gray-400 font-bold uppercase tracking-widest">Financial Ledger</p>
</div>
<input
type="month"
value={selectedMonth}
onChange={e => setSelectedMonth(e.target.value)}
className="bg-gray-100 rounded-lg px-3 py-1.5 text-sm font-bold text-gray-600 outline-none border-none shadow-sm focus:ring-1 focus:ring-indigo-300 transition-all"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className={`col-span-2 bg-gradient-to-br ${currentTheme.class} rounded-[2.5rem] p-8 text-white shadow-2xl shadow-indigo-200/40 relative overflow-hidden group transition-all duration-700`}>
<button
onClick={cycleTheme}
className="absolute bottom-4 right-5 p-1 bg-white/10 rounded-full hover:bg-white/20 transition-all z-20 opacity-40 backdrop-blur-sm border border-white/5"
>
<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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /></svg>
</button>
<div className="relative z-10 pointer-events-none">
<div className="flex justify-between items-start gap-4">
<div className="flex-1 min-w-0">
<div className="text-white/80 text-sm font-black mb-2 flex items-center gap-2 uppercase tracking-widest"><span className="w-1.5 h-1.5 bg-white/60 rounded-full"></span>本月总支出</div>
<div className="text-3xl sm:text-4xl font-black tracking-tighter font-mono whitespace-nowrap overflow-hidden">-¥{currentMonthExpense.toFixed(0)}</div>
</div>
<div className="text-right shrink-0">
<div className="text-white/50 text-xs font-black mb-1.5 uppercase tracking-widest">今日支出</div>
<div className="text-xl sm:text-2xl font-black font-mono text-white/90">-¥{todayExpense.toFixed(0)}</div>
</div>
</div>
<div className="mt-10 flex gap-8 pt-6 border-t border-white/10">
<div className="shrink-0">
<div className="text-xs text-white/60 font-black uppercase tracking-widest mb-1">本月总收入</div>
<div className="font-mono font-black text-xl sm:text-2xl text-white/90">+¥{currentMonthIncome.toFixed(0)}</div>
</div>
<div className="shrink-0">
<div className="text-xs text-white/60 font-black uppercase tracking-widest mb-1">本月结余</div>
<div className="font-mono font-black text-xl sm:text-2xl text-white/90">
{currentMonthBalance < 0 ? '-' : ''}¥{Math.abs(currentMonthBalance).toFixed(0)}
</div>
</div>
</div>
</div>
<div className="absolute -right-16 -top-24 w-80 h-80 bg-white/5 rounded-full blur-[90px]"></div>
</div>
</div>
</div>
<div className="px-5 mb-4">
<div className="bg-gray-100/50 p-1 rounded-2xl flex font-bold text-[13px] text-gray-400 border border-gray-200">
<button onClick={() => setSubTab('details')} className={`flex-1 py-2.5 rounded-xl transition-all duration-300 ${subTab === 'details' ? `${currentTheme.themeColor} text-white shadow-lg` : 'hover:text-gray-600'}`}>明细</button>
<button onClick={() => setSubTab('calendar')} className={`flex-1 py-2.5 rounded-xl transition-all duration-300 ${subTab === 'calendar' ? `${currentTheme.themeColor} text-white shadow-lg` : 'hover:text-gray-600'}`}>开销日历</button>
<button onClick={() => setSubTab('bills')} className={`flex-1 py-2.5 rounded-xl transition-all duration-300 relative ${subTab === 'bills' ? `${currentTheme.themeColor} text-white shadow-lg` : 'hover:text-gray-600'}`}>待办 {reminders.some(r => !r.isCompleted && getDaysRemaining(r) <= 1) && <span className={`absolute top-3 right-4 w-1.5 h-1.5 rounded-full animate-pulse ${subTab === 'bills' ? 'bg-white' : 'bg-rose-500'}`}></span>}</button>
</div>
</div>
<div className="flex-1 px-5 pb-20 space-y-4">
{subTab === 'details' && (
<div className="space-y-4">
{allGroupedTransactions.map(([date, items]) => {
const dayIncome = items.filter((t:any) => t.type === TransactionType.INCOME).reduce((s:any,t:any)=>s+t.amount,0);
const dayExpense = items.filter((t:any) => t.type === TransactionType.EXPENSE).reduce((s:any,t:any)=>s+t.amount,0);
const dObj = new Date(date);
const isToday = getLocalDateString() === date;
return (
<div key={date} className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden animate-float-in">
<div className="bg-gray-50/50 px-4 py-2.5 border-b border-gray-100 flex justify-between items-center text-[11px] font-bold text-gray-500">
<div className="flex items-center gap-2">
<span className={isToday ? 'text-indigo-600' : ''}>
{isToday ? '今天' : `${dObj.getMonth()+1}月${dObj.getDate()}日`}
</span>
<span className="text-gray-300 opacity-50">|</span>
<span className="text-gray-400 uppercase tracking-tighter">
{['周日','周一','周二','周三','周四','周五','周六'][dObj.getDay()]}
</span>
</div>
<div className="flex items-center gap-3">
{dayIncome > 0 && <span className="font-mono text-emerald-500">收 +¥{dayIncome.toFixed(0)}</span>}
{dayExpense > 0 && <span className="font-mono text-red-500">支 -¥{dayExpense.toFixed(0)}</span>}
</div>
</div>
<div className="divide-y divide-gray-50">
{items.slice().reverse().map((t:any) => (
<div key={t.id} onClick={() => setEditingTransaction(t)} className="flex items-center justify-between py-4 px-4 hover:bg-gray-50 active:bg-gray-100 cursor-pointer transition-colors">
<div className="flex items-center gap-3 overflow-hidden">
<div className="w-10 h-10 rounded-xl flex items-center justify-center text-lg shrink-0 bg-gray-50 border border-gray-100 shadow-inner">{getIcon(t)}</div>
<div className="min-w-0">
<p className="font-bold text-gray-800 truncate text-[14px] mb-0.5 tracking-tight">{t.item}</p>
<p className="text-[10px] text-gray-300 font-bold uppercase tracking-wider">{t.category}</p>
</div>
</div>
<div className={`font-black font-mono whitespace-nowrap pl-2 text-[15px] ${t.type === TransactionType.INCOME ? 'text-emerald-500' : 'text-red-500'}`}>
{t.type === TransactionType.INCOME ? '+' : '-'}¥{t.amount.toFixed(0)}
</div>
</div>
))}
</div>
</div>
)})}
{allGroupedTransactions.length === 0 && <div className="text-center text-gray-300 py-12 bg-white rounded-2xl border border-dashed border-gray-200"><div className="w-16 h-16 bg-gray-50 rounded-full flex items-center justify-center mx-auto mb-3 opacity-50 text-2xl">📓</div><p className="text-[11px] font-bold uppercase tracking-widest">本月暂无明细</p></div>}
</div>
)}
{subTab === 'calendar' && <CalendarView transactions={transactions} reminders={reminders} viewDate={selectedMonth} />}
{subTab === 'bills' && <BillReminders reminders={reminders.filter(r => r.frequency === 'monthly' || r.targetDate.startsWith(selectedMonth))} onAdd={onAddReminder} onUpdate={onUpdateReminder} onDelete={onDeleteReminder} onToggle={onToggleReminder} />}
</div>
{editingTransaction && <EditTransactionModal transaction={editingTransaction} onSave={(updated) => { onUpdateTransaction(updated); setEditingTransaction(null); }} onDelete={(id) => { onDeleteTransaction(id); setEditingTransaction(null); }} onClose={() => setEditingTransaction(null)} />}
</div>
);
};
export default LedgerPage;

View File

@@ -0,0 +1,274 @@
import React, { useMemo, useState, useEffect } from 'react';
import { Transaction, TransactionType, ExpenseCategory } from '../types';
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell, LineChart, Line, CartesianGrid, ReferenceLine } from 'recharts';
interface StatisticsPageProps {
transactions: Transaction[];
}
const COLORS = [
'#6366f1', // Indigo
'#ec4899', // Pink
'#10b981', // Emerald
'#f59e0b', // Amber
'#8b5cf6', // Violet
'#ef4444', // Red
'#3b82f6', // Blue
'#14b8a6', // Teal
'#f97316', // Orange
'#84cc16', // Lime
'#06b6d4', // Cyan
'#d946ef', // Fuchsia
'#64748b', // Slate
'#a855f7', // Purple
'#fbbf24' // Yellow
];
const StatisticsPage: React.FC<StatisticsPageProps> = ({ transactions }) => {
const [selectedMonth, setSelectedMonth] = useState(new Date().toISOString().slice(0, 7));
const [trendCategory, setTrendCategory] = useState<string>(ExpenseCategory.FOOD);
const monthlyTransactions = useMemo(() => {
return transactions.filter(t => t.date.startsWith(selectedMonth));
}, [transactions, selectedMonth]);
const totalIncome = useMemo(() =>
monthlyTransactions.filter(t => t.type === TransactionType.INCOME).reduce((sum, t) => sum + t.amount, 0),
[monthlyTransactions]);
const totalExpense = useMemo(() =>
monthlyTransactions.filter(t => t.type === TransactionType.EXPENSE).reduce((sum, t) => sum + t.amount, 0),
[monthlyTransactions]);
const monthlyBalance = useMemo(() => totalIncome - totalExpense, [totalIncome, totalExpense]);
// Extract month number for display
const monthLabel = useMemo(() => {
const month = parseInt(selectedMonth.split('-')[1]);
return `${month}月`;
}, [selectedMonth]);
const barData = [
{ name: '收入', amount: totalIncome, fill: '#10b981' },
{ name: '支出', amount: totalExpense, fill: '#ec4899' },
{ name: '结余', amount: monthlyBalance, fill: '#6366f1' }
];
// Category Stats (Used for Ranking and Detail)
const categoryStats = useMemo(() => {
const map = new Map<string, { count: number; amount: number }>();
monthlyTransactions
.filter(t => t.type === TransactionType.EXPENSE)
.forEach(t => {
const existing = map.get(t.category) || { count: 0, amount: 0 };
existing.count += 1;
existing.amount += t.amount;
map.set(t.category, existing);
});
return Array.from(map.entries())
.map(([name, stat], index) => ({
name,
...stat,
color: COLORS[index % COLORS.length]
}))
.sort((a, b) => b.amount - a.amount);
}, [monthlyTransactions]);
// Set default trend category to top expense of current month if available
useEffect(() => {
if (categoryStats.length > 0) {
setTrendCategory(categoryStats[0].name);
}
}, [categoryStats.length > 0 ? categoryStats[0].name : null]);
// Top 5 Ranking (Now based on Categories)
const rankingData = useMemo(() => {
return categoryStats.slice(0, 5);
}, [categoryStats]);
// Calculate 6-month trend for the selected category
const trendData = useMemo(() => {
const data = [];
const today = new Date();
// Look back 6 months
for (let i = 5; i >= 0; i--) {
const d = new Date(today.getFullYear(), today.getMonth() - i, 1);
const monthStr = d.toISOString().slice(0, 7); // YYYY-MM
const displayMonth = `${d.getMonth() + 1}月`;
const total = transactions
.filter(t =>
t.type === TransactionType.EXPENSE &&
t.category === trendCategory &&
t.date.startsWith(monthStr)
)
.reduce((sum, t) => sum + t.amount, 0);
data.push({
name: displayMonth,
amount: total,
fullDate: monthStr
});
}
return data;
}, [transactions, trendCategory]);
return (
<div className="h-full bg-gray-50 overflow-y-auto no-scrollbar pb-24 font-sans">
{/* Header */}
<div className="bg-white p-5 sticky top-0 z-10 shadow-sm">
<div className="flex justify-between items-center">
<h1 className="text-xl font-black text-gray-800">收支统计</h1>
<input
type="month"
value={selectedMonth}
onChange={e => setSelectedMonth(e.target.value)}
className="bg-gray-100 rounded-lg px-3 py-1 text-xs font-bold text-gray-500 outline-none border-none shadow-sm focus:ring-1 focus:ring-indigo-300 transition-all"
/>
</div>
</div>
<div className="p-5 space-y-6">
{/* Monthly Overview Card */}
<div className="bg-white rounded-2xl p-5 shadow-sm border border-gray-100">
<h2 className="text-sm font-bold text-gray-700 mb-6 flex items-center gap-2">
<span className="w-1.5 h-4 bg-indigo-500 rounded-full"></span>
{monthLabel}收支总览
</h2>
<div className="grid grid-cols-3 gap-2 mb-6 px-1">
<div className="text-center border-r border-gray-50">
<div className="text-[10px] text-gray-400 font-bold mb-1 uppercase tracking-tight">总收入</div>
<div className="text-sm font-black text-emerald-500 font-mono truncate">+¥{totalIncome.toFixed(0)}</div>
</div>
<div className="text-center border-r border-gray-50">
<div className="text-[10px] text-gray-400 font-bold mb-1 uppercase tracking-tight">总支出</div>
<div className="text-sm font-black text-rose-500 font-mono truncate">-¥{totalExpense.toFixed(0)}</div>
</div>
<div className="text-center">
<div className="text-[10px] text-gray-400 font-bold mb-1 uppercase tracking-tight">本月结余</div>
<div className={`text-sm font-black font-mono truncate ${monthlyBalance >= 0 ? 'text-indigo-600' : 'text-orange-500'}`}>
{monthlyBalance < 0 ? '-' : ''}¥{Math.abs(monthlyBalance).toFixed(0)}
</div>
</div>
</div>
<div className="h-56 w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={barData} layout="vertical" margin={{top:0, left:0, right:30, bottom:0}}>
<XAxis type="number" hide domain={['auto', 'auto']} />
<YAxis dataKey="name" type="category" width={40} tick={{fontSize: 12, fontWeight: 'bold'}} axisLine={false} tickLine={false} />
<Tooltip formatter={(val: number) => `¥${val.toFixed(2)}`} cursor={{fill: 'transparent'}} contentStyle={{borderRadius: '8px'}} />
<ReferenceLine x={0} stroke="#f3f4f6" />
<Bar dataKey="amount" barSize={24} radius={[0, 4, 4, 0]}>
{barData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.fill} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</div>
{/* Top Expenses Ranking (By Category) */}
<div className="bg-white rounded-2xl p-5 shadow-sm border border-gray-100">
<h2 className="text-sm font-bold text-gray-700 mb-4 flex items-center gap-2">
<span className="w-1.5 h-4 bg-orange-500 rounded-full"></span>
{monthLabel}分类消费排行榜 (Top 5)
</h2>
<div className="space-y-3">
{rankingData.length > 0 ? rankingData.map((cat, idx) => (
<div key={cat.name} className="flex items-center justify-between">
<div className="flex items-center gap-3 overflow-hidden">
<div className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold text-white shrink-0
${idx === 0 ? 'bg-yellow-400' : (idx === 1 ? 'bg-gray-400' : (idx === 2 ? 'bg-orange-400' : 'bg-indigo-100 text-indigo-500'))}`}>
{idx + 1}
</div>
<span className="text-sm text-gray-700 font-medium truncate">{cat.name}</span>
</div>
<div className="flex flex-col items-end">
<span className="font-mono font-bold text-gray-900 text-sm">¥{cat.amount.toFixed(0)}</span>
<span className="text-[10px] text-gray-400">{cat.count}笔</span>
</div>
</div>
)) : (
<div className="text-center text-gray-400 text-xs py-4">暂无支出</div>
)}
</div>
</div>
{/* Category Breakdown Details */}
<div className="bg-white rounded-2xl p-5 shadow-sm border border-gray-100">
<h2 className="text-sm font-bold text-gray-700 mb-4 flex items-center gap-2">
<span className="w-1.5 h-4 bg-purple-500 rounded-full"></span>
{monthLabel}分类统计详情
</h2>
<div className="space-y-4">
{categoryStats.length > 0 ? categoryStats.map((cat) => (
<div key={cat.name} className="relative">
<div className="flex justify-between items-center mb-1 text-sm relative z-10">
<div className="flex items-center gap-2">
<span className="font-bold text-gray-700">{cat.name}</span>
<span className="text-xs text-gray-400 bg-gray-100 px-1.5 py-0.5 rounded-md">{cat.count}笔</span>
</div>
<span className="font-mono font-bold text-gray-900">¥{cat.amount.toFixed(0)}</span>
</div>
<div className="h-1.5 w-full bg-gray-100 rounded-full overflow-hidden">
<div
className="h-full rounded-full"
style={{ width: `${(cat.amount / Math.max(totalExpense, 1)) * 100}%`, backgroundColor: cat.color }}
></div>
</div>
</div>
)) : (
<div className="text-center text-gray-400 text-xs py-4">暂无数据</div>
)}
</div>
</div>
{/* Trend Analysis Chart */}
<div className="bg-white rounded-2xl p-5 shadow-sm border border-gray-100">
<div className="flex justify-between items-center mb-4">
<h2 className="text-sm font-bold text-gray-700 flex items-center gap-2">
<span className="w-1.5 h-4 bg-blue-500 rounded-full"></span>
分类趋势 (近6个月)
</h2>
<select
value={trendCategory}
onChange={(e) => setTrendCategory(e.target.value)}
className="text-xs bg-gray-50 border-none rounded-lg py-1 px-2 text-gray-600 font-medium outline-none"
>
{Object.values(ExpenseCategory).map(cat => (
<option key={cat} value={cat}>{cat}</option>
))}
</select>
</div>
<div className="h-48 w-full -ml-2">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={trendData}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f3f4f6" />
<XAxis dataKey="name" axisLine={false} tickLine={false} tick={{fontSize: 10, fill: '#9ca3af'}} dy={10} />
<YAxis axisLine={false} tickLine={false} tick={{fontSize: 10, fill: '#9ca3af'}} />
<Tooltip
contentStyle={{borderRadius: '12px', border: 'none', boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)'}}
itemStyle={{color: '#4f46e5', fontWeight: 'bold'}}
/>
<Line
type="monotone"
dataKey="amount"
stroke="#6366f1"
strokeWidth={3}
dot={{fill: '#6366f1', strokeWidth: 2, r: 4, stroke: '#fff'}}
activeDot={{r: 6}}
/>
</LineChart>
</ResponsiveContainer>
</div>
</div>
</div>
</div>
);
};
export default StatisticsPage;

104
components/VoiceInput.tsx Normal file
View File

@@ -0,0 +1,104 @@
import React, { useState, useEffect, useRef } from 'react';
interface VoiceInputProps {
onTranscript: (text: string) => void;
isProcessing: boolean;
isFullWidth?: boolean;
}
const VoiceInput: React.FC<VoiceInputProps> = ({ onTranscript, isProcessing, isFullWidth = false }) => {
const [isListening, setIsListening] = useState(false);
const recognitionRef = useRef<any>(null);
const timerRef = useRef<number | null>(null);
useEffect(() => {
if ('SpeechRecognition' in window || 'webkitSpeechRecognition' in window) {
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
recognitionRef.current = new SpeechRecognition();
recognitionRef.current.continuous = false;
recognitionRef.current.lang = 'zh-CN';
recognitionRef.current.interimResults = false;
recognitionRef.current.onresult = (event: any) => {
const transcript = event.results[0][0].transcript;
if (transcript) onTranscript(transcript);
setIsListening(false);
};
recognitionRef.current.onerror = (event: any) => {
console.error('Speech recognition error', event.error);
setIsListening(false);
};
recognitionRef.current.onend = () => {
setIsListening(false);
};
}
}, [onTranscript]);
const startListening = (e: React.MouseEvent | React.TouchEvent) => {
e.preventDefault();
if (isProcessing || isListening) return;
if (recognitionRef.current) {
try {
recognitionRef.current.start();
setIsListening(true);
} catch (e) {
console.error("Failed to start recognition", e);
}
} else {
alert("您的浏览器不支持语音识别,请使用 Chrome 或 Edge。");
}
};
const stopListening = () => {
if (isListening) {
recognitionRef.current?.stop();
setIsListening(false);
}
};
return (
<div className={`${isFullWidth ? 'w-full' : ''} relative select-none touch-none`}>
<button
onMouseDown={startListening}
onMouseUp={stopListening}
onMouseLeave={stopListening}
onTouchStart={startListening}
onTouchEnd={stopListening}
disabled={isProcessing}
className={`relative flex items-center justify-center transition-all duration-300 active:scale-95 shadow-lg ${
isFullWidth
? 'w-full py-4 rounded-2xl font-bold text-sm tracking-widest'
: 'w-14 h-14 rounded-full'
} ${
isListening
? 'bg-rose-500 text-white shadow-rose-200'
: 'bg-indigo-600 text-white shadow-indigo-100'
} ${isProcessing ? 'opacity-50 grayscale cursor-not-allowed' : ''}`}
>
{isListening && (
<div className="absolute inset-0 rounded-2xl animate-ping bg-rose-400 opacity-20"></div>
)}
<div className="flex items-center gap-2 z-10">
<svg xmlns="http://www.w3.org/2000/svg" className={`${isFullWidth ? 'h-5 w-5' : 'h-6 w-6'}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} 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>
{isFullWidth && <span>{isListening ? '松开 结束' : '按住 说话'}</span>}
</div>
</button>
{isListening && isFullWidth && (
<div className="absolute -top-16 left-0 right-0 flex justify-center animate-bounce">
<div className="bg-rose-500 text-white px-4 py-2 rounded-full text-xs font-bold shadow-xl border-2 border-white">
正在录音...
</div>
</div>
)}
</div>
);
};
export default VoiceInput;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,907 @@
# 数据持久化接口文档
## 概述
本文档详细列出了智能账本应用中所有需要持久化数据的接口,包括存储键名、数据结构、操作方法和使用场景。应用采用浏览器 `localStorage` 作为持久化存储方案。
---
## 1. 交易记录持久化
### 存储键名
```typescript
'cui_transactions'
```
### 数据类型
```typescript
Transaction[]
```
### 数据结构
```typescript
interface Transaction {
id: string; // 唯一标识符 (UUID)
item: string; // 交易项目名称
amount: number; // 交易金额
type: TransactionType; // 交易类型: 'expense' | 'income'
category: string; // 交易分类 (来自 ExpenseCategory 或 IncomeCategory)
date: string; // 日期 (ISO格式 YYYY-MM-DD)
timestamp: number; // 时间戳 (毫秒)
}
```
### 支持的分类
#### 支出分类 (ExpenseCategory)
- 餐饮、购物、交通、住房、娱乐、医疗
- 人情、学习、金融、孩子、宠物、汽车
- 美容、经营、杂项
#### 收入分类 (IncomeCategory)
- 工资、奖金、理财、兼职、红包、其他收入
### 操作接口
#### 读取
```typescript
// 位置: App.tsx L102
const storedTransactions = localStorage.getItem('cui_transactions');
if (storedTransactions) setTransactions(JSON.parse(storedTransactions));
```
#### 写入
```typescript
// 位置: App.tsx L129
useEffect(() => {
localStorage.setItem('cui_transactions', JSON.stringify(transactions));
}, [transactions]);
```
#### 新增交易
```typescript
// 位置: App.tsx L192-204
const newTransaction: Transaction = {
id: uuidv4(),
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]);
```
#### 更新交易
```typescript
// 位置: App.tsx L144-148
const handleUpdateTransaction = (updated: Transaction) => {
setTransactions(prev => prev.map(t => t.id === updated.id ? updated : t));
};
```
#### 删除交易
```typescript
// 位置: App.tsx L150-156
const handleDeleteTransaction = (id: string) => {
if (confirm('确定要删除这条记录吗?')) {
setTransactions(prev => prev.filter(t => t.id !== id));
}
};
```
### 使用场景
- 聊天界面语音/文字输入记账
- 图片识别账单自动记账
- 账本页面手动编辑交易
- 统计页面数据查询和分析
- 日历视图显示每日交易
---
## 2. 聊天消息持久化
### 存储键名
```typescript
'cui_messages'
```
### 数据类型
```typescript
Message[]
```
### 数据结构
```typescript
interface Message {
id: string; // 消息唯一标识符 (UUID)
role: 'user' | 'assistant'; // 消息角色
content: string; // 消息内容
timestamp: number; // 时间戳 (毫秒)
relatedTransactionId?: string; // 关联的交易ID (可选)
}
```
### 操作接口
#### 读取
```typescript
// 位置: App.tsx L103
const storedMessages = localStorage.getItem('cui_messages');
if (storedMessages) setMessages(JSON.parse(storedMessages));
```
#### 写入
```typescript
// 位置: App.tsx L135-138
useEffect(() => {
localStorage.setItem('cui_messages', JSON.stringify(messages));
if (activeTab === 'chat') scrollToBottom();
}, [messages, activeTab]);
```
#### 初始化欢迎消息
```typescript
// 位置: App.tsx L119-126
if (!storedMessages) {
setMessages([{
id: 'init',
role: 'assistant',
content: `我是${aiSettings.name}。✨\n想记账?快说!🎤`,
timestamp: Date.now()
}]);
}
```
#### 新增用户消息
```typescript
// 位置: App.tsx L179-185
const userMsg: Message = {
id: uuidv4(),
role: 'user',
content: imageBase64 ? '已上传图片' : text,
timestamp: Date.now()
};
setMessages(prev => [...prev, userMsg]);
```
#### 新增助手消息(含交易回执)
```typescript
// 位置: App.tsx L206-212
setMessages(prev => [...prev, {
id: uuidv4(),
role: 'assistant',
content: 'RECEIPT',
timestamp: Date.now(),
relatedTransactionId: newTransactionId
}]);
```
### 使用场景
- 聊天界面显示对话历史
- 显示交易回执卡片
- 从聊天界面编辑/删除关联交易
- 保留用户与AI的完整对话记录
---
## 3. 预算数据持久化
### 存储键名
```typescript
'cui_budget'
```
### 数据类型
```typescript
Record<string, number>
```
### 数据结构
```typescript
// 键值对形式,支持多预算项
{
'总预算': 3000,
'餐饮预算': 1000,
'购物预算': 500,
// ... 更多预算项
}
```
### 操作接口
#### 读取(兼容旧版)
```typescript
// 位置: App.tsx L104, L111-114
const storedBudget = localStorage.getItem('cui_budget');
if (storedBudget) {
const parsed = JSON.parse(storedBudget);
setBudgets(typeof parsed === 'number' ? { '总预算': parsed } : parsed);
}
```
#### 写入
```typescript
// 位置: App.tsx L130
useEffect(() => {
localStorage.setItem('cui_budget', JSON.stringify(budgets));
}, [budgets]);
```
#### 更新预算
```typescript
// 通过 setBudgets 更新
setBudgets({ ...budgets, '总预算': 5000 });
```
### 使用场景
- 账本页面显示月度预算对比
- 预算超支提醒
- 多项预算管理(未来扩展)
### 兼容性说明
- 支持旧版单一预算值(纯数字)
- 新版支持多预算项(对象格式)
- 自动兼容转换
---
## 4. 账单提醒持久化
### 存储键名
```typescript
'cui_reminders'
```
### 数据类型
```typescript
BillReminder[]
```
### 数据结构
```typescript
interface BillReminder {
id: string; // 唯一标识符 (UUID)
title: string; // 提醒标题
amount?: number; // 金额 (可选)
frequency: 'monthly' | 'once'; // 频率: 每月 | 单次
targetDate: string; // 目标日期: 'DD' (每月) 或 'YYYY-MM-DD' (单次)
isCompleted: boolean; // 完成状态
}
```
### 操作接口
#### 读取
```typescript
// 位置: App.tsx L105
const storedReminders = localStorage.getItem('cui_reminders');
if (storedReminders) setReminders(JSON.parse(storedReminders));
```
#### 写入
```typescript
// 位置: App.tsx L131
useEffect(() => {
localStorage.setItem('cui_reminders', JSON.stringify(reminders));
}, [reminders]);
```
#### 新增提醒
```typescript
// 位置: App.tsx L158-160
const handleAddReminder = (reminder: BillReminder) => {
setReminders(prev => [...prev, reminder]);
};
```
#### 更新提醒
```typescript
// 位置: App.tsx L162-164
const handleUpdateReminder = (updated: BillReminder) => {
setReminders(prev => prev.map(r => r.id === updated.id ? updated : r));
};
```
#### 删除提醒
```typescript
// 位置: App.tsx L166-170
const handleDeleteReminder = (id: string) => {
if (confirm('确定要删除这个提醒吗?')) {
setReminders(prev => prev.filter(r => r.id !== id));
}
};
```
#### 切换完成状态
```typescript
// 位置: App.tsx L172-174
const handleToggleReminder = (id: string) => {
setReminders(prev => prev.map(r =>
r.id === id ? { ...r, isCompleted: !r.isCompleted } : r
));
};
```
### 剩余天数计算
```typescript
// 位置: BillReminders.tsx L14-38
export const getDaysRemaining = (r: BillReminder): number => {
const today = new Date();
today.setHours(0, 0, 0, 0);
let targetDate = new Date();
targetDate.setHours(0, 0, 0, 0);
if (r.frequency === 'monthly') {
const day = parseInt(r.targetDate);
targetDate.setDate(day);
// 如果本月已过期,移至下月
if (targetDate < today) {
targetDate.setMonth(targetDate.getMonth() + 1);
}
} else {
// 单次提醒
targetDate = new Date(r.targetDate);
targetDate.setHours(0, 0, 0, 0);
}
const diffTime = targetDate.getTime() - today.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
return diffDays;
};
```
### 使用场景
- 月度账单还款提醒(房贷、信用卡等)
- 一次性待办事项
- 日历视图显示提醒日期
- 账本页面待办列表
- 逾期提醒和倒计时显示
---
## 5. AI助手设置持久化
### 存储键名
```typescript
'cui_ai_settings'
```
### 数据类型
```typescript
AISettings
```
### 数据结构
```typescript
interface AISettings {
name: string; // AI助手名称
persona: string; // AI性格描述
avatar?: string; // 头像 (Base64编码图片,可选)
}
```
### 默认值
```typescript
// 位置: App.tsx L18-21
const DEFAULT_AI_SETTINGS: AISettings = {
name: "崔经理",
persona: "一个精明、毒舌但实际上很负责任的财务经理。说话喜欢带刺,吐槽用户乱花钱,但关键时刻会给出靠谱建议。"
};
```
### 操作接口
#### 读取
```typescript
// 位置: App.tsx L106
const storedAiSettings = localStorage.getItem('cui_ai_settings');
if (storedAiSettings) setAiSettings(JSON.parse(storedAiSettings));
```
#### 写入
```typescript
// 位置: App.tsx L132
useEffect(() => {
localStorage.setItem('cui_ai_settings', JSON.stringify(aiSettings));
}, [aiSettings]);
```
#### 更新设置
```typescript
// 修改名称
setAiSettings({ ...aiSettings, name: '新名字' });
// 修改性格
setAiSettings({ ...aiSettings, persona: '新的性格描述' });
// 更新头像
setAiSettings(prev => ({ ...prev, avatar: base64Data }));
```
### 预设性格列表
```typescript
// 位置: App.tsx L23-44
[
{
id: 'toxic',
name: '崔经理 (默认)',
description: '精明、毒舌、傲娇。喜欢吐槽你乱花钱...'
},
{
id: 'gentle',
name: '温柔管家',
description: '温柔、体贴、治愈。无论你怎么花钱都会温柔记录...'
},
{
id: 'strict',
name: '严厉会计',
description: '一丝不苟、冷酷无情。只关心数字...'
},
{
id: 'custom',
name: '自定义',
description: '可以根据你喜欢的方式设定助手的语气和风格'
}
]
```
### 使用场景
- 聊天界面显示AI名称和头像
- AI回复风格控制
- Gemini API调用时的persona参数
- 设置模态框显示和编辑
---
## 6. 自定义性格预设持久化
### 存储键名
```typescript
'cui_custom_preset_info'
```
### 数据类型
```typescript
{ name: string; description: string }
```
### 数据结构
```typescript
{
name: '自定义', // 自定义预设名称
description: '可以根据你喜欢的方式设定助手的语气和风格' // 描述
}
```
### 操作接口
#### 读取
```typescript
// 位置: App.tsx L107
const storedCustom = localStorage.getItem('cui_custom_preset_info');
if (storedCustom) setCustomPreset(JSON.parse(storedCustom));
```
#### 写入
```typescript
// 位置: App.tsx L133
useEffect(() => {
localStorage.setItem('cui_custom_preset_info', JSON.stringify(customPreset));
}, [customPreset]);
```
#### 自动保存自定义预设
```typescript
// 位置: App.tsx L286-292
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);
};
```
### 使用场景
- 保存用户自定义的AI性格设置
- 性格预设列表中显示自定义预设
- 区分预设性格和自定义性格
---
## 7. 主题设置持久化
### 存储键名
```typescript
'cui_card_theme'
```
### 数据类型
```typescript
string (数字索引)
```
### 数据值
```typescript
'0' | '1' | '2' | '3' | '4' // 对应5个主题的索引
```
### 主题列表
```typescript
// 位置: LedgerPage.tsx L20-26
const CARD_THEMES = [
{ id: 'dark-indigo', class: 'from-gray-900 via-indigo-950 to-black', themeColor: 'bg-indigo-600', name: '暗夜霓虹' },
{ id: 'sunset', class: 'from-orange-500 via-rose-600 to-pink-700', themeColor: 'bg-rose-600', name: '落日熔金' },
{ id: 'emerald', class: 'from-emerald-600 via-teal-700 to-cyan-900', themeColor: 'bg-emerald-600', name: '薄荷森林' },
{ id: 'royal', class: 'from-blue-700 via-indigo-800 to-indigo-950', themeColor: 'bg-blue-700', name: '皇家礼赞' },
{ id: 'rose', class: 'from-rose-500 via-pink-600 to-purple-800', themeColor: 'bg-purple-600', name: '浪漫巴黎' },
];
```
### 操作接口
#### 读取
```typescript
// 位置: LedgerPage.tsx L41
const [themeIndex, setThemeIndex] = useState(() =>
Number(localStorage.getItem('cui_card_theme')) || 0
);
```
#### 写入
```typescript
// 位置: LedgerPage.tsx L43-45
useEffect(() => {
localStorage.setItem('cui_card_theme', themeIndex.toString());
}, [themeIndex]);
```
#### 切换主题
```typescript
// 位置: LedgerPage.tsx L56
const cycleTheme = () => setThemeIndex((prev) => (prev + 1) % CARD_THEMES.length);
```
### 使用场景
- 账本页面顶部卡片主题
- 标签页按钮颜色主题
- 用户个性化界面体验
---
## 8. AI服务接口(非持久化,但涉及数据处理)
### Gemini API 调用接口
#### 服务文件
```typescript
services/geminiService.ts
```
#### 处理用户消息
```typescript
// 位置: geminiService.ts L67-115
export const processUserMessage = async (
message: string,
aiName: string,
aiPersona: string,
imageBase64?: string,
mimeType?: string
): Promise<ParseResult>
```
#### 返回结果
```typescript
// 位置: geminiService.ts L57-65
interface ParseResult {
transaction: {
item: string;
amount: number;
type: TransactionType;
category: string;
} | null;
reply: string;
}
```
#### API模型配置
```typescript
// 位置: geminiService.ts L4-7
const ai = new GoogleGenAI({ apiKey: process.env.API_KEY });
const MODEL_NAME = "gemini-3-flash-preview";
```
#### 系统指令模板
```typescript
// 位置: geminiService.ts L9-29
const getSystemInstruction = (aiName: string, aiPersona: string) => `
你现在的身份是"${aiName}"。
你的性格设定是:${aiPersona}
你的任务是:
1. 从用户的输入中提取交易信息(内容、金额、类别、交易类型)。
2. 判断是【支出】(expense) 还是【收入】(income)。
3. **严格回复规范**:
- 回复必须**极度简练**。
- 每一段话的字数必须控制在 **30字左右**(越短越好)。
- 全部的回复**最多只能分成 2 段**(中间用一个 \n 换行符隔开)。
- 必须在每一段中都加入**生动的表情包 (Emoji)** 🌈。
- 说话风格要完全符合"${aiName}"的设定。
`;
```
### 使用场景
- 语音/文字输入转交易记录
- 图片识别账单信息
- AI对话回复生成
- 自动分类和金额提取
---
## 数据流架构图
```mermaid
graph TB
subgraph 浏览器存储层
LS[localStorage]
LS --> T[cui_transactions]
LS --> M[cui_messages]
LS --> B[cui_budget]
LS --> R[cui_reminders]
LS --> AS[cui_ai_settings]
LS --> CP[cui_custom_preset_info]
LS --> TH[cui_card_theme]
end
subgraph React状态层
ST[State Management]
ST --> transactions[transactions]
ST --> messages[messages]
ST --> budgets[budgets]
ST --> reminders[reminders]
ST --> aiSettings[aiSettings]
ST --> customPreset[customPreset]
ST --> themeIndex[themeIndex]
end
subgraph 组件层
APP[App.tsx]
LEDGER[LedgerPage.tsx]
EDIT[EditTransactionModal.tsx]
REMIND[BillReminders.tsx]
CAL[CalendarView.tsx]
STATS[StatisticsPage.tsx]
end
subgraph 外部服务
GEMINI[Gemini API]
end
T <--> transactions
M <--> messages
B <--> budgets
R <--> reminders
AS <--> aiSettings
CP <--> customPreset
TH <--> themeIndex
transactions --> APP
transactions --> LEDGER
transactions --> STATS
transactions --> CAL
messages --> APP
reminders --> LEDGER
reminders --> REMIND
reminders --> CAL
APP --> GEMINI
GEMINI --> APP
APP --> EDIT
LEDGER --> EDIT
```
---
## 数据大小估算
### 单条记录大小
- **Transaction**: ~150 bytes
- **Message**: ~100 bytes
- **BillReminder**: ~120 bytes
- **AISettings**: ~200 bytes (不含头像) / ~5KB (含头像)
### 容量预估(localStorage限制 5-10MB)
- **1000条交易**: ~150KB
- **500条消息**: ~50KB
- **50个提醒**: ~6KB
- **AI设置**: ~5KB (含头像)
**总计**: 约 211KB (不含聊天历史图片)
### 容量优化建议
1. 定期归档历史数据(超过1年)
2. 限制聊天历史记录数量(如保留最近500条)
3. 图片压缩后再转Base64
4. 实现数据导出/导入功能
---
## 错误处理与兼容性
### JSON解析容错
```typescript
try {
const data = JSON.parse(localStorage.getItem('cui_transactions'));
setTransactions(data);
} catch (error) {
console.error('数据解析失败', error);
setTransactions([]); // 回退到默认值
}
```
### 版本兼容
- **预算数据**: 自动兼容旧版纯数字格式
- **新增字段**: 使用可选字段(如 BillReminder.amount?)
- **默认值**: 所有状态都有合理的初始值
### 数据校验建议
```typescript
// 类型守卫示例
function isValidTransaction(obj: any): obj is Transaction {
return (
typeof obj.id === 'string' &&
typeof obj.item === 'string' &&
typeof obj.amount === 'number' &&
['expense', 'income'].includes(obj.type) &&
typeof obj.date === 'string'
);
}
```
---
## 数据备份与恢复
### 导出数据(建议实现)
```typescript
function exportAllData() {
const data = {
transactions: JSON.parse(localStorage.getItem('cui_transactions') || '[]'),
messages: JSON.parse(localStorage.getItem('cui_messages') || '[]'),
budgets: JSON.parse(localStorage.getItem('cui_budget') || '{}'),
reminders: JSON.parse(localStorage.getItem('cui_reminders') || '[]'),
aiSettings: JSON.parse(localStorage.getItem('cui_ai_settings') || '{}'),
customPreset: JSON.parse(localStorage.getItem('cui_custom_preset_info') || '{}'),
themeIndex: localStorage.getItem('cui_card_theme') || '0',
exportDate: new Date().toISOString()
};
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `账本数据备份_${new Date().toISOString().slice(0,10)}.json`;
a.click();
}
```
### 导入数据(建议实现)
```typescript
function importAllData(file: File) {
const reader = new FileReader();
reader.onload = (e) => {
try {
const data = JSON.parse(e.target?.result as string);
localStorage.setItem('cui_transactions', JSON.stringify(data.transactions));
localStorage.setItem('cui_messages', JSON.stringify(data.messages));
localStorage.setItem('cui_budget', JSON.stringify(data.budgets));
localStorage.setItem('cui_reminders', JSON.stringify(data.reminders));
localStorage.setItem('cui_ai_settings', JSON.stringify(data.aiSettings));
localStorage.setItem('cui_custom_preset_info', JSON.stringify(data.customPreset));
localStorage.setItem('cui_card_theme', data.themeIndex);
alert('数据导入成功!请刷新页面');
window.location.reload();
} catch (error) {
alert('数据导入失败:' + error.message);
}
};
reader.readAsText(file);
}
```
---
## 安全性考虑
### 敏感数据
- **交易记录**: 包含个人财务信息
- **AI头像**: Base64图片数据
- **聊天记录**: 可能包含敏感对话
### 安全建议
1. **不存储**: Gemini API Key(使用环境变量)
2. **加密存储**: 考虑对敏感数据加密(如使用Web Crypto API)
3. **定期清理**: 提供清除所有数据的功能
4. **隐私模式**: 提示用户不要在公共设备上使用
### 清除数据示例
```typescript
function clearAllData() {
if (confirm('确定要清除所有数据吗?此操作不可恢复!')) {
localStorage.removeItem('cui_transactions');
localStorage.removeItem('cui_messages');
localStorage.removeItem('cui_budget');
localStorage.removeItem('cui_reminders');
localStorage.removeItem('cui_ai_settings');
localStorage.removeItem('cui_custom_preset_info');
localStorage.removeItem('cui_card_theme');
window.location.reload();
}
}
```
---
## 性能优化建议
### 1. 批量写入优化
```typescript
// 避免频繁写入,使用防抖
import { debounce } from 'lodash';
const debouncedSave = debounce((key: string, data: any) => {
localStorage.setItem(key, JSON.stringify(data));
}, 500);
```
### 2. 数据分页加载
```typescript
// 仅加载当前月份数据到UI
const currentMonthTransactions = transactions.filter(
t => t.date.startsWith(selectedMonth)
);
```
### 3. 索引优化
```typescript
// 创建日期索引,加速查询
const transactionsByDate = useMemo(() => {
return transactions.reduce((acc, t) => {
if (!acc[t.date]) acc[t.date] = [];
acc[t.date].push(t);
return acc;
}, {} as Record<string, Transaction[]>);
}, [transactions]);
```
---
## 总结
本文档详细列出了应用中所有7个持久化数据接口:
1. ✅ **交易记录** (cui_transactions) - 核心财务数据
2. ✅ **聊天消息** (cui_messages) - AI对话历史
3. ✅ **预算数据** (cui_budget) - 预算管理
4. ✅ **账单提醒** (cui_reminders) - 待办事项
5. ✅ **AI设置** (cui_ai_settings) - AI助手配置
6. ✅ **自定义预设** (cui_custom_preset_info) - 性格预设
7. ✅ **主题设置** (cui_card_theme) - 界面主题
所有接口均采用 **localStorage** 存储,使用 **JSON** 序列化,并通过 **React useEffect** 实现自动持久化。
---
**文档版本**: v1.0
**生成时间**: 2026-01-19
**最后更新**: 2026-01-19
**维护者**: 开发团队

104
docs/数据结构.md Normal file
View File

@@ -0,0 +1,104 @@
# App_02_记账
- 应用信息
- 应用名称
- 记账
- 英文标识
- bill
- 应用编号
- app_02
- 应用描述
- 一个精明、毒舌但实际上很负责任的财务经理。说话喜欢带刺,吐槽用户乱花钱,但关键时刻会给出靠谱建议。
- 数据结构
- Project 项目分类
- name 名称(name)
- desc 背景(context)
- target 目标(goal)
- value 价值(value)
- creator 创建人
- Member
- Task 任务项目
- title 标题(title)
- project 分类(projectId)
- Project
- desc 描述(notes)
- startDate 开始日期(date)
- startTime 开始时间(startTime)
- isCompleted 是否完成(isCompleted)
- created_at 创建时间(createdAt)
- creator 创建人
- Member
- API接口
- auth 授权
- POST /mp/auth/get_token
- 获取token
- GET /mp/auth/profile
- 获取个人信息
- 项目
- GET /mp/todo/projects
- 项目列表
- GET /mp/todo/projects/{project_id}
- 获取项目详细
- POST /mp/todo/projects
- 创建项目
- PUT /mp/todo/projects/{project_id}
- 更新项目
- DELETE /mp/todo/projects/{project_id}
- 删除项目
- 任务
- GET /mp/todo/tasks
- 任务列表
- GET /mp/todo/tasks/{task_id}
- 获取任务详细
- POST /mp/todo/tasks
- 创建任务
- PUT /mp/todo/tasks/{task_id}
- 更新任务
- DELETE /mp/todo/tasks/{task_id}
- 删除项目
- PUT /mp/todo/tasks/{task_id}/completed
- 设置为已完成
- PUT /mp/todo/tasks/{task_id}/uncompleted
- 设置为未完成
- 问题/需求
- 单用户转换为多用户模式
- 增加登录验证
- 增加creator字段
- 登录问题
- 小程序跳转到H5, 需要检测登录状态
- 如果已经登录, 需要把登录信息传递到H5首页
- 如果未登录跳转至小程序登录页
- URL传递参数openid和mobile
- H5端首页先检测token状态
- 如果存在并且未过期, 则携带token获取用户信息
- 如果不存在则通过openid和mobile获取token, 并获取用户信息
- 新建项目问题
- 目前是实时更新模式, 保存到本地缓存没问题, 但是要持久化到数据库就会报错
- 增加保存按钮, 点击保存后提交接口, 并同步本地数据
- 分别处理本地新建项目和提交接口新建项目的逻辑, 并处理错误提示, 例如项目名称重复
- 任务过滤查询
- 日期等于今天的任务
- 日期小于今日的任务
- 查询所有任务匹配到日历中
- 今日任务和日历中任务的匹配调节
- 测试任务2没有显示在今日任务中
- 最可能原因是日期转换过程中的时区差异。当API返回的日期时间字符串 "2026-01-14T00:00:00" 在客户端进行时区转换时,可能会因为客户端的时区设置导致日期变成 2026-01-13 或其他日期,从而不满足 t.date === systemDate 的条件。
- 多个在增删改查操作中存在的时区转换问题
- 开发任务
- 小程序跳转时增加登录判断和URL传参(openid和mobile)
- 1小时/人
- H5首页接收openid和mobile参数
- 0.5小时/人
- H5首页token检测并获取, 通过接口获取用户信息
- 1.5小时/人
- 开发H5端获取token接口
- 2小时/人
- 开发H5端获取用户信息接口
- 1.5小时/人
- 开发项目增删改查接口
- 4小时/人
- 开发任务增删改查接口和已完成/未完成接口
- 5小时/人
- 开发任务按时间过滤列表接口
- 2小时/人
- 调试H5页面交互, 调用项目和任务接口
- 6~10小时/人

52
index.html Normal file
View File

@@ -0,0 +1,52 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>崔经理记账</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;700;900&display=swap" rel="stylesheet">
<style>
body {
font-family: 'Noto Sans SC', sans-serif;
}
/* Hide scrollbar for Chrome, Safari and Opera */
.no-scrollbar::-webkit-scrollbar {
display: none;
}
/* Hide scrollbar for IE, Edge and Firefox */
.no-scrollbar {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
@keyframes float {
0% { transform: translateY(0px); }
50% { transform: translateY(-10px); }
100% { transform: translateY(0px); }
}
.animate-float {
animation: float 3s ease-in-out infinite;
}
</style>
<script type="importmap">
{
"imports": {
"react": "https://esm.sh/react@^19.2.3",
"react-dom/": "https://esm.sh/react-dom@^19.2.3/",
"react/": "https://esm.sh/react@^19.2.3/",
"@google/genai": "https://esm.sh/@google/genai@^1.33.0",
"uuid": "https://esm.sh/uuid@^13.0.0",
"recharts": "https://esm.sh/recharts@^2.12.0",
"lunar-javascript": "https://esm.sh/lunar-javascript@1.6.12"
}
}
</script>
<link rel="stylesheet" href="/index.css">
</head>
<body class="bg-gray-50 text-gray-900 antialiased h-screen overflow-hidden">
<div id="root" class="h-full"></div>
<script type="module" src="/index.tsx"></script>
</body>
</html>

15
index.tsx Normal file
View File

@@ -0,0 +1,15 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
const rootElement = document.getElementById('root');
if (!rootElement) {
throw new Error("Could not find root element to mount to");
}
const root = ReactDOM.createRoot(rootElement);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);

8
metadata.json Normal file
View File

@@ -0,0 +1,8 @@
{
"name": "账本(终版)",
"description": "智能语音记账助手,不仅能自动分类录入,还有一个毒舌的崔经理对你的消费习惯进行'关切'。",
"requestFramePermissions": [
"microphone",
"camera"
]
}

25
package.json Normal file
View File

@@ -0,0 +1,25 @@
{
"name": "账本(终版)",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.3",
"react-dom": "^19.2.3",
"@google/genai": "^1.33.0",
"uuid": "^13.0.0",
"recharts": "^2.12.0",
"lunar-javascript": "1.6.12"
},
"devDependencies": {
"@types/node": "^22.14.0",
"@vitejs/plugin-react": "^5.0.0",
"typescript": "~5.8.2",
"vite": "^6.2.0"
}
}

2000
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

121
services/geminiService.ts Normal file
View File

@@ -0,0 +1,121 @@
import { GoogleGenAI, Type } from "@google/genai";
import { ExpenseCategory, IncomeCategory, TransactionType } from "../types";
const ai = new GoogleGenAI({
apiKey: process.env.API_KEY,
httpOptions: {
baseUrl: process.env.GLM_API,
},
});
// 使用推荐的文本模型
const MODEL_NAME = "gemini-3-flash-preview";
const getSystemInstruction = (aiName: string, aiPersona: string) => `
你现在的身份是"${aiName}"。
你的性格设定是:${aiPersona}
你的任务是:
1. 从用户的输入中提取交易信息(内容、金额、类别、交易类型)。
2. 判断是【支出】(expense) 还是【收入】(income)。
3. **严格回复规范**:
- 回复必须**极度简练**。
- 每一段话的字数必须控制在 **30字左右**(越短越好)。
- 全部的回复**最多只能分成 2 段**(中间用一个 \\n 换行符隔开)。
- 必须在每一段中都加入**生动的表情包 (Emoji)** 🌈。
- 说话风格要完全符合"${aiName}"的设定。
**关键规则 - 模糊输入处理**:
- 如果用户只说了金额没说干嘛了,默认为“杂项”支出,并进行简短吐槽。
**交易类别参考**:
- 支出:餐饮、购物、交通、住房、娱乐、医疗、人情、学习、金融、孩子、宠物、汽车、美容、经营、杂项。
- 收入:工资、奖金、理财、兼职、红包、其他收入。
`;
const parseResponseSchema = {
type: Type.OBJECT,
properties: {
transaction: {
type: Type.OBJECT,
nullable: true,
description: "提取的交易详情",
properties: {
item: { type: Type.STRING },
amount: { type: Type.NUMBER },
type: {
type: Type.STRING,
enum: [TransactionType.EXPENSE, TransactionType.INCOME]
},
category: { type: Type.STRING },
},
required: ["item", "amount", "type", "category"],
},
reply: {
type: Type.STRING,
description: "管家的精简回复(每段约30字,最多2段),必须带表情包,用\\n换行",
},
},
required: ["reply"],
};
export interface ParseResult {
transaction: {
item: string;
amount: number;
type: TransactionType;
category: string;
} | null;
reply: string;
}
export const processUserMessage = async (
message: string,
aiName: string,
aiPersona: string,
imageBase64?: string,
mimeType?: string
): Promise<ParseResult> => {
try {
const parts: any[] = [];
if (imageBase64 && mimeType) {
parts.push({
inlineData: {
data: imageBase64,
mimeType: mimeType
}
});
if (!message) {
parts.push({ text: "请识别这张图片里的账单详情" });
}
}
if (message) {
parts.push({ text: message });
}
const response = await ai.models.generateContent({
model: MODEL_NAME,
contents: { parts },
config: {
systemInstruction: getSystemInstruction(aiName, aiPersona),
responseMimeType: "application/json",
responseSchema: parseResponseSchema,
temperature: 0.8,
},
});
const text = response.text;
if (!text) {
throw new Error("No response from Gemini");
}
return JSON.parse(text) as ParseResult;
} catch (error) {
console.error("Error processing message with Gemini:", error);
return {
transaction: null,
reply: `信号不好 📶\n你自己记一下吧!🙄`,
};
}
};

29
tsconfig.json Normal file
View File

@@ -0,0 +1,29 @@
{
"compilerOptions": {
"target": "ES2022",
"experimentalDecorators": true,
"useDefineForClassFields": false,
"module": "ESNext",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"skipLibCheck": true,
"types": [
"node"
],
"moduleResolution": "bundler",
"isolatedModules": true,
"moduleDetection": "force",
"allowJs": true,
"jsx": "react-jsx",
"paths": {
"@/*": [
"./*"
]
},
"allowImportingTsExtensions": true,
"noEmit": true
}
}

73
types.ts Normal file
View File

@@ -0,0 +1,73 @@
export enum TransactionType {
EXPENSE = 'expense',
INCOME = 'income',
}
export enum ExpenseCategory {
FOOD = '餐饮',
SHOPPING = '购物',
TRANSPORT = '交通',
HOUSING = '住房',
ENTERTAINMENT = '娱乐',
MEDICAL = '医疗',
SOCIAL = '人情',
EDUCATION = '学习',
FINANCE = '金融',
CHILD = '孩子',
PET = '宠物',
CAR = '汽车',
BEAUTY = '美容',
BUSINESS = '经营',
MISC = '杂项',
}
export enum IncomeCategory {
SALARY = '工资',
BONUS = '奖金',
INVESTMENT = '理财',
PARTTIME = '兼职',
GIFT = '红包',
OTHER = '其他收入',
}
export interface Transaction {
id: string;
item: string;
amount: number;
type: TransactionType;
category: string; // Takes values from ExpenseCategory or IncomeCategory
date: string; // ISO string YYYY-MM-DD
timestamp: number;
}
export interface Message {
id: string;
role: 'user' | 'assistant';
content: string;
timestamp: number;
relatedTransactionId?: string; // If this message created a transaction
}
export interface BillReminder {
id: string;
title: string;
amount?: number; // Made optional
frequency: 'monthly' | 'once';
targetDate: string; // 'DD' (e.g. "15") for monthly, or 'YYYY-MM-DD' for once
isCompleted: boolean;
}
// Global augmentation for Web Speech API
declare global {
interface Window {
SpeechRecognition: any;
webkitSpeechRecognition: any;
}
}
export interface DailyStat {
date: string;
total: number;
breakdown: { name: string; value: number; fill: string }[];
}

26
vite.config.ts Normal file
View File

@@ -0,0 +1,26 @@
import path from 'path';
import { defineConfig, loadEnv } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, '.', '');
return {
base: '/app_02/',
server: {
port: 3000,
host: '0.0.0.0',
},
plugins: [react()],
define: {
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY),
'process.env.GLM_API': JSON.stringify(env.GLM_API),
'process.env.API_KEY': JSON.stringify(env.API_KEY),
'process.env.API_URL': JSON.stringify(env.API_URL),
},
resolve: {
alias: {
'@': path.resolve(__dirname, '.'),
}
}
};
});