diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a4e6bd2..4992151 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -869,8 +869,8 @@ function App() { ...sidebarQuickActionBaseStyle, flex: '1 1 0', border: 'none', - background: 'linear-gradient(135deg, rgba(255,214,102,0.96) 0%, rgba(240,183,39,0.92) 100%)', - color: '#2a1f00', + background: 'linear-gradient(135deg, rgba(34,197,94,0.96) 0%, rgba(22,163,74,0.92) 100%)', + color: '#f3fff7', }), [sidebarQuickActionBaseStyle]); const utilityModalShellStyle = useMemo(() => ({ @@ -1855,6 +1855,7 @@ function App() { }; const [isToolsModalOpen, setIsToolsModalOpen] = useState(false); + const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false); const [isThemeModalOpen, setIsThemeModalOpen] = useState(false); const [themeModalSection, setThemeModalSection] = useState<'theme' | 'appearance'>('theme'); const [isAppearanceModalOpen, setIsAppearanceModalOpen] = useState(false); @@ -1888,26 +1889,11 @@ function App() { icon: , onClick: () => setIsToolsModalOpen(true), }, - proxy: { - key: 'proxy', - title: '代理', - icon: , - onClick: () => { - setSecurityUpdateRepairSource(null); - setIsProxyModalOpen(true); - }, - }, - theme: { - key: 'theme', - title: '主题', - icon: , - onClick: () => setIsThemeModalOpen(true), - }, - about: { - key: 'about', - title: '关于', - icon: , - onClick: () => setIsAboutOpen(true), + settings: { + key: 'settings', + title: '设置', + icon: , + onClick: () => setIsSettingsModalOpen(true), }, } as const; @@ -2648,7 +2634,7 @@ function App() { >
-
+
{sidebarUtilityItems.map((item) => (
- +
@@ -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 && - {selectedRowKeys.length > 0 && 已选 {selectedRowKeys.length}}
@@ -5047,78 +5070,6 @@ const DataGrid: React.FC = ({ )}
-
- -
-
- - - -
-
- { - 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 )}
+ +
+
+ + + + +
+
+ 结果视图 + 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 && } - {!isNewTable && } + {!isNewTable && } {!isNewTable && !readOnly && supportsTableCommentOps() && ( )} @@ -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';