feat(DataGrid): 实现数据视图列标题拖拽排序及顺序记忆

- 功能集成:接入 @dnd-kit 实现表头水平拖拽排序,支持多列位置灵活调整
- 持久化:Store 新增 tableColumnOrders 状态,支持按“连接-库-表”多维度记忆自定义列序
- 交互优化:重构表头 DOM 结构并消除内边距,实现“悬停手型、按住抓取”的精准指针反馈
- 性能提升:通过 React.memo 减少重渲染,并启用 will-change 硬件加速确保 60FPS 流畅度
- 稳定性:增强 Wails 环境接口调用的异常捕获,并补全前端独立开发环境下的 API Stub
This commit is contained in:
Syngnat
2026-03-10 15:49:22 +08:00
parent ca49b37dc7
commit 695713c779
5 changed files with 417 additions and 103 deletions

View File

@@ -5,6 +5,23 @@
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>GoNavi</title>
<script>
if (typeof window !== 'undefined' && !window.go) {
window.go = {
app: {
App: new Proxy({}, { get: () => async () => ({ success: false }) })
}
};
}
if (typeof window !== 'undefined' && !window.runtime) {
window.runtime = new Proxy({}, {
get: (target, prop) => {
if (prop === 'Environment') return async () => ({ platform: 'darwin' });
return typeof prop === 'string' && prop.startsWith('WindowIs') ? () => false : () => {};
}
});
}
</script>
</head>
<body>
<div id="root"></div>

View File

@@ -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();
};
}, []);

View File

@@ -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<HTMLTableCellElement, any>((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 <th {...restProps} style={nextStyle} />;
return <th ref={ref} {...restProps} style={nextStyle} />;
}
return (
<th {...restProps} style={{ ...nextStyle, position: 'relative' }}>
<th ref={ref} {...restProps} style={{ ...nextStyle, position: 'relative' }}>
{restProps.children}
<span
className="react-resizable-handle"
@@ -361,7 +378,103 @@ const ResizableTitle = (props: any) => {
/>
</th>
);
};
});
// --- Sortable Header Cell ---
interface SortableHeaderCellProps extends React.HTMLAttributes<HTMLTableCellElement> {
id?: string;
}
// --- Sortable Header Cell ---
interface SortableHeaderCellProps extends React.HTMLAttributes<HTMLTableCellElement> {
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<SortableHeaderCellProps> = 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 <ResizableTitle {...restProps} style={{ ...propStyle, ...style }}>{children}</ResizableTitle>;
}
return (
<ResizableTitle
ref={setNodeRef}
style={style}
className={`${propClassName || ''} ${isDragging ? 'is-dragging' : ''}`}
data-cursor-grabbing={isDragging || isPressed}
{...restProps}
{...attributes}
{...listeners}
onPointerDown={(e: any) => {
setIsPressed(true);
if (listeners?.onPointerDown) listeners.onPointerDown(e);
}}
>
<style>{sortableHeaderStaticStyles}</style>
<div className="sortable-header-cell-drag-handle" title="拖拽以调整列顺序">
<div style={{ flex: 1, display: 'flex', alignItems: 'center', minWidth: 0, cursor: 'inherit' }}>
{children}
</div>
</div>
</ResizableTitle>
);
});
// --- Contexts ---
const EditableContext = React.createContext<any>(null);
@@ -640,6 +753,12 @@ const DataGrid: React.FC<DataGridProps> = ({
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<DataGridProps> = ({
const canModifyData = !readOnly && !!tableName;
const showColumnComment = queryOptions?.showColumnComment !== false;
const showColumnType = queryOptions?.showColumnType !== false;
// --- Display Columns Order Management ---
const [displayColumnNames, setDisplayColumnNames] = useState<string[]>([]);
// 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<DataGridProps> = ({
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<DataGridProps> = ({
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<GridFilterCondition[]>([]);
@@ -1196,9 +1358,9 @@ const DataGrid: React.FC<DataGridProps> = ({
const columnIndexMap = useMemo(() => {
const map = new Map<string, number>();
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<string>) => {
@@ -1377,7 +1539,7 @@ const DataGrid: React.FC<DataGridProps> = ({
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<DataGridProps> = ({
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<DataGridProps> = ({
const formMap: Record<string, any> = {};
const nullCols = new Set<string>();
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<DataGridProps> = ({
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<DataGridProps> = ({
const keyStr = rowKeyStr(rowKey);
const normalizedNext: Record<string, any> = {};
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<DataGridProps> = ({
const originalRow = originalMap.get(keyStr);
if (!originalRow) continue;
const patch: Record<string, any> = {};
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<DataGridProps> = ({
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<DataGridProps> = ({
const baseRawMap = rowEditorBaseRawRef.current || {};
const patch: Record<string, any> = {};
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<DataGridProps> = ({
});
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<DataGridProps> = ({
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<HTMLElement>) => {
if (!onSort) return;
@@ -2154,7 +2318,7 @@ const DataGrid: React.FC<DataGridProps> = ({
},
}),
}));
}, [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<DataGridProps> = ({
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<DataGridProps> = ({
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<DataGridProps> = ({
id: nextFilterId,
enabled: true,
logic: 'AND',
column: columnNames[0] || '',
column: displayColumnNames[0] || '',
op: '=',
value: '',
value2: '',
@@ -2747,6 +2911,26 @@ const DataGrid: React.FC<DataGridProps> = ({
>
</Checkbox>
<div style={{ height: 1, backgroundColor: darkMode ? '#424242' : '#f0f0f0', margin: '4px 0' }} />
<Checkbox
checked={enableColumnOrderMemory}
onChange={(e) => setEnableColumnOrderMemory(e.target.checked)}
>
</Checkbox>
<Button
size="small"
danger
disabled={!connectionId || !dbName || !tableName || !tableColumnOrders[`${connectionId}-${dbName}-${tableName}`]}
onClick={() => {
if (connectionId && dbName && tableName) {
clearTableColumnOrder(connectionId, dbName, tableName);
message.success('已恢复默认列排序');
}
}}
>
</Button>
</div>
);
@@ -2776,7 +2960,7 @@ const DataGrid: React.FC<DataGridProps> = ({
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<DataGridProps> = ({
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<DataGridProps> = ({
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<DataGridProps> = ({
</div>
<Form form={rowEditorForm} layout="vertical">
<div className="custom-scrollbar" style={{ maxHeight: '62vh', overflow: 'auto', paddingRight: 8 }}>
{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<DataGridProps> = ({
<DataContext.Provider value={dataContextValue}>
<CellContextMenuContext.Provider value={cellContextMenuValue}>
<EditableContext.Provider value={form}>
<Table
components={tableComponents}
dataSource={mergedDisplayData}
columns={mergedColumns}
showSorterTooltip={{ target: 'sorter-icon' }}
size="small"
tableLayout="fixed"
scroll={tableScrollConfig}
sticky={false}
virtual={enableVirtual}
loading={loading}
rowKey={GONAVI_ROW_KEY}
pagination={false}
onChange={handleTableChange}
bordered
rowSelection={rowSelectionConfig}
rowClassName={rowClassName}
onRow={tableOnRow}
/>
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={displayColumnNames} strategy={horizontalListSortingStrategy}>
<Table
components={tableComponents}
dataSource={mergedDisplayData}
columns={mergedColumns}
showSorterTooltip={{ target: 'sorter-icon' }}
size="small"
tableLayout="fixed"
scroll={tableScrollConfig}
sticky={false}
virtual={enableVirtual}
loading={loading}
rowKey={GONAVI_ROW_KEY}
pagination={false}
onChange={handleTableChange}
bordered
rowSelection={rowSelectionConfig}
rowClassName={rowClassName}
onRow={tableOnRow}
/>
</SortableContext>
</DndContext>
</EditableContext.Provider>
</CellContextMenuContext.Provider>
</DataContext.Provider>
@@ -3734,7 +3922,7 @@ const DataGrid: React.FC<DataGridProps> = ({
)}
</div>
<div className="custom-scrollbar" style={{ flex: 1, minHeight: 0, overflow: 'auto', padding: '8px 12px' }}>
{currentTextRow ? columnNames.map((col) => (
{currentTextRow ? displayColumnNames.map((col) => (
<div key={col} style={{ display: 'grid', gridTemplateColumns: '240px 1fr', gap: 10, padding: '6px 0', borderBottom: darkMode ? '1px solid rgba(255,255,255,0.06)' : '1px solid rgba(0,0,0,0.06)', alignItems: 'start' }}>
<div style={{ fontWeight: 600, color: darkMode ? 'rgba(255,255,255,0.9)' : 'rgba(0,0,0,0.88)', wordBreak: 'break-all' }}>
{col} :

View File

@@ -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: [],

View File

@@ -416,6 +416,8 @@ interface AppState {
sqlLogs: SqlLog[];
tableAccessCount: Record<string, number>;
tableSortPreference: Record<string, 'name' | 'frequency'>;
tableColumnOrders: Record<string, string[]>;
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<string, 'name' | 'f
return result;
};
const sanitizeTableColumnOrders = (value: unknown): Record<string, string[]> => {
const raw = (value && typeof value === 'object') ? value as Record<string, unknown> : {};
const result: Record<string, string[]> = {};
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<AppState>()(
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<AppState>()(
}
};
}),
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<AppState>()(
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<AppState>()(
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) => ({