Merge pull request #90 from Syngnat/release/0.3.7

Release/0.3.7
This commit is contained in:
Syngnat
2026-02-09 16:01:12 +08:00
committed by GitHub
23 changed files with 2080 additions and 84 deletions

View File

@@ -37,6 +37,7 @@
- **Oracle**:基础数据访问与编辑支持。
- **Dameng达梦**:基础数据访问与编辑支持。
- **Kingbase人大金仓**:基础数据访问与编辑支持。
- **TDengine**:时序数据库连接、库表浏览与 SQL 查询支持。
- **Redis**Key/Value 浏览、命令执行、视图与编码切换。
- **自定义驱动**:支持配置 Driver/DSN 接入更多数据源。
- **SSH 隧道**:内置 SSH 隧道支持,安全连接内网数据库。

View File

@@ -1 +1 @@
d0f9366af59a6367ad3c7e2d4185ead4
5b8157374dae5f9340e31b2d0bd2c00e

View File

@@ -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: <CloudServerOutlined style={{ fontSize: 24, color: '#47A248' }} /> },
{ key: 'redis', name: 'Redis', icon: <CloudOutlined style={{ fontSize: 24, color: '#DC382D' }} /> },
]},
{ label: '时序数据库', items: [
{ key: 'tdengine', name: 'TDengine', icon: <DatabaseOutlined style={{ fontSize: 24, color: '#2F54EB' }} /> },
]},
{ label: '其他', items: [
{ key: 'custom', name: 'Custom (自定义)', icon: <AppstoreAddOutlined style={{ fontSize: 24, color: '#595959' }} /> },
]},

View File

@@ -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 (
<div style={{ padding: 16, color: '#ff4d4f' }}>
<h4></h4>
<p></p>
<pre style={{ fontSize: 12, whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
{this.state.error?.message}
</pre>
<Button
size="small"
onClick={() => this.setState({ hasError: false, error: null })}
>
</Button>
</div>
);
}
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 <span style={{ color: '#ccc' }}>NULL</span>;
if (typeof val === 'object') return JSON.stringify(val);
if (typeof val === 'string') {
return normalizeDateTimeString(val);
try {
if (val === null) return <span style={{ color: '#ccc' }}>NULL</span>;
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<any>(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<React.Key[]>;
@@ -167,6 +271,7 @@ const EditableCell: React.FC<EditableCellProps> = React.memo(({
...restProps
}) => {
const [editing, setEditing] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const inputRef = useRef<any>(null);
const cellRef = useRef<HTMLTableCellElement>(null);
const form = useContext(EditableContext);
@@ -247,10 +352,37 @@ const EditableCell: React.FC<EditableCellProps> = React.memo(({
) : (
<div
className="editable-cell-value-wrap"
style={{ paddingRight: 24, minHeight: 20 }}
style={{ paddingRight: 24, minHeight: 20, position: 'relative' }}
onContextMenu={handleContextMenu}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{children}
{/* 填充柄 - 仅在悬停时显示 */}
{isHovered && cellContextMenuContext && (
<div
className="fill-handle"
style={{
position: 'absolute',
right: 2,
bottom: 2,
width: 8,
height: 8,
background: '#1890ff',
cursor: 'crosshair',
borderRadius: 1,
zIndex: 10,
}}
title="拖拽向下填充"
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
if (cellRef.current && cellContextMenuContext) {
cellContextMenuContext.handleDragFillStart(record, dataIndex, cellRef.current);
}
}}
/>
)}
</div>
);
}
@@ -420,6 +552,34 @@ const DataGrid: React.FC<DataGridProps> = ({
const containerRef = useRef<HTMLDivElement>(null);
const pendingScrollToBottomRef = useRef(false);
// 拖拽填充状态 - 只保留必要的 React 状态
const [dragFillActive, setDragFillActive] = useState(false);
const dragFillGhostRef = useRef<HTMLDivElement>(null);
const dragFillRafRef = useRef<number | null>(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<DataGridProps> = ({
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<DataGridProps> = ({
</Modal>
<Form component={false} form={form}>
<DataContext.Provider value={{ selectedRowKeysRef, displayDataRef, handleCopyInsert, handleCopyJson, handleCopyCsv, handleExportSelected, copyToClipboard, tableName }}>
<CellContextMenuContext.Provider value={{ showMenu: showCellContextMenu }}>
<CellContextMenuContext.Provider value={{ showMenu: showCellContextMenu, handleBatchFillToSelected, handleDragFillStart }}>
<EditableContext.Provider value={form}>
<Table
components={tableComponents}
@@ -1592,6 +2016,26 @@ const DataGrid: React.FC<DataGridProps> = ({
>
NULL
</div>
<div
style={{
padding: '8px 12px',
cursor: selectedRowKeys.length > 0 ? 'pointer' : 'not-allowed',
transition: 'background 0.2s',
opacity: selectedRowKeys.length > 0 ? 1 : 0.5,
}}
onMouseEnter={(e) => {
if (selectedRowKeys.length > 0) e.currentTarget.style.background = darkMode ? '#303030' : '#f5f5f5';
}}
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
onClick={() => {
if (selectedRowKeys.length > 0 && cellContextMenu.record) {
handleBatchFillToSelected(cellContextMenu.record, cellContextMenu.dataIndex);
}
}}
>
<VerticalAlignBottomOutlined style={{ marginRight: 8 }} />
({selectedRowKeys.length})
</div>
<div style={{ height: 1, background: darkMode ? '#303030' : '#f0f0f0', margin: '4px 0' }} />
<div
style={{
@@ -1741,10 +2185,11 @@ const DataGrid: React.FC<DataGridProps> = ({
.${gridId} .row-modified td { background-color: ${rowModBg} !important; color: ${darkMode ? '#e6f7ff' : 'inherit'}; }
.${gridId} .ant-table-tbody > tr.row-added:hover > td { background-color: ${rowAddedHover} !important; }
.${gridId} .ant-table-tbody > tr.row-modified:hover > td { background-color: ${rowModHover} !important; }
.${gridId} .fill-handle:hover { background: #0050b3 !important; transform: scale(1.2); }
`}</style>
{/* Ghost Resize Line for Columns */}
<div
<div
ref={ghostRef}
style={{
position: 'absolute',
@@ -1759,8 +2204,32 @@ const DataGrid: React.FC<DataGridProps> = ({
willChange: 'transform'
}}
/>
{/* 拖拽填充选区指示器 - 使用 fixed 定位基于视口 */}
{dragFillActive && createPortal(
<div
ref={dragFillGhostRef}
style={{
position: 'fixed',
border: '2px solid #1890ff',
background: 'rgba(24, 144, 255, 0.1)',
pointerEvents: 'none',
zIndex: 9998,
display: 'none',
}}
/>,
document.body
)}
</div>
);
};
export default React.memo(DataGrid);
// 使用 ErrorBoundary 包裹 DataGrid防止数据渲染错误导致应用崩溃
const MemoizedDataGrid = React.memo(DataGrid);
const DataGridWithErrorBoundary: React.FC<DataGridProps> = (props) => (
<DataGridErrorBoundary>
<MemoizedDataGrid {...props} />
</DataGridErrorBoundary>
);
export default DataGridWithErrorBoundary;

View File

@@ -30,6 +30,8 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
const [showFilter, setShowFilter] = useState(false);
const [filterConditions, setFilterConditions] = useState<any[]>([]);
const currentConnType = (connections.find(c => c.id === tab.connectionId)?.config?.type || '').toLowerCase();
const forceReadOnly = currentConnType === 'tdengine';
useEffect(() => {
setPkColumns([]);
@@ -241,6 +243,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
showFilter={showFilter}
onToggleFilter={handleToggleFilter}
onApplyFilter={handleApplyFilter}
readOnly={forceReadOnly}
/>
</div>
);

View File

@@ -919,7 +919,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
const applyAutoLimit = (sql: string, dbType: string, maxRows: number): { sql: string; applied: boolean; maxRows: number } => {
const normalizedType = (dbType || 'mysql').toLowerCase();
const supportsLimit = normalizedType === 'mysql' || normalizedType === 'postgres' || normalizedType === 'kingbase' || normalizedType === 'sqlite' || normalizedType === '';
const supportsLimit = normalizedType === 'mysql' || normalizedType === 'postgres' || normalizedType === 'kingbase' || normalizedType === 'sqlite' || normalizedType === 'tdengine' || normalizedType === '';
if (!supportsLimit) return { sql, applied: false, maxRows };
if (!Number.isFinite(maxRows) || maxRows <= 0) return { sql, applied: false, maxRows };
@@ -997,6 +997,8 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
const nextResultSets: ResultSet[] = [];
const maxRows = Number(queryOptions?.maxRows) || 0;
const dbType = String((config as any).type || 'mysql');
const normalizedDbType = dbType.toLowerCase();
const forceReadOnlyResult = normalizedDbType === 'tdengine';
const wantsLimitProbe = Number.isFinite(maxRows) && maxRows > 0;
const probeLimit = wantsLimitProbe ? (maxRows + 1) : 0;
let anyTruncated = false;
@@ -1053,7 +1055,9 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
const tableMatch = rawStatement.match(/^\s*SELECT\s+\*\s+FROM\s+[`"]?(\w+)[`"]?\s*(?:WHERE.*)?(?:ORDER BY.*)?(?:LIMIT.*)?$/i);
if (tableMatch) {
simpleTableName = tableMatch[1];
pendingPk.push({ resultKey: `result-${idx + 1}`, tableName: simpleTableName });
if (!forceReadOnlyResult) {
pendingPk.push({ resultKey: `result-${idx + 1}`, tableName: simpleTableName });
}
}
nextResultSets.push({

View File

@@ -10,6 +10,10 @@ const { Search } = Input;
const KEY_GROUP_DELIMITER = ':';
const EMPTY_SEGMENT_LABEL = '(empty)';
const REDIS_TREE_KEY_TYPE_WIDTH = 92;
const REDIS_TREE_KEY_TYPE_WIDTH_NARROW = 84;
const REDIS_TREE_KEY_TTL_WIDTH = 92;
const REDIS_TREE_HIDE_TTL_THRESHOLD = 460;
interface RedisViewerProps {
connectionId: string;
@@ -263,7 +267,8 @@ const countGroupLeafNodes = (group: RedisKeyTreeGroup): number => {
const buildRedisKeyTree = (
keys: RedisKeyInfo[],
formatTTL: (ttl: number) => string,
getTypeColor: (type: string) => string
getTypeColor: (type: string) => string,
showTTL: boolean
): RedisKeyTreeResult => {
const root = createTreeGroup('__root__', '__root__');
@@ -330,48 +335,66 @@ const buildRedisKeyTree = (
title: (
<div
style={{
display: 'grid',
gridTemplateColumns: 'minmax(0, 1fr) 92px 92px',
columnGap: 8,
display: 'flex',
alignItems: 'center',
gap: 8,
minWidth: 0,
width: '100%',
overflow: 'hidden',
}}
>
<Space size={6} style={{ minWidth: 0 }}>
<KeyOutlined style={{ color: '#1677ff' }} />
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
minWidth: 0,
flex: 1,
overflow: 'hidden',
}}
>
<KeyOutlined style={{ color: '#1677ff', flexShrink: 0 }} />
<Tooltip title={leaf.keyInfo.key}>
<span
style={{
maxWidth: '100%',
minWidth: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
display: 'inline-block',
verticalAlign: 'bottom',
display: 'block',
}}
>
{leaf.label}
</span>
</Tooltip>
</Space>
</div>
<Tag
color={getTypeColor(leaf.keyInfo.type)}
style={{ marginInlineEnd: 0, width: '100%', textAlign: 'center' }}
style={{
marginInlineEnd: 0,
width: showTTL ? REDIS_TREE_KEY_TYPE_WIDTH : REDIS_TREE_KEY_TYPE_WIDTH_NARROW,
textAlign: 'center',
flexShrink: 0
}}
>
{leaf.keyInfo.type}
</Tag>
<span
style={{
width: '100%',
fontSize: 12,
color: '#999',
textAlign: 'left',
whiteSpace: 'nowrap',
}}
>
{formatTTL(leaf.keyInfo.ttl)}
</span>
{showTTL && (
<span
style={{
width: REDIS_TREE_KEY_TTL_WIDTH,
fontSize: 12,
color: '#999',
textAlign: 'left',
whiteSpace: 'nowrap',
flexShrink: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{formatTTL(leaf.keyInfo.ttl)}
</span>
)}
</div>
),
};
@@ -424,6 +447,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
// 面板宽度状态和 ref - 默认占据 50% 宽度
const [leftPanelWidth, setLeftPanelWidth] = useState<number | string>('50%');
const leftPanelRef = useRef<HTMLDivElement>(null);
const [showTreeKeyTTL, setShowTreeKeyTTL] = useState(true);
const [expandedGroupKeys, setExpandedGroupKeys] = useState<string[]>([]);
const getConfig = useCallback(() => {
@@ -614,9 +638,36 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
return `${Math.floor(ttl / 86400)}${Math.floor((ttl % 86400) / 3600)}`;
};
useEffect(() => {
const target = leftPanelRef.current;
if (!target) return;
const updateTTLVisibility = (width: number) => {
const nextShowTTL = width > REDIS_TREE_HIDE_TTL_THRESHOLD;
setShowTreeKeyTTL((prev) => (prev === nextShowTTL ? prev : nextShowTTL));
};
updateTTLVisibility(Math.round(target.getBoundingClientRect().width));
if (typeof ResizeObserver !== 'undefined') {
const observer = new ResizeObserver((entries) => {
const width = Math.round(entries[0]?.contentRect.width || target.getBoundingClientRect().width);
updateTTLVisibility(width);
});
observer.observe(target);
return () => observer.disconnect();
}
const handleWindowResize = () => {
updateTTLVisibility(Math.round(target.getBoundingClientRect().width));
};
window.addEventListener('resize', handleWindowResize);
return () => window.removeEventListener('resize', handleWindowResize);
}, []);
const keyTree = useMemo(() => {
return buildRedisKeyTree(keys, formatTTL, getTypeColor);
}, [keys]);
return buildRedisKeyTree(keys, formatTTL, getTypeColor, showTreeKeyTTL);
}, [keys, showTreeKeyTTL]);
const selectedTreeNodeKeys = useMemo(() => {
if (!selectedKey) {

View File

@@ -3,6 +3,7 @@ import { Tree, message, Dropdown, MenuProps, Input, Button, Modal, Form, Badge,
import {
DatabaseOutlined,
TableOutlined,
EyeOutlined,
ConsoleSqlOutlined,
HddOutlined,
FolderOpenOutlined,
@@ -28,7 +29,7 @@ import { Tree, message, Dropdown, MenuProps, Input, Button, Modal, Form, Badge,
} from '@ant-design/icons';
import { useStore } from '../store';
import { SavedConnection } from '../types';
import { DBGetDatabases, DBGetTables, DBShowCreateTable, ExportTable, OpenSQLFile, CreateDatabase, RenameDatabase, DropDatabase, RenameTable, DropTable } from '../../wailsjs/go/app/App';
import { DBGetDatabases, DBGetTables, DBQuery, DBShowCreateTable, ExportTable, OpenSQLFile, CreateDatabase, RenameDatabase, DropDatabase, RenameTable, DropTable } from '../../wailsjs/go/app/App';
import { normalizeOpacityForPlatform } from '../utils/appearance';
const { Search } = Input;
@@ -40,7 +41,7 @@ interface TreeNode {
children?: TreeNode[];
icon?: React.ReactNode;
dataRef?: any;
type?: 'connection' | 'database' | 'table' | 'queries-folder' | 'saved-query' | 'folder-columns' | 'folder-indexes' | 'folder-fks' | 'folder-triggers' | 'redis-db';
type?: 'connection' | 'database' | 'table' | 'view' | 'db-trigger' | 'object-group' | 'queries-folder' | 'saved-query' | 'folder-columns' | 'folder-indexes' | 'folder-fks' | 'folder-triggers' | 'redis-db';
}
type BatchTableExportMode = 'schema' | 'backup' | 'dataOnly';
@@ -53,6 +54,10 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
const removeConnection = useStore(state => state.removeConnection);
const theme = useStore(state => state.theme);
const appearance = useStore(state => state.appearance);
const tableAccessCount = useStore(state => state.tableAccessCount);
const tableSortPreference = useStore(state => state.tableSortPreference);
const recordTableAccess = useStore(state => state.recordTableAccess);
const setTableSortPreference = useStore(state => state.setTableSortPreference);
const darkMode = theme === 'dark';
const opacity = normalizeOpacityForPlatform(appearance.opacity);
const [treeData, setTreeData] = useState<TreeNode[]>([]);
@@ -165,6 +170,199 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
});
};
const SIDEBAR_SCHEMA_DB_TYPES = new Set([
'postgres',
'kingbase',
'highgo',
'vastbase',
'sqlserver',
'oracle',
'dameng',
]);
const SIDEBAR_SCHEMA_CUSTOM_DRIVERS = new Set([
'postgres',
'kingbase',
'highgo',
'vastbase',
'sqlserver',
'oracle',
'dm',
]);
const shouldHideSchemaPrefix = (conn: SavedConnection | undefined): boolean => {
const dbType = String(conn?.config?.type || '').trim().toLowerCase();
if (SIDEBAR_SCHEMA_DB_TYPES.has(dbType)) return true;
if (dbType !== 'custom') return false;
const customDriver = String((conn?.config as any)?.driver || '').trim().toLowerCase();
return SIDEBAR_SCHEMA_CUSTOM_DRIVERS.has(customDriver);
};
const getSidebarTableDisplayName = (conn: SavedConnection | undefined, tableName: string): string => {
const rawName = String(tableName || '').trim();
if (!rawName) return rawName;
if (!shouldHideSchemaPrefix(conn)) return rawName;
const lastDotIndex = rawName.lastIndexOf('.');
if (lastDotIndex <= 0 || lastDotIndex >= rawName.length - 1) return rawName;
return rawName.substring(lastDotIndex + 1);
};
const getMetadataDialect = (conn: SavedConnection | undefined): string => {
const type = String(conn?.config?.type || '').trim().toLowerCase();
if (type === 'custom') {
return String((conn?.config as any)?.driver || '').trim().toLowerCase();
}
if (type === 'mariadb') return 'mysql';
if (type === 'dameng') return 'dm';
return type;
};
const escapeSQLLiteral = (raw: string): string => String(raw || '').replace(/'/g, "''");
const quoteSqlServerIdentifier = (raw: string): string => `[${String(raw || '').replace(/]/g, ']]')}]`;
const getCaseInsensitiveValue = (row: Record<string, any>, candidateKeys: string[]): string => {
const keyMap = new Map<string, any>();
Object.keys(row || {}).forEach((key) => keyMap.set(key.toLowerCase(), row[key]));
for (const key of candidateKeys) {
const value = keyMap.get(key.toLowerCase());
if (value !== undefined && value !== null) {
const normalized = String(value).trim();
if (normalized !== '') return normalized;
}
}
return '';
};
const getFirstRowValue = (row: Record<string, any>): string => {
for (const value of Object.values(row || {})) {
if (value !== undefined && value !== null) {
const normalized = String(value).trim();
if (normalized !== '') return normalized;
}
}
return '';
};
const buildQualifiedName = (schemaName: string, objectName: string): string => {
const schema = String(schemaName || '').trim();
const name = String(objectName || '').trim();
if (!name) return '';
if (!schema) return name;
if (name.includes('.')) return name;
return `${schema}.${name}`;
};
const buildViewsMetadataQuery = (dialect: string, dbName: string): string => {
const safeDbName = escapeSQLLiteral(dbName);
switch (dialect) {
case 'mysql':
if (!safeDbName) return '';
return `SELECT TABLE_NAME AS view_name FROM information_schema.views WHERE table_schema = '${safeDbName}' ORDER BY TABLE_NAME`;
case 'postgres':
case 'kingbase':
case 'highgo':
case 'vastbase':
return `SELECT schemaname AS schema_name, viewname AS view_name FROM pg_catalog.pg_views WHERE schemaname != 'information_schema' AND schemaname NOT LIKE 'pg_%' ORDER BY schemaname, viewname`;
case 'sqlserver': {
const safeDb = quoteSqlServerIdentifier(dbName || 'master');
return `SELECT s.name AS schema_name, v.name AS view_name FROM ${safeDb}.sys.views v JOIN ${safeDb}.sys.schemas s ON v.schema_id = s.schema_id ORDER BY s.name, v.name`;
}
case 'oracle':
case 'dm': {
if (!safeDbName) {
return `SELECT VIEW_NAME AS view_name FROM USER_VIEWS ORDER BY VIEW_NAME`;
}
return `SELECT OWNER AS schema_name, VIEW_NAME AS view_name FROM ALL_VIEWS WHERE OWNER = '${safeDbName.toUpperCase()}' ORDER BY VIEW_NAME`;
}
case 'sqlite':
return `SELECT name AS view_name FROM sqlite_master WHERE type = 'view' ORDER BY name`;
default:
return '';
}
};
const buildTriggersMetadataQuery = (dialect: string, dbName: string): string => {
const safeDbName = escapeSQLLiteral(dbName);
switch (dialect) {
case 'mysql':
if (!safeDbName) return '';
return `SELECT TRIGGER_NAME AS trigger_name, EVENT_OBJECT_TABLE AS table_name, TRIGGER_SCHEMA AS schema_name FROM information_schema.triggers WHERE trigger_schema = '${safeDbName}' ORDER BY EVENT_OBJECT_TABLE, TRIGGER_NAME`;
case 'postgres':
case 'kingbase':
case 'highgo':
case 'vastbase':
return `SELECT DISTINCT event_object_schema AS schema_name, event_object_table AS table_name, trigger_name FROM information_schema.triggers WHERE trigger_schema NOT IN ('pg_catalog', 'information_schema') AND trigger_schema NOT LIKE 'pg_%' ORDER BY event_object_schema, event_object_table, trigger_name`;
case 'sqlserver': {
const safeDb = quoteSqlServerIdentifier(dbName || 'master');
return `SELECT s.name AS schema_name, t.name AS table_name, tr.name AS trigger_name FROM ${safeDb}.sys.triggers tr JOIN ${safeDb}.sys.tables t ON tr.parent_id = t.object_id JOIN ${safeDb}.sys.schemas s ON t.schema_id = s.schema_id WHERE tr.parent_class = 1 ORDER BY s.name, t.name, tr.name`;
}
case 'oracle':
case 'dm': {
if (!safeDbName) {
return `SELECT TRIGGER_NAME AS trigger_name, TABLE_NAME AS table_name FROM USER_TRIGGERS ORDER BY TABLE_NAME, TRIGGER_NAME`;
}
return `SELECT OWNER AS schema_name, TABLE_NAME AS table_name, TRIGGER_NAME AS trigger_name FROM ALL_TRIGGERS WHERE OWNER = '${safeDbName.toUpperCase()}' ORDER BY TABLE_NAME, TRIGGER_NAME`;
}
case 'sqlite':
return `SELECT name AS trigger_name, tbl_name AS table_name FROM sqlite_master WHERE type = 'trigger' ORDER BY tbl_name, name`;
default:
return '';
}
};
const queryMetadataRows = async (conn: any, dbName: string, query: string): Promise<Record<string, any>[]> => {
if (!query) return [];
try {
const config = buildRuntimeConfig(conn, dbName);
const result = await DBQuery(config as any, dbName, query);
if (!result.success || !Array.isArray(result.data)) return [];
return result.data as Record<string, any>[];
} catch {
return [];
}
};
const loadViews = async (conn: any, dbName: string): Promise<string[]> => {
const dialect = getMetadataDialect(conn as SavedConnection);
const query = buildViewsMetadataQuery(dialect, dbName);
const rows = await queryMetadataRows(conn, dbName, query);
const seen = new Set<string>();
const views: string[] = [];
rows.forEach((row) => {
const schemaName = getCaseInsensitiveValue(row, ['schema_name', 'schemaname', 'owner', 'table_schema']);
const viewName = getCaseInsensitiveValue(row, ['view_name', 'viewname', 'table_name', 'name']) || getFirstRowValue(row);
const fullName = buildQualifiedName(schemaName, viewName);
if (!fullName || seen.has(fullName)) return;
seen.add(fullName);
views.push(fullName);
});
return views;
};
const loadDatabaseTriggers = async (conn: any, dbName: string): Promise<Array<{ displayName: string; triggerName: string; tableName: string }>> => {
const dialect = getMetadataDialect(conn as SavedConnection);
const query = buildTriggersMetadataQuery(dialect, dbName);
const rows = await queryMetadataRows(conn, dbName, query);
const seen = new Set<string>();
const triggers: Array<{ displayName: string; triggerName: string; tableName: string }> = [];
rows.forEach((row) => {
const triggerName = getCaseInsensitiveValue(row, ['trigger_name', 'triggername', 'name']) || getFirstRowValue(row);
if (!triggerName) return;
const schemaName = getCaseInsensitiveValue(row, ['schema_name', 'schemaname', 'owner', 'event_object_schema', 'trigger_schema']);
const tableName = getCaseInsensitiveValue(row, ['table_name', 'event_object_table', 'tbl_name']);
const fullTableName = buildQualifiedName(schemaName, tableName);
const uniqueKey = `${triggerName}@@${fullTableName}`;
if (seen.has(uniqueKey)) return;
seen.add(uniqueKey);
const displayName = fullTableName ? `${triggerName} (${fullTableName})` : triggerName;
triggers.push({ displayName, triggerName, tableName: fullTableName });
});
return triggers;
};
const loadDatabases = async (node: any) => {
const conn = node.dataRef as SavedConnection;
const loadKey = `dbs-${conn.id}`;
@@ -280,8 +478,9 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
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: tableName,
title: tableDisplayName,
key: `${conn.id}-${conn.dbName}-${tableName}`,
icon: <TableOutlined />,
type: 'table' as const,
@@ -289,8 +488,76 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
isLeaf: false,
};
});
setTreeData(origin => updateTreeData(origin, key, [queriesNode, ...tables]));
const [views, triggers] = await Promise.all([
loadViews(conn, conn.dbName),
loadDatabaseTriggers(conn, conn.dbName),
]);
// 获取当前数据库的排序偏好
const sortPreferenceKey = `${conn.id}-${conn.dbName}`;
const sortBy = tableSortPreference[sortPreferenceKey] || 'name';
// 根据排序偏好排序表
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()));
}
// Sort views by name (case-insensitive)
views.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
// Sort triggers by display name (case-insensitive)
triggers.sort((a, b) => a.displayName.toLowerCase().localeCompare(b.displayName.toLowerCase()));
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,
}));
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,
}));
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 }
});
const groupedNodes: TreeNode[] = [
buildObjectGroup('tables', '表', <TableOutlined />, tables),
buildObjectGroup('views', '视图', <EyeOutlined />, viewNodes),
buildObjectGroup('triggers', '触发器', <FunctionOutlined />, triggerNodes),
];
setTreeData(origin => updateTreeData(origin, key, [queriesNode, ...groupedNodes]));
} else {
setConnectionStates(prev => ({ ...prev, [key as string]: 'error' }));
message.error({ content: res.message, key: `db-${key}-tables` });
@@ -309,7 +576,6 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
await loadTables({ key, dataRef });
} else if (type === 'table') {
// Expand table to show object categories
const { tableName, dbName, id } = dataRef;
const conn = dataRef;
const folders: TreeNode[] = [
@@ -398,6 +664,8 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
setActiveContext({ connectionId: dataRef.id, dbName: title });
} else if (type === 'table') {
setActiveContext({ connectionId: dataRef.id, dbName: dataRef.dbName });
} else if (type === 'view' || type === 'db-trigger') {
setActiveContext({ connectionId: dataRef.id, dbName: dataRef.dbName });
} else if (type === 'saved-query') {
setActiveContext({ connectionId: dataRef.connectionId, dbName: dataRef.dbName });
} else if (type === 'redis-db') {
@@ -418,6 +686,8 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
const onDoubleClick = (e: any, node: any) => {
if (node.type === 'table') {
const { tableName, dbName, id } = node.dataRef;
// 记录表访问
recordTableAccess(id, dbName, tableName);
addTab({
id: node.key,
title: tableName,
@@ -427,6 +697,17 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
tableName,
});
return;
} else if (node.type === 'view') {
const { viewName, dbName, id } = node.dataRef;
addTab({
id: node.key,
title: viewName,
type: 'table',
connectionId: id,
dbName,
tableName: viewName,
});
return;
} else if (node.type === 'saved-query') {
const q = node.dataRef;
addTab({
@@ -448,14 +729,25 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
redisDB: redisDB
});
return;
} else if (node.type === 'db-trigger') {
const { triggerName, dbName, id } = node.dataRef;
addTab({
id: `trigger-${node.key}`,
title: `触发器: ${triggerName}`,
type: 'trigger',
connectionId: id,
dbName,
triggerName
});
return;
}
const key = node.key;
const isExpanded = expandedKeys.includes(key);
const newExpandedKeys = isExpanded
? expandedKeys.filter(k => k !== key)
const newExpandedKeys = isExpanded
? expandedKeys.filter(k => k !== key)
: [...expandedKeys, key];
setExpandedKeys(newExpandedKeys);
if (!isExpanded) setAutoExpandParent(false);
};
@@ -1055,6 +1347,42 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
const conn = node.dataRef as SavedConnection;
const isRedis = conn?.config?.type === 'redis';
// 表分组节点的右键菜单
if (node.type === 'object-group' && node.dataRef?.groupKey === 'tables') {
const groupData = node.dataRef; // { ...conn, dbName, groupKey }
const sortPreferenceKey = `${groupData.id}-${groupData.dbName}`;
const currentSort = tableSortPreference[sortPreferenceKey] || 'name';
return [
{
key: 'sort-by-name',
label: '按名称排序',
icon: currentSort === 'name' ? <CheckSquareOutlined /> : null,
onClick: () => {
setTableSortPreference(groupData.id, groupData.dbName, 'name');
const dbNode = {
key: `${groupData.id}-${groupData.dbName}`,
dataRef: groupData
};
loadTables(dbNode);
}
},
{
key: 'sort-by-frequency',
label: '按使用频率排序',
icon: currentSort === 'frequency' ? <CheckSquareOutlined /> : null,
onClick: () => {
setTableSortPreference(groupData.id, groupData.dbName, 'frequency');
const dbNode = {
key: `${groupData.id}-${groupData.dbName}`,
dataRef: groupData
};
loadTables(dbNode);
}
}
];
}
if (node.type === 'connection') {
// Redis connection menu
if (isRedis) {
@@ -1319,6 +1647,30 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
onClick: () => handleRunSQLFile(node)
}
];
} else if (node.type === 'view') {
return [
{
key: 'open-view',
label: '浏览视图数据',
icon: <EyeOutlined />,
onClick: () => onDoubleClick(null, node)
},
{
key: 'new-query',
label: '新建查询',
icon: <ConsoleSqlOutlined />,
onClick: () => {
addTab({
id: `query-${Date.now()}`,
title: `新建查询`,
type: 'query',
connectionId: node.dataRef.id,
dbName: node.dataRef.dbName,
query: ''
});
}
}
];
} else if (node.type === 'table') {
return [
{
@@ -1397,12 +1749,25 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
if (connectionStates[node.key] === 'success') status = 'success';
else if (connectionStates[node.key] === 'error') status = 'error';
}
const statusBadge = node.type === 'connection' || node.type === 'database' ? (
<Badge status={status} style={{ marginRight: 8 }} />
) : null;
return <span title={node.title}>{statusBadge}{node.title}</span>;
const displayTitle = String(node.title ?? '');
let hoverTitle = displayTitle;
if (node.type === 'table' || node.type === 'view') {
const rawTableName = String(node?.dataRef?.tableName || node?.dataRef?.viewName || '').trim();
const conn = node?.dataRef as SavedConnection | undefined;
if (rawTableName && shouldHideSchemaPrefix(conn)) {
const lastDotIndex = rawTableName.lastIndexOf('.');
if (lastDotIndex > 0 && lastDotIndex < rawTableName.length - 1) {
hoverTitle = rawTableName;
}
}
}
return <span title={hoverTitle}>{statusBadge}{displayTitle}</span>;
};
const onRightClick = ({ event, node }: any) => {

View File

@@ -7,6 +7,7 @@ import QueryEditor from './QueryEditor';
import TableDesigner from './TableDesigner';
import RedisViewer from './RedisViewer';
import RedisCommandEditor from './RedisCommandEditor';
import TriggerViewer from './TriggerViewer';
const TabManager: React.FC = () => {
const tabs = useStore(state => state.tabs);
@@ -40,6 +41,8 @@ const TabManager: React.FC = () => {
content = <RedisViewer connectionId={tab.connectionId} redisDB={tab.redisDB ?? 0} />;
} else if (tab.type === 'redis-command') {
content = <RedisCommandEditor connectionId={tab.connectionId} redisDB={tab.redisDB ?? 0} />;
} else if (tab.type === 'trigger') {
content = <TriggerViewer tab={tab} />;
}
const menuItems: MenuProps['items'] = [

View File

@@ -1,10 +1,11 @@
import React, { useEffect, useState, useContext, useMemo, useRef } from 'react';
import { Table, Tabs, Button, message, Input, Checkbox, Modal, AutoComplete, Tooltip, Select } from 'antd';
import { ReloadOutlined, SaveOutlined, PlusOutlined, DeleteOutlined, MenuOutlined, FileTextOutlined } from '@ant-design/icons';
import { Table, Tabs, Button, message, Input, Checkbox, Modal, AutoComplete, Tooltip, Select, Empty, Space } from 'antd';
import { ReloadOutlined, SaveOutlined, PlusOutlined, DeleteOutlined, MenuOutlined, FileTextOutlined, EyeOutlined, EditOutlined, ExclamationCircleOutlined } from '@ant-design/icons';
import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, DragOverlay } from '@dnd-kit/core';
import { arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { Resizable } from 'react-resizable';
import Editor, { loader } from '@monaco-editor/react';
import { TabData, ColumnDefinition, IndexDefinition, ForeignKeyDefinition, TriggerDefinition } from '../types';
import { useStore } from '../store';
import { DBGetColumns, DBGetIndexes, DBQuery, DBGetForeignKeys, DBGetTriggers, DBShowCreateTable } from '../../wailsjs/go/app/App';
@@ -162,13 +163,47 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
const [previewSql, setPreviewSql] = useState<string>('');
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
const [activeKey, setActiveKey] = useState(tab.initialTab || "columns");
const [selectedTrigger, setSelectedTrigger] = useState<TriggerDefinition | null>(null);
const [isTriggerModalOpen, setIsTriggerModalOpen] = useState(false);
const [isTriggerEditModalOpen, setIsTriggerEditModalOpen] = useState(false);
const [triggerEditMode, setTriggerEditMode] = useState<'create' | 'edit'>('create');
const [triggerEditSql, setTriggerEditSql] = useState<string>('');
const [triggerExecuting, setTriggerExecuting] = useState(false);
const connections = useStore(state => state.connections);
const theme = useStore(state => state.theme);
const darkMode = theme === 'dark';
const readOnly = !!tab.readOnly;
const [tableHeight, setTableHeight] = useState(500);
const containerRef = useRef<HTMLDivElement>(null);
// 初始化透明 Monaco Editor 主题
useEffect(() => {
loader.init().then(monaco => {
monaco.editor.defineTheme('transparent-dark', {
base: 'vs-dark',
inherit: true,
rules: [],
colors: {
'editor.background': '#00000000',
'editor.lineHighlightBackground': '#ffffff10',
'editorGutter.background': '#00000000',
}
});
monaco.editor.defineTheme('transparent-light', {
base: 'vs',
inherit: true,
rules: [],
colors: {
'editor.background': '#00000000',
'editor.lineHighlightBackground': '#00000010',
'editorGutter.background': '#00000000',
}
});
});
}, []);
useEffect(() => {
if (!containerRef.current) return;
const resizeObserver = new ResizeObserver(entries => {
@@ -365,6 +400,215 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
fetchData();
}, [tab]);
// --- Trigger Handlers ---
const getDbType = (): string => {
const conn = connections.find(c => c.id === tab.connectionId);
const type = String(conn?.config?.type || '').toLowerCase();
if (type === 'mariadb') return 'mysql';
if (type === 'dameng') return 'dm';
return type;
};
const generateTriggerTemplate = (): string => {
const dbType = getDbType();
const tblName = tab.tableName || 'table_name';
switch (dbType) {
case 'mysql':
return `CREATE TRIGGER trigger_name
BEFORE INSERT ON \`${tblName}\`
FOR EACH ROW
BEGIN
-- 触发器逻辑
END;`;
case 'postgres':
case 'kingbase':
case 'highgo':
case 'vastbase':
return `CREATE OR REPLACE FUNCTION trigger_function_name()
RETURNS TRIGGER AS $$
BEGIN
-- 触发器逻辑
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trigger_name
BEFORE INSERT ON "${tblName}"
FOR EACH ROW
EXECUTE FUNCTION trigger_function_name();`;
case 'sqlserver':
return `CREATE TRIGGER trigger_name
ON [${tblName}]
AFTER INSERT
AS
BEGIN
SET NOCOUNT ON;
-- 触发器逻辑
END;`;
case 'oracle':
case 'dm':
return `CREATE OR REPLACE TRIGGER trigger_name
BEFORE INSERT ON "${tblName}"
FOR EACH ROW
BEGIN
-- 触发器逻辑
NULL;
END;`;
case 'sqlite':
return `CREATE TRIGGER trigger_name
AFTER INSERT ON "${tblName}"
BEGIN
-- 触发器逻辑
END;`;
default:
return `-- 请输入 CREATE TRIGGER 语句`;
}
};
const buildDropTriggerSql = (triggerName: string): string => {
const dbType = getDbType();
const tblName = tab.tableName || '';
switch (dbType) {
case 'mysql':
return `DROP TRIGGER IF EXISTS \`${triggerName}\``;
case 'postgres':
case 'kingbase':
case 'highgo':
case 'vastbase':
return `DROP TRIGGER IF EXISTS "${triggerName}" ON "${tblName}"`;
case 'sqlserver':
return `DROP TRIGGER IF EXISTS [${triggerName}]`;
case 'oracle':
case 'dm':
return `DROP TRIGGER "${triggerName}"`;
case 'sqlite':
return `DROP TRIGGER IF EXISTS "${triggerName}"`;
default:
return `DROP TRIGGER ${triggerName}`;
}
};
const handleCreateTrigger = () => {
setTriggerEditMode('create');
setTriggerEditSql(generateTriggerTemplate());
setIsTriggerEditModalOpen(true);
};
const handleEditTrigger = () => {
if (!selectedTrigger) return;
setTriggerEditMode('edit');
// 构建完整的 CREATE TRIGGER 语句
const dbType = getDbType();
const tblName = tab.tableName || '';
let createSql = '';
if (dbType === 'mysql') {
createSql = `CREATE TRIGGER \`${selectedTrigger.name}\`
${selectedTrigger.timing} ${selectedTrigger.event} ON \`${tblName}\`
FOR EACH ROW
${selectedTrigger.statement}`;
} else {
createSql = selectedTrigger.statement || '-- 无法获取完整的触发器定义';
}
setTriggerEditSql(createSql);
setIsTriggerEditModalOpen(true);
};
const handleDeleteTrigger = () => {
if (!selectedTrigger) return;
Modal.confirm({
title: '确认删除触发器',
icon: <ExclamationCircleOutlined />,
content: `确定要删除触发器 "${selectedTrigger.name}" 吗?此操作不可撤销。`,
okText: '删除',
okType: 'danger',
cancelText: '取消',
onOk: async () => {
const conn = connections.find(c => c.id === tab.connectionId);
if (!conn) {
message.error('未找到连接');
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 dropSql = buildDropTriggerSql(selectedTrigger.name);
try {
const res = await DBQuery(config as any, tab.dbName || '', dropSql);
if (res.success) {
message.success('触发器删除成功');
setSelectedTrigger(null);
fetchData(); // 刷新列表
} else {
message.error('删除失败: ' + res.message);
}
} catch (e: any) {
message.error('删除失败: ' + (e?.message || String(e)));
}
}
});
};
const handleExecuteTriggerSql = async () => {
const conn = connections.find(c => c.id === tab.connectionId);
if (!conn) {
message.error('未找到连接');
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: "" }
};
setTriggerExecuting(true);
try {
// 如果是编辑模式,先删除旧触发器
if (triggerEditMode === 'edit' && selectedTrigger) {
const dropSql = buildDropTriggerSql(selectedTrigger.name);
const dropRes = await DBQuery(config as any, tab.dbName || '', dropSql);
if (!dropRes.success) {
message.error('删除旧触发器失败: ' + dropRes.message);
setTriggerExecuting(false);
return;
}
}
// 执行创建语句
const res = await DBQuery(config as any, tab.dbName || '', triggerEditSql);
if (res.success) {
message.success(triggerEditMode === 'create' ? '触发器创建成功' : '触发器修改成功');
setIsTriggerEditModalOpen(false);
setSelectedTrigger(null);
fetchData(); // 刷新列表
} else {
message.error('执行失败: ' + res.message);
}
} catch (e: any) {
message.error('执行失败: ' + (e?.message || String(e)));
} finally {
setTriggerExecuting(false);
}
};
// --- Handlers ---
const handleColumnChange = (key: string, field: keyof EditableColumn, value: any) => {
@@ -680,19 +924,61 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
key: 'triggers',
label: '触发器',
children: (
<Table
dataSource={triggers}
columns={[
{ title: '名', dataIndex: 'name', key: 'name' },
{ title: '时间', dataIndex: 'timing', key: 'timing' },
{ title: '事件', dataIndex: 'event', key: 'event' },
{ title: '语句', dataIndex: 'statement', key: 'statement', ellipsis: true },
]}
rowKey="name"
size="small"
pagination={false}
loading={loading}
/>
<div>
<div style={{ marginBottom: 8, display: 'flex', gap: 8 }}>
<Button
size="small"
icon={<EyeOutlined />}
disabled={!selectedTrigger}
onClick={() => setIsTriggerModalOpen(true)}
>
</Button>
<Button size="small" icon={<PlusOutlined />} onClick={handleCreateTrigger}></Button>
<Button size="small" icon={<EditOutlined />} disabled={!selectedTrigger} onClick={handleEditTrigger}></Button>
<Button size="small" icon={<DeleteOutlined />} danger disabled={!selectedTrigger} onClick={handleDeleteTrigger}></Button>
<span style={{ marginLeft: 'auto', color: '#888', fontSize: 12, alignSelf: 'center' }}>
{selectedTrigger ? `已选择: ${selectedTrigger.name}` : '请点击选择触发器'}
</span>
</div>
<Table
dataSource={triggers}
columns={[
{ title: '名称', dataIndex: 'name', key: 'name' },
{ title: '时机', dataIndex: 'timing', key: 'timing', width: 100 },
{ title: '事件', dataIndex: 'event', key: 'event', width: 100 },
]}
rowKey="name"
size="small"
pagination={false}
loading={loading}
locale={{ emptyText: <Empty description="该表暂无触发器" image={Empty.PRESENTED_IMAGE_SIMPLE} /> }}
rowSelection={{
type: 'radio',
selectedRowKeys: selectedTrigger ? [selectedTrigger.name] : [],
onChange: (_, selectedRows) => setSelectedTrigger(selectedRows[0] || null),
onSelect: (record, selected) => {
// 点击单选按钮时,如果已选中则取消
if (selectedTrigger?.name === record.name) {
setSelectedTrigger(null);
} else {
setSelectedTrigger(record);
}
},
}}
onRow={(record) => ({
onClick: () => {
// 点击已选中的行时取消选择
if (selectedTrigger?.name === record.name) {
setSelectedTrigger(null);
} else {
setSelectedTrigger(record);
}
},
style: { cursor: 'pointer' }
})}
/>
</div>
)
}
] : []),
@@ -701,8 +987,22 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
label: 'DDL',
icon: <FileTextOutlined />,
children: (
<div style={{ height: 'calc(100vh - 200px)', overflow: 'auto', padding: 10, background: '#f5f5f5', border: '1px solid #eee' }}>
<pre>{ddl}</pre>
<div style={{ height: 'calc(100vh - 200px)', border: darkMode ? '1px solid #303030' : '1px solid #d9d9d9', borderRadius: 4 }}>
<Editor
height="100%"
language="sql"
theme={darkMode ? 'transparent-dark' : 'transparent-light'}
value={ddl}
options={{
readOnly: true,
minimap: { enabled: false },
fontSize: 14,
lineNumbers: 'on',
scrollBeyondLastLine: false,
wordWrap: 'on',
automaticLayout: true,
}}
/>
</div>
)
}] : [])
@@ -725,6 +1025,75 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
</div>
<p style={{ marginTop: 10, color: '#faad14' }}> SQL</p>
</Modal>
<Modal
title={selectedTrigger ? `触发器: ${selectedTrigger.name}` : '触发器详情'}
open={isTriggerModalOpen}
onCancel={() => setIsTriggerModalOpen(false)}
footer={null}
width={700}
>
{selectedTrigger && (
<div>
<div style={{ marginBottom: 12, display: 'flex', gap: 24 }}>
<span><strong>:</strong> {selectedTrigger.timing}</span>
<span><strong>:</strong> {selectedTrigger.event}</span>
</div>
<div style={{ border: darkMode ? '1px solid #303030' : '1px solid #d9d9d9', borderRadius: 4 }}>
<Editor
height="350px"
language="sql"
theme={darkMode ? 'transparent-dark' : 'transparent-light'}
value={selectedTrigger.statement}
options={{
readOnly: true,
minimap: { enabled: false },
fontSize: 14,
lineNumbers: 'on',
scrollBeyondLastLine: false,
wordWrap: 'on',
automaticLayout: true,
}}
/>
</div>
</div>
)}
</Modal>
<Modal
title={triggerEditMode === 'create' ? '新增触发器' : '修改触发器'}
open={isTriggerEditModalOpen}
onCancel={() => setIsTriggerEditModalOpen(false)}
width={800}
okText={triggerEditMode === 'create' ? '创建' : '保存'}
cancelText="取消"
confirmLoading={triggerExecuting}
onOk={handleExecuteTriggerSql}
>
<div style={{ marginBottom: 8, color: '#888', fontSize: 12 }}>
{triggerEditMode === 'edit' && selectedTrigger && (
<span></span>
)}
</div>
<div style={{ border: darkMode ? '1px solid #303030' : '1px solid #d9d9d9', borderRadius: 4 }}>
<Editor
height="350px"
language="sql"
theme={darkMode ? 'vs-dark' : 'light'}
value={triggerEditSql}
onChange={(val) => setTriggerEditSql(val || '')}
options={{
minimap: { enabled: false },
fontSize: 14,
lineNumbers: 'on',
scrollBeyondLastLine: false,
wordWrap: 'on',
automaticLayout: true,
}}
/>
</div>
<p style={{ marginTop: 10, color: '#faad14' }}> SQL </p>
</Modal>
</div>
);
};

View File

@@ -0,0 +1,240 @@
import React, { useState, useEffect } from 'react';
import Editor, { loader } from '@monaco-editor/react';
import { Spin, Alert } from 'antd';
import { TabData } from '../types';
import { useStore } from '../store';
import { DBQuery } from '../../wailsjs/go/app/App';
interface TriggerViewerProps {
tab: TabData;
}
const TriggerViewer: React.FC<TriggerViewerProps> = ({ tab }) => {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [triggerDefinition, setTriggerDefinition] = useState<string>('');
const connections = useStore(state => state.connections);
const theme = useStore(state => state.theme);
const darkMode = theme === 'dark';
// 初始化透明 Monaco Editor 主题
useEffect(() => {
loader.init().then(monaco => {
monaco.editor.defineTheme('transparent-dark', {
base: 'vs-dark',
inherit: true,
rules: [],
colors: {
'editor.background': '#00000000',
'editor.lineHighlightBackground': '#ffffff10',
'editorGutter.background': '#00000000',
}
});
monaco.editor.defineTheme('transparent-light', {
base: 'vs',
inherit: true,
rules: [],
colors: {
'editor.background': '#00000000',
'editor.lineHighlightBackground': '#00000010',
'editorGutter.background': '#00000000',
}
});
});
}, []);
const escapeSQLLiteral = (raw: string): string => String(raw || '').replace(/'/g, "''");
const quoteSqlServerIdentifier = (raw: string): string => `[${String(raw || '').replace(/]/g, ']]')}]`;
const getMetadataDialect = (conn: any): string => {
const type = String(conn?.config?.type || '').trim().toLowerCase();
if (type === 'custom') {
return String(conn?.config?.driver || '').trim().toLowerCase();
}
if (type === 'mariadb') return 'mysql';
if (type === 'dameng') return 'dm';
return type;
};
const buildShowTriggerQuery = (dialect: string, triggerName: string, dbName: string): string => {
const safeTriggerName = escapeSQLLiteral(triggerName);
const safeDbName = escapeSQLLiteral(dbName);
switch (dialect) {
case 'mysql':
return `SHOW CREATE TRIGGER \`${triggerName.replace(/`/g, '``')}\``;
case 'postgres':
case 'kingbase':
case 'highgo':
case 'vastbase':
return `SELECT pg_get_triggerdef(t.oid, true) AS trigger_definition
FROM pg_trigger t
JOIN pg_class c ON t.tgrelid = c.oid
WHERE t.tgname = '${safeTriggerName}'
AND NOT t.tgisinternal
LIMIT 1`;
case 'sqlserver': {
return `SELECT OBJECT_DEFINITION(OBJECT_ID('${safeTriggerName.replace(/'/g, "''")}')) AS trigger_definition`;
}
case 'oracle':
case 'dm':
if (!safeDbName) {
return `SELECT TRIGGER_BODY FROM USER_TRIGGERS WHERE TRIGGER_NAME = '${safeTriggerName.toUpperCase()}'`;
}
return `SELECT TRIGGER_BODY FROM ALL_TRIGGERS WHERE OWNER = '${safeDbName.toUpperCase()}' AND TRIGGER_NAME = '${safeTriggerName.toUpperCase()}'`;
case 'sqlite':
return `SELECT sql FROM sqlite_master WHERE type = 'trigger' AND name = '${safeTriggerName}'`;
case 'tdengine':
return `-- TDengine 不支持触发器`;
case 'mongodb':
return `-- MongoDB 不支持触发器`;
default:
return `-- 暂不支持该数据库类型的触发器定义查看`;
}
};
const extractTriggerDefinition = (dialect: string, data: any[]): string => {
if (!data || data.length === 0) {
return '-- 未找到触发器定义';
}
const row = data[0];
switch (dialect) {
case 'mysql': {
// MySQL SHOW CREATE TRIGGER returns: Trigger, sql_mode, SQL Original Statement, ...
const keys = Object.keys(row);
const sqlKey = keys.find(k => k.toLowerCase().includes('statement') || k.toLowerCase() === 'sql original statement');
if (sqlKey) return row[sqlKey];
// Fallback: try to find any key containing CREATE TRIGGER
for (const key of keys) {
const val = String(row[key] || '');
if (val.toUpperCase().includes('CREATE TRIGGER')) {
return val;
}
}
return JSON.stringify(row, null, 2);
}
case 'postgres':
case 'kingbase':
case 'highgo':
case 'vastbase': {
return row.trigger_definition || row.TRIGGER_DEFINITION || Object.values(row)[0] || '';
}
case 'sqlserver': {
return row.trigger_definition || row.TRIGGER_DEFINITION || Object.values(row)[0] || '';
}
case 'oracle':
case 'dm': {
return row.trigger_body || row.TRIGGER_BODY || Object.values(row)[0] || '';
}
case 'sqlite': {
return row.sql || row.SQL || Object.values(row)[0] || '';
}
default:
return JSON.stringify(row, null, 2);
}
};
useEffect(() => {
const loadTriggerDefinition = async () => {
setLoading(true);
setError(null);
const conn = connections.find(c => c.id === tab.connectionId);
if (!conn) {
setError('未找到数据库连接');
setLoading(false);
return;
}
const triggerName = tab.triggerName || '';
const dbName = tab.dbName || '';
if (!triggerName) {
setError('触发器名称为空');
setLoading(false);
return;
}
const dialect = getMetadataDialect(conn);
const query = buildShowTriggerQuery(dialect, triggerName, dbName);
if (query.startsWith('--')) {
setTriggerDefinition(query);
setLoading(false);
return;
}
try {
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 result = await DBQuery(config as any, dbName, query);
if (result.success && Array.isArray(result.data)) {
const definition = extractTriggerDefinition(dialect, result.data);
setTriggerDefinition(definition);
} else {
setError(result.message || '查询触发器定义失败');
}
} catch (e: any) {
setError('查询触发器定义失败: ' + (e?.message || String(e)));
} finally {
setLoading(false);
}
};
loadTriggerDefinition();
}, [tab.connectionId, tab.dbName, tab.triggerName, connections]);
if (loading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
<Spin tip="加载触发器定义..." />
</div>
);
}
if (error) {
return (
<div style={{ padding: 16 }}>
<Alert type="error" message="加载失败" description={error} showIcon />
</div>
);
}
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={{ padding: '8px 16px', borderBottom: darkMode ? '1px solid #303030' : '1px solid #f0f0f0' }}>
<strong>: </strong>{tab.triggerName}
{tab.dbName && <span style={{ marginLeft: 16, color: '#888' }}>: {tab.dbName}</span>}
</div>
<div style={{ flex: 1, minHeight: 0 }}>
<Editor
height="100%"
language="sql"
theme={darkMode ? 'transparent-dark' : 'transparent-light'}
value={triggerDefinition}
options={{
readOnly: true,
minimap: { enabled: false },
fontSize: 14,
lineNumbers: 'on',
scrollBeyondLastLine: false,
wordWrap: 'on',
automaticLayout: true,
}}
/>
</div>
</div>
);
};
export default TriggerViewer;

View File

@@ -37,11 +37,13 @@ interface AppState {
sqlFormatOptions: { keywordCase: 'upper' | 'lower' };
queryOptions: { maxRows: number };
sqlLogs: SqlLog[];
tableAccessCount: Record<string, number>;
tableSortPreference: Record<string, 'name' | 'frequency'>;
addConnection: (conn: SavedConnection) => void;
updateConnection: (conn: SavedConnection) => void;
removeConnection: (id: string) => void;
addTab: (tab: TabData) => void;
closeTab: (id: string) => void;
closeOtherTabs: (id: string) => void;
@@ -58,9 +60,12 @@ interface AppState {
setAppearance: (appearance: Partial<{ opacity: number; blur: number }>) => void;
setSqlFormatOptions: (options: { keywordCase: 'upper' | 'lower' }) => void;
setQueryOptions: (options: Partial<{ maxRows: number }>) => void;
addSqlLog: (log: SqlLog) => void;
clearSqlLogs: () => void;
recordTableAccess: (connectionId: string, dbName: string, tableName: string) => void;
setTableSortPreference: (connectionId: string, dbName: string, sortBy: 'name' | 'frequency') => void;
}
export const useStore = create<AppState>()(
@@ -76,10 +81,12 @@ export const useStore = create<AppState>()(
sqlFormatOptions: { keywordCase: 'upper' },
queryOptions: { maxRows: 5000 },
sqlLogs: [],
tableAccessCount: {},
tableSortPreference: {},
addConnection: (conn) => set((state) => ({ connections: [...state.connections, conn] })),
updateConnection: (conn) => set((state) => ({
connections: state.connections.map(c => c.id === conn.id ? conn : c)
updateConnection: (conn) => set((state) => ({
connections: state.connections.map(c => c.id === conn.id ? conn : c)
})),
removeConnection: (id) => set((state) => ({ connections: state.connections.filter(c => c.id !== id) })),
@@ -145,9 +152,30 @@ export const useStore = create<AppState>()(
setAppearance: (appearance) => set((state) => ({ appearance: { ...state.appearance, ...appearance } })),
setSqlFormatOptions: (options) => set({ sqlFormatOptions: options }),
setQueryOptions: (options) => set((state) => ({ queryOptions: { ...state.queryOptions, ...options } })),
addSqlLog: (log) => set((state) => ({ sqlLogs: [log, ...state.sqlLogs].slice(0, 1000) })), // Keep last 1000 logs
clearSqlLogs: () => set({ sqlLogs: [] }),
recordTableAccess: (connectionId, dbName, tableName) => set((state) => {
const key = `${connectionId}-${dbName}-${tableName}`;
const currentCount = state.tableAccessCount[key] || 0;
return {
tableAccessCount: {
...state.tableAccessCount,
[key]: currentCount + 1
}
};
}),
setTableSortPreference: (connectionId, dbName, sortBy) => set((state) => {
const key = `${connectionId}-${dbName}`;
return {
tableSortPreference: {
...state.tableSortPreference,
[key]: sortBy
}
};
}),
}),
{
name: 'lite-db-storage', // name of the item in the storage (must be unique)
@@ -178,7 +206,16 @@ export const useStore = create<AppState>()(
return nextState as AppState;
},
partialize: (state) => ({ connections: state.connections, savedQueries: state.savedQueries, theme: state.theme, appearance: state.appearance, sqlFormatOptions: state.sqlFormatOptions, queryOptions: state.queryOptions }), // Don't persist logs
partialize: (state) => ({
connections: state.connections,
savedQueries: state.savedQueries,
theme: state.theme,
appearance: state.appearance,
sqlFormatOptions: state.sqlFormatOptions,
queryOptions: state.queryOptions,
tableAccessCount: state.tableAccessCount,
tableSortPreference: state.tableSortPreference
}), // Don't persist logs
}
)
);

View File

@@ -62,7 +62,7 @@ export interface TriggerDefinition {
export interface TabData {
id: string;
title: string;
type: 'query' | 'table' | 'design' | 'redis-keys' | 'redis-command';
type: 'query' | 'table' | 'design' | 'redis-keys' | 'redis-command' | 'trigger';
connectionId: string;
dbName?: string;
tableName?: string;
@@ -70,6 +70,7 @@ export interface TabData {
initialTab?: string;
readOnly?: boolean;
redisDB?: number; // Redis database index for redis tabs
triggerName?: string; // Trigger name for trigger tabs
}
export interface DatabaseNode {

View File

@@ -35,7 +35,7 @@ export const quoteIdentPart = (dbType: string, ident: string) => {
if (!raw) return raw;
const dbTypeLower = (dbType || '').toLowerCase();
if (dbTypeLower === 'mysql') {
if (dbTypeLower === 'mysql' || dbTypeLower === 'tdengine') {
return `\`${raw.replace(/`/g, '``')}\``;
}
@@ -197,4 +197,3 @@ export const buildWhereSQL = (dbType: string, conditions: FilterCondition[]) =>
return whereParts.length > 0 ? `WHERE ${whereParts.join(' AND ')}` : '';
};

5
go.mod
View File

@@ -10,6 +10,7 @@ require (
github.com/microsoft/go-mssqldb v1.9.6
github.com/redis/go-redis/v9 v9.17.3
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
go.mongodb.org/mongo-driver/v2 v2.5.0
golang.org/x/crypto v0.47.0
@@ -29,7 +30,9 @@ require (
github.com/golang/snappy v0.0.4 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/hashicorp/go-version v1.7.0 // indirect
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.17.6 // indirect
github.com/labstack/echo/v4 v4.13.3 // indirect
github.com/labstack/gommon v0.4.2 // indirect
@@ -39,6 +42,8 @@ require (
github.com/leaanthony/u v1.1.1 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect

23
go.sum
View File

@@ -24,6 +24,7 @@ github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
@@ -47,16 +48,22 @@ github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY=
github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.17.6 h1:60eq2E/jlfwQXtvZEeBUYADs+BwKBWURIY+Gj2eRGjI=
github.com/klauspost/compress v1.17.6/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
@@ -87,6 +94,10 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/microsoft/go-mssqldb v1.9.6 h1:1MNQg5UiSsokiPz3++K2KPx4moKrwIqly1wv+RyCKTw=
github.com/microsoft/go-mssqldb v1.9.6/go.mod h1:yYMPDufyoF2vVuVCUGtZARr06DKFIhMrluTcgWlXpr4=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
@@ -108,8 +119,18 @@ github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/sijms/go-ora/v2 v2.9.0 h1:+iQbUeTeCOFMb5BsOMgUhV8KWyrv9yjKpcK4x7+MFrg=
github.com/sijms/go-ora/v2 v2.9.0/go.mod h1:QgFInVi3ZWyqAiJwzBQA+nbKYKH77tdp1PYoCqhR2dU=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
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/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/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=
@@ -182,6 +203,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=

View File

@@ -14,7 +14,7 @@ func normalizeRunConfig(config connection.ConnectionConfig, dbName string) conne
}
switch strings.ToLower(strings.TrimSpace(config.Type)) {
case "mysql", "mariadb", "postgres", "kingbase", "highgo", "vastbase", "sqlserver", "mongodb":
case "mysql", "mariadb", "postgres", "kingbase", "highgo", "vastbase", "sqlserver", "mongodb", "tdengine":
// 这些类型的 dbName 表示"数据库",需要写入连接配置以选择目标库。
runConfig.Database = name
case "dameng":
@@ -56,4 +56,3 @@ func normalizeSchemaAndTable(config connection.ConnectionConfig, dbName string,
return rawDB, rawTable
}
}

View File

@@ -51,6 +51,8 @@ func (a *App) CreateDatabase(config connection.ConnectionConfig, dbName string)
if dbType == "postgres" || dbType == "kingbase" || dbType == "highgo" || dbType == "vastbase" {
escapedDbName = strings.ReplaceAll(dbName, `"`, `""`)
query = fmt.Sprintf("CREATE DATABASE \"%s\"", escapedDbName)
} else if dbType == "tdengine" {
query = fmt.Sprintf("CREATE DATABASE IF NOT EXISTS %s", quoteIdentByType(dbType, dbName))
} else if dbType == "mariadb" {
// MariaDB uses same syntax as MySQL
}
@@ -176,7 +178,7 @@ func (a *App) DropDatabase(config connection.ConnectionConfig, dbName string) co
sql string
)
switch dbType {
case "mysql", "mariadb":
case "mysql", "mariadb", "tdengine":
runConfig = config
runConfig.Database = ""
sql = fmt.Sprintf("DROP DATABASE %s", quoteIdentByType(dbType, dbName))
@@ -264,7 +266,7 @@ func (a *App) DropTable(config connection.ConnectionConfig, dbName string, table
dbType := resolveDDLDBType(config)
switch dbType {
case "mysql", "mariadb", "postgres", "kingbase", "sqlite", "oracle", "dameng", "highgo", "vastbase", "sqlserver":
case "mysql", "mariadb", "postgres", "kingbase", "sqlite", "oracle", "dameng", "highgo", "vastbase", "sqlserver", "tdengine":
default:
return connection.QueryResult{Success: false, Message: fmt.Sprintf("当前数据源(%s)暂不支持删除表", dbType)}
}

View File

@@ -408,7 +408,7 @@ func quoteIdentByType(dbType string, ident string) string {
}
switch dbType {
case "mysql", "mariadb":
case "mysql", "mariadb", "tdengine":
return "`" + strings.ReplaceAll(ident, "`", "``") + "`"
case "sqlserver":
escaped := strings.ReplaceAll(ident, "]", "]]")

View File

@@ -221,14 +221,18 @@ func (a *App) downloadAndStageUpdate(info UpdateInfo) connection.QueryResult {
return connection.QueryResult{Success: false, Message: errMsg}
}
stagedDir, err := os.MkdirTemp(workspaceDir, ".gonavi-update-work-")
if err != nil {
errMsg := fmt.Sprintf("无法在应用目录创建更新工作目录:%s", workspaceDir)
// 使用版本号命名的工作目录,便于识别和调试
stagedDir := filepath.Join(workspaceDir, fmt.Sprintf(".gonavi-update-%s-%s", stdRuntime.GOOS, info.LatestVersion))
// 清理可能残留的旧目录(上次下载失败后未清理)
_ = os.RemoveAll(stagedDir)
if err := os.MkdirAll(stagedDir, 0o755); err != nil {
errMsg := fmt.Sprintf("无法在应用目录创建更新工作目录:%s", stagedDir)
a.emitUpdateDownloadProgress("error", 0, info.AssetSize, errMsg)
return connection.QueryResult{Success: false, Message: errMsg}
}
assetPath := filepath.Join(workspaceDir, info.AssetName)
// 下载到 staging 目录,避免覆盖正在运行的可执行文件
assetPath := filepath.Join(stagedDir, info.AssetName)
actualHash, err := downloadFileWithHash(info.AssetURL, assetPath, func(downloaded, total int64) {
reportTotal := total
if reportTotal <= 0 {

View File

@@ -50,6 +50,8 @@ func NewDatabase(dbType string) (Database, error) {
return &MariaDB{}, nil
case "vastbase":
return &VastbaseDB{}, nil
case "tdengine":
return &TDengineDB{}, nil
case "custom":
return &CustomDB{}, nil
default:

View File

@@ -95,3 +95,20 @@ func TestKingbaseDSN_QuotesPasswordWithSpaces(t *testing.T) {
t.Fatalf("dsn 未对包含空格的密码进行引号包裹:%s", dsn)
}
}
func TestTDengineDSN_UsesWebSocketFormat(t *testing.T) {
td := &TDengineDB{}
cfg := connection.ConnectionConfig{
Type: "tdengine",
Host: "127.0.0.1",
Port: 6041,
User: "root",
Password: "taosdata",
Database: "power",
}
dsn := td.getDSN(cfg)
if !strings.HasPrefix(dsn, "root:taosdata@ws(127.0.0.1:6041)/power") {
t.Fatalf("tdengine dsn 格式不正确:%s", dsn)
}
}

View File

@@ -0,0 +1,398 @@
package db
import (
"context"
"database/sql"
"fmt"
"net"
"strconv"
"strings"
"time"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/logger"
"GoNavi-Wails/internal/ssh"
"GoNavi-Wails/internal/utils"
_ "github.com/taosdata/driver-go/v3/taosWS"
)
// TDengineDB implements Database interface for TDengine.
// Uses taosWS driver via WebSocket (通常通过 taosAdapter 提供服务)。
type TDengineDB struct {
conn *sql.DB
pingTimeout time.Duration
forwarder *ssh.LocalForwarder
}
func (t *TDengineDB) getDSN(config connection.ConnectionConfig) string {
user := strings.TrimSpace(config.User)
if user == "" {
user = "root"
}
pass := config.Password
dbName := strings.TrimSpace(config.Database)
path := "/"
if dbName != "" {
path = "/" + dbName
}
return fmt.Sprintf("%s:%s@ws(%s)%s", user, pass, net.JoinHostPort(config.Host, strconv.Itoa(config.Port)), path)
}
func (t *TDengineDB) Connect(config connection.ConnectionConfig) error {
var dsn string
if config.UseSSH {
logger.Infof("TDengine 使用 SSH 连接:地址=%s:%d 用户=%s", config.Host, config.Port, config.User)
forwarder, err := ssh.GetOrCreateLocalForwarder(config.SSH, config.Host, config.Port)
if err != nil {
return fmt.Errorf("创建 SSH 隧道失败:%w", err)
}
t.forwarder = forwarder
host, portStr, err := net.SplitHostPort(forwarder.LocalAddr)
if err != nil {
return fmt.Errorf("解析本地转发地址失败:%w", err)
}
port, err := strconv.Atoi(portStr)
if err != nil {
return fmt.Errorf("解析本地端口失败:%w", err)
}
localConfig := config
localConfig.Host = host
localConfig.Port = port
localConfig.UseSSH = false
dsn = t.getDSN(localConfig)
logger.Infof("TDengine 通过本地端口转发连接:%s -> %s:%d", forwarder.LocalAddr, config.Host, config.Port)
} else {
dsn = t.getDSN(config)
}
db, err := sql.Open("taosWS", dsn)
if err != nil {
return fmt.Errorf("打开数据库连接失败:%w", err)
}
t.conn = db
t.pingTimeout = getConnectTimeout(config)
if err := t.Ping(); err != nil {
return fmt.Errorf("连接建立后验证失败:%w", err)
}
return nil
}
func (t *TDengineDB) Close() error {
if t.forwarder != nil {
if err := t.forwarder.Close(); err != nil {
logger.Warnf("关闭 TDengine SSH 端口转发失败:%v", err)
}
t.forwarder = nil
}
if t.conn != nil {
return t.conn.Close()
}
return nil
}
func (t *TDengineDB) Ping() error {
if t.conn == nil {
return fmt.Errorf("connection not open")
}
timeout := t.pingTimeout
if timeout <= 0 {
timeout = 5 * time.Second
}
ctx, cancel := utils.ContextWithTimeout(timeout)
defer cancel()
return t.conn.PingContext(ctx)
}
func (t *TDengineDB) QueryContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) {
if t.conn == nil {
return nil, nil, fmt.Errorf("connection not open")
}
rows, err := t.conn.QueryContext(ctx, query)
if err != nil {
return nil, nil, err
}
defer rows.Close()
return scanRows(rows)
}
func (t *TDengineDB) Query(query string) ([]map[string]interface{}, []string, error) {
if t.conn == nil {
return nil, nil, fmt.Errorf("connection not open")
}
rows, err := t.conn.Query(query)
if err != nil {
return nil, nil, err
}
defer rows.Close()
return scanRows(rows)
}
func (t *TDengineDB) ExecContext(ctx context.Context, query string) (int64, error) {
if t.conn == nil {
return 0, fmt.Errorf("connection not open")
}
res, err := t.conn.ExecContext(ctx, query)
if err != nil {
return 0, err
}
return res.RowsAffected()
}
func (t *TDengineDB) Exec(query string) (int64, error) {
if t.conn == nil {
return 0, fmt.Errorf("connection not open")
}
res, err := t.conn.Exec(query)
if err != nil {
return 0, err
}
return res.RowsAffected()
}
func (t *TDengineDB) GetDatabases() ([]string, error) {
data, _, err := t.Query("SHOW DATABASES")
if err != nil {
return nil, err
}
var dbs []string
for _, row := range data {
if val, ok := getValueFromRow(row, "name", "database", "Database", "db_name"); ok {
dbs = append(dbs, fmt.Sprintf("%v", val))
continue
}
for _, val := range row {
dbs = append(dbs, fmt.Sprintf("%v", val))
break
}
}
return dbs, nil
}
func (t *TDengineDB) GetTables(dbName string) ([]string, error) {
queries := make([]string, 0, 2)
if strings.TrimSpace(dbName) != "" {
queries = append(queries, fmt.Sprintf("SHOW TABLES FROM `%s`", escapeBacktickIdent(dbName)))
}
queries = append(queries, "SHOW TABLES")
var lastErr error
for _, query := range queries {
data, _, err := t.Query(query)
if err != nil {
lastErr = err
continue
}
var tables []string
for _, row := range data {
if val, ok := getValueFromRow(row, "table_name", "tablename", "name", "Table", "table"); ok {
tables = append(tables, fmt.Sprintf("%v", val))
continue
}
for _, val := range row {
tables = append(tables, fmt.Sprintf("%v", val))
break
}
}
return tables, nil
}
if lastErr != nil {
return nil, lastErr
}
return []string{}, nil
}
func (t *TDengineDB) GetCreateStatement(dbName, tableName string) (string, error) {
qualified := quoteTDengineTable(dbName, tableName)
queries := []string{
fmt.Sprintf("SHOW CREATE TABLE %s", qualified),
fmt.Sprintf("SHOW CREATE STABLE %s", qualified),
}
var lastErr error
for _, query := range queries {
data, _, err := t.Query(query)
if err != nil {
lastErr = err
continue
}
if len(data) == 0 {
continue
}
row := data[0]
if val, ok := getValueFromRow(row, "Create Table", "create table", "Create Stable", "create stable", "SQL", "sql"); ok {
return fmt.Sprintf("%v", val), nil
}
longest := ""
for _, val := range row {
text := fmt.Sprintf("%v", val)
if strings.Contains(strings.ToUpper(text), "CREATE ") && len(text) > len(longest) {
longest = text
}
}
if longest != "" {
return longest, nil
}
}
if lastErr != nil {
return "", lastErr
}
return "", fmt.Errorf("create statement not found")
}
func (t *TDengineDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) {
query := fmt.Sprintf("DESCRIBE %s", quoteTDengineTable(dbName, tableName))
data, _, err := t.Query(query)
if err != nil {
return nil, err
}
columns := make([]connection.ColumnDefinition, 0, len(data))
for _, row := range data {
name, _ := getValueFromRow(row, "Field", "field", "col_name", "column_name", "name")
colType, _ := getValueFromRow(row, "Type", "type", "data_type")
note, _ := getValueFromRow(row, "Note", "note", "Extra", "extra")
nullable, okNull := getValueFromRow(row, "Null", "null", "nullable")
comment, _ := getValueFromRow(row, "Comment", "comment")
defaultVal, hasDefault := getValueFromRow(row, "Default", "default")
col := connection.ColumnDefinition{
Name: fmt.Sprintf("%v", name),
Type: fmt.Sprintf("%v", colType),
Nullable: "YES",
Key: "",
Extra: fmt.Sprintf("%v", note),
Comment: fmt.Sprintf("%v", comment),
}
if okNull {
col.Nullable = strings.ToUpper(fmt.Sprintf("%v", nullable))
}
noteUpper := strings.ToUpper(fmt.Sprintf("%v", note))
if strings.Contains(noteUpper, "TAG") {
col.Key = "TAG"
}
if hasDefault && defaultVal != nil {
def := fmt.Sprintf("%v", defaultVal)
if def != "<nil>" {
col.Default = &def
}
}
columns = append(columns, col)
}
return columns, nil
}
func (t *TDengineDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
if strings.TrimSpace(dbName) == "" {
return nil, fmt.Errorf("database name required for GetAllColumns")
}
tables, err := t.GetTables(dbName)
if err != nil {
return nil, err
}
cols := make([]connection.ColumnDefinitionWithTable, 0)
for _, table := range tables {
tableCols, err := t.GetColumns(dbName, table)
if err != nil {
continue
}
for _, col := range tableCols {
cols = append(cols, connection.ColumnDefinitionWithTable{
TableName: table,
Name: col.Name,
Type: col.Type,
})
}
}
return cols, nil
}
func (t *TDengineDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error) {
return []connection.IndexDefinition{}, nil
}
func (t *TDengineDB) GetForeignKeys(dbName, tableName string) ([]connection.ForeignKeyDefinition, error) {
return []connection.ForeignKeyDefinition{}, nil
}
func (t *TDengineDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDefinition, error) {
return []connection.TriggerDefinition{}, nil
}
func getValueFromRow(row map[string]interface{}, keys ...string) (interface{}, bool) {
if len(row) == 0 {
return nil, false
}
for _, key := range keys {
if val, ok := row[key]; ok {
return val, true
}
}
for existingKey, val := range row {
for _, key := range keys {
if strings.EqualFold(existingKey, key) {
return val, true
}
}
}
return nil, false
}
func escapeBacktickIdent(ident string) string {
return strings.ReplaceAll(strings.TrimSpace(ident), "`", "``")
}
func quoteTDengineTable(dbName, tableName string) string {
t := escapeBacktickIdent(tableName)
if t == "" {
return "``"
}
if strings.Contains(t, ".") {
parts := strings.Split(t, ".")
quoted := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
quoted = append(quoted, fmt.Sprintf("`%s`", escapeBacktickIdent(part)))
}
if len(quoted) > 0 {
return strings.Join(quoted, ".")
}
}
db := escapeBacktickIdent(dbName)
if db == "" {
return fmt.Sprintf("`%s`", t)
}
return fmt.Sprintf("`%s`.`%s`", db, t)
}