From 695713c779a66b7c620841874288db99c788560a Mon Sep 17 00:00:00 2001 From: Syngnat Date: Tue, 10 Mar 2026 15:49:22 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(DataGrid):=20=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0=E6=95=B0=E6=8D=AE=E8=A7=86=E5=9B=BE=E5=88=97=E6=A0=87?= =?UTF-8?q?=E9=A2=98=E6=8B=96=E6=8B=BD=E6=8E=92=E5=BA=8F=E5=8F=8A=E9=A1=BA?= =?UTF-8?q?=E5=BA=8F=E8=AE=B0=E5=BF=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 功能集成:接入 @dnd-kit 实现表头水平拖拽排序,支持多列位置灵活调整 - 持久化:Store 新增 tableColumnOrders 状态,支持按“连接-库-表”多维度记忆自定义列序 - 交互优化:重构表头 DOM 结构并消除内边距,实现“悬停手型、按住抓取”的精准指针反馈 - 性能提升:通过 React.memo 减少重渲染,并启用 will-change 硬件加速确保 60FPS 流畅度 - 稳定性:增强 Wails 环境接口调用的异常捕获,并补全前端独立开发环境下的 API Stub --- frontend/index.html | 17 ++ frontend/src/App.tsx | 143 +++++++++----- frontend/src/components/DataGrid.tsx | 284 ++++++++++++++++++++++----- frontend/src/main.tsx | 30 +++ frontend/src/store.ts | 46 ++++- 5 files changed, 417 insertions(+), 103 deletions(-) diff --git a/frontend/index.html b/frontend/index.html index 127af4bc..b596c588 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -5,6 +5,23 @@ GoNavi +
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c0c5436d..ce1832eb 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -93,27 +93,39 @@ function App() { // 同步 macOS 窗口透明度:opacity=1.0 且 blur=0 时关闭 NSVisualEffectView, // 避免 GPU 持续计算窗口背后的模糊合成 useEffect(() => { - void SetWindowTranslucency(resolvedAppearance.opacity, resolvedAppearance.blur).catch(() => undefined); + try { + void SetWindowTranslucency(resolvedAppearance.opacity, resolvedAppearance.blur).catch(() => undefined); + } catch(e) { /* ignore */ } }, [resolvedAppearance.blur, resolvedAppearance.opacity]); useEffect(() => { let cancelled = false; - Environment() - .then((env) => { - if (cancelled) return; - const platform = String(env?.platform || '').toLowerCase(); - setRuntimePlatform(platform); - setIsLinuxRuntime(platform === 'linux'); - }) - .catch(() => { - if (cancelled) return; - const platform = detectNavigatorPlatform(); - const normalized = /linux/i.test(platform) - ? 'linux' - : (/mac/i.test(platform) ? 'darwin' : (/win/i.test(platform) ? 'windows' : '')); - setRuntimePlatform(normalized); - setIsLinuxRuntime(normalized === 'linux'); - }); + try { + Environment() + .then((env) => { + if (cancelled) return; + const platform = String(env?.platform || '').toLowerCase(); + setRuntimePlatform(platform); + setIsLinuxRuntime(platform === 'linux'); + }) + .catch(() => { + if (cancelled) return; + const platform = detectNavigatorPlatform(); + const normalized = /linux/i.test(platform) + ? 'linux' + : (/mac/i.test(platform) ? 'darwin' : (/win/i.test(platform) ? 'windows' : '')); + setRuntimePlatform(normalized); + setIsLinuxRuntime(normalized === 'linux'); + }); + } catch(e) { + if (cancelled) return; + const platform = detectNavigatorPlatform(); + const normalized = /linux/i.test(platform) + ? 'linux' + : (/mac/i.test(platform) ? 'darwin' : (/win/i.test(platform) ? 'windows' : '')); + setRuntimePlatform(normalized); + setIsLinuxRuntime(normalized === 'linux'); + } return () => { cancelled = true; }; @@ -156,32 +168,36 @@ function App() { const enabledForBackend = globalProxy.enabled && !invalidWhenEnabled; let cancelled = false; - ConfigureGlobalProxy(enabledForBackend, { - type: globalProxy.type, - host, - port: portValid ? port : (globalProxy.type === 'http' ? 8080 : 1080), - user: String(globalProxy.user || '').trim(), - password: globalProxy.password || '', - }) - .then((res) => { - if (cancelled || res?.success) { - return; - } - void message.error({ - content: '全局代理配置失败: ' + (res?.message || '未知错误'), - key: 'global-proxy-sync-error', - }); + try { + ConfigureGlobalProxy(enabledForBackend, { + type: globalProxy.type, + host, + port: portValid ? port : (globalProxy.type === 'http' ? 8080 : 1080), + user: String(globalProxy.user || '').trim(), + password: globalProxy.password || '', }) - .catch((err) => { - if (cancelled) { - return; - } - const errMsg = err instanceof Error ? err.message : String(err || '未知错误'); - void message.error({ - content: '全局代理配置失败: ' + errMsg, - key: 'global-proxy-sync-error', + .then((res) => { + if (cancelled || res?.success) { + return; + } + void message.error({ + content: '全局代理配置失败: ' + (res?.message || '未知错误'), + key: 'global-proxy-sync-error', + }); + }) + .catch((err) => { + if (cancelled) { + return; + } + const errMsg = err instanceof Error ? err.message : String(err || '未知错误'); + void message.error({ + content: '全局代理配置失败: ' + errMsg, + key: 'global-proxy-sync-error', + }); }); - }); + } catch (e) { + console.warn("Wails API: ConfigureGlobalProxy unavailable", e); + } return () => { cancelled = true; @@ -238,13 +254,18 @@ function App() { return; } // 优先尝试全屏,若当前平台/时机不生效,后续走最大化兜底。 - await WindowFullscreen(); - await new Promise((resolve) => window.setTimeout(resolve, settleDelayMs)); - if (await checkStartupPreferenceApplied()) { - return; + try { + await WindowFullscreen(); + await new Promise((resolve) => window.setTimeout(resolve, settleDelayMs)); + if (await checkStartupPreferenceApplied()) { + return; + } + await WindowMaximise(); + await new Promise((resolve) => window.setTimeout(resolve, settleDelayMs)); + } catch (e) { + console.warn("Wails Window APIs unavailable", e); } - await WindowMaximise(); - await new Promise((resolve) => window.setTimeout(resolve, settleDelayMs)); + if (await checkStartupPreferenceApplied()) { return; } @@ -315,11 +336,15 @@ function App() { } const nudgedWidth = width > 480 ? width - 1 : width + 1; - WindowSetSize(nudgedWidth, height); - await wait(28); - WindowSetSize(width, height); + try { + WindowSetSize(nudgedWidth, height); + await wait(28); + WindowSetSize(width, height); + } catch(e) {} window.dispatchEvent(new Event('resize')); lastFixAt = Date.now(); + } catch(e) { + console.warn("Wails Window APIs unavailable in fixWindowScaleIfNeeded", e); } finally { inFlight = false; } @@ -649,7 +674,12 @@ function App() { total: info.assetSize || 0, message: '' }); - const res = await (window as any).go.app.App.DownloadUpdate(); + let res: any = null; + try { + res = await (window as any).go.app.App.DownloadUpdate(); + } catch (e) { + console.warn("Wails API: DownloadUpdate unavailable", e); + } updateDownloadInFlightRef.current = false; if (res?.success) { const resultData = (res?.data || {}) as UpdateDownloadResultData; @@ -1050,7 +1080,7 @@ function App() { if (target?.closest('[data-no-titlebar-toggle="true"]')) { return; } - WindowToggleMaximise(); + try { WindowToggleMaximise(); } catch(e) {} }; // Sidebar Resizing @@ -1158,7 +1188,9 @@ function App() { }, [checkForUpdates]); useEffect(() => { - const offDownloadProgress = EventsOn('update:download-progress', (event: UpdateDownloadProgressEvent) => { + let offDownloadProgress: any = null; + try { + offDownloadProgress = EventsOn('update:download-progress', (event: UpdateDownloadProgressEvent) => { if (!event) return; const status = event.status || 'downloading'; const nextStatus: 'idle' | 'start' | 'downloading' | 'done' | 'error' = @@ -1181,8 +1213,11 @@ function App() { message: String(event.message || '') })); }); + } catch (e) { + console.warn("Wails API: EventsOn unavailable", e); + } return () => { - offDownloadProgress(); + if (offDownloadProgress) offDownloadProgress(); }; }, []); diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index 56264bdf..3af5d3bf 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -4,6 +4,23 @@ import { Table, message, Input, Button, Dropdown, MenuProps, Form, Pagination, S import type { SortOrder } from 'antd/es/table/interface'; import { ReloadOutlined, ImportOutlined, ExportOutlined, DownOutlined, PlusOutlined, DeleteOutlined, SaveOutlined, UndoOutlined, FilterOutlined, CloseOutlined, ConsoleSqlOutlined, FileTextOutlined, CopyOutlined, ClearOutlined, EditOutlined, VerticalAlignBottomOutlined, LeftOutlined, RightOutlined } from '@ant-design/icons'; import Editor from '@monaco-editor/react'; +import { + DndContext, + DragEndEvent, + PointerSensor, + MouseSensor, + TouchSensor, + useSensor, + useSensors, + closestCenter +} from '@dnd-kit/core'; +import { + SortableContext, + useSortable, + horizontalListSortingStrategy, + arrayMove +} from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; import { ImportData, ExportTable, ExportData, ExportQuery, ApplyChanges, DBGetColumns } from '../../wailsjs/go/app/App'; import ImportPreviewModal from './ImportPreviewModal'; import { useStore } from '../store'; @@ -323,7 +340,7 @@ const coerceJsonEditorValueForStorage = (currentValue: any, editedValue: any): a }; // --- Resizable Header (Native Implementation) --- -const ResizableTitle = (props: any) => { +const ResizableTitle = React.forwardRef((props, ref) => { const { onResizeStart, width, ...restProps } = props; const nextStyle = { ...(restProps.style || {}) } as React.CSSProperties; @@ -334,11 +351,11 @@ const ResizableTitle = (props: any) => { // 注意:virtual table 模式下,rc-table 会依赖 header cell 的 width 样式来渲染选择列。 // 若这里丢失 width,可能导致左上角“全选”checkbox 不显示。 if (!width || typeof onResizeStart !== 'function') { - return ; + return ; } return ( - + {restProps.children} { /> ); -}; +}); + +// --- Sortable Header Cell --- +interface SortableHeaderCellProps extends React.HTMLAttributes { + id?: string; +} + +// --- Sortable Header Cell --- +interface SortableHeaderCellProps extends React.HTMLAttributes { + id?: string; +} + +// 静态 CSS 移到组件外,强制去除 th 内边距并确保指针穿透 +const sortableHeaderStaticStyles = ` + .gonavi-sortable-header-cell { + padding: 0 !important; + } + .gonavi-sortable-header-cell[data-cursor-grabbing="true"], + .gonavi-sortable-header-cell[data-cursor-grabbing="true"] *, + .gonavi-sortable-header-cell.is-dragging, + .gonavi-sortable-header-cell.is-dragging * { + cursor: grabbing !important; + } + .sortable-header-cell-drag-handle { + display: flex; + align-items: center; + width: 100%; + height: 100%; + min-height: 44px; + padding: 0 10px; + user-select: none; + cursor: inherit; + } +`; + +const SortableHeaderCell: React.FC = React.memo((props) => { + const { id, children, style: propStyle, className: propClassName, ...restProps } = props; + const [isPressed, setIsPressed] = useState(false); + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: id || '' }); + + const style: React.CSSProperties = { + ...propStyle, + transform: CSS.Transform.toString(transform), + transition, + ...(isDragging ? { + position: 'relative', + zIndex: 9999, + opacity: 0.6, + backgroundColor: 'rgba(24, 144, 255, 0.15)', + boxShadow: '0 4px 12px rgba(0,0,0,0.15)' + } : {}), + touchAction: 'none', + willChange: 'transform', + // 核心修复:将指针直接绑定到 th 级别,并由 isPressed 控制 + cursor: (isDragging || isPressed) ? 'grabbing' : 'pointer', + }; + + useEffect(() => { + const handleGlobalMouseUp = () => setIsPressed(false); + window.addEventListener('mouseup', handleGlobalMouseUp); + return () => window.removeEventListener('mouseup', handleGlobalMouseUp); + }, []); + + if (!id || id === 'GONAVI_SELECTION_COLUMN') { + return {children}; + } + + return ( + { + setIsPressed(true); + if (listeners?.onPointerDown) listeners.onPointerDown(e); + }} + > + +
+
+ {children} +
+
+
+ ); +}); // --- Contexts --- const EditableContext = React.createContext(null); @@ -640,6 +753,12 @@ const DataGrid: React.FC = ({ const appearance = useStore(state => state.appearance); const queryOptions = useStore(state => state.queryOptions); const setQueryOptions = useStore(state => state.setQueryOptions); + const tableColumnOrders = useStore(state => state.tableColumnOrders); + const enableColumnOrderMemory = useStore(state => state.enableColumnOrderMemory); + const setTableColumnOrder = useStore(state => state.setTableColumnOrder); + const setEnableColumnOrderMemory = useStore(state => state.setEnableColumnOrderMemory); + const clearTableColumnOrder = useStore(state => state.clearTableColumnOrder); + const isMacLike = useMemo(() => isMacLikePlatform(), []); const darkMode = theme === 'dark'; const resolvedAppearance = resolveAppearanceValues(appearance); @@ -647,6 +766,49 @@ const DataGrid: React.FC = ({ const canModifyData = !readOnly && !!tableName; const showColumnComment = queryOptions?.showColumnComment !== false; const showColumnType = queryOptions?.showColumnType !== false; + + // --- Display Columns Order Management --- + const [displayColumnNames, setDisplayColumnNames] = useState([]); + + // Sync display order from incoming prop and store memory + useEffect(() => { + let nextOrder = [...columnNames]; + if (enableColumnOrderMemory && connectionId && dbName && tableName) { + const storedOrder = tableColumnOrders[`${connectionId}-${dbName}-${tableName}`]; + if (Array.isArray(storedOrder) && storedOrder.length > 0) { + // Only layout known columns. Filter out missing or new columns. + const storedSet = new Set(storedOrder); + const incomingSet = new Set(nextOrder); + const validStored = storedOrder.filter(col => incomingSet.has(col)); + const missingNew = nextOrder.filter(col => !storedSet.has(col)); + nextOrder = [...validStored, ...missingNew]; + } + } + setDisplayColumnNames(nextOrder); + }, [columnNames, tableColumnOrders, enableColumnOrderMemory, connectionId, dbName, tableName]); + + // Handle Dragging + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), + useSensor(MouseSensor, { activationConstraint: { distance: 8 } }), + useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 5 } }), + ); + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + if (active.id !== over?.id && over) { + setDisplayColumnNames((prev) => { + const oldIndex = prev.indexOf(active.id as string); + const newIndex = prev.indexOf(over.id as string); + const nextOrder = arrayMove(prev, oldIndex, newIndex); + if (enableColumnOrderMemory && connectionId && dbName && tableName) { + setTableColumnOrder(connectionId, dbName, tableName, nextOrder); + } + return nextOrder; + }); + } + }; + const selectionColumnWidth = 46; const currentConnConfig = connections.find(c => c.id === connectionId)?.config; const dataSourceCaps = getDataSourceCapabilities(currentConnConfig); @@ -854,7 +1016,7 @@ const DataGrid: React.FC = ({ try { const cleanRows = rows.map(({ [GONAVI_ROW_KEY]: _rowKey, ...rest }) => rest); // Pass tableName (or 'export') as default filename - const res = await ExportData(cleanRows, columnNames, tableName || 'export', format); + const res = await ExportData(cleanRows, displayColumnNames, tableName || 'export', format); if (res.success) { message.success("导出成功"); } else if (res.message !== "Cancelled") { @@ -1142,13 +1304,13 @@ const DataGrid: React.FC = ({ id: nextId, enabled: cond?.enabled !== false, logic: normalizeFilterLogic(cond?.logic), - column: rawColumn || (op === 'CUSTOM' ? '' : String(columnNames[0] || '')), + column: rawColumn || (op === 'CUSTOM' ? '' : String(displayColumnNames[0] || '')), op, value: String(cond?.value ?? ''), value2: String(cond?.value2 ?? ''), }; }); - }, [columnNames, normalizeFilterLogic]); + }, [displayColumnNames, normalizeFilterLogic]); // Filter State const [filterConditions, setFilterConditions] = useState([]); @@ -1196,9 +1358,9 @@ const DataGrid: React.FC = ({ const columnIndexMap = useMemo(() => { const map = new Map(); - columnNames.forEach((name, idx) => map.set(name, idx)); + displayColumnNames.forEach((name: string, idx: number) => map.set(name, idx)); return map; - }, [columnNames]); + }, [displayColumnNames]); // 直接操作 DOM 更新选中效果,避免 React 重渲染 const updateCellSelection = useCallback((newSelection: Set) => { @@ -1377,7 +1539,7 @@ const DataGrid: React.FC = ({ const row = currentData[i]; const rKey = String(row?.[GONAVI_ROW_KEY]); for (let j = minColIndex; j <= maxColIndex; j++) { - newSelectedCells.add(makeCellKey(rKey, columnNames[j])); + newSelectedCells.add(makeCellKey(rKey, displayColumnNames[j])); } } @@ -1548,7 +1710,7 @@ const DataGrid: React.FC = ({ cellSelectionPointerRef.current = null; isDraggingRef.current = false; }; - }, [cellEditMode, columnNames, columnIndexMap, updateCellSelection]); + }, [cellEditMode, displayColumnNames, columnIndexMap, updateCellSelection]); // 批量填充到选中行 const handleBatchFillToSelected = useCallback((sourceRecord: Item, dataIndex: string) => { @@ -1906,7 +2068,7 @@ const DataGrid: React.FC = ({ const formMap: Record = {}; const nullCols = new Set(); - columnNames.forEach((col) => { + displayColumnNames.forEach((col) => { const baseVal = (baseRow as any)?.[col]; const displayVal = (displayRow as any)?.[col]; baseRawMap[col] = baseVal; @@ -1922,7 +2084,7 @@ const DataGrid: React.FC = ({ rowEditorForm.setFieldsValue(formMap); setRowEditorRowKey(keyStr); setRowEditorOpen(true); - }, [canModifyData, mergedDisplayData, data, addedRows, columnNames, rowEditorForm, rowKeyStr]); + }, [canModifyData, mergedDisplayData, data, addedRows, displayColumnNames, rowEditorForm, rowKeyStr]); const openRowEditor = useCallback(() => { if (!canModifyData) return; @@ -2016,7 +2178,7 @@ const DataGrid: React.FC = ({ const keyStr = rowKeyStr(rowKey); const normalizedNext: Record = {}; let hasAnyVisibleChange = false; - columnNames.forEach((col) => { + displayColumnNames.forEach((col) => { const currentVal = (currentRow as any)?.[col]; const editedVal = Object.prototype.hasOwnProperty.call(nextItem, col) ? (nextItem as any)[col] : currentVal; if (!isJsonViewValueEqual(currentVal, editedVal)) hasAnyVisibleChange = true; @@ -2035,7 +2197,7 @@ const DataGrid: React.FC = ({ const originalRow = originalMap.get(keyStr); if (!originalRow) continue; const patch: Record = {}; - columnNames.forEach((col) => { + displayColumnNames.forEach((col) => { const prevVal = (originalRow as any)?.[col]; const nextVal = normalizedNext[col]; if (!isCellValueEqualForDiff(prevVal, nextVal)) patch[col] = nextVal; @@ -2062,7 +2224,7 @@ const DataGrid: React.FC = ({ setJsonEditorOpen(false); message.success("JSON 修改已应用到当前结果集,可继续“提交事务”"); - }, [canModifyData, jsonEditorValue, mergedDisplayData, addedRows, rowKeyStr, data, columnNames]); + }, [canModifyData, jsonEditorValue, mergedDisplayData, addedRows, rowKeyStr, data, displayColumnNames]); const openRowEditorFieldEditor = useCallback((dataIndex: string) => { if (!dataIndex) return; @@ -2089,7 +2251,7 @@ const DataGrid: React.FC = ({ const baseRawMap = rowEditorBaseRawRef.current || {}; const patch: Record = {}; - columnNames.forEach((col) => { + displayColumnNames.forEach((col) => { const nextVal = values[col]; const baseVal = baseRawMap[col]; if (!isCellValueEqualForDiff(baseVal, nextVal)) patch[col] = nextVal; @@ -2103,14 +2265,14 @@ const DataGrid: React.FC = ({ }); closeRowEditor(); - }, [rowEditorRowKey, rowEditorForm, addedRows, columnNames, rowKeyStr, closeRowEditor]); + }, [rowEditorRowKey, rowEditorForm, addedRows, displayColumnNames, rowKeyStr, closeRowEditor]); const enableVirtual = viewMode === 'table'; const enableInlineEditableCell = canModifyData; const columns = useMemo(() => { - return columnNames.map(key => ({ + return displayColumnNames.map(key => ({ title: renderColumnTitle(key), dataIndex: key, key: key, @@ -2130,7 +2292,9 @@ const DataGrid: React.FC = ({ return !isCellValueEqualForRender(record?.[key], prevRecord?.[key]); }, onHeaderCell: (column: any) => ({ + id: key, width: column.width, + className: 'gonavi-sortable-header-cell', onResizeStart: handleResizeStart(key), // Only need start onClickCapture: (event: React.MouseEvent) => { if (!onSort) return; @@ -2154,7 +2318,7 @@ const DataGrid: React.FC = ({ }, }), })); - }, [columnNames, columnWidths, sortInfo, handleResizeStart, canModifyData, onSort, renderColumnTitle]); + }, [displayColumnNames, columnWidths, sortInfo, handleResizeStart, canModifyData, onSort, renderColumnTitle]); const mergedColumns = useMemo(() => columns.map(col => { if (!col.editable) return col; @@ -2225,7 +2389,7 @@ const DataGrid: React.FC = ({ const handleAddRow = () => { const newKey = `new-${Date.now()}`; const newRow: any = { [GONAVI_ROW_KEY]: newKey }; - columnNames.forEach(col => newRow[col] = ''); + displayColumnNames.forEach(col => newRow[col] = ''); pendingScrollToBottomRef.current = true; setAddedRows(prev => [...prev, newRow]); }; @@ -2284,7 +2448,7 @@ const DataGrid: React.FC = ({ if (!hasRowKey) { values = { ...(newRow as any) }; } else { - columnNames.forEach((col) => { + displayColumnNames.forEach((col) => { const nextVal = (newRow as any)?.[col]; const prevVal = (originalRow as any)?.[col]; if (!isCellValueEqualForDiff(prevVal, nextVal)) values[col] = nextVal; @@ -2676,7 +2840,7 @@ const DataGrid: React.FC = ({ id: nextFilterId, enabled: true, logic: 'AND', - column: columnNames[0] || '', + column: displayColumnNames[0] || '', op: '=', value: '', value2: '', @@ -2747,6 +2911,26 @@ const DataGrid: React.FC = ({ > 下方显示类型 +
+ setEnableColumnOrderMemory(e.target.checked)} + > + 记忆自定义列序 + +
); @@ -2776,7 +2960,7 @@ const DataGrid: React.FC = ({ const rowPropsFactory = useCallback((record: any) => ({ record } as any), []); - const totalWidth = columns.reduce((sum, col) => sum + (Number(col.width) || 200), 0) + selectionColumnWidth; + const totalWidth = columns.reduce((sum: number, col: any) => sum + (Number(col.width) || 200), 0) + selectionColumnWidth; const useContextMenuRow = false; const tableScrollX = useMemo(() => { const baseWidth = Math.max(totalWidth, 1000); @@ -2796,8 +2980,8 @@ const DataGrid: React.FC = ({ body.row = ContextMenuRow; } return Object.keys(body).length > 0 - ? { body, header: { cell: ResizableTitle } } - : { header: { cell: ResizableTitle } }; + ? { body, header: { cell: SortableHeaderCell } } + : { header: { cell: SortableHeaderCell } }; }, [enableInlineEditableCell, useContextMenuRow]); const tableOnRow = useMemo(() => (useContextMenuRow ? rowPropsFactory : undefined), [useContextMenuRow, rowPropsFactory]); @@ -3412,7 +3596,7 @@ const DataGrid: React.FC = ({ style={{ width: 180 }} value={cond.column} onChange={v => updateFilter(cond.id, 'column', v)} - options={columnNames.map(c => ({ value: c, label: c }))} + options={displayColumnNames.map(c => ({ value: c, label: c }))} showSearch optionFilterProp="label" filterOption={(input, option) => @@ -3508,7 +3692,7 @@ const DataGrid: React.FC = ({
- {columnNames.map((col) => { + {displayColumnNames.map((col: string) => { const sample = rowEditorDisplayRef.current?.[col] ?? ''; const placeholder = rowEditorNullColsRef.current?.has(col) ? '(NULL)' : undefined; const isJson = looksLikeJsonText(sample); @@ -3645,25 +3829,29 @@ const DataGrid: React.FC = ({ - + + +
+ + @@ -3734,7 +3922,7 @@ const DataGrid: React.FC = ({ )}
- {currentTextRow ? columnNames.map((col) => ( + {currentTextRow ? displayColumnNames.map((col) => (
{col} : diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 9457771a..7ab4fee2 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -9,6 +9,36 @@ import { loader } from '@monaco-editor/react' import * as monaco from 'monaco-editor' loader.config({ monaco }) +if (typeof window !== 'undefined' && !(window as any).go) { + (window as any).go = { + app: { + App: { + CheckUpdate: async () => ({ success: false }), + DownloadUpdate: async () => ({ success: false }), + GetSavedConnections: async () => [], + SaveConnection: async () => null, + DeleteConnection: async () => null, + OpenConnection: async () => null, + CloseConnection: async () => null, + GetDatabases: async () => [], + GetTables: async () => [], + GetTableData: async () => ({ columns: [], rows: [], total: 0 }), + GetTableColumns: async () => [], + ExecuteQuery: async () => ({ columns: [], rows: [], time: 0 }), + GetSavedQueries: async () => [], + SaveQuery: async () => null, + DeleteQuery: async () => null, + GetAppInfo: async () => ({}), + CheckForUpdates: async () => ({ success: false }), + OpenDownloadedUpdateDirectory: async () => ({ success: false }), + InstallUpdateAndRestart: async () => ({ success: false }), + ImportConfigFile: async () => ({ success: false }), + ExportData: async () => ({ success: false }), + } + } + }; +} + // 全局注册透明主题,避免每个 Editor 组件 beforeMount 中重复定义 monaco.editor.defineTheme('transparent-dark', { base: 'vs-dark', inherit: true, rules: [], diff --git a/frontend/src/store.ts b/frontend/src/store.ts index 8d67849c..bd424a52 100644 --- a/frontend/src/store.ts +++ b/frontend/src/store.ts @@ -416,6 +416,8 @@ interface AppState { sqlLogs: SqlLog[]; tableAccessCount: Record; tableSortPreference: Record; + tableColumnOrders: Record; + enableColumnOrderMemory: boolean; addConnection: (conn: SavedConnection) => void; updateConnection: (conn: SavedConnection) => void; @@ -458,6 +460,9 @@ interface AppState { recordTableAccess: (connectionId: string, dbName: string, tableName: string) => void; setTableSortPreference: (connectionId: string, dbName: string, sortBy: 'name' | 'frequency') => void; + setTableColumnOrder: (connectionId: string, dbName: string, tableName: string, order: string[]) => void; + setEnableColumnOrderMemory: (enabled: boolean) => void; + clearTableColumnOrder: (connectionId: string, dbName: string, tableName: string) => void; } const sanitizeSavedQueries = (value: unknown): SavedQuery[] => { @@ -521,6 +526,17 @@ const sanitizeTableSortPreference = (value: unknown): Record => { + const raw = (value && typeof value === 'object') ? value as Record : {}; + const result: Record = {}; + Object.entries(raw).forEach(([key, orderArray]) => { + if (Array.isArray(orderArray)) { + result[key] = orderArray.map(col => String(col)); + } + }); + return result; +}; + const sanitizeAppearance = ( appearance: Partial<{ enabled: boolean; opacity: number; blur: number }> | undefined, version: number @@ -598,6 +614,8 @@ export const useStore = create()( sqlLogs: [], tableAccessCount: {}, tableSortPreference: {}, + tableColumnOrders: {}, + enableColumnOrderMemory: true, addConnection: (conn) => set((state) => ({ connections: [...state.connections, conn] })), updateConnection: (conn) => set((state) => ({ @@ -800,6 +818,25 @@ export const useStore = create()( } }; }), + + setTableColumnOrder: (connectionId, dbName, tableName, order) => set((state) => { + const key = `${connectionId}-${dbName}-${tableName}`; + return { + tableColumnOrders: { + ...state.tableColumnOrders, + [key]: order + } + }; + }), + + clearTableColumnOrder: (connectionId, dbName, tableName) => set((state) => { + const key = `${connectionId}-${dbName}-${tableName}`; + const newOrders = { ...state.tableColumnOrders }; + delete newOrders[key]; + return { tableColumnOrders: newOrders }; + }), + + setEnableColumnOrderMemory: (enabled) => set({ enableColumnOrderMemory: !!enabled }), }), { name: 'lite-db-storage', // name of the item in the storage (must be unique) @@ -825,6 +862,10 @@ export const useStore = create()( nextState.shortcutOptions = sanitizeShortcutOptions(state.shortcutOptions); nextState.tableAccessCount = sanitizeTableAccessCount(state.tableAccessCount); nextState.tableSortPreference = sanitizeTableSortPreference(state.tableSortPreference); + // 新增的列排序记忆状态不需要做版本特殊兼容,直接做基本的类型保护 + const safeOrders = sanitizeTableColumnOrders(state.tableColumnOrders); + nextState.tableColumnOrders = safeOrders; + nextState.enableColumnOrderMemory = state.enableColumnOrderMemory !== false; return nextState as AppState; }, merge: (persistedState, currentState) => { @@ -841,11 +882,14 @@ export const useStore = create()( fontSize: sanitizeFontSize(state.fontSize), startupFullscreen: sanitizeStartupFullscreen(state.startupFullscreen), globalProxy: sanitizeGlobalProxy(state.globalProxy), + tableSortPreference: sanitizeTableSortPreference(state.tableSortPreference), + tableColumnOrders: sanitizeTableColumnOrders(state.tableColumnOrders), + enableColumnOrderMemory: state.enableColumnOrderMemory !== false, + sqlFormatOptions: sanitizeSqlFormatOptions(state.sqlFormatOptions), queryOptions: sanitizeQueryOptions(state.queryOptions), shortcutOptions: sanitizeShortcutOptions(state.shortcutOptions), tableAccessCount: sanitizeTableAccessCount(state.tableAccessCount), - tableSortPreference: sanitizeTableSortPreference(state.tableSortPreference), }; }, partialize: (state) => ({