feat: 实现核心应用结构、全局命令面板和初步交易相关页面及服务。
This commit is contained in:
@@ -3,6 +3,7 @@ import { router } from './router';
|
||||
|
||||
|
||||
import { ThemeProvider, PrivacyProvider, NotificationProvider, GuideProvider, useAutoTokenRefresh } from './hooks';
|
||||
import { SettingsProvider } from './contexts/SettingsContext';
|
||||
|
||||
/**
|
||||
* App Content Component
|
||||
@@ -25,7 +26,9 @@ function App() {
|
||||
<PrivacyProvider>
|
||||
<NotificationProvider>
|
||||
<GuideProvider>
|
||||
<AppContent />
|
||||
<SettingsProvider>
|
||||
<AppContent />
|
||||
</SettingsProvider>
|
||||
</GuideProvider>
|
||||
</NotificationProvider>
|
||||
</PrivacyProvider>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTheme } from '../../../hooks';
|
||||
import { useSettings } from '../../../contexts/SettingsContext';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { useKey } from 'react-use';
|
||||
import { getTransactions } from '../../../services/transactionService';
|
||||
@@ -32,6 +33,8 @@ export const CommandPalette: React.FC = () => {
|
||||
|
||||
const navigate = useNavigate();
|
||||
const { toggleTheme, isDark } = useTheme();
|
||||
const { settings } = useSettings();
|
||||
const currentLedgerId = settings?.currentLedgerId;
|
||||
|
||||
// Toggle open with Ctrl+K or Cmd+K
|
||||
useEffect(() => {
|
||||
@@ -129,7 +132,7 @@ export const CommandPalette: React.FC = () => {
|
||||
|
||||
const timer = setTimeout(async () => {
|
||||
try {
|
||||
const res = await getTransactions({ search: query, pageSize: 5 });
|
||||
const res = await getTransactions({ search: query, pageSize: 5, ledgerId: currentLedgerId });
|
||||
setTransactionResults(res.items);
|
||||
} catch (err) {
|
||||
console.error('Search transactions failed', err);
|
||||
|
||||
@@ -56,6 +56,8 @@ interface TransactionFormProps {
|
||||
isEditing?: boolean;
|
||||
/** Whether to automatically skip steps if data is pre-filled */
|
||||
smartSkipConfirmedSteps?: boolean;
|
||||
/** Current active ledger ID */
|
||||
currentLedgerId?: number;
|
||||
}
|
||||
|
||||
export const TransactionForm: React.FC<TransactionFormProps> = ({
|
||||
@@ -65,6 +67,7 @@ export const TransactionForm: React.FC<TransactionFormProps> = ({
|
||||
loading = false,
|
||||
isEditing = false,
|
||||
smartSkipConfirmedSteps = false,
|
||||
currentLedgerId,
|
||||
}) => {
|
||||
// Current step (1, 2, or 3)
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
@@ -89,6 +92,7 @@ export const TransactionForm: React.FC<TransactionFormProps> = ({
|
||||
: toLocalISOString(new Date()),
|
||||
note: '',
|
||||
tagIds: [],
|
||||
ledgerId: currentLedgerId,
|
||||
...initialData,
|
||||
// Override transactionDate again after spreading initialData
|
||||
...(initialData?.transactionDate
|
||||
@@ -96,6 +100,13 @@ export const TransactionForm: React.FC<TransactionFormProps> = ({
|
||||
: {}),
|
||||
});
|
||||
|
||||
// Update ledgerId when currentLedgerId changes (only if not editing or explicit override needed)
|
||||
useEffect(() => {
|
||||
if (currentLedgerId && !isEditing) {
|
||||
setFormData(prev => ({ ...prev, ledgerId: currentLedgerId }));
|
||||
}
|
||||
}, [currentLedgerId, isEditing]);
|
||||
|
||||
// Data State
|
||||
const [accounts, setAccounts] = useState<Account[]>([]);
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
|
||||
68
src/contexts/SettingsContext.tsx
Normal file
68
src/contexts/SettingsContext.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||
import type { UserSettings } from '../types';
|
||||
import { getSettings, updateSettings as apiUpdateSettings } from '../services/settingsService';
|
||||
|
||||
interface SettingsContextType {
|
||||
settings: UserSettings | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refreshSettings: () => Promise<void>;
|
||||
updateSettings: (data: Partial<UserSettings>) => Promise<void>;
|
||||
}
|
||||
|
||||
const SettingsContext = createContext<SettingsContextType | undefined>(undefined);
|
||||
|
||||
export const SettingsProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [settings, setSettings] = useState<UserSettings | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchSettings = useCallback(async () => {
|
||||
try {
|
||||
const data = await getSettings();
|
||||
setSettings(data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error('Failed to load settings:', err);
|
||||
setError('Failed to load settings');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
}, [fetchSettings]);
|
||||
|
||||
const updateSettings = useCallback(async (data: Partial<UserSettings>) => {
|
||||
try {
|
||||
const updated = await apiUpdateSettings(data);
|
||||
setSettings(updated);
|
||||
} catch (err) {
|
||||
console.error('Failed to update settings:', err);
|
||||
throw err;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<SettingsContext.Provider
|
||||
value={{
|
||||
settings,
|
||||
loading,
|
||||
error,
|
||||
refreshSettings: fetchSettings,
|
||||
updateSettings,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</SettingsContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useSettings = (): SettingsContextType => {
|
||||
const context = useContext(SettingsContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useSettings must be used within a SettingsProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -7,7 +7,8 @@ import { getTransactions, calculateTotalExpense } from '../../services/transacti
|
||||
import { getCategories } from '../../services/categoryService';
|
||||
import { getLedgers, reorderLedgers } from '../../services/ledgerService';
|
||||
import { getStreakInfo, type StreakInfoFormatted } from '../../services/streakService';
|
||||
import { getSettings, updateSettings } from '../../services/settingsService';
|
||||
import { updateSettings } from '../../services/settingsService';
|
||||
import { useSettings } from '../../contexts/SettingsContext';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { SpendingTrendChart } from '../../components/charts/SpendingTrendChart';
|
||||
import { Skeleton } from '../../components/common/Skeleton/Skeleton';
|
||||
@@ -24,7 +25,7 @@ import { HealthScoreCard } from '../../components/home/HealthScoreCard/HealthSco
|
||||
import { QuickActionsBox } from '../../components/home/QuickActionsBox/QuickActionsBox';
|
||||
import { getBudgets } from '../../services/budgetService';
|
||||
import { toLocalDateString, isSameDay } from '../../utils/dateUtils';
|
||||
import type { Account, Transaction, Category, Ledger, UserSettings } from '../../types';
|
||||
import type { Account, Transaction, Category, Ledger } from '../../types';
|
||||
|
||||
|
||||
/**
|
||||
@@ -36,7 +37,10 @@ function Home() {
|
||||
const [recentTransactions, setRecentTransactions] = useState<Transaction[]>([]);
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [ledgers, setLedgers] = useState<Ledger[]>([]);
|
||||
const [settings, setSettings] = useState<UserSettings | null>(null);
|
||||
|
||||
const { settings, loading: settingsLoading } = useSettings();
|
||||
const currentLedgerId = settings?.currentLedgerId || 0;
|
||||
|
||||
const [ledgerSelectorOpen, setLedgerSelectorOpen] = useState(false);
|
||||
const [voiceModalOpen, setVoiceModalOpen] = useState(false);
|
||||
const [showAccountForm, setShowAccountForm] = useState(false);
|
||||
@@ -59,8 +63,9 @@ function Home() {
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (settingsLoading) return;
|
||||
loadData();
|
||||
}, []);
|
||||
}, [currentLedgerId, settingsLoading]);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
@@ -82,22 +87,20 @@ function Home() {
|
||||
const rangeStartStr = toLocalDateString(rangeStart);
|
||||
const rangeEndStr = toLocalDateString(rangeEnd);
|
||||
|
||||
const [accountsData, recentTxData, categoriesData, ledgersData, settingsData, budgetsData, streakData, rangeTxData] = await Promise.all([
|
||||
const [accountsData, recentTxData, categoriesData, ledgersData, budgetsData, streakData, rangeTxData] = await Promise.all([
|
||||
getAccounts(),
|
||||
getTransactions({ page: 1, pageSize: 5 }), // Recent list (server sort)
|
||||
getTransactions({ page: 1, pageSize: 5, ledgerId: currentLedgerId }), // Recent list (server sort)
|
||||
getCategories(),
|
||||
getLedgers().catch(() => []),
|
||||
getSettings().catch(() => null),
|
||||
getBudgets().catch(() => []),
|
||||
getStreakInfo().catch(() => null),
|
||||
getTransactions({ startDate: rangeStartStr, endDate: rangeEndStr, pageSize: 1000 }), // Bulk fetch
|
||||
getTransactions({ startDate: rangeStartStr, endDate: rangeEndStr, pageSize: 1000, ledgerId: currentLedgerId }), // Bulk fetch
|
||||
]);
|
||||
|
||||
setAccounts(accountsData || []);
|
||||
setRecentTransactions(recentTxData?.items || []);
|
||||
setCategories(categoriesData || []);
|
||||
setLedgers(ledgersData || []);
|
||||
setSettings(settingsData);
|
||||
setStreakInfo(streakData);
|
||||
|
||||
// In-Memory Aggregation fix
|
||||
@@ -198,12 +201,13 @@ function Home() {
|
||||
|
||||
const handleLedgerSelect = async (ledgerId: number) => {
|
||||
try {
|
||||
if (settings) {
|
||||
await updateSettings({ ...settings, currentLedgerId: ledgerId });
|
||||
setSettings({ ...settings, currentLedgerId: ledgerId });
|
||||
}
|
||||
setLedgerSelectorOpen(false);
|
||||
await loadData();
|
||||
try {
|
||||
if (settings) {
|
||||
await updateSettings({ currentLedgerId: ledgerId });
|
||||
}
|
||||
setLedgerSelectorOpen(false);
|
||||
// Data reload triggered by useEffect on currentLedgerId change
|
||||
} catch (err) { setError('切换账本失败'); }
|
||||
} catch (err) { setError('切换账本失败'); }
|
||||
};
|
||||
const handleLedgerReorder = async (reorderedLedgers: Ledger[]) => {
|
||||
|
||||
@@ -23,6 +23,7 @@ import { getCategories } from '../../services/categoryService';
|
||||
import { getAccounts } from '../../services/accountService';
|
||||
import { suggestAllocationForIncome } from '../../services/allocationRuleService';
|
||||
import type { AllocationResult } from '../../services/allocationRuleService';
|
||||
import { useSettings } from '../../contexts/SettingsContext';
|
||||
import { formatCurrency } from '../../utils';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { TransactionForm } from '../../components/transaction';
|
||||
@@ -66,6 +67,9 @@ export const Transactions: React.FC = () => {
|
||||
return params;
|
||||
});
|
||||
|
||||
const { settings, loading: settingsLoading } = useSettings();
|
||||
const currentLedgerId = settings?.currentLedgerId || 0;
|
||||
|
||||
const [selectedTransaction, setSelectedTransaction] = useState<Transaction | null>(null);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
@@ -115,6 +119,7 @@ export const Transactions: React.FC = () => {
|
||||
|
||||
let params: any = {
|
||||
...filterValues,
|
||||
ledgerId: currentLedgerId,
|
||||
};
|
||||
|
||||
if (viewMode === 'list') {
|
||||
@@ -143,7 +148,7 @@ export const Transactions: React.FC = () => {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[filterValues, viewMode, calendarMonth]
|
||||
[filterValues, viewMode, calendarMonth, currentLedgerId]
|
||||
);
|
||||
|
||||
// Fetch categories and accounts on mount
|
||||
@@ -163,8 +168,9 @@ export const Transactions: React.FC = () => {
|
||||
|
||||
// Fetch transactions when filters change
|
||||
useEffect(() => {
|
||||
if (settingsLoading) return;
|
||||
fetchTransactions(1);
|
||||
}, [fetchTransactions]);
|
||||
}, [fetchTransactions, settingsLoading]);
|
||||
|
||||
// Handle filter changes
|
||||
const handleFilterChange = useCallback((values: FilterValues) => {
|
||||
@@ -511,6 +517,7 @@ export const Transactions: React.FC = () => {
|
||||
onSubmit={handleCreateTransaction}
|
||||
onCancel={() => setShowTransactionForm(false)}
|
||||
loading={formLoading}
|
||||
currentLedgerId={currentLedgerId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import api from './api';
|
||||
import type {
|
||||
Transaction,
|
||||
TransactionFormInput,
|
||||
TransactionFilter,
|
||||
ApiResponse,
|
||||
PaginatedResponse,
|
||||
TransactionType,
|
||||
@@ -16,27 +17,7 @@ import type {
|
||||
RefundStatus,
|
||||
} from '../types';
|
||||
|
||||
/**
|
||||
* Transaction filter parameters
|
||||
*/
|
||||
export interface TransactionFilter {
|
||||
/** Filter by start date (inclusive) */
|
||||
startDate?: string;
|
||||
/** Filter by end date (inclusive) */
|
||||
endDate?: string;
|
||||
/** Filter by category ID */
|
||||
categoryId?: number;
|
||||
/** Filter by account ID */
|
||||
accountId?: number;
|
||||
/** Filter by transaction type */
|
||||
type?: TransactionType;
|
||||
/** Search in notes */
|
||||
search?: string;
|
||||
/** Page number (1-based) */
|
||||
page?: number;
|
||||
/** Items per page */
|
||||
pageSize?: number;
|
||||
}
|
||||
// TransactionFilter is imported from types/index.ts
|
||||
|
||||
/**
|
||||
* Transform API response transaction to frontend Transaction type
|
||||
@@ -79,6 +60,8 @@ export async function getTransactions(
|
||||
if (filter.categoryId) params.category_id = filter.categoryId;
|
||||
if (filter.accountId) params.account_id = filter.accountId;
|
||||
if (filter.type) params.type = filter.type;
|
||||
if (filter.currency) params.currency = filter.currency;
|
||||
if (filter.ledgerId) params.ledger_id = filter.ledgerId;
|
||||
if (filter.search) params.note_search = filter.search;
|
||||
if (filter.page) params.offset = ((filter.page - 1) * (filter.pageSize || 20)).toString();
|
||||
if (filter.pageSize) params.limit = filter.pageSize;
|
||||
@@ -153,6 +136,7 @@ export async function createTransaction(data: TransactionFormInput): Promise<Tra
|
||||
transaction_date: data.transactionDate,
|
||||
note: data.note,
|
||||
tag_ids: data.tagIds,
|
||||
ledger_id: data.ledgerId,
|
||||
};
|
||||
const response = await api.post<ApiResponse<Transaction>>('/transactions', payload);
|
||||
if (!response.data) {
|
||||
@@ -178,6 +162,7 @@ export async function updateTransaction(
|
||||
if (data.transactionDate !== undefined) payload.transaction_date = data.transactionDate;
|
||||
if (data.note !== undefined) payload.note = data.note;
|
||||
if (data.tagIds !== undefined) payload.tag_ids = data.tagIds;
|
||||
if (data.ledgerId !== undefined) payload.ledger_id = data.ledgerId;
|
||||
|
||||
const response = await api.put<ApiResponse<Transaction>>(`/transactions/${id}`, payload);
|
||||
if (!response.data) {
|
||||
|
||||
@@ -286,6 +286,7 @@ export interface TransactionFormInput {
|
||||
transactionDate: string;
|
||||
note?: string;
|
||||
tagIds?: number[];
|
||||
ledgerId?: number;
|
||||
}
|
||||
|
||||
export interface AccountFormInput {
|
||||
@@ -517,3 +518,20 @@ export interface UpdateDefaultAccountsInput {
|
||||
defaultExpenseAccountId?: number | null;
|
||||
defaultIncomeAccountId?: number | null;
|
||||
}
|
||||
|
||||
// Transaction List Filter Params
|
||||
export interface TransactionFilter {
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
categoryId?: number;
|
||||
accountId?: number;
|
||||
ledgerId?: number;
|
||||
type?: TransactionType;
|
||||
currency?: CurrencyCode;
|
||||
noteSearch?: string;
|
||||
sortField?: string;
|
||||
sortAsc?: boolean;
|
||||
search?: string; // Alias/Helper for noteSearch
|
||||
page?: number; // Helper for offset calculation
|
||||
pageSize?: number; // Helper for limit
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user