feat: 初始化财务管理应用前端项目,包含账户、预算、交易、报表、设置等核心功能模块。

This commit is contained in:
2026-01-26 01:45:39 +08:00
parent fd7cb4485c
commit 8eaa4dbd11
212 changed files with 30536 additions and 186 deletions

View File

@@ -0,0 +1 @@
# API service layer - HTTP client and API calls

View File

@@ -0,0 +1,158 @@
/**
* Account Service - API calls for account management
*/
import api from './api';
import type { Account, AccountFormInput, TransferFormInput, ApiResponse } from '../types';
/**
* Get all accounts
*/
export async function getAccounts(): Promise<Account[]> {
const response = await api.get<ApiResponse<Account[]>>('/accounts');
return response.data || [];
}
/**
* Get a single account by ID
*/
export async function getAccount(id: number): Promise<Account> {
const response = await api.get<ApiResponse<Account>>(`/accounts/${id}`);
if (!response.data) {
throw new Error('Account not found');
}
return response.data;
}
/**
* Create a new account
*/
export async function createAccount(data: AccountFormInput): Promise<Account> {
// Convert camelCase to snake_case for backend
const payload = {
name: data.name,
type: data.type,
balance: data.balance,
currency: data.currency,
icon: data.icon,
billing_date: data.billingDate,
payment_date: data.paymentDate,
};
const response = await api.post<ApiResponse<Account>>('/accounts', payload);
if (!response.data) {
throw new Error(response.error || 'Failed to create account');
}
return response.data;
}
/**
* Update an existing account
*/
export async function updateAccount(id: number, data: Partial<AccountFormInput>): Promise<Account> {
// Convert camelCase to snake_case for backend
const payload: Record<string, unknown> = {};
if (data.name !== undefined) payload.name = data.name;
if (data.type !== undefined) payload.type = data.type;
if (data.balance !== undefined) payload.balance = data.balance;
if (data.currency !== undefined) payload.currency = data.currency;
if (data.icon !== undefined) payload.icon = data.icon;
if (data.billingDate !== undefined) payload.billing_date = data.billingDate;
if (data.paymentDate !== undefined) payload.payment_date = data.paymentDate;
const response = await api.put<ApiResponse<Account>>(`/accounts/${id}`, payload);
if (!response.data) {
throw new Error(response.error || 'Failed to update account');
}
return response.data;
}
/**
* Delete an account
*/
export async function deleteAccount(id: number): Promise<void> {
await api.delete<ApiResponse<void>>(`/accounts/${id}`);
}
/**
* Transfer between accounts
*/
export async function transferBetweenAccounts(data: TransferFormInput): Promise<void> {
// Convert camelCase to snake_case for backend
const payload = {
from_account_id: data.fromAccountId,
to_account_id: data.toAccountId,
amount: data.amount,
note: data.note,
};
const response = await api.post<ApiResponse<void>>('/accounts/transfer', payload);
if (!response.success && response.error) {
throw new Error(response.error);
}
}
/**
* Get accounts grouped by type
*/
export function groupAccountsByType(accounts: Account[] | undefined): Record<string, Account[]> {
if (!accounts || !Array.isArray(accounts)) {
return {};
}
return accounts.reduce(
(groups, account) => {
const type = account.type;
if (!groups[type]) {
groups[type] = [];
}
groups[type].push(account);
return groups;
},
{} as Record<string, Account[]>
);
}
/**
* Calculate total balance for accounts
*/
export function calculateTotalBalance(accounts: Account[] | undefined): number {
if (!accounts || !Array.isArray(accounts)) {
return 0;
}
return accounts.reduce((total, account) => total + account.balance, 0);
}
/**
* Calculate total assets (positive balances)
*/
export function calculateTotalAssets(accounts: Account[] | undefined): number {
if (!accounts || !Array.isArray(accounts)) {
return 0;
}
return accounts
.filter((account) => account.balance > 0)
.reduce((total, account) => total + account.balance, 0);
}
/**
* Calculate total liabilities (negative balances)
*/
export function calculateTotalLiabilities(accounts: Account[] | undefined): number {
if (!accounts || !Array.isArray(accounts)) {
return 0;
}
return accounts
.filter((account) => account.balance < 0)
.reduce((total, account) => total + Math.abs(account.balance), 0);
}
export default {
getAccounts,
getAccount,
createAccount,
updateAccount,
deleteAccount,
transferBetweenAccounts,
groupAccountsByType,
calculateTotalBalance,
calculateTotalAssets,
calculateTotalLiabilities,
};

View File

@@ -0,0 +1,247 @@
/**
* Allocation Rule Service
* Handles API calls for income allocation rules
*/
import api from './api';
import type { AllocationRule, AllocationTarget, Account, AccountType, CurrencyCode } from '../types';
/**
* Transform API response to frontend format (snake_case to camelCase)
*/
function transformAllocationRule(data: Record<string, unknown>): AllocationRule {
return {
id: data.id as number,
name: data.name as string,
triggerType: (data.trigger_type ?? data.triggerType) as string,
sourceAccountId: (data.source_account_id ?? data.sourceAccountId) as number | undefined,
sourceAccount: data.source_account ? transformSourceAccount(data.source_account as Record<string, unknown>) : undefined,
isActive: (data.is_active ?? data.isActive) as boolean,
targets: ((data.targets as Record<string, unknown>[]) || []).map(transformAllocationTarget),
createdAt: (data.created_at ?? data.createdAt) as string,
};
}
function transformSourceAccount(data: Record<string, unknown>): Account {
return {
id: data.id as number,
name: data.name as string,
type: data.type as AccountType,
balance: data.balance as number,
currency: data.currency as CurrencyCode,
icon: data.icon as string,
isCredit: (data.is_credit ?? data.isCredit) as boolean,
billingDate: (data.billing_date ?? data.billingDate) as number | undefined,
paymentDate: (data.payment_date ?? data.paymentDate) as number | undefined,
createdAt: (data.created_at ?? data.createdAt) as string,
updatedAt: (data.updated_at ?? data.updatedAt) as string,
};
}
function transformAllocationTarget(data: Record<string, unknown>): AllocationTarget {
return {
id: data.id as number,
ruleId: (data.rule_id ?? data.ruleId) as number,
targetType: (data.target_type ?? data.targetType) as 'account' | 'piggy_bank',
targetId: (data.target_id ?? data.targetId) as number,
percentage: data.percentage as number | undefined,
fixedAmount: (data.fixed_amount ?? data.fixedAmount) as number | undefined,
};
}
/**
* Form input for creating/updating allocation rules
*/
export interface AllocationRuleFormInput {
name: string;
triggerType: 'income' | 'manual';
sourceAccountId?: number;
isActive: boolean;
targets: AllocationTargetInput[];
}
/**
* Form input for allocation targets
*/
export interface AllocationTargetInput {
targetType: 'account' | 'piggy_bank';
targetId: number;
percentage?: number;
fixedAmount?: number;
}
/**
* Result of applying an allocation rule
*/
export interface AllocationResult {
ruleId: number;
ruleName: string;
totalAmount: number;
allocatedAmount: number;
remaining: number;
allocations: AllocationDetail[];
}
/**
* Detail of a single allocation
*/
export interface AllocationDetail {
targetType: 'account' | 'piggy_bank';
targetId: number;
targetName: string;
amount: number;
percentage?: number;
fixedAmount?: number;
}
/**
* Input for applying an allocation rule
*/
export interface ApplyAllocationInput {
amount: number;
fromAccountId?: number;
note?: string;
}
/**
* Get all allocation rules
*/
export const getAllocationRules = async (): Promise<AllocationRule[]> => {
const response = await api.get<{ success: boolean; data: Record<string, unknown>[] }>('/allocation-rules');
return (response.data || []).map(transformAllocationRule);
};
/**
* Get active allocation rules only
*/
export const getActiveAllocationRules = async (): Promise<AllocationRule[]> => {
const response = await api.get<{ success: boolean; data: Record<string, unknown>[] }>('/allocation-rules?active=true');
return (response.data || []).map(transformAllocationRule);
};
/**
* Get a single allocation rule by ID
*/
export const getAllocationRule = async (id: number): Promise<AllocationRule> => {
const response = await api.get<{ success: boolean; data: Record<string, unknown> }>(`/allocation-rules/${id}`);
if (!response.data) {
throw new Error('Allocation rule not found');
}
return transformAllocationRule(response.data);
};
/**
* Create a new allocation rule
*/
export const createAllocationRule = async (
data: AllocationRuleFormInput
): Promise<AllocationRule> => {
// Convert camelCase to snake_case for backend
const payload = {
name: data.name,
trigger_type: data.triggerType,
source_account_id: data.sourceAccountId,
is_active: data.isActive,
targets: data.targets.map(t => ({
target_type: t.targetType,
target_id: t.targetId,
percentage: t.percentage,
fixed_amount: t.fixedAmount,
})),
};
const response = await api.post<{ success: boolean; data: Record<string, unknown> }>('/allocation-rules', payload);
if (!response.data) {
throw new Error('Failed to create allocation rule');
}
return transformAllocationRule(response.data);
};
/**
* Update an existing allocation rule
*/
export const updateAllocationRule = async (
id: number,
data: AllocationRuleFormInput
): Promise<AllocationRule> => {
// Convert camelCase to snake_case for backend
const payload = {
name: data.name,
trigger_type: data.triggerType,
source_account_id: data.sourceAccountId,
is_active: data.isActive,
targets: data.targets.map(t => ({
target_type: t.targetType,
target_id: t.targetId,
percentage: t.percentage,
fixed_amount: t.fixedAmount,
})),
};
const response = await api.put<{ success: boolean; data: Record<string, unknown> }>(`/allocation-rules/${id}`, payload);
if (!response.data) {
throw new Error('Failed to update allocation rule');
}
return transformAllocationRule(response.data);
};
/**
* Delete an allocation rule
*/
export const deleteAllocationRule = async (id: number): Promise<void> => {
await api.delete(`/allocation-rules/${id}`);
};
/**
* Apply an allocation rule to distribute an amount
*/
export const applyAllocationRule = async (
id: number,
data: ApplyAllocationInput
): Promise<AllocationResult> => {
// Convert camelCase to snake_case for backend
const payload = {
amount: data.amount,
from_account_id: data.fromAccountId,
note: data.note,
};
const response = await api.post<{ success: boolean; data: AllocationResult }>(`/allocation-rules/${id}/apply`, payload);
if (!response.data) {
throw new Error('Failed to apply allocation rule');
}
return response.data;
};
/**
* Get suggested allocation rules for income
*/
export const suggestAllocationForIncome = async (amount: number, accountId: number): Promise<AllocationRule[]> => {
const response = await api.get<{ success: boolean; data: Record<string, unknown>[] }>(`/allocation-rules/suggest?amount=${amount}&account_id=${accountId}`);
return (response.data || []).map(transformAllocationRule);
};
/**
* Get trigger type label
*/
export const getTriggerTypeLabel = (type: string): string => {
switch (type) {
case 'income':
return '收入触发';
case 'manual':
return '手动触发';
default:
return type;
}
};
/**
* Get target type label
*/
export const getTargetTypeLabel = (type: string): string => {
switch (type) {
case 'account':
return '账户';
case 'piggy_bank':
return '存钱罐';
default:
return type;
}
};

187
copy/src/services/api.ts Normal file
View File

@@ -0,0 +1,187 @@
/**
* API Service - HTTP client configuration and base API calls
* Feature: api-interface-optimization
* Validates: Requirements 12.3, 12.4
*/
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080/api/v1';
// Token storage keys
const ACCESS_TOKEN_KEY = 'access_token';
const REFRESH_TOKEN_KEY = 'refresh_token';
interface RequestOptions extends RequestInit {
params?: Record<string, string | number | boolean | undefined>;
skipAuth?: boolean;
}
/**
* Get access token from storage
*/
function getAccessToken(): string | null {
return localStorage.getItem(ACCESS_TOKEN_KEY);
}
/**
* Get refresh token from storage
*/
function getRefreshToken(): string | null {
return localStorage.getItem(REFRESH_TOKEN_KEY);
}
/**
* Set tokens in storage
*/
function setTokens(accessToken: string, refreshToken: string): void {
localStorage.setItem(ACCESS_TOKEN_KEY, accessToken);
localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken);
}
/**
* Clear tokens from storage
*/
function clearTokens(): void {
localStorage.removeItem(ACCESS_TOKEN_KEY);
localStorage.removeItem(REFRESH_TOKEN_KEY);
}
/**
* Build URL with query parameters
*/
function buildUrl(
endpoint: string,
params?: Record<string, string | number | boolean | undefined>
): string {
const url = new URL(`${API_BASE_URL}${endpoint}`);
if (params) {
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined) {
url.searchParams.append(key, String(value));
}
});
}
return url.toString();
}
/**
* Refresh access token
*/
async function refreshAccessToken(): Promise<boolean> {
const refreshToken = getRefreshToken();
if (!refreshToken) {
return false;
}
try {
const response = await fetch(`${API_BASE_URL}/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh_token: refreshToken }),
});
if (!response.ok) {
clearTokens();
return false;
}
const data = await response.json();
if (data.success && data.data) {
setTokens(data.data.access_token, data.data.refresh_token);
return true;
}
return false;
} catch {
clearTokens();
return false;
}
}
/**
* Generic fetch wrapper with error handling and automatic token refresh
* Validates: Requirements 12.3, 12.4
*/
async function request<T>(endpoint: string, options: RequestOptions = {}): Promise<T> {
const { params, skipAuth, ...fetchOptions } = options;
const url = buildUrl(endpoint, params);
const defaultHeaders: HeadersInit = {
'Content-Type': 'application/json',
};
// Add Authorization header if token exists and not skipped
if (!skipAuth) {
const accessToken = getAccessToken();
if (accessToken) {
(defaultHeaders as Record<string, string>)['Authorization'] = `Bearer ${accessToken}`;
}
}
let response = await fetch(url, {
...fetchOptions,
headers: {
...defaultHeaders,
...fetchOptions.headers,
},
});
// If unauthorized, try to refresh token and retry
if (response.status === 401 && !skipAuth) {
const refreshed = await refreshAccessToken();
if (refreshed) {
const newToken = getAccessToken();
if (newToken) {
(defaultHeaders as Record<string, string>)['Authorization'] = `Bearer ${newToken}`;
response = await fetch(url, {
...fetchOptions,
headers: {
...defaultHeaders,
...fetchOptions.headers,
},
});
}
}
}
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error?.message || errorData.error || `HTTP error! status: ${response.status}`);
}
// Handle 204 No Content - no response body
if (response.status === 204) {
return undefined as T;
}
// Check if response has content
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
return response.json();
}
// If no JSON content, return empty object
return {} as T;
}
/**
* HTTP methods
*/
export const api = {
get: <T>(endpoint: string, params?: Record<string, string | number | boolean | undefined>) =>
request<T>(endpoint, { method: 'GET', params }),
post: <T>(endpoint: string, data?: unknown) =>
request<T>(endpoint, {
method: 'POST',
body: data ? JSON.stringify(data) : undefined,
}),
put: <T>(endpoint: string, data?: unknown) =>
request<T>(endpoint, {
method: 'PUT',
body: data ? JSON.stringify(data) : undefined,
}),
delete: <T>(endpoint: string) => request<T>(endpoint, { method: 'DELETE' }),
};
export default api;

View File

@@ -0,0 +1,126 @@
/**
* App Lock Service - API calls for application lock management
*/
import api from './api';
import type { ApiResponse } from '../types';
/**
* App lock status interface
*/
export interface AppLockStatus {
is_enabled: boolean;
is_locked: boolean;
failed_attempts: number;
locked_until?: string;
}
/**
* Set password request interface
*/
export interface SetPasswordRequest {
password: string;
}
/**
* Verify password request interface
*/
export interface VerifyPasswordRequest {
password: string;
}
/**
* Verify password response interface
*/
export interface VerifyPasswordResponse {
valid: boolean;
failed_attempts: number;
locked_until?: string;
}
/**
* Change password request interface
*/
export interface ChangePasswordRequest {
old_password: string;
new_password: string;
}
/**
* Disable lock request interface
*/
export interface DisableLockRequest {
password: string;
}
/**
* Get app lock status
*/
export async function getAppLockStatus(): Promise<AppLockStatus> {
const response = await api.get<ApiResponse<AppLockStatus>>('/app-lock/status');
if (!response.data) {
throw new Error(response.error || 'Failed to get app lock status');
}
return response.data;
}
/**
* Set app lock password
*/
export async function setAppLockPassword(password: string): Promise<void> {
const response = await api.post<ApiResponse<void>>('/app-lock/password', {
password,
});
if (!response.success && response.error) {
throw new Error(response.error);
}
}
/**
* Verify app lock password
*/
export async function verifyAppLockPassword(password: string): Promise<VerifyPasswordResponse> {
const response = await api.post<ApiResponse<VerifyPasswordResponse>>('/app-lock/verify', {
password,
});
if (!response.data) {
throw new Error(response.error || 'Failed to verify password');
}
return response.data;
}
/**
* Change app lock password
*/
export async function changeAppLockPassword(
oldPassword: string,
newPassword: string
): Promise<void> {
const response = await api.post<ApiResponse<void>>('/app-lock/password/change', {
old_password: oldPassword,
new_password: newPassword,
});
if (!response.success && response.error) {
throw new Error(response.error);
}
}
/**
* Disable app lock
*/
export async function disableAppLock(password: string): Promise<void> {
const response = await api.post<ApiResponse<void>>('/app-lock/disable', {
password,
});
if (!response.success && response.error) {
throw new Error(response.error);
}
}
export default {
getAppLockStatus,
setAppLockPassword,
verifyAppLockPassword,
changeAppLockPassword,
disableAppLock,
};

View File

@@ -0,0 +1,179 @@
/**
* Authentication Service - Handles user authentication operations
* Feature: api-interface-optimization
* Validates: Requirements 12, 13
*/
import api from './api';
// Token storage keys
const ACCESS_TOKEN_KEY = 'access_token';
const REFRESH_TOKEN_KEY = 'refresh_token';
// Types
export interface User {
id: number;
email: string;
username: string;
avatar?: string;
is_active: boolean;
created_at: string;
updated_at: string;
}
export interface TokenPair {
access_token: string;
refresh_token: string;
expires_in: number;
}
export interface RegisterInput {
email: string;
password: string;
username: string;
}
export interface LoginInput {
email: string;
password: string;
}
export interface AuthResponse {
success: boolean;
data: {
user: User;
tokens: TokenPair;
};
}
export interface RefreshResponse {
success: boolean;
data: TokenPair;
}
// Token management
export function getAccessToken(): string | null {
return localStorage.getItem(ACCESS_TOKEN_KEY);
}
export function getRefreshToken(): string | null {
return localStorage.getItem(REFRESH_TOKEN_KEY);
}
export function setTokens(tokens: TokenPair): void {
localStorage.setItem(ACCESS_TOKEN_KEY, tokens.access_token);
localStorage.setItem(REFRESH_TOKEN_KEY, tokens.refresh_token);
}
export function clearTokens(): void {
localStorage.removeItem(ACCESS_TOKEN_KEY);
localStorage.removeItem(REFRESH_TOKEN_KEY);
}
export function isAuthenticated(): boolean {
return !!getAccessToken();
}
/**
* Register a new user
* Validates: Requirements 12.1, 12.2
*/
export async function register(input: RegisterInput): Promise<{ user: User; tokens: TokenPair }> {
const response = await api.post<AuthResponse>('/auth/register', input);
if (response.success && response.data) {
setTokens(response.data.tokens);
return response.data;
}
throw new Error('Registration failed');
}
/**
* Login with email and password
* Validates: Requirements 12.2
*/
export async function login(input: LoginInput): Promise<{ user: User; tokens: TokenPair }> {
const response = await api.post<AuthResponse>('/auth/login', input);
if (response.success && response.data) {
setTokens(response.data.tokens);
return response.data;
}
throw new Error('Login failed');
}
/**
* Refresh access token using refresh token
* Validates: Requirements 12.4
*/
export async function refreshAccessToken(): Promise<TokenPair> {
const refreshToken = getRefreshToken();
if (!refreshToken) {
throw new Error('No refresh token available');
}
const response = await api.post<RefreshResponse>('/auth/refresh', {
refresh_token: refreshToken,
});
if (response.success && response.data) {
setTokens(response.data);
return response.data;
}
throw new Error('Token refresh failed');
}
/**
* Logout - clear tokens
*/
export function logout(): void {
clearTokens();
}
/**
* Get GitHub OAuth login URL
* Validates: Requirements 13.1
*/
export function getGitHubLoginUrl(state?: string): string {
const baseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080/api/v1';
const url = new URL(`${baseUrl}/auth/github`);
if (state) {
url.searchParams.append('state', state);
}
return url.toString();
}
/**
* Redirect to GitHub OAuth login
* Validates: Requirements 13.1
*/
export function loginWithGitHub(state?: string): void {
window.location.href = getGitHubLoginUrl(state);
}
/**
* Handle GitHub OAuth callback
* Validates: Requirements 13.4, 13.5
*/
export async function handleGitHubCallback(code: string): Promise<{ user: User; tokens: TokenPair }> {
const response = await api.get<AuthResponse>('/auth/github/callback', { code });
if (response.success && response.data) {
setTokens(response.data.tokens);
return response.data;
}
throw new Error('GitHub authentication failed');
}
export default {
register,
login,
logout,
refreshAccessToken,
getAccessToken,
getRefreshToken,
setTokens,
clearTokens,
isAuthenticated,
loginWithGitHub,
getGitHubLoginUrl,
handleGitHubCallback,
};

View File

@@ -0,0 +1,92 @@
/**
* Backup Service - API calls for backup and restore operations
*/
import api from './api';
import type { ApiResponse } from '../types';
/**
* Backup request interface
*/
export interface BackupRequest {
password: string;
}
/**
* Backup response interface
*/
export interface BackupResponse {
file_path: string;
checksum: string;
created_at: string;
size_bytes: number;
}
/**
* Restore request interface
*/
export interface RestoreRequest {
file_path: string;
password: string;
}
/**
* Verify backup request interface
*/
export interface VerifyBackupRequest {
file_path: string;
}
/**
* Verify backup response interface
*/
export interface VerifyBackupResponse {
valid: boolean;
checksum: string;
message: string;
}
/**
* Export database backup
*/
export async function exportBackup(password: string): Promise<BackupResponse> {
const response = await api.post<ApiResponse<BackupResponse>>('/backup/export', {
password,
});
if (!response.data) {
throw new Error(response.error || 'Failed to export backup');
}
return response.data;
}
/**
* Import and restore from backup
*/
export async function importBackup(filePath: string, password: string): Promise<void> {
const response = await api.post<ApiResponse<void>>('/backup/import', {
file_path: filePath,
password,
});
if (!response.success && response.error) {
throw new Error(response.error);
}
}
/**
* Verify backup file integrity
*/
export async function verifyBackup(filePath: string): Promise<VerifyBackupResponse> {
const response = await api.post<ApiResponse<VerifyBackupResponse>>('/backup/verify', {
file_path: filePath,
});
if (!response.data) {
throw new Error(response.error || 'Failed to verify backup');
}
return response.data;
}
export default {
exportBackup,
importBackup,
verifyBackup,
};

View File

@@ -0,0 +1,171 @@
/**
* Budget Service - API calls for budget management
*/
import api from './api';
import type { Budget, ApiResponse, BudgetPeriodType } from '../types';
/**
* Budget form input interface
*/
export interface BudgetFormInput {
name: string;
amount: number;
periodType: BudgetPeriodType;
categoryId?: number;
accountId?: number;
isRolling: boolean;
startDate: string;
endDate?: string;
}
/**
* Budget progress response interface
*/
export interface BudgetProgress {
budgetId: number;
spent: number;
remaining: number;
progress: number;
isWarning: boolean; // >= 80%
isOverBudget: boolean; // > 100%
}
/**
* Get all budgets
*/
export async function getBudgets(): Promise<Budget[]> {
const response = await api.get<ApiResponse<Budget[]>>('/budgets');
return response.data || [];
}
/**
* Get a single budget by ID
*/
export async function getBudget(id: number): Promise<Budget> {
const response = await api.get<ApiResponse<Budget>>(`/budgets/${id}`);
if (!response.data) {
throw new Error('Budget not found');
}
return response.data;
}
/**
* Get budget progress
*/
export async function getBudgetProgress(id: number): Promise<BudgetProgress> {
const response = await api.get<ApiResponse<BudgetProgress>>(`/budgets/${id}/progress`);
if (!response.data) {
throw new Error('Budget progress not found');
}
return response.data;
}
/**
* Create a new budget
*/
export async function createBudget(data: BudgetFormInput): Promise<Budget> {
// Convert camelCase to snake_case for backend
// Convert date strings to RFC3339 format (YYYY-MM-DDTHH:MM:SSZ)
const payload = {
name: data.name,
amount: data.amount,
period_type: data.periodType,
category_id: data.categoryId,
account_id: data.accountId,
is_rolling: data.isRolling,
start_date: data.startDate ? `${data.startDate}T00:00:00Z` : undefined,
end_date: data.endDate ? `${data.endDate}T23:59:59Z` : undefined,
};
const response = await api.post<ApiResponse<Budget>>('/budgets', payload);
if (!response.data) {
throw new Error(response.error || 'Failed to create budget');
}
return response.data;
}
/**
* Update an existing budget
*/
export async function updateBudget(id: number, data: Partial<BudgetFormInput>): Promise<Budget> {
// Convert camelCase to snake_case for backend
// Convert date strings to RFC3339 format (YYYY-MM-DDTHH:MM:SSZ)
const payload: Record<string, unknown> = {};
if (data.name !== undefined) payload.name = data.name;
if (data.amount !== undefined) payload.amount = data.amount;
if (data.periodType !== undefined) payload.period_type = data.periodType;
if (data.categoryId !== undefined) payload.category_id = data.categoryId;
if (data.accountId !== undefined) payload.account_id = data.accountId;
if (data.isRolling !== undefined) payload.is_rolling = data.isRolling;
if (data.startDate !== undefined) payload.start_date = `${data.startDate}T00:00:00Z`;
if (data.endDate !== undefined) payload.end_date = data.endDate ? `${data.endDate}T23:59:59Z` : null;
const response = await api.put<ApiResponse<Budget>>(`/budgets/${id}`, payload);
if (!response.data) {
throw new Error(response.error || 'Failed to update budget');
}
return response.data;
}
/**
* Delete a budget
*/
export async function deleteBudget(id: number): Promise<void> {
await api.delete<ApiResponse<void>>(`/budgets/${id}`);
}
/**
* Get all budget progress
* Returns progress for all active budgets including warning flags
*/
export async function getAllBudgetProgress(): Promise<BudgetProgress[]> {
const response = await api.get<ApiResponse<BudgetProgress[]>>('/budgets/progress');
return response.data || [];
}
/**
* Calculate budget status
*/
export function calculateBudgetStatus(budget: Budget): {
isWarning: boolean;
isOverBudget: boolean;
statusText: string;
} {
const progress = budget.progress || 0;
const isWarning = progress >= 80 && progress < 100;
const isOverBudget = progress >= 100;
let statusText = '正常';
if (isOverBudget) {
statusText = '超支';
} else if (isWarning) {
statusText = '预警';
}
return { isWarning, isOverBudget, statusText };
}
/**
* Get period type label in Chinese
*/
export function getPeriodTypeLabel(periodType: BudgetPeriodType): string {
const labels: Record<BudgetPeriodType, string> = {
daily: '每日',
weekly: '每周',
monthly: '每月',
yearly: '每年',
};
return labels[periodType] || periodType;
}
export default {
getBudgets,
getBudget,
getBudgetProgress,
getAllBudgetProgress,
createBudget,
updateBudget,
deleteBudget,
calculateBudgetStatus,
getPeriodTypeLabel,
};

View File

@@ -0,0 +1,119 @@
/**
* Category Service - API calls for category management
*/
import api from './api';
import type { Category, ApiResponse, TransactionType } from '../types';
export interface CategoryFormInput {
name: string;
icon: string;
type: TransactionType;
parentId?: number;
sortOrder?: number;
}
/**
* Get all categories (flat list)
*/
export async function getCategories(type?: TransactionType): Promise<Category[]> {
const params: Record<string, string> = {};
if (type) {
params.type = type;
}
const response = await api.get<ApiResponse<Category[]>>('/categories', params);
return response.data || [];
}
/**
* Get categories as hierarchical tree
*/
export async function getCategoryTree(type?: TransactionType): Promise<Category[]> {
const params: Record<string, string> = { tree: 'true' };
if (type) {
params.type = type;
}
const response = await api.get<ApiResponse<Category[]>>('/categories', params);
return response.data || [];
}
/**
* Get a single category by ID
*/
export async function getCategory(id: number, withChildren = false): Promise<Category> {
const params: Record<string, string> = {};
if (withChildren) {
params.children = 'true';
}
const response = await api.get<ApiResponse<Category>>(`/categories/${id}`, params);
if (!response.data) {
throw new Error('Category not found');
}
return response.data;
}
/**
* Get child categories of a parent
*/
export async function getChildCategories(parentId: number): Promise<Category[]> {
const response = await api.get<ApiResponse<Category[]>>(`/categories/${parentId}/children`);
return response.data || [];
}
/**
* Create a new category
*/
export async function createCategory(data: CategoryFormInput): Promise<Category> {
// Convert camelCase to snake_case for backend
const payload = {
name: data.name,
icon: data.icon,
type: data.type,
parent_id: data.parentId,
sort_order: data.sortOrder,
};
const response = await api.post<ApiResponse<Category>>('/categories', payload);
if (!response.data) {
throw new Error(response.error || 'Failed to create category');
}
return response.data;
}
/**
* Update an existing category
*/
export async function updateCategory(
id: number,
data: Partial<CategoryFormInput>
): Promise<Category> {
// Convert camelCase to snake_case for backend
const payload: Record<string, unknown> = {};
if (data.name !== undefined) payload.name = data.name;
if (data.icon !== undefined) payload.icon = data.icon;
if (data.type !== undefined) payload.type = data.type;
if (data.parentId !== undefined) payload.parent_id = data.parentId;
if (data.sortOrder !== undefined) payload.sort_order = data.sortOrder;
const response = await api.put<ApiResponse<Category>>(`/categories/${id}`, payload);
if (!response.data) {
throw new Error(response.error || 'Failed to update category');
}
return response.data;
}
/**
* Delete a category
*/
export async function deleteCategory(id: number): Promise<void> {
await api.delete<ApiResponse<void>>(`/categories/${id}`);
}
export default {
getCategories,
getCategoryTree,
getCategory,
getChildCategories,
createCategory,
updateCategory,
deleteCategory,
};

View File

@@ -0,0 +1,404 @@
/**
* Exchange Rate Service - API calls for exchange rate management
* Updated to support the new CNY-based exchange rate system with Redis caching
*/
import api from './api';
import type { ApiResponse } from '../types';
// ============================================================================
// New Types for the redesigned exchange rate system
// ============================================================================
/**
* Exchange rate data transfer object from the new API
*/
export interface ExchangeRateDTO {
currency: string;
currency_name: string;
symbol: string;
rate: number;
updated_at: string;
}
/**
* Sync status information
*/
export interface SyncStatus {
last_sync_time: string;
last_sync_status: 'success' | 'failed';
next_sync_time: string;
rates_count: number;
error_message?: string;
}
/**
* Response from GET /api/v1/exchange-rates
*/
export interface AllRatesResponse {
rates: ExchangeRateDTO[];
base_currency: string;
sync_status?: SyncStatus;
}
/**
* Input for currency conversion (new format)
*/
export interface ConvertCurrencyInput {
amount: number;
from_currency: string;
to_currency: string;
}
/**
* Currency conversion result (new format)
*/
export interface ConversionResultDTO {
original_amount: number;
from_currency: string;
to_currency: string;
converted_amount: number;
rate_used: number;
converted_at: string;
}
/**
* Sync result from manual refresh
*/
export interface SyncResultDTO {
message: string;
rates_updated: number;
sync_time: string;
}
/**
* Currency information for display
*/
export interface CurrencyInfo {
code: string;
name: string;
symbol: string;
flag: string;
}
// ============================================================================
// Legacy types for backward compatibility
// ============================================================================
/**
* @deprecated Use ConvertCurrencyInput instead
*/
export interface LegacyConvertCurrencyInput {
amount: number;
from_currency: string;
to_currency: string;
date?: string;
}
/**
* @deprecated Use ConversionResultDTO instead
*/
export interface LegacyConversionResult {
original_amount: number;
from_currency: string;
to_currency: string;
converted_amount: number;
date?: string;
}
// ============================================================================
// Legacy Types for backward compatibility with old API
// ============================================================================
/**
* Old API exchange rate format (from_currency/to_currency pairs)
*/
interface LegacyExchangeRate {
id: number;
from_currency: string;
to_currency: string;
rate: number;
effective_date: string;
}
// ============================================================================
// API Functions
// ============================================================================
/**
* Get all exchange rates with sync status
* Supports both old API format (array) and new API format (object with rates)
* New API: GET /api/v1/exchange-rates
* @param currencies - Optional array of currency codes to fetch (batch query)
*/
export async function getExchangeRates(currencies?: string[]): Promise<AllRatesResponse> {
// Build query string for batch query
const queryParams = currencies && currencies.length > 0
? `?currencies=${currencies.join(',')}`
: '';
const response = await api.get<ApiResponse<AllRatesResponse | LegacyExchangeRate[]>>(
`/exchange-rates${queryParams}`
);
if (!response.data) {
return {
rates: [],
base_currency: 'CNY',
};
}
// Check if response is in old format (array) or new format (object with rates)
if (Array.isArray(response.data)) {
// Old API format: transform to new format
// Old API returns rates like: { from_currency: "EUR", to_currency: "CNY", rate: 8.2027 }
// This means 1 EUR = 8.2027 CNY
// We need to display as: 1 CNY = X foreign currency
const legacyRates = response.data as LegacyExchangeRate[];
const transformedRates: ExchangeRateDTO[] = legacyRates
.filter(rate => rate.from_currency === 'CNY' || rate.to_currency === 'CNY')
.map(rate => {
// Determine the target currency (the one that's not CNY)
const currency = rate.to_currency === 'CNY' ? rate.from_currency : rate.to_currency;
// Old API: from_currency -> to_currency at rate
// If to_currency is CNY: 1 foreign = rate CNY, so 1 CNY = 1/rate foreign
// If from_currency is CNY: 1 CNY = rate foreign (already correct)
const normalizedRate = rate.to_currency === 'CNY' ? 1 / rate.rate : rate.rate;
const currencyInfo = getCurrencyInfo(currency);
return {
currency,
currency_name: currencyInfo?.name || currency,
symbol: currencyInfo?.symbol || currency,
rate: normalizedRate,
updated_at: rate.effective_date,
};
});
// Remove duplicates (keep the most recent by effective_date)
const uniqueRates = new Map<string, ExchangeRateDTO>();
transformedRates.forEach(rate => {
const existing = uniqueRates.get(rate.currency);
if (!existing || rate.updated_at > existing.updated_at) {
uniqueRates.set(rate.currency, rate);
}
});
return {
rates: Array.from(uniqueRates.values()),
base_currency: 'CNY',
};
}
// New API format: return as-is
return response.data as AllRatesResponse;
}
/**
* Get exchange rate for a specific currency
* New API: GET /api/v1/exchange-rates/:currency
* @param currency - Currency code (e.g., 'USD', 'EUR')
*/
export async function getRate(currency: string): Promise<ExchangeRateDTO> {
const response = await api.get<ApiResponse<ExchangeRateDTO>>(`/exchange-rates/${currency}`);
if (!response.data) {
throw new Error(`Exchange rate not found for currency: ${currency}`);
}
return response.data;
}
/**
* Convert currency amount
* New API: POST /api/v1/exchange-rates/convert
*/
export async function convertCurrency(input: ConvertCurrencyInput): Promise<ConversionResultDTO> {
const response = await api.post<ApiResponse<ConversionResultDTO>>(
'/exchange-rates/convert',
input
);
if (!response.data) {
throw new Error(response.error || 'Failed to convert currency');
}
return response.data;
}
/**
* Manually refresh exchange rates from YunAPI
* New API: POST /api/v1/exchange-rates/refresh
* Supports both old and new API response formats
*/
export async function refreshExchangeRates(): Promise<SyncResultDTO> {
const response = await api.post<ApiResponse<SyncResultDTO | { message: string; rates_saved?: number }>>('/exchange-rates/refresh');
if (!response.data) {
throw new Error(response.error || 'Failed to refresh exchange rates');
}
// Check if response is in old format
const data = response.data as { message?: string; rates_saved?: number; rates_updated?: number; sync_time?: string };
if ('rates_saved' in data && !('rates_updated' in data)) {
// Old API format
return {
message: data.message || 'Exchange rates refreshed',
rates_updated: data.rates_saved || 0,
sync_time: new Date().toISOString(),
};
}
return response.data as SyncResultDTO;
}
/**
* Get sync status
* New API: GET /api/v1/exchange-rates/sync-status
* Returns null if the endpoint is not available (old API)
*/
export async function getSyncStatus(): Promise<SyncStatus | null> {
try {
const response = await api.get<ApiResponse<SyncStatus>>('/exchange-rates/sync-status');
if (!response.data) {
return null;
}
return response.data;
} catch {
// Old API doesn't have this endpoint
return null;
}
}
/**
* Health check for exchange rate service
* New API: GET /api/v1/exchange-rates/health
* Returns null if the endpoint is not available
*/
export async function getHealthStatus(): Promise<{
status: string;
cache_status?: string;
sync_status?: string;
last_sync?: string;
rates_count?: number;
api_url?: string;
} | null> {
try {
const response = await api.get<ApiResponse<any>>('/exchange-rates/health');
if (!response.data) {
return null;
}
return response.data;
} catch {
return null;
}
}
// ============================================================================
// Currency Information - Extended to support all 37 YunAPI currencies
// ============================================================================
/**
* Get all supported currencies with their display information
* Extended to include all 37 currencies from YunAPI
*/
export function getSupportedCurrencies(): CurrencyInfo[] {
return [
// Major currencies (6)
{ code: 'CNY', name: '人民币', symbol: '¥', flag: 'https://flagcdn.com/w40/cn.png' },
{ code: 'USD', name: '美元', symbol: '$', flag: 'https://flagcdn.com/w40/us.png' },
{ code: 'EUR', name: '欧元', symbol: '€', flag: 'https://flagcdn.com/w40/eu.png' },
{ code: 'JPY', name: '日元', symbol: '¥', flag: 'https://flagcdn.com/w40/jp.png' },
{ code: 'GBP', name: '英镑', symbol: '£', flag: 'https://flagcdn.com/w40/gb.png' },
{ code: 'HKD', name: '港币', symbol: 'HK$', flag: 'https://flagcdn.com/w40/hk.png' },
// Asia Pacific (16)
{ code: 'AUD', name: '澳元', symbol: 'A$', flag: 'https://flagcdn.com/w40/au.png' },
{ code: 'NZD', name: '新西兰元', symbol: 'NZ$', flag: 'https://flagcdn.com/w40/nz.png' },
{ code: 'SGD', name: '新加坡元', symbol: 'S$', flag: 'https://flagcdn.com/w40/sg.png' },
{ code: 'KRW', name: '韩元', symbol: '₩', flag: 'https://flagcdn.com/w40/kr.png' },
{ code: 'THB', name: '泰铢', symbol: '฿', flag: 'https://flagcdn.com/w40/th.png' },
{ code: 'TWD', name: '新台币', symbol: 'NT$', flag: 'https://flagcdn.com/w40/tw.png' },
{ code: 'MOP', name: '澳门元', symbol: 'MOP$', flag: 'https://flagcdn.com/w40/mo.png' },
{ code: 'PHP', name: '菲律宾比索', symbol: '₱', flag: 'https://flagcdn.com/w40/ph.png' },
{ code: 'IDR', name: '印尼盾', symbol: 'Rp', flag: 'https://flagcdn.com/w40/id.png' },
{ code: 'INR', name: '印度卢比', symbol: '₹', flag: 'https://flagcdn.com/w40/in.png' },
{ code: 'VND', name: '越南盾', symbol: '₫', flag: 'https://flagcdn.com/w40/vn.png' },
{ code: 'MNT', name: '蒙古图格里克', symbol: '₮', flag: 'https://flagcdn.com/w40/mn.png' },
{ code: 'KHR', name: '柬埔寨瑞尔', symbol: '៛', flag: 'https://flagcdn.com/w40/kh.png' },
{ code: 'NPR', name: '尼泊尔卢比', symbol: '₨', flag: 'https://flagcdn.com/w40/np.png' },
{ code: 'PKR', name: '巴基斯坦卢比', symbol: '₨', flag: 'https://flagcdn.com/w40/pk.png' },
{ code: 'BND', name: '文莱元', symbol: 'B$', flag: 'https://flagcdn.com/w40/bn.png' },
// Europe (8)
{ code: 'CHF', name: '瑞士法郎', symbol: 'CHF', flag: 'https://flagcdn.com/w40/ch.png' },
{ code: 'SEK', name: '瑞典克朗', symbol: 'kr', flag: 'https://flagcdn.com/w40/se.png' },
{ code: 'NOK', name: '挪威克朗', symbol: 'kr', flag: 'https://flagcdn.com/w40/no.png' },
{ code: 'DKK', name: '丹麦克朗', symbol: 'kr', flag: 'https://flagcdn.com/w40/dk.png' },
{ code: 'CZK', name: '捷克克朗', symbol: 'Kč', flag: 'https://flagcdn.com/w40/cz.png' },
{ code: 'HUF', name: '匈牙利福林', symbol: 'Ft', flag: 'https://flagcdn.com/w40/hu.png' },
{ code: 'RUB', name: '俄罗斯卢布', symbol: '₽', flag: 'https://flagcdn.com/w40/ru.png' },
{ code: 'TRY', name: '土耳其里拉', symbol: '₺', flag: 'https://flagcdn.com/w40/tr.png' },
// Americas (3)
{ code: 'CAD', name: '加元', symbol: 'C$', flag: 'https://flagcdn.com/w40/ca.png' },
{ code: 'MXN', name: '墨西哥比索', symbol: 'Mex$', flag: 'https://flagcdn.com/w40/mx.png' },
{ code: 'BRL', name: '巴西雷亚尔', symbol: 'R$', flag: 'https://flagcdn.com/w40/br.png' },
// Middle East & Africa (6)
{ code: 'AED', name: '阿联酋迪拉姆', symbol: 'د.إ', flag: 'https://flagcdn.com/w40/ae.png' },
{ code: 'SAR', name: '沙特里亚尔', symbol: '﷼', flag: 'https://flagcdn.com/w40/sa.png' },
{ code: 'QAR', name: '卡塔尔里亚尔', symbol: '﷼', flag: 'https://flagcdn.com/w40/qa.png' },
{ code: 'KWD', name: '科威特第纳尔', symbol: 'د.ك', flag: 'https://flagcdn.com/w40/kw.png' },
{ code: 'ILS', name: '以色列新谢克尔', symbol: '₪', flag: 'https://flagcdn.com/w40/il.png' },
{ code: 'ZAR', name: '南非兰特', symbol: 'R', flag: 'https://flagcdn.com/w40/za.png' },
];
}
/**
* Get currency info by code
*/
export function getCurrencyInfo(code: string): CurrencyInfo | undefined {
return getSupportedCurrencies().find((c) => c.code === code);
}
/**
* Format currency amount with symbol
*/
export function formatCurrency(amount: number, currency: string): string {
const info = getCurrencyInfo(currency);
const symbol = info?.symbol || currency;
return `${symbol}${amount.toFixed(2)}`;
}
/**
* Get currency name by code
*/
export function getCurrencyName(code: string): string {
const info = getCurrencyInfo(code);
return info?.name || code;
}
/**
* Get currency symbol by code
*/
export function getCurrencySymbol(code: string): string {
const info = getCurrencyInfo(code);
return info?.symbol || code;
}
// ============================================================================
// Default Export
// ============================================================================
export default {
// New API methods
getExchangeRates,
getRate,
convertCurrency,
refreshExchangeRates,
getSyncStatus,
getHealthStatus,
// Currency utilities
getSupportedCurrencies,
getCurrencyInfo,
formatCurrency,
getCurrencyName,
getCurrencySymbol,
};

View File

@@ -0,0 +1,218 @@
/**
* Piggy Bank Service - API calls for piggy bank (savings goals) management
*/
import api from './api';
import type { PiggyBank, ApiResponse, PiggyBankType } from '../types';
/**
* Piggy Bank form input interface
*/
export interface PiggyBankFormInput {
name: string;
targetAmount: number;
type: PiggyBankType;
targetDate?: string;
linkedAccountId?: number;
autoRule?: string;
}
/**
* Deposit/Withdraw request interface
*/
export interface PiggyBankTransactionInput {
amount: number;
note?: string;
}
/**
* Get all piggy banks
*/
export async function getPiggyBanks(): Promise<PiggyBank[]> {
const response = await api.get<ApiResponse<PiggyBank[]>>('/piggy-banks');
return response.data || [];
}
/**
* Get a single piggy bank by ID
*/
export async function getPiggyBank(id: number): Promise<PiggyBank> {
const response = await api.get<ApiResponse<PiggyBank>>(`/piggy-banks/${id}`);
if (!response.data) {
throw new Error('Piggy bank not found');
}
return response.data;
}
/**
* Create a new piggy bank
*/
export async function createPiggyBank(data: PiggyBankFormInput): Promise<PiggyBank> {
// Convert camelCase to snake_case for backend
const payload = {
name: data.name,
target_amount: data.targetAmount,
type: data.type,
target_date: data.targetDate,
linked_account_id: data.linkedAccountId,
auto_rule: data.autoRule,
};
const response = await api.post<ApiResponse<PiggyBank>>('/piggy-banks', payload);
if (!response.data) {
throw new Error(response.error || 'Failed to create piggy bank');
}
return response.data;
}
/**
* Update an existing piggy bank
*/
export async function updatePiggyBank(
id: number,
data: Partial<PiggyBankFormInput>
): Promise<PiggyBank> {
// Convert camelCase to snake_case for backend
const payload: Record<string, unknown> = {};
if (data.name !== undefined) payload.name = data.name;
if (data.targetAmount !== undefined) payload.target_amount = data.targetAmount;
if (data.type !== undefined) payload.type = data.type;
if (data.targetDate !== undefined) payload.target_date = data.targetDate;
if (data.linkedAccountId !== undefined) payload.linked_account_id = data.linkedAccountId;
if (data.autoRule !== undefined) payload.auto_rule = data.autoRule;
const response = await api.put<ApiResponse<PiggyBank>>(`/piggy-banks/${id}`, payload);
if (!response.data) {
throw new Error(response.error || 'Failed to update piggy bank');
}
return response.data;
}
/**
* Delete a piggy bank
*/
export async function deletePiggyBank(id: number): Promise<void> {
await api.delete<ApiResponse<void>>(`/piggy-banks/${id}`);
}
/**
* Deposit money into a piggy bank
*/
export async function depositToPiggyBank(
id: number,
data: PiggyBankTransactionInput
): Promise<PiggyBank> {
// Convert camelCase to snake_case for backend
const payload = {
amount: data.amount,
note: data.note,
};
const response = await api.post<ApiResponse<PiggyBank>>(`/piggy-banks/${id}/deposit`, payload);
if (!response.data) {
throw new Error(response.error || 'Failed to deposit to piggy bank');
}
return response.data;
}
/**
* Withdraw money from a piggy bank
*/
export async function withdrawFromPiggyBank(
id: number,
data: PiggyBankTransactionInput
): Promise<PiggyBank> {
// Convert camelCase to snake_case for backend
const payload = {
amount: data.amount,
note: data.note,
};
const response = await api.post<ApiResponse<PiggyBank>>(`/piggy-banks/${id}/withdraw`, payload);
if (!response.data) {
throw new Error(response.error || 'Failed to withdraw from piggy bank');
}
return response.data;
}
/**
* Piggy Bank progress response interface
*/
export interface PiggyBankProgress {
piggyBankId: number;
currentAmount: number;
targetAmount: number;
progress: number;
daysRemaining?: number;
}
/**
* Get all piggy bank progress
* Returns progress for all piggy banks
*/
export async function getAllPiggyBankProgress(): Promise<PiggyBankProgress[]> {
const response = await api.get<ApiResponse<PiggyBankProgress[]>>('/piggy-banks/progress');
return response.data || [];
}
/**
* Get piggy bank type label in Chinese
*/
export function getPiggyBankTypeLabel(type: PiggyBankType): string {
const labels: Record<PiggyBankType, string> = {
manual: '手动存钱罐',
auto: '自动存钱罐',
fixed_deposit: '零存整取',
week_52: '52周存钱法',
};
return labels[type] || type;
}
/**
* Calculate days remaining until target date
*/
export function calculateDaysRemaining(targetDate?: string): number | null {
if (!targetDate) return null;
const target = new Date(targetDate);
const now = new Date();
const diffTime = target.getTime() - now.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
return diffDays;
}
/**
* Calculate estimated completion date based on current progress
*/
export function estimateCompletionDate(
currentAmount: number,
targetAmount: number,
createdAt: string
): string | null {
if (currentAmount >= targetAmount) return null;
if (currentAmount === 0) return null;
const created = new Date(createdAt);
const now = new Date();
const daysElapsed = Math.max(
1,
Math.ceil((now.getTime() - created.getTime()) / (1000 * 60 * 60 * 24))
);
const dailyRate = currentAmount / daysElapsed;
const remainingAmount = targetAmount - currentAmount;
const daysRemaining = Math.ceil(remainingAmount / dailyRate);
const estimatedDate = new Date(now);
estimatedDate.setDate(estimatedDate.getDate() + daysRemaining);
return estimatedDate.toISOString().split('T')[0];
}
export default {
getPiggyBanks,
getPiggyBank,
createPiggyBank,
updatePiggyBank,
deletePiggyBank,
depositToPiggyBank,
withdrawFromPiggyBank,
getAllPiggyBankProgress,
getPiggyBankTypeLabel,
calculateDaysRemaining,
estimateCompletionDate,
};

View File

@@ -0,0 +1,225 @@
/**
* Recurring Transaction Service - API calls for recurring transaction management
* Implements requirements 1.2.1 (create recurring transactions), 1.2.3 (edit recurring transactions)
*/
import api from './api';
import type {
RecurringTransaction,
ApiResponse,
TransactionType,
FrequencyType,
CurrencyCode,
Transaction,
} from '../types';
/**
* Recurring transaction form input
*/
export interface RecurringTransactionFormInput {
amount: number;
type: TransactionType;
categoryId: number;
accountId: number;
currency: CurrencyCode;
note?: string;
frequency: FrequencyType;
startDate: string;
endDate?: string;
}
/**
* Process recurring transactions response (from backend, snake_case)
*/
interface ProcessRecurringTransactionsBackendResponse {
processed_count: number;
transactions: Transaction[];
}
/**
* Process recurring transactions response (frontend, camelCase)
*/
export interface ProcessRecurringTransactionsResponse {
processedCount: number;
transactions: Transaction[];
}
/**
* Get all recurring transactions
* @param activeOnly - If true, only return active recurring transactions
*/
export async function getRecurringTransactions(
activeOnly?: boolean
): Promise<RecurringTransaction[]> {
const params: Record<string, string | number | boolean | undefined> = {};
if (activeOnly !== undefined) {
params.active = activeOnly;
}
const response = await api.get<ApiResponse<RecurringTransaction[]>>(
'/recurring-transactions',
params
);
return response.data || [];
}
/**
* Get a single recurring transaction by ID
*/
export async function getRecurringTransaction(id: number): Promise<RecurringTransaction> {
const response = await api.get<ApiResponse<RecurringTransaction>>(
`/recurring-transactions/${id}`
);
if (!response.data) {
throw new Error('Recurring transaction not found');
}
return response.data;
}
/**
* Create a new recurring transaction
* Validates: Requirements 1.2.1 (创建周期性交易并保存周期规则)
*/
export async function createRecurringTransaction(
data: RecurringTransactionFormInput
): Promise<RecurringTransaction> {
// Convert camelCase to snake_case for backend
const payload = {
amount: data.amount,
type: data.type,
category_id: data.categoryId,
account_id: data.accountId,
currency: data.currency,
note: data.note,
frequency: data.frequency,
start_date: data.startDate,
end_date: data.endDate,
};
const response = await api.post<ApiResponse<RecurringTransaction>>(
'/recurring-transactions',
payload
);
if (!response.data) {
throw new Error(response.error || 'Failed to create recurring transaction');
}
return response.data;
}
/**
* Update an existing recurring transaction
* Validates: Requirements 1.2.3 (编辑周期性交易模板)
*/
export async function updateRecurringTransaction(
id: number,
data: Partial<RecurringTransactionFormInput> & { isActive?: boolean }
): Promise<RecurringTransaction> {
// Convert camelCase to snake_case for backend
const payload: Record<string, unknown> = {};
if (data.amount !== undefined) payload.amount = data.amount;
if (data.type !== undefined) payload.type = data.type;
if (data.categoryId !== undefined) payload.category_id = data.categoryId;
if (data.accountId !== undefined) payload.account_id = data.accountId;
if (data.currency !== undefined) payload.currency = data.currency;
if (data.note !== undefined) payload.note = data.note;
if (data.frequency !== undefined) payload.frequency = data.frequency;
if (data.startDate !== undefined) payload.start_date = data.startDate;
if (data.endDate !== undefined) payload.end_date = data.endDate;
if (data.isActive !== undefined) payload.is_active = data.isActive;
const response = await api.put<ApiResponse<RecurringTransaction>>(
`/recurring-transactions/${id}`,
payload
);
if (!response.data) {
throw new Error(response.error || 'Failed to update recurring transaction');
}
return response.data;
}
/**
* Delete a recurring transaction
* Validates: Requirements 1.2.4 (删除周期性交易)
*/
export async function deleteRecurringTransaction(id: number): Promise<void> {
await api.delete<ApiResponse<void>>(`/recurring-transactions/${id}`);
}
/**
* Process due recurring transactions
* Validates: Requirements 1.2.2 (到达周期触发时间自动生成交易记录)
* @param time - Optional time parameter for testing (YYYY-MM-DD format)
*/
export async function processRecurringTransactions(
time?: string
): Promise<ProcessRecurringTransactionsResponse> {
const params: Record<string, string | number | boolean | undefined> = {};
if (time) {
params.time = time;
}
const response = await api.post<ApiResponse<ProcessRecurringTransactionsBackendResponse>>(
'/recurring-transactions/process',
undefined
);
// Convert snake_case to camelCase
const data = response.data;
return {
processedCount: data?.processed_count || 0,
transactions: data?.transactions || [],
};
}
/**
* Get frequency display name in Chinese
*/
export function getFrequencyDisplayName(frequency: FrequencyType): string {
const names: Record<FrequencyType, string> = {
daily: '每日',
weekly: '每周',
monthly: '每月',
yearly: '每年',
};
return names[frequency] || frequency;
}
/**
* Calculate next occurrence date based on frequency
* This is a client-side helper for preview purposes
*/
export function calculateNextOccurrence(
startDate: string,
frequency: FrequencyType,
occurrences: number = 1
): string {
const date = new Date(startDate);
switch (frequency) {
case 'daily':
date.setDate(date.getDate() + occurrences);
break;
case 'weekly':
date.setDate(date.getDate() + 7 * occurrences);
break;
case 'monthly':
date.setMonth(date.getMonth() + occurrences);
break;
case 'yearly':
date.setFullYear(date.getFullYear() + occurrences);
break;
}
return date.toISOString().split('T')[0];
}
export default {
getRecurringTransactions,
getRecurringTransaction,
createRecurringTransaction,
updateRecurringTransaction,
deleteRecurringTransaction,
processRecurringTransactions,
getFrequencyDisplayName,
calculateNextOccurrence,
};

View File

@@ -0,0 +1,185 @@
/**
* Report Service
* Handles API calls for statistical reports and analysis
*/
import api from './api';
import type { CurrencyCode } from '../types';
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080/api/v1';
// Report API Response Types
export interface CurrencySummary {
currency: CurrencyCode;
total_income: number;
total_expense: number;
balance: number;
count: number;
}
export interface UnifiedSummary {
target_currency: CurrencyCode;
total_income: number;
total_expense: number;
balance: number;
conversion_date: string;
}
export interface TransactionSummaryResponse {
by_currency: CurrencySummary[];
unified?: UnifiedSummary;
}
export interface CategorySummaryItem {
category_id: number;
category_name: string;
currency?: CurrencyCode;
total_amount: number;
count: number;
percentage: number;
}
export interface CategorySummaryResponse {
by_currency: CategorySummaryItem[];
unified?: CategorySummaryItem[];
}
export interface TrendDataPoint {
Date: string;
TotalIncome: number;
TotalExpense: number;
Balance: number;
Count: number;
}
export interface TrendDataResponse {
period: 'day' | 'week' | 'month' | 'year';
currency?: CurrencyCode;
data_points: TrendDataPoint[];
}
export interface SummaryParams {
start_date: string;
end_date: string;
target_currency?: CurrencyCode;
conversion_date?: string;
}
export interface CategoryParams {
start_date: string;
end_date: string;
type: 'income' | 'expense';
target_currency?: CurrencyCode;
conversion_date?: string;
}
export interface TrendParams {
start_date: string;
end_date: string;
period: 'day' | 'week' | 'month' | 'year';
currency?: CurrencyCode;
}
export interface ExportParams {
start_date: string;
end_date: string;
format: 'pdf' | 'excel';
target_currency?: CurrencyCode;
}
/**
* Get transaction summary for a date range
*/
export const getTransactionSummary = async (
params: SummaryParams
): Promise<TransactionSummaryResponse> => {
const response = await api.get<{ success: boolean; data: TransactionSummaryResponse }>(
'/reports/summary',
params as unknown as Record<string, string | number | boolean | undefined>
);
if (!response.data) {
throw new Error('Failed to get transaction summary');
}
return response.data;
};
/**
* Get category summary for a date range
*/
export const getCategorySummary = async (
params: CategoryParams
): Promise<CategorySummaryResponse> => {
const response = await api.get<{ success: boolean; data: CategorySummaryResponse }>(
'/reports/category',
params as unknown as Record<string, string | number | boolean | undefined>
);
if (!response.data) {
throw new Error('Failed to get category summary');
}
return response.data;
};
/**
* Get trend data for a date range
*/
export const getTrendData = async (
params: TrendParams
): Promise<TrendDataResponse> => {
const response = await api.get<{ success: boolean; data: TrendDataResponse }>(
'/reports/trend',
params as unknown as Record<string, string | number | boolean | undefined>
);
if (!response.data) {
throw new Error('Failed to get trend data');
}
return response.data;
};
/**
* Export report as PDF or Excel
* Downloads the file directly to the user's device
*/
export const exportReport = async (params: ExportParams): Promise<void> => {
// Use fetch directly for blob response
const response = await fetch(`${API_BASE_URL}/reports/export`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(params),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || `Export failed with status: ${response.status}`);
}
// Get the blob from response
const blob = await response.blob();
// Create a temporary URL for the blob
const url = window.URL.createObjectURL(blob);
// Create a temporary anchor element and trigger download
const link = document.createElement('a');
link.href = url;
// Generate filename based on date range and format
const filename = `report_${params.start_date.replace(/-/g, '')}_to_${params.end_date.replace(/-/g, '')}.${params.format === 'pdf' ? 'pdf' : 'xlsx'}`;
link.download = filename;
// Trigger the download
document.body.appendChild(link);
link.click();
// Clean up
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
};
export default {
getTransactionSummary,
getCategorySummary,
getTrendData,
exportReport,
};

View File

@@ -0,0 +1,67 @@
/**
* Tag Service - API calls for tag management
*/
import api from './api';
import type { Tag, ApiResponse } from '../types';
export interface TagFormInput {
name: string;
color?: string;
}
/**
* Get all tags
*/
export async function getTags(): Promise<Tag[]> {
const response = await api.get<ApiResponse<Tag[]>>('/tags');
return response.data || [];
}
/**
* Get a single tag by ID
*/
export async function getTag(id: number): Promise<Tag> {
const response = await api.get<ApiResponse<Tag>>(`/tags/${id}`);
if (!response.data) {
throw new Error('Tag not found');
}
return response.data;
}
/**
* Create a new tag
*/
export async function createTag(data: TagFormInput): Promise<Tag> {
const response = await api.post<ApiResponse<Tag>>('/tags', data);
if (!response.data) {
throw new Error(response.error || 'Failed to create tag');
}
return response.data;
}
/**
* Update an existing tag
*/
export async function updateTag(id: number, data: Partial<TagFormInput>): Promise<Tag> {
const response = await api.put<ApiResponse<Tag>>(`/tags/${id}`, data);
if (!response.data) {
throw new Error(response.error || 'Failed to update tag');
}
return response.data;
}
/**
* Delete a tag
*/
export async function deleteTag(id: number): Promise<void> {
await api.delete<ApiResponse<void>>(`/tags/${id}`);
}
export default {
getTags,
getTag,
createTag,
updateTag,
deleteTag,
};

View File

@@ -0,0 +1,107 @@
import api from './api';
import type { ApiResponse } from '../types';
// Types
export interface TransactionTemplate {
id: number;
user_id?: number;
name: string;
amount: number;
type: 'income' | 'expense' | 'transfer';
category_id: number;
account_id: number;
currency: string;
note?: string;
sort_order: number;
created_at: string;
updated_at: string;
category?: {
id: number;
name: string;
icon?: string;
type: string;
};
account?: {
id: number;
name: string;
type: string;
icon?: string;
};
}
export interface TemplateInput {
name: string;
amount: number;
type: 'income' | 'expense' | 'transfer';
category_id: number;
account_id: number;
currency?: string;
note?: string;
sort_order?: number;
}
// API Functions
/**
* Get all transaction templates
*/
export async function getAllTemplates(): Promise<TransactionTemplate[]> {
const response = await api.get<ApiResponse<TransactionTemplate[]>>('/templates');
return response.data || [];
}
/**
* Get a template by ID
*/
export async function getTemplate(id: number): Promise<TransactionTemplate> {
const response = await api.get<ApiResponse<TransactionTemplate>>(`/templates/${id}`);
if (!response.data) {
throw new Error('Template not found');
}
return response.data;
}
/**
* Create a new template
*/
export async function createTemplate(input: TemplateInput): Promise<TransactionTemplate> {
const response = await api.post<ApiResponse<TransactionTemplate>>('/templates', input);
if (!response.data) {
throw new Error('Failed to create template');
}
return response.data;
}
/**
* Update an existing template
*/
export async function updateTemplate(id: number, input: TemplateInput): Promise<TransactionTemplate> {
const response = await api.put<ApiResponse<TransactionTemplate>>(`/templates/${id}`, input);
if (!response.data) {
throw new Error('Failed to update template');
}
return response.data;
}
/**
* Delete a template
*/
export async function deleteTemplate(id: number): Promise<void> {
await api.delete(`/templates/${id}`);
}
/**
* Update template sort order
*/
export async function updateSortOrder(ids: number[]): Promise<void> {
await api.put('/templates/sort', { ids });
}
export default {
getAllTemplates,
getTemplate,
createTemplate,
updateTemplate,
deleteTemplate,
updateSortOrder,
};

View File

@@ -0,0 +1,252 @@
/**
* Transaction Service - API calls for transaction management
* Implements requirements 1.4 (view transactions sorted by time descending)
*/
import api from './api';
import type {
Transaction,
TransactionFormInput,
ApiResponse,
PaginatedResponse,
TransactionType,
CurrencyCode,
Tag,
} 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;
}
/**
* Transform API response transaction to frontend Transaction type
* Converts snake_case fields to camelCase
*/
function transformTransaction(apiTransaction: Record<string, unknown>): Transaction {
return {
id: apiTransaction.id as number,
amount: apiTransaction.amount as number,
type: apiTransaction.type as TransactionType,
categoryId: (apiTransaction.category_id ?? apiTransaction.categoryId) as number,
accountId: (apiTransaction.account_id ?? apiTransaction.accountId) as number,
currency: apiTransaction.currency as CurrencyCode,
transactionDate: (apiTransaction.transaction_date ?? apiTransaction.transactionDate) as string,
note: apiTransaction.note as string | undefined,
imagePath: (apiTransaction.image_path ?? apiTransaction.imagePath) as string | undefined,
tags: (apiTransaction.tags as Tag[]) || [],
recurringId: (apiTransaction.recurring_id ?? apiTransaction.recurringId) as number | undefined,
createdAt: (apiTransaction.created_at ?? apiTransaction.createdAt) as string,
updatedAt: (apiTransaction.updated_at ?? apiTransaction.updatedAt) as string,
};
}
/**
* Get transactions with optional filtering and pagination
* Results are sorted by transaction date descending (newest first) per requirement 1.4
*/
export async function getTransactions(
filter?: TransactionFilter
): Promise<PaginatedResponse<Transaction>> {
const params: Record<string, string | number | boolean | undefined> = {};
if (filter) {
if (filter.startDate) params.start_date = filter.startDate;
if (filter.endDate) params.end_date = filter.endDate;
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.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;
}
try {
const response = await api.get<{
success: boolean;
data: Record<string, unknown>[];
meta?: {
page?: number;
page_size?: number;
total_count?: number;
total_pages?: number;
};
}>('/transactions', params);
// Handle backend response format
if (response && response.success && Array.isArray(response.data)) {
return {
items: response.data.map(transformTransaction),
total: response.meta?.total_count || 0,
page: response.meta?.page || 1,
pageSize: response.meta?.page_size || 20,
totalPages: response.meta?.total_pages || 0,
};
}
// Fallback for unexpected response structure
return {
items: [],
total: 0,
page: 1,
pageSize: 20,
totalPages: 0,
};
} catch (error) {
console.error('Error fetching transactions:', error);
// Return empty result on error
return {
items: [],
total: 0,
page: 1,
pageSize: 20,
totalPages: 0,
};
}
}
/**
* Get a single transaction by ID
*/
export async function getTransaction(id: number): Promise<Transaction> {
const response = await api.get<ApiResponse<Transaction>>(`/transactions/${id}`);
if (!response.data) {
throw new Error('Transaction not found');
}
return response.data;
}
/**
* Create a new transaction
*/
export async function createTransaction(data: TransactionFormInput): Promise<Transaction> {
// Convert camelCase to snake_case for backend
const payload = {
amount: data.amount,
type: data.type,
category_id: data.categoryId,
account_id: data.accountId,
currency: data.currency,
transaction_date: data.transactionDate,
note: data.note,
tag_ids: data.tagIds,
};
const response = await api.post<ApiResponse<Transaction>>('/transactions', payload);
if (!response.data) {
throw new Error(response.error || 'Failed to create transaction');
}
return response.data;
}
/**
* Update an existing transaction
*/
export async function updateTransaction(
id: number,
data: Partial<TransactionFormInput>
): Promise<Transaction> {
// Convert camelCase to snake_case for backend
const payload: Record<string, unknown> = {};
if (data.amount !== undefined) payload.amount = data.amount;
if (data.type !== undefined) payload.type = data.type;
if (data.categoryId !== undefined) payload.category_id = data.categoryId;
if (data.accountId !== undefined) payload.account_id = data.accountId;
if (data.currency !== undefined) payload.currency = data.currency;
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;
const response = await api.put<ApiResponse<Transaction>>(`/transactions/${id}`, payload);
if (!response.data) {
throw new Error(response.error || 'Failed to update transaction');
}
return response.data;
}
/**
* Delete a transaction
*/
export async function deleteTransaction(id: number): Promise<void> {
await api.delete<ApiResponse<void>>(`/transactions/${id}`);
}
/**
* Group transactions by date
*/
export function groupTransactionsByDate(transactions: Transaction[] | undefined): Map<string, Transaction[]> {
const groups = new Map<string, Transaction[]>();
if (!transactions || !Array.isArray(transactions)) {
return groups;
}
for (const transaction of transactions) {
// Handle both camelCase (transactionDate) and snake_case (transaction_date) from API
const dateValue = transaction.transactionDate || (transaction as unknown as Record<string, unknown>)['transaction_date'] as string;
if (!dateValue) {
continue; // Skip transactions without a date
}
const date = dateValue.split('T')[0];
const existing = groups.get(date) || [];
existing.push(transaction);
groups.set(date, existing);
}
return groups;
}
/**
* Calculate total income from transactions
*/
export function calculateTotalIncome(transactions: Transaction[] | undefined): number {
if (!transactions || !Array.isArray(transactions)) {
return 0;
}
return transactions.filter((t) => t.type === 'income').reduce((sum, t) => sum + t.amount, 0);
}
/**
* Calculate total expense from transactions
*/
export function calculateTotalExpense(transactions: Transaction[] | undefined): number {
if (!transactions || !Array.isArray(transactions)) {
return 0;
}
return transactions.filter((t) => t.type === 'expense').reduce((sum, t) => sum + t.amount, 0);
}
/**
* Calculate balance (income - expense)
*/
export function calculateBalance(transactions: Transaction[] | undefined): number {
return calculateTotalIncome(transactions) - calculateTotalExpense(transactions);
}
export default {
getTransactions,
getTransaction,
createTransaction,
updateTransaction,
deleteTransaction,
groupTransactionsByDate,
calculateTotalIncome,
calculateTotalExpense,
calculateBalance,
};