feat: 新增汇率管理页面,支持净资产历史趋势、货币转换,并引入交易相关组件和预算页面

This commit is contained in:
2026-01-29 07:58:35 +08:00
parent 732793c5c3
commit fbdd9cf0ae
9 changed files with 125 additions and 23 deletions

View File

@@ -2,6 +2,7 @@ import React, { useMemo } from 'react';
import ReactECharts from 'echarts-for-react'; import ReactECharts from 'echarts-for-react';
import type { Transaction } from '../../types'; import type { Transaction } from '../../types';
import { formatCurrency } from '../../utils/format'; import { formatCurrency } from '../../utils/format';
import { toLocalDateString } from '../../utils/dateUtils';
interface SpendingTrendChartProps { interface SpendingTrendChartProps {
transactions: Transaction[]; transactions: Transaction[];
@@ -20,11 +21,11 @@ export const SpendingTrendChart: React.FC<SpendingTrendChartProps> = ({
for (let i = days - 1; i >= 0; i--) { for (let i = days - 1; i >= 0; i--) {
const d = new Date(today); const d = new Date(today);
d.setDate(d.getDate() - i); d.setDate(d.getDate() - i);
const dateStr = d.toISOString().split('T')[0]; const dateStr = toLocalDateString(d);
const displayDate = `${d.getMonth() + 1}/${d.getDate()}`; const displayDate = `${d.getMonth() + 1}/${d.getDate()}`;
const dailyTotal = transactions const dailyTotal = transactions
.filter(t => t.type === 'expense' && t.transactionDate.startsWith(dateStr)) .filter(t => t.type === 'expense' && toLocalDateString(t.transactionDate) === dateStr)
.reduce((sum, t) => sum + Math.abs(t.amount), 0); .reduce((sum, t) => sum + Math.abs(t.amount), 0);
data.push({ data.push({

View File

@@ -7,6 +7,7 @@ import React, { useMemo } from 'react';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import type { Transaction } from '../../../types'; import type { Transaction } from '../../../types';
import { formatCurrency } from '../../../utils/format'; import { formatCurrency } from '../../../utils/format';
import { toLocalDateString } from '../../../utils/dateUtils';
import './TransactionCalendar.css'; import './TransactionCalendar.css';
interface TransactionCalendarProps { interface TransactionCalendarProps {
@@ -83,7 +84,7 @@ export const TransactionCalendar: React.FC<TransactionCalendarProps> = ({
transactions.forEach(t => { transactions.forEach(t => {
// Handle timezone issues by taking date part only // Handle timezone issues by taking date part only
const dateStr = t.transactionDate.split('T')[0]; // Assuming ISO string const dateStr = toLocalDateString(t.transactionDate);
if (!stats.has(dateStr)) { if (!stats.has(dateStr)) {
stats.set(dateStr, { income: 0, expense: 0, transactions: [] }); stats.set(dateStr, { income: 0, expense: 0, transactions: [] });
} }

View File

@@ -10,6 +10,7 @@ import type { Category, Account, TransactionType } from '../../../types';
import { getCategories } from '../../../services/categoryService'; import { getCategories } from '../../../services/categoryService';
import { getAccounts } from '../../../services/accountService'; import { getAccounts } from '../../../services/accountService';
import { getDisplayIcon } from '../../../utils/iconUtils'; import { getDisplayIcon } from '../../../utils/iconUtils';
import { toLocalDateString } from '../../../utils/dateUtils';
import './TransactionFilter.css'; import './TransactionFilter.css';
export interface FilterValues { export interface FilterValues {
@@ -58,35 +59,37 @@ const DATE_PRESETS = [
* Get date range based on preset * Get date range based on preset
*/ */
function getDateRange(preset: 'today' | 'week' | 'month' | 'year'): { function getDateRange(preset: 'today' | 'week' | 'month' | 'year'): {
startDate: string; startDate: string;
endDate: string; endDate: string;
} { } {
const now = new Date(); const now = new Date();
const today = now.toISOString().split('T')[0]; const today = toLocalDateString(now);
switch (preset) { switch (preset) {
case 'today': case 'today':
return { startDate: today, endDate: today }; return { startDate: today, endDate: today };
case 'week': { case 'week': {
const dayOfWeek = now.getDay(); const dayOfWeek = now.getDay() || 7; // Monday = 1, Sunday = 7
const startOfWeek = new Date(now); const startOfWeek = new Date(now);
startOfWeek.setDate(now.getDate() - dayOfWeek); // Adjust to Monday of current week
startOfWeek.setDate(now.getDate() - dayOfWeek + 1);
return { return {
startDate: startOfWeek.toISOString().split('T')[0], startDate: toLocalDateString(startOfWeek),
endDate: today, endDate: today,
}; };
} }
case 'month': { case 'month': {
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1); const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
return { return {
startDate: startOfMonth.toISOString().split('T')[0], startDate: toLocalDateString(startOfMonth),
endDate: today, endDate: today,
}; };
} }
case 'year': { case 'year': {
const startOfYear = new Date(now.getFullYear(), 0, 1); const startOfYear = new Date(now.getFullYear(), 0, 1);
return { return {
startDate: startOfYear.toISOString().split('T')[0], startDate: toLocalDateString(startOfYear),
endDate: today, endDate: today,
}; };
} }

View File

@@ -43,22 +43,30 @@ interface TransactionListProps {
* Format date header for grouping * Format date header for grouping
*/ */
function formatDateHeader(dateString: string): string { function formatDateHeader(dateString: string): string {
// dateString is YYYY-MM-DD (local) from grouping logic
const today = new Date(); const today = new Date();
const year = today.getFullYear();
const month = String(today.getMonth() + 1).padStart(2, '0');
const day = String(today.getDate()).padStart(2, '0');
const todayStr = `${year}-${month}-${day}`;
const yesterday = new Date(today); const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1); yesterday.setDate(yesterday.getDate() - 1);
const yYear = yesterday.getFullYear();
const yMonth = String(yesterday.getMonth() + 1).padStart(2, '0');
const yDay = String(yesterday.getDate()).padStart(2, '0');
const yesterdayStr = `${yYear}-${yMonth}-${yDay}`;
const dateOnly = dateString.split('T')[0]; if (dateString === todayStr) {
const todayOnly = today.toISOString().split('T')[0];
const yesterdayOnly = yesterday.toISOString().split('T')[0];
if (dateOnly === todayOnly) {
return '今天'; return '今天';
} }
if (dateOnly === yesterdayOnly) { if (dateString === yesterdayStr) {
return '昨天'; return '昨天';
} }
return formatDate(dateString, { // Construct a date object that represents this local date specifically for formatting
// Using T00:00:00 to ensure it's treated as local time, not UTC
return formatDate(dateString + 'T00:00:00', {
year: 'numeric', year: 'numeric',
month: 'long', month: 'long',
day: 'numeric', day: 'numeric',

View File

@@ -33,6 +33,7 @@ import { createTransaction } from '../../services/transactionService';
import { TransactionForm } from '../../components/transaction'; import { TransactionForm } from '../../components/transaction';
import { getCategories } from '../../services/categoryService'; import { getCategories } from '../../services/categoryService';
import { getAccounts } from '../../services/accountService'; import { getAccounts } from '../../services/accountService';
import { toLocalDateString } from '../../utils/dateUtils';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import './Budget.css'; import './Budget.css';
@@ -305,7 +306,7 @@ function Budget() {
type: 'expense', type: 'expense',
categoryId: budget.categoryId || 0, categoryId: budget.categoryId || 0,
accountId: budget.accountId || 0, accountId: budget.accountId || 0,
transactionDate: new Date().toISOString().split('T')[0], transactionDate: toLocalDateString(new Date()),
amount: 0 amount: 0
}); });
setShowTransactionForm(true); setShowTransactionForm(true);

View File

@@ -17,6 +17,7 @@ import {
} from '../../services/exchangeRateService'; } from '../../services/exchangeRateService';
import { getAccounts, calculateTotalBalance } from '../../services/accountService'; import { getAccounts, calculateTotalBalance } from '../../services/accountService';
import { getTransactions } from '../../services/transactionService'; import { getTransactions } from '../../services/transactionService';
import { toLocalDateString } from '../../utils/dateUtils';
import { ExchangeRateCard } from '../../components/exchangeRate/ExchangeRateCard'; import { ExchangeRateCard } from '../../components/exchangeRate/ExchangeRateCard';
import NetWorthCard from '../../components/exchangeRate/NetWorthCard/NetWorthCard'; import NetWorthCard from '../../components/exchangeRate/NetWorthCard/NetWorthCard';
import { SyncStatusBar } from '../../components/exchangeRate/SyncStatusBar'; import { SyncStatusBar } from '../../components/exchangeRate/SyncStatusBar';
@@ -107,8 +108,8 @@ const ExchangeRates: React.FC = () => {
startDate.setDate(startDate.getDate() - 30); startDate.setDate(startDate.getDate() - 30);
const { items: transactions } = await getTransactions({ const { items: transactions } = await getTransactions({
startDate: startDate.toISOString().split('T')[0], startDate: toLocalDateString(startDate),
endDate: endDate.toISOString().split('T')[0], endDate: toLocalDateString(endDate),
pageSize: 1000 // Ensure we get all relevant transactions pageSize: 1000 // Ensure we get all relevant transactions
}); });
@@ -122,7 +123,7 @@ const ExchangeRates: React.FC = () => {
for (let i = 0; i < 30; i++) { for (let i = 0; i < 30; i++) {
const date = new Date(); const date = new Date();
date.setDate(date.getDate() - i); date.setDate(date.getDate() - i);
const dateStr = date.toISOString().split('T')[0]; // YYYY-MM-DD const dateStr = toLocalDateString(date); // YYYY-MM-DD
// Push current day's End-of-Day balance // Push current day's End-of-Day balance
// Note: Array is being built backwards (Today ... 30 days ago) // Note: Array is being built backwards (Today ... 30 days ago)

View File

@@ -178,7 +178,7 @@ export async function updateTransaction(
if (data.transactionDate !== undefined) payload.transaction_date = data.transactionDate; if (data.transactionDate !== undefined) payload.transaction_date = data.transactionDate;
if (data.note !== undefined) payload.note = data.note; if (data.note !== undefined) payload.note = data.note;
if (data.tagIds !== undefined) payload.tag_ids = data.tagIds; if (data.tagIds !== undefined) payload.tag_ids = data.tagIds;
const response = await api.put<ApiResponse<Transaction>>(`/transactions/${id}`, payload); const response = await api.put<ApiResponse<Transaction>>(`/transactions/${id}`, payload);
if (!response.data) { if (!response.data) {
throw new Error(response.error || 'Failed to update transaction'); throw new Error(response.error || 'Failed to update transaction');
@@ -209,7 +209,14 @@ export function groupTransactionsByDate(transactions: Transaction[] | undefined)
if (!dateValue) { if (!dateValue) {
continue; // Skip transactions without a date continue; // Skip transactions without a date
} }
const date = dateValue.split('T')[0];
// Fix: Use local time for grouping instead of UTC string split
const d = new Date(dateValue);
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
const date = `${year}-${month}-${day}`;
const existing = groups.get(date) || []; const existing = groups.get(date) || [];
existing.push(transaction); existing.push(transaction);
groups.set(date, existing); groups.set(date, existing);

80
src/utils/dateUtils.ts Normal file
View File

@@ -0,0 +1,80 @@
/**
* Date Utilities
* Handles local date formatting and manipulation to avoid UTC timezone issues.
*/
/**
* Formats a date object to 'YYYY-MM-DD' string using local time.
* Replaces: date.toISOString().split('T')[0]
*/
export function toLocalDateString(date: Date | string | number): string {
const d = new Date(date);
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
/**
* Checks if two dates refer to the same day in local time.
*/
export function isSameDay(date1: Date | string, date2: Date | string): boolean {
const d1 = new Date(date1);
const d2 = new Date(date2);
return (
d1.getFullYear() === d2.getFullYear() &&
d1.getMonth() === d2.getMonth() &&
d1.getDate() === d2.getDate()
);
}
/**
* Returns a new Date object set to the start of the day (00:00:00.000) in local time.
*/
export function getStartOfDay(date: Date = new Date()): Date {
const d = new Date(date);
d.setHours(0, 0, 0, 0);
return d;
}
/**
* Returns a new Date object set to the end of the day (23:59:59.999) in local time.
*/
export function getEndOfDay(date: Date = new Date()): Date {
const d = new Date(date);
d.setHours(23, 59, 59, 999);
return d;
}
/**
* Calculates date range helpers
*/
export const DateRanges = {
today: () => {
const start = getStartOfDay();
const end = getEndOfDay();
return { start, end, startStr: toLocalDateString(start), endStr: toLocalDateString(end) };
},
yesterday: () => {
const start = getStartOfDay();
start.setDate(start.getDate() - 1);
const end = getEndOfDay(start);
return { start, end, startStr: toLocalDateString(start), endStr: toLocalDateString(end) };
},
thisWeek: () => {
const now = new Date();
const day = now.getDay() || 7; // Sunday is 0, make it 7 for ISO week (Mon-Sun)? Or standard week. Let's assume Mon start.
const start = getStartOfDay();
if (day !== 1) {
start.setDate(now.getDate() - day + 1 + (day === 0 ? -7 : 0)); // Move to Monday
}
const end = getEndOfDay();
return { start, end, startStr: toLocalDateString(start), endStr: toLocalDateString(end) };
},
thisMonth: () => {
const now = new Date();
const start = getStartOfDay(new Date(now.getFullYear(), now.getMonth(), 1));
const end = getEndOfDay(new Date(now.getFullYear(), now.getMonth() + 1, 0));
return { start, end, startStr: toLocalDateString(start), endStr: toLocalDateString(end) };
}
};

View File

@@ -3,4 +3,4 @@
*/ */
export { formatCurrency, formatDate, formatRelativeTime, formatPercentage } from './format'; export { formatCurrency, formatDate, formatRelativeTime, formatPercentage } from './format';
export * from './dateUtils';