feat: 新增预算详情模态框、交易表单、小猪罐功能,并支持生成预算洞察。
This commit is contained in:
342
src/components/budget/BudgetDetailModal/BudgetDetailModal.css
Normal file
342
src/components/budget/BudgetDetailModal/BudgetDetailModal.css
Normal 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;
|
||||
}
|
||||
383
src/components/budget/BudgetDetailModal/BudgetDetailModal.tsx
Normal file
383
src/components/budget/BudgetDetailModal/BudgetDetailModal.tsx
Normal 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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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';
|
||||
|
||||
162
src/components/home/DailyInsightCard/DailyInsightCard.css
Normal file
162
src/components/home/DailyInsightCard/DailyInsightCard.css
Normal 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;
|
||||
}
|
||||
144
src/components/home/DailyInsightCard/DailyInsightCard.tsx
Normal file
144
src/components/home/DailyInsightCard/DailyInsightCard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
```
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user