mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-05-13 06:49:50 +08:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3b77f490d | ||
|
|
52abed83e6 | ||
|
|
80dc863455 | ||
|
|
1a3b55ce19 | ||
|
|
fa318a9f0e |
1
frontend/.gitignore
vendored
Normal file
1
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
.ace-tool/
|
||||
@@ -63,8 +63,8 @@ body {
|
||||
}
|
||||
|
||||
body[data-theme='dark'] {
|
||||
/* Improve contrast on transparent backgrounds */
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8);
|
||||
/* 移除全局 text-shadow:对每个文本元素增加 GPU compositing 成本,
|
||||
在透明窗口环境下会显著加剧 GPU 负载 */
|
||||
}
|
||||
|
||||
/* 连接配置弹窗:滚动仅在弹窗 body 内部,不使用外层 wrap 滚动条 */
|
||||
|
||||
@@ -11,6 +11,7 @@ import LogPanel from './components/LogPanel';
|
||||
import { useStore } from './store';
|
||||
import { SavedConnection } from './types';
|
||||
import { blurToFilter, normalizeBlurForPlatform, normalizeOpacityForPlatform, isWindowsPlatform } from './utils/appearance';
|
||||
import { SetWindowTranslucency } from '../wailsjs/go/app/App';
|
||||
import './App.css';
|
||||
|
||||
const { Sider, Content } = Layout;
|
||||
@@ -29,6 +30,12 @@ function App() {
|
||||
const blurFilter = blurToFilter(effectiveBlur);
|
||||
const windowCornerRadius = 14;
|
||||
|
||||
// 同步 macOS 窗口透明度:opacity=1.0 且 blur=0 时关闭 NSVisualEffectView,
|
||||
// 避免 GPU 持续计算窗口背后的模糊合成
|
||||
useEffect(() => {
|
||||
SetWindowTranslucency(appearance.opacity, appearance.blur).catch(() => {});
|
||||
}, [appearance.opacity, appearance.blur]);
|
||||
|
||||
// Background Helper
|
||||
const getBg = (darkHex: string, lightHex: string) => {
|
||||
if (!darkMode) return `rgba(255, 255, 255, ${effectiveOpacity})`; // Light mode usually white
|
||||
@@ -601,8 +608,6 @@ function App() {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
background: bgMain,
|
||||
backdropFilter: blurFilter,
|
||||
WebkitBackdropFilter: blurFilter,
|
||||
borderBottom: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitAppRegion: 'drag', // Wails drag region
|
||||
@@ -653,8 +658,6 @@ function App() {
|
||||
padding: '0 8px',
|
||||
borderBottom: 'none',
|
||||
background: bgMain,
|
||||
backdropFilter: blurFilter,
|
||||
WebkitBackdropFilter: blurFilter,
|
||||
}}
|
||||
>
|
||||
<Dropdown menu={{ items: toolsMenu }} placement="bottomLeft">
|
||||
@@ -717,7 +720,7 @@ function App() {
|
||||
/>
|
||||
</Sider>
|
||||
<Content style={{ background: 'transparent', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden', display: 'flex', flexDirection: 'column', background: bgContent, backdropFilter: blurFilter, WebkitBackdropFilter: blurFilter }}>
|
||||
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden', display: 'flex', flexDirection: 'column', background: bgContent }}>
|
||||
<TabManager />
|
||||
</div>
|
||||
{isLogPanelOpen && (
|
||||
|
||||
@@ -1056,17 +1056,6 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
|
||||
<Form.Item name="mongoAuthSource" label="认证库 (authSource)">
|
||||
<Input placeholder="默认使用 database 或 admin" />
|
||||
</Form.Item>
|
||||
<Form.Item name="mongoAuthMechanism" label="验证方式 (authMechanism)">
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="默认由 MongoDB 驱动协商"
|
||||
options={[
|
||||
{ value: 'SCRAM-SHA-1', label: 'SCRAM-SHA-1' },
|
||||
{ value: 'SCRAM-SHA-256', label: 'SCRAM-SHA-256' },
|
||||
{ value: 'MONGODB-AWS', label: 'MONGODB-AWS' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="mongoReadPreference" label="读偏好 (readPreference)">
|
||||
<Select
|
||||
options={[
|
||||
@@ -1109,6 +1098,19 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
|
||||
<Form.Item name="password" label="密码" style={{ flex: 1 }}>
|
||||
<Input.Password />
|
||||
</Form.Item>
|
||||
{dbType === 'mongodb' && (
|
||||
<Form.Item name="mongoAuthMechanism" label="验证方式" style={{ width: 160 }}>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="自动协商"
|
||||
options={[
|
||||
{ value: 'SCRAM-SHA-1', label: 'SCRAM-SHA-1' },
|
||||
{ value: 'SCRAM-SHA-256', label: 'SCRAM-SHA-256' },
|
||||
{ value: 'MONGODB-AWS', label: 'MONGODB-AWS' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import React, { useState, useEffect, useRef, useContext, useMemo, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Table, message, Input, Button, Dropdown, MenuProps, Form, Pagination, Select, Modal, Checkbox } from 'antd';
|
||||
import { Table, message, Input, Button, Dropdown, MenuProps, Form, Pagination, Select, Modal, Checkbox, Segmented } from 'antd';
|
||||
import type { SortOrder } from 'antd/es/table/interface';
|
||||
import { ReloadOutlined, ImportOutlined, ExportOutlined, DownOutlined, PlusOutlined, DeleteOutlined, SaveOutlined, UndoOutlined, FilterOutlined, CloseOutlined, ConsoleSqlOutlined, FileTextOutlined, CopyOutlined, ClearOutlined, EditOutlined, VerticalAlignBottomOutlined } from '@ant-design/icons';
|
||||
import Editor from '@monaco-editor/react';
|
||||
import { ImportData, ExportTable, ExportData, ExportQuery, ApplyChanges } from '../../wailsjs/go/app/App';
|
||||
import ImportPreviewModal from './ImportPreviewModal';
|
||||
import { useStore } from '../store';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import 'react-resizable/css/styles.css';
|
||||
import { buildWhereSQL, escapeLiteral, quoteIdentPart, quoteQualifiedIdent, type FilterCondition } from '../utils/sql';
|
||||
import { blurToFilter, normalizeBlurForPlatform, normalizeOpacityForPlatform } from '../utils/appearance';
|
||||
import { isMacLikePlatform, normalizeOpacityForPlatform } from '../utils/appearance';
|
||||
|
||||
// --- Error Boundary ---
|
||||
interface DataGridErrorBoundaryState {
|
||||
@@ -72,15 +73,18 @@ const splitCellKey = (cellKey: string): { rowKey: string; colName: string } | nu
|
||||
};
|
||||
};
|
||||
|
||||
// Normalize RFC3339-like datetime strings to `YYYY-MM-DD HH:mm:ss` for display/editing.
|
||||
// Also handle invalid datetime values like '0000-00-00 00:00:00'
|
||||
// Normalize common datetime strings to `YYYY-MM-DD HH:mm:ss` for display/editing.
|
||||
// Handles RFC3339 and Go-style datetime text like `2024-05-13 08:32:47 +0800 CST`.
|
||||
// Also keep invalid datetime values like `0000-00-00 00:00:00` unchanged.
|
||||
const normalizeDateTimeString = (val: string) => {
|
||||
// 检查是否为无效日期时间(0000-00-00 或类似格式)
|
||||
if (/^0{4}-0{2}-0{2}/.test(val)) {
|
||||
return val; // 保持原样显示,不尝试转换
|
||||
}
|
||||
|
||||
const match = val.match(/^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2})/);
|
||||
const match = val.match(
|
||||
/^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2}:\d{2})(?:\.\d+)?(?:\s*(?:Z|[+-]\d{2}:?\d{2})(?:\s+[A-Za-z_\/+-]+)?)?$/
|
||||
);
|
||||
if (!match) return val;
|
||||
return `${match[1]} ${match[2]}`;
|
||||
};
|
||||
@@ -135,7 +139,10 @@ const INLINE_EDIT_MAX_CHARS = 2000;
|
||||
const shouldOpenModalEditor = (val: any): boolean => {
|
||||
if (val === null || val === undefined) return false;
|
||||
if (typeof val === 'string') {
|
||||
return val.length > INLINE_EDIT_MAX_CHARS || val.includes('\n');
|
||||
if (val.length > INLINE_EDIT_MAX_CHARS || val.includes('\n')) return true;
|
||||
const trimmed = val.trimStart();
|
||||
if (trimmed.startsWith('{') || trimmed.startsWith('[')) return true;
|
||||
return false;
|
||||
}
|
||||
if (typeof val === 'object') {
|
||||
return true;
|
||||
@@ -167,6 +174,68 @@ const looksLikeJsonText = (text: string): boolean => {
|
||||
return (first === '{' && last === '}') || (first === '[' && last === ']');
|
||||
};
|
||||
|
||||
const isPlainObject = (value: any): value is Record<string, any> => {
|
||||
return Object.prototype.toString.call(value) === '[object Object]';
|
||||
};
|
||||
|
||||
const normalizeValueForJsonView = (value: any): any => {
|
||||
if (value === null || value === undefined) return value;
|
||||
|
||||
if (typeof value === 'string') {
|
||||
const normalizedText = normalizeDateTimeString(value);
|
||||
if (!looksLikeJsonText(normalizedText)) return normalizedText;
|
||||
try {
|
||||
return normalizeValueForJsonView(JSON.parse(normalizedText));
|
||||
} catch {
|
||||
return normalizedText;
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => normalizeValueForJsonView(item));
|
||||
}
|
||||
|
||||
if (isPlainObject(value)) {
|
||||
const next: Record<string, any> = {};
|
||||
Object.entries(value).forEach(([key, val]) => {
|
||||
next[key] = normalizeValueForJsonView(val);
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
const isJsonViewValueEqual = (left: any, right: any): boolean => {
|
||||
const leftNormalized = normalizeValueForJsonView(left);
|
||||
const rightNormalized = normalizeValueForJsonView(right);
|
||||
|
||||
if (leftNormalized === rightNormalized) return true;
|
||||
if (leftNormalized === null || rightNormalized === null) return leftNormalized === rightNormalized;
|
||||
if (leftNormalized === undefined || rightNormalized === undefined) return leftNormalized === rightNormalized;
|
||||
|
||||
if (typeof leftNormalized !== 'object' && typeof rightNormalized !== 'object') {
|
||||
return String(leftNormalized) === String(rightNormalized);
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(leftNormalized) === JSON.stringify(rightNormalized);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const coerceJsonEditorValueForStorage = (currentValue: any, editedValue: any): any => {
|
||||
if (typeof currentValue === 'string') {
|
||||
const raw = currentValue.trim();
|
||||
const parsedCurrent = looksLikeJsonText(raw);
|
||||
if (parsedCurrent && (isPlainObject(editedValue) || Array.isArray(editedValue))) {
|
||||
return JSON.stringify(editedValue);
|
||||
}
|
||||
}
|
||||
return editedValue;
|
||||
};
|
||||
|
||||
// --- Resizable Header (Native Implementation) ---
|
||||
const ResizableTitle = (props: any) => {
|
||||
const { onResizeStart, width, ...restProps } = props;
|
||||
@@ -441,6 +510,8 @@ type GridFilterCondition = FilterCondition & {
|
||||
value2?: string;
|
||||
};
|
||||
|
||||
type GridViewMode = 'table' | 'json' | 'text';
|
||||
|
||||
const DataGrid: React.FC<DataGridProps> = ({
|
||||
data, columnNames, loading, tableName, dbName, connectionId, pkColumns = [], readOnly = false,
|
||||
onReload, onSort, onPageChange, pagination, showFilter, onToggleFilter, onApplyFilter
|
||||
@@ -449,10 +520,10 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
const addSqlLog = useStore(state => state.addSqlLog);
|
||||
const theme = useStore(state => state.theme);
|
||||
const appearance = useStore(state => state.appearance);
|
||||
const isMacLike = useMemo(() => isMacLikePlatform(), []);
|
||||
const darkMode = theme === 'dark';
|
||||
const opacity = normalizeOpacityForPlatform(appearance.opacity);
|
||||
const blur = normalizeBlurForPlatform(appearance.blur);
|
||||
const blurFilter = blurToFilter(blur);
|
||||
const canModifyData = !readOnly && !!tableName;
|
||||
const selectionColumnWidth = 46;
|
||||
|
||||
// Background Helper
|
||||
@@ -478,11 +549,15 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
const [form] = Form.useForm();
|
||||
const [modal, contextHolder] = Modal.useModal();
|
||||
const gridId = useMemo(() => `grid-${uuidv4()}`, []);
|
||||
const [viewMode, setViewMode] = useState<GridViewMode>('table');
|
||||
const [textRecordIndex, setTextRecordIndex] = useState(0);
|
||||
const [cellEditorOpen, setCellEditorOpen] = useState(false);
|
||||
const [cellEditorValue, setCellEditorValue] = useState('');
|
||||
const [cellEditorIsJson, setCellEditorIsJson] = useState(false);
|
||||
const [cellEditorMeta, setCellEditorMeta] = useState<{ record: Item; dataIndex: string; title: string } | null>(null);
|
||||
const cellEditorApplyRef = useRef<((val: string) => void) | null>(null);
|
||||
const [jsonEditorOpen, setJsonEditorOpen] = useState(false);
|
||||
const [jsonEditorValue, setJsonEditorValue] = useState('');
|
||||
const [rowEditorOpen, setRowEditorOpen] = useState(false);
|
||||
const [rowEditorRowKey, setRowEditorRowKey] = useState<string>('');
|
||||
const rowEditorBaseRawRef = useRef<Record<string, any>>({});
|
||||
@@ -521,6 +596,10 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
const cellSelectionRafRef = useRef<number | null>(null);
|
||||
const cellSelectionScrollRafRef = useRef<number | null>(null);
|
||||
const isDraggingRef = useRef(false);
|
||||
|
||||
// 导入预览 Modal 状态
|
||||
const [importPreviewVisible, setImportPreviewVisible] = useState(false);
|
||||
const [importFilePath, setImportFilePath] = useState('');
|
||||
const currentSelectionRef = useRef<Set<string>>(new Set());
|
||||
const selectionStartRef = useRef<{ rowKey: string; colName: string; rowIndex: number; colIndex: number } | null>(null);
|
||||
const rowIndexMapRef = useRef<Map<string, number>>(new Map());
|
||||
@@ -606,6 +685,44 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
|
||||
// Dynamic Height
|
||||
const [tableHeight, setTableHeight] = useState(500);
|
||||
const [tableViewportWidth, setTableViewportWidth] = useState(0);
|
||||
const [tableBodyBottomPadding, setTableBodyBottomPadding] = useState(0);
|
||||
const recalculateTableMetrics = useCallback((targetElement?: HTMLElement | null) => {
|
||||
const target = targetElement || containerRef.current;
|
||||
if (!target) return;
|
||||
|
||||
const height = target.getBoundingClientRect().height;
|
||||
const width = target.getBoundingClientRect().width;
|
||||
if (!Number.isFinite(height) || height < 50) return;
|
||||
if (Number.isFinite(width) && width > 0) {
|
||||
setTableViewportWidth(Math.floor(width));
|
||||
}
|
||||
|
||||
const headerEl =
|
||||
(target.querySelector('.ant-table-header') as HTMLElement | null) ||
|
||||
(target.querySelector('.ant-table-thead') as HTMLElement | null);
|
||||
const rawHeaderHeight = headerEl ? headerEl.getBoundingClientRect().height : NaN;
|
||||
const headerHeight =
|
||||
Number.isFinite(rawHeaderHeight) && rawHeaderHeight >= 24 && rawHeaderHeight <= 120 ? rawHeaderHeight : 42;
|
||||
|
||||
const bodyEl = target.querySelector('.ant-table-body') as HTMLElement | null;
|
||||
const stickyScrollEl = target.querySelector('.ant-table-sticky-scroll') as HTMLElement | null;
|
||||
const hasHorizontalOverflow = !!bodyEl && (bodyEl.scrollWidth - bodyEl.clientWidth > 1);
|
||||
const nativeHorizontalScrollbarHeight = bodyEl ? Math.max(0, Math.ceil(bodyEl.offsetHeight - bodyEl.clientHeight)) : 0;
|
||||
const stickyScrollHeight = stickyScrollEl ? Math.ceil(stickyScrollEl.getBoundingClientRect().height) : 0;
|
||||
// 动态为横向滚动条(含 sticky 条)预留空间,避免最后一行被遮住。
|
||||
const horizontalReserve = hasHorizontalOverflow
|
||||
? Math.max(nativeHorizontalScrollbarHeight, stickyScrollHeight, 14)
|
||||
: Math.max(nativeHorizontalScrollbarHeight, 0);
|
||||
// sticky 横向滚动条会覆盖在表格底部,额外给 body 增加内边距,确保最后一行完整可见。
|
||||
const nextBodyBottomPadding = hasHorizontalOverflow
|
||||
? Math.max(stickyScrollHeight, nativeHorizontalScrollbarHeight, 14) + 6
|
||||
: 0;
|
||||
setTableBodyBottomPadding(nextBodyBottomPadding);
|
||||
const extraBottom = 10 + horizontalReserve;
|
||||
const nextHeight = Math.max(100, Math.floor(height - headerHeight - extraBottom));
|
||||
setTableHeight(nextHeight);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
@@ -617,31 +734,17 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
if (rafId !== null) cancelAnimationFrame(rafId);
|
||||
rafId = requestAnimationFrame(() => {
|
||||
const target = (entries[0]?.target as HTMLElement | undefined) || containerRef.current;
|
||||
if (!target) return;
|
||||
|
||||
const height = target.getBoundingClientRect().height;
|
||||
if (!Number.isFinite(height) || height < 50) return;
|
||||
|
||||
const headerEl =
|
||||
(target.querySelector('.ant-table-header') as HTMLElement | null) ||
|
||||
(target.querySelector('.ant-table-thead') as HTMLElement | null);
|
||||
const rawHeaderHeight = headerEl ? headerEl.getBoundingClientRect().height : NaN;
|
||||
const headerHeight =
|
||||
Number.isFinite(rawHeaderHeight) && rawHeaderHeight >= 24 && rawHeaderHeight <= 120 ? rawHeaderHeight : 42;
|
||||
|
||||
// 留一点余量,避免底部(边框/滚动条)遮挡最后一行
|
||||
const extraBottom = 16;
|
||||
const nextHeight = Math.max(100, Math.floor(height - headerHeight - extraBottom));
|
||||
setTableHeight(nextHeight);
|
||||
recalculateTableMetrics(target);
|
||||
});
|
||||
});
|
||||
|
||||
resizeObserver.observe(el);
|
||||
rafId = requestAnimationFrame(() => recalculateTableMetrics(el));
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
if (rafId !== null) cancelAnimationFrame(rafId);
|
||||
};
|
||||
}, []);
|
||||
}, [recalculateTableMetrics]);
|
||||
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
|
||||
const [addedRows, setAddedRows] = useState<any[]>([]);
|
||||
@@ -1193,6 +1296,47 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
});
|
||||
}, [displayData, modifiedRows]);
|
||||
|
||||
useEffect(() => {
|
||||
setTextRecordIndex(prev => {
|
||||
if (mergedDisplayData.length === 0) return 0;
|
||||
return Math.min(prev, mergedDisplayData.length - 1);
|
||||
});
|
||||
}, [mergedDisplayData.length]);
|
||||
|
||||
const jsonViewText = useMemo(() => {
|
||||
const cleanRows = mergedDisplayData.map((row) => {
|
||||
const { [GONAVI_ROW_KEY]: _rowKey, ...rest } = row || {};
|
||||
return normalizeValueForJsonView(rest);
|
||||
});
|
||||
return JSON.stringify(cleanRows, null, 2);
|
||||
}, [mergedDisplayData]);
|
||||
|
||||
const textViewRows = useMemo(() => {
|
||||
return mergedDisplayData.map((row) => {
|
||||
const { [GONAVI_ROW_KEY]: _rowKey, ...rest } = row || {};
|
||||
return rest;
|
||||
});
|
||||
}, [mergedDisplayData]);
|
||||
|
||||
const currentTextRow = useMemo(() => {
|
||||
if (textViewRows.length === 0) return null;
|
||||
return textViewRows[textRecordIndex] || null;
|
||||
}, [textViewRows, textRecordIndex]);
|
||||
|
||||
const formatTextViewValue = useCallback((val: any): string => {
|
||||
if (val === null) return 'NULL';
|
||||
if (val === undefined) return '';
|
||||
if (typeof val === 'string') return normalizeDateTimeString(val);
|
||||
if (typeof val === 'object') {
|
||||
try {
|
||||
return JSON.stringify(val, null, 2);
|
||||
} catch {
|
||||
return String(val);
|
||||
}
|
||||
}
|
||||
return String(val);
|
||||
}, []);
|
||||
|
||||
const closeRowEditor = useCallback(() => {
|
||||
setRowEditorOpen(false);
|
||||
setRowEditorRowKey('');
|
||||
@@ -1202,20 +1346,12 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
rowEditorForm.resetFields();
|
||||
}, [rowEditorForm]);
|
||||
|
||||
const openRowEditor = useCallback(() => {
|
||||
if (readOnly || !tableName) return;
|
||||
if (selectedRowKeys.length > 1) {
|
||||
message.info('一次只能编辑一行,请仅选择一行');
|
||||
return;
|
||||
}
|
||||
|
||||
const keyStr =
|
||||
selectedRowKeys.length === 1 ? rowKeyStr(selectedRowKeys[0]) : undefined;
|
||||
const openRowEditorByKey = useCallback((keyStr?: string) => {
|
||||
if (!canModifyData) return;
|
||||
if (!keyStr) {
|
||||
message.info('请先选择一行(勾选复选框)');
|
||||
message.info('请先定位到要编辑的记录');
|
||||
return;
|
||||
}
|
||||
|
||||
const displayRow = mergedDisplayData.find(r => rowKeyStr(r?.[GONAVI_ROW_KEY]) === keyStr);
|
||||
if (!displayRow) {
|
||||
message.error('未找到目标行,请刷新后重试');
|
||||
@@ -1248,7 +1384,147 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
rowEditorForm.setFieldsValue(formMap);
|
||||
setRowEditorRowKey(keyStr);
|
||||
setRowEditorOpen(true);
|
||||
}, [readOnly, tableName, selectedRowKeys, mergedDisplayData, data, addedRows, columnNames, rowEditorForm, rowKeyStr]);
|
||||
}, [canModifyData, mergedDisplayData, data, addedRows, columnNames, rowEditorForm, rowKeyStr]);
|
||||
|
||||
const openRowEditor = useCallback(() => {
|
||||
if (!canModifyData) return;
|
||||
if (selectedRowKeys.length > 1) {
|
||||
message.info('一次只能编辑一行,请仅选择一行');
|
||||
return;
|
||||
}
|
||||
const keyStr = selectedRowKeys.length === 1 ? rowKeyStr(selectedRowKeys[0]) : undefined;
|
||||
if (!keyStr) {
|
||||
message.info('请先选择一行(勾选复选框)');
|
||||
return;
|
||||
}
|
||||
openRowEditorByKey(keyStr);
|
||||
}, [canModifyData, selectedRowKeys, rowKeyStr, openRowEditorByKey]);
|
||||
|
||||
const openCurrentViewRowEditor = useCallback(() => {
|
||||
if (!canModifyData) return;
|
||||
const currentRow = mergedDisplayData[textRecordIndex];
|
||||
const rowKey = currentRow?.[GONAVI_ROW_KEY];
|
||||
if (rowKey === undefined || rowKey === null) {
|
||||
message.info('当前记录不可编辑');
|
||||
return;
|
||||
}
|
||||
openRowEditorByKey(rowKeyStr(rowKey));
|
||||
}, [canModifyData, mergedDisplayData, textRecordIndex, rowKeyStr, openRowEditorByKey]);
|
||||
|
||||
const openJsonEditor = useCallback(() => {
|
||||
if (!canModifyData) return;
|
||||
setJsonEditorValue(jsonViewText);
|
||||
setJsonEditorOpen(true);
|
||||
}, [canModifyData, jsonViewText]);
|
||||
|
||||
const handleFormatJsonEditor = useCallback(() => {
|
||||
try {
|
||||
const parsed = JSON.parse(jsonEditorValue);
|
||||
setJsonEditorValue(JSON.stringify(parsed, null, 2));
|
||||
} catch (e: any) {
|
||||
message.error("JSON 格式无效:" + (e?.message || String(e)));
|
||||
}
|
||||
}, [jsonEditorValue]);
|
||||
|
||||
const applyJsonEditor = useCallback(() => {
|
||||
if (!canModifyData) return;
|
||||
let parsed: any;
|
||||
try {
|
||||
parsed = JSON.parse(jsonEditorValue);
|
||||
} catch (e: any) {
|
||||
message.error("JSON 解析失败:" + (e?.message || String(e)));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Array.isArray(parsed)) {
|
||||
message.error("JSON 视图必须是数组格式(每项对应一条记录)");
|
||||
return;
|
||||
}
|
||||
if (parsed.length !== mergedDisplayData.length) {
|
||||
message.error(`记录条数不一致:当前 ${mergedDisplayData.length} 条,JSON 中 ${parsed.length} 条。请勿在此模式增删记录。`);
|
||||
return;
|
||||
}
|
||||
|
||||
const addedKeySet = new Set<string>();
|
||||
addedRows.forEach((r) => {
|
||||
const key = r?.[GONAVI_ROW_KEY];
|
||||
if (key === undefined) return;
|
||||
addedKeySet.add(rowKeyStr(key));
|
||||
});
|
||||
|
||||
const originalMap = new Map<string, any>();
|
||||
data.forEach((r) => {
|
||||
const key = r?.[GONAVI_ROW_KEY];
|
||||
if (key === undefined) return;
|
||||
originalMap.set(rowKeyStr(key), r);
|
||||
});
|
||||
|
||||
const addedPatchMap = new Map<string, Record<string, any>>();
|
||||
const updatePatchMap = new Map<string, Record<string, any>>();
|
||||
|
||||
for (let idx = 0; idx < parsed.length; idx += 1) {
|
||||
const nextItem = parsed[idx];
|
||||
if (!isPlainObject(nextItem)) {
|
||||
message.error(`第 ${idx + 1} 条记录不是对象,无法应用`);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentRow = mergedDisplayData[idx];
|
||||
const rowKey = currentRow?.[GONAVI_ROW_KEY];
|
||||
if (rowKey === undefined || rowKey === null) {
|
||||
message.error(`第 ${idx + 1} 条记录缺少行标识,无法应用`);
|
||||
return;
|
||||
}
|
||||
const keyStr = rowKeyStr(rowKey);
|
||||
const normalizedNext: Record<string, any> = {};
|
||||
let hasAnyVisibleChange = false;
|
||||
columnNames.forEach((col) => {
|
||||
const currentVal = (currentRow as any)?.[col];
|
||||
const editedVal = Object.prototype.hasOwnProperty.call(nextItem, col) ? (nextItem as any)[col] : currentVal;
|
||||
if (!isJsonViewValueEqual(currentVal, editedVal)) hasAnyVisibleChange = true;
|
||||
normalizedNext[col] = coerceJsonEditorValueForStorage(currentVal, editedVal);
|
||||
});
|
||||
|
||||
if (!hasAnyVisibleChange) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (addedKeySet.has(keyStr)) {
|
||||
addedPatchMap.set(keyStr, normalizedNext);
|
||||
continue;
|
||||
}
|
||||
|
||||
const originalRow = originalMap.get(keyStr);
|
||||
if (!originalRow) continue;
|
||||
const patch: Record<string, any> = {};
|
||||
columnNames.forEach((col) => {
|
||||
const prevVal = (originalRow as any)?.[col];
|
||||
const nextVal = normalizedNext[col];
|
||||
if (!isCellValueEqualForDiff(prevVal, nextVal)) patch[col] = nextVal;
|
||||
});
|
||||
updatePatchMap.set(keyStr, patch);
|
||||
}
|
||||
|
||||
setAddedRows((prev) => prev.map((row) => {
|
||||
const key = row?.[GONAVI_ROW_KEY];
|
||||
if (key === undefined) return row;
|
||||
const patch = addedPatchMap.get(rowKeyStr(key));
|
||||
if (!patch) return row;
|
||||
return { ...row, ...patch };
|
||||
}));
|
||||
|
||||
setModifiedRows((prev) => {
|
||||
const next = { ...prev };
|
||||
updatePatchMap.forEach((patch, keyStr) => {
|
||||
if (Object.keys(patch).length === 0) delete next[keyStr];
|
||||
else next[keyStr] = patch;
|
||||
});
|
||||
return next;
|
||||
});
|
||||
|
||||
setJsonEditorOpen(false);
|
||||
message.success("JSON 修改已应用到当前结果集,可继续“提交事务”");
|
||||
}, [canModifyData, jsonEditorValue, mergedDisplayData, addedRows, rowKeyStr, data, columnNames]);
|
||||
|
||||
const openRowEditorFieldEditor = useCallback((dataIndex: string) => {
|
||||
if (!dataIndex) return;
|
||||
@@ -1300,7 +1576,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
width: columnWidths[key] || 200,
|
||||
sorter: !!onSort,
|
||||
sortOrder: (sortInfo?.columnKey === key ? sortInfo.order : null) as SortOrder | undefined,
|
||||
editable: !readOnly && !!tableName, // Only editable if table name known
|
||||
editable: canModifyData, // Only editable if table name known and not readonly
|
||||
render: (text: any) => (
|
||||
<div style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{formatCellValue(text)}
|
||||
@@ -1311,7 +1587,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
onResizeStart: handleResizeStart(key), // Only need start
|
||||
}),
|
||||
}));
|
||||
}, [columnNames, columnWidths, sortInfo, handleResizeStart, readOnly, tableName, onSort]);
|
||||
}, [columnNames, columnWidths, sortInfo, handleResizeStart, canModifyData, onSort]);
|
||||
|
||||
const mergedColumns = useMemo(() => columns.map(col => {
|
||||
if (!col.editable) return col;
|
||||
@@ -1651,9 +1927,21 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
if (!connectionId || !tableName) return;
|
||||
const config = buildConnConfig();
|
||||
if (!config) return;
|
||||
|
||||
|
||||
const res = await ImportData(config as any, dbName || '', tableName);
|
||||
if (res.success) { message.success(res.message); if (onReload) onReload(); } else if (res.message !== "Cancelled") { message.error("Import Failed: " + res.message); }
|
||||
if (res.success && res.data && res.data.filePath) {
|
||||
setImportFilePath(res.data.filePath);
|
||||
setImportPreviewVisible(true);
|
||||
} else if (res.message !== "Cancelled") {
|
||||
message.error("选择文件失败: " + res.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImportSuccess = () => {
|
||||
setImportPreviewVisible(false);
|
||||
setImportFilePath('');
|
||||
message.success('导入完成');
|
||||
if (onReload) onReload();
|
||||
};
|
||||
|
||||
// Filters
|
||||
@@ -1730,9 +2018,25 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
|
||||
const totalWidth = columns.reduce((sum, col) => sum + (Number(col.width) || 200), 0) + selectionColumnWidth;
|
||||
const enableVirtual = mergedDisplayData.length >= 200;
|
||||
const tableScrollX = useMemo(() => {
|
||||
const baseWidth = Math.max(totalWidth, 1000);
|
||||
if (!isMacLike || tableViewportWidth <= 0) return baseWidth;
|
||||
// macOS 在“自动隐藏滚动条”模式下容易误判为无横向滚动,预留 2px 触发稳定滚动轨道。
|
||||
return Math.max(baseWidth, tableViewportWidth + 2);
|
||||
}, [totalWidth, isMacLike, tableViewportWidth]);
|
||||
const tableStickyConfig = useMemo(() => ({
|
||||
getContainer: () => containerRef.current || document.body,
|
||||
offsetScroll: 0,
|
||||
}), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (viewMode !== 'table') return;
|
||||
const rafId = requestAnimationFrame(() => recalculateTableMetrics(containerRef.current));
|
||||
return () => cancelAnimationFrame(rafId);
|
||||
}, [viewMode, totalWidth, mergedDisplayData.length, recalculateTableMetrics]);
|
||||
|
||||
return (
|
||||
<div className={`${gridId}${cellEditMode ? ' cell-edit-mode' : ''}`} ref={containerRef} style={{ flex: '1 1 auto', height: '100%', overflow: 'hidden', padding: 0, display: 'flex', flexDirection: 'column', minHeight: 0, background: bgContent, backdropFilter: blurFilter, WebkitBackdropFilter: blurFilter }}>
|
||||
<div className={`${gridId}${cellEditMode ? ' cell-edit-mode' : ''}`} ref={containerRef} style={{ flex: '1 1 auto', height: '100%', overflow: 'hidden', padding: 0, display: 'flex', flexDirection: 'column', minHeight: 0, background: bgContent }}>
|
||||
{/* Toolbar */}
|
||||
<div style={{ padding: '8px', borderBottom: '1px solid #eee', display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
{onReload && <Button icon={<ReloadOutlined />} disabled={loading} onClick={() => {
|
||||
@@ -1745,7 +2049,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
{tableName && <Button icon={<ImportOutlined />} onClick={handleImport}>导入</Button>}
|
||||
{tableName && <Dropdown menu={{ items: exportMenu }}><Button icon={<ExportOutlined />}>导出 <DownOutlined /></Button></Dropdown>}
|
||||
|
||||
{!readOnly && tableName && (
|
||||
{canModifyData && (
|
||||
<>
|
||||
<div style={{ width: 1, background: '#eee', height: 20, margin: '0 8px' }} />
|
||||
<Button icon={<PlusOutlined />} onClick={handleAddRow}>添加行</Button>
|
||||
@@ -1817,6 +2121,37 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
}}>筛选</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div style={{ marginLeft: 'auto' }} />
|
||||
<Segmented
|
||||
size="small"
|
||||
value={viewMode}
|
||||
options={[
|
||||
{ label: '表格', value: 'table' },
|
||||
{ label: 'JSON', value: 'json' },
|
||||
{ label: '文本', value: 'text' }
|
||||
]}
|
||||
onChange={(val) => {
|
||||
const nextMode = String(val) as GridViewMode;
|
||||
if (nextMode === 'json' && cellEditMode) {
|
||||
setCellEditMode(false);
|
||||
setSelectedCells(new Set());
|
||||
currentSelectionRef.current = new Set();
|
||||
selectionStartRef.current = null;
|
||||
updateCellSelection(new Set());
|
||||
}
|
||||
if (nextMode === 'text') {
|
||||
const selectedKey = selectedRowKeys[0];
|
||||
if (selectedKey !== undefined) {
|
||||
const idx = mergedDisplayData.findIndex((row) => rowKeyStr(row?.[GONAVI_ROW_KEY]) === rowKeyStr(selectedKey));
|
||||
if (idx >= 0) {
|
||||
setTextRecordIndex(idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
setViewMode(nextMode);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Filter Panel */}
|
||||
@@ -1960,7 +2295,6 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
open={cellEditorOpen}
|
||||
onCancel={closeCellEditor}
|
||||
width={960}
|
||||
destroyOnHidden
|
||||
maskClosable={false}
|
||||
footer={[
|
||||
<Button key="format" onClick={handleFormatJsonInEditor} disabled={!cellEditorIsJson}>
|
||||
@@ -1973,23 +2307,21 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
<div style={{ marginBottom: 8, color: '#888', fontSize: 12 }}>
|
||||
{cellEditorMeta ? `${tableName || ''}${tableName ? '.' : ''}${cellEditorMeta.dataIndex}` : ''}
|
||||
</div>
|
||||
{cellEditorOpen && (
|
||||
<Editor
|
||||
height="56vh"
|
||||
language={cellEditorIsJson ? "json" : "plaintext"}
|
||||
theme={darkMode ? "vs-dark" : "light"}
|
||||
value={cellEditorValue}
|
||||
onChange={(val) => setCellEditorValue(val || '')}
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
scrollBeyondLastLine: false,
|
||||
wordWrap: "on",
|
||||
fontSize: 14,
|
||||
tabSize: 2,
|
||||
automaticLayout: true,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Editor
|
||||
height="56vh"
|
||||
language={cellEditorIsJson ? "json" : "plaintext"}
|
||||
theme={darkMode ? "transparent-dark" : "transparent-light"}
|
||||
value={cellEditorValue}
|
||||
onChange={(val) => setCellEditorValue(val || '')}
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
scrollBeyondLastLine: false,
|
||||
wordWrap: "on",
|
||||
fontSize: 14,
|
||||
tabSize: 2,
|
||||
automaticLayout: true,
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
{/* 批量编辑弹窗 */}
|
||||
@@ -2018,44 +2350,145 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
<Modal
|
||||
title="编辑 JSON 结果集"
|
||||
open={jsonEditorOpen}
|
||||
onCancel={() => setJsonEditorOpen(false)}
|
||||
width={980}
|
||||
maskClosable={false}
|
||||
footer={[
|
||||
<Button key="format" onClick={handleFormatJsonEditor}>格式化 JSON</Button>,
|
||||
<Button key="cancel" onClick={() => setJsonEditorOpen(false)}>取消</Button>,
|
||||
<Button key="ok" type="primary" onClick={applyJsonEditor}>应用修改</Button>,
|
||||
]}
|
||||
>
|
||||
<div style={{ marginBottom: 8, color: '#888', fontSize: 12 }}>
|
||||
说明:此处按当前结果集顺序编辑,不支持在 JSON 模式增删记录(可在表格模式操作)。
|
||||
</div>
|
||||
<Editor
|
||||
height="56vh"
|
||||
language="json"
|
||||
theme={darkMode ? "transparent-dark" : "transparent-light"}
|
||||
value={jsonEditorValue}
|
||||
onChange={(val) => setJsonEditorValue(val || '')}
|
||||
options={{
|
||||
readOnly: false,
|
||||
minimap: { enabled: false },
|
||||
scrollBeyondLastLine: false,
|
||||
wordWrap: "off",
|
||||
fontSize: 12,
|
||||
tabSize: 2,
|
||||
automaticLayout: true,
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<Form component={false} form={form}>
|
||||
<DataContext.Provider value={{ selectedRowKeysRef, displayDataRef, handleCopyInsert, handleCopyJson, handleCopyCsv, handleExportSelected, copyToClipboard, tableName }}>
|
||||
<CellContextMenuContext.Provider value={{ showMenu: showCellContextMenu, handleBatchFillToSelected }}>
|
||||
<EditableContext.Provider value={form}>
|
||||
<Table
|
||||
components={tableComponents}
|
||||
dataSource={mergedDisplayData}
|
||||
columns={mergedColumns}
|
||||
size="small"
|
||||
tableLayout="fixed"
|
||||
scroll={{ x: Math.max(totalWidth, 1000), y: tableHeight }}
|
||||
virtual={enableVirtual}
|
||||
loading={loading}
|
||||
rowKey={GONAVI_ROW_KEY}
|
||||
pagination={false}
|
||||
onChange={handleTableChange}
|
||||
bordered
|
||||
rowSelection={{
|
||||
selectedRowKeys,
|
||||
onChange: setSelectedRowKeys,
|
||||
columnWidth: selectionColumnWidth,
|
||||
}}
|
||||
rowClassName={(record) => {
|
||||
const k = record?.[GONAVI_ROW_KEY];
|
||||
if (k !== undefined && addedRows.some(r => r?.[GONAVI_ROW_KEY] === k)) return 'row-added';
|
||||
if (k !== undefined && (modifiedRows[rowKeyStr(k)] || deletedRowKeys.has(rowKeyStr(k)))) return 'row-modified'; // deleted won't show
|
||||
return '';
|
||||
}}
|
||||
onRow={(record) => ({ record } as any)}
|
||||
/>
|
||||
</EditableContext.Provider>
|
||||
</CellContextMenuContext.Provider>
|
||||
</DataContext.Provider>
|
||||
</Form>
|
||||
{viewMode === 'table' ? (
|
||||
<Form component={false} form={form}>
|
||||
<DataContext.Provider value={{ selectedRowKeysRef, displayDataRef, handleCopyInsert, handleCopyJson, handleCopyCsv, handleExportSelected, copyToClipboard, tableName }}>
|
||||
<CellContextMenuContext.Provider value={{ showMenu: showCellContextMenu, handleBatchFillToSelected }}>
|
||||
<EditableContext.Provider value={form}>
|
||||
<Table
|
||||
components={tableComponents}
|
||||
dataSource={mergedDisplayData}
|
||||
columns={mergedColumns}
|
||||
size="small"
|
||||
tableLayout="fixed"
|
||||
scroll={{ x: tableScrollX, y: tableHeight }}
|
||||
sticky={tableStickyConfig}
|
||||
virtual={enableVirtual}
|
||||
loading={loading}
|
||||
rowKey={GONAVI_ROW_KEY}
|
||||
pagination={false}
|
||||
onChange={handleTableChange}
|
||||
bordered
|
||||
rowSelection={{
|
||||
selectedRowKeys,
|
||||
onChange: setSelectedRowKeys,
|
||||
columnWidth: selectionColumnWidth,
|
||||
}}
|
||||
rowClassName={(record) => {
|
||||
const k = record?.[GONAVI_ROW_KEY];
|
||||
if (k !== undefined && addedRows.some(r => r?.[GONAVI_ROW_KEY] === k)) return 'row-added';
|
||||
if (k !== undefined && (modifiedRows[rowKeyStr(k)] || deletedRowKeys.has(rowKeyStr(k)))) return 'row-modified'; // deleted won't show
|
||||
return '';
|
||||
}}
|
||||
onRow={(record) => ({ record } as any)}
|
||||
/>
|
||||
</EditableContext.Provider>
|
||||
</CellContextMenuContext.Provider>
|
||||
</DataContext.Provider>
|
||||
</Form>
|
||||
) : viewMode === 'json' ? (
|
||||
<div style={{ height: '100%', minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ padding: '8px 10px', borderBottom: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(0,0,0,0.08)', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ fontSize: 12, color: darkMode ? '#999' : '#666' }}>
|
||||
{mergedDisplayData.length === 0 ? '当前结果集无数据' : `当前结果集 ${mergedDisplayData.length} 条记录`}
|
||||
</span>
|
||||
{canModifyData && (
|
||||
<Button size="small" type="primary" onClick={openJsonEditor} disabled={mergedDisplayData.length === 0}>
|
||||
编辑 JSON
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ flex: 1, minHeight: 0, padding: '8px 10px 10px 10px' }}>
|
||||
<Editor
|
||||
height="100%"
|
||||
defaultLanguage="json"
|
||||
language="json"
|
||||
theme={darkMode ? "transparent-dark" : "transparent-light"}
|
||||
value={jsonViewText}
|
||||
options={{
|
||||
readOnly: true,
|
||||
minimap: { enabled: false },
|
||||
scrollBeyondLastLine: false,
|
||||
wordWrap: "off",
|
||||
fontSize: 12,
|
||||
tabSize: 2,
|
||||
automaticLayout: true,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ padding: '8px 12px', borderBottom: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(0,0,0,0.08)', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Button size="small" onClick={() => setTextRecordIndex(i => Math.max(0, i - 1))} disabled={textViewRows.length === 0 || textRecordIndex <= 0}>
|
||||
上一条
|
||||
</Button>
|
||||
<Button size="small" onClick={() => setTextRecordIndex(i => Math.min(textViewRows.length - 1, i + 1))} disabled={textViewRows.length === 0 || textRecordIndex >= textViewRows.length - 1}>
|
||||
下一条
|
||||
</Button>
|
||||
<span style={{ fontSize: 12, color: darkMode ? '#999' : '#666' }}>
|
||||
{textViewRows.length === 0 ? '当前结果集无数据' : `记录 ${textRecordIndex + 1} / ${textViewRows.length}`}
|
||||
</span>
|
||||
{canModifyData && (
|
||||
<Button size="small" type="primary" onClick={openCurrentViewRowEditor} disabled={textViewRows.length === 0}>
|
||||
编辑当前记录
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="custom-scrollbar" style={{ flex: 1, overflow: 'auto', padding: '8px 12px' }}>
|
||||
{currentTextRow ? columnNames.map((col) => (
|
||||
<div key={col} style={{ display: 'grid', gridTemplateColumns: '240px 1fr', gap: 10, padding: '6px 0', borderBottom: darkMode ? '1px solid rgba(255,255,255,0.06)' : '1px solid rgba(0,0,0,0.06)', alignItems: 'start' }}>
|
||||
<div style={{ fontWeight: 600, color: darkMode ? 'rgba(255,255,255,0.9)' : 'rgba(0,0,0,0.88)', wordBreak: 'break-all' }}>
|
||||
{col} :
|
||||
</div>
|
||||
<div style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', color: darkMode ? 'rgba(255,255,255,0.88)' : 'rgba(0,0,0,0.88)' }}>
|
||||
{formatTextViewValue((currentTextRow as any)[col])}
|
||||
</div>
|
||||
</div>
|
||||
)) : (
|
||||
<div style={{ fontSize: 12, color: darkMode ? '#999' : '#666', paddingTop: 4 }}>
|
||||
当前结果集无数据
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cell Context Menu - 使用 Portal 渲染到 body,避免 backdropFilter 影响 fixed 定位 */}
|
||||
{cellContextMenu.visible && createPortal(
|
||||
{viewMode === 'table' && cellContextMenu.visible && createPortal(
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
@@ -2063,8 +2496,6 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
top: cellContextMenu.y,
|
||||
zIndex: 10000,
|
||||
background: bgContextMenu,
|
||||
backdropFilter: blurFilter,
|
||||
WebkitBackdropFilter: blurFilter,
|
||||
border: darkMode ? '1px solid #303030' : '1px solid #d9d9d9',
|
||||
borderRadius: 4,
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
|
||||
@@ -2261,6 +2692,23 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
box-shadow: inset 0 0 0 2px #1890ff;
|
||||
background-image: linear-gradient(${darkMode ? 'rgba(24, 144, 255, 0.18)' : 'rgba(24, 144, 255, 0.08)'}, ${darkMode ? 'rgba(24, 144, 255, 0.18)' : 'rgba(24, 144, 255, 0.08)'});
|
||||
}
|
||||
.${gridId} .ant-table-content,
|
||||
.${gridId} .ant-table-body {
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
.${gridId} .ant-table-body {
|
||||
padding-bottom: ${tableBodyBottomPadding}px;
|
||||
box-sizing: border-box;
|
||||
scroll-padding-bottom: ${tableBodyBottomPadding}px;
|
||||
}
|
||||
.${gridId} .ant-table-sticky-scroll {
|
||||
height: 10px !important;
|
||||
background: ${darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)'};
|
||||
z-index: 20 !important;
|
||||
}
|
||||
.${gridId} .ant-table-sticky-scroll-bar {
|
||||
background: ${darkMode ? 'rgba(255,255,255,0.35)' : 'rgba(0,0,0,0.28)'} !important;
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{/* Ghost Resize Line for Columns */}
|
||||
@@ -2279,6 +2727,20 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
willChange: 'transform'
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Import Preview Modal */}
|
||||
<ImportPreviewModal
|
||||
visible={importPreviewVisible}
|
||||
filePath={importFilePath}
|
||||
connectionId={connectionId || ''}
|
||||
dbName={dbName || ''}
|
||||
tableName={tableName || ''}
|
||||
onClose={() => {
|
||||
setImportPreviewVisible(false);
|
||||
setImportFilePath('');
|
||||
}}
|
||||
onSuccess={handleImportSuccess}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
282
frontend/src/components/DefinitionViewer.tsx
Normal file
282
frontend/src/components/DefinitionViewer.tsx
Normal file
@@ -0,0 +1,282 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Editor 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 DefinitionViewerProps {
|
||||
tab: TabData;
|
||||
}
|
||||
|
||||
const DefinitionViewer: React.FC<DefinitionViewerProps> = ({ tab }) => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [definition, setDefinition] = useState<string>('');
|
||||
|
||||
const connections = useStore(state => state.connections);
|
||||
const theme = useStore(state => state.theme);
|
||||
const darkMode = theme === 'dark';
|
||||
|
||||
const escapeSQLLiteral = (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 parseSchemaAndName = (fullName: string): { schema: string; name: string } => {
|
||||
const raw = String(fullName || '').trim();
|
||||
const idx = raw.lastIndexOf('.');
|
||||
if (idx > 0 && idx < raw.length - 1) {
|
||||
return { schema: raw.substring(0, idx), name: raw.substring(idx + 1) };
|
||||
}
|
||||
return { schema: '', name: raw };
|
||||
};
|
||||
|
||||
const buildShowViewQuery = (dialect: string, viewName: string, dbName: string): string => {
|
||||
const { schema, name } = parseSchemaAndName(viewName);
|
||||
const safeName = escapeSQLLiteral(name);
|
||||
const safeDbName = escapeSQLLiteral(dbName);
|
||||
|
||||
switch (dialect) {
|
||||
case 'mysql':
|
||||
return `SHOW CREATE VIEW \`${name.replace(/`/g, '``')}\``;
|
||||
case 'postgres':
|
||||
case 'kingbase':
|
||||
case 'highgo':
|
||||
case 'vastbase': {
|
||||
const schemaRef = schema || 'public';
|
||||
return `SELECT pg_get_viewdef('${escapeSQLLiteral(schemaRef)}.${safeName}'::regclass, true) AS view_definition`;
|
||||
}
|
||||
case 'sqlserver':
|
||||
return `SELECT OBJECT_DEFINITION(OBJECT_ID('${escapeSQLLiteral(viewName)}')) AS view_definition`;
|
||||
case 'oracle':
|
||||
case 'dm':
|
||||
if (schema) {
|
||||
return `SELECT TEXT AS view_definition FROM ALL_VIEWS WHERE OWNER = '${escapeSQLLiteral(schema).toUpperCase()}' AND VIEW_NAME = '${safeName.toUpperCase()}'`;
|
||||
}
|
||||
if (safeDbName) {
|
||||
return `SELECT TEXT AS view_definition FROM ALL_VIEWS WHERE OWNER = '${safeDbName.toUpperCase()}' AND VIEW_NAME = '${safeName.toUpperCase()}'`;
|
||||
}
|
||||
return `SELECT TEXT AS view_definition FROM USER_VIEWS WHERE VIEW_NAME = '${safeName.toUpperCase()}'`;
|
||||
case 'sqlite':
|
||||
return `SELECT sql AS view_definition FROM sqlite_master WHERE type='view' AND name='${safeName}'`;
|
||||
default:
|
||||
return `-- 暂不支持该数据库类型的视图定义查看`;
|
||||
}
|
||||
};
|
||||
|
||||
const buildShowRoutineQuery = (dialect: string, routineName: string, routineType: string, dbName: string): string => {
|
||||
const { schema, name } = parseSchemaAndName(routineName);
|
||||
const safeName = escapeSQLLiteral(name);
|
||||
const safeDbName = escapeSQLLiteral(dbName);
|
||||
const upperType = (routineType || 'FUNCTION').toUpperCase();
|
||||
|
||||
switch (dialect) {
|
||||
case 'mysql':
|
||||
return `SHOW CREATE ${upperType} \`${name.replace(/`/g, '``')}\``;
|
||||
case 'postgres':
|
||||
case 'kingbase':
|
||||
case 'highgo':
|
||||
case 'vastbase': {
|
||||
const schemaRef = schema || 'public';
|
||||
return `SELECT pg_get_functiondef(p.oid) AS routine_definition FROM pg_proc p JOIN pg_namespace n ON p.pronamespace = n.oid WHERE n.nspname = '${escapeSQLLiteral(schemaRef)}' AND p.proname = '${safeName}' LIMIT 1`;
|
||||
}
|
||||
case 'sqlserver':
|
||||
return `SELECT OBJECT_DEFINITION(OBJECT_ID('${escapeSQLLiteral(routineName)}')) AS routine_definition`;
|
||||
case 'oracle':
|
||||
case 'dm': {
|
||||
const owner = schema ? escapeSQLLiteral(schema).toUpperCase() : (safeDbName ? safeDbName.toUpperCase() : '');
|
||||
if (owner) {
|
||||
return `SELECT TEXT FROM ALL_SOURCE WHERE OWNER = '${owner}' AND NAME = '${safeName.toUpperCase()}' AND TYPE = '${upperType}' ORDER BY LINE`;
|
||||
}
|
||||
return `SELECT TEXT FROM USER_SOURCE WHERE NAME = '${safeName.toUpperCase()}' AND TYPE = '${upperType}' ORDER BY LINE`;
|
||||
}
|
||||
case 'sqlite':
|
||||
return `-- SQLite 不支持存储函数/存储过程`;
|
||||
default:
|
||||
return `-- 暂不支持该数据库类型的函数/存储过程定义查看`;
|
||||
}
|
||||
};
|
||||
|
||||
const extractViewDefinition = (dialect: string, data: any[]): string => {
|
||||
if (!data || data.length === 0) return '-- 未找到视图定义';
|
||||
const row = data[0];
|
||||
|
||||
switch (dialect) {
|
||||
case 'mysql': {
|
||||
const keys = Object.keys(row);
|
||||
const sqlKey = keys.find(k => k.toLowerCase().includes('create view') || k.toLowerCase() === 'create view');
|
||||
if (sqlKey) return row[sqlKey];
|
||||
for (const key of keys) {
|
||||
const val = String(row[key] || '');
|
||||
if (val.toUpperCase().includes('CREATE') && val.toUpperCase().includes('VIEW')) {
|
||||
return val;
|
||||
}
|
||||
}
|
||||
return JSON.stringify(row, null, 2);
|
||||
}
|
||||
case 'oracle':
|
||||
case 'dm':
|
||||
return row.view_definition || row.VIEW_DEFINITION || row.text || row.TEXT || Object.values(row)[0] || '';
|
||||
default:
|
||||
return row.view_definition || row.VIEW_DEFINITION || row.sql || row.SQL || Object.values(row)[0] || '';
|
||||
}
|
||||
};
|
||||
|
||||
const extractRoutineDefinition = (dialect: string, data: any[]): string => {
|
||||
if (!data || data.length === 0) return '-- 未找到函数/存储过程定义';
|
||||
|
||||
switch (dialect) {
|
||||
case 'mysql': {
|
||||
const row = data[0];
|
||||
const keys = Object.keys(row);
|
||||
const sqlKey = keys.find(k => k.toLowerCase().includes('create function') || k.toLowerCase().includes('create procedure'));
|
||||
if (sqlKey) return row[sqlKey];
|
||||
for (const key of keys) {
|
||||
const val = String(row[key] || '');
|
||||
if (val.toUpperCase().includes('CREATE') && (val.toUpperCase().includes('FUNCTION') || val.toUpperCase().includes('PROCEDURE'))) {
|
||||
return val;
|
||||
}
|
||||
}
|
||||
return JSON.stringify(row, null, 2);
|
||||
}
|
||||
case 'oracle':
|
||||
case 'dm': {
|
||||
// Oracle/DM ALL_SOURCE returns multiple rows, one per line
|
||||
return data.map(row => row.text || row.TEXT || Object.values(row)[0] || '').join('');
|
||||
}
|
||||
default: {
|
||||
const row = data[0];
|
||||
return row.routine_definition || row.ROUTINE_DEFINITION || Object.values(row)[0] || '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const loadDefinition = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const conn = connections.find(c => c.id === tab.connectionId);
|
||||
if (!conn) {
|
||||
setError('未找到数据库连接');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const dbName = tab.dbName || '';
|
||||
const dialect = getMetadataDialect(conn);
|
||||
|
||||
let query: string;
|
||||
let extractFn: (dialect: string, data: any[]) => string;
|
||||
|
||||
if (tab.type === 'view-def') {
|
||||
const viewName = tab.viewName || '';
|
||||
if (!viewName) {
|
||||
setError('视图名称为空');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
query = buildShowViewQuery(dialect, viewName, dbName);
|
||||
extractFn = extractViewDefinition;
|
||||
} else {
|
||||
const routineName = tab.routineName || '';
|
||||
const routineType = tab.routineType || 'FUNCTION';
|
||||
if (!routineName) {
|
||||
setError('函数/存储过程名称为空');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
query = buildShowRoutineQuery(dialect, routineName, routineType, dbName);
|
||||
extractFn = extractRoutineDefinition;
|
||||
}
|
||||
|
||||
if (query.startsWith('--')) {
|
||||
setDefinition(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 def = extractFn(dialect, result.data);
|
||||
setDefinition(def);
|
||||
} else {
|
||||
setError(result.message || '查询定义失败');
|
||||
}
|
||||
} catch (e: any) {
|
||||
setError('查询定义失败: ' + (e?.message || String(e)));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadDefinition();
|
||||
}, [tab.connectionId, tab.dbName, tab.viewName, tab.routineName, tab.routineType, tab.type, connections]);
|
||||
|
||||
const objectLabel = tab.type === 'view-def' ? '视图' : '函数/存储过程';
|
||||
const objectName = tab.type === 'view-def' ? tab.viewName : tab.routineName;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
|
||||
<Spin tip={`加载${objectLabel}定义...`} />
|
||||
</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>{objectLabel}: </strong>{objectName}
|
||||
{tab.dbName && <span style={{ marginLeft: 16, color: '#888' }}>数据库: {tab.dbName}</span>}
|
||||
{tab.routineType && <span style={{ marginLeft: 16, color: '#888' }}>类型: {tab.routineType}</span>}
|
||||
</div>
|
||||
<div style={{ flex: 1, minHeight: 0 }}>
|
||||
<Editor
|
||||
height="100%"
|
||||
language="sql"
|
||||
theme={darkMode ? 'transparent-dark' : 'transparent-light'}
|
||||
value={definition}
|
||||
options={{
|
||||
readOnly: true,
|
||||
minimap: { enabled: false },
|
||||
fontSize: 14,
|
||||
lineNumbers: 'on',
|
||||
scrollBeyondLastLine: false,
|
||||
wordWrap: 'on',
|
||||
automaticLayout: true,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DefinitionViewer;
|
||||
250
frontend/src/components/ImportPreviewModal.tsx
Normal file
250
frontend/src/components/ImportPreviewModal.tsx
Normal file
@@ -0,0 +1,250 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Modal, Table, Alert, Progress, Button, Space } from 'antd';
|
||||
import { CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons';
|
||||
import { PreviewImportFile, ImportDataWithProgress } from '../../wailsjs/go/app/App';
|
||||
import { EventsOn, EventsOff } from '../../wailsjs/runtime/runtime';
|
||||
import { useStore } from '../store';
|
||||
|
||||
interface ImportPreviewModalProps {
|
||||
visible: boolean;
|
||||
filePath: string;
|
||||
connectionId: string;
|
||||
dbName: string;
|
||||
tableName: string;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
interface PreviewData {
|
||||
columns: string[];
|
||||
totalRows: number;
|
||||
previewRows: any[];
|
||||
}
|
||||
|
||||
interface ImportProgress {
|
||||
current: number;
|
||||
total: number;
|
||||
success: number;
|
||||
errors: number;
|
||||
}
|
||||
|
||||
const ImportPreviewModal: React.FC<ImportPreviewModalProps> = ({
|
||||
visible,
|
||||
filePath,
|
||||
connectionId,
|
||||
dbName,
|
||||
tableName,
|
||||
onClose,
|
||||
onSuccess
|
||||
}) => {
|
||||
const connections = useStore(state => state.connections);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [previewData, setPreviewData] = useState<PreviewData | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [progress, setProgress] = useState<ImportProgress | null>(null);
|
||||
const [importResult, setImportResult] = useState<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible && filePath) {
|
||||
loadPreview();
|
||||
}
|
||||
}, [visible, filePath]);
|
||||
|
||||
useEffect(() => {
|
||||
if (importing) {
|
||||
const unsubscribe = EventsOn('import:progress', (data: ImportProgress) => {
|
||||
setProgress(data);
|
||||
});
|
||||
return () => {
|
||||
EventsOff('import:progress');
|
||||
};
|
||||
}
|
||||
}, [importing]);
|
||||
|
||||
const loadPreview = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await PreviewImportFile(filePath);
|
||||
if (res.success && res.data) {
|
||||
setPreviewData({
|
||||
columns: res.data.columns || [],
|
||||
totalRows: res.data.totalRows || 0,
|
||||
previewRows: res.data.previewRows || []
|
||||
});
|
||||
} else {
|
||||
setError(res.message || '预览失败');
|
||||
}
|
||||
} catch (e: any) {
|
||||
setError('预览失败: ' + e.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!previewData) return;
|
||||
|
||||
setImporting(true);
|
||||
setProgress({ current: 0, total: previewData.totalRows, success: 0, errors: 0 });
|
||||
setImportResult(null);
|
||||
|
||||
try {
|
||||
const conn = connections.find(c => c.id === connectionId);
|
||||
if (!conn) {
|
||||
setError('连接配置未找到');
|
||||
setImporting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const config = {
|
||||
...conn.config,
|
||||
port: Number(conn.config.port),
|
||||
password: conn.config.password || '',
|
||||
database: conn.config.database || '',
|
||||
useSSH: conn.config.useSSH || false,
|
||||
ssh: conn.config.ssh || { host: '', port: 22, user: '', password: '', keyPath: '' }
|
||||
};
|
||||
|
||||
const res = await ImportDataWithProgress(config as any, dbName, tableName, filePath);
|
||||
|
||||
if (res.success && res.data) {
|
||||
setImportResult(res.data);
|
||||
if (res.data.failed === 0) {
|
||||
onSuccess();
|
||||
}
|
||||
} else {
|
||||
setError(res.message || '导入失败');
|
||||
}
|
||||
} catch (e: any) {
|
||||
setError('导入失败: ' + e.message);
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = previewData?.columns.map(col => ({
|
||||
title: col,
|
||||
dataIndex: col,
|
||||
key: col,
|
||||
ellipsis: true,
|
||||
width: 150
|
||||
})) || [];
|
||||
|
||||
const progressPercent = progress ? Math.round((progress.current / progress.total) * 100) : 0;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="导入数据预览"
|
||||
open={visible}
|
||||
onCancel={onClose}
|
||||
width={900}
|
||||
footer={
|
||||
importResult ? (
|
||||
<Space>
|
||||
<Button onClick={onClose}>关闭</Button>
|
||||
</Space>
|
||||
) : importing ? null : (
|
||||
<Space>
|
||||
<Button onClick={onClose}>取消</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleImport}
|
||||
disabled={!previewData || loading}
|
||||
>
|
||||
开始导入
|
||||
</Button>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
>
|
||||
{error && <Alert type="error" message={error} style={{ marginBottom: 16 }} showIcon />}
|
||||
|
||||
{loading && <div style={{ textAlign: 'center', padding: 40 }}>加载预览数据...</div>}
|
||||
|
||||
{!loading && previewData && !importing && !importResult && (
|
||||
<>
|
||||
<Alert
|
||||
type="info"
|
||||
message={`共 ${previewData.totalRows} 行数据,${previewData.columns.length} 个字段`}
|
||||
description='以下是前 5 行预览数据,确认无误后点击“开始导入”'
|
||||
style={{ marginBottom: 16 }}
|
||||
showIcon
|
||||
/>
|
||||
<div style={{ marginBottom: 8, fontWeight: 600 }}>字段列表:</div>
|
||||
<div style={{ marginBottom: 16, padding: 8, background: '#f5f5f5', borderRadius: 4 }}>
|
||||
{previewData.columns.join(', ')}
|
||||
</div>
|
||||
<div style={{ marginBottom: 8, fontWeight: 600 }}>数据预览(前 5 行):</div>
|
||||
<Table
|
||||
dataSource={previewData.previewRows}
|
||||
columns={columns}
|
||||
pagination={false}
|
||||
scroll={{ x: 'max-content' }}
|
||||
size="small"
|
||||
bordered
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{importing && progress && (
|
||||
<div style={{ padding: '40px 20px' }}>
|
||||
<div style={{ marginBottom: 16, fontSize: 16, fontWeight: 600, textAlign: 'center' }}>
|
||||
正在导入数据...
|
||||
</div>
|
||||
<Progress percent={progressPercent} status="active" />
|
||||
<div style={{ marginTop: 16, textAlign: 'center', color: '#666' }}>
|
||||
已处理 {progress.current} / {progress.total} 行
|
||||
<span style={{ marginLeft: 16, color: '#52c41a' }}>
|
||||
<CheckCircleOutlined /> 成功 {progress.success}
|
||||
</span>
|
||||
{progress.errors > 0 && (
|
||||
<span style={{ marginLeft: 16, color: '#ff4d4f' }}>
|
||||
<CloseCircleOutlined /> 失败 {progress.errors}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{importResult && (
|
||||
<div style={{ padding: 20 }}>
|
||||
<Alert
|
||||
type={importResult.failed === 0 ? 'success' : 'warning'}
|
||||
message="导入完成"
|
||||
description={
|
||||
<div>
|
||||
<div>成功导入 {importResult.success} 行</div>
|
||||
{importResult.failed > 0 && <div>失败 {importResult.failed} 行</div>}
|
||||
</div>
|
||||
}
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
{importResult.errorLogs && importResult.errorLogs.length > 0 && (
|
||||
<>
|
||||
<div style={{ marginBottom: 8, fontWeight: 600, color: '#ff4d4f' }}>错误日志:</div>
|
||||
<div style={{
|
||||
maxHeight: 300,
|
||||
overflow: 'auto',
|
||||
background: '#fff1f0',
|
||||
border: '1px solid #ffccc7',
|
||||
borderRadius: 4,
|
||||
padding: 12,
|
||||
fontSize: 12,
|
||||
fontFamily: 'monospace'
|
||||
}}>
|
||||
{importResult.errorLogs.map((log: string, idx: number) => (
|
||||
<div key={idx} style={{ marginBottom: 4 }}>{log}</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImportPreviewModal;
|
||||
@@ -2,7 +2,7 @@ import React, { useRef, useEffect } from 'react';
|
||||
import { Table, Tag, Button, Tooltip } from 'antd';
|
||||
import { ClearOutlined, CloseOutlined, CaretRightOutlined, BugOutlined } from '@ant-design/icons';
|
||||
import { useStore } from '../store';
|
||||
import { blurToFilter, normalizeBlurForPlatform, normalizeOpacityForPlatform } from '../utils/appearance';
|
||||
import { normalizeOpacityForPlatform } from '../utils/appearance';
|
||||
|
||||
interface LogPanelProps {
|
||||
height: number;
|
||||
@@ -17,7 +17,6 @@ const LogPanel: React.FC<LogPanelProps> = ({ height, onClose, onResizeStart }) =
|
||||
const appearance = useStore(state => state.appearance);
|
||||
const darkMode = theme === 'dark';
|
||||
const opacity = normalizeOpacityForPlatform(appearance.opacity);
|
||||
const blur = normalizeBlurForPlatform(appearance.blur);
|
||||
|
||||
// Background Helper
|
||||
const getBg = (darkHex: string) => {
|
||||
@@ -30,7 +29,6 @@ const LogPanel: React.FC<LogPanelProps> = ({ height, onClose, onResizeStart }) =
|
||||
};
|
||||
const bgMain = getBg('#1f1f1f');
|
||||
const bgToolbar = getBg('#2a2a2a');
|
||||
const blurFilter = blurToFilter(blur);
|
||||
|
||||
const columns = [
|
||||
{
|
||||
@@ -73,8 +71,6 @@ const LogPanel: React.FC<LogPanelProps> = ({ height, onClose, onResizeStart }) =
|
||||
height,
|
||||
borderTop: 'none',
|
||||
background: bgMain,
|
||||
backdropFilter: blurFilter,
|
||||
WebkitBackdropFilter: blurFilter,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'relative',
|
||||
|
||||
@@ -196,6 +196,9 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
editorRef.current = editor;
|
||||
monacoRef.current = monaco;
|
||||
|
||||
// 应用透明主题(主题已在 main.tsx 全局注册)
|
||||
monaco.editor.setTheme(darkMode ? 'transparent-dark' : 'transparent-light');
|
||||
|
||||
monaco.languages.registerCompletionItemProvider('sql', {
|
||||
triggerCharacters: ['.'],
|
||||
provideCompletionItems: async (model: any, position: any) => {
|
||||
@@ -1211,7 +1214,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
transition: none !important;
|
||||
}
|
||||
`}</style>
|
||||
<div style={{ padding: '8px', borderBottom: '1px solid #eee', display: 'flex', gap: '8px', flexShrink: 0, alignItems: 'center' }}>
|
||||
<div style={{ padding: '8px', display: 'flex', gap: '8px', flexShrink: 0, alignItems: 'center' }}>
|
||||
<Select
|
||||
style={{ width: 150 }}
|
||||
placeholder="选择连接"
|
||||
@@ -1265,11 +1268,11 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
</Button.Group>
|
||||
</div>
|
||||
|
||||
<div style={{ height: editorHeight, minHeight: '100px', borderBottom: '1px solid #eee' }}>
|
||||
<div style={{ height: editorHeight, minHeight: '100px' }}>
|
||||
<Editor
|
||||
height="100%"
|
||||
defaultLanguage="sql"
|
||||
theme={darkMode ? "vs-dark" : "light"}
|
||||
theme={darkMode ? "transparent-dark" : "transparent-light"}
|
||||
value={query}
|
||||
onChange={(val) => setQuery(val || '')}
|
||||
onMount={handleEditorDidMount}
|
||||
@@ -1287,7 +1290,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
style={{
|
||||
height: '5px',
|
||||
cursor: 'row-resize',
|
||||
background: darkMode ? '#333' : '#f0f0f0',
|
||||
background: darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.04)',
|
||||
flexShrink: 0,
|
||||
zIndex: 10
|
||||
}}
|
||||
|
||||
@@ -25,11 +25,12 @@ import { Tree, message, Dropdown, MenuProps, Input, Button, Modal, Form, Badge,
|
||||
DeleteOutlined,
|
||||
DisconnectOutlined,
|
||||
CloudOutlined,
|
||||
CheckSquareOutlined
|
||||
CheckSquareOutlined,
|
||||
CodeOutlined
|
||||
} from '@ant-design/icons';
|
||||
import { useStore } from '../store';
|
||||
import { SavedConnection } from '../types';
|
||||
import { DBGetDatabases, DBGetTables, DBQuery, 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, DropView, DropFunction, RenameView } from '../../wailsjs/go/app/App';
|
||||
import { normalizeOpacityForPlatform } from '../utils/appearance';
|
||||
|
||||
const { Search } = Input;
|
||||
@@ -41,7 +42,7 @@ interface TreeNode {
|
||||
children?: TreeNode[];
|
||||
icon?: React.ReactNode;
|
||||
dataRef?: any;
|
||||
type?: 'connection' | 'database' | 'table' | 'view' | 'db-trigger' | 'object-group' | 'queries-folder' | 'saved-query' | 'folder-columns' | 'folder-indexes' | 'folder-fks' | 'folder-triggers' | 'redis-db';
|
||||
type?: 'connection' | 'database' | 'table' | 'view' | 'db-trigger' | 'routine' | 'object-group' | 'queries-folder' | 'saved-query' | 'folder-columns' | 'folder-indexes' | 'folder-fks' | 'folder-triggers' | 'redis-db';
|
||||
}
|
||||
|
||||
type BatchTableExportMode = 'schema' | 'backup' | 'dataOnly';
|
||||
@@ -109,6 +110,9 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
const [isRenameTableModalOpen, setIsRenameTableModalOpen] = useState(false);
|
||||
const [renameTableForm] = Form.useForm();
|
||||
const [renameTableTarget, setRenameTableTarget] = useState<any>(null);
|
||||
const [isRenameViewModalOpen, setIsRenameViewModalOpen] = useState(false);
|
||||
const [renameViewForm] = Form.useForm();
|
||||
const [renameViewTarget, setRenameViewTarget] = useState<any>(null);
|
||||
|
||||
// Batch Operations Modal
|
||||
const [isBatchModalOpen, setIsBatchModalOpen] = useState(false);
|
||||
@@ -264,6 +268,19 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
return `${schema}.${name}`;
|
||||
};
|
||||
|
||||
const splitQualifiedName = (qualifiedName: string): { schemaName: string; objectName: string } => {
|
||||
const raw = String(qualifiedName || '').trim();
|
||||
if (!raw) return { schemaName: '', objectName: '' };
|
||||
const idx = raw.lastIndexOf('.');
|
||||
if (idx <= 0 || idx >= raw.length - 1) {
|
||||
return { schemaName: '', objectName: raw };
|
||||
}
|
||||
return {
|
||||
schemaName: raw.substring(0, idx),
|
||||
objectName: raw.substring(idx + 1),
|
||||
};
|
||||
};
|
||||
|
||||
const buildViewsMetadataQuery = (dialect: string, dbName: string): string => {
|
||||
const safeDbName = escapeSQLLiteral(dbName);
|
||||
switch (dialect) {
|
||||
@@ -374,6 +391,54 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
return triggers;
|
||||
};
|
||||
|
||||
const buildFunctionsMetadataQuery = (dialect: string, dbName: string): string => {
|
||||
const safeDbName = escapeSQLLiteral(dbName);
|
||||
switch (dialect) {
|
||||
case 'mysql':
|
||||
if (!safeDbName) return '';
|
||||
return `SELECT ROUTINE_NAME AS routine_name, ROUTINE_TYPE AS routine_type FROM information_schema.routines WHERE routine_schema = '${safeDbName}' ORDER BY ROUTINE_TYPE, ROUTINE_NAME`;
|
||||
case 'postgres':
|
||||
case 'kingbase':
|
||||
case 'highgo':
|
||||
case 'vastbase':
|
||||
return `SELECT n.nspname AS schema_name, p.proname AS routine_name, CASE WHEN p.prokind = 'p' THEN 'PROCEDURE' ELSE 'FUNCTION' END AS routine_type FROM pg_proc p JOIN pg_namespace n ON p.pronamespace = n.oid WHERE n.nspname NOT IN ('pg_catalog', 'information_schema') AND n.nspname NOT LIKE 'pg_%' ORDER BY n.nspname, routine_type, p.proname`;
|
||||
case 'sqlserver': {
|
||||
const safeDb = quoteSqlServerIdentifier(dbName || 'master');
|
||||
return `SELECT s.name AS schema_name, o.name AS routine_name, CASE o.type WHEN 'P' THEN 'PROCEDURE' WHEN 'FN' THEN 'FUNCTION' WHEN 'IF' THEN 'FUNCTION' WHEN 'TF' THEN 'FUNCTION' END AS routine_type FROM ${safeDb}.sys.objects o JOIN ${safeDb}.sys.schemas s ON o.schema_id = s.schema_id WHERE o.type IN ('P','FN','IF','TF') ORDER BY o.type, s.name, o.name`;
|
||||
}
|
||||
case 'oracle':
|
||||
case 'dm': {
|
||||
if (!safeDbName) {
|
||||
return `SELECT OBJECT_NAME AS routine_name, OBJECT_TYPE AS routine_type FROM USER_OBJECTS WHERE OBJECT_TYPE IN ('FUNCTION','PROCEDURE') ORDER BY OBJECT_TYPE, OBJECT_NAME`;
|
||||
}
|
||||
return `SELECT OWNER AS schema_name, OBJECT_NAME AS routine_name, OBJECT_TYPE AS routine_type FROM ALL_OBJECTS WHERE OWNER = '${safeDbName.toUpperCase()}' AND OBJECT_TYPE IN ('FUNCTION','PROCEDURE') ORDER BY OBJECT_TYPE, OBJECT_NAME`;
|
||||
}
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const loadFunctions = async (conn: any, dbName: string): Promise<Array<{ displayName: string; routineName: string; routineType: string }>> => {
|
||||
const dialect = getMetadataDialect(conn as SavedConnection);
|
||||
const query = buildFunctionsMetadataQuery(dialect, dbName);
|
||||
const rows = await queryMetadataRows(conn, dbName, query);
|
||||
const seen = new Set<string>();
|
||||
const routines: Array<{ displayName: string; routineName: string; routineType: string }> = [];
|
||||
|
||||
rows.forEach((row) => {
|
||||
const routineName = getCaseInsensitiveValue(row, ['routine_name', 'object_name', 'proname', 'name']);
|
||||
if (!routineName) return;
|
||||
const schemaName = getCaseInsensitiveValue(row, ['schema_name', 'nspname', 'owner']);
|
||||
const routineType = getCaseInsensitiveValue(row, ['routine_type', 'object_type']) || 'FUNCTION';
|
||||
const fullName = buildQualifiedName(schemaName, routineName);
|
||||
if (!fullName || seen.has(fullName)) return;
|
||||
seen.add(fullName);
|
||||
const typeLabel = routineType.toUpperCase() === 'PROCEDURE' ? 'P' : 'F';
|
||||
routines.push({ displayName: `${fullName} [${typeLabel}]`, routineName: fullName, routineType: routineType.toUpperCase() });
|
||||
});
|
||||
return routines;
|
||||
};
|
||||
|
||||
const loadDatabases = async (node: any) => {
|
||||
const conn = node.dataRef as SavedConnection;
|
||||
const loadKey = `dbs-${conn.id}`;
|
||||
@@ -487,91 +552,214 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
const res = await DBGetTables(config as any, conn.dbName);
|
||||
if (res.success) {
|
||||
setConnectionStates(prev => ({ ...prev, [key as string]: 'success' }));
|
||||
const tables = (res.data as any[]).map((row: any) => {
|
||||
const tableName = Object.values(row)[0] as string;
|
||||
const tableDisplayName = getSidebarTableDisplayName(conn, tableName);
|
||||
return {
|
||||
title: tableDisplayName,
|
||||
key: `${conn.id}-${conn.dbName}-${tableName}`,
|
||||
icon: <TableOutlined />,
|
||||
type: 'table' as const,
|
||||
dataRef: { ...conn, tableName },
|
||||
isLeaf: false,
|
||||
};
|
||||
});
|
||||
|
||||
const [views, triggers] = await Promise.all([
|
||||
loadViews(conn, conn.dbName),
|
||||
loadDatabaseTriggers(conn, conn.dbName),
|
||||
]);
|
||||
const tableEntries = (res.data as any[]).map((row: any) => {
|
||||
const tableName = Object.values(row)[0] as string;
|
||||
const parsed = splitQualifiedName(tableName);
|
||||
return {
|
||||
tableName,
|
||||
schemaName: parsed.schemaName,
|
||||
displayName: getSidebarTableDisplayName(conn, tableName),
|
||||
};
|
||||
});
|
||||
|
||||
// 获取当前数据库的排序偏好
|
||||
const sortPreferenceKey = `${conn.id}-${conn.dbName}`;
|
||||
const sortBy = tableSortPreference[sortPreferenceKey] || 'name';
|
||||
const [views, triggers, routines] = await Promise.all([
|
||||
loadViews(conn, conn.dbName),
|
||||
loadDatabaseTriggers(conn, conn.dbName),
|
||||
loadFunctions(conn, conn.dbName),
|
||||
]);
|
||||
|
||||
// 根据排序偏好排序表
|
||||
if (sortBy === 'frequency') {
|
||||
// 按使用频率排序(降序)
|
||||
tables.sort((a, b) => {
|
||||
const keyA = `${conn.id}-${conn.dbName}-${a.dataRef.tableName}`;
|
||||
const keyB = `${conn.id}-${conn.dbName}-${b.dataRef.tableName}`;
|
||||
const countA = tableAccessCount[keyA] || 0;
|
||||
const countB = tableAccessCount[keyB] || 0;
|
||||
if (countA !== countB) {
|
||||
return countB - countA; // 降序
|
||||
}
|
||||
// 频率相同时按名称排序
|
||||
return a.title.toLowerCase().localeCompare(b.title.toLowerCase());
|
||||
});
|
||||
} else {
|
||||
// 按名称排序(字母顺序)
|
||||
tables.sort((a, b) => a.title.toLowerCase().localeCompare(b.title.toLowerCase()));
|
||||
}
|
||||
const viewEntries = views.map((viewName) => {
|
||||
const parsed = splitQualifiedName(viewName);
|
||||
return {
|
||||
viewName,
|
||||
schemaName: parsed.schemaName,
|
||||
displayName: getSidebarTableDisplayName(conn, viewName),
|
||||
};
|
||||
});
|
||||
|
||||
// Sort views by name (case-insensitive)
|
||||
views.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
|
||||
const triggerEntries = triggers.map((trigger) => {
|
||||
const triggerParsed = splitQualifiedName(trigger.triggerName);
|
||||
const tableParsed = splitQualifiedName(trigger.tableName);
|
||||
const schemaName = tableParsed.schemaName || triggerParsed.schemaName;
|
||||
const triggerObjectName = triggerParsed.objectName || trigger.triggerName;
|
||||
const tableObjectName = tableParsed.objectName || trigger.tableName;
|
||||
const displayName = tableObjectName ? `${triggerObjectName} (${tableObjectName})` : triggerObjectName;
|
||||
return {
|
||||
...trigger,
|
||||
schemaName,
|
||||
displayName,
|
||||
};
|
||||
});
|
||||
|
||||
// Sort triggers by display name (case-insensitive)
|
||||
triggers.sort((a, b) => a.displayName.toLowerCase().localeCompare(b.displayName.toLowerCase()));
|
||||
const routineEntries = routines.map((routine) => {
|
||||
const parsed = splitQualifiedName(routine.routineName);
|
||||
const typeLabel = routine.routineType === 'PROCEDURE' ? 'P' : 'F';
|
||||
return {
|
||||
...routine,
|
||||
schemaName: parsed.schemaName,
|
||||
displayName: `${parsed.objectName || routine.routineName} [${typeLabel}]`,
|
||||
};
|
||||
});
|
||||
|
||||
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 sortPreferenceKey = `${conn.id}-${conn.dbName}`;
|
||||
const sortBy = tableSortPreference[sortPreferenceKey] || 'name';
|
||||
|
||||
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,
|
||||
}));
|
||||
// 根据排序偏好排序表
|
||||
if (sortBy === 'frequency') {
|
||||
// 按使用频率排序(降序)
|
||||
tableEntries.sort((a, b) => {
|
||||
const keyA = `${conn.id}-${conn.dbName}-${a.tableName}`;
|
||||
const keyB = `${conn.id}-${conn.dbName}-${b.tableName}`;
|
||||
const countA = tableAccessCount[keyA] || 0;
|
||||
const countB = tableAccessCount[keyB] || 0;
|
||||
if (countA !== countB) {
|
||||
return countB - countA;
|
||||
}
|
||||
return a.displayName.toLowerCase().localeCompare(b.displayName.toLowerCase());
|
||||
});
|
||||
} else {
|
||||
tableEntries.sort((a, b) => a.displayName.toLowerCase().localeCompare(b.displayName.toLowerCase()));
|
||||
}
|
||||
|
||||
const buildObjectGroup = (groupKey: string, groupTitle: string, groupIcon: React.ReactNode, children: TreeNode[]): TreeNode => ({
|
||||
title: `${groupTitle} (${children.length})`,
|
||||
key: `${key}-${groupKey}`,
|
||||
icon: groupIcon,
|
||||
type: 'object-group',
|
||||
isLeaf: children.length === 0,
|
||||
children: children.length > 0 ? children : undefined,
|
||||
dataRef: { ...conn, dbName: conn.dbName, groupKey }
|
||||
});
|
||||
// Sort views by name (case-insensitive)
|
||||
viewEntries.sort((a, b) => a.displayName.toLowerCase().localeCompare(b.displayName.toLowerCase()));
|
||||
|
||||
const groupedNodes: TreeNode[] = [
|
||||
buildObjectGroup('tables', '表', <TableOutlined />, tables),
|
||||
buildObjectGroup('views', '视图', <EyeOutlined />, viewNodes),
|
||||
buildObjectGroup('triggers', '触发器', <FunctionOutlined />, triggerNodes),
|
||||
];
|
||||
// Sort triggers by display name (case-insensitive)
|
||||
triggerEntries.sort((a, b) => a.displayName.toLowerCase().localeCompare(b.displayName.toLowerCase()));
|
||||
|
||||
setTreeData(origin => updateTreeData(origin, key, [queriesNode, ...groupedNodes]));
|
||||
} else {
|
||||
setConnectionStates(prev => ({ ...prev, [key as string]: 'error' }));
|
||||
message.error({ content: res.message, key: `db-${key}-tables` });
|
||||
// Sort routines by display name (case-insensitive)
|
||||
routineEntries.sort((a, b) => a.displayName.toLowerCase().localeCompare(b.displayName.toLowerCase()));
|
||||
|
||||
const buildTableNode = (entry: { tableName: string; schemaName: string; displayName: string }): TreeNode => ({
|
||||
title: entry.displayName,
|
||||
key: `${conn.id}-${conn.dbName}-${entry.tableName}`,
|
||||
icon: <TableOutlined />,
|
||||
type: 'table',
|
||||
dataRef: { ...conn, tableName: entry.tableName, schemaName: entry.schemaName },
|
||||
isLeaf: false,
|
||||
});
|
||||
|
||||
const buildViewNode = (entry: { viewName: string; schemaName: string; displayName: string }): TreeNode => ({
|
||||
title: entry.displayName,
|
||||
key: `${conn.id}-${conn.dbName}-view-${entry.viewName}`,
|
||||
icon: <EyeOutlined />,
|
||||
type: 'view',
|
||||
dataRef: { ...conn, viewName: entry.viewName, tableName: entry.viewName, schemaName: entry.schemaName },
|
||||
isLeaf: true,
|
||||
});
|
||||
|
||||
const buildTriggerNode = (entry: { triggerName: string; tableName: string; schemaName: string; displayName: string }): TreeNode => ({
|
||||
title: entry.displayName,
|
||||
key: `${conn.id}-${conn.dbName}-trigger-${entry.triggerName}-${entry.tableName}`,
|
||||
icon: <FunctionOutlined />,
|
||||
type: 'db-trigger',
|
||||
dataRef: { ...conn, triggerName: entry.triggerName, triggerTableName: entry.tableName, schemaName: entry.schemaName },
|
||||
isLeaf: true,
|
||||
});
|
||||
|
||||
const buildRoutineNode = (entry: { routineName: string; routineType: string; schemaName: string; displayName: string }): TreeNode => ({
|
||||
title: entry.displayName,
|
||||
key: `${conn.id}-${conn.dbName}-routine-${entry.routineName}`,
|
||||
icon: <CodeOutlined />,
|
||||
type: 'routine',
|
||||
dataRef: { ...conn, routineName: entry.routineName, routineType: entry.routineType, schemaName: entry.schemaName },
|
||||
isLeaf: true,
|
||||
});
|
||||
|
||||
const buildObjectGroup = (
|
||||
parentKey: string,
|
||||
groupKey: string,
|
||||
groupTitle: string,
|
||||
groupIcon: React.ReactNode,
|
||||
children: TreeNode[],
|
||||
extraData: Record<string, any> = {}
|
||||
): TreeNode => ({
|
||||
title: `${groupTitle} (${children.length})`,
|
||||
key: `${parentKey}-${groupKey}`,
|
||||
icon: groupIcon,
|
||||
type: 'object-group',
|
||||
isLeaf: children.length === 0,
|
||||
children: children.length > 0 ? children : undefined,
|
||||
dataRef: { ...conn, dbName: conn.dbName, groupKey, ...extraData }
|
||||
});
|
||||
|
||||
const shouldGroupBySchema = shouldHideSchemaPrefix(conn as SavedConnection);
|
||||
if (shouldGroupBySchema) {
|
||||
type SchemaBucket = {
|
||||
schemaName: string;
|
||||
tables: TreeNode[];
|
||||
views: TreeNode[];
|
||||
routines: TreeNode[];
|
||||
triggers: TreeNode[];
|
||||
};
|
||||
|
||||
const schemaMap = new Map<string, SchemaBucket>();
|
||||
const getSchemaBucket = (rawSchemaName: string): SchemaBucket => {
|
||||
const schemaName = String(rawSchemaName || '').trim();
|
||||
const schemaKey = schemaName || '__default__';
|
||||
let bucket = schemaMap.get(schemaKey);
|
||||
if (!bucket) {
|
||||
bucket = {
|
||||
schemaName,
|
||||
tables: [],
|
||||
views: [],
|
||||
routines: [],
|
||||
triggers: [],
|
||||
};
|
||||
schemaMap.set(schemaKey, bucket);
|
||||
}
|
||||
return bucket;
|
||||
};
|
||||
|
||||
tableEntries.forEach((entry) => getSchemaBucket(entry.schemaName).tables.push(buildTableNode(entry)));
|
||||
viewEntries.forEach((entry) => getSchemaBucket(entry.schemaName).views.push(buildViewNode(entry)));
|
||||
routineEntries.forEach((entry) => getSchemaBucket(entry.schemaName).routines.push(buildRoutineNode(entry)));
|
||||
triggerEntries.forEach((entry) => getSchemaBucket(entry.schemaName).triggers.push(buildTriggerNode(entry)));
|
||||
|
||||
const schemaNodes: TreeNode[] = Array.from(schemaMap.values())
|
||||
.sort((a, b) => {
|
||||
if (!a.schemaName && !b.schemaName) return 0;
|
||||
if (!a.schemaName) return -1;
|
||||
if (!b.schemaName) return 1;
|
||||
return a.schemaName.toLowerCase().localeCompare(b.schemaName.toLowerCase());
|
||||
})
|
||||
.map((bucket) => {
|
||||
const schemaNodeKey = `${key}-schema-${bucket.schemaName || 'default'}`;
|
||||
const schemaTitle = bucket.schemaName || '默认模式';
|
||||
const groupedNodes: TreeNode[] = [
|
||||
buildObjectGroup(schemaNodeKey, 'tables', '表', <TableOutlined />, bucket.tables, { schemaName: bucket.schemaName }),
|
||||
buildObjectGroup(schemaNodeKey, 'views', '视图', <EyeOutlined />, bucket.views, { schemaName: bucket.schemaName }),
|
||||
buildObjectGroup(schemaNodeKey, 'routines', '函数', <CodeOutlined />, bucket.routines, { schemaName: bucket.schemaName }),
|
||||
buildObjectGroup(schemaNodeKey, 'triggers', '触发器', <FunctionOutlined />, bucket.triggers, { schemaName: bucket.schemaName }),
|
||||
];
|
||||
|
||||
return {
|
||||
title: schemaTitle,
|
||||
key: schemaNodeKey,
|
||||
icon: <FolderOpenOutlined />,
|
||||
type: 'object-group' as const,
|
||||
isLeaf: groupedNodes.length === 0,
|
||||
children: groupedNodes,
|
||||
dataRef: { ...conn, dbName: conn.dbName, groupKey: 'schema', schemaName: bucket.schemaName }
|
||||
};
|
||||
});
|
||||
|
||||
setTreeData(origin => updateTreeData(origin, key, [queriesNode, ...schemaNodes]));
|
||||
} else {
|
||||
const groupedNodes: TreeNode[] = [
|
||||
buildObjectGroup(key as string, 'tables', '表', <TableOutlined />, tableEntries.map(buildTableNode)),
|
||||
buildObjectGroup(key as string, 'views', '视图', <EyeOutlined />, viewEntries.map(buildViewNode)),
|
||||
buildObjectGroup(key as string, 'routines', '函数', <CodeOutlined />, routineEntries.map(buildRoutineNode)),
|
||||
buildObjectGroup(key as string, 'triggers', '触发器', <FunctionOutlined />, triggerEntries.map(buildTriggerNode)),
|
||||
];
|
||||
|
||||
setTreeData(origin => updateTreeData(origin, key, [queriesNode, ...groupedNodes]));
|
||||
}
|
||||
} else {
|
||||
setConnectionStates(prev => ({ ...prev, [key as string]: 'error' }));
|
||||
message.error({ content: res.message, key: `db-${key}-tables` });
|
||||
}
|
||||
} finally {
|
||||
loadingNodesRef.current.delete(loadKey);
|
||||
@@ -675,7 +863,7 @@ 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') {
|
||||
} else if (type === 'view' || type === 'db-trigger' || type === 'routine') {
|
||||
setActiveContext({ connectionId: dataRef.id, dbName: dataRef.dbName });
|
||||
} else if (type === 'saved-query') {
|
||||
setActiveContext({ connectionId: dataRef.connectionId, dbName: dataRef.dbName });
|
||||
@@ -751,6 +939,19 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
triggerName
|
||||
});
|
||||
return;
|
||||
} else if (node.type === 'routine') {
|
||||
const { routineName, routineType, dbName, id } = node.dataRef;
|
||||
const typeLabel = routineType === 'PROCEDURE' ? '存储过程' : '函数';
|
||||
addTab({
|
||||
id: `routine-def-${node.key}`,
|
||||
title: `${typeLabel}: ${routineName}`,
|
||||
type: 'routine-def',
|
||||
connectionId: id,
|
||||
dbName,
|
||||
routineName,
|
||||
routineType
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const key = node.key;
|
||||
@@ -1326,6 +1527,298 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
});
|
||||
};
|
||||
|
||||
// --- 视图操作 ---
|
||||
const openViewDefinition = (node: any) => {
|
||||
const { viewName, dbName, id } = node.dataRef;
|
||||
addTab({
|
||||
id: `view-def-${id}-${dbName}-${viewName}`,
|
||||
title: `视图: ${viewName}`,
|
||||
type: 'view-def',
|
||||
connectionId: id,
|
||||
dbName,
|
||||
viewName,
|
||||
});
|
||||
};
|
||||
|
||||
const openEditView = async (node: any) => {
|
||||
const conn = node.dataRef;
|
||||
const { viewName, dbName, id } = conn;
|
||||
// 获取视图定义后打开查询编辑器
|
||||
const dialect = getMetadataDialect(conn as SavedConnection);
|
||||
let template = `-- 编辑视图 ${viewName}\n-- 请修改后执行\nCREATE OR REPLACE VIEW ${viewName} AS\nSELECT * FROM your_table;`;
|
||||
|
||||
try {
|
||||
const config = buildRuntimeConfig(conn, dbName);
|
||||
let query = '';
|
||||
switch (dialect) {
|
||||
case 'mysql':
|
||||
query = `SHOW CREATE VIEW \`${viewName.replace(/`/g, '``')}\``;
|
||||
break;
|
||||
case 'postgres': case 'kingbase': case 'highgo': case 'vastbase': {
|
||||
const parts = viewName.split('.');
|
||||
const schema = parts.length > 1 ? parts[0] : 'public';
|
||||
const name = parts.length > 1 ? parts[1] : viewName;
|
||||
query = `SELECT pg_get_viewdef('${escapeSQLLiteral(schema)}.${escapeSQLLiteral(name)}'::regclass, true) AS view_definition`;
|
||||
break;
|
||||
}
|
||||
case 'sqlserver':
|
||||
query = `SELECT OBJECT_DEFINITION(OBJECT_ID('${escapeSQLLiteral(viewName)}')) AS view_definition`;
|
||||
break;
|
||||
case 'sqlite':
|
||||
query = `SELECT sql AS view_definition FROM sqlite_master WHERE type='view' AND name='${escapeSQLLiteral(viewName)}'`;
|
||||
break;
|
||||
}
|
||||
if (query) {
|
||||
const result = await DBQuery(config as any, dbName, query);
|
||||
if (result.success && Array.isArray(result.data) && result.data.length > 0) {
|
||||
const row = result.data[0] as Record<string, any>;
|
||||
const def = row.view_definition || row.VIEW_DEFINITION || Object.values(row).find(v => typeof v === 'string' && String(v).length > 10) || '';
|
||||
if (def) {
|
||||
template = `-- 编辑视图 ${viewName}\nCREATE OR REPLACE VIEW ${viewName} AS\n${def}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { /* 降级使用模板 */ }
|
||||
|
||||
addTab({
|
||||
id: `query-edit-view-${Date.now()}`,
|
||||
title: `编辑视图: ${viewName}`,
|
||||
type: 'query',
|
||||
connectionId: id,
|
||||
dbName,
|
||||
query: template
|
||||
});
|
||||
};
|
||||
|
||||
const openCreateView = (node: any) => {
|
||||
const conn = node.dataRef;
|
||||
const { dbName, id } = conn;
|
||||
const dialect = getMetadataDialect(conn as SavedConnection);
|
||||
let template: string;
|
||||
switch (dialect) {
|
||||
case 'mysql':
|
||||
template = `CREATE VIEW \`view_name\` AS\nSELECT column1, column2\nFROM table_name\nWHERE condition;`;
|
||||
break;
|
||||
case 'postgres': case 'kingbase': case 'highgo': case 'vastbase':
|
||||
template = `CREATE OR REPLACE VIEW view_name AS\nSELECT column1, column2\nFROM table_name\nWHERE condition;`;
|
||||
break;
|
||||
case 'sqlserver':
|
||||
template = `CREATE VIEW dbo.view_name AS\nSELECT column1, column2\nFROM table_name\nWHERE condition;`;
|
||||
break;
|
||||
case 'oracle': case 'dm':
|
||||
template = `CREATE OR REPLACE VIEW view_name AS\nSELECT column1, column2\nFROM table_name\nWHERE condition;`;
|
||||
break;
|
||||
case 'sqlite':
|
||||
template = `CREATE VIEW view_name AS\nSELECT column1, column2\nFROM table_name\nWHERE condition;`;
|
||||
break;
|
||||
default:
|
||||
template = `CREATE VIEW view_name AS\nSELECT column1, column2\nFROM table_name\nWHERE condition;`;
|
||||
}
|
||||
addTab({
|
||||
id: `query-create-view-${Date.now()}`,
|
||||
title: `新建视图`,
|
||||
type: 'query',
|
||||
connectionId: id,
|
||||
dbName,
|
||||
query: template
|
||||
});
|
||||
};
|
||||
|
||||
const handleDropView = (node: any) => {
|
||||
const conn = node.dataRef;
|
||||
const viewName = String(conn.viewName || '').trim();
|
||||
if (!viewName) return;
|
||||
Modal.confirm({
|
||||
title: '确认删除视图',
|
||||
content: `确定删除视图 "${viewName}" 吗?该操作不可恢复。`,
|
||||
okButtonProps: { danger: true },
|
||||
onOk: async () => {
|
||||
const config = buildRuntimeConfig(conn, conn.dbName);
|
||||
const res = await DropView(config as any, conn.dbName, viewName);
|
||||
if (res.success) {
|
||||
message.success("视图删除成功");
|
||||
await loadTables(getDatabaseNodeRef(conn, conn.dbName));
|
||||
} else {
|
||||
message.error("删除失败: " + res.message);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleRenameView = async () => {
|
||||
if (!renameViewTarget) return;
|
||||
try {
|
||||
const values = await renameViewForm.validateFields();
|
||||
const conn = renameViewTarget.dataRef;
|
||||
const oldViewName = String(conn.viewName || '').trim();
|
||||
const newViewName = String(values.newName || '').trim();
|
||||
if (!oldViewName || !newViewName) {
|
||||
message.error("视图名称不能为空");
|
||||
return;
|
||||
}
|
||||
if (extractObjectName(oldViewName) === newViewName || oldViewName === newViewName) {
|
||||
message.warning("新旧视图名相同,无需修改");
|
||||
return;
|
||||
}
|
||||
const config = buildRuntimeConfig(conn, conn.dbName);
|
||||
const res = await RenameView(config as any, conn.dbName, oldViewName, newViewName);
|
||||
if (res.success) {
|
||||
message.success("视图重命名成功");
|
||||
await loadTables(getDatabaseNodeRef(conn, conn.dbName));
|
||||
setIsRenameViewModalOpen(false);
|
||||
setRenameViewTarget(null);
|
||||
renameViewForm.resetFields();
|
||||
} else {
|
||||
message.error("重命名失败: " + res.message);
|
||||
}
|
||||
} catch (e) {
|
||||
// Validate failed
|
||||
}
|
||||
};
|
||||
|
||||
// --- 函数/存储过程操作 ---
|
||||
const openRoutineDefinition = (node: any) => {
|
||||
const { routineName, routineType, dbName, id } = node.dataRef;
|
||||
const typeLabel = routineType === 'PROCEDURE' ? '存储过程' : '函数';
|
||||
addTab({
|
||||
id: `routine-def-${id}-${dbName}-${routineName}`,
|
||||
title: `${typeLabel}: ${routineName}`,
|
||||
type: 'routine-def',
|
||||
connectionId: id,
|
||||
dbName,
|
||||
routineName,
|
||||
routineType
|
||||
});
|
||||
};
|
||||
|
||||
const openEditRoutine = async (node: any) => {
|
||||
const conn = node.dataRef;
|
||||
const { routineName, routineType, dbName, id } = conn;
|
||||
const dialect = getMetadataDialect(conn as SavedConnection);
|
||||
const typeLabel = routineType === 'PROCEDURE' ? '存储过程' : '函数';
|
||||
let template = `-- 编辑${typeLabel} ${routineName}`;
|
||||
|
||||
try {
|
||||
const config = buildRuntimeConfig(conn, dbName);
|
||||
let query = '';
|
||||
const parts = routineName.split('.');
|
||||
const name = parts.length > 1 ? parts[1] : routineName;
|
||||
const schema = parts.length > 1 ? parts[0] : '';
|
||||
|
||||
switch (dialect) {
|
||||
case 'mysql':
|
||||
query = `SHOW CREATE ${routineType} \`${name.replace(/`/g, '``')}\``;
|
||||
break;
|
||||
case 'postgres': case 'kingbase': case 'highgo': case 'vastbase': {
|
||||
const schemaRef = schema || 'public';
|
||||
query = `SELECT pg_get_functiondef(p.oid) AS routine_definition FROM pg_proc p JOIN pg_namespace n ON p.pronamespace = n.oid WHERE n.nspname = '${escapeSQLLiteral(schemaRef)}' AND p.proname = '${escapeSQLLiteral(name)}' LIMIT 1`;
|
||||
break;
|
||||
}
|
||||
case 'sqlserver':
|
||||
query = `SELECT OBJECT_DEFINITION(OBJECT_ID('${escapeSQLLiteral(routineName)}')) AS routine_definition`;
|
||||
break;
|
||||
case 'oracle': case 'dm': {
|
||||
const owner = schema ? escapeSQLLiteral(schema).toUpperCase() : '';
|
||||
if (owner) {
|
||||
query = `SELECT TEXT FROM ALL_SOURCE WHERE OWNER = '${owner}' AND NAME = '${escapeSQLLiteral(name).toUpperCase()}' AND TYPE = '${routineType}' ORDER BY LINE`;
|
||||
} else {
|
||||
query = `SELECT TEXT FROM USER_SOURCE WHERE NAME = '${escapeSQLLiteral(name).toUpperCase()}' AND TYPE = '${routineType}' ORDER BY LINE`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (query) {
|
||||
const result = await DBQuery(config as any, dbName, query);
|
||||
if (result.success && Array.isArray(result.data) && result.data.length > 0) {
|
||||
if (dialect === 'oracle' || dialect === 'dm') {
|
||||
const lines = result.data.map((row: any) => row.text || row.TEXT || Object.values(row)[0] || '').join('');
|
||||
if (lines) template = `-- 编辑${typeLabel} ${routineName}\nCREATE OR REPLACE ${lines}`;
|
||||
} else {
|
||||
const row = result.data[0] as Record<string, any>;
|
||||
const def = row.routine_definition || row.ROUTINE_DEFINITION || Object.values(row).find(v => typeof v === 'string' && String(v).length > 10) || '';
|
||||
if (def) template = `-- 编辑${typeLabel} ${routineName}\n${def}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { /* 降级使用模板 */ }
|
||||
|
||||
addTab({
|
||||
id: `query-edit-routine-${Date.now()}`,
|
||||
title: `编辑${typeLabel}: ${routineName}`,
|
||||
type: 'query',
|
||||
connectionId: id,
|
||||
dbName,
|
||||
query: template
|
||||
});
|
||||
};
|
||||
|
||||
const openCreateRoutine = (node: any, type: 'FUNCTION' | 'PROCEDURE') => {
|
||||
const conn = node.dataRef;
|
||||
const { dbName, id } = conn;
|
||||
const dialect = getMetadataDialect(conn as SavedConnection);
|
||||
const isProc = type === 'PROCEDURE';
|
||||
let template: string;
|
||||
|
||||
switch (dialect) {
|
||||
case 'mysql':
|
||||
template = isProc
|
||||
? `DELIMITER $$\nCREATE PROCEDURE proc_name(IN param1 INT)\nBEGIN\n SELECT * FROM table_name WHERE id = param1;\nEND$$\nDELIMITER ;`
|
||||
: `DELIMITER $$\nCREATE FUNCTION func_name(param1 INT)\nRETURNS INT\nDETERMINISTIC\nBEGIN\n RETURN param1 * 2;\nEND$$\nDELIMITER ;`;
|
||||
break;
|
||||
case 'postgres': case 'kingbase': case 'highgo': case 'vastbase':
|
||||
template = isProc
|
||||
? `CREATE OR REPLACE PROCEDURE proc_name(param1 integer)\nLANGUAGE plpgsql\nAS $$\nBEGIN\n -- procedure body\nEND;\n$$;`
|
||||
: `CREATE OR REPLACE FUNCTION func_name(param1 integer)\nRETURNS integer\nLANGUAGE plpgsql\nAS $$\nBEGIN\n RETURN param1 * 2;\nEND;\n$$;`;
|
||||
break;
|
||||
case 'sqlserver':
|
||||
template = isProc
|
||||
? `CREATE PROCEDURE dbo.proc_name\n @param1 INT\nAS\nBEGIN\n SELECT * FROM table_name WHERE id = @param1;\nEND;`
|
||||
: `CREATE FUNCTION dbo.func_name(@param1 INT)\nRETURNS INT\nAS\nBEGIN\n RETURN @param1 * 2;\nEND;`;
|
||||
break;
|
||||
case 'oracle': case 'dm':
|
||||
template = isProc
|
||||
? `CREATE OR REPLACE PROCEDURE proc_name(param1 IN NUMBER)\nIS\nBEGIN\n -- procedure body\n NULL;\nEND;`
|
||||
: `CREATE OR REPLACE FUNCTION func_name(param1 IN NUMBER)\nRETURN NUMBER\nIS\nBEGIN\n RETURN param1 * 2;\nEND;`;
|
||||
break;
|
||||
default:
|
||||
template = isProc
|
||||
? `CREATE PROCEDURE proc_name()\nBEGIN\n -- procedure body\nEND;`
|
||||
: `CREATE FUNCTION func_name()\nRETURNS INTEGER\nBEGIN\n RETURN 0;\nEND;`;
|
||||
}
|
||||
|
||||
addTab({
|
||||
id: `query-create-routine-${Date.now()}`,
|
||||
title: isProc ? '新建存储过程' : '新建函数',
|
||||
type: 'query',
|
||||
connectionId: id,
|
||||
dbName,
|
||||
query: template
|
||||
});
|
||||
};
|
||||
|
||||
const handleDropRoutine = (node: any) => {
|
||||
const conn = node.dataRef;
|
||||
const routineName = String(conn.routineName || '').trim();
|
||||
const routineType = String(conn.routineType || 'FUNCTION').trim();
|
||||
if (!routineName) return;
|
||||
const typeLabel = routineType === 'PROCEDURE' ? '存储过程' : '函数';
|
||||
Modal.confirm({
|
||||
title: `确认删除${typeLabel}`,
|
||||
content: `确定删除${typeLabel} "${routineName}" 吗?该操作不可恢复。`,
|
||||
okButtonProps: { danger: true },
|
||||
onOk: async () => {
|
||||
const config = buildRuntimeConfig(conn, conn.dbName);
|
||||
const res = await DropFunction(config as any, conn.dbName, routineName, routineType);
|
||||
if (res.success) {
|
||||
message.success(`${typeLabel}删除成功`);
|
||||
await loadTables(getDatabaseNodeRef(conn, conn.dbName));
|
||||
} else {
|
||||
message.error("删除失败: " + res.message);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const onSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { value } = e.target;
|
||||
setSearchValue(value);
|
||||
@@ -1394,6 +1887,36 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
];
|
||||
}
|
||||
|
||||
// 视图分组节点的右键菜单
|
||||
if (node.type === 'object-group' && node.dataRef?.groupKey === 'views') {
|
||||
return [
|
||||
{
|
||||
key: 'create-view',
|
||||
label: '新建视图',
|
||||
icon: <PlusOutlined />,
|
||||
onClick: () => openCreateView(node)
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// 函数分组节点的右键菜单
|
||||
if (node.type === 'object-group' && node.dataRef?.groupKey === 'routines') {
|
||||
return [
|
||||
{
|
||||
key: 'create-function',
|
||||
label: '新建函数',
|
||||
icon: <PlusOutlined />,
|
||||
onClick: () => openCreateRoutine(node, 'FUNCTION')
|
||||
},
|
||||
{
|
||||
key: 'create-procedure',
|
||||
label: '新建存储过程',
|
||||
icon: <PlusOutlined />,
|
||||
onClick: () => openCreateRoutine(node, 'PROCEDURE')
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (node.type === 'connection') {
|
||||
// Redis connection menu
|
||||
if (isRedis) {
|
||||
@@ -1666,6 +2189,19 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
icon: <EyeOutlined />,
|
||||
onClick: () => onDoubleClick(null, node)
|
||||
},
|
||||
{
|
||||
key: 'view-definition',
|
||||
label: '查看视图定义',
|
||||
icon: <CodeOutlined />,
|
||||
onClick: () => openViewDefinition(node)
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
key: 'edit-view',
|
||||
label: '编辑视图',
|
||||
icon: <EditOutlined />,
|
||||
onClick: () => openEditView(node)
|
||||
},
|
||||
{
|
||||
key: 'new-query',
|
||||
label: '新建查询',
|
||||
@@ -1680,7 +2216,50 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
query: ''
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
key: 'rename-view',
|
||||
label: '重命名视图',
|
||||
icon: <EditOutlined />,
|
||||
onClick: () => {
|
||||
setRenameViewTarget(node);
|
||||
renameViewForm.setFieldsValue({ newName: extractObjectName(node.dataRef?.viewName || node.title) });
|
||||
setIsRenameViewModalOpen(true);
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'drop-view',
|
||||
label: '删除视图',
|
||||
icon: <DeleteOutlined />,
|
||||
danger: true,
|
||||
onClick: () => handleDropView(node)
|
||||
},
|
||||
];
|
||||
} else if (node.type === 'routine') {
|
||||
const routineType = node.dataRef?.routineType || 'FUNCTION';
|
||||
const typeLabel = routineType === 'PROCEDURE' ? '存储过程' : '函数';
|
||||
return [
|
||||
{
|
||||
key: 'view-routine-def',
|
||||
label: '查看定义',
|
||||
icon: <CodeOutlined />,
|
||||
onClick: () => openRoutineDefinition(node)
|
||||
},
|
||||
{
|
||||
key: 'edit-routine',
|
||||
label: '编辑定义',
|
||||
icon: <EditOutlined />,
|
||||
onClick: () => openEditRoutine(node)
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
key: 'drop-routine',
|
||||
label: `删除${typeLabel}`,
|
||||
icon: <DeleteOutlined />,
|
||||
danger: true,
|
||||
onClick: () => handleDropRoutine(node)
|
||||
},
|
||||
];
|
||||
} else if (node.type === 'table') {
|
||||
return [
|
||||
@@ -1897,6 +2476,23 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={`重命名视图${renameViewTarget?.dataRef?.viewName ? ` (${renameViewTarget.dataRef.viewName})` : ''}`}
|
||||
open={isRenameViewModalOpen}
|
||||
onOk={handleRenameView}
|
||||
onCancel={() => {
|
||||
setIsRenameViewModalOpen(false);
|
||||
setRenameViewTarget(null);
|
||||
renameViewForm.resetFields();
|
||||
}}
|
||||
>
|
||||
<Form form={renameViewForm} layout="vertical">
|
||||
<Form.Item name="newName" label="新视图名" rules={[{ required: true, message: '请输入新视图名' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="批量操作表"
|
||||
open={isBatchModalOpen}
|
||||
|
||||
@@ -8,6 +8,7 @@ import TableDesigner from './TableDesigner';
|
||||
import RedisViewer from './RedisViewer';
|
||||
import RedisCommandEditor from './RedisCommandEditor';
|
||||
import TriggerViewer from './TriggerViewer';
|
||||
import DefinitionViewer from './DefinitionViewer';
|
||||
import type { TabData } from '../types';
|
||||
|
||||
const detectConnectionEnvLabel = (connectionName: string): string | null => {
|
||||
@@ -65,6 +66,8 @@ const TabManager: React.FC = () => {
|
||||
content = <RedisCommandEditor connectionId={tab.connectionId} redisDB={tab.redisDB ?? 0} />;
|
||||
} else if (tab.type === 'trigger') {
|
||||
content = <TriggerViewer tab={tab} />;
|
||||
} else if (tab.type === 'view-def' || tab.type === 'routine-def') {
|
||||
content = <DefinitionViewer tab={tab} />;
|
||||
}
|
||||
|
||||
const menuItems: MenuProps['items'] = [
|
||||
|
||||
@@ -3,6 +3,22 @@ import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
// import './index.css' // Optional global styles
|
||||
|
||||
// 全局配置 Monaco Editor 使用本地打包的文件,避免从 CDN (jsdelivr) 加载。
|
||||
// Windows WebView2 环境下访问外部 CDN 可能失败,导致编辑器一直显示 Loading。
|
||||
import { loader } from '@monaco-editor/react'
|
||||
import * as monaco from 'monaco-editor'
|
||||
loader.config({ monaco })
|
||||
|
||||
// 全局注册透明主题,避免每个 Editor 组件 beforeMount 中重复定义
|
||||
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' }
|
||||
})
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
|
||||
@@ -84,7 +84,7 @@ export interface TriggerDefinition {
|
||||
export interface TabData {
|
||||
id: string;
|
||||
title: string;
|
||||
type: 'query' | 'table' | 'design' | 'redis-keys' | 'redis-command' | 'trigger';
|
||||
type: 'query' | 'table' | 'design' | 'redis-keys' | 'redis-command' | 'trigger' | 'view-def' | 'routine-def';
|
||||
connectionId: string;
|
||||
dbName?: string;
|
||||
tableName?: string;
|
||||
@@ -93,6 +93,9 @@ export interface TabData {
|
||||
readOnly?: boolean;
|
||||
redisDB?: number; // Redis database index for redis tabs
|
||||
triggerName?: string; // Trigger name for trigger tabs
|
||||
viewName?: string; // View name for view definition tabs
|
||||
routineName?: string; // Routine name for function/procedure definition tabs
|
||||
routineType?: string; // 'FUNCTION' or 'PROCEDURE'
|
||||
}
|
||||
|
||||
export interface DatabaseNode {
|
||||
|
||||
12
frontend/wailsjs/go/app/App.d.ts
vendored
12
frontend/wailsjs/go/app/App.d.ts
vendored
@@ -40,8 +40,12 @@ export function DownloadUpdate():Promise<connection.QueryResult>;
|
||||
|
||||
export function DropDatabase(arg1:connection.ConnectionConfig,arg2:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function DropFunction(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function DropTable(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function DropView(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function ExportData(arg1:Array<Record<string, any>>,arg2:Array<string>,arg3:string,arg4:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function ExportDatabaseSQL(arg1:connection.ConnectionConfig,arg2:string,arg3:boolean):Promise<connection.QueryResult>;
|
||||
@@ -60,6 +64,8 @@ export function ImportConfigFile():Promise<connection.QueryResult>;
|
||||
|
||||
export function ImportData(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function ImportDataWithProgress(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function InstallUpdateAndRestart():Promise<connection.QueryResult>;
|
||||
|
||||
export function MongoDiscoverMembers(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;
|
||||
@@ -76,6 +82,8 @@ export function MySQLShowCreateTable(arg1:connection.ConnectionConfig,arg2:strin
|
||||
|
||||
export function OpenSQLFile():Promise<connection.QueryResult>;
|
||||
|
||||
export function PreviewImportFile(arg1:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function RedisConnect(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;
|
||||
|
||||
export function RedisDeleteHashField(arg1:connection.ConnectionConfig,arg2:string,arg3:Array<string>):Promise<connection.QueryResult>;
|
||||
@@ -122,4 +130,8 @@ export function RenameDatabase(arg1:connection.ConnectionConfig,arg2:string,arg3
|
||||
|
||||
export function RenameTable(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function RenameView(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function SetWindowTranslucency(arg1:number,arg2:number):Promise<void>;
|
||||
|
||||
export function TestConnection(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;
|
||||
|
||||
@@ -74,10 +74,18 @@ export function DropDatabase(arg1, arg2) {
|
||||
return window['go']['app']['App']['DropDatabase'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function DropFunction(arg1, arg2, arg3, arg4) {
|
||||
return window['go']['app']['App']['DropFunction'](arg1, arg2, arg3, arg4);
|
||||
}
|
||||
|
||||
export function DropTable(arg1, arg2, arg3) {
|
||||
return window['go']['app']['App']['DropTable'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function DropView(arg1, arg2, arg3) {
|
||||
return window['go']['app']['App']['DropView'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function ExportData(arg1, arg2, arg3, arg4) {
|
||||
return window['go']['app']['App']['ExportData'](arg1, arg2, arg3, arg4);
|
||||
}
|
||||
@@ -114,6 +122,10 @@ export function ImportData(arg1, arg2, arg3) {
|
||||
return window['go']['app']['App']['ImportData'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function ImportDataWithProgress(arg1, arg2, arg3, arg4) {
|
||||
return window['go']['app']['App']['ImportDataWithProgress'](arg1, arg2, arg3, arg4);
|
||||
}
|
||||
|
||||
export function InstallUpdateAndRestart() {
|
||||
return window['go']['app']['App']['InstallUpdateAndRestart']();
|
||||
}
|
||||
@@ -146,6 +158,10 @@ export function OpenSQLFile() {
|
||||
return window['go']['app']['App']['OpenSQLFile']();
|
||||
}
|
||||
|
||||
export function PreviewImportFile(arg1) {
|
||||
return window['go']['app']['App']['PreviewImportFile'](arg1);
|
||||
}
|
||||
|
||||
export function RedisConnect(arg1) {
|
||||
return window['go']['app']['App']['RedisConnect'](arg1);
|
||||
}
|
||||
@@ -238,6 +254,14 @@ export function RenameTable(arg1, arg2, arg3, arg4) {
|
||||
return window['go']['app']['App']['RenameTable'](arg1, arg2, arg3, arg4);
|
||||
}
|
||||
|
||||
export function RenameView(arg1, arg2, arg3, arg4) {
|
||||
return window['go']['app']['App']['RenameView'](arg1, arg2, arg3, arg4);
|
||||
}
|
||||
|
||||
export function SetWindowTranslucency(arg1, arg2) {
|
||||
return window['go']['app']['App']['SetWindowTranslucency'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function TestConnection(arg1) {
|
||||
return window['go']['app']['App']['TestConnection'](arg1);
|
||||
}
|
||||
|
||||
6
go.mod
6
go.mod
@@ -12,6 +12,7 @@ require (
|
||||
github.com/sijms/go-ora/v2 v2.9.0
|
||||
github.com/taosdata/driver-go/v3 v3.7.8
|
||||
github.com/wailsapp/wails/v2 v2.11.0
|
||||
github.com/xuri/excelize/v2 v2.10.0
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0
|
||||
golang.org/x/crypto v0.47.0
|
||||
golang.org/x/text v0.33.0
|
||||
@@ -49,9 +50,12 @@ require (
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/richardlehane/mscfb v1.0.4 // indirect
|
||||
github.com/richardlehane/msoleps v1.0.4 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/samber/lo v1.49.1 // indirect
|
||||
github.com/shopspring/decimal v1.4.0 // indirect
|
||||
github.com/tiendc/go-deepcopy v1.7.1 // indirect
|
||||
github.com/tkrajina/go-reflector v0.5.8 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||
@@ -60,6 +64,8 @@ require (
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
||||
github.com/xdg-go/scram v1.2.0 // indirect
|
||||
github.com/xdg-go/stringprep v1.0.4 // indirect
|
||||
github.com/xuri/efp v0.0.1 // indirect
|
||||
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
||||
golang.org/x/net v0.48.0 // indirect
|
||||
|
||||
19
go.sum
19
go.sum
@@ -110,6 +110,11 @@ github.com/redis/go-redis/v9 v9.17.3 h1:fN29NdNrE17KttK5Ndf20buqfDZwGNgoUr9qjl1D
|
||||
github.com/redis/go-redis/v9 v9.17.3/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/richardlehane/mscfb v1.0.4 h1:WULscsljNPConisD5hR0+OyZjwK46Pfyr6mPu5ZawpM=
|
||||
github.com/richardlehane/mscfb v1.0.4/go.mod h1:YzVpcZg9czvAuhk9T+a3avCpcFPMUWm7gK3DypaEsUk=
|
||||
github.com/richardlehane/msoleps v1.0.1/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
|
||||
github.com/richardlehane/msoleps v1.0.4 h1:WuESlvhX3gH2IHcd8UqyCuFY5yiq/GR/yqaSM/9/g00=
|
||||
github.com/richardlehane/msoleps v1.0.4/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
@@ -127,10 +132,12 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/taosdata/driver-go/v3 v3.7.8 h1:N2H6HLLZH2ve2ipcoFgG9BJS+yW0XksqNYwEdSmHaJk=
|
||||
github.com/taosdata/driver-go/v3 v3.7.8/go.mod h1:gSxBEPOueMg0rTmMO1Ug6aeD7AwGdDGvUtLrsDTTpYc=
|
||||
github.com/tiendc/go-deepcopy v1.7.1 h1:LnubftI6nYaaMOcaz0LphzwraqN8jiWTwm416sitff4=
|
||||
github.com/tiendc/go-deepcopy v1.7.1/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ=
|
||||
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
|
||||
github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
@@ -149,6 +156,12 @@ github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
|
||||
github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8=
|
||||
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
|
||||
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
|
||||
github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
|
||||
github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
|
||||
github.com/xuri/excelize/v2 v2.10.0 h1:8aKsP7JD39iKLc6dH5Tw3dgV3sPRh8uRVXu/fMstfW4=
|
||||
github.com/xuri/excelize/v2 v2.10.0/go.mod h1:SC5TzhQkaOsTWpANfm+7bJCldzcnU/jrhqkTi/iBHBU=
|
||||
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE=
|
||||
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
@@ -160,6 +173,8 @@ golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
|
||||
golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ=
|
||||
golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
|
||||
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
|
||||
|
||||
@@ -49,6 +49,13 @@ func (a *App) Startup(ctx context.Context) {
|
||||
logger.Infof("应用启动完成")
|
||||
}
|
||||
|
||||
// SetWindowTranslucency 动态调整 macOS 窗口透明度。
|
||||
// 前端在加载用户外观设置后、以及用户修改外观时调用此方法。
|
||||
// opacity=1.0 且 blur=0 时窗口标记为 opaque,GPU 不再持续计算窗口背后的模糊合成。
|
||||
func (a *App) SetWindowTranslucency(opacity float64, blur float64) {
|
||||
setMacWindowTranslucency(opacity, blur)
|
||||
}
|
||||
|
||||
// Shutdown is called when the app terminates
|
||||
func (a *App) Shutdown(ctx context.Context) {
|
||||
logger.Infof("应用开始关闭,准备释放资源")
|
||||
|
||||
@@ -367,7 +367,12 @@ func (a *App) DBQuery(config connection.ConnectionConfig, dbName string, query s
|
||||
defer cancel()
|
||||
|
||||
lowerQuery := strings.TrimSpace(strings.ToLower(query))
|
||||
if strings.HasPrefix(lowerQuery, "select") || strings.HasPrefix(lowerQuery, "show") || strings.HasPrefix(lowerQuery, "describe") || strings.HasPrefix(lowerQuery, "explain") {
|
||||
isReadQuery := strings.HasPrefix(lowerQuery, "select") || strings.HasPrefix(lowerQuery, "show") || strings.HasPrefix(lowerQuery, "describe") || strings.HasPrefix(lowerQuery, "explain")
|
||||
// MongoDB JSON 命令中的 find/count/aggregate 也属于读查询
|
||||
if !isReadQuery && strings.ToLower(strings.TrimSpace(runConfig.Type)) == "mongodb" && strings.HasPrefix(strings.TrimSpace(query), "{") {
|
||||
isReadQuery = true
|
||||
}
|
||||
if isReadQuery {
|
||||
var data []map[string]interface{}
|
||||
var columns []string
|
||||
if q, ok := dbInst.(interface {
|
||||
@@ -539,6 +544,125 @@ func (a *App) DBGetTriggers(config connection.ConnectionConfig, dbName string, t
|
||||
return connection.QueryResult{Success: true, Data: triggers}
|
||||
}
|
||||
|
||||
func (a *App) DropView(config connection.ConnectionConfig, dbName string, viewName string) connection.QueryResult {
|
||||
viewName = strings.TrimSpace(viewName)
|
||||
if viewName == "" {
|
||||
return connection.QueryResult{Success: false, Message: "视图名称不能为空"}
|
||||
}
|
||||
|
||||
dbType := resolveDDLDBType(config)
|
||||
switch dbType {
|
||||
case "mysql", "mariadb", "postgres", "kingbase", "sqlite", "oracle", "dameng", "highgo", "vastbase", "sqlserver":
|
||||
default:
|
||||
return connection.QueryResult{Success: false, Message: fmt.Sprintf("当前数据源(%s)暂不支持删除视图", dbType)}
|
||||
}
|
||||
|
||||
schemaName, pureViewName := normalizeSchemaAndTableByType(dbType, dbName, viewName)
|
||||
if pureViewName == "" {
|
||||
return connection.QueryResult{Success: false, Message: "视图名称不能为空"}
|
||||
}
|
||||
qualifiedView := quoteTableIdentByType(dbType, schemaName, pureViewName)
|
||||
sql := fmt.Sprintf("DROP VIEW %s", qualifiedView)
|
||||
|
||||
runConfig := buildRunConfigForDDL(config, dbType, dbName)
|
||||
dbInst, err := a.getDatabase(runConfig)
|
||||
if err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
if _, err := dbInst.Exec(sql); err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
return connection.QueryResult{Success: true, Message: "视图删除成功"}
|
||||
}
|
||||
|
||||
func (a *App) DropFunction(config connection.ConnectionConfig, dbName string, routineName string, routineType string) connection.QueryResult {
|
||||
routineName = strings.TrimSpace(routineName)
|
||||
routineType = strings.TrimSpace(strings.ToUpper(routineType))
|
||||
if routineName == "" {
|
||||
return connection.QueryResult{Success: false, Message: "函数/存储过程名称不能为空"}
|
||||
}
|
||||
if routineType != "FUNCTION" && routineType != "PROCEDURE" {
|
||||
routineType = "FUNCTION"
|
||||
}
|
||||
|
||||
dbType := resolveDDLDBType(config)
|
||||
switch dbType {
|
||||
case "mysql", "mariadb", "postgres", "kingbase", "oracle", "dameng", "highgo", "vastbase", "sqlserver":
|
||||
default:
|
||||
return connection.QueryResult{Success: false, Message: fmt.Sprintf("当前数据源(%s)暂不支持删除函数/存储过程", dbType)}
|
||||
}
|
||||
|
||||
schemaName, pureName := normalizeSchemaAndTableByType(dbType, dbName, routineName)
|
||||
if pureName == "" {
|
||||
return connection.QueryResult{Success: false, Message: "函数/存储过程名称不能为空"}
|
||||
}
|
||||
qualifiedName := quoteTableIdentByType(dbType, schemaName, pureName)
|
||||
sql := fmt.Sprintf("DROP %s %s", routineType, qualifiedName)
|
||||
|
||||
runConfig := buildRunConfigForDDL(config, dbType, dbName)
|
||||
dbInst, err := a.getDatabase(runConfig)
|
||||
if err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
if _, err := dbInst.Exec(sql); err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
|
||||
label := "函数"
|
||||
if routineType == "PROCEDURE" {
|
||||
label = "存储过程"
|
||||
}
|
||||
return connection.QueryResult{Success: true, Message: fmt.Sprintf("%s删除成功", label)}
|
||||
}
|
||||
|
||||
func (a *App) RenameView(config connection.ConnectionConfig, dbName string, oldName string, newName string) connection.QueryResult {
|
||||
oldName = strings.TrimSpace(oldName)
|
||||
newName = strings.TrimSpace(newName)
|
||||
if oldName == "" || newName == "" {
|
||||
return connection.QueryResult{Success: false, Message: "视图名称不能为空"}
|
||||
}
|
||||
if strings.EqualFold(oldName, newName) {
|
||||
return connection.QueryResult{Success: false, Message: "新旧视图名称不能相同"}
|
||||
}
|
||||
if strings.Contains(newName, ".") {
|
||||
return connection.QueryResult{Success: false, Message: "新视图名不能包含 schema 或数据库前缀"}
|
||||
}
|
||||
|
||||
dbType := resolveDDLDBType(config)
|
||||
schemaName, pureOldName := normalizeSchemaAndTableByType(dbType, dbName, oldName)
|
||||
if pureOldName == "" {
|
||||
return connection.QueryResult{Success: false, Message: "旧视图名不能为空"}
|
||||
}
|
||||
oldQualified := quoteTableIdentByType(dbType, schemaName, pureOldName)
|
||||
newQuoted := quoteIdentByType(dbType, newName)
|
||||
|
||||
var sql string
|
||||
switch dbType {
|
||||
case "mysql", "mariadb":
|
||||
newQualified := quoteTableIdentByType(dbType, schemaName, newName)
|
||||
sql = fmt.Sprintf("RENAME TABLE %s TO %s", oldQualified, newQualified)
|
||||
case "postgres", "kingbase", "highgo", "vastbase":
|
||||
sql = fmt.Sprintf("ALTER VIEW %s RENAME TO %s", oldQualified, newQuoted)
|
||||
case "sqlserver":
|
||||
oldFullName := schemaName + "." + pureOldName
|
||||
escapedOld := strings.ReplaceAll(oldFullName, "'", "''")
|
||||
escapedNew := strings.ReplaceAll(newName, "'", "''")
|
||||
sql = fmt.Sprintf("EXEC sp_rename '%s', '%s'", escapedOld, escapedNew)
|
||||
default:
|
||||
return connection.QueryResult{Success: false, Message: fmt.Sprintf("当前数据源(%s)暂不支持重命名视图", dbType)}
|
||||
}
|
||||
|
||||
runConfig := buildRunConfigForDDL(config, dbType, dbName)
|
||||
dbInst, err := a.getDatabase(runConfig)
|
||||
if err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
if _, err := dbInst.Exec(sql); err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
return connection.QueryResult{Success: true, Message: "视图重命名成功"}
|
||||
}
|
||||
|
||||
func (a *App) DBGetAllColumns(config connection.ConnectionConfig, dbName string) connection.QueryResult {
|
||||
runConfig := normalizeRunConfig(config, dbName)
|
||||
|
||||
|
||||
@@ -14,9 +14,9 @@ import (
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
"GoNavi-Wails/internal/db"
|
||||
"GoNavi-Wails/internal/logger"
|
||||
|
||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
"github.com/xuri/excelize/v2"
|
||||
)
|
||||
|
||||
func (a *App) OpenSQLFile() connection.QueryResult {
|
||||
@@ -77,13 +77,40 @@ func (a *App) ImportConfigFile() connection.QueryResult {
|
||||
return connection.QueryResult{Success: true, Data: string(content)}
|
||||
}
|
||||
|
||||
// PreviewImportFile 解析导入文件,返回字段列表、总行数、前 5 行预览数据
|
||||
func (a *App) PreviewImportFile(filePath string) connection.QueryResult {
|
||||
if filePath == "" {
|
||||
return connection.QueryResult{Success: false, Message: "File path required"}
|
||||
}
|
||||
|
||||
rows, columns, err := parseImportFile(filePath)
|
||||
if err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
|
||||
totalRows := len(rows)
|
||||
previewRows := rows
|
||||
if len(rows) > 5 {
|
||||
previewRows = rows[:5]
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"columns": columns,
|
||||
"totalRows": totalRows,
|
||||
"previewRows": previewRows,
|
||||
"filePath": filePath,
|
||||
}
|
||||
|
||||
return connection.QueryResult{Success: true, Data: result}
|
||||
}
|
||||
|
||||
func (a *App) ImportData(config connection.ConnectionConfig, dbName, tableName string) connection.QueryResult {
|
||||
selection, err := runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{
|
||||
Title: fmt.Sprintf("Import into %s", tableName),
|
||||
Filters: []runtime.FileFilter{
|
||||
{
|
||||
DisplayName: "Data Files",
|
||||
Pattern: "*.csv;*.json",
|
||||
Pattern: "*.csv;*.json;*.xlsx;*.xls",
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -96,44 +123,249 @@ func (a *App) ImportData(config connection.ConnectionConfig, dbName, tableName s
|
||||
return connection.QueryResult{Success: false, Message: "Cancelled"}
|
||||
}
|
||||
|
||||
f, err := os.Open(selection)
|
||||
if err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
defer f.Close()
|
||||
// 返回文件路径供前端预览
|
||||
return connection.QueryResult{Success: true, Data: map[string]interface{}{"filePath": selection}}
|
||||
}
|
||||
|
||||
// parseImportFile 解析导入文件,返回数据行和列名
|
||||
func parseImportFile(filePath string) ([]map[string]interface{}, []string, error) {
|
||||
var rows []map[string]interface{}
|
||||
var columns []string
|
||||
lower := strings.ToLower(filePath)
|
||||
|
||||
if strings.HasSuffix(strings.ToLower(selection), ".json") {
|
||||
if strings.HasSuffix(lower, ".json") {
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
decoder := json.NewDecoder(f)
|
||||
if err := decoder.Decode(&rows); err != nil {
|
||||
return connection.QueryResult{Success: false, Message: "JSON Parse Error: " + err.Error()}
|
||||
return nil, nil, fmt.Errorf("JSON Parse Error: %w", err)
|
||||
}
|
||||
} else if strings.HasSuffix(strings.ToLower(selection), ".csv") {
|
||||
if len(rows) > 0 {
|
||||
for k := range rows[0] {
|
||||
columns = append(columns, k)
|
||||
}
|
||||
}
|
||||
} else if strings.HasSuffix(lower, ".csv") {
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
reader := csv.NewReader(f)
|
||||
records, err := reader.ReadAll()
|
||||
if err != nil {
|
||||
return connection.QueryResult{Success: false, Message: "CSV Parse Error: " + err.Error()}
|
||||
return nil, nil, fmt.Errorf("CSV Parse Error: %w", err)
|
||||
}
|
||||
if len(records) < 2 {
|
||||
return connection.QueryResult{Success: false, Message: "CSV empty or missing header"}
|
||||
return nil, nil, fmt.Errorf("CSV empty or missing header")
|
||||
}
|
||||
headers := records[0]
|
||||
columns = records[0]
|
||||
for _, record := range records[1:] {
|
||||
row := make(map[string]interface{})
|
||||
for i, val := range record {
|
||||
if i < len(headers) {
|
||||
if i < len(columns) {
|
||||
if val == "NULL" {
|
||||
row[headers[i]] = nil
|
||||
row[columns[i]] = nil
|
||||
} else {
|
||||
row[headers[i]] = val
|
||||
row[columns[i]] = val
|
||||
}
|
||||
}
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
} else if strings.HasSuffix(lower, ".xlsx") || strings.HasSuffix(lower, ".xls") {
|
||||
xlsx, err := excelize.OpenFile(filePath)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("Excel Parse Error: %w", err)
|
||||
}
|
||||
defer xlsx.Close()
|
||||
|
||||
sheetName := xlsx.GetSheetName(0)
|
||||
if sheetName == "" {
|
||||
return nil, nil, fmt.Errorf("Excel file has no sheets")
|
||||
}
|
||||
|
||||
xlRows, err := xlsx.GetRows(sheetName)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("Excel Read Error: %w", err)
|
||||
}
|
||||
if len(xlRows) < 2 {
|
||||
return nil, nil, fmt.Errorf("Excel empty or missing header")
|
||||
}
|
||||
|
||||
columns = xlRows[0]
|
||||
for _, record := range xlRows[1:] {
|
||||
row := make(map[string]interface{})
|
||||
for i, val := range record {
|
||||
if i < len(columns) && columns[i] != "" {
|
||||
if val == "NULL" {
|
||||
row[columns[i]] = nil
|
||||
} else {
|
||||
row[columns[i]] = val
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(row) > 0 {
|
||||
rows = append(rows, row)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return connection.QueryResult{Success: false, Message: "Unsupported file format"}
|
||||
return nil, nil, fmt.Errorf("Unsupported file format")
|
||||
}
|
||||
|
||||
return rows, columns, nil
|
||||
}
|
||||
|
||||
func normalizeColumnName(name string) string {
|
||||
return strings.ToLower(strings.TrimSpace(name))
|
||||
}
|
||||
|
||||
func buildImportColumnTypeMap(defs []connection.ColumnDefinition) map[string]string {
|
||||
result := make(map[string]string, len(defs))
|
||||
for _, def := range defs {
|
||||
key := normalizeColumnName(def.Name)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
result[key] = strings.TrimSpace(def.Type)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func isTimezoneAwareColumnType(columnType string) bool {
|
||||
typ := strings.ToLower(strings.TrimSpace(columnType))
|
||||
if typ == "" {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(typ, "with time zone") ||
|
||||
strings.Contains(typ, "with timezone") ||
|
||||
strings.Contains(typ, "datetimeoffset") ||
|
||||
strings.Contains(typ, "timestamptz")
|
||||
}
|
||||
|
||||
func isDateTimeColumnType(columnType string) bool {
|
||||
typ := strings.ToLower(strings.TrimSpace(columnType))
|
||||
if typ == "" {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(typ, "datetime") || strings.Contains(typ, "timestamp")
|
||||
}
|
||||
|
||||
func isTimeOnlyColumnType(columnType string) bool {
|
||||
typ := strings.ToLower(strings.TrimSpace(columnType))
|
||||
if typ == "" {
|
||||
return false
|
||||
}
|
||||
if strings.Contains(typ, "datetime") || strings.Contains(typ, "timestamp") {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(typ, "time")
|
||||
}
|
||||
|
||||
func isDateOnlyColumnType(dbType, columnType string) bool {
|
||||
typ := strings.ToLower(strings.TrimSpace(columnType))
|
||||
if typ == "" {
|
||||
return false
|
||||
}
|
||||
if strings.Contains(typ, "datetime") || strings.Contains(typ, "timestamp") || strings.Contains(typ, "time") {
|
||||
return false
|
||||
}
|
||||
if !strings.Contains(typ, "date") {
|
||||
return false
|
||||
}
|
||||
db := strings.ToLower(strings.TrimSpace(dbType))
|
||||
// Oracle/Dameng 的 DATE 带时间语义,不能按纯日期裁剪。
|
||||
return db != "oracle" && db != "dameng"
|
||||
}
|
||||
|
||||
func isTemporalColumnType(dbType, columnType string) bool {
|
||||
return isDateTimeColumnType(columnType) || isTimeOnlyColumnType(columnType) || isDateOnlyColumnType(dbType, columnType)
|
||||
}
|
||||
|
||||
func parseTemporalString(raw string) (time.Time, bool) {
|
||||
text := strings.TrimSpace(raw)
|
||||
if text == "" {
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
layouts := []string{
|
||||
"2006-01-02 15:04:05.999999999 -0700 MST",
|
||||
"2006-01-02 15:04:05 -0700 MST",
|
||||
"2006-01-02 15:04:05.999999999 -0700",
|
||||
"2006-01-02 15:04:05 -0700",
|
||||
time.RFC3339Nano,
|
||||
time.RFC3339,
|
||||
"2006-01-02 15:04:05.999999999",
|
||||
"2006-01-02 15:04:05",
|
||||
"2006-01-02",
|
||||
"15:04:05.999999999",
|
||||
"15:04:05",
|
||||
}
|
||||
|
||||
for _, layout := range layouts {
|
||||
parsed, err := time.Parse(layout, text)
|
||||
if err == nil {
|
||||
return parsed, true
|
||||
}
|
||||
}
|
||||
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
func normalizeImportTemporalValue(dbType, columnType, raw string) string {
|
||||
text := strings.TrimSpace(raw)
|
||||
if text == "" {
|
||||
return text
|
||||
}
|
||||
|
||||
parsed, ok := parseTemporalString(text)
|
||||
if !ok {
|
||||
if isDateTimeColumnType(columnType) {
|
||||
candidate := strings.ReplaceAll(text, "T", " ")
|
||||
if len(candidate) >= 19 {
|
||||
prefix := candidate[:19]
|
||||
if _, err := time.Parse("2006-01-02 15:04:05", prefix); err == nil {
|
||||
return prefix
|
||||
}
|
||||
}
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
if isTimeOnlyColumnType(columnType) {
|
||||
return parsed.Format("15:04:05")
|
||||
}
|
||||
if isDateOnlyColumnType(dbType, columnType) {
|
||||
return parsed.Format("2006-01-02")
|
||||
}
|
||||
if isTimezoneAwareColumnType(columnType) {
|
||||
return parsed.Format("2006-01-02 15:04:05-07:00")
|
||||
}
|
||||
return parsed.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
func formatImportSQLValue(dbType, columnType string, value interface{}) string {
|
||||
if value == nil {
|
||||
return "NULL"
|
||||
}
|
||||
|
||||
if isTemporalColumnType(dbType, columnType) {
|
||||
normalized := normalizeImportTemporalValue(dbType, columnType, fmt.Sprintf("%v", value))
|
||||
escaped := strings.ReplaceAll(normalized, "'", "''")
|
||||
return "'" + escaped + "'"
|
||||
}
|
||||
|
||||
return formatSQLValue(dbType, value)
|
||||
}
|
||||
|
||||
// ImportDataWithProgress 执行导入并发送进度事件
|
||||
func (a *App) ImportDataWithProgress(config connection.ConnectionConfig, dbName, tableName, filePath string) connection.QueryResult {
|
||||
rows, columns, err := parseImportFile(filePath)
|
||||
if err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
|
||||
if len(rows) == 0 {
|
||||
@@ -146,29 +378,27 @@ func (a *App) ImportData(config connection.ConnectionConfig, dbName, tableName s
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
|
||||
successCount := 0
|
||||
errCount := 0
|
||||
firstRow := rows[0]
|
||||
var cols []string
|
||||
for k := range firstRow {
|
||||
cols = append(cols, k)
|
||||
schemaName, pureTableName := normalizeSchemaAndTable(config, dbName, tableName)
|
||||
columnTypeMap := map[string]string{}
|
||||
if defs, colErr := dbInst.GetColumns(schemaName, pureTableName); colErr == nil {
|
||||
columnTypeMap = buildImportColumnTypeMap(defs)
|
||||
}
|
||||
|
||||
for _, row := range rows {
|
||||
totalRows := len(rows)
|
||||
successCount := 0
|
||||
var errorLogs []string
|
||||
|
||||
quotedCols := make([]string, len(columns))
|
||||
for i, c := range columns {
|
||||
quotedCols[i] = quoteIdentByType(runConfig.Type, c)
|
||||
}
|
||||
|
||||
for idx, row := range rows {
|
||||
var values []string
|
||||
for _, col := range cols {
|
||||
for _, col := range columns {
|
||||
val := row[col]
|
||||
if val == nil {
|
||||
values = append(values, "NULL")
|
||||
} else {
|
||||
vStr := fmt.Sprintf("%v", val)
|
||||
vStr = strings.ReplaceAll(vStr, "'", "''")
|
||||
values = append(values, fmt.Sprintf("'%s'", vStr))
|
||||
}
|
||||
}
|
||||
quotedCols := make([]string, len(cols))
|
||||
for i, c := range cols {
|
||||
quotedCols[i] = quoteIdentByType(runConfig.Type, c)
|
||||
colType := columnTypeMap[normalizeColumnName(col)]
|
||||
values = append(values, formatImportSQLValue(runConfig.Type, colType, val))
|
||||
}
|
||||
|
||||
query := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)",
|
||||
@@ -178,14 +408,31 @@ func (a *App) ImportData(config connection.ConnectionConfig, dbName, tableName s
|
||||
|
||||
_, err := dbInst.Exec(query)
|
||||
if err != nil {
|
||||
errCount++
|
||||
logger.Error(err, "导入数据失败:表=%s", tableName)
|
||||
errorLogs = append(errorLogs, fmt.Sprintf("Row %d: %s", idx+1, err.Error()))
|
||||
} else {
|
||||
successCount++
|
||||
}
|
||||
|
||||
// 每 10 行发送一次进度事件
|
||||
if (idx+1)%10 == 0 || idx == totalRows-1 {
|
||||
runtime.EventsEmit(a.ctx, "import:progress", map[string]interface{}{
|
||||
"current": idx + 1,
|
||||
"total": totalRows,
|
||||
"success": successCount,
|
||||
"errors": len(errorLogs),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return connection.QueryResult{Success: true, Message: fmt.Sprintf("Imported: %d, Failed: %d", successCount, errCount)}
|
||||
result := map[string]interface{}{
|
||||
"success": successCount,
|
||||
"failed": len(errorLogs),
|
||||
"total": totalRows,
|
||||
"errorLogs": errorLogs,
|
||||
"errorSummary": fmt.Sprintf("Imported: %d, Failed: %d", successCount, len(errorLogs)),
|
||||
}
|
||||
|
||||
return connection.QueryResult{Success: true, Data: result, Message: fmt.Sprintf("Imported: %d, Failed: %d", successCount, len(errorLogs))}
|
||||
}
|
||||
|
||||
func (a *App) ApplyChanges(config connection.ConnectionConfig, dbName, tableName string, changes connection.ChangeSet) connection.QueryResult {
|
||||
@@ -695,12 +942,17 @@ func writeRowsToFile(f *os.File, data []map[string]interface{}, columns []string
|
||||
return fmt.Errorf("file required")
|
||||
}
|
||||
|
||||
// xlsx 使用 excelize 写入真正的 Excel 格式
|
||||
if format == "xlsx" {
|
||||
return writeRowsToXlsx(f.Name(), data, columns)
|
||||
}
|
||||
|
||||
var csvWriter *csv.Writer
|
||||
var jsonEncoder *json.Encoder
|
||||
isJsonFirstRow := true
|
||||
|
||||
switch format {
|
||||
case "csv", "xlsx":
|
||||
case "csv":
|
||||
if _, err := f.Write([]byte{0xEF, 0xBB, 0xBF}); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -738,7 +990,7 @@ func writeRowsToFile(f *os.File, data []map[string]interface{}, columns []string
|
||||
continue
|
||||
}
|
||||
|
||||
s := fmt.Sprintf("%v", val)
|
||||
s := formatExportCellText(val)
|
||||
if format == "md" {
|
||||
s = strings.ReplaceAll(s, "|", "\\|")
|
||||
s = strings.ReplaceAll(s, "\n", "<br>")
|
||||
@@ -747,7 +999,7 @@ func writeRowsToFile(f *os.File, data []map[string]interface{}, columns []string
|
||||
}
|
||||
|
||||
switch format {
|
||||
case "csv", "xlsx":
|
||||
case "csv":
|
||||
if err := csvWriter.Write(record); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -768,7 +1020,7 @@ func writeRowsToFile(f *os.File, data []map[string]interface{}, columns []string
|
||||
}
|
||||
}
|
||||
|
||||
if format == "csv" || format == "xlsx" {
|
||||
if format == "csv" {
|
||||
csvWriter.Flush()
|
||||
if err := csvWriter.Error(); err != nil {
|
||||
return err
|
||||
@@ -783,3 +1035,50 @@ func writeRowsToFile(f *os.File, data []map[string]interface{}, columns []string
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func formatExportCellText(val interface{}) string {
|
||||
if val == nil {
|
||||
return "NULL"
|
||||
}
|
||||
|
||||
switch v := val.(type) {
|
||||
case time.Time:
|
||||
return v.Format("2006-01-02 15:04:05")
|
||||
case *time.Time:
|
||||
if v == nil {
|
||||
return "NULL"
|
||||
}
|
||||
return v.Format("2006-01-02 15:04:05")
|
||||
default:
|
||||
return fmt.Sprintf("%v", val)
|
||||
}
|
||||
}
|
||||
|
||||
// writeRowsToXlsx 使用 excelize 写入真正的 xlsx 格式文件
|
||||
func writeRowsToXlsx(filename string, data []map[string]interface{}, columns []string) error {
|
||||
xlsx := excelize.NewFile()
|
||||
defer xlsx.Close()
|
||||
|
||||
sheet := "Sheet1"
|
||||
|
||||
// 写入表头
|
||||
for i, col := range columns {
|
||||
cell, _ := excelize.CoordinatesToCellName(i+1, 1)
|
||||
xlsx.SetCellValue(sheet, cell, col)
|
||||
}
|
||||
|
||||
// 写入数据行
|
||||
for rowIdx, rowMap := range data {
|
||||
for colIdx, col := range columns {
|
||||
cell, _ := excelize.CoordinatesToCellName(colIdx+1, rowIdx+2)
|
||||
val := rowMap[col]
|
||||
if val == nil {
|
||||
xlsx.SetCellValue(sheet, cell, "NULL")
|
||||
} else {
|
||||
xlsx.SetCellValue(sheet, cell, formatExportCellText(val))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return xlsx.SaveAs(filename)
|
||||
}
|
||||
|
||||
@@ -224,7 +224,19 @@ func (a *App) downloadAndStageUpdate(info UpdateInfo) connection.QueryResult {
|
||||
// 使用版本号命名的工作目录,便于识别和调试
|
||||
stagedDir := filepath.Join(workspaceDir, fmt.Sprintf(".gonavi-update-%s-%s", stdRuntime.GOOS, info.LatestVersion))
|
||||
// 清理可能残留的旧目录(上次下载失败后未清理)
|
||||
_ = os.RemoveAll(stagedDir)
|
||||
// Windows 上文件可能被杀毒软件/索引服务占用,需要重试
|
||||
for retry := 0; retry < 5; retry++ {
|
||||
err := os.RemoveAll(stagedDir)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
if retry < 4 {
|
||||
time.Sleep(time.Duration(retry+1) * 500 * time.Millisecond)
|
||||
} else {
|
||||
// 最后一次仍然失败,换一个带时间戳的目录名避免冲突
|
||||
stagedDir = filepath.Join(workspaceDir, fmt.Sprintf(".gonavi-update-%s-%s-%d", stdRuntime.GOOS, info.LatestVersion, time.Now().UnixNano()))
|
||||
}
|
||||
}
|
||||
if err := os.MkdirAll(stagedDir, 0o755); err != nil {
|
||||
errMsg := fmt.Sprintf("无法在应用目录创建更新工作目录:%s", stagedDir)
|
||||
a.emitUpdateDownloadProgress("error", 0, info.AssetSize, errMsg)
|
||||
@@ -490,11 +502,21 @@ func downloadFileWithHash(url, filePath string, onProgress func(downloaded, tota
|
||||
return "", fmt.Errorf("下载更新包失败:HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
out, err := os.Create(filePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
// Windows 上旧文件可能被杀毒软件/索引服务占用,先尝试删除并重试
|
||||
_ = os.Remove(filePath)
|
||||
var out *os.File
|
||||
for retry := 0; retry < 5; retry++ {
|
||||
out, err = os.Create(filePath)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
if retry < 4 {
|
||||
time.Sleep(time.Duration(retry+1) * 500 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("更新下载失败,文件被占用:%w", err)
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
hasher := sha256.New()
|
||||
total := resp.ContentLength
|
||||
@@ -508,12 +530,22 @@ func downloadFileWithHash(url, filePath string, onProgress func(downloaded, tota
|
||||
onProgress(0, total)
|
||||
}
|
||||
if _, err := io.Copy(io.MultiWriter(writers...), resp.Body); err != nil {
|
||||
out.Close()
|
||||
return "", err
|
||||
}
|
||||
if onProgress != nil {
|
||||
onProgress(progressWriter.written, total)
|
||||
}
|
||||
|
||||
// 显式 Sync + Close,确保数据落盘且文件句柄释放
|
||||
if err := out.Sync(); err != nil {
|
||||
out.Close()
|
||||
return "", err
|
||||
}
|
||||
if err := out.Close(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return hex.EncodeToString(hasher.Sum(nil)), nil
|
||||
}
|
||||
|
||||
@@ -544,18 +576,13 @@ func buildUpdateInstallLogPath(baseDir string) string {
|
||||
}
|
||||
|
||||
func resolveUpdateWorkspaceDir() string {
|
||||
exePath, err := os.Executable()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
exePath, _ = filepath.EvalSymlinks(exePath)
|
||||
if stdRuntime.GOOS == "darwin" {
|
||||
appPath := detectMacAppPath(exePath)
|
||||
if appPath != "" {
|
||||
return filepath.Dir(appPath)
|
||||
}
|
||||
}
|
||||
return filepath.Dir(exePath)
|
||||
// 使用系统临时目录作为更新工作区,避免以下问题:
|
||||
// 1. Windows: exe 所在目录可能被杀毒软件/索引服务锁定,或缺少写权限(如 Program Files)
|
||||
// 2. macOS: /Applications 需要管理员权限才能写入
|
||||
// 3. 运行中的 exe 文件锁与 staging 文件冲突
|
||||
dir := filepath.Join(os.TempDir(), "gonavi-updates")
|
||||
_ = os.MkdirAll(dir, 0o755)
|
||||
return dir
|
||||
}
|
||||
|
||||
func resolveUpdateInstallTarget() string {
|
||||
|
||||
@@ -47,14 +47,16 @@ static void gonaviTuneWindowTranslucency(NSWindow *window) {
|
||||
[effectView setMaterial:NSVisualEffectMaterialHUDWindow];
|
||||
[effectView setBlendingMode:NSVisualEffectBlendingModeBehindWindow];
|
||||
[effectView setState:NSVisualEffectStateActive];
|
||||
[effectView setAlphaValue:0.72];
|
||||
// 默认 alpha=0(不可见),由前端根据用户外观设置动态启用
|
||||
[effectView setAlphaValue:0.0];
|
||||
[effectView setWantsLayer:YES];
|
||||
[[effectView layer] setCornerRadius:cornerRadius];
|
||||
[[effectView layer] setMasksToBounds:YES];
|
||||
}
|
||||
|
||||
static void gonaviApplyWindowTranslucencyFix() {
|
||||
for (int i = 0; i < 24; i++) {
|
||||
// 启动时应用窗口透明度修复,减少重试次数以降低启动期 GPU 负载
|
||||
for (int i = 0; i < 8; i++) {
|
||||
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(i * 250 * NSEC_PER_MSEC)), dispatch_get_main_queue(), ^{
|
||||
for (NSWindow *window in [NSApp windows]) {
|
||||
gonaviTuneWindowTranslucency(window);
|
||||
@@ -62,9 +64,56 @@ static void gonaviApplyWindowTranslucencyFix() {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 动态设置 NSVisualEffectView 的透明度和窗口不透明标志。
|
||||
// alpha <= 0 时窗口标记为 opaque,GPU 不再持续计算窗口背后的模糊效果。
|
||||
static void gonaviSetEffectViewAlpha(double alpha) {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
for (NSWindow *window in [NSApp windows]) {
|
||||
NSView *contentView = [window contentView];
|
||||
if (contentView == nil) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (NSView *subview in [contentView subviews]) {
|
||||
if ([subview isKindOfClass:[NSVisualEffectView class]]) {
|
||||
NSVisualEffectView *effectView = (NSVisualEffectView *)subview;
|
||||
[effectView setAlphaValue:alpha];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (alpha <= 0.01) {
|
||||
[window setOpaque:YES];
|
||||
} else {
|
||||
[window setOpaque:NO];
|
||||
[window setBackgroundColor:[NSColor clearColor]];
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
*/
|
||||
import "C"
|
||||
|
||||
func applyMacWindowTranslucencyFix() {
|
||||
C.gonaviApplyWindowTranslucencyFix()
|
||||
}
|
||||
|
||||
// setMacWindowTranslucency 根据用户外观设置动态调整 macOS 窗口透明度。
|
||||
// opacity=1.0 且 blur=0 时关闭 NSVisualEffectView(alpha=0),窗口标记为 opaque,
|
||||
// GPU 不再持续计算窗口背后的模糊合成,显著降低 CPU/GPU 温度。
|
||||
func setMacWindowTranslucency(opacity float64, blur float64) {
|
||||
if opacity >= 0.999 && blur <= 0 {
|
||||
C.gonaviSetEffectViewAlpha(C.double(0.0))
|
||||
} else {
|
||||
// 半透明模式:NSVisualEffectView alpha 根据透明度动态映射
|
||||
alpha := (1.0 - opacity) * 1.2
|
||||
if alpha < 0.3 {
|
||||
alpha = 0.3
|
||||
}
|
||||
if alpha > 0.85 {
|
||||
alpha = 0.85
|
||||
}
|
||||
C.gonaviSetEffectViewAlpha(C.double(alpha))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,3 +3,5 @@
|
||||
package app
|
||||
|
||||
func applyMacWindowTranslucencyFix() {}
|
||||
|
||||
func setMacWindowTranslucency(opacity float64, blur float64) {}
|
||||
|
||||
@@ -200,13 +200,13 @@ func (m *MongoDB) getURI(config connection.ConnectionConfig) string {
|
||||
uri := fmt.Sprintf("%s://%s", scheme, hostText)
|
||||
|
||||
if config.User != "" {
|
||||
encodedUser := url.PathEscape(config.User)
|
||||
var userinfo *url.Userinfo
|
||||
if config.Password != "" {
|
||||
encodedPass := url.PathEscape(config.Password)
|
||||
uri = fmt.Sprintf("%s://%s:%s@%s", scheme, encodedUser, encodedPass, hostText)
|
||||
userinfo = url.UserPassword(config.User, config.Password)
|
||||
} else {
|
||||
uri = fmt.Sprintf("%s://%s@%s", scheme, encodedUser, hostText)
|
||||
userinfo = url.User(config.User)
|
||||
}
|
||||
uri = fmt.Sprintf("%s://%s@%s", scheme, userinfo.String(), hostText)
|
||||
}
|
||||
|
||||
path := "/"
|
||||
@@ -441,6 +441,23 @@ func asMongoBool(raw interface{}) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func asMongoInt64(raw interface{}) int64 {
|
||||
switch value := raw.(type) {
|
||||
case int:
|
||||
return int64(value)
|
||||
case int32:
|
||||
return int64(value)
|
||||
case int64:
|
||||
return value
|
||||
case float32:
|
||||
return int64(value)
|
||||
case float64:
|
||||
return int64(value)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func mongoStateByCode(code int) string {
|
||||
switch code {
|
||||
case 1:
|
||||
@@ -613,6 +630,98 @@ func (m *MongoDB) QueryContext(ctx context.Context, query string) ([]map[string]
|
||||
return m.queryWithContext(ctx, query)
|
||||
}
|
||||
|
||||
// sqlToMongoFind 将前端生成的简单 SQL 转换为 MongoDB find 命令 JSON。
|
||||
// 支持:SELECT * FROM "coll" LIMIT n OFFSET m / SELECT COUNT(*) as total FROM "coll"
|
||||
func sqlToMongoFind(sql string) (string, bool) {
|
||||
lower := strings.ToLower(strings.TrimSpace(sql))
|
||||
|
||||
// SELECT COUNT(*) as total FROM "coll" ...
|
||||
if strings.HasPrefix(lower, "select count(") {
|
||||
coll := extractCollectionFromSQL(sql)
|
||||
if coll == "" {
|
||||
return "", false
|
||||
}
|
||||
return fmt.Sprintf(`{"count":"%s","query":{}}`, coll), true
|
||||
}
|
||||
|
||||
// SELECT * FROM "coll" ... LIMIT n OFFSET m
|
||||
if !strings.HasPrefix(lower, "select") {
|
||||
return "", false
|
||||
}
|
||||
coll := extractCollectionFromSQL(sql)
|
||||
if coll == "" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
limit := int64(0)
|
||||
skip := int64(0)
|
||||
|
||||
// 提取 LIMIT
|
||||
if idx := strings.Index(lower, "limit "); idx >= 0 {
|
||||
after := strings.TrimSpace(lower[idx+6:])
|
||||
parts := strings.Fields(after)
|
||||
if len(parts) > 0 {
|
||||
if n, err := strconv.ParseInt(parts[0], 10, 64); err == nil {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 提取 OFFSET
|
||||
if idx := strings.Index(lower, "offset "); idx >= 0 {
|
||||
after := strings.TrimSpace(lower[idx+7:])
|
||||
parts := strings.Fields(after)
|
||||
if len(parts) > 0 {
|
||||
if n, err := strconv.ParseInt(parts[0], 10, 64); err == nil {
|
||||
skip = n
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cmd := fmt.Sprintf(`{"find":"%s","filter":{}`, coll)
|
||||
if limit > 0 {
|
||||
cmd += fmt.Sprintf(`,"limit":%d`, limit)
|
||||
}
|
||||
if skip > 0 {
|
||||
cmd += fmt.Sprintf(`,"skip":%d`, skip)
|
||||
}
|
||||
cmd += "}"
|
||||
return cmd, true
|
||||
}
|
||||
|
||||
// extractCollectionFromSQL 从 SQL 中提取 FROM 后的 collection 名称。
|
||||
func extractCollectionFromSQL(sql string) string {
|
||||
lower := strings.ToLower(sql)
|
||||
idx := strings.Index(lower, "from ")
|
||||
if idx < 0 {
|
||||
return ""
|
||||
}
|
||||
after := strings.TrimSpace(sql[idx+5:])
|
||||
|
||||
// 去掉引号包裹
|
||||
var coll string
|
||||
if len(after) > 0 && after[0] == '"' {
|
||||
end := strings.Index(after[1:], "\"")
|
||||
if end < 0 {
|
||||
return ""
|
||||
}
|
||||
coll = after[1 : end+1]
|
||||
} else if len(after) > 0 && after[0] == '`' {
|
||||
end := strings.Index(after[1:], "`")
|
||||
if end < 0 {
|
||||
return ""
|
||||
}
|
||||
coll = after[1 : end+1]
|
||||
} else {
|
||||
parts := strings.Fields(after)
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
coll = parts[0]
|
||||
}
|
||||
return strings.TrimSpace(coll)
|
||||
}
|
||||
|
||||
func (m *MongoDB) queryWithContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) {
|
||||
if m.client == nil {
|
||||
return nil, nil, fmt.Errorf("connection not open")
|
||||
@@ -623,18 +732,44 @@ func (m *MongoDB) queryWithContext(ctx context.Context, query string) ([]map[str
|
||||
return nil, nil, fmt.Errorf("empty query")
|
||||
}
|
||||
|
||||
// 如果输入是 SQL 语句(前端 DataViewer 统一生成),自动转换为 MongoDB JSON 命令
|
||||
lowerQuery := strings.ToLower(query)
|
||||
if strings.HasPrefix(lowerQuery, "select") || strings.HasPrefix(lowerQuery, "show") {
|
||||
if converted, ok := sqlToMongoFind(query); ok {
|
||||
query = converted
|
||||
}
|
||||
}
|
||||
|
||||
// Parse JSON command
|
||||
var cmd bson.D
|
||||
if err := bson.UnmarshalExtJSON([]byte(query), true, &cmd); err != nil {
|
||||
return nil, nil, fmt.Errorf("invalid JSON command: %w", err)
|
||||
}
|
||||
|
||||
// 对 find 和 count 命令使用原生 driver API,避免 RunCommand 的 firstBatch 限制
|
||||
if len(cmd) > 0 {
|
||||
switch cmd[0].Key {
|
||||
case "find":
|
||||
return m.execFind(ctx, cmd)
|
||||
case "count":
|
||||
return m.execCount(ctx, cmd)
|
||||
}
|
||||
}
|
||||
|
||||
// 其他命令走 RunCommand
|
||||
db := m.client.Database(m.database)
|
||||
var result bson.M
|
||||
if err := db.RunCommand(ctx, cmd).Decode(&result); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// Handle COUNT result (e.g. delete/update returns "n")
|
||||
if n, ok := result["n"]; ok {
|
||||
if _, hasCursor := result["cursor"]; !hasCursor {
|
||||
return []map[string]interface{}{{"total": n}}, []string{"total"}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Convert result to standard format
|
||||
data := []map[string]interface{}{{"result": result}}
|
||||
columns := []string{"result"}
|
||||
@@ -664,6 +799,156 @@ func (m *MongoDB) queryWithContext(ctx context.Context, query string) ([]map[str
|
||||
return data, columns, nil
|
||||
}
|
||||
|
||||
// execFind 使用原生 Collection.Find() 执行查询,正确处理游标迭代
|
||||
func (m *MongoDB) execFind(ctx context.Context, cmd bson.D) ([]map[string]interface{}, []string, error) {
|
||||
var collName string
|
||||
var filter interface{}
|
||||
var limit int64
|
||||
var skip int64
|
||||
var sortDoc interface{}
|
||||
var projection interface{}
|
||||
|
||||
for _, elem := range cmd {
|
||||
switch elem.Key {
|
||||
case "find":
|
||||
collName = fmt.Sprintf("%v", elem.Value)
|
||||
case "filter":
|
||||
filter = elem.Value
|
||||
case "limit":
|
||||
limit = asMongoInt64(elem.Value)
|
||||
case "skip":
|
||||
skip = asMongoInt64(elem.Value)
|
||||
case "sort":
|
||||
sortDoc = elem.Value
|
||||
case "projection":
|
||||
projection = elem.Value
|
||||
}
|
||||
}
|
||||
|
||||
if collName == "" {
|
||||
return nil, nil, fmt.Errorf("find command missing collection name")
|
||||
}
|
||||
if filter == nil {
|
||||
filter = bson.D{}
|
||||
}
|
||||
|
||||
collection := m.client.Database(m.database).Collection(collName)
|
||||
opts := options.Find()
|
||||
if limit > 0 {
|
||||
opts.SetLimit(limit)
|
||||
}
|
||||
if skip > 0 {
|
||||
opts.SetSkip(skip)
|
||||
}
|
||||
if sortDoc != nil {
|
||||
opts.SetSort(sortDoc)
|
||||
}
|
||||
if projection != nil {
|
||||
opts.SetProjection(projection)
|
||||
}
|
||||
|
||||
cursor, err := collection.Find(ctx, filter, opts)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
defer cursor.Close(ctx)
|
||||
|
||||
var data []map[string]interface{}
|
||||
columnSet := make(map[string]bool)
|
||||
|
||||
for cursor.Next(ctx) {
|
||||
var doc bson.M
|
||||
if err := cursor.Decode(&doc); err != nil {
|
||||
continue
|
||||
}
|
||||
row := make(map[string]interface{})
|
||||
for k, v := range doc {
|
||||
row[k] = convertBsonValue(v)
|
||||
columnSet[k] = true
|
||||
}
|
||||
data = append(data, row)
|
||||
}
|
||||
|
||||
if err := cursor.Err(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
columns := make([]string, 0, len(columnSet))
|
||||
for k := range columnSet {
|
||||
columns = append(columns, k)
|
||||
}
|
||||
sort.Strings(columns)
|
||||
|
||||
// 将 _id 列置首
|
||||
for i, col := range columns {
|
||||
if col == "_id" && i > 0 {
|
||||
columns = append(columns[:i], columns[i+1:]...)
|
||||
columns = append([]string{"_id"}, columns...)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return data, columns, nil
|
||||
}
|
||||
|
||||
// execCount 使用原生 Collection.CountDocuments() 执行计数
|
||||
func (m *MongoDB) execCount(ctx context.Context, cmd bson.D) ([]map[string]interface{}, []string, error) {
|
||||
var collName string
|
||||
var filter interface{}
|
||||
|
||||
for _, elem := range cmd {
|
||||
switch elem.Key {
|
||||
case "count":
|
||||
collName = fmt.Sprintf("%v", elem.Value)
|
||||
case "query":
|
||||
filter = elem.Value
|
||||
}
|
||||
}
|
||||
|
||||
if collName == "" {
|
||||
return nil, nil, fmt.Errorf("count command missing collection name")
|
||||
}
|
||||
if filter == nil {
|
||||
filter = bson.D{}
|
||||
}
|
||||
|
||||
collection := m.client.Database(m.database).Collection(collName)
|
||||
n, err := collection.CountDocuments(ctx, filter)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return []map[string]interface{}{{"total": n}}, []string{"total"}, nil
|
||||
}
|
||||
|
||||
// convertBsonValue 将 BSON 特殊类型转换为前端可读的 JSON 友好值
|
||||
func convertBsonValue(v interface{}) interface{} {
|
||||
switch val := v.(type) {
|
||||
case bson.ObjectID:
|
||||
return val.Hex()
|
||||
case bson.M:
|
||||
result := make(map[string]interface{}, len(val))
|
||||
for k, v2 := range val {
|
||||
result[k] = convertBsonValue(v2)
|
||||
}
|
||||
return result
|
||||
case bson.D:
|
||||
result := make(map[string]interface{}, len(val))
|
||||
for _, elem := range val {
|
||||
result[elem.Key] = convertBsonValue(elem.Value)
|
||||
}
|
||||
return result
|
||||
case bson.A:
|
||||
result := make([]interface{}, len(val))
|
||||
for i, v2 := range val {
|
||||
result[i] = convertBsonValue(v2)
|
||||
}
|
||||
return result
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MongoDB) Exec(query string) (int64, error) {
|
||||
_, _, err := m.Query(query)
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user