diff --git a/README.md b/README.md
index 470d143..2596349 100644
--- a/README.md
+++ b/README.md
@@ -37,6 +37,7 @@
- **Oracle**:基础数据访问与编辑支持。
- **Dameng(达梦)**:基础数据访问与编辑支持。
- **Kingbase(人大金仓)**:基础数据访问与编辑支持。
+- **TDengine**:时序数据库连接、库表浏览与 SQL 查询支持。
- **Redis**:Key/Value 浏览、命令执行、视图与编码切换。
- **自定义驱动**:支持配置 Driver/DSN 接入更多数据源。
- **SSH 隧道**:内置 SSH 隧道支持,安全连接内网数据库。
diff --git a/frontend/package.json.md5 b/frontend/package.json.md5
index a7661c0..0f8f4fe 100755
--- a/frontend/package.json.md5
+++ b/frontend/package.json.md5
@@ -1 +1 @@
-d0f9366af59a6367ad3c7e2d4185ead4
\ No newline at end of file
+5b8157374dae5f9340e31b2d0bd2c00e
\ No newline at end of file
diff --git a/frontend/src/components/ConnectionModal.tsx b/frontend/src/components/ConnectionModal.tsx
index 4b6f9ba..afb2863 100644
--- a/frontend/src/components/ConnectionModal.tsx
+++ b/frontend/src/components/ConnectionModal.tsx
@@ -194,6 +194,7 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
case 'mysql': defaultPort = 3306; break;
case 'postgres': defaultPort = 5432; break;
case 'redis': defaultPort = 6379; break;
+ case 'tdengine': defaultPort = 6041; break;
case 'oracle': defaultPort = 1521; break;
case 'dameng': defaultPort = 5236; break;
case 'kingbase': defaultPort = 54321; break;
@@ -234,6 +235,9 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
{ key: 'mongodb', name: 'MongoDB', icon: },
{ key: 'redis', name: 'Redis', icon: },
]},
+ { label: '时序数据库', items: [
+ { key: 'tdengine', name: 'TDengine', icon: },
+ ]},
{ label: '其他', items: [
{ key: 'custom', name: 'Custom (自定义)', icon: },
]},
diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx
index f0ec973..c678324 100644
--- a/frontend/src/components/DataGrid.tsx
+++ b/frontend/src/components/DataGrid.tsx
@@ -2,7 +2,7 @@ import React, { useState, useEffect, useRef, useContext, useMemo, useCallback }
import { createPortal } from 'react-dom';
import { Table, message, Input, Button, Dropdown, MenuProps, Form, Pagination, Select, Modal } 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 } from '@ant-design/icons';
+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 { useStore } from '../store';
@@ -11,11 +11,62 @@ import 'react-resizable/css/styles.css';
import { buildWhereSQL, escapeLiteral, quoteIdentPart, quoteQualifiedIdent } from '../utils/sql';
import { blurToFilter, normalizeBlurForPlatform, normalizeOpacityForPlatform } from '../utils/appearance';
+// --- Error Boundary ---
+interface DataGridErrorBoundaryState {
+ hasError: boolean;
+ error: Error | null;
+}
+
+class DataGridErrorBoundary extends React.Component<
+ { children: React.ReactNode },
+ DataGridErrorBoundaryState
+> {
+ constructor(props: { children: React.ReactNode }) {
+ super(props);
+ this.state = { hasError: false, error: null };
+ }
+
+ static getDerivedStateFromError(error: Error): DataGridErrorBoundaryState {
+ return { hasError: true, error };
+ }
+
+ componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
+ console.error('DataGrid render error:', error, errorInfo);
+ }
+
+ render() {
+ if (this.state.hasError) {
+ return (
+
+
渲染错误
+
数据表格渲染时发生错误,可能是数据格式问题。
+
+ {this.state.error?.message}
+
+
+
+ );
+ }
+ return this.props.children;
+ }
+}
+
// 内部行标识字段:避免与真实业务字段(如 `key` 列)冲突。
export const GONAVI_ROW_KEY = '__gonavi_row_key__';
// 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'
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})/);
if (!match) return val;
return `${match[1]} ${match[2]}`;
@@ -23,12 +74,23 @@ const normalizeDateTimeString = (val: string) => {
// --- Helper: Format Value ---
const formatCellValue = (val: any) => {
- if (val === null) return NULL;
- if (typeof val === 'object') return JSON.stringify(val);
- if (typeof val === 'string') {
- return normalizeDateTimeString(val);
+ try {
+ if (val === null) return NULL;
+ if (typeof val === 'object') {
+ try {
+ return JSON.stringify(val);
+ } catch {
+ return '[Object]';
+ }
+ }
+ if (typeof val === 'string') {
+ return normalizeDateTimeString(val);
+ }
+ return String(val);
+ } catch (e) {
+ console.error('formatCellValue error:', e);
+ return '[Error]';
}
- return String(val);
};
const toEditableText = (val: any): string => {
@@ -49,6 +111,46 @@ const toFormText = (val: any): string => {
const INLINE_EDIT_MAX_CHARS = 2000;
+/**
+ * 智能自增算法:
+ * - 纯数字:+1
+ * - 字符串末尾数字:末尾数字 +1(保持前导零位数)
+ * - 无数字:原值不变
+ */
+const smartIncrement = (value: any, step: number = 1): any => {
+ if (value === null || value === undefined) return value;
+
+ // 纯数字类型
+ if (typeof value === 'number') {
+ return value + step;
+ }
+
+ const str = String(value);
+
+ // 纯数字字符串
+ if (/^-?\d+(\.\d+)?$/.test(str)) {
+ const num = parseFloat(str);
+ if (Number.isInteger(num)) {
+ return String(num + step);
+ }
+ return String((num + step).toFixed((str.split('.')[1] || '').length));
+ }
+
+ // 字符串末尾数字模式(如 item_1, user001)
+ const match = str.match(/^(.*?)(\d+)$/);
+ if (match) {
+ const prefix = match[1];
+ const numStr = match[2];
+ const num = parseInt(numStr, 10) + step;
+ // 保持前导零位数
+ const newNumStr = String(num).padStart(numStr.length, '0');
+ return prefix + newNumStr;
+ }
+
+ // 无法自增,返回原值
+ return value;
+};
+
const shouldOpenModalEditor = (val: any): boolean => {
if (val === null || val === undefined) return false;
if (typeof val === 'string') {
@@ -129,6 +231,8 @@ const ResizableTitle = (props: any) => {
const EditableContext = React.createContext(null);
const CellContextMenuContext = React.createContext<{
showMenu: (e: React.MouseEvent, record: Item, dataIndex: string, title: React.ReactNode) => void;
+ handleBatchFillToSelected: (record: Item, dataIndex: string) => void;
+ handleDragFillStart: (record: Item, dataIndex: string, cellElement: HTMLElement) => void;
} | null>(null);
const DataContext = React.createContext<{
selectedRowKeysRef: React.MutableRefObject;
@@ -167,6 +271,7 @@ const EditableCell: React.FC = React.memo(({
...restProps
}) => {
const [editing, setEditing] = useState(false);
+ const [isHovered, setIsHovered] = useState(false);
const inputRef = useRef(null);
const cellRef = useRef(null);
const form = useContext(EditableContext);
@@ -247,10 +352,37 @@ const EditableCell: React.FC = React.memo(({
) : (
setIsHovered(true)}
+ onMouseLeave={() => setIsHovered(false)}
>
{children}
+ {/* 填充柄 - 仅在悬停时显示 */}
+ {isHovered && cellContextMenuContext && (
+
{
+ e.preventDefault();
+ e.stopPropagation();
+ if (cellRef.current && cellContextMenuContext) {
+ cellContextMenuContext.handleDragFillStart(record, dataIndex, cellRef.current);
+ }
+ }}
+ />
+ )}
);
}
@@ -420,6 +552,34 @@ const DataGrid: React.FC
= ({
const containerRef = useRef(null);
const pendingScrollToBottomRef = useRef(false);
+ // 拖拽填充状态 - 只保留必要的 React 状态
+ const [dragFillActive, setDragFillActive] = useState(false);
+ const dragFillGhostRef = useRef(null);
+ const dragFillRafRef = useRef(null);
+ // 使用 ref 存储拖拽数据,避免状态更新导致重渲染
+ const dragFillDataRef = useRef<{
+ startRecord: Item | null;
+ dataIndex: string;
+ startRowIndex: number;
+ currentRowIndex: number;
+ startCellRect: DOMRect | null;
+ colIndex: number;
+ // 缓存 DOM 查询结果
+ cachedRows: HTMLElement[];
+ cachedRowKeys: string[];
+ cachedStartEl: HTMLElement | null;
+ }>({
+ startRecord: null,
+ dataIndex: '',
+ startRowIndex: -1,
+ currentRowIndex: -1,
+ startCellRect: null,
+ colIndex: -1,
+ cachedRows: [],
+ cachedRowKeys: [],
+ cachedStartEl: null,
+ });
+
const scrollTableBodyToBottom = useCallback(() => {
const root = containerRef.current;
if (!root) return;
@@ -580,6 +740,270 @@ const DataGrid: React.FC = ({
const rowKeyStr = useCallback((k: React.Key) => String(k), []);
+ // 批量填充到选中行
+ const handleBatchFillToSelected = useCallback((sourceRecord: Item, dataIndex: string) => {
+ const sourceValue = sourceRecord[dataIndex];
+ const selKeys = selectedRowKeysRef.current;
+
+ if (selKeys.length === 0) {
+ message.info('请先选择要填充的行');
+ return;
+ }
+
+ const sourceKey = sourceRecord?.[GONAVI_ROW_KEY];
+ // 过滤掉源行本身
+ const targetKeys = selKeys.filter(k => k !== sourceKey);
+
+ if (targetKeys.length === 0) {
+ message.info('没有其他选中的行可以填充');
+ return;
+ }
+
+ // 批量更新
+ let updatedCount = 0;
+ targetKeys.forEach(key => {
+ const keyStr = rowKeyStr(key);
+ const isAdded = addedRows.some(r => rowKeyStr(r?.[GONAVI_ROW_KEY]) === keyStr);
+
+ if (isAdded) {
+ setAddedRows(prev => prev.map(r => {
+ if (rowKeyStr(r?.[GONAVI_ROW_KEY]) === keyStr) {
+ updatedCount++;
+ return { ...r, [dataIndex]: sourceValue };
+ }
+ return r;
+ }));
+ } else {
+ setModifiedRows(prev => {
+ const existing = prev[keyStr] || {};
+ // 获取原始行数据
+ const originalRow = displayDataRef.current.find(r => rowKeyStr(r?.[GONAVI_ROW_KEY]) === keyStr);
+ updatedCount++;
+ return {
+ ...prev,
+ [keyStr]: { ...originalRow, ...existing, [dataIndex]: sourceValue }
+ };
+ });
+ }
+ });
+
+ message.success(`已填充 ${updatedCount} 行`);
+ setCellContextMenu(prev => ({ ...prev, visible: false }));
+ }, [addedRows, rowKeyStr]);
+
+ // 拖拽填充开始
+ const handleDragFillStart = useCallback((record: Item, dataIndex: string, cellElement: HTMLElement) => {
+ const currentData = displayDataRef.current;
+ const rowKey = record?.[GONAVI_ROW_KEY];
+ const rowIndex = currentData.findIndex(r => r?.[GONAVI_ROW_KEY] === rowKey);
+
+ if (rowIndex === -1) return;
+
+ const cellRect = cellElement.getBoundingClientRect();
+
+ // 预先计算列索引
+ let colIndex = -1;
+ const headerRow = containerRef.current?.querySelector('.ant-table-thead tr');
+ if (headerRow) {
+ const headerCells = headerRow.querySelectorAll('th');
+ headerCells.forEach((th, idx) => {
+ const titleSpan = th.querySelector('.ant-table-column-title');
+ const titleText = titleSpan?.textContent?.trim() || th.textContent?.trim();
+ if (titleText === dataIndex) {
+ colIndex = idx;
+ }
+ });
+ }
+
+ // 预先缓存所有行的 DOM 元素和 key
+ const tableBody = containerRef.current?.querySelector('.ant-table-body');
+ const rows = tableBody ? Array.from(tableBody.querySelectorAll('tr[data-row-key]')) as HTMLElement[] : [];
+ const rowKeys = rows.map(r => r.getAttribute('data-row-key') || '');
+ const startKey = String(rowKey);
+ const startEl = rows.find((_, i) => rowKeys[i] === startKey) || null;
+
+ // 存储到 ref
+ dragFillDataRef.current = {
+ startRecord: record,
+ dataIndex,
+ startRowIndex: rowIndex,
+ currentRowIndex: rowIndex,
+ startCellRect: cellRect,
+ colIndex,
+ cachedRows: rows,
+ cachedRowKeys: rowKeys,
+ cachedStartEl: startEl,
+ };
+
+ setDragFillActive(true);
+ document.body.style.cursor = 'crosshair';
+ document.body.style.userSelect = 'none';
+ }, []);
+
+ // 拖拽填充移动(极致优化:最小化 DOM 操作)
+ const handleDragFillMove = useCallback((e: MouseEvent) => {
+ const data = dragFillDataRef.current;
+ if (!data.startRecord) return;
+
+ const ghost = dragFillGhostRef.current;
+ if (!ghost) return;
+
+ const mouseY = e.clientY;
+ const rows = data.cachedRows;
+ const rowKeys = data.cachedRowKeys;
+ const startEl = data.cachedStartEl;
+
+ if (!startEl || rows.length === 0) {
+ ghost.style.display = 'none';
+ return;
+ }
+
+ // 二分查找优化:找到鼠标所在的行
+ let endEl: HTMLElement = startEl;
+ let endIdx = data.startRowIndex;
+
+ // 使用简单遍历(行数通常不多,二分查找收益有限)
+ for (let i = 0; i < rows.length; i++) {
+ const row = rows[i];
+ const rect = row.getBoundingClientRect();
+
+ // 只需要检查行的底部边界
+ if (mouseY >= rect.top) {
+ const currentData = displayDataRef.current;
+ const rowKey = rowKeys[i];
+ const dataIdx = currentData.findIndex(r => String(r?.[GONAVI_ROW_KEY]) === rowKey);
+
+ if (dataIdx > data.startRowIndex) {
+ endEl = row;
+ endIdx = dataIdx;
+ }
+ }
+ }
+
+ data.currentRowIndex = endIdx;
+
+ // 直接读取位置并更新样式(单次 reflow)
+ const startRect = startEl.getBoundingClientRect();
+ const endRect = endEl.getBoundingClientRect();
+
+ const cells = startEl.querySelectorAll('td');
+ const targetCell = (data.colIndex >= 0 && cells[data.colIndex]) ? cells[data.colIndex] : null;
+ const cellLeft = targetCell ? targetCell.getBoundingClientRect().left : data.startCellRect!.left;
+ const cellWidth = targetCell ? targetCell.getBoundingClientRect().width : data.startCellRect!.width;
+
+ // 批量设置样式(浏览器会合并为一次重绘)
+ ghost.style.cssText = `
+ position: fixed;
+ display: block;
+ left: ${cellLeft}px;
+ top: ${startRect.top}px;
+ width: ${cellWidth}px;
+ height: ${endRect.bottom - startRect.top}px;
+ border: 2px solid #1890ff;
+ background: rgba(24, 144, 255, 0.1);
+ pointer-events: none;
+ z-index: 9998;
+ `;
+ }, []);
+
+ // 拖拽填充结束
+ const handleDragFillEnd = useCallback(() => {
+ // 清理 RAF
+ if (dragFillRafRef.current !== null) {
+ cancelAnimationFrame(dragFillRafRef.current);
+ dragFillRafRef.current = null;
+ }
+
+ const data = dragFillDataRef.current;
+
+ if (!data.startRecord) {
+ setDragFillActive(false);
+ return;
+ }
+
+ const { startRecord, dataIndex, startRowIndex, currentRowIndex } = data;
+ const sourceValue = startRecord[dataIndex];
+ const currentData = displayDataRef.current;
+
+ // 计算需要填充的行
+ if (currentRowIndex > startRowIndex) {
+ let updatedCount = 0;
+ for (let i = startRowIndex + 1; i <= currentRowIndex && i < currentData.length; i++) {
+ const targetRow = currentData[i];
+ const targetKey = targetRow?.[GONAVI_ROW_KEY];
+ if (targetKey === undefined) continue;
+
+ const keyStr = rowKeyStr(targetKey);
+ const step = i - startRowIndex;
+ const fillValue = smartIncrement(sourceValue, step);
+
+ const isAdded = addedRows.some(r => rowKeyStr(r?.[GONAVI_ROW_KEY]) === keyStr);
+
+ if (isAdded) {
+ setAddedRows(prev => prev.map(r => {
+ if (rowKeyStr(r?.[GONAVI_ROW_KEY]) === keyStr) {
+ updatedCount++;
+ return { ...r, [dataIndex]: fillValue };
+ }
+ return r;
+ }));
+ } else {
+ setModifiedRows(prev => {
+ const existing = prev[keyStr] || {};
+ updatedCount++;
+ return {
+ ...prev,
+ [keyStr]: { ...targetRow, ...existing, [dataIndex]: fillValue }
+ };
+ });
+ }
+ }
+
+ if (updatedCount > 0) {
+ message.success(`已填充 ${updatedCount} 行`);
+ }
+ }
+
+ // 重置状态
+ document.body.style.cursor = '';
+ document.body.style.userSelect = '';
+
+ if (dragFillGhostRef.current) {
+ dragFillGhostRef.current.style.display = 'none';
+ }
+
+ // 重置 ref
+ dragFillDataRef.current = {
+ startRecord: null,
+ dataIndex: '',
+ startRowIndex: -1,
+ currentRowIndex: -1,
+ startCellRect: null,
+ colIndex: -1,
+ cachedRows: [],
+ cachedRowKeys: [],
+ cachedStartEl: null,
+ };
+
+ setDragFillActive(false);
+ }, [addedRows, rowKeyStr]);
+
+ // 全局鼠标事件监听(拖拽填充)
+ useEffect(() => {
+ if (!dragFillActive) return;
+
+ const handleMouseMove = (e: MouseEvent) => handleDragFillMove(e);
+ const handleMouseUp = () => handleDragFillEnd();
+
+ document.addEventListener('mousemove', handleMouseMove);
+ document.addEventListener('mouseup', handleMouseUp);
+
+ return () => {
+ document.removeEventListener('mousemove', handleMouseMove);
+ document.removeEventListener('mouseup', handleMouseUp);
+ };
+ }, [dragFillActive, handleDragFillMove, handleDragFillEnd]);
+
const displayData = useMemo(() => {
return [...data, ...addedRows].filter(item => {
const k = item?.[GONAVI_ROW_KEY];
@@ -1528,7 +1952,7 @@ const DataGrid: React.FC = ({