From 4844c481968cf18e2d3e8d7ea2877dfab7eede50 Mon Sep 17 00:00:00 2001 From: gin-melodic <4485145+gin-melodic@users.noreply.github.com> Date: Mon, 5 Jan 2026 17:20:09 +0800 Subject: [PATCH 1/2] feat:import & export function. fix api prefix. --- openapi.yaml.txt | 2 +- src/components/features/Settings.tsx | 2 + src/components/features/TaskCenterModal.tsx | 24 + .../features/settings/DataExportSettings.tsx | 441 ++++++++++++++++++ .../features/settings/MainSettings.tsx | 12 +- src/context/GlobalContext.tsx | 68 +-- src/lib/api.ts | 10 +- src/lib/hooks/useTaskNotifications.ts | 26 +- src/lib/services/accountService.ts | 14 +- src/lib/services/authService.ts | 18 +- src/lib/services/dashboardService.ts | 4 +- src/lib/services/taskService.ts | 10 +- src/lib/services/transactionService.ts | 12 +- src/locales/en/settings.json | 38 +- src/locales/ja/settings.json | 38 +- src/locales/zh-CN/settings.json | 38 +- src/locales/zh-TW/settings.json | 38 +- 17 files changed, 713 insertions(+), 82 deletions(-) create mode 100644 src/components/features/settings/DataExportSettings.tsx diff --git a/openapi.yaml.txt b/openapi.yaml.txt index fe11e88..32be663 100644 --- a/openapi.yaml.txt +++ b/openapi.yaml.txt @@ -34,7 +34,7 @@ tags: description: Analytics and summaries servers: - - url: http://localhost:3000/api + - url: http://localhost:3000/api/v1 description: Local development server (HTTPS) security: - BearerAuth: [] diff --git a/src/components/features/Settings.tsx b/src/components/features/Settings.tsx index f3bea4e..1a8ad2c 100644 --- a/src/components/features/Settings.tsx +++ b/src/components/features/Settings.tsx @@ -8,6 +8,7 @@ import { CurrencySettings } from './settings/CurrencySettings'; import { ThemeSettings } from './settings/ThemeSettings'; import { LanguageSettings } from './settings/LanguageSettings'; import { MainSettings } from './settings/MainSettings'; +import { DataExportSettings } from './settings/DataExportSettings'; const Settings = () => { const { settingsView, setSettingsView } = useGlobal(); @@ -20,6 +21,7 @@ const Settings = () => { {settingsView === 'CURRENCY' && setSettingsView('MAIN')} onUpgrade={() => setSettingsView('SUBSCRIPTION')} />} {settingsView === 'THEME' && setSettingsView('MAIN')} onUpgrade={() => setSettingsView('SUBSCRIPTION')} />} {settingsView === 'LANGUAGE' && setSettingsView('MAIN')} />} + {settingsView === 'DATA_EXPORT' && setSettingsView('MAIN')} />} ); }; diff --git a/src/components/features/TaskCenterModal.tsx b/src/components/features/TaskCenterModal.tsx index 7f43809..696cb15 100644 --- a/src/components/features/TaskCenterModal.tsx +++ b/src/components/features/TaskCenterModal.tsx @@ -102,12 +102,36 @@ const TaskCenterModal = () => { return `${sourceAccountName} → ${targetAccountIds.length} ${t('common:accounts')}`; } return sourceAccountName; + } else if (task.type === 'DATA_EXPORT' && task.payload) { + const payload = task.payload as { startDate: string; endDate: string }; + return `${payload.startDate} - ${payload.endDate}`; + } else if (task.type === 'DATA_IMPORT' && task.payload) { + const payload = task.payload as { fileName: string }; + // Extract filename from path if needed, or just show it + const fileName = payload.fileName.split(/[/\\]/).pop() || payload.fileName; + + if (task.status === 'COMPLETED' && task.result) { + const result = task.result as { + accountsImported?: number; + transactionsImported?: number; + accountsSkipped?: number; + transactionsSkipped?: number; + }; + const added = (result.accountsImported || 0) + (result.transactionsImported || 0); + const updated = (result.accountsSkipped || 0) + (result.transactionsSkipped || 0); + const duplicated = updated; + return `${fileName} (${t('settings:import_result_summary', { added, updated, duplicated })})`; + } + + return fileName; } return ''; }; const getTypeText = (type: string) => { if (type === 'ACCOUNT_MIGRATION') return t('settings:task_type_account_migration'); + if (type === 'DATA_EXPORT') return t('settings:task_type_data_export'); + if (type === 'DATA_IMPORT') return t('settings:task_type_data_import'); return type; }; diff --git a/src/components/features/settings/DataExportSettings.tsx b/src/components/features/settings/DataExportSettings.tsx new file mode 100644 index 0000000..f301a85 --- /dev/null +++ b/src/components/features/settings/DataExportSettings.tsx @@ -0,0 +1,441 @@ +'use client'; + +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ArrowLeft, Download, Upload, Calendar, Loader2, CheckCircle, AlertCircle, FileArchive } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import apiRequest, { API_BASE_PATH } from '@/lib/api'; + +interface DataExportSettingsProps { + onBack: () => void; +} + +type DateRangePreset = '7d' | '30d' | '90d' | '1y' | '3y' | 'custom'; + +interface TaskStatus { + taskId: string; + status: 'PENDING' | 'RUNNING' | 'COMPLETED' | 'FAILED'; + progress: number; + payload?: { + startDate: string; + endDate: string; + }; + result?: { + fileName?: string; + accountsExported?: number; + transactionsExported?: number; + accountsImported?: number; + transactionsImported?: number; + accountsSkipped?: number; + transactionsSkipped?: number; + error?: string; + }; +} + +export const DataExportSettings = ({ onBack }: DataExportSettingsProps) => { + const { t } = useTranslation(['settings', 'common']); + + // Export state + const [dateRangePreset, setDateRangePreset] = useState('7d'); + const [customStartDate, setCustomStartDate] = useState(''); + const [customEndDate, setCustomEndDate] = useState(''); + const [exportTask, setExportTask] = useState(null); + const [isExporting, setIsExporting] = useState(false); + + // Import state + const [importFile, setImportFile] = useState(null); + const [importTask, setImportTask] = useState(null); + const [isImporting, setIsImporting] = useState(false); + + // Error state + const [error, setError] = useState(null); + + const getDateRange = (): { startDate: string; endDate: string } => { + const today = new Date(); + const endDate = today.toISOString().split('T')[0]; + let startDate: Date; + + switch (dateRangePreset) { + case '7d': + startDate = new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000); + break; + case '30d': + startDate = new Date(today.getTime() - 30 * 24 * 60 * 60 * 1000); + break; + case '90d': + startDate = new Date(today.getTime() - 90 * 24 * 60 * 60 * 1000); + break; + case '1y': + startDate = new Date(today); + startDate.setFullYear(startDate.getFullYear() - 1); + break; + case '3y': + startDate = new Date(today); + startDate.setFullYear(startDate.getFullYear() - 3); + break; + case 'custom': + return { startDate: customStartDate, endDate: customEndDate }; + default: + startDate = new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000); + } + + return { startDate: startDate.toISOString().split('T')[0], endDate }; + }; + + const handleExport = async () => { + setError(null); + setIsExporting(true); + + try { + const { startDate, endDate } = getDateRange(); + + if (!startDate || !endDate) { + throw new Error(t('settings:data_export.select_date_range')); + } + + const response = await apiRequest<{ taskId: string }>(`${API_BASE_PATH}/data/export`, { + method: 'POST', + body: JSON.stringify({ startDate, endDate }), + }); + + setExportTask({ + taskId: response.taskId, + status: 'PENDING', + progress: 0, + }); + + // Poll for status + pollTaskStatus(response.taskId, 'export'); + } catch (err) { + setError(err instanceof Error ? err.message : t('settings:data_export.export_failed')); + } finally { + setIsExporting(false); + } + }; + + const handleImport = async () => { + if (!importFile) { + setError(t('settings:data_export.select_file')); + return; + } + + setError(null); + setIsImporting(true); + + try { + const formData = new FormData(); + formData.append('file', importFile); + + const response = await apiRequest<{ taskId: string }>(`${API_BASE_PATH}/data/import`, { + method: 'POST', + body: formData, + headers: {}, // Let browser set Content-Type for multipart + }); + + setImportTask({ + taskId: response.taskId, + status: 'PENDING', + progress: 0, + }); + + // Poll for status + pollTaskStatus(response.taskId, 'import'); + } catch (err) { + setError(err instanceof Error ? err.message : t('settings:data_export.import_failed')); + } finally { + setIsImporting(false); + } + }; + + const pollTaskStatus = async (taskId: string, type: 'export' | 'import') => { + const maxAttempts = 120; // 2 minutes with 1s intervals + let attempts = 0; + + const poll = async () => { + try { + const response = await apiRequest(`${API_BASE_PATH}/data/export/${taskId}`); + + if (type === 'export') { + setExportTask(response); + } else { + setImportTask(response); + } + + if (response.status === 'COMPLETED' || response.status === 'FAILED') { + return; + } + + attempts++; + if (attempts < maxAttempts) { + setTimeout(poll, 1000); + } + } catch { + // Stop polling on error + } + }; + + poll(); + }; + + const handleDownload = async () => { + if (!exportTask?.taskId) return; + + try { + const response = await fetch(`${API_BASE_PATH}/data/download/${exportTask.taskId}`, { + headers: { + Authorization: `Bearer ${localStorage.getItem('token')}`, + }, + }); + + if (!response.ok) throw new Error('Download failed'); + + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = exportTask.result?.fileName || 'export.zip'; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + } catch { + setError(t('settings:data_export.download_failed')); + } + }; + + const handleFileSelect = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) { + if (!file.name.endsWith('.zip')) { + setError(t('settings:data_export.invalid_file_type')); + return; + } + setImportFile(file); + setError(null); + } + }; + + return ( +
+
+ +

+ {t('settings:data_export.title')} +

+
+ + {error && ( +
+ + {error} +
+ )} + + {/* Export Section */} + + + + + {t('settings:data_export.export_title')} + + + +
+ + +
+ + {dateRangePreset === 'custom' && ( +
+
+ +
+ + setCustomStartDate(e.target.value)} + className="pl-10" + /> +
+
+
+ +
+ + setCustomEndDate(e.target.value)} + className="pl-10" + /> +
+
+
+ )} + + {exportTask && ( +
+
+ + {exportTask.status === 'PENDING' && t('settings:data_export.status_pending')} + {exportTask.status === 'RUNNING' && t('settings:data_export.status_running')} + {exportTask.status === 'COMPLETED' && t('settings:data_export.status_completed')} + {exportTask.status === 'FAILED' && t('settings:data_export.status_failed')} + + {exportTask.status === 'COMPLETED' && } + {exportTask.status === 'FAILED' && } +
+ + {exportTask.payload && ( +
+ {t('settings:data_export.date_range')}: {exportTask.payload.startDate} - {exportTask.payload.endDate} +
+ )} + + {exportTask.status === 'RUNNING' && ( +
+
+
+ )} + {exportTask.status === 'COMPLETED' && exportTask.result && ( +
+ {t('settings:data_export.export_result', { + accounts: exportTask.result.accountsExported, + transactions: exportTask.result.transactionsExported, + })} +
+ )} + {exportTask.status === 'FAILED' && exportTask.result?.error && ( +
{exportTask.result.error}
+ )} +
+ )} + +
+ + {exportTask?.status === 'COMPLETED' && ( + + )} +
+ + + + {/* Import Section */} + + + + + {t('settings:data_export.import_title')} + + + +
+ + + {importFile && ( +

+ {t('settings:data_export.selected_file')}: {importFile.name} +

+ )} +
+ +
+ + {t('settings:data_export.import_warning')} +
+ + {importTask && ( +
+
+ + {importTask.status === 'PENDING' && t('settings:data_export.status_pending')} + {importTask.status === 'RUNNING' && t('settings:data_export.status_importing')} + {importTask.status === 'COMPLETED' && t('settings:data_export.status_completed')} + {importTask.status === 'FAILED' && t('settings:data_export.status_failed')} + + {importTask.status === 'COMPLETED' && } + {importTask.status === 'FAILED' && } +
+ {importTask.status === 'RUNNING' && ( +
+
+
+ )} + {importTask.status === 'COMPLETED' && importTask.result && ( +
+ {(() => { + const r = importTask.result; + const added = (r.accountsImported || 0) + (r.transactionsImported || 0); + const updated = (r.accountsSkipped || 0) + (r.transactionsSkipped || 0); + const duplicated = updated; + return t('settings:import_result_summary', { added, updated, duplicated }); + })()} +
+ )} + {importTask.status === 'FAILED' && importTask.result?.error && ( +
{importTask.result.error}
+ )} +
+ )} + + + + +
+ ); +}; diff --git a/src/components/features/settings/MainSettings.tsx b/src/components/features/settings/MainSettings.tsx index a458976..8f8cd71 100644 --- a/src/components/features/settings/MainSettings.tsx +++ b/src/components/features/settings/MainSettings.tsx @@ -11,7 +11,8 @@ import { Globe, Languages, ListTodo, - LogOut + LogOut, + HardDrive } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; @@ -89,6 +90,15 @@ export const MainSettings = ({ onNavigate }: { onNavigate: (view: SettingsView)
+ {/* Data Management */} + +
{t('settings:data_management')}
+
onNavigate('DATA_EXPORT')} className="p-4 flex justify-between items-center hover:bg-[var(--bg-main)] cursor-pointer"> +
{t('settings:data_export.title')}
+
+
+
+