mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-25 10:10:29 +08:00
✨ feat(sidebar-redis-db): 新增库表重命名删除、批量仅数据导出与Redis多选删键能力
- 后端新增数据库/表重命名与删除能力,覆盖多数据源差异处理 - 批量操作表新增“仅导出数据(INSERT)”模式并完善导出链路 - Redis Key 列表支持分组展示、勾选批量删除与当前Key删除入口 - 同步 Wails 前后端绑定接口并优化批量操作弹窗按钮布局
This commit is contained in:
@@ -1,13 +1,16 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { Table, Input, Button, Space, Tag, message, Modal, Form, InputNumber, Popconfirm, Tooltip, Radio } from 'antd';
|
||||
import { ReloadOutlined, DeleteOutlined, PlusOutlined, EditOutlined, SearchOutlined, ClockCircleOutlined, CopyOutlined } from '@ant-design/icons';
|
||||
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import { Table, Input, Button, Space, Tag, Tree, Spin, message, Modal, Form, InputNumber, Popconfirm, Tooltip, Radio } from 'antd';
|
||||
import { ReloadOutlined, DeleteOutlined, PlusOutlined, EditOutlined, SearchOutlined, ClockCircleOutlined, CopyOutlined, FolderOpenOutlined, KeyOutlined } from '@ant-design/icons';
|
||||
import { useStore } from '../store';
|
||||
import { RedisKeyInfo, RedisValue } from '../types';
|
||||
import Editor from '@monaco-editor/react';
|
||||
import type { ColumnType } from 'antd/es/table';
|
||||
import type { DataNode } from 'antd/es/tree';
|
||||
|
||||
const { Search } = Input;
|
||||
|
||||
const KEY_GROUP_DELIMITER = ':';
|
||||
const EMPTY_SEGMENT_LABEL = '(empty)';
|
||||
|
||||
interface RedisViewerProps {
|
||||
connectionId: string;
|
||||
redisDB: number;
|
||||
@@ -222,86 +225,167 @@ const ResizableDivider: React.FC<{
|
||||
};
|
||||
|
||||
// 可拖拽列头组件 - 纯 DOM 操作实现
|
||||
const ResizableTitle: React.FC<any> = (props) => {
|
||||
const { onResize, width, children, ...restProps } = props;
|
||||
const thRef = useRef<HTMLTableCellElement>(null);
|
||||
type RedisKeyTreeLeaf = {
|
||||
keyInfo: RedisKeyInfo;
|
||||
label: string;
|
||||
};
|
||||
|
||||
// 如果没有 onResize 或 width,说明这列不需要拖拽(如复选框列)
|
||||
if (!onResize || !width) {
|
||||
return <th {...restProps}>{children}</th>;
|
||||
}
|
||||
type RedisKeyTreeGroup = {
|
||||
name: string;
|
||||
path: string;
|
||||
children: Map<string, RedisKeyTreeGroup>;
|
||||
leaves: RedisKeyTreeLeaf[];
|
||||
};
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
type RedisKeyTreeResult = {
|
||||
treeData: DataNode[];
|
||||
rawKeyByNodeKey: Map<string, string>;
|
||||
leafNodeKeyByRawKey: Map<string, string>;
|
||||
groupKeys: string[];
|
||||
};
|
||||
|
||||
const startX = e.clientX;
|
||||
const startWidth = width;
|
||||
const th = thRef.current;
|
||||
if (!th) return;
|
||||
const normalizeKeySegment = (segment: string): string => {
|
||||
return segment === '' ? EMPTY_SEGMENT_LABEL : segment;
|
||||
};
|
||||
|
||||
// 找到对应的 colgroup col 元素来同步更新列宽
|
||||
const table = th.closest('table');
|
||||
const thIndex = Array.from(th.parentElement?.children || []).indexOf(th);
|
||||
const col = table?.querySelector(`colgroup col:nth-child(${thIndex + 1})`) as HTMLElement | null;
|
||||
const createTreeGroup = (name: string, path: string): RedisKeyTreeGroup => {
|
||||
return { name, path, children: new Map(), leaves: [] };
|
||||
};
|
||||
|
||||
// 创建遮罩层防止文本选择
|
||||
const overlay = document.createElement('div');
|
||||
overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;cursor:col-resize;z-index:9999;';
|
||||
document.body.appendChild(overlay);
|
||||
const countGroupLeafNodes = (group: RedisKeyTreeGroup): number => {
|
||||
let count = group.leaves.length;
|
||||
group.children.forEach((child) => {
|
||||
count += countGroupLeafNodes(child);
|
||||
});
|
||||
return count;
|
||||
};
|
||||
|
||||
let currentWidth = startWidth;
|
||||
const buildRedisKeyTree = (
|
||||
keys: RedisKeyInfo[],
|
||||
formatTTL: (ttl: number) => string,
|
||||
getTypeColor: (type: string) => string
|
||||
): RedisKeyTreeResult => {
|
||||
const root = createTreeGroup('__root__', '__root__');
|
||||
|
||||
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||
moveEvent.preventDefault();
|
||||
const delta = moveEvent.clientX - startX;
|
||||
currentWidth = Math.max(50, startWidth + delta);
|
||||
// 直接操作 DOM
|
||||
th.style.width = `${currentWidth}px`;
|
||||
if (col) {
|
||||
col.style.width = `${currentWidth}px`;
|
||||
keys.forEach((keyInfo) => {
|
||||
const segments = keyInfo.key.split(KEY_GROUP_DELIMITER);
|
||||
if (segments.length <= 1) {
|
||||
root.leaves.push({ keyInfo, label: keyInfo.key });
|
||||
return;
|
||||
}
|
||||
|
||||
const groupSegments = segments.slice(0, -1);
|
||||
const leafLabel = normalizeKeySegment(segments[segments.length - 1]);
|
||||
let current = root;
|
||||
const pathParts: string[] = [];
|
||||
|
||||
groupSegments.forEach((segment) => {
|
||||
const normalized = normalizeKeySegment(segment);
|
||||
pathParts.push(normalized);
|
||||
const groupPath = pathParts.join(KEY_GROUP_DELIMITER);
|
||||
let child = current.children.get(normalized);
|
||||
if (!child) {
|
||||
child = createTreeGroup(normalized, groupPath);
|
||||
current.children.set(normalized, child);
|
||||
}
|
||||
};
|
||||
current = child;
|
||||
});
|
||||
|
||||
const handleMouseUp = () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
document.body.removeChild(overlay);
|
||||
// 拖拽结束时更新 React state
|
||||
onResize(null, { size: { width: currentWidth } });
|
||||
};
|
||||
current.leaves.push({ keyInfo, label: leafLabel });
|
||||
});
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
const rawKeyByNodeKey = new Map<string, string>();
|
||||
const leafNodeKeyByRawKey = new Map<string, string>();
|
||||
const groupKeys: string[] = [];
|
||||
|
||||
const toTreeNodes = (group: RedisKeyTreeGroup): DataNode[] => {
|
||||
const childGroups = Array.from(group.children.values()).sort((a, b) => a.name.localeCompare(b.name));
|
||||
const childLeaves = [...group.leaves].sort((a, b) => a.keyInfo.key.localeCompare(b.keyInfo.key));
|
||||
|
||||
const groupNodes: DataNode[] = childGroups.map((child) => {
|
||||
const groupNodeKey = `group:${child.path}`;
|
||||
groupKeys.push(groupNodeKey);
|
||||
return {
|
||||
key: groupNodeKey,
|
||||
title: (
|
||||
<Space size={6}>
|
||||
<FolderOpenOutlined style={{ color: '#8c8c8c' }} />
|
||||
<span>{child.name}</span>
|
||||
<span style={{ fontSize: 12, color: '#999' }}>({countGroupLeafNodes(child)})</span>
|
||||
</Space>
|
||||
),
|
||||
selectable: false,
|
||||
disableCheckbox: true,
|
||||
children: toTreeNodes(child),
|
||||
};
|
||||
});
|
||||
|
||||
const leafNodes: DataNode[] = childLeaves.map((leaf) => {
|
||||
const nodeKey = `key:${leaf.keyInfo.key}`;
|
||||
rawKeyByNodeKey.set(nodeKey, leaf.keyInfo.key);
|
||||
leafNodeKeyByRawKey.set(leaf.keyInfo.key, nodeKey);
|
||||
return {
|
||||
key: nodeKey,
|
||||
isLeaf: true,
|
||||
title: (
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'minmax(0, 1fr) 92px 92px',
|
||||
columnGap: 8,
|
||||
alignItems: 'center',
|
||||
minWidth: 0,
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<Space size={6} style={{ minWidth: 0 }}>
|
||||
<KeyOutlined style={{ color: '#1677ff' }} />
|
||||
<Tooltip title={leaf.keyInfo.key}>
|
||||
<span
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
display: 'inline-block',
|
||||
verticalAlign: 'bottom',
|
||||
}}
|
||||
>
|
||||
{leaf.label}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
<Tag
|
||||
color={getTypeColor(leaf.keyInfo.type)}
|
||||
style={{ marginInlineEnd: 0, width: '100%', textAlign: 'center' }}
|
||||
>
|
||||
{leaf.keyInfo.type}
|
||||
</Tag>
|
||||
<span
|
||||
style={{
|
||||
width: '100%',
|
||||
fontSize: 12,
|
||||
color: '#999',
|
||||
textAlign: 'left',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{formatTTL(leaf.keyInfo.ttl)}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
return [...groupNodes, ...leafNodes];
|
||||
};
|
||||
|
||||
return (
|
||||
<th
|
||||
ref={thRef}
|
||||
{...restProps}
|
||||
style={{
|
||||
...restProps.style,
|
||||
position: 'relative'
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: 10,
|
||||
cursor: 'col-resize',
|
||||
zIndex: 1,
|
||||
background: 'transparent'
|
||||
}}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseOver={(e) => { e.currentTarget.style.background = 'rgba(0,0,0,0.06)'; }}
|
||||
onMouseOut={(e) => { e.currentTarget.style.background = 'transparent'; }}
|
||||
/>
|
||||
</th>
|
||||
);
|
||||
return {
|
||||
treeData: toTreeNodes(root),
|
||||
rawKeyByNodeKey,
|
||||
leafNodeKeyByRawKey,
|
||||
groupKeys,
|
||||
};
|
||||
};
|
||||
|
||||
const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
@@ -317,7 +401,6 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
const [keyValue, setKeyValue] = useState<RedisValue | null>(null);
|
||||
const [valueLoading, setValueLoading] = useState(false);
|
||||
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||
const [editForm] = Form.useForm();
|
||||
const [newKeyModalOpen, setNewKeyModalOpen] = useState(false);
|
||||
const [newKeyForm] = Form.useForm();
|
||||
const [ttlModalOpen, setTtlModalOpen] = useState(false);
|
||||
@@ -341,15 +424,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
// 面板宽度状态和 ref - 默认占据 50% 宽度
|
||||
const [leftPanelWidth, setLeftPanelWidth] = useState<number | string>('50%');
|
||||
const leftPanelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 列宽状态 - 复选框列约 32px,总宽度需要接近面板宽度
|
||||
// Key 列自适应剩余空间,其他列固定宽度
|
||||
const [columnWidths, setColumnWidths] = useState({
|
||||
key: 220, // Key 名称,需要较宽
|
||||
type: 65, // 类型标签
|
||||
ttl: 80, // TTL 显示
|
||||
action: 50 // 操作按钮
|
||||
});
|
||||
const [expandedGroupKeys, setExpandedGroupKeys] = useState<string[]>([]);
|
||||
|
||||
const getConfig = useCallback(() => {
|
||||
if (!connection) return null;
|
||||
@@ -373,7 +448,12 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
if (res.success) {
|
||||
const result = res.data;
|
||||
if (append) {
|
||||
setKeys(prev => [...prev, ...result.keys]);
|
||||
setKeys(prev => {
|
||||
const keyMap = new Map<string, RedisKeyInfo>();
|
||||
prev.forEach(item => keyMap.set(item.key, item));
|
||||
result.keys.forEach((item: RedisKeyInfo) => keyMap.set(item.key, item));
|
||||
return Array.from(keyMap.values());
|
||||
});
|
||||
} else {
|
||||
setKeys(result.keys);
|
||||
}
|
||||
@@ -451,6 +531,11 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteCurrentKey = async () => {
|
||||
if (!selectedKey) return;
|
||||
await handleDeleteKeys([selectedKey]);
|
||||
};
|
||||
|
||||
const handleSetTTL = async () => {
|
||||
const config = getConfig();
|
||||
if (!config || !selectedKey) return;
|
||||
@@ -529,65 +614,54 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
return `${Math.floor(ttl / 86400)}天${Math.floor((ttl % 86400) / 3600)}时`;
|
||||
};
|
||||
|
||||
// 处理列宽调整 - react-resizable 的 onResize 回调格式
|
||||
const handleColumnResize = (key: string) => (_e: any, { size }: { size: { width: number } }) => {
|
||||
setColumnWidths(prev => ({ ...prev, [key]: size.width }));
|
||||
const keyTree = useMemo(() => {
|
||||
return buildRedisKeyTree(keys, formatTTL, getTypeColor);
|
||||
}, [keys]);
|
||||
|
||||
const selectedTreeNodeKeys = useMemo(() => {
|
||||
if (!selectedKey) {
|
||||
return [] as string[];
|
||||
}
|
||||
const nodeKey = keyTree.leafNodeKeyByRawKey.get(selectedKey);
|
||||
return nodeKey ? [nodeKey] : [];
|
||||
}, [selectedKey, keyTree]);
|
||||
|
||||
const checkedTreeNodeKeys = useMemo(() => {
|
||||
return selectedKeys
|
||||
.map(rawKey => keyTree.leafNodeKeyByRawKey.get(rawKey))
|
||||
.filter((nodeKey): nodeKey is string => Boolean(nodeKey));
|
||||
}, [selectedKeys, keyTree]);
|
||||
|
||||
useEffect(() => {
|
||||
const existingKeySet = new Set(keys.map(item => item.key));
|
||||
setSelectedKeys(prev => prev.filter(rawKey => existingKeySet.has(rawKey)));
|
||||
}, [keys]);
|
||||
|
||||
useEffect(() => {
|
||||
setExpandedGroupKeys((prev) => {
|
||||
const validKeys = prev.filter(nodeKey => keyTree.groupKeys.includes(nodeKey));
|
||||
return validKeys;
|
||||
});
|
||||
}, [keyTree]);
|
||||
|
||||
const handleTreeSelect = (nodeKeys: React.Key[]) => {
|
||||
if (nodeKeys.length === 0) {
|
||||
return;
|
||||
}
|
||||
const rawKey = keyTree.rawKeyByNodeKey.get(String(nodeKeys[0]));
|
||||
if (!rawKey) {
|
||||
return;
|
||||
}
|
||||
loadKeyValue(rawKey);
|
||||
};
|
||||
|
||||
const columns: ColumnType<RedisKeyInfo>[] = [
|
||||
{
|
||||
title: 'Key',
|
||||
dataIndex: 'key',
|
||||
key: 'key',
|
||||
width: columnWidths.key,
|
||||
ellipsis: true,
|
||||
onHeaderCell: (column: any) => ({
|
||||
width: column.width,
|
||||
onResize: handleColumnResize('key')
|
||||
}),
|
||||
render: (text: string) => (
|
||||
<Tooltip title={text}>
|
||||
<span style={{ cursor: 'pointer' }} onClick={() => loadKeyValue(text)}>{text}</span>
|
||||
</Tooltip>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'type',
|
||||
key: 'type',
|
||||
width: columnWidths.type,
|
||||
onHeaderCell: (column: any) => ({
|
||||
width: column.width,
|
||||
onResize: handleColumnResize('type')
|
||||
}),
|
||||
render: (type: string) => <Tag color={getTypeColor(type)}>{type}</Tag>
|
||||
},
|
||||
{
|
||||
title: 'TTL',
|
||||
dataIndex: 'ttl',
|
||||
key: 'ttl',
|
||||
width: columnWidths.ttl,
|
||||
onHeaderCell: (column: any) => ({
|
||||
width: column.width,
|
||||
onResize: handleColumnResize('ttl')
|
||||
}),
|
||||
render: (ttl: number) => formatTTL(ttl)
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: columnWidths.action,
|
||||
onHeaderCell: (column: any) => ({
|
||||
width: column.width,
|
||||
onResize: handleColumnResize('action')
|
||||
}),
|
||||
render: (_: any, record: RedisKeyInfo) => (
|
||||
<Popconfirm title="确定删除此 Key?" onConfirm={() => handleDeleteKeys([record.key])}>
|
||||
<Button type="text" danger size="small" icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
)
|
||||
}
|
||||
];
|
||||
const handleTreeCheck = (checked: React.Key[] | { checked: React.Key[]; halfChecked: React.Key[] }) => {
|
||||
const checkedNodeKeys = Array.isArray(checked) ? checked : checked.checked;
|
||||
const rawKeys = checkedNodeKeys
|
||||
.map(nodeKey => keyTree.rawKeyByNodeKey.get(String(nodeKey)))
|
||||
.filter((rawKey): rawKey is string => Boolean(rawKey));
|
||||
setSelectedKeys(rawKeys);
|
||||
};
|
||||
|
||||
const renderValueEditor = () => {
|
||||
if (!keyValue || !selectedKey) {
|
||||
@@ -1375,6 +1449,9 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
setTtlModalOpen(true);
|
||||
}}>设置 TTL</Button>
|
||||
<Button size="small" onClick={() => loadKeyValue(selectedKey)} icon={<ReloadOutlined />}>刷新</Button>
|
||||
<Popconfirm title={`确定删除 Key "${selectedKey}"?`} onConfirm={handleDeleteCurrentKey}>
|
||||
<Button size="small" danger icon={<DeleteOutlined />}>删除 Key</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
</div>
|
||||
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden' }}>
|
||||
@@ -1410,36 +1487,35 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
<Button size="small" icon={<ReloadOutlined />} onClick={handleRefresh}>刷新</Button>
|
||||
<Button size="small" icon={<PlusOutlined />} onClick={() => setNewKeyModalOpen(true)}>新建</Button>
|
||||
</Space>
|
||||
{selectedKeys.length > 0 && (
|
||||
<Popconfirm title={`确定删除选中的 ${selectedKeys.length} 个 Key?`} onConfirm={() => handleDeleteKeys(selectedKeys)}>
|
||||
<Button size="small" danger icon={<DeleteOutlined />}>删除选中</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
<Popconfirm
|
||||
title={`确定删除选中的 ${selectedKeys.length} 个 Key?`}
|
||||
onConfirm={() => handleDeleteKeys(selectedKeys)}
|
||||
disabled={selectedKeys.length === 0}
|
||||
>
|
||||
<Button size="small" danger icon={<DeleteOutlined />} disabled={selectedKeys.length === 0}>
|
||||
删除选中({selectedKeys.length})
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||
<Table
|
||||
dataSource={keys}
|
||||
columns={columns}
|
||||
rowKey="key"
|
||||
size="small"
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
components={{
|
||||
header: {
|
||||
cell: ResizableTitle
|
||||
}
|
||||
}}
|
||||
rowSelection={{
|
||||
selectedRowKeys: selectedKeys,
|
||||
onChange: (keys) => setSelectedKeys(keys as string[])
|
||||
}}
|
||||
onRow={(record) => ({
|
||||
onClick: () => loadKeyValue(record.key),
|
||||
style: { cursor: 'pointer', background: selectedKey === record.key ? '#e6f7ff' : undefined }
|
||||
})}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
<Spin spinning={loading} size="small">
|
||||
<Tree
|
||||
blockNode
|
||||
showIcon={false}
|
||||
checkable
|
||||
checkStrictly
|
||||
selectable
|
||||
treeData={keyTree.treeData}
|
||||
selectedKeys={selectedTreeNodeKeys}
|
||||
checkedKeys={checkedTreeNodeKeys}
|
||||
expandedKeys={expandedGroupKeys}
|
||||
onExpand={(nextExpandedKeys) => setExpandedGroupKeys(nextExpandedKeys as string[])}
|
||||
onSelect={(nodeKeys) => handleTreeSelect(nodeKeys)}
|
||||
onCheck={(checked) => handleTreeCheck(checked)}
|
||||
style={{ padding: '8px 6px' }}
|
||||
/>
|
||||
</Spin>
|
||||
{hasMore && (
|
||||
<div style={{ padding: 8, textAlign: 'center' }}>
|
||||
<Button onClick={handleLoadMore} loading={loading}>加载更多</Button>
|
||||
|
||||
@@ -28,7 +28,7 @@ import { Tree, message, Dropdown, MenuProps, Input, Button, Modal, Form, Badge,
|
||||
} from '@ant-design/icons';
|
||||
import { useStore } from '../store';
|
||||
import { SavedConnection } from '../types';
|
||||
import { DBGetDatabases, DBGetTables, DBShowCreateTable, ExportTable, OpenSQLFile, CreateDatabase } from '../../wailsjs/go/app/App';
|
||||
import { DBGetDatabases, DBGetTables, DBShowCreateTable, ExportTable, OpenSQLFile, CreateDatabase, RenameDatabase, DropDatabase, RenameTable, DropTable } from '../../wailsjs/go/app/App';
|
||||
import { normalizeOpacityForPlatform } from '../utils/appearance';
|
||||
|
||||
const { Search } = Input;
|
||||
@@ -43,6 +43,8 @@ interface TreeNode {
|
||||
type?: 'connection' | 'database' | 'table' | 'queries-folder' | 'saved-query' | 'folder-columns' | 'folder-indexes' | 'folder-fks' | 'folder-triggers' | 'redis-db';
|
||||
}
|
||||
|
||||
type BatchTableExportMode = 'schema' | 'backup' | 'dataOnly';
|
||||
|
||||
const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> = ({ onEditConnection }) => {
|
||||
const connections = useStore(state => state.connections);
|
||||
const savedQueries = useStore(state => state.savedQueries);
|
||||
@@ -96,6 +98,12 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
const [isCreateDbModalOpen, setIsCreateDbModalOpen] = useState(false);
|
||||
const [createDbForm] = Form.useForm();
|
||||
const [targetConnection, setTargetConnection] = useState<any>(null);
|
||||
const [isRenameDbModalOpen, setIsRenameDbModalOpen] = useState(false);
|
||||
const [renameDbForm] = Form.useForm();
|
||||
const [renameDbTarget, setRenameDbTarget] = useState<any>(null);
|
||||
const [isRenameTableModalOpen, setIsRenameTableModalOpen] = useState(false);
|
||||
const [renameTableForm] = Form.useForm();
|
||||
const [renameTableTarget, setRenameTableTarget] = useState<any>(null);
|
||||
|
||||
// Batch Operations Modal
|
||||
const [isBatchModalOpen, setIsBatchModalOpen] = useState(false);
|
||||
@@ -661,7 +669,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchExport = async (includeData: boolean) => {
|
||||
const handleBatchExport = async (mode: BatchTableExportMode) => {
|
||||
const selectedTables = batchTables.filter(t => checkedTableKeys.includes(t.key));
|
||||
if (selectedTables.length === 0) {
|
||||
message.warning('请至少选择一张表');
|
||||
@@ -673,9 +681,17 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
const { conn, dbName } = batchDbContext;
|
||||
const tableNames = selectedTables.map(t => t.tableName);
|
||||
|
||||
const hide = message.loading(includeData ? `正在备份选中表 (${tableNames.length})...` : `正在导出选中表结构 (${tableNames.length})...`, 0);
|
||||
const loadingText = mode === 'backup'
|
||||
? `正在备份选中表 (${tableNames.length})...`
|
||||
: mode === 'dataOnly'
|
||||
? `正在导出选中表数据 (INSERT) (${tableNames.length})...`
|
||||
: `正在导出选中表结构 (${tableNames.length})...`;
|
||||
const hide = message.loading(loadingText, 0);
|
||||
try {
|
||||
const res = await (window as any).go.app.App.ExportTablesSQL(normalizeConnConfig(conn.config), dbName, tableNames, includeData);
|
||||
const app = (window as any).go.app.App;
|
||||
const res = mode === 'dataOnly'
|
||||
? await app.ExportTablesDataSQL(normalizeConnConfig(conn.config), dbName, tableNames)
|
||||
: await app.ExportTablesSQL(normalizeConnConfig(conn.config), dbName, tableNames, mode === 'backup');
|
||||
hide();
|
||||
if (res.success) {
|
||||
message.success('导出成功');
|
||||
@@ -865,6 +881,148 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
}
|
||||
};
|
||||
|
||||
const buildRuntimeConfig = (conn: any, overrideDatabase?: string, clearDatabase: boolean = false) => {
|
||||
return {
|
||||
...conn.config,
|
||||
port: Number(conn.config.port),
|
||||
password: conn.config.password || "",
|
||||
database: clearDatabase ? "" : ((overrideDatabase ?? conn.config.database) || ""),
|
||||
useSSH: conn.config.useSSH || false,
|
||||
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
|
||||
};
|
||||
};
|
||||
|
||||
const getConnectionNodeRef = (connRef: any) => {
|
||||
const latestConn = connections.find(c => c.id === connRef.id);
|
||||
return { key: connRef.id, dataRef: latestConn || connRef };
|
||||
};
|
||||
|
||||
const getDatabaseNodeRef = (connRef: any, dbName: string) => {
|
||||
const latestConn = connections.find(c => c.id === connRef.id);
|
||||
return {
|
||||
key: `${connRef.id}-${dbName}`,
|
||||
dataRef: { ...(latestConn || connRef), dbName }
|
||||
};
|
||||
};
|
||||
|
||||
const extractObjectName = (fullName: string) => {
|
||||
const raw = String(fullName || '').trim();
|
||||
const idx = raw.lastIndexOf('.');
|
||||
if (idx >= 0 && idx < raw.length - 1) {
|
||||
return raw.substring(idx + 1);
|
||||
}
|
||||
return raw;
|
||||
};
|
||||
|
||||
const handleRenameDatabase = async () => {
|
||||
if (!renameDbTarget) return;
|
||||
try {
|
||||
const values = await renameDbForm.validateFields();
|
||||
const conn = renameDbTarget.dataRef;
|
||||
const oldDbName = String(conn.dbName || '').trim();
|
||||
const newDbName = String(values.newName || '').trim();
|
||||
if (!oldDbName || !newDbName) {
|
||||
message.error("数据库名称不能为空");
|
||||
return;
|
||||
}
|
||||
if (oldDbName === newDbName) {
|
||||
message.warning("新旧数据库名称相同,无需修改");
|
||||
return;
|
||||
}
|
||||
|
||||
const config = buildRuntimeConfig(conn, conn.dbName);
|
||||
const res = await RenameDatabase(config as any, oldDbName, newDbName);
|
||||
if (res.success) {
|
||||
message.success("数据库重命名成功");
|
||||
setExpandedKeys(prev => prev.filter(k => !k.toString().startsWith(`${conn.id}-${oldDbName}`)));
|
||||
setLoadedKeys(prev => prev.filter(k => !k.toString().startsWith(`${conn.id}-${oldDbName}`)));
|
||||
await loadDatabases(getConnectionNodeRef(conn));
|
||||
setIsRenameDbModalOpen(false);
|
||||
setRenameDbTarget(null);
|
||||
renameDbForm.resetFields();
|
||||
} else {
|
||||
message.error("重命名失败: " + res.message);
|
||||
}
|
||||
} catch (e) {
|
||||
// Validate failed
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteDatabase = (node: any) => {
|
||||
const conn = node.dataRef;
|
||||
const dbName = String(conn.dbName || '').trim();
|
||||
if (!dbName) return;
|
||||
Modal.confirm({
|
||||
title: '确认删除数据库',
|
||||
content: `确定删除数据库 "${dbName}" 吗?该操作不可恢复。`,
|
||||
okButtonProps: { danger: true },
|
||||
onOk: async () => {
|
||||
const config = buildRuntimeConfig(conn, conn.dbName);
|
||||
const res = await DropDatabase(config as any, dbName);
|
||||
if (res.success) {
|
||||
message.success("数据库删除成功");
|
||||
setExpandedKeys(prev => prev.filter(k => !k.toString().startsWith(`${conn.id}-${dbName}`)));
|
||||
setLoadedKeys(prev => prev.filter(k => !k.toString().startsWith(`${conn.id}-${dbName}`)));
|
||||
await loadDatabases(getConnectionNodeRef(conn));
|
||||
} else {
|
||||
message.error("删除失败: " + res.message);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleRenameTable = async () => {
|
||||
if (!renameTableTarget) return;
|
||||
try {
|
||||
const values = await renameTableForm.validateFields();
|
||||
const conn = renameTableTarget.dataRef;
|
||||
const oldTableName = String(conn.tableName || '').trim();
|
||||
const newTableName = String(values.newName || '').trim();
|
||||
if (!oldTableName || !newTableName) {
|
||||
message.error("表名不能为空");
|
||||
return;
|
||||
}
|
||||
if (extractObjectName(oldTableName) === newTableName || oldTableName === newTableName) {
|
||||
message.warning("新旧表名相同,无需修改");
|
||||
return;
|
||||
}
|
||||
const config = buildRuntimeConfig(conn, conn.dbName);
|
||||
const res = await RenameTable(config as any, conn.dbName, oldTableName, newTableName);
|
||||
if (res.success) {
|
||||
message.success("表重命名成功");
|
||||
await loadTables(getDatabaseNodeRef(conn, conn.dbName));
|
||||
setIsRenameTableModalOpen(false);
|
||||
setRenameTableTarget(null);
|
||||
renameTableForm.resetFields();
|
||||
} else {
|
||||
message.error("重命名失败: " + res.message);
|
||||
}
|
||||
} catch (e) {
|
||||
// Validate failed
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteTable = (node: any) => {
|
||||
const conn = node.dataRef;
|
||||
const tableName = String(conn.tableName || '').trim();
|
||||
if (!tableName) return;
|
||||
Modal.confirm({
|
||||
title: '确认删除表',
|
||||
content: `确定删除表 "${tableName}" 吗?该操作不可恢复。`,
|
||||
okButtonProps: { danger: true },
|
||||
onOk: async () => {
|
||||
const config = buildRuntimeConfig(conn, conn.dbName);
|
||||
const res = await DropTable(config as any, conn.dbName, tableName);
|
||||
if (res.success) {
|
||||
message.success("表删除成功");
|
||||
await loadTables(getDatabaseNodeRef(conn, conn.dbName));
|
||||
} else {
|
||||
message.error("删除失败: " + res.message);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const onSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { value } = e.target;
|
||||
setSearchValue(value);
|
||||
@@ -1088,6 +1246,23 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
icon: <TableOutlined />,
|
||||
onClick: () => openNewTableDesign(node)
|
||||
},
|
||||
{
|
||||
key: 'rename-db',
|
||||
label: '重命名数据库',
|
||||
icon: <EditOutlined />,
|
||||
onClick: () => {
|
||||
setRenameDbTarget(node);
|
||||
renameDbForm.setFieldsValue({ newName: node.dataRef?.dbName || '' });
|
||||
setIsRenameDbModalOpen(true);
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'drop-db',
|
||||
label: '删除数据库',
|
||||
icon: <DeleteOutlined />,
|
||||
danger: true,
|
||||
onClick: () => handleDeleteDatabase(node)
|
||||
},
|
||||
{
|
||||
key: 'refresh',
|
||||
label: '刷新',
|
||||
@@ -1180,6 +1355,23 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
icon: <SaveOutlined />,
|
||||
onClick: () => handleExport(node, 'sql')
|
||||
},
|
||||
{
|
||||
key: 'rename-table',
|
||||
label: '重命名表',
|
||||
icon: <EditOutlined />,
|
||||
onClick: () => {
|
||||
setRenameTableTarget(node);
|
||||
renameTableForm.setFieldsValue({ newName: extractObjectName(node.dataRef?.tableName || node.title) });
|
||||
setIsRenameTableModalOpen(true);
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'drop-table',
|
||||
label: '删除表',
|
||||
icon: <DeleteOutlined />,
|
||||
danger: true,
|
||||
onClick: () => handleDeleteTable(node)
|
||||
},
|
||||
{
|
||||
type: 'divider'
|
||||
},
|
||||
@@ -1295,33 +1487,79 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={`重命名数据库${renameDbTarget?.dataRef?.dbName ? ` (${renameDbTarget.dataRef.dbName})` : ''}`}
|
||||
open={isRenameDbModalOpen}
|
||||
onOk={handleRenameDatabase}
|
||||
onCancel={() => {
|
||||
setIsRenameDbModalOpen(false);
|
||||
setRenameDbTarget(null);
|
||||
renameDbForm.resetFields();
|
||||
}}
|
||||
>
|
||||
<Form form={renameDbForm} layout="vertical">
|
||||
<Form.Item name="newName" label="新数据库名称" rules={[{ required: true, message: '请输入新数据库名称' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={`重命名表${renameTableTarget?.dataRef?.tableName ? ` (${renameTableTarget.dataRef.tableName})` : ''}`}
|
||||
open={isRenameTableModalOpen}
|
||||
onOk={handleRenameTable}
|
||||
onCancel={() => {
|
||||
setIsRenameTableModalOpen(false);
|
||||
setRenameTableTarget(null);
|
||||
renameTableForm.resetFields();
|
||||
}}
|
||||
>
|
||||
<Form form={renameTableForm} layout="vertical">
|
||||
<Form.Item name="newName" label="新表名" rules={[{ required: true, message: '请输入新表名' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="批量操作表"
|
||||
open={isBatchModalOpen}
|
||||
onCancel={() => setIsBatchModalOpen(false)}
|
||||
width={600}
|
||||
footer={[
|
||||
<Button key="cancel" onClick={() => setIsBatchModalOpen(false)}>
|
||||
取消
|
||||
</Button>,
|
||||
<Button
|
||||
key="export-schema"
|
||||
icon={<ExportOutlined />}
|
||||
onClick={() => handleBatchExport(false)}
|
||||
disabled={checkedTableKeys.length === 0}
|
||||
>
|
||||
导出表结构 ({checkedTableKeys.length})
|
||||
</Button>,
|
||||
<Button
|
||||
key="backup"
|
||||
type="primary"
|
||||
icon={<SaveOutlined />}
|
||||
onClick={() => handleBatchExport(true)}
|
||||
disabled={checkedTableKeys.length === 0}
|
||||
>
|
||||
备份表 ({checkedTableKeys.length})
|
||||
</Button>
|
||||
]}
|
||||
width={680}
|
||||
footer={
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<Button key="cancel" onClick={() => setIsBatchModalOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Space size={8} wrap style={{ marginLeft: 'auto' }}>
|
||||
<Button
|
||||
key="export-schema"
|
||||
icon={<ExportOutlined />}
|
||||
onClick={() => handleBatchExport('schema')}
|
||||
disabled={checkedTableKeys.length === 0}
|
||||
>
|
||||
导出结构
|
||||
</Button>
|
||||
<Button
|
||||
key="export-data-only"
|
||||
icon={<SaveOutlined />}
|
||||
onClick={() => handleBatchExport('dataOnly')}
|
||||
disabled={checkedTableKeys.length === 0}
|
||||
>
|
||||
仅数据(INSERT)
|
||||
</Button>
|
||||
<Button
|
||||
key="backup"
|
||||
type="primary"
|
||||
icon={<SaveOutlined />}
|
||||
onClick={() => handleBatchExport('backup')}
|
||||
disabled={checkedTableKeys.length === 0}
|
||||
>
|
||||
备份(结构+数据)
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
|
||||
Reference in New Issue
Block a user