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:
Alina
2026-02-17 22:02:25 +03:00
commit ef816cdcf4
48 changed files with 8738 additions and 0 deletions

View 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>
);
}