Merge pull request #95 from Syngnat/release/0.4.0

🔧 fix(data-grid/sidebar/import): 修复时间格式异常并完善schema分层分组
This commit is contained in:
Syngnat
2026-02-10 17:00:48 +08:00
committed by GitHub
11 changed files with 1376 additions and 206 deletions

View File

@@ -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<string, any> => {
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<string, any> = {};
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<DataGridProps> = ({
data, columnNames, loading, tableName, dbName, connectionId, pkColumns = [], readOnly = false,
onReload, onSort, onPageChange, pagination, showFilter, onToggleFilter, onApplyFilter
@@ -452,8 +520,10 @@ const DataGrid: React.FC<DataGridProps> = ({
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<DataGridProps> = ({
const [form] = Form.useForm();
const [modal, contextHolder] = Modal.useModal();
const gridId = useMemo(() => `grid-${uuidv4()}`, []);
const [viewMode, setViewMode] = useState<GridViewMode>('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<string>('');
const rowEditorBaseRawRef = useRef<Record<string, any>>({});
@@ -522,6 +596,10 @@ const DataGrid: React.FC<DataGridProps> = ({
const cellSelectionRafRef = useRef<number | null>(null);
const cellSelectionScrollRafRef = useRef<number | null>(null);
const isDraggingRef = useRef(false);
// 导入预览 Modal 状态
const [importPreviewVisible, setImportPreviewVisible] = useState(false);
const [importFilePath, setImportFilePath] = useState('');
const currentSelectionRef = useRef<Set<string>>(new Set());
const selectionStartRef = useRef<{ rowKey: string; colName: string; rowIndex: number; colIndex: number } | null>(null);
const rowIndexMapRef = useRef<Map<string, number>>(new Map());
@@ -607,6 +685,44 @@ const DataGrid: React.FC<DataGridProps> = ({
// 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<DataGridProps> = ({
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<React.Key[]>([]);
const [addedRows, setAddedRows] = useState<any[]>([]);
@@ -1194,6 +1296,47 @@ const DataGrid: React.FC<DataGridProps> = ({
});
}, [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<DataGridProps> = ({
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<DataGridProps> = ({
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<string>();
addedRows.forEach((r) => {
const key = r?.[GONAVI_ROW_KEY];
if (key === undefined) return;
addedKeySet.add(rowKeyStr(key));
});
const originalMap = new Map<string, any>();
data.forEach((r) => {
const key = r?.[GONAVI_ROW_KEY];
if (key === undefined) return;
originalMap.set(rowKeyStr(key), r);
});
const addedPatchMap = new Map<string, Record<string, any>>();
const updatePatchMap = new Map<string, Record<string, any>>();
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<string, any> = {};
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<string, any> = {};
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<DataGridProps> = ({
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) => (
<div style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{formatCellValue(text)}
@@ -1312,7 +1587,7 @@ const DataGrid: React.FC<DataGridProps> = ({
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<DataGridProps> = ({
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<DataGridProps> = ({
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 (
<div className={`${gridId}${cellEditMode ? ' cell-edit-mode' : ''}`} ref={containerRef} style={{ flex: '1 1 auto', height: '100%', overflow: 'hidden', padding: 0, display: 'flex', flexDirection: 'column', minHeight: 0, background: bgContent }}>
@@ -1746,7 +2049,7 @@ const DataGrid: React.FC<DataGridProps> = ({
{tableName && <Button icon={<ImportOutlined />} onClick={handleImport}></Button>}
{tableName && <Dropdown menu={{ items: exportMenu }}><Button icon={<ExportOutlined />}> <DownOutlined /></Button></Dropdown>}
{!readOnly && tableName && (
{canModifyData && (
<>
<div style={{ width: 1, background: '#eee', height: 20, margin: '0 8px' }} />
<Button icon={<PlusOutlined />} onClick={handleAddRow}></Button>
@@ -1818,6 +2121,37 @@ const DataGrid: React.FC<DataGridProps> = ({
}}></Button>
</>
)}
<div style={{ marginLeft: 'auto' }} />
<Segmented
size="small"
value={viewMode}
options={[
{ label: '表格', value: 'table' },
{ label: 'JSON', value: 'json' },
{ label: '文本', value: 'text' }
]}
onChange={(val) => {
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);
}}
/>
</div>
{/* Filter Panel */}
@@ -2016,44 +2350,145 @@ const DataGrid: React.FC<DataGridProps> = ({
/>
)}
</Modal>
<Modal
title="编辑 JSON 结果集"
open={jsonEditorOpen}
onCancel={() => setJsonEditorOpen(false)}
width={980}
maskClosable={false}
footer={[
<Button key="format" onClick={handleFormatJsonEditor}> JSON</Button>,
<Button key="cancel" onClick={() => setJsonEditorOpen(false)}></Button>,
<Button key="ok" type="primary" onClick={applyJsonEditor}></Button>,
]}
>
<div style={{ marginBottom: 8, color: '#888', fontSize: 12 }}>
JSON
</div>
<Editor
height="56vh"
language="json"
theme={darkMode ? "transparent-dark" : "transparent-light"}
value={jsonEditorValue}
onChange={(val) => setJsonEditorValue(val || '')}
options={{
readOnly: false,
minimap: { enabled: false },
scrollBeyondLastLine: false,
wordWrap: "off",
fontSize: 12,
tabSize: 2,
automaticLayout: true,
}}
/>
</Modal>
<Form component={false} form={form}>
<DataContext.Provider value={{ selectedRowKeysRef, displayDataRef, handleCopyInsert, handleCopyJson, handleCopyCsv, handleExportSelected, copyToClipboard, tableName }}>
<CellContextMenuContext.Provider value={{ showMenu: showCellContextMenu, handleBatchFillToSelected }}>
<EditableContext.Provider value={form}>
<Table
components={tableComponents}
dataSource={mergedDisplayData}
columns={mergedColumns}
size="small"
tableLayout="fixed"
scroll={{ x: Math.max(totalWidth, 1000), y: tableHeight }}
virtual={enableVirtual}
loading={loading}
rowKey={GONAVI_ROW_KEY}
pagination={false}
onChange={handleTableChange}
bordered
rowSelection={{
selectedRowKeys,
onChange: setSelectedRowKeys,
columnWidth: selectionColumnWidth,
}}
rowClassName={(record) => {
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)}
/>
</EditableContext.Provider>
</CellContextMenuContext.Provider>
</DataContext.Provider>
</Form>
{viewMode === 'table' ? (
<Form component={false} form={form}>
<DataContext.Provider value={{ selectedRowKeysRef, displayDataRef, handleCopyInsert, handleCopyJson, handleCopyCsv, handleExportSelected, copyToClipboard, tableName }}>
<CellContextMenuContext.Provider value={{ showMenu: showCellContextMenu, handleBatchFillToSelected }}>
<EditableContext.Provider value={form}>
<Table
components={tableComponents}
dataSource={mergedDisplayData}
columns={mergedColumns}
size="small"
tableLayout="fixed"
scroll={{ x: tableScrollX, y: tableHeight }}
sticky={tableStickyConfig}
virtual={enableVirtual}
loading={loading}
rowKey={GONAVI_ROW_KEY}
pagination={false}
onChange={handleTableChange}
bordered
rowSelection={{
selectedRowKeys,
onChange: setSelectedRowKeys,
columnWidth: selectionColumnWidth,
}}
rowClassName={(record) => {
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)}
/>
</EditableContext.Provider>
</CellContextMenuContext.Provider>
</DataContext.Provider>
</Form>
) : viewMode === 'json' ? (
<div style={{ height: '100%', minHeight: 0, display: 'flex', flexDirection: 'column' }}>
<div style={{ padding: '8px 10px', borderBottom: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(0,0,0,0.08)', display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 12, color: darkMode ? '#999' : '#666' }}>
{mergedDisplayData.length === 0 ? '当前结果集无数据' : `当前结果集 ${mergedDisplayData.length} 条记录`}
</span>
{canModifyData && (
<Button size="small" type="primary" onClick={openJsonEditor} disabled={mergedDisplayData.length === 0}>
JSON
</Button>
)}
</div>
<div style={{ flex: 1, minHeight: 0, padding: '8px 10px 10px 10px' }}>
<Editor
height="100%"
defaultLanguage="json"
language="json"
theme={darkMode ? "transparent-dark" : "transparent-light"}
value={jsonViewText}
options={{
readOnly: true,
minimap: { enabled: false },
scrollBeyondLastLine: false,
wordWrap: "off",
fontSize: 12,
tabSize: 2,
automaticLayout: true,
}}
/>
</div>
</div>
) : (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
<div style={{ padding: '8px 12px', borderBottom: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(0,0,0,0.08)', display: 'flex', alignItems: 'center', gap: 8 }}>
<Button size="small" onClick={() => setTextRecordIndex(i => Math.max(0, i - 1))} disabled={textViewRows.length === 0 || textRecordIndex <= 0}>
</Button>
<Button size="small" onClick={() => setTextRecordIndex(i => Math.min(textViewRows.length - 1, i + 1))} disabled={textViewRows.length === 0 || textRecordIndex >= textViewRows.length - 1}>
</Button>
<span style={{ fontSize: 12, color: darkMode ? '#999' : '#666' }}>
{textViewRows.length === 0 ? '当前结果集无数据' : `记录 ${textRecordIndex + 1} / ${textViewRows.length}`}
</span>
{canModifyData && (
<Button size="small" type="primary" onClick={openCurrentViewRowEditor} disabled={textViewRows.length === 0}>
</Button>
)}
</div>
<div className="custom-scrollbar" style={{ flex: 1, overflow: 'auto', padding: '8px 12px' }}>
{currentTextRow ? columnNames.map((col) => (
<div key={col} style={{ display: 'grid', gridTemplateColumns: '240px 1fr', gap: 10, padding: '6px 0', borderBottom: darkMode ? '1px solid rgba(255,255,255,0.06)' : '1px solid rgba(0,0,0,0.06)', alignItems: 'start' }}>
<div style={{ fontWeight: 600, color: darkMode ? 'rgba(255,255,255,0.9)' : 'rgba(0,0,0,0.88)', wordBreak: 'break-all' }}>
{col} :
</div>
<div style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', color: darkMode ? 'rgba(255,255,255,0.88)' : 'rgba(0,0,0,0.88)' }}>
{formatTextViewValue((currentTextRow as any)[col])}
</div>
</div>
)) : (
<div style={{ fontSize: 12, color: darkMode ? '#999' : '#666', paddingTop: 4 }}>
</div>
)}
</div>
</div>
)}
{/* Cell Context Menu - 使用 Portal 渲染到 body避免 backdropFilter 影响 fixed 定位 */}
{cellContextMenu.visible && createPortal(
{viewMode === 'table' && cellContextMenu.visible && createPortal(
<div
style={{
position: 'fixed',
@@ -2257,6 +2692,23 @@ const DataGrid: React.FC<DataGridProps> = ({
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;
}
`}</style>
{/* Ghost Resize Line for Columns */}
@@ -2275,6 +2727,20 @@ const DataGrid: React.FC<DataGridProps> = ({
willChange: 'transform'
}}
/>
{/* Import Preview Modal */}
<ImportPreviewModal
visible={importPreviewVisible}
filePath={importFilePath}
connectionId={connectionId || ''}
dbName={dbName || ''}
tableName={tableName || ''}
onClose={() => {
setImportPreviewVisible(false);
setImportFilePath('');
}}
onSuccess={handleImportSuccess}
/>
</div>
);
};

View File

@@ -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<ImportPreviewModalProps> = ({
visible,
filePath,
connectionId,
dbName,
tableName,
onClose,
onSuccess
}) => {
const connections = useStore(state => state.connections);
const [loading, setLoading] = useState(true);
const [previewData, setPreviewData] = useState<PreviewData | null>(null);
const [error, setError] = useState<string | null>(null);
const [importing, setImporting] = useState(false);
const [progress, setProgress] = useState<ImportProgress | null>(null);
const [importResult, setImportResult] = useState<any>(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 (
<Modal
title="导入数据预览"
open={visible}
onCancel={onClose}
width={900}
footer={
importResult ? (
<Space>
<Button onClick={onClose}></Button>
</Space>
) : importing ? null : (
<Space>
<Button onClick={onClose}></Button>
<Button
type="primary"
onClick={handleImport}
disabled={!previewData || loading}
>
</Button>
</Space>
)
}
>
{error && <Alert type="error" message={error} style={{ marginBottom: 16 }} showIcon />}
{loading && <div style={{ textAlign: 'center', padding: 40 }}>...</div>}
{!loading && previewData && !importing && !importResult && (
<>
<Alert
type="info"
message={`${previewData.totalRows} 行数据,${previewData.columns.length} 个字段`}
description='以下是前 5 行预览数据,确认无误后点击“开始导入”'
style={{ marginBottom: 16 }}
showIcon
/>
<div style={{ marginBottom: 8, fontWeight: 600 }}></div>
<div style={{ marginBottom: 16, padding: 8, background: '#f5f5f5', borderRadius: 4 }}>
{previewData.columns.join(', ')}
</div>
<div style={{ marginBottom: 8, fontWeight: 600 }}> 5 </div>
<Table
dataSource={previewData.previewRows}
columns={columns}
pagination={false}
scroll={{ x: 'max-content' }}
size="small"
bordered
/>
</>
)}
{importing && progress && (
<div style={{ padding: '40px 20px' }}>
<div style={{ marginBottom: 16, fontSize: 16, fontWeight: 600, textAlign: 'center' }}>
...
</div>
<Progress percent={progressPercent} status="active" />
<div style={{ marginTop: 16, textAlign: 'center', color: '#666' }}>
{progress.current} / {progress.total}
<span style={{ marginLeft: 16, color: '#52c41a' }}>
<CheckCircleOutlined /> {progress.success}
</span>
{progress.errors > 0 && (
<span style={{ marginLeft: 16, color: '#ff4d4f' }}>
<CloseCircleOutlined /> {progress.errors}
</span>
)}
</div>
</div>
)}
{importResult && (
<div style={{ padding: 20 }}>
<Alert
type={importResult.failed === 0 ? 'success' : 'warning'}
message="导入完成"
description={
<div>
<div> {importResult.success} </div>
{importResult.failed > 0 && <div> {importResult.failed} </div>}
</div>
}
showIcon
style={{ marginBottom: 16 }}
/>
{importResult.errorLogs && importResult.errorLogs.length > 0 && (
<>
<div style={{ marginBottom: 8, fontWeight: 600, color: '#ff4d4f' }}></div>
<div style={{
maxHeight: 300,
overflow: 'auto',
background: '#fff1f0',
border: '1px solid #ffccc7',
borderRadius: 4,
padding: 12,
fontSize: 12,
fontFamily: 'monospace'
}}>
{importResult.errorLogs.map((log: string, idx: number) => (
<div key={idx} style={{ marginBottom: 4 }}>{log}</div>
))}
</div>
</>
)}
</div>
)}
</Modal>
);
};
export default ImportPreviewModal;

View File

@@ -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: <TableOutlined />,
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: <EyeOutlined />,
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: <FunctionOutlined />,
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: <CodeOutlined />,
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', '表', <TableOutlined />, tables),
buildObjectGroup('views', '视图', <EyeOutlined />, viewNodes),
buildObjectGroup('routines', '函数', <CodeOutlined />, routineNodes),
buildObjectGroup('triggers', '触发器', <FunctionOutlined />, triggerNodes),
];
const buildTableNode = (entry: { tableName: string; schemaName: string; displayName: string }): TreeNode => ({
title: entry.displayName,
key: `${conn.id}-${conn.dbName}-${entry.tableName}`,
icon: <TableOutlined />,
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: <EyeOutlined />,
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: <FunctionOutlined />,
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: <CodeOutlined />,
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<string, any> = {}
): 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<string, SchemaBucket>();
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', '表', <TableOutlined />, bucket.tables, { schemaName: bucket.schemaName }),
buildObjectGroup(schemaNodeKey, 'views', '视图', <EyeOutlined />, bucket.views, { schemaName: bucket.schemaName }),
buildObjectGroup(schemaNodeKey, 'routines', '函数', <CodeOutlined />, bucket.routines, { schemaName: bucket.schemaName }),
buildObjectGroup(schemaNodeKey, 'triggers', '触发器', <FunctionOutlined />, bucket.triggers, { schemaName: bucket.schemaName }),
];
return {
title: schemaTitle,
key: schemaNodeKey,
icon: <FolderOpenOutlined />,
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', '表', <TableOutlined />, tableEntries.map(buildTableNode)),
buildObjectGroup(key as string, 'views', '视图', <EyeOutlined />, viewEntries.map(buildViewNode)),
buildObjectGroup(key as string, 'routines', '函数', <CodeOutlined />, routineEntries.map(buildRoutineNode)),
buildObjectGroup(key as string, 'triggers', '触发器', <FunctionOutlined />, 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);

View File

@@ -64,6 +64,8 @@ export function ImportConfigFile():Promise<connection.QueryResult>;
export function ImportData(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise<connection.QueryResult>;
export function ImportDataWithProgress(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise<connection.QueryResult>;
export function InstallUpdateAndRestart():Promise<connection.QueryResult>;
export function MongoDiscoverMembers(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;
@@ -80,6 +82,8 @@ export function MySQLShowCreateTable(arg1:connection.ConnectionConfig,arg2:strin
export function OpenSQLFile():Promise<connection.QueryResult>;
export function PreviewImportFile(arg1:string):Promise<connection.QueryResult>;
export function RedisConnect(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;
export function RedisDeleteHashField(arg1:connection.ConnectionConfig,arg2:string,arg3:Array<string>):Promise<connection.QueryResult>;

View File

@@ -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);
}

0
frontend/wailsjs/runtime/package.json Executable file → Normal file
View File

0
frontend/wailsjs/runtime/runtime.d.ts vendored Executable file → Normal file
View File

0
frontend/wailsjs/runtime/runtime.js Executable file → Normal file
View File

6
go.mod
View File

@@ -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

19
go.sum
View File

@@ -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=

View File

@@ -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", "<br>")
@@ -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)
}