feat: 新增预算详情模态框、交易表单、小猪罐功能,并支持生成预算洞察。

This commit is contained in:
2026-01-28 21:44:47 +08:00
parent 2d84f66bdc
commit e163fadd01
14 changed files with 1592 additions and 210 deletions

View File

@@ -0,0 +1,342 @@
/**
* BudgetDetailModal Styles
*/
.budget-detail-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
}
.budget-detail-modal {
background-color: var(--color-bg);
border-radius: 16px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
width: 100%;
max-width: 600px;
max-height: 85vh;
display: flex;
flex-direction: column;
overflow: hidden;
animation: modalFadeIn 0.2s ease-out;
}
@keyframes modalFadeIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
.budget-detail-modal__header {
padding: 1.5rem;
border-bottom: 1px solid var(--color-border);
display: flex;
justify-content: space-between;
align-items: center;
background-color: var(--color-bg-secondary);
}
.budget-detail-modal__title-section {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.budget-detail-modal__title {
margin: 0;
font-size: 1.25rem;
font-weight: 700;
color: var(--color-text);
}
.budget-detail-modal__period {
font-size: 0.875rem;
color: var(--color-text-secondary);
}
.budget-detail-modal__close {
background: none;
border: none;
cursor: pointer;
color: var(--color-text-secondary);
padding: 0.5rem;
border-radius: 50%;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
}
.budget-detail-modal__close:hover {
background-color: rgba(0, 0, 0, 0.05);
color: var(--color-text);
}
.budget-detail-modal__content {
flex: 1;
overflow-y: auto;
padding: 1.5rem;
}
.budget-detail-modal__summary {
display: flex;
justify-content: space-between;
margin-bottom: 2rem;
padding: 1rem;
background: var(--color-bg-secondary);
border-radius: 12px;
border: 1px solid var(--color-border);
}
.budget-detail-modal__stat {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
flex: 1;
}
.budget-detail-modal__stat-label {
font-size: 0.75rem;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.budget-detail-modal__stat-value {
font-size: 1.125rem;
font-weight: 700;
color: var(--color-text);
font-family: 'Outfit', sans-serif;
}
.budget-detail-modal__stat-value--spent {
color: var(--color-primary);
}
.budget-detail-modal__stat-value--remaining {
color: var(--color-success);
}
.budget-detail-modal__stat-value--negative {
color: var(--color-error);
}
.budget-detail-modal__stat-divider {
width: 1px;
background-color: var(--color-border);
height: 40px;
align-self: center;
}
.budget-detail-modal__transactions h4 {
margin: 0 0 1rem 0;
font-size: 1rem;
color: var(--color-text);
display: flex;
align-items: center;
gap: 0.5rem;
}
.budget-detail-modal__list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.budget-detail-modal__empty {
text-align: center;
padding: 2rem;
color: var(--color-text-secondary);
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
}
.budget-detail-modal__loading {
display: flex;
justify-content: center;
padding: 2rem;
}
.budget-spinner {
width: 24px;
height: 24px;
border: 2px solid var(--color-border);
border-top-color: var(--color-primary);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Chart */
.budget-detail-modal__chart {
margin-bottom: 1.5rem;
padding: 1rem;
background: var(--color-bg);
border-radius: 12px;
border: 1px dashed var(--color-border);
}
.budget-detail-modal__chart-bars {
display: flex;
align-items: flex-end;
justify-content: space-around;
height: 100px;
gap: 0.5rem;
padding-bottom: 0.5rem;
}
.budget-detail-modal__chart-col {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
height: 100%;
justify-content: flex-end;
gap: 0.25rem;
}
.budget-detail-modal__chart-bar {
width: 80%;
max-width: 20px;
background: linear-gradient(180deg, var(--color-primary-light) 0%, var(--color-primary) 100%);
border-radius: 4px;
min-height: 4px;
opacity: 0.8;
transition: all 0.2s;
}
.budget-detail-modal__chart-bar:hover {
opacity: 1;
transform: scaleY(1.05);
}
.budget-detail-modal__quick-add-btn {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.375rem 0.75rem;
background-color: rgba(var(--color-primary-rgb), 0.1);
color: var(--color-primary);
border: 1px solid transparent;
border-radius: 999px;
font-size: 0.75rem;
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.budget-detail-modal__quick-add-btn:hover {
background-color: var(--color-primary);
color: white;
transform: translateY(-1px);
}
/* AI Insight */
.budget-detail-modal__ai-insight {
display: flex;
gap: 0.75rem;
padding: 1rem;
margin-bottom: 1.5rem;
border-radius: 12px;
background: var(--glass-panel-bg);
border: 1px solid transparent;
align-items: flex-start;
transition: all 0.3s ease;
}
.budget-detail-modal__ai-insight--normal {
background: rgba(var(--color-primary-rgb), 0.05);
border-color: rgba(var(--color-primary-rgb), 0.1);
}
.budget-detail-modal__ai-insight--normal .budget-detail-modal__ai-icon {
color: var(--color-primary);
}
.budget-detail-modal__ai-insight--warning {
background: rgba(245, 158, 11, 0.05);
border-color: rgba(245, 158, 11, 0.2);
}
.budget-detail-modal__ai-insight--warning .budget-detail-modal__ai-icon {
color: #F59E0B;
}
.budget-detail-modal__ai-insight--danger {
background: rgba(239, 68, 68, 0.05);
border-color: rgba(239, 68, 68, 0.2);
}
.budget-detail-modal__ai-insight--danger .budget-detail-modal__ai-icon {
color: #EF4444;
}
.budget-detail-modal__ai-insight--success {
background: rgba(16, 185, 129, 0.05);
border-color: rgba(16, 185, 129, 0.2);
}
.budget-detail-modal__ai-insight--success .budget-detail-modal__ai-icon {
color: #10B981;
}
.budget-detail-modal__ai-insight--info {
background: rgba(59, 130, 246, 0.05);
border-color: rgba(59, 130, 246, 0.2);
}
.budget-detail-modal__ai-insight--info .budget-detail-modal__ai-icon {
color: #3B82F6;
}
.budget-detail-modal__ai-icon {
margin-top: 2px;
display: flex;
align-items: center;
justify-content: center;
}
.budget-detail-modal__ai-content {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.budget-detail-modal__ai-title {
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
opacity: 0.8;
}
.budget-detail-modal__ai-text {
font-size: 0.9rem;
line-height: 1.5;
color: var(--color-text);
margin: 0;
}

View File

@@ -0,0 +1,383 @@
/**
* BudgetDetailModal Component
* Shows details and transaction history for a specific budget
*/
import React, { useEffect, useState } from 'react';
import { Icon } from '@iconify/react';
import type { Budget, Transaction } from '../../../types';
import { getTransactions } from '../../../services/transactionService';
import TransactionItem from '../../transaction/TransactionItem/TransactionItem';
import { formatCurrency } from '../../../utils/format';
import { getPeriodTypeLabel } from '../../../services/budgetService';
import './BudgetDetailModal.css';
interface BudgetDetailModalProps {
budget: Budget;
onClose: () => void;
onEdit?: () => void;
onAddTransaction?: () => void;
refreshTrigger?: number;
categoryName?: string;
}
const toLocalDateString = (date: Date) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
const getPeriodDates = (periodType: string, customStartDate?: string) => {
const now = new Date();
// Default logic
let start = new Date(now);
let end = new Date(now);
start.setHours(0, 0, 0, 0);
end.setHours(23, 59, 59, 999);
if (periodType === 'daily') {
// Current day
} else if (periodType === 'weekly') {
// Start of week (Monday)
const day = start.getDay() || 7; // Sunday is 0, make it 7
start.setDate(start.getDate() - day + 1);
end.setDate(start.getDate() + 6);
} else if (periodType === 'monthly') {
let startDay = 1;
if (customStartDate) {
const d = new Date(customStartDate);
if (!isNaN(d.getTime())) startDay = d.getDate();
}
if (now.getDate() >= startDay) {
start.setDate(startDay);
end = new Date(start);
end.setMonth(end.getMonth() + 1);
end.setDate(startDay - 1);
} else {
start.setMonth(start.getMonth() - 1);
start.setDate(startDay);
end = new Date(start);
end.setMonth(end.getMonth() + 1);
end.setDate(startDay - 1);
}
} else if (periodType === 'yearly') {
start.setMonth(0, 1);
end.setMonth(11, 31);
}
return {
startDate: toLocalDateString(start),
endDate: toLocalDateString(end)
};
};
const generateInsight = (budget: Budget, transactions: Transaction[], startDate: string, endDate: string, categoryName?: string) => {
// 1. Basic Metrics
const total = budget.amount;
const spent = budget.spent || 0;
const remaining = total - spent;
const ratio = spent / total; // % used
const txCount = transactions.length;
// 2. Time Metrics
const start = new Date(startDate).getTime();
const end = new Date(endDate).getTime();
const now = new Date().getTime();
const totalDays = Math.max(1, (end - start) / (1000 * 60 * 60 * 24));
const daysPassed = Math.max(1, (now - start) / (1000 * 60 * 60 * 24));
const daysRemaining = Math.max(0, totalDays - daysPassed);
const timeRatio = daysPassed / totalDays; // % time passed
// 3. Analytics & Projections
const dailyAverage = spent / daysPassed;
const projectedTotal = dailyAverage * totalDays;
const projectedOverspend = projectedTotal - total;
const runwayDays = dailyAverage > 0 ? remaining / dailyAverage : 999;
// 4. Composition Analysis
const avgTxAmount = spent / (txCount || 1);
// "Latte Factor": Many small transactions (< 2% of budget)
const smallTxCount = transactions.filter(t => t.amount < total * 0.02).length;
const isNickelAndDimed = (smallTxCount / txCount > 0.7) && (spent > total * 0.4);
// "Whale": Few large transactions (> 20% of budget)
const largeTx = transactions.filter(t => t.amount > total * 0.2);
const isWhaleMoved = largeTx.length > 0 && (largeTx.reduce((s, t) => s + t.amount, 0) > spent * 0.6);
const categoryPrefix = categoryName ? `${categoryName}` : '';
// --- Decision Logic ---
// Case 0: No spending
if (spent === 0) {
if (daysPassed > totalDays * 0.5) {
return { type: 'success', text: `哇!周期过半${categoryPrefix}还没有任何支出?真是省钱大神!继续保持!🏆` };
}
return { type: 'normal', text: `新周期开始,预算充足。建议规划好每日可用额度约 ${formatCurrency(total / totalDays, 'CNY')}。📅` };
}
// Case 1: Budget Exceeded
if (remaining <= 0) {
return { type: 'danger', text: `🚨 ${categoryPrefix}预算已耗尽!目前超支 ${formatCurrency(Math.abs(remaining), 'CNY')}。请立即停止非必要消费!` };
}
// Case 2: Critical condition
if (runwayDays < daysRemaining - 3) { // 3 days buffer
const daysShort = Math.round(daysRemaining - runwayDays);
return {
type: 'danger',
text: `⚠️ 红色预警:按目前速度,${categoryPrefix}预算将在 ${Math.round(runwayDays)} 天后提前耗尽。请务必大幅削减开支!`
};
}
// Case 3: Pattern detection - Nickel and Dimed
if (patternMatched(isNickelAndDimed, 'warning')) {
return {
type: 'info',
text: `🐜 蚂蚁搬家式消费!虽然单笔不贵,但已累计 ${smallTxCount} 笔小额支出。警惕“拿铁效应”,这些琐碎开支正在悄悄吃掉您的预算。`
};
}
// Case 4: Pattern detection - Whale
if (patternMatched(isWhaleMoved, 'info')) {
return {
type: 'info',
text: `🐋 巨鲸出没!仅 ${largeTx.length} 笔大额支出就占据了绝大部分开销。控制好大件消费是守住预算的关键。`
};
}
// Case 5: Projected to Overspend
if (projectedTotal > total * 1.05) { // 5% tolerance
return {
type: 'warning',
text: `📉 趋势不妙:虽然现在还有余额,但预估期末将超支 ${formatCurrency(projectedOverspend, 'CNY')}。建议将日均消费控制在 ${formatCurrency(remaining / daysRemaining, 'CNY')} 以内。`
};
}
// Case 6: Early Spree
if (ratio > 0.4 && timeRatio < 0.2) {
return {
type: 'warning',
text: `🔥 开局太猛了!刚开始就花了近一半预算。如果是为了囤货或交租那没问题,否则需要“急刹车”了。`
};
}
// Case 7: Healthy
if (projectedTotal < total * 0.85) {
const projectedSavings = total - projectedTotal;
return {
type: 'success',
text: `✨ 表现完美!按当前节奏,${categoryPrefix}期末预计能结余 ${formatCurrency(projectedSavings, 'CNY')}。继续保持这份从容!`
};
}
return {
type: 'normal',
text: `✅ 一切尽在掌握。当前消费节奏稳健,继续保持,本周期安全无忧。`
};
};
// Helper to avoid TS errors or cleaner logic usage
function patternMatched(condition: boolean, type: string) {
return condition;
}
export const BudgetDetailModal: React.FC<BudgetDetailModalProps> = ({
budget,
onClose,
onEdit,
onAddTransaction,
refreshTrigger = 0,
categoryName
}) => {
const [transactions, setTransactions] = useState<Transaction[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const loadTransactions = async () => {
setIsLoading(true);
try {
// Determine date range based on budget period
const { startDate, endDate } = getPeriodDates(budget.periodType, budget.startDate);
const response = await getTransactions({
startDate,
endDate,
categoryId: budget.categoryId,
accountId: budget.accountId,
type: 'expense', // Budgets are usually for expenses
pageSize: 50 // Limit to reasonable number
});
setTransactions(response.items);
} catch (error) {
console.error('Failed to load budget transactions:', error);
} finally {
setIsLoading(false);
}
};
loadTransactions();
}, [budget, refreshTrigger]);
const remaining = (budget.amount || 0) - (budget.spent || 0);
return (
<div className="budget-detail-modal-overlay" onClick={onClose}>
<div className="budget-detail-modal" onClick={e => e.stopPropagation()}>
<div className="budget-detail-modal__header">
<div className="budget-detail-modal__title-section">
<h2 className="budget-detail-modal__title">{budget.name}</h2>
<span className="budget-detail-modal__period">
{getPeriodTypeLabel(budget.periodType)}
</span>
</div>
<div className="flex gap-2">
{onEdit && (
<button
className="budget-detail-modal__close"
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
title="编辑预算"
>
<Icon icon="solar:pen-new-square-bold-duotone" width="20" />
</button>
)}
<button className="budget-detail-modal__close" onClick={onClose} title="关闭">
<Icon icon="solar:close-circle-bold-duotone" width="24" />
</button>
</div>
</div>
<div className="budget-detail-modal__content custom-scrollbar">
<div className="budget-detail-modal__summary">
<div className="budget-detail-modal__stat">
<span className="budget-detail-modal__stat-label"></span>
<span className="budget-detail-modal__stat-value">
{formatCurrency(budget.amount, 'CNY')}
</span>
</div>
<div className="budget-detail-modal__stat-divider" />
<div className="budget-detail-modal__stat">
<span className="budget-detail-modal__stat-label">使</span>
<span className="budget-detail-modal__stat-value budget-detail-modal__stat-value--spent">
{formatCurrency(budget.spent || 0, 'CNY')}
</span>
<span className="text-xs text-gray-400 font-normal">
{((budget.spent || 0) / budget.amount * 100).toFixed(0)}%
</span>
</div>
<div className="budget-detail-modal__stat-divider" />
<div className="budget-detail-modal__stat">
<span className="budget-detail-modal__stat-label"></span>
<span className={`budget-detail-modal__stat-value ${remaining < 0 ? 'budget-detail-modal__stat-value--negative' : 'budget-detail-modal__stat-value--remaining'}`}>
{formatCurrency(remaining, 'CNY')}
</span>
</div>
</div>
{/* AI Insight */}
{(() => {
const insight = generateInsight(budget, transactions,
getPeriodDates(budget.periodType, budget.startDate).startDate,
getPeriodDates(budget.periodType, budget.startDate).endDate,
categoryName
);
return (
<div className={`budget-detail-modal__ai-insight budget-detail-modal__ai-insight--${insight.type}`}>
<div className="budget-detail-modal__ai-icon">
<Icon icon="solar:magic-stick-3-bold-duotone" width="20" />
</div>
<div className="budget-detail-modal__ai-content">
<span className="budget-detail-modal__ai-title">AI </span>
<p className="budget-detail-modal__ai-text">{insight.text}</p>
</div>
</div>
);
})()}
{/* Simple Trend Chart */}
{!isLoading && transactions.length > 0 && (
<div className="budget-detail-modal__chart">
<div className="budget-detail-modal__chart-bars">
{(() => {
// Calculate daily layout
const dailyMap = new Map<string, number>();
transactions.forEach(t => {
const d = t.transactionDate.split('T')[0].slice(5); // MM-DD
dailyMap.set(d, (dailyMap.get(d) || 0) + t.amount);
});
const dailyData = Array.from(dailyMap.entries())
.map(([date, amount]) => ({ date, amount }))
.sort((a, b) => a.date.localeCompare(b.date));
// Limit to last 7 days or substantial days if too many
const chartData = dailyData.slice(-14);
const maxVal = Math.max(...chartData.map(d => d.amount), 1);
return chartData.map(d => (
<div key={d.date} className="budget-detail-modal__chart-col">
<div
className="budget-detail-modal__chart-bar"
style={{ height: `${(d.amount / maxVal) * 100}%` }}
title={`${d.date}: ${formatCurrency(d.amount, 'CNY')}`}
/>
<span className="budget-detail-modal__chart-label">{d.date}</span>
</div>
));
})()}
</div>
</div>
)}
<div className="budget-detail-modal__transactions">
<div className="flex justify-between items-center mb-4">
<h4>
<Icon icon="solar:bill-list-bold-duotone" width="18" />
</h4>
{onAddTransaction && (
<button
className="budget-detail-modal__quick-add-btn"
onClick={onAddTransaction}
>
<Icon icon="solar:add-circle-bold-duotone" width="16" />
</button>
)}
</div>
{isLoading ? (
<div className="budget-detail-modal__loading">
<div className="budget-spinner" />
</div>
) : transactions.length > 0 ? (
<div className="budget-detail-modal__list">
{transactions.map(transaction => (
<TransactionItem
key={transaction.id}
transaction={transaction}
showDate={true}
/>
))}
</div>
) : (
<div className="budget-detail-modal__empty">
<Icon icon="solar:clipboard-list-linear" width="48" />
<p></p>
</div>
)}
</div>
</div>
</div>
</div>
);
};
export default BudgetDetailModal;

View File

@@ -35,9 +35,11 @@ export const PiggyBankCard: React.FC<PiggyBankCardProps> = ({
}) => {
const [showActions, setShowActions] = useState(false);
const typeLabel = getPiggyBankTypeLabel(piggyBank.type);
const remaining = piggyBank.targetAmount - piggyBank.currentAmount;
const progress = piggyBank.targetAmount > 0
? Math.min((piggyBank.currentAmount / piggyBank.targetAmount) * 100, 100)
const currentAmount = piggyBank.currentAmount || 0;
const targetAmount = piggyBank.targetAmount || 0;
const remaining = targetAmount - currentAmount;
const progress = targetAmount > 0
? Math.min((currentAmount / targetAmount) * 100, 100)
: 0;
const isCompleted = progress >= 100;
const daysRemaining = calculateDaysRemaining(piggyBank.targetDate);
@@ -95,9 +97,9 @@ export const PiggyBankCard: React.FC<PiggyBankCardProps> = ({
<div className="piggy-bank-card__title-section">
<div className="piggy-bank-card__icon">
{isCompleted ? (
<Icon icon="solar:cup-star-bold-duotone" width="28" />
<Icon icon="solar:cup-first-bold-duotone" width="28" />
) : (
<Icon icon="solar:piggy-bank-bold-duotone" width="28" />
<Icon icon="solar:pig-money-bold-duotone" width="28" />
)}
</div>
<div className="piggy-bank-card__title-info">
@@ -146,7 +148,16 @@ export const PiggyBankCard: React.FC<PiggyBankCardProps> = ({
<div className="piggy-bank-card__progress-track">
<div
className={`piggy-bank-card__progress-fill ${isCompleted ? 'piggy-bank-card__progress-fill--completed' : ''}`}
style={{ width: `${progress}%` }}
style={{
width: `${progress}%`,
background: isCompleted
? undefined
: progress < 25
? 'linear-gradient(90deg, #EF4444 0%, #F87171 100%)' // Red for low progress
: progress < 75
? 'linear-gradient(90deg, #F97316 0%, #FDBA74 100%)' // Orange for medium
: 'linear-gradient(90deg, #10B981 0%, #34D399 100%)' // Green for high
}}
>
<div className="piggy-bank-card__progress-glow"></div>
</div>

View File

@@ -81,6 +81,52 @@
color: var(--color-text-secondary);
}
.piggy-bank-transaction-modal__source-account {
margin-top: 1rem;
padding: 0.75rem;
background-color: var(--color-bg);
border-radius: 8px;
border: 1px dashed var(--color-border);
display: flex;
justify-content: space-between;
align-items: center;
}
.piggy-bank-transaction-modal__source-account.warning {
background-color: #FEF3C7;
border-color: #F59E0B;
flex-direction: column;
gap: 0.25rem;
align-items: flex-start;
}
.piggy-bank-transaction-modal__source-label {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
color: var(--color-text-secondary);
font-weight: 500;
}
.piggy-bank-transaction-modal__source-details {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.25rem;
}
.piggy-bank-transaction-modal__source-preview {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.75rem;
color: var(--color-success);
background: rgba(34, 197, 94, 0.1);
padding: 0 0.375rem;
border-radius: 4px;
}
.piggy-bank-transaction-modal__form {
padding: 1.5rem;
display: flex;
@@ -143,6 +189,79 @@
margin-top: -0.25rem;
}
/* Progress Preview */
.piggy-bank-transaction-modal__progress-container {
margin-top: 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.piggy-bank-transaction-modal__progress-labels {
display: flex;
justify-content: space-between;
font-size: 0.75rem;
color: var(--color-text-secondary);
}
.piggy-bank-transaction-modal__progress-val {
font-weight: 600;
color: var(--color-text);
display: flex;
gap: 0.25rem;
}
.piggy-bank-transaction-modal__progress-preview {
color: var(--color-primary);
}
.piggy-bank-transaction-modal__progress-track {
position: relative;
height: 8px;
background-color: var(--color-border);
border-radius: 4px;
overflow: hidden;
}
.piggy-bank-transaction-modal__progress-fill {
position: absolute;
top: 0;
left: 0;
height: 100%;
background-color: var(--color-primary);
border-radius: 4px;
}
.piggy-bank-transaction-modal__progress-projected {
position: absolute;
top: 0;
height: 100%;
background-color: var(--color-success);
opacity: 0.6;
border-radius: 0 4px 4px 0;
background-image: linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);
background-size: 1rem 1rem;
animation: progress-stripe 1s linear infinite;
}
.piggy-bank-transaction-modal__progress-withdraw {
position: absolute;
top: 0;
height: 100%;
background-color: var(--color-error);
opacity: 0.8;
}
@keyframes progress-stripe {
from {
background-position: 1rem 0;
}
to {
background-position: 0 0;
}
}
.piggy-bank-transaction-modal__quick-amounts {
display: grid;
grid-template-columns: repeat(4, 1fr);
@@ -270,4 +389,4 @@
.piggy-bank-transaction-modal__button {
width: 100%;
}
}
}

View File

@@ -4,8 +4,9 @@
*/
import React, { useState } from 'react';
import type { PiggyBank } from '../../../types';
import type { PiggyBank, Account } from '../../../types';
import { formatCurrency } from '../../../utils/format';
import { Icon } from '@iconify/react';
import './PiggyBankTransactionModal.css';
interface PiggyBankTransactionModalProps {
@@ -13,6 +14,7 @@ interface PiggyBankTransactionModalProps {
type: 'deposit' | 'withdraw';
onSubmit: (amount: number, note?: string) => void;
onCancel: () => void;
linkedAccount?: Account; // Added prop
isLoading?: boolean;
}
@@ -21,6 +23,7 @@ export const PiggyBankTransactionModal: React.FC<PiggyBankTransactionModalProps>
type,
onSubmit,
onCancel,
linkedAccount,
isLoading = false,
}) => {
const [amount, setAmount] = useState<string>('');
@@ -54,6 +57,11 @@ export const PiggyBankTransactionModal: React.FC<PiggyBankTransactionModalProps>
return;
}
if (isDeposit && linkedAccount && numAmount > linkedAccount.balance) {
setError(`账户余额不足 (可用: ${formatCurrency(linkedAccount.balance, linkedAccount.currency)})`);
return;
}
onSubmit(numAmount, note.trim() || undefined);
};
@@ -100,6 +108,49 @@ export const PiggyBankTransactionModal: React.FC<PiggyBankTransactionModalProps>
: {formatCurrency(piggyBank.currentAmount, 'CNY')}
</span>
</div>
{piggyBank.targetAmount > 0 && (
<div className="piggy-bank-transaction-modal__progress-container">
<div className="piggy-bank-transaction-modal__progress-labels">
<span></span>
<span className="piggy-bank-transaction-modal__progress-val">
{Math.min((piggyBank.currentAmount / piggyBank.targetAmount) * 100, 100).toFixed(1)}%
{amount && !isNaN(parseFloat(amount)) && parseFloat(amount) > 0 && (
<span className="piggy-bank-transaction-modal__progress-preview">
{' '} {Math.min(((piggyBank.currentAmount + (isDeposit ? parseFloat(amount) : -parseFloat(amount))) / piggyBank.targetAmount) * 100, 100).toFixed(1)}%
</span>
)}
</span>
</div>
<div className="piggy-bank-transaction-modal__progress-track">
{/* Base Progress */}
<div
className="piggy-bank-transaction-modal__progress-fill"
style={{ width: `${Math.min((piggyBank.currentAmount / piggyBank.targetAmount) * 100, 100)}%` }}
/>
{/* Projected Add/Remove (Visualized only for deposit for simplicity in MVP, or handle both) */}
{amount && !isNaN(parseFloat(amount)) && parseFloat(amount) > 0 && isDeposit && (
<div
className="piggy-bank-transaction-modal__progress-projected"
style={{
left: `${Math.min((piggyBank.currentAmount / piggyBank.targetAmount) * 100, 100)}%`,
width: `${Math.min((parseFloat(amount) / piggyBank.targetAmount) * 100, 100 - (piggyBank.currentAmount / piggyBank.targetAmount) * 100)}%`
}}
/>
)}
{/* Projected Withdraw */}
{amount && !isNaN(parseFloat(amount)) && parseFloat(amount) > 0 && !isDeposit && (
<div
className="piggy-bank-transaction-modal__progress-withdraw"
style={{
left: `${Math.max(0, Math.min(((piggyBank.currentAmount - parseFloat(amount)) / piggyBank.targetAmount) * 100, 100))}%`,
width: `${Math.min((parseFloat(amount) / piggyBank.targetAmount) * 100, (piggyBank.currentAmount / piggyBank.targetAmount) * 100)}%`
}}
/>
)}
</div>
</div>
)}
</div>
<form className="piggy-bank-transaction-modal__form" onSubmit={handleSubmit}>
@@ -124,8 +175,49 @@ export const PiggyBankTransactionModal: React.FC<PiggyBankTransactionModalProps>
{error && <span className="piggy-bank-transaction-modal__error">{error}</span>}
</div>
{linkedAccount ? (
<div className="piggy-bank-transaction-modal__source-account">
<div className="piggy-bank-transaction-modal__source-label">
<Icon
icon={isDeposit ? "solar:card-transfer-bold-duotone" : "solar:card-receive-bold-duotone"}
width="16"
/>
<span>{isDeposit ? '资金来源:' : '资金退回至:'}</span>
</div>
<div className="piggy-bank-transaction-modal__source-details">
<div className="piggy-bank-transaction-modal__source-value">
<span className="font-medium text-gray-700">{linkedAccount.name}</span>
<span className="text-xs text-gray-500 ml-2 privacy-mask">
(: {formatCurrency(linkedAccount.balance, linkedAccount.currency)})
</span>
</div>
{amount && !isNaN(parseFloat(amount)) && (
<div className="piggy-bank-transaction-modal__source-preview">
<Icon icon="solar:arrow-right-linear" width="12" />
<span className="privacy-mask">
{isDeposit
? formatCurrency(linkedAccount.balance - parseFloat(amount), linkedAccount.currency)
: formatCurrency(linkedAccount.balance + parseFloat(amount), linkedAccount.currency)
}
</span>
</div>
)}
</div>
</div>
) : (
<div className="piggy-bank-transaction-modal__source-account warning">
<Icon icon="solar:danger-circle-bold-duotone" width="16" className="text-amber-500" />
<div className="flex flex-col">
<span className="text-xs font-semibold text-amber-700"></span>
<span className="text-xs text-amber-600">
{isDeposit ? '将作为额外收入存入' : '将作为额外支出取出'}
</span>
</div>
</div>
)}
{quickAmounts.length > 0 && (
<div className="piggy-bank-transaction-modal__quick-amounts">
<div className="piggy-bank-transaction-modal__quick-amounts mt-4">
{quickAmounts.map((value) => (
<button
key={value}

View File

@@ -7,3 +7,4 @@ export { PiggyBankTransactionModal } from './PiggyBankTransactionModal';
export { AllocationRuleForm } from './AllocationRuleForm';
export { AllocationSuggestion } from './AllocationSuggestion';
export { default as PiggyBankIntro } from './PiggyBankIntro';
export { default as BudgetDetailModal } from './BudgetDetailModal/BudgetDetailModal';

View File

@@ -0,0 +1,162 @@
.daily-insight-card {
background: var(--glass-panel-bg);
backdrop-filter: blur(24px);
border: 1px solid var(--glass-border);
border-radius: var(--radius-xl);
padding: 1.25rem;
margin-bottom: 2rem;
box-shadow: var(--shadow-md);
animation: slideUp 0.5s ease-out;
display: flex;
flex-direction: column;
gap: 1rem;
position: relative;
overflow: hidden;
}
.daily-insight-card--ai::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(var(--color-primary-rgb), 0.5), transparent);
animation: scan 3s infinite linear;
}
@keyframes scan {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
}
.daily-insight__header {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--color-primary);
font-weight: 700;
font-size: 0.9rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.daily-insight__content {
display: grid;
grid-template-columns: 1fr 1px 1fr;
gap: 1rem;
align-items: center;
}
.daily-insight__divider {
width: 1px;
height: 40px;
background: var(--color-border);
}
.daily-insight__section {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.daily-insight__title {
font-size: 0.8rem;
color: var(--color-text-secondary);
font-weight: 600;
}
.daily-insight__text {
font-size: 0.95rem;
color: var(--color-text);
line-height: 1.4;
}
.daily-insight__highlight {
font-weight: 700;
color: var(--color-primary);
}
.daily-insight__highlight--success {
color: var(--color-success);
}
.daily-insight__highlight--warning {
color: #F59E0B;
}
.daily-insight__highlight--danger {
color: #EF4444;
}
@media (max-width: 640px) {
.daily-insight__content {
grid-template-columns: 1fr;
grid-template-rows: auto auto auto;
gap: 1rem;
}
.daily-insight__divider {
width: 100%;
height: 1px;
}
}
.daily-insight-card--ai {
border: 1px solid rgba(var(--color-primary-rgb), 0.3);
background: linear-gradient(145deg, rgba(var(--color-primary-rgb), 0.05), var(--glass-panel-bg));
}
.daily-insight__loading-badge {
font-size: 0.7rem;
color: var(--color-text-secondary);
background: rgba(0, 0, 0, 0.05);
padding: 2px 6px;
border-radius: 4px;
margin-left: auto;
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 0.6;
}
50% {
opacity: 1;
}
}
.animate-fade-in {
animation: fadeIn 0.5s ease;
}
.section-header-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.week-diff-badge {
font-size: 0.7rem;
padding: 1px 6px;
border-radius: 4px;
font-weight: 600;
}
.week-diff-badge.green {
background: rgba(16, 185, 129, 0.1);
color: #10B981;
}
.week-diff-badge.red {
background: rgba(239, 68, 68, 0.1);
color: #EF4444;
}

View File

@@ -0,0 +1,144 @@
```typescript
import React, { useState, useEffect } from 'react';
import { Icon } from '@iconify/react';
import { formatCurrency } from '../../../utils/format';
import { getDailyInsight } from '../../../services/aiService';
import './DailyInsightCard.css';
interface DailyInsightCardProps {
todaySpend: number;
yesterdaySpend: number;
monthlyBudget: number;
monthlySpent: number;
topCategory?: { name: string; amount: number };
maxTransaction?: { note?: string; amount: number };
lastWeekSpend?: number;
streakDays?: number;
}
export const DailyInsightCard: React.FC<DailyInsightCardProps> = ({
todaySpend,
yesterdaySpend,
monthlyBudget,
monthlySpent,
topCategory,
maxTransaction,
lastWeekSpend,
streakDays
}) => {
const [aiData, setAiData] = useState<{spending: string; budget: string} | null>(null);
const [isAiLoading, setIsAiLoading] = useState(false);
useEffect(() => {
// Only fetch if has meaningful data or at least budget is set
if (monthlyBudget === 0 && todaySpend === 0 && yesterdaySpend === 0) return;
const fetchAI = async () => {
setIsAiLoading(true);
try {
const today = new Date().getDate();
const daysInMonth = new Date(new Date().getFullYear(), new Date().getMonth() + 1, 0).getDate();
const result = await getDailyInsight({
todaySpend,
yesterdaySpend,
monthlyBudget,
monthlySpent,
monthProgress: today / daysInMonth,
topCategory,
maxTransaction: maxTransaction ? {
note: maxTransaction.note || '',
amount: maxTransaction.amount
} : undefined,
lastWeekSpend,
streakDays
});
setAiData(result);
} catch (e) {
console.warn('AI insight fetch failed, sticking to local logic');
} finally {
setIsAiLoading(false);
}
};
const timer = setTimeout(fetchAI, 500);
return () => clearTimeout(timer);
}, [todaySpend, yesterdaySpend, monthlyBudget, monthlySpent, topCategory, maxTransaction, lastWeekSpend, streakDays]);
const getSpendingInsight = (today: number, yesterday: number) => {
if (aiData) return { text: <span>{aiData.spending}</span>, type: 'ai' };
if (today === 0) return { text: "今天还没有花钱,保持这种“零消费”状态就是最大的赚钱!", type: 'success' };
if (yesterday === 0) return { text: <span>今天花了 <strong className="daily-insight__highlight">{formatCurrency(today)}</strong>,既然昨天没花钱,今天稍微奢侈一点也无妨。</span>, type: 'normal' };
const diff = today - yesterday;
if (Math.abs(diff) < 10) return { text: <span>今天支出 <strong className="daily-insight__highlight">{formatCurrency(today)}</strong>,跟昨天差不多,生活节奏很稳。</span>, type: 'normal' };
if (today < yesterday) {
const percent = Math.round(((yesterday - today) / yesterday) * 100);
return { text: <span>比昨天少花了 <strong className="daily-insight__highlight daily-insight__highlight--success">{percent}%</strong>!这就是进步,省下来的钱可以积少成多。</span>, type: 'success' };
} else {
const percent = Math.round(((today - yesterday) / yesterday) * 100);
return { text: <span>比昨天多花了 <strong className="daily-insight__highlight daily-insight__highlight--warning">{percent}%</strong>。如果不是必需品,记得明天稍微控制一下哦。</span>, type: 'warning' };
}
};
const getBudgetInsight = (spent: number, total: number) => {
if (aiData) return { text: <span>{aiData.budget}</span>, type: 'ai' };
if (total === 0) return { text: "您还没有设置月度预算,建议去设置一个。", type: 'normal' };
const ratio = spent / total;
const today = new Date().getDate();
const daysInMonth = new Date(new Date().getFullYear(), new Date().getMonth() + 1, 0).getDate();
const timeRatio = today / daysInMonth;
if (ratio >= 1) return { text: <span className="daily-insight__highlight daily-insight__highlight--danger">本月预算已耗尽!接下来的每一天都需要极限生存挑战了。</span>, type: 'danger' };
if (ratio > timeRatio + 0.15) {
return { text: <span>进度 <strong className="daily-insight__highlight daily-insight__highlight--warning">{Math.round(ratio*100)}%</strong> (时间 {Math.round(timeRatio*100)}%)。花钱速度有点快了,建议踩踩刹车。</span>, type: 'warning' };
}
if (ratio < timeRatio - 0.1) {
return { text: <span>进度 <strong className="daily-insight__highlight daily-insight__highlight--success">{Math.round(ratio*100)}%</strong> (时间 {Math.round(timeRatio*100)}%)。控制得非常完美,月底可能有惊喜结余!</span>, type: 'success' };
}
return { text: <span>进度 <strong>{Math.round(ratio*100)}%</strong> (时间 {Math.round(timeRatio*100)}%)。目前节奏刚刚好,稳扎稳打。</span>, type: 'normal' };
};
const spendingInsight = getSpendingInsight(todaySpend, yesterdaySpend);
const budgetInsight = getBudgetInsight(monthlySpent, monthlyBudget);
return (
<div className={`daily - insight - card ${ aiData ? 'daily-insight-card--ai' : '' } `}>
<div className="daily-insight__header">
<Icon icon={aiData ? "solar:magic-stick-3-bold-duotone" : "solar:stars-minimalistic-bold-duotone"} width="20" />
<span>{aiData ? 'AI 每日简报' : '每日简报'}</span>
{isAiLoading && !aiData && <span className="daily-insight__loading-badge">AI 思考中...</span>}
</div>
<div className="daily-insight__content">
<div className="daily-insight__section">
<div className="section-header-row">
<span className="daily-insight__title">今日消费</span>
{lastWeekSpend !== undefined && lastWeekSpend > 0 && (
<span className={`week - diff - badge ${ todaySpend <= lastWeekSpend ? 'green' : 'red' } `}>
周同比 {todaySpend <= lastWeekSpend ? '↓' : '↑'}{Math.abs(todaySpend - lastWeekSpend).toFixed(0)}
</span>
)}
</div>
<p className="daily-insight__text animate-fade-in">{spendingInsight.text}</p>
</div>
<div className="daily-insight__divider" />
<div className="daily-insight__section">
<span className="daily-insight__title">预算风向标</span>
<p className="daily-insight__text animate-fade-in">{budgetInsight.text}</p>
</div>
</div>
</div>
);
};
```

View File

@@ -33,44 +33,11 @@ interface TransactionFormProps {
loading?: boolean;
/** Whether this is an edit form */
isEditing?: boolean;
/** Whether to automatically skip steps if data is pre-filled */
smartSkipConfirmedSteps?: boolean;
}
/**
* Currency options
*/
const CURRENCIES: { value: CurrencyCode; label: string; symbol: string }[] = [
{ value: 'CNY', label: '人民币', symbol: '¥' },
{ value: 'USD', label: '美元', symbol: '$' },
{ value: 'EUR', label: '欧元', symbol: '€' },
{ value: 'JPY', label: '日元', symbol: '¥' },
{ value: 'GBP', label: '英镑', symbol: '£' },
{ value: 'HKD', label: '港币', symbol: 'HK$' },
];
/**
* Transaction type options
*/
import { Icon } from '@iconify/react';
const TRANSACTION_TYPES: { value: TransactionType; label: string; icon: React.ReactNode }[] = [
{ value: 'expense', label: '支出', icon: <Icon icon="solar:wallet-money-bold-duotone" width="24" className="text-error" /> },
{ value: 'income', label: '收入', icon: <Icon icon="solar:hand-money-bold-duotone" width="24" className="text-success" /> },
];
/**
* Get today's date in YYYY-MM-DD format
*/
function getTodayDate(): string {
return new Date().toISOString().split('T')[0];
}
/**
* Format currency symbol
*/
function getCurrencySymbol(currency: CurrencyCode): string {
const found = CURRENCIES.find((c) => c.value === currency);
return found?.symbol || '¥';
}
// ...
export const TransactionForm: React.FC<TransactionFormProps> = ({
initialData,
@@ -78,160 +45,26 @@ export const TransactionForm: React.FC<TransactionFormProps> = ({
onCancel,
loading = false,
isEditing = false,
smartSkipConfirmedSteps = false,
}) => {
// Current step (1, 2, or 3)
const [currentStep, setCurrentStep] = useState(1);
// Form data
const [formData, setFormData] = useState<TransactionFormInput>({
amount: initialData?.amount || 0,
type: initialData?.type || 'expense',
categoryId: initialData?.categoryId || 0,
accountId: initialData?.accountId || 0,
currency: initialData?.currency || 'CNY',
transactionDate: initialData?.transactionDate || getTodayDate(),
note: initialData?.note || '',
tagIds: initialData?.tagIds || [],
});
// Selected entities for display
const [selectedCategory, setSelectedCategory] = useState<Category | undefined>();
// Accounts list
const [accounts, setAccounts] = useState<Account[]>([]);
const [accountsLoading, setAccountsLoading] = useState(false);
// Validation errors
const [errors, setErrors] = useState<Partial<Record<keyof TransactionFormInput, string>>>({});
// Amount input ref for auto-focus
const amountInputRef = useRef<HTMLInputElement>(null);
// Load accounts on mount
useEffect(() => {
const loadAccounts = async () => {
setAccountsLoading(true);
try {
const data = await getAccounts();
setAccounts(data);
// Set default account if not already set
if (!formData.accountId && data.length > 0) {
setFormData((prev) => ({ ...prev, accountId: data[0].id }));
}
} catch (err) {
console.error('Failed to load accounts:', err);
} finally {
setAccountsLoading(false);
}
};
loadAccounts();
}, []);
// Auto-focus amount input on step 1
useEffect(() => {
if (currentStep === 1 && amountInputRef.current) {
amountInputRef.current.focus();
}
}, [currentStep]);
// Handle amount change
const handleAmountChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
// Allow empty string or valid number
if (value === '' || /^\d*\.?\d{0,2}$/.test(value)) {
setFormData((prev) => ({
...prev,
amount: value === '' ? 0 : parseFloat(value),
}));
if (errors.amount) {
setErrors((prev) => ({ ...prev, amount: undefined }));
}
}
};
// Handle type change
const handleTypeChange = (type: TransactionType) => {
setFormData((prev) => ({ ...prev, type }));
// Reset category when type changes
setFormData((prev) => ({ ...prev, categoryId: 0 }));
setSelectedCategory(undefined);
};
// Handle category change
const handleCategoryChange = (categoryId: number | undefined, category?: Category) => {
setFormData((prev) => ({ ...prev, categoryId: categoryId || 0 }));
setSelectedCategory(category);
if (errors.categoryId) {
setErrors((prev) => ({ ...prev, categoryId: undefined }));
}
};
// Handle account change
const handleAccountChange = (accountId: number) => {
setFormData((prev) => ({ ...prev, accountId }));
if (errors.accountId) {
setErrors((prev) => ({ ...prev, accountId: undefined }));
}
};
// Handle currency change
const handleCurrencyChange = (currency: CurrencyCode) => {
setFormData((prev) => ({ ...prev, currency }));
};
// Handle date change
const handleDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData((prev) => ({ ...prev, transactionDate: e.target.value }));
};
// Handle note change
const handleNoteChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setFormData((prev) => ({ ...prev, note: e.target.value }));
};
// Handle tags change
const handleTagsChange = (tagIds: number[]) => {
setFormData((prev) => ({ ...prev, tagIds }));
};
// Validate step 1
const validateStep1 = (): boolean => {
const newErrors: Partial<Record<keyof TransactionFormInput, string>> = {};
if (!formData.amount || formData.amount <= 0) {
newErrors.amount = '请输入有效金额';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
// Validate step 2
const validateStep2 = (): boolean => {
const newErrors: Partial<Record<keyof TransactionFormInput, string>> = {};
if (!formData.categoryId) {
newErrors.categoryId = '请选择分类';
}
if (!formData.accountId) {
newErrors.accountId = '请选择账户';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
// ... existing code ...
// Go to next step
const handleNextStep = useCallback(() => {
if (currentStep === 1 && validateStep1()) {
setCurrentStep(2);
// Smart skip: If step 2 data (category & account) is already valid, jump to step 3
if (smartSkipConfirmedSteps && formData.categoryId && formData.accountId) {
setCurrentStep(3);
} else {
setCurrentStep(2);
}
} else if (currentStep === 2 && validateStep2()) {
setCurrentStep(3);
}
}, [currentStep, formData]);
}, [currentStep, formData, smartSkipConfirmedSteps]);
// Go to previous step
const handlePrevStep = () => {

View File

@@ -300,4 +300,45 @@
.piggy-banks-list {
grid-template-columns: repeat(3, 1fr);
}
.budgets-list,
.piggy-banks-list {
grid-template-columns: repeat(3, 1fr);
}
}
/* Generic Modal for Budget Page */
.budget-modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
z-index: 1100;
display: flex;
justify-content: center;
align-items: center;
padding: var(--spacing-md);
animation: fadeIn 0.2s ease-out;
}
.budget-modal-content {
width: 100%;
max-width: 500px;
background: transparent;
animation: scaleIn 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes scaleIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}

View File

@@ -8,7 +8,9 @@ import {
PiggyBankCardSkeleton,
PiggyBankForm,
PiggyBankTransactionModal,
PiggyBankTransactionModal,
PiggyBankIntro,
BudgetDetailModal,
} from '../../components/budget';
import type { Budget as BudgetType, Category, Account, PiggyBank } from '../../types';
import {
@@ -27,6 +29,8 @@ import {
withdrawFromPiggyBank,
type PiggyBankFormInput,
} from '../../services/piggyBankService';
import { createTransaction, type TransactionFormInput } from '../../services/transactionService';
import { TransactionForm } from '../../components/transaction';
import { getCategories } from '../../services/categoryService';
import { getAccounts } from '../../services/accountService';
import { Icon } from '@iconify/react';
@@ -47,6 +51,7 @@ function Budget() {
const [showPiggyBankForm, setShowPiggyBankForm] = useState(false);
const [showPiggyBankIntro, setShowPiggyBankIntro] = useState(false);
const [editingBudget, setEditingBudget] = useState<BudgetType | undefined>(undefined);
const [selectedBudget, setSelectedBudget] = useState<BudgetType | undefined>(undefined);
const [editingPiggyBank, setEditingPiggyBank] = useState<PiggyBank | undefined>(undefined);
const [isSubmitting, setIsSubmitting] = useState(false);
const [transactionModal, setTransactionModal] = useState<{
@@ -54,6 +59,11 @@ function Budget() {
type: 'deposit' | 'withdraw';
} | null>(null);
// Transaction Form State
const [showTransactionForm, setShowTransactionForm] = useState(false);
const [initialTransactionData, setInitialTransactionData] = useState<Partial<TransactionFormInput>>({});
const [refreshKey, setRefreshKey] = useState(0);
const [showConfetti, setShowConfetti] = useState(false);
// Load budgets, categories, and accounts
@@ -107,6 +117,10 @@ function Budget() {
setShowBudgetForm(true);
};
const handleViewBudget = (budget: BudgetType) => {
setSelectedBudget(budget);
};
const handleDeleteBudget = async (budget: BudgetType) => {
if (!window.confirm(`确定要删除预算"${budget.name}"吗?`)) {
return;
@@ -286,6 +300,34 @@ function Budget() {
setTransactionModal(null);
};
const handleAddTransactionForBudget = (budget: BudgetType) => {
setInitialTransactionData({
type: 'expense',
categoryId: budget.categoryId || 0,
accountId: budget.accountId || 0,
transactionDate: new Date().toISOString().split('T')[0],
amount: 0
});
setShowTransactionForm(true);
};
const handleCreateTransaction = async (data: TransactionFormInput) => {
try {
setIsSubmitting(true);
await createTransaction(data);
setShowTransactionForm(false);
setRefreshKey(prev => prev + 1);
// Also reload budgets to update spent amount
loadData();
setShowConfetti(true); // Small celebration for tracking spending!
} catch (err) {
console.error('Failed to create transaction:', err);
alert('创建交易失败,请重试');
} finally {
setIsSubmitting(false);
}
};
return (
<div className="budget-page">
<header className="budget-header">
@@ -378,8 +420,10 @@ function Budget() {
budget={budget}
categoryName={getCategoryName(budget.categoryId)}
accountName={getAccountName(budget.accountId)}
accountName={getAccountName(budget.accountId)}
onEdit={handleEditBudget}
onDelete={handleDeleteBudget}
onClick={() => handleViewBudget(budget)}
/>
))}
</div>
@@ -421,10 +465,41 @@ function Budget() {
type={transactionModal.type}
onSubmit={handleSubmitTransaction}
onCancel={handleCancelTransaction}
linkedAccount={accounts.find(
(a) => a.id === transactionModal.piggyBank.linkedAccountId
)}
isLoading={isSubmitting}
/>
)}
{selectedBudget && (
<BudgetDetailModal
budget={selectedBudget}
onClose={() => setSelectedBudget(undefined)}
onEdit={() => {
setSelectedBudget(undefined);
handleEditBudget(selectedBudget);
}}
onAddTransaction={() => handleAddTransactionForBudget(selectedBudget)}
refreshTrigger={refreshKey}
categoryName={getCategoryName(selectedBudget.categoryId)}
/>
)}
{showTransactionForm && (
<div className="budget-modal-overlay" onClick={() => setShowTransactionForm(false)}>
<div className="budget-modal-content" onClick={e => e.stopPropagation()}>
<TransactionForm
initialData={initialTransactionData}
onSubmit={handleCreateTransaction}
onCancel={() => setShowTransactionForm(false)}
loading={isSubmitting}
smartSkipConfirmedSteps={true}
/>
</div>
</div>
)}
<Confetti
active={showConfetti}
onComplete={() => setShowConfetti(false)}

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useState, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import './Home.css';
@@ -18,7 +18,9 @@ import { AccountForm } from '../../components/account/AccountForm/AccountForm';
import { createAccount } from '../../services/accountService';
import { Confetti } from '../../components/common/Confetti';
import { HealthScoreModal } from '../../components/home/HealthScoreModal/HealthScoreModal';
import { ContributionModal } from '../../components/common/ContributionGraph/ContributionModal'; // Import Component
import { ContributionModal } from '../../components/common/ContributionGraph/ContributionModal';
import { DailyInsightCard } from '../../components/home/DailyInsightCard/DailyInsightCard'; // Import
import { getBudgets } from '../../services/budgetService'; // Import
import type { Account, Transaction, Category, Ledger, UserSettings } from '../../types';
@@ -50,6 +52,12 @@ function Home() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Budget stats for insight
const [monthlyBudgetTotal, setMonthlyBudgetTotal] = useState(0);
const [monthlyBudgetSpentTotal, setMonthlyBudgetSpentTotal] = useState(0);
const [todayTransactions, setTodayTransactions] = useState<Transaction[]>([]);
const [lastWeekSpend, setLastWeekSpend] = useState(0); // New State
useEffect(() => {
loadData();
@@ -60,23 +68,28 @@ function Home() {
setLoading(true);
setError(null);
// Calculate dates for today and yesterday
// Calculate dates for today, yesterday, and last week same day
const today = new Date();
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
const lastWeek = new Date(today);
lastWeek.setDate(lastWeek.getDate() - 7);
const todayStr = today.toISOString().split('T')[0];
const yesterdayStr = yesterday.toISOString().split('T')[0];
const lastWeekStr = lastWeek.toISOString().split('T')[0];
// Load accounts, recent transactions, today/yesterday stats
const [accountsData, transactionsData, categoriesData, ledgersData, settingsData, todayData, yesterdayData, streakData] = await Promise.all([
// Load accounts, recent transactions, today/yesterday stats... AND last week
const [accountsData, transactionsData, categoriesData, ledgersData, settingsData, budgetsData, todayData, yesterdayData, lastWeekData, streakData] = await Promise.all([
getAccounts(),
getTransactions({ page: 1, pageSize: 5 }), // Recent transactions
getTransactions({ page: 1, pageSize: 5 }), // Recent
getCategories(),
getLedgers().catch(() => []),
getSettings().catch(() => null),
getBudgets().catch(() => []),
getTransactions({ startDate: todayStr, endDate: todayStr, type: 'expense', pageSize: 100 }),
getTransactions({ startDate: yesterdayStr, endDate: yesterdayStr, type: 'expense', pageSize: 100 }),
getTransactions({ startDate: lastWeekStr, endDate: lastWeekStr, type: 'expense', pageSize: 100 }), // Last week same day
getStreakInfo().catch(() => null),
]);
@@ -88,7 +101,22 @@ function Home() {
// Calculate daily spends
setTodaySpend(calculateTotalExpense(todayData.items));
setTodayTransactions(todayData.items || []);
setYesterdaySpend(calculateTotalExpense(yesterdayData.items));
setLastWeekSpend(calculateTotalExpense(lastWeekData.items)); // Store this in new state
// Calculate monthly budget stats
let mTotal = 0;
let mSpent = 0;
// ...
(budgetsData || []).forEach((b: any) => {
if (b.periodType === 'monthly') {
mTotal += b.amount;
mSpent += (b.spent || 0);
}
});
setMonthlyBudgetTotal(mTotal);
setMonthlyBudgetSpentTotal(mSpent);
// Set streak info
setStreakInfo(streakData);
@@ -100,6 +128,35 @@ function Home() {
}
};
const { maxTransaction, topCategory } = useMemo(() => {
if (todayTransactions.length === 0) return { maxTransaction: undefined, topCategory: undefined };
// Max Transaction
const maxTx = [...todayTransactions].sort((a, b) => b.amount - a.amount)[0];
// Top Category
const catMap: Record<number, number> = {};
todayTransactions.forEach((t) => {
catMap[t.categoryId] = (catMap[t.categoryId] || 0) + t.amount;
});
// Sort categories
const sortedCatIds = Object.keys(catMap).sort((a, b) => catMap[Number(b)] - catMap[Number(a)]);
// Find top one
let topCatName = undefined;
if (sortedCatIds.length > 0) {
const topCatId = Number(sortedCatIds[0]);
topCatName = categories.find((c) => c.id === topCatId)?.name;
// Optimization: if category name not found, try to look up in transaction's populated data if available or fallback
}
return {
maxTransaction: maxTx,
topCategory: topCatName ? { name: topCatName, amount: catMap[Number(sortedCatIds[0])] } : undefined
};
}, [todayTransactions, categories]);
const handleQuickTransaction = () => {
navigate('/transactions?action=new');
};
@@ -393,8 +450,21 @@ function Home() {
</div>
</header>
<main className="home-content">
{/* Daily Insight Card */}
<div style={{ marginBottom: '1.5rem' }}>
<DailyInsightCard
todaySpend={todaySpend}
yesterdaySpend={yesterdaySpend}
monthlyBudget={monthlyBudgetTotal}
monthlySpent={monthlyBudgetSpentTotal}
maxTransaction={maxTransaction}
topCategory={topCategory}
lastWeekSpend={lastWeekSpend}
streakDays={streakInfo?.currentStreak || 0}
/>
</div>
{/* Asset Dashboard - Requirement 8.1 */}
<section className="dashboard-grid">
{/* Net Worth Card - Main Hero */}

View File

@@ -289,17 +289,125 @@ Output Requirements:
// Here we choose to throw so fallback static text appears.
throw error;
}
}
// Cache storage for daily insight
let dailyInsightCache: {
data: { spending: string; budget: string };
timestamp: number;
contentHash: string;
} | null = null;
export default {
getSessionId,
clearSession,
sendChatMessage,
transcribeAudio,
confirmTransaction,
cancelSession,
processVoiceInput,
isConfirmationCardComplete,
formatConfirmationCard,
getFinancialAdvice,
};
export interface DailyInsightContext {
todaySpend: number;
yesterdaySpend: number;
monthlyBudget: number;
monthlySpent: number;
monthProgress: number; // 0-1
topCategory?: { name: string; amount: number };
maxTransaction?: { note: string; amount: number };
lastWeekSpend?: number;
streakDays?: number;
}
/**
* Get AI-powered daily insight
*/
export async function getDailyInsight(context: DailyInsightContext): Promise<{ spending: string; budget: string }> {
// Hash needs to include new fields
const currentHash = JSON.stringify(context);
const NOW = Date.now();
const CACHE_TTL = 30 * 60 * 1000; // 30 Minutes
// 1. Check Cache
if (dailyInsightCache &&
(NOW - dailyInsightCache.timestamp < CACHE_TTL) &&
dailyInsightCache.contentHash === currentHash) {
return dailyInsightCache.data;
}
const weekday = new Date().toLocaleDateString('zh-CN', { weekday: 'long' });
const weekDiff = context.lastWeekSpend !== undefined ? (context.todaySpend - context.lastWeekSpend) : 0;
const prompt = `System: 你是 Novault 首席财务AI也是用户的贴身管家。你的点评需要非常有温度、有依据。
Context:
- 今天是: ${weekday}
- 连续记账: ${context.streakDays || 0}
- 今日支出: ${context.todaySpend}
- 对比昨日: ${context.yesterdaySpend}
- 对比上周同日: ${context.lastWeekSpend || '无数据'} (差额: ${weekDiff})
- 月预算: ${context.monthlyBudget} (已用: ${context.monthlySpent}, 进度: ${(context.monthlySpent / (context.monthlyBudget || 1) * 100).toFixed(0)}%)
- 月时间进度: ${(context.monthProgress * 100).toFixed(0)}%
- 今日支出之王: ${context.topCategory ? `${context.topCategory.name}】 ¥${context.topCategory.amount}` : '无'}
- 今日最大一笔: ${context.maxTransaction ? `${context.maxTransaction.note || '未备注'}${context.maxTransaction.amount})` : '无'}
Task:
请输出 JSON 对象(无 markdown 标记):
1. "spending": 针对今日支出的点评40字内
- 必须结合【${weekday}】的场景(如周五可以放松,周一要收心)。
- 如果有【连续记账】成就(>3天请顺带夸奖。
- 如果对比上周同日波动大,请指出。
- 结合最大支出进行调侃。
2. "budget": 针对预算状况的建议40字内
- 结合月度进度,给出具体行动指南。
`;
try {
const response = await sendChatMessage(prompt);
if (response.message) {
let content = response.message.trim();
// Extract JSON object using regex to handle potential markdown or extra text
const jsonMatch = content.match(/\{[\s\S]*\}/);
if (jsonMatch) {
content = jsonMatch[0];
} else {
// Fallback cleanup if regex fails but it might still be JSON-ish
content = content.replace(/^```json\s*/, '').replace(/\s*```$/, '');
}
let parsed;
try {
parsed = JSON.parse(content);
} catch (e) {
console.warn('AI returned invalid JSON, falling back to raw text split or default', content);
// Fallback: simple split if possible or default
parsed = {
spending: content.slice(0, 50) + '...',
budget: 'AI 分析数据格式异常,请稍后再试。'
};
}
const result = {
spending: parsed.spending || '暂无点评',
budget: parsed.budget || '暂无建议'
};
// Update Cache
dailyInsightCache = {
data: result,
timestamp: NOW,
contentHash: currentHash
};
return result;
}
throw new Error('No insight received');
} catch (error) {
console.error('Failed to get AI insight:', error);
throw error;
}
}
export default {
getSessionId,
clearSession,
sendChatMessage,
transcribeAudio,
confirmTransaction,
cancelSession,
processVoiceInput,
isConfirmationCardComplete,
formatConfirmationCard,
getFinancialAdvice,
};

View File

@@ -21,11 +21,12 @@ const CURRENCY_SYMBOLS: Record<CurrencyCode, string> = {
*/
export function formatCurrency(amount: number, currency: CurrencyCode = 'CNY'): string {
const symbol = CURRENCY_SYMBOLS[currency] || currency;
const formatted = Math.abs(amount).toLocaleString('zh-CN', {
const validAmount = (amount === undefined || amount === null || isNaN(amount)) ? 0 : amount;
const formatted = Math.abs(validAmount).toLocaleString('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
return amount < 0 ? `-${symbol}${formatted}` : `${symbol}${formatted}`;
return validAmount < 0 ? `-${symbol}${formatted}` : `${symbol}${formatted}`;
}
/**