diff --git a/frontend/index.html b/frontend/index.html index 8d845b0..127af4b 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,7 +2,7 @@ - + GoNavi @@ -10,4 +10,4 @@
- \ No newline at end of file + diff --git a/frontend/public/logo.svg b/frontend/public/logo.svg new file mode 100644 index 0000000..a72f0e8 --- /dev/null +++ b/frontend/public/logo.svg @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f5b630f..b7d5cfd 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -17,7 +17,14 @@ function App() { const [isModalOpen, setIsModalOpen] = useState(false); const [isSyncModalOpen, setIsSyncModalOpen] = useState(false); const [editingConnection, setEditingConnection] = useState(null); - const { darkMode, toggleDarkMode, addTab, activeContext, connections, addConnection, tabs, activeTabId } = useStore(); + const darkMode = useStore(state => state.darkMode); + const toggleDarkMode = useStore(state => state.toggleDarkMode); + const addTab = useStore(state => state.addTab); + const activeContext = useStore(state => state.activeContext); + const connections = useStore(state => state.connections); + const addConnection = useStore(state => state.addConnection); + const tabs = useStore(state => state.tabs); + const activeTabId = useStore(state => state.activeTabId); const handleNewQuery = () => { let connId = activeContext?.connectionId || ''; diff --git a/frontend/src/components/ConnectionModal.tsx b/frontend/src/components/ConnectionModal.tsx index f986b31..26f3152 100644 --- a/frontend/src/components/ConnectionModal.tsx +++ b/frontend/src/components/ConnectionModal.tsx @@ -1,8 +1,8 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import { Modal, Form, Input, InputNumber, Button, message, Checkbox, Divider, Select, Alert, Card, Row, Col, Typography, Collapse } from 'antd'; import { DatabaseOutlined, ConsoleSqlOutlined, FileTextOutlined, CloudServerOutlined, AppstoreAddOutlined, CloudOutlined } from '@ant-design/icons'; import { useStore } from '../store'; -import { DBConnect, DBGetDatabases, TestConnection, RedisConnect } from '../../wailsjs/go/app/App'; +import { DBGetDatabases, TestConnection, RedisConnect } from '../../wailsjs/go/app/App'; import { SavedConnection } from '../types'; const { Meta } = Card; @@ -17,6 +17,8 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal const [testResult, setTestResult] = useState<{ type: 'success' | 'error', message: string } | null>(null); const [dbList, setDbList] = useState([]); const [redisDbList, setRedisDbList] = useState([]); // Redis databases 0-15 + const testInFlightRef = useRef(false); + const testTimerRef = useRef(null); const addConnection = useStore((state) => state.addConnection); const updateConnection = useStore((state) => state.updateConnection); @@ -64,6 +66,15 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal } }, [open, initialValues]); + useEffect(() => { + return () => { + if (testTimerRef.current !== null) { + window.clearTimeout(testTimerRef.current); + testTimerRef.current = null; + } + }; + }, []); + const handleOk = async () => { try { const values = await form.validateFields(); @@ -71,45 +82,46 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal const config = await buildConfig(values); - // Use different API for Redis const isRedisType = values.type === 'redis'; - const res = isRedisType - ? await RedisConnect(config as any) - : await DBConnect(config as any); + const newConn = { + id: initialValues ? initialValues.id : Date.now().toString(), + name: values.name || (values.type === 'sqlite' ? 'SQLite DB' : (values.type === 'redis' ? `Redis ${values.host}` : values.host)), + config: config, + includeDatabases: values.includeDatabases, + includeRedisDatabases: isRedisType ? values.includeRedisDatabases : undefined + }; + + if (initialValues) { + updateConnection(newConn); + message.success('配置已更新(未连接)'); + } else { + addConnection(newConn); + message.success('配置已保存(未连接)'); + } setLoading(false); - - if (res.success) { - const newConn = { - id: initialValues ? initialValues.id : Date.now().toString(), - name: values.name || (values.type === 'sqlite' ? 'SQLite DB' : (values.type === 'redis' ? `Redis ${values.host}` : values.host)), - config: config, - includeDatabases: values.includeDatabases, - includeRedisDatabases: isRedisType ? values.includeRedisDatabases : undefined - }; - - if (initialValues) { - updateConnection(newConn); - message.success('连接已更新!'); - } else { - addConnection(newConn); - message.success('连接已保存!'); - } - - form.resetFields(); - setUseSSH(false); - setDbType('mysql'); - setStep(1); - onClose(); - } else { - message.error('连接失败: ' + res.message); - } + form.resetFields(); + setUseSSH(false); + setDbType('mysql'); + setStep(1); + onClose(); } catch (e) { setLoading(false); } }; + const requestTest = () => { + if (loading) return; + if (testTimerRef.current !== null) return; + testTimerRef.current = window.setTimeout(() => { + testTimerRef.current = null; + handleTest(); + }, 0); + }; + const handleTest = async () => { + if (testInFlightRef.current) return; + testInFlightRef.current = true; try { const values = await form.validateFields(); setLoading(true); @@ -122,7 +134,6 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal ? await RedisConnect(config as any) : await TestConnection(config as any); - setLoading(false); if (res.success) { setTestResult({ type: 'success', message: res.message }); if (isRedisType) { @@ -140,6 +151,9 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal setTestResult({ type: 'error', message: "测试失败: " + res.message }); } } catch (e) { + // ignore + } finally { + testInFlightRef.current = false; setLoading(false); } }; @@ -254,7 +268,10 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal <>
- + {!isSqlite && ( @@ -371,7 +388,7 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal } return [ !initialValues && , - , + , , ]; diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index 04b1f37..f15630d 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -45,6 +45,35 @@ const toFormText = (val: any): string => { return toEditableText(val); }; +const INLINE_EDIT_MAX_CHARS = 2000; + +const shouldOpenModalEditor = (val: any): boolean => { + if (val === null || val === undefined) return false; + if (typeof val === 'string') { + return val.length > INLINE_EDIT_MAX_CHARS || val.includes('\n'); + } + if (typeof val === 'object') { + return true; + } + return false; +}; + +const getCellFieldName = (record: Item, dataIndex: string) => { + const rowKey = record?.[GONAVI_ROW_KEY]; + if (rowKey === undefined || rowKey === null) return dataIndex; + return [String(rowKey), dataIndex]; +}; + +const setCellFieldValue = (form: any, fieldName: string | (string | number)[], value: any) => { + if (!form) return; + if (Array.isArray(fieldName)) { + const [rowKey, colKey] = fieldName; + form.setFieldsValue({ [rowKey]: { [colKey]: value } }); + return; + } + form.setFieldsValue({ [fieldName]: value }); +}; + const looksLikeJsonText = (text: string): boolean => { const raw = (text || '').trim(); if (!raw) return false; @@ -96,6 +125,9 @@ const ResizableTitle = (props: any) => { // --- Contexts --- const EditableContext = React.createContext(null); +const CellContextMenuContext = React.createContext<{ + showMenu: (e: React.MouseEvent, record: Item, dataIndex: string, title: React.ReactNode) => void; +} | null>(null); const DataContext = React.createContext<{ selectedRowKeysRef: React.MutableRefObject; displayDataRef: React.MutableRefObject; @@ -134,7 +166,9 @@ const EditableCell: React.FC = React.memo(({ }) => { const [editing, setEditing] = useState(false); const inputRef = useRef(null); + const cellRef = useRef(null); const form = useContext(EditableContext); + const cellContextMenuContext = useContext(CellContextMenuContext); useEffect(() => { if (editing) { @@ -146,29 +180,73 @@ const EditableCell: React.FC = React.memo(({ setEditing(!editing); const raw = record[dataIndex]; const initialValue = typeof raw === 'string' ? normalizeDateTimeString(raw) : raw; - form.setFieldsValue({ [dataIndex]: initialValue }); + const fieldName = getCellFieldName(record, dataIndex); + setCellFieldValue(form, fieldName, initialValue); }; const save = async () => { try { if (!form) return; - const values = await form.validateFields([dataIndex]); + const fieldName = getCellFieldName(record, dataIndex); + await form.validateFields([fieldName]); + const nextValue = form.getFieldValue(fieldName); + const prevText = toFormText(record?.[dataIndex]); + const nextText = toFormText(nextValue); toggleEdit(); - handleSave({ ...record, ...values }); + // 仅当值发生变化时才标记为修改,避免“双击-失焦”导致整行进入 modified 状态(蓝色高亮不清除)。 + if (nextText !== prevText) { + handleSave({ ...record, [dataIndex]: nextValue }); + } + // 保存后移除焦点 + if (inputRef.current) { + inputRef.current.blur(); + } } catch (errInfo) { console.log('Save failed:', errInfo); } }; + const handleContextMenu = (e: React.MouseEvent) => { + if (!editable) return; + e.preventDefault(); + if (cellContextMenuContext) { + cellContextMenuContext.showMenu(e, record, dataIndex, title); + } + }; + let childNode = children; if (editable) { childNode = editing ? ( - - + + { + // Enter 编辑态时直接全选,便于快速替换;同时避免双击在 input 内冒泡导致关闭编辑态。 + try { + (e.target as HTMLInputElement)?.select?.(); + } catch { + // ignore + } + }} + onDoubleClick={(e) => { + e.stopPropagation(); + try { + (e.target as HTMLInputElement)?.select?.(); + } catch { + // ignore + } + }} + /> ) : ( -
+
{children}
); @@ -176,19 +254,20 @@ const EditableCell: React.FC = React.memo(({ const handleDoubleClick = () => { if (!editable) return; + // 已在编辑态时再次双击不应退出编辑;双击应支持在 Input 内进行全选。 + if (editing) return; + const raw = record?.[dataIndex]; + if (focusCell && shouldOpenModalEditor(raw)) { + focusCell(record, dataIndex, title); + return; + } toggleEdit(); }; - const handleClick = (e: React.MouseEvent) => { - restProps?.onClick?.(e); - if (!editable) return; - if (typeof focusCell === 'function') focusCell(record, dataIndex, title); - }; - return ( {childNode} @@ -273,7 +352,7 @@ const DataGrid: React.FC = ({ data, columnNames, loading, tableName, dbName, connectionId, pkColumns = [], readOnly = false, onReload, onSort, onPageChange, pagination, showFilter, onToggleFilter, onApplyFilter }) => { - const { connections } = useStore(); + const connections = useStore(state => state.connections); const addSqlLog = useStore(state => state.addSqlLog); const darkMode = useStore(state => state.darkMode); const selectionColumnWidth = 46; @@ -285,14 +364,77 @@ const DataGrid: React.FC = ({ const [cellEditorIsJson, setCellEditorIsJson] = useState(false); const [cellEditorMeta, setCellEditorMeta] = useState<{ record: Item; dataIndex: string; title: string } | null>(null); const cellEditorApplyRef = useRef<((val: string) => void) | null>(null); - const [activeCell, setActiveCell] = useState<{ rowKey: string; dataIndex: string; title: string } | null>(null); const [rowEditorOpen, setRowEditorOpen] = useState(false); const [rowEditorRowKey, setRowEditorRowKey] = useState(''); const rowEditorBaseRef = useRef>({}); const rowEditorDisplayRef = useRef>({}); const rowEditorNullColsRef = useRef>(new Set()); const [rowEditorForm] = Form.useForm(); - + + // Cell Context Menu State + const [cellContextMenu, setCellContextMenu] = useState<{ + visible: boolean; + x: number; + y: number; + record: Item | null; + dataIndex: string; + title: string; + }>({ + visible: false, + x: 0, + y: 0, + record: null, + dataIndex: '', + title: '', + }); + const [cellSetValueInput, setCellSetValueInput] = useState(''); + const containerRef = useRef(null); + const pendingScrollToBottomRef = useRef(false); + + const scrollTableBodyToBottom = useCallback(() => { + const root = containerRef.current; + if (!root) return; + const body = root.querySelector('.ant-table-body') as HTMLElement | null; + if (!body) return; + body.scrollTop = body.scrollHeight; + }, []); + + // Close cell context menu when clicking outside + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (cellContextMenu.visible) { + setCellContextMenu(prev => ({ ...prev, visible: false })); + } + // Remove focus from any focused cell when clicking outside the table + const target = e.target as HTMLElement; + const tableContainer = containerRef.current; + if (tableContainer && !tableContainer.contains(target)) { + // Remove focus from any input elements in the table + const focusedElement = document.activeElement as HTMLElement; + if (focusedElement && focusedElement.tagName === 'INPUT' && tableContainer.contains(focusedElement)) { + focusedElement.blur(); + } + } + }; + document.addEventListener('click', handleClickOutside); + return () => document.removeEventListener('click', handleClickOutside); + }, [cellContextMenu.visible]); + + const showCellContextMenu = useCallback((e: React.MouseEvent, record: Item, dataIndex: string, title: React.ReactNode) => { + e.preventDefault(); + e.stopPropagation(); + const titleText = typeof title === 'string' ? title : (typeof title === 'number' ? String(title) : String(dataIndex)); + setCellContextMenu({ + visible: true, + x: e.clientX, + y: e.clientY, + record, + dataIndex, + title: titleText, + }); + setCellSetValueInput(toFormText(record[dataIndex])); + }, []); + // Helper to export specific data const exportData = async (rows: any[], format: string) => { const hide = message.loading(`正在导出 ${rows.length} 条数据...`, 0); @@ -327,10 +469,9 @@ const DataGrid: React.FC = ({ setCellEditorOpen(true); cellEditorApplyRef.current = typeof onApplyValue === 'function' ? onApplyValue : null; }, []); - + // Dynamic Height const [tableHeight, setTableHeight] = useState(500); - const containerRef = useRef(null); useEffect(() => { const el = containerRef.current; @@ -382,13 +523,22 @@ const DataGrid: React.FC = ({ useEffect(() => { selectedRowKeysRef.current = selectedRowKeys; }, [selectedRowKeys]); + useEffect(() => { + if (!pendingScrollToBottomRef.current) return; + pendingScrollToBottomRef.current = false; + // 等待 Table 渲染出新增行后再滚动到底部(virtual 模式也适用) + requestAnimationFrame(() => { + scrollTableBodyToBottom(); + requestAnimationFrame(() => scrollTableBodyToBottom()); + }); + }, [addedRows.length, scrollTableBodyToBottom]); + // Reset local state when data source likely changes (e.g. tableName change) useEffect(() => { setAddedRows([]); setModifiedRows({}); setDeletedRowKeys(new Set()); setSelectedRowKeys([]); - setActiveCell(null); setRowEditorOpen(false); setRowEditorRowKey(''); rowEditorBaseRef.current = {}; @@ -550,6 +700,18 @@ const DataGrid: React.FC = ({ } }, [addedRows]); + const handleCellSetNull = useCallback(() => { + if (!cellContextMenu.record) return; + handleCellSave({ ...cellContextMenu.record, [cellContextMenu.dataIndex]: null }); + setCellContextMenu(prev => ({ ...prev, visible: false })); + }, [cellContextMenu, handleCellSave]); + + const handleCellSetValue = useCallback(() => { + if (!cellContextMenu.record) return; + handleCellSave({ ...cellContextMenu.record, [cellContextMenu.dataIndex]: cellSetValueInput }); + setCellContextMenu(prev => ({ ...prev, visible: false })); + }, [cellContextMenu, cellSetValueInput, handleCellSave]); + const handleCellEditorSave = useCallback(() => { if (!cellEditorMeta) return; const apply = cellEditorApplyRef.current; @@ -586,13 +748,6 @@ const DataGrid: React.FC = ({ }); }, [displayData, modifiedRows]); - const focusCell = useCallback((record: Item, dataIndex: string, title: React.ReactNode) => { - const k = record?.[GONAVI_ROW_KEY]; - if (k === undefined) return; - const titleText = typeof title === 'string' ? title : (typeof title === 'number' ? String(title) : String(dataIndex)); - setActiveCell({ rowKey: rowKeyStr(k), dataIndex, title: titleText }); - }, [rowKeyStr]); - const closeRowEditor = useCallback(() => { setRowEditorOpen(false); setRowEditorRowKey(''); @@ -610,9 +765,9 @@ const DataGrid: React.FC = ({ } const keyStr = - selectedRowKeys.length === 1 ? rowKeyStr(selectedRowKeys[0]) : activeCell?.rowKey; + selectedRowKeys.length === 1 ? rowKeyStr(selectedRowKeys[0]) : undefined; if (!keyStr) { - message.info('请先选择一行(勾选一行或点击任意单元格)'); + message.info('请先选择一行(勾选复选框)'); return; } @@ -646,7 +801,7 @@ const DataGrid: React.FC = ({ rowEditorForm.setFieldsValue(displayMap); setRowEditorRowKey(keyStr); setRowEditorOpen(true); - }, [readOnly, tableName, selectedRowKeys, activeCell, mergedDisplayData, data, addedRows, columnNames, rowEditorForm, rowKeyStr]); + }, [readOnly, tableName, selectedRowKeys, mergedDisplayData, data, addedRows, columnNames, rowEditorForm, rowKeyStr]); const openRowEditorFieldEditor = useCallback((dataIndex: string) => { if (!dataIndex) return; @@ -695,12 +850,16 @@ const DataGrid: React.FC = ({ title: key, dataIndex: key, key: key, - ellipsis: true, - width: columnWidths[key] || 200, - sorter: !!onSort, + // 不使用 ellipsis,避免 Ant Design 的 Tooltip 展开行为 + 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), + render: (text: any) => ( +
+ {formatCellValue(text)} +
+ ), onHeaderCell: (column: any) => ({ width: column.width, onResizeStart: handleResizeStart(key), // Only need start @@ -718,18 +877,16 @@ const DataGrid: React.FC = ({ dataIndex: col.dataIndex, title: col.title, handleSave: handleCellSave, - focusCell, - className: (activeCell && rowKeyStr(record?.[GONAVI_ROW_KEY]) === activeCell.rowKey && col.dataIndex === activeCell.dataIndex) - ? 'gonavi-active-cell' - : undefined, + focusCell: openCellEditor, }), }; - }), [columns, handleCellSave, openCellEditor, focusCell, activeCell, rowKeyStr]); + }), [columns, handleCellSave, openCellEditor]); const handleAddRow = () => { const newKey = `new-${Date.now()}`; const newRow: any = { [GONAVI_ROW_KEY]: newKey }; columnNames.forEach(col => newRow[col] = ''); + pendingScrollToBottomRef.current = true; setAddedRows(prev => [...prev, newRow]); }; @@ -770,9 +927,24 @@ const DataGrid: React.FC = ({ const pkData: any = {}; if (pkColumns.length > 0) pkColumns.forEach(k => pkData[k] = originalRow[k]); else { const { [GONAVI_ROW_KEY]: _rowKey, ...rest } = originalRow; Object.assign(pkData, rest); } - - const { [GONAVI_ROW_KEY]: _rowKey, ...vals } = newRow; - updates.push({ keys: pkData, values: vals }); + + const hasRowKey = Object.prototype.hasOwnProperty.call(newRow as any, GONAVI_ROW_KEY); + let values: any = {}; + + if (!hasRowKey) { + values = { ...(newRow as any) }; + } else { + columnNames.forEach((col) => { + const nextVal = (newRow as any)?.[col]; + const prevVal = (originalRow as any)?.[col]; + const nextStr = toFormText(nextVal); + const prevStr = toFormText(prevVal); + if (nextStr !== prevStr) values[col] = nextVal; + }); + } + + if (Object.keys(values).length === 0) return; + updates.push({ keys: pkData, values }); }); if (inserts.length === 0 && updates.length === 0 && deletes.length === 0) { @@ -809,7 +981,7 @@ const DataGrid: React.FC = ({ message: res.message, dbName }); - message.success("Changes committed successfully!"); + message.success("事务提交成功"); setAddedRows([]); setModifiedRows({}); setDeletedRowKeys(new Set()); @@ -824,7 +996,7 @@ const DataGrid: React.FC = ({ message: res.message, dbName }); - message.error("Commit failed: " + res.message); + message.error("提交失败: " + res.message); } }; @@ -1118,12 +1290,11 @@ const DataGrid: React.FC = ({
{/* Toolbar */}
- {onReload && } {tableName && } @@ -1135,7 +1306,7 @@ const DataGrid: React.FC = ({ , @@ -1290,7 +1461,7 @@ const DataGrid: React.FC = ({ open={cellEditorOpen} onCancel={closeCellEditor} width={960} - destroyOnClose + destroyOnHidden maskClosable={false} footer={[