feat(frontend): 升级 DataGrid 组件并引入高性能拖拽交互

- 实现基于原生 DOM 事件的零渲染列宽拖拽,彻底解决卡顿与误触排序问题
- 查询编辑器集成 DataGrid,支持 SQL 结果直接编辑与事务提交
- 侧边栏新增上下文感知的 "新建查询" 快捷入口
- 优化 TabManager 渲染逻辑与全局布局,消除不必要的滚动条
This commit is contained in:
杨国锋
2026-02-02 11:32:49 +08:00
parent e0181cc7ac
commit af91c916c3
33 changed files with 2020 additions and 1618 deletions

View File

@@ -1,7 +1,7 @@
import React, { useState } from 'react';
import { Modal, Form, Input, InputNumber, Button, message, Checkbox, Divider, Collapse, Select } from 'antd';
import { useStore } from '../store';
import { MySQLConnect } from '../../wailsjs/go/main/App';
import { MySQLConnect } from '../../wailsjs/go/app/App';
const ConnectionModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open, onClose }) => {
const [form] = Form.useForm();

View File

@@ -0,0 +1,726 @@
import React, { useState, useEffect, useRef, useContext, useMemo, useCallback } from 'react';
import { Table, message, Input, Button, Dropdown, MenuProps, Form, Pagination, Select } from 'antd';
import type { SortOrder } from 'antd/es/table/interface';
import { ReloadOutlined, ImportOutlined, ExportOutlined, DownOutlined, PlusOutlined, DeleteOutlined, SaveOutlined, UndoOutlined, FilterOutlined, CloseOutlined, ConsoleSqlOutlined, FileTextOutlined, CopyOutlined } from '@ant-design/icons';
import { Resizable } from 'react-resizable';
import { ImportData, ExportTable, ApplyChanges } from '../../wailsjs/go/app/App';
import { useStore } from '../store';
import 'react-resizable/css/styles.css';
// --- Helper: Format Value ---
const formatCellValue = (val: any) => {
if (val === null) return <span style={{ color: '#ccc' }}>NULL</span>;
if (typeof val === 'object') return JSON.stringify(val);
if (typeof val === 'string') {
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(val)) {
return val.replace('T', ' ').replace(/\+.*$/, '').replace(/Z$/, '');
}
}
return String(val);
};
// --- Resizable Header (Native Implementation) ---
const ResizableTitle = (props: any) => {
const { onResizeStart, width, ...restProps } = props;
if (!width) {
return <th {...restProps} />;
}
return (
<th {...restProps} style={{ ...restProps.style, position: 'relative' }}>
{restProps.children}
<span
className="react-resizable-handle"
onMouseDown={(e) => {
e.stopPropagation();
// Pass the header element reference implicitly via event target
onResizeStart(e);
}}
onClick={(e) => e.stopPropagation()}
style={{
position: 'absolute',
right: 0, // Align to right edge
bottom: 0,
top: 0,
width: 10,
cursor: 'col-resize',
zIndex: 10,
touchAction: 'none'
}}
/>
</th>
);
};
// --- Contexts ---
const EditableContext = React.createContext<any>(null);
const DataContext = React.createContext<{
selectedRowKeysRef: React.MutableRefObject<React.Key[]>;
displayDataRef: React.MutableRefObject<any[]>;
handleCopyInsert: (r: any) => void;
handleCopyJson: (r: any) => void;
handleCopyCsv: (r: any) => void;
copyToClipboard: (t: string) => void;
tableName?: string;
} | null>(null);
interface Item {
key: string;
[key: string]: any;
}
interface EditableCellProps {
title: React.ReactNode;
editable: boolean;
children: React.ReactNode;
dataIndex: string;
record: Item;
handleSave: (record: Item) => void;
[key: string]: any;
}
const EditableCell: React.FC<EditableCellProps> = React.memo(({
title,
editable,
children,
dataIndex,
record,
handleSave,
...restProps
}) => {
const [editing, setEditing] = useState(false);
const inputRef = useRef<any>(null);
const form = useContext(EditableContext);
useEffect(() => {
if (editing) {
inputRef.current?.focus();
}
}, [editing]);
const toggleEdit = () => {
setEditing(!editing);
form.setFieldsValue({ [dataIndex]: record[dataIndex] });
};
const save = async () => {
try {
if (!form) return;
const values = await form.validateFields();
toggleEdit();
handleSave({ ...record, ...values });
} catch (errInfo) {
console.log('Save failed:', errInfo);
}
};
let childNode = children;
if (editable) {
childNode = editing ? (
<Form.Item style={{ margin: 0 }} name={dataIndex}>
<Input ref={inputRef} onPressEnter={save} onBlur={save} />
</Form.Item>
) : (
<div className="editable-cell-value-wrap" style={{ paddingRight: 24, minHeight: 20 }} onClick={toggleEdit}>
{children}
</div>
);
}
return <td {...restProps}>{childNode}</td>;
});
const ContextMenuRow = React.memo(({ children, ...props }: any) => {
const record = props.record;
const context = useContext(DataContext);
if (!record || !context) return <tr {...props}>{children}</tr>;
const { selectedRowKeysRef, displayDataRef, handleCopyInsert, handleCopyJson, handleCopyCsv, copyToClipboard } = context;
const getTargets = () => {
const keys = selectedRowKeysRef.current;
if (keys.includes(record.key)) {
return displayDataRef.current.filter(d => keys.includes(d.key));
}
return [record];
};
const menuItems: MenuProps['items'] = [
{
key: 'insert',
label: `复制为 INSERT`,
icon: <ConsoleSqlOutlined />,
onClick: () => handleCopyInsert(record)
},
{ key: 'json', label: '复制为 JSON', icon: <FileTextOutlined />, onClick: () => handleCopyJson(record) },
{ key: 'csv', label: '复制为 CSV', icon: <FileTextOutlined />, onClick: () => handleCopyCsv(record) },
{ key: 'copy', label: '复制为 Markdown', icon: <CopyOutlined />, onClick: () => {
const records = getTargets();
const lines = records.map((r: any) => {
const { key, ...vals } = r;
return `| ${Object.values(vals).join(' | ')} |`;
});
copyToClipboard(lines.join('\n'));
} },
];
return (
<Dropdown menu={{ items: menuItems }} trigger={['contextMenu']}>
<tr {...props}>{children}</tr>
</Dropdown>
);
});
interface DataGridProps {
data: any[];
columnNames: string[];
loading: boolean;
tableName?: string;
dbName?: string;
connectionId?: string;
pkColumns?: string[];
readOnly?: boolean;
onReload?: () => void;
onSort?: (field: string, order: string) => void;
onPageChange?: (page: number, size: number) => void;
pagination?: { current: number, pageSize: number, total: number };
// Filtering
showFilter?: boolean;
onToggleFilter?: () => void;
onApplyFilter?: (conditions: any[]) => void;
}
const DataGrid: React.FC<DataGridProps> = ({
data, columnNames, loading, tableName, dbName, connectionId, pkColumns = [], readOnly = false,
onReload, onSort, onPageChange, pagination, showFilter, onToggleFilter, onApplyFilter
}) => {
const connections = useStore(state => state.connections);
const [form] = Form.useForm();
const [sortInfo, setSortInfo] = useState<{ columnKey: string, order: string } | null>(null);
const [columnWidths, setColumnWidths] = useState<Record<string, number>>({});
// Dynamic Height
const [tableHeight, setTableHeight] = useState(500);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!containerRef.current) return;
const resizeObserver = new ResizeObserver(entries => {
for (let entry of entries) {
// Subtract header height (~40px)
// Ensure minimum height to prevent collapse loop
const h = Math.max(100, entry.contentRect.height - 42);
setTableHeight(h);
}
});
resizeObserver.observe(containerRef.current);
return () => resizeObserver.disconnect();
}, []);
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
const [addedRows, setAddedRows] = useState<any[]>([]);
const [modifiedRows, setModifiedRows] = useState<Record<string, any>>({});
const [deletedRowKeys, setDeletedRowKeys] = useState<Set<React.Key>>(new Set());
// Filter State
const [filterConditions, setFilterConditions] = useState<{ id: number, column: string, op: string, value: string }[]>([]);
const [nextFilterId, setNextFilterId] = useState(1);
const selectedRowKeysRef = useRef(selectedRowKeys);
const displayDataRef = useRef<any[]>([]);
useEffect(() => { selectedRowKeysRef.current = selectedRowKeys; }, [selectedRowKeys]);
// Reset local state when data source likely changes (e.g. tableName change)
useEffect(() => {
setAddedRows([]);
setModifiedRows({});
setDeletedRowKeys(new Set());
setSelectedRowKeys([]);
}, [tableName, dbName, connectionId]); // Reset on context change
const displayData = useMemo(() => {
return [...data, ...addedRows].filter(item => !deletedRowKeys.has(item.key));
}, [data, addedRows, deletedRowKeys]);
useEffect(() => { displayDataRef.current = displayData; }, [displayData]);
const hasChanges = addedRows.length > 0 || Object.keys(modifiedRows).length > 0 || deletedRowKeys.size > 0;
const handleTableChange = (pag: any, filtersArg: any, sorter: any) => {
if (isResizingRef.current) return; // Block sort if resizing
if (sorter.field) {
const order = sorter.order as string;
setSortInfo({ columnKey: sorter.field as string, order });
if (onSort) onSort(sorter.field, order);
} else {
setSortInfo(null);
if (onSort) onSort('', '');
}
};
// Native Drag State
const draggingRef = useRef<{
startX: number,
startWidth: number,
key: string
} | null>(null);
const ghostRef = useRef<HTMLDivElement>(null);
const isResizingRef = useRef(false); // Lock for sorting
// 1. Drag Start
const handleResizeStart = useCallback((key: string) => (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
isResizingRef.current = true; // Engage lock
const startX = e.clientX;
const currentWidth = columnWidths[key] || 200;
draggingRef.current = { startX, startWidth: currentWidth, key };
// Show Ghost Line at initial position
if (ghostRef.current && containerRef.current) {
const containerRect = containerRef.current.getBoundingClientRect();
const relativeLeft = startX - containerRect.left;
ghostRef.current.style.left = `${relativeLeft}px`;
ghostRef.current.style.display = 'block';
}
// Add global listeners
document.addEventListener('mousemove', handleResizeMove);
document.addEventListener('mouseup', handleResizeStop);
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
}, [columnWidths]);
// 2. Drag Move (Global)
const handleResizeMove = useCallback((e: MouseEvent) => {
if (!draggingRef.current || !ghostRef.current || !containerRef.current) return;
// Update Ghost Line Position directly
const containerRect = containerRef.current.getBoundingClientRect();
const relativeLeft = e.clientX - containerRect.left;
ghostRef.current.style.left = `${relativeLeft}px`;
}, []);
// 3. Drag Stop (Global)
const handleResizeStop = useCallback((e: MouseEvent) => {
if (!draggingRef.current) return;
const { startX, startWidth, key } = draggingRef.current;
const deltaX = e.clientX - startX;
const newWidth = Math.max(50, startWidth + deltaX);
// Commit State
setColumnWidths(prev => ({ ...prev, [key]: newWidth }));
// Cleanup
if (ghostRef.current) ghostRef.current.style.display = 'none';
document.removeEventListener('mousemove', handleResizeMove);
document.removeEventListener('mouseup', handleResizeStop);
document.body.style.cursor = '';
document.body.style.userSelect = '';
draggingRef.current = null;
// Release lock after a short delay to block subsequent click events (sorting)
setTimeout(() => {
isResizingRef.current = false;
}, 100);
}, []);
const handleCellSave = useCallback((row: any) => {
// Optimistic update for display
// In parent-controlled data, we might need parent to update 'data',
// but here we manage 'modifiedRows' locally and overlay it.
// Since 'displayData' is derived from 'data' + 'modifiedRows', we need to update the source if it's in 'data'.
// But 'data' prop is immutable.
// So we update 'modifiedRows'.
// Check if it's an added row
const isAdded = addedRows.some(r => r.key === row.key);
if (isAdded) {
setAddedRows(prev => prev.map(r => r.key === row.key ? { ...r, ...row } : r));
} else {
setModifiedRows(prev => ({ ...prev, [row.key]: row }));
}
}, [addedRows]);
// Merge Data for Display
// 'displayData' already merges addedRows.
// We need to merge modifiedRows into it for rendering.
const mergedDisplayData = useMemo(() => {
return displayData.map(row => {
if (modifiedRows[row.key]) {
return { ...row, ...modifiedRows[row.key] };
}
return row;
});
}, [displayData, modifiedRows]);
const columns = useMemo(() => {
return columnNames.map(key => ({
title: key,
dataIndex: key,
key: key,
ellipsis: true,
width: columnWidths[key] || 200,
sorter: !!onSort,
sortOrder: (sortInfo?.columnKey === key ? sortInfo.order : null) as SortOrder | undefined,
editable: !readOnly && !!tableName, // Only editable if table name known
render: (text: any) => formatCellValue(text),
onHeaderCell: (column: any) => ({
width: column.width,
onResizeStart: handleResizeStart(key), // Only need start
}),
}));
}, [columnNames, columnWidths, sortInfo, handleResizeStart, readOnly, tableName, onSort]);
const mergedColumns = useMemo(() => columns.map(col => {
if (!col.editable) return col;
return {
...col,
onCell: (record: Item) => ({
record,
editable: col.editable,
dataIndex: col.dataIndex,
title: col.title,
handleSave: handleCellSave,
}),
};
}), [columns, handleCellSave]);
const handleAddRow = () => {
const newKey = `new-${Date.now()}`;
const newRow: any = { key: newKey };
columnNames.forEach(col => newRow[col] = '');
setAddedRows(prev => [...prev, newRow]);
};
const handleDeleteSelected = () => {
setDeletedRowKeys(prev => {
const newDeleted = new Set(prev);
selectedRowKeys.forEach(key => newDeleted.add(key));
return newDeleted;
});
setSelectedRowKeys([]);
};
const handleCommit = async () => {
if (!connectionId || !tableName) return;
const conn = connections.find(c => c.id === connectionId);
if (!conn) return;
const inserts: any[] = [];
const updates: any[] = [];
const deletes: any[] = [];
addedRows.forEach(row => { const { key, ...vals } = row; inserts.push(vals); });
deletedRowKeys.forEach(key => {
// Find original data
const originalRow = data.find(d => d.key === key) || addedRows.find(d => d.key === key);
if (originalRow) {
const pkData: any = {};
if (pkColumns.length > 0) pkColumns.forEach(k => pkData[k] = originalRow[k]);
else { const { key: _, ...rest } = originalRow; Object.assign(pkData, rest); }
deletes.push(pkData);
}
});
Object.entries(modifiedRows).forEach(([key, newRow]) => {
if (deletedRowKeys.has(key)) return;
const originalRow = data.find(d => d.key === key);
if (!originalRow) return; // Should not happen for modified rows unless deleted
const pkData: any = {};
if (pkColumns.length > 0) pkColumns.forEach(k => pkData[k] = originalRow[k]);
else { const { key: _, ...rest } = originalRow; Object.assign(pkData, rest); }
const { key: _, ...vals } = newRow;
updates.push({ keys: pkData, values: vals });
});
if (inserts.length === 0 && updates.length === 0 && deletes.length === 0) {
message.info("No changes to commit");
return;
}
const config = {
...conn.config,
port: Number(conn.config.port),
password: conn.config.password || "",
database: conn.config.database || "",
useSSH: conn.config.useSSH || false,
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
};
const res = await ApplyChanges(config as any, dbName || '', tableName, { inserts, updates, deletes } as any);
if (res.success) {
message.success("Changes committed successfully!");
setAddedRows([]);
setModifiedRows({});
setDeletedRowKeys(new Set());
if (onReload) onReload();
} else {
message.error("Commit failed: " + res.message);
}
};
const copyToClipboard = useCallback((text: string) => {
navigator.clipboard.writeText(text);
message.success("Copied to clipboard");
}, []);
const getTargets = useCallback((clickedRecord: any) => {
const selKeys = selectedRowKeysRef.current;
const currentData = displayDataRef.current;
if (selKeys.includes(clickedRecord.key)) {
return currentData.filter(d => selKeys.includes(d.key));
}
return [clickedRecord];
}, []);
const handleCopyInsert = useCallback((record: any) => {
const records = getTargets(record);
const sqls = records.map((r: any) => {
const { key, ...vals } = r;
const cols = Object.keys(vals);
const values = Object.values(vals).map(v => v === null ? 'NULL' : `'${v}'`);
const targetTable = tableName || 'table';
return `INSERT INTO \`${targetTable}\` (${cols.map(c => `\`${c}\``).join(', ')}) VALUES (${values.join(', ')});`;
});
copyToClipboard(sqls.join('\n'));
}, [tableName, getTargets, copyToClipboard]);
const handleCopyJson = useCallback((record: any) => {
const records = getTargets(record);
const cleanRecords = records.map((r: any) => {
const { key, ...rest } = r;
return rest;
});
copyToClipboard(JSON.stringify(cleanRecords, null, 2));
}, [getTargets, copyToClipboard]);
const handleCopyCsv = useCallback((record: any) => {
const records = getTargets(record);
const lines = records.map((r: any) => {
const { key, ...vals } = r;
const values = Object.values(vals).map(v => v === null ? 'NULL' : `"${v}"`);
return values.join(',');
});
copyToClipboard(lines.join('\n'));
}, [getTargets, copyToClipboard]);
// Export
const handleExport = async (format: string) => {
if (!connectionId || !tableName) return;
const conn = connections.find(c => c.id === connectionId);
if (!conn) return;
const config = { ...conn.config, port: Number(conn.config.port), password: conn.config.password || "", database: conn.config.database || "", useSSH: conn.config.useSSH || false, ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" } };
const hide = message.loading(`Exporting as ${format.toUpperCase()}...`, 0);
const res = await ExportTable(config as any, dbName || '', tableName, format);
hide();
if (res.success) { message.success("Export Successful"); } else if (res.message !== "Cancelled") { message.error("Export Failed: " + res.message); }
};
const handleImport = async () => {
if (!connectionId || !tableName) return;
const conn = connections.find(c => c.id === connectionId);
if (!conn) return;
const config = { ...conn.config, port: Number(conn.config.port), password: conn.config.password || "", database: conn.config.database || "", useSSH: conn.config.useSSH || false, ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" } };
const res = await ImportData(config as any, dbName || '', tableName);
if (res.success) { message.success(res.message); if (onReload) onReload(); } else if (res.message !== "Cancelled") { message.error("Import Failed: " + res.message); }
};
// Filters
const addFilter = () => {
setFilterConditions([...filterConditions, { id: nextFilterId, column: columnNames[0] || '', op: '=', value: '' }]);
setNextFilterId(nextFilterId + 1);
};
const updateFilter = (id: number, field: string, val: string) => {
setFilterConditions(prev => prev.map(c => c.id === id ? { ...c, [field]: val } : c));
};
const removeFilter = (id: number) => {
setFilterConditions(prev => prev.filter(c => c.id !== id));
};
const applyFilters = () => {
if (onApplyFilter) onApplyFilter(filterConditions);
};
const exportMenu: MenuProps['items'] = [
{ key: 'csv', label: 'CSV', onClick: () => handleExport('csv') },
{ key: 'xlsx', label: 'Excel (XLSX)', onClick: () => handleExport('xlsx') },
{ key: 'json', label: 'JSON', onClick: () => handleExport('json') },
{ key: 'md', label: 'Markdown', onClick: () => handleExport('md') },
];
const tableComponents = useMemo(() => ({
body: { cell: EditableCell, row: ContextMenuRow },
header: { cell: ResizableTitle }
}), []);
const totalWidth = columns.reduce((sum, col) => sum + (col.width as number || 200), 0);
return (
<div style={{ height: '100%', overflow: 'hidden', padding: 0, display: 'flex', flexDirection: 'column' }}>
{/* Toolbar */}
<div style={{ padding: '8px', borderBottom: '1px solid #eee', display: 'flex', gap: 8, alignItems: 'center' }}>
{onReload && <Button icon={<ReloadOutlined />} onClick={() => {
setAddedRows([]);
setModifiedRows({});
setDeletedRowKeys(new Set());
setSelectedRowKeys([]);
onReload();
}}></Button>}
{tableName && <Button icon={<ImportOutlined />} onClick={handleImport}></Button>}
{tableName && <Dropdown menu={{ items: exportMenu }}><Button icon={<ExportOutlined />}> <DownOutlined /></Button></Dropdown>}
{!readOnly && tableName && (
<>
<div style={{ width: 1, background: '#eee', height: 20, margin: '0 8px' }} />
<Button icon={<PlusOutlined />} onClick={handleAddRow}></Button>
<Button icon={<DeleteOutlined />} danger disabled={selectedRowKeys.length === 0} onClick={handleDeleteSelected}></Button>
{selectedRowKeys.length > 0 && <span style={{ fontSize: '12px', color: '#888' }}> {selectedRowKeys.length}</span>}
<div style={{ width: 1, background: '#eee', height: 20, margin: '0 8px' }} />
<Button icon={<SaveOutlined />} type="primary" disabled={!hasChanges} onClick={handleCommit}> ({addedRows.length + Object.keys(modifiedRows).length + deletedRowKeys.size})</Button>
{hasChanges && (<Button icon={<UndoOutlined />} onClick={() => {
setAddedRows([]);
setModifiedRows({});
setDeletedRowKeys(new Set());
}}></Button>)}
</>
)}
{onToggleFilter && (
<>
<div style={{ width: 1, background: '#eee', height: 20, margin: '0 8px' }} />
<Button icon={<FilterOutlined />} type={showFilter ? 'primary' : 'default'} onClick={() => {
onToggleFilter();
if (filterConditions.length === 0 && !showFilter) addFilter();
}}></Button>
</>
)}
</div>
{/* Filter Panel */}
{showFilter && (
<div style={{ padding: '8px', background: '#f5f5f5', borderBottom: '1px solid #eee' }}>
{filterConditions.map(cond => (
<div key={cond.id} style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
<Select style={{ width: 150 }} value={cond.column} onChange={v => updateFilter(cond.id, 'column', v)} options={columnNames.map(c => ({ value: c, label: c }))} />
<Select style={{ width: 100 }} value={cond.op} onChange={v => updateFilter(cond.id, 'op', v)} options={[{ value: '=', label: '=' }, { value: 'LIKE', label: '包含' }]} />
<Input style={{ width: 200 }} value={cond.value} onChange={e => updateFilter(cond.id, 'value', e.target.value)} />
<Button icon={<CloseOutlined />} onClick={() => removeFilter(cond.id)} type="text" danger />
</div>
))}
<div style={{ display: 'flex', gap: 8 }}>
<Button type="dashed" onClick={addFilter} size="small" icon={<FilterOutlined />}>Add Condition</Button>
<Button type="primary" onClick={applyFilters} size="small">Apply</Button>
</div>
</div>
)}
<div ref={containerRef} style={{ flex: 1, overflow: 'hidden', position: 'relative' }}>
<Form component={false} form={form}>
<DataContext.Provider value={{ selectedRowKeysRef, displayDataRef, handleCopyInsert, handleCopyJson, handleCopyCsv, copyToClipboard, tableName }}>
<EditableContext.Provider value={form}>
<Table
components={tableComponents}
dataSource={mergedDisplayData}
columns={mergedColumns}
size="small"
scroll={{ x: Math.max(totalWidth, 1000), y: tableHeight }}
loading={loading}
pagination={false}
onChange={handleTableChange}
bordered
rowSelection={{
selectedRowKeys,
onChange: setSelectedRowKeys,
}}
rowClassName={(record) => {
if (addedRows.some(r => r.key === record.key)) return 'row-added';
if (modifiedRows[record.key] || deletedRowKeys.has(record.key)) return 'row-modified'; // deleted won't show
return '';
}}
onRow={(record) => ({ record } as any)}
/>
</EditableContext.Provider>
</DataContext.Provider>
</Form>
</div>
{pagination && (
<div style={{ padding: '8px', borderTop: '1px solid #eee', display: 'flex', justifyContent: 'flex-end', background: '#fff' }}>
<Pagination
current={pagination.current}
pageSize={pagination.pageSize}
total={pagination.total}
showTotal={(total, range) => `当前 ${range[1] - range[0] + 1} 条 / 共 ${total}`}
showSizeChanger
pageSizeOptions={['100', '200', '500', '1000']}
onChange={onPageChange}
size="small"
/>
</div>
)}
<style>{`
.row-added td { background-color: #f6ffed !important; }
.row-modified td { background-color: #e6f7ff !important; }
.ant-table-body {
height: ${tableHeight}px !important;
max-height: ${tableHeight}px !important;
}
`}</style>
{/* Ghost Resize Line for Columns */}
<div
ref={ghostRef}
style={{
position: 'absolute',
top: 0,
bottom: 0, // Fits container height
width: '2px',
background: '#1890ff',
zIndex: 9999,
display: 'none',
pointerEvents: 'none'
}}
/>
</div>
);
};
export default React.memo(DataGrid);

View File

@@ -1,200 +1,9 @@
import React, { useEffect, useState, useRef, useContext, useMemo, useCallback } from 'react';
import { Table, message, Spin, Input, Button, Space, Select, Tag, Dropdown, MenuProps, Form, Popconfirm, Pagination } from 'antd';
import type { SortOrder } from 'antd/es/table/interface';
import { SearchOutlined, FilterOutlined, CloseOutlined, ReloadOutlined, ImportOutlined, ExportOutlined, DownOutlined, PlusOutlined, DeleteOutlined, SaveOutlined, UndoOutlined, CheckOutlined, ConsoleSqlOutlined, FileTextOutlined, CopyOutlined } from '@ant-design/icons';
import { Resizable } from 'react-resizable';
import React, { useEffect, useState, useCallback } from 'react';
import { message } from 'antd';
import { TabData, ColumnDefinition } from '../types';
import { useStore } from '../store';
import { MySQLQuery, ImportData, ExportTable, ApplyChanges, DBGetColumns } from '../../wailsjs/go/main/App';
import 'react-resizable/css/styles.css';
// --- Helper: Format Value ---
const formatCellValue = (val: any) => {
if (val === null) return <span style={{ color: '#ccc' }}>NULL</span>;
if (typeof val === 'object') return JSON.stringify(val);
if (typeof val === 'string') {
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(val)) {
return val.replace('T', ' ').replace(/\+.*$/, '').replace(/Z$/, '');
}
}
return String(val);
};
// --- Resizable Header ---
const ResizableTitle = (props: any) => {
const { onResize, width, ...restProps } = props;
if (!width) {
return <th {...restProps} />;
}
return (
<Resizable
width={width}
height={0}
handle={
<span
className="react-resizable-handle"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
}}
onMouseDown={(e) => e.stopPropagation()}
style={{
position: 'absolute',
right: -5,
bottom: 0,
top: 0,
width: 10,
cursor: 'col-resize',
zIndex: 100,
touchAction: 'none'
}}
/>
}
onResize={onResize}
draggableOpts={{ enableUserSelectHack: false }}
>
<th
{...restProps}
style={{
...restProps.style,
position: 'relative',
userSelect: 'none'
}}
/>
</Resizable>
);
};
// --- Contexts ---
const EditableContext = React.createContext<any>(null);
// Use Ref for selection to prevent Context updates on every selection change
const DataContext = React.createContext<{
selectedRowKeysRef: React.MutableRefObject<React.Key[]>;
displayDataRef: React.MutableRefObject<any[]>;
handleCopyInsert: (r: any) => void;
handleCopyJson: (r: any) => void;
handleCopyCsv: (r: any) => void;
copyToClipboard: (t: string) => void;
} | null>(null);
interface Item {
key: string;
[key: string]: any;
}
interface EditableCellProps {
title: React.ReactNode;
editable: boolean;
children: React.ReactNode;
dataIndex: string;
record: Item;
handleSave: (record: Item) => void;
[key: string]: any;
}
// Optimization: Memoize EditableCell
const EditableCell: React.FC<EditableCellProps> = React.memo(({
title,
editable,
children,
dataIndex,
record,
handleSave,
...restProps
}) => {
const [editing, setEditing] = useState(false);
const inputRef = useRef<any>(null);
const form = useContext(EditableContext);
useEffect(() => {
if (editing) {
inputRef.current?.focus();
}
}, [editing]);
const toggleEdit = () => {
setEditing(!editing);
form.setFieldsValue({ [dataIndex]: record[dataIndex] });
};
const save = async () => {
try {
if (!form) return;
const values = await form.validateFields();
toggleEdit();
handleSave({ ...record, ...values });
} catch (errInfo) {
console.log('Save failed:', errInfo);
}
};
let childNode = children;
if (editable) {
childNode = editing ? (
<Form.Item
style={{ margin: 0 }}
name={dataIndex}
>
<Input ref={inputRef} onPressEnter={save} onBlur={save} />
</Form.Item>
) : (
<div className="editable-cell-value-wrap" style={{ paddingRight: 24, minHeight: 20 }} onClick={toggleEdit}>
{children}
</div>
);
}
return <td {...restProps}>{childNode}</td>;
});
// --- Context Menu Row Wrapper (External & Memoized) ---
const ContextMenuRow = React.memo(({ children, ...props }: any) => {
const record = props.record;
const context = useContext(DataContext);
if (!record || !context) {
return <tr {...props}>{children}</tr>;
}
const { selectedRowKeysRef, displayDataRef, handleCopyInsert, handleCopyJson, handleCopyCsv, copyToClipboard } = context;
const getTargets = () => {
const keys = selectedRowKeysRef.current;
if (keys.includes(record.key)) {
return displayDataRef.current.filter(d => keys.includes(d.key));
}
return [record];
};
const menuItems: MenuProps['items'] = [
{
key: 'insert',
label: `复制为 INSERT`,
icon: <ConsoleSqlOutlined />,
onClick: () => handleCopyInsert(record)
},
{ key: 'json', label: '复制为 JSON', icon: <FileTextOutlined />, onClick: () => handleCopyJson(record) },
{ key: 'csv', label: '复制为 CSV', icon: <FileTextOutlined />, onClick: () => handleCopyCsv(record) },
{ key: 'copy', label: '复制为 Markdown', icon: <CopyOutlined />, onClick: () => {
const records = getTargets();
const lines = records.map((r: any) => {
const { key, ...vals } = r;
return `| ${Object.values(vals).join(' | ')} |`;
});
copyToClipboard(lines.join('\n'));
} },
];
return (
<Dropdown menu={{ items: menuItems }} trigger={['contextMenu']}>
<tr {...props}>{children}</tr>
</Dropdown>
);
});
import { MySQLQuery, DBGetColumns } from '../../wailsjs/go/app/App';
import DataGrid from './DataGrid';
const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
const [data, setData] = useState<any[]>([]);
@@ -209,39 +18,12 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
total: 0
});
const [form] = Form.useForm();
const [sortInfo, setSortInfo] = useState<{ columnKey: string, order: string } | null>(null);
const [showFilter, setShowFilter] = useState(false);
const [filterConditions, setFilterConditions] = useState<{ id: number, column: string, op: string, value: string }[]>([]);
const [nextFilterId, setNextFilterId] = useState(1);
const [filterConditions, setFilterConditions] = useState<any[]>([]);
const [columnWidths, setColumnWidths] = useState<Record<string, number>>({});
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
const [addedRows, setAddedRows] = useState<any[]>([]);
const [modifiedRows, setModifiedRows] = useState<Record<string, any>>({});
const [deletedRowKeys, setDeletedRowKeys] = useState<Set<React.Key>>(new Set());
// Refs
const selectedRowKeysRef = useRef(selectedRowKeys);
const displayDataRef = useRef<any[]>([]);
useEffect(() => {
selectedRowKeysRef.current = selectedRowKeys;
}, [selectedRowKeys]);
const displayData = useMemo(() => {
return [...data, ...addedRows].filter(item => !deletedRowKeys.has(item.key));
}, [data, addedRows, deletedRowKeys]);
useEffect(() => {
displayDataRef.current = displayData;
}, [displayData]);
const hasChanges = addedRows.length > 0 || Object.keys(modifiedRows).length > 0 || deletedRowKeys.size > 0;
const fetchData = async (page = pagination.current, size = pagination.pageSize) => {
const fetchData = useCallback(async (page = pagination.current, size = pagination.pageSize) => {
setLoading(true);
const conn = connections.find(c => c.id === tab.connectionId);
if (!conn) {
@@ -320,11 +102,6 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
setData(resultData.map((row: any, i: number) => ({ ...row, key: `row-${i}` })));
setPagination(prev => ({ ...prev, current: page, pageSize: size, total: totalRecords }));
setAddedRows([]);
setModifiedRows({});
setDeletedRowKeys(new Set());
setSelectedRowKeys([]);
} else {
message.error(resData.message);
}
@@ -332,323 +109,42 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
message.error("Error fetching data: " + e.message);
}
setLoading(false);
};
}, [connections, tab, sortInfo, filterConditions, pkColumns.length]);
// Depend on pkColumns.length to avoid loop? No, pkColumns is updated inside.
// Actually, 'pkColumns' state shouldn't trigger re-fetch.
// The 'if (pkColumns.length === 0)' check is inside.
// So adding pkColumns to dependency is safer but might trigger double fetch if not careful?
// Only if pkColumns changes. It changes once from [] to [...].
// So it's fine.
// Handlers memoized
const handleReload = useCallback(() => fetchData(), [fetchData]);
const handleSort = useCallback((field: string, order: string) => setSortInfo({ columnKey: field, order }), []);
const handlePageChange = useCallback((page: number, size: number) => fetchData(page, size), [fetchData]);
const handleToggleFilter = useCallback(() => setShowFilter(prev => !prev), []);
const handleApplyFilter = useCallback((conditions: any[]) => setFilterConditions(conditions), []);
useEffect(() => {
fetchData(1, pagination.pageSize);
}, [tab, sortInfo]);
const handlePaginationChange = (page: number, pageSize: number) => {
fetchData(page, pageSize);
};
const handleTableChange = (pag: any, filtersArg: any, sorter: any) => {
if (sorter.field) {
setSortInfo({ columnKey: sorter.field as string, order: sorter.order as string });
} else {
setSortInfo(null);
}
};
const handleResize = useCallback((key: string) => (_: React.SyntheticEvent, { size }: { size: { width: number } }) => {
window.requestAnimationFrame(() => {
setColumnWidths(prev => ({ ...prev, [key]: size.width }));
});
}, []);
const columns = useMemo(() => {
return columnNames.map(key => ({
title: key,
dataIndex: key,
key: key,
ellipsis: true,
width: columnWidths[key] || 200,
sorter: true,
sortOrder: (sortInfo?.columnKey === key ? sortInfo.order : null) as SortOrder | undefined,
editable: true,
render: (text: any) => formatCellValue(text),
onHeaderCell: (column: any) => ({
width: column.width,
onResize: handleResize(key),
}),
}));
}, [columnNames, columnWidths, sortInfo, handleResize]);
// Calculate total width
const totalWidth = columns.reduce((sum, col) => sum + (col.width as number || 200), 0);
const handleCellSave = useCallback((row: any) => {
setData(prevData => {
const newData = [...prevData];
const index = newData.findIndex(item => item.key === row.key);
if (index > -1) {
const item = newData[index];
newData.splice(index, 1, { ...item, ...row });
setModifiedRows(prev => ({ ...prev, [row.key]: row }));
return newData;
}
return prevData;
});
}, []);
// Compute merged columns for editable
const mergedColumns = useMemo(() => columns.map(col => {
if (!col.editable) return col;
return {
...col,
onCell: (record: Item) => ({
record,
editable: col.editable,
dataIndex: col.dataIndex,
title: col.title,
handleSave: handleCellSave,
}),
};
}), [columns, handleCellSave]);
const handleAddRow = () => {
const newKey = `new-${Date.now()}`;
const newRow: any = { key: newKey };
columnNames.forEach(col => newRow[col] = '');
setAddedRows(prev => [...prev, newRow]);
};
const handleDeleteSelected = () => {
setDeletedRowKeys(prev => {
const newDeleted = new Set(prev);
selectedRowKeys.forEach(key => {
newDeleted.add(key);
});
return newDeleted;
});
setSelectedRowKeys([]);
};
const handleCommit = async () => {
const conn = connections.find(c => c.id === tab.connectionId);
if (!conn) return;
const inserts: any[] = [];
const updates: any[] = [];
const deletes: any[] = [];
addedRows.forEach(row => { const { key, ...vals } = row; inserts.push(vals); });
deletedRowKeys.forEach(key => {
const originalRow = data.find(d => d.key === key);
if (originalRow) {
const pkData: any = {};
if (pkColumns.length > 0) pkColumns.forEach(k => pkData[k] = originalRow[k]);
else { const { key: _, ...rest } = originalRow; Object.assign(pkData, rest); }
deletes.push(pkData);
}
});
Object.entries(modifiedRows).forEach(([key, newRow]) => {
if (deletedRowKeys.has(key)) return;
const originalRow = data.find(d => d.key === key);
if (!originalRow) return;
const pkData: any = {};
if (pkColumns.length > 0) pkColumns.forEach(k => pkData[k] = originalRow[k]);
else { const { key: _, ...rest } = originalRow; Object.assign(pkData, rest); }
const { key: _, ...vals } = newRow;
updates.push({ keys: pkData, values: vals });
});
if (inserts.length === 0 && updates.length === 0 && deletes.length === 0) {
message.info("No changes to commit");
return;
}
const config = { ...conn.config, port: Number(conn.config.port), password: conn.config.password || "", database: conn.config.database || "", useSSH: conn.config.useSSH || false, ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" } };
const res = await ApplyChanges(config as any, tab.dbName || '', tab.tableName || '', { inserts, updates, deletes } as any);
if (res.success) {
message.success("Changes committed successfully!");
fetchData();
} else {
message.error("Commit failed: " + res.message);
}
};
const copyToClipboard = useCallback((text: string) => {
navigator.clipboard.writeText(text);
message.success("Copied to clipboard");
}, []);
const getTargets = useCallback((clickedRecord: any) => {
const selKeys = selectedRowKeysRef.current;
const currentData = displayDataRef.current;
if (selKeys.includes(clickedRecord.key)) {
return currentData.filter(d => selKeys.includes(d.key));
}
return [clickedRecord];
}, []);
const handleCopyInsert = useCallback((record: any) => {
const records = getTargets(record);
const sqls = records.map((r: any) => {
const { key, ...vals } = r;
const cols = Object.keys(vals);
const values = Object.values(vals).map(v => v === null ? 'NULL' : `'${v}'`);
return `INSERT INTO \`${tab.tableName}\` (${cols.map(c => `\`${c}\``).join(', ')}) VALUES (${values.join(', ')});`;
});
copyToClipboard(sqls.join('\n'));
}, [tab.tableName, getTargets, copyToClipboard]);
const handleCopyJson = useCallback((record: any) => {
const records = getTargets(record);
const cleanRecords = records.map((r: any) => {
const { key, ...rest } = r;
return rest;
});
copyToClipboard(JSON.stringify(cleanRecords, null, 2));
}, [getTargets, copyToClipboard]);
const handleCopyCsv = useCallback((record: any) => {
const records = getTargets(record);
const lines = records.map((r: any) => {
const { key, ...vals } = r;
const values = Object.values(vals).map(v => v === null ? 'NULL' : `"${v}"`);
return values.join(',');
});
copyToClipboard(lines.join('\n'));
}, [getTargets, copyToClipboard]);
// ... (Filter Handlers)
const addFilter = () => {
setFilterConditions([...filterConditions, { id: nextFilterId, column: columnNames[0] || '', op: '=', value: '' }]);
setNextFilterId(nextFilterId + 1);
setShowFilter(true);
};
const updateFilter = (id: number, field: string, val: string) => {
setFilterConditions(prev => prev.map(c => c.id === id ? { ...c, [field]: val } : c));
};
const removeFilter = (id: number) => {
setFilterConditions(prev => prev.filter(c => c.id !== id));
};
const applyFilters = () => fetchData(1, pagination.pageSize);
const handleImport = async () => {
const conn = connections.find(c => c.id === tab.connectionId);
if (!conn) return;
const config = { ...conn.config, port: Number(conn.config.port), password: conn.config.password || "", database: conn.config.database || "", useSSH: conn.config.useSSH || false, ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" } };
const res = await ImportData(config as any, tab.dbName || '', tab.tableName || '');
if (res.success) { message.success(res.message); fetchData(); } else if (res.message !== "Cancelled") { message.error("Import Failed: " + res.message); }
};
const handleExport = async (format: string) => {
const conn = connections.find(c => c.id === tab.connectionId);
if (!conn) return;
const config = { ...conn.config, port: Number(conn.config.port), password: conn.config.password || "", database: conn.config.database || "", useSSH: conn.config.useSSH || false, ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" } };
const hide = message.loading(`Exporting as ${format.toUpperCase()}...`, 0);
const res = await ExportTable(config as any, tab.dbName || '', tab.tableName || '', format);
hide();
if (res.success) { message.success("Export Successful"); } else if (res.message !== "Cancelled") { message.error("Export Failed: " + res.message); }
};
const exportMenu: MenuProps['items'] = [
{ key: 'csv', label: 'CSV', onClick: () => handleExport('csv') },
{ key: 'xlsx', label: 'Excel (XLSX)', onClick: () => handleExport('xlsx') },
{ key: 'json', label: 'JSON', onClick: () => handleExport('json') },
{ key: 'md', label: 'Markdown', onClick: () => handleExport('md') },
];
const contextValue = useMemo(() => ({
selectedRowKeysRef,
displayDataRef,
handleCopyInsert,
handleCopyJson,
handleCopyCsv,
copyToClipboard
}), [handleCopyInsert, handleCopyJson, handleCopyCsv, copyToClipboard]);
const tableComponents = useMemo(() => ({
body: { cell: EditableCell, row: ContextMenuRow },
header: { cell: ResizableTitle }
}), []);
}, [tab, sortInfo, filterConditions]); // Initial load and re-load on sort/filter
return (
<div style={{ height: '100%', overflow: 'hidden', padding: 0, display: 'flex', flexDirection: 'column' }}>
{/* Toolbar */}
<div style={{ padding: '8px', borderBottom: '1px solid #eee', display: 'flex', gap: 8, alignItems: 'center' }}>
<Button icon={<ReloadOutlined />} onClick={() => fetchData()}></Button>
<Button icon={<ImportOutlined />} onClick={handleImport}></Button>
<Dropdown menu={{ items: exportMenu }}><Button icon={<ExportOutlined />}> <DownOutlined /></Button></Dropdown>
<div style={{ width: 1, background: '#eee', height: 20, margin: '0 8px' }} />
<Button icon={<PlusOutlined />} onClick={handleAddRow}></Button>
<Button icon={<DeleteOutlined />} danger disabled={selectedRowKeys.length === 0} onClick={handleDeleteSelected}></Button>
<div style={{ width: 1, background: '#eee', height: 20, margin: '0 8px' }} />
<Button icon={<SaveOutlined />} type="primary" disabled={!hasChanges} onClick={handleCommit}> ({addedRows.length + Object.keys(modifiedRows).length + deletedRowKeys.size})</Button>
{hasChanges && (<Button icon={<UndoOutlined />} onClick={() => fetchData()}></Button>)}
<div style={{ width: 1, background: '#eee', height: 20, margin: '0 8px' }} />
<Button icon={<FilterOutlined />} type={showFilter ? 'primary' : 'default'} onClick={() => { setShowFilter(!showFilter); if (filterConditions.length === 0 && !showFilter) addFilter(); }}></Button>
</div>
{/* Filter Panel */}
{showFilter && (
<div style={{ padding: '8px', background: '#f5f5f5', borderBottom: '1px solid #eee' }}>
{filterConditions.map(cond => (
<div key={cond.id} style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
<Select style={{ width: 150 }} value={cond.column} onChange={v => updateFilter(cond.id, 'column', v)} options={columnNames.map(c => ({ value: c, label: c }))} />
<Select style={{ width: 100 }} value={cond.op} onChange={v => updateFilter(cond.id, 'op', v)} options={[{ value: '=', label: '=' }, { value: 'LIKE', label: '包含' }]} />
<Input style={{ width: 200 }} value={cond.value} onChange={e => updateFilter(cond.id, 'value', e.target.value)} />
<Button icon={<CloseOutlined />} onClick={() => removeFilter(cond.id)} type="text" danger />
</div>
))}
<div style={{ display: 'flex', gap: 8 }}>
<Button type="dashed" onClick={addFilter} size="small" icon={<FilterOutlined />}>Add Condition</Button>
<Button type="primary" onClick={applyFilters} size="small">Apply</Button>
</div>
</div>
)}
<div style={{ flex: 1, overflow: 'hidden' }}>
<Form component={false} form={form}>
<DataContext.Provider value={contextValue}>
<EditableContext.Provider value={form}>
<Table
components={tableComponents}
dataSource={displayData}
columns={mergedColumns}
size="small"
scroll={{ x: Math.max(totalWidth, 1000), y: 'calc(100vh - 200px - 40px)' }}
loading={loading}
pagination={false}
onChange={handleTableChange}
bordered
rowSelection={{
selectedRowKeys,
onChange: setSelectedRowKeys,
}}
rowClassName={(record) => {
if (addedRows.includes(record)) return 'row-added';
if (modifiedRows[record.key]) return 'row-modified';
return '';
}}
onRow={(record) => ({ record } as any)}
/>
</EditableContext.Provider>
</DataContext.Provider>
</Form>
</div>
{/* Pagination Bar */}
<div style={{ padding: '8px', borderTop: '1px solid #eee', display: 'flex', justifyContent: 'flex-end', background: '#fff' }}>
<Pagination
current={pagination.current}
pageSize={pagination.pageSize}
total={pagination.total}
showTotal={(total, range) => `当前 ${range[1] - range[0] + 1} 条 / 共 ${total}`}
showSizeChanger
pageSizeOptions={['100', '200', '500', '1000']}
onChange={handlePaginationChange}
size="small"
/>
</div>
<style>{`
.row-added td { background-color: #f6ffed !important; }
.row-modified td { background-color: #e6f7ff !important; }
`}</style>
</div>
<DataGrid
data={data}
columnNames={columnNames}
loading={loading}
tableName={tab.tableName}
dbName={tab.dbName}
connectionId={tab.connectionId}
pkColumns={pkColumns}
onReload={handleReload}
onSort={handleSort}
onPageChange={handlePageChange}
pagination={pagination}
showFilter={showFilter}
onToggleFilter={handleToggleFilter}
onApplyFilter={handleApplyFilter}
/>
);
};

View File

@@ -1,20 +1,31 @@
import React, { useState, useEffect, useRef } from 'react';
import Editor, { OnMount } from '@monaco-editor/react';
import { Button, Table, message, Modal, Input, Form, Dropdown, MenuProps, Tooltip } from 'antd';
import { Button, message, Modal, Input, Form, Dropdown, MenuProps, Tooltip, Select } from 'antd';
import { PlayCircleOutlined, SaveOutlined, FormatPainterOutlined, SettingOutlined } from '@ant-design/icons';
import { format } from 'sql-formatter';
import { TabData } from '../types';
import { TabData, ColumnDefinition } from '../types';
import { useStore } from '../store';
import { MySQLQuery, DBGetTables, DBGetAllColumns } from '../../wailsjs/go/main/App';
import { MySQLQuery, DBGetTables, DBGetAllColumns, MySQLGetDatabases, DBGetColumns } from '../../wailsjs/go/app/App';
import DataGrid from './DataGrid';
const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
const [query, setQuery] = useState(tab.query || 'SELECT * FROM ');
// DataGrid State
const [results, setResults] = useState<any[]>([]);
const [columns, setColumns] = useState<any[]>([]);
const [columnNames, setColumnNames] = useState<string[]>([]);
const [pkColumns, setPkColumns] = useState<string[]>([]);
const [targetTableName, setTargetTableName] = useState<string | undefined>(undefined);
const [loading, setLoading] = useState(false);
const [isSaveModalOpen, setIsSaveModalOpen] = useState(false);
const [saveForm] = Form.useForm();
// Database Selection
const [currentConnectionId, setCurrentConnectionId] = useState<string>(tab.connectionId);
const [currentDb, setCurrentDb] = useState<string>(tab.dbName || '');
const [dbList, setDbList] = useState<string[]>([]);
// Resizing state
const [editorHeight, setEditorHeight] = useState(300);
const editorRef = useRef<any>(null);
@@ -31,16 +42,44 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
// If opening a saved query, load its SQL
useEffect(() => {
if (tab.query) {
setQuery(tab.query);
}
if (tab.query) setQuery(tab.query);
}, [tab.query]);
// Fetch Database List
useEffect(() => {
const fetchDbs = async () => {
const conn = connections.find(c => c.id === currentConnectionId);
if (!conn) return;
const config = {
...conn.config,
port: Number(conn.config.port),
password: conn.config.password || "",
database: conn.config.database || "",
useSSH: conn.config.useSSH || false,
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
};
const res = await MySQLGetDatabases(config as any);
if (res.success && Array.isArray(res.data)) {
const dbs = res.data.map((row: any) => row.Database || row.database);
setDbList(dbs);
if (!currentDb) {
if (conn.config.database) setCurrentDb(conn.config.database);
else if (dbs.length > 0 && dbs[0] !== 'information_schema') setCurrentDb(dbs[0]);
}
} else {
setDbList([]);
}
};
fetchDbs();
}, [currentConnectionId, connections, currentDb]);
// Fetch Metadata for Autocomplete
useEffect(() => {
const fetchMetadata = async () => {
const conn = connections.find(c => c.id === tab.connectionId);
if (!conn) return;
const conn = connections.find(c => c.id === currentConnectionId);
if (!conn || !currentDb) return;
const config = {
...conn.config,
@@ -51,26 +90,25 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
};
const dbName = tab.dbName || conn.config.database || "";
// Fetch Tables
const resTables = await DBGetTables(config as any, dbName);
const resTables = await DBGetTables(config as any, currentDb);
if (resTables.success && Array.isArray(resTables.data)) {
// res.data is [{Table: "name"}, ...]
const tableNames = resTables.data.map((row: any) => Object.values(row)[0] as string);
tablesRef.current = tableNames;
} else {
tablesRef.current = [];
}
// Fetch All Columns (Optimized for autocomplete)
if (config.type === 'mysql' || !config.type) {
const resCols = await DBGetAllColumns(config as any, dbName);
const resCols = await DBGetAllColumns(config as any, currentDb);
if (resCols.success && Array.isArray(resCols.data)) {
allColumnsRef.current = resCols.data;
} else {
allColumnsRef.current = [];
}
}
};
fetchMetadata();
}, [tab.connectionId, tab.dbName, connections]);
}, [currentConnectionId, currentDb, connections]);
// Handle Resizing
const handleMouseDown = (e: React.MouseEvent) => {
@@ -98,7 +136,6 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
editorRef.current = editor;
monacoRef.current = monaco;
// SQL Autocomplete
monaco.languages.registerCompletionItemProvider('sql', {
provideCompletionItems: (model: any, position: any) => {
const word = model.getWordUntilPosition(position);
@@ -109,7 +146,6 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
endColumn: word.endColumn,
};
// Simple Heuristic: Find tables mentioned in the query
const tableRegex = /(?:FROM|JOIN|UPDATE|INTO)\s+[`"]?(\w+)[`"]?/gi;
const foundTables = new Set<string>();
let match;
@@ -118,7 +154,6 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
foundTables.add(match[1]);
}
// Columns suggestion
const relevantColumns = allColumnsRef.current
.filter(c => foundTables.has(c.tableName))
.map(c => ({
@@ -131,14 +166,12 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
}));
const suggestions = [
// Keywords
...['SELECT', 'FROM', 'WHERE', 'LIMIT', 'INSERT', 'UPDATE', 'DELETE', 'JOIN', 'LEFT', 'RIGHT', 'INNER', 'OUTER', 'ON', 'GROUP BY', 'ORDER BY', 'AS', 'AND', 'OR', 'NOT', 'NULL', 'IS', 'IN', 'VALUES', 'SET', 'CREATE', 'TABLE', 'DROP', 'ALTER', 'Add', 'MODIFY', 'CHANGE', 'COLUMN', 'KEY', 'PRIMARY', 'FOREIGN', 'REFERENCES', 'CONSTRAINT', 'DEFAULT', 'AUTO_INCREMENT', 'COMMENT', 'SHOW', 'DESCRIBE', 'EXPLAIN'].map(k => ({
label: k,
kind: monaco.languages.CompletionItemKind.Keyword,
insertText: k,
range
})),
// Tables
...tablesRef.current.map(t => ({
label: t,
kind: monaco.languages.CompletionItemKind.Class,
@@ -146,7 +179,6 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
detail: 'Table',
range
})),
// Columns
...relevantColumns
];
return { suggestions };
@@ -180,8 +212,12 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
const handleRun = async () => {
if (!query.trim()) return;
if (!currentDb) {
message.error("请先选择数据库");
return;
}
setLoading(true);
const conn = connections.find(c => c.id === tab.connectionId);
const conn = connections.find(c => c.id === currentConnectionId);
if (!conn) {
message.error("Connection not found");
setLoading(false);
@@ -196,30 +232,42 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
useSSH: conn.config.useSSH || false,
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
};
const res = await MySQLQuery(config as any, tab.dbName || conn.config.database || '', query);
// Detect Simple Table Query
let simpleTableName: string | undefined = undefined;
let primaryKeys: string[] = [];
// Naive regex to detect SELECT * FROM table
const tableMatch = query.match(/^\s*SELECT\s+\*\s+FROM\s+[`"]?(\w+)[`"]?\s*(?:WHERE.*)?(?:ORDER BY.*)?(?:LIMIT.*)?$/i);
if (tableMatch) {
simpleTableName = tableMatch[1];
// Fetch PKs for editing
const resCols = await DBGetColumns(config as any, currentDb, simpleTableName);
if (resCols.success) {
primaryKeys = (resCols.data as ColumnDefinition[]).filter(c => c.key === 'PRI').map(c => c.name);
}
}
setTargetTableName(simpleTableName);
setPkColumns(primaryKeys);
const res = await MySQLQuery(config as any, currentDb, query);
if (res.success) {
if (Array.isArray(res.data)) {
if (res.data.length > 0) {
const cols = Object.keys(res.data[0]).map(key => ({
title: key,
dataIndex: key,
key: key,
ellipsis: true,
render: (text: any) => typeof text === 'object' ? JSON.stringify(text) : String(text),
}));
setColumns(cols);
const cols = Object.keys(res.data[0]);
setColumnNames(cols);
setResults(res.data.map((row: any, i: number) => ({ ...row, key: i })));
} else {
message.info('查询执行成功,但没有返回结果。');
setResults([]);
setColumns([]);
setColumnNames([]);
}
} else {
// Handle update/insert results
const affected = (res.data as any).affectedRows;
message.success(`受影响行数: ${affected}`);
setResults([]);
setColumnNames([]);
}
} else {
message.error(res.message);
@@ -234,20 +282,38 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
id: tab.id.startsWith('saved-') ? tab.id : `saved-${Date.now()}`,
name: values.name,
sql: query,
connectionId: tab.connectionId,
dbName: tab.dbName || '',
connectionId: currentConnectionId,
dbName: currentDb || tab.dbName || '',
createdAt: Date.now()
});
message.success('查询已保存!');
setIsSaveModalOpen(false);
} catch (e) {
// validation failed
}
};
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<div style={{ padding: '8px', borderBottom: '1px solid #eee', display: 'flex', gap: '8px', flexShrink: 0 }}>
<div style={{ padding: '8px', borderBottom: '1px solid #eee', display: 'flex', gap: '8px', flexShrink: 0, alignItems: 'center' }}>
<Select
style={{ width: 150 }}
placeholder="选择连接"
value={currentConnectionId}
onChange={(val) => {
setCurrentConnectionId(val);
setCurrentDb('');
}}
options={connections.map(c => ({ label: c.name, value: c.id }))}
showSearch
/>
<Select
style={{ width: 200 }}
placeholder="选择数据库"
value={currentDb}
onChange={setCurrentDb}
options={dbList.map(db => ({ label: db, value: db }))}
showSearch
/>
<Button type="primary" icon={<PlayCircleOutlined />} onClick={handleRun} loading={loading}>
</Button>
@@ -268,7 +334,6 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
</Button.Group>
</div>
{/* Editor Area - Resizable */}
<div style={{ height: editorHeight, minHeight: '100px', borderBottom: '1px solid #eee' }}>
<Editor
height="100%"
@@ -286,7 +351,6 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
/>
</div>
{/* Resize Handle */}
<div
onMouseDown={handleMouseDown}
style={{
@@ -299,16 +363,17 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
title="拖动调整高度"
/>
{/* Results Area - Fills remaining space */}
<div style={{ flex: 1, overflow: 'hidden', padding: 10, display: 'flex', flexDirection: 'column' }}>
<Table
dataSource={results}
columns={columns}
size="small"
scroll={{ x: 'max-content', y: 'calc(100% - 40px)' }}
<div style={{ flex: 1, overflow: 'hidden', padding: 0, display: 'flex', flexDirection: 'column' }}>
<DataGrid
data={results}
columnNames={columnNames}
loading={loading}
pagination={false}
style={{ flex: 1, overflow: 'hidden' }}
tableName={targetTableName} // Pass table name only if detection succeeded
dbName={currentDb}
connectionId={currentConnectionId}
pkColumns={pkColumns}
onReload={handleRun}
readOnly={!targetTableName} // Read-only if not a simple table query
/>
</div>
@@ -330,4 +395,4 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
);
};
export default QueryEditor;
export default QueryEditor;

View File

@@ -24,7 +24,7 @@ import {
} from '@ant-design/icons';
import { useStore } from '../store';
import { SavedConnection } from '../types';
import { MySQLGetDatabases, MySQLGetTables, MySQLShowCreateTable, ExportTable, OpenSQLFile, CreateDatabase } from '../../wailsjs/go/main/App';
import { MySQLGetDatabases, MySQLGetTables, MySQLShowCreateTable, ExportTable, OpenSQLFile, CreateDatabase } from '../../wailsjs/go/app/App';
const { Search } = Input;
@@ -39,7 +39,7 @@ interface TreeNode {
}
const Sidebar: React.FC = () => {
const { connections, savedQueries, addTab } = useStore();
const { connections, savedQueries, addTab, setActiveContext } = useStore();
const [treeData, setTreeData] = useState<TreeNode[]>([]);
const [searchValue, setSearchValue] = useState('');
const [expandedKeys, setExpandedKeys] = useState<React.Key[]>([]);
@@ -50,6 +50,27 @@ const Sidebar: React.FC = () => {
const [createDbForm] = Form.useForm();
const [targetConnection, setTargetConnection] = useState<any>(null);
useEffect(() => {
// Refresh queries for expanded databases
const findNode = (nodes: TreeNode[], k: React.Key): TreeNode | null => {
for (const node of nodes) {
if (node.key === k) return node;
if (node.children) {
const res = findNode(node.children, k);
if (res) return res;
}
}
return null;
};
expandedKeys.forEach(key => {
const node = findNode(treeData, key);
if (node && node.type === 'database') {
loadTables(node);
}
});
}, [savedQueries]);
useEffect(() => {
setTreeData(connections.map(conn => ({
title: conn.name,
@@ -230,9 +251,24 @@ const Sidebar: React.FC = () => {
};
const onSelect = (keys: React.Key[], info: any) => {
if (!info.node.selected) return;
if (!info.node.selected) {
setActiveContext(null);
return;
}
const { type, dataRef } = info.node;
const { type, dataRef, key, title } = info.node;
// Update active context
if (type === 'connection') {
setActiveContext({ connectionId: key, dbName: '' });
} else if (type === 'database') {
setActiveContext({ connectionId: dataRef.id, dbName: title });
} else if (type === 'table') {
setActiveContext({ connectionId: dataRef.id, dbName: dataRef.dbName });
} else if (type === 'saved-query') {
setActiveContext({ connectionId: dataRef.connectionId, dbName: dataRef.dbName });
}
if (type === 'folder-columns') openDesign(info.node, 'columns', true);
else if (type === 'folder-indexes') openDesign(info.node, 'indexes', true);
else if (type === 'folder-fks') openDesign(info.node, 'foreignKeys', true);
@@ -315,7 +351,7 @@ const Sidebar: React.FC = () => {
};
const handleRunSQLFile = async (node: any) => {
const res = await (window as any).go.main.App.OpenSQLFile();
const res = await (window as any).go.app.App.OpenSQLFile();
if (res.success) {
const sqlContent = res.data;
const { dbName, id } = node.dataRef;

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { useMemo } from 'react';
import { Tabs, Button } from 'antd';
import { useStore } from '../store';
import DataViewer from './DataViewer';
@@ -18,7 +18,7 @@ const TabManager: React.FC = () => {
}
};
const items = tabs.map(tab => {
const items = useMemo(() => tabs.map(tab => {
let content;
if (tab.type === 'query') {
content = <QueryEditor tab={tab} />;
@@ -33,18 +33,24 @@ const TabManager: React.FC = () => {
key: tab.id,
children: content,
};
});
}), [tabs]);
return (
<Tabs
type="editable-card"
onChange={onChange}
activeKey={activeTabId || undefined}
onEdit={onEdit}
items={items}
style={{ height: '100%' }}
hideAdd
/>
<>
<style>{`
.ant-tabs-content { height: 100%; }
.ant-tabs-tabpane { height: 100%; }
`}</style>
<Tabs
type="editable-card"
onChange={onChange}
activeKey={activeTabId || undefined}
onEdit={onEdit}
items={items}
style={{ height: '100%' }}
hideAdd
/>
</>
);
};

View File

@@ -7,7 +7,7 @@ import { CSS } from '@dnd-kit/utilities';
import { Resizable } from 'react-resizable';
import { TabData, ColumnDefinition, IndexDefinition, ForeignKeyDefinition, TriggerDefinition } from '../types';
import { useStore } from '../store';
import { DBGetColumns, DBGetIndexes, MySQLQuery, DBGetForeignKeys, DBGetTriggers, DBShowCreateTable } from '../../wailsjs/go/main/App';
import { DBGetColumns, DBGetIndexes, MySQLQuery, DBGetForeignKeys, DBGetTriggers, DBShowCreateTable } from '../../wailsjs/go/app/App';
// Need styles for react-resizable
import 'react-resizable/css/styles.css';
@@ -74,6 +74,11 @@ const ResizableTitle = (props: any) => {
className="react-resizable-handle"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
}}
onMouseDown={(e) => {
e.stopPropagation();
e.preventDefault(); // Prevent text selection and focus hijacking
}}
style={{
position: 'absolute',
@@ -87,7 +92,7 @@ const ResizableTitle = (props: any) => {
/>
}
onResize={onResize}
draggableOpts={{ enableUserSelectHack: false }}
draggableOpts={{ enableUserSelectHack: true }}
>
<th {...restProps} style={{ ...restProps.style, position: 'relative' }} />
</Resizable>
@@ -263,16 +268,24 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
setTableColumns(initialCols);
}, [readOnly]); // Re-create if readOnly changes
const rafRef = React.useRef<number | null>(null);
// Resize Handler
const handleResize = (index: number) => (_: React.SyntheticEvent, { size }: { size: { width: number } }) => {
setTableColumns((columns) => {
const nextColumns = [...columns];
nextColumns[index] = {
...nextColumns[index],
width: size.width,
};
return nextColumns;
});
if (rafRef.current) {
cancelAnimationFrame(rafRef.current);
}
rafRef.current = requestAnimationFrame(() => {
setTableColumns((columns) => {
const nextColumns = [...columns];
nextColumns[index] = {
...nextColumns[index],
width: size.width,
};
return nextColumns;
});
rafRef.current = null;
});
};
const fetchData = async () => {
@@ -587,8 +600,8 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
)}
{!readOnly && <Button icon={<SaveOutlined />} type="primary" onClick={generateDDL}></Button>}
{!isNewTable && <Button icon={<ReloadOutlined />} onClick={fetchData}></Button>}
<div style={{ flex: 1 }} />
{!readOnly && <Button icon={<PlusOutlined />} onClick={handleAddColumn}></Button>}
<div style={{ flex: 1 }} />
</div>
<Tabs
activeKey={activeKey}