mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-10 00:33:28 +08:00
- 表导入与数据库导入统一提供遇错停止和继续策略,并跟随主题变量 - 增加能力校验、高级解析选项、字节进度、停止状态与持久任务历史 - 防止重复启动和运行中面板卸载,完善六语言语句统计及回归测试
389 lines
13 KiB
TypeScript
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;
|