mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-03 18:01:25 +08:00
✨ feat(export-workbench): 新增导出工作台与进度历史
- 新增表级导出工作台标签页,统一承载导出范围、格式和 XLSX sheet 行数配置 - 结果集、表概览、侧栏和右键菜单统一接入导出工作台与带进度的导出入口 - 导出进度改为事件驱动,未知总数时展示不定进度和实时已写入行数 - 持久化每张表的导出历史并复用同一导出标签,重启后仍可查看最近任务 - 调整导出页签标题、状态胶囊和历史列表,补充工作台与状态流测试覆盖
This commit is contained in:
209
frontend/src/components/DataExportDialog.tsx
Normal file
209
frontend/src/components/DataExportDialog.tsx
Normal 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);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
86
frontend/src/components/ExportProgressBar.tsx
Normal file
86
frontend/src/components/ExportProgressBar.tsx
Normal 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;
|
||||
75
frontend/src/components/ExportProgressModal.tsx
Normal file
75
frontend/src/components/ExportProgressModal.tsx
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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();");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
215
frontend/src/components/TableExportWorkbench.test.tsx
Normal file
215
frontend/src/components/TableExportWorkbench.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
728
frontend/src/components/TableExportWorkbench.tsx
Normal file
728
frontend/src/components/TableExportWorkbench.tsx
Normal 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;
|
||||
@@ -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}>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
129
frontend/src/components/useExportProgressRunner.test.tsx
Normal file
129
frontend/src/components/useExportProgressRunner.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
235
frontend/src/components/useExportProgressRunner.ts
Normal file
235
frontend/src/components/useExportProgressRunner.ts
Normal 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',
|
||||
};
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
34
frontend/src/utils/exportProgress.test.ts
Normal file
34
frontend/src/utils/exportProgress.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
91
frontend/src/utils/exportProgress.ts
Normal file
91
frontend/src/utils/exportProgress.ts
Normal 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)}`;
|
||||
};
|
||||
@@ -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',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
63
frontend/src/utils/tableExportTab.test.ts
Normal file
63
frontend/src/utils/tableExportTab.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
123
frontend/src/utils/tableExportTab.ts
Normal file
123
frontend/src/utils/tableExportTab.ts
Normal 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),
|
||||
};
|
||||
};
|
||||
6
frontend/wailsjs/go/app/App.d.ts
vendored
6
frontend/wailsjs/go/app/App.d.ts
vendored
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "ビュー",
|
||||
|
||||
@@ -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": "Представление",
|
||||
|
||||
@@ -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": "视图",
|
||||
|
||||
@@ -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": "視圖",
|
||||
|
||||
Reference in New Issue
Block a user