/
PashigorevAY
/
TestFastAPI
Обзор
Документация
Войти
/
PashigorevAY
/
TestFastAPI
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
frontend/src/components/PathEditor.tsx
482 строки
15 KB
Pashigorev
Статусы из файла
05 июл 2026, 22:12
05 июл 2026, 22:12
f5ff24a
Код
Авторство
О чём код?
import { useState, useEffect, useCallback, useRef } from 'react'; import ReactFlow, { ReactFlowProvider, addEdge, useNodesState, useEdgesState, Controls, Background, Handle, Position, Connection, Edge, Node, MarkerType, Panel, } from 'reactflow'; import 'reactflow/dist/style.css'; import { PathItem, PathStep, Task } from '../types'; import { api } from '../api'; import dagre from 'dagre'; // Настройки dagre const dagreGraph = new dagre.graphlib.Graph(); dagreGraph.setDefaultEdgeLabel(() => ({})); const nodeWidth = 172; const nodeHeight = 80; const getLayoutedElements = (nodes: Node[], edges: Edge[], direction = 'TB') => { const isHorizontal = direction === 'LR'; dagreGraph.setGraph({ rankdir: direction }); nodes.forEach((node) => { dagreGraph.setNode(node.id, { width: nodeWidth, height: nodeHeight }); }); edges.forEach((edge) => { dagreGraph.setEdge(edge.source, edge.target); }); dagre.layout(dagreGraph); return { nodes: nodes.map((node) => { const nodeWithPosition = dagreGraph.node(node.id); return { ...node, position: { x: nodeWithPosition.x - nodeWidth / 2, y: nodeWithPosition.y - nodeHeight / 2, }, }; }), edges, }; }; interface PathEditorProps { pathName: string; initialData: PathItem; onBack: () => void; } interface CustomNodeData { step: PathStep; onUpdate: (id: string, updates: Partial<PathStep>) => void; onDelete: (id: string) => void; availableTasks: string[]; } function CustomNode({ data, selected }: { data: CustomNodeData; selected: boolean }) { return ( <div style={{ padding: '12px', border: selected ? '2px solid #2196F3' : '1px solid #ddd', borderRadius: '8px', background: 'white', minWidth: '150px', boxShadow: selected ? '0 4px 12px rgba(0,0,0,0.15)' : '0 2px 4px rgba(0,0,0,0.05)', }} > <Handle type="target" position={Position.Top} /> <div style={{ fontWeight: 'bold', marginBottom: '4px' }}>{data.step.name}</div> <div style={{ fontSize: '12px', color: '#666' }}>ID: {data.step.id}</div> <div style={{ fontSize: '11px', marginTop: '6px', display: 'flex', gap: '4px', flexWrap: 'wrap' }}> <span style={{ background: data.step.optional ? '#E3F2FD' : '#F5F5F5', color: data.step.optional ? '#1565C0' : '#333', padding: '2px 6px', borderRadius: '4px', }}> {data.step.optional ? 'Опционально' : 'Обязательно'} </span> </div> <Handle type="source" position={Position.Bottom} /> </div> ); } const nodeTypes = { custom: CustomNode }; function PathEditorContent({ pathName, initialData, onBack }: PathEditorProps) { const reactFlowWrapper = useRef<HTMLDivElement>(null); const [nodes, setNodes, onNodesChange] = useNodesState([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); const [reactFlowInstance, setReactFlowInstance] = useState<any>(null); const [pathData, setPathData] = useState<PathItem>(initialData); const [originalData, setOriginalData] = useState<PathItem>(JSON.parse(JSON.stringify(initialData))); const [tasks, setTasks] = useState<Task[]>([]); const [showAddNode, setShowAddNode] = useState(false); const [selectedTaskId, setSelectedTaskId] = useState(''); const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null); const [isInitialLoad, setIsInitialLoad] = useState(true); const [availableStatuses, setAvailableStatuses] = useState<string[]>([]); const hasChanges = JSON.stringify(pathData) !== JSON.stringify(originalData); const selectedStep = pathData.path.find(s => s.id === selectedNodeId); useEffect(() => { loadTasks(); loadStatuses(); }, [pathData.tasks_group]); useEffect(() => { if (isInitialLoad) { buildFlow(); setIsInitialLoad(false); } }, [isInitialLoad]); async function loadTasks() { try { const tasksData = await api.getTasks(pathData.tasks_group); setTasks(tasksData.tasks); } catch (e) { console.error('Failed to load tasks', e); } } async function loadStatuses() { try { const data = await api.getStatuses(); setAvailableStatuses(data.statuses); } catch (e) { console.error('Failed to load statuses', e); } } function buildFlow() { const initialNodes: Node<CustomNodeData>[] = pathData.path.map((step) => ({ id: step.id, type: 'custom', position: { x: 0, y: 0 }, data: { step, onUpdate: updateStep, onDelete: deleteStep, availableTasks: tasks.map(t => t.id), }, })); const initialEdges: Edge[] = []; pathData.path.forEach((step) => { step.depends_on.forEach((depId) => { initialEdges.push({ id: `e-${depId}-${step.id}`, source: depId, target: step.id, markerEnd: { type: MarkerType.ArrowClosed }, }); }); }); // Применяем автоматическое расположение const { nodes: layoutedNodes, edges: layoutedEdges } = getLayoutedElements( initialNodes, initialEdges ); setNodes(layoutedNodes); setEdges(layoutedEdges); } function handleLayout() { const { nodes: layoutedNodes, edges: layoutedEdges } = getLayoutedElements( nodes, edges ); setNodes(layoutedNodes); setEdges(layoutedEdges); } const onConnect = useCallback( (params: Connection) => { setEdges((eds) => { const newEdges = addEdge(params, eds); syncPathDataFromFlow(newEdges); return newEdges; }); }, [] ); function syncPathDataFromFlow(currentEdges: Edge[]) { setPathData((prevPathData) => { // Сохраняем текущие шаги, но обновляем depends_on из ребер const newPath = prevPathData.path.map((step) => { const deps = currentEdges .filter((e) => e.target === step.id) .map((e) => e.source); return { ...step, depends_on: deps }; }); return { ...prevPathData, path: newPath }; }); } const onNodesChangeHandler = useCallback( (changes: any) => { onNodesChange(changes); changes.forEach((change: any) => { if (change.type === 'select') { if (change.selected) { setSelectedNodeId(change.id); } else if (change.id === selectedNodeId && !change.selected) { setSelectedNodeId(null); } } }); }, [onNodesChange, selectedNodeId] ); const onEdgesChangeHandler = useCallback( (changes: any) => { onEdgesChange(changes); // После любых изменений ребер обновляем данные setTimeout(() => { setEdges((currentEdges) => { syncPathDataFromFlow(currentEdges); return currentEdges; }); }, 0); }, [onEdgesChange] ); function updateStep(id: string, updates: Partial<PathStep>) { const newPath = pathData.path.map((step) => step.id === id ? { ...step, ...updates } : step ); setPathData({ ...pathData, path: newPath }); // Обновляем данные в узле setNodes((nds) => nds.map((node) => { if (node.id === id) { return { ...node, data: { ...node.data, step: { ...node.data.step, ...updates }, }, }; } return node; }) ); } function deleteStep(id: string) { if (confirm('Удалить шаг?')) { const newPath = pathData.path.filter((s) => s.id !== id); const newPathWithUpdatedDeps = newPath.map((s) => ({ ...s, depends_on: s.depends_on.filter((d) => d !== id), })); setPathData({ ...pathData, path: newPathWithUpdatedDeps }); // Удаляем узел и связанные ребра setNodes((nds) => nds.filter((n) => n.id !== id)); setEdges((eds) => eds.filter((e) => e.source !== id && e.target !== id)); if (selectedNodeId === id) setSelectedNodeId(null); } } function addStep() { if (!selectedTaskId) return; const task = tasks.find((t) => t.id === selectedTaskId); if (!task) return; const newStep: PathStep = { id: task.id, name: task.title, optional: false, statuses: [...availableStatuses], // Используем все доступные статусы depends_on: [], }; const newPath = [...pathData.path, newStep]; setPathData({ ...pathData, path: newPath }); // Добавляем новый узел const newNode: Node<CustomNodeData> = { id: task.id, type: 'custom', position: { x: Math.random() * 400, y: Math.random() * 300 }, data: { step: newStep, onUpdate: updateStep, onDelete: deleteStep, availableTasks: tasks.map(t => t.id), }, }; setNodes((nds) => [...nds, newNode]); setShowAddNode(false); setSelectedTaskId(''); } async function handleSave() { await api.savePath(pathName, pathData); setOriginalData(JSON.parse(JSON.stringify(pathData))); } return ( <div style={{ height: '100vh', display: 'flex', flexDirection: 'column' }}> <div style={{ padding: '16px 20px', background: 'white', borderBottom: '1px solid #ddd', display: 'flex', gap: '10px', alignItems: 'center', }}> <button onClick={onBack} style={{ backgroundColor: '#e0e0e0' }}>← Назад</button> <h2 style={{ flex: 1, margin: 0 }}>Путь: {pathName} (группа: {pathData.tasks_group})</h2> <button onClick={handleLayout} style={{ backgroundColor: '#FF9800', color: 'white' }}> Расположить </button> <button onClick={() => setShowAddNode(true)} style={{ backgroundColor: '#4CAF50', color: 'white' }}> Добавить шаг </button> <button onClick={handleSave} disabled={!hasChanges} style={{ backgroundColor: hasChanges ? '#2196F3' : '#e0e0e0', color: 'white' }}> Сохранить </button> </div> <div style={{ display: 'flex', flex: 1 }}> <div ref={reactFlowWrapper} style={{ flex: 1 }}> <ReactFlow nodes={nodes} edges={edges} onNodesChange={onNodesChangeHandler} onEdgesChange={onEdgesChangeHandler} onConnect={onConnect} onInit={setReactFlowInstance} nodeTypes={nodeTypes} fitView > <Background /> <Controls /> </ReactFlow> </div> {selectedStep && ( <div style={{ width: '300px', background: 'white', borderLeft: '1px solid #ddd', padding: '20px', overflowY: 'auto', }}> <h3 style={{ margin: '0 0 16px 0' }}>Редактирование шага</h3> <div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}> <div> <label style={{ display: 'block', marginBottom: '4px' }}>Название</label> <input value={selectedStep.name} onChange={(e) => updateStep(selectedStep.id, { name: e.target.value })} style={{ width: '100%', padding: '8px' }} /> </div> <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}> <input type="checkbox" id="optional" checked={selectedStep.optional} onChange={(e) => updateStep(selectedStep.id, { optional: e.target.checked })} /> <label htmlFor="optional" style={{ margin: 0 }}>Опционально</label> </div> <div> <label style={{ display: 'block', marginBottom: '4px' }}>Статусы</label> <div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}> {availableStatuses.map((status) => ( <div key={status} style={{ display: 'flex', alignItems: 'center', gap: '8px' }}> <input type="checkbox" id={`status-${status}`} checked={selectedStep.statuses.includes(status)} onChange={(e) => { const currentStatuses = selectedStep.statuses; const newStatuses = e.target.checked ? [...currentStatuses, status] : currentStatuses.filter(s => s !== status); updateStep(selectedStep.id, { statuses: newStatuses }); }} /> <label htmlFor={`status-${status}`} style={{ margin: 0 }}>{status}</label> </div> ))} </div> </div> <button onClick={() => deleteStep(selectedStep.id)} style={{ marginTop: '16px', backgroundColor: '#F44336', color: 'white', border: 'none', padding: '8px 16px', borderRadius: '4px', cursor: 'pointer' }} > Удалить шаг </button> </div> </div> )} </div> {showAddNode && ( <div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000, }}> <div style={{ background: 'white', padding: '24px', borderRadius: '8px', minWidth: '400px' }}> <h3>Добавить шаг</h3> <div style={{ marginTop: '16px' }}> <label style={{ display: 'block', marginBottom: '4px' }}>Выберите задачу</label> <select value={selectedTaskId} onChange={(e) => setSelectedTaskId(e.target.value)} style={{ width: '100%', padding: '8px' }} > <option value="">-- Выберите --</option> {tasks.map((task) => ( <option key={task.id} value={task.id} disabled={pathData.path.some(s => s.id === task.id)} > {task.title} ({task.id}) </option> ))} </select> </div> <div style={{ marginTop: '20px', display: 'flex', gap: '10px', justifyContent: 'flex-end' }}> <button onClick={() => setShowAddNode(false)} style={{ backgroundColor: '#e0e0e0' }}> Отмена </button> <button onClick={addStep} disabled={!selectedTaskId} style={{ backgroundColor: '#4CAF50', color: 'white' }}> Добавить </button> </div> </div> </div> )} </div> ); } export function PathEditor(props: PathEditorProps) { return ( <ReactFlowProvider> <PathEditorContent {...props} /> </ReactFlowProvider> ); }