feat(app): 实现完整的财务管家记账聊天与统计功能
- 新增App主组件,实现聊天、账本、统计三大功能模块切换 - 支持语音及文本输入,自动解析记账信息并生成交易记录 - 交易记录支持编辑、删除、提醒等操作,数据本地持久化 - 管家性格预设和自定义设定,包含头像上传和语气调整 - 会话展示支持关联交易显示,样式区分收入与支出 - 账本页添加日期分组交易列表及饼图统计展示 - 消息列表实现渐变背景和交互动画,提升用户体验 - 支持图片上传并同步发送给AI进行解析记账 - 底部导航实现三标签切换并同步显示对应内容 - 全局事务编辑弹窗支持直接调用和删除操作 - 增加环境变量示例和.gitignore规则,完善工程配置
This commit is contained in:
6
.env.sample
Normal file
6
.env.sample
Normal 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
27
.gitignore
vendored
Normal 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
535
App.tsx
Normal 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
205
LedgerPage.tsx
Normal 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
20
README.md
Normal 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`
|
||||
247
components/BillReminders.tsx
Normal file
247
components/BillReminders.tsx
Normal 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
179
components/CalendarView.tsx
Normal 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
114
components/Dashboard.tsx
Normal 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;
|
||||
116
components/EditTransactionModal.tsx
Normal file
116
components/EditTransactionModal.tsx
Normal 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
208
components/LedgerPage.tsx
Normal 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;
|
||||
274
components/StatisticsPage.tsx
Normal file
274
components/StatisticsPage.tsx
Normal 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
104
components/VoiceInput.tsx
Normal 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;
|
||||
1686
docs/后端API接口设计文档.md
Normal file
1686
docs/后端API接口设计文档.md
Normal file
File diff suppressed because it is too large
Load Diff
907
docs/数据持久化接口文档.md
Normal file
907
docs/数据持久化接口文档.md
Normal 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
104
docs/数据结构.md
Normal 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
52
index.html
Normal 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
15
index.tsx
Normal 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
8
metadata.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "账本(终版)",
|
||||
"description": "智能语音记账助手,不仅能自动分类录入,还有一个毒舌的崔经理对你的消费习惯进行'关切'。",
|
||||
"requestFramePermissions": [
|
||||
"microphone",
|
||||
"camera"
|
||||
]
|
||||
}
|
||||
25
package.json
Normal file
25
package.json
Normal 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
2000
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
121
services/geminiService.ts
Normal file
121
services/geminiService.ts
Normal 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
29
tsconfig.json
Normal 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
73
types.ts
Normal 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
26
vite.config.ts
Normal 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, '.'),
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user