Files
MyGoNavi/frontend/src/components/DatabaseImportExecutionPanel.tsx
Syngnat 16a601e6a4 feat(data-import): 完善导入工作台与任务交互
- 表导入与数据库导入统一提供遇错停止和继续策略,并跟随主题变量

- 增加能力校验、高级解析选项、字节进度、停止状态与持久任务历史

- 防止重复启动和运行中面板卸载,完善六语言语句统计及回归测试
2026-08-08 20:51:59 +08:00

389 lines
13 KiB
TypeScript

import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Alert, Button, Progress, Typography } from 'antd';
import {
PlayCircleOutlined,
ReloadOutlined,
StopOutlined,
} from '@ant-design/icons';
import { CancelSQLFileExecution, ImportDatabaseSQL } from '../../wailsjs/go/app/App';
import { t as defaultTranslate } from '../i18n';
import { useOptionalI18n } from '../i18n/provider';
import type { SavedConnection } from '../types';
import { confirmProductionRisk } from '../utils/productionRiskConfirm';
import { formatImportBytes, formatImportDuration } from './importProgressMetrics';
import Modal from './common/ResizableDraggableModal';
import {
useSQLFileExecutionRunner,
type SQLFileExecutionRunnerStatus,
} from './useSQLFileExecutionRunner';
const { Paragraph, Text, Title } = Typography;
type DatabaseImportExecutionPanelProps = {
connection?: SavedConnection;
connectionConfig: Record<string, unknown> | null;
dbName?: string;
filePath: string;
fileSizeMB?: string;
darkMode: boolean;
continueOnError: boolean;
onRunningChange?: (running: boolean) => void;
};
const getFileName = (filePath: string): string => {
const parts = String(filePath || '').split(/[\\/]/);
return parts[parts.length - 1] || filePath;
};
const resolveProgressStatus = (
status: SQLFileExecutionRunnerStatus,
): 'active' | 'success' | 'exception' | 'normal' => {
if (status === 'done') return 'success';
if (status === 'error') return 'exception';
if (status === 'start' || status === 'running' || status === 'stopping') return 'active';
return 'normal';
};
const DatabaseImportExecutionPanel: React.FC<DatabaseImportExecutionPanelProps> = ({
connection,
connectionConfig,
dbName = '',
filePath,
fileSizeMB,
darkMode,
continueOnError,
onRunningChange,
}) => {
const i18n = useOptionalI18n();
const t = i18n?.t ?? defaultTranslate;
const [executionPending, setExecutionPending] = useState(false);
const [cancelRequested, setCancelRequested] = useState(false);
const lastReportedRunningRef = useRef<boolean | null>(null);
const {
state,
reset,
cancelExecution,
runSQLFileExecutionWithProgress,
isRunning,
} = useSQLFileExecutionRunner({ showToast: false });
const taskRunning = isRunning || executionPending;
const progressPercent = Math.max(0, Math.min(100, Number(state.percent) || 0));
const terminal = state.status === 'done'
|| state.status === 'cancelled'
|| state.status === 'error';
const completedWithErrors = state.status === 'done' && state.failed > 0;
const subtleBackground = `var(--gn-bg-subtle, var(--gn-bg-panel-2, ${darkMode
? 'rgba(255,255,255,0.04)'
: '#f8fafc'}))`;
const dividerColor = `var(--gn-br-1, ${darkMode
? 'rgba(255,255,255,0.08)'
: 'rgba(15,23,42,0.08)'})`;
const warningColor = 'var(--gn-warn, #faad14)';
const transferMetrics = useMemo(() => {
if (state.bytesRead <= 0 && state.totalBytes <= 0) return '';
const details = [t('data_import.workbench.progress.bytes', {
processed: formatImportBytes(state.bytesRead),
total: state.totalBytes > 0 ? formatImportBytes(state.totalBytes) : '—',
})];
if (state.bytesPerSecond > 0) {
details.push(t('data_import.workbench.progress.throughput', {
rate: formatImportBytes(state.bytesPerSecond),
}));
}
if (state.etaSeconds > 0) {
details.push(t('data_import.workbench.progress.eta', {
duration: formatImportDuration(state.etaSeconds, i18n?.language),
}));
}
return details.join(' · ');
}, [i18n?.language, state.bytesPerSecond, state.bytesRead, state.etaSeconds, state.totalBytes, t]);
useEffect(() => {
if (lastReportedRunningRef.current === taskRunning) return;
lastReportedRunningRef.current = taskRunning;
onRunningChange?.(taskRunning);
}, [onRunningChange, taskRunning]);
const startImport = useCallback(async () => {
if (!connectionConfig || !String(filePath || '').trim() || taskRunning) return;
const approved = await confirmProductionRisk({
connection,
action: t('connection.production_risk.action.execute_sql'),
target: [dbName, getFileName(filePath)].filter(Boolean).join(' / '),
translate: t,
});
if (!approved) return;
setExecutionPending(true);
setCancelRequested(false);
try {
await runSQLFileExecutionWithProgress({
title: getFileName(filePath),
filePath,
fileSizeMB,
run: async (jobId) => {
const result = await ImportDatabaseSQL(
connectionConfig as any,
String(dbName || '').trim(),
filePath,
jobId,
continueOnError,
);
// Reaching EOF with recorded statement errors is a completed import,
// not a transport/fatal failure. Preserve the counters and render it
// as a warning result instead of offering a misleading fatal retry.
if (continueOnError && result.data?.completed === true) {
return { ...result, success: true };
}
return result;
},
cancel: async (jobId) => {
const result = await CancelSQLFileExecution(jobId);
if (!result?.success) {
throw new Error(result?.message || t('import_preview.error.stop_failed'));
}
},
});
} catch {
// The shared runner already records and displays the RPC error state.
} finally {
setExecutionPending(false);
}
}, [
connectionConfig,
connection,
continueOnError,
dbName,
filePath,
fileSizeMB,
runSQLFileExecutionWithProgress,
taskRunning,
t,
]);
const requestCancel = useCallback(async () => {
if (!taskRunning || cancelRequested) return;
setCancelRequested(true);
try {
await cancelExecution();
} catch {
setCancelRequested(false);
}
}, [cancelExecution, cancelRequested, taskRunning]);
const resetProgress = useCallback(() => {
if (taskRunning) return;
setCancelRequested(false);
reset();
}, [reset, taskRunning]);
const requestStartImport = useCallback(() => {
if (!terminal) {
void startImport();
return;
}
Modal.confirm({
title: t('data_import.workbench.confirm.rerun_title'),
content: t('data_import.workbench.confirm.rerun_content'),
okText: t('data_import.workbench.action.retry_database_import'),
cancelText: t('common.cancel'),
okButtonProps: { danger: true },
onOk: startImport,
});
}, [startImport, t, terminal]);
const statusText = useMemo(() => {
if (cancelRequested && taskRunning) return t('data_import.workbench.state.cancelling');
switch (state.status) {
case 'start':
case 'running':
return t('data_import.workbench.state.running');
case 'done':
return state.failed > 0
? t('data_import.workbench.state.completed_with_errors')
: t('data_import.workbench.state.completed');
case 'error':
return t('data_import.workbench.state.failed');
case 'cancelled':
return t('data_import.workbench.state.cancelled');
default:
return t('data_import.workbench.state.ready_sql_title');
}
}, [cancelRequested, state.failed, state.status, t, taskRunning]);
const stageText = useMemo(() => {
switch (state.stage) {
case 'prepare':
return t('import_preview.stage.prepare');
case 'preflight':
return t('import_preview.stage.preflight');
case 'read':
return t('import_preview.stage.read');
case 'parse':
return t('import_preview.stage.parse');
case 'write':
return t('import_preview.stage.write');
case 'finalize':
return t('import_preview.stage.finalize');
default:
return state.stage || statusText;
}
}, [state.stage, statusText, t]);
const resultAlertType = state.status === 'error'
? 'error'
: state.status === 'cancelled'
? 'warning'
: completedWithErrors
? 'warning'
: 'success';
return (
<div
data-database-import-execution-panel="true"
style={{ display: 'flex', flexDirection: 'column', gap: 16, minWidth: 0 }}
>
<Alert
type="warning"
showIcon
message={continueOnError
? t('data_import.workbench.notice.continue_on_error')
: t('data_import.workbench.notice.stop_on_error')}
/>
<Alert
type="info"
showIcon
message={t('data_import.workbench.notice.gonavi_mysql_restore')}
/>
<div
data-database-import-status-card="true"
style={{
padding: 16,
borderRadius: 8,
border: `1px solid ${dividerColor}`,
background: subtleBackground,
}}
>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 }}>
<div style={{ minWidth: 0 }}>
<Title level={5} style={{ margin: 0, letterSpacing: 0 }}>
{statusText}
</Title>
<Paragraph
type="secondary"
title={filePath}
style={{ margin: '6px 0 0', wordBreak: 'break-all' }}
>
{state.status === 'idle'
? t('data_import.workbench.state.ready_sql_description')
: state.filePath || filePath}
</Paragraph>
</div>
{fileSizeMB ? <Text type="secondary">{fileSizeMB} MB</Text> : null}
</div>
{state.status !== 'idle' ? (
<div style={{ marginTop: 16 }}>
<Progress
data-database-import-progress="true"
percent={Math.round(progressPercent)}
status={completedWithErrors ? 'normal' : resolveProgressStatus(state.status)}
strokeColor={state.status === 'cancelled' || completedWithErrors ? warningColor : undefined}
/>
<div style={{ marginTop: 8, display: 'flex', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
<Text data-database-import-stage="true" type="secondary">{stageText}</Text>
<Text type="secondary">
{t('data_import.workbench.progress.statements', {
executed: state.executed,
failed: state.failed,
total: state.total,
})}
</Text>
</div>
{transferMetrics ? (
<Text
data-database-import-transfer-metrics="true"
type="secondary"
style={{ display: 'block', marginTop: 6, fontSize: 12 }}
>
{transferMetrics}
</Text>
) : null}
</div>
) : null}
{state.currentSQL ? (
<div
data-database-import-current-sql="true"
style={{
marginTop: 14,
maxHeight: 112,
overflow: 'auto',
padding: '10px 12px',
borderRadius: 6,
border: `1px solid ${dividerColor}`,
fontFamily: 'var(--gn-font-mono)',
fontSize: 12,
wordBreak: 'break-all',
}}
>
{state.currentSQL}
</div>
) : null}
{terminal && state.message ? (
<Alert
data-database-import-result="true"
style={{ marginTop: 14 }}
type={resultAlertType}
showIcon
message={<div style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>{state.message}</div>}
/>
) : null}
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', marginTop: 16 }}>
{taskRunning ? (
<Button
data-database-import-cancel-action="true"
danger
icon={<StopOutlined />}
loading={cancelRequested}
disabled={cancelRequested}
onClick={() => void requestCancel()}
>
{cancelRequested
? t('data_import.workbench.state.cancelling')
: t('data_import.workbench.action.cancel_database_import')}
</Button>
) : (
<Button
data-database-import-start-action="true"
type="primary"
icon={terminal ? <ReloadOutlined /> : <PlayCircleOutlined />}
disabled={!connectionConfig || !String(filePath || '').trim()}
onClick={requestStartImport}
>
{terminal
? t('data_import.workbench.action.retry_database_import')
: t('data_import.workbench.action.start_database_import')}
</Button>
)}
{terminal ? (
<Button
data-database-import-clear-progress-action="true"
icon={<ReloadOutlined />}
onClick={resetProgress}
>
{t('data_export.action.clear_progress')}
</Button>
) : null}
</div>
</div>
</div>
);
};
export default DatabaseImportExecutionPanel;