feat(diagram): enhance ER diagram editing and exports
This commit is contained in:
1 parent
9fda1d690a
commit
212cf1da7e
92 files changed
+12231
-943
No files matched your search
@@ -0,0 +1,90 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import type { DiagramLayer } from "@/types/diagram";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean;
|
||||
layers: DiagramLayer[];
|
||||
activeLayerId: string | null;
|
||||
existingNames: string[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:open", value: boolean): void;
|
||||
(e: "create", payload: { name: string; layerId: string | null; withDefaultId: boolean }): void;
|
||||
}>();
|
||||
|
||||
const openModel = computed({
|
||||
get: () => props.open,
|
||||
set: (v: boolean) => emit("update:open", v),
|
||||
});
|
||||
|
||||
const name = ref("");
|
||||
const layerId = ref<string | "">("");
|
||||
const withDefaultId = ref(true);
|
||||
const error = ref("");
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(open) => {
|
||||
if (open) {
|
||||
name.value = "";
|
||||
layerId.value = props.activeLayerId || "";
|
||||
withDefaultId.value = true;
|
||||
error.value = "";
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
function submit() {
|
||||
const trimmed = name.value.trim();
|
||||
if (!trimmed) {
|
||||
error.value = t("diagram.tableNameRequired");
|
||||
return;
|
||||
}
|
||||
if (props.existingNames.some((n) => n.toLowerCase() === trimmed.toLowerCase())) {
|
||||
error.value = t("diagram.tableNameExists");
|
||||
return;
|
||||
}
|
||||
emit("create", { name: trimmed, layerId: layerId.value || null, withDefaultId: withDefaultId.value });
|
||||
openModel.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="openModel">
|
||||
<DialogContent class="max-w-md gap-3">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ t("diagram.createTable") }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div class="space-y-3">
|
||||
<div class="space-y-1">
|
||||
<label class="text-xs text-muted-foreground">{{ t("diagram.tableName") }}</label>
|
||||
<Input v-model="name" class="h-8 text-xs" @keydown.enter="submit" />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<label class="text-xs text-muted-foreground">{{ t("diagram.assignLayer") }}</label>
|
||||
<select v-model="layerId" class="h-8 w-full rounded border border-border bg-background px-2 text-xs">
|
||||
<option value="">{{ t("diagram.noLayer") }}</option>
|
||||
<option v-for="layer in layers" :key="layer.id" :value="layer.id">{{ layer.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-xs">
|
||||
<input v-model="withDefaultId" type="checkbox" />
|
||||
{{ t("diagram.withDefaultIdPk") }}
|
||||
</label>
|
||||
<p v-if="error" class="text-xs text-destructive">{{ error }}</p>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" size="sm" @click="openModel = false">{{ t("common.cancel") }}</Button>
|
||||
<Button type="button" size="sm" @click="submit">{{ t("diagram.create") }}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,721 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Plus, Trash2, X, KeyRound } from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { SearchableSelect } from "@/components/ui/searchable-select";
|
||||
import type { DiagramTable, DiagramRelationship, CustomDiagramRelationship } from "@/lib/diagram/erDiagram";
|
||||
import { editableStructureIndexes, hasDroppedColumns, hasPendingColumns, isDraftTable, isDroppedColumn, isPendingColumn } from "@/lib/diagram/erDiagram";
|
||||
import type { InferredRelationship } from "@/types/diagram";
|
||||
import type { InspectorTarget } from "@/types/diagram";
|
||||
import type { ColumnInfo, DatabaseType } from "@/types/database";
|
||||
import type { EditableStructureIndex } from "@/lib/table/tableStructureEditorSql";
|
||||
import { createDraftIndex, nextUniqueColumnName } from "@/lib/diagram/draft-table";
|
||||
import { resolveDiagramDialectAdapter } from "@/lib/diagram/diagram-dialect-adapter";
|
||||
import { cardinalityChoiceFromPair, cardinalityPairFromChoice, edgeCardinalityPair, type CardinalityChoice } from "@/lib/diagram/cardinality";
|
||||
import { canAddTableStructureColumn, getTableStructureCapabilities } from "@/lib/table/tableStructureCapabilities";
|
||||
import { combineDataTypeForDatabase, combineDataTypeForDatabaseWithLengthUnit, dataTypeLengthInputValue, dataTypeLengthUnitValue, getDataTypeLengthUnitOptions, getDataTypeOptions, getDefaultLengthForType, isDataTypeLengthDisabled, splitDataType } from "@/lib/table/tableStructureEditorState";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps<{
|
||||
target: InspectorTarget;
|
||||
tables: DiagramTable[];
|
||||
relationships: (DiagramRelationship | InferredRelationship | CustomDiagramRelationship)[];
|
||||
databaseType?: DatabaseType;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "close"): void;
|
||||
(e: "update-table", table: DiagramTable): void;
|
||||
(e: "delete-draft-table", tableName: string): void;
|
||||
(e: "delete-live-table", tableName: string): void;
|
||||
(e: "update-relationship", payload: { id: string; patch: Partial<CustomDiagramRelationship> }): void;
|
||||
(e: "remove-relationship", id: string): void;
|
||||
(e: "save-relationship", payload: Omit<CustomDiagramRelationship, "id"> & { id?: string }): void;
|
||||
(e: "confirm-relationship", payload: { id: string; sourceCardinality: "1" | "N"; targetCardinality: "1" | "N" }): void;
|
||||
(e: "ignore-relationship", id: string): void;
|
||||
}>();
|
||||
|
||||
type InspectorTab = "fields" | "indexes";
|
||||
|
||||
const tableTab = ref<InspectorTab>("fields");
|
||||
const confirmingDelete = ref(false);
|
||||
const confirmingDropTable = ref(false);
|
||||
|
||||
const relationshipDraft = reactive({
|
||||
sourceTable: "",
|
||||
sourceColumn: "",
|
||||
targetTable: "",
|
||||
targetColumn: "",
|
||||
cardinality: "many-to-one" as CardinalityChoice,
|
||||
});
|
||||
|
||||
const adapter = computed(() => resolveDiagramDialectAdapter(props.databaseType));
|
||||
const databaseType = computed(() => props.databaseType);
|
||||
const structureCapabilities = computed(() => getTableStructureCapabilities(props.databaseType));
|
||||
|
||||
const selectedTable = computed(() => {
|
||||
const target = props.target;
|
||||
if (target?.kind !== "table") return null;
|
||||
return props.tables.find((t) => t.name === target.tableName) ?? null;
|
||||
});
|
||||
|
||||
const selectedEdge = computed(() => {
|
||||
const target = props.target;
|
||||
if (target?.kind !== "edge") return null;
|
||||
return props.relationships.find((r) => r.id === target.edgeId) ?? null;
|
||||
});
|
||||
|
||||
const relationshipKind = computed(() => {
|
||||
const rel = selectedEdge.value;
|
||||
if (!rel) return "unknown";
|
||||
if ("kind" in rel) return rel.kind;
|
||||
return "inferred";
|
||||
});
|
||||
|
||||
const relationshipEditable = computed(() => relationshipKind.value === "custom" || relationshipKind.value === "inferred");
|
||||
|
||||
const tableMap = computed(() => new Map(props.tables.map((table) => [table.name, table])));
|
||||
const sourceColumns = computed(() => tableMap.value.get(relationshipDraft.sourceTable)?.columns ?? []);
|
||||
const targetColumns = computed(() => tableMap.value.get(relationshipDraft.targetTable)?.columns ?? []);
|
||||
|
||||
/** Full draft table edit (rename, indexes, delete draft). */
|
||||
const editable = computed(() => selectedTable.value != null && isDraftTable(selectedTable.value));
|
||||
const isLive = computed(() => selectedTable.value != null && !isDraftTable(selectedTable.value));
|
||||
const canAddField = computed(() => {
|
||||
if (!selectedTable.value) return false;
|
||||
return canAddTableStructureColumn(props.databaseType, isDraftTable(selectedTable.value));
|
||||
});
|
||||
const showLivePendingHint = computed(() => isLive.value && selectedTable.value != null && canAddField.value);
|
||||
const canDropLiveColumn = computed(() => isLive.value && structureCapabilities.value.dropColumn);
|
||||
const canDropLiveTable = computed(() => isLive.value && structureCapabilities.value.createTable);
|
||||
const supportsCreateIndex = computed(() => structureCapabilities.value.createIndex);
|
||||
const supportsComment = computed(() => structureCapabilities.value.comment);
|
||||
|
||||
function isColumnEditable(columnName: string): boolean {
|
||||
const table = selectedTable.value;
|
||||
if (!table) return false;
|
||||
if (isDroppedColumn(table, columnName)) return false;
|
||||
if (isDraftTable(table)) return true;
|
||||
return isPendingColumn(table, columnName);
|
||||
}
|
||||
|
||||
function canRemoveColumn(columnName: string): boolean {
|
||||
const table = selectedTable.value;
|
||||
if (!table) return false;
|
||||
if (isDraftTable(table)) return true;
|
||||
if (isPendingColumn(table, columnName)) return true;
|
||||
return canDropLiveColumn.value;
|
||||
}
|
||||
|
||||
const tableIndexes = computed(() => editableStructureIndexes(selectedTable.value ?? { name: "", columns: [], foreignKeys: [] }).filter((index) => !index.markedForDrop));
|
||||
|
||||
function syncRelationshipDraft() {
|
||||
const rel = selectedEdge.value;
|
||||
if (!rel) return;
|
||||
relationshipDraft.sourceTable = rel.sourceTable;
|
||||
relationshipDraft.sourceColumn = rel.sourceColumn;
|
||||
relationshipDraft.targetTable = rel.targetTable;
|
||||
relationshipDraft.targetColumn = rel.targetColumn;
|
||||
relationshipDraft.cardinality = cardinalityChoiceFromPair("sourceCardinality" in rel && "targetCardinality" in rel ? { sourceCardinality: rel.sourceCardinality, targetCardinality: rel.targetCardinality } : undefined);
|
||||
confirmingDelete.value = false;
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.target,
|
||||
() => {
|
||||
tableTab.value = "fields";
|
||||
confirmingDropTable.value = false;
|
||||
},
|
||||
);
|
||||
|
||||
watch(selectedEdge, syncRelationshipDraft, { immediate: true });
|
||||
|
||||
watch(
|
||||
() => relationshipDraft.sourceTable,
|
||||
() => {
|
||||
if (!sourceColumns.value.some((c) => c.name === relationshipDraft.sourceColumn)) {
|
||||
relationshipDraft.sourceColumn = sourceColumns.value[0]?.name ?? "";
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => relationshipDraft.targetTable,
|
||||
() => {
|
||||
if (!targetColumns.value.some((c) => c.name === relationshipDraft.targetColumn)) {
|
||||
relationshipDraft.targetColumn = targetColumns.value[0]?.name ?? "";
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
function patchTable(mutator: (table: DiagramTable) => void, options?: { requireDraft?: boolean }) {
|
||||
const table = selectedTable.value;
|
||||
if (!table) return;
|
||||
if (options?.requireDraft && !isDraftTable(table)) return;
|
||||
const next: DiagramTable = {
|
||||
...table,
|
||||
columns: table.columns.map((c) => ({ ...c })),
|
||||
foreignKeys: [...table.foreignKeys],
|
||||
indexes: editableStructureIndexes(table).map((index) => ({
|
||||
...index,
|
||||
columns: [...index.columns],
|
||||
includedColumns: [...index.includedColumns],
|
||||
})),
|
||||
pendingColumnNames: table.pendingColumnNames ? [...table.pendingColumnNames] : undefined,
|
||||
droppedColumnNames: table.droppedColumnNames ? [...table.droppedColumnNames] : undefined,
|
||||
pendingDrop: table.pendingDrop,
|
||||
};
|
||||
mutator(next);
|
||||
emit("update-table", next);
|
||||
}
|
||||
|
||||
function renameTable(name: string) {
|
||||
patchTable(
|
||||
(table) => {
|
||||
table.name = name.trim() || table.name;
|
||||
},
|
||||
{ requireDraft: true },
|
||||
);
|
||||
}
|
||||
|
||||
function updateColumn(index: number, patch: Partial<ColumnInfo>) {
|
||||
const table = selectedTable.value;
|
||||
if (!table) return;
|
||||
const col = table.columns[index];
|
||||
if (!col || !isColumnEditable(col.name)) return;
|
||||
patchTable((next) => {
|
||||
const target = next.columns[index];
|
||||
if (!target) return;
|
||||
const oldName = target.name;
|
||||
Object.assign(target, patch);
|
||||
if (patch.name !== undefined && next.pendingColumnNames) {
|
||||
const idx = next.pendingColumnNames.indexOf(oldName);
|
||||
if (idx >= 0) next.pendingColumnNames[idx] = String(patch.name);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function addColumn() {
|
||||
if (!canAddField.value) return;
|
||||
patchTable((table) => {
|
||||
const name = nextUniqueColumnName(table.columns);
|
||||
table.columns.push(adapter.value.createEmptyColumn(name));
|
||||
if (!isDraftTable(table)) {
|
||||
table.pendingColumnNames = [...(table.pendingColumnNames ?? []), name];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function removeColumn(index: number) {
|
||||
const table = selectedTable.value;
|
||||
if (!table) return;
|
||||
const removed = table.columns[index]?.name;
|
||||
if (!removed || !canRemoveColumn(removed)) return;
|
||||
|
||||
// Pending add / draft: hard delete (no DB impact yet).
|
||||
if (isDraftTable(table) || isPendingColumn(table, removed)) {
|
||||
patchTable((next) => {
|
||||
next.columns.splice(index, 1);
|
||||
if (next.pendingColumnNames) {
|
||||
next.pendingColumnNames = next.pendingColumnNames.filter((name) => name !== removed);
|
||||
if (next.pendingColumnNames.length === 0) delete next.pendingColumnNames;
|
||||
}
|
||||
if (!next.indexes) return;
|
||||
for (const idx of next.indexes) {
|
||||
idx.columns = idx.columns.filter((col) => col !== removed);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Live existing column: soft-mark for DROP COLUMN (toggle).
|
||||
patchTable((next) => {
|
||||
const dropped = new Set(next.droppedColumnNames ?? []);
|
||||
if (dropped.has(removed)) {
|
||||
dropped.delete(removed);
|
||||
} else {
|
||||
dropped.add(removed);
|
||||
}
|
||||
next.droppedColumnNames = dropped.size ? [...dropped] : undefined;
|
||||
});
|
||||
}
|
||||
|
||||
function requestDeleteLiveTable() {
|
||||
confirmingDropTable.value = true;
|
||||
}
|
||||
|
||||
function confirmDeleteLiveTable() {
|
||||
const table = selectedTable.value;
|
||||
if (!table || isDraftTable(table)) return;
|
||||
confirmingDropTable.value = false;
|
||||
emit("delete-live-table", table.name);
|
||||
}
|
||||
|
||||
function cancelDeleteLiveTable() {
|
||||
confirmingDropTable.value = false;
|
||||
}
|
||||
|
||||
function addIndex() {
|
||||
if (!supportsCreateIndex.value) return;
|
||||
patchTable(
|
||||
(table) => {
|
||||
const existing = editableStructureIndexes(table);
|
||||
const firstCol = table.columns[0]?.name;
|
||||
table.indexes = [...existing, createDraftIndex(table.name, firstCol ? [firstCol] : [], existing)];
|
||||
},
|
||||
{ requireDraft: true },
|
||||
);
|
||||
}
|
||||
|
||||
function updateIndex(indexId: string, patch: Partial<EditableStructureIndex>) {
|
||||
patchTable(
|
||||
(table) => {
|
||||
const index = editableStructureIndexes(table).find((item) => item.id === indexId);
|
||||
if (!index) return;
|
||||
Object.assign(index, patch);
|
||||
table.indexes = editableStructureIndexes(table).map((item) => (item.id === indexId ? { ...item, ...patch } : item));
|
||||
},
|
||||
{ requireDraft: true },
|
||||
);
|
||||
}
|
||||
|
||||
function toggleIndexColumn(indexId: string, columnName: string) {
|
||||
patchTable(
|
||||
(table) => {
|
||||
table.indexes = editableStructureIndexes(table).map((index) => {
|
||||
if (index.id !== indexId) return index;
|
||||
if (index.columns.includes(columnName)) {
|
||||
return { ...index, columns: index.columns.filter((col) => col !== columnName) };
|
||||
}
|
||||
return { ...index, columns: [...index.columns, columnName] };
|
||||
});
|
||||
},
|
||||
{ requireDraft: true },
|
||||
);
|
||||
}
|
||||
|
||||
function removeIndex(indexId: string) {
|
||||
patchTable(
|
||||
(table) => {
|
||||
table.indexes = editableStructureIndexes(table).filter((index) => index.id !== indexId);
|
||||
},
|
||||
{ requireDraft: true },
|
||||
);
|
||||
}
|
||||
|
||||
const draftCardinality = computed(() => cardinalityPairFromChoice(relationshipDraft.cardinality));
|
||||
|
||||
function directedCardinalityLabel(source: "1" | "N", target: "1" | "N"): string {
|
||||
return t("diagram.cardinalityDirected", { source, target });
|
||||
}
|
||||
|
||||
const edgeDirectedCardinalityLabel = computed(() => {
|
||||
const rel = selectedEdge.value;
|
||||
if (!rel) return directedCardinalityLabel("N", "1");
|
||||
if (relationshipEditable.value) {
|
||||
const pair = draftCardinality.value;
|
||||
return directedCardinalityLabel(pair.sourceCardinality, pair.targetCardinality);
|
||||
}
|
||||
const pair = edgeCardinalityPair(rel);
|
||||
return directedCardinalityLabel(pair.sourceCardinality, pair.targetCardinality);
|
||||
});
|
||||
|
||||
function handleSaveRelationship() {
|
||||
const rel = selectedEdge.value;
|
||||
if (!rel) return;
|
||||
const card = cardinalityPairFromChoice(relationshipDraft.cardinality);
|
||||
emit("save-relationship", {
|
||||
id: "kind" in rel && rel.kind === "custom" ? rel.id : undefined,
|
||||
name: "name" in rel ? rel.name : `${relationshipDraft.sourceTable}_${relationshipDraft.sourceColumn}_${relationshipDraft.targetTable}_${relationshipDraft.targetColumn}`,
|
||||
sourceTable: relationshipDraft.sourceTable,
|
||||
sourceColumn: relationshipDraft.sourceColumn,
|
||||
targetTable: relationshipDraft.targetTable,
|
||||
targetColumn: relationshipDraft.targetColumn,
|
||||
...card,
|
||||
});
|
||||
}
|
||||
|
||||
function handleConfirmRelationship() {
|
||||
const rel = selectedEdge.value;
|
||||
if (!rel) return;
|
||||
emit("confirm-relationship", { id: rel.id, ...cardinalityPairFromChoice(relationshipDraft.cardinality) });
|
||||
}
|
||||
|
||||
function handleIgnoreRelationship() {
|
||||
const rel = selectedEdge.value;
|
||||
if (!rel) return;
|
||||
emit("ignore-relationship", rel.id);
|
||||
}
|
||||
|
||||
function confirmDeleteRelationship() {
|
||||
const rel = selectedEdge.value;
|
||||
if (!rel) return;
|
||||
confirmingDelete.value = false;
|
||||
emit("remove-relationship", rel.id);
|
||||
}
|
||||
|
||||
function columnBaseType(dataType: string): string {
|
||||
return splitDataType(dataType).baseType;
|
||||
}
|
||||
|
||||
function columnLengthEnabled(dataType: string): boolean {
|
||||
return !isDataTypeLengthDisabled(databaseType.value, columnBaseType(dataType));
|
||||
}
|
||||
|
||||
function columnLengthUnitOptions(dataType: string): readonly string[] {
|
||||
return getDataTypeLengthUnitOptions(databaseType.value, dataType);
|
||||
}
|
||||
|
||||
function updateColumnBaseType(index: number, baseType: string) {
|
||||
const next = combineDataTypeForDatabase(databaseType.value, baseType, getDefaultLengthForType(databaseType.value, baseType));
|
||||
updateColumn(index, { data_type: next });
|
||||
}
|
||||
|
||||
function updateColumnLength(index: number, value: string | number) {
|
||||
const table = selectedTable.value;
|
||||
const col = table?.columns[index];
|
||||
if (!col) return;
|
||||
const baseType = columnBaseType(col.data_type);
|
||||
const next = combineDataTypeForDatabaseWithLengthUnit(databaseType.value, baseType, String(value), dataTypeLengthUnitValue(databaseType.value, col.data_type));
|
||||
updateColumn(index, { data_type: next });
|
||||
}
|
||||
|
||||
function updateColumnLengthUnit(index: number, value: unknown) {
|
||||
const table = selectedTable.value;
|
||||
const col = table?.columns[index];
|
||||
if (!col) return;
|
||||
const unit = value === "__default" || value == null ? "" : String(value);
|
||||
const baseType = columnBaseType(col.data_type);
|
||||
const next = combineDataTypeForDatabaseWithLengthUnit(databaseType.value, baseType, dataTypeLengthInputValue(databaseType.value, col.data_type), unit);
|
||||
updateColumn(index, { data_type: next });
|
||||
}
|
||||
|
||||
const dataTypeOptionsForColumn = computed(() => {
|
||||
const options = getDataTypeOptions(props.databaseType);
|
||||
const table = selectedTable.value;
|
||||
if (!table) return options;
|
||||
const seen = new Set(options.map((type) => type.toLowerCase()));
|
||||
const extras: string[] = [];
|
||||
for (const col of table.columns) {
|
||||
const base = columnBaseType(col.data_type);
|
||||
if (!base || seen.has(base.toLowerCase())) continue;
|
||||
seen.add(base.toLowerCase());
|
||||
extras.push(base);
|
||||
}
|
||||
return extras.length ? [...options, ...extras] : options;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full min-w-0 flex-col overflow-hidden">
|
||||
<div class="flex items-center justify-between gap-2 border-b border-border px-3 py-2 shrink-0">
|
||||
<div class="flex min-w-0 flex-1 items-center gap-2">
|
||||
<h3 class="min-w-0 truncate text-sm font-semibold text-foreground">
|
||||
<template v-if="selectedTable">{{ t("diagram.inspectorTable") }} · {{ selectedTable.name }}</template>
|
||||
<template v-else-if="selectedEdge">{{ t("diagram.inspectorRelationship") }}</template>
|
||||
</h3>
|
||||
<Badge v-if="selectedTable && editable" variant="outline" class="h-5 shrink-0 text-[10px]">Draft</Badge>
|
||||
<Badge v-else-if="selectedTable && selectedTable.pendingDrop" variant="destructive" class="h-5 shrink-0 text-[10px]">{{ t("diagram.pendingDropTableBadge") }}</Badge>
|
||||
<Badge v-else-if="selectedTable && (hasPendingColumns(selectedTable) || hasDroppedColumns(selectedTable))" variant="outline" class="h-5 shrink-0 text-[10px]">{{ t("diagram.pendingColumnsBadge") }}</Badge>
|
||||
</div>
|
||||
<button type="button" class="p-1.5 rounded-md hover:bg-muted transition-colors" @click="emit('close')">
|
||||
<X class="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto p-3 space-y-3">
|
||||
<template v-if="selectedTable">
|
||||
<div class="space-y-1">
|
||||
<label class="text-[10px] text-muted-foreground">{{ t("diagram.tableName") }}</label>
|
||||
<Input class="h-8 text-xs" :model-value="selectedTable.name" :disabled="!editable" @update:model-value="(v: string | number) => renameTable(String(v))" />
|
||||
</div>
|
||||
|
||||
<div class="flex border-b border-border">
|
||||
<button type="button" class="flex-1 px-1 py-1.5 text-[10px] transition-colors" :class="tableTab === 'fields' ? 'border-b-2 border-primary text-primary font-medium' : 'text-muted-foreground hover:text-foreground'" @click="tableTab = 'fields'">
|
||||
{{ t("diagram.tabFields") }}
|
||||
</button>
|
||||
<button type="button" class="flex-1 px-1 py-1.5 text-[10px] transition-colors" :class="tableTab === 'indexes' ? 'border-b-2 border-primary text-primary font-medium' : 'text-muted-foreground hover:text-foreground'" @click="tableTab = 'indexes'">
|
||||
{{ t("diagram.tabIndexes") }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<template v-if="tableTab === 'fields'">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs font-medium">{{ t("diagram.fields") }}</span>
|
||||
<Button v-if="canAddField" type="button" size="sm" variant="outline" class="h-7 text-[11px]" @click="addColumn">
|
||||
<Plus class="mr-1 h-3 w-3" />
|
||||
{{ t("diagram.addField") }}
|
||||
</Button>
|
||||
</div>
|
||||
<p v-if="showLivePendingHint" class="text-[11px] text-muted-foreground">{{ t("diagram.liveTableAddColumnsHint") }}</p>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div v-for="(col, index) in selectedTable.columns" :key="`field-${index}`" class="rounded border p-2 space-y-1.5" :class="isDroppedColumn(selectedTable, col.name) ? 'border-destructive/50 bg-destructive/5 opacity-70' : 'border-border/80'">
|
||||
<div class="flex items-center gap-1">
|
||||
<KeyRound v-if="col.is_primary_key" class="h-3 w-3 shrink-0 text-amber-500" />
|
||||
<Input class="h-7 flex-1 font-mono text-[11px]" :class="isDroppedColumn(selectedTable, col.name) ? 'line-through' : ''" :model-value="col.name" :disabled="!isColumnEditable(col.name)" @update:model-value="(v: string | number) => updateColumn(index, { name: String(v) })" />
|
||||
<Badge v-if="isDroppedColumn(selectedTable, col.name)" variant="outline" class="h-5 shrink-0 text-[10px] text-destructive">{{ t("diagram.pendingDropColumnBadge") }}</Badge>
|
||||
<button v-if="canRemoveColumn(col.name)" type="button" class="rounded p-1 hover:bg-muted" :title="isDroppedColumn(selectedTable, col.name) ? t('diagram.undoDropField') : t('diagram.deleteField')" @click="removeColumn(index)">
|
||||
<Trash2 class="h-3 w-3 text-red-500" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<SearchableSelect
|
||||
class="min-w-0 flex-1 basis-[7rem]"
|
||||
:model-value="columnBaseType(col.data_type)"
|
||||
:options="dataTypeOptionsForColumn"
|
||||
:placeholder="t('structureEditor.typePlaceholder')"
|
||||
:search-placeholder="t('structureEditor.typePlaceholder')"
|
||||
:empty-text="t('structureEditor.noMatchingType')"
|
||||
:allow-custom="true"
|
||||
:disabled="!isColumnEditable(col.name)"
|
||||
:trigger-class="['h-7 w-full justify-between px-2 text-[11px] font-mono']"
|
||||
@update:model-value="(v: string) => updateColumnBaseType(index, v)"
|
||||
/>
|
||||
<div v-if="columnLengthEnabled(col.data_type)" class="flex min-w-0 items-center gap-1">
|
||||
<Input
|
||||
class="h-7 w-16 shrink-0 font-mono text-[11px]"
|
||||
:model-value="dataTypeLengthInputValue(databaseType, col.data_type)"
|
||||
:disabled="!isColumnEditable(col.name)"
|
||||
:placeholder="t('structureEditor.length')"
|
||||
@update:model-value="(v: string | number) => updateColumnLength(index, v)"
|
||||
/>
|
||||
<Select v-if="columnLengthUnitOptions(col.data_type).length" :model-value="dataTypeLengthUnitValue(databaseType, col.data_type) || '__default'" :disabled="!isColumnEditable(col.name)" @update:model-value="(v: unknown) => updateColumnLengthUnit(index, v)">
|
||||
<SelectTrigger class="h-7 w-14 shrink-0 px-1 text-[10px] font-mono" :aria-label="t('structureEditor.lengthUnit')" :title="t('structureEditor.lengthUnit')">
|
||||
<SelectValue :placeholder="t('structureEditor.unitPlaceholder')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__default">{{ t("structureEditor.defaultAction") }}</SelectItem>
|
||||
<SelectItem v-for="unit in columnLengthUnitOptions(col.data_type)" :key="unit" :value="unit">{{ unit }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<label class="flex items-center gap-1 text-[10px] text-muted-foreground">
|
||||
<input type="checkbox" :checked="col.is_primary_key" :disabled="!isColumnEditable(col.name)" @change="updateColumn(index, { is_primary_key: ($event.target as HTMLInputElement).checked })" />
|
||||
PK
|
||||
</label>
|
||||
<label class="flex items-center gap-1 text-[10px] text-muted-foreground">
|
||||
<input type="checkbox" :checked="col.is_nullable" :disabled="!isColumnEditable(col.name)" @change="updateColumn(index, { is_nullable: ($event.target as HTMLInputElement).checked })" />
|
||||
NULL
|
||||
</label>
|
||||
</div>
|
||||
<Input v-if="supportsComment" class="h-7 text-[11px]" :model-value="col.comment ?? ''" :disabled="!isColumnEditable(col.name)" :placeholder="t('diagram.fieldComment')" @update:model-value="(v: string | number) => updateColumn(index, { comment: String(v) })" />
|
||||
</div>
|
||||
<p v-if="selectedTable.columns.length === 0" class="text-[11px] text-muted-foreground">{{ t("diagram.noFieldsYet") }}</p>
|
||||
<p v-else-if="!canAddField && isLive" class="text-[11px] text-muted-foreground">{{ t("diagram.addColumnNotSupported") }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div v-if="!supportsCreateIndex" class="text-[11px] text-muted-foreground">
|
||||
{{ t("diagram.indexesNotSupported") }}
|
||||
</div>
|
||||
<template v-else>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs font-medium">{{ t("diagram.tabIndexes") }}</span>
|
||||
<Button v-if="editable" type="button" size="sm" variant="outline" class="h-7 text-[11px]" @click="addIndex">
|
||||
<Plus class="mr-1 h-3 w-3" />
|
||||
{{ t("diagram.addIndex") }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="!editable" class="text-[11px] text-muted-foreground">{{ t("diagram.liveTableIndexesReadOnly") }}</div>
|
||||
|
||||
<div v-else class="space-y-2">
|
||||
<div v-for="index in tableIndexes" :key="index.id" class="rounded border border-border/80 p-2 space-y-1.5">
|
||||
<div class="flex items-center gap-1">
|
||||
<Input class="h-7 flex-1 font-mono text-[11px]" :model-value="index.name" :placeholder="t('diagram.indexName')" @update:model-value="(v: string | number) => updateIndex(index.id, { name: String(v) })" />
|
||||
<button type="button" class="rounded p-1 hover:bg-muted" :title="t('diagram.deleteField')" @click="removeIndex(index.id)">
|
||||
<Trash2 class="h-3 w-3 text-red-500" />
|
||||
</button>
|
||||
</div>
|
||||
<label class="flex items-center gap-1.5 text-[10px] text-muted-foreground">
|
||||
<input type="checkbox" :checked="index.isUnique" @change="updateIndex(index.id, { isUnique: ($event.target as HTMLInputElement).checked })" />
|
||||
{{ t("diagram.indexUnique") }}
|
||||
</label>
|
||||
<div class="space-y-1">
|
||||
<div class="text-[10px] text-muted-foreground">{{ t("diagram.indexColumns") }}</div>
|
||||
<div v-if="selectedTable.columns.length === 0" class="text-[10px] text-muted-foreground">{{ t("diagram.noFieldsYet") }}</div>
|
||||
<div v-else class="max-h-32 space-y-0.5 overflow-y-auto rounded border border-border/60 p-1.5">
|
||||
<label v-for="col in selectedTable.columns" :key="`${index.id}-${col.name}`" class="flex items-center gap-1.5 rounded px-1 py-0.5 text-[11px] hover:bg-muted/50">
|
||||
<input type="checkbox" :checked="index.columns.includes(col.name)" @change="toggleIndexColumn(index.id, col.name)" />
|
||||
<span class="font-mono truncate">{{ col.name }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="tableIndexes.length === 0" class="text-[11px] text-muted-foreground">{{ t("diagram.noIndexesYet") }}</p>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<Button v-if="editable" type="button" variant="destructive" size="sm" class="w-full h-8 text-xs" @click="emit('delete-draft-table', selectedTable.name)">
|
||||
{{ t("diagram.deleteDraftTable") }}
|
||||
</Button>
|
||||
|
||||
<template v-else-if="isLive && canDropLiveTable">
|
||||
<div v-if="!confirmingDropTable" class="space-y-1">
|
||||
<Button type="button" variant="destructive" size="sm" class="w-full h-8 text-xs" @click="requestDeleteLiveTable">
|
||||
{{ t("diagram.deleteLiveTable") }}
|
||||
</Button>
|
||||
<p class="text-[10px] text-muted-foreground">{{ t("diagram.deleteLiveTableHint") }}</p>
|
||||
</div>
|
||||
<div v-else class="space-y-2 rounded border border-destructive/40 bg-destructive/5 p-2">
|
||||
<p class="text-[11px] text-destructive">{{ t("diagram.deleteLiveTableConfirm") }}</p>
|
||||
<div class="flex gap-2">
|
||||
<Button type="button" variant="destructive" size="sm" class="h-7 flex-1 text-[11px]" @click="confirmDeleteLiveTable">{{ t("common.confirm") }}</Button>
|
||||
<Button type="button" variant="outline" size="sm" class="h-7 flex-1 text-[11px]" @click="cancelDeleteLiveTable">{{ t("common.cancel") }}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<p v-else-if="isLive && !canDropLiveTable" class="text-[11px] text-muted-foreground">{{ t("diagram.dropTableNotSupported") }}</p>
|
||||
</template>
|
||||
|
||||
<template v-else-if="selectedEdge">
|
||||
<div class="space-y-3 text-xs">
|
||||
<div class="text-[10px] text-muted-foreground">
|
||||
<template v-if="relationshipKind === 'foreign-key'">{{ t("diagram.relationshipKindFk") }}</template>
|
||||
<template v-else-if="relationshipKind === 'custom'">{{ t("diagram.relationshipKindCustom") }}</template>
|
||||
<template v-else-if="relationshipKind === 'inferred'">{{ t("diagram.relationshipKindInferred") }}</template>
|
||||
<span> · {{ edgeDirectedCardinalityLabel }}</span>
|
||||
<span v-if="'confidence' in selectedEdge"> · {{ selectedEdge.confidence }}</span>
|
||||
</div>
|
||||
|
||||
<p v-if="relationshipKind === 'foreign-key'" class="text-[11px] text-muted-foreground">
|
||||
{{ t("diagram.relationshipReadOnlyFk") }}
|
||||
</p>
|
||||
|
||||
<template v-if="relationshipKind === 'foreign-key'">
|
||||
<div>
|
||||
<div class="flex items-center gap-1.5 text-[10px] text-muted-foreground">
|
||||
<span>{{ t("diagram.source") }}</span>
|
||||
<span class="inline-flex min-w-[1.1rem] items-center justify-center rounded border border-border/80 bg-background px-1 py-0.5 font-mono text-[10px] font-semibold leading-none text-foreground">
|
||||
{{ edgeCardinalityPair(selectedEdge).sourceCardinality }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="font-mono">{{ selectedEdge.sourceTable }}.{{ selectedEdge.sourceColumn }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center gap-1.5 text-[10px] text-muted-foreground">
|
||||
<span>{{ t("diagram.target") }}</span>
|
||||
<span class="inline-flex min-w-[1.1rem] items-center justify-center rounded border border-border/80 bg-background px-1 py-0.5 font-mono text-[10px] font-semibold leading-none text-foreground">
|
||||
{{ edgeCardinalityPair(selectedEdge).targetCardinality }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="font-mono">{{ selectedEdge.targetTable }}.{{ selectedEdge.targetColumn }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-[10px] text-muted-foreground">{{ t("diagram.cardinality") }}</div>
|
||||
<div>{{ edgeDirectedCardinalityLabel }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="relationshipEditable">
|
||||
<div class="space-y-1.5">
|
||||
<label class="flex items-center gap-1.5 text-[10px] text-muted-foreground">
|
||||
<span>{{ t("diagram.sourceTable") }}</span>
|
||||
<span class="inline-flex min-w-[1.1rem] items-center justify-center rounded border border-border/80 bg-background px-1 py-0.5 font-mono text-[10px] font-semibold leading-none text-foreground">
|
||||
{{ draftCardinality.sourceCardinality }}
|
||||
</span>
|
||||
</label>
|
||||
<Select v-model="relationshipDraft.sourceTable">
|
||||
<SelectTrigger class="h-8 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="table in tables" :key="`s-${table.name}`" :value="table.name" :disabled="table.columns.length === 0">{{ table.name }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<label class="flex items-center gap-1.5 text-[10px] text-muted-foreground">
|
||||
<span>{{ t("diagram.sourceColumn") }}</span>
|
||||
<span class="inline-flex min-w-[1.1rem] items-center justify-center rounded border border-border/80 bg-background px-1 py-0.5 font-mono text-[10px] font-semibold leading-none text-foreground">
|
||||
{{ draftCardinality.sourceCardinality }}
|
||||
</span>
|
||||
</label>
|
||||
<Select v-model="relationshipDraft.sourceColumn">
|
||||
<SelectTrigger class="h-8 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="col in sourceColumns" :key="`sc-${col.name}`" :value="col.name">{{ col.name }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-[10px] text-muted-foreground">{{ t("diagram.cardinality") }}</label>
|
||||
<Select v-model="relationshipDraft.cardinality">
|
||||
<SelectTrigger class="h-8 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="one-to-one">{{ t("diagram.cardinalityOneToOne") }}</SelectItem>
|
||||
<SelectItem value="one-to-many">{{ t("diagram.cardinalityOneToMany") }}</SelectItem>
|
||||
<SelectItem value="many-to-one">{{ t("diagram.cardinalityManyToOne") }}</SelectItem>
|
||||
<SelectItem value="many-to-many">{{ t("diagram.cardinalityManyToMany") }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<label class="flex items-center gap-1.5 text-[10px] text-muted-foreground">
|
||||
<span>{{ t("diagram.targetTable") }}</span>
|
||||
<span class="inline-flex min-w-[1.1rem] items-center justify-center rounded border border-border/80 bg-background px-1 py-0.5 font-mono text-[10px] font-semibold leading-none text-foreground">
|
||||
{{ draftCardinality.targetCardinality }}
|
||||
</span>
|
||||
</label>
|
||||
<Select v-model="relationshipDraft.targetTable">
|
||||
<SelectTrigger class="h-8 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="table in tables" :key="`t-${table.name}`" :value="table.name" :disabled="table.columns.length === 0">{{ table.name }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<label class="flex items-center gap-1.5 text-[10px] text-muted-foreground">
|
||||
<span>{{ t("diagram.targetColumn") }}</span>
|
||||
<span class="inline-flex min-w-[1.1rem] items-center justify-center rounded border border-border/80 bg-background px-1 py-0.5 font-mono text-[10px] font-semibold leading-none text-foreground">
|
||||
{{ draftCardinality.targetCardinality }}
|
||||
</span>
|
||||
</label>
|
||||
<Select v-model="relationshipDraft.targetColumn">
|
||||
<SelectTrigger class="h-8 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="col in targetColumns" :key="`tc-${col.name}`" :value="col.name">{{ col.name }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1.5 pt-1">
|
||||
<Button v-if="relationshipKind === 'custom' || relationshipKind === 'inferred'" type="button" size="sm" class="h-8 w-full text-xs" @click="handleSaveRelationship">
|
||||
{{ t("diagram.saveRelationship") }}
|
||||
</Button>
|
||||
<Button v-if="relationshipKind === 'inferred'" type="button" size="sm" variant="outline" class="h-8 w-full text-xs" @click="handleConfirmRelationship">
|
||||
{{ t("diagram.confirmRelationship") }}
|
||||
</Button>
|
||||
<Button v-if="relationshipKind === 'inferred'" type="button" size="sm" variant="outline" class="h-8 w-full text-xs" @click="handleIgnoreRelationship">
|
||||
{{ t("diagram.ignoreRelationship") }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<template v-if="relationshipKind === 'custom'">
|
||||
<div v-if="confirmingDelete" class="space-y-2 rounded border border-destructive/30 bg-destructive/5 p-2">
|
||||
<p class="text-[11px] text-muted-foreground">{{ t("diagram.confirmDeleteRelationship") }}</p>
|
||||
<div class="flex gap-1.5">
|
||||
<Button type="button" size="sm" variant="destructive" class="h-7 flex-1 text-xs" @click="confirmDeleteRelationship">
|
||||
{{ t("diagram.confirmDeleteRelationshipAction") }}
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="outline" class="h-7 flex-1 text-xs" @click="confirmingDelete = false">
|
||||
{{ t("common.cancel") }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Button v-else type="button" variant="destructive" size="sm" class="h-8 w-full text-xs" @click="confirmingDelete = true">
|
||||
{{ t("diagram.removeRelationship") }}
|
||||
</Button>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,199 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { DiagramTable } from "@/lib/diagram/erDiagram";
|
||||
import { isDraftTable, needsDiagramSync } from "@/lib/diagram/erDiagram";
|
||||
import { draftTableToCreateSqlOptions, hasLiveColumnChanges, liveTableToAlterSqlOptions, validateDraftTable, validateLivePendingColumns } from "@/lib/diagram/draft-table";
|
||||
import { buildDropTableSql } from "@/lib/database/dbAdminSql";
|
||||
import { getTableStructureCapabilities } from "@/lib/table/tableStructureCapabilities";
|
||||
import type { DatabaseType } from "@/types/database";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { copyToClipboard } from "@/lib/common/clipboard";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean;
|
||||
tables: DiagramTable[];
|
||||
connectionId: string;
|
||||
database: string;
|
||||
schema: string;
|
||||
databaseType?: DatabaseType;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:open", value: boolean): void;
|
||||
(e: "synced", tableNames: string[]): void;
|
||||
}>();
|
||||
|
||||
const openModel = computed({
|
||||
get: () => props.open,
|
||||
set: (v: boolean) => emit("update:open", v),
|
||||
});
|
||||
|
||||
const syncTables = computed(() => props.tables.filter(needsDiagramSync));
|
||||
const draftTables = computed(() => syncTables.value.filter(isDraftTable));
|
||||
const liveDropTables = computed(() => syncTables.value.filter((table) => !isDraftTable(table) && !!table.pendingDrop));
|
||||
const liveAlterTables = computed(() => syncTables.value.filter((table) => !isDraftTable(table) && !table.pendingDrop && hasLiveColumnChanges(table)));
|
||||
const structureCapabilities = computed(() => getTableStructureCapabilities(props.databaseType));
|
||||
const validationErrors = ref<string[]>([]);
|
||||
const sqlText = ref("");
|
||||
const warnings = ref<string[]>([]);
|
||||
const building = ref(false);
|
||||
const executing = ref(false);
|
||||
const execError = ref("");
|
||||
|
||||
function validateStructureCapabilities(): string[] {
|
||||
const caps = structureCapabilities.value;
|
||||
const errors: string[] = [];
|
||||
if (draftTables.value.length && !caps.createTable) {
|
||||
errors.push(t("diagram.createTableNotSupported"));
|
||||
}
|
||||
if (liveAlterTables.value.some((table) => (table.pendingColumnNames?.length ?? 0) > 0) && !caps.addColumn) {
|
||||
errors.push(t("diagram.addColumnNotSupported"));
|
||||
}
|
||||
if (liveAlterTables.value.some((table) => (table.droppedColumnNames?.length ?? 0) > 0) && !caps.dropColumn) {
|
||||
errors.push(t("diagram.dropColumnNotSupported"));
|
||||
}
|
||||
if (liveDropTables.value.length && !caps.createTable) {
|
||||
errors.push(t("diagram.dropTableNotSupported"));
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
async function rebuildSql() {
|
||||
building.value = true;
|
||||
validationErrors.value = [];
|
||||
warnings.value = [];
|
||||
sqlText.value = "";
|
||||
execError.value = "";
|
||||
try {
|
||||
const capabilityErrors = validateStructureCapabilities();
|
||||
const errors = [...capabilityErrors, ...draftTables.value.flatMap(validateDraftTable), ...liveAlterTables.value.flatMap(validateLivePendingColumns)];
|
||||
if (errors.length) {
|
||||
validationErrors.value = errors;
|
||||
return;
|
||||
}
|
||||
const allStatements: string[] = [];
|
||||
const allWarnings: string[] = [];
|
||||
// Same SQL APIs as TableStructureEditor — do not generate dialect SQL in the diagram layer.
|
||||
for (const table of draftTables.value) {
|
||||
const result = await api.buildCreateTableSql(draftTableToCreateSqlOptions(table, props.databaseType, props.schema || undefined));
|
||||
allStatements.push(...result.statements);
|
||||
allWarnings.push(...result.warnings);
|
||||
}
|
||||
for (const table of liveAlterTables.value) {
|
||||
// Live ER sync only maps ADD/DROP COLUMN. Existing-column type changes stay in TableStructureEditor
|
||||
// (including SQLite rebuild via previewSqliteTableStructureChange).
|
||||
const result = await api.buildTableStructureChangeSql(liveTableToAlterSqlOptions(table, props.databaseType, props.schema || undefined));
|
||||
allStatements.push(...result.statements);
|
||||
allWarnings.push(...result.warnings);
|
||||
}
|
||||
for (const table of liveDropTables.value) {
|
||||
const sql = await buildDropTableSql({
|
||||
databaseType: props.databaseType,
|
||||
schema: props.schema || undefined,
|
||||
tableName: table.name,
|
||||
cascade: false,
|
||||
});
|
||||
if (sql.trim()) allStatements.push(sql.trim());
|
||||
}
|
||||
sqlText.value = allStatements.join("\n\n");
|
||||
warnings.value = allWarnings;
|
||||
} catch (e: any) {
|
||||
validationErrors.value = [e?.message || String(e)];
|
||||
} finally {
|
||||
building.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(open) => {
|
||||
if (open) void rebuildSql();
|
||||
},
|
||||
);
|
||||
|
||||
async function copySql() {
|
||||
if (!sqlText.value) return;
|
||||
await copyToClipboard(sqlText.value);
|
||||
}
|
||||
|
||||
async function execute() {
|
||||
if (!sqlText.value || validationErrors.value.length) return;
|
||||
executing.value = true;
|
||||
execError.value = "";
|
||||
try {
|
||||
const statements = sqlText.value
|
||||
.split(/;\s*\n/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
.map((s) => (s.endsWith(";") ? s : `${s};`));
|
||||
await api.executeBatch(props.connectionId, props.database, statements, props.schema || undefined);
|
||||
emit(
|
||||
"synced",
|
||||
syncTables.value.map((table) => table.name),
|
||||
);
|
||||
openModel.value = false;
|
||||
} catch (e: any) {
|
||||
execError.value = e?.message || String(e);
|
||||
} finally {
|
||||
executing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function syncTableLabel(table: DiagramTable): string {
|
||||
if (isDraftTable(table)) {
|
||||
return `${table.name} (${table.columns.length} cols, CREATE)`;
|
||||
}
|
||||
if (table.pendingDrop) {
|
||||
return `${table.name} (DROP TABLE)`;
|
||||
}
|
||||
const added = table.pendingColumnNames?.length ?? 0;
|
||||
const dropped = table.droppedColumnNames?.length ?? 0;
|
||||
const parts: string[] = [];
|
||||
if (added) parts.push(`+${added}`);
|
||||
if (dropped) parts.push(`-${dropped}`);
|
||||
return `${table.name} (${parts.join("/") || "0"} cols, ALTER)`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="openModel">
|
||||
<DialogContent class="max-w-2xl max-h-[80vh] flex flex-col gap-3">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ t("diagram.syncToDatabase") }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="text-xs text-muted-foreground">
|
||||
{{ t("diagram.syncDraftCount", { count: syncTables.length }) }}
|
||||
</div>
|
||||
|
||||
<ul v-if="syncTables.length" class="text-xs list-disc pl-4 space-y-0.5">
|
||||
<li v-for="table in syncTables" :key="table.name" class="font-mono">{{ syncTableLabel(table) }}</li>
|
||||
</ul>
|
||||
|
||||
<div v-if="validationErrors.length" class="rounded border border-destructive/40 bg-destructive/5 p-2 text-xs text-destructive space-y-1">
|
||||
<div v-for="(err, i) in validationErrors" :key="i">{{ err }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="warnings.length" class="rounded border border-amber-500/40 bg-amber-500/5 p-2 text-xs text-amber-700 dark:text-amber-300 space-y-1">
|
||||
<div v-for="(w, i) in warnings" :key="i">{{ w }}</div>
|
||||
</div>
|
||||
|
||||
<pre class="flex-1 min-h-[160px] max-h-[40vh] overflow-auto rounded border bg-muted/30 p-3 text-[11px] font-mono whitespace-pre-wrap">{{ building ? t("diagram.buildingSql") : sqlText || t("diagram.noSqlYet") }}</pre>
|
||||
|
||||
<p v-if="execError" class="text-xs text-destructive">{{ execError }}</p>
|
||||
|
||||
<DialogFooter class="gap-2 sm:gap-2">
|
||||
<Button type="button" variant="outline" size="sm" :disabled="!sqlText" @click="copySql">{{ t("diagram.copySql") }}</Button>
|
||||
<Button type="button" variant="ghost" size="sm" @click="openModel = false">{{ t("common.cancel") }}</Button>
|
||||
<Button type="button" size="sm" :disabled="!sqlText || !!validationErrors.length || executing || building" @click="execute">
|
||||
{{ executing ? t("diagram.syncing") : t("diagram.executeSync") }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,230 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Copy, Download, Link2, Loader2, Maximize2, Minimize2, Network, Plus, RefreshCw, Search, Table2, Upload, X, ZoomIn, ZoomOut, LayoutGrid } from "@lucide/vue";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
|
||||
import ConnectionGroupBadge from "@/components/connection/ConnectionGroupBadge.vue";
|
||||
import type { ConnectionConfig } from "@/types/database";
|
||||
import type { DiagramExportFormat } from "@/lib/export/diagramFormats";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps<{
|
||||
connectionId: string;
|
||||
database: string;
|
||||
schema: string;
|
||||
databases: string[];
|
||||
schemas: string[];
|
||||
sqlConnections: ConnectionConfig[];
|
||||
selectedConnection: ConnectionConfig | undefined;
|
||||
isSchemaAware: boolean;
|
||||
loadingDatabases: boolean;
|
||||
loadingSchemas: boolean;
|
||||
loadingDiagram: boolean;
|
||||
diagramReady: boolean;
|
||||
tablesCount: number;
|
||||
relationshipsCount: number;
|
||||
customRelationshipCount: number;
|
||||
matchRelationshipCount: number;
|
||||
diagramMode: "table" | "engineering";
|
||||
tableSearch: string;
|
||||
showMatchPanel: boolean;
|
||||
showLayersPanel: boolean;
|
||||
showAllTables: boolean;
|
||||
focusTableName: string;
|
||||
generatedJoinSql: string;
|
||||
isFullscreen?: boolean;
|
||||
draftTableCount?: number;
|
||||
canCreateTable?: boolean;
|
||||
canSyncToDatabase?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "set-connection", value: string): void;
|
||||
(e: "set-database", value: string): void;
|
||||
(e: "set-schema", value: string): void;
|
||||
(e: "update:table-search", value: string): void;
|
||||
(e: "set-diagram-mode", value: "table" | "engineering"): void;
|
||||
(e: "toggle-match-panel"): void;
|
||||
(e: "toggle-layers-panel"): void;
|
||||
(e: "copy-join-sql"): void;
|
||||
(e: "toggle-show-all-tables"): void;
|
||||
(e: "export-format", format: DiagramExportFormat): void;
|
||||
(e: "refresh"): void;
|
||||
(e: "zoom-out"): void;
|
||||
(e: "zoom-in"): void;
|
||||
(e: "toggle-fullscreen"): void;
|
||||
(e: "auto-layout"): void;
|
||||
(e: "create-table"): void;
|
||||
(e: "sync-to-database"): void;
|
||||
}>();
|
||||
|
||||
const EXPORT_FORMATS: DiagramExportFormat[] = ["svg", "png", "json", "dbml", "mermaid"];
|
||||
|
||||
function exportFormatLabel(format: DiagramExportFormat): string {
|
||||
switch (format) {
|
||||
case "svg":
|
||||
return t("diagram.exportSvg");
|
||||
case "png":
|
||||
return t("diagram.exportPng");
|
||||
case "json":
|
||||
return t("diagram.exportJson");
|
||||
case "dbml":
|
||||
return t("diagram.exportDbml");
|
||||
case "mermaid":
|
||||
return t("diagram.exportMermaid");
|
||||
}
|
||||
}
|
||||
|
||||
function connectionIconType(id: string) {
|
||||
const config = props.sqlConnections.find((c) => c.id === id);
|
||||
return config?.driver_profile || config?.db_type || "mysql";
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center gap-2 border-b px-3 py-2 shrink-0 overflow-x-auto">
|
||||
<Select :model-value="connectionId" @update:model-value="(value: any) => emit('set-connection', String(value))">
|
||||
<SelectTrigger class="h-8 w-48 text-xs">
|
||||
<div v-if="connectionId" class="flex min-w-0 items-center gap-2">
|
||||
<DatabaseIcon :db-type="connectionIconType(connectionId)" class="w-3.5 h-3.5 shrink-0" />
|
||||
<span class="truncate">{{ selectedConnection?.name }}</span>
|
||||
</div>
|
||||
<SelectValue v-else :placeholder="t('diagram.selectConnection')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="connection in sqlConnections" :key="connection.id" :value="connection.id">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<DatabaseIcon :db-type="connection.driver_profile || connection.db_type" class="w-3.5 h-3.5 shrink-0" />
|
||||
<ConnectionGroupBadge :connection-id="connection.id" />
|
||||
<span class="min-w-0 flex-1 truncate">{{ connection.name }}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select :model-value="database" :disabled="!databases.length || loadingDatabases" @update:model-value="(value: any) => emit('set-database', String(value))">
|
||||
<SelectTrigger class="h-8 w-44 text-xs">
|
||||
<SelectValue :placeholder="loadingDatabases ? t('common.loading') : t('diagram.selectDatabase')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="db in databases" :key="db" :value="db">{{ db }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select v-if="isSchemaAware" :model-value="schema" :disabled="!schemas.length || loadingSchemas" @update:model-value="(value: any) => emit('set-schema', String(value))">
|
||||
<SelectTrigger class="h-8 w-40 text-xs">
|
||||
<SelectValue :placeholder="loadingSchemas ? t('common.loading') : t('diagram.selectSchema')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="name in schemas" :key="name" :value="name">{{ name }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<div class="relative min-w-40 flex-1">
|
||||
<Search class="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input :value="tableSearch" @input="(e: Event) => emit('update:table-search', (e.target as HTMLInputElement).value)" class="h-8 pl-7 pr-9 text-xs" :placeholder="t('diagram.searchTables')" />
|
||||
<button v-if="tableSearch" type="button" class="absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 rounded-full bg-muted-foreground/20 hover:bg-muted-foreground/40 flex items-center justify-center transition-colors" @click="emit('update:table-search', '')">
|
||||
<X class="h-3 w-3 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex h-8 shrink-0 items-center overflow-hidden rounded-md border bg-background">
|
||||
<Button variant="ghost" size="sm" class="h-8 rounded-none px-2 text-xs" :class="diagramMode === 'table' ? 'bg-accent' : ''" @click="emit('set-diagram-mode', 'table')">
|
||||
<Table2 class="mr-1 h-3.5 w-3.5" />
|
||||
{{ t("diagram.tableMode") }}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" class="h-8 rounded-none border-l px-2 text-xs" :class="diagramMode === 'engineering' ? 'bg-accent' : ''" @click="emit('set-diagram-mode', 'engineering')">
|
||||
<Network class="mr-1 h-3.5 w-3.5" />
|
||||
{{ t("diagram.engineeringMode") }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button variant="outline" size="sm" class="h-8 px-2 text-xs" :disabled="!diagramReady || canCreateTable === false" :title="canCreateTable === false ? t('diagram.createTableNotSupported') : t('diagram.createTable')" @click="emit('create-table')">
|
||||
<Plus class="mr-1 h-3.5 w-3.5" />
|
||||
{{ t("diagram.createTable") }}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-8 px-2 text-xs"
|
||||
:disabled="!diagramReady || !(draftTableCount ?? 0) || canSyncToDatabase === false"
|
||||
:title="canSyncToDatabase === false ? t('diagram.structureSyncNotSupported') : t('diagram.syncToDatabase')"
|
||||
:class="(draftTableCount ?? 0) > 0 ? 'bg-primary/10 border-primary text-primary' : ''"
|
||||
@click="emit('sync-to-database')"
|
||||
>
|
||||
<Upload class="mr-1 h-3.5 w-3.5" />
|
||||
{{ t("diagram.syncToDatabase") }}
|
||||
<Badge v-if="(draftTableCount ?? 0) > 0" variant="secondary" class="ml-1 h-4 px-1 text-[10px]">{{ draftTableCount }}</Badge>
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" size="sm" class="h-8 px-2 text-xs" :disabled="tablesCount === 0" :title="t('diagram.modelRelationships')" :class="showMatchPanel ? 'bg-primary/10 border-primary text-primary' : ''" @click="emit('toggle-match-panel')">
|
||||
<Link2 class="mr-1 h-3.5 w-3.5" />
|
||||
{{ t("diagram.modelRelationships") }}
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" size="sm" class="h-8 px-2 text-xs" :disabled="!diagramReady" :title="t('diagram.layers')" :class="showLayersPanel ? 'bg-primary/10 border-primary text-primary' : ''" @click="emit('toggle-layers-panel')">
|
||||
<LayoutGrid class="mr-1 h-3.5 w-3.5" />
|
||||
{{ t("diagram.layers") }}
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" size="sm" class="h-8 px-2 text-xs" :disabled="tablesCount === 0" :title="t('diagram.autoLayout')" @click="emit('auto-layout')">
|
||||
<LayoutGrid class="mr-1 h-3.5 w-3.5" />
|
||||
{{ t("diagram.autoLayout") }}
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" size="sm" class="h-8 px-2 text-xs" :disabled="!generatedJoinSql.trim()" :title="t('diagram.copyJoinSql')" @click="emit('copy-join-sql')">
|
||||
<Copy class="mr-1 h-3.5 w-3.5" />
|
||||
{{ t("diagram.copyJoinSql") }}
|
||||
</Button>
|
||||
|
||||
<Button v-if="focusTableName && tablesCount > 0" variant="outline" size="sm" class="h-8 px-2 text-xs" @click="emit('toggle-show-all-tables')">
|
||||
{{ showAllTables ? t("diagram.relatedTables") : t("diagram.allTables") }}
|
||||
</Button>
|
||||
|
||||
<Badge variant="secondary" class="h-6 shrink-0">
|
||||
{{ t("diagram.tablesCount", { count: tablesCount }) }}
|
||||
</Badge>
|
||||
<Badge variant="secondary" class="h-6 shrink-0">
|
||||
{{ t("diagram.relationshipsCount", { count: relationshipsCount }) }}
|
||||
</Badge>
|
||||
<Badge v-if="matchRelationshipCount > 0" variant="outline" class="h-6 shrink-0">
|
||||
{{ t("diagram.matchRelationshipsCount", { count: matchRelationshipCount }) }}
|
||||
</Badge>
|
||||
<Badge v-if="customRelationshipCount > 0" variant="outline" class="h-6 shrink-0">
|
||||
{{ t("diagram.customRelationshipsCount", { count: customRelationshipCount }) }}
|
||||
</Badge>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8" :disabled="loadingDiagram || tablesCount === 0" :title="t('diagram.export')">
|
||||
<Download class="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" class="w-44">
|
||||
<DropdownMenuItem v-for="format in EXPORT_FORMATS" :key="format" @click="emit('export-format', format)">
|
||||
{{ exportFormatLabel(format) }}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8" :disabled="!diagramReady || loadingDiagram" :title="t('diagram.refresh')" @click="emit('refresh')">
|
||||
<Loader2 v-if="loadingDiagram" class="h-4 w-4 animate-spin" />
|
||||
<RefreshCw v-else class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8" :disabled="diagramMode === 'engineering' || tablesCount === 0" :title="t('diagram.zoomOut')" @click="emit('zoom-out')">
|
||||
<ZoomOut class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8" :disabled="diagramMode === 'engineering' || tablesCount === 0" :title="t('diagram.zoomIn')" @click="emit('zoom-in')">
|
||||
<ZoomIn class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8" :title="isFullscreen ? t('diagram.exitFullscreen') : t('diagram.fullscreen')" @click="emit('toggle-fullscreen')">
|
||||
<Minimize2 v-if="isFullscreen" class="h-4 w-4" />
|
||||
<Maximize2 v-else class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Lock, Unlock } from "@lucide/vue";
|
||||
import type { DiagramLayer } from "@/types/diagram";
|
||||
|
||||
defineProps<{
|
||||
data: {
|
||||
layer: DiagramLayer;
|
||||
};
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
function isLocked(layer: DiagramLayer): boolean {
|
||||
return (layer.layoutMode ?? "auto") === "free";
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="relative rounded-lg border-2 bg-background/40 box-border pointer-events-none overflow-hidden"
|
||||
:style="{
|
||||
borderColor: data.layer.color,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}"
|
||||
>
|
||||
<div class="layer-drag-handle absolute top-0 left-0 right-0 h-10 flex items-center gap-2 px-4 rounded-t-lg cursor-grab active:cursor-grabbing pointer-events-auto" :style="{ backgroundColor: data.layer.color + '33' }">
|
||||
<span class="text-sm font-semibold truncate" :style="{ color: data.layer.color }">
|
||||
{{ data.layer.name }}
|
||||
</span>
|
||||
<span class="ml-auto shrink-0 flex items-center gap-1 text-[10px] font-normal opacity-70" :title="isLocked(data.layer) ? t('diagram.layerLocked') : t('diagram.layerUnlocked')">
|
||||
<Lock v-if="isLocked(data.layer)" class="h-3 w-3" :style="{ color: data.layer.color }" />
|
||||
<Unlock v-else class="h-3 w-3" :style="{ color: data.layer.color }" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,450 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, nextTick } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Plus, Trash2, Eye, EyeOff, ChevronDown, ChevronRight, Edit3, Check, X, CheckSquare, Square, Lock, Unlock } from "@lucide/vue";
|
||||
import { useLayerStore } from "@/lib/diagram/layer-store";
|
||||
import type { DiagramTable } from "@/lib/diagram/erDiagram";
|
||||
import { filterAssignableDiagramTables } from "@/lib/diagram/erDiagram";
|
||||
import type { LayerLayoutMode } from "@/types/diagram";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
|
||||
const props = defineProps<{
|
||||
tables: DiagramTable[];
|
||||
recordHistory?: () => void;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "layer-changed"): void;
|
||||
(e: "add-layer"): void;
|
||||
(e: "layout-mode-changed", payload: { layerId: string; layoutMode: LayerLayoutMode }): void;
|
||||
(e: "focus-layer", layerId: string): void;
|
||||
(e: "create-draft-table", payload: { name: string; layerId: string | null; withDefaultId: boolean }): void;
|
||||
(e: "delete-table", tableName: string): void;
|
||||
}>();
|
||||
|
||||
const store = useLayerStore();
|
||||
const { toast } = useToast();
|
||||
const { t } = useI18n();
|
||||
|
||||
function beforeMutate() {
|
||||
props.recordHistory?.();
|
||||
}
|
||||
|
||||
type AddTableTab = "existing" | "create";
|
||||
|
||||
const editingLayerId = ref<string | null>(null);
|
||||
const editingName = ref("");
|
||||
const renameInputRef = ref<HTMLInputElement | null>(null);
|
||||
const selectedTableNames = ref<Map<string, Set<string>>>(new Map());
|
||||
const addTableFilter = ref("");
|
||||
/** Layer ids whose "tables in other layers" section is expanded (default: collapsed). */
|
||||
const otherLayersExpandedByLayer = ref<Set<string>>(new Set());
|
||||
const addTableTabByLayerId = reactive<Record<string, AddTableTab>>({});
|
||||
const createNameByLayerId = reactive<Record<string, string>>({});
|
||||
const createWithIdByLayerId = reactive<Record<string, boolean>>({});
|
||||
const createErrorByLayerId = reactive<Record<string, string>>({});
|
||||
|
||||
function getAddTableTab(layerId: string): AddTableTab {
|
||||
return addTableTabByLayerId[layerId] || "existing";
|
||||
}
|
||||
|
||||
function ensureCreateFormDefaults(layerId: string) {
|
||||
if (createNameByLayerId[layerId] === undefined) createNameByLayerId[layerId] = "";
|
||||
if (createWithIdByLayerId[layerId] === undefined) createWithIdByLayerId[layerId] = true;
|
||||
}
|
||||
|
||||
function setAddTableTab(layerId: string, tab: AddTableTab) {
|
||||
addTableTabByLayerId[layerId] = tab;
|
||||
if (tab === "create") ensureCreateFormDefaults(layerId);
|
||||
}
|
||||
|
||||
function submitCreateDraft(layerId: string) {
|
||||
const trimmed = (createNameByLayerId[layerId] || "").trim();
|
||||
if (!trimmed) {
|
||||
createErrorByLayerId[layerId] = t("diagram.tableNameRequired");
|
||||
return;
|
||||
}
|
||||
if (props.tables.some((tbl) => tbl.name.toLowerCase() === trimmed.toLowerCase())) {
|
||||
createErrorByLayerId[layerId] = t("diagram.tableNameExists");
|
||||
return;
|
||||
}
|
||||
delete createErrorByLayerId[layerId];
|
||||
createNameByLayerId[layerId] = "";
|
||||
emit("create-draft-table", {
|
||||
name: trimmed,
|
||||
layerId,
|
||||
withDefaultId: createWithIdByLayerId[layerId] ?? true,
|
||||
});
|
||||
}
|
||||
function getSelectedTables(layerId: string): Set<string> {
|
||||
return selectedTableNames.value.get(layerId) || new Set();
|
||||
}
|
||||
|
||||
function toggleSelectedTable(layerId: string, tableName: string) {
|
||||
const selected = selectedTableNames.value.get(layerId) || new Set();
|
||||
if (selected.has(tableName)) {
|
||||
selected.delete(tableName);
|
||||
} else {
|
||||
selected.add(tableName);
|
||||
}
|
||||
selectedTableNames.value.set(layerId, selected);
|
||||
}
|
||||
|
||||
function isTableSelected(layerId: string, tableName: string): boolean {
|
||||
return getSelectedTables(layerId).has(tableName);
|
||||
}
|
||||
|
||||
const assignableTables = computed(() => filterAssignableDiagramTables(props.tables));
|
||||
|
||||
const availableTables = computed(() => {
|
||||
return assignableTables.value.filter((table) => !store.getLayerByTable(table.name));
|
||||
});
|
||||
|
||||
function matchesAddTableFilter(name: string): boolean {
|
||||
const q = addTableFilter.value.trim().toLowerCase();
|
||||
if (!q) return true;
|
||||
return name.toLowerCase().includes(q);
|
||||
}
|
||||
|
||||
const filteredAvailableTables = computed(() => {
|
||||
return availableTables.value.filter((table) => matchesAddTableFilter(table.name));
|
||||
});
|
||||
|
||||
function getOtherLayerTables(excludeLayerId: string): DiagramTable[] {
|
||||
return assignableTables.value.filter((table) => getOtherLayerForTable(table.name, excludeLayerId));
|
||||
}
|
||||
|
||||
function getFilteredOtherLayerTables(excludeLayerId: string): DiagramTable[] {
|
||||
return getOtherLayerTables(excludeLayerId).filter((table) => matchesAddTableFilter(table.name));
|
||||
}
|
||||
|
||||
function isOtherLayersExpanded(layerId: string): boolean {
|
||||
return otherLayersExpandedByLayer.value.has(layerId);
|
||||
}
|
||||
|
||||
function toggleOtherLayersExpanded(layerId: string) {
|
||||
const next = new Set(otherLayersExpandedByLayer.value);
|
||||
if (next.has(layerId)) {
|
||||
next.delete(layerId);
|
||||
} else {
|
||||
next.add(layerId);
|
||||
}
|
||||
otherLayersExpandedByLayer.value = next;
|
||||
}
|
||||
|
||||
function getOtherLayerForTable(tableName: string, excludeLayerId: string): string | null {
|
||||
const layer = store.layers.find((l) => l.id !== excludeLayerId && l.tableNames.includes(tableName));
|
||||
return layer?.name || null;
|
||||
}
|
||||
|
||||
function handleAddSelectedTables(layerId: string) {
|
||||
const selected = getSelectedTables(layerId);
|
||||
if (selected.size === 0) return;
|
||||
|
||||
beforeMutate();
|
||||
selected.forEach((tableName) => {
|
||||
const currentLayer = store.getLayerByTable(tableName);
|
||||
if (currentLayer) {
|
||||
store.removeTableFromLayer(currentLayer.id, tableName);
|
||||
}
|
||||
store.addTableToLayer(layerId, tableName);
|
||||
});
|
||||
|
||||
selectedTableNames.value.delete(layerId);
|
||||
const next = new Set(otherLayersExpandedByLayer.value);
|
||||
next.delete(layerId);
|
||||
otherLayersExpandedByLayer.value = next;
|
||||
addTableFilter.value = "";
|
||||
emit("layer-changed");
|
||||
}
|
||||
|
||||
function selectAllAvailableTables(layerId: string) {
|
||||
const all = new Set(filteredAvailableTables.value.map((t) => t.name));
|
||||
selectedTableNames.value.set(layerId, all);
|
||||
}
|
||||
|
||||
function selectAllTablesFromOtherLayers(layerId: string) {
|
||||
const selected = getSelectedTables(layerId);
|
||||
getFilteredOtherLayerTables(layerId).forEach((table) => {
|
||||
selected.add(table.name);
|
||||
});
|
||||
selectedTableNames.value.set(layerId, selected);
|
||||
}
|
||||
|
||||
function clearSelection(layerId: string) {
|
||||
selectedTableNames.value.set(layerId, new Set());
|
||||
}
|
||||
|
||||
function handleAddLayer() {
|
||||
emit("add-layer");
|
||||
}
|
||||
|
||||
function handleRemoveLayer(layerId: string) {
|
||||
beforeMutate();
|
||||
store.removeLayer(layerId);
|
||||
selectedTableNames.value.delete(layerId);
|
||||
delete addTableTabByLayerId[layerId];
|
||||
delete createNameByLayerId[layerId];
|
||||
delete createWithIdByLayerId[layerId];
|
||||
delete createErrorByLayerId[layerId];
|
||||
const next = new Set(otherLayersExpandedByLayer.value);
|
||||
next.delete(layerId);
|
||||
otherLayersExpandedByLayer.value = next;
|
||||
emit("layer-changed");
|
||||
}
|
||||
|
||||
function handleRenameLayer(layerId: string) {
|
||||
const layer = store.layers.find((l) => l.id === layerId);
|
||||
if (!layer) return;
|
||||
|
||||
const newName = editingName.value.trim();
|
||||
if (!newName) {
|
||||
cancelRename();
|
||||
return;
|
||||
}
|
||||
|
||||
if (newName.length > 50) {
|
||||
toast(t("diagram.layerNameTooLong"), 3000);
|
||||
return;
|
||||
}
|
||||
|
||||
// Unicode letters/digits (incl. Chinese); allow space, _, -, . in the middle
|
||||
const nameRegex = /^[\p{L}\p{N}][\p{L}\p{N}_\-.\s]*$/u;
|
||||
if (!nameRegex.test(newName)) {
|
||||
toast(t("diagram.layerNameInvalid"), 3000);
|
||||
return;
|
||||
}
|
||||
|
||||
const existingLayer = store.layers.find((l) => l.id !== layerId && l.name === newName);
|
||||
if (existingLayer) {
|
||||
toast(t("diagram.layerNameExists"), 3000);
|
||||
return;
|
||||
}
|
||||
|
||||
if (newName === layer.name) {
|
||||
cancelRename();
|
||||
return;
|
||||
}
|
||||
|
||||
beforeMutate();
|
||||
store.renameLayer(layerId, newName);
|
||||
editingLayerId.value = null;
|
||||
editingName.value = "";
|
||||
emit("layer-changed");
|
||||
}
|
||||
|
||||
function cancelRename() {
|
||||
editingLayerId.value = null;
|
||||
editingName.value = "";
|
||||
}
|
||||
|
||||
async function startRename(layerId: string) {
|
||||
const layer = store.layers.find((l) => l.id === layerId);
|
||||
if (!layer) return;
|
||||
editingLayerId.value = layerId;
|
||||
editingName.value = layer.name;
|
||||
await nextTick();
|
||||
const focusRenameInput = () => {
|
||||
renameInputRef.value?.focus();
|
||||
renameInputRef.value?.select();
|
||||
};
|
||||
focusRenameInput();
|
||||
// dblclick 前的 click 会触发 focus-layer → fitView,延后一帧避免被抢走焦点
|
||||
requestAnimationFrame(focusRenameInput);
|
||||
}
|
||||
|
||||
function handleRemoveTable(layerId: string, tableName: string) {
|
||||
beforeMutate();
|
||||
store.removeTableFromLayer(layerId, tableName);
|
||||
emit("layer-changed");
|
||||
}
|
||||
|
||||
function handleDeleteTable(tableName: string) {
|
||||
emit("delete-table", tableName);
|
||||
}
|
||||
|
||||
function handleToggleVisibility(layerId: string) {
|
||||
beforeMutate();
|
||||
store.toggleLayerVisibility(layerId);
|
||||
emit("layer-changed");
|
||||
}
|
||||
|
||||
function isLayerLocked(layer: { layoutMode?: LayerLayoutMode }): boolean {
|
||||
return (layer.layoutMode ?? "auto") === "free";
|
||||
}
|
||||
|
||||
function handleToggleLayoutLock(layerId: string) {
|
||||
const layer = store.layers.find((l) => l.id === layerId);
|
||||
if (!layer) return;
|
||||
const nextMode: LayerLayoutMode = isLayerLocked(layer) ? "auto" : "free";
|
||||
emit("layout-mode-changed", { layerId, layoutMode: nextMode });
|
||||
}
|
||||
|
||||
function handleToggleCollapse(layerId: string) {
|
||||
store.toggleLayerCollapse(layerId);
|
||||
}
|
||||
|
||||
function handleSetActiveLayer(layerId: string) {
|
||||
store.setActiveLayer(layerId);
|
||||
emit("focus-layer", layerId);
|
||||
}
|
||||
|
||||
function getLayerTables(layerId: string): DiagramTable[] {
|
||||
const layer = store.layers.find((l) => l.id === layerId);
|
||||
if (!layer) return [];
|
||||
return assignableTables.value.filter((table) => layer.tableNames.includes(table.name));
|
||||
}
|
||||
|
||||
function getLayerColor(layerId: string): string {
|
||||
const layer = store.layers.find((l) => l.id === layerId);
|
||||
return layer?.color || "#ccc";
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col h-full max-h-full min-h-0 bg-background border-r border-border">
|
||||
<div class="flex items-center justify-between px-3 py-2 border-b border-border shrink-0">
|
||||
<h3 class="text-sm font-semibold text-foreground">Layers</h3>
|
||||
<button type="button" class="p-1.5 rounded-md hover:bg-muted transition-colors" title="Add Layer" @click="handleAddLayer">
|
||||
<Plus class="h-4 w-4 text-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto p-2 space-y-1">
|
||||
<div v-if="store.layers.length === 0" class="text-xs text-muted-foreground text-center py-4">No layers yet. Click + to create one.</div>
|
||||
|
||||
<div v-for="layer in store.layers" :key="layer.id" class="rounded-md border transition-all" :class="store.activeLayerId === layer.id ? 'border-primary bg-primary/5' : 'border-border hover:border-muted-foreground/50'">
|
||||
<div class="flex items-center gap-1 px-2 py-1.5 cursor-pointer" @click="handleSetActiveLayer(layer.id)">
|
||||
<button type="button" class="p-0.5 rounded hover:bg-muted transition-colors" @click.stop="handleToggleCollapse(layer.id)">
|
||||
<ChevronDown v-if="!layer.collapsed" class="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<ChevronRight v-else class="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</button>
|
||||
|
||||
<div class="w-3 h-3 rounded-full shrink-0" :style="{ backgroundColor: layer.color }" />
|
||||
|
||||
<template v-if="editingLayerId === layer.id">
|
||||
<input ref="renameInputRef" v-model="editingName" class="flex-1 min-w-0 text-xs bg-background border border-border rounded px-1.5 py-0.5 outline-none focus:border-primary" @mousedown.stop @click.stop @keydown.enter="handleRenameLayer(layer.id)" @keydown.escape="cancelRename" />
|
||||
<button type="button" class="p-0.5 rounded hover:bg-muted transition-colors" @click.stop="handleRenameLayer(layer.id)">
|
||||
<Check class="h-3 w-3 text-green-500" />
|
||||
</button>
|
||||
<button type="button" class="p-0.5 rounded hover:bg-muted transition-colors" @click.stop="cancelRename">
|
||||
<X class="h-3 w-3 text-red-500" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<span class="flex-1 min-w-0 text-xs font-medium truncate cursor-pointer hover:bg-muted/50 rounded px-1" @dblclick.stop="startRename(layer.id)">{{ layer.name }}</span>
|
||||
<button type="button" class="p-0.5 rounded hover:bg-muted transition-colors opacity-0 hover:opacity-100" @click.stop="startRename(layer.id)">
|
||||
<Edit3 class="h-3 w-3 text-muted-foreground" />
|
||||
</button>
|
||||
<button type="button" class="p-0.5 rounded hover:bg-muted transition-colors" @click.stop="handleToggleLayoutLock(layer.id)" :title="isLayerLocked(layer) ? t('diagram.layerUnlock') : t('diagram.layerLock')">
|
||||
<Lock v-if="isLayerLocked(layer)" class="h-3 w-3 text-muted-foreground" />
|
||||
<Unlock v-else class="h-3 w-3 text-muted-foreground" />
|
||||
</button>
|
||||
<button type="button" class="p-0.5 rounded hover:bg-muted transition-colors" @click.stop="handleToggleVisibility(layer.id)" :title="layer.visible ? t('diagram.hideLayer') : t('diagram.showLayer')">
|
||||
<Eye v-if="layer.visible" class="h-3 w-3 text-muted-foreground" />
|
||||
<EyeOff v-else class="h-3 w-3 text-muted-foreground/50" />
|
||||
</button>
|
||||
<button type="button" class="p-0.5 rounded hover:bg-muted transition-colors" @click.stop="handleRemoveLayer(layer.id)" :title="t('diagram.deleteLayer')">
|
||||
<Trash2 class="h-3 w-3 text-red-500" />
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="!layer.collapsed" class="px-2 pb-2">
|
||||
<div class="space-y-0.5">
|
||||
<div v-for="table in getLayerTables(layer.id)" :key="table.name" class="flex items-center gap-1 px-2 py-1 rounded text-xs bg-muted/50">
|
||||
<span class="flex-1 min-w-0 truncate">{{ table.name }}</span>
|
||||
<button type="button" class="p-0.5 rounded hover:bg-background transition-colors" @click.stop="handleRemoveTable(layer.id, table.name)" :title="t('diagram.removeFromLayer')">
|
||||
<X class="h-3 w-3 text-muted-foreground" />
|
||||
</button>
|
||||
<button type="button" class="p-0.5 rounded hover:bg-background transition-colors" @click.stop="handleDeleteTable(table.name)" :title="t('diagram.deleteLiveTable')">
|
||||
<Trash2 class="h-3 w-3 text-red-500" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 space-y-1.5">
|
||||
<div class="flex border-b border-border">
|
||||
<button type="button" class="flex-1 px-1 py-1 text-[10px] transition-colors" :class="getAddTableTab(layer.id) === 'existing' ? 'border-b-2 border-primary text-primary font-medium' : 'text-muted-foreground hover:text-foreground'" @click.stop="setAddTableTab(layer.id, 'existing')">
|
||||
{{ t("diagram.tabSelectExisting") }}
|
||||
</button>
|
||||
<button type="button" class="flex-1 px-1 py-1 text-[10px] transition-colors" :class="getAddTableTab(layer.id) === 'create' ? 'border-b-2 border-primary text-primary font-medium' : 'text-muted-foreground hover:text-foreground'" @click.stop="setAddTableTab(layer.id, 'create')">
|
||||
{{ t("diagram.tabCreateTable") }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="getAddTableTab(layer.id) === 'existing'" class="space-y-1">
|
||||
<div class="flex gap-1">
|
||||
<button type="button" class="flex-1 text-[9px] bg-muted hover:bg-muted/80 py-0.5 rounded transition-colors" @click.stop="selectAllAvailableTables(layer.id)">
|
||||
{{ t("diagram.selectAll") }}
|
||||
</button>
|
||||
<button type="button" class="flex-1 text-[9px] bg-muted hover:bg-muted/80 py-0.5 rounded transition-colors" @click.stop="clearSelection(layer.id)">
|
||||
{{ t("diagram.clearSelection") }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<input v-model="addTableFilter" type="search" class="w-full h-7 text-[11px] bg-background border border-border rounded px-2 outline-none focus:border-primary" :placeholder="t('diagram.filterTables')" @click.stop />
|
||||
|
||||
<div class="space-y-0.5 max-h-48 overflow-y-auto border border-border rounded">
|
||||
<div v-for="table in filteredAvailableTables" :key="table.name" class="flex items-center gap-1.5 px-2 py-1 text-xs hover:bg-muted/50 cursor-pointer" @click.stop="toggleSelectedTable(layer.id, table.name)">
|
||||
<CheckSquare v-if="isTableSelected(layer.id, table.name)" class="h-3 w-3 text-primary shrink-0" />
|
||||
<Square v-else class="h-3 w-3 text-muted-foreground shrink-0" />
|
||||
<span class="flex-1 min-w-0 truncate">{{ table.name }}</span>
|
||||
</div>
|
||||
<div v-if="filteredAvailableTables.length === 0" class="px-2 py-2 text-[10px] text-muted-foreground text-center">
|
||||
{{ t("diagram.noMatchingTables") }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="store.layers.length > 1" class="mt-1">
|
||||
<div class="flex items-center justify-between mb-1 gap-1">
|
||||
<button type="button" class="flex items-center gap-0.5 min-w-0 text-[9px] text-muted-foreground hover:text-foreground" @click.stop="toggleOtherLayersExpanded(layer.id)">
|
||||
<ChevronDown v-if="isOtherLayersExpanded(layer.id)" class="h-3 w-3 shrink-0" />
|
||||
<ChevronRight v-else class="h-3 w-3 shrink-0" />
|
||||
<span class="truncate">{{ t("diagram.tablesInOtherLayers") }}</span>
|
||||
</button>
|
||||
<button type="button" class="text-[9px] text-primary/70 hover:text-primary shrink-0" @click.stop="selectAllTablesFromOtherLayers(layer.id)">
|
||||
{{ t("diagram.selectAll") }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="isOtherLayersExpanded(layer.id)" class="space-y-0.5 max-h-32 overflow-y-auto border border-border rounded bg-muted/20">
|
||||
<div v-for="table in getFilteredOtherLayerTables(layer.id)" :key="table.name" class="flex items-center gap-1.5 px-2 py-1 text-xs hover:bg-muted/50 cursor-pointer" @click.stop="toggleSelectedTable(layer.id, table.name)">
|
||||
<div class="relative shrink-0">
|
||||
<CheckSquare v-if="isTableSelected(layer.id, table.name)" class="h-3 w-3 text-primary" />
|
||||
<Square v-else class="h-3 w-3 text-muted-foreground" />
|
||||
<div class="absolute -top-0.5 -right-0.5 w-1.5 h-1.5 rounded-full border border-background" :style="{ backgroundColor: getLayerColor(store.layers.find((l) => l.id !== layer.id && l.tableNames.includes(table.name))?.id || '') }" />
|
||||
</div>
|
||||
<span class="flex-1 min-w-0 truncate opacity-70">{{ table.name }}</span>
|
||||
<span class="text-[9px] px-1 py-0.5 rounded bg-muted-foreground/10 text-muted-foreground shrink-0">
|
||||
{{ getOtherLayerForTable(table.name, layer.id) }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="getFilteredOtherLayerTables(layer.id).length === 0" class="px-2 py-2 text-[10px] text-muted-foreground text-center">
|
||||
{{ t("diagram.noMatchingTables") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" class="w-full mt-1 text-xs bg-primary text-primary-foreground py-1 rounded hover:bg-primary/90 transition-colors" :disabled="getSelectedTables(layer.id).size === 0" @click.stop="handleAddSelectedTables(layer.id)">
|
||||
{{ t("diagram.addSelectedCount", { count: getSelectedTables(layer.id).size }) }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-1.5" @click.stop>
|
||||
<input v-model="createNameByLayerId[layer.id]" class="w-full h-7 text-[11px] bg-background border border-border rounded px-2 outline-none focus:border-primary" :placeholder="t('diagram.tableName')" @keydown.enter="submitCreateDraft(layer.id)" />
|
||||
<label class="flex items-center gap-1.5 text-[10px] text-muted-foreground">
|
||||
<input v-model="createWithIdByLayerId[layer.id]" type="checkbox" />
|
||||
{{ t("diagram.withDefaultIdPk") }}
|
||||
</label>
|
||||
<p v-if="createErrorByLayerId[layer.id]" class="text-[10px] text-destructive">{{ createErrorByLayerId[layer.id] }}</p>
|
||||
<button type="button" class="w-full text-xs bg-primary text-primary-foreground py-1 rounded hover:bg-primary/90 transition-colors" @click.stop="submitCreateDraft(layer.id)">
|
||||
{{ t("diagram.create") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,163 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Check, X, Trash2, Zap } from "@lucide/vue";
|
||||
import type { InferredRelationship } from "@/types/diagram";
|
||||
import { cardinalityPairFromChoice, type CardinalityChoice, type CardinalityPair } from "@/lib/diagram/cardinality";
|
||||
|
||||
const DEFAULT_CHOICE: CardinalityChoice = "many-to-one";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps<{
|
||||
relationships: InferredRelationship[];
|
||||
conflicts: InferredRelationship[];
|
||||
pending: InferredRelationship[];
|
||||
confirmedIds: string[];
|
||||
ignoredIds: string[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "confirm", payload: { id: string } & CardinalityPair): void;
|
||||
(e: "ignore", id: string): void;
|
||||
(e: "confirm-all", payload: Array<{ id: string } & CardinalityPair>): void;
|
||||
(e: "ignore-all"): void;
|
||||
(e: "clear-all"): void;
|
||||
}>();
|
||||
|
||||
/** Per-row cardinality selection; defaults to N:1 */
|
||||
const cardinalityById = reactive<Record<string, CardinalityChoice>>({});
|
||||
|
||||
watch(
|
||||
() => props.relationships.map((r) => r.id).join(","),
|
||||
() => {
|
||||
for (const rel of props.relationships) {
|
||||
if (!cardinalityById[rel.id]) {
|
||||
cardinalityById[rel.id] = DEFAULT_CHOICE;
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const isConfirmed = (id: string) => props.confirmedIds.includes(id);
|
||||
const isIgnored = (id: string) => props.ignoredIds.includes(id);
|
||||
|
||||
const confidenceBadge = (confidence: string) => {
|
||||
if (confidence === "high") return { class: "bg-green-100 text-green-800", text: t("diagram.confidenceHigh") };
|
||||
if (confidence === "medium") return { class: "bg-amber-100 text-amber-800", text: t("diagram.confidenceMedium") };
|
||||
return { class: "bg-red-100 text-red-800", text: t("diagram.confidenceLow") };
|
||||
};
|
||||
|
||||
const relationshipTitle = (relationship: InferredRelationship) => {
|
||||
return `${relationship.sourceTable}.${relationship.sourceColumn} -> ${relationship.targetTable}.${relationship.targetColumn}`;
|
||||
};
|
||||
|
||||
const confidenceOrder = { high: 2, medium: 1, low: 0 };
|
||||
const sortedRelationships = computed(() => {
|
||||
return [...props.relationships].sort((a, b) => {
|
||||
if (isConfirmed(a.id) !== isConfirmed(b.id)) return isConfirmed(a.id) ? -1 : 1;
|
||||
if (isIgnored(a.id) !== isIgnored(b.id)) return isIgnored(a.id) ? 1 : -1;
|
||||
return (confidenceOrder[b.confidence] || 0) - (confidenceOrder[a.confidence] || 0);
|
||||
});
|
||||
});
|
||||
|
||||
function choiceFor(id: string): CardinalityChoice {
|
||||
return cardinalityById[id] ?? DEFAULT_CHOICE;
|
||||
}
|
||||
|
||||
function emitConfirm(id: string) {
|
||||
emit("confirm", { id, ...cardinalityPairFromChoice(choiceFor(id)) });
|
||||
}
|
||||
|
||||
function emitConfirmAll() {
|
||||
const payload = props.pending.map((rel) => ({
|
||||
id: rel.id,
|
||||
...cardinalityPairFromChoice(choiceFor(rel.id)),
|
||||
}));
|
||||
emit("confirm-all", payload);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full min-w-0 flex-col overflow-hidden">
|
||||
<div class="shrink-0 border-b border-border px-3 py-2">
|
||||
<p class="mb-2 text-[11px] text-muted-foreground">{{ t("diagram.matchPickCardinality") }}</p>
|
||||
<div class="mb-2 flex min-w-0 items-center justify-between">
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-1">
|
||||
<Badge variant="secondary" class="h-5 px-2 text-xs"> {{ relationships.length }} {{ t("diagram.total") }} </Badge>
|
||||
<Badge variant="default" class="h-5 px-2 text-xs"> {{ confirmedIds.length }} {{ t("diagram.confirmed") }} </Badge>
|
||||
<Badge variant="outline" class="h-5 px-2 text-xs"> {{ pending.length }} {{ t("diagram.pending") }} </Badge>
|
||||
<Badge v-if="conflicts.length > 0" variant="destructive" class="h-5 px-2 text-xs"> {{ conflicts.length }} {{ t("diagram.conflicts") }} </Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex min-w-0 items-center gap-1.5">
|
||||
<Button variant="outline" size="sm" class="h-7 min-w-0 flex-1 truncate px-2 text-xs" :disabled="pending.length === 0" @click="emitConfirmAll">
|
||||
<Check class="mr-1 h-3 w-3 shrink-0" />
|
||||
<span class="truncate">{{ t("diagram.confirmAll") }}</span>
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" class="h-7 min-w-0 flex-1 truncate px-2 text-xs" :disabled="pending.length === 0" @click="emit('ignore-all')">
|
||||
<X class="mr-1 h-3 w-3 shrink-0" />
|
||||
<span class="truncate">{{ t("diagram.ignoreAll") }}</span>
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7 shrink-0" :disabled="confirmedIds.length === 0 && ignoredIds.length === 0" :title="t('diagram.clearAll')" @click="emit('clear-all')">
|
||||
<Trash2 class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="min-h-0 min-w-0 flex-1 overflow-x-hidden overflow-y-auto p-2">
|
||||
<div v-if="relationships.length === 0" class="flex flex-col items-center justify-center py-8 text-xs text-muted-foreground">
|
||||
<Zap class="mb-2 h-5 w-5" />
|
||||
<span>{{ t("diagram.noInferred") }}</span>
|
||||
</div>
|
||||
<div v-else class="flex min-w-0 flex-col gap-1">
|
||||
<div
|
||||
v-for="relationship in sortedRelationships"
|
||||
:key="relationship.id"
|
||||
class="flex min-w-0 items-stretch gap-2 rounded-md border p-2 text-xs transition-all hover:bg-muted/50"
|
||||
:class="[
|
||||
isConfirmed(relationship.id) ? 'border-primary/30 bg-primary/5' : '',
|
||||
isIgnored(relationship.id) ? 'border-border opacity-50' : '',
|
||||
props.conflicts.includes(relationship) && !isConfirmed(relationship.id) && !isIgnored(relationship.id) ? 'border-red-400/50 bg-red-50/50' : '',
|
||||
!isConfirmed(relationship.id) && !isIgnored(relationship.id) && !props.conflicts.includes(relationship) ? 'border-border hover:border-muted-foreground/50' : '',
|
||||
]"
|
||||
:title="`${relationshipTitle(relationship)}\nConfidence: ${relationship.confidence}\nStrategy: ${relationship.strategy}`"
|
||||
>
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
<div class="flex min-w-0 items-center gap-1.5">
|
||||
<Badge :class="confidenceBadge(relationship.confidence).class" class="h-4 shrink-0 px-1.5 text-[10px]">
|
||||
{{ confidenceBadge(relationship.confidence).text }}
|
||||
</Badge>
|
||||
<span class="min-w-0 flex-1 truncate font-mono text-[11px]" :title="`${relationship.sourceTable}.${relationship.sourceColumn}`"> {{ relationship.sourceTable }}.{{ relationship.sourceColumn }} </span>
|
||||
</div>
|
||||
<Select v-if="!isConfirmed(relationship.id) && !isIgnored(relationship.id)" :model-value="choiceFor(relationship.id)" @update:model-value="(value: any) => (cardinalityById[relationship.id] = String(value) as CardinalityChoice)">
|
||||
<SelectTrigger class="h-7 w-full text-[11px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="one-to-one">{{ t("diagram.cardinalityOneToOne") }}</SelectItem>
|
||||
<SelectItem value="one-to-many">{{ t("diagram.cardinalityOneToMany") }}</SelectItem>
|
||||
<SelectItem value="many-to-one">{{ t("diagram.cardinalityManyToOne") }}</SelectItem>
|
||||
<SelectItem value="many-to-many">{{ t("diagram.cardinalityManyToMany") }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div v-else class="text-center text-[10px] text-muted-foreground">↓</div>
|
||||
<span class="min-w-0 truncate font-mono text-[11px]" :title="`${relationship.targetTable}.${relationship.targetColumn}`"> {{ relationship.targetTable }}.{{ relationship.targetColumn }} </span>
|
||||
</div>
|
||||
<div v-if="!isConfirmed(relationship.id) && !isIgnored(relationship.id)" class="flex shrink-0 flex-col justify-between">
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" :title="t('diagram.ignoreMatch')" @click="emit('ignore', relationship.id)">
|
||||
<X class="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" :title="t('diagram.confirmMatch')" @click="emitConfirm(relationship.id)">
|
||||
<Check class="h-3.5 w-3.5 text-green-600" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,212 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, type Ref, type ComputedRef } from "vue";
|
||||
import { BaseEdge, EdgeLabelRenderer, getSmoothStepPath, type EdgeProps } from "@vue-flow/core";
|
||||
import type { DiagramRelationship } from "@/lib/diagram/erDiagram";
|
||||
import type { InferredRelationship } from "@/types/diagram";
|
||||
import { DIAGRAM_HOVERED_EDGE_KEY, DIAGRAM_EDGE_OBSTACLES_KEY, EDGE_ROUTE_OFFSET, EDGE_STROKE_IDLE, EDGE_STROKE_HOVER } from "@/lib/diagram/diagram-constants";
|
||||
import type { RelationshipEdgeData } from "@/lib/diagram/vue-flow-adapter";
|
||||
import { alignWaypointsToEndpoints, endpointRectsFromObstacles, pathSkimsEndpoints, pointAlongPolyline, pointsToSvgPath, polylineLength, routeOrthogonalAroundObstacles, type ObstacleRect, type Point } from "@/lib/diagram/edge-obstacle-router";
|
||||
|
||||
const HOVER_BLUE = "#2563eb";
|
||||
/** Prefer live obstacle routing when it is at most this fraction of aligned ELK path length. */
|
||||
const LIVE_ROUTE_LENGTH_RATIO = 0.85;
|
||||
/** Cardinality badge positions along the edge (arc-length fraction). */
|
||||
const SOURCE_CARDINALITY_T = 0.18;
|
||||
const TARGET_CARDINALITY_T = 0.82;
|
||||
|
||||
const props = defineProps<EdgeProps<RelationshipEdgeData>>();
|
||||
|
||||
const hoveredEdgeId = inject<Ref<string | null> | null>(DIAGRAM_HOVERED_EDGE_KEY, null);
|
||||
const obstacles = inject<ComputedRef<ObstacleRect[]> | Ref<ObstacleRect[]> | null>(DIAGRAM_EDGE_OBSTACLES_KEY, null);
|
||||
const isHovered = computed(() => hoveredEdgeId?.value === props.id);
|
||||
|
||||
function isDiagramRelationship(rel: DiagramRelationship | InferredRelationship): rel is DiagramRelationship {
|
||||
return "kind" in rel;
|
||||
}
|
||||
|
||||
function snapRoutedToHandles(points: Point[]): Point[] {
|
||||
if (points.length < 2) return points;
|
||||
const snapped = points.map((p) => ({ ...p }));
|
||||
snapped[0] = { x: props.sourceX, y: props.sourceY };
|
||||
snapped[snapped.length - 1] = { x: props.targetX, y: props.targetY };
|
||||
return snapped;
|
||||
}
|
||||
|
||||
function buildRoutedPoints(): Point[] | null {
|
||||
const obstacleList = obstacles?.value ?? [];
|
||||
const endpointIds: [string, string] = [props.source, props.target];
|
||||
const endpointRects = endpointRectsFromObstacles(obstacleList, endpointIds);
|
||||
|
||||
const live = routeOrthogonalAroundObstacles({
|
||||
source: { x: props.sourceX, y: props.sourceY },
|
||||
target: { x: props.targetX, y: props.targetY },
|
||||
sourcePosition: props.sourcePosition,
|
||||
targetPosition: props.targetPosition,
|
||||
obstacles: obstacleList,
|
||||
endpointIds,
|
||||
offset: EDGE_ROUTE_OFFSET,
|
||||
});
|
||||
|
||||
const stored = props.data?.waypoints;
|
||||
let aligned: Point[] | null = null;
|
||||
if (stored?.length) {
|
||||
aligned = alignWaypointsToEndpoints(stored, props.sourceX, props.sourceY, props.targetX, props.targetY, {
|
||||
obstacles: obstacleList,
|
||||
endpointIds,
|
||||
});
|
||||
// Drop ELK paths that skim endpoint table borders (align also rejects when obstacles present)
|
||||
if (aligned?.length && pathSkimsEndpoints(aligned, endpointRects)) {
|
||||
aligned = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (aligned?.length && live?.length) {
|
||||
const alignedLen = polylineLength(aligned);
|
||||
const liveLen = polylineLength(live);
|
||||
if (alignedLen > 0 && liveLen <= alignedLen * LIVE_ROUTE_LENGTH_RATIO) {
|
||||
return snapRoutedToHandles(live);
|
||||
}
|
||||
return snapRoutedToHandles(aligned);
|
||||
}
|
||||
if (aligned?.length) return snapRoutedToHandles(aligned);
|
||||
if (live?.length) return snapRoutedToHandles(live);
|
||||
return null;
|
||||
}
|
||||
|
||||
const pathResult = computed(() => {
|
||||
const routed = buildRoutedPoints();
|
||||
if (routed?.length) {
|
||||
return {
|
||||
path: pointsToSvgPath(routed),
|
||||
points: routed,
|
||||
};
|
||||
}
|
||||
|
||||
const [path] = getSmoothStepPath({
|
||||
sourceX: props.sourceX,
|
||||
sourceY: props.sourceY,
|
||||
targetX: props.targetX,
|
||||
targetY: props.targetY,
|
||||
sourcePosition: props.sourcePosition,
|
||||
targetPosition: props.targetPosition,
|
||||
borderRadius: 0,
|
||||
offset: EDGE_ROUTE_OFFSET,
|
||||
});
|
||||
// Smooth-step fallback: approximate with endpoint → mid → endpoint for badge placement
|
||||
const mid = {
|
||||
x: (props.sourceX + props.targetX) / 2,
|
||||
y: (props.sourceY + props.targetY) / 2,
|
||||
};
|
||||
return {
|
||||
path,
|
||||
points: [{ x: props.sourceX, y: props.sourceY }, mid, { x: props.targetX, y: props.targetY }],
|
||||
};
|
||||
});
|
||||
|
||||
const path = computed(() => pathResult.value.path);
|
||||
|
||||
const sourceBadgePos = computed(() => pointAlongPolyline(pathResult.value.points, SOURCE_CARDINALITY_T));
|
||||
const targetBadgePos = computed(() => pointAlongPolyline(pathResult.value.points, TARGET_CARDINALITY_T));
|
||||
|
||||
const idleStroke = computed(() => {
|
||||
const rel = props.data?.relationship;
|
||||
if (!rel) {
|
||||
return "color-mix(in srgb, var(--muted-foreground) 45%, transparent)";
|
||||
}
|
||||
if (isDiagramRelationship(rel)) {
|
||||
if (rel.kind === "foreign-key") {
|
||||
return "color-mix(in srgb, var(--primary) 55%, transparent)";
|
||||
}
|
||||
if (rel.kind === "custom") {
|
||||
return "color-mix(in srgb, var(--primary) 70%, transparent)";
|
||||
}
|
||||
}
|
||||
return "color-mix(in srgb, var(--muted-foreground) 45%, transparent)";
|
||||
});
|
||||
|
||||
const strokeColor = computed(() => (isHovered.value ? HOVER_BLUE : idleStroke.value));
|
||||
const strokeWidth = computed(() => (isHovered.value ? EDGE_STROKE_HOVER : EDGE_STROKE_IDLE));
|
||||
|
||||
const strokeDasharray = computed(() => {
|
||||
const rel = props.data?.relationship;
|
||||
if (rel && isDiagramRelationship(rel) && (rel.kind === "foreign-key" || rel.kind === "custom")) {
|
||||
return "none";
|
||||
}
|
||||
return "5,5";
|
||||
});
|
||||
|
||||
const sourceCardinality = computed(() => {
|
||||
const rel = props.data?.relationship;
|
||||
if (rel && isDiagramRelationship(rel) && rel.sourceCardinality) return rel.sourceCardinality;
|
||||
return "N";
|
||||
});
|
||||
|
||||
const targetCardinality = computed(() => {
|
||||
const rel = props.data?.relationship;
|
||||
if (rel && isDiagramRelationship(rel) && rel.targetCardinality) return rel.targetCardinality;
|
||||
return "1";
|
||||
});
|
||||
|
||||
const badgeClass = computed(() => (isHovered.value ? "border-blue-500 text-blue-600" : "border-border/80 text-foreground"));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseEdge
|
||||
:id="id"
|
||||
:path="path"
|
||||
:interaction-width="28"
|
||||
:style="{
|
||||
stroke: strokeColor,
|
||||
strokeWidth: strokeWidth,
|
||||
strokeDasharray: strokeDasharray === 'none' ? undefined : strokeDasharray,
|
||||
}"
|
||||
/>
|
||||
<EdgeLabelRenderer>
|
||||
<div
|
||||
class="nopan nodrag pointer-events-none absolute z-10 min-w-[1.1rem] rounded border bg-background/95 px-1 py-0.5 text-center font-mono text-[10px] font-semibold leading-none shadow-sm"
|
||||
:class="badgeClass"
|
||||
:style="{
|
||||
transform: `translate(-50%, -50%) translate(${sourceBadgePos.x}px, ${sourceBadgePos.y}px)`,
|
||||
}"
|
||||
>
|
||||
{{ sourceCardinality }}
|
||||
</div>
|
||||
<div
|
||||
class="nopan nodrag pointer-events-none absolute z-10 min-w-[1.1rem] rounded border bg-background/95 px-1 py-0.5 text-center font-mono text-[10px] font-semibold leading-none shadow-sm"
|
||||
:class="badgeClass"
|
||||
:style="{
|
||||
transform: `translate(-50%, -50%) translate(${targetBadgePos.x}px, ${targetBadgePos.y}px)`,
|
||||
}"
|
||||
>
|
||||
{{ targetCardinality }}
|
||||
</div>
|
||||
</EdgeLabelRenderer>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.diagram-flow .vue-flow__edge.relationship-edge,
|
||||
.diagram-flow .vue-flow__edge.relationship-edge.inactive {
|
||||
pointer-events: stroke !important;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.diagram-flow .vue-flow__edge.relationship-edge .vue-flow__edge-interaction {
|
||||
stroke: #000 !important;
|
||||
stroke-opacity: 0 !important;
|
||||
pointer-events: stroke !important;
|
||||
}
|
||||
|
||||
.diagram-flow .vue-flow__edge.relationship-edge:hover .vue-flow__edge-path,
|
||||
.diagram-flow .vue-flow__edge.relationship-edge.updating .vue-flow__edge-path {
|
||||
stroke: #2563eb !important;
|
||||
stroke-width: 3.5px !important;
|
||||
}
|
||||
|
||||
.diagram-flow .vue-flow__node-layer {
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
.diagram-flow .vue-flow__node-layer .layer-drag-handle {
|
||||
pointer-events: auto !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "resize", delta: number): void;
|
||||
}>();
|
||||
|
||||
const isResizing = ref(false);
|
||||
const startX = ref(0);
|
||||
|
||||
function onMouseDown(e: MouseEvent) {
|
||||
isResizing.value = true;
|
||||
startX.value = e.clientX;
|
||||
document.body.style.userSelect = "none";
|
||||
document.addEventListener("mousemove", onMouseMove);
|
||||
document.addEventListener("mouseup", onMouseUp);
|
||||
}
|
||||
|
||||
function onMouseMove(e: MouseEvent) {
|
||||
if (!isResizing.value) return;
|
||||
const delta = e.clientX - startX.value;
|
||||
startX.value = e.clientX;
|
||||
emit("resize", delta);
|
||||
}
|
||||
|
||||
function onMouseUp() {
|
||||
isResizing.value = false;
|
||||
document.body.style.userSelect = "";
|
||||
document.removeEventListener("mousemove", onMouseMove);
|
||||
document.removeEventListener("mouseup", onMouseUp);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-1 cursor-col-resize flex items-center justify-center hover:bg-muted/50 transition-colors group" :class="{ 'bg-muted': isResizing }" @mousedown="onMouseDown">
|
||||
<div class="w-0.5 h-8 bg-border group-hover:bg-muted-foreground/50 transition-colors" />
|
||||
</div>
|
||||
</template>
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,94 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { Handle, Position } from "@vue-flow/core";
|
||||
import { Table2, KeyRound, Link2 } from "@lucide/vue";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { DiagramTable, DiagramRelationship } from "@/lib/diagram/erDiagram";
|
||||
import { isDraftTable, isDroppedColumn } from "@/lib/diagram/erDiagram";
|
||||
import type { InferredRelationship } from "@/types/diagram";
|
||||
import { useLayerStore } from "@/lib/diagram/layer-store";
|
||||
import { CARD_WIDTH, COLUMN_TYPE_WIDTH, COLUMN_NAME_MAX_CHARS, COLUMN_TYPE_MAX_CHARS, TABLE_NAME_MAX_CHARS, EDGE_HANDLE_OUTSET } from "@/lib/diagram/diagram-constants";
|
||||
|
||||
const layerStore = useLayerStore();
|
||||
|
||||
const props = defineProps<{
|
||||
data: {
|
||||
table: DiagramTable;
|
||||
relationships?: (DiagramRelationship | InferredRelationship)[];
|
||||
};
|
||||
selected?: boolean;
|
||||
}>();
|
||||
const emit = defineEmits<{
|
||||
(e: "dblclick", event: MouseEvent): void;
|
||||
}>();
|
||||
|
||||
const isDraft = computed(() => isDraftTable(props.data.table));
|
||||
|
||||
function visibleColumns(table: DiagramTable) {
|
||||
return table.columns.filter((column) => !isDroppedColumn(table, column.name));
|
||||
}
|
||||
|
||||
function isForeignKeyColumn(table: DiagramTable, columnName: string): boolean {
|
||||
return table.foreignKeys.some((fk) => fk.column === columnName);
|
||||
}
|
||||
|
||||
function isRelationshipColumn(table: DiagramTable, columnName: string): boolean {
|
||||
if (!props.data.relationships) return false;
|
||||
return props.data.relationships.some((relationship) => (relationship.sourceTable === table.name && relationship.sourceColumn === columnName) || (relationship.targetTable === table.name && relationship.targetColumn === columnName));
|
||||
}
|
||||
|
||||
function truncateLabel(value: string, maxChars: number): string {
|
||||
if (value.length <= maxChars) return value;
|
||||
return `${value.slice(0, Math.max(1, maxChars - 1))}…`;
|
||||
}
|
||||
|
||||
const layerColor = computed(() => layerStore.getLayerColor(props.data.table.name));
|
||||
|
||||
const handleOffsetStyle = computed(() =>
|
||||
EDGE_HANDLE_OUTSET > 0
|
||||
? {
|
||||
left: { left: `-${EDGE_HANDLE_OUTSET}px` },
|
||||
right: { right: `-${EDGE_HANDLE_OUTSET}px` },
|
||||
top: { top: `-${EDGE_HANDLE_OUTSET}px` },
|
||||
bottom: { bottom: `-${EDGE_HANDLE_OUTSET}px` },
|
||||
}
|
||||
: { left: undefined, right: undefined, top: undefined, bottom: undefined },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative rounded-md border bg-background shadow-sm" :class="selected ? 'border-primary ring-1 ring-primary/30' : 'border-border'" :style="{ width: `${CARD_WIDTH}px`, borderLeft: `3px solid ${layerColor}` }" @dblclick.stop="emit('dblclick', $event)">
|
||||
<Handle id="left" type="source" :position="Position.Left" class="!h-2 !w-2 !min-h-0 !min-w-0 !border-0 !bg-transparent !opacity-0" :style="handleOffsetStyle.left" />
|
||||
<Handle id="left-target" type="target" :position="Position.Left" class="!h-2 !w-2 !min-h-0 !min-w-0 !border-0 !bg-transparent !opacity-0" :style="handleOffsetStyle.left" />
|
||||
<Handle id="right" type="source" :position="Position.Right" class="!h-2 !w-2 !min-h-0 !min-w-0 !border-0 !bg-transparent !opacity-0" :style="handleOffsetStyle.right" />
|
||||
<Handle id="right-target" type="target" :position="Position.Right" class="!h-2 !w-2 !min-h-0 !min-w-0 !border-0 !bg-transparent !opacity-0" :style="handleOffsetStyle.right" />
|
||||
<Handle id="top" type="source" :position="Position.Top" class="!h-2 !w-2 !min-h-0 !min-w-0 !border-0 !bg-transparent !opacity-0" :style="handleOffsetStyle.top" />
|
||||
<Handle id="top-target" type="target" :position="Position.Top" class="!h-2 !w-2 !min-h-0 !min-w-0 !border-0 !bg-transparent !opacity-0" :style="handleOffsetStyle.top" />
|
||||
<Handle id="bottom" type="source" :position="Position.Bottom" class="!h-2 !w-2 !min-h-0 !min-w-0 !border-0 !bg-transparent !opacity-0" :style="handleOffsetStyle.bottom" />
|
||||
<Handle id="bottom-target" type="target" :position="Position.Bottom" class="!h-2 !w-2 !min-h-0 !min-w-0 !border-0 !bg-transparent !opacity-0" :style="handleOffsetStyle.bottom" />
|
||||
<div class="overflow-hidden rounded-[inherit]">
|
||||
<div class="flex h-11 cursor-grab items-center gap-2 border-b bg-muted/40 px-3 active:cursor-grabbing">
|
||||
<Table2 class="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span class="min-w-0 flex-1 truncate text-sm font-medium" :title="data.table.name">
|
||||
{{ truncateLabel(data.table.name, TABLE_NAME_MAX_CHARS) }}
|
||||
</span>
|
||||
<Badge v-if="isDraft" variant="outline" class="h-5 shrink-0 px-1.5 text-[10px] border-amber-500/50 text-amber-700 dark:text-amber-400">Draft</Badge>
|
||||
<Badge variant="outline" class="h-5 px-1.5 text-[10px]">{{ visibleColumns(data.table).length }}</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<div v-for="column in visibleColumns(data.table)" :key="column.name" class="flex h-6 min-w-0 items-center gap-1.5 border-b border-border/40 px-3 text-xs last:border-b-0">
|
||||
<KeyRound v-if="column.is_primary_key" class="h-3 w-3 shrink-0 text-amber-500" />
|
||||
<Link2 v-else-if="isForeignKeyColumn(data.table, column.name)" class="h-3 w-3 shrink-0 text-primary" />
|
||||
<Link2 v-else-if="isRelationshipColumn(data.table, column.name)" class="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
<span v-else class="h-3 w-3 shrink-0" />
|
||||
<span class="min-w-0 flex-1 truncate font-mono" :title="column.name">
|
||||
{{ truncateLabel(column.name, COLUMN_NAME_MAX_CHARS) }}
|
||||
</span>
|
||||
<span class="shrink-0 truncate text-right text-[10px] text-muted-foreground" :style="{ width: `${COLUMN_TYPE_WIDTH}px` }" :title="column.data_type">
|
||||
{{ truncateLabel(column.data_type, COLUMN_TYPE_MAX_CHARS) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
import { useVueFlow } from "@vue-flow/core";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ZoomIn, ZoomOut, Maximize2, RotateCcw, Undo2, Redo2 } from "@lucide/vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
defineProps<{
|
||||
canUndo?: boolean;
|
||||
canRedo?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "undo"): void;
|
||||
(e: "redo"): void;
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const { zoomIn: vfZoomIn, zoomOut: vfZoomOut, fitView } = useVueFlow();
|
||||
|
||||
const FIT_OPTIONS = { padding: 0.15, duration: 150, minZoom: 0.05, maxZoom: 2 } as const;
|
||||
|
||||
function zoomIn() {
|
||||
vfZoomIn({ duration: 150 });
|
||||
}
|
||||
|
||||
function zoomOut() {
|
||||
vfZoomOut({ duration: 150 });
|
||||
}
|
||||
|
||||
function resetZoom() {
|
||||
void fitView({ ...FIT_OPTIONS });
|
||||
}
|
||||
|
||||
function fitToView() {
|
||||
void fitView({ ...FIT_OPTIONS });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="absolute bottom-3 left-3 z-50 flex flex-col items-center gap-1">
|
||||
<Button variant="outline" size="icon" class="h-7 w-7" :title="t('diagram.undo')" :disabled="!canUndo" @click="emit('undo')">
|
||||
<Undo2 class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="outline" size="icon" class="h-7 w-7" :title="t('diagram.redo')" :disabled="!canRedo" @click="emit('redo')">
|
||||
<Redo2 class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<div class="w-px h-1 bg-border my-1" />
|
||||
<Button variant="outline" size="icon" class="h-7 w-7" :title="t('diagram.zoomIn')" @click="zoomIn">
|
||||
<ZoomIn class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="outline" size="icon" class="h-7 w-7" :title="t('diagram.zoomOut')" @click="zoomOut">
|
||||
<ZoomOut class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<div class="w-px h-1 bg-border my-1" />
|
||||
<Button variant="outline" size="icon" class="h-7 w-7" :title="t('diagram.fitView')" @click="fitToView">
|
||||
<Maximize2 class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="outline" size="icon" class="h-7 w-7" :title="t('diagram.resetZoom')" @click="resetZoom">
|
||||
<RotateCcw class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,118 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { createApp, defineComponent, h, nextTick, ref, type App } from "vue";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { DiagramTable } from "@/lib/diagram/erDiagram";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
buildDropTableSql: vi.fn(async (options: { schema?: string; tableName: string; cascade?: boolean }) => {
|
||||
const qualifiedName = options.schema ? `"${options.schema}"."${options.tableName}"` : `"${options.tableName}"`;
|
||||
return `DROP TABLE ${qualifiedName}${options.cascade ? " CASCADE" : ""};`;
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("vue-i18n", () => ({
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/dialog", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
const passthrough = defineComponent({
|
||||
setup(_props, { slots }) {
|
||||
return () => h("div", slots.default?.());
|
||||
},
|
||||
});
|
||||
return {
|
||||
Dialog: passthrough,
|
||||
DialogContent: passthrough,
|
||||
DialogFooter: passthrough,
|
||||
DialogHeader: passthrough,
|
||||
DialogTitle: passthrough,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/components/ui/button", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
return {
|
||||
Button: defineComponent({
|
||||
inheritAttrs: false,
|
||||
setup(_props, { attrs, slots }) {
|
||||
return () => h("button", attrs, slots.default?.());
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/backend/api", () => ({
|
||||
buildCreateTableSql: vi.fn(),
|
||||
buildTableStructureChangeSql: vi.fn(),
|
||||
executeBatch: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/database/dbAdminSql", () => ({
|
||||
buildDropTableSql: mocks.buildDropTableSql,
|
||||
supportsDropTableCascade: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
import DiagramSyncDialog from "../DiagramSyncDialog.vue";
|
||||
|
||||
const mountedApps: Array<{ app: App; host: HTMLElement }> = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const { app, host } of mountedApps.splice(0)) {
|
||||
app.unmount();
|
||||
host.remove();
|
||||
}
|
||||
mocks.buildDropTableSql.mockClear();
|
||||
});
|
||||
|
||||
async function flushAsyncUpdates() {
|
||||
await nextTick();
|
||||
await Promise.resolve();
|
||||
await nextTick();
|
||||
}
|
||||
|
||||
describe("DiagramSyncDialog", () => {
|
||||
it("previews live table deletion without silently enabling CASCADE", async () => {
|
||||
const liveTable: DiagramTable = {
|
||||
name: "orders",
|
||||
columns: [],
|
||||
foreignKeys: [],
|
||||
origin: "live",
|
||||
pendingDrop: true,
|
||||
};
|
||||
const open = ref(false);
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const app = createApp(
|
||||
defineComponent({
|
||||
setup() {
|
||||
return () =>
|
||||
h(DiagramSyncDialog, {
|
||||
open: open.value,
|
||||
tables: [liveTable],
|
||||
connectionId: "connection-1",
|
||||
database: "app",
|
||||
schema: "public",
|
||||
databaseType: "postgres",
|
||||
"onUpdate:open": (value: boolean) => (open.value = value),
|
||||
});
|
||||
},
|
||||
}),
|
||||
);
|
||||
app.mount(host);
|
||||
mountedApps.push({ app, host });
|
||||
|
||||
open.value = true;
|
||||
await flushAsyncUpdates();
|
||||
|
||||
expect(mocks.buildDropTableSql).toHaveBeenCalledWith({
|
||||
databaseType: "postgres",
|
||||
schema: "public",
|
||||
tableName: "orders",
|
||||
cascade: false,
|
||||
});
|
||||
expect(host.textContent).toContain('DROP TABLE "public"."orders";');
|
||||
expect(host.textContent).not.toContain("CASCADE");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { createApp, nextTick, type App } from "vue";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import ZoomControls from "@/components/diagram/ZoomControls.vue";
|
||||
|
||||
const vueFlowMock = vi.hoisted(() => ({
|
||||
zoomIn: vi.fn(),
|
||||
zoomOut: vi.fn(),
|
||||
fitView: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
vi.mock("@vue-flow/core", () => ({
|
||||
useVueFlow: () => vueFlowMock,
|
||||
}));
|
||||
|
||||
vi.mock("vue-i18n", () => ({
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
const mountedApps: App[] = [];
|
||||
|
||||
function mountZoom(props: { canUndo?: boolean; canRedo?: boolean } = {}) {
|
||||
const emits: string[] = [];
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
const app = createApp(ZoomControls, {
|
||||
canUndo: props.canUndo ?? true,
|
||||
canRedo: props.canRedo ?? true,
|
||||
onUndo: () => emits.push("undo"),
|
||||
onRedo: () => emits.push("redo"),
|
||||
});
|
||||
mountedApps.push(app);
|
||||
app.mount(container);
|
||||
return { container, emits };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const app of mountedApps.splice(0)) app.unmount();
|
||||
document.body.innerHTML = "";
|
||||
vueFlowMock.zoomIn.mockClear();
|
||||
vueFlowMock.zoomOut.mockClear();
|
||||
vueFlowMock.fitView.mockClear();
|
||||
});
|
||||
|
||||
describe("ZoomControls", () => {
|
||||
it("emits undo/redo and calls vue-flow zoom helpers", async () => {
|
||||
const { container, emits } = mountZoom();
|
||||
await nextTick();
|
||||
const buttons = container.querySelectorAll("button");
|
||||
expect(buttons.length).toBeGreaterThanOrEqual(6);
|
||||
|
||||
buttons[0].dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
buttons[1].dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
buttons[2].dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
buttons[3].dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
buttons[4].dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
buttons[5].dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
|
||||
expect(emits).toEqual(["undo", "redo"]);
|
||||
expect(vueFlowMock.zoomIn).toHaveBeenCalled();
|
||||
expect(vueFlowMock.zoomOut).toHaveBeenCalled();
|
||||
expect(vueFlowMock.fitView).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("disables undo/redo when props are false", async () => {
|
||||
const { container } = mountZoom({ canUndo: false, canRedo: false });
|
||||
await nextTick();
|
||||
const buttons = container.querySelectorAll("button");
|
||||
expect((buttons[0] as HTMLButtonElement).disabled).toBe(true);
|
||||
expect((buttons[1] as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user