import { useCallback, useEffect, useRef, useState } from 'react'; import { message } from 'antd'; import { EventsOn } from '../../wailsjs/runtime/runtime'; import { t } from '../i18n'; import type { ExportProgressStatus } from '../utils/exportProgress'; export type ExportProgressEvent = { jobId: string; status?: ExportProgressStatus; stage?: string; current?: number; total?: number; totalRowsKnown?: boolean; format?: string; targetName?: string; filePath?: string; message?: string; }; export type ExportProgressState = { open: boolean; jobId: string; title: string; targetName: string; format: string; startedAt: number; finishedAt: number; status: ExportProgressStatus; stage: string; current: number; total: number; totalRowsKnown: boolean; filePath: string; message: string; }; export type ExportRunResult = { success: boolean; message: string; }; export type RunExportWithProgressOptions = { title: string; targetName: string; format: string; totalRows?: number; run: (jobId: string) => Promise; }; type UseExportProgressRunnerOptions = { showToast?: boolean; }; const createInitialState = (): ExportProgressState => ({ open: false, jobId: '', title: '', targetName: '', format: '', startedAt: 0, finishedAt: 0, status: 'idle', stage: '', current: 0, total: 0, totalRowsKnown: false, filePath: '', message: '', }); const normalizeCount = (value: unknown): number => { const next = Number(value); if (!Number.isFinite(next) || next < 0) { return 0; } return Math.trunc(next); }; const hasUsableTotalRows = (known: boolean, total: unknown): boolean => { if (!known) { return false; } return normalizeCount(total) > 0; }; const buildExportJobId = (): string => `export-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const EXPORT_CANCELED_MESSAGE = '\u5df2\u53d6\u6d88'; const isActiveExportStatus = (status: ExportProgressStatus): boolean => status === 'start' || status === 'running' || status === 'finalizing' || status === 'done' || status === 'error'; export function useExportProgressRunner(options?: UseExportProgressRunnerOptions) { const showToast = options?.showToast !== false; const [state, setState] = useState(() => createInitialState()); const activeJobIdRef = useRef(''); useEffect(() => { const off = EventsOn('export:progress', (event: ExportProgressEvent) => { if (!event || String(event.jobId || '') !== activeJobIdRef.current) { return; } setState((prev) => { if (prev.jobId !== activeJobIdRef.current) { return prev; } const nextStatus = (event.status || prev.status || 'running') as ExportProgressStatus; const nextStartedAt = prev.startedAt > 0 || !isActiveExportStatus(nextStatus) ? prev.startedAt : Date.now(); const rawNextTotal = normalizeCount(typeof event.total === 'number' ? event.total : prev.total); const requestedTotalRowsKnown = typeof event.totalRowsKnown === 'boolean' ? event.totalRowsKnown : prev.totalRowsKnown; const nextTotalRowsKnown = hasUsableTotalRows(requestedTotalRowsKnown, rawNextTotal); const nextTotal = nextTotalRowsKnown ? rawNextTotal : 0; return { ...prev, open: true, startedAt: nextStartedAt, status: nextStatus, finishedAt: (nextStatus === 'done' || nextStatus === 'error') ? (prev.finishedAt || Date.now()) : prev.finishedAt, stage: typeof event.stage === 'string' && event.stage.trim() ? event.stage.trim() : prev.stage, current: normalizeCount(typeof event.current === 'number' ? event.current : prev.current), total: nextTotal, totalRowsKnown: nextTotalRowsKnown, format: typeof event.format === 'string' && event.format.trim() ? String(event.format).toUpperCase() : prev.format, targetName: typeof event.targetName === 'string' && event.targetName.trim() ? event.targetName.trim() : prev.targetName, filePath: typeof event.filePath === 'string' && event.filePath.trim() ? event.filePath.trim() : prev.filePath, message: typeof event.message === 'string' ? event.message : prev.message, }; }); }); return () => { if (typeof off === 'function') off(); }; }, []); const reset = useCallback(() => { activeJobIdRef.current = ''; setState(createInitialState()); }, []); const runExportWithProgress = useCallback(async ( runOptions: RunExportWithProgressOptions, ): Promise => { if (state.open && (state.status === 'start' || state.status === 'running' || state.status === 'finalizing')) { if (showToast) { void message.warning(t('data_export.message.already_running')); } return null; } const jobId = buildExportJobId(); const requestedTotal = normalizeCount(runOptions.totalRows); const totalRowsKnown = hasUsableTotalRows( Number.isFinite(runOptions.totalRows) && Number(runOptions.totalRows) >= 0, requestedTotal, ); activeJobIdRef.current = jobId; setState({ open: true, jobId, title: runOptions.title, targetName: String(runOptions.targetName || '').trim(), format: String(runOptions.format || '').trim().toUpperCase(), startedAt: 0, finishedAt: 0, status: 'start', stage: t('data_export.progress.stage.waiting_file_selection'), current: 0, total: totalRowsKnown ? requestedTotal : 0, totalRowsKnown, filePath: '', message: '', }); try { const result = await runOptions.run(jobId); if (result.success) { setState((prev) => { if (prev.jobId !== jobId) { return prev; } return { ...prev, open: true, status: 'done', finishedAt: prev.finishedAt || Date.now(), stage: prev.stage || t('data_export.progress.title.done'), current: prev.totalRowsKnown ? Math.max(prev.current, prev.total) : prev.current, message: '', }; }); if (showToast) { void message.success(t('data_export.message.export_success')); } } else if (result.message !== EXPORT_CANCELED_MESSAGE) { setState((prev) => { if (prev.jobId !== jobId) { return prev; } return { ...prev, open: true, status: 'error', finishedAt: prev.finishedAt || Date.now(), stage: prev.stage || t('data_export.progress.title.error'), message: result.message, }; }); if (showToast) { void message.error(t('data_export.message.export_failed', { error: result.message })); } } else { reset(); } return result; } catch (error: any) { const errorMessage = error?.message || String(error); setState((prev) => { if (prev.jobId !== jobId) { return prev; } return { ...prev, open: true, status: 'error', finishedAt: prev.finishedAt || Date.now(), stage: prev.stage || t('data_export.progress.title.error'), message: errorMessage, }; }); if (showToast) { void message.error(t('data_export.message.export_failed', { error: errorMessage })); } throw error; } }, [reset, showToast, state.open, state.status]); return { state, reset, runExportWithProgress, isRunning: state.status === 'start' || state.status === 'running' || state.status === 'finalizing', }; }