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,119 @@
import { useState, type ReactNode } from 'react';
import { Button, Tooltip } from 'antd';
import { MenuFoldOutlined, MenuUnfoldOutlined } from '@ant-design/icons';
interface AppLayoutProps {
toolbar: ReactNode;
leftPanel: ReactNode;
canvas: ReactNode;
rightPanel: ReactNode;
bottomPanel: ReactNode;
}
export function AppLayout({
toolbar,
leftPanel,
canvas,
rightPanel,
bottomPanel,
}: AppLayoutProps) {
const [leftCollapsed, setLeftCollapsed] = useState(false);
const [rightCollapsed, setRightCollapsed] = useState(false);
const leftWidth = leftCollapsed ? 0 : 240;
const rightWidth = rightCollapsed ? 0 : 280;
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
height: '100vh',
overflow: 'hidden',
}}
>
{/* Toolbar */}
{toolbar}
{/* Main content */}
<div style={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
{/* Left panel */}
<div
style={{
width: leftWidth,
transition: 'width 0.2s',
overflow: 'hidden',
flexShrink: 0,
position: 'relative',
}}
>
{!leftCollapsed && leftPanel}
</div>
{/* Left toggle */}
<div
style={{
width: 20,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
borderRight: '1px solid #f0f0f0',
background: '#fafafa',
}}
>
<Tooltip title={leftCollapsed ? 'Показать панель' : 'Скрыть панель'} placement="right">
<Button
type="text"
size="small"
icon={leftCollapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
onClick={() => setLeftCollapsed(!leftCollapsed)}
style={{ fontSize: 10 }}
/>
</Tooltip>
</div>
{/* Canvas + bottom panel */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<div style={{ flex: 1, overflow: 'hidden' }}>{canvas}</div>
{bottomPanel}
</div>
{/* Right toggle */}
<div
style={{
width: 20,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
borderLeft: '1px solid #f0f0f0',
background: '#fafafa',
}}
>
<Tooltip title={rightCollapsed ? 'Показать панель' : 'Скрыть панель'} placement="left">
<Button
type="text"
size="small"
icon={rightCollapsed ? <MenuFoldOutlined /> : <MenuUnfoldOutlined />}
onClick={() => setRightCollapsed(!rightCollapsed)}
style={{ fontSize: 10 }}
/>
</Tooltip>
</div>
{/* Right panel */}
<div
style={{
width: rightWidth,
transition: 'width 0.2s',
overflow: 'hidden',
flexShrink: 0,
}}
>
{!rightCollapsed && rightPanel}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,170 @@
import { useState } from 'react';
import { Table, Input, Button, Tag, Space } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { CloseOutlined, FullscreenOutlined, FullscreenExitOutlined } from '@ant-design/icons';
import { useSchemaStore } from '../../store/schemaStore.ts';
import { STATUS_COLORS, STATUS_LABELS } from '../../constants/statusColors.ts';
import { mockData } from '../../mock/schemaData.ts';
import type { EntityStatus, Line } from '../../types/index.ts';
const { Search } = Input;
interface ConnectionRow {
key: string;
lineName: string;
lineStatus: EntityStatus;
deviceAName: string;
portAName: string;
deviceZName: string;
portZName: string;
}
export function ConnectionsPanel() {
const visible = useSchemaStore((s) => s.connectionsPanelVisible);
const setVisible = useSchemaStore((s) => s.setConnectionsPanelVisible);
const panelData = useSchemaStore((s) => s.connectionsPanelData);
const [searchValue, setSearchValue] = useState('');
const [expanded, setExpanded] = useState(false);
if (!visible || !panelData) return null;
// Build rows from panel data
const rows: ConnectionRow[] = [];
if (panelData.line) {
// Single line mode
const line = panelData.line as Line;
const portA = panelData.portA as { name: string } | null;
const portZ = panelData.portZ as { name: string } | null;
const devA = panelData.deviceA as { name: string } | null;
const devZ = panelData.deviceZ as { name: string } | null;
rows.push({
key: line.id,
lineName: line.name,
lineStatus: line.status,
deviceAName: devA?.name ?? '—',
portAName: portA?.name ?? '—',
deviceZName: devZ?.name ?? '—',
portZName: portZ?.name ?? '—',
});
} else if (panelData.lines) {
// Multiple lines mode
const lines = panelData.lines as Line[];
for (const line of lines) {
const portA = mockData.ports.find((p) => p.id === line.portAId);
const portZ = mockData.ports.find((p) => p.id === line.portZId);
const devA = portA ? mockData.devices.find((d) => d.id === portA.deviceId) : null;
const devZ = portZ ? mockData.devices.find((d) => d.id === portZ.deviceId) : null;
rows.push({
key: line.id,
lineName: line.name,
lineStatus: line.status,
deviceAName: devA?.name ?? '—',
portAName: portA?.name ?? '—',
deviceZName: devZ?.name ?? '—',
portZName: portZ?.name ?? '—',
});
}
}
const filtered = searchValue
? rows.filter(
(r) =>
r.lineName.toLowerCase().includes(searchValue.toLowerCase()) ||
r.deviceAName.toLowerCase().includes(searchValue.toLowerCase()) ||
r.deviceZName.toLowerCase().includes(searchValue.toLowerCase()),
)
: rows;
const columns: ColumnsType<ConnectionRow> = [
{
title: 'Линия',
dataIndex: 'lineName',
key: 'lineName',
render: (name: string, record: ConnectionRow) => {
const colors = STATUS_COLORS[record.lineStatus];
return (
<Space size={4}>
<Tag color={colors.border} style={{ color: colors.text, fontSize: 10 }}>
{STATUS_LABELS[record.lineStatus]}
</Tag>
<span>{name}</span>
</Space>
);
},
},
{
title: 'Устройство A',
key: 'deviceA',
render: (_: unknown, record: ConnectionRow) => (
<span>
{record.deviceAName} ({record.portAName})
</span>
),
},
{
title: 'Устройство Z',
key: 'deviceZ',
render: (_: unknown, record: ConnectionRow) => (
<span>
{record.deviceZName} ({record.portZName})
</span>
),
},
];
return (
<div
style={{
height: expanded ? '60%' : '30%',
minHeight: 150,
borderTop: '1px solid #f0f0f0',
background: '#fff',
display: 'flex',
flexDirection: 'column',
flexShrink: 0,
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '6px 12px',
borderBottom: '1px solid #f0f0f0',
}}
>
<span style={{ fontWeight: 600, fontSize: 13 }}>Соединения</span>
<Space size={4}>
<Search
placeholder="Поиск..."
size="small"
style={{ width: 180 }}
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
allowClear
/>
<Button
size="small"
icon={expanded ? <FullscreenExitOutlined /> : <FullscreenOutlined />}
onClick={() => setExpanded(!expanded)}
/>
<Button
size="small"
icon={<CloseOutlined />}
onClick={() => setVisible(false)}
/>
</Space>
</div>
<div style={{ flex: 1, overflow: 'auto' }}>
<Table
dataSource={filtered}
columns={columns}
size="small"
pagination={false}
style={{ fontSize: 11 }}
/>
</div>
</div>
);
}

View File

@ -0,0 +1,86 @@
import { Modal, Space, Tag } from 'antd';
import { useSchemaStore } from '../../store/schemaStore.ts';
import { STATUS_COLORS, STATUS_LABELS } from '../../constants/statusColors.ts';
import { EntityStatus, LineStyle, Medium } from '../../types/index.ts';
export function LegendModal() {
const visible = useSchemaStore((s) => s.legendVisible);
const setVisible = useSchemaStore((s) => s.setLegendVisible);
return (
<Modal
title="Легенда"
open={visible}
onCancel={() => setVisible(false)}
footer={null}
width={480}
>
<div style={{ marginBottom: 20 }}>
<h4 style={{ marginBottom: 8 }}>Цвета статусов</h4>
<Space wrap>
{Object.values(EntityStatus).map((status) => {
const colors = STATUS_COLORS[status];
const label = STATUS_LABELS[status];
return (
<Tag
key={status}
style={{
borderColor: colors.border,
backgroundColor: colors.fill,
color: colors.text,
}}
>
{label}
</Tag>
);
})}
</Space>
</div>
<div style={{ marginBottom: 20 }}>
<h4 style={{ marginBottom: 8 }}>Типы линий</h4>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{Object.values(LineStyle).map((style) => {
const dasharray =
style === LineStyle.Solid
? ''
: style === LineStyle.Dashed
? '8 4'
: '2 4';
return (
<div key={style} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<svg width={60} height={20}>
<line
x1={0}
y1={10}
x2={60}
y2={10}
stroke="#333"
strokeWidth={2}
strokeDasharray={dasharray}
/>
</svg>
<span style={{ fontSize: 12 }}>{style}</span>
</div>
);
})}
</div>
</div>
<div>
<h4 style={{ marginBottom: 8 }}>Среда передачи</h4>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{Object.values(Medium).map((medium) => (
<div key={medium} style={{ fontSize: 12 }}>
<strong>{medium}</strong>
{medium === Medium.Optical && ' — оптическое волокно'}
{medium === Medium.Copper && ' — медный кабель'}
{medium === Medium.Wireless && ' — беспроводная связь'}
{medium === Medium.Unknown && ' — неизвестная среда'}
</div>
))}
</div>
</div>
</Modal>
);
}

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

View File

@ -0,0 +1,134 @@
import { Descriptions, Empty, Tag } from 'antd';
import { useSchemaStore } from '../../store/schemaStore.ts';
import { STATUS_COLORS, STATUS_LABELS } from '../../constants/statusColors.ts';
import type { EntityStatus } from '../../types/index.ts';
function StatusTag({ status }: { status: EntityStatus }) {
const colors = STATUS_COLORS[status];
const label = STATUS_LABELS[status];
return (
<Tag color={colors.border} style={{ color: colors.text }}>
{label}
</Tag>
);
}
export function RightPanel() {
const data = useSchemaStore((s) => s.rightPanelData);
if (!data) {
return (
<div
style={{
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: '#fff',
borderLeft: '1px solid #f0f0f0',
}}
>
<Empty description="Выберите объект" image={Empty.PRESENTED_IMAGE_SIMPLE} />
</div>
);
}
const entityType = data.entityType as string;
const status = data.status as EntityStatus;
if (entityType === 'site') {
return (
<div style={{ padding: 12, background: '#fff', borderLeft: '1px solid #f0f0f0', height: '100%', overflow: 'auto' }}>
<Descriptions
title="Сайт"
column={1}
size="small"
bordered
labelStyle={{ fontSize: 11, width: 110 }}
contentStyle={{ fontSize: 11 }}
>
<Descriptions.Item label="Название">{data.name as string}</Descriptions.Item>
<Descriptions.Item label="Адрес">{data.address as string}</Descriptions.Item>
<Descriptions.Item label="ERP">{data.erpCode as string}</Descriptions.Item>
<Descriptions.Item label="1С">{data.code1C as string}</Descriptions.Item>
<Descriptions.Item label="Статус"><StatusTag status={status} /></Descriptions.Item>
</Descriptions>
</div>
);
}
if (entityType === 'device') {
return (
<div style={{ padding: 12, background: '#fff', borderLeft: '1px solid #f0f0f0', height: '100%', overflow: 'auto' }}>
<Descriptions
title="Устройство"
column={1}
size="small"
bordered
labelStyle={{ fontSize: 11, width: 110 }}
contentStyle={{ fontSize: 11 }}
>
<Descriptions.Item label="Название">{data.name as string}</Descriptions.Item>
<Descriptions.Item label="Сетевое имя">{data.networkName as string}</Descriptions.Item>
<Descriptions.Item label="IP">{data.ipAddress as string}</Descriptions.Item>
<Descriptions.Item label="Маркировка">{data.marking as string}</Descriptions.Item>
<Descriptions.Item label="Группа">{data.group as string}</Descriptions.Item>
<Descriptions.Item label="Категория">{data.category as string}</Descriptions.Item>
<Descriptions.Item label="Статус"><StatusTag status={status} /></Descriptions.Item>
</Descriptions>
</div>
);
}
if (entityType === 'line') {
return (
<div style={{ padding: 12, background: '#fff', borderLeft: '1px solid #f0f0f0', height: '100%', overflow: 'auto' }}>
<Descriptions
title="Линия"
column={1}
size="small"
bordered
labelStyle={{ fontSize: 11, width: 110 }}
contentStyle={{ fontSize: 11 }}
>
<Descriptions.Item label="Название">{data.name as string}</Descriptions.Item>
<Descriptions.Item label="Среда">{data.medium as string}</Descriptions.Item>
<Descriptions.Item label="Тип линии">{data.lineStyle as string}</Descriptions.Item>
<Descriptions.Item label="Тип">{data.type as string}</Descriptions.Item>
<Descriptions.Item label="Статус"><StatusTag status={status} /></Descriptions.Item>
</Descriptions>
</div>
);
}
if (entityType === 'card') {
return (
<div style={{ padding: 12, background: '#fff', borderLeft: '1px solid #f0f0f0', height: '100%', overflow: 'auto' }}>
<Descriptions
title="Карта"
column={1}
size="small"
bordered
labelStyle={{ fontSize: 11, width: 110 }}
contentStyle={{ fontSize: 11 }}
>
<Descriptions.Item label="Слот">{data.slotName as string}</Descriptions.Item>
<Descriptions.Item label="Сетевое имя">{data.networkName as string}</Descriptions.Item>
<Descriptions.Item label="Статус"><StatusTag status={status} /></Descriptions.Item>
</Descriptions>
</div>
);
}
return (
<div style={{ padding: 12, background: '#fff', borderLeft: '1px solid #f0f0f0', height: '100%', overflow: 'auto' }}>
<Descriptions title="Объект" column={1} size="small" bordered>
{Object.entries(data).map(([key, value]) => (
<Descriptions.Item key={key} label={key}>
{String(value ?? '')}
</Descriptions.Item>
))}
</Descriptions>
</div>
);
}

View File

@ -0,0 +1,176 @@
import { Button, Slider, Space, Switch, Tooltip, message } from 'antd';
import {
ZoomInOutlined,
ZoomOutOutlined,
ExpandOutlined,
PlusOutlined,
DeleteOutlined,
ReloadOutlined,
PictureOutlined,
AppstoreOutlined,
NodeIndexOutlined,
EyeOutlined,
EditOutlined,
InfoCircleOutlined,
} from '@ant-design/icons';
import { useSchemaStore } from '../../store/schemaStore.ts';
export function Toolbar() {
const graph = useSchemaStore((s) => s.graph);
const mode = useSchemaStore((s) => s.mode);
const setMode = useSchemaStore((s) => s.setMode);
const displaySettings = useSchemaStore((s) => s.displaySettings);
const toggleGrid = useSchemaStore((s) => s.toggleGrid);
const toggleMinimap = useSchemaStore((s) => s.toggleMinimap);
const switchLineType = useSchemaStore((s) => s.switchLineType);
const toggleLabels = useSchemaStore((s) => s.toggleLabels);
const setLegendVisible = useSchemaStore((s) => s.setLegendVisible);
const zoom = graph ? Math.round(graph.zoom() * 100) : 100;
const handleZoomIn = () => graph?.zoom(0.1);
const handleZoomOut = () => graph?.zoom(-0.1);
const handleFit = () => graph?.zoomToFit({ padding: 40 });
const handleZoomChange = (value: number) => {
if (graph) {
graph.zoomTo(value / 100);
}
};
const handleExportPng = () => {
message.info('В разработке');
};
return (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '0 16px',
height: 48,
borderBottom: '1px solid #f0f0f0',
background: '#fff',
flexShrink: 0,
}}
>
{/* Left: display settings */}
<Space size="middle">
<Tooltip title="Сетка">
<Switch
size="small"
checked={displaySettings.showGrid}
onChange={toggleGrid}
checkedChildren={<AppstoreOutlined />}
unCheckedChildren={<AppstoreOutlined />}
/>
</Tooltip>
<Tooltip title="Мини-карта">
<Switch
size="small"
checked={displaySettings.showMinimap}
onChange={toggleMinimap}
/>
</Tooltip>
<Tooltip
title={
displaySettings.lineType === 'manhattan'
? 'Ломаные линии'
: 'Прямые линии'
}
>
<Button
size="small"
icon={<NodeIndexOutlined />}
onClick={switchLineType}
type={displaySettings.lineType === 'manhattan' ? 'primary' : 'default'}
/>
</Tooltip>
<Tooltip title="Подписи">
<Switch
size="small"
checked={displaySettings.showLabels}
onChange={toggleLabels}
/>
</Tooltip>
<Tooltip title="Легенда">
<Button
size="small"
icon={<InfoCircleOutlined />}
onClick={() => setLegendVisible(true)}
/>
</Tooltip>
</Space>
{/* Center: actions */}
<Space>
<Tooltip title="Добавить объект">
<Button
size="small"
icon={<PlusOutlined />}
onClick={() => message.info('В разработке')}
/>
</Tooltip>
<Tooltip title="Удалить">
<Button
size="small"
icon={<DeleteOutlined />}
onClick={() => {
if (graph) {
const cells = graph.getSelectedCells();
if (cells.length) graph.removeCells(cells);
}
}}
/>
</Tooltip>
<Tooltip title="Обновить раскладку">
<Button
size="small"
icon={<ReloadOutlined />}
onClick={() => message.info('В разработке')}
/>
</Tooltip>
<Tooltip title="Экспорт PNG">
<Button
size="small"
icon={<PictureOutlined />}
onClick={handleExportPng}
/>
</Tooltip>
</Space>
{/* Right: zoom + mode */}
<Space size="middle">
<Space size={4}>
<Tooltip title="Уменьшить">
<Button size="small" icon={<ZoomOutOutlined />} onClick={handleZoomOut} />
</Tooltip>
<Slider
style={{ width: 100 }}
min={10}
max={300}
value={zoom}
onChange={handleZoomChange}
tooltip={{ formatter: (v) => `${v}%` }}
/>
<Tooltip title="Увеличить">
<Button size="small" icon={<ZoomInOutlined />} onClick={handleZoomIn} />
</Tooltip>
<Tooltip title="Уместить на экран">
<Button size="small" icon={<ExpandOutlined />} onClick={handleFit} />
</Tooltip>
</Space>
<Tooltip title={mode === 'view' ? 'Режим просмотра' : 'Режим редактирования'}>
<Button
size="small"
type={mode === 'edit' ? 'primary' : 'default'}
icon={mode === 'view' ? <EyeOutlined /> : <EditOutlined />}
onClick={() => setMode(mode === 'view' ? 'edit' : 'view')}
>
{mode === 'view' ? 'Просмотр' : 'Редактирование'}
</Button>
</Tooltip>
</Space>
</div>
);
}