import React, { useMemo, useEffect, useRef } from 'react'; import ReactFlow, { ReactFlowProvider, useReactFlow, Background, Controls, Panel, Handle, Position, } from 'reactflow'; import type { Node, Edge, NodeProps } from 'reactflow'; import { toPng } from 'html-to-image'; import 'reactflow/dist/style.css'; import { useStore } from '../../store/useStore'; import type { TestCaseNode, Priority } from '../../store/useStore'; import { Plus, CornerDownRight, ChevronRight, ChevronDown, Bug, Link, Trash2 } from 'lucide-react'; import { useToastStore } from '../layout/Toast'; // Custom Node Component const MindMapNode = ({ data }: NodeProps) => { const node = data.node as TestCaseNode; const { updateNode, addNode, addSiblingNode, deleteNode, selectedNodeId, setSelectedNodeId, editingNodeId, setEditingNodeId } = useStore(); const isSelected = selectedNodeId === node.id; const isEditing = editingNodeId === node.id; const isSet = !node.caseId; // Local state for editing — avoids store re-renders breaking the input const [editingText, setEditingText] = React.useState(node.text); React.useEffect(() => { if (isEditing) setEditingText(node.text); }, [isEditing, node.id]); const commitEdit = () => { updateNode(node.id, { text: editingText }); setEditingNodeId(null); }; const priorityColors: Record = { P0: '#F53F3F', P1: '#FF7D00', P2: '#F7BA1E', P3: '#165DFF', }; const statusStyles: Record = { PASS: { border: '#00B42A', bg: '#E8FFFB', color: '#00B42A' }, FAIL: { border: '#F53F3F', bg: '#FFECE8', color: '#F53F3F' }, BLOCK: { border: '#FF7D00', bg: '#FFF7E8', color: '#FF7D00' }, UNTESTED: { border: '#E5E6EB', bg: 'white', color: '#86909C' } }; const currentStatus = node.executionStatus || 'UNTESTED'; const statusStyle = statusStyles[currentStatus]; return (
{ e.stopPropagation(); setSelectedNodeId(node.id); }} onDoubleClick={(e) => { e.stopPropagation(); setEditingNodeId(node.id); }} >
{node.priority && ( {node.priority} )} {isEditing ? ( setEditingText(e.target.value)} onBlur={commitEdit} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); commitEdit(); } if (e.key === 'Escape') { setEditingText(node.text); setEditingNodeId(null); } }} placeholder="" /> ) : ( {node.text || '未命名'} )}
{(node.requirementId || node.bugId || (node.tags && node.tags.length > 0)) && (
{node.requirementId && ( {node.requirementId} )} {node.bugId && ( {node.bugId} )} {node.tags && node.tags.map((tag, idx) => ( {tag} ))}
)}
{node.children && node.children.length > 0 && ( )}
); }; const nodeTypes = { mindMap: MindMapNode, }; interface MindMapProps { selectedModuleId?: string | null; onClearModuleSelection?: () => void; executionMode?: boolean; } const MindMapInner: React.FC = ({ selectedModuleId, onClearModuleSelection, executionMode }) => { const { spaceData, currentSpaceId, selectedNodeId, editingNodeId, addNode, addSiblingNode, deleteNode, setEditingNodeId, selectedPlanId, testPlans } = useStore(); const testCases = spaceData[currentSpaceId] || []; const { addToast } = useToastStore(); const { fitView } = useReactFlow(); const handleExportImage = () => { const element = document.querySelector('.react-flow__viewport') as HTMLElement; if (!element) return; addToast('正在生成图片...', 'info'); toPng(document.querySelector('.react-flow') as HTMLElement, { backgroundColor: '#f8fafc', filter: (node) => { // Exclude controls and panels from the export if (node?.classList?.contains('react-flow__controls') || node?.classList?.contains('react-flow__panel')) { return false; } return true; } }).then((dataUrl) => { const link = document.createElement('a'); link.download = `mindmap-${currentSpaceId}-${Date.now()}.png`; link.href = dataUrl; link.click(); addToast('导出成功', 'success'); }).catch((err) => { console.error('Export failed', err); addToast('导出失败', 'error'); }); }; const handleAutoLayout = () => { fitView({ duration: 800, padding: 0.2 }); addToast('布局已整理', 'success'); }; const activePlanCaseIds = useMemo(() => { if (!selectedPlanId) return null; const plan = testPlans.find(p => p.id === selectedPlanId); return plan ? new Set(plan.caseIds) : null; }, [selectedPlanId, testPlans]); // Keyboard shortcuts handler useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { const state = useStore.getState(); const { selectedNodeId, editingNodeId, addNode, addSiblingNode, deleteNode, setEditingNodeId } = state; if (editingNodeId) return; if (!selectedNodeId) return; const target = e.target as HTMLElement; if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) { return; } switch (e.key) { case 'Tab': e.preventDefault(); e.stopPropagation(); addNode(selectedNodeId); break; case 'Enter': e.preventDefault(); e.stopPropagation(); addSiblingNode(selectedNodeId); break; case 'Backspace': case 'Delete': e.preventDefault(); e.stopPropagation(); deleteNode(selectedNodeId); break; case ' ': case 'F2': e.preventDefault(); e.stopPropagation(); setEditingNodeId(selectedNodeId); break; case 'Escape': e.preventDefault(); e.stopPropagation(); setEditingNodeId(null); break; default: break; } }; window.addEventListener('keydown', handleKeyDown, true); return () => window.removeEventListener('keydown', handleKeyDown, true); }, []); const { nodes, edges } = useMemo(() => { const initialNodes: Node[] = []; const initialEdges: Edge[] = []; const verticalSpacing = 90; const horizontalSpacing = 280; const getSubtreeHeight = (node: TestCaseNode): number => { if (!node.children || node.children.length === 0 || node.isExpanded === false) { return verticalSpacing; } return node.children.reduce((acc, child) => acc + getSubtreeHeight(child), 0); }; const filterTree = (nodes: TestCaseNode[]): TestCaseNode[] => { if (!activePlanCaseIds) return nodes; return nodes .map(node => { const children = node.children ? filterTree(node.children) : []; const isInPlan = activePlanCaseIds.has(node.id); if (isInPlan || children.length > 0) { return { ...node, children }; } return null; }) .filter((n): n is TestCaseNode => n !== null); }; const tree = filterTree(testCases); const filteredTestCases = (() => { if (executionMode) { // If in execution mode but plan not loaded/ready, show nothing instead of everything if (!activePlanCaseIds) return []; return tree; } if (!selectedModuleId) return []; // Focus mode: show nothing if not selected const findModuleNode = (nodes: TestCaseNode[]): TestCaseNode | null => { for (const node of nodes) { if (node.id === selectedModuleId) return node; if (node.children) { const found = findModuleNode(node.children); if (found) return found; } } return null; }; const modNode = findModuleNode(tree); return modNode ? [modNode] : []; })(); const traverse = (node: TestCaseNode, x: number, y: number, parentId: string | null = null) => { const currentId = node.id; initialNodes.push({ id: currentId, type: 'mindMap', data: { node }, position: { x, y }, }); if (parentId) { initialEdges.push({ id: `edge-${parentId}-${currentId}`, source: parentId, target: currentId, type: 'default', style: { stroke: '#86909C', strokeWidth: 2 }, }); } if (node.children && node.isExpanded !== false) { const nodeHeight = getSubtreeHeight(node); let startY = y - nodeHeight / 2; node.children.forEach((child) => { const childHeight = getSubtreeHeight(child); const childY = startY + childHeight / 2; traverse(child, x + horizontalSpacing, childY, currentId); startY += childHeight; }); } }; filteredTestCases.forEach((rootNode) => { // Always center the single root at 0,0 for clean layout traverse(rootNode, 0, 0); }); return { nodes: initialNodes, edges: initialEdges }; }, [testCases, activePlanCaseIds, selectedModuleId]); return (
{ setEditingNodeId(null); useStore.getState().setSelectedNodeId(null); onClearModuleSelection?.(); }} fitView fitViewOptions={{ padding: 0.2 }} minZoom={0.1} maxZoom={2} deleteKeyCode={null} selectionKeyCode={null} multiSelectionKeyCode={null} >
{!executionMode ? ( <>
Tab 添加子节点
Enter 添加同级
Delete 删除节点
空格/F2 编辑文本
) : (
执行预览模式
)}
); }; const MindMapView: React.FC = (props) => ( ); export default MindMapView;