feat(schema-diff): professional DDL diff view with deployment-aware colors and custom themes (#1112)
Co-authored-by: Sam <14344444@@qq.com>
This commit is contained in:
1 parent
2554ed95be
commit
ef5ed05ae7
41 files changed
+5695
-1215
No files matched your search
@@ -0,0 +1,457 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
|
||||
import * as api from "@/lib/api";
|
||||
import { isSchemaAware } from "@/lib/databaseCapabilities";
|
||||
import { ArrowLeftRight, GitCompareArrows, Save, FolderOpen, Settings, X } from "@lucide/vue";
|
||||
import type { SchemaDiffConfig, SchemaDiffCompareOptions } from "@/types/schemaDiff";
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useConnectionStore();
|
||||
|
||||
const props = defineProps<{
|
||||
configs: SchemaDiffConfig[];
|
||||
activeConfigId: string;
|
||||
sourceConnectionId: string;
|
||||
sourceDatabase: string;
|
||||
sourceSchema: string;
|
||||
targetConnectionId: string;
|
||||
targetDatabase: string;
|
||||
targetSchema: string;
|
||||
ignoreComments: boolean;
|
||||
options: SchemaDiffCompareOptions;
|
||||
loading: boolean;
|
||||
recentConfigs: SchemaDiffConfig[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:sourceConnectionId", value: string): void;
|
||||
(e: "update:sourceDatabase", value: string): void;
|
||||
(e: "update:sourceSchema", value: string): void;
|
||||
(e: "update:targetConnectionId", value: string): void;
|
||||
(e: "update:targetDatabase", value: string): void;
|
||||
(e: "update:targetSchema", value: string): void;
|
||||
(e: "update:ignoreComments", value: boolean): void;
|
||||
(e: "compare"): void;
|
||||
(e: "saveConfig"): void;
|
||||
(e: "loadConfig"): void;
|
||||
(e: "showOptions"): void;
|
||||
(e: "swap"): void;
|
||||
(e: "loadHistoryConfig", config: SchemaDiffConfig): void;
|
||||
(e: "deleteHistoryConfig", configId: string): void;
|
||||
}>();
|
||||
|
||||
const sourceDatabases = ref<string[]>([]);
|
||||
const sourceSchemas = ref<string[]>([]);
|
||||
const targetDatabases = ref<string[]>([]);
|
||||
const targetSchemas = ref<string[]>([]);
|
||||
const sourceDbVersion = ref<string | null>(null);
|
||||
const targetDbVersion = ref<string | null>(null);
|
||||
|
||||
const sqlConnections = computed(() => store.connections.filter((c: any) => c.db_type !== "mongodb" && c.db_type !== "redis"));
|
||||
|
||||
const sourceConfig = computed(() => store.getConfig(props.sourceConnectionId));
|
||||
const targetConfig = computed(() => store.getConfig(props.targetConnectionId));
|
||||
|
||||
const canCompare = computed(() => {
|
||||
return props.sourceConnectionId && props.targetConnectionId && props.sourceDatabase && props.targetDatabase && (!isSchemaAware(sourceConfig.value?.db_type) || props.sourceSchema) && (!isSchemaAware(targetConfig.value?.db_type) || props.targetSchema);
|
||||
});
|
||||
|
||||
async function loadDatabases(connectionId: string, side: "source" | "target") {
|
||||
if (!connectionId) return;
|
||||
try {
|
||||
await store.ensureConnected(connectionId);
|
||||
const dbs = await api.listDatabases(connectionId);
|
||||
const dbNames = Array.isArray(dbs) ? dbs.map((db: any) => (typeof db === "string" ? db : db.name || db.database)) : [];
|
||||
if (side === "source") {
|
||||
sourceDatabases.value = dbNames;
|
||||
if (props.sourceDatabase) {
|
||||
await fetchDbVersion(connectionId, props.sourceDatabase, props.sourceSchema, "source");
|
||||
// Ensure schema list is loaded after databases are available (handles race with sourceDatabase watcher)
|
||||
if (isSchemaAware(sourceConfig.value?.db_type)) {
|
||||
await loadSchemas("source");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
targetDatabases.value = dbNames;
|
||||
if (props.targetDatabase) {
|
||||
await fetchDbVersion(connectionId, props.targetDatabase, props.targetSchema, "target");
|
||||
// Ensure schema list is loaded after databases are available (handles race with targetDatabase watcher)
|
||||
if (isSchemaAware(targetConfig.value?.db_type)) {
|
||||
await loadSchemas("target");
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if (side === "source") {
|
||||
sourceDatabases.value = [];
|
||||
sourceDbVersion.value = null;
|
||||
} else {
|
||||
targetDatabases.value = [];
|
||||
targetDbVersion.value = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSchemas(side: "source" | "target") {
|
||||
const connectionId = side === "source" ? props.sourceConnectionId : props.targetConnectionId;
|
||||
const database = side === "source" ? props.sourceDatabase : props.targetDatabase;
|
||||
const schema = side === "source" ? props.sourceSchema : props.targetSchema;
|
||||
if (!connectionId || !database) return;
|
||||
|
||||
try {
|
||||
await store.ensureConnected(connectionId);
|
||||
const schemas = await api.listSchemas(connectionId, database);
|
||||
if (side === "source") {
|
||||
sourceSchemas.value = schemas;
|
||||
} else {
|
||||
targetSchemas.value = schemas;
|
||||
}
|
||||
await fetchDbVersion(connectionId, database, schema, side);
|
||||
} catch {
|
||||
if (side === "source") {
|
||||
sourceSchemas.value = [];
|
||||
} else {
|
||||
targetSchemas.value = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.sourceConnectionId,
|
||||
async (id) => {
|
||||
if (id) {
|
||||
await loadDatabases(id, "source");
|
||||
} else {
|
||||
sourceDatabases.value = [];
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.sourceDatabase,
|
||||
async (db) => {
|
||||
if (db && props.sourceConnectionId) {
|
||||
await loadSchemas("source");
|
||||
} else {
|
||||
sourceSchemas.value = [];
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.targetConnectionId,
|
||||
async (id) => {
|
||||
if (id) {
|
||||
await loadDatabases(id, "target");
|
||||
} else {
|
||||
targetDatabases.value = [];
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.targetDatabase,
|
||||
async (db) => {
|
||||
if (db && props.targetConnectionId) {
|
||||
await loadSchemas("target");
|
||||
} else {
|
||||
targetSchemas.value = [];
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function connectionIconType(connectionId: string) {
|
||||
const c = store.getConfig(connectionId);
|
||||
return c?.driver_profile || c?.db_type || "mysql";
|
||||
}
|
||||
|
||||
function getConnectionInfo(connectionId: string) {
|
||||
const c = store.getConfig(connectionId);
|
||||
if (!c) return null;
|
||||
return {
|
||||
name: c.name,
|
||||
dbType: c.db_type,
|
||||
host: c.host,
|
||||
port: c.port,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchDbVersion(connectionId: string, database: string, schema: string, side: "source" | "target") {
|
||||
try {
|
||||
await store.ensureConnected(connectionId);
|
||||
const config = store.getConfig(connectionId);
|
||||
const dbType = config?.db_type;
|
||||
let sql = "";
|
||||
switch (dbType) {
|
||||
case "postgres":
|
||||
case "opengauss":
|
||||
sql = "SELECT version()";
|
||||
break;
|
||||
case "mysql":
|
||||
sql = "SELECT VERSION()";
|
||||
break;
|
||||
case "sqlite":
|
||||
sql = "SELECT sqlite_version()";
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
const result = await api.executeQuery(connectionId, database, sql, schema || undefined);
|
||||
if (result.rows && result.rows.length > 0) {
|
||||
const version = String(result.rows[0][0]);
|
||||
if (side === "source") {
|
||||
sourceDbVersion.value = version;
|
||||
} else {
|
||||
targetDbVersion.value = version;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[fetchDbVersion] Failed to fetch version for ${side}:`, e);
|
||||
if (side === "source") {
|
||||
sourceDbVersion.value = null;
|
||||
} else {
|
||||
targetDbVersion.value = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-center gap-4 py-2">
|
||||
<div class="text-center">
|
||||
<div class="text-xs text-muted-foreground">{{ sourceConfig?.name || t("diff.source") }}</div>
|
||||
<div class="text-xs font-medium">{{ sourceDatabase }}{{ sourceSchema ? `.${sourceSchema}` : "" }}</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<DatabaseIcon v-if="sourceConnectionId" :db-type="connectionIconType(sourceConnectionId)" class="w-5 h-5" />
|
||||
<ArrowLeftRight class="w-4 h-4 text-muted-foreground" />
|
||||
<DatabaseIcon v-if="targetConnectionId" :db-type="connectionIconType(targetConnectionId)" class="w-5 h-5" />
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-xs text-muted-foreground">{{ targetConfig?.name || t("diff.target") }}</div>
|
||||
<div class="text-xs font-medium">{{ targetDatabase }}{{ targetSchema ? `.${targetSchema}` : "" }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Source / Target Selection -->
|
||||
<div class="grid grid-cols-[1fr_auto_1fr] gap-4 items-start">
|
||||
<!-- Source Side -->
|
||||
<div class="space-y-3">
|
||||
<div class="text-sm font-medium text-blue-500">{{ t("diff.source") }}</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">{{ t("diff.connection") }}</Label>
|
||||
<Select :model-value="sourceConnectionId" @update:model-value="(v: any) => $emit('update:sourceConnectionId', String(v))">
|
||||
<SelectTrigger class="h-8 text-xs">
|
||||
<div class="flex items-center gap-2">
|
||||
<DatabaseIcon v-if="sourceConnectionId" :db-type="connectionIconType(sourceConnectionId)" class="w-3.5 h-3.5" />
|
||||
<SelectValue :placeholder="t('diff.selectConnection')" />
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="c in sqlConnections" :key="c.id" :value="c.id">
|
||||
<div class="flex items-center gap-2">
|
||||
<DatabaseIcon :db-type="c.driver_profile || c.db_type" class="w-3.5 h-3.5" />
|
||||
{{ c.name }}
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">{{ t("diff.database") }}</Label>
|
||||
<Select :model-value="sourceDatabase" @update:model-value="(v: any) => $emit('update:sourceDatabase', String(v))">
|
||||
<SelectTrigger class="h-8 text-xs" :disabled="!sourceDatabases.length">
|
||||
<SelectValue :placeholder="t('diff.selectDatabase')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="db in sourceDatabases" :key="db" :value="db">{{ db }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div v-if="isSchemaAware(sourceConfig?.db_type)" class="space-y-1.5">
|
||||
<Label class="text-xs">{{ t("diff.schema") }}</Label>
|
||||
<Select :model-value="sourceSchema" @update:model-value="(v: any) => $emit('update:sourceSchema', String(v))">
|
||||
<SelectTrigger class="h-8 text-xs" :disabled="!sourceSchemas.length">
|
||||
<SelectValue :placeholder="t('diff.selectSchema')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="schema in sourceSchemas" :key="schema" :value="schema">{{ schema }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<!-- Source Info -->
|
||||
<div v-if="getConnectionInfo(sourceConnectionId)" class="mt-4 p-3 rounded-lg bg-muted/30 border space-y-1.5">
|
||||
<div class="text-xs font-medium text-blue-500">{{ t("diff.info") }}</div>
|
||||
<div class="grid grid-cols-[80px_1fr] gap-x-2 gap-y-0.5 text-xs">
|
||||
<span class="text-muted-foreground">{{ t("diff.connType") }}</span>
|
||||
<span>{{ getConnectionInfo(sourceConnectionId)?.dbType }}</span>
|
||||
<span class="text-muted-foreground">{{ t("diff.connName") }}</span>
|
||||
<span>{{ getConnectionInfo(sourceConnectionId)?.name }}</span>
|
||||
<span class="text-muted-foreground">{{ t("diff.host") }}</span>
|
||||
<span>{{ getConnectionInfo(sourceConnectionId)?.host }}</span>
|
||||
<span class="text-muted-foreground">{{ t("diff.port") }}</span>
|
||||
<span>{{ getConnectionInfo(sourceConnectionId)?.port }}</span>
|
||||
<span class="text-muted-foreground">{{ t("diff.serverVersion") }}</span>
|
||||
<span>{{ sourceDbVersion || "--" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Swap Button -->
|
||||
<div class="pt-8">
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8" @click="$emit('swap')">
|
||||
<ArrowLeftRight class="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Target Side -->
|
||||
<div class="space-y-3">
|
||||
<div class="text-sm font-medium text-green-500">{{ t("diff.target") }}</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">{{ t("diff.connection") }}</Label>
|
||||
<Select :model-value="targetConnectionId" @update:model-value="(v: any) => $emit('update:targetConnectionId', String(v))">
|
||||
<SelectTrigger class="h-8 text-xs">
|
||||
<div class="flex items-center gap-2">
|
||||
<DatabaseIcon v-if="targetConnectionId" :db-type="connectionIconType(targetConnectionId)" class="w-3.5 h-3.5" />
|
||||
<SelectValue :placeholder="t('diff.selectConnection')" />
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="c in sqlConnections" :key="c.id" :value="c.id">
|
||||
<div class="flex items-center gap-2">
|
||||
<DatabaseIcon :db-type="c.driver_profile || c.db_type" class="w-3.5 h-3.5" />
|
||||
{{ c.name }}
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">{{ t("diff.database") }}</Label>
|
||||
<Select :model-value="targetDatabase" @update:model-value="(v: any) => $emit('update:targetDatabase', String(v))">
|
||||
<SelectTrigger class="h-8 text-xs" :disabled="!targetDatabases.length">
|
||||
<SelectValue :placeholder="t('diff.selectDatabase')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="db in targetDatabases" :key="db" :value="db">{{ db }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div v-if="isSchemaAware(targetConfig?.db_type)" class="space-y-1.5">
|
||||
<Label class="text-xs">{{ t("diff.schema") }}</Label>
|
||||
<Select :model-value="targetSchema" @update:model-value="(v: any) => $emit('update:targetSchema', String(v))">
|
||||
<SelectTrigger class="h-8 text-xs" :disabled="!targetSchemas.length">
|
||||
<SelectValue :placeholder="t('diff.selectSchema')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="schema in targetSchemas" :key="schema" :value="schema">{{ schema }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<!-- Target Info -->
|
||||
<div v-if="getConnectionInfo(targetConnectionId)" class="mt-4 p-3 rounded-lg bg-muted/30 border space-y-1.5">
|
||||
<div class="text-xs font-medium text-green-500">{{ t("diff.info") }}</div>
|
||||
<div class="grid grid-cols-[80px_1fr] gap-x-2 gap-y-0.5 text-xs">
|
||||
<span class="text-muted-foreground">{{ t("diff.connType") }}</span>
|
||||
<span>{{ getConnectionInfo(targetConnectionId)?.dbType }}</span>
|
||||
<span class="text-muted-foreground">{{ t("diff.connName") }}</span>
|
||||
<span>{{ getConnectionInfo(targetConnectionId)?.name }}</span>
|
||||
<span class="text-muted-foreground">{{ t("diff.host") }}</span>
|
||||
<span>{{ getConnectionInfo(targetConnectionId)?.host }}</span>
|
||||
<span class="text-muted-foreground">{{ t("diff.port") }}</span>
|
||||
<span>{{ getConnectionInfo(targetConnectionId)?.port }}</span>
|
||||
<span class="text-muted-foreground">{{ t("diff.serverVersion") }}</span>
|
||||
<span>{{ targetDbVersion || "--" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Options -->
|
||||
<div class="flex items-center gap-2 p-3 rounded-lg border bg-muted/20">
|
||||
<input id="schema-diff-ignore-comments" :checked="ignoreComments" type="checkbox" class="accent-primary" @change="$emit('update:ignoreComments', ($event.target as HTMLInputElement).checked)" />
|
||||
<Label for="schema-diff-ignore-comments" class="cursor-pointer text-xs">
|
||||
{{ t("diff.ignoreComments") }}
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<!-- Recent Configs Dropdown -->
|
||||
<div v-if="recentConfigs.length > 0" class="flex items-center gap-2">
|
||||
<Label class="text-xs text-muted-foreground">{{ t("diff.recentConfigs") }}</Label>
|
||||
<Select
|
||||
:model-value="''"
|
||||
@update:model-value="
|
||||
(v: any) => {
|
||||
const config = recentConfigs.find((c) => c.id === v);
|
||||
if (config) $emit('loadHistoryConfig', config);
|
||||
}
|
||||
"
|
||||
>
|
||||
<SelectTrigger class="h-8 text-xs w-[280px]">
|
||||
<SelectValue :placeholder="t('diff.selectRecentConfig')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="config in recentConfigs" :key="config.id" :value="config.id" class="pr-8">
|
||||
<div class="flex items-center justify-between w-full gap-2">
|
||||
<div class="flex flex-col gap-0.5 min-w-0">
|
||||
<span class="text-xs font-medium truncate">{{ config.name }}</span>
|
||||
<span class="text-[10px] text-muted-foreground truncate">
|
||||
{{ store.getConfig(config.sourceConnectionId)?.name || config.sourceConnectionId }}
|
||||
/{{ config.sourceDatabase }}{{ config.sourceSchema ? `.${config.sourceSchema}` : "" }}
|
||||
→
|
||||
{{ store.getConfig(config.targetConnectionId)?.name || config.targetConnectionId }}
|
||||
/{{ config.targetDatabase }}{{ config.targetSchema ? `.${config.targetSchema}` : "" }}
|
||||
</span>
|
||||
</div>
|
||||
<button class="shrink-0 p-1 rounded hover:bg-destructive/10 hover:text-destructive transition-colors" @click.stop="$emit('deleteHistoryConfig', config.id)">
|
||||
<X class="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<!-- Bottom Actions -->
|
||||
<div class="flex items-center justify-between pt-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" @click="$emit('saveConfig')">
|
||||
<Save class="w-3.5 h-3.5 mr-1" />
|
||||
{{ t("diff.saveConfig") }}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" @click="$emit('loadConfig')">
|
||||
<FolderOpen class="w-3.5 h-3.5 mr-1" />
|
||||
{{ t("diff.loadConfig") }}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" @click="$emit('showOptions')">
|
||||
<Settings class="w-3.5 h-3.5 mr-1" />
|
||||
{{ t("diff.options") }}
|
||||
</Button>
|
||||
</div>
|
||||
<Button size="sm" :disabled="!canCompare || loading" @click="$emit('compare')">
|
||||
<GitCompareArrows class="w-3.5 h-3.5 mr-1" />
|
||||
{{ loading ? t("common.loading") : t("diff.compare") }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in new issue
Block a user