diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index e6129ca..7a1a604 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -1,15 +1,16 @@ import React, { useState, useEffect, useRef, useContext, useMemo, useCallback } from 'react'; import { createPortal } from 'react-dom'; -import { Table, message, Input, Button, Dropdown, MenuProps, Form, Pagination, Select, Modal, Checkbox } from 'antd'; +import { Table, message, Input, Button, Dropdown, MenuProps, Form, Pagination, Select, Modal, Checkbox, Segmented } from 'antd'; 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 } from '@ant-design/icons'; import Editor from '@monaco-editor/react'; import { ImportData, ExportTable, ExportData, ExportQuery, ApplyChanges } from '../../wailsjs/go/app/App'; +import ImportPreviewModal from './ImportPreviewModal'; import { useStore } from '../store'; import { v4 as uuidv4 } from 'uuid'; import 'react-resizable/css/styles.css'; import { buildWhereSQL, escapeLiteral, quoteIdentPart, quoteQualifiedIdent, type FilterCondition } from '../utils/sql'; -import { normalizeOpacityForPlatform } from '../utils/appearance'; +import { isMacLikePlatform, normalizeOpacityForPlatform } from '../utils/appearance'; // --- Error Boundary --- interface DataGridErrorBoundaryState { @@ -72,15 +73,18 @@ const splitCellKey = (cellKey: string): { rowKey: string; colName: string } | nu }; }; -// Normalize RFC3339-like datetime strings to `YYYY-MM-DD HH:mm:ss` for display/editing. -// Also handle invalid datetime values like '0000-00-00 00:00:00' +// Normalize common datetime strings to `YYYY-MM-DD HH:mm:ss` for display/editing. +// Handles RFC3339 and Go-style datetime text like `2024-05-13 08:32:47 +0800 CST`. +// Also keep invalid datetime values like `0000-00-00 00:00:00` unchanged. const normalizeDateTimeString = (val: string) => { // 检查是否为无效日期时间(0000-00-00 或类似格式) if (/^0{4}-0{2}-0{2}/.test(val)) { return val; // 保持原样显示,不尝试转换 } - const match = val.match(/^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2})/); + const match = val.match( + /^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2}:\d{2})(?:\.\d+)?(?:\s*(?:Z|[+-]\d{2}:?\d{2})(?:\s+[A-Za-z_\/+-]+)?)?$/ + ); if (!match) return val; return `${match[1]} ${match[2]}`; }; @@ -170,6 +174,68 @@ const looksLikeJsonText = (text: string): boolean => { return (first === '{' && last === '}') || (first === '[' && last === ']'); }; +const isPlainObject = (value: any): value is Record => { + return Object.prototype.toString.call(value) === '[object Object]'; +}; + +const normalizeValueForJsonView = (value: any): any => { + if (value === null || value === undefined) return value; + + if (typeof value === 'string') { + const normalizedText = normalizeDateTimeString(value); + if (!looksLikeJsonText(normalizedText)) return normalizedText; + try { + return normalizeValueForJsonView(JSON.parse(normalizedText)); + } catch { + return normalizedText; + } + } + + if (Array.isArray(value)) { + return value.map((item) => normalizeValueForJsonView(item)); + } + + if (isPlainObject(value)) { + const next: Record = {}; + Object.entries(value).forEach(([key, val]) => { + next[key] = normalizeValueForJsonView(val); + }); + return next; + } + + return value; +}; + +const isJsonViewValueEqual = (left: any, right: any): boolean => { + const leftNormalized = normalizeValueForJsonView(left); + const rightNormalized = normalizeValueForJsonView(right); + + if (leftNormalized === rightNormalized) return true; + if (leftNormalized === null || rightNormalized === null) return leftNormalized === rightNormalized; + if (leftNormalized === undefined || rightNormalized === undefined) return leftNormalized === rightNormalized; + + if (typeof leftNormalized !== 'object' && typeof rightNormalized !== 'object') { + return String(leftNormalized) === String(rightNormalized); + } + + try { + return JSON.stringify(leftNormalized) === JSON.stringify(rightNormalized); + } catch { + return false; + } +}; + +const coerceJsonEditorValueForStorage = (currentValue: any, editedValue: any): any => { + if (typeof currentValue === 'string') { + const raw = currentValue.trim(); + const parsedCurrent = looksLikeJsonText(raw); + if (parsedCurrent && (isPlainObject(editedValue) || Array.isArray(editedValue))) { + return JSON.stringify(editedValue); + } + } + return editedValue; +}; + // --- Resizable Header (Native Implementation) --- const ResizableTitle = (props: any) => { const { onResizeStart, width, ...restProps } = props; @@ -444,6 +510,8 @@ type GridFilterCondition = FilterCondition & { value2?: string; }; +type GridViewMode = 'table' | 'json' | 'text'; + const DataGrid: React.FC = ({ data, columnNames, loading, tableName, dbName, connectionId, pkColumns = [], readOnly = false, onReload, onSort, onPageChange, pagination, showFilter, onToggleFilter, onApplyFilter @@ -452,8 +520,10 @@ const DataGrid: React.FC = ({ const addSqlLog = useStore(state => state.addSqlLog); const theme = useStore(state => state.theme); const appearance = useStore(state => state.appearance); + const isMacLike = useMemo(() => isMacLikePlatform(), []); const darkMode = theme === 'dark'; const opacity = normalizeOpacityForPlatform(appearance.opacity); + const canModifyData = !readOnly && !!tableName; const selectionColumnWidth = 46; // Background Helper @@ -479,11 +549,15 @@ const DataGrid: React.FC = ({ const [form] = Form.useForm(); const [modal, contextHolder] = Modal.useModal(); const gridId = useMemo(() => `grid-${uuidv4()}`, []); + const [viewMode, setViewMode] = useState('table'); + const [textRecordIndex, setTextRecordIndex] = useState(0); const [cellEditorOpen, setCellEditorOpen] = useState(false); const [cellEditorValue, setCellEditorValue] = useState(''); 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 [jsonEditorOpen, setJsonEditorOpen] = useState(false); + const [jsonEditorValue, setJsonEditorValue] = useState(''); const [rowEditorOpen, setRowEditorOpen] = useState(false); const [rowEditorRowKey, setRowEditorRowKey] = useState(''); const rowEditorBaseRawRef = useRef>({}); @@ -522,6 +596,10 @@ const DataGrid: React.FC = ({ const cellSelectionRafRef = useRef(null); const cellSelectionScrollRafRef = useRef(null); const isDraggingRef = useRef(false); + + // 导入预览 Modal 状态 + const [importPreviewVisible, setImportPreviewVisible] = useState(false); + const [importFilePath, setImportFilePath] = useState(''); const currentSelectionRef = useRef>(new Set()); const selectionStartRef = useRef<{ rowKey: string; colName: string; rowIndex: number; colIndex: number } | null>(null); const rowIndexMapRef = useRef>(new Map()); @@ -607,6 +685,44 @@ const DataGrid: React.FC = ({ // Dynamic Height const [tableHeight, setTableHeight] = useState(500); + const [tableViewportWidth, setTableViewportWidth] = useState(0); + const [tableBodyBottomPadding, setTableBodyBottomPadding] = useState(0); + const recalculateTableMetrics = useCallback((targetElement?: HTMLElement | null) => { + const target = targetElement || containerRef.current; + if (!target) return; + + const height = target.getBoundingClientRect().height; + const width = target.getBoundingClientRect().width; + if (!Number.isFinite(height) || height < 50) return; + if (Number.isFinite(width) && width > 0) { + setTableViewportWidth(Math.floor(width)); + } + + const headerEl = + (target.querySelector('.ant-table-header') as HTMLElement | null) || + (target.querySelector('.ant-table-thead') as HTMLElement | null); + const rawHeaderHeight = headerEl ? headerEl.getBoundingClientRect().height : NaN; + const headerHeight = + Number.isFinite(rawHeaderHeight) && rawHeaderHeight >= 24 && rawHeaderHeight <= 120 ? rawHeaderHeight : 42; + + const bodyEl = target.querySelector('.ant-table-body') as HTMLElement | null; + const stickyScrollEl = target.querySelector('.ant-table-sticky-scroll') as HTMLElement | null; + const hasHorizontalOverflow = !!bodyEl && (bodyEl.scrollWidth - bodyEl.clientWidth > 1); + const nativeHorizontalScrollbarHeight = bodyEl ? Math.max(0, Math.ceil(bodyEl.offsetHeight - bodyEl.clientHeight)) : 0; + const stickyScrollHeight = stickyScrollEl ? Math.ceil(stickyScrollEl.getBoundingClientRect().height) : 0; + // 动态为横向滚动条(含 sticky 条)预留空间,避免最后一行被遮住。 + const horizontalReserve = hasHorizontalOverflow + ? Math.max(nativeHorizontalScrollbarHeight, stickyScrollHeight, 14) + : Math.max(nativeHorizontalScrollbarHeight, 0); + // sticky 横向滚动条会覆盖在表格底部,额外给 body 增加内边距,确保最后一行完整可见。 + const nextBodyBottomPadding = hasHorizontalOverflow + ? Math.max(stickyScrollHeight, nativeHorizontalScrollbarHeight, 14) + 6 + : 0; + setTableBodyBottomPadding(nextBodyBottomPadding); + const extraBottom = 10 + horizontalReserve; + const nextHeight = Math.max(100, Math.floor(height - headerHeight - extraBottom)); + setTableHeight(nextHeight); + }, []); useEffect(() => { const el = containerRef.current; @@ -618,31 +734,17 @@ const DataGrid: React.FC = ({ if (rafId !== null) cancelAnimationFrame(rafId); rafId = requestAnimationFrame(() => { const target = (entries[0]?.target as HTMLElement | undefined) || containerRef.current; - if (!target) return; - - const height = target.getBoundingClientRect().height; - if (!Number.isFinite(height) || height < 50) return; - - const headerEl = - (target.querySelector('.ant-table-header') as HTMLElement | null) || - (target.querySelector('.ant-table-thead') as HTMLElement | null); - const rawHeaderHeight = headerEl ? headerEl.getBoundingClientRect().height : NaN; - const headerHeight = - Number.isFinite(rawHeaderHeight) && rawHeaderHeight >= 24 && rawHeaderHeight <= 120 ? rawHeaderHeight : 42; - - // 留一点余量,避免底部(边框/滚动条)遮挡最后一行 - const extraBottom = 16; - const nextHeight = Math.max(100, Math.floor(height - headerHeight - extraBottom)); - setTableHeight(nextHeight); + recalculateTableMetrics(target); }); }); resizeObserver.observe(el); + rafId = requestAnimationFrame(() => recalculateTableMetrics(el)); return () => { resizeObserver.disconnect(); if (rafId !== null) cancelAnimationFrame(rafId); }; - }, []); + }, [recalculateTableMetrics]); const [selectedRowKeys, setSelectedRowKeys] = useState([]); const [addedRows, setAddedRows] = useState([]); @@ -1194,6 +1296,47 @@ const DataGrid: React.FC = ({ }); }, [displayData, modifiedRows]); + useEffect(() => { + setTextRecordIndex(prev => { + if (mergedDisplayData.length === 0) return 0; + return Math.min(prev, mergedDisplayData.length - 1); + }); + }, [mergedDisplayData.length]); + + const jsonViewText = useMemo(() => { + const cleanRows = mergedDisplayData.map((row) => { + const { [GONAVI_ROW_KEY]: _rowKey, ...rest } = row || {}; + return normalizeValueForJsonView(rest); + }); + return JSON.stringify(cleanRows, null, 2); + }, [mergedDisplayData]); + + const textViewRows = useMemo(() => { + return mergedDisplayData.map((row) => { + const { [GONAVI_ROW_KEY]: _rowKey, ...rest } = row || {}; + return rest; + }); + }, [mergedDisplayData]); + + const currentTextRow = useMemo(() => { + if (textViewRows.length === 0) return null; + return textViewRows[textRecordIndex] || null; + }, [textViewRows, textRecordIndex]); + + const formatTextViewValue = useCallback((val: any): string => { + if (val === null) return 'NULL'; + if (val === undefined) return ''; + if (typeof val === 'string') return normalizeDateTimeString(val); + if (typeof val === 'object') { + try { + return JSON.stringify(val, null, 2); + } catch { + return String(val); + } + } + return String(val); + }, []); + const closeRowEditor = useCallback(() => { setRowEditorOpen(false); setRowEditorRowKey(''); @@ -1203,20 +1346,12 @@ const DataGrid: React.FC = ({ rowEditorForm.resetFields(); }, [rowEditorForm]); - const openRowEditor = useCallback(() => { - if (readOnly || !tableName) return; - if (selectedRowKeys.length > 1) { - message.info('一次只能编辑一行,请仅选择一行'); - return; - } - - const keyStr = - selectedRowKeys.length === 1 ? rowKeyStr(selectedRowKeys[0]) : undefined; + const openRowEditorByKey = useCallback((keyStr?: string) => { + if (!canModifyData) return; if (!keyStr) { - message.info('请先选择一行(勾选复选框)'); + message.info('请先定位到要编辑的记录'); return; } - const displayRow = mergedDisplayData.find(r => rowKeyStr(r?.[GONAVI_ROW_KEY]) === keyStr); if (!displayRow) { message.error('未找到目标行,请刷新后重试'); @@ -1249,7 +1384,147 @@ const DataGrid: React.FC = ({ rowEditorForm.setFieldsValue(formMap); setRowEditorRowKey(keyStr); setRowEditorOpen(true); - }, [readOnly, tableName, selectedRowKeys, mergedDisplayData, data, addedRows, columnNames, rowEditorForm, rowKeyStr]); + }, [canModifyData, mergedDisplayData, data, addedRows, columnNames, rowEditorForm, rowKeyStr]); + + const openRowEditor = useCallback(() => { + if (!canModifyData) return; + if (selectedRowKeys.length > 1) { + message.info('一次只能编辑一行,请仅选择一行'); + return; + } + const keyStr = selectedRowKeys.length === 1 ? rowKeyStr(selectedRowKeys[0]) : undefined; + if (!keyStr) { + message.info('请先选择一行(勾选复选框)'); + return; + } + openRowEditorByKey(keyStr); + }, [canModifyData, selectedRowKeys, rowKeyStr, openRowEditorByKey]); + + const openCurrentViewRowEditor = useCallback(() => { + if (!canModifyData) return; + const currentRow = mergedDisplayData[textRecordIndex]; + const rowKey = currentRow?.[GONAVI_ROW_KEY]; + if (rowKey === undefined || rowKey === null) { + message.info('当前记录不可编辑'); + return; + } + openRowEditorByKey(rowKeyStr(rowKey)); + }, [canModifyData, mergedDisplayData, textRecordIndex, rowKeyStr, openRowEditorByKey]); + + const openJsonEditor = useCallback(() => { + if (!canModifyData) return; + setJsonEditorValue(jsonViewText); + setJsonEditorOpen(true); + }, [canModifyData, jsonViewText]); + + const handleFormatJsonEditor = useCallback(() => { + try { + const parsed = JSON.parse(jsonEditorValue); + setJsonEditorValue(JSON.stringify(parsed, null, 2)); + } catch (e: any) { + message.error("JSON 格式无效:" + (e?.message || String(e))); + } + }, [jsonEditorValue]); + + const applyJsonEditor = useCallback(() => { + if (!canModifyData) return; + let parsed: any; + try { + parsed = JSON.parse(jsonEditorValue); + } catch (e: any) { + message.error("JSON 解析失败:" + (e?.message || String(e))); + return; + } + + if (!Array.isArray(parsed)) { + message.error("JSON 视图必须是数组格式(每项对应一条记录)"); + return; + } + if (parsed.length !== mergedDisplayData.length) { + message.error(`记录条数不一致:当前 ${mergedDisplayData.length} 条,JSON 中 ${parsed.length} 条。请勿在此模式增删记录。`); + return; + } + + const addedKeySet = new Set(); + addedRows.forEach((r) => { + const key = r?.[GONAVI_ROW_KEY]; + if (key === undefined) return; + addedKeySet.add(rowKeyStr(key)); + }); + + const originalMap = new Map(); + data.forEach((r) => { + const key = r?.[GONAVI_ROW_KEY]; + if (key === undefined) return; + originalMap.set(rowKeyStr(key), r); + }); + + const addedPatchMap = new Map>(); + const updatePatchMap = new Map>(); + + for (let idx = 0; idx < parsed.length; idx += 1) { + const nextItem = parsed[idx]; + if (!isPlainObject(nextItem)) { + message.error(`第 ${idx + 1} 条记录不是对象,无法应用`); + return; + } + + const currentRow = mergedDisplayData[idx]; + const rowKey = currentRow?.[GONAVI_ROW_KEY]; + if (rowKey === undefined || rowKey === null) { + message.error(`第 ${idx + 1} 条记录缺少行标识,无法应用`); + return; + } + const keyStr = rowKeyStr(rowKey); + const normalizedNext: Record = {}; + let hasAnyVisibleChange = false; + columnNames.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; + normalizedNext[col] = coerceJsonEditorValueForStorage(currentVal, editedVal); + }); + + if (!hasAnyVisibleChange) { + continue; + } + + if (addedKeySet.has(keyStr)) { + addedPatchMap.set(keyStr, normalizedNext); + continue; + } + + const originalRow = originalMap.get(keyStr); + if (!originalRow) continue; + const patch: Record = {}; + columnNames.forEach((col) => { + const prevVal = (originalRow as any)?.[col]; + const nextVal = normalizedNext[col]; + if (!isCellValueEqualForDiff(prevVal, nextVal)) patch[col] = nextVal; + }); + updatePatchMap.set(keyStr, patch); + } + + setAddedRows((prev) => prev.map((row) => { + const key = row?.[GONAVI_ROW_KEY]; + if (key === undefined) return row; + const patch = addedPatchMap.get(rowKeyStr(key)); + if (!patch) return row; + return { ...row, ...patch }; + })); + + setModifiedRows((prev) => { + const next = { ...prev }; + updatePatchMap.forEach((patch, keyStr) => { + if (Object.keys(patch).length === 0) delete next[keyStr]; + else next[keyStr] = patch; + }); + return next; + }); + + setJsonEditorOpen(false); + message.success("JSON 修改已应用到当前结果集,可继续“提交事务”"); + }, [canModifyData, jsonEditorValue, mergedDisplayData, addedRows, rowKeyStr, data, columnNames]); const openRowEditorFieldEditor = useCallback((dataIndex: string) => { if (!dataIndex) return; @@ -1301,7 +1576,7 @@ const DataGrid: React.FC = ({ 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 + editable: canModifyData, // Only editable if table name known and not readonly render: (text: any) => (
{formatCellValue(text)} @@ -1312,7 +1587,7 @@ const DataGrid: React.FC = ({ onResizeStart: handleResizeStart(key), // Only need start }), })); - }, [columnNames, columnWidths, sortInfo, handleResizeStart, readOnly, tableName, onSort]); + }, [columnNames, columnWidths, sortInfo, handleResizeStart, canModifyData, onSort]); const mergedColumns = useMemo(() => columns.map(col => { if (!col.editable) return col; @@ -1652,9 +1927,21 @@ const DataGrid: React.FC = ({ if (!connectionId || !tableName) return; const config = buildConnConfig(); if (!config) return; - + 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); } + if (res.success && res.data && res.data.filePath) { + setImportFilePath(res.data.filePath); + setImportPreviewVisible(true); + } else if (res.message !== "Cancelled") { + message.error("选择文件失败: " + res.message); + } + }; + + const handleImportSuccess = () => { + setImportPreviewVisible(false); + setImportFilePath(''); + message.success('导入完成'); + if (onReload) onReload(); }; // Filters @@ -1731,6 +2018,22 @@ const DataGrid: React.FC = ({ const totalWidth = columns.reduce((sum, col) => sum + (Number(col.width) || 200), 0) + selectionColumnWidth; const enableVirtual = mergedDisplayData.length >= 200; + const tableScrollX = useMemo(() => { + const baseWidth = Math.max(totalWidth, 1000); + if (!isMacLike || tableViewportWidth <= 0) return baseWidth; + // macOS 在“自动隐藏滚动条”模式下容易误判为无横向滚动,预留 2px 触发稳定滚动轨道。 + return Math.max(baseWidth, tableViewportWidth + 2); + }, [totalWidth, isMacLike, tableViewportWidth]); + const tableStickyConfig = useMemo(() => ({ + getContainer: () => containerRef.current || document.body, + offsetScroll: 0, + }), []); + + useEffect(() => { + if (viewMode !== 'table') return; + const rafId = requestAnimationFrame(() => recalculateTableMetrics(containerRef.current)); + return () => cancelAnimationFrame(rafId); + }, [viewMode, totalWidth, mergedDisplayData.length, recalculateTableMetrics]); return (
@@ -1746,7 +2049,7 @@ const DataGrid: React.FC = ({ {tableName && } {tableName && } - {!readOnly && tableName && ( + {canModifyData && ( <>
@@ -1818,6 +2121,37 @@ const DataGrid: React.FC = ({ }}>筛选 )} + +
+ { + const nextMode = String(val) as GridViewMode; + if (nextMode === 'json' && cellEditMode) { + setCellEditMode(false); + setSelectedCells(new Set()); + currentSelectionRef.current = new Set(); + selectionStartRef.current = null; + updateCellSelection(new Set()); + } + if (nextMode === 'text') { + const selectedKey = selectedRowKeys[0]; + if (selectedKey !== undefined) { + const idx = mergedDisplayData.findIndex((row) => rowKeyStr(row?.[GONAVI_ROW_KEY]) === rowKeyStr(selectedKey)); + if (idx >= 0) { + setTextRecordIndex(idx); + } + } + } + setViewMode(nextMode); + }} + />
{/* Filter Panel */} @@ -2016,44 +2350,145 @@ const DataGrid: React.FC = ({ /> )} + setJsonEditorOpen(false)} + width={980} + maskClosable={false} + footer={[ + , + , + , + ]} + > +
+ 说明:此处按当前结果集顺序编辑,不支持在 JSON 模式增删记录(可在表格模式操作)。 +
+ setJsonEditorValue(val || '')} + options={{ + readOnly: false, + minimap: { enabled: false }, + scrollBeyondLastLine: false, + wordWrap: "off", + fontSize: 12, + tabSize: 2, + automaticLayout: true, + }} + /> +
-
- - - - { - const k = record?.[GONAVI_ROW_KEY]; - if (k !== undefined && addedRows.some(r => r?.[GONAVI_ROW_KEY] === k)) return 'row-added'; - if (k !== undefined && (modifiedRows[rowKeyStr(k)] || deletedRowKeys.has(rowKeyStr(k)))) return 'row-modified'; // deleted won't show - return ''; - }} - onRow={(record) => ({ record } as any)} - /> - - - - + {viewMode === 'table' ? ( + + + + +
{ + const k = record?.[GONAVI_ROW_KEY]; + if (k !== undefined && addedRows.some(r => r?.[GONAVI_ROW_KEY] === k)) return 'row-added'; + if (k !== undefined && (modifiedRows[rowKeyStr(k)] || deletedRowKeys.has(rowKeyStr(k)))) return 'row-modified'; // deleted won't show + return ''; + }} + onRow={(record) => ({ record } as any)} + /> + + + + + ) : viewMode === 'json' ? ( +
+
+ + {mergedDisplayData.length === 0 ? '当前结果集无数据' : `当前结果集 ${mergedDisplayData.length} 条记录`} + + {canModifyData && ( + + )} +
+
+ +
+
+ ) : ( +
+
+ + + + {textViewRows.length === 0 ? '当前结果集无数据' : `记录 ${textRecordIndex + 1} / ${textViewRows.length}`} + + {canModifyData && ( + + )} +
+
+ {currentTextRow ? columnNames.map((col) => ( +
+
+ {col} : +
+
+ {formatTextViewValue((currentTextRow as any)[col])} +
+
+ )) : ( +
+ 当前结果集无数据 +
+ )} +
+
+ )} {/* Cell Context Menu - 使用 Portal 渲染到 body,避免 backdropFilter 影响 fixed 定位 */} - {cellContextMenu.visible && createPortal( + {viewMode === 'table' && cellContextMenu.visible && createPortal(
= ({ box-shadow: inset 0 0 0 2px #1890ff; background-image: linear-gradient(${darkMode ? 'rgba(24, 144, 255, 0.18)' : 'rgba(24, 144, 255, 0.08)'}, ${darkMode ? 'rgba(24, 144, 255, 0.18)' : 'rgba(24, 144, 255, 0.08)'}); } + .${gridId} .ant-table-content, + .${gridId} .ant-table-body { + scrollbar-gutter: stable; + } + .${gridId} .ant-table-body { + padding-bottom: ${tableBodyBottomPadding}px; + box-sizing: border-box; + scroll-padding-bottom: ${tableBodyBottomPadding}px; + } + .${gridId} .ant-table-sticky-scroll { + height: 10px !important; + background: ${darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)'}; + z-index: 20 !important; + } + .${gridId} .ant-table-sticky-scroll-bar { + background: ${darkMode ? 'rgba(255,255,255,0.35)' : 'rgba(0,0,0,0.28)'} !important; + } `} {/* Ghost Resize Line for Columns */} @@ -2275,6 +2727,20 @@ const DataGrid: React.FC = ({ willChange: 'transform' }} /> + + {/* Import Preview Modal */} + { + setImportPreviewVisible(false); + setImportFilePath(''); + }} + onSuccess={handleImportSuccess} + />
); }; diff --git a/frontend/src/components/ImportPreviewModal.tsx b/frontend/src/components/ImportPreviewModal.tsx new file mode 100644 index 0000000..160eb7d --- /dev/null +++ b/frontend/src/components/ImportPreviewModal.tsx @@ -0,0 +1,250 @@ +import React, { useState, useEffect } from 'react'; +import { Modal, Table, Alert, Progress, Button, Space } from 'antd'; +import { CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons'; +import { PreviewImportFile, ImportDataWithProgress } from '../../wailsjs/go/app/App'; +import { EventsOn, EventsOff } from '../../wailsjs/runtime/runtime'; +import { useStore } from '../store'; + +interface ImportPreviewModalProps { + visible: boolean; + filePath: string; + connectionId: string; + dbName: string; + tableName: string; + onClose: () => void; + onSuccess: () => void; +} + +interface PreviewData { + columns: string[]; + totalRows: number; + previewRows: any[]; +} + +interface ImportProgress { + current: number; + total: number; + success: number; + errors: number; +} + +const ImportPreviewModal: React.FC = ({ + visible, + filePath, + connectionId, + dbName, + tableName, + onClose, + onSuccess +}) => { + const connections = useStore(state => state.connections); + const [loading, setLoading] = useState(true); + const [previewData, setPreviewData] = useState(null); + const [error, setError] = useState(null); + const [importing, setImporting] = useState(false); + const [progress, setProgress] = useState(null); + const [importResult, setImportResult] = useState(null); + + useEffect(() => { + if (visible && filePath) { + loadPreview(); + } + }, [visible, filePath]); + + useEffect(() => { + if (importing) { + const unsubscribe = EventsOn('import:progress', (data: ImportProgress) => { + setProgress(data); + }); + return () => { + EventsOff('import:progress'); + }; + } + }, [importing]); + + const loadPreview = async () => { + setLoading(true); + setError(null); + try { + const res = await PreviewImportFile(filePath); + if (res.success && res.data) { + setPreviewData({ + columns: res.data.columns || [], + totalRows: res.data.totalRows || 0, + previewRows: res.data.previewRows || [] + }); + } else { + setError(res.message || '预览失败'); + } + } catch (e: any) { + setError('预览失败: ' + e.message); + } finally { + setLoading(false); + } + }; + + const handleImport = async () => { + if (!previewData) return; + + setImporting(true); + setProgress({ current: 0, total: previewData.totalRows, success: 0, errors: 0 }); + setImportResult(null); + + try { + const conn = connections.find(c => c.id === connectionId); + if (!conn) { + setError('连接配置未找到'); + setImporting(false); + 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 ImportDataWithProgress(config as any, dbName, tableName, filePath); + + if (res.success && res.data) { + setImportResult(res.data); + if (res.data.failed === 0) { + onSuccess(); + } + } else { + setError(res.message || '导入失败'); + } + } catch (e: any) { + setError('导入失败: ' + e.message); + } finally { + setImporting(false); + } + }; + + const columns = previewData?.columns.map(col => ({ + title: col, + dataIndex: col, + key: col, + ellipsis: true, + width: 150 + })) || []; + + const progressPercent = progress ? Math.round((progress.current / progress.total) * 100) : 0; + + return ( + + + + ) : importing ? null : ( + + + + + ) + } + > + {error && } + + {loading &&
加载预览数据...
} + + {!loading && previewData && !importing && !importResult && ( + <> + +
字段列表:
+
+ {previewData.columns.join(', ')} +
+
数据预览(前 5 行):
+
+ + )} + + {importing && progress && ( +
+
+ 正在导入数据... +
+ +
+ 已处理 {progress.current} / {progress.total} 行 + + 成功 {progress.success} + + {progress.errors > 0 && ( + + 失败 {progress.errors} + + )} +
+
+ )} + + {importResult && ( +
+ +
成功导入 {importResult.success} 行
+ {importResult.failed > 0 &&
失败 {importResult.failed} 行
} +
+ } + showIcon + style={{ marginBottom: 16 }} + /> + {importResult.errorLogs && importResult.errorLogs.length > 0 && ( + <> +
错误日志:
+
+ {importResult.errorLogs.map((log: string, idx: number) => ( +
{log}
+ ))} +
+ + )} + + )} + + ); +}; + +export default ImportPreviewModal; diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 59b461f..fc07757 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -268,6 +268,19 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> return `${schema}.${name}`; }; + const splitQualifiedName = (qualifiedName: string): { schemaName: string; objectName: string } => { + const raw = String(qualifiedName || '').trim(); + if (!raw) return { schemaName: '', objectName: '' }; + const idx = raw.lastIndexOf('.'); + if (idx <= 0 || idx >= raw.length - 1) { + return { schemaName: '', objectName: raw }; + } + return { + schemaName: raw.substring(0, idx), + objectName: raw.substring(idx + 1), + }; + }; + const buildViewsMetadataQuery = (dialect: string, dbName: string): string => { const safeDbName = escapeSQLLiteral(dbName); switch (dialect) { @@ -539,105 +552,214 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> const res = await DBGetTables(config as any, conn.dbName); if (res.success) { setConnectionStates(prev => ({ ...prev, [key as string]: 'success' })); - const tables = (res.data as any[]).map((row: any) => { - const tableName = Object.values(row)[0] as string; - const tableDisplayName = getSidebarTableDisplayName(conn, tableName); - return { - title: tableDisplayName, - key: `${conn.id}-${conn.dbName}-${tableName}`, - icon: , - type: 'table' as const, - dataRef: { ...conn, tableName }, - isLeaf: false, - }; - }); - const [views, triggers, routines] = await Promise.all([ - loadViews(conn, conn.dbName), - loadDatabaseTriggers(conn, conn.dbName), - loadFunctions(conn, conn.dbName), - ]); + const tableEntries = (res.data as any[]).map((row: any) => { + const tableName = Object.values(row)[0] as string; + const parsed = splitQualifiedName(tableName); + return { + tableName, + schemaName: parsed.schemaName, + displayName: getSidebarTableDisplayName(conn, tableName), + }; + }); - // 获取当前数据库的排序偏好 - const sortPreferenceKey = `${conn.id}-${conn.dbName}`; - const sortBy = tableSortPreference[sortPreferenceKey] || 'name'; + const [views, triggers, routines] = await Promise.all([ + loadViews(conn, conn.dbName), + loadDatabaseTriggers(conn, conn.dbName), + loadFunctions(conn, conn.dbName), + ]); - // 根据排序偏好排序表 - if (sortBy === 'frequency') { - // 按使用频率排序(降序) - tables.sort((a, b) => { - const keyA = `${conn.id}-${conn.dbName}-${a.dataRef.tableName}`; - const keyB = `${conn.id}-${conn.dbName}-${b.dataRef.tableName}`; - const countA = tableAccessCount[keyA] || 0; - const countB = tableAccessCount[keyB] || 0; - if (countA !== countB) { - return countB - countA; // 降序 - } - // 频率相同时按名称排序 - return a.title.toLowerCase().localeCompare(b.title.toLowerCase()); - }); - } else { - // 按名称排序(字母顺序) - tables.sort((a, b) => a.title.toLowerCase().localeCompare(b.title.toLowerCase())); - } + const viewEntries = views.map((viewName) => { + const parsed = splitQualifiedName(viewName); + return { + viewName, + schemaName: parsed.schemaName, + displayName: getSidebarTableDisplayName(conn, viewName), + }; + }); - // Sort views by name (case-insensitive) - views.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())); + const triggerEntries = triggers.map((trigger) => { + const triggerParsed = splitQualifiedName(trigger.triggerName); + const tableParsed = splitQualifiedName(trigger.tableName); + const schemaName = tableParsed.schemaName || triggerParsed.schemaName; + const triggerObjectName = triggerParsed.objectName || trigger.triggerName; + const tableObjectName = tableParsed.objectName || trigger.tableName; + const displayName = tableObjectName ? `${triggerObjectName} (${tableObjectName})` : triggerObjectName; + return { + ...trigger, + schemaName, + displayName, + }; + }); - // Sort triggers by display name (case-insensitive) - triggers.sort((a, b) => a.displayName.toLowerCase().localeCompare(b.displayName.toLowerCase())); + const routineEntries = routines.map((routine) => { + const parsed = splitQualifiedName(routine.routineName); + const typeLabel = routine.routineType === 'PROCEDURE' ? 'P' : 'F'; + return { + ...routine, + schemaName: parsed.schemaName, + displayName: `${parsed.objectName || routine.routineName} [${typeLabel}]`, + }; + }); - // Sort routines by display name (case-insensitive) - routines.sort((a, b) => a.displayName.toLowerCase().localeCompare(b.displayName.toLowerCase())); + // 获取当前数据库的排序偏好 + const sortPreferenceKey = `${conn.id}-${conn.dbName}`; + const sortBy = tableSortPreference[sortPreferenceKey] || 'name'; - const viewNodes: TreeNode[] = views.map((viewName) => ({ - title: getSidebarTableDisplayName(conn, viewName), - key: `${conn.id}-${conn.dbName}-view-${viewName}`, - icon: , - type: 'view', - dataRef: { ...conn, viewName, tableName: viewName }, - isLeaf: true, - })); + // 根据排序偏好排序表 + if (sortBy === 'frequency') { + // 按使用频率排序(降序) + tableEntries.sort((a, b) => { + const keyA = `${conn.id}-${conn.dbName}-${a.tableName}`; + const keyB = `${conn.id}-${conn.dbName}-${b.tableName}`; + const countA = tableAccessCount[keyA] || 0; + const countB = tableAccessCount[keyB] || 0; + if (countA !== countB) { + return countB - countA; + } + return a.displayName.toLowerCase().localeCompare(b.displayName.toLowerCase()); + }); + } else { + tableEntries.sort((a, b) => a.displayName.toLowerCase().localeCompare(b.displayName.toLowerCase())); + } - const triggerNodes: TreeNode[] = triggers.map((trigger) => ({ - title: trigger.displayName, - key: `${conn.id}-${conn.dbName}-trigger-${trigger.triggerName}-${trigger.tableName}`, - icon: , - type: 'db-trigger', - dataRef: { ...conn, triggerName: trigger.triggerName, triggerTableName: trigger.tableName }, - isLeaf: true, - })); + // Sort views by name (case-insensitive) + viewEntries.sort((a, b) => a.displayName.toLowerCase().localeCompare(b.displayName.toLowerCase())); - const routineNodes: TreeNode[] = routines.map((r) => ({ - title: r.displayName, - key: `${conn.id}-${conn.dbName}-routine-${r.routineName}`, - icon: , - type: 'routine', - dataRef: { ...conn, routineName: r.routineName, routineType: r.routineType }, - isLeaf: true, - })); + // Sort triggers by display name (case-insensitive) + triggerEntries.sort((a, b) => a.displayName.toLowerCase().localeCompare(b.displayName.toLowerCase())); - const buildObjectGroup = (groupKey: string, groupTitle: string, groupIcon: React.ReactNode, children: TreeNode[]): TreeNode => ({ - title: `${groupTitle} (${children.length})`, - key: `${key}-${groupKey}`, - icon: groupIcon, - type: 'object-group', - isLeaf: children.length === 0, - children: children.length > 0 ? children : undefined, - dataRef: { ...conn, dbName: conn.dbName, groupKey } - }); + // Sort routines by display name (case-insensitive) + routineEntries.sort((a, b) => a.displayName.toLowerCase().localeCompare(b.displayName.toLowerCase())); - const groupedNodes: TreeNode[] = [ - buildObjectGroup('tables', '表', , tables), - buildObjectGroup('views', '视图', , viewNodes), - buildObjectGroup('routines', '函数', , routineNodes), - buildObjectGroup('triggers', '触发器', , triggerNodes), - ]; + const buildTableNode = (entry: { tableName: string; schemaName: string; displayName: string }): TreeNode => ({ + title: entry.displayName, + key: `${conn.id}-${conn.dbName}-${entry.tableName}`, + icon: , + type: 'table', + dataRef: { ...conn, tableName: entry.tableName, schemaName: entry.schemaName }, + isLeaf: false, + }); - setTreeData(origin => updateTreeData(origin, key, [queriesNode, ...groupedNodes])); - } else { - setConnectionStates(prev => ({ ...prev, [key as string]: 'error' })); - message.error({ content: res.message, key: `db-${key}-tables` }); + const buildViewNode = (entry: { viewName: string; schemaName: string; displayName: string }): TreeNode => ({ + title: entry.displayName, + key: `${conn.id}-${conn.dbName}-view-${entry.viewName}`, + icon: , + type: 'view', + dataRef: { ...conn, viewName: entry.viewName, tableName: entry.viewName, schemaName: entry.schemaName }, + isLeaf: true, + }); + + const buildTriggerNode = (entry: { triggerName: string; tableName: string; schemaName: string; displayName: string }): TreeNode => ({ + title: entry.displayName, + key: `${conn.id}-${conn.dbName}-trigger-${entry.triggerName}-${entry.tableName}`, + icon: , + type: 'db-trigger', + dataRef: { ...conn, triggerName: entry.triggerName, triggerTableName: entry.tableName, schemaName: entry.schemaName }, + isLeaf: true, + }); + + const buildRoutineNode = (entry: { routineName: string; routineType: string; schemaName: string; displayName: string }): TreeNode => ({ + title: entry.displayName, + key: `${conn.id}-${conn.dbName}-routine-${entry.routineName}`, + icon: , + type: 'routine', + dataRef: { ...conn, routineName: entry.routineName, routineType: entry.routineType, schemaName: entry.schemaName }, + isLeaf: true, + }); + + const buildObjectGroup = ( + parentKey: string, + groupKey: string, + groupTitle: string, + groupIcon: React.ReactNode, + children: TreeNode[], + extraData: Record = {} + ): TreeNode => ({ + title: `${groupTitle} (${children.length})`, + key: `${parentKey}-${groupKey}`, + icon: groupIcon, + type: 'object-group', + isLeaf: children.length === 0, + children: children.length > 0 ? children : undefined, + dataRef: { ...conn, dbName: conn.dbName, groupKey, ...extraData } + }); + + const shouldGroupBySchema = shouldHideSchemaPrefix(conn as SavedConnection); + if (shouldGroupBySchema) { + type SchemaBucket = { + schemaName: string; + tables: TreeNode[]; + views: TreeNode[]; + routines: TreeNode[]; + triggers: TreeNode[]; + }; + + const schemaMap = new Map(); + const getSchemaBucket = (rawSchemaName: string): SchemaBucket => { + const schemaName = String(rawSchemaName || '').trim(); + const schemaKey = schemaName || '__default__'; + let bucket = schemaMap.get(schemaKey); + if (!bucket) { + bucket = { + schemaName, + tables: [], + views: [], + routines: [], + triggers: [], + }; + schemaMap.set(schemaKey, bucket); + } + return bucket; + }; + + tableEntries.forEach((entry) => getSchemaBucket(entry.schemaName).tables.push(buildTableNode(entry))); + viewEntries.forEach((entry) => getSchemaBucket(entry.schemaName).views.push(buildViewNode(entry))); + routineEntries.forEach((entry) => getSchemaBucket(entry.schemaName).routines.push(buildRoutineNode(entry))); + triggerEntries.forEach((entry) => getSchemaBucket(entry.schemaName).triggers.push(buildTriggerNode(entry))); + + const schemaNodes: TreeNode[] = Array.from(schemaMap.values()) + .sort((a, b) => { + if (!a.schemaName && !b.schemaName) return 0; + if (!a.schemaName) return -1; + if (!b.schemaName) return 1; + return a.schemaName.toLowerCase().localeCompare(b.schemaName.toLowerCase()); + }) + .map((bucket) => { + const schemaNodeKey = `${key}-schema-${bucket.schemaName || 'default'}`; + const schemaTitle = bucket.schemaName || '默认模式'; + const groupedNodes: TreeNode[] = [ + buildObjectGroup(schemaNodeKey, 'tables', '表', , bucket.tables, { schemaName: bucket.schemaName }), + buildObjectGroup(schemaNodeKey, 'views', '视图', , bucket.views, { schemaName: bucket.schemaName }), + buildObjectGroup(schemaNodeKey, 'routines', '函数', , bucket.routines, { schemaName: bucket.schemaName }), + buildObjectGroup(schemaNodeKey, 'triggers', '触发器', , bucket.triggers, { schemaName: bucket.schemaName }), + ]; + + return { + title: schemaTitle, + key: schemaNodeKey, + icon: , + type: 'object-group' as const, + isLeaf: groupedNodes.length === 0, + children: groupedNodes, + dataRef: { ...conn, dbName: conn.dbName, groupKey: 'schema', schemaName: bucket.schemaName } + }; + }); + + setTreeData(origin => updateTreeData(origin, key, [queriesNode, ...schemaNodes])); + } else { + const groupedNodes: TreeNode[] = [ + buildObjectGroup(key as string, 'tables', '表', , tableEntries.map(buildTableNode)), + buildObjectGroup(key as string, 'views', '视图', , viewEntries.map(buildViewNode)), + buildObjectGroup(key as string, 'routines', '函数', , routineEntries.map(buildRoutineNode)), + buildObjectGroup(key as string, 'triggers', '触发器', , triggerEntries.map(buildTriggerNode)), + ]; + + setTreeData(origin => updateTreeData(origin, key, [queriesNode, ...groupedNodes])); + } + } else { + setConnectionStates(prev => ({ ...prev, [key as string]: 'error' })); + message.error({ content: res.message, key: `db-${key}-tables` }); } } finally { loadingNodesRef.current.delete(loadKey); diff --git a/frontend/wailsjs/go/app/App.d.ts b/frontend/wailsjs/go/app/App.d.ts index b1471f5..544c857 100755 --- a/frontend/wailsjs/go/app/App.d.ts +++ b/frontend/wailsjs/go/app/App.d.ts @@ -64,6 +64,8 @@ export function ImportConfigFile():Promise; export function ImportData(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise; +export function ImportDataWithProgress(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise; + export function InstallUpdateAndRestart():Promise; export function MongoDiscoverMembers(arg1:connection.ConnectionConfig):Promise; @@ -80,6 +82,8 @@ export function MySQLShowCreateTable(arg1:connection.ConnectionConfig,arg2:strin export function OpenSQLFile():Promise; +export function PreviewImportFile(arg1:string):Promise; + export function RedisConnect(arg1:connection.ConnectionConfig):Promise; export function RedisDeleteHashField(arg1:connection.ConnectionConfig,arg2:string,arg3:Array):Promise; diff --git a/frontend/wailsjs/go/app/App.js b/frontend/wailsjs/go/app/App.js index 936bea4..5952703 100755 --- a/frontend/wailsjs/go/app/App.js +++ b/frontend/wailsjs/go/app/App.js @@ -122,6 +122,10 @@ export function ImportData(arg1, arg2, arg3) { return window['go']['app']['App']['ImportData'](arg1, arg2, arg3); } +export function ImportDataWithProgress(arg1, arg2, arg3, arg4) { + return window['go']['app']['App']['ImportDataWithProgress'](arg1, arg2, arg3, arg4); +} + export function InstallUpdateAndRestart() { return window['go']['app']['App']['InstallUpdateAndRestart'](); } @@ -154,6 +158,10 @@ export function OpenSQLFile() { return window['go']['app']['App']['OpenSQLFile'](); } +export function PreviewImportFile(arg1) { + return window['go']['app']['App']['PreviewImportFile'](arg1); +} + export function RedisConnect(arg1) { return window['go']['app']['App']['RedisConnect'](arg1); } diff --git a/frontend/wailsjs/runtime/package.json b/frontend/wailsjs/runtime/package.json old mode 100755 new mode 100644 diff --git a/frontend/wailsjs/runtime/runtime.d.ts b/frontend/wailsjs/runtime/runtime.d.ts old mode 100755 new mode 100644 diff --git a/frontend/wailsjs/runtime/runtime.js b/frontend/wailsjs/runtime/runtime.js old mode 100755 new mode 100644 diff --git a/go.mod b/go.mod index 618bede..85b59f6 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/sijms/go-ora/v2 v2.9.0 github.com/taosdata/driver-go/v3 v3.7.8 github.com/wailsapp/wails/v2 v2.11.0 + github.com/xuri/excelize/v2 v2.10.0 go.mongodb.org/mongo-driver/v2 v2.5.0 golang.org/x/crypto v0.47.0 golang.org/x/text v0.33.0 @@ -49,9 +50,12 @@ require ( github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pkg/errors v0.9.1 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/richardlehane/mscfb v1.0.4 // indirect + github.com/richardlehane/msoleps v1.0.4 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/samber/lo v1.49.1 // indirect github.com/shopspring/decimal v1.4.0 // indirect + github.com/tiendc/go-deepcopy v1.7.1 // indirect github.com/tkrajina/go-reflector v0.5.8 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect @@ -60,6 +64,8 @@ require ( github.com/xdg-go/pbkdf2 v1.0.0 // indirect github.com/xdg-go/scram v1.2.0 // indirect github.com/xdg-go/stringprep v1.0.4 // indirect + github.com/xuri/efp v0.0.1 // indirect + github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect golang.org/x/net v0.48.0 // indirect diff --git a/go.sum b/go.sum index 0e70b1b..d11cbb5 100644 --- a/go.sum +++ b/go.sum @@ -110,6 +110,11 @@ github.com/redis/go-redis/v9 v9.17.3 h1:fN29NdNrE17KttK5Ndf20buqfDZwGNgoUr9qjl1D github.com/redis/go-redis/v9 v9.17.3/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/richardlehane/mscfb v1.0.4 h1:WULscsljNPConisD5hR0+OyZjwK46Pfyr6mPu5ZawpM= +github.com/richardlehane/mscfb v1.0.4/go.mod h1:YzVpcZg9czvAuhk9T+a3avCpcFPMUWm7gK3DypaEsUk= +github.com/richardlehane/msoleps v1.0.1/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= +github.com/richardlehane/msoleps v1.0.4 h1:WuESlvhX3gH2IHcd8UqyCuFY5yiq/GR/yqaSM/9/g00= +github.com/richardlehane/msoleps v1.0.4/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= @@ -127,10 +132,12 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/taosdata/driver-go/v3 v3.7.8 h1:N2H6HLLZH2ve2ipcoFgG9BJS+yW0XksqNYwEdSmHaJk= github.com/taosdata/driver-go/v3 v3.7.8/go.mod h1:gSxBEPOueMg0rTmMO1Ug6aeD7AwGdDGvUtLrsDTTpYc= +github.com/tiendc/go-deepcopy v1.7.1 h1:LnubftI6nYaaMOcaz0LphzwraqN8jiWTwm416sitff4= +github.com/tiendc/go-deepcopy v1.7.1/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ= github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ= github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= @@ -149,6 +156,12 @@ github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs= github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8= github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= +github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8= +github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI= +github.com/xuri/excelize/v2 v2.10.0 h1:8aKsP7JD39iKLc6dH5Tw3dgV3sPRh8uRVXu/fMstfW4= +github.com/xuri/excelize/v2 v2.10.0/go.mod h1:SC5TzhQkaOsTWpANfm+7bJCldzcnU/jrhqkTi/iBHBU= +github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE= +github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= @@ -160,6 +173,8 @@ golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= +golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ= +golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= diff --git a/internal/app/methods_file.go b/internal/app/methods_file.go index 9ebec06..711a094 100644 --- a/internal/app/methods_file.go +++ b/internal/app/methods_file.go @@ -14,9 +14,9 @@ import ( "GoNavi-Wails/internal/connection" "GoNavi-Wails/internal/db" - "GoNavi-Wails/internal/logger" "github.com/wailsapp/wails/v2/pkg/runtime" + "github.com/xuri/excelize/v2" ) func (a *App) OpenSQLFile() connection.QueryResult { @@ -77,13 +77,40 @@ func (a *App) ImportConfigFile() connection.QueryResult { return connection.QueryResult{Success: true, Data: string(content)} } +// PreviewImportFile 解析导入文件,返回字段列表、总行数、前 5 行预览数据 +func (a *App) PreviewImportFile(filePath string) connection.QueryResult { + if filePath == "" { + return connection.QueryResult{Success: false, Message: "File path required"} + } + + rows, columns, err := parseImportFile(filePath) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + + totalRows := len(rows) + previewRows := rows + if len(rows) > 5 { + previewRows = rows[:5] + } + + result := map[string]interface{}{ + "columns": columns, + "totalRows": totalRows, + "previewRows": previewRows, + "filePath": filePath, + } + + return connection.QueryResult{Success: true, Data: result} +} + func (a *App) ImportData(config connection.ConnectionConfig, dbName, tableName string) connection.QueryResult { selection, err := runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{ Title: fmt.Sprintf("Import into %s", tableName), Filters: []runtime.FileFilter{ { DisplayName: "Data Files", - Pattern: "*.csv;*.json", + Pattern: "*.csv;*.json;*.xlsx;*.xls", }, }, }) @@ -96,44 +123,249 @@ func (a *App) ImportData(config connection.ConnectionConfig, dbName, tableName s return connection.QueryResult{Success: false, Message: "Cancelled"} } - f, err := os.Open(selection) - if err != nil { - return connection.QueryResult{Success: false, Message: err.Error()} - } - defer f.Close() + // 返回文件路径供前端预览 + return connection.QueryResult{Success: true, Data: map[string]interface{}{"filePath": selection}} +} +// parseImportFile 解析导入文件,返回数据行和列名 +func parseImportFile(filePath string) ([]map[string]interface{}, []string, error) { var rows []map[string]interface{} + var columns []string + lower := strings.ToLower(filePath) - if strings.HasSuffix(strings.ToLower(selection), ".json") { + if strings.HasSuffix(lower, ".json") { + f, err := os.Open(filePath) + if err != nil { + return nil, nil, err + } + defer f.Close() decoder := json.NewDecoder(f) if err := decoder.Decode(&rows); err != nil { - return connection.QueryResult{Success: false, Message: "JSON Parse Error: " + err.Error()} + return nil, nil, fmt.Errorf("JSON Parse Error: %w", err) } - } else if strings.HasSuffix(strings.ToLower(selection), ".csv") { + if len(rows) > 0 { + for k := range rows[0] { + columns = append(columns, k) + } + } + } else if strings.HasSuffix(lower, ".csv") { + f, err := os.Open(filePath) + if err != nil { + return nil, nil, err + } + defer f.Close() reader := csv.NewReader(f) records, err := reader.ReadAll() if err != nil { - return connection.QueryResult{Success: false, Message: "CSV Parse Error: " + err.Error()} + return nil, nil, fmt.Errorf("CSV Parse Error: %w", err) } if len(records) < 2 { - return connection.QueryResult{Success: false, Message: "CSV empty or missing header"} + return nil, nil, fmt.Errorf("CSV empty or missing header") } - headers := records[0] + columns = records[0] for _, record := range records[1:] { row := make(map[string]interface{}) for i, val := range record { - if i < len(headers) { + if i < len(columns) { if val == "NULL" { - row[headers[i]] = nil + row[columns[i]] = nil } else { - row[headers[i]] = val + row[columns[i]] = val } } } rows = append(rows, row) } + } else if strings.HasSuffix(lower, ".xlsx") || strings.HasSuffix(lower, ".xls") { + xlsx, err := excelize.OpenFile(filePath) + if err != nil { + return nil, nil, fmt.Errorf("Excel Parse Error: %w", err) + } + defer xlsx.Close() + + sheetName := xlsx.GetSheetName(0) + if sheetName == "" { + return nil, nil, fmt.Errorf("Excel file has no sheets") + } + + xlRows, err := xlsx.GetRows(sheetName) + if err != nil { + return nil, nil, fmt.Errorf("Excel Read Error: %w", err) + } + if len(xlRows) < 2 { + return nil, nil, fmt.Errorf("Excel empty or missing header") + } + + columns = xlRows[0] + for _, record := range xlRows[1:] { + row := make(map[string]interface{}) + for i, val := range record { + if i < len(columns) && columns[i] != "" { + if val == "NULL" { + row[columns[i]] = nil + } else { + row[columns[i]] = val + } + } + } + if len(row) > 0 { + rows = append(rows, row) + } + } } else { - return connection.QueryResult{Success: false, Message: "Unsupported file format"} + return nil, nil, fmt.Errorf("Unsupported file format") + } + + return rows, columns, nil +} + +func normalizeColumnName(name string) string { + return strings.ToLower(strings.TrimSpace(name)) +} + +func buildImportColumnTypeMap(defs []connection.ColumnDefinition) map[string]string { + result := make(map[string]string, len(defs)) + for _, def := range defs { + key := normalizeColumnName(def.Name) + if key == "" { + continue + } + result[key] = strings.TrimSpace(def.Type) + } + return result +} + +func isTimezoneAwareColumnType(columnType string) bool { + typ := strings.ToLower(strings.TrimSpace(columnType)) + if typ == "" { + return false + } + return strings.Contains(typ, "with time zone") || + strings.Contains(typ, "with timezone") || + strings.Contains(typ, "datetimeoffset") || + strings.Contains(typ, "timestamptz") +} + +func isDateTimeColumnType(columnType string) bool { + typ := strings.ToLower(strings.TrimSpace(columnType)) + if typ == "" { + return false + } + return strings.Contains(typ, "datetime") || strings.Contains(typ, "timestamp") +} + +func isTimeOnlyColumnType(columnType string) bool { + typ := strings.ToLower(strings.TrimSpace(columnType)) + if typ == "" { + return false + } + if strings.Contains(typ, "datetime") || strings.Contains(typ, "timestamp") { + return false + } + return strings.Contains(typ, "time") +} + +func isDateOnlyColumnType(dbType, columnType string) bool { + typ := strings.ToLower(strings.TrimSpace(columnType)) + if typ == "" { + return false + } + if strings.Contains(typ, "datetime") || strings.Contains(typ, "timestamp") || strings.Contains(typ, "time") { + return false + } + if !strings.Contains(typ, "date") { + return false + } + db := strings.ToLower(strings.TrimSpace(dbType)) + // Oracle/Dameng 的 DATE 带时间语义,不能按纯日期裁剪。 + return db != "oracle" && db != "dameng" +} + +func isTemporalColumnType(dbType, columnType string) bool { + return isDateTimeColumnType(columnType) || isTimeOnlyColumnType(columnType) || isDateOnlyColumnType(dbType, columnType) +} + +func parseTemporalString(raw string) (time.Time, bool) { + text := strings.TrimSpace(raw) + if text == "" { + return time.Time{}, false + } + + layouts := []string{ + "2006-01-02 15:04:05.999999999 -0700 MST", + "2006-01-02 15:04:05 -0700 MST", + "2006-01-02 15:04:05.999999999 -0700", + "2006-01-02 15:04:05 -0700", + time.RFC3339Nano, + time.RFC3339, + "2006-01-02 15:04:05.999999999", + "2006-01-02 15:04:05", + "2006-01-02", + "15:04:05.999999999", + "15:04:05", + } + + for _, layout := range layouts { + parsed, err := time.Parse(layout, text) + if err == nil { + return parsed, true + } + } + + return time.Time{}, false +} + +func normalizeImportTemporalValue(dbType, columnType, raw string) string { + text := strings.TrimSpace(raw) + if text == "" { + return text + } + + parsed, ok := parseTemporalString(text) + if !ok { + if isDateTimeColumnType(columnType) { + candidate := strings.ReplaceAll(text, "T", " ") + if len(candidate) >= 19 { + prefix := candidate[:19] + if _, err := time.Parse("2006-01-02 15:04:05", prefix); err == nil { + return prefix + } + } + } + return text + } + + if isTimeOnlyColumnType(columnType) { + return parsed.Format("15:04:05") + } + if isDateOnlyColumnType(dbType, columnType) { + return parsed.Format("2006-01-02") + } + if isTimezoneAwareColumnType(columnType) { + return parsed.Format("2006-01-02 15:04:05-07:00") + } + return parsed.Format("2006-01-02 15:04:05") +} + +func formatImportSQLValue(dbType, columnType string, value interface{}) string { + if value == nil { + return "NULL" + } + + if isTemporalColumnType(dbType, columnType) { + normalized := normalizeImportTemporalValue(dbType, columnType, fmt.Sprintf("%v", value)) + escaped := strings.ReplaceAll(normalized, "'", "''") + return "'" + escaped + "'" + } + + return formatSQLValue(dbType, value) +} + +// ImportDataWithProgress 执行导入并发送进度事件 +func (a *App) ImportDataWithProgress(config connection.ConnectionConfig, dbName, tableName, filePath string) connection.QueryResult { + rows, columns, err := parseImportFile(filePath) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} } if len(rows) == 0 { @@ -146,29 +378,27 @@ func (a *App) ImportData(config connection.ConnectionConfig, dbName, tableName s return connection.QueryResult{Success: false, Message: err.Error()} } - successCount := 0 - errCount := 0 - firstRow := rows[0] - var cols []string - for k := range firstRow { - cols = append(cols, k) + schemaName, pureTableName := normalizeSchemaAndTable(config, dbName, tableName) + columnTypeMap := map[string]string{} + if defs, colErr := dbInst.GetColumns(schemaName, pureTableName); colErr == nil { + columnTypeMap = buildImportColumnTypeMap(defs) } - for _, row := range rows { + totalRows := len(rows) + successCount := 0 + var errorLogs []string + + quotedCols := make([]string, len(columns)) + for i, c := range columns { + quotedCols[i] = quoteIdentByType(runConfig.Type, c) + } + + for idx, row := range rows { var values []string - for _, col := range cols { + for _, col := range columns { val := row[col] - if val == nil { - values = append(values, "NULL") - } else { - vStr := fmt.Sprintf("%v", val) - vStr = strings.ReplaceAll(vStr, "'", "''") - values = append(values, fmt.Sprintf("'%s'", vStr)) - } - } - quotedCols := make([]string, len(cols)) - for i, c := range cols { - quotedCols[i] = quoteIdentByType(runConfig.Type, c) + colType := columnTypeMap[normalizeColumnName(col)] + values = append(values, formatImportSQLValue(runConfig.Type, colType, val)) } query := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", @@ -178,14 +408,31 @@ func (a *App) ImportData(config connection.ConnectionConfig, dbName, tableName s _, err := dbInst.Exec(query) if err != nil { - errCount++ - logger.Error(err, "导入数据失败:表=%s", tableName) + errorLogs = append(errorLogs, fmt.Sprintf("Row %d: %s", idx+1, err.Error())) } else { successCount++ } + + // 每 10 行发送一次进度事件 + if (idx+1)%10 == 0 || idx == totalRows-1 { + runtime.EventsEmit(a.ctx, "import:progress", map[string]interface{}{ + "current": idx + 1, + "total": totalRows, + "success": successCount, + "errors": len(errorLogs), + }) + } } - return connection.QueryResult{Success: true, Message: fmt.Sprintf("Imported: %d, Failed: %d", successCount, errCount)} + result := map[string]interface{}{ + "success": successCount, + "failed": len(errorLogs), + "total": totalRows, + "errorLogs": errorLogs, + "errorSummary": fmt.Sprintf("Imported: %d, Failed: %d", successCount, len(errorLogs)), + } + + return connection.QueryResult{Success: true, Data: result, Message: fmt.Sprintf("Imported: %d, Failed: %d", successCount, len(errorLogs))} } func (a *App) ApplyChanges(config connection.ConnectionConfig, dbName, tableName string, changes connection.ChangeSet) connection.QueryResult { @@ -695,12 +942,17 @@ func writeRowsToFile(f *os.File, data []map[string]interface{}, columns []string return fmt.Errorf("file required") } + // xlsx 使用 excelize 写入真正的 Excel 格式 + if format == "xlsx" { + return writeRowsToXlsx(f.Name(), data, columns) + } + var csvWriter *csv.Writer var jsonEncoder *json.Encoder isJsonFirstRow := true switch format { - case "csv", "xlsx": + case "csv": if _, err := f.Write([]byte{0xEF, 0xBB, 0xBF}); err != nil { return err } @@ -738,7 +990,7 @@ func writeRowsToFile(f *os.File, data []map[string]interface{}, columns []string continue } - s := fmt.Sprintf("%v", val) + s := formatExportCellText(val) if format == "md" { s = strings.ReplaceAll(s, "|", "\\|") s = strings.ReplaceAll(s, "\n", "
") @@ -747,7 +999,7 @@ func writeRowsToFile(f *os.File, data []map[string]interface{}, columns []string } switch format { - case "csv", "xlsx": + case "csv": if err := csvWriter.Write(record); err != nil { return err } @@ -768,7 +1020,7 @@ func writeRowsToFile(f *os.File, data []map[string]interface{}, columns []string } } - if format == "csv" || format == "xlsx" { + if format == "csv" { csvWriter.Flush() if err := csvWriter.Error(); err != nil { return err @@ -783,3 +1035,50 @@ func writeRowsToFile(f *os.File, data []map[string]interface{}, columns []string return nil } + +func formatExportCellText(val interface{}) string { + if val == nil { + return "NULL" + } + + switch v := val.(type) { + case time.Time: + return v.Format("2006-01-02 15:04:05") + case *time.Time: + if v == nil { + return "NULL" + } + return v.Format("2006-01-02 15:04:05") + default: + return fmt.Sprintf("%v", val) + } +} + +// writeRowsToXlsx 使用 excelize 写入真正的 xlsx 格式文件 +func writeRowsToXlsx(filename string, data []map[string]interface{}, columns []string) error { + xlsx := excelize.NewFile() + defer xlsx.Close() + + sheet := "Sheet1" + + // 写入表头 + for i, col := range columns { + cell, _ := excelize.CoordinatesToCellName(i+1, 1) + xlsx.SetCellValue(sheet, cell, col) + } + + // 写入数据行 + for rowIdx, rowMap := range data { + for colIdx, col := range columns { + cell, _ := excelize.CoordinatesToCellName(colIdx+1, rowIdx+2) + val := rowMap[col] + if val == nil { + xlsx.SetCellValue(sheet, cell, "NULL") + } else { + xlsx.SetCellValue(sheet, cell, formatExportCellText(val)) + } + } + } + + return xlsx.SaveAs(filename) +}