mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-05-12 12:19:47 +08:00
@@ -37,6 +37,7 @@
|
||||
- **Oracle**:基础数据访问与编辑支持。
|
||||
- **Dameng(达梦)**:基础数据访问与编辑支持。
|
||||
- **Kingbase(人大金仓)**:基础数据访问与编辑支持。
|
||||
- **TDengine**:时序数据库连接、库表浏览与 SQL 查询支持。
|
||||
- **Redis**:Key/Value 浏览、命令执行、视图与编码切换。
|
||||
- **自定义驱动**:支持配置 Driver/DSN 接入更多数据源。
|
||||
- **SSH 隧道**:内置 SSH 隧道支持,安全连接内网数据库。
|
||||
|
||||
@@ -1 +1 @@
|
||||
d0f9366af59a6367ad3c7e2d4185ead4
|
||||
5b8157374dae5f9340e31b2d0bd2c00e
|
||||
@@ -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' }} /> },
|
||||
]},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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'] = [
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
240
frontend/src/components/TriggerViewer.tsx
Normal file
240
frontend/src/components/TriggerViewer.tsx
Normal 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;
|
||||
@@ -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
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
5
go.mod
@@ -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
23
go.sum
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)}
|
||||
}
|
||||
|
||||
@@ -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, "]", "]]")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
398
internal/db/tdengine_impl.go
Normal file
398
internal/db/tdengine_impl.go
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user