Merge pull request #702 from DengQingNian/optimize/export-pagination-orderby-batchsize

perf(ui): 优化数据导出的ui体验和性能
This commit is contained in:
skyler 2026-06-03 21:42:38 +08:00 committed by GitHub
commit ff2210e5a4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 597 additions and 160 deletions

View File

@ -122,6 +122,7 @@ const editDisconnectTabHandlingMode = ref<DisconnectTabHandlingMode>(
const editSidebarHiddenTablePrefixes = ref(settingsStore.editorSettings.sidebarHiddenTablePrefixes.join("\n"));
const editSidebarHideTableComments = ref(settingsStore.editorSettings.sidebarHideTableComments);
const editSidebarAllowHorizontalScroll = ref(settingsStore.editorSettings.sidebarAllowHorizontalScroll);
const editExportBatchSize = ref(settingsStore.editorSettings.exportBatchSize);
const redisScanPageSizeOptions = [200, 1000, 5000, 10000];
const systemFonts = ref<string[]>([]);
const systemFontsLoading = ref(false);
@ -269,6 +270,7 @@ watch(
editSidebarHiddenTablePrefixes.value = settingsStore.editorSettings.sidebarHiddenTablePrefixes.join("\n");
editSidebarHideTableComments.value = settingsStore.editorSettings.sidebarHideTableComments;
editSidebarAllowHorizontalScroll.value = settingsStore.editorSettings.sidebarAllowHorizontalScroll;
editExportBatchSize.value = settingsStore.editorSettings.exportBatchSize;
editSnippets.value = settingsStore.editorSettings.snippets.map((s) => ({ ...s }));
void loadSystemFontOptions();
}
@ -309,6 +311,7 @@ function hasChanges(): boolean {
editDisconnectTabHandlingMode.value !== settingsStore.editorSettings.disconnectTabHandlingMode ||
editSidebarHideTableComments.value !== settingsStore.editorSettings.sidebarHideTableComments ||
editSidebarAllowHorizontalScroll.value !== settingsStore.editorSettings.sidebarAllowHorizontalScroll ||
editExportBatchSize.value !== settingsStore.editorSettings.exportBatchSize ||
JSON.stringify(normalizeSidebarHiddenTablePrefixes(editSidebarHiddenTablePrefixes.value)) !==
JSON.stringify(settingsStore.editorSettings.sidebarHiddenTablePrefixes) ||
JSON.stringify(editSnippets.value) !== JSON.stringify(settingsStore.editorSettings.snippets)
@ -338,6 +341,7 @@ async function persistSettings() {
sidebarHideTableComments: editSidebarHideTableComments.value,
sidebarAllowHorizontalScroll: editSidebarAllowHorizontalScroll.value,
sidebarHiddenTablePrefixes: normalizeSidebarHiddenTablePrefixes(editSidebarHiddenTablePrefixes.value),
exportBatchSize: editExportBatchSize.value,
snippets: editSnippets.value,
});
await settingsStore.updateDesktopSettings({
@ -379,6 +383,7 @@ function resetDefaults() {
editSidebarHideTableComments.value = DEFAULT_EDITOR_SETTINGS.sidebarHideTableComments;
editSidebarAllowHorizontalScroll.value = DEFAULT_EDITOR_SETTINGS.sidebarAllowHorizontalScroll;
editSidebarHiddenTablePrefixes.value = DEFAULT_EDITOR_SETTINGS.sidebarHiddenTablePrefixes.join("\n");
editExportBatchSize.value = DEFAULT_EDITOR_SETTINGS.exportBatchSize;
editSnippets.value = DEFAULT_SQL_SNIPPETS.map((s) => ({ ...s }));
}
@ -453,6 +458,7 @@ type SettingsCategory =
| "editor"
| "appearance"
| "navigation"
| "data"
| "redis"
| "shortcuts"
| "snippets"
@ -465,6 +471,7 @@ const settingsCategoryNav = computed<{ value: SettingsCategory; label: string }[
{ value: "editor", label: t("settings.editorTab") },
{ value: "appearance", label: t("settings.appearanceTab") },
{ value: "navigation", label: t("settings.navigationTab") },
{ value: "data", label: t("settings.dataTab") },
{ value: "redis", label: t("settings.redisTab") },
{ value: "shortcuts", label: t("settings.shortcutsTab") },
{ value: "snippets", label: t("settings.snippetsTab") },
@ -478,6 +485,7 @@ const settingsTabsWithApplyFooter = new Set<SettingsCategory>([
"editor",
"appearance",
"navigation",
"data",
"redis",
"shortcuts",
"snippets",
@ -1622,6 +1630,35 @@ watch(
</div>
</section>
<!-- Data Tab -->
<section v-else-if="activeSettingsTab === 'data'" class="flex flex-col gap-5 py-2">
<div class="space-y-3">
<div class="text-sm font-medium text-muted-foreground">{{ t("settings.exportSection") }}</div>
<div class="space-y-2">
<Label>{{ t("settings.exportBatchSize") }}</Label>
<div class="flex items-center gap-3">
<Input
type="number"
list="export-batch-sizes"
min="100"
max="100000"
step="100"
v-model.number="editExportBatchSize"
class="h-9 w-28 [&::-webkit-inner-spin-button]:appearance-none"
/>
<datalist id="export-batch-sizes">
<option value="500" />
<option value="1000" />
<option value="2000" />
<option value="5000" />
<option value="10000" />
</datalist>
<span class="text-xs text-muted-foreground">{{ t("settings.exportBatchSizeDescription") }}</span>
</div>
</div>
</div>
</section>
<section v-else-if="activeSettingsTab === 'redis'" class="flex flex-col gap-5 py-2">
<div class="space-y-2">
<Label>{{ t("settings.redisScanPageSize") }}</Label>

View File

@ -0,0 +1,181 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { useI18n } from "vue-i18n";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
import { Loader2, CheckCircle2, XCircle, AlertCircle, X, FileDown } from "@lucide/vue";
import { useExportTracker } from "@/composables/useExportTracker";
const { t } = useI18n();
const { tasks, activeCount, hasActive, clearFinished, cancelTask, removeTask } = useExportTracker();
const open = ref(false);
const showAll = ref(false);
const MAX_VISIBLE = 5;
const reversedTasks = computed(() => {
return [...tasks.value].reverse();
});
const visibleTasks = computed(() => {
return showAll.value ? reversedTasks.value : reversedTasks.value.slice(0, MAX_VISIBLE);
});
const hasMore = computed(() => tasks.value.length > MAX_VISIBLE);
const isActive = (status: string) => status === "Running" || status === "Writing";
const isFinished = (status: string) => status === "Done" || status === "Error" || status === "Cancelled";
const finishedCount = computed(() => tasks.value.filter((t) => isFinished(t.status)).length);
const progressPercent = (totalRows: number | null, rowsExported: number) => {
if (!totalRows || totalRows <= 0) return 0;
return Math.min(100, Math.round((rowsExported / totalRows) * 100));
};
const rowsText = (task: { totalRows: number | null; rowsExported: number }) => {
if (task.totalRows) {
return `${task.rowsExported.toLocaleString()} / ${task.totalRows.toLocaleString()}`;
}
return `${task.rowsExported.toLocaleString()} ${t("exportProgress.rowsShort")}`;
};
const statusIcon = (status: string) => {
switch (status) {
case "Running":
case "Writing":
return Loader2;
case "Done":
return CheckCircle2;
case "Error":
return XCircle;
case "Cancelled":
return AlertCircle;
default:
return Loader2;
}
};
const statusColor = (status: string) => {
switch (status) {
case "Running":
case "Writing":
return "text-primary";
case "Done":
return "text-green-500";
case "Error":
return "text-destructive";
case "Cancelled":
return "text-yellow-500";
default:
return "text-muted-foreground";
}
};
function toggleShowAll() {
showAll.value = !showAll.value;
}
</script>
<template>
<Popover v-if="tasks.length > 0" v-model:open="open">
<PopoverTrigger as-child>
<Button
variant="ghost"
size="icon"
class="relative h-8 w-8"
:title="t('exportProgress.tooltip')"
:class="{ 'bg-accent text-primary': hasActive }"
>
<FileDown class="h-4 w-4" />
<span
v-if="hasActive"
class="absolute -right-0.5 -top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-medium leading-none text-primary-foreground"
>
{{ activeCount > 9 ? "9+" : activeCount }}
</span>
</Button>
</PopoverTrigger>
<PopoverContent align="start" class="w-80 p-0 gap-0 overflow-hidden" :side-offset="8">
<div class="border-b bg-muted/40 px-3 py-2">
<div class="text-sm font-semibold">{{ t("exportProgress.popoverTitle") }}</div>
</div>
<div class="max-h-80 overflow-y-auto">
<div
v-for="task in visibleTasks"
:key="task.exportId"
class="flex items-center gap-2 border-b px-3 py-2.5 text-xs last:border-b-0"
>
<div class="flex-1 min-w-0 flex flex-col gap-1">
<div class="flex items-center gap-1.5">
<component
:is="statusIcon(task.status)"
:class="[statusColor(task.status), isActive(task.status) ? 'animate-spin' : '']"
class="h-3.5 w-3.5 shrink-0"
/>
<span class="truncate font-medium">{{ task.tableName }}.{{ task.format }}</span>
</div>
<!-- Progress bar -->
<div v-if="isActive(task.status)" class="w-full bg-muted rounded-full h-1.5 overflow-hidden">
<div
class="h-full bg-primary rounded-full transition-all duration-300"
:class="{ 'animate-pulse': !task.totalRows }"
:style="{ width: task.totalRows ? `${progressPercent(task.totalRows, task.rowsExported)}%` : '50%' }"
/>
</div>
<div v-else-if="task.status === 'Done'" class="w-full bg-muted rounded-full h-1.5 overflow-hidden">
<div class="h-full bg-green-500 rounded-full" style="width: 100%" />
</div>
<div class="flex items-center justify-between text-muted-foreground">
<span class="tabular-nums">{{ rowsText(task) }}</span>
<span
v-if="task.status === 'Error' && task.errorMessage"
class="truncate ml-2 text-destructive"
:title="task.errorMessage"
>
{{ task.errorMessage }}
</span>
</div>
</div>
<!-- Actions: stop/cancel for active, delete for finished -->
<div class="flex shrink-0 self-center">
<button
v-if="isActive(task.status)"
class="flex h-6 w-6 items-center justify-center rounded hover:bg-muted"
:title="t('exportProgress.cancel')"
@click="cancelTask(task.exportId)"
>
<X class="h-3.5 w-3.5 text-muted-foreground hover:text-destructive" />
</button>
<button
v-else
class="flex h-6 w-6 items-center justify-center rounded hover:bg-muted"
:title="t('exportProgress.delete')"
@click="removeTask(task.exportId)"
>
<X class="h-3.5 w-3.5 text-muted-foreground hover:text-foreground" />
</button>
</div>
</div>
</div>
<div v-if="hasMore" class="border-t bg-muted/30 px-3 py-1.5">
<button class="w-full text-center text-xs text-muted-foreground hover:text-foreground" @click="toggleShowAll">
{{
showAll ? t("exportProgress.showLess") : t("exportProgress.showMore", { count: tasks.length - MAX_VISIBLE })
}}
</button>
</div>
<div v-if="finishedCount > 0" class="border-t bg-muted/30 px-3 py-1.5">
<button class="w-full text-center text-xs text-muted-foreground hover:text-foreground" @click="clearFinished">
{{ t("exportProgress.clearFinished") }}
</button>
</div>
</PopoverContent>
</Popover>
</template>

View File

@ -23,6 +23,7 @@ import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import LightDropdown from "@/components/ui/LightDropdown.vue";
import WindowControls from "@/components/layout/WindowControls.vue";
import ExportProgressPopover from "@/components/export/ExportProgressPopover.vue";
import { shouldReserveMacTrafficLightInset, useWindowControls } from "@/composables/useWindowControls";
import { currentLocale, setLocale, type Locale } from "@/i18n";
import type { AppThemeMode } from "@/lib/appTheme";
@ -193,6 +194,8 @@ function onToolbarDblClick(e: MouseEvent) {
<TooltipContent>{{ t("updates.check") }}</TooltipContent>
</Tooltip>
<ExportProgressPopover />
<Tooltip>
<TooltipTrigger as-child>
<Button

View File

@ -42,7 +42,6 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
import ProcedureExecutionDialog from "@/components/objects/ProcedureExecutionDialog.vue";
import ExportProgressDialog from "@/components/export/ExportProgressDialog.vue";
import * as api from "@/lib/api";
import type { ConnectionConfig, ObjectInfo, ObjectSourceKind } from "@/types/database";
import { isSchemaAware } from "@/lib/databaseCapabilities";
@ -75,6 +74,7 @@ import { copyToClipboard } from "@/lib/clipboard";
import { formatSqlInsert } from "@/lib/exportFormats";
import { fetchTableDataForExport } from "@/lib/tableDataExport";
import { useConnectionStore } from "@/stores/connectionStore";
import { useExportTracker, type ExportTask } from "@/composables/useExportTracker";
import { useSettingsStore } from "@/stores/settingsStore";
import { useQueryStore } from "@/stores/queryStore";
import QueryEditor from "@/components/editor/QueryEditor.vue";
@ -156,15 +156,8 @@ const showBatchDropConfirm = ref(false);
const batchDropPreviewSql = ref("");
let loadId = 0;
// Export progress state
const showExportProgress = ref(false);
const exportProgressId = ref("");
const exportProgressTableName = ref("");
const exportProgressFormat = ref("");
const exportProgressRows = ref(0);
const exportProgressTotal = ref<number | null>(null);
const exportProgressStatus = ref<string>("");
const exportProgressError = ref<string | null>(null);
// Export via background tracker
const { addTask: addExportTask } = useExportTracker();
const needsSchema = computed(() => isSchemaAware(props.connection.db_type));
const tableCount = computed(() => rows.value.filter((row) => row.type === "TABLE").length);
@ -868,56 +861,44 @@ async function exportTableData(row: ObjectBrowserRow, format: "csv" | "xlsx") {
filePath = `__web_export_${webExportId}.${format}`;
}
// Set up progress state and open dialog
const exportId = generateDatabaseExportId();
exportProgressId.value = exportId;
exportProgressTableName.value = row.name;
exportProgressFormat.value = format;
exportProgressRows.value = 0;
exportProgressTotal.value = null;
exportProgressStatus.value = "Running";
exportProgressError.value = null;
showExportProgress.value = true;
// Get columns for neo4j only
const queryColumns =
props.connection.db_type === "neo4j"
? (await api.getColumns(props.connection.id, props.database, schema || props.database, row.name)).map(
(column) => column.name,
)
: undefined;
const request: api.TableExportRequest = {
exportId,
connectionId: props.connection.id,
database: props.database,
schema,
tableName: row.name,
filePath,
format,
columns: queryColumns,
};
let task: ExportTask | null = null;
try {
await api.startTableExport(request, (progress) => {
exportProgressRows.value = progress.rowsExported;
exportProgressTotal.value = progress.totalRows;
exportProgressStatus.value = progress.status;
exportProgressError.value = progress.errorMessage || null;
});
toast(t("grid.exported"));
} catch (e: any) {
if (exportProgressStatus.value !== "Cancelled") {
exportProgressStatus.value = "Error";
exportProgressError.value = e?.message || String(e);
toast(t("grid.exportFailed", { message: e?.message || String(e) }), 5000);
}
}
}
const queryColumns =
props.connection.db_type === "neo4j"
? (await api.getColumns(props.connection.id, props.database, schema || props.database, row.name)).map(
(column) => column.name,
)
: undefined;
async function cancelExport() {
if (exportProgressId.value) {
await api.cancelTableExport(exportProgressId.value);
task = addExportTask(row.name, format, filePath);
const currentTask = task;
const request: api.TableExportRequest = {
exportId: currentTask.exportId,
connectionId: props.connection.id,
database: props.database,
schema,
tableName: row.name,
filePath,
format,
columns: queryColumns,
batchSize: settingsStore.editorSettings.exportBatchSize,
};
const terminalProgress = await api.startTableExport(request, (progress) => {
currentTask.rowsExported = progress.rowsExported;
currentTask.totalRows = progress.totalRows;
currentTask.status = progress.status;
currentTask.errorMessage = progress.errorMessage || null;
});
if (terminalProgress.status === "Done") {
toast(t("grid.exported"));
}
} catch (e: any) {
if (task) {
task.status = "Error";
task.errorMessage = e?.message || String(e);
}
toast(t("grid.exportFailed", { message: e?.message || String(e) }), 5000);
}
}
@ -1708,18 +1689,6 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
</DialogFooter>
</DialogContent>
</Dialog>
<ExportProgressDialog
v-model:open="showExportProgress"
:title="t('grid.exporting')"
:table-name="exportProgressTableName"
:format="exportProgressFormat"
:rows-exported="exportProgressRows"
:total-rows="exportProgressTotal"
:status="exportProgressStatus"
:error-message="exportProgressError"
@cancel="cancelExport"
/>
</template>
<style scoped>

View File

@ -90,7 +90,6 @@ import {
} from "@/lib/treeNodeClick";
import { formatSqlInsert } from "@/lib/exportFormats";
import { fetchTableDataForExport } from "@/lib/tableDataExport";
import { generateDatabaseExportId } from "@/lib/databaseExport";
import {
buildCreateDatabaseSql,
buildDuckDbAttachDatabaseSql,
@ -127,7 +126,7 @@ import {
} from "@/lib/sidebarTreeSelection";
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
import ProcedureExecutionDialog from "@/components/objects/ProcedureExecutionDialog.vue";
import ExportProgressDialog from "@/components/export/ExportProgressDialog.vue";
import { useExportTracker, type ExportTask } from "@/composables/useExportTracker";
import { isTauriRuntime } from "@/lib/tauriRuntime";
import { copyToClipboard } from "@/lib/clipboard";
import { formatShortcut } from "@/lib/shortcutRegistry";
@ -162,26 +161,7 @@ type StructureCopyFormat = "tsv" | "markdown";
type DuplicateStructureSource = TreeNode & { connectionId: string; database: string };
const { getDatabaseOptions } = useDatabaseOptions();
const showVisibleDatabasesDialog = ref(false);
const exportProgressDialogOpen = ref(false);
const exportProgress = ref<{
title: string;
tableName: string;
format: string;
rowsExported: number;
totalRows: number | null;
status: string;
errorMessage: string | null;
}>({
title: "",
tableName: "",
format: "",
rowsExported: 0,
totalRows: null,
status: "",
errorMessage: null,
});
const exportCancelled = ref(false);
const currentExportId = ref("");
const { addTask: addExportTask } = useExportTracker();
const props = defineProps<{
node: TreeNode;
@ -2142,6 +2122,7 @@ async function exportTableData(format: "csv" | "xlsx") {
const config = connectionStore.getConfig(node.connectionId);
if (!config) return;
let task: ExportTask | null = null;
try {
await connectionStore.ensureConnected(connectionId);
@ -2157,20 +2138,9 @@ async function exportTableData(format: "csv" | "xlsx") {
outputPath = path as string;
}
// Step 2: Prepare progress state and open dialog
const exportId = generateDatabaseExportId();
currentExportId.value = exportId;
exportCancelled.value = false;
exportProgress.value = {
title: t("exportProgress.title"),
tableName: node.label,
format,
rowsExported: 0,
totalRows: null,
status: "Running",
errorMessage: null,
};
exportProgressDialogOpen.value = true;
// Step 2: Register task in export tracker (background)
task = addExportTask(node.label, format, outputPath);
const currentTask = task;
// Step 3: Get query columns for neo4j
const queryColumns =
@ -2178,9 +2148,9 @@ async function exportTableData(format: "csv" | "xlsx") {
? (await api.getColumns(connectionId, database, node.schema || database, node.label)).map((c) => c.name)
: undefined;
// Step 4: Start streaming export
// Step 4: Start streaming export (background, non-blocking)
const request: api.TableExportRequest = {
exportId,
exportId: currentTask.exportId,
connectionId,
database,
schema: node.schema || undefined,
@ -2188,16 +2158,14 @@ async function exportTableData(format: "csv" | "xlsx") {
filePath: outputPath,
format,
columns: queryColumns,
batchSize: settingsStore.editorSettings.exportBatchSize,
};
await api.startTableExport(request, (progress) => {
exportProgress.value = {
...exportProgress.value,
rowsExported: progress.rowsExported,
totalRows: progress.totalRows,
status: progress.status,
errorMessage: progress.errorMessage || null,
};
currentTask.rowsExported = progress.rowsExported;
currentTask.totalRows = progress.totalRows;
currentTask.status = progress.status;
currentTask.errorMessage = progress.errorMessage || null;
if (progress.status === "Done") {
toast(t("grid.exported"));
} else if (progress.status === "Error") {
@ -2205,25 +2173,11 @@ async function exportTableData(format: "csv" | "xlsx") {
}
});
} catch (e: any) {
if (!exportCancelled.value) {
exportProgress.value = {
...exportProgress.value,
status: "Error",
errorMessage: e?.message || String(e),
};
toast(t("grid.exportFailed", { message: e?.message || String(e) }), 5000);
}
}
}
async function cancelExport() {
exportCancelled.value = true;
if (currentExportId.value) {
try {
await api.cancelTableExport(currentExportId.value);
} catch {
/* ignore */
if (task) {
task.status = "Error";
task.errorMessage = e?.message || String(e);
}
toast(t("grid.exportFailed", { message: e?.message || String(e) }), 5000);
}
}
@ -3709,8 +3663,6 @@ function treeItemMenuItems(): ContextMenuItem[] {
:confirm-label="t('contextMenu.dropSchema')"
@confirm="confirmDropSchema"
/>
<ExportProgressDialog v-model:open="exportProgressDialogOpen" v-bind="exportProgress" @cancel="cancelExport" />
</template>
<style>

View File

@ -0,0 +1,71 @@
import { reactive, computed } from "vue";
import * as api from "@/lib/api";
export interface ExportTask {
exportId: string;
tableName: string;
format: "csv" | "xlsx";
filePath: string;
rowsExported: number;
totalRows: number | null;
status: string;
errorMessage: string | null;
}
const taskMap = reactive<Map<string, ExportTask>>(new Map());
export function useExportTracker() {
const tasks = computed(() => Array.from(taskMap.values()));
const activeCount = computed(
() => tasks.value.filter((t) => t.status === "Running" || t.status === "Writing").length,
);
const hasActive = computed(() => activeCount.value > 0);
function addTask(tableName: string, format: "csv" | "xlsx", filePath: string): ExportTask {
const exportId = crypto.randomUUID();
const task = reactive<ExportTask>({
exportId,
tableName,
format,
filePath,
rowsExported: 0,
totalRows: null,
status: "Running",
errorMessage: null,
});
taskMap.set(exportId, task);
return task;
}
function removeTask(exportId: string) {
taskMap.delete(exportId);
}
function clearFinished() {
for (const [id, task] of taskMap) {
if (task.status === "Done" || task.status === "Error" || task.status === "Cancelled") {
taskMap.delete(id);
}
}
}
async function cancelTask(exportId: string) {
try {
await api.cancelTableExport(exportId);
} catch {
// ignore
}
}
return {
tasks,
activeCount,
hasActive,
addTask,
removeTask,
clearFinished,
cancelTask,
};
}

View File

@ -663,8 +663,16 @@ export default {
cancelled: "Export cancelled.",
rowsCount: "{exported} / {total} rows",
rowsExported: "{count} rows exported",
rowsShort: "rows",
cancel: "Cancel",
close: "Close",
tooltip: "Export Progress",
popoverTitle: "Exports",
noTasks: "No exports",
clearFinished: "Clear finished",
showMore: "Show {count} more",
showLess: "Show less",
delete: "Remove",
},
welcome: {
title: "Database Workspace",
@ -1555,6 +1563,7 @@ export default {
editorTab: "Editor",
appearanceTab: "Appearance",
navigationTab: "Navigation",
dataTab: "Data",
redisTab: "Redis",
shortcutsTab: "Shortcuts",
snippetsTab: "Snippets",
@ -1688,6 +1697,9 @@ export default {
redisScanPageSize: "Redis scan count",
redisScanPageSizeDescription: "Keys requested per Redis SCAN page when browsing keys.",
redisScanPageSizeOption: "{count} keys",
exportBatchSize: "Export batch size",
exportBatchSizeDescription: "Rows fetched per batch when exporting table data (100-100000).",
exportSection: "Export",
shortcutExecuteSql: "Execute SQL",
shortcutFind: "Find",
shortcutReplace: "Replace",

View File

@ -596,8 +596,16 @@ export default {
cancelled: "Exportación cancelada.",
rowsCount: "{exported} / {total} filas",
rowsExported: "{count} filas exportadas",
rowsShort: "filas",
cancel: "Cancelar",
close: "Cerrar",
tooltip: "Progreso de exportación",
popoverTitle: "Exportaciones",
noTasks: "Sin exportaciones",
clearFinished: "Limpiar finalizados",
showMore: "Mostrar {count} más",
showLess: "Mostrar menos",
delete: "Eliminar",
},
welcome: {
title: "Espacio de trabajo de base de datos",
@ -1447,6 +1455,7 @@ export default {
editorTab: "Editor",
appearanceTab: "Apariencia",
navigationTab: "Navegación",
dataTab: "Datos",
redisTab: "Redis",
shortcutsTab: "Atajos",
snippetsTab: "Fragmentos",
@ -1580,6 +1589,9 @@ export default {
redisScanPageSize: "Cantidad de escaneo Redis",
redisScanPageSizeDescription: "Claves solicitadas por página SCAN al explorar claves Redis.",
redisScanPageSizeOption: "{count} claves",
exportBatchSize: "Tamaño de lote de exportación",
exportBatchSizeDescription: "Filas obtenidas por lote al exportar datos de tabla (100-100000).",
exportSection: "Exportar",
shortcutExecuteSql: "Ejecutar SQL",
shortcutFind: "Buscar",
shortcutReplace: "Reemplazar",

View File

@ -653,8 +653,16 @@ export default {
cancelled: "导出已取消。",
rowsCount: "已导出 {exported} / {total} 行",
rowsExported: "已导出 {count} 行",
rowsShort: "行",
cancel: "取消",
close: "关闭",
tooltip: "导出进度",
popoverTitle: "导出任务",
noTasks: "暂无导出任务",
clearFinished: "清除已完成",
showMore: "展开其余 {count} 个",
showLess: "收起",
delete: "删除",
},
welcome: {
title: "数据库工作台",
@ -1528,6 +1536,7 @@ export default {
editorTab: "编辑器",
appearanceTab: "外观",
navigationTab: "导航",
dataTab: "数据",
redisTab: "Redis",
shortcutsTab: "快捷键",
snippetsTab: "代码片段",
@ -1647,6 +1656,9 @@ export default {
redisScanPageSize: "Redis 扫描数量",
redisScanPageSizeDescription: "浏览 Redis Key 时每次 SCAN 请求的 Key 数量。",
redisScanPageSizeOption: "{count} 个 Key",
exportBatchSize: "导出批次大小",
exportBatchSizeDescription: "导出表数据时每批次获取的行数100-100000。",
exportSection: "导出",
shortcutExecuteSql: "执行 SQL",
shortcutFind: "查找",
shortcutReplace: "替换",

View File

@ -1519,6 +1519,7 @@ export interface TableExportRequest {
filePath: string;
format: "csv" | "xlsx";
columns?: string[];
batchSize?: number;
}
export interface TableCsvExportOptions {

View File

@ -220,6 +220,7 @@ export interface EditorSettings {
columnFormatters: Record<string, ColumnFormatterConfig>;
customColumnFormatters: Record<string, CustomColumnFormatterConfig>;
snippets: SqlSnippet[];
exportBatchSize: number;
}
export const EDITOR_THEMES: { value: EditorTheme; label: string; dark: boolean }[] = [
@ -277,6 +278,7 @@ export const DEFAULT_EDITOR_SETTINGS: EditorSettings = {
columnFormatters: {},
customColumnFormatters: {},
snippets: DEFAULT_SQL_SNIPPETS,
exportBatchSize: 2000,
};
export const STORAGE_KEY = "dbx-editor-settings";
@ -425,6 +427,12 @@ export function normalizeEditorSettings(settings: Partial<EditorSettings>, exist
columnFormatters: normalizeColumnFormatters(settings.columnFormatters),
customColumnFormatters: normalizeCustomColumnFormatters(settings.customColumnFormatters),
snippets: normalizeSqlSnippets(settings.snippets, existing?.snippets),
exportBatchSize:
typeof settings.exportBatchSize === "number" &&
settings.exportBatchSize >= 100 &&
settings.exportBatchSize <= 100000
? Math.round(settings.exportBatchSize)
: DEFAULT_EDITOR_SETTINGS.exportBatchSize,
};
}
@ -571,6 +579,8 @@ export const useSettingsStore = defineStore("settings", () => {
if (partial.customColumnFormatters !== undefined)
editorSettings.value.customColumnFormatters = partial.customColumnFormatters;
if (partial.snippets !== undefined) editorSettings.value.snippets = normalizeSqlSnippets(partial.snippets);
if (partial.exportBatchSize !== undefined)
editorSettings.value.exportBatchSize = Math.min(100000, Math.max(100, Math.round(partial.exportBatchSize)));
saveEditorSettings(editorSettings.value);
}

View File

@ -6,10 +6,10 @@ use crate::connection::AppState;
use crate::csv_export::{escape_csv, format_csv, value_to_csv_text};
use crate::database_export::is_export_cancelled;
pub use crate::database_export::ExportStatus;
use crate::transfer::{count_sql, execute_on_pool, pagination_sql};
use crate::transfer::{count_sql, execute_on_pool, keyset_pagination_sql, pagination_sql};
use crate::xlsx_export::{build_xlsx_workbook, XlsxWorksheetData};
const DEFAULT_BATCH_SIZE: usize = 500;
const DEFAULT_BATCH_SIZE: usize = 2000;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@ -24,6 +24,8 @@ pub struct TableExportRequest {
pub format: String,
#[serde(default)]
pub columns: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub batch_size: Option<usize>,
}
#[derive(Debug, Clone, Serialize)]
@ -76,7 +78,10 @@ pub async fn export_table_data_core(
let col_names: Vec<String> = columns.iter().map(|c| c.name.clone()).collect();
// 4. Optionally filter to requested columns
// 4. Extract primary keys from column metadata (before column filtering)
let primary_keys: Vec<String> = columns.iter().filter(|c| c.is_primary_key).map(|c| c.name.clone()).collect();
// 5. Optionally filter to requested columns
let col_names = if let Some(requested_cols) = &request.columns {
if requested_cols.is_empty() {
col_names
@ -95,7 +100,19 @@ pub async fn export_table_data_core(
return Err("No columns found for table".to_string());
}
// 5. Get total row count for progress estimation
// Use keyset pagination when all PKs are in the selected (filtered) columns.
// This avoids the OFFSET performance penalty for large tables.
// When no PK is available, falls back to offset-based pagination.
let use_keyset = !primary_keys.is_empty() && primary_keys.iter().all(|pk| col_names.contains(pk));
// PK column indices within result rows (for extracting last-row values)
let pk_indices: Vec<usize> = if use_keyset {
primary_keys.iter().map(|pk| col_names.iter().position(|c| c == pk).unwrap()).collect()
} else {
Vec::new()
};
// 6. Get total row count for progress estimation
let count_query = count_sql(&request.table_name, request.schema.as_deref().unwrap_or(""), &db_type);
let total_rows = match execute_on_pool(state, &pool_key, &count_query).await {
Ok(result) => result.rows.first().and_then(|r| r.first()).and_then(|v| match v {
@ -106,7 +123,7 @@ pub async fn export_table_data_core(
Err(_) => None,
};
// 6. Emit initial Running progress
// 7. Emit initial Running progress
on_progress(TableExportProgress {
export_id: request.export_id.clone(),
table_name: request.table_name.clone(),
@ -116,12 +133,15 @@ pub async fn export_table_data_core(
error_message: None,
});
// 7. Create output file
// 8. Create output file
let mut file = std::fs::File::create(&request.file_path).map_err(|e| format!("Failed to create file: {e}"))?;
let mut rows_exported: u64 = 0;
let batch_size = request.batch_size.unwrap_or(DEFAULT_BATCH_SIZE).max(1);
let mut offset: u64 = 0;
let batch_size = DEFAULT_BATCH_SIZE;
// Track last primary key values for keyset pagination
let mut last_pk_values: Vec<Value> = Vec::new();
match request.format.to_lowercase().as_str() {
"csv" => {
@ -144,14 +164,26 @@ pub async fn export_table_data_core(
return Ok(());
}
let sql = pagination_sql(
&col_names,
&request.table_name,
request.schema.as_deref().unwrap_or(""),
&db_type,
offset,
batch_size,
);
let sql = if use_keyset {
keyset_pagination_sql(
&col_names,
&request.table_name,
request.schema.as_deref().unwrap_or(""),
&db_type,
&primary_keys,
&last_pk_values,
batch_size,
)
} else {
pagination_sql(
&col_names,
&request.table_name,
request.schema.as_deref().unwrap_or(""),
&db_type,
offset,
batch_size,
)
};
let result = execute_on_pool(state, &pool_key, &sql).await?;
let row_count = result.rows.len();
@ -173,7 +205,15 @@ pub async fn export_table_data_core(
}
rows_exported += row_count as u64;
offset += row_count as u64;
if use_keyset {
// Keyset pagination: track last PK values for next batch
if let Some(last_row) = result.rows.last() {
last_pk_values = pk_indices.iter().map(|&i| last_row[i].clone()).collect();
}
} else {
offset += row_count as u64;
}
on_progress(TableExportProgress {
export_id: request.export_id.clone(),
@ -206,14 +246,26 @@ pub async fn export_table_data_core(
return Ok(());
}
let sql = pagination_sql(
&col_names,
&request.table_name,
request.schema.as_deref().unwrap_or(""),
&db_type,
offset,
batch_size,
);
let sql = if use_keyset {
keyset_pagination_sql(
&col_names,
&request.table_name,
request.schema.as_deref().unwrap_or(""),
&db_type,
&primary_keys,
&last_pk_values,
batch_size,
)
} else {
pagination_sql(
&col_names,
&request.table_name,
request.schema.as_deref().unwrap_or(""),
&db_type,
offset,
batch_size,
)
};
let result = execute_on_pool(state, &pool_key, &sql).await?;
let row_count = result.rows.len();
@ -223,7 +275,15 @@ pub async fn export_table_data_core(
all_rows.extend(result.rows);
rows_exported += row_count as u64;
offset += row_count as u64;
if use_keyset {
// Keyset pagination: track last PK values for next batch
if let Some(last_row) = all_rows.last() {
last_pk_values = pk_indices.iter().map(|&i| last_row[i].clone()).collect();
}
} else {
offset += row_count as u64;
}
on_progress(TableExportProgress {
export_id: request.export_id.clone(),

View File

@ -1253,6 +1253,87 @@ pub fn count_sql(table: &str, schema: &str, db_type: &DatabaseType) -> String {
format!("SELECT COUNT(*) FROM {full_table}")
}
pub fn keyset_pagination_sql(
columns: &[String],
table: &str,
schema: &str,
db_type: &DatabaseType,
primary_keys: &[String],
last_pk_values: &[serde_json::Value],
limit: usize,
) -> String {
let full_table = qualified_table(table, schema, db_type);
let col_list = columns.iter().map(|c| quote_identifier(c, db_type)).collect::<Vec<_>>().join(", ");
let order =
primary_keys.iter().map(|pk| format!("{} ASC", quote_identifier(pk, db_type))).collect::<Vec<_>>().join(", ");
let where_clause = keyset_where_clause(primary_keys, last_pk_values, db_type);
match db_type {
DatabaseType::SqlServer | DatabaseType::Oracle => {
format!(
"SELECT {col_list} FROM {full_table}{where_clause} ORDER BY {order} OFFSET 0 ROWS FETCH NEXT {limit} ROWS ONLY"
)
}
_ => {
format!("SELECT {col_list} FROM {full_table}{where_clause} ORDER BY {order} LIMIT {limit}")
}
}
}
fn keyset_where_clause(
primary_keys: &[String],
last_pk_values: &[serde_json::Value],
db_type: &DatabaseType,
) -> String {
if primary_keys.is_empty() || last_pk_values.is_empty() {
return String::new();
}
let quoted_keys = primary_keys.iter().map(|pk| quote_identifier(pk, db_type)).collect::<Vec<_>>();
let literals = last_pk_values.iter().map(|v| value_to_sql_literal(v, db_type)).collect::<Vec<_>>();
let comparison_count = quoted_keys.len().min(literals.len());
if comparison_count == 0 {
return String::new();
}
let mut clauses = Vec::with_capacity(comparison_count);
for index in 0..comparison_count {
let mut parts = Vec::with_capacity(index + 1);
for prefix_index in 0..index {
parts.push(format!("{} = {}", quoted_keys[prefix_index], literals[prefix_index]));
}
parts.push(format!("{} > {}", quoted_keys[index], literals[index]));
if parts.len() == 1 {
clauses.push(parts.remove(0));
} else {
clauses.push(format!("({})", parts.join(" AND ")));
}
}
if clauses.len() == 1 {
format!(" WHERE {}", clauses[0])
} else {
format!(" WHERE ({})", clauses.join(" OR "))
}
}
fn value_to_sql_literal(value: &serde_json::Value, _db_type: &DatabaseType) -> String {
match value {
serde_json::Value::Null => "NULL".to_string(),
serde_json::Value::Bool(b) => {
if *b {
"TRUE".to_string()
} else {
"FALSE".to_string()
}
}
serde_json::Value::Number(n) => n.to_string(),
serde_json::Value::String(s) => quote_string_literal(s),
_ => quote_string_literal(&value.to_string()),
}
}
pub async fn execute_on_pool(state: &AppState, pool_key: &str, sql: &str) -> Result<db::QueryResult, String> {
let connections = state.connections.read().await;
let pool = connections.get(pool_key).ok_or("Connection not found")?;
@ -2746,6 +2827,42 @@ mod tests {
assert_eq!(sql, "SELECT \"id\", \"name\" FROM \"public\".\"users\" ORDER BY \"id\" LIMIT 100 OFFSET 200");
}
#[test]
fn sqlserver_keyset_pagination_includes_offset_fetch() {
let sql = keyset_pagination_sql(
&[String::from("id"), String::from("name")],
"users",
"dbo",
&DatabaseType::SqlServer,
&[String::from("id")],
&[],
100,
);
assert_eq!(
sql,
"SELECT [id], [name] FROM [dbo].[users] ORDER BY [id] ASC OFFSET 0 ROWS FETCH NEXT 100 ROWS ONLY"
);
}
#[test]
fn composite_keyset_pagination_uses_portable_lexicographic_predicate() {
let sql = keyset_pagination_sql(
&[String::from("tenant_id"), String::from("id"), String::from("name")],
"users",
"dbo",
&DatabaseType::SqlServer,
&[String::from("tenant_id"), String::from("id")],
&[json!(10), json!(25)],
100,
);
assert_eq!(
sql,
"SELECT [tenant_id], [id], [name] FROM [dbo].[users] WHERE ([tenant_id] > 10 OR ([tenant_id] = 10 AND [id] > 25)) ORDER BY [tenant_id] ASC, [id] ASC OFFSET 0 ROWS FETCH NEXT 100 ROWS ONLY"
);
}
#[test]
fn postgres_generates_index_and_foreign_key_sql() {
let indexes = vec![db::IndexInfo {