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:
26
frontend/src/App.tsx
Normal file
26
frontend/src/App.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import { ConfigProvider } from 'antd';
|
||||
import ruRU from 'antd/locale/ru_RU';
|
||||
import { AppLayout } from './components/AppLayout.tsx';
|
||||
import { Toolbar } from './components/Toolbar/Toolbar.tsx';
|
||||
import { LeftPanel } from './components/SidePanel/LeftPanel.tsx';
|
||||
import { RightPanel } from './components/SidePanel/RightPanel.tsx';
|
||||
import { ConnectionsPanel } from './components/ConnectionsPanel/ConnectionsPanel.tsx';
|
||||
import { LegendModal } from './components/Legend/LegendModal.tsx';
|
||||
import { SchemaCanvas } from './features/schema/SchemaCanvas.tsx';
|
||||
import { ContextMenu } from './features/schema/context-menu/ContextMenu.tsx';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<ConfigProvider locale={ruRU}>
|
||||
<AppLayout
|
||||
toolbar={<Toolbar />}
|
||||
leftPanel={<LeftPanel />}
|
||||
canvas={<SchemaCanvas />}
|
||||
rightPanel={<RightPanel />}
|
||||
bottomPanel={<ConnectionsPanel />}
|
||||
/>
|
||||
<ContextMenu />
|
||||
<LegendModal />
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
119
frontend/src/components/AppLayout.tsx
Normal file
119
frontend/src/components/AppLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
170
frontend/src/components/ConnectionsPanel/ConnectionsPanel.tsx
Normal file
170
frontend/src/components/ConnectionsPanel/ConnectionsPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
86
frontend/src/components/Legend/LegendModal.tsx
Normal file
86
frontend/src/components/Legend/LegendModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
134
frontend/src/components/SidePanel/RightPanel.tsx
Normal file
134
frontend/src/components/SidePanel/RightPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
176
frontend/src/components/Toolbar/Toolbar.tsx
Normal file
176
frontend/src/components/Toolbar/Toolbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
frontend/src/constants/layerMapping.ts
Normal file
40
frontend/src/constants/layerMapping.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { DeviceCategory } from '../types/index.ts';
|
||||
|
||||
export const LAYER_MAPPING: Record<number, DeviceCategory[]> = {
|
||||
1: [
|
||||
DeviceCategory.CrossOptical,
|
||||
DeviceCategory.RRL,
|
||||
DeviceCategory.Wireless,
|
||||
DeviceCategory.Satellite,
|
||||
],
|
||||
2: [DeviceCategory.TSPU, DeviceCategory.DWDM],
|
||||
3: [
|
||||
DeviceCategory.MEN,
|
||||
DeviceCategory.SDH,
|
||||
DeviceCategory.MultiservicePlatform,
|
||||
],
|
||||
4: [
|
||||
DeviceCategory.IP,
|
||||
DeviceCategory.OpticalModem,
|
||||
DeviceCategory.OpticalMux,
|
||||
DeviceCategory.LanWlan,
|
||||
],
|
||||
5: [
|
||||
DeviceCategory.RanController,
|
||||
DeviceCategory.MGN,
|
||||
DeviceCategory.MGX,
|
||||
DeviceCategory.Server,
|
||||
DeviceCategory.SORM,
|
||||
DeviceCategory.MOB,
|
||||
DeviceCategory.FIX,
|
||||
],
|
||||
6: [DeviceCategory.VOIP, DeviceCategory.xDSL, DeviceCategory.PDH],
|
||||
7: [
|
||||
DeviceCategory.RanBaseStation,
|
||||
DeviceCategory.Unknown,
|
||||
DeviceCategory.VideoSurveillance,
|
||||
],
|
||||
8: [DeviceCategory.CrossCopper],
|
||||
};
|
||||
|
||||
export const MAX_CROSS_PER_LAYER = 6;
|
||||
29
frontend/src/constants/lineStyles.ts
Normal file
29
frontend/src/constants/lineStyles.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { LineStyle, Medium } from '../types/index.ts';
|
||||
|
||||
export interface LineVisualStyle {
|
||||
strokeDasharray: string;
|
||||
strokeWidth: number;
|
||||
}
|
||||
|
||||
const LINE_STYLE_MAP: Record<LineStyle, string> = {
|
||||
[LineStyle.Solid]: '',
|
||||
[LineStyle.Dashed]: '8 4',
|
||||
[LineStyle.Dotted]: '2 4',
|
||||
};
|
||||
|
||||
const MEDIUM_WIDTH_MAP: Record<Medium, number> = {
|
||||
[Medium.Optical]: 2,
|
||||
[Medium.Copper]: 1.5,
|
||||
[Medium.Wireless]: 1.5,
|
||||
[Medium.Unknown]: 1,
|
||||
};
|
||||
|
||||
export function getLineVisualStyle(
|
||||
lineStyle: LineStyle,
|
||||
medium: Medium,
|
||||
): LineVisualStyle {
|
||||
return {
|
||||
strokeDasharray: LINE_STYLE_MAP[lineStyle],
|
||||
strokeWidth: MEDIUM_WIDTH_MAP[medium],
|
||||
};
|
||||
}
|
||||
24
frontend/src/constants/sizes.ts
Normal file
24
frontend/src/constants/sizes.ts
Normal file
@ -0,0 +1,24 @@
|
||||
export const SITE_HEADER_HEIGHT = 80;
|
||||
export const SITE_PADDING = 30;
|
||||
export const SITE_MIN_WIDTH = 400;
|
||||
export const SITE_MIN_HEIGHT = 200;
|
||||
|
||||
export const CROSS_WIDTH = 115;
|
||||
export const CROSS_HEIGHT = 345;
|
||||
export const CROSS_BORDER_RADIUS = 28;
|
||||
|
||||
export const SPLICE_SIZE = 98;
|
||||
export const SPLICE_BORDER_RADIUS = 6;
|
||||
|
||||
export const DEVICE_MIN_WIDTH = 140;
|
||||
export const DEVICE_MIN_HEIGHT = 80;
|
||||
export const DEVICE_BORDER_RADIUS = 6;
|
||||
|
||||
export const CARD_WIDTH = 100;
|
||||
export const CARD_HEIGHT = 40;
|
||||
|
||||
export const PORT_RADIUS = 6;
|
||||
|
||||
export const LAYER_GAP = 40;
|
||||
export const DEVICE_GAP = 30;
|
||||
export const LAYER_PADDING_X = 20;
|
||||
55
frontend/src/constants/statusColors.ts
Normal file
55
frontend/src/constants/statusColors.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import { EntityStatus } from '../types/index.ts';
|
||||
|
||||
export interface StatusColorSet {
|
||||
border: string;
|
||||
fill: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export const STATUS_COLORS: Record<EntityStatus, StatusColorSet> = {
|
||||
[EntityStatus.Active]: {
|
||||
border: '#52c41a',
|
||||
fill: '#f6ffed',
|
||||
text: '#389e0d',
|
||||
},
|
||||
[EntityStatus.Planned]: {
|
||||
border: '#1890ff',
|
||||
fill: '#e6f7ff',
|
||||
text: '#096dd9',
|
||||
},
|
||||
[EntityStatus.UnderConstruction]: {
|
||||
border: '#faad14',
|
||||
fill: '#fffbe6',
|
||||
text: '#d48806',
|
||||
},
|
||||
[EntityStatus.Reserved]: {
|
||||
border: '#722ed1',
|
||||
fill: '#f9f0ff',
|
||||
text: '#531dab',
|
||||
},
|
||||
[EntityStatus.Faulty]: {
|
||||
border: '#ff4d4f',
|
||||
fill: '#fff2f0',
|
||||
text: '#cf1322',
|
||||
},
|
||||
[EntityStatus.Decommissioned]: {
|
||||
border: '#8c8c8c',
|
||||
fill: '#fafafa',
|
||||
text: '#595959',
|
||||
},
|
||||
[EntityStatus.Unknown]: {
|
||||
border: '#bfbfbf',
|
||||
fill: '#fafafa',
|
||||
text: '#8c8c8c',
|
||||
},
|
||||
};
|
||||
|
||||
export const STATUS_LABELS: Record<EntityStatus, string> = {
|
||||
[EntityStatus.Active]: 'Активный',
|
||||
[EntityStatus.Planned]: 'Планируемый',
|
||||
[EntityStatus.UnderConstruction]: 'Строится',
|
||||
[EntityStatus.Reserved]: 'Резерв',
|
||||
[EntityStatus.Faulty]: 'Неисправный',
|
||||
[EntityStatus.Decommissioned]: 'Выведен',
|
||||
[EntityStatus.Unknown]: 'Неизвестно',
|
||||
};
|
||||
228
frontend/src/features/schema/SchemaCanvas.tsx
Normal file
228
frontend/src/features/schema/SchemaCanvas.tsx
Normal file
@ -0,0 +1,228 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useSchemaStore } from '../../store/schemaStore.ts';
|
||||
import { initGraph } from './graph/initGraph.ts';
|
||||
import { registerAllNodes } from './graph/registerNodes.ts';
|
||||
import { buildGraphData } from './helpers/dataMapper.ts';
|
||||
import { mockData } from '../../mock/schemaData.ts';
|
||||
import { DeviceGroup } from '../../types/index.ts';
|
||||
|
||||
let nodesRegistered = false;
|
||||
|
||||
export function SchemaCanvas() {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const minimapRef = useRef<HTMLDivElement>(null);
|
||||
const setGraph = useSchemaStore((state) => state.setGraph);
|
||||
const setContextMenu = useSchemaStore((state) => state.setContextMenu);
|
||||
const setRightPanelData = useSchemaStore((state) => state.setRightPanelData);
|
||||
const setConnectionsPanelData = useSchemaStore(
|
||||
(state) => state.setConnectionsPanelData,
|
||||
);
|
||||
const setConnectionsPanelVisible = useSchemaStore(
|
||||
(state) => state.setConnectionsPanelVisible,
|
||||
);
|
||||
const displaySettings = useSchemaStore((state) => state.displaySettings);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
if (!nodesRegistered) {
|
||||
registerAllNodes();
|
||||
nodesRegistered = true;
|
||||
}
|
||||
|
||||
const graph = initGraph(containerRef.current, minimapRef.current);
|
||||
setGraph(graph);
|
||||
|
||||
const { nodes, edges } = buildGraphData(mockData, displaySettings.lineType);
|
||||
|
||||
// Add nodes first (sites, then devices, then cards)
|
||||
const siteNodes = nodes.filter((n) => n.shape === 'site-node');
|
||||
const deviceNodes = nodes.filter(
|
||||
(n) => n.shape !== 'site-node' && n.shape !== 'card-node',
|
||||
);
|
||||
const cardNodes = nodes.filter((n) => n.shape === 'card-node');
|
||||
|
||||
for (const node of siteNodes) {
|
||||
graph.addNode(node);
|
||||
}
|
||||
for (const node of deviceNodes) {
|
||||
const graphNode = graph.addNode(node);
|
||||
// Set parent (embed in site)
|
||||
if (node.parent) {
|
||||
const parentNode = graph.getCellById(node.parent);
|
||||
if (parentNode) {
|
||||
parentNode.addChild(graphNode);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const node of cardNodes) {
|
||||
const graphNode = graph.addNode(node);
|
||||
if (node.parent) {
|
||||
const parentNode = graph.getCellById(node.parent);
|
||||
if (parentNode) {
|
||||
parentNode.addChild(graphNode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add edges
|
||||
for (const edge of edges) {
|
||||
graph.addEdge(edge);
|
||||
}
|
||||
|
||||
// Center content
|
||||
graph.centerContent();
|
||||
|
||||
// Event handlers
|
||||
graph.on('node:click', ({ node }) => {
|
||||
const data = node.getData() as Record<string, unknown>;
|
||||
setRightPanelData(data);
|
||||
});
|
||||
|
||||
graph.on('edge:click', ({ edge }) => {
|
||||
const data = edge.getData() as Record<string, unknown>;
|
||||
const line = mockData.lines.find((l) => l.id === edge.id);
|
||||
if (line) {
|
||||
setRightPanelData({
|
||||
entityType: 'line',
|
||||
entityId: line.id,
|
||||
name: line.name,
|
||||
status: line.status,
|
||||
medium: line.medium,
|
||||
lineStyle: line.lineStyle,
|
||||
type: line.type,
|
||||
});
|
||||
} else if (data) {
|
||||
setRightPanelData(data);
|
||||
}
|
||||
});
|
||||
|
||||
graph.on('node:contextmenu', ({ e, node }) => {
|
||||
e.preventDefault();
|
||||
const data = node.getData() as Record<string, unknown>;
|
||||
const entityType = data.entityType as string;
|
||||
|
||||
let menuType: 'site' | 'active-device' | 'passive-device' = 'active-device';
|
||||
if (entityType === 'site') {
|
||||
menuType = 'site';
|
||||
} else if (entityType === 'device') {
|
||||
const group = data.group as string;
|
||||
menuType = group === DeviceGroup.Passive ? 'passive-device' : 'active-device';
|
||||
}
|
||||
|
||||
setContextMenu({
|
||||
visible: true,
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
type: menuType,
|
||||
data: data,
|
||||
});
|
||||
});
|
||||
|
||||
graph.on('edge:contextmenu', ({ e, edge }) => {
|
||||
e.preventDefault();
|
||||
const line = mockData.lines.find((l) => l.id === edge.id);
|
||||
setContextMenu({
|
||||
visible: true,
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
type: 'line',
|
||||
data: line
|
||||
? {
|
||||
entityType: 'line',
|
||||
entityId: line.id,
|
||||
name: line.name,
|
||||
status: line.status,
|
||||
}
|
||||
: {},
|
||||
});
|
||||
});
|
||||
|
||||
graph.on('blank:contextmenu', ({ e }) => {
|
||||
e.preventDefault();
|
||||
setContextMenu({
|
||||
visible: true,
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
type: 'blank',
|
||||
data: {},
|
||||
});
|
||||
});
|
||||
|
||||
graph.on('blank:click', () => {
|
||||
setContextMenu(null);
|
||||
setRightPanelData(null);
|
||||
});
|
||||
|
||||
// Port side recalculation on node move
|
||||
graph.on('node:moved', () => {
|
||||
// In a full implementation, we'd recalculate port sides here
|
||||
});
|
||||
|
||||
// Show connections panel on edge double click
|
||||
graph.on('edge:dblclick', ({ edge }) => {
|
||||
const line = mockData.lines.find((l) => l.id === edge.id);
|
||||
if (line) {
|
||||
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;
|
||||
|
||||
setConnectionsPanelData({
|
||||
line,
|
||||
portA,
|
||||
portZ,
|
||||
deviceA: devA,
|
||||
deviceZ: devZ,
|
||||
});
|
||||
setConnectionsPanelVisible(true);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
graph.dispose();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Sync display settings
|
||||
useEffect(() => {
|
||||
const graph = useSchemaStore.getState().graph;
|
||||
if (!graph) return;
|
||||
|
||||
if (displaySettings.showGrid) {
|
||||
graph.showGrid();
|
||||
} else {
|
||||
graph.hideGrid();
|
||||
}
|
||||
}, [displaySettings.showGrid]);
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
/>
|
||||
<div
|
||||
ref={minimapRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 16,
|
||||
right: 16,
|
||||
width: 200,
|
||||
height: 150,
|
||||
border: '1px solid #d9d9d9',
|
||||
background: '#fff',
|
||||
borderRadius: 4,
|
||||
overflow: 'hidden',
|
||||
display: displaySettings.showMinimap ? 'block' : 'none',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
139
frontend/src/features/schema/context-menu/ContextMenu.tsx
Normal file
139
frontend/src/features/schema/context-menu/ContextMenu.tsx
Normal file
@ -0,0 +1,139 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Dropdown, message } from 'antd';
|
||||
import type { MenuProps } from 'antd';
|
||||
import { useContextMenu } from '../../../hooks/useContextMenu.ts';
|
||||
import { useSchemaStore } from '../../../store/schemaStore.ts';
|
||||
import { mockData } from '../../../mock/schemaData.ts';
|
||||
|
||||
export function ContextMenu() {
|
||||
const { contextMenu, hideMenu } = useContextMenu();
|
||||
const setRightPanelData = useSchemaStore((s) => s.setRightPanelData);
|
||||
const setConnectionsPanelVisible = useSchemaStore(
|
||||
(s) => s.setConnectionsPanelVisible,
|
||||
);
|
||||
const setConnectionsPanelData = useSchemaStore(
|
||||
(s) => s.setConnectionsPanelData,
|
||||
);
|
||||
const triggerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClick = () => hideMenu();
|
||||
document.addEventListener('click', handleClick);
|
||||
return () => document.removeEventListener('click', handleClick);
|
||||
}, [hideMenu]);
|
||||
|
||||
if (!contextMenu?.visible) return null;
|
||||
|
||||
const wip = () => message.info('В разработке');
|
||||
|
||||
const siteMenu: MenuProps['items'] = [
|
||||
{ key: 'view', label: 'Просмотр карточки', onClick: () => { setRightPanelData(contextMenu.data); hideMenu(); } },
|
||||
{ key: 'add-device', label: 'Добавить устройство', onClick: wip },
|
||||
{ key: 'edit', label: 'Редактировать', onClick: wip },
|
||||
{ type: 'divider' },
|
||||
{ key: 'delete', label: 'Удалить', danger: true, onClick: wip },
|
||||
];
|
||||
|
||||
const activeDeviceMenu: MenuProps['items'] = [
|
||||
{ key: 'view', label: 'Просмотр карточки', onClick: () => { setRightPanelData(contextMenu.data); hideMenu(); } },
|
||||
{ key: 'connections', label: 'Показать соединения', onClick: () => {
|
||||
const deviceId = contextMenu.data.entityId as string;
|
||||
const deviceLines = mockData.lines.filter((l) => {
|
||||
const portA = mockData.ports.find((p) => p.id === l.portAId);
|
||||
const portZ = mockData.ports.find((p) => p.id === l.portZId);
|
||||
return portA?.deviceId === deviceId || portZ?.deviceId === deviceId;
|
||||
});
|
||||
setConnectionsPanelData({ lines: deviceLines, deviceId });
|
||||
setConnectionsPanelVisible(true);
|
||||
hideMenu();
|
||||
}},
|
||||
{ key: 'create-line', label: 'Создать линию', onClick: wip },
|
||||
{ key: 'copy', label: 'Копировать', onClick: wip },
|
||||
{ key: 'move', label: 'Переместить на другой сайт', onClick: wip },
|
||||
{ type: 'divider' },
|
||||
{ key: 'delete', label: 'Удалить', danger: true, onClick: wip },
|
||||
];
|
||||
|
||||
const passiveDeviceMenu: MenuProps['items'] = [
|
||||
{ key: 'view', label: 'Просмотр карточки', onClick: () => { setRightPanelData(contextMenu.data); hideMenu(); } },
|
||||
{ key: 'connections', label: 'Показать соединения', onClick: () => {
|
||||
const deviceId = contextMenu.data.entityId as string;
|
||||
const deviceLines = mockData.lines.filter((l) => {
|
||||
const portA = mockData.ports.find((p) => p.id === l.portAId);
|
||||
const portZ = mockData.ports.find((p) => p.id === l.portZId);
|
||||
return portA?.deviceId === deviceId || portZ?.deviceId === deviceId;
|
||||
});
|
||||
setConnectionsPanelData({ lines: deviceLines, deviceId });
|
||||
setConnectionsPanelVisible(true);
|
||||
hideMenu();
|
||||
}},
|
||||
{ key: 'copy', label: 'Копировать', onClick: wip },
|
||||
{ type: 'divider' },
|
||||
{ key: 'delete', label: 'Удалить', danger: true, onClick: wip },
|
||||
];
|
||||
|
||||
const lineMenu: MenuProps['items'] = [
|
||||
{ key: 'view', label: 'Просмотр карточки', onClick: () => { setRightPanelData(contextMenu.data); hideMenu(); } },
|
||||
{ key: 'connections', label: 'Показать соединения', onClick: () => {
|
||||
const lineId = contextMenu.data.entityId as string;
|
||||
const line = mockData.lines.find((l) => l.id === lineId);
|
||||
if (line) {
|
||||
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;
|
||||
setConnectionsPanelData({ line, portA, portZ, deviceA: devA, deviceZ: devZ });
|
||||
setConnectionsPanelVisible(true);
|
||||
}
|
||||
hideMenu();
|
||||
}},
|
||||
{ key: 'break', label: 'Разорвать линию', onClick: wip },
|
||||
{ key: 'copy', label: 'Копировать', onClick: wip },
|
||||
{ type: 'divider' },
|
||||
{ key: 'delete', label: 'Удалить', danger: true, onClick: wip },
|
||||
];
|
||||
|
||||
const blankMenu: MenuProps['items'] = [
|
||||
{ key: 'add-device', label: 'Добавить устройство', onClick: wip },
|
||||
{ key: 'create-line', label: 'Создать линию', onClick: wip },
|
||||
{ key: 'paste', label: 'Вставить', onClick: wip },
|
||||
{ type: 'divider' },
|
||||
{ key: 'fit', label: 'Уместить на экран', onClick: () => {
|
||||
const graph = useSchemaStore.getState().graph;
|
||||
graph?.zoomToFit({ padding: 40 });
|
||||
hideMenu();
|
||||
}},
|
||||
];
|
||||
|
||||
const menuMap: Record<string, MenuProps['items']> = {
|
||||
'site': siteMenu,
|
||||
'active-device': activeDeviceMenu,
|
||||
'passive-device': passiveDeviceMenu,
|
||||
'line': lineMenu,
|
||||
'line-group': lineMenu,
|
||||
'blank': blankMenu,
|
||||
};
|
||||
|
||||
const items = menuMap[contextMenu.type] ?? blankMenu;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={triggerRef}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: contextMenu.x,
|
||||
top: contextMenu.y,
|
||||
zIndex: 1000,
|
||||
}}
|
||||
>
|
||||
<Dropdown
|
||||
menu={{ items }}
|
||||
open={true}
|
||||
onOpenChange={(open) => { if (!open) hideMenu(); }}
|
||||
trigger={['contextMenu']}
|
||||
>
|
||||
<div style={{ width: 1, height: 1 }} />
|
||||
</Dropdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
76
frontend/src/features/schema/edges/edgeConfig.ts
Normal file
76
frontend/src/features/schema/edges/edgeConfig.ts
Normal file
@ -0,0 +1,76 @@
|
||||
import type { EntityStatus } from '../../../types/index.ts';
|
||||
import { Medium, LineStyle } from '../../../types/index.ts';
|
||||
import { STATUS_COLORS } from '../../../constants/statusColors.ts';
|
||||
import { getLineVisualStyle } from '../../../constants/lineStyles.ts';
|
||||
import type { GraphEdgeConfig } from '../../../types/graph.ts';
|
||||
|
||||
export function createEdgeConfig(
|
||||
id: string,
|
||||
sourceCell: string,
|
||||
sourcePort: string,
|
||||
targetCell: string,
|
||||
targetPort: string,
|
||||
status: EntityStatus,
|
||||
medium: Medium,
|
||||
lineStyle: LineStyle,
|
||||
label: string,
|
||||
routerType: 'manhattan' | 'normal' = 'manhattan',
|
||||
): GraphEdgeConfig {
|
||||
const colors = STATUS_COLORS[status];
|
||||
const visualStyle = getLineVisualStyle(lineStyle, medium);
|
||||
|
||||
const router =
|
||||
routerType === 'manhattan'
|
||||
? { name: 'manhattan' as const, args: { padding: 20 } }
|
||||
: { name: 'normal' as const };
|
||||
|
||||
return {
|
||||
id,
|
||||
source: { cell: sourceCell, port: sourcePort },
|
||||
target: { cell: targetCell, port: targetPort },
|
||||
router,
|
||||
connector: { name: 'rounded', args: { radius: 8 } },
|
||||
attrs: {
|
||||
line: {
|
||||
stroke: colors.border,
|
||||
strokeWidth: visualStyle.strokeWidth,
|
||||
strokeDasharray: visualStyle.strokeDasharray || undefined,
|
||||
targetMarker: null,
|
||||
sourceMarker: null,
|
||||
},
|
||||
wrap: {
|
||||
fill: 'none',
|
||||
stroke: 'transparent',
|
||||
strokeWidth: 20,
|
||||
},
|
||||
},
|
||||
labels: label
|
||||
? [
|
||||
{
|
||||
attrs: {
|
||||
label: {
|
||||
text: label,
|
||||
fontSize: 9,
|
||||
fill: '#595959',
|
||||
textAnchor: 'middle',
|
||||
textVerticalAnchor: 'middle',
|
||||
},
|
||||
rect: {
|
||||
ref: 'label',
|
||||
fill: '#fff',
|
||||
rx: 3,
|
||||
ry: 3,
|
||||
refWidth: '140%',
|
||||
refHeight: '140%',
|
||||
refX: '-20%',
|
||||
refY: '-20%',
|
||||
},
|
||||
},
|
||||
position: { distance: 0.5 },
|
||||
},
|
||||
]
|
||||
: [],
|
||||
data: { status, medium, lineStyle, label },
|
||||
zIndex: 5,
|
||||
};
|
||||
}
|
||||
76
frontend/src/features/schema/edges/edgeGrouping.ts
Normal file
76
frontend/src/features/schema/edges/edgeGrouping.ts
Normal file
@ -0,0 +1,76 @@
|
||||
import type { Line, Port, Device, SchemaData } from '../../../types/index.ts';
|
||||
import { STATUS_COLORS, STATUS_LABELS } from '../../../constants/statusColors.ts';
|
||||
|
||||
export interface LineGroup {
|
||||
key: string;
|
||||
deviceAId: string;
|
||||
deviceZId: string;
|
||||
lines: Line[];
|
||||
representativeLine: Line;
|
||||
count: number;
|
||||
}
|
||||
|
||||
function getDeviceIdForPort(
|
||||
portId: string,
|
||||
data: SchemaData,
|
||||
): string | null {
|
||||
const port = data.ports.find((p: Port) => p.id === portId);
|
||||
return port ? port.deviceId : null;
|
||||
}
|
||||
|
||||
export function groupLinesByDevicePair(data: SchemaData): LineGroup[] {
|
||||
const groupMap = new Map<string, Line[]>();
|
||||
|
||||
for (const line of data.lines) {
|
||||
const devA = getDeviceIdForPort(line.portAId, data);
|
||||
const devZ = getDeviceIdForPort(line.portZId, data);
|
||||
if (!devA || !devZ) continue;
|
||||
|
||||
const key = [devA, devZ].sort().join('::');
|
||||
const existing = groupMap.get(key);
|
||||
if (existing) {
|
||||
existing.push(line);
|
||||
} else {
|
||||
groupMap.set(key, [line]);
|
||||
}
|
||||
}
|
||||
|
||||
const groups: LineGroup[] = [];
|
||||
for (const [key, lines] of groupMap.entries()) {
|
||||
const [deviceAId, deviceZId] = key.split('::');
|
||||
groups.push({
|
||||
key,
|
||||
deviceAId,
|
||||
deviceZId,
|
||||
lines,
|
||||
representativeLine: lines[0],
|
||||
count: lines.length,
|
||||
});
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
export function getGroupTooltip(
|
||||
group: LineGroup,
|
||||
devices: Device[],
|
||||
): string {
|
||||
const devA = devices.find((d) => d.id === group.deviceAId);
|
||||
const devZ = devices.find((d) => d.id === group.deviceZId);
|
||||
|
||||
const statusCounts = new Map<string, number>();
|
||||
for (const line of group.lines) {
|
||||
const current = statusCounts.get(line.status) ?? 0;
|
||||
statusCounts.set(line.status, current + 1);
|
||||
}
|
||||
|
||||
let tooltip = `${devA?.name ?? '?'} — ${devZ?.name ?? '?'}\n`;
|
||||
tooltip += `Линий: ${group.count}\n`;
|
||||
for (const [status, count] of statusCounts.entries()) {
|
||||
const color = STATUS_COLORS[status as keyof typeof STATUS_COLORS];
|
||||
const label = STATUS_LABELS[status as keyof typeof STATUS_LABELS];
|
||||
tooltip += ` ${label}: ${count} (${color.border})\n`;
|
||||
}
|
||||
|
||||
return tooltip;
|
||||
}
|
||||
207
frontend/src/features/schema/graph/initGraph.ts
Normal file
207
frontend/src/features/schema/graph/initGraph.ts
Normal file
@ -0,0 +1,207 @@
|
||||
import { Graph } from '@antv/x6';
|
||||
import { Selection } from '@antv/x6-plugin-selection';
|
||||
import { Snapline } from '@antv/x6-plugin-snapline';
|
||||
import { MiniMap } from '@antv/x6-plugin-minimap';
|
||||
import { Keyboard } from '@antv/x6-plugin-keyboard';
|
||||
import { Clipboard } from '@antv/x6-plugin-clipboard';
|
||||
import { History } from '@antv/x6-plugin-history';
|
||||
|
||||
|
||||
export function initGraph(
|
||||
container: HTMLDivElement,
|
||||
minimapContainer: HTMLDivElement | null,
|
||||
): Graph {
|
||||
const graph = new Graph({
|
||||
container,
|
||||
autoResize: true,
|
||||
background: { color: '#f8f9fa' },
|
||||
grid: {
|
||||
visible: true,
|
||||
size: 10,
|
||||
type: 'dot',
|
||||
args: { color: '#d9d9d9', thickness: 1 },
|
||||
},
|
||||
panning: { enabled: true, eventTypes: ['rightMouseDown'] },
|
||||
mousewheel: {
|
||||
enabled: true,
|
||||
modifiers: ['ctrl', 'meta'],
|
||||
minScale: 0.1,
|
||||
maxScale: 3,
|
||||
},
|
||||
connecting: {
|
||||
snap: { radius: 30 },
|
||||
allowBlank: false,
|
||||
allowLoop: true,
|
||||
allowNode: false,
|
||||
allowEdge: false,
|
||||
allowMulti: true,
|
||||
allowPort: true,
|
||||
highlight: true,
|
||||
router: { name: 'manhattan' },
|
||||
connector: { name: 'rounded', args: { radius: 8 } },
|
||||
connectionPoint: 'anchor',
|
||||
anchor: 'center',
|
||||
sourceAnchor: 'center',
|
||||
targetAnchor: 'center',
|
||||
createEdge() {
|
||||
return this.createEdge({
|
||||
attrs: {
|
||||
line: {
|
||||
stroke: '#52c41a',
|
||||
strokeWidth: 2,
|
||||
targetMarker: null,
|
||||
sourceMarker: null,
|
||||
},
|
||||
},
|
||||
router: { name: 'manhattan' },
|
||||
connector: { name: 'rounded', args: { radius: 8 } },
|
||||
});
|
||||
},
|
||||
validateConnection({ sourcePort, targetPort, sourceMagnet, targetMagnet }) {
|
||||
if (!sourceMagnet || !targetMagnet) return false;
|
||||
if (!sourcePort || !targetPort) return false;
|
||||
return sourcePort !== targetPort;
|
||||
},
|
||||
},
|
||||
embedding: {
|
||||
enabled: true,
|
||||
findParent({ node }) {
|
||||
// Only site nodes can be parents
|
||||
const bbox = node.getBBox();
|
||||
return this.getNodes().filter((n) => {
|
||||
if (n.id === node.id) return false;
|
||||
const data = n.getData<{ entityType?: string }>();
|
||||
if (data?.entityType !== 'site') return false;
|
||||
return n.getBBox().containsRect(bbox);
|
||||
});
|
||||
},
|
||||
},
|
||||
interacting: {
|
||||
nodeMovable: true,
|
||||
edgeMovable: true,
|
||||
edgeLabelMovable: true,
|
||||
vertexMovable: true,
|
||||
vertexAddable: true,
|
||||
vertexDeletable: true,
|
||||
},
|
||||
highlighting: {
|
||||
magnetAvailable: {
|
||||
name: 'stroke',
|
||||
args: { attrs: { fill: '#5F95FF', stroke: '#5F95FF' } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Show tools on edge hover: segments, vertices, and arrowheads for reconnecting
|
||||
graph.on('edge:mouseenter', ({ edge }) => {
|
||||
edge.addTools([
|
||||
{
|
||||
name: 'segments',
|
||||
args: {
|
||||
snapRadius: 20,
|
||||
attrs: {
|
||||
fill: '#444',
|
||||
width: 20,
|
||||
height: 8,
|
||||
rx: 4,
|
||||
ry: 4,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'vertices',
|
||||
args: {
|
||||
attrs: {
|
||||
r: 5,
|
||||
fill: '#fff',
|
||||
stroke: '#333',
|
||||
strokeWidth: 1,
|
||||
cursor: 'move',
|
||||
},
|
||||
snapRadius: 20,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'source-arrowhead',
|
||||
args: {
|
||||
attrs: {
|
||||
d: 'M 0 -5 L 10 0 L 0 5 Z',
|
||||
fill: '#333',
|
||||
stroke: '#fff',
|
||||
strokeWidth: 1,
|
||||
cursor: 'move',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'target-arrowhead',
|
||||
args: {
|
||||
attrs: {
|
||||
d: 'M 0 -5 L 10 0 L 0 5 Z',
|
||||
fill: '#333',
|
||||
stroke: '#fff',
|
||||
strokeWidth: 1,
|
||||
cursor: 'move',
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
graph.on('edge:mouseleave', ({ edge }) => {
|
||||
// Don't remove tools if edge is being dragged
|
||||
if (edge.hasTool('source-arrowhead') || edge.hasTool('target-arrowhead')) {
|
||||
// Delay removal to avoid removing during drag
|
||||
setTimeout(() => {
|
||||
if (!document.querySelector('.x6-widget-transform')) {
|
||||
edge.removeTools();
|
||||
}
|
||||
}, 200);
|
||||
} else {
|
||||
edge.removeTools();
|
||||
}
|
||||
});
|
||||
|
||||
graph.use(
|
||||
new Selection({
|
||||
enabled: true,
|
||||
multiple: true,
|
||||
rubberband: true,
|
||||
movable: true,
|
||||
showNodeSelectionBox: true,
|
||||
}),
|
||||
);
|
||||
|
||||
graph.use(new Snapline({ enabled: true }));
|
||||
|
||||
if (minimapContainer) {
|
||||
graph.use(
|
||||
new MiniMap({
|
||||
container: minimapContainer,
|
||||
width: 200,
|
||||
height: 150,
|
||||
padding: 10,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
graph.use(new Keyboard({ enabled: true }));
|
||||
graph.use(new Clipboard({ enabled: true }));
|
||||
graph.use(new History({ enabled: true }));
|
||||
|
||||
// Keyboard shortcuts
|
||||
graph.bindKey('ctrl+z', () => graph.undo());
|
||||
graph.bindKey('ctrl+shift+z', () => graph.redo());
|
||||
graph.bindKey('ctrl+c', () => graph.copy(graph.getSelectedCells()));
|
||||
graph.bindKey('ctrl+v', () => graph.paste());
|
||||
graph.bindKey('delete', () => {
|
||||
const cells = graph.getSelectedCells();
|
||||
if (cells.length) graph.removeCells(cells);
|
||||
});
|
||||
graph.bindKey('ctrl+a', () => {
|
||||
const cells = graph.getCells();
|
||||
graph.select(cells);
|
||||
});
|
||||
|
||||
return graph;
|
||||
}
|
||||
64
frontend/src/features/schema/graph/registerNodes.ts
Normal file
64
frontend/src/features/schema/graph/registerNodes.ts
Normal file
@ -0,0 +1,64 @@
|
||||
import { register } from '@antv/x6-react-shape';
|
||||
import { SiteNode } from '../nodes/SiteNode.tsx';
|
||||
import { CrossDeviceNode } from '../nodes/CrossDeviceNode.tsx';
|
||||
import { SpliceNode } from '../nodes/SpliceNode.tsx';
|
||||
import { DeviceNode } from '../nodes/DeviceNode.tsx';
|
||||
import { CardNode } from '../nodes/CardNode.tsx';
|
||||
import {
|
||||
CROSS_WIDTH,
|
||||
CROSS_HEIGHT,
|
||||
SPLICE_SIZE,
|
||||
DEVICE_MIN_WIDTH,
|
||||
DEVICE_MIN_HEIGHT,
|
||||
CARD_WIDTH,
|
||||
CARD_HEIGHT,
|
||||
SITE_MIN_WIDTH,
|
||||
SITE_MIN_HEIGHT,
|
||||
} from '../../../constants/sizes.ts';
|
||||
import { portGroups } from '../ports/portConfig.ts';
|
||||
|
||||
export function registerAllNodes(): void {
|
||||
register({
|
||||
shape: 'site-node',
|
||||
width: SITE_MIN_WIDTH,
|
||||
height: SITE_MIN_HEIGHT,
|
||||
component: SiteNode,
|
||||
effect: ['data'],
|
||||
});
|
||||
|
||||
register({
|
||||
shape: 'cross-device-node',
|
||||
width: CROSS_WIDTH,
|
||||
height: CROSS_HEIGHT,
|
||||
component: CrossDeviceNode,
|
||||
ports: { groups: portGroups },
|
||||
effect: ['data'],
|
||||
});
|
||||
|
||||
register({
|
||||
shape: 'splice-node',
|
||||
width: SPLICE_SIZE,
|
||||
height: SPLICE_SIZE,
|
||||
component: SpliceNode,
|
||||
ports: { groups: portGroups },
|
||||
effect: ['data'],
|
||||
});
|
||||
|
||||
register({
|
||||
shape: 'device-node',
|
||||
width: DEVICE_MIN_WIDTH,
|
||||
height: DEVICE_MIN_HEIGHT,
|
||||
component: DeviceNode,
|
||||
ports: { groups: portGroups },
|
||||
effect: ['data'],
|
||||
});
|
||||
|
||||
register({
|
||||
shape: 'card-node',
|
||||
width: CARD_WIDTH,
|
||||
height: CARD_HEIGHT,
|
||||
component: CardNode,
|
||||
ports: { groups: portGroups },
|
||||
effect: ['data'],
|
||||
});
|
||||
}
|
||||
283
frontend/src/features/schema/helpers/dataMapper.ts
Normal file
283
frontend/src/features/schema/helpers/dataMapper.ts
Normal file
@ -0,0 +1,283 @@
|
||||
import type { SchemaData } from '../../../types/index.ts';
|
||||
import { DeviceCategory } from '../../../types/index.ts';
|
||||
import type { GraphNodeConfig, GraphEdgeConfig, GraphBuildResult } from '../../../types/graph.ts';
|
||||
import { createPortItem } from '../ports/portConfig.ts';
|
||||
import { createEdgeConfig } from '../edges/edgeConfig.ts';
|
||||
import { autoLayout, type LayoutResult } from '../layout/autoLayout.ts';
|
||||
import { resolvePortSides } from './portSideResolver.ts';
|
||||
import {
|
||||
CROSS_WIDTH,
|
||||
CROSS_HEIGHT,
|
||||
SPLICE_SIZE,
|
||||
DEVICE_MIN_WIDTH,
|
||||
DEVICE_MIN_HEIGHT,
|
||||
CARD_WIDTH,
|
||||
CARD_HEIGHT,
|
||||
} from '../../../constants/sizes.ts';
|
||||
|
||||
function getDeviceShape(category: DeviceCategory, deviceName: string, marking: string): string {
|
||||
if (
|
||||
category === DeviceCategory.CrossOptical ||
|
||||
category === DeviceCategory.CrossCopper
|
||||
) {
|
||||
return 'cross-device-node';
|
||||
}
|
||||
if (
|
||||
deviceName.toLowerCase().includes('муфта') ||
|
||||
marking.toLowerCase().includes('мток')
|
||||
) {
|
||||
return 'splice-node';
|
||||
}
|
||||
return 'device-node';
|
||||
}
|
||||
|
||||
function getDeviceSize(
|
||||
category: DeviceCategory,
|
||||
deviceName: string,
|
||||
marking: string,
|
||||
portCount: number,
|
||||
): { width: number; height: number } {
|
||||
if (
|
||||
category === DeviceCategory.CrossOptical ||
|
||||
category === DeviceCategory.CrossCopper
|
||||
) {
|
||||
return { width: CROSS_WIDTH, height: CROSS_HEIGHT };
|
||||
}
|
||||
if (
|
||||
deviceName.toLowerCase().includes('муфта') ||
|
||||
marking.toLowerCase().includes('мток')
|
||||
) {
|
||||
return { width: SPLICE_SIZE, height: SPLICE_SIZE };
|
||||
}
|
||||
const portHeight = Math.max(portCount * 22, 60);
|
||||
return {
|
||||
width: DEVICE_MIN_WIDTH,
|
||||
height: Math.max(DEVICE_MIN_HEIGHT, portHeight + 30),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildGraphData(
|
||||
data: SchemaData,
|
||||
routerType: 'manhattan' | 'normal' = 'manhattan',
|
||||
): GraphBuildResult {
|
||||
const layout: LayoutResult = autoLayout(data);
|
||||
const nodes: GraphNodeConfig[] = [];
|
||||
const edges: GraphEdgeConfig[] = [];
|
||||
|
||||
// Resolve port sides based on device positions
|
||||
const devicePositions = new Map<string, { x: number; y: number }>();
|
||||
for (const [id, pos] of layout.nodePositions.entries()) {
|
||||
devicePositions.set(id, { x: pos.x, y: pos.y });
|
||||
}
|
||||
const portSideMap = resolvePortSides(data, devicePositions);
|
||||
|
||||
// 1. Create site nodes
|
||||
for (const site of data.sites) {
|
||||
const sitePos = layout.sitePositions.get(site.id);
|
||||
if (!sitePos) continue;
|
||||
|
||||
nodes.push({
|
||||
id: site.id,
|
||||
shape: 'site-node',
|
||||
x: sitePos.x,
|
||||
y: sitePos.y,
|
||||
width: sitePos.width,
|
||||
height: sitePos.height,
|
||||
data: {
|
||||
name: site.name,
|
||||
address: site.address,
|
||||
erpCode: site.erpCode,
|
||||
code1C: site.code1C,
|
||||
status: site.status,
|
||||
entityType: 'site',
|
||||
entityId: site.id,
|
||||
},
|
||||
zIndex: 1,
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Create device nodes with ports
|
||||
for (const device of data.devices) {
|
||||
const pos = layout.nodePositions.get(device.id);
|
||||
if (!pos) continue;
|
||||
|
||||
const devicePorts = data.ports.filter(
|
||||
(p) => p.deviceId === device.id && !p.cardId,
|
||||
);
|
||||
const shape = getDeviceShape(device.category, device.name, device.marking);
|
||||
const size = getDeviceSize(device.category, device.name, device.marking, devicePorts.length);
|
||||
|
||||
const portItems = devicePorts.map((port) => {
|
||||
const resolvedSide = portSideMap.get(port.id) ?? port.side;
|
||||
const label = port.slotName ? `${port.slotName}:${port.name}` : port.name;
|
||||
return createPortItem(port.id, resolvedSide, label, port.labelColor || undefined);
|
||||
});
|
||||
|
||||
const node: GraphNodeConfig = {
|
||||
id: device.id,
|
||||
shape,
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
data: {
|
||||
name: device.name,
|
||||
networkName: device.networkName,
|
||||
ipAddress: device.ipAddress,
|
||||
marking: device.marking,
|
||||
id1: device.id1,
|
||||
id2: device.id2,
|
||||
group: device.group,
|
||||
category: device.category,
|
||||
status: device.status,
|
||||
entityType: 'device',
|
||||
entityId: device.id,
|
||||
},
|
||||
ports: {
|
||||
groups: {
|
||||
left: {
|
||||
position: 'left',
|
||||
attrs: {
|
||||
circle: {
|
||||
r: 6,
|
||||
magnet: true,
|
||||
stroke: '#8c8c8c',
|
||||
strokeWidth: 1,
|
||||
fill: '#fff',
|
||||
},
|
||||
},
|
||||
label: {
|
||||
position: { name: 'left', args: { x: -8 } },
|
||||
},
|
||||
},
|
||||
right: {
|
||||
position: 'right',
|
||||
attrs: {
|
||||
circle: {
|
||||
r: 6,
|
||||
magnet: true,
|
||||
stroke: '#8c8c8c',
|
||||
strokeWidth: 1,
|
||||
fill: '#fff',
|
||||
},
|
||||
},
|
||||
label: {
|
||||
position: { name: 'right', args: { x: 8 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
items: portItems,
|
||||
},
|
||||
parent: device.siteId,
|
||||
zIndex: 2,
|
||||
};
|
||||
|
||||
nodes.push(node);
|
||||
}
|
||||
|
||||
// 3. Create card nodes (embedded inside devices)
|
||||
for (const card of data.cards) {
|
||||
if (!card.visible) continue;
|
||||
|
||||
const parentDevice = data.devices.find((d) => d.id === card.deviceId);
|
||||
if (!parentDevice) continue;
|
||||
|
||||
const parentPos = layout.nodePositions.get(card.deviceId);
|
||||
if (!parentPos) continue;
|
||||
|
||||
const cardPorts = data.ports.filter((p) => p.cardId === card.id);
|
||||
const cardIndex = data.cards.filter(
|
||||
(c) => c.deviceId === card.deviceId && c.visible,
|
||||
).indexOf(card);
|
||||
|
||||
const cardX = parentPos.x + 10;
|
||||
const cardY = parentPos.y + 40 + cardIndex * (CARD_HEIGHT + 6);
|
||||
|
||||
const portItems = cardPorts.map((port) => {
|
||||
const resolvedSide = portSideMap.get(port.id) ?? port.side;
|
||||
const label = `${port.slotName}:${port.name}`;
|
||||
return createPortItem(port.id, resolvedSide, label, port.labelColor || undefined);
|
||||
});
|
||||
|
||||
nodes.push({
|
||||
id: card.id,
|
||||
shape: 'card-node',
|
||||
x: cardX,
|
||||
y: cardY,
|
||||
width: CARD_WIDTH,
|
||||
height: CARD_HEIGHT,
|
||||
data: {
|
||||
slotName: card.slotName,
|
||||
networkName: card.networkName,
|
||||
status: card.status,
|
||||
entityType: 'card',
|
||||
entityId: card.id,
|
||||
},
|
||||
ports: {
|
||||
groups: {
|
||||
left: {
|
||||
position: 'left',
|
||||
attrs: {
|
||||
circle: {
|
||||
r: 5,
|
||||
magnet: true,
|
||||
stroke: '#8c8c8c',
|
||||
strokeWidth: 1,
|
||||
fill: '#fff',
|
||||
},
|
||||
},
|
||||
label: {
|
||||
position: { name: 'left', args: { x: -6 } },
|
||||
},
|
||||
},
|
||||
right: {
|
||||
position: 'right',
|
||||
attrs: {
|
||||
circle: {
|
||||
r: 5,
|
||||
magnet: true,
|
||||
stroke: '#8c8c8c',
|
||||
strokeWidth: 1,
|
||||
fill: '#fff',
|
||||
},
|
||||
},
|
||||
label: {
|
||||
position: { name: 'right', args: { x: 6 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
items: portItems,
|
||||
},
|
||||
parent: card.deviceId,
|
||||
zIndex: 3,
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Create edges from lines
|
||||
for (const line of data.lines) {
|
||||
const portA = data.ports.find((p) => p.id === line.portAId);
|
||||
const portZ = data.ports.find((p) => p.id === line.portZId);
|
||||
if (!portA || !portZ) continue;
|
||||
|
||||
// Determine source and target cells
|
||||
const sourceCellId = portA.cardId ?? portA.deviceId;
|
||||
const targetCellId = portZ.cardId ?? portZ.deviceId;
|
||||
|
||||
edges.push(
|
||||
createEdgeConfig(
|
||||
line.id,
|
||||
sourceCellId,
|
||||
line.portAId,
|
||||
targetCellId,
|
||||
line.portZId,
|
||||
line.status,
|
||||
line.medium,
|
||||
line.lineStyle,
|
||||
line.name,
|
||||
routerType,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return { nodes, edges };
|
||||
}
|
||||
64
frontend/src/features/schema/helpers/portSideResolver.ts
Normal file
64
frontend/src/features/schema/helpers/portSideResolver.ts
Normal file
@ -0,0 +1,64 @@
|
||||
import type { SchemaData, Port } from '../../../types/index.ts';
|
||||
import { DeviceCategory } from '../../../types/index.ts';
|
||||
|
||||
interface DevicePosition {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export function resolvePortSides(
|
||||
data: SchemaData,
|
||||
devicePositions: Map<string, DevicePosition>,
|
||||
): Map<string, 'left' | 'right'> {
|
||||
const portSideMap = new Map<string, 'left' | 'right'>();
|
||||
const device = (id: string) => data.devices.find((d) => d.id === id);
|
||||
|
||||
for (const port of data.ports) {
|
||||
const dev = device(port.deviceId);
|
||||
if (!dev) {
|
||||
portSideMap.set(port.id, port.side);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Cross devices: L ports → left, S ports → right (hardcoded)
|
||||
if (
|
||||
dev.category === DeviceCategory.CrossOptical ||
|
||||
dev.category === DeviceCategory.CrossCopper
|
||||
) {
|
||||
const side = port.slotName === 'L' || port.name.startsWith('L') ? 'left' : 'right';
|
||||
portSideMap.set(port.id, side);
|
||||
continue;
|
||||
}
|
||||
|
||||
// For other devices, determine side based on connected device position
|
||||
const connectedLine = data.lines.find(
|
||||
(l) => l.portAId === port.id || l.portZId === port.id,
|
||||
);
|
||||
|
||||
if (!connectedLine) {
|
||||
portSideMap.set(port.id, port.side);
|
||||
continue;
|
||||
}
|
||||
|
||||
const otherPortId =
|
||||
connectedLine.portAId === port.id
|
||||
? connectedLine.portZId
|
||||
: connectedLine.portAId;
|
||||
const otherPort = data.ports.find((p: Port) => p.id === otherPortId);
|
||||
if (!otherPort) {
|
||||
portSideMap.set(port.id, port.side);
|
||||
continue;
|
||||
}
|
||||
|
||||
const thisPos = devicePositions.get(port.deviceId);
|
||||
const otherPos = devicePositions.get(otherPort.deviceId);
|
||||
|
||||
if (thisPos && otherPos) {
|
||||
portSideMap.set(port.id, otherPos.x < thisPos.x ? 'left' : 'right');
|
||||
} else {
|
||||
portSideMap.set(port.id, port.side);
|
||||
}
|
||||
}
|
||||
|
||||
return portSideMap;
|
||||
}
|
||||
193
frontend/src/features/schema/layout/autoLayout.ts
Normal file
193
frontend/src/features/schema/layout/autoLayout.ts
Normal file
@ -0,0 +1,193 @@
|
||||
import type { Device, Site, SchemaData } from '../../../types/index.ts';
|
||||
import { DeviceCategory } from '../../../types/index.ts';
|
||||
import { LAYER_MAPPING, MAX_CROSS_PER_LAYER } from '../../../constants/layerMapping.ts';
|
||||
import {
|
||||
CROSS_WIDTH,
|
||||
CROSS_HEIGHT,
|
||||
SPLICE_SIZE,
|
||||
DEVICE_MIN_WIDTH,
|
||||
DEVICE_MIN_HEIGHT,
|
||||
SITE_HEADER_HEIGHT,
|
||||
SITE_PADDING,
|
||||
SITE_MIN_WIDTH,
|
||||
LAYER_GAP,
|
||||
DEVICE_GAP,
|
||||
LAYER_PADDING_X,
|
||||
} from '../../../constants/sizes.ts';
|
||||
|
||||
export interface LayoutResult {
|
||||
nodePositions: Map<string, { x: number; y: number; width: number; height: number }>;
|
||||
sitePositions: Map<string, { x: number; y: number; width: number; height: number }>;
|
||||
}
|
||||
|
||||
function getDeviceSize(device: Device, portCount: number): { width: number; height: number } {
|
||||
if (
|
||||
device.category === DeviceCategory.CrossOptical ||
|
||||
device.category === DeviceCategory.CrossCopper
|
||||
) {
|
||||
return { width: CROSS_WIDTH, height: CROSS_HEIGHT };
|
||||
}
|
||||
|
||||
// Splice device detection by name/marking
|
||||
if (device.name.toLowerCase().includes('муфта') || device.marking.toLowerCase().includes('мток')) {
|
||||
return { width: SPLICE_SIZE, height: SPLICE_SIZE };
|
||||
}
|
||||
|
||||
// Dynamic height based on port count
|
||||
const portHeight = Math.max(portCount * 22, 60);
|
||||
return {
|
||||
width: DEVICE_MIN_WIDTH,
|
||||
height: Math.max(DEVICE_MIN_HEIGHT, portHeight + 30),
|
||||
};
|
||||
}
|
||||
|
||||
function getLayerForDevice(device: Device): number {
|
||||
for (const [layer, categories] of Object.entries(LAYER_MAPPING)) {
|
||||
if (categories.includes(device.category)) {
|
||||
return parseInt(layer, 10);
|
||||
}
|
||||
}
|
||||
return 7; // default to "unknown" layer
|
||||
}
|
||||
|
||||
function getSiteDevices(siteId: string, data: SchemaData): Device[] {
|
||||
return data.devices.filter((d) => d.siteId === siteId);
|
||||
}
|
||||
|
||||
function layoutDevicesInSite(
|
||||
devices: Device[],
|
||||
data: SchemaData,
|
||||
startX: number,
|
||||
startY: number,
|
||||
): { positions: Map<string, { x: number; y: number; width: number; height: number }>; totalWidth: number; totalHeight: number } {
|
||||
const positions = new Map<string, { x: number; y: number; width: number; height: number }>();
|
||||
|
||||
// Assign devices to layers
|
||||
const layers = new Map<number, { device: Device; size: { width: number; height: number } }[]>();
|
||||
for (const device of devices) {
|
||||
const layer = getLayerForDevice(device);
|
||||
const portCount = data.ports.filter((p) => p.deviceId === device.id && !p.cardId).length;
|
||||
const size = getDeviceSize(device, portCount);
|
||||
const existing = layers.get(layer);
|
||||
if (existing) {
|
||||
existing.push({ device, size });
|
||||
} else {
|
||||
layers.set(layer, [{ device, size }]);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle overflow: layer 1 with >MAX_CROSS crosses → overflow to layer 8
|
||||
const layer1 = layers.get(1);
|
||||
if (layer1 && layer1.length > MAX_CROSS_PER_LAYER) {
|
||||
const overflow = layer1.splice(MAX_CROSS_PER_LAYER);
|
||||
const layer8 = layers.get(8) ?? [];
|
||||
layer8.push(...overflow);
|
||||
layers.set(8, layer8);
|
||||
}
|
||||
|
||||
// Calculate layer Y positions (skip empty layers)
|
||||
const sortedLayers = Array.from(layers.entries()).sort((a, b) => a[0] - b[0]);
|
||||
|
||||
let currentY = startY;
|
||||
let maxWidth = 0;
|
||||
|
||||
for (const [, layerDevices] of sortedLayers) {
|
||||
let currentX = startX + LAYER_PADDING_X;
|
||||
let layerMaxHeight = 0;
|
||||
|
||||
for (const { device, size } of layerDevices) {
|
||||
positions.set(device.id, {
|
||||
x: currentX,
|
||||
y: currentY,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
});
|
||||
currentX += size.width + DEVICE_GAP;
|
||||
layerMaxHeight = Math.max(layerMaxHeight, size.height);
|
||||
}
|
||||
|
||||
maxWidth = Math.max(maxWidth, currentX - startX);
|
||||
currentY += layerMaxHeight + LAYER_GAP;
|
||||
}
|
||||
|
||||
const totalHeight = currentY - startY;
|
||||
const totalWidth = Math.max(maxWidth + LAYER_PADDING_X, SITE_MIN_WIDTH);
|
||||
|
||||
return { positions, totalWidth, totalHeight };
|
||||
}
|
||||
|
||||
export function autoLayout(data: SchemaData): LayoutResult {
|
||||
const nodePositions = new Map<string, { x: number; y: number; width: number; height: number }>();
|
||||
const sitePositions = new Map<string, { x: number; y: number; width: number; height: number }>();
|
||||
|
||||
// Separate root sites and child sites
|
||||
const rootSites = data.sites.filter((s: Site) => !s.parentSiteId);
|
||||
const childSites = data.sites.filter((s: Site) => s.parentSiteId);
|
||||
|
||||
let siteX = 50;
|
||||
|
||||
for (const site of rootSites) {
|
||||
const siteDevices = getSiteDevices(site.id, data);
|
||||
|
||||
const contentStartY = SITE_HEADER_HEIGHT + SITE_PADDING;
|
||||
const { positions, totalWidth, totalHeight } = layoutDevicesInSite(
|
||||
siteDevices,
|
||||
data,
|
||||
siteX + SITE_PADDING,
|
||||
contentStartY + 50, // offset for site positioning
|
||||
);
|
||||
|
||||
// Add device positions
|
||||
for (const [deviceId, pos] of positions.entries()) {
|
||||
nodePositions.set(deviceId, pos);
|
||||
}
|
||||
|
||||
// Handle child sites at bottom of parent
|
||||
const siteChildren = childSites.filter((cs) => cs.parentSiteId === site.id);
|
||||
let childSiteExtraHeight = 0;
|
||||
let childX = siteX + SITE_PADDING;
|
||||
|
||||
for (const childSite of siteChildren) {
|
||||
const childDevices = getSiteDevices(childSite.id, data);
|
||||
const childContentStartY = contentStartY + totalHeight + 50;
|
||||
|
||||
const childLayout = layoutDevicesInSite(
|
||||
childDevices,
|
||||
data,
|
||||
childX + SITE_PADDING,
|
||||
childContentStartY + SITE_HEADER_HEIGHT + SITE_PADDING,
|
||||
);
|
||||
|
||||
for (const [deviceId, pos] of childLayout.positions.entries()) {
|
||||
nodePositions.set(deviceId, pos);
|
||||
}
|
||||
|
||||
const childSiteWidth = Math.max(childLayout.totalWidth + SITE_PADDING * 2, 250);
|
||||
const childSiteHeight = childLayout.totalHeight + SITE_HEADER_HEIGHT + SITE_PADDING * 2;
|
||||
|
||||
sitePositions.set(childSite.id, {
|
||||
x: childX,
|
||||
y: childContentStartY,
|
||||
width: childSiteWidth,
|
||||
height: childSiteHeight,
|
||||
});
|
||||
|
||||
childX += childSiteWidth + DEVICE_GAP;
|
||||
childSiteExtraHeight = Math.max(childSiteExtraHeight, childSiteHeight + SITE_PADDING);
|
||||
}
|
||||
|
||||
const siteWidth = Math.max(totalWidth + SITE_PADDING * 2, childX - siteX);
|
||||
const siteHeight = totalHeight + contentStartY + childSiteExtraHeight + SITE_PADDING;
|
||||
|
||||
sitePositions.set(site.id, {
|
||||
x: siteX,
|
||||
y: 50,
|
||||
width: siteWidth,
|
||||
height: siteHeight,
|
||||
});
|
||||
|
||||
siteX += siteWidth + 80;
|
||||
}
|
||||
|
||||
return { nodePositions, sitePositions };
|
||||
}
|
||||
44
frontend/src/features/schema/nodes/CardNode.tsx
Normal file
44
frontend/src/features/schema/nodes/CardNode.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
import type { Node } from '@antv/x6';
|
||||
import { STATUS_COLORS } from '../../../constants/statusColors.ts';
|
||||
import type { EntityStatus } from '../../../types/index.ts';
|
||||
|
||||
interface CardNodeData {
|
||||
slotName: string;
|
||||
networkName: string;
|
||||
status: EntityStatus;
|
||||
}
|
||||
|
||||
export function CardNode({ node }: { node: Node }) {
|
||||
const data = node.getData() as CardNodeData;
|
||||
const colors = STATUS_COLORS[data.status];
|
||||
const size = node.getSize();
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
borderTop: `1.5px solid ${colors.border}`,
|
||||
borderBottom: `1.5px solid ${colors.border}`,
|
||||
borderLeft: `5px solid ${colors.border}`,
|
||||
borderRight: `5px solid ${colors.border}`,
|
||||
borderRadius: 0,
|
||||
background: colors.fill,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxSizing: 'border-box',
|
||||
fontSize: 9,
|
||||
fontWeight: 600,
|
||||
color: colors.text,
|
||||
textAlign: 'center',
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'nowrap',
|
||||
textOverflow: 'ellipsis',
|
||||
padding: '0 4px',
|
||||
}}
|
||||
>
|
||||
{data.slotName}:{data.networkName}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
83
frontend/src/features/schema/nodes/CrossDeviceNode.tsx
Normal file
83
frontend/src/features/schema/nodes/CrossDeviceNode.tsx
Normal file
@ -0,0 +1,83 @@
|
||||
import type { Node } from '@antv/x6';
|
||||
import { STATUS_COLORS } from '../../../constants/statusColors.ts';
|
||||
import { CROSS_BORDER_RADIUS } from '../../../constants/sizes.ts';
|
||||
import type { EntityStatus } from '../../../types/index.ts';
|
||||
|
||||
interface CrossDeviceData {
|
||||
name: string;
|
||||
networkName: string;
|
||||
marking: string;
|
||||
id1: string;
|
||||
id2: string;
|
||||
status: EntityStatus;
|
||||
}
|
||||
|
||||
export function CrossDeviceNode({ node }: { node: Node }) {
|
||||
const data = node.getData() as CrossDeviceData;
|
||||
const colors = STATUS_COLORS[data.status];
|
||||
const size = node.getSize();
|
||||
const r = CROSS_BORDER_RADIUS;
|
||||
const w = size.width;
|
||||
const h = size.height;
|
||||
|
||||
// Asymmetric rounding: top-left and bottom-right rounded
|
||||
const path = `
|
||||
M ${r} 0
|
||||
L ${w} 0
|
||||
L ${w} ${h - r}
|
||||
Q ${w} ${h} ${w - r} ${h}
|
||||
L 0 ${h}
|
||||
L 0 ${r}
|
||||
Q 0 0 ${r} 0
|
||||
Z
|
||||
`;
|
||||
|
||||
return (
|
||||
<div style={{ width: w, height: h, position: 'relative' }}>
|
||||
<svg
|
||||
width={w}
|
||||
height={h}
|
||||
style={{ position: 'absolute', top: 0, left: 0 }}
|
||||
>
|
||||
<path
|
||||
d={path}
|
||||
fill={colors.fill}
|
||||
stroke={colors.border}
|
||||
strokeWidth={1}
|
||||
/>
|
||||
</svg>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: w,
|
||||
height: h,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '8px 6px',
|
||||
boxSizing: 'border-box',
|
||||
textAlign: 'center',
|
||||
fontSize: 10,
|
||||
lineHeight: '14px',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 700, fontSize: 11, marginBottom: 4, wordBreak: 'break-word' }}>
|
||||
{data.name}
|
||||
</div>
|
||||
{data.networkName && (
|
||||
<div style={{ color: '#595959', fontSize: 9 }}>{data.networkName}</div>
|
||||
)}
|
||||
{data.marking && (
|
||||
<div style={{ color: '#8c8c8c', fontSize: 9 }}>{data.marking}</div>
|
||||
)}
|
||||
{data.id1 && (
|
||||
<div style={{ color: '#8c8c8c', fontSize: 9 }}>{data.id1}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
80
frontend/src/features/schema/nodes/DeviceNode.tsx
Normal file
80
frontend/src/features/schema/nodes/DeviceNode.tsx
Normal file
@ -0,0 +1,80 @@
|
||||
import type { Node } from '@antv/x6';
|
||||
import { STATUS_COLORS } from '../../../constants/statusColors.ts';
|
||||
import { DEVICE_BORDER_RADIUS } from '../../../constants/sizes.ts';
|
||||
import { DeviceGroup, type EntityStatus } from '../../../types/index.ts';
|
||||
|
||||
interface DeviceNodeData {
|
||||
name: string;
|
||||
networkName: string;
|
||||
ipAddress: string;
|
||||
marking: string;
|
||||
id1: string;
|
||||
id2: string;
|
||||
group: DeviceGroup;
|
||||
status: EntityStatus;
|
||||
}
|
||||
|
||||
export function DeviceNode({ node }: { node: Node }) {
|
||||
const data = node.getData() as DeviceNodeData;
|
||||
const colors = STATUS_COLORS[data.status];
|
||||
const size = node.getSize();
|
||||
const isActive = data.group === DeviceGroup.Active;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
border: `1px solid ${colors.border}`,
|
||||
borderRadius: DEVICE_BORDER_RADIUS,
|
||||
background: colors.fill,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxSizing: 'border-box',
|
||||
textAlign: 'center',
|
||||
padding: '6px 8px',
|
||||
fontSize: 10,
|
||||
lineHeight: '14px',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: 700,
|
||||
fontSize: 11,
|
||||
marginBottom: 2,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: '100%',
|
||||
}}
|
||||
>
|
||||
{data.name}
|
||||
</div>
|
||||
{isActive ? (
|
||||
<>
|
||||
{data.networkName && (
|
||||
<div style={{ color: '#595959', fontSize: 9 }}>{data.networkName}</div>
|
||||
)}
|
||||
{data.ipAddress && (
|
||||
<div style={{ color: '#1890ff', fontSize: 9 }}>{data.ipAddress}</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{data.marking && (
|
||||
<div style={{ color: '#595959', fontSize: 9 }}>{data.marking}</div>
|
||||
)}
|
||||
{data.id1 && (
|
||||
<div style={{ color: '#8c8c8c', fontSize: 9 }}>{data.id1}</div>
|
||||
)}
|
||||
{data.id2 && (
|
||||
<div style={{ color: '#8c8c8c', fontSize: 9 }}>{data.id2}</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
60
frontend/src/features/schema/nodes/SiteNode.tsx
Normal file
60
frontend/src/features/schema/nodes/SiteNode.tsx
Normal file
@ -0,0 +1,60 @@
|
||||
import type { Node } from '@antv/x6';
|
||||
import { STATUS_COLORS } from '../../../constants/statusColors.ts';
|
||||
import { SITE_HEADER_HEIGHT } from '../../../constants/sizes.ts';
|
||||
import type { EntityStatus } from '../../../types/index.ts';
|
||||
|
||||
interface SiteNodeData {
|
||||
name: string;
|
||||
address: string;
|
||||
erpCode: string;
|
||||
code1C: string;
|
||||
status: EntityStatus;
|
||||
}
|
||||
|
||||
export function SiteNode({ node }: { node: Node }) {
|
||||
const data = node.getData() as SiteNodeData;
|
||||
const colors = STATUS_COLORS[data.status];
|
||||
const size = node.getSize();
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
border: `3.87px solid ${colors.border}`,
|
||||
borderRadius: 0,
|
||||
background: 'transparent',
|
||||
position: 'relative',
|
||||
boxSizing: 'border-box',
|
||||
overflow: 'visible',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: SITE_HEADER_HEIGHT,
|
||||
background: '#1a1a2e',
|
||||
color: '#ffffff',
|
||||
padding: '6px 10px',
|
||||
fontSize: 11,
|
||||
lineHeight: '16px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
gap: 1,
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 700, fontSize: 12, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{data.name}
|
||||
</div>
|
||||
<div style={{ opacity: 0.8, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{data.address}
|
||||
</div>
|
||||
<div style={{ opacity: 0.7, fontSize: 10 }}>
|
||||
ERP: {data.erpCode} | 1С: {data.code1C}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
46
frontend/src/features/schema/nodes/SpliceNode.tsx
Normal file
46
frontend/src/features/schema/nodes/SpliceNode.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
import type { Node } from '@antv/x6';
|
||||
import { STATUS_COLORS } from '../../../constants/statusColors.ts';
|
||||
import { SPLICE_BORDER_RADIUS } from '../../../constants/sizes.ts';
|
||||
import type { EntityStatus } from '../../../types/index.ts';
|
||||
|
||||
interface SpliceNodeData {
|
||||
name: string;
|
||||
marking: string;
|
||||
id1: string;
|
||||
id2: string;
|
||||
status: EntityStatus;
|
||||
}
|
||||
|
||||
export function SpliceNode({ node }: { node: Node }) {
|
||||
const data = node.getData() as SpliceNodeData;
|
||||
const colors = STATUS_COLORS[data.status];
|
||||
const size = node.getSize();
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
border: `1px solid ${colors.border}`,
|
||||
borderRadius: SPLICE_BORDER_RADIUS,
|
||||
background: colors.fill,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxSizing: 'border-box',
|
||||
textAlign: 'center',
|
||||
padding: 4,
|
||||
fontSize: 10,
|
||||
lineHeight: '14px',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 700, fontSize: 11, marginBottom: 2, wordBreak: 'break-word' }}>
|
||||
{data.name}
|
||||
</div>
|
||||
{data.marking && (
|
||||
<div style={{ color: '#595959', fontSize: 9 }}>{data.marking}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
63
frontend/src/features/schema/ports/portConfig.ts
Normal file
63
frontend/src/features/schema/ports/portConfig.ts
Normal file
@ -0,0 +1,63 @@
|
||||
import { PORT_RADIUS } from '../../../constants/sizes.ts';
|
||||
|
||||
export const portGroups = {
|
||||
left: {
|
||||
position: 'left',
|
||||
attrs: {
|
||||
circle: {
|
||||
r: PORT_RADIUS,
|
||||
magnet: true,
|
||||
stroke: '#8c8c8c',
|
||||
strokeWidth: 1,
|
||||
fill: '#fff',
|
||||
},
|
||||
},
|
||||
label: {
|
||||
position: {
|
||||
name: 'left',
|
||||
args: { x: -8, y: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
right: {
|
||||
position: 'right',
|
||||
attrs: {
|
||||
circle: {
|
||||
r: PORT_RADIUS,
|
||||
magnet: true,
|
||||
stroke: '#8c8c8c',
|
||||
strokeWidth: 1,
|
||||
fill: '#fff',
|
||||
},
|
||||
},
|
||||
label: {
|
||||
position: {
|
||||
name: 'right',
|
||||
args: { x: 8, y: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export function createPortItem(
|
||||
portId: string,
|
||||
side: 'left' | 'right',
|
||||
label: string,
|
||||
labelColor?: string,
|
||||
) {
|
||||
return {
|
||||
id: portId,
|
||||
group: side,
|
||||
attrs: {
|
||||
text: {
|
||||
text: label,
|
||||
fontSize: 8,
|
||||
fill: labelColor || '#595959',
|
||||
},
|
||||
circle: {
|
||||
fill: labelColor || '#fff',
|
||||
stroke: labelColor || '#8c8c8c',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
21
frontend/src/hooks/useContextMenu.ts
Normal file
21
frontend/src/hooks/useContextMenu.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useSchemaStore } from '../store/schemaStore.ts';
|
||||
import type { ContextMenuState } from '../store/schemaStore.ts';
|
||||
|
||||
export function useContextMenu() {
|
||||
const contextMenu = useSchemaStore((state) => state.contextMenu);
|
||||
const setContextMenu = useSchemaStore((state) => state.setContextMenu);
|
||||
|
||||
const showMenu = useCallback(
|
||||
(menu: Omit<ContextMenuState, 'visible'>) => {
|
||||
setContextMenu({ ...menu, visible: true });
|
||||
},
|
||||
[setContextMenu],
|
||||
);
|
||||
|
||||
const hideMenu = useCallback(() => {
|
||||
setContextMenu(null);
|
||||
}, [setContextMenu]);
|
||||
|
||||
return { contextMenu, showMenu, hideMenu };
|
||||
}
|
||||
5
frontend/src/hooks/useGraph.ts
Normal file
5
frontend/src/hooks/useGraph.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { useSchemaStore } from '../store/schemaStore.ts';
|
||||
|
||||
export function useGraph() {
|
||||
return useSchemaStore((state) => state.graph);
|
||||
}
|
||||
12
frontend/src/index.css
Normal file
12
frontend/src/index.css
Normal file
@ -0,0 +1,12 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body, #root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
5
frontend/src/main.tsx
Normal file
5
frontend/src/main.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import './index.css';
|
||||
import App from './App.tsx';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(<App />);
|
||||
315
frontend/src/mock/schemaData.ts
Normal file
315
frontend/src/mock/schemaData.ts
Normal file
@ -0,0 +1,315 @@
|
||||
import type { SchemaData } from '../types/index.ts';
|
||||
import {
|
||||
EntityStatus,
|
||||
DeviceGroup,
|
||||
DeviceCategory,
|
||||
Medium,
|
||||
LineStyle,
|
||||
LineType,
|
||||
} from '../types/index.ts';
|
||||
|
||||
export const mockData: SchemaData = {
|
||||
sites: [
|
||||
{
|
||||
id: 'site-1',
|
||||
name: 'Узел связи Центральный',
|
||||
address: 'ул. Ленина, 1',
|
||||
erpCode: 'ERP-001',
|
||||
code1C: '1C-001',
|
||||
status: EntityStatus.Active,
|
||||
parentSiteId: null,
|
||||
},
|
||||
{
|
||||
id: 'site-2',
|
||||
name: 'Узел связи Северный',
|
||||
address: 'ул. Мира, 15',
|
||||
erpCode: 'ERP-002',
|
||||
code1C: '1C-002',
|
||||
status: EntityStatus.Active,
|
||||
parentSiteId: null,
|
||||
},
|
||||
{
|
||||
id: 'site-1-1',
|
||||
name: 'Подузел Восточный',
|
||||
address: 'ул. Ленина, 1, корп. 2',
|
||||
erpCode: 'ERP-003',
|
||||
code1C: '1C-003',
|
||||
status: EntityStatus.Planned,
|
||||
parentSiteId: 'site-1',
|
||||
},
|
||||
],
|
||||
|
||||
devices: [
|
||||
// Site 1 devices
|
||||
{
|
||||
id: 'dev-cross-1',
|
||||
name: 'Кросс оптический ОРШ-1',
|
||||
networkName: 'ORS-1',
|
||||
ipAddress: '',
|
||||
marking: 'ОРШ-32',
|
||||
id1: 'INV-001',
|
||||
id2: 'SN-001',
|
||||
group: DeviceGroup.Passive,
|
||||
category: DeviceCategory.CrossOptical,
|
||||
status: EntityStatus.Active,
|
||||
siteId: 'site-1',
|
||||
},
|
||||
{
|
||||
id: 'dev-cross-2',
|
||||
name: 'Кросс оптический ОРШ-2',
|
||||
networkName: 'ORS-2',
|
||||
ipAddress: '',
|
||||
marking: 'ОРШ-16',
|
||||
id1: 'INV-002',
|
||||
id2: 'SN-002',
|
||||
group: DeviceGroup.Passive,
|
||||
category: DeviceCategory.CrossOptical,
|
||||
status: EntityStatus.Reserved,
|
||||
siteId: 'site-1',
|
||||
},
|
||||
{
|
||||
id: 'dev-dwdm-1',
|
||||
name: 'DWDM Huawei OSN 8800',
|
||||
networkName: 'DWDM-HW-01',
|
||||
ipAddress: '10.0.1.10',
|
||||
marking: '',
|
||||
id1: 'INV-003',
|
||||
id2: '',
|
||||
group: DeviceGroup.Active,
|
||||
category: DeviceCategory.DWDM,
|
||||
status: EntityStatus.Active,
|
||||
siteId: 'site-1',
|
||||
},
|
||||
{
|
||||
id: 'dev-router-1',
|
||||
name: 'Маршрутизатор Cisco ASR 9000',
|
||||
networkName: 'RTR-CISCO-01',
|
||||
ipAddress: '10.0.1.1',
|
||||
marking: '',
|
||||
id1: 'INV-004',
|
||||
id2: '',
|
||||
group: DeviceGroup.Active,
|
||||
category: DeviceCategory.IP,
|
||||
status: EntityStatus.Active,
|
||||
siteId: 'site-1',
|
||||
},
|
||||
{
|
||||
id: 'dev-switch-1',
|
||||
name: 'Коммутатор Huawei S6730',
|
||||
networkName: 'SW-HW-01',
|
||||
ipAddress: '10.0.1.2',
|
||||
marking: '',
|
||||
id1: 'INV-005',
|
||||
id2: '',
|
||||
group: DeviceGroup.Active,
|
||||
category: DeviceCategory.LanWlan,
|
||||
status: EntityStatus.Active,
|
||||
siteId: 'site-1',
|
||||
},
|
||||
{
|
||||
id: 'dev-server-1',
|
||||
name: 'Сервер мониторинга',
|
||||
networkName: 'SRV-MON-01',
|
||||
ipAddress: '10.0.1.100',
|
||||
marking: '',
|
||||
id1: 'INV-006',
|
||||
id2: '',
|
||||
group: DeviceGroup.Active,
|
||||
category: DeviceCategory.Server,
|
||||
status: EntityStatus.Active,
|
||||
siteId: 'site-1',
|
||||
},
|
||||
// Site 1-1 (child site) device
|
||||
{
|
||||
id: 'dev-splice-1',
|
||||
name: 'Муфта МТОК-96',
|
||||
networkName: '',
|
||||
ipAddress: '',
|
||||
marking: 'МТОК-96',
|
||||
id1: 'INV-007',
|
||||
id2: 'SN-007',
|
||||
group: DeviceGroup.Passive,
|
||||
category: DeviceCategory.Unknown,
|
||||
status: EntityStatus.Active,
|
||||
siteId: 'site-1-1',
|
||||
},
|
||||
// Site 2 devices
|
||||
{
|
||||
id: 'dev-cross-3',
|
||||
name: 'Кросс оптический ОРШ-3',
|
||||
networkName: 'ORS-3',
|
||||
ipAddress: '',
|
||||
marking: 'ОРШ-48',
|
||||
id1: 'INV-008',
|
||||
id2: 'SN-008',
|
||||
group: DeviceGroup.Passive,
|
||||
category: DeviceCategory.CrossOptical,
|
||||
status: EntityStatus.Active,
|
||||
siteId: 'site-2',
|
||||
},
|
||||
{
|
||||
id: 'dev-olt-1',
|
||||
name: 'OLT Huawei MA5800',
|
||||
networkName: 'OLT-HW-01',
|
||||
ipAddress: '10.0.2.10',
|
||||
marking: '',
|
||||
id1: 'INV-009',
|
||||
id2: '',
|
||||
group: DeviceGroup.Active,
|
||||
category: DeviceCategory.IP,
|
||||
status: EntityStatus.UnderConstruction,
|
||||
siteId: 'site-2',
|
||||
},
|
||||
{
|
||||
id: 'dev-xdsl-1',
|
||||
name: 'DSLAM ZTE C300',
|
||||
networkName: 'DSLAM-ZTE-01',
|
||||
ipAddress: '10.0.2.20',
|
||||
marking: '',
|
||||
id1: 'INV-010',
|
||||
id2: '',
|
||||
group: DeviceGroup.Active,
|
||||
category: DeviceCategory.xDSL,
|
||||
status: EntityStatus.Faulty,
|
||||
siteId: 'site-2',
|
||||
},
|
||||
],
|
||||
|
||||
cards: [
|
||||
{
|
||||
id: 'card-1',
|
||||
slotName: '1',
|
||||
networkName: 'TN12ST2',
|
||||
status: EntityStatus.Active,
|
||||
visible: true,
|
||||
deviceId: 'dev-dwdm-1',
|
||||
},
|
||||
{
|
||||
id: 'card-2',
|
||||
slotName: '2',
|
||||
networkName: 'TN11LSX',
|
||||
status: EntityStatus.Active,
|
||||
visible: true,
|
||||
deviceId: 'dev-dwdm-1',
|
||||
},
|
||||
{
|
||||
id: 'card-3',
|
||||
slotName: '1',
|
||||
networkName: 'RSP-480',
|
||||
status: EntityStatus.Active,
|
||||
visible: true,
|
||||
deviceId: 'dev-router-1',
|
||||
},
|
||||
],
|
||||
|
||||
ports: [
|
||||
// Cross 1 ports (L = left, S = right)
|
||||
{ id: 'p-c1-l1', name: 'L1', slotName: 'L', side: 'left', sortOrder: 1, labelColor: '#1890ff', deviceId: 'dev-cross-1', cardId: null },
|
||||
{ id: 'p-c1-l2', name: 'L2', slotName: 'L', side: 'left', sortOrder: 2, labelColor: '#1890ff', deviceId: 'dev-cross-1', cardId: null },
|
||||
{ id: 'p-c1-l3', name: 'L3', slotName: 'L', side: 'left', sortOrder: 3, labelColor: '#1890ff', deviceId: 'dev-cross-1', cardId: null },
|
||||
{ id: 'p-c1-l4', name: 'L4', slotName: 'L', side: 'left', sortOrder: 4, labelColor: '#1890ff', deviceId: 'dev-cross-1', cardId: null },
|
||||
{ id: 'p-c1-s1', name: 'S1', slotName: 'S', side: 'right', sortOrder: 1, labelColor: '#52c41a', deviceId: 'dev-cross-1', cardId: null },
|
||||
{ id: 'p-c1-s2', name: 'S2', slotName: 'S', side: 'right', sortOrder: 2, labelColor: '#52c41a', deviceId: 'dev-cross-1', cardId: null },
|
||||
{ id: 'p-c1-s3', name: 'S3', slotName: 'S', side: 'right', sortOrder: 3, labelColor: '#52c41a', deviceId: 'dev-cross-1', cardId: null },
|
||||
{ id: 'p-c1-s4', name: 'S4', slotName: 'S', side: 'right', sortOrder: 4, labelColor: '#52c41a', deviceId: 'dev-cross-1', cardId: null },
|
||||
|
||||
// Cross 2 ports
|
||||
{ id: 'p-c2-l1', name: 'L1', slotName: 'L', side: 'left', sortOrder: 1, labelColor: '#1890ff', deviceId: 'dev-cross-2', cardId: null },
|
||||
{ id: 'p-c2-l2', name: 'L2', slotName: 'L', side: 'left', sortOrder: 2, labelColor: '#1890ff', deviceId: 'dev-cross-2', cardId: null },
|
||||
{ id: 'p-c2-s1', name: 'S1', slotName: 'S', side: 'right', sortOrder: 1, labelColor: '#52c41a', deviceId: 'dev-cross-2', cardId: null },
|
||||
{ id: 'p-c2-s2', name: 'S2', slotName: 'S', side: 'right', sortOrder: 2, labelColor: '#52c41a', deviceId: 'dev-cross-2', cardId: null },
|
||||
|
||||
// DWDM card-1 ports
|
||||
{ id: 'p-dw-c1-1', name: 'IN', slotName: '1', side: 'left', sortOrder: 1, labelColor: '', deviceId: 'dev-dwdm-1', cardId: 'card-1' },
|
||||
{ id: 'p-dw-c1-2', name: 'OUT', slotName: '1', side: 'right', sortOrder: 2, labelColor: '', deviceId: 'dev-dwdm-1', cardId: 'card-1' },
|
||||
// DWDM card-2 ports
|
||||
{ id: 'p-dw-c2-1', name: 'IN', slotName: '2', side: 'left', sortOrder: 1, labelColor: '', deviceId: 'dev-dwdm-1', cardId: 'card-2' },
|
||||
{ id: 'p-dw-c2-2', name: 'OUT', slotName: '2', side: 'right', sortOrder: 2, labelColor: '', deviceId: 'dev-dwdm-1', cardId: 'card-2' },
|
||||
// DWDM device-level ports
|
||||
{ id: 'p-dw-1', name: 'Ge0/0/1', slotName: '0', side: 'left', sortOrder: 1, labelColor: '', deviceId: 'dev-dwdm-1', cardId: null },
|
||||
{ id: 'p-dw-2', name: 'Ge0/0/2', slotName: '0', side: 'right', sortOrder: 2, labelColor: '', deviceId: 'dev-dwdm-1', cardId: null },
|
||||
|
||||
// Router card-3 ports
|
||||
{ id: 'p-rtr-c3-1', name: 'Te0/0/0/0', slotName: '1', side: 'left', sortOrder: 1, labelColor: '', deviceId: 'dev-router-1', cardId: 'card-3' },
|
||||
{ id: 'p-rtr-c3-2', name: 'Te0/0/0/1', slotName: '1', side: 'right', sortOrder: 2, labelColor: '', deviceId: 'dev-router-1', cardId: 'card-3' },
|
||||
// Router device-level ports
|
||||
{ id: 'p-rtr-1', name: 'Ge0/0/0', slotName: '0', side: 'left', sortOrder: 1, labelColor: '', deviceId: 'dev-router-1', cardId: null },
|
||||
{ id: 'p-rtr-2', name: 'Ge0/0/1', slotName: '0', side: 'left', sortOrder: 2, labelColor: '', deviceId: 'dev-router-1', cardId: null },
|
||||
{ id: 'p-rtr-3', name: 'Ge0/0/2', slotName: '0', side: 'right', sortOrder: 3, labelColor: '', deviceId: 'dev-router-1', cardId: null },
|
||||
{ id: 'p-rtr-4', name: 'Ge0/0/3', slotName: '0', side: 'right', sortOrder: 4, labelColor: '', deviceId: 'dev-router-1', cardId: null },
|
||||
|
||||
// Switch ports
|
||||
{ id: 'p-sw-1', name: 'Ge1/0/1', slotName: '1', side: 'left', sortOrder: 1, labelColor: '', deviceId: 'dev-switch-1', cardId: null },
|
||||
{ id: 'p-sw-2', name: 'Ge1/0/2', slotName: '1', side: 'left', sortOrder: 2, labelColor: '', deviceId: 'dev-switch-1', cardId: null },
|
||||
{ id: 'p-sw-3', name: 'Ge1/0/3', slotName: '1', side: 'right', sortOrder: 3, labelColor: '', deviceId: 'dev-switch-1', cardId: null },
|
||||
{ id: 'p-sw-4', name: 'Ge1/0/4', slotName: '1', side: 'right', sortOrder: 4, labelColor: '', deviceId: 'dev-switch-1', cardId: null },
|
||||
|
||||
// Server ports
|
||||
{ id: 'p-srv-1', name: 'eth0', slotName: '0', side: 'left', sortOrder: 1, labelColor: '', deviceId: 'dev-server-1', cardId: null },
|
||||
{ id: 'p-srv-2', name: 'eth1', slotName: '0', side: 'right', sortOrder: 2, labelColor: '', deviceId: 'dev-server-1', cardId: null },
|
||||
|
||||
// Splice ports
|
||||
{ id: 'p-sp-1', name: '1', slotName: '0', side: 'left', sortOrder: 1, labelColor: '', deviceId: 'dev-splice-1', cardId: null },
|
||||
{ id: 'p-sp-2', name: '2', slotName: '0', side: 'left', sortOrder: 2, labelColor: '', deviceId: 'dev-splice-1', cardId: null },
|
||||
{ id: 'p-sp-3', name: '3', slotName: '0', side: 'right', sortOrder: 3, labelColor: '', deviceId: 'dev-splice-1', cardId: null },
|
||||
{ id: 'p-sp-4', name: '4', slotName: '0', side: 'right', sortOrder: 4, labelColor: '', deviceId: 'dev-splice-1', cardId: null },
|
||||
|
||||
// Cross 3 ports (site 2)
|
||||
{ id: 'p-c3-l1', name: 'L1', slotName: 'L', side: 'left', sortOrder: 1, labelColor: '#1890ff', deviceId: 'dev-cross-3', cardId: null },
|
||||
{ id: 'p-c3-l2', name: 'L2', slotName: 'L', side: 'left', sortOrder: 2, labelColor: '#1890ff', deviceId: 'dev-cross-3', cardId: null },
|
||||
{ id: 'p-c3-l3', name: 'L3', slotName: 'L', side: 'left', sortOrder: 3, labelColor: '#1890ff', deviceId: 'dev-cross-3', cardId: null },
|
||||
{ id: 'p-c3-s1', name: 'S1', slotName: 'S', side: 'right', sortOrder: 1, labelColor: '#52c41a', deviceId: 'dev-cross-3', cardId: null },
|
||||
{ id: 'p-c3-s2', name: 'S2', slotName: 'S', side: 'right', sortOrder: 2, labelColor: '#52c41a', deviceId: 'dev-cross-3', cardId: null },
|
||||
{ id: 'p-c3-s3', name: 'S3', slotName: 'S', side: 'right', sortOrder: 3, labelColor: '#52c41a', deviceId: 'dev-cross-3', cardId: null },
|
||||
|
||||
// OLT ports
|
||||
{ id: 'p-olt-1', name: 'GPON0/0', slotName: '0', side: 'left', sortOrder: 1, labelColor: '', deviceId: 'dev-olt-1', cardId: null },
|
||||
{ id: 'p-olt-2', name: 'GPON0/1', slotName: '0', side: 'left', sortOrder: 2, labelColor: '', deviceId: 'dev-olt-1', cardId: null },
|
||||
{ id: 'p-olt-3', name: 'Uplink1', slotName: '0', side: 'right', sortOrder: 3, labelColor: '', deviceId: 'dev-olt-1', cardId: null },
|
||||
{ id: 'p-olt-4', name: 'Uplink2', slotName: '0', side: 'right', sortOrder: 4, labelColor: '', deviceId: 'dev-olt-1', cardId: null },
|
||||
|
||||
// xDSL ports
|
||||
{ id: 'p-xdsl-1', name: 'ADSL0/0', slotName: '0', side: 'left', sortOrder: 1, labelColor: '', deviceId: 'dev-xdsl-1', cardId: null },
|
||||
{ id: 'p-xdsl-2', name: 'ADSL0/1', slotName: '0', side: 'left', sortOrder: 2, labelColor: '', deviceId: 'dev-xdsl-1', cardId: null },
|
||||
{ id: 'p-xdsl-3', name: 'Uplink1', slotName: '0', side: 'right', sortOrder: 3, labelColor: '', deviceId: 'dev-xdsl-1', cardId: null },
|
||||
{ id: 'p-xdsl-4', name: 'Uplink2', slotName: '0', side: 'right', sortOrder: 4, labelColor: '', deviceId: 'dev-xdsl-1', cardId: null },
|
||||
],
|
||||
|
||||
lines: [
|
||||
// Cross1 L1 → DWDM card-1 IN (optical, active)
|
||||
{ id: 'line-1', name: 'ВОК Центр-DWDM-1', templateName: '', status: EntityStatus.Active, type: LineType.Simple, medium: Medium.Optical, lineStyle: LineStyle.Solid, portAId: 'p-c1-s1', portZId: 'p-dw-c1-1' },
|
||||
// Cross1 L2 → DWDM card-2 IN (optical, active)
|
||||
{ id: 'line-2', name: 'ВОК Центр-DWDM-2', templateName: '', status: EntityStatus.Active, type: LineType.Simple, medium: Medium.Optical, lineStyle: LineStyle.Solid, portAId: 'p-c1-s2', portZId: 'p-dw-c2-1' },
|
||||
// DWDM card-1 OUT → Router card-3 IN (optical, active)
|
||||
{ id: 'line-3', name: 'Транзит DWDM-RTR', templateName: '', status: EntityStatus.Active, type: LineType.Simple, medium: Medium.Optical, lineStyle: LineStyle.Solid, portAId: 'p-dw-c1-2', portZId: 'p-rtr-c3-1' },
|
||||
// Router → Switch (copper, active)
|
||||
{ id: 'line-4', name: 'LAN RTR-SW', templateName: '', status: EntityStatus.Active, type: LineType.Simple, medium: Medium.Copper, lineStyle: LineStyle.Solid, portAId: 'p-rtr-3', portZId: 'p-sw-1' },
|
||||
// Switch → Server (copper, active)
|
||||
{ id: 'line-5', name: 'LAN SW-SRV', templateName: '', status: EntityStatus.Active, type: LineType.Simple, medium: Medium.Copper, lineStyle: LineStyle.Solid, portAId: 'p-sw-3', portZId: 'p-srv-1' },
|
||||
// Cross1 L3 → Splice (optical, planned)
|
||||
{ id: 'line-6', name: 'ВОК Центр-Муфта', templateName: '', status: EntityStatus.Planned, type: LineType.Simple, medium: Medium.Optical, lineStyle: LineStyle.Dashed, portAId: 'p-c1-s3', portZId: 'p-sp-1' },
|
||||
// Splice → Cross3 (optical, planned)
|
||||
{ id: 'line-7', name: 'ВОК Муфта-Северный', templateName: '', status: EntityStatus.Planned, type: LineType.Simple, medium: Medium.Optical, lineStyle: LineStyle.Dashed, portAId: 'p-sp-3', portZId: 'p-c3-l1' },
|
||||
// Cross3 → OLT (optical, under construction)
|
||||
{ id: 'line-8', name: 'ВОК Кросс3-OLT', templateName: '', status: EntityStatus.UnderConstruction, type: LineType.Simple, medium: Medium.Optical, lineStyle: LineStyle.Solid, portAId: 'p-c3-s1', portZId: 'p-olt-1' },
|
||||
// OLT → xDSL (copper, faulty)
|
||||
{ id: 'line-9', name: 'Транзит OLT-DSLAM', templateName: '', status: EntityStatus.Faulty, type: LineType.Simple, medium: Medium.Copper, lineStyle: LineStyle.Solid, portAId: 'p-olt-3', portZId: 'p-xdsl-3' },
|
||||
// Cross2 → Router (optical, reserved)
|
||||
{ id: 'line-10', name: 'Резерв Кросс2-RTR', templateName: '', status: EntityStatus.Reserved, type: LineType.Simple, medium: Medium.Optical, lineStyle: LineStyle.Dotted, portAId: 'p-c2-s1', portZId: 'p-rtr-1' },
|
||||
// Router → Router (loopback copper)
|
||||
{ id: 'line-11', name: 'Перемычка RTR', templateName: '', status: EntityStatus.Active, type: LineType.Simple, medium: Medium.Copper, lineStyle: LineStyle.Solid, portAId: 'p-rtr-2', portZId: 'p-rtr-4' },
|
||||
// Cross1 → Cross2 (optical, active)
|
||||
{ id: 'line-12', name: 'Кросс-кросс', templateName: '', status: EntityStatus.Active, type: LineType.Simple, medium: Medium.Optical, lineStyle: LineStyle.Solid, portAId: 'p-c1-s4', portZId: 'p-c2-l1' },
|
||||
// Cross1 L4 → Cross3 L2 (optical, decommissioned)
|
||||
{ id: 'line-13', name: 'ВОК Центр-Север (выведен)', templateName: '', status: EntityStatus.Decommissioned, type: LineType.Simple, medium: Medium.Optical, lineStyle: LineStyle.Solid, portAId: 'p-c1-l4', portZId: 'p-c3-l2' },
|
||||
// Complex line: Cross3 → xDSL (optical, with fibers)
|
||||
{ id: 'line-14', name: 'Комплекс Кросс3-DSLAM', templateName: 'TEMPLATE-01', status: EntityStatus.Active, type: LineType.Complex, medium: Medium.Optical, lineStyle: LineStyle.Solid, portAId: 'p-c3-s2', portZId: 'p-xdsl-1' },
|
||||
// Switch → OLT uplink (copper, unknown)
|
||||
{ id: 'line-15', name: 'Транзит SW-OLT', templateName: '', status: EntityStatus.Unknown, type: LineType.Simple, medium: Medium.Copper, lineStyle: LineStyle.Dotted, portAId: 'p-sw-4', portZId: 'p-olt-4' },
|
||||
],
|
||||
|
||||
fibers: [
|
||||
{ id: 'fiber-1', name: 'Волокно 1', status: EntityStatus.Active, lineId: 'line-14', portAId: 'p-c3-s2', portZId: 'p-xdsl-1' },
|
||||
{ id: 'fiber-2', name: 'Волокно 2', status: EntityStatus.Planned, lineId: 'line-14', portAId: 'p-c3-s3', portZId: 'p-xdsl-2' },
|
||||
],
|
||||
};
|
||||
108
frontend/src/store/schemaStore.ts
Normal file
108
frontend/src/store/schemaStore.ts
Normal file
@ -0,0 +1,108 @@
|
||||
import { create } from 'zustand';
|
||||
import type { Graph } from '@antv/x6';
|
||||
|
||||
export interface DisplaySettings {
|
||||
showGrid: boolean;
|
||||
showMinimap: boolean;
|
||||
lineType: 'manhattan' | 'normal';
|
||||
snapToGrid: boolean;
|
||||
showLabels: boolean;
|
||||
}
|
||||
|
||||
export interface ContextMenuState {
|
||||
visible: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
type: 'site' | 'active-device' | 'passive-device' | 'line' | 'line-group' | 'blank';
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface SchemaStore {
|
||||
graph: Graph | null;
|
||||
mode: 'view' | 'edit';
|
||||
selectedElements: string[];
|
||||
displaySettings: DisplaySettings;
|
||||
contextMenu: ContextMenuState | null;
|
||||
rightPanelData: Record<string, unknown> | null;
|
||||
connectionsPanelData: Record<string, unknown> | null;
|
||||
connectionsPanelVisible: boolean;
|
||||
legendVisible: boolean;
|
||||
|
||||
setGraph: (graph: Graph) => void;
|
||||
setMode: (mode: 'view' | 'edit') => void;
|
||||
setSelectedElements: (elements: string[]) => void;
|
||||
toggleGrid: () => void;
|
||||
toggleMinimap: () => void;
|
||||
switchLineType: () => void;
|
||||
toggleSnapToGrid: () => void;
|
||||
toggleLabels: () => void;
|
||||
setContextMenu: (menu: ContextMenuState | null) => void;
|
||||
setRightPanelData: (data: Record<string, unknown> | null) => void;
|
||||
setConnectionsPanelData: (data: Record<string, unknown> | null) => void;
|
||||
setConnectionsPanelVisible: (visible: boolean) => void;
|
||||
setLegendVisible: (visible: boolean) => void;
|
||||
}
|
||||
|
||||
export const useSchemaStore = create<SchemaStore>((set) => ({
|
||||
graph: null,
|
||||
mode: 'view',
|
||||
selectedElements: [],
|
||||
displaySettings: {
|
||||
showGrid: true,
|
||||
showMinimap: true,
|
||||
lineType: 'manhattan',
|
||||
snapToGrid: true,
|
||||
showLabels: true,
|
||||
},
|
||||
contextMenu: null,
|
||||
rightPanelData: null,
|
||||
connectionsPanelData: null,
|
||||
connectionsPanelVisible: false,
|
||||
legendVisible: false,
|
||||
|
||||
setGraph: (graph) => set({ graph }),
|
||||
setMode: (mode) => set({ mode }),
|
||||
setSelectedElements: (elements) => set({ selectedElements: elements }),
|
||||
toggleGrid: () =>
|
||||
set((state) => ({
|
||||
displaySettings: {
|
||||
...state.displaySettings,
|
||||
showGrid: !state.displaySettings.showGrid,
|
||||
},
|
||||
})),
|
||||
toggleMinimap: () =>
|
||||
set((state) => ({
|
||||
displaySettings: {
|
||||
...state.displaySettings,
|
||||
showMinimap: !state.displaySettings.showMinimap,
|
||||
},
|
||||
})),
|
||||
switchLineType: () =>
|
||||
set((state) => ({
|
||||
displaySettings: {
|
||||
...state.displaySettings,
|
||||
lineType:
|
||||
state.displaySettings.lineType === 'manhattan' ? 'normal' : 'manhattan',
|
||||
},
|
||||
})),
|
||||
toggleSnapToGrid: () =>
|
||||
set((state) => ({
|
||||
displaySettings: {
|
||||
...state.displaySettings,
|
||||
snapToGrid: !state.displaySettings.snapToGrid,
|
||||
},
|
||||
})),
|
||||
toggleLabels: () =>
|
||||
set((state) => ({
|
||||
displaySettings: {
|
||||
...state.displaySettings,
|
||||
showLabels: !state.displaySettings.showLabels,
|
||||
},
|
||||
})),
|
||||
setContextMenu: (menu) => set({ contextMenu: menu }),
|
||||
setRightPanelData: (data) => set({ rightPanelData: data }),
|
||||
setConnectionsPanelData: (data) => set({ connectionsPanelData: data }),
|
||||
setConnectionsPanelVisible: (visible) =>
|
||||
set({ connectionsPanelVisible: visible }),
|
||||
setLegendVisible: (visible) => set({ legendVisible: visible }),
|
||||
}));
|
||||
13
frontend/src/types/graph.ts
Normal file
13
frontend/src/types/graph.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import type { Node, Edge } from '@antv/x6';
|
||||
|
||||
export type GraphNodeConfig = Node.Metadata & { parent?: string };
|
||||
|
||||
export type GraphEdgeConfig = Edge.Metadata;
|
||||
|
||||
export interface GraphBuildResult {
|
||||
nodes: GraphNodeConfig[];
|
||||
edges: GraphEdgeConfig[];
|
||||
}
|
||||
|
||||
export type X6Node = Node;
|
||||
export type X6Edge = Edge;
|
||||
136
frontend/src/types/index.ts
Normal file
136
frontend/src/types/index.ts
Normal file
@ -0,0 +1,136 @@
|
||||
export enum EntityStatus {
|
||||
Active = 'active',
|
||||
Planned = 'planned',
|
||||
UnderConstruction = 'under_construction',
|
||||
Reserved = 'reserved',
|
||||
Faulty = 'faulty',
|
||||
Decommissioned = 'decommissioned',
|
||||
Unknown = 'unknown',
|
||||
}
|
||||
|
||||
export enum DeviceCategory {
|
||||
CrossOptical = 'cross_optical',
|
||||
CrossCopper = 'cross_copper',
|
||||
RRL = 'rrl',
|
||||
Wireless = 'wireless',
|
||||
Satellite = 'satellite',
|
||||
TSPU = 'tspu',
|
||||
DWDM = 'dwdm',
|
||||
MEN = 'men',
|
||||
SDH = 'sdh',
|
||||
MultiservicePlatform = 'multiservice_platform',
|
||||
IP = 'ip',
|
||||
OpticalModem = 'optical_modem',
|
||||
OpticalMux = 'optical_mux',
|
||||
LanWlan = 'lan_wlan',
|
||||
RanController = 'ran_controller',
|
||||
MGN = 'mgn',
|
||||
MGX = 'mgx',
|
||||
Server = 'server',
|
||||
SORM = 'sorm',
|
||||
MOB = 'mob',
|
||||
FIX = 'fix',
|
||||
VOIP = 'voip',
|
||||
xDSL = 'xdsl',
|
||||
PDH = 'pdh',
|
||||
RanBaseStation = 'ran_base_station',
|
||||
Unknown = 'unknown',
|
||||
VideoSurveillance = 'video_surveillance',
|
||||
}
|
||||
|
||||
export enum DeviceGroup {
|
||||
Active = 'active',
|
||||
Passive = 'passive',
|
||||
}
|
||||
|
||||
export enum Medium {
|
||||
Optical = 'optical',
|
||||
Copper = 'copper',
|
||||
Wireless = 'wireless',
|
||||
Unknown = 'unknown',
|
||||
}
|
||||
|
||||
export enum LineStyle {
|
||||
Solid = 'solid',
|
||||
Dashed = 'dashed',
|
||||
Dotted = 'dotted',
|
||||
}
|
||||
|
||||
export enum LineType {
|
||||
Simple = 'simple',
|
||||
Complex = 'complex',
|
||||
}
|
||||
|
||||
export interface Site {
|
||||
id: string;
|
||||
name: string;
|
||||
address: string;
|
||||
erpCode: string;
|
||||
code1C: string;
|
||||
status: EntityStatus;
|
||||
parentSiteId: string | null;
|
||||
}
|
||||
|
||||
export interface Device {
|
||||
id: string;
|
||||
name: string;
|
||||
networkName: string;
|
||||
ipAddress: string;
|
||||
marking: string;
|
||||
id1: string;
|
||||
id2: string;
|
||||
group: DeviceGroup;
|
||||
category: DeviceCategory;
|
||||
status: EntityStatus;
|
||||
siteId: string;
|
||||
}
|
||||
|
||||
export interface Card {
|
||||
id: string;
|
||||
slotName: string;
|
||||
networkName: string;
|
||||
status: EntityStatus;
|
||||
visible: boolean;
|
||||
deviceId: string;
|
||||
}
|
||||
|
||||
export interface Port {
|
||||
id: string;
|
||||
name: string;
|
||||
slotName: string;
|
||||
side: 'left' | 'right';
|
||||
sortOrder: number;
|
||||
labelColor: string;
|
||||
deviceId: string;
|
||||
cardId: string | null;
|
||||
}
|
||||
|
||||
export interface Line {
|
||||
id: string;
|
||||
name: string;
|
||||
templateName: string;
|
||||
status: EntityStatus;
|
||||
type: LineType;
|
||||
medium: Medium;
|
||||
lineStyle: LineStyle;
|
||||
portAId: string;
|
||||
portZId: string;
|
||||
}
|
||||
|
||||
export interface Fiber {
|
||||
id: string;
|
||||
name: string;
|
||||
status: EntityStatus;
|
||||
lineId: string;
|
||||
portAId: string;
|
||||
portZId: string;
|
||||
}
|
||||
|
||||
export interface SchemaData {
|
||||
sites: Site[];
|
||||
devices: Device[];
|
||||
cards: Card[];
|
||||
ports: Port[];
|
||||
lines: Line[];
|
||||
fibers: Fiber[];
|
||||
}
|
||||
14
frontend/src/utils/index.ts
Normal file
14
frontend/src/utils/index.ts
Normal file
@ -0,0 +1,14 @@
|
||||
let counter = 0;
|
||||
|
||||
export function generateId(prefix = 'id'): string {
|
||||
counter += 1;
|
||||
return `${prefix}-${Date.now()}-${counter}`;
|
||||
}
|
||||
|
||||
export function isCrossDevice(category: string): boolean {
|
||||
return category === 'cross_optical' || category === 'cross_copper';
|
||||
}
|
||||
|
||||
export function isSpliceDevice(category: string): boolean {
|
||||
return category === 'splice';
|
||||
}
|
||||
Reference in New Issue
Block a user