mirror of
https://github.com/t8y2/dbx.git
synced 2026-11-01 16:28:46 +00:00
feat(diff): support batch data compare
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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: "数据同步执行成功",
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user