feat: 实现核心应用结构、全局命令面板和初步交易相关页面及服务。

This commit is contained in:
2026-01-30 15:40:59 +08:00
parent d919a42d15
commit 5e587837e8
9 changed files with 201 additions and 40 deletions

View File

@@ -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>

View File

@@ -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);

View File

@@ -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[]>([]);

View 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;
};

View File

@@ -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[]) => {

View File

@@ -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>

View File

@@ -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) {

View File

@@ -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
}