From 022df3665fce23597e61abf4c3d5a23a3bb6d5e2 Mon Sep 17 00:00:00 2001 From: zipg Date: Thu, 2 Jul 2026 16:09:53 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E8=A1=A8=E7=BB=93=E6=9E=84=E9=A1=B5=E9=9D=A2=E4=BD=93=E9=AA=8C?= =?UTF-8?q?=20(#2406)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: staff --- .../structure/TableStructureEditor.vue | 257 +++++++++++++++++- apps/desktop/src/i18n/locales/en.ts | 4 + apps/desktop/src/i18n/locales/es.ts | 4 + apps/desktop/src/i18n/locales/it.ts | 4 + apps/desktop/src/i18n/locales/ja.ts | 4 + apps/desktop/src/i18n/locales/pt-BR.ts | 4 + apps/desktop/src/i18n/locales/zh-CN.ts | 4 + apps/desktop/src/i18n/locales/zh-TW.ts | 4 + 8 files changed, 271 insertions(+), 14 deletions(-) diff --git a/apps/desktop/src/components/structure/TableStructureEditor.vue b/apps/desktop/src/components/structure/TableStructureEditor.vue index 670f0a117c..9e6a68475f 100644 --- a/apps/desktop/src/components/structure/TableStructureEditor.vue +++ b/apps/desktop/src/components/structure/TableStructureEditor.vue @@ -6,7 +6,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { AlertTriangle, Check, ChevronDown, Copy, Database, Info, KeyRound, ListChevronsUpDown, Loader2, Maximize2, Plus, RefreshCw, Save, Settings, SlidersHorizontal, Trash2, X } from "@lucide/vue"; +import { AlertTriangle, Check, ChevronDown, ChevronUp, Copy, Database, Info, KeyRound, ListChevronsUpDown, Loader2, Maximize2, Plus, RefreshCw, Save, Search, Settings, SlidersHorizontal, Trash2, X } from "@lucide/vue"; import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; @@ -22,6 +22,7 @@ import { type SqlHighlighter, createShikiSqlHighlighter } from "@/lib/sqlHighlig import { copyToClipboard } from "@/lib/clipboard"; import { formatSqlForDisplay, sqlFormatDialectForDbType } from "@/lib/sqlFormatter"; import { queryTimeoutSecsForConnection } from "@/lib/queryTimeout"; +import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/safeStorage"; import { type EditableStructureColumn, type EditableStructureForeignKey, type EditableStructureIndex, type EditableStructureTrigger } from "@/lib/tableStructureEditorSql"; import { PRESET_FIELDS_TEMPLATE_ID, createTableColumnTemplateDrafts } from "@/lib/tableColumnTemplates"; import { getTableMetadataCapabilities } from "@/lib/tableMetadataCapabilities"; @@ -221,6 +222,12 @@ function isPlainModShortcut(event: KeyboardEvent, key: string): boolean { } const structureDensityValues: StructureEditorDensity[] = ["compact", "standard", "comfortable"]; +const STRUCTURE_COLUMNS_WIDTHS_STORAGE_KEY = "dbx-structure-editor-column-widths"; +const STRUCTURE_INDEX_COLUMNS_WIDTHS_STORAGE_KEY = "dbx-structure-editor-index-column-widths"; +const STRUCTURE_SQL_PREVIEW_COLLAPSED_STORAGE_KEY = "dbx-structure-editor-sql-preview-collapsed"; +const STRUCTURE_COLUMN_WIDTH_COUNT = 10; +const STRUCTURE_INDEX_COLUMN_WIDTH_COUNT = 8; +const PERSISTED_STRUCTURE_INDEX_COLUMN_WIDTHS = new Set([0, 1, 6]); const structureDensityMetrics: Record< StructureEditorDensity, { @@ -242,7 +249,7 @@ const structureDensityMetrics: Record< } > = { compact: { - columns: [28, 120, 136, 82, 60, 52, 108, 124, 144, 108], + columns: [28, 168, 136, 82, 60, 52, 108, 220, 144, 108], indexes: [120, 180, 60, 88, 124, 144, 120, 70], minColumnWidth: 24, minIndexColumnWidth: 48, @@ -259,7 +266,7 @@ const structureDensityMetrics: Record< lineHeight: 1.35, }, standard: { - columns: [32, 144, 160, 104, 72, 64, 128, 152, 160, 136], + columns: [32, 200, 160, 104, 72, 64, 128, 260, 160, 136], indexes: [148, 224, 72, 108, 148, 180, 148, 84], minColumnWidth: 28, minIndexColumnWidth: 60, @@ -276,7 +283,7 @@ const structureDensityMetrics: Record< lineHeight: 1.4, }, comfortable: { - columns: [36, 168, 188, 116, 84, 76, 152, 188, 188, 148], + columns: [36, 232, 188, 116, 84, 76, 152, 300, 188, 148], indexes: [176, 260, 84, 124, 176, 216, 176, 104], minColumnWidth: 32, minIndexColumnWidth: 64, @@ -302,6 +309,79 @@ function metricsForDensity(density: StructureEditorDensity) { return structureDensityMetrics[density]; } +function normalizeStructureColumnWidths(value: unknown, density: StructureEditorDensity): number[] | null { + if (!Array.isArray(value) || value.length !== STRUCTURE_COLUMN_WIDTH_COUNT) return null; + const minWidth = metricsForDensity(density).minColumnWidth; + const widths = value.map((item) => Number(item)); + if (widths.some((item) => !Number.isFinite(item))) return null; + return widths.map((item) => Math.max(minWidth, item)); +} + +function normalizeStructureIndexColumnWidths(value: unknown, density: StructureEditorDensity): number[] | null { + if (!Array.isArray(value) || value.length !== STRUCTURE_INDEX_COLUMN_WIDTH_COUNT) return null; + const minWidth = metricsForDensity(density).minIndexColumnWidth; + const widths = value.map((item) => Number(item)); + if (widths.some((item) => !Number.isFinite(item))) return null; + return widths.map((item) => Math.max(minWidth, item)); +} + +function loadStructureWidthsByDensity(storageKey: string, density: StructureEditorDensity): unknown { + const raw = safeLocalStorageGet(storageKey); + if (!raw) return undefined; + try { + const parsed = JSON.parse(raw) as Partial>; + return parsed?.[density]; + } catch { + return undefined; + } +} + +function loadStructureColumnWidths(density: StructureEditorDensity): number[] { + const fallback = [...metricsForDensity(density).columns]; + const stored = loadStructureWidthsByDensity(STRUCTURE_COLUMNS_WIDTHS_STORAGE_KEY, density); + return normalizeStructureColumnWidths(stored, density) ?? fallback; +} + +function loadStructureIndexColumnWidths(density: StructureEditorDensity): number[] { + const fallback = [...metricsForDensity(density).indexes]; + const stored = normalizeStructureIndexColumnWidths(loadStructureWidthsByDensity(STRUCTURE_INDEX_COLUMNS_WIDTHS_STORAGE_KEY, density), density); + if (!stored) return fallback; + return fallback.map((width, index) => (PERSISTED_STRUCTURE_INDEX_COLUMN_WIDTHS.has(index) ? stored[index] : width)); +} + +function saveStructureWidthsByDensity(storageKey: string, density: StructureEditorDensity, widths: readonly number[]) { + let payload: Partial> = {}; + const raw = safeLocalStorageGet(storageKey); + if (raw) { + try { + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === "object") payload = parsed; + } catch { + payload = {}; + } + } + payload[density] = [...widths]; + safeLocalStorageSet(storageKey, JSON.stringify(payload)); +} + +function saveStructureColumnWidths(density: StructureEditorDensity, widths: readonly number[]) { + const normalized = normalizeStructureColumnWidths([...widths], density); + if (!normalized) return; + saveStructureWidthsByDensity(STRUCTURE_COLUMNS_WIDTHS_STORAGE_KEY, density, normalized); +} + +function saveStructureIndexColumnWidths(density: StructureEditorDensity, widths: readonly number[]) { + const normalized = normalizeStructureIndexColumnWidths([...widths], density); + if (!normalized) return; + const fallback = metricsForDensity(density).indexes; + const stored = fallback.map((width, index) => (PERSISTED_STRUCTURE_INDEX_COLUMN_WIDTHS.has(index) ? normalized[index] : width)); + saveStructureWidthsByDensity(STRUCTURE_INDEX_COLUMNS_WIDTHS_STORAGE_KEY, density, stored); +} + +function loadSqlPreviewCollapsed(): boolean { + return safeLocalStorageGet(STRUCTURE_SQL_PREVIEW_COLLAPSED_STORAGE_KEY) === "true"; +} + const structureDensity = computed(() => settingsStore.editorSettings.structureEditorDensity); const localStructureDensity = ref(structureDensity.value); const structureDensityMetric = computed(() => metricsForDensity(localStructureDensity.value)); @@ -341,9 +421,8 @@ const structureDensityMenuOpen = ref(false); const structureDensityMenuRef = ref(); function applyStructureDensityWidths(density: StructureEditorDensity) { - const metric = metricsForDensity(density); - colWidths.value = [...metric.columns]; - indexColWidths.value = [...metric.indexes]; + colWidths.value = loadStructureColumnWidths(density); + indexColWidths.value = loadStructureIndexColumnWidths(density); } function setStructureDensity(value: unknown) { @@ -408,11 +487,15 @@ function persistStructureDensity(density = localStructureDensity.value) { settingsStore.updateEditorSettings({ structureEditorDensity: density }); } -const initialDensityMetric = metricsForDensity(structureDensity.value); -const colWidths = ref([...initialDensityMetric.columns]); +const colWidths = ref(loadStructureColumnWidths(structureDensity.value)); const colResizing = ref<{ col: number; startX: number; startW: number } | null>(null); -const indexColWidths = ref([...initialDensityMetric.indexes]); +const indexColWidths = ref(loadStructureIndexColumnWidths(structureDensity.value)); const resizing = ref<{ col: number; startX: number; startW: number } | null>(null); +const columnSearchInputRef = ref>(); +const columnSearchText = ref(""); +const highlightedColumnId = ref(null); +const sqlPreviewCollapsed = ref(loadSqlPreviewCollapsed()); +let columnHighlightTimer: ReturnType | undefined; watch( structureDensity, @@ -440,6 +523,7 @@ function onColResize(e: MouseEvent, col: number) { }; const onUp = () => { colResizing.value = null; + saveStructureColumnWidths(localStructureDensity.value, colWidths.value); document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); }; @@ -457,6 +541,9 @@ function onIndexColResize(e: MouseEvent, col: number) { }; const onUp = () => { resizing.value = null; + if (PERSISTED_STRUCTURE_INDEX_COLUMN_WIDTHS.has(col)) { + saveStructureIndexColumnWidths(localStructureDensity.value, indexColWidths.value); + } document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); }; @@ -586,6 +673,22 @@ const colLabels = computed(() => { return labels; }); const indexColLabels = computed(() => [t("structureEditor.indexName"), t("structureEditor.indexColumns"), t("structureEditor.unique"), t("structureEditor.indexType"), t("structureEditor.includedColumns"), t("structureEditor.filter"), t("structureEditor.comment"), t("structureEditor.actions")]); +const filteredColumnRowIds = computed(() => { + const query = columnSearchText.value.trim().toLowerCase(); + if (!query) return new Set(); + return new Set( + columns.value + .filter((column) => + [column.name, column.comment].some((value) => + String(value ?? "") + .toLowerCase() + .includes(query), + ), + ) + .map((column) => column.id), + ); +}); +const columnSearchMatchCount = computed(() => (columnSearchText.value.trim() ? filteredColumnRowIds.value.size : 0)); const foreignKeyActionOptions = ["", "CASCADE", "SET NULL", "RESTRICT", "NO ACTION"]; const triggerTimingOptions = ["BEFORE", "AFTER"]; const triggerEventOptions = ["INSERT", "UPDATE", "DELETE"]; @@ -1264,8 +1367,11 @@ function onColumnDragEnd() { function columnRowClass(column: EditableStructureColumn, index: number) { const dragState = columnDragState.value; + const isSearchMatch = filteredColumnRowIds.value.has(column.id); return { "bg-destructive/5 opacity-60": column.markedForDrop, + "structure-column-search-match": isSearchMatch, + "structure-column-search-current": highlightedColumnId.value === column.id, "opacity-55": dragState?.columnId === column.id, "bg-primary/5": dragState && (dragState.insertionIndex === index || dragState.insertionIndex === index + 1), "[&>td]:border-t-2 [&>td]:border-t-primary": dragState?.insertionIndex === index, @@ -1273,6 +1379,68 @@ function columnRowClass(column: EditableStructureColumn, index: number) { }; } +function columnMatchesSearch(column: EditableStructureColumn): boolean { + const query = columnSearchText.value.trim().toLowerCase(); + if (!query) return false; + return [column.name, column.comment].some((value) => + String(value ?? "") + .toLowerCase() + .includes(query), + ); +} + +function columnFieldMatchesSearch(value: string | null | undefined): boolean { + const query = columnSearchText.value.trim().toLowerCase(); + return ( + !!query && + String(value ?? "") + .toLowerCase() + .includes(query) + ); +} + +function columnSearchFieldClass(column: EditableStructureColumn, value: string | null | undefined) { + const matches = columnFieldMatchesSearch(value); + return { + "!border-primary/60 !bg-primary/10": matches, + "!border-primary !ring-2 !ring-primary/30": matches && highlightedColumnId.value === column.id, + }; +} + +function focusColumnSearch() { + activeTab.value = "columns"; + void nextTick(() => { + const input = columnSearchInputRef.value?.$el as HTMLInputElement | undefined; + input?.focus(); + input?.select(); + }); +} + +function scrollToColumnSearchMatch(direction: 1 | -1 = 1) { + const query = columnSearchText.value.trim(); + if (!query) { + focusColumnSearch(); + return; + } + const rows = Array.from(rootRef.value?.querySelectorAll("[data-column-row-index]") ?? []); + const matches = columns.value.map((column, index) => ({ column, index })).filter(({ column }) => columnMatchesSearch(column)); + if (!matches.length) return; + const currentIndex = highlightedColumnId.value ? matches.findIndex(({ column }) => column.id === highlightedColumnId.value) : -1; + const nextMatch = matches[(currentIndex + direction + matches.length) % matches.length] ?? matches[0]; + highlightedColumnId.value = nextMatch.column.id; + rows[nextMatch.index]?.scrollIntoView({ block: "center", inline: "nearest" }); + if (columnHighlightTimer) window.clearTimeout(columnHighlightTimer); + columnHighlightTimer = window.setTimeout(() => { + highlightedColumnId.value = null; + }, 1800); +} + +function onColumnSearchKeydown(event: KeyboardEvent) { + if (event.key !== "Enter") return; + event.preventDefault(); + scrollToColumnSearchMatch(event.shiftKey ? -1 : 1); +} + function columnDragInsertionIndex(index: number, event: DragEvent): number { const target = event.currentTarget; if (!(target instanceof HTMLElement)) return index; @@ -1550,6 +1718,11 @@ async function copyPreviewSql() { } } +function toggleSqlPreviewCollapsed() { + sqlPreviewCollapsed.value = !sqlPreviewCollapsed.value; + safeLocalStorageSet(STRUCTURE_SQL_PREVIEW_COLLAPSED_STORAGE_KEY, String(sqlPreviewCollapsed.value)); +} + async function applyChanges() { if (!canApply.value || !props.connectionId || !props.database) return; saving.value = true; @@ -1607,6 +1780,12 @@ function addItemForActiveTab(): boolean { } function onStructureEditorKeydown(event: KeyboardEvent) { + if (isPlainModShortcut(event, "f")) { + event.preventDefault(); + event.stopPropagation(); + if (activeTab.value === "columns") focusColumnSearch(); + return; + } if (isPlainModShortcut(event, "s")) { event.preventDefault(); event.stopPropagation(); @@ -1663,6 +1842,7 @@ onBeforeUnmount(() => { stopColumnDragTracking(); unregisterStructureEditorShortcuts(); clearSqlPreviewState(); + if (columnHighlightTimer) window.clearTimeout(columnHighlightTimer); persistStructureDensity(); }); @@ -1712,6 +1892,7 @@ watch( ); watch(activeTab, () => { + highlightedColumnId.value = null; syncDraftToParent(); }); @@ -1820,6 +2001,25 @@ watch(activeTab, (tab) => { +
+ + + +