Files
MyGoNavi/frontend/src/components/TableExportWorkbench.tsx
2026-06-23 13:14:22 +08:00

1445 lines
59 KiB
TypeScript

import React, { useEffect, useMemo, useState } from 'react';
import { Alert, Button, Empty, InputNumber, Select, Tooltip, Typography } from 'antd';
import { ClockCircleOutlined, ExportOutlined, ReloadOutlined } from '@ant-design/icons';
import {
DBGetDatabases,
DBGetTables,
ExportDatabasesSQLWithOptions,
ExportQueryWithOptions,
ExportTableWithOptions,
ExportTablesSQLWithOptions,
} from '../../wailsjs/go/app/App';
import { useStore } from '../store';
import type {
SavedConnection,
TabData,
TableExportHistoryEntry,
TableExportScope,
TableExportScopeOption,
} from '../types';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
import { resolveConnectionHostSummary } from '../utils/tabDisplay';
import { buildExportWorkbenchHistoryKey } from '../utils/tableExportTab';
import {
formatExportElapsed,
formatExportProgressRows,
resolveExportElapsedMs,
type ExportProgressStatus,
} from '../utils/exportProgress';
import { t } from '../i18n';
import {
DATA_EXPORT_FORMAT_OPTIONS,
DEFAULT_DATA_EXPORT_FORMAT,
DEFAULT_XLSX_ROWS_PER_SHEET,
MAX_XLSX_ROWS_PER_SHEET,
type DataExportFormat,
} from './DataExportDialog';
import ExportProgressBar from './ExportProgressBar';
import { useExportProgressRunner } from './useExportProgressRunner';
import type { ExportProgressState } from './useExportProgressRunner';
const { Text, Paragraph, Title } = Typography;
const EMPTY_HISTORY: TableExportHistoryEntry[] = [];
type ExportWorkbenchMode = NonNullable<TabData['exportWorkbenchMode']>;
type BatchTableExportMode = 'schema' | 'dataOnly' | 'backup';
type BatchDatabaseExportMode = 'schema' | 'backup';
type SelectOption = { value: string; label: React.ReactNode; title: string };
const createDefaultScopeOptions = (): TableExportScopeOption[] => [
{
value: 'all',
label: t('data_export.workbench.scope.all.label'),
description: t('data_export.workbench.scope.all.description'),
},
];
const SELECT_ELLIPSIS_LABEL_STYLE: React.CSSProperties = {
display: 'block',
width: '100%',
minWidth: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
};
const createBatchTableExportModeOptions = (): Array<{ value: BatchTableExportMode; label: string; description: string }> => [
{
value: 'schema',
label: t('data_export.workbench.batch_tables.mode.schema.label'),
description: t('data_export.workbench.batch_tables.mode.schema.description'),
},
{
value: 'dataOnly',
label: t('data_export.workbench.batch_tables.mode.data_only.label'),
description: t('data_export.workbench.batch_tables.mode.data_only.description'),
},
{
value: 'backup',
label: t('data_export.workbench.batch_tables.mode.backup.label'),
description: t('data_export.workbench.batch_tables.mode.backup.description'),
},
];
const createBatchDatabaseExportModeOptions = (): Array<{ value: BatchDatabaseExportMode; label: string; description: string }> => [
{
value: 'schema',
label: t('data_export.workbench.batch_databases.mode.schema.label'),
description: t('data_export.workbench.batch_databases.mode.schema.description'),
},
{
value: 'backup',
label: t('data_export.workbench.batch_databases.mode.backup.label'),
description: t('data_export.workbench.batch_databases.mode.backup.description'),
},
];
const normalizeScopeOptions = (input: TabData['tableExportScopeOptions']): TableExportScopeOption[] => {
if (!Array.isArray(input) || input.length === 0) {
return createDefaultScopeOptions();
}
return input;
};
const resolveInitialScope = (
scopeOptions: TableExportScopeOption[],
preferred?: TableExportScope,
): TableExportScope => {
if (preferred && scopeOptions.some((item) => item.value === preferred && !item.disabled)) {
return preferred;
}
return scopeOptions.find((item) => !item.disabled)?.value || 'all';
};
const normalizeConnectionConfig = (connection: SavedConnection) => ({
...connection.config,
port: Number(connection.config.port),
password: connection.config.password || '',
database: connection.config.database || '',
useSSH: connection.config.useSSH || false,
ssh: connection.config.ssh || { host: '', port: 22, user: '', password: '', keyPath: '' },
});
const formatDateTime = (timestamp: number): string => {
if (!Number.isFinite(timestamp) || timestamp <= 0) return '-';
return new Date(timestamp).toLocaleString('zh-CN', { hour12: false });
};
const resolveWorkbenchMode = (tab: TabData): ExportWorkbenchMode => tab.exportWorkbenchMode || 'single';
const resolveObjectTypeLabel = (objectType?: TabData['objectType']): string => {
if (objectType === 'view') return t('data_export.workbench.object_type.view');
if (objectType === 'materialized-view') return t('data_export.workbench.object_type.materialized_view');
return t('data_export.workbench.object_type.table');
};
const resolveStatusMeta = (status: ExportProgressStatus): { label: string; border: string; bg: string; text: string } => {
const meta: Record<ExportProgressStatus, { label: string; border: string; bg: string; text: string }> = {
idle: { label: t('data_export.progress.status.idle'), border: 'rgba(148, 163, 184, 0.35)', bg: 'rgba(148, 163, 184, 0.12)', text: '#475467' },
start: { label: t('data_export.progress.status.start'), border: 'rgba(59, 130, 246, 0.3)', bg: 'rgba(59, 130, 246, 0.12)', text: '#1d4ed8' },
running: { label: t('data_export.progress.status.running'), border: 'rgba(16, 185, 129, 0.3)', bg: 'rgba(16, 185, 129, 0.14)', text: '#047857' },
finalizing: { label: t('data_export.progress.status.finalizing'), border: 'rgba(249, 115, 22, 0.3)', bg: 'rgba(249, 115, 22, 0.12)', text: '#c2410c' },
done: { label: t('data_export.progress.status.done'), border: 'rgba(34, 197, 94, 0.3)', bg: 'rgba(34, 197, 94, 0.14)', text: '#15803d' },
error: { label: t('data_export.progress.status.error'), border: 'rgba(239, 68, 68, 0.32)', bg: 'rgba(239, 68, 68, 0.12)', text: '#dc2626' },
};
return meta[status] || meta.idle;
};
const renderStatusPill = (status: ExportProgressStatus) => {
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 renderSelectLabel = (text: string): React.ReactNode => (
<span title={text} style={SELECT_ELLIPSIS_LABEL_STYLE}>
{text}
</span>
);
const toSortedSelectOptions = (values: string[]): SelectOption[] =>
values
.filter((value) => String(value || '').trim())
.map((value) => ({
value: String(value).trim(),
label: renderSelectLabel(String(value).trim()),
title: String(value).trim(),
}))
.sort((a, b) => a.title.toLowerCase().localeCompare(b.title.toLowerCase()));
const filterOptionByLabel = (input: string, option?: { title?: string; value?: string }) =>
String(option?.title || option?.value || '').toLowerCase().includes(String(input || '').trim().toLowerCase());
const resolveBatchTableModeMeta = (mode: BatchTableExportMode) =>
createBatchTableExportModeOptions().find((item) => item.value === mode) || createBatchTableExportModeOptions()[0];
const resolveBatchDatabaseModeMeta = (mode: BatchDatabaseExportMode) =>
createBatchDatabaseExportModeOptions().find((item) => item.value === mode) || createBatchDatabaseExportModeOptions()[0];
const resolveBatchTablesTargetName = (dbName: string, objectCount: number): string => {
const safeDbName = String(dbName || '').trim() || t('data_export.workbench.target.current_database');
return t('data_export.workbench.target.batch_tables', { database: safeDbName, count: objectCount });
};
const resolveBatchDatabasesTargetName = (databaseCount: number): string => (
t('data_export.workbench.target.batch_databases', { count: databaseCount })
);
const formatWorkbenchProgressSummary = (
mode: ExportWorkbenchMode,
current: number,
total: number,
totalRowsKnown: boolean,
): string => {
if (mode === 'batch-tables') {
if (!totalRowsKnown) return t('data_export.workbench.summary.batch_tables_running');
return t('data_export.workbench.summary.batch_tables_done', {
current: Math.min(current, total).toLocaleString(),
total: total.toLocaleString(),
});
}
if (mode === 'batch-databases') {
if (!totalRowsKnown) return t('data_export.workbench.summary.batch_databases_running');
return t('data_export.workbench.summary.batch_databases_done', {
current: Math.min(current, total).toLocaleString(),
total: total.toLocaleString(),
});
}
return formatExportProgressRows(current, total, totalRowsKnown);
};
const resolveProgressHint = (mode: ExportWorkbenchMode, status: ExportProgressStatus, totalRowsKnown: boolean): string | null => {
if (totalRowsKnown || status === 'done' || status === 'error') {
return null;
}
if (mode === 'single') {
return t('data_export.hint.rows_unknown');
}
return t('data_export.hint.batch_stage');
};
const resolveOutputLabel = (mode: ExportWorkbenchMode): string => (
mode === 'batch-databases' ? t('data_export.label.directory') : t('data_export.label.file')
);
export const buildTableExportHistoryEntry = ({
progressState,
existingEntry,
fallbackTargetName,
fallbackFormat,
scope,
scopeLabel,
strategyLabel,
}: {
progressState: ExportProgressState;
existingEntry?: TableExportHistoryEntry;
fallbackTargetName: string;
fallbackFormat: string;
scope: string;
scopeLabel: string;
strategyLabel: string;
}): TableExportHistoryEntry => ({
jobId: progressState.jobId,
targetName: progressState.targetName || fallbackTargetName || t('data_export.progress.value.target_fallback'),
startedAt: progressState.startedAt || existingEntry?.startedAt || 0,
finishedAt: progressState.finishedAt || existingEntry?.finishedAt || 0,
format: progressState.format || existingEntry?.format || fallbackFormat,
scope,
scopeLabel: existingEntry?.scopeLabel || scopeLabel,
strategyLabel: existingEntry?.strategyLabel || strategyLabel,
status: progressState.status as ExportProgressStatus,
stage: progressState.stage,
current: progressState.current,
total: progressState.total,
totalRowsKnown: progressState.totalRowsKnown,
filePath: progressState.filePath,
message: progressState.message,
});
const TableExportWorkbench: React.FC<{ tab: TabData }> = ({ tab }) => {
const connections = useStore((state) => state.connections);
const theme = useStore((state) => state.theme);
const upsertTableExportHistory = useStore((state) => state.upsertTableExportHistory);
const workbenchMode = resolveWorkbenchMode(tab);
const isSingleWorkbench = workbenchMode === 'single';
const isBatchTablesWorkbench = workbenchMode === 'batch-tables';
const isBatchDatabasesWorkbench = workbenchMode === 'batch-databases';
const [selectedConnectionId, setSelectedConnectionId] = useState(() => String(tab.connectionId || '').trim());
const [selectedDbName, setSelectedDbName] = useState(() => String(tab.dbName || '').trim());
const [availableDatabases, setAvailableDatabases] = useState<SelectOption[]>([]);
const [availableObjects, setAvailableObjects] = useState<SelectOption[]>([]);
const [selectedObjectNames, setSelectedObjectNames] = useState<string[]>([]);
const [selectedDatabaseNames, setSelectedDatabaseNames] = useState<string[]>([]);
const [batchTableMode, setBatchTableMode] = useState<BatchTableExportMode>('schema');
const [batchDatabaseMode, setBatchDatabaseMode] = useState<BatchDatabaseExportMode>('schema');
const [loadingDatabases, setLoadingDatabases] = useState(false);
const [loadingObjects, setLoadingObjects] = useState(false);
const [databaseLoadError, setDatabaseLoadError] = useState('');
const [objectLoadError, setObjectLoadError] = useState('');
const effectiveConnectionId = isSingleWorkbench ? String(tab.connectionId || '').trim() : selectedConnectionId;
const effectiveDbName = isSingleWorkbench ? String(tab.dbName || '').trim() : selectedDbName;
const connection = useMemo(
() => connections.find((item) => item.id === effectiveConnectionId),
[connections, effectiveConnectionId],
);
const connectionOptions = useMemo(
() =>
connections.map((item) => ({
value: item.id,
label: renderSelectLabel(item.name),
title: item.name,
})),
[connections],
);
const connectionConfig = useMemo(
() => (connection ? normalizeConnectionConfig(connection) : null),
[connection],
);
const exportHistoryKey = useMemo(
() => buildExportWorkbenchHistoryKey({
connectionId: effectiveConnectionId,
dbName: isBatchDatabasesWorkbench ? undefined : effectiveDbName,
tableName: isSingleWorkbench ? tab.tableName : undefined,
exportWorkbenchMode: workbenchMode,
}),
[effectiveConnectionId, effectiveDbName, isBatchDatabasesWorkbench, isSingleWorkbench, tab.tableName, workbenchMode],
);
const history = useStore((state) => state.tableExportHistories[exportHistoryKey] || EMPTY_HISTORY);
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 pillBg = darkMode ? 'rgba(255,255,255,0.06)' : '#eef2f7';
const scopeOptions = useMemo(
() => normalizeScopeOptions(tab.tableExportScopeOptions),
[tab.tableExportScopeOptions],
);
const [scope, setScope] = useState<TableExportScope>(() => resolveInitialScope(scopeOptions, tab.tableExportInitialScope));
const [format, setFormat] = useState<DataExportFormat>(DEFAULT_DATA_EXPORT_FORMAT);
const [xlsxMaxRowsPerSheet, setXlsxMaxRowsPerSheet] = useState<number>(DEFAULT_XLSX_ROWS_PER_SHEET);
const [nowTick, setNowTick] = useState(() => Date.now());
const { state: progressState, reset, runExportWithProgress, isRunning } = useExportProgressRunner();
useEffect(() => {
if (isSingleWorkbench || selectedConnectionId || connections.length === 0) {
return;
}
setSelectedConnectionId(connections[0].id);
}, [connections, isSingleWorkbench, selectedConnectionId]);
useEffect(() => {
setScope((prev) => {
if (scopeOptions.some((item) => item.value === prev && !item.disabled)) {
return prev;
}
return resolveInitialScope(scopeOptions, tab.tableExportInitialScope);
});
}, [scopeOptions, tab.tableExportInitialScope]);
useEffect(() => {
if (!progressState.startedAt || progressState.finishedAt > 0) return undefined;
const timer = window.setInterval(() => {
setNowTick(Date.now());
}, 1000);
return () => {
window.clearInterval(timer);
};
}, [progressState.startedAt, progressState.finishedAt, isRunning]);
useEffect(() => {
if (isSingleWorkbench || !connectionConfig) {
if (!isSingleWorkbench) {
setAvailableDatabases([]);
setAvailableObjects([]);
setSelectedObjectNames([]);
setSelectedDatabaseNames([]);
setDatabaseLoadError('');
setObjectLoadError('');
}
return;
}
let alive = true;
setLoadingDatabases(true);
setDatabaseLoadError('');
DBGetDatabases(buildRpcConnectionConfig(connectionConfig) as any)
.then((res) => {
if (!alive) return;
if (!res.success) {
setAvailableDatabases([]);
setSelectedDatabaseNames([]);
setDatabaseLoadError(res.message || t('data_export.message.load_databases_failed'));
return;
}
const dbRows: any[] = Array.isArray(res.data) ? res.data : [];
let nextOptions = dbRows
.map((row) => String(row.Database || row.database || '').trim())
.filter(Boolean);
if (connection?.includeDatabases && connection.includeDatabases.length > 0) {
nextOptions = nextOptions.filter((name) => connection.includeDatabases!.includes(name));
}
const normalizedOptions = toSortedSelectOptions(nextOptions);
setAvailableDatabases(normalizedOptions);
if (isBatchTablesWorkbench) {
const hasCurrentDb = normalizedOptions.some((item) => item.value === selectedDbName);
if (!hasCurrentDb) {
setSelectedDbName('');
setAvailableObjects([]);
setSelectedObjectNames([]);
}
} else {
const availableNameSet = new Set(normalizedOptions.map((item) => item.value));
setSelectedDatabaseNames((prev) => prev.filter((name) => availableNameSet.has(name)));
}
})
.catch((error: any) => {
if (!alive) return;
setAvailableDatabases([]);
setSelectedDatabaseNames([]);
setDatabaseLoadError(error?.message || t('data_export.message.load_databases_failed'));
})
.finally(() => {
if (alive) {
setLoadingDatabases(false);
}
});
return () => {
alive = false;
};
}, [connection?.includeDatabases, connectionConfig, isBatchTablesWorkbench, isSingleWorkbench, selectedDbName]);
useEffect(() => {
if (!isBatchTablesWorkbench || !connectionConfig || !selectedDbName) {
if (isBatchTablesWorkbench) {
setAvailableObjects([]);
setSelectedObjectNames([]);
setObjectLoadError('');
}
return;
}
let alive = true;
setLoadingObjects(true);
setObjectLoadError('');
DBGetTables(buildRpcConnectionConfig(connectionConfig) as any, selectedDbName)
.then((res) => {
if (!alive) return;
if (!res.success) {
setAvailableObjects([]);
setSelectedObjectNames([]);
setObjectLoadError(res.message || t('data_export.message.load_objects_failed'));
return;
}
const tableRows: any[] = Array.isArray(res.data) ? res.data : [];
const nextOptions = toSortedSelectOptions(
tableRows
.map((row) => String(Object.values(row)[0] || '').trim())
.filter(Boolean),
);
setAvailableObjects(nextOptions);
const availableNameSet = new Set(nextOptions.map((item) => item.value));
setSelectedObjectNames((prev) => prev.filter((name) => availableNameSet.has(name)));
})
.catch((error: any) => {
if (!alive) return;
setAvailableObjects([]);
setSelectedObjectNames([]);
setObjectLoadError(error?.message || t('data_export.message.load_objects_failed'));
})
.finally(() => {
if (alive) {
setLoadingObjects(false);
}
});
return () => {
alive = false;
};
}, [connectionConfig, isBatchTablesWorkbench, selectedDbName]);
const hostSummary = useMemo(
() => resolveConnectionHostSummary(connection?.config),
[connection?.config],
);
const activeScopeOption = useMemo(
() => scopeOptions.find((item) => item.value === scope),
[scope, scopeOptions],
);
const activeScopeQuery = useMemo(
() => String(tab.tableExportQueryByScope?.[scope] || '').trim(),
[scope, tab.tableExportQueryByScope],
);
const singleScopeRowCount = useMemo(() => {
const raw = tab.tableExportRowCountByScope?.[scope];
return Number.isFinite(Number(raw)) && Number(raw) > 0 ? Number(raw) : undefined;
}, [scope, tab.tableExportRowCountByScope]);
const singleTotalRowsKnown = typeof singleScopeRowCount === 'number';
const singleScopeLabel = activeScopeOption?.label || scope;
const batchTableModeMeta = resolveBatchTableModeMeta(batchTableMode);
const batchDatabaseModeMeta = resolveBatchDatabaseModeMeta(batchDatabaseMode);
const activeScopeLabel = isSingleWorkbench
? singleScopeLabel
: isBatchTablesWorkbench
? t('data_export.workbench.scope.selected_objects', { count: selectedObjectNames.length })
: t('data_export.workbench.scope.selected_databases', { count: selectedDatabaseNames.length });
const activeScopeCount = isSingleWorkbench
? singleScopeRowCount
: (isBatchTablesWorkbench ? selectedObjectNames.length : selectedDatabaseNames.length);
const totalRowsKnown = isSingleWorkbench ? singleTotalRowsKnown : true;
const exportStrategyLabel = isSingleWorkbench
? (scope === 'all' && !activeScopeQuery
? t('data_export.workbench.strategy.full_table')
: t('data_export.workbench.strategy.query_replay'))
: (isBatchTablesWorkbench
? t('data_export.workbench.strategy.batch_tables', { mode: batchTableModeMeta.label })
: t('data_export.workbench.strategy.batch_databases', { mode: batchDatabaseModeMeta.label }));
const currentElapsedMs = useMemo(
() => resolveExportElapsedMs(progressState.startedAt, progressState.finishedAt, nowTick),
[nowTick, progressState.finishedAt, progressState.startedAt],
);
const currentProgressHint = resolveProgressHint(workbenchMode, progressState.status, progressState.totalRowsKnown);
const progressOutputLabel = resolveOutputLabel(workbenchMode);
const fallbackTargetName = isSingleWorkbench
? (tab.tableName || t('data_export.progress.value.target_fallback'))
: (isBatchTablesWorkbench ? resolveBatchTablesTargetName(selectedDbName, selectedObjectNames.length) : resolveBatchDatabasesTargetName(selectedDatabaseNames.length));
const fallbackFormat = isSingleWorkbench ? String(format || '').toUpperCase() : 'SQL';
useEffect(() => {
const jobId = String(progressState.jobId || '').trim();
if (!jobId) return;
const existingEntry = history.find((item) => item.jobId === jobId);
const entry = buildTableExportHistoryEntry({
progressState,
existingEntry,
fallbackTargetName,
fallbackFormat,
scope: isSingleWorkbench ? scope : (isBatchTablesWorkbench ? 'selectedObjects' : 'selectedDatabases'),
scopeLabel: activeScopeLabel,
strategyLabel: exportStrategyLabel,
});
upsertTableExportHistory(exportHistoryKey, entry);
}, [
activeScopeLabel,
exportHistoryKey,
exportStrategyLabel,
fallbackFormat,
fallbackTargetName,
history,
isBatchDatabasesWorkbench,
isBatchTablesWorkbench,
isSingleWorkbench,
progressState.current,
progressState.filePath,
progressState.finishedAt,
progressState.format,
progressState.jobId,
progressState.message,
progressState.stage,
progressState.startedAt,
progressState.status,
progressState.targetName,
progressState.total,
progressState.totalRowsKnown,
scope,
upsertTableExportHistory,
]);
const historyEntries = useMemo(
() => history.filter((entry) => entry.jobId !== progressState.jobId),
[history, progressState.jobId],
);
const currentHistoryEntry = useMemo(
() => history.find((entry) => entry.jobId === progressState.jobId),
[history, progressState.jobId],
);
const currentScopeLabel = currentHistoryEntry?.scopeLabel || activeScopeLabel;
const currentStrategyLabel = currentHistoryEntry?.strategyLabel || exportStrategyLabel;
const currentProgressSummary = formatWorkbenchProgressSummary(
workbenchMode,
progressState.current,
progressState.total,
progressState.totalRowsKnown,
);
const canStart = useMemo(() => {
if (!connectionConfig || isRunning) {
return false;
}
if (isSingleWorkbench) {
return !!tab.tableName && !!scope && !activeScopeOption?.disabled && (scope === 'all' || !!activeScopeQuery);
}
if (isBatchTablesWorkbench) {
return !!selectedDbName && selectedObjectNames.length > 0;
}
return selectedDatabaseNames.length > 0;
}, [
activeScopeOption?.disabled,
activeScopeQuery,
connectionConfig,
isBatchTablesWorkbench,
isRunning,
isSingleWorkbench,
scope,
selectedDatabaseNames.length,
selectedDbName,
selectedObjectNames.length,
tab.tableName,
]);
const handleStartSingleExport = async () => {
if (!connectionConfig) {
return;
}
const objectName = String(tab.tableName || '').trim();
if (!objectName) {
return;
}
await runExportWithProgress({
title: tab.title || t('data_export.workbench.task.export_target', { name: objectName }),
targetName: objectName,
format,
totalRows: singleScopeRowCount,
run: (jobId) => {
const options = {
format,
xlsxMaxRowsPerSheet,
jobId,
totalRowsHint: singleTotalRowsKnown ? singleScopeRowCount : 0,
totalRowsKnown: singleTotalRowsKnown,
};
if (scope !== 'all' && activeScopeQuery) {
return ExportQueryWithOptions(
buildRpcConnectionConfig(connectionConfig) as any,
tab.dbName || '',
activeScopeQuery,
objectName,
options as any,
);
}
if (scope === 'all' && activeScopeQuery) {
return ExportQueryWithOptions(
buildRpcConnectionConfig(connectionConfig) as any,
tab.dbName || '',
activeScopeQuery,
objectName,
options as any,
);
}
return ExportTableWithOptions(
buildRpcConnectionConfig(connectionConfig) as any,
tab.dbName || '',
objectName,
options as any,
);
},
});
};
const handleStartBatchTablesExport = async () => {
if (!connectionConfig || !selectedDbName || selectedObjectNames.length === 0) {
return;
}
const includeSchema = batchTableMode !== 'dataOnly';
const includeData = batchTableMode !== 'schema';
await runExportWithProgress({
title: `${batchTableModeMeta.label} · ${selectedDbName}`,
targetName: resolveBatchTablesTargetName(selectedDbName, selectedObjectNames.length),
format: 'sql',
totalRows: selectedObjectNames.length,
run: (jobId) =>
ExportTablesSQLWithOptions(
buildRpcConnectionConfig(connectionConfig) as any,
selectedDbName,
selectedObjectNames,
includeSchema,
includeData,
{
format: 'sql',
jobId,
totalRowsHint: selectedObjectNames.length,
totalRowsKnown: true,
} as any,
),
});
};
const handleStartBatchDatabasesExport = async () => {
if (!connectionConfig || selectedDatabaseNames.length === 0) {
return;
}
const includeData = batchDatabaseMode === 'backup';
await runExportWithProgress({
title: batchDatabaseModeMeta.label,
targetName: resolveBatchDatabasesTargetName(selectedDatabaseNames.length),
format: 'sql',
totalRows: selectedDatabaseNames.length,
run: (jobId) =>
ExportDatabasesSQLWithOptions(
buildRpcConnectionConfig(connectionConfig) as any,
selectedDatabaseNames,
includeData,
{
format: 'sql',
jobId,
totalRowsHint: selectedDatabaseNames.length,
totalRowsKnown: true,
} as any,
),
});
};
const handleStartExport = async () => {
if (isSingleWorkbench) {
await handleStartSingleExport();
return;
}
if (isBatchTablesWorkbench) {
await handleStartBatchTablesExport();
return;
}
await handleStartBatchDatabasesExport();
};
const headerBadges = useMemo(() => {
if (isSingleWorkbench) {
return [
`${resolveObjectTypeLabel(tab.objectType)} · ${tab.tableName || '-'}`,
`${t('data_export.label.database')} · ${tab.dbName || '-'}`,
`${t('data_export.label.connection')} · ${connection?.name || '-'}`,
`${t('data_export.label.host')} · ${hostSummary || '-'}`,
];
}
if (isBatchTablesWorkbench) {
return [
`${t('data_export.label.mode')} · ${t('data_export.workbench.mode.batch_tables')}`,
`${t('data_export.label.database')} · ${selectedDbName || '-'}`,
`${t('data_export.label.connection')} · ${connection?.name || '-'}`,
`${t('data_export.label.object_count')} · ${selectedObjectNames.length}`,
`${t('data_export.label.host')} · ${hostSummary || '-'}`,
];
}
return [
`${t('data_export.label.mode')} · ${t('data_export.workbench.mode.batch_databases')}`,
`${t('data_export.label.connection')} · ${connection?.name || '-'}`,
`${t('data_export.label.selected_databases')} · ${selectedDatabaseNames.length}`,
`${t('data_export.label.host')} · ${hostSummary || '-'}`,
];
}, [
connection?.name,
hostSummary,
isBatchTablesWorkbench,
isSingleWorkbench,
selectedDatabaseNames.length,
selectedDbName,
selectedObjectNames.length,
tab.dbName,
tab.objectType,
tab.tableName,
]);
return (
<div
data-export-workbench="true"
style={{ display: 'flex', flexDirection: 'column', height: '100%', minHeight: 0, background: shellBg }}
>
<div
style={{
padding: '20px 24px 16px',
borderBottom: panelBorder,
background: panelBg,
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
gap: 16,
flexWrap: 'wrap',
}}
>
<div style={{ minWidth: 0 }}>
<Title level={4} style={{ margin: 0, color: headingColor }}>{t('data_export.workbench.title')}</Title>
<div style={{ marginTop: 6, color: secondaryTextColor, fontSize: 13 }}>
{t('data_export.workbench.subtitle')}
</div>
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{headerBadges.map((label) => (
<span
key={label}
style={{
display: 'inline-flex',
alignItems: 'center',
padding: '6px 10px',
borderRadius: 999,
background: pillBg,
color: headingColor,
fontSize: 12,
fontWeight: 500,
whiteSpace: 'nowrap',
}}
>
{label}
</span>
))}
</div>
</div>
<div
data-export-workbench-layout="true"
style={{
flex: 1,
minHeight: 0,
overflow: 'auto',
padding: 24,
display: 'grid',
gridTemplateColumns: 'minmax(300px, 360px) minmax(0, 1fr)',
gap: 24,
alignItems: 'start',
}}
>
<section
data-export-workbench-config="true"
style={{
display: 'flex',
flexDirection: 'column',
gap: 18,
minHeight: 0,
padding: 20,
borderRadius: 8,
background: panelBg,
border: panelBorder,
}}
>
<div>
<div style={{ fontSize: 13, fontWeight: 600, color: headingColor, marginBottom: 10 }}>{t('data_export.workbench.section.config')}</div>
{isSingleWorkbench ? (
<div style={{ display: 'grid', gridTemplateColumns: '84px minmax(0, 1fr)', rowGap: 10, columnGap: 12 }}>
<Text type="secondary">{t('data_export.label.object')}</Text>
<Text>{tab.tableName || '-'}</Text>
<Text type="secondary">{t('data_export.label.type')}</Text>
<Text>{resolveObjectTypeLabel(tab.objectType)}</Text>
<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('data_export.label.host')}</Text>
<Text>{hostSummary || '-'}</Text>
</div>
) : isBatchTablesWorkbench ? (
<div style={{ display: 'grid', gridTemplateColumns: '84px minmax(0, 1fr)', rowGap: 10, columnGap: 12 }}>
<Text type="secondary">{t('data_export.label.mode')}</Text>
<Text>{t('data_export.workbench.mode.batch_tables')}</Text>
<Text type="secondary">{t('data_export.label.connection')}</Text>
<Text>{connection?.name || '-'}</Text>
<Text type="secondary">{t('data_export.label.database')}</Text>
<Text>{selectedDbName || '-'}</Text>
<Text type="secondary">{t('data_export.label.object_count')}</Text>
<Text>{selectedObjectNames.length}</Text>
<Text type="secondary">{t('data_export.label.host')}</Text>
<Text>{hostSummary || '-'}</Text>
</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: '84px minmax(0, 1fr)', rowGap: 10, columnGap: 12 }}>
<Text type="secondary">{t('data_export.label.mode')}</Text>
<Text>{t('data_export.workbench.mode.batch_databases')}</Text>
<Text type="secondary">{t('data_export.label.connection')}</Text>
<Text>{connection?.name || '-'}</Text>
<Text type="secondary">{t('data_export.label.selected_databases')}</Text>
<Text>{selectedDatabaseNames.length}</Text>
<Text type="secondary">{t('data_export.label.host')}</Text>
<Text>{hostSummary || '-'}</Text>
</div>
)}
</div>
{!connectionConfig ? (
<Alert
type="warning"
showIcon
message={t('data_export.workbench.alert.connection_missing_title')}
description={t('data_export.workbench.alert.connection_missing_description')}
/>
) : null}
{databaseLoadError ? (
<Alert
type="error"
showIcon
message={t('data_export.workbench.alert.database_load_failed')}
description={databaseLoadError}
/>
) : null}
{objectLoadError ? (
<Alert
type="error"
showIcon
message={t('data_export.workbench.alert.object_load_failed')}
description={objectLoadError}
/>
) : null}
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
{isSingleWorkbench ? (
<>
<div>
<div style={{ marginBottom: 6, fontSize: 12, color: secondaryTextColor }}>{t('data_export.label.export_scope')}</div>
<Select
style={{ width: '100%' }}
value={scope}
options={scopeOptions.map((item) => ({
value: item.value,
label: item.label,
disabled: item.disabled,
}))}
onChange={(next) => setScope(next as TableExportScope)}
/>
{activeScopeOption?.description ? (
<div style={{ marginTop: 6, fontSize: 12, color: secondaryTextColor }}>
{activeScopeOption.description}
</div>
) : null}
</div>
<div>
<div style={{ marginBottom: 6, fontSize: 12, color: secondaryTextColor }}>{t('data_export.label.format')}</div>
<Select
style={{ width: '100%' }}
value={format}
options={DATA_EXPORT_FORMAT_OPTIONS}
onChange={(next) => setFormat(next as DataExportFormat)}
/>
</div>
{format === 'xlsx' ? (
<div>
<div style={{ marginBottom: 6, fontSize: 12, color: secondaryTextColor }}>{t('data_export.label.xlsx_max_rows')}</div>
<InputNumber
min={1}
max={MAX_XLSX_ROWS_PER_SHEET}
step={100000}
value={xlsxMaxRowsPerSheet}
style={{ width: '100%' }}
onChange={(value) => {
const next = Number(value);
setXlsxMaxRowsPerSheet(
Number.isFinite(next) && next > 0
? Math.min(MAX_XLSX_ROWS_PER_SHEET, Math.trunc(next))
: DEFAULT_XLSX_ROWS_PER_SHEET,
);
}}
/>
<div style={{ marginTop: 6, fontSize: 12, color: secondaryTextColor }}>
{t('data_export.dialog.field.xlsx_max_rows_help', {
maxRows: MAX_XLSX_ROWS_PER_SHEET.toLocaleString(),
})}
</div>
</div>
) : null}
</>
) : isBatchTablesWorkbench ? (
<>
<div>
<div style={{ marginBottom: 6, fontSize: 12, color: secondaryTextColor }}>{t('data_export.label.connection')}</div>
<Tooltip title={connection?.name || undefined}>
<div>
<Select
style={{ width: '100%' }}
value={selectedConnectionId || undefined}
placeholder={t('data_export.workbench.placeholder.select_connection')}
options={connectionOptions}
showSearch
optionFilterProp="title"
filterOption={filterOptionByLabel as any}
onChange={(next) => {
setSelectedConnectionId(String(next || '').trim());
setSelectedDbName('');
setSelectedObjectNames([]);
setAvailableObjects([]);
setObjectLoadError('');
}}
/>
</div>
</Tooltip>
</div>
<div>
<div style={{ marginBottom: 6, fontSize: 12, color: secondaryTextColor }}>{t('data_export.label.database')}</div>
<Tooltip title={selectedDbName || undefined}>
<div>
<Select
style={{ width: '100%' }}
value={selectedDbName || undefined}
placeholder={loadingDatabases
? t('data_export.workbench.placeholder.loading_databases')
: t('data_export.workbench.placeholder.select_database')}
loading={loadingDatabases}
options={availableDatabases}
showSearch
optionFilterProp="title"
filterOption={filterOptionByLabel as any}
onChange={(next) => {
setSelectedDbName(String(next || '').trim());
setSelectedObjectNames([]);
setObjectLoadError('');
}}
/>
</div>
</Tooltip>
</div>
<div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 6 }}>
<div style={{ fontSize: 12, color: secondaryTextColor }}>{t('data_export.label.object')}</div>
<div style={{ display: 'flex', gap: 8 }}>
<Button
size="small"
type="text"
disabled={availableObjects.length === 0}
onClick={() => setSelectedObjectNames(availableObjects.map((item) => item.value))}
>
{t('data_export.action.select_all')}
</Button>
<Button
size="small"
type="text"
disabled={selectedObjectNames.length === 0}
onClick={() => setSelectedObjectNames([])}
>
{t('data_export.action.clear')}
</Button>
</div>
</div>
<Select
style={{ width: '100%' }}
mode="multiple"
value={selectedObjectNames}
placeholder={selectedDbName
? (loadingObjects
? t('data_export.workbench.placeholder.loading_objects')
: t('data_export.workbench.placeholder.select_object'))
: t('data_export.workbench.placeholder.select_database_first')}
loading={loadingObjects}
options={availableObjects}
disabled={!selectedDbName}
showSearch
optionFilterProp="title"
filterOption={filterOptionByLabel as any}
maxTagCount="responsive"
onChange={(next) => setSelectedObjectNames((next as string[]).map((item) => String(item).trim()).filter(Boolean))}
/>
<div style={{ marginTop: 6, fontSize: 12, color: secondaryTextColor }}>
{t('data_export.workbench.helper.available_objects', {
available: availableObjects.length,
selected: selectedObjectNames.length,
})}
</div>
</div>
<div>
<div style={{ marginBottom: 6, fontSize: 12, color: secondaryTextColor }}>{t('data_export.label.export_content')}</div>
<Select
style={{ width: '100%' }}
value={batchTableMode}
options={createBatchTableExportModeOptions().map((item) => ({ value: item.value, label: item.label }))}
onChange={(next) => setBatchTableMode(next as BatchTableExportMode)}
/>
<div style={{ marginTop: 6, fontSize: 12, color: secondaryTextColor }}>
{batchTableModeMeta.description}
</div>
</div>
<div>
<div style={{ marginBottom: 6, fontSize: 12, color: secondaryTextColor }}>{t('data_export.label.format')}</div>
<Select
style={{ width: '100%' }}
value="sql"
disabled
options={[{ value: 'sql', label: t('data_export.label.sql_file') }]}
/>
</div>
</>
) : (
<>
<div>
<div style={{ marginBottom: 6, fontSize: 12, color: secondaryTextColor }}>{t('data_export.label.connection')}</div>
<Tooltip title={connection?.name || undefined}>
<div>
<Select
style={{ width: '100%' }}
value={selectedConnectionId || undefined}
placeholder={t('data_export.workbench.placeholder.select_connection')}
options={connectionOptions}
showSearch
optionFilterProp="title"
filterOption={filterOptionByLabel as any}
onChange={(next) => {
setSelectedConnectionId(String(next || '').trim());
setSelectedDatabaseNames([]);
setDatabaseLoadError('');
}}
/>
</div>
</Tooltip>
</div>
<div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 6 }}>
<div style={{ fontSize: 12, color: secondaryTextColor }}>{t('data_export.label.database')}</div>
<div style={{ display: 'flex', gap: 8 }}>
<Button
size="small"
type="text"
disabled={availableDatabases.length === 0}
onClick={() => setSelectedDatabaseNames(availableDatabases.map((item) => item.value))}
>
{t('data_export.action.select_all')}
</Button>
<Button
size="small"
type="text"
disabled={selectedDatabaseNames.length === 0}
onClick={() => setSelectedDatabaseNames([])}
>
{t('data_export.action.clear')}
</Button>
</div>
</div>
<Select
style={{ width: '100%' }}
mode="multiple"
value={selectedDatabaseNames}
placeholder={loadingDatabases
? t('data_export.workbench.placeholder.loading_databases')
: t('data_export.workbench.placeholder.select_database')}
loading={loadingDatabases}
options={availableDatabases}
showSearch
optionFilterProp="title"
filterOption={filterOptionByLabel as any}
maxTagCount="responsive"
onChange={(next) => setSelectedDatabaseNames((next as string[]).map((item) => String(item).trim()).filter(Boolean))}
/>
<div style={{ marginTop: 6, fontSize: 12, color: secondaryTextColor }}>
{t('data_export.workbench.helper.batch_database_output')}
</div>
</div>
<div>
<div style={{ marginBottom: 6, fontSize: 12, color: secondaryTextColor }}>{t('data_export.label.export_content')}</div>
<Select
style={{ width: '100%' }}
value={batchDatabaseMode}
options={createBatchDatabaseExportModeOptions().map((item) => ({ value: item.value, label: item.label }))}
onChange={(next) => setBatchDatabaseMode(next as BatchDatabaseExportMode)}
/>
<div style={{ marginTop: 6, fontSize: 12, color: secondaryTextColor }}>
{batchDatabaseModeMeta.description}
</div>
</div>
<div>
<div style={{ marginBottom: 6, fontSize: 12, color: secondaryTextColor }}>{t('data_export.label.format')}</div>
<Select
style={{ width: '100%' }}
value="sql"
disabled
options={[{ value: 'sql', label: t('data_export.label.sql_file') }]}
/>
</div>
</>
)}
</div>
{isSingleWorkbench && scope !== 'all' && !activeScopeQuery ? (
<Alert
type="info"
showIcon
message={t('data_export.workbench.alert.scope_unavailable_title')}
description={t('data_export.workbench.alert.scope_unavailable_description')}
/>
) : null}
<div
style={{
marginTop: 'auto',
padding: 14,
borderRadius: 8,
background: subtleBg,
border: `1px solid ${dividerColor}`,
display: 'flex',
flexDirection: 'column',
gap: 10,
}}
>
<div style={{ display: 'grid', gridTemplateColumns: '96px minmax(0, 1fr)', rowGap: 6, columnGap: 8 }}>
<Text type="secondary">{isSingleWorkbench
? t('data_export.label.estimated_rows')
: (isBatchDatabasesWorkbench ? t('data_export.label.selected_databases') : t('data_export.label.object'))}</Text>
<Text>
{typeof activeScopeCount === 'number'
? activeScopeCount.toLocaleString()
: t('data_export.value.unestimated')}
</Text>
<Text type="secondary">{t('data_export.label.strategy')}</Text>
<Text>{exportStrategyLabel}</Text>
</div>
<div style={{ fontSize: 12, color: secondaryTextColor }}>
{isSingleWorkbench
? t('data_export.workbench.helper.single_export_start')
: isBatchTablesWorkbench
? t('data_export.workbench.helper.batch_tables_start')
: t('data_export.workbench.helper.batch_databases_start')}
</div>
<Button
type="primary"
size="large"
icon={<ExportOutlined />}
disabled={!canStart}
loading={isRunning}
onClick={() => {
void handleStartExport();
}}
>
{t('data_export.action.start')}
</Button>
</div>
</section>
<div style={{ display: 'flex', flexDirection: 'column', gap: 20, minWidth: 0 }}>
<section
data-export-workbench-progress-panel="true"
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(progressState.status)}
</div>
<Title level={5} style={{ margin: '10px 0 0', color: headingColor }}>
{progressState.title || tab.title || t('data_export.progress.value.task_fallback')}
</Title>
<div style={{ marginTop: 6, color: secondaryTextColor, fontSize: 13 }}>
{progressState.jobId
? `${progressState.targetName || fallbackTargetName} · ${currentScopeLabel} · ${progressState.format || fallbackFormat}`
: t('data_export.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('data_export.label.elapsed')}</div>
<div style={{ color: headingColor, fontWeight: 600, display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<ClockCircleOutlined />
{progressState.startedAt ? formatExportElapsed(currentElapsedMs) : '--:--'}
</div>
</div>
<div>
<div style={{ fontSize: 12, color: secondaryTextColor, marginBottom: 4 }}>{t('data_export.label.started_at')}</div>
<div style={{ color: headingColor, fontWeight: 600 }}>{formatDateTime(progressState.startedAt)}</div>
</div>
<div>
<div style={{ fontSize: 12, color: secondaryTextColor, marginBottom: 4 }}>{t('data_export.label.export_scope')}</div>
<div style={{ color: headingColor, fontWeight: 600 }}>{progressState.jobId ? currentScopeLabel : '-'}</div>
</div>
<div>
<div style={{ fontSize: 12, color: secondaryTextColor, marginBottom: 4 }}>{t('data_export.label.strategy')}</div>
<div style={{ color: headingColor, fontWeight: 600 }}>{progressState.jobId ? currentStrategyLabel : '-'}</div>
</div>
</div>
</div>
{progressState.jobId ? (
<>
<div data-export-workbench-main-progress="true">
<ExportProgressBar
status={progressState.status}
current={progressState.current}
total={progressState.total}
totalRowsKnown={progressState.totalRowsKnown}
/>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) minmax(260px, 0.9fr)', gap: 18 }}>
<div>
<div style={{ fontSize: 12, color: secondaryTextColor, marginBottom: 6 }}>{t('data_export.label.current_stage')}</div>
<Text data-export-workbench-stage="true">
{progressState.stage || resolveStatusMeta(progressState.status).label || t('data_export.value.waiting_to_start')}
</Text>
<div style={{ fontSize: 12, color: secondaryTextColor, margin: '12px 0 6px' }}>{t('data_export.label.progress_summary')}</div>
<Text>{currentProgressSummary}</Text>
{currentProgressHint ? (
<div style={{ marginTop: 6, fontSize: 12, color: secondaryTextColor }}>
{currentProgressHint}
</div>
) : null}
</div>
<div>
<div style={{ fontSize: 12, color: secondaryTextColor, marginBottom: 6 }}>{progressOutputLabel}</div>
{progressState.filePath ? (
<Paragraph style={{ marginBottom: 0, wordBreak: 'break-all' }}>{progressState.filePath}</Paragraph>
) : (
<Text type="secondary">{t('data_export.value.waiting_target_path')}</Text>
)}
</div>
</div>
{progressState.message ? (
<Alert
type={progressState.status === 'error' ? 'error' : 'info'}
showIcon
message={progressState.message}
/>
) : null}
{(progressState.status === 'done' || progressState.status === 'error') ? (
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button icon={<ReloadOutlined />} onClick={reset}>{t('data_export.action.clear_progress')}</Button>
</div>
) : null}
</>
) : (
<div data-export-workbench-current-empty="true">
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={t('data_export.workbench.empty.not_started')}
/>
</div>
)}
</section>
<section
data-export-workbench-history="true"
style={{
padding: 20,
borderRadius: 8,
background: panelBg,
border: panelBorder,
display: 'flex',
flexDirection: 'column',
gap: 14,
}}
>
<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('data_export.workbench.description.history')}
</div>
</div>
<div style={{ color: secondaryTextColor, fontSize: 12 }}>
{t('data_export.workbench.history.count', { count: historyEntries.length })}
</div>
</div>
{historyEntries.length > 0 ? (
<div data-export-workbench-history-list="true" style={{ display: 'flex', flexDirection: 'column' }}>
{historyEntries.map((entry, index) => (
<div
key={entry.jobId}
style={{
display: 'grid',
gridTemplateColumns: 'minmax(0, 1.15fr) minmax(280px, 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.targetName}</Text>
{renderStatusPill(entry.status)}
<span style={{ fontSize: 12, color: secondaryTextColor }}>
{entry.scopeLabel} · {entry.format || '-'}
</span>
</div>
<div style={{ marginTop: 6, fontSize: 13, color: headingColor }}>
{entry.stage || resolveStatusMeta(entry.status).label}
</div>
<div style={{ marginTop: 4, fontSize: 12, color: secondaryTextColor }}>
{formatWorkbenchProgressSummary(workbenchMode, entry.current, entry.total, entry.totalRowsKnown)}
</div>
{entry.message ? (
<div style={{ marginTop: 8, fontSize: 12, color: '#dc2626' }}>{entry.message}</div>
) : null}
</div>
<div style={{ minWidth: 0 }}>
<div style={{ display: 'grid', gridTemplateColumns: '80px minmax(0, 1fr)', rowGap: 6, columnGap: 10 }}>
<Text type="secondary">{t('data_export.label.started_at')}</Text>
<Text>{formatDateTime(entry.startedAt)}</Text>
<Text type="secondary">{t('data_export.label.elapsed')}</Text>
<Text>{formatExportElapsed(resolveExportElapsedMs(entry.startedAt, entry.finishedAt, nowTick))}</Text>
<Text type="secondary">{isBatchDatabasesWorkbench ? t('data_export.label.directory') : t('data_export.label.file')}</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('data_export.workbench.empty.history')}
</div>
)}
</section>
</div>
</div>
</div>
);
};
export default TableExportWorkbench;