Feature/add aibook (#160)

* feat: 增加技术圈连接

* feat: 增强水平滚动条在大量数据下支持鼠标滚轮 (refs #146)

* feat: 新增连接标签分组功能,支持创建/编辑/删除标签、拖拽归组、右键移至标签 refs #148
This commit is contained in:
凌封
2026-03-04 11:50:34 +08:00
committed by GitHub
parent f2fc7cbd05
commit 8c91d8929b
6 changed files with 390 additions and 82 deletions

View File

@@ -2705,29 +2705,31 @@ const DataGrid: React.FC<DataGridProps> = ({
horizontalSyncSourceRef.current = '';
}, []);
const handleExternalHorizontalWheel = useCallback((event: React.WheelEvent<HTMLDivElement>) => {
// 非虚拟模式:外部水平滚动条的 wheel 处理(通过原生事件绑定,确保 preventDefault 生效)
useEffect(() => {
const externalScroll = externalHScrollRef.current;
if (!(externalScroll instanceof HTMLDivElement)) {
return;
}
const dominantDelta = Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY;
if (!Number.isFinite(dominantDelta) || Math.abs(dominantDelta) < 0.5) {
return;
}
if (!externalScroll || !horizontalScrollVisible) return;
const maxScrollLeft = Math.max(0, externalScroll.scrollWidth - externalScroll.clientWidth);
if (maxScrollLeft <= 0) {
return;
}
const handleExternalWheel = (e: WheelEvent) => {
// 鼠标在水平滚动条区域时,始终阻止垂直滚动冒泡
e.preventDefault();
e.stopPropagation();
const nextScrollLeft = Math.max(0, Math.min(maxScrollLeft, externalScroll.scrollLeft + dominantDelta));
if (Math.abs(nextScrollLeft - externalScroll.scrollLeft) < 0.5) {
return;
}
const dominantDelta = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY;
if (!Number.isFinite(dominantDelta) || Math.abs(dominantDelta) < 0.5) return;
event.preventDefault();
externalScroll.scrollLeft = nextScrollLeft;
}, []);
const maxScrollLeft = Math.max(0, externalScroll.scrollWidth - externalScroll.clientWidth);
if (maxScrollLeft <= 0) return;
const nextScrollLeft = Math.max(0, Math.min(maxScrollLeft, externalScroll.scrollLeft + dominantDelta));
externalScroll.scrollLeft = nextScrollLeft;
};
externalScroll.addEventListener('wheel', handleExternalWheel, { passive: false, capture: true });
return () => {
externalScroll.removeEventListener('wheel', handleExternalWheel, { capture: true } as EventListenerOptions);
};
}, [horizontalScrollVisible]);
useEffect(() => {
if (viewMode !== 'table') return;
@@ -2735,19 +2737,24 @@ const DataGrid: React.FC<DataGridProps> = ({
return () => cancelAnimationFrame(rafId);
}, [viewMode, totalWidth, mergedDisplayData.length, recalculateTableMetrics]);
// 虚拟模式下,为 rc-virtual-list 的内置水平滚动条添加鼠标滚轮支持
// rc-virtual-list 的 ScrollBar 组件原生只支持拖拽,不支持 wheel 事件
// 方案:使用 MutationObserver 发现滚动条元素后直接绑定 wheel 事件
// 虚拟模式下,在容器级别监听 wheel 事件,当鼠标在底部水平滚动条区域时拦截并转为水平滚动
useEffect(() => {
if (viewMode !== 'table' || !enableVirtual) return;
const container = tableContainerRef.current;
if (!container) return;
let currentScrollbarEl: HTMLElement | null = null;
// 滚动条区域高度:滚动条高度 + 间距 + 容错
const scrollbarZoneHeight = floatingScrollbarHeight + floatingScrollbarGap + 8;
const handleScrollbarWheel = (e: WheelEvent) => {
const innerEl = container.querySelector('.rc-virtual-list-holder-inner') as HTMLElement | null;
const holderEl = container.querySelector('.rc-virtual-list-holder') as HTMLElement | null;
const handleContainerWheel = (e: WheelEvent) => {
// 判断鼠标是否在底部滚动条区域
const containerRect = container.getBoundingClientRect();
if (e.clientY < containerRect.bottom - scrollbarZoneHeight) return;
// 适配 antd 的虚拟列表类名
const holderEl = container.querySelector('.ant-table-tbody-virtual-holder') as HTMLElement | null;
const innerEl = holderEl?.querySelector('.ant-table-tbody-virtual-holder-inner') as HTMLElement | null;
if (!innerEl || !holderEl) return;
const dominantDelta = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY;
@@ -2769,12 +2776,13 @@ const DataGrid: React.FC<DataGridProps> = ({
innerEl.style.marginLeft = `${-newOffset}px`;
// 同步 scrollbar thumb 位置
if (currentScrollbarEl && maxScroll > 0) {
const thumbEl = currentScrollbarEl.querySelector('[class*="scrollbar-thumb"]') as HTMLElement | null;
const scrollbarEl = container.querySelector('.ant-table-tbody-virtual-scrollbar-horizontal') as HTMLElement | null;
if (scrollbarEl && maxScroll > 0) {
const thumbEl = scrollbarEl.querySelector('[class*="scrollbar-thumb"]') as HTMLElement | null;
if (thumbEl) {
const ratio = newOffset / maxScroll;
const thumbWidth = parseFloat(thumbEl.style.width) || thumbEl.offsetWidth;
const trackWidth = currentScrollbarEl.clientWidth;
const trackWidth = scrollbarEl.clientWidth;
const thumbMaxOffset = trackWidth - thumbWidth;
thumbEl.style.left = `${ratio * thumbMaxOffset}px`;
}
@@ -2787,33 +2795,12 @@ const DataGrid: React.FC<DataGridProps> = ({
}
};
const bindScrollbar = () => {
const el = container.querySelector('.ant-table-tbody-virtual-scrollbar-horizontal') as HTMLElement | null;
if (el && el !== currentScrollbarEl) {
if (currentScrollbarEl) {
currentScrollbarEl.removeEventListener('wheel', handleScrollbarWheel);
}
currentScrollbarEl = el;
el.addEventListener('wheel', handleScrollbarWheel, { passive: false });
}
};
// 初次尝试绑定
bindScrollbar();
// 使用 MutationObserver 监听 DOM 变化,确保即使元素延迟渲染也能绑定
const observer = new MutationObserver(() => {
bindScrollbar();
});
observer.observe(container, { childList: true, subtree: true });
container.addEventListener('wheel', handleContainerWheel, { passive: false, capture: true });
return () => {
observer.disconnect();
if (currentScrollbarEl) {
currentScrollbarEl.removeEventListener('wheel', handleScrollbarWheel);
}
container.removeEventListener('wheel', handleContainerWheel, { capture: true } as EventListenerOptions);
};
}, [viewMode, enableVirtual, tableScrollX, mergedDisplayData.length]);
}, [viewMode, enableVirtual, tableScrollX, floatingScrollbarHeight, floatingScrollbarGap]);
useEffect(() => {
if (viewMode !== 'table') return;
@@ -3307,7 +3294,6 @@ const DataGrid: React.FC<DataGridProps> = ({
className="data-grid-external-hscroll"
aria-hidden={!horizontalScrollVisible}
onScroll={applyExternalScrollToTableTargets}
onWheel={handleExternalHorizontalWheel}
style={{
opacity: horizontalScrollVisible ? 1 : 0,
pointerEvents: horizontalScrollVisible ? 'auto' : 'none',

View File

@@ -6,6 +6,7 @@ import { Tree, message, Dropdown, MenuProps, Input, Button, Modal, Form, Badge,
EyeOutlined,
ConsoleSqlOutlined,
HddOutlined,
FolderOutlined,
FolderOpenOutlined,
FileTextOutlined,
CopyOutlined,
@@ -42,7 +43,7 @@ interface TreeNode {
children?: TreeNode[];
icon?: React.ReactNode;
dataRef?: any;
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?: 'connection' | 'database' | 'table' | 'view' | 'db-trigger' | 'routine' | 'object-group' | 'queries-folder' | 'saved-query' | 'folder-columns' | 'folder-indexes' | 'folder-fks' | 'folder-triggers' | 'redis-db' | 'tag';
}
type BatchTableExportMode = 'schema' | 'backup' | 'dataOnly';
@@ -64,6 +65,12 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
const addTab = useStore(state => state.addTab);
const setActiveContext = useStore(state => state.setActiveContext);
const removeConnection = useStore(state => state.removeConnection);
const connectionTags = useStore(state => state.connectionTags);
const addConnectionTag = useStore(state => state.addConnectionTag);
const updateConnectionTag = useStore(state => state.updateConnectionTag);
const removeConnectionTag = useStore(state => state.removeConnectionTag);
const moveConnectionToTag = useStore(state => state.moveConnectionToTag);
const reorderTags = useStore(state => state.reorderTags);
const closeTabsByConnection = useStore(state => state.closeTabsByConnection);
const closeTabsByDatabase = useStore(state => state.closeTabsByDatabase);
const theme = useStore(state => state.theme);
@@ -127,6 +134,10 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
const [renameViewForm] = Form.useForm();
const [renameViewTarget, setRenameViewTarget] = useState<any>(null);
// Connection Tag Modals
const [isCreateTagModalOpen, setIsCreateTagModalOpen] = useState(false);
const [createTagForm] = Form.useForm();
// Batch Operations Modal
const [isBatchModalOpen, setIsBatchModalOpen] = useState(false);
const [batchTables, setBatchTables] = useState<BatchObjectItem[]>([]);
@@ -208,11 +219,21 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
useEffect(() => {
setTreeData((prev) => {
const prevMap = new Map<string, TreeNode>();
prev.forEach((node) => {
prevMap.set(String(node.key), node);
});
// We need to recursively extract connections from old tag structures
// so if a user expands a connection that was tagged, the state remains
const recurseCollect = (nodes: TreeNode[]) => {
nodes.forEach((node) => {
if (node.type === 'tag') {
if (node.children) recurseCollect(node.children);
} else if (node.type === 'connection') {
prevMap.set(String(node.key), node);
}
});
};
recurseCollect(prev);
return connections.map((conn) => {
const buildConnectionNode = (conn: SavedConnection): TreeNode => {
const existing = prevMap.get(conn.id);
return {
title: conn.name,
@@ -223,9 +244,32 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
isLeaf: false,
children: existing?.children,
} as TreeNode;
};
const taggedConnIds = new Set<string>();
const tagNodes: TreeNode[] = connectionTags.map((tag) => {
tag.connectionIds.forEach(id => taggedConnIds.add(id));
return {
title: tag.name,
key: `tag-${tag.id}`,
icon: <FolderOutlined style={{ color: '#faad14' }} />,
type: 'tag',
dataRef: tag,
isLeaf: false,
children: tag.connectionIds
.map(cid => connections.find(c => c.id === cid))
.filter(Boolean)
.map(conn => buildConnectionNode(conn!)),
} as TreeNode;
});
const ungroupedNodes: TreeNode[] = connections
.filter(c => !taggedConnIds.has(c.id))
.map(conn => buildConnectionNode(conn));
return [...tagNodes, ...ungroupedNodes];
});
}, [connections]);
}, [connections, connectionTags]);
const updateTreeData = (list: TreeNode[], key: React.Key, children: TreeNode[] | undefined): TreeNode[] => {
return list.map(node => {
@@ -1042,6 +1086,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
};
const onLoadData = async ({ key, children, dataRef, type }: any) => {
if (type === 'tag') return;
if (children) return;
if (type === 'connection') {
@@ -2284,6 +2329,38 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
return routineMenu;
}
// Connection Tag Menu — must be BEFORE the connection check
if (node.type === 'tag') {
return [
{
key: 'edit-tag',
label: '编辑标签',
icon: <EditOutlined />,
onClick: () => {
createTagForm.setFieldsValue({ name: node.title, connectionIds: node.dataRef.connectionIds });
setRenameViewTarget(node);
setIsCreateTagModalOpen(true);
}
},
{ type: 'divider' },
{
key: 'delete-tag',
label: '删除标签',
icon: <DeleteOutlined />,
danger: true,
onClick: () => {
Modal.confirm({
title: '确认删除',
content: `确定要删除标签 "${node.title}" 吗?这不会删除里面的连接。`,
onOk: () => {
removeConnectionTag(node.dataRef.id);
}
});
}
}
];
}
if (node.type === 'connection') {
// Redis connection menu
if (isRedis) {
@@ -2358,6 +2435,22 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
];
}
// Tag submenu for connection
const tagSubMenuItems: MenuProps['items'] = connectionTags.map(tag => ({
key: `move-to-tag-${tag.id}`,
label: tag.name,
icon: <FolderOutlined />,
onClick: () => moveConnectionToTag(node.key, tag.id)
}));
if (connectionTags.length > 0) {
tagSubMenuItems.push({ type: 'divider' });
}
tagSubMenuItems.push({
key: 'move-to-ungrouped',
label: '移出标签',
onClick: () => moveConnectionToTag(node.key, null)
});
// Regular database connection menu
return [
{
@@ -2400,6 +2493,12 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
if (onEditConnection) onEditConnection(node.dataRef);
}
},
{
key: 'move-to-tag',
label: '移至标签',
icon: <FolderOpenOutlined />,
children: tagSubMenuItems
},
{
key: 'disconnect',
label: '断开连接',
@@ -2741,6 +2840,72 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
return <span title={hoverTitle}>{statusBadge}{displayTitle}</span>;
};
const handleDrop = (info: any) => {
const dropKey = info.node.key;
const dragKey = info.dragNode.key;
const dropPos = info.node.pos.split('-');
const dropPosition = info.dropPosition - Number(dropPos[dropPos.length - 1]);
const dragNode = info.dragNode;
const dropNode = info.node;
// Tag to Tag reordering
if (dragNode.type === 'tag') {
// You can only drop tags onto the root level (before/after other tags or connections at root)
if (dropNode.type === 'tag' || dropNode.type === 'connection') {
// Get current order
const currentTagOrder = connectionTags.map(t => t.id);
const dragTagId = dragNode.dataRef.id;
// Filter out the dragging tag
const newOrder = currentTagOrder.filter(id => id !== dragTagId);
let insertIndex = newOrder.length;
if (dropNode.type === 'tag') {
const dropTagId = dropNode.dataRef.id;
const dropIndex = newOrder.indexOf(dropTagId);
if (dropPosition === -1) {
insertIndex = dropIndex;
} else {
insertIndex = dropIndex + 1;
}
} else {
// Dropped onto a root connection, usually meaning moving to the end of tags
// Since tags are always displayed before ungrouped connections, just put it at the end
insertIndex = newOrder.length;
}
newOrder.splice(insertIndex, 0, dragTagId);
reorderTags(newOrder);
}
return;
}
// Connection moving to tag (any drop position on a tag node counts as "into")
if (dragNode.type === 'connection' && dropNode.type === 'tag') {
moveConnectionToTag(dragNode.key, dropNode.dataRef.id);
return;
}
// Connection moving to another connection inside a tag
if (dragNode.type === 'connection' && dropNode.type === 'connection') {
// Find if drop target is under a tag
const targetTag = connectionTags.find(t => t.connectionIds.includes(dropNode.key));
if (targetTag) {
moveConnectionToTag(dragNode.key, targetTag.id);
return;
}
// Drop target is NOT under a tag (ungrouped) -> move OUT of tag
const sourceTag = connectionTags.find(t => t.connectionIds.includes(dragNode.key));
if (sourceTag) {
moveConnectionToTag(dragNode.key, null);
return;
}
}
};
const onRightClick = ({ event, node }: any) => {
const items = getNodeMenuItems(node);
if (items && items.length > 0) {
@@ -2758,13 +2923,25 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
<Search placeholder="搜索..." onChange={onSearch} size="small" />
</div>
{/* Toolbar for batch operations - always visible */}
<div style={{ padding: '4px 8px', borderBottom: 'none', display: 'flex', gap: 4 }}>
{/* Toolbar */}
<div style={{ padding: '4px 8px', borderBottom: 'none', display: 'flex', flexWrap: 'wrap', gap: 4 }}>
<Button
size="small"
icon={<FolderOpenOutlined />}
onClick={() => {
setRenameViewTarget(null); // Create mode
createTagForm.resetFields();
setIsCreateTagModalOpen(true);
}}
style={{ flex: '1 1 auto' }}
>
</Button>
<Button
size="small"
icon={<CheckSquareOutlined />}
onClick={() => openBatchOperationModal()}
style={{ flex: 1 }}
style={{ flex: '1 1 auto' }}
>
</Button>
@@ -2772,7 +2949,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
size="small"
icon={<CheckSquareOutlined />}
onClick={() => openBatchDatabaseModal()}
style={{ flex: 1 }}
style={{ flex: '1 1 auto' }}
>
</Button>
@@ -2781,6 +2958,11 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
<div ref={treeContainerRef} style={{ flex: 1, overflow: 'hidden', minHeight: 0 }}>
<Tree
showIcon
draggable={{
icon: false,
nodeDraggable: (node: any) => node.type === 'connection' || node.type === 'tag'
}}
onDrop={handleDrop}
loadData={onLoadData}
treeData={displayTreeData}
onDoubleClick={onDoubleClick}
@@ -2809,6 +2991,60 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
</Dropdown>
)}
<Modal
title={renameViewTarget?.type === 'tag' ? "编辑标签" : "新建组"}
open={isCreateTagModalOpen}
onOk={() => {
createTagForm.validateFields().then(values => {
if (renameViewTarget?.type === 'tag') {
// Rename
updateConnectionTag({
...renameViewTarget.dataRef,
name: values.name,
connectionIds: values.connectionIds || []
});
// update cross-connections
const allOtherTagsIds = connectionTags.filter(t => t.id !== renameViewTarget.dataRef.id).flatMap(t => t.connectionIds);
(values.connectionIds || []).forEach((cid: string) => {
if (allOtherTagsIds.includes(cid)) {
moveConnectionToTag(cid, renameViewTarget.dataRef.id);
}
});
} else {
// Create
const tagId = Date.now().toString();
addConnectionTag({
id: tagId,
name: values.name,
connectionIds: values.connectionIds || []
});
(values.connectionIds || []).forEach((cid: string) => {
moveConnectionToTag(cid, tagId);
});
}
setIsCreateTagModalOpen(false);
});
}}
onCancel={() => setIsCreateTagModalOpen(false)}
>
<Form form={createTagForm} layout="vertical">
<Form.Item name="name" label="标签名称" rules={[{ required: true, message: '请输入标签名称' }]}>
<Input />
</Form.Item>
<Form.Item name="connectionIds" label="选择连接">
<Checkbox.Group style={{ width: '100%' }}>
<Space direction="vertical" style={{ width: '100%', maxHeight: '400px', overflowY: 'auto' }}>
{connections.map(conn => (
<Checkbox key={conn.id} value={conn.id}>
{conn.name} {conn.config.host ? `(${conn.config.host})` : ''}
</Checkbox>
))}
</Space>
</Checkbox.Group>
</Form.Item>
</Form>
</Modal>
<Modal
title="新建数据库"
open={isCreateDbModalOpen}