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:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user