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