feat(export-workbench): 新增导出工作台与进度历史

- 新增表级导出工作台标签页,统一承载导出范围、格式和 XLSX sheet 行数配置
- 结果集、表概览、侧栏和右键菜单统一接入导出工作台与带进度的导出入口
- 导出进度改为事件驱动,未知总数时展示不定进度和实时已写入行数
- 持久化每张表的导出历史并复用同一导出标签,重启后仍可查看最近任务
- 调整导出页签标题、状态胶囊和历史列表,补充工作台与状态流测试覆盖
This commit is contained in:
Syngnat
2026-06-17 14:19:16 +08:00
parent b3c321be67
commit 5b31ab7435
34 changed files with 2673 additions and 425 deletions

View File

@@ -0,0 +1,209 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Form, InputNumber, Modal, Select, message } from 'antd';
import { ExportOutlined } from '@ant-design/icons';
export type DataExportFormat = 'csv' | 'xlsx' | 'json' | 'md' | 'html';
export type DataExportScope = 'selected' | 'page' | 'all' | 'filteredAll';
export type DataExportFileOptions = {
format: DataExportFormat;
xlsxMaxRowsPerSheet?: number;
};
export type DataExportDialogValues = DataExportFileOptions & {
scope: DataExportScope | string;
};
export type DataExportScopeOption = {
value: DataExportScope | string;
label: string;
description?: string;
disabled?: boolean;
};
export type ShowDataExportDialogOptions = {
title: string;
scopeOptions: DataExportScopeOption[];
initialValues?: Partial<DataExportDialogValues>;
okText?: string;
};
export const MAX_XLSX_ROWS_PER_SHEET = 1048575;
export const DEFAULT_XLSX_ROWS_PER_SHEET = MAX_XLSX_ROWS_PER_SHEET;
export const DEFAULT_DATA_EXPORT_FORMAT: DataExportFormat = 'xlsx';
export const DATA_EXPORT_FORMAT_OPTIONS: Array<{ value: DataExportFormat; label: string }> = [
{ value: 'xlsx', label: 'Excel (XLSX)' },
{ value: 'csv', label: 'CSV' },
{ value: 'json', label: 'JSON' },
{ value: 'md', label: 'Markdown' },
{ value: 'html', label: 'HTML' },
];
const resolveDefaultScope = (scopeOptions: DataExportScopeOption[], initialScope?: string): string => {
const matchedInitial = scopeOptions.find((item) => item.value === initialScope && !item.disabled);
if (matchedInitial) return String(matchedInitial.value);
const firstEnabled = scopeOptions.find((item) => !item.disabled);
return String(firstEnabled?.value || scopeOptions[0]?.value || 'all');
};
const normalizeDialogValues = (
scopeOptions: DataExportScopeOption[],
initialValues?: Partial<DataExportDialogValues>,
): DataExportDialogValues => {
const format = (initialValues?.format || DEFAULT_DATA_EXPORT_FORMAT) as DataExportFormat;
const scope = resolveDefaultScope(scopeOptions, initialValues?.scope ? String(initialValues.scope) : undefined);
const xlsxMaxRowsPerSheet = Number(initialValues?.xlsxMaxRowsPerSheet) > 0
? Math.min(MAX_XLSX_ROWS_PER_SHEET, Math.trunc(Number(initialValues?.xlsxMaxRowsPerSheet)))
: DEFAULT_XLSX_ROWS_PER_SHEET;
return {
format,
scope,
xlsxMaxRowsPerSheet,
};
};
const validateDialogValues = (
values: DataExportDialogValues,
scopeOptions: DataExportScopeOption[],
): string | null => {
if (!DATA_EXPORT_FORMAT_OPTIONS.some((item) => item.value === values.format)) {
return '请选择导出格式';
}
if (scopeOptions.length > 0) {
const matchedScope = scopeOptions.find((item) => String(item.value) === String(values.scope));
if (!matchedScope || matchedScope.disabled) {
return '请选择可用的导出范围';
}
}
if (values.format === 'xlsx') {
const rows = Math.trunc(Number(values.xlsxMaxRowsPerSheet) || 0);
if (!Number.isFinite(rows) || rows <= 0) {
return '请输入有效的每个工作表最大行数';
}
if (rows > MAX_XLSX_ROWS_PER_SHEET) {
return `每个工作表最大行数不能超过 ${MAX_XLSX_ROWS_PER_SHEET.toLocaleString()}`;
}
}
return null;
};
const DataExportDialogContent: React.FC<{
scopeOptions: DataExportScopeOption[];
initialValues?: Partial<DataExportDialogValues>;
onChange: (values: DataExportDialogValues) => void;
}> = ({ scopeOptions, initialValues, onChange }) => {
const [values, setValues] = useState<DataExportDialogValues>(() => normalizeDialogValues(scopeOptions, initialValues));
useEffect(() => {
onChange(values);
}, [onChange, values]);
const selectedScope = useMemo(
() => scopeOptions.find((item) => String(item.value) === String(values.scope)),
[scopeOptions, values.scope],
);
return (
<div data-export-config-modal="true">
<Form layout="vertical" colon={false}>
<Form.Item label="导出格式" style={{ marginBottom: 16 }}>
<Select
value={values.format}
options={DATA_EXPORT_FORMAT_OPTIONS}
onChange={(format) => setValues((prev) => ({ ...prev, format: format as DataExportFormat }))}
/>
</Form.Item>
<Form.Item label="导出范围" style={{ marginBottom: 8 }}>
<Select
value={values.scope}
disabled={scopeOptions.length <= 1}
options={scopeOptions.map((item) => ({
value: item.value,
label: item.label,
disabled: item.disabled,
}))}
onChange={(scope) => setValues((prev) => ({ ...prev, scope }))}
/>
</Form.Item>
{selectedScope?.description && (
<div style={{ marginBottom: 16, color: 'rgba(0,0,0,0.45)', fontSize: 12 }}>
{selectedScope.description}
</div>
)}
{values.format === 'xlsx' && (
<Form.Item
label="每个工作表最大行数"
extra={`仅 XLSX 生效,最大 ${MAX_XLSX_ROWS_PER_SHEET.toLocaleString()} 行(不含表头)`}
style={{ marginBottom: 0 }}
>
<InputNumber
min={1}
max={MAX_XLSX_ROWS_PER_SHEET}
step={100000}
style={{ width: '100%' }}
value={values.xlsxMaxRowsPerSheet}
onChange={(nextValue) => setValues((prev) => ({
...prev,
xlsxMaxRowsPerSheet: Number(nextValue) > 0
? Math.min(MAX_XLSX_ROWS_PER_SHEET, Math.trunc(Number(nextValue)))
: 0,
}))}
/>
</Form.Item>
)}
</Form>
</div>
);
};
export async function showDataExportDialog(
modal: ReturnType<typeof Modal.useModal>[0],
options: ShowDataExportDialogOptions,
): Promise<DataExportDialogValues | null> {
const initialValues = normalizeDialogValues(options.scopeOptions, options.initialValues);
return new Promise((resolve) => {
let resolved = false;
let latestValues = initialValues;
const finish = (nextValue: DataExportDialogValues | null) => {
if (resolved) return;
resolved = true;
resolve(nextValue);
};
modal.confirm({
title: options.title,
icon: <ExportOutlined />,
width: 520,
centered: true,
maskClosable: true,
okText: options.okText || '开始导出',
cancelText: '取消',
content: (
<DataExportDialogContent
scopeOptions={options.scopeOptions}
initialValues={initialValues}
onChange={(values) => {
latestValues = values;
}}
/>
),
onOk: async () => {
const errorMessage = validateDialogValues(latestValues, options.scopeOptions);
if (errorMessage) {
void message.error(errorMessage);
throw new Error(errorMessage);
}
finish(latestValues);
},
onCancel: () => {
finish(null);
},
});
});
}

View File

@@ -2175,111 +2175,28 @@ describe('DataGrid layout', () => {
it('keeps export and import chrome behind translateDataGrid while preserving raw details', () => {
const source = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8');
const exportDataSource = source.slice(
source.indexOf(' const exportData = async (rows: any[], format: string) => {'),
source.indexOf(' const [sortInfo, setSortInfo]'),
);
const exportChromeSource = source.slice(
source.indexOf(' const exportByQuery = useCallback(async (sql: string, format: string, defaultName: string) => {'),
source.indexOf(" const queryResultCopyMenu: MenuProps['items'] ="),
);
const exportSource = `${exportDataSource}\n${exportChromeSource}`;
const exportChromeKeys = [
'data_grid.message.exporting_rows',
'data_grid.message.exporting',
'data_grid.message.export_success',
'data_grid.message.export_failed',
'data_grid.message.export_with_uncommitted_changes',
'data_grid.message.no_filter_applied',
'data_grid.message.filtered_export_not_supported',
'data_grid.message.filtered_export_uses_committed_data',
'data_grid.message.no_rows_selected',
'data_grid.message.select_file_failed',
'data_grid.message.import_done',
'data_grid.export.query_result_title',
'data_grid.export.scope_prompt',
'data_grid.export.selected_rows',
'data_grid.export.current_page_rows',
'data_grid.export.all_rows',
'data_grid.export.all_rows_requery',
'data_grid.export.options_title',
'data_grid.export.no_selection_prompt',
'data_grid.export.current_page',
'data_grid.export.all_data',
'data_grid.export.group_filtered_results',
'data_grid.export.group_full_table',
];
const exportDialogSource = readFileSync(new URL('./DataExportDialog.tsx', import.meta.url), 'utf8');
expect(source).toContain("type QueryResultExportScope = 'selected' | 'page' | 'all';");
exportChromeKeys.forEach((key) => {
expect(exportSource).toContain(`translateDataGrid('${key}'`);
});
expect(exportChromeSource).toContain("translateDataGrid('common.cancel')");
expect(exportChromeSource).toContain('data-query-result-export-scope="true"');
expect(exportDataSource).toMatch(/message\.loading\(\s*translateDataGrid\(\s*'data_grid\.message\.exporting_rows'\s*,\s*\{\s*count:\s*rows\.length\s*\}\s*\)\s*,\s*0\s*\)/);
expect(exportDataSource).toMatch(/message\.success\(\s*translateDataGrid\(\s*'data_grid\.message\.export_success'\s*\)\s*\)/);
expect(exportDataSource).toMatch(/translateDataGrid\(\s*'data_grid\.message\.export_failed'\s*,\s*\{\s*detail:\s*res\.message\s*\}\s*\)/);
expect(exportDataSource).toMatch(/const\s+rawErrorMessage\s*=\s*e\?\.message\s*\|\|\s*String\(e\);[\s\S]*translateDataGrid\(\s*'data_grid\.message\.export_failed'\s*,\s*\{\s*detail:\s*rawErrorMessage\s*\}\s*\)/);
expect(exportChromeSource).toMatch(/translateDataGrid\(\s*'data_grid\.message\.export_failed'\s*,\s*\{\s*detail:\s*res\.message\s*\}\s*\)/);
expect(exportChromeSource).toMatch(/const\s+rawErrorMessage\s*=\s*e\?\.message\s*\|\|\s*String\(e\);[\s\S]*translateDataGrid\(\s*'data_grid\.message\.export_failed'\s*,\s*\{\s*detail:\s*rawErrorMessage\s*\}\s*\)/);
expect(exportChromeSource).toMatch(/translateDataGrid\(\s*'data_grid\.message\.select_file_failed'\s*,\s*\{\s*detail:\s*res\.message\s*\}\s*\)/);
expect(exportChromeSource).toMatch(/},\s*\[[\s\S]*translateDataGrid[\s\S]*\]\);/);
expect(source).toContain('data-query-result-export-scope="true"');
expect(source).toContain("type DataGridExportScope = 'selected' | 'page' | 'all' | 'filteredAll';");
expect(source).toContain('const handleOpenExportDialog = useCallback(async () => {');
expect(source).toContain('await runExportWithProgress({');
expect(source).toContain("title: '导出查询结果'");
expect(source).toContain("label: '筛选结果(全部)'");
expect(source).toContain("label: '全表数据'");
expect(source).toContain("const fallbackAllSql = String(resultSql || '').trim();");
expect(source).toContain("const backendExportSql = exportAllSql || fallbackAllSql;");
expect(source).toContain("if (backendExportSql && connectionId) {");
expect(source).toContain("label: allRowsLabel");
expect(exportDialogSource).toContain('data-export-config-modal="true"');
expect(exportDialogSource).toContain('label="导出格式"');
expect(exportDialogSource).toContain('label="每个工作表最大行数"');
expect(exportDialogSource).toContain('仅 XLSX 生效');
expect(source).toContain('const queryResultCurrentPageRows = useMemo(() => {');
expect(source).toContain('const resolveContextMenuPosition = useCallback((x: number, y: number, estimatedWidth: number, estimatedHeight: number) => {');
expect(source).toContain('const rect = element.getBoundingClientRect();');
expect(source).toContain('ref={cellContextMenuPortalRef}');
[
'正在导出 ${rows.length} 条数据...',
'正在导出...',
'导出成功',
'导出失败: ',
'导出失败:',
'当前未选中任何行',
'导出查询结果',
'请选择导出范围:',
'选中导出',
'当前页导出',
'全部导出',
'当前存在未提交修改,导出将按界面数据生成;如需完整长字段建议先提交后再导出。',
'导出选项',
'您未选中任何行,请选择导出范围:',
'导出当前页',
'导出全部数据',
'当前数据源不支持按筛选结果导出',
'当前存在未提交修改,筛选结果导出基于数据库已提交数据。',
'选择文件失败: ',
'导入完成',
'筛选结果',
'全表',
].forEach((literal) => {
expect(exportSource).not.toContain(literal);
});
[
'zh-CN',
'zh-TW',
'en-US',
'ja-JP',
'de-DE',
'ru-RU',
].forEach((locale) => {
const catalog = JSON.parse(
readFileSync(new URL(`../../../shared/i18n/${locale}.json`, import.meta.url), 'utf8'),
) as Record<string, string>;
exportChromeKeys.forEach((key) => {
expect(catalog[key]).toEqual(expect.any(String));
expect(catalog[key].length).toBeGreaterThan(0);
});
expect(catalog['data_grid.message.exporting_rows']).toContain('{{count}}');
expect(catalog['data_grid.message.export_failed']).toContain('{{detail}}');
expect(catalog['data_grid.message.select_file_failed']).toContain('{{detail}}');
expect(catalog['data_grid.export.selected_rows']).toContain('{{count}}');
expect(catalog['data_grid.export.current_page_rows']).toContain('{{count}}');
expect(catalog['data_grid.export.all_rows']).toContain('{{count}}');
expect(catalog['data_grid.export.current_page']).toContain('{{count}}');
});
expect(source).not.toContain('const openQueryResultExportScopeModal = useCallback(');
expect(source).not.toContain('const exportMenu: MenuProps[\'items\'] =');
});
it('keeps inline cell editors stretched to the full cell width', () => {

View File

@@ -21,7 +21,7 @@ import {
arrayMove
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { ImportData, ExportTable, ExportData, ExportQuery, ApplyChanges, PreviewChanges, DBGetColumns, DBGetIndexes, DBGetForeignKeys, DBShowCreateTable } from '../../wailsjs/go/app/App';
import { ImportData, ExportDataWithOptions, ExportQueryWithOptions, ApplyChanges, PreviewChanges, DBGetColumns, DBGetIndexes, DBGetForeignKeys, DBShowCreateTable } from '../../wailsjs/go/app/App';
import ImportPreviewModal from './ImportPreviewModal';
import { useStore } from '../store';
import { getCurrentLanguage, t } from '../i18n';
@@ -133,14 +133,24 @@ import DataGridToolbarFrame from './DataGridToolbarFrame';
import DataGridModals from './DataGridModals';
import DataGridLegacyCellContextMenu from './DataGridLegacyCellContextMenu';
import DataGridPreviewPanel from './DataGridPreviewPanel';
import {
DEFAULT_DATA_EXPORT_FORMAT,
DEFAULT_XLSX_ROWS_PER_SHEET,
showDataExportDialog,
type DataExportDialogValues,
type DataExportFileOptions,
type DataExportScopeOption,
} from './DataExportDialog';
import { DataGridJsonView, DataGridTextView } from './DataGridRecordViews';
import { DataGridV2DdlSideWorkspace, DataGridV2DdlView } from './DataGridV2DdlWorkspace';
import { DataGridV2ErView, DataGridV2FieldsView } from './DataGridV2MetadataViews';
import TableDesigner from './TableDesigner';
import { useExportProgressDialog } from './ExportProgressModal';
import { useDataGridFilters } from './useDataGridFilters';
import { useDataGridDdlView } from './useDataGridDdlView';
import { useDataGridModalEditors } from './useDataGridModalEditors';
import { useDataGridPreviewPanel } from './useDataGridPreviewPanel';
import { buildTableExportTab } from '../utils/tableExportTab';
// --- Error Boundary ---
interface DataGridErrorBoundaryState {
@@ -823,7 +833,7 @@ const DataContext = React.createContext<{
handleCopyDelete: (r: any) => void;
handleCopyJson: (r: any) => void;
handleCopyCsv: (r: any) => void;
handleExportSelected: (format: string, r: any) => Promise<void>;
handleExportSelected: (options: DataExportFileOptions, r: any) => Promise<void>;
copyToClipboard: (t: string) => void;
tableName?: string;
enableRowContextMenu: boolean;
@@ -1253,11 +1263,11 @@ const ContextMenuRow = React.memo(({ children, record, ...props }: any) => {
label: t('data_grid.context_menu.export_selected'),
icon: <ExportOutlined />,
children: [
{ key: 'exp-csv', label: 'CSV', onClick: () => handleExportSelected('csv', record).catch(console.error) },
{ key: 'exp-xlsx', label: 'Excel', onClick: () => handleExportSelected('xlsx', record).catch(console.error) },
{ key: 'exp-json', label: 'JSON', onClick: () => handleExportSelected('json', record).catch(console.error) },
{ key: 'exp-md', label: 'Markdown', onClick: () => handleExportSelected('md', record).catch(console.error) },
{ key: 'exp-html', label: 'HTML', onClick: () => handleExportSelected('html', record).catch(console.error) },
{ key: 'exp-csv', label: 'CSV', onClick: () => handleExportSelected({ format: 'csv' }, record).catch(console.error) },
{ key: 'exp-xlsx', label: 'Excel', onClick: () => handleExportSelected({ format: 'xlsx' }, record).catch(console.error) },
{ key: 'exp-json', label: 'JSON', onClick: () => handleExportSelected({ format: 'json' }, record).catch(console.error) },
{ key: 'exp-md', label: 'Markdown', onClick: () => handleExportSelected({ format: 'md' }, record).catch(console.error) },
{ key: 'exp-html', label: 'HTML', onClick: () => handleExportSelected({ format: 'html' }, record).catch(console.error) },
]
}
];
@@ -1323,7 +1333,7 @@ type GridFilterCondition = FilterCondition & {
type GridViewMode = 'table' | 'json' | 'text' | 'fields' | 'ddl' | 'er';
type DdlViewLayoutMode = 'bottom' | 'side';
type QueryResultExportScope = 'selected' | 'page' | 'all';
type DataGridExportScope = 'selected' | 'page' | 'all' | 'filteredAll';
type VirtualEditingCellState = {
rowKey: string;
dataIndex: string;
@@ -1572,6 +1582,7 @@ const VIRTUAL_EDITING_CELL_STYLE: React.CSSProperties = {
const DataGrid: React.FC<DataGridProps> = ({
data, columnNames, loading, tableName, objectType = 'table', exportScope = 'table', dbName, connectionId, pkColumns = [], editLocator, readOnly = false,
resultSql,
resultExportAllSql,
onReload, onSort, onPageChange, pagination, onRequestTotalCount, onCancelTotalCount, sortInfoExternal, showFilter, onToggleFilter, exportSqlWithFilter, onApplyFilter, appliedFilterConditions, quickWhereCondition,
onApplyQuickWhereCondition,
@@ -1937,6 +1948,7 @@ const DataGrid: React.FC<DataGridProps> = ({
const [form] = Form.useForm();
const [modal, contextHolder] = Modal.useModal();
const { exportProgressModal, runExportWithProgress } = useExportProgressDialog();
const gridId = useMemo(() => `grid-${generateUuid()}`, []);
const [textRecordIndex, setTextRecordIndex] = useState(0);
const {
@@ -2172,23 +2184,25 @@ const DataGrid: React.FC<DataGridProps> = ({
}, [resolveContextMenuPosition]);
// Helper to export specific data
const exportData = async (rows: any[], format: string) => {
const hide = message.loading(translateDataGrid('data_grid.message.exporting_rows', { count: rows.length }), 0);
try {
const cleanRows = pickDataGridOutputRows(rows, displayOutputColumnNames);
// Pass tableName (or 'export') as default filename
const res = await ExportData(cleanRows, displayOutputColumnNames, tableName || 'export', format);
if (res.success) {
void message.success(translateDataGrid('data_grid.message.export_success'));
} else if (res.message !== "已取消") {
void message.error(translateDataGrid('data_grid.message.export_failed', { detail: res.message }));
}
} catch (e: any) {
const rawErrorMessage = e?.message || String(e);
void message.error(translateDataGrid('data_grid.message.export_failed', { detail: rawErrorMessage }));
} finally {
hide();
}
const exportData = async (rows: any[], options: DataExportFileOptions) => {
const cleanRows = pickDataGridOutputRows(rows, displayOutputColumnNames);
await runExportWithProgress({
title: `导出 ${tableName || '数据'}`,
targetName: tableName || 'export',
format: options.format,
totalRows: cleanRows.length,
run: (jobId) => ExportDataWithOptions(
cleanRows,
displayOutputColumnNames,
tableName || 'export',
{
...options,
jobId,
totalRowsHint: cleanRows.length,
totalRowsKnown: true,
} as any,
),
});
};
const [sortInfo, setSortInfo] = useState<Array<{ columnKey: string, order: string, enabled?: boolean }>>([]);
@@ -6089,24 +6103,29 @@ const DataGrid: React.FC<DataGridProps> = ({
};
}, [connections, connectionId]);
const exportByQuery = useCallback(async (sql: string, format: string, defaultName: string) => {
const exportByQuery = useCallback(async (sql: string, defaultName: string, options: DataExportFileOptions, totalRows?: number) => {
const config = buildConnConfig();
if (!config) return;
const hide = message.loading(translateDataGrid('data_grid.message.exporting'), 0);
try {
const res = await ExportQuery(buildRpcConnectionConfig(config) as any, dbName || '', sql, defaultName || 'export', format);
if (res.success) {
void message.success(translateDataGrid('data_grid.message.export_success'));
} else if (res.message !== "已取消") {
void message.error(translateDataGrid('data_grid.message.export_failed', { detail: res.message }));
}
} catch (e: any) {
const rawErrorMessage = e?.message || String(e);
void message.error(translateDataGrid('data_grid.message.export_failed', { detail: rawErrorMessage }));
} finally {
hide();
}
}, [buildConnConfig, dbName, translateDataGrid]);
const totalRowsKnown = Number.isFinite(totalRows) && Number(totalRows) >= 0;
await runExportWithProgress({
title: `导出 ${defaultName || '查询结果'}`,
targetName: defaultName || 'export',
format: options.format,
totalRows: totalRowsKnown ? Number(totalRows) : undefined,
run: (jobId) => ExportQueryWithOptions(
buildRpcConnectionConfig(config) as any,
dbName || '',
sql,
defaultName || 'export',
{
...options,
jobId,
totalRowsHint: totalRowsKnown ? Number(totalRows) : 0,
totalRowsKnown,
} as any,
),
});
}, [buildConnConfig, dbName, runExportWithProgress]);
const buildPkWhereSql = useCallback((rows: any[], dbType: string) => {
if (!tableName || pkColumns.length === 0) return '';
@@ -6191,7 +6210,7 @@ const DataGrid: React.FC<DataGridProps> = ({
return mergedDisplayData.slice(offset, offset + pagination.pageSize);
}, [isQueryResultExport, mergedDisplayData, pagination]);
const exportQueryResultRows = useCallback(async (format: string, scope: QueryResultExportScope) => {
const exportQueryResultRows = useCallback(async (options: DataExportFileOptions, scope: Exclude<DataGridExportScope, 'filteredAll'>) => {
if (scope === 'selected') {
const selectedKeySet = new Set(selectedRowKeys.map((key) => rowKeyStr(key)));
const rows = mergedDisplayData.filter((row) => {
@@ -6202,87 +6221,53 @@ const DataGrid: React.FC<DataGridProps> = ({
void message.info(translateDataGrid('data_grid.message.no_rows_selected'));
return;
}
await exportData(rows, format);
await exportData(rows, options);
return;
}
if (scope === 'page') {
await exportData(queryResultCurrentPageRows, format);
await exportData(queryResultCurrentPageRows, options);
return;
}
const exportAllSql = String(resultExportAllSql || '').trim();
if (exportAllSql && connectionId) {
await exportByQuery(exportAllSql, format, tableName || 'query_result');
const fallbackAllSql = String(resultSql || '').trim();
const backendExportSql = exportAllSql || fallbackAllSql;
if (backendExportSql && connectionId) {
const totalRows = pagination && pagination.totalKnown !== false ? Number(pagination.total) : undefined;
await exportByQuery(backendExportSql, tableName || 'query_result', options, totalRows);
return;
}
await exportData(mergedDisplayData, format);
}, [connectionId, exportByQuery, exportData, mergedDisplayData, queryResultCurrentPageRows, resultExportAllSql, rowKeyStr, selectedRowKeys, tableName, translateDataGrid]);
const openQueryResultExportScopeModal = useCallback((format: string) => {
let instance: { destroy: () => void } | null = null;
const selectedCount = selectedRowKeys.length;
const runExport = async (scope: QueryResultExportScope) => {
instance?.destroy();
await exportQueryResultRows(format, scope);
};
instance = modal.info({
title: translateDataGrid('data_grid.export.query_result_title'),
content: (
<div data-query-result-export-scope="true">
<p style={{ marginBottom: 12 }}>{translateDataGrid('data_grid.export.scope_prompt')}</p>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
<Button onClick={() => instance?.destroy()}>{translateDataGrid('common.cancel')}</Button>
<Button
disabled={selectedCount <= 0}
onClick={() => { void runExport('selected'); }}
>
{translateDataGrid('data_grid.export.selected_rows', { count: selectedCount })}
</Button>
<Button onClick={() => { void runExport('page'); }}>
{translateDataGrid('data_grid.export.current_page_rows', { count: queryResultCurrentPageRows.length })}
</Button>
<Button type="primary" onClick={() => { void runExport('all'); }}>
{resultExportAllSql
? translateDataGrid('data_grid.export.all_rows_requery')
: translateDataGrid('data_grid.export.all_rows', { count: mergedDisplayData.length })}
</Button>
</div>
</div>
),
icon: <ExportOutlined />,
okButtonProps: { style: { display: 'none' } },
maskClosable: true,
});
}, [exportQueryResultRows, mergedDisplayData.length, modal, queryResultCurrentPageRows.length, resultExportAllSql, selectedRowKeys.length, translateDataGrid]);
await exportData(mergedDisplayData, options);
}, [connectionId, exportByQuery, exportData, mergedDisplayData, pagination, queryResultCurrentPageRows, resultExportAllSql, resultSql, rowKeyStr, selectedRowKeys, tableName]);
// Context Menu Export
const handleExportSelected = useCallback(async (format: string, record: any) => {
const handleExportSelected = useCallback(async (options: DataExportFileOptions, record: any) => {
if (isQueryResultExport) {
await exportData(getContextMenuTargetRows(record), format);
await exportData(getContextMenuTargetRows(record), options);
return;
}
const records = getTargets(record);
if (!connectionId || !tableName) {
await exportData(records, format);
await exportData(records, options);
return;
}
// 有未提交修改时,优先按界面数据导出,避免与数据库不一致。
if (hasChanges) {
await exportData(records, options);
void message.warning(translateDataGrid('data_grid.message.export_with_uncommitted_changes'));
await exportData(records, format);
return;
}
const config = buildConnConfig();
if (!config) {
await exportData(records, format);
await exportData(records, options);
return;
}
const dbType = resolveDataSourceType(config);
const pkWhere = buildPkWhereSql(records, dbType);
if (!pkWhere) {
await exportData(records, format);
await exportData(records, options);
return;
}
@@ -6292,7 +6277,7 @@ const DataGrid: React.FC<DataGridProps> = ({
columnNames: displayOutputColumnNames,
whereSql: `WHERE ${pkWhere}`,
});
await exportByQuery(sql, format, tableName || 'export');
await exportByQuery(sql, tableName || 'export', options, records.length);
}, [getTargets, isQueryResultExport, connectionId, tableName, hasChanges, exportData, buildConnConfig, buildPkWhereSql, exportByQuery, displayOutputColumnNames, translateDataGrid]);
const handleV2CellContextMenuAction = useCallback((action: V2CellContextMenuActionKey) => {
@@ -6381,8 +6366,8 @@ const DataGrid: React.FC<DataGridProps> = ({
case 'export-json':
case 'export-html':
if (record) {
const format = action.replace('export-', '');
handleExportSelected(format, record).catch(console.error);
const format = action.replace('export-', '') as DataExportDialogValues['format'];
handleExportSelected({ format }, record).catch(console.error);
}
closeMenu();
return;
@@ -6417,96 +6402,124 @@ const DataGrid: React.FC<DataGridProps> = ({
]);
// Export
const handleExport = async (format: string) => {
const handleOpenExportDialog = useCallback(async () => {
const selectedCount = selectedRowKeys.length;
const allRowsLabel = (resultExportAllSql || resultSql)
? '全部结果(重新查询)'
: `全部结果(当前缓存 ${mergedDisplayData.length} 条)`;
const commonInitialValues: Partial<DataExportDialogValues> = {
format: DEFAULT_DATA_EXPORT_FORMAT,
xlsxMaxRowsPerSheet: DEFAULT_XLSX_ROWS_PER_SHEET,
};
if (isQueryResultExport) {
openQueryResultExportScopeModal(format);
const scopeOptions: DataExportScopeOption[] = [
{
value: 'selected',
label: selectedCount > 0 ? `选中行 (${selectedCount} 条)` : '选中行',
description: '仅导出当前结果集中已勾选的行。',
disabled: selectedCount <= 0,
},
{
value: 'page',
label: `当前页 (${queryResultCurrentPageRows.length} 条)`,
description: '直接按当前结果页缓存导出。',
},
{
value: 'all',
label: allRowsLabel,
description: (resultExportAllSql || resultSql)
? '后台会重新执行 SQL避免只导出当前页或当前缓存。'
: '当前查询缺少可重放 SQL 时,将导出当前缓存的全部结果。',
},
];
const values = await showDataExportDialog(modal, {
title: '导出查询结果',
scopeOptions,
initialValues: {
...commonInitialValues,
scope: (resultExportAllSql || resultSql) ? 'all' : (selectedCount > 0 ? 'selected' : 'page'),
},
});
if (!values) return;
await exportQueryResultRows(values, values.scope as Exclude<DataGridExportScope, 'filteredAll'>);
return;
}
if (!connectionId) return;
// 1. Export Selected
if (selectedRowKeys.length > 0) {
const selectedRows = displayData.filter(d => selectedRowKeys.includes(d?.[GONAVI_ROW_KEY]));
await handleExportSelected(format, selectedRows[0]);
return;
}
// 2. Prompt for Current vs All
// Using a custom modal content with buttons to handle 3 states
let instance: any;
const handleAll = async () => {
instance.destroy();
if (!tableName) return;
const config = buildConnConfig();
if (!config) return;
const sql = buildAllRowsSql(resolveDataSourceType(config));
if (!sql) return;
await exportByQuery(sql, format, tableName || 'export');
};
const handlePage = async () => {
instance.destroy();
if (hasChanges) {
void message.warning(translateDataGrid('data_grid.message.export_with_uncommitted_changes'));
await exportData(displayData, format);
return;
}
const config = buildConnConfig();
if (!config) {
await exportData(displayData, format);
return;
}
const sql = buildCurrentPageSql(resolveDataSourceType(config));
if (!sql) {
await exportData(displayData, format);
return;
}
await exportByQuery(sql, format, tableName || 'export');
};
instance = modal.info({
title: translateDataGrid('data_grid.export.options_title'),
content: (
<div>
<p>{translateDataGrid('data_grid.export.no_selection_prompt')}</p>
<div style={{ display: 'flex', gap: 8, marginTop: 16, justifyContent: 'flex-end' }}>
<Button onClick={() => instance.destroy()}>{translateDataGrid('common.cancel')}</Button>
<Button onClick={handlePage}>{translateDataGrid('data_grid.export.current_page', { count: displayData.length })}</Button>
<Button type="primary" onClick={handleAll}>{translateDataGrid('data_grid.export.all_data')}</Button>
</div>
</div>
),
icon: <ExportOutlined />,
okButtonProps: { style: { display: 'none' } }, // Hide default OK
maskClosable: true,
});
};
const handleExportFilteredAll = async (format: string) => {
if (!connectionId || !tableName) return;
if (!filteredExportSql) {
void message.warning(translateDataGrid('data_grid.message.no_filter_applied'));
return;
}
if (!supportsSqlQueryExport) {
void message.error(translateDataGrid('data_grid.message.filtered_export_not_supported'));
return;
}
const config = buildConnConfig();
if (!config) return;
if (hasChanges) {
void message.warning(translateDataGrid('data_grid.message.filtered_export_uses_committed_data'));
}
const dbType = config ? resolveDataSourceType(config) : '';
const currentPageSql = config && !hasChanges ? buildCurrentPageSql(dbType) : '';
const filteredAllSql = config && supportsSqlQueryExport ? buildFilteredAllSql(dbType) : '';
const allRowsSql = config && objectType !== 'table' ? buildAllRowsSql(dbType) : '';
const hasKnownFilteredTotal = hasFilteredExportSql && pagination && pagination.totalKnown !== false;
const hasKnownAllTotal = !hasFilteredExportSql && pagination && pagination.totalKnown !== false;
const sql = buildFilteredAllSql(resolveDataSourceType(config));
if (!sql) {
void message.warning(translateDataGrid('data_grid.message.no_filter_applied'));
return;
}
await exportByQuery(sql, format, `${tableName || 'export'}_filtered`);
};
addTab(buildTableExportTab({
connectionId,
dbName,
tableName: tableName || 'export',
title: `导出 ${tableName || '数据'}`,
objectType,
scopeOptions: [
{
value: 'page',
label: `当前页 (${displayData.length} 条)`,
description: currentPageSql
? '后台按当前分页条件重新查询后导出当前页。'
: '当前页依赖前端临时状态,建议直接使用快捷导出。',
disabled: !currentPageSql,
},
...(hasFilteredExportSql ? [{
value: 'filteredAll' as const,
label: '筛选结果(全部)',
description: filteredAllSql
? '按当前筛选条件重新查询数据库并导出全部筛选结果。'
: '当前数据源或当前状态暂不支持在工作台重放筛选导出。',
disabled: !filteredAllSql,
}] : []),
{
value: 'all',
label: '全表数据',
description: '后台重新查询整张表并导出全部数据。',
},
],
initialScope: hasFilteredExportSql && filteredAllSql ? 'filteredAll' : 'all',
queryByScope: {
...(currentPageSql ? { page: currentPageSql } : {}),
...(filteredAllSql ? { filteredAll: filteredAllSql } : {}),
...(allRowsSql ? { all: allRowsSql } : {}),
},
rowCountByScope: {
page: displayData.length,
...(hasKnownFilteredTotal ? { filteredAll: Number(pagination?.total) } : {}),
...(hasKnownAllTotal ? { all: Number(pagination?.total) } : {}),
},
}));
}, [
addTab,
buildAllRowsSql,
buildConnConfig,
buildCurrentPageSql,
buildFilteredAllSql,
connectionId,
dbName,
displayData.length,
exportQueryResultRows,
hasFilteredExportSql,
objectType,
isQueryResultExport,
mergedDisplayData.length,
modal,
pagination,
queryResultCurrentPageRows.length,
resultExportAllSql,
resultSql,
selectedRowKeys.length,
supportsSqlQueryExport,
tableName,
hasChanges,
]);
const handleImport = async () => {
if (!connectionId || !tableName) return;
@@ -6529,36 +6542,6 @@ const DataGrid: React.FC<DataGridProps> = ({
if (onReload) onReload();
};
const exportMenu: MenuProps['items'] = isQueryResultExport ? [
{ key: 'query-csv', label: 'CSV', onClick: () => handleExport('csv') },
{ key: 'query-xlsx', label: 'Excel (XLSX)', onClick: () => handleExport('xlsx') },
{ key: 'query-json', label: 'JSON', onClick: () => handleExport('json') },
{ key: 'query-md', label: 'Markdown', onClick: () => handleExport('md') },
{ key: 'query-html', label: 'HTML', onClick: () => handleExport('html') },
] : hasFilteredExportSql ? [
{ type: 'group', label: translateDataGrid('data_grid.export.group_filtered_results'), children: [
{ key: 'filtered-csv', label: 'CSV', onClick: () => handleExportFilteredAll('csv') },
{ key: 'filtered-xlsx', label: 'Excel (XLSX)', onClick: () => handleExportFilteredAll('xlsx') },
{ key: 'filtered-json', label: 'JSON', onClick: () => handleExportFilteredAll('json') },
{ key: 'filtered-md', label: 'Markdown', onClick: () => handleExportFilteredAll('md') },
{ key: 'filtered-html', label: 'HTML', onClick: () => handleExportFilteredAll('html') },
]},
{ type: 'divider' },
{ type: 'group', label: translateDataGrid('data_grid.export.group_full_table'), children: [
{ key: 'table-csv', label: 'CSV', onClick: () => handleExport('csv') },
{ key: 'table-xlsx', label: 'Excel (XLSX)', onClick: () => handleExport('xlsx') },
{ key: 'table-json', label: 'JSON', onClick: () => handleExport('json') },
{ key: 'table-md', label: 'Markdown', onClick: () => handleExport('md') },
{ key: 'table-html', label: 'HTML', onClick: () => handleExport('html') },
]},
] : [
{ key: 'csv', label: 'CSV', onClick: () => handleExport('csv') },
{ key: 'xlsx', label: 'Excel (XLSX)', onClick: () => handleExport('xlsx') },
{ key: 'json', label: 'JSON', onClick: () => handleExport('json') },
{ key: 'md', label: 'Markdown', onClick: () => handleExport('md') },
{ key: 'html', label: 'HTML', onClick: () => handleExport('html') },
];
const queryResultCopyMenu: MenuProps['items'] = [
{ key: 'csv', label: 'CSV', onClick: handleCopyQueryResultCsv },
{ key: 'json', label: 'JSON', onClick: handleCopyQueryResultJson },
@@ -7871,7 +7854,6 @@ const DataGrid: React.FC<DataGridProps> = ({
noAutoCapInputProps={noAutoCapInputProps as Record<string, unknown>}
filterFieldSelectStyle={FILTER_FIELD_SELECT_STYLE}
filterFieldPopupWidth={FILTER_FIELD_POPUP_WIDTH}
exportMenu={exportMenu}
queryResultCopyMenu={queryResultCopyMenu}
dbType={dbType}
onResetPendingChanges={handleResetPendingChanges}
@@ -7890,6 +7872,7 @@ const DataGrid: React.FC<DataGridProps> = ({
onCommit={handleCommit}
onPreviewChanges={handlePreviewChanges}
onImport={handleImport}
onOpenExportModal={handleOpenExportDialog}
onCopyQueryResultCsv={handleCopyQueryResultCsv}
onRequestAiInsight={handleRequestAiInsight}
onToggleTotalCount={handleToggleTotalCount}
@@ -7942,6 +7925,7 @@ const DataGrid: React.FC<DataGridProps> = ({
<div ref={containerRef} style={{ flex: 1, overflow: 'hidden', position: 'relative', minHeight: 0, display: 'flex', flexDirection: 'column', background: bgContent, borderRadius: panelRadius, border: `1px solid ${panelFrameColor}`, boxSizing: 'border-box' }}>
{contextHolder}
{exportProgressModal}
<DataGridModals
tableName={tableName}
darkMode={darkMode}
@@ -8214,16 +8198,16 @@ const DataGrid: React.FC<DataGridProps> = ({
}
}}
onExportCsv={() => {
if (cellContextMenu.record) handleExportSelected('csv', cellContextMenu.record).catch(console.error);
if (cellContextMenu.record) handleExportSelected({ format: 'csv' }, cellContextMenu.record).catch(console.error);
}}
onExportXlsx={() => {
if (cellContextMenu.record) handleExportSelected('xlsx', cellContextMenu.record).catch(console.error);
if (cellContextMenu.record) handleExportSelected({ format: 'xlsx' }, cellContextMenu.record).catch(console.error);
}}
onExportJson={() => {
if (cellContextMenu.record) handleExportSelected('json', cellContextMenu.record).catch(console.error);
if (cellContextMenu.record) handleExportSelected({ format: 'json' }, cellContextMenu.record).catch(console.error);
}}
onExportHtml={() => {
if (cellContextMenu.record) handleExportSelected('html', cellContextMenu.record).catch(console.error);
if (cellContextMenu.record) handleExportSelected({ format: 'html' }, cellContextMenu.record).catch(console.error);
}}
/>
</div>

View File

@@ -93,7 +93,7 @@ export interface DataGridToolbarFrameProps {
noAutoCapInputProps: Record<string, unknown>;
filterFieldSelectStyle: React.CSSProperties;
filterFieldPopupWidth: number;
exportMenu: MenuProps['items'];
onOpenExportModal: () => void;
queryResultCopyMenu: MenuProps['items'];
dbType: string;
onResetPendingChanges: () => void;
@@ -194,7 +194,7 @@ const DataGridToolbarFrame: React.FC<DataGridToolbarFrameProps> = ({
noAutoCapInputProps,
filterFieldSelectStyle,
filterFieldPopupWidth,
exportMenu,
onOpenExportModal,
queryResultCopyMenu,
dbType,
onResetPendingChanges,
@@ -412,7 +412,7 @@ const DataGridToolbarFrame: React.FC<DataGridToolbarFrameProps> = ({
<>
{renderToolbarDivider()}
{canImport && <Button icon={<ImportOutlined />} onClick={onImport}>{translate('data_grid.toolbar.import')}</Button>}
{canExport && <Dropdown menu={{ items: exportMenu }}><Button icon={<ExportOutlined />}>{translate('data_grid.toolbar.export')} <DownOutlined /></Button></Dropdown>}
{canExport && <Button icon={<ExportOutlined />} onClick={onOpenExportModal}>{translate('data_grid.toolbar.export')}</Button>}
</>
)}

View File

@@ -0,0 +1,86 @@
import React from 'react';
import { Progress } from 'antd';
import {
resolveExportProgressPercent,
shouldUseIndeterminateExportProgress,
type ExportProgressStatus,
} from '../utils/exportProgress';
const INDETERMINATE_ANIMATION_NAME = 'gonavi-export-indeterminate-progress';
const normalizeCount = (value: unknown): number => {
const next = Number(value);
if (!Number.isFinite(next) || next < 0) {
return 0;
}
return Math.trunc(next);
};
type ExportProgressBarProps = {
status: ExportProgressStatus;
current: number;
total: number;
totalRowsKnown: boolean;
};
export const ExportProgressBar: React.FC<ExportProgressBarProps> = ({
status,
current,
total,
totalRowsKnown,
}) => {
const isIndeterminate = shouldUseIndeterminateExportProgress(status, totalRowsKnown);
const progressStatus = status === 'error'
? 'exception'
: (status === 'done' ? 'success' : 'active');
if (isIndeterminate) {
return (
<div data-export-progress-mode="indeterminate">
<style>{`
@keyframes ${INDETERMINATE_ANIMATION_NAME} {
0% { transform: translateX(-130%); }
100% { transform: translateX(430%); }
}
`}</style>
<div
style={{
position: 'relative',
width: '100%',
height: 8,
overflow: 'hidden',
borderRadius: 999,
background: 'rgba(15, 23, 42, 0.08)',
}}
>
<div
style={{
position: 'absolute',
inset: 0,
width: '24%',
borderRadius: 999,
background: '#1677ff',
animation: `${INDETERMINATE_ANIMATION_NAME} 1.25s ease-in-out infinite`,
}}
/>
</div>
</div>
);
}
const percent = resolveExportProgressPercent(status, current, total, totalRowsKnown);
return (
<div data-export-progress-mode="determinate">
<Progress
percent={Math.round(percent)}
status={progressStatus}
format={() => totalRowsKnown
? `${normalizeCount(current)}/${normalizeCount(total)}`
: `${Math.round(percent)}%`}
/>
</div>
);
};
export default ExportProgressBar;

View File

@@ -0,0 +1,75 @@
import React from 'react';
import { Button, Modal, Typography } from 'antd';
import {
formatExportProgressRows,
} from '../utils/exportProgress';
import ExportProgressBar from './ExportProgressBar';
import { useExportProgressRunner } from './useExportProgressRunner';
const { Text, Paragraph } = Typography;
export function useExportProgressDialog() {
const { state, reset, runExportWithProgress } = useExportProgressRunner();
const canClose = state.status === 'done' || state.status === 'error';
const modalNode = (
<Modal
title={state.status === 'error' ? '导出失败' : (state.status === 'done' ? '导出完成' : '正在导出')}
open={state.open}
width={560}
mask={false}
keyboard={canClose}
closable={canClose}
onCancel={reset}
footer={canClose ? [
<Button key="close" onClick={reset}></Button>,
] : null}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'grid', gridTemplateColumns: '72px 1fr', rowGap: 8, columnGap: 8 }}>
<Text type="secondary"></Text>
<Text>{state.title || state.targetName || '导出任务'}</Text>
<Text type="secondary"></Text>
<Text>{state.targetName || '未命名对象'}</Text>
<Text type="secondary"></Text>
<Text>{state.format || '-'}</Text>
<Text type="secondary"></Text>
<Text>{state.stage || '准备中'}</Text>
{state.filePath ? (
<>
<Text type="secondary"></Text>
<Paragraph style={{ marginBottom: 0, wordBreak: 'break-all' }}>{state.filePath}</Paragraph>
</>
) : null}
</div>
<ExportProgressBar
status={state.status}
current={state.current}
total={state.total}
totalRowsKnown={state.totalRowsKnown}
/>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<Text type="secondary">{formatExportProgressRows(state.current, state.total, state.totalRowsKnown)}</Text>
{!state.totalRowsKnown && state.status !== 'done' && state.status !== 'error' ? (
<Text type="secondary"></Text>
) : null}
{state.message ? (
<Text type="danger">{state.message}</Text>
) : null}
</div>
</div>
</Modal>
);
return {
exportProgressModal: modalNode,
runExportWithProgress,
};
}

View File

@@ -1429,9 +1429,10 @@ describe('Sidebar locate toolbar', () => {
expect(markup).toContain('备份 · SQL Dump');
expect(markup).toContain('刷新统计信息');
expect(markup).toContain('导出表数据');
expect(markup).toContain('Excel · .xlsx');
expect(markup).toContain('CSV · .csv');
expect(markup).toContain('JSON · .json');
expect(markup).toContain('打开导出工作台…');
expect(markup).not.toContain('Excel · .xlsx');
expect(markup).not.toContain('CSV · .csv');
expect(markup).not.toContain('JSON · .json');
expect(markup).not.toContain('Markdown · .md');
expect(markup).not.toContain('HTML · .html');
expect(markup).toContain('用 AI 解释这张表');
@@ -1570,19 +1571,10 @@ describe('Sidebar locate toolbar', () => {
expect(exportSourceStart).toBeGreaterThanOrEqual(0);
expect(exportSourceEnd).toBeGreaterThan(exportSourceStart);
expect(exportSource).toContain("t('sidebar.menu.export_table_data')");
expect(exportSource).toContain("t('sidebar.v2_table_menu.item_with_suffix', { label: 'Excel', suffix: '.xlsx' })");
expect(exportSource).toContain("t('sidebar.v2_table_menu.item_with_suffix', { label: 'CSV', suffix: '.csv' })");
expect(exportSource).toContain("t('sidebar.v2_table_menu.item_with_suffix', { label: 'JSON', suffix: '.json' })");
expect(exportSource).toContain('Excel');
expect(exportSource).toContain('CSV');
expect(exportSource).toContain('JSON');
expect(exportSource).toContain('.xlsx');
expect(exportSource).toContain('.csv');
expect(exportSource).toContain('.json');
expect(exportSource).toContain("t('sidebar.v2_table_menu.open_export_workbench')");
expect(exportSource).toContain("{ action: 'export-data'");
expect(exportSource).not.toContain('导出表数据');
expect(exportSource).not.toContain("'Excel · .xlsx'");
expect(exportSource).not.toContain("'CSV · .csv'");
expect(exportSource).not.toContain("'JSON · .json'");
expect(exportSource).not.toContain("'打开导出工作台…'");
expect(exportSource).not.toContain('用 AI 解释这张表');
});
@@ -2641,4 +2633,11 @@ describe('Sidebar locate toolbar', () => {
expect(source).toContain('return !loadingNodesRef.current.has(loadKey);');
expect(source).toContain('对象仍在加载中');
});
it('resolves sidebar export workbench connection ids from live tree nodes instead of only reading dataRef.connectionId', () => {
const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8');
expect(source).toContain("const connectionId = resolveSidebarNodeConnectionId(node, connectionIds) || String(node?.dataRef?.id || '').trim();");
expect(source).not.toContain("const connectionId = String(node?.dataRef?.connectionId || '').trim();");
});
});

View File

@@ -56,7 +56,7 @@ import {
import { buildOverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
import { ConnectionTag, SavedConnection, SavedQuery, ExternalSQLDirectory, ExternalSQLTreeEntry, JVMCapability, JVMResourceSummary } from '../types';
import { getDbIcon } from './DatabaseIcons';
import { DBGetDatabases, DBGetTables, DBQuery, DBShowCreateTable, DBReleaseConnection, ExportTable, OpenSQLFile, ExecuteSQLFile, CancelSQLFileExecution, CreateDatabase, CreateSchema, RenameDatabase, DropDatabase, RenameTable, DropTable, DropView, DropFunction, RenameView, SelectSQLDirectory, ListSQLDirectory, ReadSQLFile, CreateSQLFile, CreateSQLDirectory, DeleteSQLFile, DeleteSQLDirectory, RenameSQLFile, RenameSQLDirectory, JVMProbeCapabilities, GetDriverStatusList } from '../../wailsjs/go/app/App';
import { DBGetDatabases, DBGetTables, DBQuery, DBShowCreateTable, DBReleaseConnection, ExportTableWithOptions, OpenSQLFile, ExecuteSQLFile, CancelSQLFileExecution, CreateDatabase, CreateSchema, RenameDatabase, DropDatabase, RenameTable, DropTable, DropView, DropFunction, RenameView, SelectSQLDirectory, ListSQLDirectory, ReadSQLFile, CreateSQLFile, CreateSQLDirectory, DeleteSQLFile, DeleteSQLDirectory, RenameSQLFile, RenameSQLDirectory, JVMProbeCapabilities, GetDriverStatusList } from '../../wailsjs/go/app/App';
import { getTableDataDangerActionMeta, supportsTableTruncateAction, type TableDataDangerActionKind } from './tableDataDangerActions';
import { EventsOn } from '../../wailsjs/runtime/runtime';
import { isMacLikePlatform, normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
@@ -89,6 +89,8 @@ import { resolveConnectionAccentColor, resolveConnectionIconType } from '../util
import { buildJVMTabTitle } from '../utils/jvmRuntimePresentation';
import { buildJVMDiagnosticActionDescriptor, buildJVMMonitoringActionDescriptors } from '../utils/jvmSidebarActions';
import { buildTableSelectQuery } from '../utils/objectQueryTemplates';
import { buildTableExportTab } from '../utils/tableExportTab';
import { useExportProgressDialog } from './ExportProgressModal';
import { getShortcutPlatform, resolveShortcutDisplay } from '../utils/shortcuts';
import { buildExternalSQLDirectoryId, buildExternalSQLRootNode, buildExternalSQLTabId, type ExternalSQLTreeNode } from '../utils/externalSqlTree';
import { getCurrentLanguage, t } from '../i18n';
@@ -1155,6 +1157,7 @@ const Sidebar: React.FC<{
const darkMode = theme === 'dark';
const resolvedAppearance = resolveAppearanceValues(appearance);
const opacity = normalizeOpacityForPlatform(resolvedAppearance.opacity);
const { exportProgressModal, runExportWithProgress } = useExportProgressDialog();
const disableLocalBackdropFilter = isMacLikePlatform();
const autoFetchVisible = useAutoFetchVisibility();
const activeShortcutPlatform = getShortcutPlatform(isMacLikePlatform());
@@ -1479,7 +1482,6 @@ const Sidebar: React.FC<{
const [externalSQLFileForm] = Form.useForm();
const [externalSQLFileModalMode, setExternalSQLFileModalMode] = useState<ExternalSQLFileModalMode>('create');
const [externalSQLFileTarget, setExternalSQLFileTarget] = useState<any>(null);
// Connection Tag Modals
const [isCreateTagModalOpen, setIsCreateTagModalOpen] = useState(false);
const [createTagForm] = Form.useForm();
@@ -3787,23 +3789,53 @@ const Sidebar: React.FC<{
}
};
const handleExport = async (node: any, format: string) => {
const handleExport = async (node: any, options: { format: string; xlsxMaxRowsPerSheet?: number }) => {
const { config, dbName, tableName } = node.dataRef;
const hide = message.loading(t('sidebar.message.exporting_table_format', {
table: tableName,
format: format.toUpperCase(),
}), 0);
const res = await ExportTable(buildRpcConnectionConfig(config) as any, dbName, tableName, format);
hide();
if (res.success) {
message.success(t('sidebar.message.export_success'));
} else if (res.message !== '已取消') {
message.error(t('sidebar.message.export_failed', { error: res.message }));
const rowCount = Number(node?.dataRef?.rowCount);
const totalRowsKnown = Number.isFinite(rowCount) && rowCount >= 0;
await runExportWithProgress({
title: `导出 ${tableName}`,
targetName: tableName,
format: options.format,
totalRows: totalRowsKnown ? rowCount : undefined,
run: (jobId) => ExportTableWithOptions(
buildRpcConnectionConfig(config) as any,
dbName,
tableName,
{
...options,
jobId,
totalRowsHint: totalRowsKnown ? rowCount : 0,
totalRowsKnown,
} as any,
),
});
};
const openExportDialog = async (node: any) => {
const tableName = String(node?.dataRef?.tableName || node?.title || '').trim();
if (!tableName) {
message.warning('未识别到表名,无法导出');
return;
}
const connectionId = resolveSidebarNodeConnectionId(node, connectionIds) || String(node?.dataRef?.id || '').trim();
const dbName = String(node?.dataRef?.dbName || '').trim();
addTab(buildTableExportTab({
connectionId,
dbName,
tableName,
title: `导出 ${tableName}`,
objectType: node?.type === 'view' ? 'view' : (node?.type === 'materialized-view' ? 'materialized-view' : 'table'),
schemaName: typeof node?.dataRef?.schemaName === 'string' ? node.dataRef.schemaName : undefined,
sidebarLocateKey: typeof node?.key === 'string' ? node.key : undefined,
rowCountByScope: Number.isFinite(Number(node?.dataRef?.rowCount)) && Number(node?.dataRef?.rowCount) >= 0
? { all: Math.trunc(Number(node.dataRef.rowCount)) }
: undefined,
}));
};
const handleCopyTableAsInsert = async (node: any) => {
await handleExport(node, 'sql');
await handleExport(node, { format: 'sql' });
};
const openTableDdlInDesigner = (node: any) => {
@@ -5957,19 +5989,13 @@ const Sidebar: React.FC<{
openCreateStarRocksRollup(node);
return;
case 'backup-table':
void handleExport(node, 'sql');
void handleExport(node, { format: 'sql' });
return;
case 'refresh-stats':
refreshV2TableContextMenuStats(node);
return;
case 'export-xlsx':
void handleExport(node, 'xlsx');
return;
case 'export-csv':
void handleExport(node, 'csv');
return;
case 'export-json':
void handleExport(node, 'json');
case 'export-data':
void openExportDialog(node);
return;
case 'ai-explain':
void injectTablePromptToAI(node, 'explain');
@@ -8355,7 +8381,7 @@ const Sidebar: React.FC<{
key: 'backup-table',
label: '备份表 (SQL)',
icon: <SaveOutlined />,
onClick: () => handleExport(node, 'sql')
onClick: () => handleExport(node, { format: 'sql' })
},
{
key: 'rename-table',
@@ -8398,15 +8424,9 @@ const Sidebar: React.FC<{
},
{
key: 'export',
label: '导出表数据',
label: '导出表数据',
icon: <ExportOutlined />,
children: [
{ key: 'export-csv', label: '导出 CSV', onClick: () => handleExport(node, 'csv') },
{ key: 'export-xlsx', label: '导出 Excel (XLSX)', onClick: () => handleExport(node, 'xlsx') },
{ key: 'export-json', label: '导出 JSON', onClick: () => handleExport(node, 'json') },
{ key: 'export-md', label: '导出 Markdown', onClick: () => handleExport(node, 'md') },
{ key: 'export-html', label: '导出 HTML', onClick: () => handleExport(node, 'html') },
]
onClick: () => openExportDialog(node),
}
];
}
@@ -9279,6 +9299,7 @@ const Sidebar: React.FC<{
return (
<div className={isV2Ui ? 'gn-v2-sidebar-redesign' : undefined} style={{ display: 'flex', height: '100%', minHeight: 0 }}>
{exportProgressModal}
{isV2Ui && renderV2ConnectionRail()}
<div className={isV2Ui ? 'gn-v2-object-explorer' : undefined} style={{ display: 'flex', flexDirection: 'column', height: '100%', minWidth: 0, flex: 1 }}>
{isV2Ui && (

View File

@@ -3,12 +3,6 @@ import { describe, expect, it } from 'vitest';
const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8');
const locales = ['zh-CN', 'zh-TW', 'en-US', 'ja-JP', 'de-DE', 'ru-RU'] as const;
const requiredKeys = [
'sidebar.message.exporting_table_format',
'sidebar.message.export_success',
'sidebar.message.export_failed',
] as const;
const extractHandleExportBlock = (): string => {
const start = source.indexOf('const handleExport = async');
const end = source.indexOf('const handleCopyTableAsInsert', start);
@@ -22,29 +16,22 @@ const placeholders = (value: string): string[] => [...value.matchAll(/\{\{(\w+)\
.sort();
describe('Sidebar table export feedback i18n', () => {
it('localizes handleExport loading, success, and failure wrappers', () => {
it('routes handleExport through the progress runner and option-based backend export', () => {
const block = extractHandleExportBlock();
expect(block).not.toContain('`正在导出 ${tableName} 为 ${format.toUpperCase()}...`');
expect(block).not.toContain("message.success('导出成功')");
expect(block).not.toContain("'导出失败: ' + res.message");
expect(block).toContain("t('sidebar.message.exporting_table_format'");
expect(block).toContain("t('sidebar.message.export_success')");
expect(block).toContain("t('sidebar.message.export_failed'");
expect(block).toContain('table: tableName');
expect(block).toContain('format: format.toUpperCase()');
expect(block).toContain('error: res.message');
expect(block).not.toContain('ExportTable(');
expect(block).toContain('runExportWithProgress({');
expect(block).toContain('ExportTableWithOptions(');
expect(block).toContain('jobId');
expect(block).toContain('totalRowsHint');
expect(block).toContain('totalRowsKnown');
});
it('keeps table export feedback keys available with stable placeholders', () => {
it('keeps the export workbench entry key available across locales', () => {
locales.forEach((locale) => {
const catalog = JSON.parse(readFileSync(new URL(`../../../shared/i18n/${locale}.json`, import.meta.url), 'utf8')) as Record<string, string>;
requiredKeys.forEach((key) => {
expect(catalog[key], `${locale}:${key}`).toBeTruthy();
});
expect(placeholders(catalog['sidebar.message.exporting_table_format'])).toEqual(['format', 'table']);
expect(placeholders(catalog['sidebar.message.export_success'])).toEqual([]);
expect(placeholders(catalog['sidebar.message.export_failed'])).toEqual(['error']);
expect(catalog['sidebar.v2_table_menu.open_export_workbench'], `${locale}:sidebar.v2_table_menu.open_export_workbench`).toBeTruthy();
expect(placeholders(catalog['sidebar.v2_table_menu.open_export_workbench'])).toEqual([]);
});
});
});

View File

@@ -17,6 +17,7 @@ import RedisMonitor from './RedisMonitor';
import TriggerViewer from './TriggerViewer';
import DefinitionViewer from './DefinitionViewer';
import TableOverview from './TableOverview';
import TableExportWorkbench from './TableExportWorkbench';
import JVMOverview from './JVMOverview';
import JVMResourceBrowser from './JVMResourceBrowser';
import JVMAuditViewer from './JVMAuditViewer';
@@ -46,6 +47,7 @@ const getTabKindLabel = (tab: TabData): string => {
if (tab.type === 'table') return t('tab_manager.kind_badge.table');
if (tab.type === 'design') return t('tab_manager.kind_badge.design');
if (tab.type === 'table-overview') return t('tab_manager.kind_badge.table_overview');
if (tab.type === 'table-export') return t('tab_manager.kind_badge.table_export');
if (tab.type.startsWith('redis')) return t('tab_manager.kind_badge.redis');
if (tab.type.startsWith('jvm')) return t('tab_manager.kind_badge.jvm');
if (tab.type === 'trigger') return t('tab_manager.kind_badge.trigger');
@@ -66,6 +68,7 @@ const getTabKindTooltipLabel = (tab: TabData): string => {
if (tab.type === 'table') return t('tab_manager.hover.kind.table');
if (tab.type === 'design') return t('tab_manager.hover.kind.design');
if (tab.type === 'table-overview') return t('tab_manager.hover.kind.table_overview');
if (tab.type === 'table-export') return t('tab_manager.hover.kind.table_export');
if (tab.type === 'redis-keys') return t('tab_manager.hover.kind.redis_keys');
if (tab.type === 'redis-command') return t('tab_manager.hover.kind.redis_command');
if (tab.type === 'redis-monitor') return t('tab_manager.hover.kind.redis_monitor');
@@ -407,6 +410,9 @@ const TabContent: React.FC<{ tab: TabData; isActive: boolean }> = React.memo(({
if (tab.type === 'table-overview') {
return <TableOverview tab={tab} />;
}
if (tab.type === 'table-export') {
return <TableExportWorkbench tab={tab} />;
}
if (tab.type === 'jvm-overview') {
return <JVMOverview tab={tab} />;
}

View File

@@ -0,0 +1,215 @@
import React from 'react';
import { readFileSync } from 'node:fs';
import { renderToStaticMarkup } from 'react-dom/server';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import TableExportWorkbench, { buildTableExportHistoryEntry } from './TableExportWorkbench';
const mockUpsertTableExportHistory = vi.fn();
const createMockStoreState = () => ({
theme: 'light',
connections: [
{
id: 'conn-1',
name: '本地',
config: {
type: 'mysql',
host: 'localhost',
port: 3306,
user: 'root',
database: 'SYS',
},
},
],
tableExportHistories: {},
upsertTableExportHistory: mockUpsertTableExportHistory,
});
const createMockProgressRunnerState = () => ({
open: true,
jobId: 'job-1',
title: '导出 SYS.test',
targetName: 'SYS.test',
format: 'XLSX',
startedAt: 1_000,
finishedAt: 0,
status: 'running',
stage: '正在写入文件',
current: 259_000,
total: 0,
totalRowsKnown: false,
filePath: '/Users/yangguofeng/Desktop/SYS.test.xlsx',
message: '',
});
let mockStoreState = createMockStoreState();
let mockProgressRunnerState = createMockProgressRunnerState();
vi.mock('../store', () => ({
useStore: (selector: (state: any) => any) => selector(mockStoreState),
}));
vi.mock('../../wailsjs/go/app/App', () => ({
ExportQueryWithOptions: vi.fn(),
ExportTableWithOptions: vi.fn(),
}));
vi.mock('./useExportProgressRunner', () => ({
useExportProgressRunner: () => ({
state: mockProgressRunnerState,
reset: vi.fn(),
runExportWithProgress: vi.fn(),
isRunning: ['start', 'running', 'finalizing'].includes(mockProgressRunnerState.status),
}),
}));
describe('TableExportWorkbench', () => {
beforeEach(() => {
mockUpsertTableExportHistory.mockReset();
mockStoreState = createMockStoreState();
mockProgressRunnerState = createMockProgressRunnerState();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('renders the redesigned workbench with a single main progress area and elapsed time', () => {
vi.spyOn(Date, 'now').mockReturnValue(61_000);
const markup = renderToStaticMarkup(
<TableExportWorkbench
tab={{
id: 'table-export-conn-1-SYS-SYS.test',
title: '导出 SYS.test',
type: 'table-export',
connectionId: 'conn-1',
dbName: 'SYS',
tableName: 'SYS.test',
objectType: 'table',
tableExportScopeOptions: [{ value: 'all', label: '全表数据' }],
tableExportInitialScope: 'all',
}}
/>,
);
expect(markup).toContain('data-export-workbench-layout="true"');
expect(markup).toContain('data-export-workbench-main-progress="true"');
expect(markup).toContain('data-export-progress-mode="indeterminate"');
expect(markup).toContain('导出耗时');
expect(markup).toContain('01:00');
expect(markup).toContain('当前任务');
expect(markup).toContain('最近任务');
expect(markup).toContain('正在写入文件');
expect(markup).toContain('暂不显示百分比');
expect(markup).toContain('/Users/yangguofeng/Desktop/SYS.test.xlsx');
});
it('renders persisted history when reopening the workbench without an active export job', () => {
mockProgressRunnerState = {
open: false,
jobId: '',
title: '',
targetName: '',
format: '',
startedAt: 0,
finishedAt: 0,
status: 'idle',
stage: '',
current: 0,
total: 0,
totalRowsKnown: false,
filePath: '',
message: '',
};
mockStoreState = {
...createMockStoreState(),
tableExportHistories: {
'conn-1::SYS::SYS.test': [
{
jobId: 'job-finished-1',
targetName: 'SYS.test',
startedAt: 1_000,
finishedAt: 61_000,
format: 'XLSX',
scope: 'all',
scopeLabel: '全表数据',
strategyLabel: '整表导出链路',
status: 'done',
stage: '导出完成',
current: 500_000,
total: 500_000,
totalRowsKnown: true,
filePath: '/Users/yangguofeng/Desktop/SYS.test.xlsx',
message: '',
},
],
},
};
const markup = renderToStaticMarkup(
<TableExportWorkbench
tab={{
id: 'table-export-conn-1-SYS-SYS.test',
title: '导出 SYS.test',
type: 'table-export',
connectionId: 'conn-1',
dbName: 'SYS',
tableName: 'SYS.test',
objectType: 'table',
tableExportScopeOptions: [{ value: 'all', label: '全表数据' }],
tableExportInitialScope: 'all',
}}
/>,
);
expect(markup).toContain('1 条记录');
expect(markup).toContain('导出完成');
expect(markup).toContain('/Users/yangguofeng/Desktop/SYS.test.xlsx');
});
it('keeps only one progress component in source and no longer uses top tabs', () => {
const source = readFileSync(new URL('./TableExportWorkbench.tsx', import.meta.url), 'utf8');
const progressMatches = source.match(/<ExportProgressBar\b/g) || [];
expect(progressMatches).toHaveLength(1);
expect(source).not.toContain('<Tabs');
expect(source).toContain('当前任务不在这里重复展示');
expect(source).toContain('导出耗时');
});
it('prefers backend startedAt over a placeholder history timestamp for the same job', () => {
const entry = buildTableExportHistoryEntry({
progressState: {
...createMockProgressRunnerState(),
startedAt: 8_000,
stage: '正在准备导出',
filePath: '/Users/yangguofeng/Desktop/SYS.test.xlsx',
},
existingEntry: {
jobId: 'job-1',
targetName: 'SYS.test',
startedAt: 0,
finishedAt: 0,
format: 'XLSX',
scope: 'all',
scopeLabel: '全表数据',
strategyLabel: '整表导出链路',
status: 'start',
stage: '等待选择导出文件',
current: 0,
total: 500_000,
totalRowsKnown: true,
filePath: '',
message: '',
},
fallbackTargetName: 'SYS.test',
fallbackFormat: 'XLSX',
scope: 'all',
scopeLabel: '全表数据',
strategyLabel: '整表导出链路',
});
expect(entry.startedAt).toBe(8_000);
expect(entry.filePath).toBe('/Users/yangguofeng/Desktop/SYS.test.xlsx');
});
});

View File

@@ -0,0 +1,728 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Alert, Button, Empty, InputNumber, Select, Typography } from 'antd';
import { ClockCircleOutlined, ExportOutlined, ReloadOutlined } from '@ant-design/icons';
import { ExportQueryWithOptions, ExportTableWithOptions } 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 { buildTableExportHistoryKey } from '../utils/tableExportTab';
import {
formatExportElapsed,
formatExportProgressRows,
resolveExportElapsedMs,
type ExportProgressStatus,
} from '../utils/exportProgress';
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[] = [];
const DEFAULT_SCOPE_OPTIONS: TableExportScopeOption[] = [
{ value: 'all', label: '全表数据', description: '后台重新查询整张表并导出全部数据。' },
];
const normalizeScopeOptions = (input: TabData['tableExportScopeOptions']): TableExportScopeOption[] => {
if (!Array.isArray(input) || input.length === 0) {
return DEFAULT_SCOPE_OPTIONS;
}
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 resolveObjectTypeLabel = (objectType?: TabData['objectType']): string => {
if (objectType === 'view') return '视图';
if (objectType === 'materialized-view') return '物化视图';
return '表';
};
const STATUS_META: Record<ExportProgressStatus, { label: string; border: string; bg: string; text: string }> = {
idle: { label: '待开始', border: 'rgba(148, 163, 184, 0.35)', bg: 'rgba(148, 163, 184, 0.12)', text: '#475467' },
start: { label: '准备中', border: 'rgba(59, 130, 246, 0.3)', bg: 'rgba(59, 130, 246, 0.12)', text: '#1d4ed8' },
running: { label: '执行中', border: 'rgba(16, 185, 129, 0.3)', bg: 'rgba(16, 185, 129, 0.14)', text: '#047857' },
finalizing: { label: '收尾中', border: 'rgba(249, 115, 22, 0.3)', bg: 'rgba(249, 115, 22, 0.12)', text: '#c2410c' },
done: { label: '已完成', border: 'rgba(34, 197, 94, 0.3)', bg: 'rgba(34, 197, 94, 0.14)', text: '#15803d' },
error: { label: '失败', border: 'rgba(239, 68, 68, 0.32)', bg: 'rgba(239, 68, 68, 0.12)', text: '#dc2626' },
};
const renderStatusPill = (status: ExportProgressStatus) => {
const meta = STATUS_META[status] || STATUS_META.idle;
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>
);
};
export const buildTableExportHistoryEntry = ({
progressState,
existingEntry,
fallbackTargetName,
fallbackFormat,
scope,
scopeLabel,
strategyLabel,
}: {
progressState: ExportProgressState;
existingEntry?: TableExportHistoryEntry;
fallbackTargetName: string;
fallbackFormat: string;
scope: TableExportScope;
scopeLabel: string;
strategyLabel: string;
}): TableExportHistoryEntry => ({
jobId: progressState.jobId,
targetName: progressState.targetName || fallbackTargetName || '未命名对象',
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 exportHistoryKey = useMemo(
() => buildTableExportHistoryKey(tab.connectionId, tab.dbName, tab.tableName),
[tab.connectionId, tab.dbName, tab.tableName],
);
const history = useStore((state) => state.tableExportHistories[exportHistoryKey] || EMPTY_HISTORY);
const upsertTableExportHistory = useStore((state) => state.upsertTableExportHistory);
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 connection = useMemo(
() => connections.find((item) => item.id === tab.connectionId),
[connections, tab.connectionId],
);
const connectionConfig = useMemo(
() => (connection ? normalizeConnectionConfig(connection) : null),
[connection],
);
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(() => {
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]);
const hostSummary = useMemo(
() => resolveConnectionHostSummary(connection?.config),
[connection?.config],
);
const activeScopeOption = useMemo(
() => scopeOptions.find((item) => item.value === scope),
[scope, scopeOptions],
);
const activeScopeLabel = activeScopeOption?.label || scope;
const activeScopeQuery = useMemo(
() => String(tab.tableExportQueryByScope?.[scope] || '').trim(),
[scope, tab.tableExportQueryByScope],
);
const activeScopeRowCount = useMemo(() => {
const raw = tab.tableExportRowCountByScope?.[scope];
return Number.isFinite(Number(raw)) && Number(raw) >= 0 ? Number(raw) : undefined;
}, [scope, tab.tableExportRowCountByScope]);
const totalRowsKnown = typeof activeScopeRowCount === 'number';
const exportStrategyLabel = scope === 'all' && !activeScopeQuery ? '整表导出链路' : '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: tab.tableName || '未命名对象',
fallbackFormat: String(format || '').toUpperCase(),
scope,
scopeLabel: activeScopeLabel,
strategyLabel: exportStrategyLabel,
});
upsertTableExportHistory(exportHistoryKey, entry);
}, [
activeScopeLabel,
exportHistoryKey,
format,
history,
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,
tab.tableName,
exportStrategyLabel,
upsertTableExportHistory,
]);
const canStart = !!connectionConfig && !!tab.tableName && !!scope && !activeScopeOption?.disabled && (scope === 'all' || !!activeScopeQuery);
const currentElapsedMs = useMemo(
() => resolveExportElapsedMs(progressState.startedAt, progressState.finishedAt, nowTick),
[nowTick, progressState.finishedAt, progressState.startedAt],
);
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 handleStartExport = async () => {
if (!connectionConfig) {
return;
}
const objectName = String(tab.tableName || '').trim();
if (!objectName) {
return;
}
await runExportWithProgress({
title: tab.title || `导出 ${objectName}`,
targetName: objectName,
format,
totalRows: activeScopeRowCount,
run: (jobId) => {
const options = {
format,
xlsxMaxRowsPerSheet,
jobId,
totalRowsHint: totalRowsKnown ? activeScopeRowCount : 0,
totalRowsKnown,
};
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,
);
},
});
};
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 }}></Title>
<div style={{ marginTop: 6, color: secondaryTextColor, fontSize: 13 }}>
</div>
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{[
`${resolveObjectTypeLabel(tab.objectType)} · ${tab.tableName || '-'}`,
`数据库 · ${tab.dbName || '-'}`,
`连接 · ${connection?.name || '-'}`,
`Host · ${hostSummary || '-'}`,
].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 }}></div>
<div style={{ display: 'grid', gridTemplateColumns: '84px minmax(0, 1fr)', rowGap: 10, columnGap: 12 }}>
<Text type="secondary"></Text>
<Text>{tab.tableName || '-'}</Text>
<Text type="secondary"></Text>
<Text>{resolveObjectTypeLabel(tab.objectType)}</Text>
<Text type="secondary"></Text>
<Text>{connection?.name || '-'}</Text>
<Text type="secondary"></Text>
<Text>{tab.dbName || '-'}</Text>
<Text type="secondary">Host</Text>
<Text>{hostSummary || '-'}</Text>
</div>
</div>
{!connectionConfig ? (
<Alert
type="warning"
showIcon
message="当前连接已不存在"
description="请先恢复连接配置,再执行该导出任务。"
/>
) : null}
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div>
<div style={{ marginBottom: 6, fontSize: 12, color: secondaryTextColor }}></div>
<Select
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 }}></div>
<Select
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 }}></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 }}>
XLSX {MAX_XLSX_ROWS_PER_SHEET.toLocaleString()}
</div>
</div>
) : null}
</div>
{scope !== 'all' && !activeScopeQuery ? (
<Alert
type="info"
showIcon
message="当前范围暂无法在导出工作台复现"
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"></Text>
<Text>{typeof activeScopeRowCount === 'number' ? activeScopeRowCount.toLocaleString() : '当前未预先统计'}</Text>
<Text type="secondary"></Text>
<Text>{exportStrategyLabel}</Text>
</div>
<div style={{ fontSize: 12, color: secondaryTextColor }}>
</div>
<Button
type="primary"
size="large"
icon={<ExportOutlined />}
disabled={!canStart}
loading={isRunning}
onClick={() => {
void handleStartExport();
}}
>
</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 }}></div>
{renderStatusPill(progressState.status)}
</div>
<Title level={5} style={{ margin: '10px 0 0', color: headingColor }}>
{progressState.title || `导出 ${tab.tableName || '未命名对象'}`}
</Title>
<div style={{ marginTop: 6, color: secondaryTextColor, fontSize: 13 }}>
{progressState.jobId
? `${progressState.targetName || tab.tableName || '-'} · ${currentScopeLabel} · ${progressState.format || String(format).toUpperCase()}`
: '开始导出后,这里会展示当前任务的唯一主进度。'}
</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 }}></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 }}></div>
<div style={{ color: headingColor, fontWeight: 600 }}>{formatDateTime(progressState.startedAt)}</div>
</div>
<div>
<div style={{ fontSize: 12, color: secondaryTextColor, marginBottom: 4 }}></div>
<div style={{ color: headingColor, fontWeight: 600 }}>{progressState.jobId ? currentScopeLabel : '-'}</div>
</div>
<div>
<div style={{ fontSize: 12, color: secondaryTextColor, marginBottom: 4 }}></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 }}></div>
<Text data-export-workbench-stage="true">
{progressState.stage || STATUS_META[progressState.status]?.label || '等待开始'}
</Text>
<div style={{ fontSize: 12, color: secondaryTextColor, margin: '12px 0 6px' }}></div>
<Text>{formatExportProgressRows(progressState.current, progressState.total, progressState.totalRowsKnown)}</Text>
{!progressState.totalRowsKnown && progressState.status !== 'done' && progressState.status !== 'error' ? (
<div style={{ marginTop: 6, fontSize: 12, color: secondaryTextColor }}>
</div>
) : null}
</div>
<div>
<div style={{ fontSize: 12, color: secondaryTextColor, marginBottom: 6 }}></div>
{progressState.filePath ? (
<Paragraph style={{ marginBottom: 0, wordBreak: 'break-all' }}>{progressState.filePath}</Paragraph>
) : (
<Text type="secondary"></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}></Button>
</div>
) : null}
</>
) : (
<div data-export-workbench-current-empty="true">
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="尚未开始导出"
/>
</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 }}></div>
<div style={{ marginTop: 4, fontSize: 12, color: secondaryTextColor }}>
</div>
</div>
<div style={{ color: secondaryTextColor, fontSize: 12 }}>
{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 || STATUS_META[entry.status].label}
</div>
<div style={{ marginTop: 4, fontSize: 12, color: secondaryTextColor }}>
{formatExportProgressRows(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"></Text>
<Text>{formatDateTime(entry.startedAt)}</Text>
<Text type="secondary"></Text>
<Text>{formatExportElapsed(resolveExportElapsedMs(entry.startedAt, entry.finishedAt, nowTick))}</Text>
<Text type="secondary"></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 }}>
</div>
)}
</section>
</div>
</div>
</div>
);
};
export default TableExportWorkbench;

View File

@@ -4,7 +4,7 @@ import { Input, Spin, Empty, Dropdown, message, Tooltip, Modal, Button } from 'a
import type { MenuProps } from 'antd';
import { TableOutlined, SearchOutlined, ReloadOutlined, SortAscendingOutlined, DatabaseOutlined, ConsoleSqlOutlined, EditOutlined, CopyOutlined, SaveOutlined, DeleteOutlined, ExportOutlined, AppstoreOutlined, UnorderedListOutlined, WarningOutlined } from '@ant-design/icons';
import { buildSidebarTablePinKey, useStore } from '../store';
import { DBGetTables, DBQuery, DBShowCreateTable, ExportTable, DropTable, RenameTable } from '../../wailsjs/go/app/App';
import { DBGetTables, DBQuery, DBShowCreateTable, ExportTableWithOptions, DropTable, RenameTable } from '../../wailsjs/go/app/App';
import type { TabData } from '../types';
import { useAutoFetchVisibility } from '../utils/autoFetchVisibility';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
@@ -24,7 +24,9 @@ import { normalizeOceanBaseProtocol } from '../utils/oceanBaseProtocol';
import { isMacLikePlatform } from '../utils/appearance';
import { getShortcutPlatform } from '../utils/shortcuts';
import { t } from '../i18n';
import { buildTableExportTab } from '../utils/tableExportTab';
import { V2TableContextMenuView, type V2TableContextMenuActionKey } from './V2TableContextMenu';
import { useExportProgressDialog } from './ExportProgressModal';
interface TableOverviewProps {
tab: TabData;
@@ -264,6 +266,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
const [sortOrder, setSortOrder] = useState<SortOrder>('asc');
const [viewMode, setViewMode] = useState<ViewMode>(isV2Ui ? 'card' : 'list');
const [v2ContextMenu, setV2ContextMenu] = useState<OverviewContextMenuState | null>(null);
const { exportProgressModal, runExportWithProgress } = useExportProgressDialog();
const v2ContextMenuPortalRef = useRef<HTMLDivElement | null>(null);
const [visibleTableLimit, setVisibleTableLimit] = useState(TABLE_OVERVIEW_RENDER_BATCH_SIZE);
const deferredSearchText = useDeferredValue(searchText);
@@ -535,21 +538,44 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
}
}, []);
const handleExport = useCallback(async (tableName: string, format: string) => {
const handleExport = useCallback(async (tableName: string, options: { format: string; xlsxMaxRowsPerSheet?: number }, totalRows?: number) => {
const config = buildConfig();
if (!config) return;
const hide = message.loading(`正在导出 ${tableName}${format.toUpperCase()}...`, 0);
const res = await ExportTable(buildRpcConnectionConfig(config) as any, tab.dbName || '', tableName, format);
hide();
if (res.success) {
message.success('导出成功');
} else if (res.message !== '已取消') {
message.error('导出失败: ' + res.message);
}
}, [buildConfig, tab.dbName]);
const totalRowsKnown = Number.isFinite(totalRows) && Number(totalRows) >= 0;
await runExportWithProgress({
title: `导出 ${tableName}`,
targetName: tableName,
format: options.format,
totalRows: totalRowsKnown ? Number(totalRows) : undefined,
run: (jobId) => ExportTableWithOptions(
buildRpcConnectionConfig(config) as any,
tab.dbName || '',
tableName,
{
...options,
jobId,
totalRowsHint: totalRowsKnown ? Number(totalRows) : 0,
totalRowsKnown,
} as any,
),
});
}, [buildConfig, runExportWithProgress, tab.dbName]);
const openExportDialog = useCallback(async (tableName: string, totalRows?: number) => {
addTab(buildTableExportTab({
connectionId: tab.connectionId,
dbName: tab.dbName,
tableName,
title: `导出 ${tableName}`,
objectType: 'table',
rowCountByScope: Number.isFinite(Number(totalRows)) && Number(totalRows) >= 0
? { all: Math.trunc(Number(totalRows)) }
: undefined,
}));
}, [addTab, tab.connectionId, tab.dbName]);
const handleCopyTableAsInsert = useCallback(async (tableName: string) => {
await handleExport(tableName, 'sql');
await handleExport(tableName, { format: 'sql' });
}, [handleExport]);
const handleDeleteTable = useCallback((tableName: string) => {
@@ -813,19 +839,13 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
openCreateStarRocksRollup(tableName);
return;
case 'backup-table':
void handleExport(tableName, 'sql');
void handleExport(tableName, { format: 'sql' });
return;
case 'refresh-stats':
void loadData();
return;
case 'export-xlsx':
void handleExport(tableName, 'xlsx');
return;
case 'export-csv':
void handleExport(tableName, 'csv');
return;
case 'export-json':
void handleExport(tableName, 'json');
case 'export-data':
void openExportDialog(tableName, tables.find((item) => item.name === tableName)?.rows);
return;
case 'ai-explain':
void injectTablePromptToAI(tableName, 'explain');
@@ -850,6 +870,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
handleExport,
handleRenameTable,
handleTableDataDangerAction,
openExportDialog,
injectTablePromptToAI,
loadData,
openCreateStarRocksRollup,
@@ -858,6 +879,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
openTable,
openTableDdl,
openTableInER,
tables,
toggleOverviewTablePinned,
]);
@@ -887,7 +909,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
{ key: 'design-table', label: supportsDesignWrite ? '设计表' : '表结构', icon: <EditOutlined />, onClick: () => openDesign(table.name) },
{ key: 'copy-table-name', label: '复制表名', icon: <CopyOutlined />, onClick: () => handleCopyTableName(table.name) },
{ key: 'copy-structure', label: '复制表结构', icon: <CopyOutlined />, onClick: () => handleCopyStructure(table.name) },
{ key: 'backup-table', label: '备份表 (SQL)', icon: <SaveOutlined />, onClick: () => handleExport(table.name, 'sql') },
{ key: 'backup-table', label: '备份表 (SQL)', icon: <SaveOutlined />, onClick: () => handleExport(table.name, { format: 'sql' }) },
{ key: 'rename-table', label: '重命名表', icon: <EditOutlined />, onClick: () => handleRenameTable(table.name) },
{ key: 'danger-zone', label: '危险操作', icon: <WarningOutlined />, children: [
...(allowTruncate ? [{ key: 'truncate-table', label: '截断表', danger: true, onClick: () => handleTableDataDangerAction(table.name, 'truncate') }] : []),
@@ -895,13 +917,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
{ key: 'drop-table', label: '删除表', icon: <DeleteOutlined />, danger: true, onClick: () => handleDeleteTable(table.name) },
]},
{ type: 'divider' },
{ key: 'export', label: '导出表数据', icon: <ExportOutlined />, children: [
{ key: 'export-csv', label: '导出 CSV', onClick: () => handleExport(table.name, 'csv') },
{ key: 'export-xlsx', label: '导出 Excel (XLSX)', onClick: () => handleExport(table.name, 'xlsx') },
{ key: 'export-json', label: '导出 JSON', onClick: () => handleExport(table.name, 'json') },
{ key: 'export-md', label: '导出 Markdown', onClick: () => handleExport(table.name, 'md') },
{ key: 'export-html', label: '导出 HTML', onClick: () => handleExport(table.name, 'html') },
]},
{ key: 'export', label: '导出表数据', icon: <ExportOutlined />, onClick: () => openExportDialog(table.name, table.rows) },
], [
allowTruncate,
handleCopyStructure,
@@ -910,6 +926,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
handleExport,
handleRenameTable,
handleTableDataDangerAction,
openExportDialog,
openDesign,
openQueryForTable,
supportsDesignWrite,
@@ -1128,6 +1145,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
return (
<div className={isV2Ui ? 'gn-v2-table-overview' : undefined} style={{ display: 'flex', flexDirection: 'column', height: '100%', background: containerBg, overflow: 'hidden' }}>
{exportProgressModal}
{/* Toolbar */}
<div className={isV2Ui ? 'gn-v2-table-overview-header' : undefined} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '12px 16px', flexShrink: 0 }}>
<span className={isV2Ui ? 'gn-v2-table-overview-icon' : undefined}>

View File

@@ -52,9 +52,7 @@ export type V2TableContextMenuActionKey =
| 'new-rollup'
| 'backup-table'
| 'refresh-stats'
| 'export-xlsx'
| 'export-csv'
| 'export-json'
| 'export-data'
| 'ai-explain'
| 'ai-generate-query'
| 'truncate-table'
@@ -248,9 +246,7 @@ export const V2TableContextMenuView: React.FC<{
<div className="gn-v2-context-menu-section-title">{t('sidebar.menu.export_table_data')}</div>
{renderItems([
{ action: 'export-xlsx', icon: <ExportOutlined />, title: t('sidebar.v2_table_menu.item_with_suffix', { label: 'Excel', suffix: '.xlsx' }) },
{ action: 'export-csv', icon: <ExportOutlined />, title: t('sidebar.v2_table_menu.item_with_suffix', { label: 'CSV', suffix: '.csv' }) },
{ action: 'export-json', icon: <ExportOutlined />, title: t('sidebar.v2_table_menu.item_with_suffix', { label: 'JSON', suffix: '.json' }) },
{ action: 'export-data', icon: <ExportOutlined />, title: t('sidebar.v2_table_menu.open_export_workbench') },
])}
<div className="gn-v2-context-menu-divider" />

View File

@@ -0,0 +1,129 @@
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useExportProgressRunner } from './useExportProgressRunner';
const runtimeApi = vi.hoisted(() => {
let exportProgressHandler: ((event: any) => void) | null = null;
return {
EventsOn: vi.fn((eventName: string, handler: (event: any) => void) => {
if (eventName === 'export:progress') {
exportProgressHandler = handler;
}
return () => {
if (exportProgressHandler === handler) {
exportProgressHandler = null;
}
};
}),
emitExportProgress: (event: any) => {
exportProgressHandler?.(event);
},
reset: () => {
exportProgressHandler = null;
},
};
});
const messageApi = vi.hoisted(() => ({
warning: vi.fn(),
success: vi.fn(),
error: vi.fn(),
}));
vi.mock('../../wailsjs/runtime/runtime', () => runtimeApi);
vi.mock('antd', () => ({
message: messageApi,
}));
describe('useExportProgressRunner', () => {
let runner: ReturnType<typeof useExportProgressRunner> | null = null;
let renderer: ReactTestRenderer | null = null;
let now = 1_000;
const renderRunner = () => {
const Harness = () => {
runner = useExportProgressRunner({ showToast: false });
return null;
};
act(() => {
renderer = create(<Harness />);
});
};
beforeEach(() => {
runner = null;
renderer = null;
now = 1_000;
runtimeApi.reset();
runtimeApi.EventsOn.mockClear();
messageApi.warning.mockReset();
messageApi.success.mockReset();
messageApi.error.mockReset();
vi.spyOn(Date, 'now').mockImplementation(() => now);
});
afterEach(() => {
act(() => {
renderer?.unmount();
});
vi.restoreAllMocks();
});
it('starts elapsed timing only after backend progress begins and path is selected', async () => {
renderRunner();
let resolveRun!: (value: { success: boolean; message: string }) => void;
const pendingRun = new Promise<{ success: boolean; message: string }>((resolve) => {
resolveRun = resolve;
});
let runPromise: Promise<{ success: boolean; message: string } | null> | null = null;
await act(async () => {
runPromise = runner?.runExportWithProgress({
title: '导出 SYS.test',
targetName: 'SYS.test',
format: 'xlsx',
totalRows: 500_000,
run: async () => pendingRun,
}) || null;
await Promise.resolve();
});
expect(runner?.state.status).toBe('start');
expect(runner?.state.stage).toBe('等待选择导出文件');
expect(runner?.state.startedAt).toBe(0);
expect(runner?.state.filePath).toBe('');
const jobId = runner?.state.jobId || '';
expect(jobId).not.toBe('');
now = 8_000;
act(() => {
runtimeApi.emitExportProgress({
jobId,
status: 'start',
stage: '正在准备导出',
filePath: '/Users/yangguofeng/Desktop/SYS.test.xlsx',
});
});
expect(runner?.state.status).toBe('start');
expect(runner?.state.stage).toBe('正在准备导出');
expect(runner?.state.startedAt).toBe(8_000);
expect(runner?.state.filePath).toBe('/Users/yangguofeng/Desktop/SYS.test.xlsx');
now = 13_000;
await act(async () => {
resolveRun({ success: true, message: '导出完成' });
await runPromise;
});
expect(runner?.state.status).toBe('done');
expect(runner?.state.startedAt).toBe(8_000);
expect(runner?.state.finishedAt).toBe(13_000);
});
});

View File

@@ -0,0 +1,235 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { message } from 'antd';
import { EventsOn } from '../../wailsjs/runtime/runtime';
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 buildExportJobId = (): string => `export-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
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 nextTotalRowsKnown = typeof event.totalRowsKnown === 'boolean' ? event.totalRowsKnown : prev.totalRowsKnown;
const nextTotal = nextTotalRowsKnown
? normalizeCount(typeof event.total === 'number' ? event.total : prev.total)
: prev.total;
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('当前已有导出任务正在执行,请等待完成后再发起新的导出');
}
return null;
}
const jobId = buildExportJobId();
const totalRowsKnown = Number.isFinite(runOptions.totalRows) && Number(runOptions.totalRows) >= 0;
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: '等待选择导出文件',
current: 0,
total: totalRowsKnown ? normalizeCount(runOptions.totalRows) : 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 || '导出完成',
current: prev.totalRowsKnown ? Math.max(prev.current, prev.total) : prev.current,
message: '',
};
});
if (showToast) {
void message.success('导出成功');
}
} else if (result.message !== '已取消') {
setState((prev) => {
if (prev.jobId !== jobId) {
return prev;
}
return {
...prev,
open: true,
status: 'error',
finishedAt: prev.finishedAt || Date.now(),
stage: prev.stage || '导出失败',
message: result.message,
};
});
if (showToast) {
void message.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 || '导出失败',
message: errorMessage,
};
});
if (showToast) {
void message.error(`导出失败: ${errorMessage}`);
}
throw error;
}
}, [reset, showToast, state.open, state.status]);
return {
state,
reset,
runExportWithProgress,
isRunning: state.status === 'start' || state.status === 'running' || state.status === 'finalizing',
};
}

View File

@@ -1110,6 +1110,79 @@ describe('store appearance persistence', () => {
});
});
it('reuses the same table-export tab for the same connection and table identity', async () => {
const { useStore } = await importStore();
useStore.getState().addTab({
id: 'table-export-conn-1-main-users',
title: '导出 users',
type: 'table-export',
connectionId: 'conn-1',
dbName: 'main',
tableName: 'users',
initialTab: 'config',
});
useStore.getState().addTab({
id: 'another-id-that-should-collapse',
title: '导出 users',
type: 'table-export',
connectionId: 'conn-1',
dbName: 'main',
tableName: 'users',
initialTab: 'progress',
});
expect(useStore.getState().tabs).toHaveLength(1);
expect(useStore.getState().tabs[0]).toEqual(expect.objectContaining({
id: 'table-export-conn-1-main-users',
type: 'table-export',
initialTab: 'progress',
}));
expect(useStore.getState().activeTabId).toBe('table-export-conn-1-main-users');
});
it('persists table export history across store reloads', async () => {
const { useStore } = await importStore();
useStore.getState().upsertTableExportHistory('conn-1::main::users', {
jobId: 'job-1',
targetName: 'users',
startedAt: 1_000,
finishedAt: 61_000,
format: 'XLSX',
scope: 'all',
scopeLabel: '全表数据',
strategyLabel: '整表导出链路',
status: 'done',
stage: '导出完成',
current: 500_000,
total: 500_000,
totalRowsKnown: true,
filePath: '/tmp/users.xlsx',
message: '',
});
const persisted = JSON.parse(storage.getItem('lite-db-storage') || '{}');
expect(persisted.state.tableExportHistories['conn-1::main::users']).toEqual([
expect.objectContaining({
jobId: 'job-1',
status: 'done',
filePath: '/tmp/users.xlsx',
}),
]);
vi.resetModules();
const reloaded = await importStore();
expect(reloaded.useStore.getState().tableExportHistories['conn-1::main::users']).toEqual([
expect.objectContaining({
jobId: 'job-1',
current: 500_000,
total: 500_000,
status: 'done',
}),
]);
});
it('only restores persisted query tabs with useful SQL state', async () => {
storage.setItem('lite-db-storage', JSON.stringify({
state: {

View File

@@ -19,6 +19,7 @@ import {
JVMDiagnosticCommandDraft,
JVMDiagnosticEventChunk,
SqlSnippet,
TableExportHistoryEntry,
} from "./types";
import {
ShortcutAction,
@@ -125,7 +126,7 @@ const DEFAULT_TIMEOUT_SECONDS = 30;
const MAX_TIMEOUT_SECONDS = 3600;
const DEFAULT_DIAGNOSTIC_TIMEOUT_SECONDS = 15;
const MAX_DIAGNOSTIC_TIMEOUT_SECONDS = 300;
const PERSIST_VERSION = 11;
const PERSIST_VERSION = 12;
const PERSIST_STORAGE_KEY = "lite-db-storage";
const PERSIST_WRITE_DEBOUNCE_MS = 160;
const MAX_PERSISTED_QUERY_TABS = 20;
@@ -134,6 +135,8 @@ const MAX_SQL_LOGS = 1000;
const MAX_PERSISTED_SQL_LOGS = 200;
const MAX_PERSISTED_SQL_LOG_LENGTH = 100 * 1024;
const MAX_PERSISTED_SQL_LOG_MESSAGE_LENGTH = 2 * 1024;
const MAX_TABLE_EXPORT_HISTORY_PER_TARGET = 20;
const MAX_TABLE_EXPORT_HISTORY_TARGETS = 200;
const DEFAULT_CONNECTION_TYPE = "mysql";
const DEFAULT_JVM_PORT = 9010;
const DEFAULT_LANGUAGE_PREFERENCE: LanguagePreference = "system";
@@ -1217,6 +1220,7 @@ interface AppState {
shortcutOptions: ShortcutOptions;
sqlSnippets: SqlSnippet[];
sqlLogs: SqlLog[];
tableExportHistories: Record<string, TableExportHistoryEntry[]>;
tableAccessCount: Record<string, number>;
tableSortPreference: Record<string, "name" | "frequency">;
tableColumnOrders: Record<string, string[]>;
@@ -1348,6 +1352,10 @@ interface AppState {
addSqlLog: (log: SqlLog) => void;
clearSqlLogs: () => void;
upsertTableExportHistory: (
historyKey: string,
entry: TableExportHistoryEntry,
) => void;
recordTableAccess: (
connectionId: string,
@@ -1490,6 +1498,95 @@ const sanitizeExternalSQLDirectories = (
return result;
};
const sanitizeTableExportHistoryEntry = (
value: unknown,
): TableExportHistoryEntry | null => {
if (!value || typeof value !== "object") {
return null;
}
const raw = value as Record<string, unknown>;
const jobId = toTrimmedString(raw.jobId);
if (!jobId) {
return null;
}
const normalizeCount = (input: unknown): number => {
const next = Number(input);
if (!Number.isFinite(next) || next < 0) {
return 0;
}
return Math.trunc(next);
};
const normalizeTimestamp = (input: unknown): number => {
const next = Number(input);
if (!Number.isFinite(next) || next <= 0) {
return 0;
}
return Math.trunc(next);
};
const statusRaw = toTrimmedString(raw.status).toLowerCase();
const status: TableExportHistoryEntry["status"] =
statusRaw === "start" ||
statusRaw === "running" ||
statusRaw === "finalizing" ||
statusRaw === "done" ||
statusRaw === "error"
? statusRaw
: "idle";
return {
jobId,
targetName: toTrimmedString(raw.targetName, "未命名对象") || "未命名对象",
startedAt: normalizeTimestamp(raw.startedAt),
finishedAt: normalizeTimestamp(raw.finishedAt),
format: toTrimmedString(raw.format).slice(0, 32),
scope: toTrimmedString(raw.scope).slice(0, 64),
scopeLabel: toTrimmedString(raw.scopeLabel).slice(0, 128),
strategyLabel: toTrimmedString(raw.strategyLabel).slice(0, 128),
status,
stage: toTrimmedString(raw.stage).slice(0, 256),
current: normalizeCount(raw.current),
total: normalizeCount(raw.total),
totalRowsKnown: raw.totalRowsKnown === true,
filePath: toTrimmedString(raw.filePath).slice(0, MAX_URI_LENGTH),
message: toTrimmedString(raw.message).slice(0, MAX_PERSISTED_SQL_LOG_MESSAGE_LENGTH),
};
};
const sanitizeTableExportHistories = (
value: unknown,
): Record<string, TableExportHistoryEntry[]> => {
if (!value || typeof value !== "object") {
return {};
}
const raw = value as Record<string, unknown>;
const entries = Object.entries(raw)
.filter(([key, history]) => toTrimmedString(key) && Array.isArray(history))
.slice(0, MAX_TABLE_EXPORT_HISTORY_TARGETS);
const result: Record<string, TableExportHistoryEntry[]> = {};
entries.forEach(([key, history]) => {
const seenJobIds = new Set<string>();
const sanitizedHistory = (history as unknown[])
.map((entry) => sanitizeTableExportHistoryEntry(entry))
.filter((entry): entry is TableExportHistoryEntry => !!entry)
.filter((entry) => {
if (seenJobIds.has(entry.jobId)) {
return false;
}
seenJobIds.add(entry.jobId);
return true;
})
.sort((a, b) => {
const timeA = a.finishedAt || a.startedAt || 0;
const timeB = b.finishedAt || b.startedAt || 0;
return timeB - timeA;
})
.slice(0, MAX_TABLE_EXPORT_HISTORY_PER_TARGET);
if (sanitizedHistory.length > 0) {
result[toTrimmedString(key)] = sanitizedHistory;
}
});
return result;
};
const sanitizeQueryTabs = (value: unknown): TabData[] => {
if (!Array.isArray(value)) return [];
const result: TabData[] = [];
@@ -2151,6 +2248,7 @@ export const useStore = create<AppState>()(
shortcutOptions: cloneShortcutOptions(DEFAULT_SHORTCUT_OPTIONS),
sqlSnippets: DEFAULT_SQL_SNIPPETS,
sqlLogs: [],
tableExportHistories: {},
tableAccessCount: {},
tableSortPreference: {},
tableColumnOrders: {},
@@ -2497,9 +2595,13 @@ export const useStore = create<AppState>()(
),
};
}
// 语义去重:对 table/design 类型按 connectionId+dbName+tableName 匹配已有 Tab
// 语义去重:对表相关标签页按 connectionId+dbName+tableName 匹配已有 Tab
if (
(incomingTab.type === "table" || incomingTab.type === "design") &&
(
incomingTab.type === "table" ||
incomingTab.type === "design" ||
incomingTab.type === "table-export"
) &&
incomingTab.tableName &&
incomingTab.connectionId &&
incomingTab.dbName
@@ -3012,6 +3114,39 @@ export const useStore = create<AppState>()(
addSqlLog: (log) =>
set((state) => ({ sqlLogs: sanitizeSqlLogs([log, ...state.sqlLogs], MAX_SQL_LOGS) })),
clearSqlLogs: () => set({ sqlLogs: [] }),
upsertTableExportHistory: (historyKey, entry) =>
set((state) => {
const safeHistoryKey = toTrimmedString(historyKey);
const safeEntry = sanitizeTableExportHistoryEntry(entry);
if (!safeHistoryKey || !safeEntry) {
return state;
}
const existingEntries = state.tableExportHistories[safeHistoryKey] || [];
const existingIndex = existingEntries.findIndex(
(item) => item.jobId === safeEntry.jobId,
);
const nextEntries =
existingIndex >= 0
? existingEntries.map((item, index) =>
index === existingIndex ? { ...item, ...safeEntry } : item,
)
: [safeEntry, ...existingEntries];
const trimmedEntries = nextEntries.slice(0, MAX_TABLE_EXPORT_HISTORY_PER_TARGET);
const unchanged =
existingEntries.length === trimmedEntries.length &&
existingEntries.every((item, index) =>
JSON.stringify(item) === JSON.stringify(trimmedEntries[index]),
);
if (unchanged) {
return state;
}
return {
tableExportHistories: {
...state.tableExportHistories,
[safeHistoryKey]: trimmedEntries,
},
};
}),
recordTableAccess: (connectionId, dbName, tableName) =>
set((state) => {
@@ -3375,6 +3510,9 @@ export const useStore = create<AppState>()(
state.shortcutOptions,
);
nextState.sqlLogs = sanitizeSqlLogs(state.sqlLogs);
nextState.tableExportHistories = sanitizeTableExportHistories(
state.tableExportHistories,
);
const existingSnippets = sanitizeSqlSnippets(state.sqlSnippets);
const existingSnippetIds = new Set(existingSnippets.map((s) => s.id));
const missingSnippets = DEFAULT_SQL_SNIPPETS.filter(
@@ -3517,6 +3655,9 @@ export const useStore = create<AppState>()(
sqlEditorTransactionOptions: state.sqlEditorTransactionOptions,
shortcutOptions: resolveShortcutOptionsForPersistence(state.shortcutOptions),
sqlLogs: sanitizeSqlLogs(state.sqlLogs),
tableExportHistories: sanitizeTableExportHistories(
state.tableExportHistories,
),
sqlSnippets: state.sqlSnippets,
tableAccessCount: state.tableAccessCount,
tableSortPreference: state.tableSortPreference,

View File

@@ -392,6 +392,41 @@ export interface TriggerDefinition {
statement: string;
}
export type TableExportScope = "selected" | "page" | "all" | "filteredAll";
export interface TableExportScopeOption {
value: TableExportScope;
label: string;
description?: string;
disabled?: boolean;
}
export type TableExportHistoryStatus =
| "idle"
| "start"
| "running"
| "finalizing"
| "done"
| "error";
export interface TableExportHistoryEntry {
jobId: string;
targetName: string;
startedAt: number;
finishedAt: number;
format: string;
scope: string;
scopeLabel: string;
strategyLabel: string;
status: TableExportHistoryStatus;
stage: string;
current: number;
total: number;
totalRowsKnown: boolean;
filePath: string;
message: string;
}
export interface TabData {
id: string;
title: string;
@@ -407,6 +442,7 @@ export interface TabData {
| "event-def"
| "routine-def"
| "table-overview"
| "table-export"
| "jvm-overview"
| "jvm-resource"
| "jvm-audit"
@@ -436,6 +472,10 @@ export interface TabData {
sidebarLocateKey?: string; // Precise sidebar tree key for locating an object node
savedQueryId?: string; // Saved query identity for quick-save behavior
objectType?: 'table' | 'view' | 'materialized-view'; // Table-like object type for shared viewers
tableExportScopeOptions?: TableExportScopeOption[];
tableExportInitialScope?: TableExportScope;
tableExportQueryByScope?: Partial<Record<TableExportScope, string>>;
tableExportRowCountByScope?: Partial<Record<TableExportScope, number>>;
formatRestoreSnapshot?: {
query: string;
createdAt: number;

View File

@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest';
import {
formatExportElapsed,
formatExportProgressRows,
resolveExportElapsedMs,
resolveExportProgressPercent,
shouldUseExactExportProgress,
shouldUseIndeterminateExportProgress,
} from './exportProgress';
describe('exportProgress', () => {
it('uses actual percent when total row count is known', () => {
expect(resolveExportProgressPercent('running', 25, 100, true)).toBe(25);
});
it('does not fabricate percentages when total row count is unknown', () => {
expect(resolveExportProgressPercent('running', 5000, 0, false)).toBe(0);
expect(resolveExportProgressPercent('finalizing', 5000, 0, false)).toBe(0);
expect(shouldUseExactExportProgress('running', 0, false)).toBe(false);
expect(shouldUseIndeterminateExportProgress('running', false)).toBe(true);
});
it('formats row summary for known and unknown totals', () => {
expect(formatExportProgressRows(12345, 0, false)).toBe('已写入 12,345 行');
expect(formatExportProgressRows(12345, 880000, true)).toBe('已写入 12,345 / 880,000 行');
});
it('resolves and formats elapsed export duration', () => {
expect(resolveExportElapsedMs(1000, 91_000)).toBe(90_000);
expect(resolveExportElapsedMs(1000, 0, 31_500)).toBe(30_500);
expect(formatExportElapsed(30_500)).toBe('00:30');
expect(formatExportElapsed(3_723_000)).toBe('01:02:03');
});
});

View File

@@ -0,0 +1,91 @@
export type ExportProgressStatus = 'idle' | 'start' | 'running' | 'finalizing' | 'done' | 'error';
const clampPercent = (value: number): number => {
if (!Number.isFinite(value)) return 0;
if (value <= 0) return 0;
if (value >= 100) return 100;
return value;
};
export const shouldUseExactExportProgress = (
status: ExportProgressStatus,
total: number,
totalRowsKnown: boolean,
): boolean => {
const normalizedTotal = Number.isFinite(total) ? Math.max(0, Math.trunc(total)) : 0;
if (totalRowsKnown && normalizedTotal > 0) {
return true;
}
if ((status === 'done' || status === 'error') && totalRowsKnown) {
return true;
}
return false;
};
export const shouldUseIndeterminateExportProgress = (
status: ExportProgressStatus,
totalRowsKnown: boolean,
): boolean => !totalRowsKnown && status !== 'idle' && status !== 'done' && status !== 'error';
export const resolveExportProgressPercent = (
status: ExportProgressStatus,
current: number,
total: number,
totalRowsKnown: boolean,
): number => {
const normalizedCurrent = Number.isFinite(current) ? Math.max(0, current) : 0;
const normalizedTotal = Number.isFinite(total) ? Math.max(0, total) : 0;
if (totalRowsKnown && normalizedTotal > 0) {
return clampPercent((normalizedCurrent / normalizedTotal) * 100);
}
if ((status === 'done' || status === 'error') && (totalRowsKnown || normalizedCurrent >= 0)) {
return 100;
}
return 0;
};
export const formatExportProgressRows = (
current: number,
total: number,
totalRowsKnown: boolean,
): string => {
const formatter = new Intl.NumberFormat('zh-CN');
const safeCurrent = formatter.format(Math.max(0, Math.trunc(Number(current) || 0)));
if (!totalRowsKnown) {
return `已写入 ${safeCurrent}`;
}
const safeTotal = formatter.format(Math.max(0, Math.trunc(Number(total) || 0)));
return `已写入 ${safeCurrent} / ${safeTotal}`;
};
export const resolveExportElapsedMs = (
startedAt: number,
finishedAt = 0,
now = Date.now(),
): number => {
const safeStartedAt = Number(startedAt);
if (!Number.isFinite(safeStartedAt) || safeStartedAt <= 0) {
return 0;
}
const safeFinishedAt = Number(finishedAt);
const endAt = Number.isFinite(safeFinishedAt) && safeFinishedAt > 0
? safeFinishedAt
: Number(now);
if (!Number.isFinite(endAt) || endAt <= safeStartedAt) {
return 0;
}
return Math.max(0, Math.trunc(endAt - safeStartedAt));
};
const padTimePart = (value: number): string => String(Math.max(0, Math.trunc(value))).padStart(2, '0');
export const formatExportElapsed = (elapsedMs: number): string => {
const totalSeconds = Math.max(0, Math.trunc(Number(elapsedMs) / 1000));
const hours = Math.trunc(totalSeconds / 3600);
const minutes = Math.trunc((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
if (hours > 0) {
return `${padTimePart(hours)}:${padTimePart(minutes)}:${padTimePart(seconds)}`;
}
return `${padTimePart(minutes)}:${padTimePart(seconds)}`;
};

View File

@@ -75,6 +75,19 @@ describe('tabDisplay', () => {
expect(buildTabDisplayTitle(tableTab, redisConnection)).toBe('[订单缓存] orders');
});
it('keeps table export tabs on the same connection prefix strategy', () => {
const exportTab: TabData = {
id: 'table-export-1',
title: '导出 public.orders',
type: 'table-export',
connectionId: 'redis-1',
dbName: 'app',
tableName: 'public.orders',
};
expect(buildTabDisplayTitle(exportTab, redisConnection)).toBe('[订单缓存] 导出 orders');
});
it('hides schema prefixes from schema-qualified table tab labels', () => {
const connection: SavedConnection = {
id: 'kingbase-1',

View File

@@ -440,6 +440,9 @@ const buildCompactObjectTabTitle = (tab: TabData): string => {
if (tab.type === 'table-overview') {
return stripSchemaFromTableOverviewTitle(tab.title);
}
if (tab.type === 'table-export') {
return replaceTitleObjectLabel(tab.title, tab.tableName);
}
if (tab.type === 'view-def') {
return replaceTitleObjectLabel(tab.title, tab.viewName);
}
@@ -460,6 +463,7 @@ export const getTabDisplayKindLabel = (tab: TabData): string => {
if (tab.type === 'table') return 'TABLE';
if (tab.type === 'design') return 'DESIGN';
if (tab.type === 'table-overview') return 'DB';
if (tab.type === 'table-export') return 'EXPORT';
if (tab.type.startsWith('redis')) return 'REDIS';
if (tab.type.startsWith('jvm')) return 'JVM';
if (tab.type === 'trigger') return 'TRG';
@@ -590,7 +594,12 @@ export const buildTabDisplayTitle = (
}
const baseTitle = buildCompactObjectTabTitle(tab);
if (tab.type !== 'table' && tab.type !== 'design' && tab.type !== 'table-overview') {
if (
tab.type !== 'table' &&
tab.type !== 'design' &&
tab.type !== 'table-overview' &&
tab.type !== 'table-export'
) {
return baseTitle;
}
if (!connectionName) {

View File

@@ -0,0 +1,63 @@
import { describe, expect, it } from 'vitest';
import {
buildTableExportHistoryKey,
buildTableExportTab,
DEFAULT_TABLE_EXPORT_SCOPE_OPTION,
} from './tableExportTab';
describe('tableExportTab', () => {
it('builds a stable history key for persisted export records', () => {
expect(buildTableExportHistoryKey(' conn-1 ', ' app ', ' public.orders ')).toBe('conn-1::app::public.orders');
});
it('builds a stable table export tab with normalized defaults', () => {
const tab = buildTableExportTab({
connectionId: 'conn-1',
dbName: 'app',
tableName: 'public.orders',
});
expect(tab.id).toBe('table-export-conn-1-app-public.orders');
expect(tab.type).toBe('table-export');
expect(tab.title).toBe('导出 public.orders');
expect(tab.tableExportScopeOptions).toEqual([DEFAULT_TABLE_EXPORT_SCOPE_OPTION]);
expect(tab.tableExportInitialScope).toBe('all');
expect(tab.tableExportQueryByScope).toBeUndefined();
expect(tab.tableExportRowCountByScope).toBeUndefined();
});
it('deduplicates scope options and sanitizes scope payloads', () => {
const tab = buildTableExportTab({
connectionId: 'conn-1',
dbName: 'app',
tableName: 'orders',
scopeOptions: [
{ value: 'filteredAll', label: '筛选结果', description: 'desc' },
{ value: 'filteredAll', label: '重复项应移除' },
{ value: 'page', label: '' },
],
initialScope: 'filteredAll',
queryByScope: {
filteredAll: ' select * from orders where status = 1 ',
page: ' ',
},
rowCountByScope: {
filteredAll: 42.8,
page: -1,
},
});
expect(tab.tableExportScopeOptions).toEqual([
{ value: 'filteredAll', label: '筛选结果', description: 'desc', disabled: false },
{ value: 'page', label: 'page', description: undefined, disabled: false },
]);
expect(tab.tableExportInitialScope).toBe('filteredAll');
expect(tab.tableExportQueryByScope).toEqual({
filteredAll: 'select * from orders where status = 1',
});
expect(tab.tableExportRowCountByScope).toEqual({
filteredAll: 42,
});
});
});

View File

@@ -0,0 +1,123 @@
import type { TabData, TableExportScope, TableExportScopeOption } from '../types';
export const DEFAULT_TABLE_EXPORT_SCOPE_OPTION: TableExportScopeOption = {
value: 'all',
label: '全表数据',
description: '后台重新查询整张表并导出全部数据。',
};
export const buildTableExportHistoryKey = (
connectionId: string,
dbName: string | undefined,
tableName: string | undefined,
): string => {
return [
String(connectionId || '').trim(),
String(dbName || '').trim(),
String(tableName || '').trim(),
].join('::');
};
type BuildTableExportTabInput = {
connectionId: string;
dbName?: string;
tableName: string;
title?: string;
objectType?: TabData['objectType'];
schemaName?: string;
sidebarLocateKey?: string;
scopeOptions?: TableExportScopeOption[];
initialScope?: TableExportScope;
queryByScope?: Partial<Record<TableExportScope, string>>;
rowCountByScope?: Partial<Record<TableExportScope, number>>;
};
const normalizeScopeOptions = (
scopeOptions: TableExportScopeOption[] | undefined,
): TableExportScopeOption[] => {
if (!Array.isArray(scopeOptions) || scopeOptions.length === 0) {
return [{ ...DEFAULT_TABLE_EXPORT_SCOPE_OPTION }];
}
const seen = new Set<TableExportScope>();
const normalized = scopeOptions
.filter((item): item is TableExportScopeOption => !!item && typeof item.value === 'string')
.map((item) => ({
value: item.value,
label: String(item.label || '').trim() || item.value,
description: typeof item.description === 'string' ? item.description : undefined,
disabled: item.disabled === true,
}))
.filter((item) => {
if (seen.has(item.value)) return false;
seen.add(item.value);
return true;
});
return normalized.length > 0 ? normalized : [{ ...DEFAULT_TABLE_EXPORT_SCOPE_OPTION }];
};
const resolveInitialScope = (
scopeOptions: TableExportScopeOption[],
initialScope?: TableExportScope,
): TableExportScope => {
if (initialScope && scopeOptions.some((item) => item.value === initialScope && !item.disabled)) {
return initialScope;
}
return scopeOptions.find((item) => !item.disabled)?.value || 'all';
};
const normalizeQueryByScope = (
queryByScope: BuildTableExportTabInput['queryByScope'],
): Partial<Record<TableExportScope, string>> | undefined => {
if (!queryByScope || typeof queryByScope !== 'object') {
return undefined;
}
const next: Partial<Record<TableExportScope, string>> = {};
(['selected', 'page', 'all', 'filteredAll'] as TableExportScope[]).forEach((scope) => {
const value = String(queryByScope[scope] || '').trim();
if (value) {
next[scope] = value;
}
});
return Object.keys(next).length > 0 ? next : undefined;
};
const normalizeRowCountByScope = (
rowCountByScope: BuildTableExportTabInput['rowCountByScope'],
): Partial<Record<TableExportScope, number>> | undefined => {
if (!rowCountByScope || typeof rowCountByScope !== 'object') {
return undefined;
}
const next: Partial<Record<TableExportScope, number>> = {};
(['selected', 'page', 'all', 'filteredAll'] as TableExportScope[]).forEach((scope) => {
const value = Number(rowCountByScope[scope]);
if (Number.isFinite(value) && value >= 0) {
next[scope] = Math.trunc(value);
}
});
return Object.keys(next).length > 0 ? next : undefined;
};
export const buildTableExportTab = (input: BuildTableExportTabInput): TabData => {
const connectionId = String(input.connectionId || '').trim();
const dbName = String(input.dbName || '').trim();
const tableName = String(input.tableName || '').trim();
const scopeOptions = normalizeScopeOptions(input.scopeOptions);
const initialScope = resolveInitialScope(scopeOptions, input.initialScope);
const objectLabel = tableName || '未命名对象';
return {
id: `table-export-${connectionId}-${dbName}-${tableName}`,
title: String(input.title || `导出 ${objectLabel}`).trim() || `导出 ${objectLabel}`,
type: 'table-export',
connectionId,
dbName,
tableName,
objectType: input.objectType,
schemaName: input.schemaName,
sidebarLocateKey: input.sidebarLocateKey,
initialTab: 'config',
tableExportScopeOptions: scopeOptions,
tableExportInitialScope: initialScope,
tableExportQueryByScope: normalizeQueryByScope(input.queryByScope),
tableExportRowCountByScope: normalizeRowCountByScope(input.rowCountByScope),
};
};

View File

@@ -106,16 +106,22 @@ export function ExportConnectionsPackage(arg1:app.ConnectionExportOptions):Promi
export function ExportData(arg1:Array<Record<string, any>>,arg2:Array<string>,arg3:string,arg4:string):Promise<connection.QueryResult>;
export function ExportDataWithOptions(arg1:Array<Record<string, any>>,arg2:Array<string>,arg3:string,arg4:app.ExportFileOptions):Promise<connection.QueryResult>;
export function ExportDatabaseSQL(arg1:connection.ConnectionConfig,arg2:string,arg3:boolean):Promise<connection.QueryResult>;
export function ExportQuery(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string,arg5:string):Promise<connection.QueryResult>;
export function ExportQueryWithOptions(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string,arg5:app.ExportFileOptions):Promise<connection.QueryResult>;
export function ExportSQLFile(arg1:string,arg2:string):Promise<connection.QueryResult>;
export function ExportSchemaSQL(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:boolean):Promise<connection.QueryResult>;
export function ExportTable(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise<connection.QueryResult>;
export function ExportTableWithOptions(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:app.ExportFileOptions):Promise<connection.QueryResult>;
export function ExportTablesDataSQL(arg1:connection.ConnectionConfig,arg2:string,arg3:Array<string>):Promise<connection.QueryResult>;
export function ExportTablesSQL(arg1:connection.ConnectionConfig,arg2:string,arg3:Array<string>,arg4:boolean):Promise<connection.QueryResult>;

View File

@@ -202,6 +202,10 @@ export function ExportData(arg1, arg2, arg3, arg4) {
return window['go']['app']['App']['ExportData'](arg1, arg2, arg3, arg4);
}
export function ExportDataWithOptions(arg1, arg2, arg3, arg4) {
return window['go']['app']['App']['ExportDataWithOptions'](arg1, arg2, arg3, arg4);
}
export function ExportDatabaseSQL(arg1, arg2, arg3) {
return window['go']['app']['App']['ExportDatabaseSQL'](arg1, arg2, arg3);
}
@@ -210,6 +214,10 @@ export function ExportQuery(arg1, arg2, arg3, arg4, arg5) {
return window['go']['app']['App']['ExportQuery'](arg1, arg2, arg3, arg4, arg5);
}
export function ExportQueryWithOptions(arg1, arg2, arg3, arg4, arg5) {
return window['go']['app']['App']['ExportQueryWithOptions'](arg1, arg2, arg3, arg4, arg5);
}
export function ExportSQLFile(arg1, arg2) {
return window['go']['app']['App']['ExportSQLFile'](arg1, arg2);
}
@@ -222,6 +230,10 @@ export function ExportTable(arg1, arg2, arg3, arg4) {
return window['go']['app']['App']['ExportTable'](arg1, arg2, arg3, arg4);
}
export function ExportTableWithOptions(arg1, arg2, arg3, arg4) {
return window['go']['app']['App']['ExportTableWithOptions'](arg1, arg2, arg3, arg4);
}
export function ExportTablesDataSQL(arg1, arg2, arg3) {
return window['go']['app']['App']['ExportTablesDataSQL'](arg1, arg2, arg3);
}

View File

@@ -432,6 +432,26 @@ export namespace app {
this.filePassword = source["filePassword"];
}
}
export class ExportFileOptions {
format: string;
xlsxMaxRowsPerSheet?: number;
jobId?: string;
totalRowsHint?: number;
totalRowsKnown?: boolean;
static createFrom(source: any = {}) {
return new ExportFileOptions(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.format = source["format"];
this.xlsxMaxRowsPerSheet = source["xlsxMaxRowsPerSheet"];
this.jobId = source["jobId"];
this.totalRowsHint = source["totalRowsHint"];
this.totalRowsKnown = source["totalRowsKnown"];
}
}
export class SecurityUpdateOptions {
allowPartial?: boolean;
writeBackup?: boolean;

View File

@@ -1391,6 +1391,7 @@
"sidebar.v2_table_menu.backup_sql_dump": "Backup · {{keyword}}",
"sidebar.v2_table_menu.refresh_stats": "Statistiken aktualisieren",
"sidebar.v2_table_menu.item_with_suffix": "{{label}} · {{suffix}}",
"sidebar.v2_table_menu.open_export_workbench": "Export-Workbench öffnen...",
"sidebar.v2_table_menu.truncate_table": "Tabelle abschneiden",
"sidebar.v2_table_menu.ai_explain_table": "Mit AI diese Tabelle erklären",
"sidebar.v2_table_menu.ai_generate_query": "Mit AI eine Abfrage erzeugen",
@@ -1563,6 +1564,7 @@
"tab_manager.kind_badge.view": "Ansicht",
"tab_manager.kind_badge.event": "Ereignis",
"tab_manager.kind_badge.routine": "Funktion",
"tab_manager.kind_badge.table_export": "Export",
"tab_manager.kind_badge.fallback": "Tab",
"tab_manager.empty.action.open_ai": "AI öffnen",
"tab_manager.empty.aria.start_workbench": "GoNavi-Startarbeitsbereich",
@@ -1596,6 +1598,7 @@
"tab_manager.hover.kind.redis_monitor": "Redis-Monitor",
"tab_manager.hover.kind.routine": "Funktion / Prozedur",
"tab_manager.hover.kind.table": "Tabellendaten",
"tab_manager.hover.kind.table_export": "Export-Workbench",
"tab_manager.hover.kind.table_overview": "Tabellenübersicht",
"tab_manager.hover.kind.trigger": "Trigger",
"tab_manager.hover.kind.view": "Ansicht",

View File

@@ -1391,6 +1391,7 @@
"sidebar.v2_table_menu.backup_sql_dump": "Backup · {{keyword}}",
"sidebar.v2_table_menu.refresh_stats": "Refresh stats",
"sidebar.v2_table_menu.item_with_suffix": "{{label}} · {{suffix}}",
"sidebar.v2_table_menu.open_export_workbench": "Open export workbench...",
"sidebar.v2_table_menu.truncate_table": "Truncate table",
"sidebar.v2_table_menu.ai_explain_table": "Use AI to explain this table",
"sidebar.v2_table_menu.ai_generate_query": "Use AI to generate a query",
@@ -1571,6 +1572,7 @@
"tab_manager.kind_badge.view": "View",
"tab_manager.kind_badge.event": "Event",
"tab_manager.kind_badge.routine": "Func",
"tab_manager.kind_badge.table_export": "Export",
"tab_manager.kind_badge.fallback": "Tab",
"tab_manager.empty.action.open_ai": "Open AI",
"tab_manager.empty.aria.start_workbench": "GoNavi start workbench",
@@ -1604,6 +1606,7 @@
"tab_manager.hover.kind.redis_monitor": "Redis monitor",
"tab_manager.hover.kind.routine": "Function / procedure",
"tab_manager.hover.kind.table": "Table data",
"tab_manager.hover.kind.table_export": "Export workbench",
"tab_manager.hover.kind.table_overview": "Table overview",
"tab_manager.hover.kind.trigger": "Trigger",
"tab_manager.hover.kind.view": "View",

View File

@@ -1391,6 +1391,7 @@
"sidebar.v2_table_menu.backup_sql_dump": "バックアップ · {{keyword}}",
"sidebar.v2_table_menu.refresh_stats": "統計情報を更新",
"sidebar.v2_table_menu.item_with_suffix": "{{label}} · {{suffix}}",
"sidebar.v2_table_menu.open_export_workbench": "エクスポートワークベンチを開く…",
"sidebar.v2_table_menu.truncate_table": "テーブルを切り詰め",
"sidebar.v2_table_menu.ai_explain_table": "AI でこのテーブルを説明",
"sidebar.v2_table_menu.ai_generate_query": "AI でクエリを生成",
@@ -1563,6 +1564,7 @@
"tab_manager.kind_badge.view": "ビュー",
"tab_manager.kind_badge.event": "イベント",
"tab_manager.kind_badge.routine": "関数",
"tab_manager.kind_badge.table_export": "エクスポート",
"tab_manager.kind_badge.fallback": "タブ",
"tab_manager.empty.action.open_ai": "AI を開く",
"tab_manager.empty.aria.start_workbench": "GoNavi 開始ワークベンチ",
@@ -1596,6 +1598,7 @@
"tab_manager.hover.kind.redis_monitor": "Redis 監視",
"tab_manager.hover.kind.routine": "関数 / プロシージャ",
"tab_manager.hover.kind.table": "テーブルデータ",
"tab_manager.hover.kind.table_export": "エクスポートワークベンチ",
"tab_manager.hover.kind.table_overview": "テーブル概要",
"tab_manager.hover.kind.trigger": "トリガー",
"tab_manager.hover.kind.view": "ビュー",

View File

@@ -1391,6 +1391,7 @@
"sidebar.v2_table_menu.backup_sql_dump": "Резервная копия · {{keyword}}",
"sidebar.v2_table_menu.refresh_stats": "Обновить статистику",
"sidebar.v2_table_menu.item_with_suffix": "{{label}} · {{suffix}}",
"sidebar.v2_table_menu.open_export_workbench": "Открыть рабочую область экспорта…",
"sidebar.v2_table_menu.truncate_table": "Усечь таблицу",
"sidebar.v2_table_menu.ai_explain_table": "Объяснить эту таблицу с помощью AI",
"sidebar.v2_table_menu.ai_generate_query": "Сгенерировать запрос с помощью AI",
@@ -1563,6 +1564,7 @@
"tab_manager.kind_badge.view": "Вид",
"tab_manager.kind_badge.event": "Событие",
"tab_manager.kind_badge.routine": "Функция",
"tab_manager.kind_badge.table_export": "Экспорт",
"tab_manager.kind_badge.fallback": "Вкладка",
"tab_manager.empty.action.open_ai": "Открыть AI",
"tab_manager.empty.aria.start_workbench": "Стартовая рабочая область GoNavi",
@@ -1596,6 +1598,7 @@
"tab_manager.hover.kind.redis_monitor": "Монитор Redis",
"tab_manager.hover.kind.routine": "Функция / процедура",
"tab_manager.hover.kind.table": "Данные таблицы",
"tab_manager.hover.kind.table_export": "Рабочая область экспорта",
"tab_manager.hover.kind.table_overview": "Обзор таблицы",
"tab_manager.hover.kind.trigger": "Триггер",
"tab_manager.hover.kind.view": "Представление",

View File

@@ -1391,6 +1391,7 @@
"sidebar.v2_table_menu.backup_sql_dump": "备份 · {{keyword}}",
"sidebar.v2_table_menu.refresh_stats": "刷新统计信息",
"sidebar.v2_table_menu.item_with_suffix": "{{label}} · {{suffix}}",
"sidebar.v2_table_menu.open_export_workbench": "打开导出工作台…",
"sidebar.v2_table_menu.truncate_table": "截断表",
"sidebar.v2_table_menu.ai_explain_table": "用 AI 解释这张表",
"sidebar.v2_table_menu.ai_generate_query": "用 AI 生成查询",
@@ -1571,6 +1572,7 @@
"tab_manager.kind_badge.view": "视图",
"tab_manager.kind_badge.event": "事件",
"tab_manager.kind_badge.routine": "函数",
"tab_manager.kind_badge.table_export": "导出",
"tab_manager.kind_badge.fallback": "标签",
"tab_manager.empty.action.open_ai": "打开 AI",
"tab_manager.empty.aria.start_workbench": "GoNavi 起始工作台",
@@ -1604,6 +1606,7 @@
"tab_manager.hover.kind.redis_monitor": "Redis 监控",
"tab_manager.hover.kind.routine": "函数 / 存储过程",
"tab_manager.hover.kind.table": "表数据",
"tab_manager.hover.kind.table_export": "导出工作台",
"tab_manager.hover.kind.table_overview": "表概览",
"tab_manager.hover.kind.trigger": "触发器",
"tab_manager.hover.kind.view": "视图",

View File

@@ -1391,6 +1391,7 @@
"sidebar.v2_table_menu.backup_sql_dump": "備份 · {{keyword}}",
"sidebar.v2_table_menu.refresh_stats": "重新整理統計資訊",
"sidebar.v2_table_menu.item_with_suffix": "{{label}} · {{suffix}}",
"sidebar.v2_table_menu.open_export_workbench": "開啟匯出工作台…",
"sidebar.v2_table_menu.truncate_table": "截斷資料表",
"sidebar.v2_table_menu.ai_explain_table": "用 AI 解釋這張資料表",
"sidebar.v2_table_menu.ai_generate_query": "用 AI 產生查詢",
@@ -1563,6 +1564,7 @@
"tab_manager.kind_badge.view": "視圖",
"tab_manager.kind_badge.event": "事件",
"tab_manager.kind_badge.routine": "函式",
"tab_manager.kind_badge.table_export": "匯出",
"tab_manager.kind_badge.fallback": "標籤",
"tab_manager.empty.action.open_ai": "開啟 AI",
"tab_manager.empty.aria.start_workbench": "GoNavi 起始工作台",
@@ -1596,6 +1598,7 @@
"tab_manager.hover.kind.redis_monitor": "Redis 監控",
"tab_manager.hover.kind.routine": "函數 / 程序",
"tab_manager.hover.kind.table": "表資料",
"tab_manager.hover.kind.table_export": "匯出工作台",
"tab_manager.hover.kind.table_overview": "表概覽",
"tab_manager.hover.kind.trigger": "觸發器",
"tab_manager.hover.kind.view": "視圖",