feat: 优化编辑表结构页面体验 (#2406)

Co-authored-by: staff <staff@qimaos-MacBook-Pro.local>
This commit is contained in:
zipgandstaff authored and GitHub committed 2026-07-02 16:09:53 +08:00
1 parent 1ef757e88f
commit 022df3665f
8 files changed
+271 -14

No files matched your search

@@ -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<Record<StructureEditorDensity, unknown>>;
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<Record<StructureEditorDensity, number[]>> = {};
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<StructureEditorDensity>(structureDensity.value);
const structureDensityMetric = computed(() => metricsForDensity(localStructureDensity.value));
@@ -341,9 +421,8 @@ const structureDensityMenuOpen = ref(false);
const structureDensityMenuRef = ref<HTMLElement>();
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<InstanceType<typeof Input>>();
const columnSearchText = ref("");
const highlightedColumnId = ref<string | null>(null);
const sqlPreviewCollapsed = ref(loadSqlPreviewCollapsed());
let columnHighlightTimer: ReturnType<typeof window.setTimeout> | 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<string>();
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<HTMLElement>("[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) => {
</div>
</div>
</div>
<div v-if="activeTab === 'columns'" class="relative flex w-40 shrink-0 items-center">
<Search :class="[structureIconClass, 'pointer-events-none absolute left-2 text-muted-foreground']" />
<Input
ref="columnSearchInputRef"
v-model="columnSearchText"
:placeholder="t('structureEditor.searchColumns')"
:class="[structureControlClass, 'pl-7 pr-14 text-[length:var(--structure-font-size)] placeholder:text-[length:var(--structure-font-size)]']"
@keydown="onColumnSearchKeydown"
/>
<button
v-if="columnSearchText"
type="button"
class="absolute right-1.5 top-1/2 -translate-y-1/2 rounded px-1 text-[length:var(--structure-font-size)] text-muted-foreground hover:bg-muted hover:text-foreground"
:title="t('structureEditor.nextColumnMatch')"
@click="scrollToColumnSearchMatch(1)"
>
{{ columnSearchMatchCount }}
</button>
</div>
<Button v-if="activeTab === 'columns'" size="sm" :class="structureToolbarButtonClass" :disabled="!canAddColumn" @click="addColumn">
<Plus :class="structureIconClass" />
{{ t("structureEditor.addColumn") }}
@@ -1878,7 +2078,7 @@ watch(activeTab, (tab) => {
</div>
</td>
<td :class="structureCellClass">
<Input v-model="column.name" :class="structureControlClass" :disabled="isColumnNameDisabled(column)" data-column-name-input />
<Input v-model="column.name" :class="[structureControlClass, columnSearchFieldClass(column, column.name)]" :disabled="isColumnNameDisabled(column)" data-column-name-input />
</td>
<td :class="structureCellClass">
<SearchableSelect
@@ -1936,7 +2136,7 @@ watch(activeTab, (tab) => {
</td>
<td v-if="columnEditorControls.comment" :class="structureCellClass">
<div class="flex min-w-0 items-center gap-1">
<Input v-model="column.comment" :class="[structureControlClass, 'flex-1']" :disabled="isColumnCommentDisabled(column)" />
<Input v-model="column.comment" :class="[structureControlClass, 'flex-1', columnSearchFieldClass(column, column.comment)]" :disabled="isColumnCommentDisabled(column)" />
<Popover>
<PopoverTrigger as-child>
<Button variant="ghost" size="icon" :class="[structureIconButtonClass, 'shrink-0']" :disabled="isColumnCommentDisabled(column)" :aria-label="t('structureEditor.editComment')" :title="t('structureEditor.editComment')">
@@ -2335,7 +2535,7 @@ watch(activeTab, (tab) => {
</Tabs>
</div>
<div class="flex h-[28%] min-h-40 min-w-0 max-h-64 shrink-0 flex-col overflow-hidden rounded-md border">
<div :class="['flex min-w-0 shrink-0 flex-col overflow-hidden rounded-md border', sqlPreviewCollapsed ? '' : 'h-[28%] min-h-40 max-h-64']">
<div class="flex shrink-0 items-center justify-between border-b px-[var(--structure-cell-px)] py-[var(--structure-header-py)] text-[length:var(--structure-font-size)] font-medium">
<div class="flex items-center gap-1.5">
<span>{{ t("structureEditor.sqlPreview") }}</span>
@@ -2345,6 +2545,16 @@ watch(activeTab, (tab) => {
</Badge>
</div>
<div class="flex items-center gap-1.5">
<Button
variant="ghost"
:class="structureIconButtonClass"
:aria-label="sqlPreviewCollapsed ? t('structureEditor.expandSqlPreview') : t('structureEditor.collapseSqlPreview')"
:title="sqlPreviewCollapsed ? t('structureEditor.expandSqlPreview') : t('structureEditor.collapseSqlPreview')"
@click="toggleSqlPreviewCollapsed"
>
<ChevronUp v-if="sqlPreviewCollapsed" :class="structureIconClass" />
<ChevronDown v-else :class="structureIconClass" />
</Button>
<Button variant="ghost" :class="structureToolbarButtonClass" :disabled="!previewSqlText.trim()" @click="copyPreviewSql">
<Copy :class="[structureIconClass, 'mr-1']" />
{{ t("structureEditor.copySql") }}
@@ -2355,7 +2565,7 @@ watch(activeTab, (tab) => {
</Badge>
</div>
</div>
<div class="min-h-0 flex-1 overflow-auto p-2.5">
<div v-if="!sqlPreviewCollapsed" class="min-h-0 flex-1 overflow-auto p-2.5">
<div v-if="warnings.length" class="mb-2 space-y-1">
<div v-for="warning in warnings" :key="warning" class="flex gap-1.5 rounded-md border border-yellow-300/40 bg-yellow-500/10 px-[var(--structure-cell-px)] py-[var(--structure-cell-py)] text-[length:var(--structure-font-size)] text-yellow-700 dark:text-yellow-300">
<AlertTriangle :class="[structureIconClass, 'mt-0.5 shrink-0']" />
@@ -2383,3 +2593,22 @@ watch(activeTab, (tab) => {
</div>
</div>
</template>
<style scoped>
.structure-column-search-match > td:first-child {
box-shadow: inset 3px 0 0 hsl(var(--primary) / 0.55);
}
.structure-column-search-current > td {
box-shadow:
inset 0 1px 0 hsl(var(--primary) / 0.55),
inset 0 -1px 0 hsl(var(--primary) / 0.55);
}
.structure-column-search-current > td:first-child {
box-shadow:
inset 3px 0 0 hsl(var(--primary)),
inset 0 1px 0 hsl(var(--primary) / 0.55),
inset 0 -1px 0 hsl(var(--primary) / 0.55);
}
</style>