feat: 新增预算管理页面,支持预算和存钱罐的增删改查功能,并引入预算详情和交易表单组件。

This commit is contained in:
2026-01-28 21:57:41 +08:00
parent 303b4ed001
commit 4347b0d800
3 changed files with 145 additions and 50 deletions

View File

@@ -100,7 +100,7 @@ const generateInsight = (budget: Budget, transactions: Transaction[], startDate:
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);
@@ -128,7 +128,7 @@ const generateInsight = (budget: Budget, transactions: Transaction[], startDate:
// 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)} 天后提前耗尽。请务必大幅削减开支!`
@@ -136,7 +136,7 @@ const generateInsight = (budget: Budget, transactions: Transaction[], startDate:
}
// Case 3: Pattern detection - Nickel and Dimed
if (patternMatched(isNickelAndDimed, 'warning')) {
if (isNickelAndDimed) {
return {
type: 'info',
text: `🐜 蚂蚁搬家式消费!虽然单笔不贵,但已累计 ${smallTxCount} 笔小额支出。警惕“拿铁效应”,这些琐碎开支正在悄悄吃掉您的预算。`
@@ -144,7 +144,7 @@ const generateInsight = (budget: Budget, transactions: Transaction[], startDate:
}
// Case 4: Pattern detection - Whale
if (patternMatched(isWhaleMoved, 'info')) {
if (isWhaleMoved) {
return {
type: 'info',
text: `🐋 巨鲸出没!仅 ${largeTx.length} 笔大额支出就占据了绝大部分开销。控制好大件消费是守住预算的关键。`
@@ -183,9 +183,7 @@ const generateInsight = (budget: Budget, transactions: Transaction[], startDate:
};
// Helper to avoid TS errors or cleaner logic usage
function patternMatched(condition: boolean, type: string) {
return condition;
}
export const BudgetDetailModal: React.FC<BudgetDetailModalProps> = ({
budget,

View File

@@ -10,6 +10,7 @@
*/
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { Icon } from '@iconify/react';
import type {
TransactionType,
CurrencyCode,
@@ -20,8 +21,28 @@ import type {
import { CategorySelector } from '../../category/CategorySelector/CategorySelector';
import { TagInput } from '../../tag/TagInput/TagInput';
import { getAccounts } from '../../../services/accountService';
import { getCategories } from '../../../services/categoryService';
import './TransactionForm.css';
// Constants
const TRANSACTION_TYPES: { value: TransactionType; label: string; icon: string }[] = [
{ value: 'expense', label: '支出', icon: '💰' },
{ value: 'income', label: '收入', icon: '💸' },
{ value: 'transfer', label: '转账', icon: '↔️' },
];
const CURRENCIES: { value: CurrencyCode; symbol: string }[] = [
{ value: 'CNY', symbol: '¥' },
{ value: 'USD', symbol: '$' },
{ value: 'EUR', symbol: '€' },
{ value: 'JPY', symbol: '¥' },
{ value: 'GBP', symbol: '£' },
];
const getCurrencySymbol = (code: string) => {
return CURRENCIES.find((c) => c.value === code)?.symbol || code;
};
interface TransactionFormProps {
/** Initial form data for editing */
initialData?: Partial<TransactionFormInput>;
@@ -37,8 +58,6 @@ interface TransactionFormProps {
smartSkipConfirmedSteps?: boolean;
}
// ...
export const TransactionForm: React.FC<TransactionFormProps> = ({
initialData,
onSubmit,
@@ -50,40 +69,135 @@ export const TransactionForm: React.FC<TransactionFormProps> = ({
// Current step (1, 2, or 3)
const [currentStep, setCurrentStep] = useState(1);
// ... existing code ...
// Form State
const [formData, setFormData] = useState<TransactionFormInput>({
amount: 0,
type: 'expense',
currency: 'CNY',
categoryId: 0,
accountId: 0,
transactionDate: new Date().toISOString().split('T')[0],
note: '',
tagIds: [],
...initialData,
});
// Go to next step
// Data State
const [accounts, setAccounts] = useState<Account[]>([]);
const [categories, setCategories] = useState<Category[]>([]);
const [accountsLoading, setAccountsLoading] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
const amountInputRef = useRef<HTMLInputElement>(null);
// Load Data
useEffect(() => {
const loadData = async () => {
setAccountsLoading(true);
try {
const [accs, cats] = await Promise.all([
getAccounts(),
getCategories()
]);
setAccounts(accs);
setCategories(cats);
} catch (err) {
console.error('Failed to load form data', err);
} finally {
setAccountsLoading(false);
}
};
loadData();
}, []);
// Handlers
const handleTypeChange = (type: TransactionType) => {
setFormData((prev) => ({ ...prev, type }));
};
const handleCurrencyChange = (currency: CurrencyCode) => {
setFormData((prev) => ({ ...prev, currency }));
};
const handleAmountChange = (e: React.ChangeEvent<HTMLInputElement>) => {
// Basic number input handling
const val = e.target.value;
// Allow empty string or ending with decimal
if (val === '' || /^\d*\.?\d*$/.test(val)) {
// We might store it as string locally if we want perfect input, but TransactionFormInput expects number.
// For simplicity, we parse float immediately but this prevents typing "1." comfortably.
// Let's rely on type="number" behavior of input if possible or assume user types clean numbers.
// Or better: update state properly.
// The original broken code used `value={formData.amount || ''}` and `inputMode="decimal"`.
// Let's do simple float parsing.
setFormData((prev) => ({ ...prev, amount: parseFloat(val) || 0 }));
if (errors.amount) setErrors((prev) => ({ ...prev, amount: '' }));
}
};
const handleCategoryChange = (categoryId: number | undefined) => {
setFormData((prev) => ({ ...prev, categoryId: categoryId || 0 }));
if (errors.categoryId) setErrors((prev) => ({ ...prev, categoryId: '' }));
};
const handleAccountChange = (accountId: number) => {
setFormData((prev) => ({ ...prev, accountId }));
if (errors.accountId) setErrors((prev) => ({ ...prev, accountId: '' }));
};
const handleDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData((prev) => ({ ...prev, transactionDate: e.target.value }));
};
const handleNoteChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setFormData((prev) => ({ ...prev, note: e.target.value }));
};
const handleTagsChange = (tagIds: number[]) => {
setFormData((prev) => ({ ...prev, tagIds }));
};
// Navigation
const handleNextStep = useCallback(() => {
if (currentStep === 1 && validateStep1()) {
// Smart skip: If step 2 data (category & account) is already valid, jump to step 3
if (currentStep === 1) {
// Validate Step 1
if (!formData.amount || formData.amount <= 0) {
setErrors(prev => ({ ...prev, amount: '请输入有效金额' }));
return;
}
// Smart Skip
if (smartSkipConfirmedSteps && formData.categoryId && formData.accountId) {
setCurrentStep(3);
} else {
setCurrentStep(2);
}
} else if (currentStep === 2 && validateStep2()) {
setCurrentStep(3);
}
}, [currentStep, formData, smartSkipConfirmedSteps]);
} else if (currentStep === 2) {
// Validate Step 2
let isValid = true;
const newErrors = { ...errors };
if (!formData.categoryId) { newErrors.categoryId = '请选择分类'; isValid = false; }
if (!formData.accountId) { newErrors.accountId = '请选择账户'; isValid = false; }
setErrors(newErrors);
if (isValid) setCurrentStep(3);
}
}, [currentStep, formData, smartSkipConfirmedSteps, errors]);
// Go to previous step
const handlePrevStep = () => {
if (currentStep > 1) {
setCurrentStep(currentStep - 1);
}
};
// Handle form submission
const handleSubmit = useCallback(() => {
// Final validation
if (!validateStep1() || !validateStep2()) {
return;
}
if (!formData.amount) return;
if (!formData.categoryId) return;
if (!formData.accountId) return;
onSubmit(formData);
}, [formData, onSubmit]);
// Handle keyboard shortcuts
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
@@ -98,10 +212,11 @@ export const TransactionForm: React.FC<TransactionFormProps> = ({
[currentStep, handleNextStep, handleSubmit]
);
// Get selected account
// Helpers
const selectedAccount = accounts.find((a) => a.id === formData.accountId);
const selectedCategory = categories.find((c) => c.id === formData.categoryId);
// Render step indicator
// Render Functions
const renderStepIndicator = () => (
<div className="transaction-form__steps">
{[1, 2, 3].map((step) => (
@@ -118,12 +233,9 @@ export const TransactionForm: React.FC<TransactionFormProps> = ({
</div>
);
// Render step 1: Amount and Type
const renderStep1 = () => (
<div className="transaction-form__step-content">
<h3 className="transaction-form__step-title"></h3>
{/* Transaction Type Toggle */}
<div className="transaction-form__type-toggle">
{TRANSACTION_TYPES.map((type) => (
<button
@@ -138,8 +250,6 @@ export const TransactionForm: React.FC<TransactionFormProps> = ({
</button>
))}
</div>
{/* Amount Input */}
<div className="transaction-form__amount-container">
<div className="transaction-form__currency-selector">
<select
@@ -157,7 +267,7 @@ export const TransactionForm: React.FC<TransactionFormProps> = ({
</div>
<input
ref={amountInputRef}
type="text"
type="number" // Changed to number for simpler handling
inputMode="decimal"
className={`transaction-form__amount-input ${errors.amount ? 'transaction-form__amount-input--error' : ''}`}
value={formData.amount || ''}
@@ -165,18 +275,16 @@ export const TransactionForm: React.FC<TransactionFormProps> = ({
onKeyDown={handleKeyDown}
placeholder="0.00"
disabled={loading}
autoFocus
/>
</div>
{errors.amount && <span className="transaction-form__error">{errors.amount}</span>}
</div>
);
// Render step 2: Category and Account
const renderStep2 = () => (
<div className="transaction-form__step-content">
<h3 className="transaction-form__step-title"></h3>
{/* Category Selector */}
<div className="transaction-form__field">
<CategorySelector
value={formData.categoryId || undefined}
@@ -188,8 +296,6 @@ export const TransactionForm: React.FC<TransactionFormProps> = ({
disabled={loading}
/>
</div>
{/* Account Selector */}
<div className="transaction-form__field">
<label className="transaction-form__label">
<span className="transaction-form__required">*</span>
@@ -229,12 +335,9 @@ export const TransactionForm: React.FC<TransactionFormProps> = ({
</div>
);
// Render step 3: Optional details and confirmation
const renderStep3 = () => (
<div className="transaction-form__step-content">
<h3 className="transaction-form__step-title"></h3>
{/* Summary */}
<div className="transaction-form__summary">
<div className="transaction-form__summary-row">
<span className="transaction-form__summary-label"></span>
@@ -267,8 +370,6 @@ export const TransactionForm: React.FC<TransactionFormProps> = ({
</span>
</div>
</div>
{/* Date */}
<div className="transaction-form__field">
<label className="transaction-form__label" htmlFor="transactionDate">
@@ -282,8 +383,6 @@ export const TransactionForm: React.FC<TransactionFormProps> = ({
disabled={loading}
/>
</div>
{/* Note */}
<div className="transaction-form__field">
<label className="transaction-form__label" htmlFor="note">
@@ -298,8 +397,6 @@ export const TransactionForm: React.FC<TransactionFormProps> = ({
disabled={loading}
/>
</div>
{/* Tags */}
<div className="transaction-form__field">
<TagInput
value={formData.tagIds}

View File

@@ -8,11 +8,11 @@ import {
PiggyBankCardSkeleton,
PiggyBankForm,
PiggyBankTransactionModal,
PiggyBankTransactionModal,
PiggyBankIntro,
BudgetDetailModal,
} from '../../components/budget';
import type { Budget as BudgetType, Category, Account, PiggyBank } from '../../types';
import type { Budget as BudgetType, Category, Account, PiggyBank, TransactionFormInput } from '../../types';
import {
getBudgets,
createBudget,
@@ -29,7 +29,7 @@ import {
withdrawFromPiggyBank,
type PiggyBankFormInput,
} from '../../services/piggyBankService';
import { createTransaction, type TransactionFormInput } from '../../services/transactionService';
import { createTransaction } from '../../services/transactionService';
import { TransactionForm } from '../../components/transaction';
import { getCategories } from '../../services/categoryService';
import { getAccounts } from '../../services/accountService';
@@ -420,7 +420,7 @@ function Budget() {
budget={budget}
categoryName={getCategoryName(budget.categoryId)}
accountName={getAccountName(budget.accountId)}
accountName={getAccountName(budget.accountId)}
onEdit={handleEditBudget}
onDelete={handleDeleteBudget}
onClick={() => handleViewBudget(budget)}