feat(sidebar-redis-db): 新增库表重命名删除、批量仅数据导出与Redis多选删键能力

- 后端新增数据库/表重命名与删除能力,覆盖多数据源差异处理
- 批量操作表新增“仅导出数据(INSERT)”模式并完善导出链路
- Redis Key 列表支持分组展示、勾选批量删除与当前Key删除入口
- 同步 Wails 前后端绑定接口并优化批量操作弹窗按钮布局
This commit is contained in:
Syngnat
2026-02-06 16:57:05 +08:00
parent 70cb6148c6
commit 820b064e7f
6 changed files with 796 additions and 221 deletions

View File

@@ -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>

View File

@@ -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 }}>