Fix:保留表结构编辑未保存草稿

This commit is contained in:
zipg 2026-06-29 12:11:59 +08:00 committed by GitHub
parent 7173f07968
commit a65a5881d5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 143 additions and 4 deletions

View File

@ -1262,6 +1262,8 @@ defineExpose({ focusSearch, refreshData, handleModRTarget, requestQueryEditorExe
:database="activeTab.database"
:schema="activeTab.schema"
:table-name="activeTab.structureTableName || ''"
:draft="activeTab.structureDraft"
@update:draft="(draft) => (activeTab.structureDraft = draft)"
@saved="(commentChanged) => emit('structureEditorSaved', commentChanged)"
@close="emit('structureEditorClose')"
@open-settings="(initialTab, initialSection) => emit('openSettings', initialTab, initialSection)"

View File

@ -27,6 +27,7 @@ import { PRESET_FIELDS_TEMPLATE_ID, createTableColumnTemplateDrafts } from "@/li
import { getTableMetadataCapabilities } from "@/lib/tableMetadataCapabilities";
import { canAddTableStructureColumn, getTableStructureCapabilities } from "@/lib/tableStructureCapabilities";
import { connectionObjectTreeQuerySchema, tableStructureDatabaseTypeForConnection } from "@/lib/jdbcDialect";
import type { TableStructureEditorDraft } from "@/types/database";
import {
buildStructureTargetLabel,
combineDataTypeForDatabase,
@ -79,9 +80,11 @@ const props = defineProps<{
database: string;
schema?: string;
tableName: string;
draft?: TableStructureEditorDraft;
}>();
const emit = defineEmits<{
"update:draft": [draft: TableStructureEditorDraft | undefined];
saved: [commentChanged: boolean];
close: [];
openSettings: [initialTab?: string, initialSection?: string];
@ -597,6 +600,54 @@ let sqlPreviewDebounceTimer: ReturnType<typeof setTimeout> | undefined;
let deferredSqlPreviewRefresh = false;
let keydownListenerRegistered = false;
let skipNextRefreshVersion = false;
let restoringDraft = false;
let syncingDraft = false;
let draftHydrated = false;
function cloneDraftValue<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}
function createCurrentDraft(initialized = true): TableStructureEditorDraft {
return {
activeTab: activeTab.value as TableStructureEditorDraft["activeTab"],
newTableName: newTableName.value,
tableComment: tableComment.value,
originalTableComment: originalTableComment.value,
columns: cloneDraftValue(columns.value),
indexes: cloneDraftValue(indexes.value),
foreignKeys: cloneDraftValue(foreignKeys.value),
triggers: cloneDraftValue(triggers.value),
initialized,
};
}
function syncDraftToParent() {
if (!draftHydrated) return;
if (restoringDraft || syncingDraft) return;
syncingDraft = true;
emit("update:draft", createCurrentDraft());
syncingDraft = false;
}
function restoreDraft(draft: TableStructureEditorDraft) {
restoringDraft = true;
activeTab.value = draft.activeTab || "columns";
newTableName.value = draft.newTableName || "";
tableComment.value = draft.tableComment || "";
originalTableComment.value = draft.originalTableComment || "";
columns.value = cloneDraftValue(draft.columns || []);
indexes.value = cloneDraftValue(draft.indexes || []);
foreignKeys.value = cloneDraftValue(draft.foreignKeys || []);
triggers.value = cloneDraftValue(draft.triggers || []);
restoringDraft = false;
draftHydrated = true;
}
function markDraftHydratedAndSync() {
draftHydrated = true;
syncDraftToParent();
}
function hasPendingStructureChanges(): boolean {
if (isCreateMode.value) {
@ -729,6 +780,11 @@ const canApply = computed(
() => !loading.value && !saving.value && !postSaveRefreshing.value && !secondaryMetadataLoading.value && !sqlPreviewLoading.value && pendingStatements.value.length > 0 && warnings.value.length === 0 && !!props.connectionId && (isCreateMode.value ? !!newTableName.value.trim() : !!props.tableName),
);
function clearDraft() {
draftHydrated = false;
emit("update:draft", undefined);
}
function resetState() {
activeTab.value = "columns";
loading.value = false;
@ -752,6 +808,12 @@ function resetState() {
originalTableComment.value = "";
}
async function reloadStructureFromDatabase() {
if (isCreateMode.value) return;
draftHydrated = false;
await loadStructure(false, FULL_STRUCTURE_REFRESH_SCOPE, true, { blockSecondaryMetadata: true });
}
function setSecondaryMetadataLoading(scope: StructureRefreshScope, value: boolean) {
if (scope.indexes && tableMetadataCapabilities.value.indexes) indexesLoading.value = value;
if (scope.foreignKeys && tableMetadataCapabilities.value.foreignKeys) foreignKeysLoading.value = value;
@ -772,7 +834,7 @@ async function fetchTableCommentValue(connectionId: string, database: string, sc
}
}
async function loadStructure(silent = false, scope: StructureRefreshScope = FULL_STRUCTURE_REFRESH_SCOPE, showErrors = true, options: { blockSecondaryMetadata?: boolean } = {}) {
async function loadStructure(silent = false, scope: StructureRefreshScope = FULL_STRUCTURE_REFRESH_SCOPE, showErrors = true, options: { blockSecondaryMetadata?: boolean; preserveDraft?: boolean } = {}) {
const connectionId = props.connectionId;
const database = props.database;
const schema = metadataSchema.value;
@ -783,6 +845,7 @@ async function loadStructure(silent = false, scope: StructureRefreshScope = FULL
setSecondaryMetadataLoading(scope, true);
errorMessage.value = "";
let secondaryMetadataScheduled = false;
let loadedSuccessfully = false;
try {
await store.ensureConnected(connectionId);
@ -831,6 +894,7 @@ async function loadStructure(silent = false, scope: StructureRefreshScope = FULL
if (options.blockSecondaryMetadata) {
await secondaryMetadataPromise;
}
loadedSuccessfully = true;
} catch (e: any) {
if (showErrors) {
errorMessage.value = e?.message || String(e);
@ -842,6 +906,9 @@ async function loadStructure(silent = false, scope: StructureRefreshScope = FULL
setSecondaryMetadataLoading(scope, false);
}
if (!silent) loading.value = false;
if (!options.preserveDraft && loadedSuccessfully && requestId === structureLoadRequestId) {
markDraftHydratedAndSync();
}
}
}
@ -1211,6 +1278,7 @@ async function applyChanges() {
ddlFetched.value = false;
ddlContent.value = "";
if (isCreateMode.value) {
clearDraft();
emit("saved", tableComment.value !== originalTableComment.value);
emit("close");
} else {
@ -1280,13 +1348,21 @@ onMounted(() => {
resetState();
registerStructureEditorShortcuts();
void loadDynamicDataTypeOptions();
void loadStructure();
if (props.draft?.initialized) {
restoreDraft(props.draft);
} else if (isCreateMode.value) {
markDraftHydratedAndSync();
} else {
void loadStructure(false, FULL_STRUCTURE_REFRESH_SCOPE, true, { blockSecondaryMetadata: true });
}
});
onActivated(() => {
registerStructureEditorShortcuts();
void loadDynamicDataTypeOptions();
if (!isCreateMode.value) void loadStructure(true);
if (props.draft?.initialized && !draftHydrated) {
restoreDraft(props.draft);
}
});
onDeactivated(unregisterStructureEditorShortcuts);
onBeforeUnmount(() => {
@ -1322,10 +1398,15 @@ watch(
[isCreateMode, databaseType, () => props.schema, () => props.tableName, newTableName, tableComment, columns, indexes, foreignKeys, triggers],
() => {
scheduleSqlPreviewRefresh();
syncDraftToParent();
},
{ deep: true, immediate: true },
);
watch(activeTab, () => {
syncDraftToParent();
});
watch(secondaryMetadataLoading, (value) => {
if (value || !deferredSqlPreviewRefresh) return;
scheduleSqlPreviewRefresh();
@ -1359,7 +1440,7 @@ watch(activeTab, (tab) => {
<Database :class="[structureIconClass, 'text-muted-foreground']" />
<span class="min-w-0 flex-1 truncate font-medium">{{ targetLabel || t("editor.noDatabase") }}</span>
<Badge variant="outline">{{ connection?.driver_label || databaseType }}</Badge>
<Button v-if="!isCreateMode" variant="ghost" size="sm" :class="structureToolbarButtonClass" :disabled="loading || saving" @click="loadStructure()">
<Button v-if="!isCreateMode" variant="ghost" size="sm" :class="structureToolbarButtonClass" :disabled="loading || saving" @click="reloadStructureFromDatabase">
<RefreshCw :class="structureIconClass" />
{{ t("structureEditor.refresh") }}
</Button>

View File

@ -54,6 +54,10 @@ const ORACLE_LIKE_METADATA_TYPES = new Set<string>(["oracle", "dameng", "oceanba
const BACKGROUND_CLIENT_SESSION_SUFFIXES = ["count", "explain", "export"] as const;
const CANCEL_QUERY_TIMEOUT_MS = 10_000;
function cloneTabDraft<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}
interface BuildQueryResultExportRequestOptions {
exportId: string;
filePath: string;
@ -909,6 +913,7 @@ export const useQueryStore = defineStore("query", () => {
nacosNamespace: original.nacosNamespace,
nacosNamespaceName: original.nacosNamespaceName,
structureTableName: original.structureTableName,
structureDraft: original.structureDraft ? cloneTabDraft(original.structureDraft) : undefined,
objectBrowser: original.objectBrowser ? { ...original.objectBrowser } : undefined,
objectSource: original.objectSource ? { ...original.objectSource } : undefined,
tableMeta: original.tableMeta ? { ...original.tableMeta, columns: [...original.tableMeta.columns], primaryKeys: [...original.tableMeta.primaryKeys] } : undefined,

View File

@ -559,6 +559,18 @@ export interface TreeNode {
export type TableInfoTab = "columns" | "indexes" | "foreignKeys" | "triggers" | "ddl";
export interface TableStructureEditorDraft {
activeTab: TableInfoTab;
newTableName: string;
tableComment: string;
originalTableComment: string;
columns: import("@/lib/tableStructureEditorSql").EditableStructureColumn[];
indexes: import("@/lib/tableStructureEditorSql").EditableStructureIndex[];
foreignKeys: import("@/lib/tableStructureEditorSql").EditableStructureForeignKey[];
triggers: import("@/lib/tableStructureEditorSql").EditableStructureTrigger[];
initialized: boolean;
}
export interface QueryTab {
id: string;
title: string;
@ -619,6 +631,7 @@ export interface QueryTab {
nacosNamespace?: string;
nacosNamespaceName?: string;
structureTableName?: string;
structureDraft?: TableStructureEditorDraft;
objectBrowser?: {
schema?: string;
objectType?: "tables";

View File

@ -2606,6 +2606,44 @@ test("table structure refresh versions are scoped by table target", () => {
assert.equal(store.tableStructureRefreshVersion("conn-1", "db", "public", "orders"), 0);
});
test("duplicating a table structure tab clones its unsaved draft", () => {
setActivePinia(createPinia());
const store = useQueryStore();
const tabId = store.openTableStructure("conn-1", "db", "public", "users");
const tab = store.tabs.find((item) => item.id === tabId)!;
tab.structureDraft = {
activeTab: "columns",
newTableName: "",
tableComment: "",
originalTableComment: "",
columns: [
{
id: "new:1",
name: "draft_name",
dataType: "varchar(255)",
isNullable: true,
defaultValue: "",
comment: "",
isPrimaryKey: false,
extra: {},
markedForDrop: false,
},
],
indexes: [],
foreignKeys: [],
triggers: [],
initialized: true,
};
store.duplicateTab(tabId);
const copy = store.tabs.find((item) => item.id !== tabId && item.mode === "structure")!;
assert.deepEqual(copy.structureDraft, tab.structureDraft);
copy.structureDraft!.columns[0]!.name = "copy_only";
assert.equal(tab.structureDraft.columns[0]!.name, "draft_name");
});
test("reorderTab keeps pinned tabs before unpinned tabs after reorder", () => {
setActivePinia(createPinia());
const store = useQueryStore();