-
+
{sidebarUtilityItems.map((item) => (
@@ -2658,12 +2644,12 @@ function App() {
- } onClick={handleNewQuery} title="新建查询" style={sidebarQueryActionStyle}>
- 新建查询
-
} onClick={handleCreateConnection} title="新建连接" style={sidebarCreateConnectionActionStyle}>
新建连接
+ } onClick={handleNewQuery} title="新建查询" style={sidebarQueryActionStyle}>
+ 新建查询
+
@@ -2910,6 +2896,71 @@ function App() {
))}
+
, '设置中心', '集中处理代理、主题、AI 与关于等通用配置入口。')}
+ open={isSettingsModalOpen}
+ onCancel={() => setIsSettingsModalOpen(false)}
+ footer={null}
+ width={560}
+ styles={{ content: utilityModalShellStyle, header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 }, body: { paddingTop: 8 }, footer: { background: 'transparent', borderTop: 'none', paddingTop: 10 } }}
+ >
+
+ {[
+ {
+ key: 'theme',
+ icon: ,
+ title: '主题与外观',
+ description: '切换亮暗主题并调整界面观感。',
+ onClick: () => {
+ setIsSettingsModalOpen(false);
+ setThemeModalSection('theme');
+ setIsThemeModalOpen(true);
+ },
+ },
+ {
+ key: 'proxy',
+ icon: ,
+ title: '全局代理',
+ description: '统一配置更新检查、驱动管理和公共网络出口。',
+ onClick: () => {
+ setIsSettingsModalOpen(false);
+ setSecurityUpdateRepairSource(null);
+ setIsProxyModalOpen(true);
+ },
+ },
+ {
+ key: 'ai',
+ icon: ,
+ title: 'AI 设置',
+ description: '管理模型供应商、密钥和默认行为。',
+ onClick: () => {
+ setIsSettingsModalOpen(false);
+ handleOpenAISettings();
+ },
+ },
+ {
+ key: 'about',
+ icon: ,
+ title: '关于 GoNavi',
+ description: '查看版本信息、仓库地址和更新状态。',
+ onClick: () => {
+ setIsSettingsModalOpen(false);
+ setIsAboutOpen(true);
+ },
+ },
+ ].map((item) => (
+
+ ))}
+
+
, '数据存储位置', '统一管理连接、代理、AI 配置与驱动等文件型数据的根目录。')}
open={isDataRootModalOpen}
diff --git a/frontend/src/components/DataGrid.layout.test.tsx b/frontend/src/components/DataGrid.layout.test.tsx
new file mode 100644
index 0000000..bb1edbf
--- /dev/null
+++ b/frontend/src/components/DataGrid.layout.test.tsx
@@ -0,0 +1,80 @@
+import React from 'react';
+import { renderToStaticMarkup } from 'react-dom/server';
+import { describe, expect, it, vi } from 'vitest';
+
+import DataGrid from './DataGrid';
+
+vi.mock('../store', () => ({
+ useStore: (selector: (state: any) => any) => selector({
+ connections: [],
+ addSqlLog: vi.fn(),
+ theme: 'light',
+ appearance: {
+ enabled: true,
+ opacity: 1,
+ blur: 0,
+ showDataTableVerticalBorders: false,
+ dataTableColumnWidthMode: 'standard',
+ },
+ queryOptions: {
+ showColumnComment: false,
+ showColumnType: false,
+ },
+ setQueryOptions: vi.fn(),
+ tableColumnOrders: {},
+ enableColumnOrderMemory: false,
+ setTableColumnOrder: vi.fn(),
+ setEnableColumnOrderMemory: vi.fn(),
+ clearTableColumnOrder: vi.fn(),
+ tableHiddenColumns: {},
+ enableHiddenColumnMemory: false,
+ setTableHiddenColumns: vi.fn(),
+ setEnableHiddenColumnMemory: vi.fn(),
+ clearTableHiddenColumns: vi.fn(),
+ aiPanelVisible: false,
+ setAIPanelVisible: vi.fn(),
+ }),
+}));
+
+vi.mock('../../wailsjs/go/app/App', () => ({
+ ImportData: vi.fn(),
+ ExportTable: vi.fn(),
+ ExportData: vi.fn(),
+ ExportQuery: vi.fn(),
+ ApplyChanges: vi.fn(),
+ DBGetColumns: vi.fn(),
+ DBGetIndexes: vi.fn(),
+}));
+
+vi.mock('@monaco-editor/react', () => ({
+ default: () => null,
+}));
+
+describe('DataGrid layout', () => {
+ it('renders a secondary action strip for view switching and auxiliary actions', () => {
+ const markup = renderToStaticMarkup(
+
{}}
+ />,
+ );
+
+ expect(markup).toContain('data-grid-secondary-actions="true"');
+ expect(markup).toContain('data-grid-view-switcher="true"');
+ });
+});
diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx
index a87085a..42740e9 100644
--- a/frontend/src/components/DataGrid.tsx
+++ b/frontend/src/components/DataGrid.tsx
@@ -3247,20 +3247,6 @@ const DataGrid: React.FC = ({
setRowEditorOpen(true);
}, [canModifyData, mergedDisplayData, data, addedRows, displayColumnNames, rowEditorForm, rowKeyStr, columnMetaMap, columnMetaMapByLowerName]);
- const openRowEditor = useCallback(() => {
- if (!canModifyData) return;
- if (selectedRowKeys.length > 1) {
- void message.info('一次只能编辑一行,请仅选择一行');
- return;
- }
- const keyStr = selectedRowKeys.length === 1 ? rowKeyStr(selectedRowKeys[0]) : undefined;
- if (!keyStr) {
- void message.info('请先选择一行(勾选复选框)');
- return;
- }
- openRowEditorByKey(keyStr);
- }, [canModifyData, selectedRowKeys, rowKeyStr, openRowEditorByKey]);
-
const openCurrentViewRowEditor = useCallback(() => {
if (!canModifyData) return;
const currentRow = mergedDisplayData[textRecordIndex];
@@ -3278,6 +3264,50 @@ const DataGrid: React.FC = ({
setJsonEditorOpen(true);
}, [canModifyData, jsonViewText]);
+ const handleViewModeChange = useCallback((nextMode: GridViewMode) => {
+ if (nextMode === 'json' && cellEditMode) {
+ setCellEditMode(false);
+ setSelectedCells(new Set());
+ currentSelectionRef.current = new Set();
+ selectionStartRef.current = null;
+ isDraggingRef.current = false;
+ cellSelectionPointerRef.current = null;
+ if (cellSelectionRafRef.current !== null) {
+ cancelAnimationFrame(cellSelectionRafRef.current);
+ cellSelectionRafRef.current = null;
+ }
+ if (cellSelectionScrollRafRef.current !== null) {
+ cancelAnimationFrame(cellSelectionScrollRafRef.current);
+ cellSelectionScrollRafRef.current = null;
+ }
+ if (cellSelectionAutoScrollRafRef.current !== null) {
+ cancelAnimationFrame(cellSelectionAutoScrollRafRef.current);
+ cellSelectionAutoScrollRafRef.current = null;
+ }
+ updateCellSelection(new Set());
+ }
+
+ if (nextMode === 'text') {
+ const selectedKey = selectedRowKeys[0];
+ if (selectedKey !== undefined) {
+ const idx = mergedDisplayData.findIndex((row) => rowKeyStr(row?.[GONAVI_ROW_KEY]) === rowKeyStr(selectedKey));
+ if (idx >= 0) {
+ setTextRecordIndex(idx);
+ }
+ }
+ }
+
+ setViewMode(nextMode);
+ }, [cellEditMode, mergedDisplayData, selectedRowKeys, rowKeyStr, updateCellSelection]);
+
+ const handleOpenContextMenuRowEditor = useCallback(() => {
+ if (!canModifyData) return;
+ const rowKey = cellContextMenu.record?.[GONAVI_ROW_KEY];
+ if (rowKey === undefined || rowKey === null) return;
+ openRowEditorByKey(rowKeyStr(rowKey));
+ setCellContextMenu(prev => ({ ...prev, visible: false }));
+ }, [canModifyData, cellContextMenu.record, openRowEditorByKey, rowKeyStr]);
+
const handleFormatJsonEditor = useCallback(() => {
try {
const parsed = JSON.parse(jsonEditorValue);
@@ -4868,7 +4898,7 @@ const DataGrid: React.FC = ({
{/* Toolbar + Filter Panel */}
-
+
{onReload &&
} disabled={loading} onClick={() => {
setAddedRows([]);
setModifiedRows({});
@@ -4891,13 +4921,6 @@ const DataGrid: React.FC
= ({
<>
} onClick={handleAddRow}>添加行
- }
- disabled={selectedRowKeys.length !== 1}
- onClick={openRowEditor}
- >
- 编辑行
-
} danger disabled={selectedRowKeys.length === 0} onClick={handleDeleteSelected}>删除选中
{selectedRowKeys.length > 0 && 已选 {selectedRowKeys.length}}
@@ -5047,78 +5070,6 @@ const DataGrid: React.FC = ({
)}
-
- }
- type={dataPanelOpen ? 'primary' : 'default'}
- onClick={() => {
- const next = !dataPanelOpen;
- setDataPanelOpen(next);
- if (!next) {
- setFocusedCellInfo(null);
- setDataPanelValue('');
- setDataPanelIsJson(false);
- dataPanelDirtyRef.current = false;
- }
- }}
- >
- 数据预览
-
-
-
-
- {
- const nextMode = String(val) as GridViewMode;
- if (nextMode === 'json' && cellEditMode) {
- setCellEditMode(false);
- setSelectedCells(new Set());
- currentSelectionRef.current = new Set();
- selectionStartRef.current = null;
- isDraggingRef.current = false;
- cellSelectionPointerRef.current = null;
- if (cellSelectionRafRef.current !== null) {
- cancelAnimationFrame(cellSelectionRafRef.current);
- cellSelectionRafRef.current = null;
- }
- if (cellSelectionScrollRafRef.current !== null) {
- cancelAnimationFrame(cellSelectionScrollRafRef.current);
- cellSelectionScrollRafRef.current = null;
- }
- if (cellSelectionAutoScrollRafRef.current !== null) {
- cancelAnimationFrame(cellSelectionAutoScrollRafRef.current);
- cellSelectionAutoScrollRafRef.current = null;
- }
- updateCellSelection(new Set());
- }
- if (nextMode === 'text') {
- const selectedKey = selectedRowKeys[0];
- if (selectedKey !== undefined) {
- const idx = mergedDisplayData.findIndex((row) => rowKeyStr(row?.[GONAVI_ROW_KEY]) === rowKeyStr(selectedKey));
- if (idx >= 0) {
- setTextRecordIndex(idx);
- }
- }
- }
- setViewMode(nextMode);
- }}
- />
-
{showFilter && (
@@ -5710,6 +5661,19 @@ const DataGrid: React.FC
= ({
>
设置为 NULL
+
e.currentTarget.style.background = darkMode ? '#303030' : '#f5f5f5'}
+ onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
+ onClick={handleOpenContextMenuRowEditor}
+ >
+
+ 编辑本行
+
= ({
document.body
)}
+
+
+
+
}
+ type={dataPanelOpen ? 'primary' : 'default'}
+ disabled={viewMode !== 'table'}
+ onClick={() => {
+ const next = !dataPanelOpen;
+ setDataPanelOpen(next);
+ if (!next) {
+ setFocusedCellInfo(null);
+ setDataPanelValue('');
+ setDataPanelIsJson(false);
+ dataPanelDirtyRef.current = false;
+ }
+ }}
+ >
+ 数据预览
+
+
+ }>字段信息
+
+
+
+ 结果视图
+ handleViewModeChange(String(val) as GridViewMode)}
+ />
+
+
{pagination && (
diff --git a/frontend/src/components/TabManager.tsx b/frontend/src/components/TabManager.tsx
index 89b08b6..7a54693 100644
--- a/frontend/src/components/TabManager.tsx
+++ b/frontend/src/components/TabManager.tsx
@@ -320,6 +320,10 @@ const TabManager: React.FC = () => {
box-shadow: 0 0 0 2px rgba(9, 109, 217, 0.32);
background: rgba(9, 109, 217, 0.08);
}
+ body[data-theme='light'] .main-tabs .ant-tabs-tab.ant-tabs-tab-active {
+ background: rgba(24, 144, 255, 0.10) !important;
+ border-color: rgba(24, 144, 255, 0.28) !important;
+ }
body[data-theme='dark'] .main-tabs .ant-tabs-tab.ant-tabs-tab-active {
background: rgba(255, 214, 102, 0.12) !important;
border-color: rgba(255, 214, 102, 0.4) !important;
diff --git a/frontend/src/components/TableDesigner.tsx b/frontend/src/components/TableDesigner.tsx
index 1589107..2b29caa 100644
--- a/frontend/src/components/TableDesigner.tsx
+++ b/frontend/src/components/TableDesigner.tsx
@@ -9,7 +9,7 @@ import { TabData, ColumnDefinition, IndexDefinition, ForeignKeyDefinition, Trigg
import { useStore } from '../store';
import { DBGetColumns, DBGetIndexes, DBQuery, DBGetForeignKeys, DBGetTriggers, DBShowCreateTable } from '../../wailsjs/go/app/App';
import { hasIndexFormChanged, normalizeIndexFormFromRow, shouldRestoreOriginalIndex, toggleIndexSelection as getNextIndexSelection, type IndexDisplaySnapshot } from './tableDesignerIndexUtils';
-import { buildAlterTablePreviewSql } from './tableDesignerSchemaSql';
+import { buildAlterTablePreviewSql, hasAlterTableDraftChanges } from './tableDesignerSchemaSql';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
import { noAutoCapInputProps } from '../utils/inputAutoCap';
@@ -1396,6 +1396,19 @@ ${selectedTrigger.statement}`;
};
};
+ const hasUnsavedDraftChanges = useMemo(() => {
+ if (isNewTable || readOnly) {
+ return false;
+ }
+ const tableInfo = resolveTableInfo();
+ return hasAlterTableDraftChanges({
+ dbType: tableInfo.dbType,
+ tableName: tableInfo.qualifiedName,
+ originalColumns,
+ columns,
+ });
+ }, [columns, connections, isNewTable, originalColumns, readOnly, tab.connectionId, tab.dbName, tab.tableName]);
+
const supportsIndexSchemaOps = (): boolean => {
const dbType = getDbType();
if (!dbType) return false;
@@ -2143,6 +2156,24 @@ END;`;
}
};
+ const handleRefreshDesigner = () => {
+ if (!hasUnsavedDraftChanges) {
+ void fetchData();
+ return;
+ }
+
+ Modal.confirm({
+ title: '存在未保存的字段变更',
+ icon:
,
+ content: '刷新后会丢失当前尚未保存的字段调整,是否仍要刷新并覆盖当前草稿?',
+ okText: '仍然刷新',
+ cancelText: '取消',
+ onOk: async () => {
+ await fetchData();
+ },
+ });
+ };
+
const handleExecuteSave = async () => {
const result = await executeSchemaStatements(previewSql);
if (!result.ok) {
@@ -2519,7 +2550,7 @@ END;`;
>
)}
{!readOnly &&
} type="primary" onClick={generateDDL}>保存}
- {!isNewTable &&
} onClick={fetchData}>刷新}
+ {!isNewTable &&
} onClick={handleRefreshDesigner}>刷新}
{!isNewTable && !readOnly && supportsTableCommentOps() && (
} onClick={openTableCommentModal}>表备注
)}
@@ -2983,10 +3014,24 @@ END;`;
okText="执行"
cancelText="取消"
>
-
-
- {previewSql}
-
+
+
请仔细检查 SQL,执行后不可撤销。
diff --git a/frontend/src/components/tableDesignerSchemaSql.test.ts b/frontend/src/components/tableDesignerSchemaSql.test.ts
index e45c380..c29c913 100644
--- a/frontend/src/components/tableDesignerSchemaSql.test.ts
+++ b/frontend/src/components/tableDesignerSchemaSql.test.ts
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
import {
buildAlterTablePreviewSql,
+ hasAlterTableDraftChanges,
type BuildAlterTablePreviewInput,
type EditableColumnSnapshot,
} from './tableDesignerSchemaSql';
@@ -29,6 +30,18 @@ const buildInput = (overrides: Partial
): BuildAlter
});
describe('tableDesignerSchemaSql', () => {
+ it('detects when alter table drafts contain unsaved column changes', () => {
+ expect(hasAlterTableDraftChanges(buildInput({ dbType: 'mysql' }))).toBe(true);
+ expect(
+ hasAlterTableDraftChanges(
+ buildInput({
+ dbType: 'mysql',
+ columns: [baseColumn({ _key: 'id', name: 'id', key: 'PRI', nullable: 'NO' })],
+ }),
+ ),
+ ).toBe(false);
+ });
+
it('keeps mysql alter preview syntax with column position clauses', () => {
const sql = buildAlterTablePreviewSql(buildInput({ dbType: 'mysql' }));
diff --git a/frontend/src/components/tableDesignerSchemaSql.ts b/frontend/src/components/tableDesignerSchemaSql.ts
index 82ff9f5..7851dab 100644
--- a/frontend/src/components/tableDesignerSchemaSql.ts
+++ b/frontend/src/components/tableDesignerSchemaSql.ts
@@ -260,3 +260,6 @@ export const buildAlterTablePreviewSql = (input: BuildAlterTablePreviewInput): s
}
return buildMySqlAlterPreviewSql({ ...input, dbType });
};
+
+export const hasAlterTableDraftChanges = (input: BuildAlterTablePreviewInput): boolean =>
+ buildAlterTablePreviewSql(input).trim().length > 0;
diff --git a/frontend/src/utils/aiEntryLayout.test.ts b/frontend/src/utils/aiEntryLayout.test.ts
index e44f444..6731165 100644
--- a/frontend/src/utils/aiEntryLayout.test.ts
+++ b/frontend/src/utils/aiEntryLayout.test.ts
@@ -8,8 +8,8 @@ import {
} from './aiEntryLayout';
describe('ai entry layout', () => {
- it('keeps the sidebar utility group free of the AI entry', () => {
- expect(SIDEBAR_UTILITY_ITEM_KEYS).toEqual(['tools', 'proxy', 'theme', 'about']);
+ it('keeps the sidebar utility group compact and free of the AI entry', () => {
+ expect(SIDEBAR_UTILITY_ITEM_KEYS).toEqual(['tools', 'settings']);
});
it('anchors the AI entry to the content edge', () => {
diff --git a/frontend/src/utils/aiEntryLayout.ts b/frontend/src/utils/aiEntryLayout.ts
index a0624fb..c4da184 100644
--- a/frontend/src/utils/aiEntryLayout.ts
+++ b/frontend/src/utils/aiEntryLayout.ts
@@ -1,6 +1,6 @@
import type { CSSProperties } from 'react';
-export const SIDEBAR_UTILITY_ITEM_KEYS = ['tools', 'proxy', 'theme', 'about'] as const;
+export const SIDEBAR_UTILITY_ITEM_KEYS = ['tools', 'settings'] as const;
export type AIEntryPlacement = 'content-edge';
export type AIEdgeHandleAttachment = 'content-shell' | 'panel-shell';