feat(diff): support batch data compare

This commit is contained in:
t8y2
2026-05-23 12:33:14 +08:00
parent 4cd0f2367b
commit d8650df323
4 changed files with 614 additions and 140 deletions
@@ -3,16 +3,45 @@ import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useConnectionStore } from "@/stores/connectionStore";
import { useToast } from "@/composables/useToast";
import { isSchemaAware } from "@/lib/databaseCapabilities";
import { copyToClipboard } from "@/lib/clipboard";
import type { DataCompareResult } from "@/lib/dataCompare";
import * as api from "@/lib/api";
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
import { Copy, GitCompareArrows, Loader2, Play, ArrowLeftRight } from "lucide-vue-next";
import { ArrowLeftRight, CheckSquare, Copy, GitCompareArrows, Loader2, Play, Square } from "lucide-vue-next";
interface CompareColumn {
name: string;
is_primary_key?: boolean;
}
interface DataCompareTableTask {
sourceTable: string;
targetTable: string;
}
type DataCompareTableStatus = "different" | "same" | "error";
interface DataCompareTableResult {
sourceTable: string;
targetTable: string;
keyColumns: string[];
status: DataCompareTableStatus;
added: number;
removed: number;
modified: number;
sourceRowCount: number;
targetRowCount: number;
sourceTruncated: boolean;
targetTruncated: boolean;
syncStatements: string[];
syncSql: string;
error?: string;
}
const { t } = useI18n();
const { toast } = useToast();
@@ -33,6 +62,8 @@ const sourceTable = ref("");
const sourceDatabases = ref<string[]>([]);
const sourceSchemas = ref<string[]>([]);
const sourceTables = ref<string[]>([]);
const sourceTableSearch = ref("");
const selectedSourceTables = ref<Set<string>>(new Set());
const targetConnectionId = ref("");
const targetDatabase = ref("");
@@ -44,12 +75,13 @@ const targetTables = ref<string[]>([]);
const keyColumnsText = ref("");
const rowLimit = ref("1000");
const sourceRowCount = ref<number | null>(null);
const targetRowCount = ref<number | null>(null);
const result = ref<DataCompareResult | null>(null);
const syncSql = ref("");
const syncStatements = ref<string[]>([]);
const batchResults = ref<DataCompareTableResult[]>([]);
const comparing = ref(false);
const compareProgressCurrent = ref(0);
const compareProgressTotal = ref(0);
const compareProgressTable = ref("");
const executing = ref(false);
const executedCount = ref(0);
const executeTotal = ref(0);
@@ -59,16 +91,45 @@ const rowLimitOptions = [1000, 5000, 10000, 50000];
const sqlConnections = computed(() =>
store.connections.filter((connection) => !["redis", "mongodb", "elasticsearch"].includes(connection.db_type)),
);
const selectedSourceTableNames = computed(() =>
sourceTables.value.filter((table) => selectedSourceTables.value.has(table)),
);
const isBatchCompare = computed(() => selectedSourceTableNames.value.length > 1);
const filteredSourceTables = computed(() => {
const query = sourceTableSearch.value.trim().toLowerCase();
if (!query) return sourceTables.value;
return sourceTables.value.filter((table) => table.toLowerCase().includes(query));
});
const allFilteredTablesSelected = computed(
() =>
filteredSourceTables.value.length > 0 &&
filteredSourceTables.value.every((table) => selectedSourceTables.value.has(table)),
);
const compareTasksPreview = computed(() =>
selectedSourceTableNames.value.map((table) => {
const target = isBatchCompare.value ? table : targetTable.value || table;
const matched = !!target && targetTables.value.includes(target);
return {
sourceTable: table,
targetTable: target,
matched,
};
}),
);
const matchedTaskCount = computed(() => compareTasksPreview.value.filter((task) => task.matched).length);
const missingTargetTables = computed(() =>
compareTasksPreview.value.filter((task) => !task.matched).map((task) => task.targetTable || task.sourceTable),
);
const canCompare = computed(
() =>
sourceConnectionId.value &&
sourceDatabase.value &&
sourceSchema.value &&
sourceTable.value &&
selectedSourceTableNames.value.length > 0 &&
targetConnectionId.value &&
targetDatabase.value &&
targetSchema.value &&
targetTable.value,
(!isBatchCompare.value ? !!targetTable.value : true),
);
const keyColumns = computed(() =>
keyColumnsText.value
@@ -76,46 +137,123 @@ const keyColumns = computed(() =>
.map((value) => value.trim())
.filter(Boolean),
);
const rowLimitNumber = computed(() => Number(rowLimit.value) || 1000);
const comparedResults = computed(() => batchResults.value.filter((item) => item.status !== "error"));
const sameTableCount = computed(() => batchResults.value.filter((item) => item.status === "same").length);
const differentTableCount = computed(() => batchResults.value.filter((item) => item.status === "different").length);
const failedTableCount = computed(() => batchResults.value.filter((item) => item.status === "error").length);
const totalAdded = computed(() => batchResults.value.reduce((sum, item) => sum + item.added, 0));
const totalRemoved = computed(() => batchResults.value.reduce((sum, item) => sum + item.removed, 0));
const totalModified = computed(() => batchResults.value.reduce((sum, item) => sum + item.modified, 0));
const hasAnyTruncated = computed(() => batchResults.value.some((item) => item.sourceTruncated || item.targetTruncated));
const hasResults = computed(() => batchResults.value.length > 0);
const summary = computed(() => {
const diff = result.value;
if (!diff) return "";
return t("dataCompare.summary", {
added: diff.added.length,
removed: diff.removed.length,
modified: diff.modified.length,
if (!hasResults.value) return "";
if (batchResults.value.length === 1 && batchResults.value[0]?.status !== "error") {
const item = batchResults.value[0];
return t("dataCompare.summary", {
added: item.added,
removed: item.removed,
modified: item.modified,
});
}
return t("dataCompare.batchSummary", {
tables: batchResults.value.length,
different: differentTableCount.value,
same: sameTableCount.value,
failed: failedTableCount.value,
added: totalAdded.value,
removed: totalRemoved.value,
modified: totalModified.value,
});
});
const compareProgressLabel = computed(() => {
if (!comparing.value || compareProgressTotal.value === 0) return "";
return t("dataCompare.comparingTable", {
current: compareProgressCurrent.value,
total: compareProgressTotal.value,
table: compareProgressTable.value,
});
});
const rowLimitNumber = computed(() => Number(rowLimit.value) || 1000);
const isSourceTruncated = computed(() => sourceRowCount.value !== null && sourceRowCount.value > rowLimitNumber.value);
const isTargetTruncated = computed(() => targetRowCount.value !== null && targetRowCount.value > rowLimitNumber.value);
function connectionIconType(connectionId: string) {
const config = store.getConfig(connectionId);
return config?.driver_profile || config?.db_type || "mysql";
}
function resetSelectedSourceTables(nextTables: Iterable<string>) {
selectedSourceTables.value = new Set(nextTables);
}
function toggleSourceTable(table: string) {
const next = new Set(selectedSourceTables.value);
if (next.has(table)) next.delete(table);
else next.add(table);
resetSelectedSourceTables(next);
}
function toggleSelectAllSourceTables() {
const next = new Set(selectedSourceTables.value);
if (allFilteredTablesSelected.value) {
filteredSourceTables.value.forEach((table) => next.delete(table));
} else {
filteredSourceTables.value.forEach((table) => next.add(table));
}
resetSelectedSourceTables(next);
}
function buildCompareTasks(): DataCompareTableTask[] {
if (!selectedSourceTableNames.value.length) return [];
if (!isBatchCompare.value) {
const table = selectedSourceTableNames.value[0];
return targetTable.value ? [{ sourceTable: table, targetTable: targetTable.value }] : [];
}
return selectedSourceTableNames.value.map((table) => ({
sourceTable: table,
targetTable: table,
}));
}
function clearResult() {
batchResults.value = [];
syncSql.value = "";
syncStatements.value = [];
syncErrors.value = [];
compareProgressCurrent.value = 0;
compareProgressTotal.value = 0;
compareProgressTable.value = "";
}
function swapSourceTarget() {
const previousSelectedTables = [...selectedSourceTableNames.value];
const nextSingleTarget = previousSelectedTables.length === 1 ? (previousSelectedTables[0] ?? "") : "";
const nextSourceSelection =
previousSelectedTables.length <= 1 ? [targetTable.value].filter(Boolean) : previousSelectedTables;
const tmpConnId = sourceConnectionId.value;
const tmpDb = sourceDatabase.value;
const tmpDbs = sourceDatabases.value;
const tmpSchema = sourceSchema.value;
const tmpSchemas = sourceSchemas.value;
const tmpTable = sourceTable.value;
const tmpTables = sourceTables.value;
sourceConnectionId.value = targetConnectionId.value;
sourceDatabase.value = targetDatabase.value;
sourceDatabases.value = targetDatabases.value;
sourceSchema.value = targetSchema.value;
sourceSchemas.value = targetSchemas.value;
sourceTable.value = targetTable.value;
sourceTables.value = targetTables.value;
resetSelectedSourceTables(nextSourceSelection.filter((table) => targetTables.value.includes(table)));
targetConnectionId.value = tmpConnId;
targetDatabase.value = tmpDb;
targetDatabases.value = tmpDbs;
targetSchema.value = tmpSchema;
targetSchemas.value = tmpSchemas;
targetTable.value = tmpTable;
targetTables.value = tmpTables;
targetTable.value = nextSingleTarget;
sourceTable.value = selectedSourceTableNames.value.length === 1 ? selectedSourceTableNames.value[0] : "";
clearResult();
}
@@ -173,6 +311,7 @@ async function loadDatabases(connectionId: string, side: "source" | "target") {
sourceSchema.value = "";
sourceTables.value = [];
sourceTable.value = "";
resetSelectedSourceTables([]);
} else {
targetDatabases.value = names;
targetDatabase.value = names.length === 1 ? names[0] : "";
@@ -194,97 +333,225 @@ async function loadTables(side: "source" | "target") {
const tables = (await api.listTables(connectionId, database, schema))
.filter((table) => table.table_type !== "VIEW")
.map((table) => table.name);
if (side === "source") {
const preferredSelection =
props.prefillTable && tables.includes(props.prefillTable)
? [props.prefillTable]
: [...selectedSourceTables.value].filter((table) => tables.includes(table));
sourceSchema.value = schema;
sourceTables.value = tables;
const preferred =
props.prefillTable && tables.includes(props.prefillTable) ? props.prefillTable : sourceTable.value;
sourceTable.value = tables.includes(preferred) ? preferred : "";
resetSelectedSourceTables(preferredSelection);
sourceTable.value = preferredSelection.length === 1 ? preferredSelection[0] : "";
} else {
targetSchema.value = schema;
targetTables.value = tables;
const preferred = sourceTable.value && tables.includes(sourceTable.value) ? sourceTable.value : targetTable.value;
targetTable.value = tables.includes(preferred) ? preferred : "";
const singleSourceTable = selectedSourceTableNames.value.length === 1 ? selectedSourceTableNames.value[0] : "";
const preferred =
targetTable.value && tables.includes(targetTable.value)
? targetTable.value
: singleSourceTable && tables.includes(singleSourceTable)
? singleSourceTable
: "";
targetTable.value = preferred;
}
}
function clearResult() {
result.value = null;
syncSql.value = "";
syncStatements.value = [];
sourceRowCount.value = null;
targetRowCount.value = null;
async function loadColumnsWithCache(
cache: Map<string, CompareColumn[]>,
connectionId: string,
database: string,
schema: string,
table: string,
): Promise<CompareColumn[]> {
const key = `${connectionId}:${database}:${schema}:${table}`;
const cached = cache.get(key);
if (cached) return cached;
const columns = (await api.getColumns(connectionId, database, schema, table)) as CompareColumn[];
cache.set(key, columns);
return columns;
}
async function inferKeyColumnsForTable(
table: string,
sourceColumnCache?: Map<string, CompareColumn[]>,
): Promise<string[]> {
if (!sourceConnectionId.value || !sourceDatabase.value || !sourceSchema.value || !table) return [];
const columns = sourceColumnCache
? await loadColumnsWithCache(
sourceColumnCache,
sourceConnectionId.value,
sourceDatabase.value,
sourceSchema.value,
table,
)
: (((await api.getColumns(
sourceConnectionId.value,
sourceDatabase.value,
sourceSchema.value,
table,
)) as CompareColumn[]) ?? []);
const primaryKeys = columns.filter((column) => column.is_primary_key).map((column) => column.name);
if (primaryKeys.length > 0) return primaryKeys;
return columns.slice(0, 1).map((column) => column.name);
}
async function inferKeyColumns() {
if (!sourceConnectionId.value || !sourceDatabase.value || !sourceTable.value) return;
const columns = await api.getColumns(
sourceConnectionId.value,
sourceDatabase.value,
sourceSchema.value,
sourceTable.value,
);
const primaryKeys = columns.filter((column) => column.is_primary_key).map((column) => column.name);
keyColumnsText.value = (primaryKeys.length ? primaryKeys : columns.slice(0, 1).map((column) => column.name)).join(
", ",
);
const table = selectedSourceTableNames.value.length === 1 ? selectedSourceTableNames.value[0] : "";
if (!table) return;
const inferred = await inferKeyColumnsForTable(table);
keyColumnsText.value = inferred.join(", ");
}
function resultStatusLabel(status: DataCompareTableStatus): string {
if (status === "different") return t("dataCompare.statusDifferent");
if (status === "same") return t("dataCompare.statusSame");
return t("dataCompare.statusError");
}
function resultStatusClass(status: DataCompareTableStatus): string {
if (status === "different") return "bg-amber-500/15 text-amber-700";
if (status === "same") return "bg-emerald-500/15 text-emerald-700";
return "bg-destructive/15 text-destructive";
}
async function startCompare() {
if (!canCompare.value || comparing.value) return;
const tasks = buildCompareTasks();
if (tasks.length === 0) {
toast(t("dataCompare.noComparableTables"), 5000);
return;
}
comparing.value = true;
clearResult();
compareProgressTotal.value = tasks.length;
const sourceColumnCache = new Map<string, CompareColumn[]>();
const targetColumnCache = new Map<string, CompareColumn[]>();
const results: DataCompareTableResult[] = [];
const mergedStatements: string[] = [];
const mergedSqlParts: string[] = [];
try {
await Promise.all([
store.ensureConnected(sourceConnectionId.value),
store.ensureConnected(targetConnectionId.value),
]);
if (keyColumns.value.length === 0) await inferKeyColumns();
if (keyColumns.value.length === 0) throw new Error(t("dataCompare.noKeyColumns"));
const sourceColumns = await api.getColumns(
sourceConnectionId.value,
sourceDatabase.value,
sourceSchema.value,
sourceTable.value,
);
const targetColumns = await api.getColumns(
targetConnectionId.value,
targetDatabase.value,
targetSchema.value,
targetTable.value,
);
const columns = sourceColumns
.map((column) => column.name)
.filter((column) => targetColumns.some((target) => target.name === column));
const missingKeys = keyColumns.value.filter((column) => !columns.includes(column));
if (missingKeys.length > 0) {
throw new Error(t("dataCompare.missingKeyColumns", { columns: missingKeys.join(", ") }));
for (const [index, task] of tasks.entries()) {
compareProgressCurrent.value = index + 1;
compareProgressTable.value = task.sourceTable;
try {
if (!targetTables.value.includes(task.targetTable)) {
throw new Error(t("dataCompare.targetTableMissing", { table: task.targetTable }));
}
const resolvedKeys =
keyColumns.value.length > 0
? keyColumns.value
: await inferKeyColumnsForTable(task.sourceTable, sourceColumnCache);
if (resolvedKeys.length === 0) {
throw new Error(t("dataCompare.noKeyColumns"));
}
const sourceColumns = await loadColumnsWithCache(
sourceColumnCache,
sourceConnectionId.value,
sourceDatabase.value,
sourceSchema.value,
task.sourceTable,
);
const targetColumns = await loadColumnsWithCache(
targetColumnCache,
targetConnectionId.value,
targetDatabase.value,
targetSchema.value,
task.targetTable,
);
const columns = sourceColumns
.map((column) => column.name)
.filter((column) => targetColumns.some((target) => target.name === column));
const missingKeys = resolvedKeys.filter((column) => !columns.includes(column));
if (missingKeys.length > 0) {
throw new Error(t("dataCompare.missingKeyColumns", { columns: missingKeys.join(", ") }));
}
if (columns.length === 0) {
throw new Error(t("dataCompare.noCommonColumns"));
}
const preparation = await api.prepareDataCompareFromTables({
sourceConnectionId: sourceConnectionId.value,
sourceDatabase: sourceDatabase.value,
sourceSchema: sourceSchema.value,
sourceTable: task.sourceTable,
targetConnectionId: targetConnectionId.value,
targetDatabase: targetDatabase.value,
targetSchema: targetSchema.value,
targetTable: task.targetTable,
columns,
keyColumns: resolvedKeys,
rowLimit: rowLimitNumber.value,
});
const added = preparation.result.added.length;
const removed = preparation.result.removed.length;
const modified = preparation.result.modified.length;
const status: DataCompareTableStatus = added || removed || modified ? "different" : "same";
results.push({
sourceTable: task.sourceTable,
targetTable: task.targetTable,
keyColumns: resolvedKeys,
status,
added,
removed,
modified,
sourceRowCount: preparation.sourceRowCount,
targetRowCount: preparation.targetRowCount,
sourceTruncated: preparation.sourceTruncated,
targetTruncated: preparation.targetTruncated,
syncStatements: preparation.syncStatements,
syncSql: preparation.syncSql,
});
if (preparation.syncStatements.length > 0) {
mergedStatements.push(...preparation.syncStatements);
}
if (preparation.syncSql.trim()) {
mergedSqlParts.push(preparation.syncSql.trim());
}
} catch (e: any) {
results.push({
sourceTable: task.sourceTable,
targetTable: task.targetTable,
keyColumns: keyColumns.value,
status: "error",
added: 0,
removed: 0,
modified: 0,
sourceRowCount: 0,
targetRowCount: 0,
sourceTruncated: false,
targetTruncated: false,
syncStatements: [],
syncSql: "",
error: e?.message || String(e),
});
}
}
if (columns.length === 0) throw new Error(t("dataCompare.noCommonColumns"));
const preparation = await api.prepareDataCompareFromTables({
sourceConnectionId: sourceConnectionId.value,
sourceDatabase: sourceDatabase.value,
sourceSchema: sourceSchema.value,
sourceTable: sourceTable.value,
targetConnectionId: targetConnectionId.value,
targetDatabase: targetDatabase.value,
targetSchema: targetSchema.value,
targetTable: targetTable.value,
columns,
keyColumns: keyColumns.value,
rowLimit: rowLimitNumber.value,
});
sourceRowCount.value = preparation.sourceRowCount;
targetRowCount.value = preparation.targetRowCount;
result.value = preparation.result;
syncStatements.value = preparation.syncStatements;
syncSql.value = preparation.syncSql;
batchResults.value = results;
syncStatements.value = mergedStatements;
syncSql.value = mergedSqlParts.join("\n\n");
} catch (e: any) {
toast(e?.message || String(e), 5000);
} finally {
comparing.value = false;
compareProgressCurrent.value = 0;
compareProgressTotal.value = 0;
compareProgressTable.value = "";
}
}
@@ -332,6 +599,9 @@ watch(sourceConnectionId, (id) => {
sourceSchema.value = "";
sourceSchemas.value = [];
sourceTables.value = [];
sourceTable.value = "";
sourceTableSearch.value = "";
resetSelectedSourceTables([]);
loadDatabases(id, "source").catch((e) => toast(String(e), 5000));
});
watch(targetConnectionId, (id) => {
@@ -340,6 +610,7 @@ watch(targetConnectionId, (id) => {
targetSchema.value = "";
targetSchemas.value = [];
targetTables.value = [];
targetTable.value = "";
loadDatabases(id, "target").catch((e) => toast(String(e), 5000));
});
watch(sourceDatabase, () => {
@@ -348,6 +619,8 @@ watch(sourceDatabase, () => {
sourceSchemas.value = [];
sourceTables.value = [];
sourceTable.value = "";
sourceTableSearch.value = "";
resetSelectedSourceTables([]);
loadSchemas("source", props.prefillSchema).catch((e) => toast(String(e), 5000));
});
watch(targetDatabase, () => {
@@ -362,6 +635,8 @@ watch(sourceSchema, () => {
clearResult();
sourceTables.value = [];
sourceTable.value = "";
sourceTableSearch.value = "";
resetSelectedSourceTables([]);
if (sourceSchema.value) loadTables("source").catch((e) => toast(String(e), 5000));
});
watch(targetSchema, () => {
@@ -370,21 +645,30 @@ watch(targetSchema, () => {
targetTable.value = "";
if (targetSchema.value) loadTables("target").catch((e) => toast(String(e), 5000));
});
watch(sourceTable, (table, previous) => {
watch(selectedSourceTableNames, (tables, previous) => {
clearResult();
if (table !== previous) keyColumnsText.value = "";
if (table && targetTables.value.includes(table)) targetTable.value = table;
if (sourceTable.value) {
inferKeyColumns().catch(() => {});
sourceTable.value = tables.length === 1 ? tables[0] : "";
if (tables.length !== 1) {
keyColumnsText.value = "";
return;
}
const table = tables[0];
if (targetTables.value.includes(table)) {
targetTable.value = table;
} else if (previous?.length === 1 && targetTable.value === previous[0]) {
targetTable.value = "";
}
if (table !== previous?.[0]) {
keyColumnsText.value = "";
}
inferKeyColumns().catch(() => {});
});
watch(targetTable, () => clearResult());
watch(
open,
async (value) => {
if (!value) return;
result.value = null;
syncSql.value = "";
clearResult();
if (props.prefillConnectionId) {
sourceConnectionId.value = props.prefillConnectionId;
await loadDatabases(props.prefillConnectionId, "source");
@@ -392,7 +676,10 @@ watch(
if (props.prefillDatabase) await loadSchemas("source", props.prefillSchema);
if (props.prefillTable) {
await loadTables("source");
if (sourceTables.value.includes(props.prefillTable)) sourceTable.value = props.prefillTable;
if (sourceTables.value.includes(props.prefillTable)) {
resetSelectedSourceTables([props.prefillTable]);
sourceTable.value = props.prefillTable;
}
}
}
},
@@ -402,7 +689,7 @@ watch(
<template>
<Dialog v-model:open="open">
<DialogContent class="sm:max-w-3xl max-h-[85vh] flex flex-col overflow-hidden">
<DialogContent class="sm:max-w-4xl max-h-[85vh] flex flex-col overflow-hidden">
<DialogHeader>
<DialogTitle class="flex items-center gap-2">
<GitCompareArrows class="w-4 h-4" />
@@ -452,14 +739,61 @@ watch(
<SelectItem v-for="schema in sourceSchemas" :key="schema" :value="schema">{{ schema }}</SelectItem>
</SelectContent>
</Select>
<Select :model-value="sourceTable" @update:model-value="(v: any) => (sourceTable = String(v))">
<SelectTrigger class="h-8 text-xs"
><SelectValue :placeholder="t('dataCompare.selectTable')"
/></SelectTrigger>
<SelectContent>
<SelectItem v-for="table in sourceTables" :key="table" :value="table">{{ table }}</SelectItem>
</SelectContent>
</Select>
<div class="space-y-2 rounded-lg border p-2">
<div class="flex items-center justify-between gap-2">
<Label class="text-xs font-medium">{{ t("dataCompare.sourceTables") }}</Label>
<div v-if="sourceTables.length" class="text-[11px] text-muted-foreground">
{{
t("dataCompare.selectedTables", {
selected: selectedSourceTableNames.length,
total: sourceTables.length,
})
}}
</div>
</div>
<Input
v-if="sourceTables.length > 5"
v-model="sourceTableSearch"
class="h-7 text-xs"
:placeholder="t('dataCompare.searchTables')"
/>
<div class="flex items-center gap-2">
<Button
v-if="sourceTables.length"
variant="outline"
size="sm"
class="h-7 px-2 text-xs"
@click="toggleSelectAllSourceTables"
>
{{
allFilteredTablesSelected ? t("dataCompare.deselectAllTables") : t("dataCompare.selectAllTables")
}}
</Button>
</div>
<div v-if="!sourceConnectionId || !sourceDatabase" class="text-xs text-muted-foreground py-3 text-center">
{{ t("dataCompare.selectSourceTables") }}
</div>
<div v-else-if="sourceTables.length === 0" class="text-xs text-muted-foreground py-3 text-center">
{{ t("dataCompare.noTables") }}
</div>
<div v-else class="max-h-40 overflow-auto rounded border">
<button
v-for="table in filteredSourceTables"
:key="table"
type="button"
class="flex w-full items-center gap-2 px-2.5 py-1.5 text-left text-xs hover:bg-muted/50"
@click="toggleSourceTable(table)"
>
<CheckSquare v-if="selectedSourceTables.has(table)" class="w-3.5 h-3.5 text-primary shrink-0" />
<Square v-else class="w-3.5 h-3.5 text-muted-foreground/40 shrink-0" />
<span class="truncate">{{ table }}</span>
</button>
</div>
</div>
</div>
<div class="flex items-center pt-6">
@@ -508,24 +842,51 @@ watch(
<SelectItem v-for="schema in targetSchemas" :key="schema" :value="schema">{{ schema }}</SelectItem>
</SelectContent>
</Select>
<Select :model-value="targetTable" @update:model-value="(v: any) => (targetTable = String(v))">
<SelectTrigger class="h-8 text-xs"
><SelectValue :placeholder="t('dataCompare.selectTable')"
/></SelectTrigger>
<SelectContent>
<SelectItem v-for="table in targetTables" :key="table" :value="table">{{ table }}</SelectItem>
</SelectContent>
</Select>
<div v-if="!isBatchCompare" class="space-y-1">
<Label class="text-xs font-medium">{{ t("dataCompare.targetTable") }}</Label>
<Select :model-value="targetTable" @update:model-value="(v: any) => (targetTable = String(v))">
<SelectTrigger class="h-8 text-xs">
<SelectValue :placeholder="t('dataCompare.selectTable')" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="table in targetTables" :key="table" :value="table">{{ table }}</SelectItem>
</SelectContent>
</Select>
</div>
<div v-else class="space-y-2 rounded-lg border p-3 text-xs">
<div class="font-medium">{{ t("dataCompare.autoMatchHint") }}</div>
<div class="text-muted-foreground">
{{
t("dataCompare.matchedTables", { matched: matchedTaskCount, total: selectedSourceTableNames.length })
}}
</div>
<div v-if="missingTargetTables.length" class="text-destructive">
{{ t("dataCompare.missingTargetTables", { tables: missingTargetTables.join(", ") }) }}
</div>
<div v-if="compareTasksPreview.length" class="max-h-36 overflow-auto rounded border bg-muted/20">
<div
v-for="task in compareTasksPreview"
:key="`${task.sourceTable}:${task.targetTable}`"
class="flex items-center justify-between gap-2 border-b px-2 py-1 last:border-b-0"
>
<span class="truncate font-mono">{{ task.sourceTable }}</span>
<span class="text-muted-foreground">→</span>
<span class="truncate font-mono" :class="task.matched ? '' : 'text-destructive'">
{{ task.targetTable || t("dataCompare.targetTableMissing", { table: task.sourceTable }) }}
</span>
</div>
</div>
</div>
</div>
</div>
<div class="space-y-1">
<Label class="text-xs font-medium">{{ t("dataCompare.keyColumns") }}</Label>
<input
v-model="keyColumnsText"
class="h-8 w-full rounded-md border bg-background px-2 text-xs"
:placeholder="t('dataCompare.keyColumnsPlaceholder')"
/>
<Input v-model="keyColumnsText" class="h-8 text-xs" :placeholder="t('dataCompare.keyColumnsPlaceholder')" />
<div class="text-[11px] text-muted-foreground">
{{ t("dataCompare.keyColumnsAutoHint") }}
</div>
</div>
<div class="space-y-1">
@@ -542,40 +903,87 @@ watch(
</Select>
</div>
<Button size="sm" :disabled="!canCompare || comparing" @click="startCompare">
<Loader2 v-if="comparing" class="w-3.5 h-3.5 animate-spin mr-1" />
<GitCompareArrows v-else class="w-3.5 h-3.5 mr-1" />
{{ t("dataCompare.compare") }}
</Button>
<div class="flex items-center gap-3">
<Button size="sm" :disabled="!canCompare || comparing" @click="startCompare">
<Loader2 v-if="comparing" class="w-3.5 h-3.5 animate-spin mr-1" />
<GitCompareArrows v-else class="w-3.5 h-3.5 mr-1" />
{{ t("dataCompare.compare") }}
</Button>
<span v-if="compareProgressLabel" class="text-xs text-muted-foreground">{{ compareProgressLabel }}</span>
</div>
<div v-if="result" class="rounded-lg border p-3 text-sm">
{{ summary }}
<div v-if="sourceRowCount !== null && targetRowCount !== null" class="mt-1 text-xs text-muted-foreground">
{{
t("dataCompare.rowCounts", {
source: sourceRowCount,
target: targetRowCount,
limit: rowLimitNumber,
})
}}
<div v-if="hasResults" class="space-y-3">
<div class="rounded-lg border p-3 text-sm">
{{ summary }}
<div v-if="hasAnyTruncated" class="mt-1 text-xs text-yellow-600">
{{ t("dataCompare.truncatedWarning") }}
</div>
</div>
<div v-if="isSourceTruncated || isTargetTruncated" class="mt-1 text-xs text-yellow-600">
{{ t("dataCompare.truncatedWarning") }}
<div class="rounded-lg border overflow-hidden">
<div class="max-h-64 overflow-auto">
<table class="w-full text-xs">
<thead class="bg-muted sticky top-0 z-10">
<tr>
<th class="px-3 py-2 text-left font-medium">{{ t("diff.table") }}</th>
<th class="px-3 py-2 text-left font-medium">{{ t("dataCompare.targetTable") }}</th>
<th class="px-3 py-2 text-left font-medium">{{ t("diff.status") }}</th>
<th class="px-3 py-2 text-left font-medium">{{ t("diff.details") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="item in batchResults" :key="`${item.sourceTable}:${item.targetTable}`" class="border-t">
<td class="px-3 py-2 align-top font-mono">{{ item.sourceTable }}</td>
<td class="px-3 py-2 align-top font-mono text-muted-foreground">{{ item.targetTable }}</td>
<td class="px-3 py-2 align-top">
<span class="inline-flex rounded px-2 py-0.5 text-[11px]" :class="resultStatusClass(item.status)">
{{ resultStatusLabel(item.status) }}
</span>
</td>
<td class="px-3 py-2 align-top text-muted-foreground">
<div v-if="item.status === 'error'" class="text-destructive">{{ item.error }}</div>
<template v-else>
<div>
{{
t("dataCompare.summary", {
added: item.added,
removed: item.removed,
modified: item.modified,
})
}}
</div>
<div class="mt-1">
{{
t("dataCompare.rowCounts", {
source: item.sourceRowCount,
target: item.targetRowCount,
limit: rowLimitNumber,
})
}}
</div>
<div class="mt-1">
{{ t("dataCompare.keyColumnsInline", { columns: item.keyColumns.join(", ") }) }}
</div>
</template>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div v-if="syncSql.trim()" class="space-y-1">
<Label class="text-xs font-medium">{{ t("diff.generatedSql") }}</Label>
<textarea
v-model="syncSql"
class="w-full h-48 rounded-lg border bg-muted/20 p-3 font-mono text-xs resize-none focus:outline-none focus:ring-1 focus:ring-ring"
/>
</div>
<div v-else-if="differentTableCount === 0 && failedTableCount === 0" class="text-sm text-muted-foreground">
{{ t("dataCompare.noDifferences") }}
</div>
</div>
<div v-if="result && syncSql.trim()" class="space-y-1">
<Label class="text-xs font-medium">{{ t("diff.generatedSql") }}</Label>
<textarea
v-model="syncSql"
class="w-full h-48 rounded-lg border bg-muted/20 p-3 font-mono text-xs resize-none focus:outline-none focus:ring-1 focus:ring-ring"
/>
</div>
<div v-else-if="result" class="text-sm text-muted-foreground">
{{ t("dataCompare.noDifferences") }}
</div>
<!-- Sync Errors -->
<div v-if="syncErrors.length > 0" class="space-y-1">
<Label class="text-xs font-medium text-destructive">
{{ t("diff.syncSummary", { success: executeTotal - syncErrors.length, failed: syncErrors.length }) }}
@@ -591,7 +999,7 @@ watch(
</div>
</div>
<DialogFooter v-if="result && syncSql.trim()" class="flex items-center gap-2">
<DialogFooter v-if="hasResults && syncSql.trim()" class="flex items-center gap-2">
<span v-if="executing" class="text-xs text-muted-foreground mr-auto">
{{ t("diff.syncProgress", { current: executedCount, total: executeTotal }) }}
</span>
+22
View File
@@ -1165,8 +1165,21 @@ export default {
dataCompare: {
title: "Compare Data",
selectTable: "Select table",
sourceTables: "Source tables",
targetTable: "Target table",
searchTables: "Search tables...",
selectedTables: "{selected}/{total} selected",
selectAllTables: "Select all",
deselectAllTables: "Deselect all",
selectSourceTables: "Select source connection and database first, then choose tables to compare",
noTables: "No tables available for compare",
autoMatchHint: "Batch mode automatically matches target tables by the same name",
matchedTables: "{matched}/{total} tables matched",
missingTargetTables: "Missing same-name target tables: {tables}",
keyColumns: "Key Columns",
keyColumnsPlaceholder: "Comma-separated primary or unique columns",
keyColumnsAutoHint:
"Leave blank to auto-detect primary keys for single-table compare, or per table in batch compare.",
rowLimit: "Compare row limit",
rowLimitOption: "{count} rows",
rowCounts: "Source {source} rows, target {target} rows; comparing up to the first {limit} rows",
@@ -1174,9 +1187,18 @@ export default {
"The table exceeds this compare limit, so results only cover loaded rows. Increase the limit or use chunked compare later.",
compare: "Compare Data",
summary: "Added {added}, removed {removed}, modified {modified}",
batchSummary:
"Compared {tables} tables: {different} different, {same} identical, {failed} failed; added {added}, removed {removed}, modified {modified}",
comparingTable: "Comparing {current}/{total}: {table}",
keyColumnsInline: "Key columns: {columns}",
statusDifferent: "Different",
statusSame: "Same",
statusError: "Failed",
noKeyColumns: "Select at least one key column",
missingKeyColumns: "Key columns must exist in both source and target tables: {columns}",
noCommonColumns: "Source and target tables do not have common columns to compare",
targetTableMissing: "Target table not found: {table}",
noComparableTables: "No comparable tables found. Check whether same-name target tables exist.",
noDifferences: "Data is identical, no sync SQL needed",
syncSuccess: "Data sync executed successfully",
},
+23
View File
@@ -1063,8 +1063,21 @@ export default {
dataCompare: {
title: "Comparar datos",
selectTable: "Seleccionar tabla",
sourceTables: "Tablas de origen",
targetTable: "Tabla de destino",
searchTables: "Buscar tablas...",
selectedTables: "{selected}/{total} seleccionadas",
selectAllTables: "Seleccionar todo",
deselectAllTables: "Deseleccionar todo",
selectSourceTables: "Selecciona primero la conexión y base de datos de origen, luego las tablas a comparar",
noTables: "No hay tablas disponibles para comparar",
autoMatchHint: "El modo por lotes empareja automáticamente tablas de destino con el mismo nombre",
matchedTables: "{matched}/{total} tablas emparejadas",
missingTargetTables: "Faltan tablas de destino con el mismo nombre: {tables}",
keyColumns: "Columnas clave",
keyColumnsPlaceholder: "Columnas primarias o únicas separadas por comas",
keyColumnsAutoHint:
"Déjalo vacío para detectar claves primarias automáticamente en comparación simple o por tabla en lote.",
rowLimit: "Límite de filas a comparar",
rowLimitOption: "{count} filas",
rowCounts: "Origen {source} filas, destino {target} filas; se comparan hasta las primeras {limit} filas",
@@ -1072,9 +1085,19 @@ export default {
"La tabla supera este límite de comparación, por lo que los resultados solo cubren las filas cargadas. Aumenta el límite o usa comparación por fragmentos más adelante.",
compare: "Comparar datos",
summary: "Agregadas {added}, eliminadas {removed}, modificadas {modified}",
batchSummary:
"Se compararon {tables} tablas: {different} con diferencias, {same} iguales, {failed} con error; agregadas {added}, eliminadas {removed}, modificadas {modified}",
comparingTable: "Comparando {current}/{total}: {table}",
keyColumnsInline: "Columnas clave: {columns}",
statusDifferent: "Con diferencias",
statusSame: "Igual",
statusError: "Error",
noKeyColumns: "Selecciona al menos una columna clave",
missingKeyColumns: "Las columnas clave deben existir en las tablas de origen y destino: {columns}",
noCommonColumns: "Las tablas de origen y destino no tienen columnas en común para comparar",
targetTableMissing: "No se encontró la tabla de destino: {table}",
noComparableTables:
"No se encontraron tablas comparables. Verifica si existen tablas de destino con el mismo nombre.",
noDifferences: "Los datos son idénticos, no se necesita SQL de sincronización",
syncSuccess: "Sincronización de datos ejecutada exitosamente",
},
+21
View File
@@ -1144,17 +1144,38 @@ export default {
dataCompare: {
title: "比较数据",
selectTable: "选择表",
sourceTables: "源表",
targetTable: "目标表",
searchTables: "搜索表名...",
selectedTables: "已选择 {selected}/{total}",
selectAllTables: "全选",
deselectAllTables: "取消全选",
selectSourceTables: "请先选择源连接和数据库,再选择要比较的表",
noTables: "暂无可比较的表",
autoMatchHint: "多表模式会按同名表自动匹配目标表",
matchedTables: "已匹配 {matched}/{total} 张表",
missingTargetTables: "目标端缺少同名表:{tables}",
keyColumns: "匹配字段",
keyColumnsPlaceholder: "用逗号分隔主键或唯一键字段",
keyColumnsAutoHint: "留空时,单表会自动推断主键;多表会对每张表分别自动推断。",
rowLimit: "比较行数上限",
rowLimitOption: "{count} 行",
rowCounts: "源表 {source} 行,目标表 {target} 行;本次最多比较前 {limit} 行",
truncatedWarning: "表数据超过本次比较上限,结果只代表已加载范围。建议提高上限或后续使用分块比较。",
compare: "开始比较数据",
summary: "新增 {added} 行,删除 {removed} 行,修改 {modified} 行",
batchSummary:
"已比较 {tables} 张表:存在差异 {different} 张,一致 {same} 张,失败 {failed} 张;累计新增 {added} 行,删除 {removed} 行,修改 {modified} 行",
comparingTable: "正在比较 {current}/{total}:{table}",
keyColumnsInline: "匹配字段:{columns}",
statusDifferent: "有差异",
statusSame: "一致",
statusError: "失败",
noKeyColumns: "请至少选择一个匹配字段",
missingKeyColumns: "匹配字段在源表和目标表中都必须存在:{columns}",
noCommonColumns: "源表和目标表没有可比较的同名字段",
targetTableMissing: "目标端找不到表:{table}",
noComparableTables: "没有可比较的表,请检查目标端是否存在同名表",
noDifferences: "数据完全一致,无需生成同步 SQL",
syncSuccess: "数据同步执行成功",
},