feat: frontend MVP — детальная схема связей устройств (AntV X6)
- React 18 + TypeScript strict + AntV X6 2.x + AntD 5 + Zustand - Custom nodes: SiteNode, CrossDeviceNode, SpliceNode, DeviceNode, CardNode - 8-слойный автолейаут, порты (left/right), линии с цветами по статусу - Toolbar, дерево навигации, карточка объекта, таблица соединений - Контекстные меню, легенда, drag линий/нод, создание линий из портов - Моковые данные: 3 сайта, 10 устройств, 15 линий Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
149
frontend/src/components/SidePanel/LeftPanel.tsx
Normal file
149
frontend/src/components/SidePanel/LeftPanel.tsx
Normal file
@ -0,0 +1,149 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { Tree, Input } from 'antd';
|
||||
import type { TreeDataNode } from 'antd';
|
||||
import {
|
||||
ApartmentOutlined,
|
||||
HddOutlined,
|
||||
ClusterOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { mockData } from '../../mock/schemaData.ts';
|
||||
import { useSchemaStore } from '../../store/schemaStore.ts';
|
||||
|
||||
const { Search } = Input;
|
||||
|
||||
export function LeftPanel() {
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const [expandedKeys, setExpandedKeys] = useState<string[]>(['sites', 'all-devices']);
|
||||
const graph = useSchemaStore((s) => s.graph);
|
||||
const setRightPanelData = useSchemaStore((s) => s.setRightPanelData);
|
||||
|
||||
const treeData = useMemo((): TreeDataNode[] => {
|
||||
const sitesTree: TreeDataNode[] = mockData.sites
|
||||
.filter((s) => !s.parentSiteId)
|
||||
.map((site) => {
|
||||
const children: TreeDataNode[] = [];
|
||||
|
||||
// Add devices belonging to this site
|
||||
const siteDevices = mockData.devices.filter(
|
||||
(d) => d.siteId === site.id,
|
||||
);
|
||||
for (const device of siteDevices) {
|
||||
children.push({
|
||||
key: device.id,
|
||||
title: device.name,
|
||||
icon: <HddOutlined />,
|
||||
});
|
||||
}
|
||||
|
||||
// Add child sites
|
||||
const childSites = mockData.sites.filter(
|
||||
(s) => s.parentSiteId === site.id,
|
||||
);
|
||||
for (const childSite of childSites) {
|
||||
const childDevices = mockData.devices.filter(
|
||||
(d) => d.siteId === childSite.id,
|
||||
);
|
||||
children.push({
|
||||
key: childSite.id,
|
||||
title: childSite.name,
|
||||
icon: <ApartmentOutlined />,
|
||||
children: childDevices.map((d) => ({
|
||||
key: d.id,
|
||||
title: d.name,
|
||||
icon: <HddOutlined />,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
key: site.id,
|
||||
title: site.name,
|
||||
icon: <ApartmentOutlined />,
|
||||
children,
|
||||
};
|
||||
});
|
||||
|
||||
return [
|
||||
{
|
||||
key: 'sites',
|
||||
title: 'Сайты',
|
||||
icon: <ClusterOutlined />,
|
||||
children: sitesTree,
|
||||
},
|
||||
];
|
||||
}, []);
|
||||
|
||||
const filteredTreeData = useMemo(() => {
|
||||
if (!searchValue) return treeData;
|
||||
|
||||
const filterTree = (nodes: TreeDataNode[]): TreeDataNode[] => {
|
||||
return nodes
|
||||
.map((node) => {
|
||||
const title = String(node.title ?? '');
|
||||
const match = title.toLowerCase().includes(searchValue.toLowerCase());
|
||||
const filteredChildren = node.children
|
||||
? filterTree(node.children)
|
||||
: [];
|
||||
if (match || filteredChildren.length > 0) {
|
||||
return { ...node, children: filteredChildren };
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter(Boolean) as TreeDataNode[];
|
||||
};
|
||||
|
||||
return filterTree(treeData);
|
||||
}, [treeData, searchValue]);
|
||||
|
||||
const handleSelect = (selectedKeys: React.Key[]) => {
|
||||
const key = selectedKeys[0] as string;
|
||||
if (!key || !graph) return;
|
||||
|
||||
// Find the node on the graph and center on it
|
||||
const cell = graph.getCellById(key);
|
||||
if (cell) {
|
||||
graph.centerCell(cell);
|
||||
graph.select(cell);
|
||||
|
||||
// Set right panel data
|
||||
const data = cell.getData() as Record<string, unknown> | undefined;
|
||||
if (data) {
|
||||
setRightPanelData(data);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: '#fff',
|
||||
borderRight: '1px solid #f0f0f0',
|
||||
}}
|
||||
>
|
||||
<div style={{ padding: '8px 12px', borderBottom: '1px solid #f0f0f0' }}>
|
||||
<Search
|
||||
placeholder="Поиск..."
|
||||
size="small"
|
||||
value={searchValue}
|
||||
onChange={(e) => setSearchValue(e.target.value)}
|
||||
allowClear
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: '4px 0' }}>
|
||||
<Tree
|
||||
showIcon
|
||||
treeData={filteredTreeData}
|
||||
expandedKeys={expandedKeys}
|
||||
onExpand={(keys) => setExpandedKeys(keys as string[])}
|
||||
onSelect={handleSelect}
|
||||
blockNode
|
||||
style={{ fontSize: 12 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user