Files
MyGoNavi/frontend/src/components/SQLFileExecutionWorkbench.tsx
Syngnat b388b08226 feat(sql-file-execution): 外部SQL执行切换工作台并优化大文件链路
- 前端将运行外部SQL改为工作台Tab并复用导出式进度视图

- 后端新增仅选择文件元数据接口并优化流式读取与批量拼接

- 补充执行Runner、国际化文案和定向测试
2026-07-03 22:11:49 +08:00

576 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { Alert, Button, Empty, Progress, Typography } from 'antd';
import {
ClockCircleOutlined,
FileTextOutlined,
ReloadOutlined,
StopOutlined,
} from '@ant-design/icons';
import { ExecuteSQLFile, CancelSQLFileExecution } from '../../wailsjs/go/app/App';
import { useStore } from '../store';
import type { TabData } from '../types';
import { t } from '../i18n';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
import { resolveConnectionHostSummary } from '../utils/tabDisplay';
import { formatExportElapsed, resolveExportElapsedMs } from '../utils/exportProgress';
import {
useSQLFileExecutionRunner,
type SQLFileExecutionRunnerStatus,
type SQLFileExecutionState,
} from './useSQLFileExecutionRunner';
const { Paragraph, Text, Title } = Typography;
type SQLFileExecutionHistoryEntry = SQLFileExecutionState & {
requestKey: string;
};
const EMPTY_HISTORY: SQLFileExecutionHistoryEntry[] = [];
const formatDateTime = (timestamp: number): string => {
if (!Number.isFinite(timestamp) || timestamp <= 0) {
return '-';
}
return new Date(timestamp).toLocaleString('zh-CN', { hour12: false });
};
const resolveStatusMeta = (status: SQLFileExecutionRunnerStatus): {
label: string;
border: string;
bg: string;
text: string;
} => {
const meta: Record<SQLFileExecutionRunnerStatus, { label: string; border: string; bg: string; text: string }> = {
idle: {
label: t('sidebar.sql_file_exec.workbench.empty.not_started'),
border: 'rgba(148, 163, 184, 0.35)',
bg: 'rgba(148, 163, 184, 0.12)',
text: '#475467',
},
start: {
label: t('sidebar.sql_file_exec.workbench.stage.preparing'),
border: 'rgba(59, 130, 246, 0.3)',
bg: 'rgba(59, 130, 246, 0.12)',
text: '#1d4ed8',
},
running: {
label: t('sidebar.sql_file_exec.status.running'),
border: 'rgba(16, 185, 129, 0.3)',
bg: 'rgba(16, 185, 129, 0.14)',
text: '#047857',
},
done: {
label: t('sidebar.sql_file_exec.status.done'),
border: 'rgba(34, 197, 94, 0.3)',
bg: 'rgba(34, 197, 94, 0.14)',
text: '#15803d',
},
cancelled: {
label: t('sidebar.sql_file_exec.status.cancelled'),
border: 'rgba(249, 115, 22, 0.3)',
bg: 'rgba(249, 115, 22, 0.12)',
text: '#c2410c',
},
error: {
label: t('sidebar.sql_file_exec.status.error'),
border: 'rgba(239, 68, 68, 0.32)',
bg: 'rgba(239, 68, 68, 0.12)',
text: '#dc2626',
},
};
return meta[status];
};
const renderStatusPill = (status: SQLFileExecutionRunnerStatus) => {
const meta = resolveStatusMeta(status);
return (
<span
style={{
display: 'inline-flex',
alignItems: 'center',
padding: '4px 10px',
borderRadius: 999,
border: `1px solid ${meta.border}`,
background: meta.bg,
color: meta.text,
fontSize: 12,
lineHeight: 1.2,
fontWeight: 600,
whiteSpace: 'nowrap',
}}
>
{meta.label}
</span>
);
};
const formatExecutionSummary = (executed: number, failed: number): string =>
`${t('sidebar.sql_file_exec.executed_label')}${executed.toLocaleString()}${t('sidebar.sql_file_exec.rows_separator')}${failed.toLocaleString()}${t('sidebar.sql_file_exec.rows_suffix')}`;
const resolveProgressStatus = (status: SQLFileExecutionRunnerStatus): 'active' | 'success' | 'exception' | 'normal' => {
if (status === 'done') return 'success';
if (status === 'error') return 'exception';
if (status === 'start' || status === 'running') return 'active';
return 'normal';
};
const SQLFileExecutionWorkbench: React.FC<{ tab: TabData }> = ({ tab }) => {
const connections = useStore((state) => state.connections);
const theme = useStore((state) => state.theme);
const [nowTick, setNowTick] = useState(() => Date.now());
const [historyEntries, setHistoryEntries] = useState<SQLFileExecutionHistoryEntry[]>(EMPTY_HISTORY);
const lastArchivedJobIdRef = useRef('');
const lastRequestKeyRef = useRef('');
const darkMode = theme === 'dark';
const shellBg = darkMode ? '#101319' : '#f5f7fb';
const panelBg = darkMode ? '#161b22' : '#ffffff';
const panelBorder = darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(15,23,42,0.08)';
const dividerColor = darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(15,23,42,0.08)';
const headingColor = darkMode ? 'rgba(255,255,255,0.96)' : '#101828';
const secondaryTextColor = darkMode ? 'rgba(255,255,255,0.68)' : '#667085';
const subtleBg = darkMode ? 'rgba(255,255,255,0.04)' : '#f8fafc';
const connection = useMemo(
() => connections.find((item) => item.id === String(tab.connectionId || '').trim()),
[connections, tab.connectionId],
);
const connectionConfig = useMemo(
() => (connection ? buildRpcConnectionConfig(connection.config) : null),
[connection],
);
const hostSummary = useMemo(
() => resolveConnectionHostSummary(connection?.config),
[connection?.config],
);
const { state, reset, cancelExecution, runSQLFileExecutionWithProgress, isRunning } = useSQLFileExecutionRunner({
showToast: true,
});
useEffect(() => {
if (!state.startedAt || state.finishedAt > 0) {
return undefined;
}
const timer = globalThis.setInterval(() => {
setNowTick(Date.now());
}, 1000);
return () => {
globalThis.clearInterval(timer);
};
}, [state.finishedAt, state.startedAt]);
useEffect(() => {
if (!state.jobId) {
return;
}
if (state.status !== 'done' && state.status !== 'cancelled' && state.status !== 'error') {
return;
}
if (lastArchivedJobIdRef.current === state.jobId) {
return;
}
lastArchivedJobIdRef.current = state.jobId;
setHistoryEntries((prev) => [
{
...state,
requestKey: String(tab.sqlFileExecutionRequestKey || '').trim(),
},
...prev.filter((entry) => entry.jobId !== state.jobId),
].slice(0, 10));
}, [state, tab.sqlFileExecutionRequestKey]);
const startExecution = React.useCallback(async () => {
const filePath = String(tab.filePath || '').trim();
if (!connectionConfig || !filePath) {
return;
}
await runSQLFileExecutionWithProgress({
title: tab.title || t('sidebar.sql_file_exec.title'),
filePath,
fileSizeMB: tab.sqlFileExecutionFileSizeMB,
run: (jobId) => ExecuteSQLFile(connectionConfig as any, tab.dbName || '', filePath, jobId),
cancel: (jobId) => {
CancelSQLFileExecution(jobId);
},
});
}, [
connectionConfig,
runSQLFileExecutionWithProgress,
tab.dbName,
tab.filePath,
tab.sqlFileExecutionFileSizeMB,
tab.title,
]);
useEffect(() => {
const requestKey = String(tab.sqlFileExecutionRequestKey || '').trim();
if (!requestKey || requestKey === lastRequestKeyRef.current) {
return;
}
if (!connectionConfig || !String(tab.filePath || '').trim()) {
return;
}
lastRequestKeyRef.current = requestKey;
void startExecution();
}, [connectionConfig, startExecution, tab.filePath, tab.sqlFileExecutionRequestKey]);
const currentElapsedMs = useMemo(
() => resolveExportElapsedMs(state.startedAt, state.finishedAt, nowTick),
[nowTick, state.finishedAt, state.startedAt],
);
const progressPercent = Math.max(0, Math.min(100, Number(state.percent) || 0));
const currentSummary = formatExecutionSummary(state.executed, state.failed);
const historyList = useMemo(
() => historyEntries.filter((entry) => entry.jobId !== state.jobId),
[historyEntries, state.jobId],
);
return (
<div
data-sql-file-execution-workbench="true"
style={{
display: 'flex',
flexDirection: 'column',
height: '100%',
minHeight: 0,
background: shellBg,
color: headingColor,
}}
>
<div style={{ padding: '18px 22px 10px' }}>
<Title level={4} style={{ margin: 0, color: headingColor }}>
{t('sidebar.sql_file_exec.title')}
</Title>
<div style={{ marginTop: 6, color: secondaryTextColor, fontSize: 13 }}>
{t('sidebar.sql_file_exec.workbench.helper.auto_run')}
</div>
</div>
<div
style={{
display: 'grid',
gridTemplateColumns: 'minmax(280px, 360px) minmax(0, 1fr)',
gap: 20,
padding: '0 22px 22px',
flex: 1,
minHeight: 0,
}}
>
<section
style={{
padding: 20,
borderRadius: 8,
background: panelBg,
border: panelBorder,
display: 'flex',
flexDirection: 'column',
gap: 16,
minHeight: 0,
}}
>
<div>
<div style={{ fontSize: 13, fontWeight: 600, color: headingColor, marginBottom: 10 }}>
{t('sidebar.sql_file_exec.workbench.section.config')}
</div>
<div style={{ display: 'grid', gridTemplateColumns: '96px minmax(0, 1fr)', rowGap: 8, columnGap: 10 }}>
<Text type="secondary">{t('data_export.label.connection')}</Text>
<Text>{connection?.name || '-'}</Text>
<Text type="secondary">{t('data_export.label.database')}</Text>
<Text>{tab.dbName || '-'}</Text>
<Text type="secondary">{t('sidebar.sql_file_exec.workbench.label.file_path')}</Text>
<Paragraph style={{ marginBottom: 0, wordBreak: 'break-all' }}>
{tab.filePath || '-'}
</Paragraph>
<Text type="secondary">{t('sidebar.sql_file_exec.file_size').replace(/[:]\s*$/, '')}</Text>
<Text>{tab.sqlFileExecutionFileSizeMB ? `${tab.sqlFileExecutionFileSizeMB} MB` : '-'}</Text>
<Text type="secondary">{t('data_export.label.host')}</Text>
<Text>{hostSummary || '-'}</Text>
</div>
</div>
{!connectionConfig ? (
<Alert
type="warning"
showIcon
message={t('sidebar.message.connection_config_not_found')}
/>
) : null}
<div
style={{
marginTop: 'auto',
padding: 14,
borderRadius: 8,
background: subtleBg,
border: `1px solid ${dividerColor}`,
display: 'flex',
flexDirection: 'column',
gap: 10,
}}
>
<div style={{ fontSize: 12, color: secondaryTextColor }}>
{t('sidebar.sql_file_exec.workbench.helper.reuse')}
</div>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
{isRunning ? (
<Button danger icon={<StopOutlined />} onClick={() => void cancelExecution()}>
{t('sidebar.sql_file_exec.cancel')}
</Button>
) : (
<Button
type="primary"
icon={state.status === 'idle' ? <FileTextOutlined /> : <ReloadOutlined />}
disabled={!connectionConfig || !String(tab.filePath || '').trim()}
onClick={() => {
void startExecution();
}}
>
{t('sidebar.sql_file_exec.workbench.action.run_again')}
</Button>
)}
{(state.status === 'done' || state.status === 'cancelled' || state.status === 'error') ? (
<Button icon={<ReloadOutlined />} onClick={reset}>
{t('data_export.action.clear_progress')}
</Button>
) : null}
</div>
</div>
</section>
<div style={{ display: 'flex', flexDirection: 'column', gap: 20, minWidth: 0, minHeight: 0 }}>
<section
style={{
padding: 20,
borderRadius: 8,
background: panelBg,
border: panelBorder,
display: 'flex',
flexDirection: 'column',
gap: 18,
}}
>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16, flexWrap: 'wrap' }}>
<div style={{ minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
<div style={{ fontSize: 13, fontWeight: 600, color: headingColor }}>
{t('data_export.workbench.section.current_task')}
</div>
{renderStatusPill(state.status)}
</div>
<Title level={5} style={{ margin: '10px 0 0', color: headingColor }}>
{state.title || tab.title || t('sidebar.sql_file_exec.title')}
</Title>
<div style={{ marginTop: 6, color: secondaryTextColor, fontSize: 13 }}>
{state.jobId ? `${state.filePath || tab.filePath || '-'} · ${currentSummary}` : t('sidebar.sql_file_exec.workbench.description.current_task_empty')}
</div>
</div>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(2, minmax(120px, auto))',
gap: '12px 18px',
alignSelf: 'stretch',
}}
>
<div>
<div style={{ fontSize: 12, color: secondaryTextColor, marginBottom: 4 }}>
{t('sidebar.sql_file_exec.workbench.label.elapsed')}
</div>
<div style={{ color: headingColor, fontWeight: 600, display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<ClockCircleOutlined />
{state.startedAt ? formatExportElapsed(currentElapsedMs) : '--:--'}
</div>
</div>
<div>
<div style={{ fontSize: 12, color: secondaryTextColor, marginBottom: 4 }}>
{t('sidebar.sql_file_exec.workbench.label.started_at')}
</div>
<div style={{ color: headingColor, fontWeight: 600 }}>{formatDateTime(state.startedAt)}</div>
</div>
<div>
<div style={{ fontSize: 12, color: secondaryTextColor, marginBottom: 4 }}>
{t('sidebar.sql_file_exec.workbench.label.file_path')}
</div>
<div style={{ color: headingColor, fontWeight: 600, maxWidth: 360, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{state.filePath || tab.filePath || '-'}
</div>
</div>
<div>
<div style={{ fontSize: 12, color: secondaryTextColor, marginBottom: 4 }}>
{t('sidebar.sql_file_exec.workbench.label.progress_summary')}
</div>
<div style={{ color: headingColor, fontWeight: 600 }}>{state.jobId ? currentSummary : '-'}</div>
</div>
</div>
</div>
{state.jobId ? (
<>
<div>
<Progress
percent={Math.round(progressPercent)}
status={resolveProgressStatus(state.status)}
strokeColor={state.status === 'cancelled' ? '#faad14' : undefined}
/>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 0.9fr)', gap: 18 }}>
<div>
<div style={{ fontSize: 12, color: secondaryTextColor, marginBottom: 6 }}>
{t('sidebar.sql_file_exec.workbench.label.current_stage')}
</div>
<Text>{state.stage || resolveStatusMeta(state.status).label}</Text>
</div>
<div>
<div style={{ fontSize: 12, color: secondaryTextColor, marginBottom: 6 }}>
{t('sidebar.sql_file_exec.workbench.label.file_path')}
</div>
<Paragraph style={{ marginBottom: 0, wordBreak: 'break-all' }}>
{state.filePath || '-'}
</Paragraph>
</div>
</div>
{state.currentSQL ? (
<div>
<div style={{ fontSize: 12, color: secondaryTextColor, marginBottom: 6 }}>
{t('sidebar.sql_file_exec.workbench.label.current_sql')}
</div>
<div
style={{
fontSize: 12,
color: secondaryTextColor,
background: subtleBg,
borderRadius: 8,
padding: '10px 12px',
fontFamily: 'var(--gn-font-mono)',
wordBreak: 'break-all',
maxHeight: 96,
overflow: 'auto',
}}
>
{state.currentSQL}
</div>
</div>
) : null}
{state.message ? (
<Alert
type={state.status === 'error' ? 'error' : state.status === 'cancelled' ? 'warning' : 'info'}
showIcon
message={state.message}
/>
) : null}
</>
) : (
<div>
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={t('sidebar.sql_file_exec.workbench.empty.not_started')}
/>
</div>
)}
</section>
<section
style={{
padding: 20,
borderRadius: 8,
background: panelBg,
border: panelBorder,
display: 'flex',
flexDirection: 'column',
gap: 14,
minHeight: 0,
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
<div>
<div style={{ fontSize: 13, fontWeight: 600, color: headingColor }}>
{t('data_export.workbench.section.history')}
</div>
<div style={{ marginTop: 4, fontSize: 12, color: secondaryTextColor }}>
{t('sidebar.sql_file_exec.workbench.empty.history')}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{ color: secondaryTextColor, fontSize: 12 }}>
{historyList.length.toLocaleString()}
</div>
{historyList.length > 0 ? (
<Button size="small" type="text" onClick={() => setHistoryEntries(EMPTY_HISTORY)}>
{t('sidebar.sql_file_exec.workbench.action.clear_history')}
</Button>
) : null}
</div>
</div>
{historyList.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', minHeight: 0, overflow: 'auto' }}>
{historyList.map((entry, index) => {
const elapsed = formatExportElapsed(resolveExportElapsedMs(entry.startedAt, entry.finishedAt, nowTick));
return (
<div
key={entry.jobId}
style={{
display: 'grid',
gridTemplateColumns: 'minmax(0, 1fr) minmax(260px, 0.85fr)',
gap: 18,
padding: '14px 0',
borderTop: index === 0 ? 'none' : `1px solid ${dividerColor}`,
}}
>
<div style={{ minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
<Text strong>{entry.title || tab.title}</Text>
{renderStatusPill(entry.status)}
<span style={{ fontSize: 12, color: secondaryTextColor }}>
{formatExecutionSummary(entry.executed, entry.failed)}
</span>
</div>
<div style={{ marginTop: 6, fontSize: 13, color: headingColor }}>
{entry.stage || resolveStatusMeta(entry.status).label}
</div>
{entry.message ? (
<div style={{ marginTop: 8, fontSize: 12, color: entry.status === 'error' ? '#dc2626' : secondaryTextColor, whiteSpace: 'pre-wrap' }}>
{entry.message}
</div>
) : null}
</div>
<div style={{ minWidth: 0 }}>
<div style={{ display: 'grid', gridTemplateColumns: '84px minmax(0, 1fr)', rowGap: 6, columnGap: 10 }}>
<Text type="secondary">{t('sidebar.sql_file_exec.workbench.label.started_at')}</Text>
<Text>{formatDateTime(entry.startedAt)}</Text>
<Text type="secondary">{t('sidebar.sql_file_exec.workbench.label.elapsed')}</Text>
<Text>{elapsed}</Text>
<Text type="secondary">{t('sidebar.sql_file_exec.workbench.label.file_path')}</Text>
<Paragraph style={{ marginBottom: 0, wordBreak: 'break-all' }}>
{entry.filePath || '-'}
</Paragraph>
</div>
</div>
</div>
);
})}
</div>
) : (
<div style={{ padding: '6px 0 2px', color: secondaryTextColor, fontSize: 13 }}>
{t('sidebar.sql_file_exec.workbench.empty.history')}
</div>
)}
</section>
</div>
</div>
</div>
);
};
export default SQLFileExecutionWorkbench;