Files
MyGoNavi/frontend/src/components/useExportProgressRunner.ts
2026-06-23 17:05:35 +08:00

249 lines
7.7 KiB
TypeScript

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<T extends ExportRunResult> = {
title: string;
targetName: string;
format: string;
totalRows?: number;
run: (jobId: string) => Promise<T>;
};
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<ExportProgressState>(() => 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 <T extends ExportRunResult,>(
runOptions: RunExportWithProgressOptions<T>,
): Promise<T | null> => {
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',
};
}