FE/src/components/editor/MindMapView.tsx

619 lines
19 KiB
TypeScript

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<Priority, string> = {
P0: '#F53F3F',
P1: '#FF7D00',
P2: '#F7BA1E',
P3: '#165DFF',
};
const statusStyles: Record<string, { border: string, bg: string, color: string }> = {
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 (
<div
className={`mind-node status-${currentStatus} ${node.priority ? 'has-priority' : ''} ${isSelected ? 'selected' : ''} ${isSet ? 'is-set' : ''}`}
style={{
position: 'relative',
borderColor: isSelected ? 'var(--primary)' : statusStyle.border,
background: statusStyle.bg
}}
onClick={(e) => {
e.stopPropagation();
setSelectedNodeId(node.id);
}}
onDoubleClick={(e) => {
e.stopPropagation();
setEditingNodeId(node.id);
}}
>
<Handle type="target" position={Position.Left} style={{ background: '#165DFF', width: '8px', height: '8px' }} />
<div className="node-content">
<div className="node-main">
{node.priority && (
<span
className="priority-badge"
style={{ backgroundColor: priorityColors[node.priority] }}
>
{node.priority}
</span>
)}
{isEditing ? (
<input
autoFocus
className="node-input"
value={editingText}
onChange={(e) => 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=""
/>
) : (
<span className="node-text">{node.text || '未命名'}</span>
)}
</div>
{(node.requirementId || node.bugId || (node.tags && node.tags.length > 0)) && (
<div className="node-meta">
{node.requirementId && (
<span className="meta-badge req" title={`需求: ${node.requirementId}`}>
<Link size={10} /> {node.requirementId}
</span>
)}
{node.bugId && (
<span className="meta-badge bug" title={`Bug: ${node.bugId}`}>
<Bug size={10} /> {node.bugId}
</span>
)}
{node.tags && node.tags.map((tag, idx) => (
<span key={idx} className="meta-badge tag">
{tag}
</span>
))}
</div>
)}
<div className="node-actions">
<button onClick={(e) => { e.stopPropagation(); addNode(node.id); }} className="action-btn" title="添加子节点 (Tab)">
<Plus size={12} />
</button>
<button onClick={(e) => { e.stopPropagation(); addSiblingNode(node.id); }} className="action-btn" title="添加同级 (Enter)">
<CornerDownRight size={12} />
</button>
<button onClick={(e) => { e.stopPropagation(); deleteNode(node.id); }} className="action-btn action-btn-danger" title="删除 (Delete)">
<Trash2 size={12} />
</button>
</div>
</div>
<Handle type="source" position={Position.Right} style={{ background: '#165DFF', width: '8px', height: '8px' }} />
{node.children && node.children.length > 0 && (
<button
className="collapse-btn"
onClick={(e) => {
e.stopPropagation();
updateNode(node.id, { isExpanded: node.isExpanded === false ? true : false });
}}
>
{node.isExpanded === false ? <ChevronRight size={10} /> : <ChevronDown size={10} />}
</button>
)}
<style>{`
.mind-node {
background: white;
padding: 8px 12px;
border-radius: 8px;
border: 2px solid #E5E6EB;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
min-width: 140px;
transition: all 0.2s;
border-left-width: 6px; /* Status emphasis */
}
.mind-node.status-PASS { border-left-color: #00B42A; }
.mind-node.status-FAIL { border-left-color: #F53F3F; }
.mind-node.status-BLOCK { border-left-color: #FF7D00; }
.mind-node.status-UNTESTED { border-left-color: #E5E6EB; }
.mind-node:hover {
border-color: var(--primary);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.mind-node.selected {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(0, 102, 255, 0.2);
background: #F0F7FF;
}
.mind-node.is-set {
border-left: 4px solid var(--primary);
}
.node-content {
display: flex;
flex-direction: column;
gap: 4px;
}
.node-main {
display: flex;
align-items: center;
gap: 8px;
}
.node-meta {
display: flex;
gap: 6px;
margin-top: 2px;
}
.meta-badge {
font-size: 10px;
display: flex;
align-items: center;
gap: 2px;
padding: 2px 4px;
border-radius: 4px;
}
.meta-badge.req { background: #E8F4FF; color: #165DFF; }
.meta-badge.bug { background: #FFECE8; color: #F53F3F; }
.meta-badge.tag { background: #F2F3F5; color: #4E5969; border: 1px solid #E5E6EB; }
.node-text {
font-size: 13px;
font-weight: 500;
color: #1D2129;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.node-input {
border: none;
outline: none;
font-size: 13px;
font-weight: 500;
color: #1D2129;
width: 100%;
background: transparent;
}
.priority-badge {
font-size: 10px;
color: white;
padding: 1px 4px;
border-radius: 4px;
font-weight: 700;
}
.node-set-label {
font-size: 10px;
color: var(--primary);
background: #E8F4FF;
border: 1px solid rgba(22, 93, 255, 0.2);
border-radius: 4px;
padding: 1px 6px;
font-weight: 500;
display: inline-block;
align-self: flex-start;
margin-top: 2px;
letter-spacing: 0.5px;
}
.node-actions {
position: absolute;
right: -24px;
top: 50%;
transform: translateY(-50%);
opacity: 0;
transition: opacity 0.2s;
display: flex;
flex-direction: column;
gap: 2px;
}
.mind-node:hover .node-actions {
opacity: 1;
}
.action-btn {
border: none;
background: white;
border: 1px solid #E5E6EB;
width: 20px;
height: 20px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: var(--primary);
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}
.action-btn:hover {
background: #F0F7FF;
}
.action-btn-danger:hover {
background: #FFF1F0 !important;
color: #F53F3F !important;
border-color: #F53F3F !important;
}
.collapse-btn {
position: absolute;
right: -8px;
top: 50%;
transform: translateY(-50%);
width: 16px;
height: 16px;
border-radius: 50%;
border: 1px solid #E5E6EB;
background: white;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 10;
color: #86909C;
}
.collapse-btn:hover {
border-color: var(--primary);
color: var(--primary);
}
`}</style>
</div>
);
};
const nodeTypes = {
mindMap: MindMapNode,
};
interface MindMapProps {
selectedModuleId?: string | null;
onClearModuleSelection?: () => void;
executionMode?: boolean;
}
const MindMapInner: React.FC<MindMapProps> = ({ 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 (
<div className="mindmap-container" style={{ width: '100%', height: '100%', minHeight: '500px', position: 'relative' }}>
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
onPaneClick={() => {
setEditingNodeId(null);
useStore.getState().setSelectedNodeId(null);
onClearModuleSelection?.();
}}
fitView
fitViewOptions={{ padding: 0.2 }}
minZoom={0.1}
maxZoom={2}
deleteKeyCode={null}
selectionKeyCode={null}
multiSelectionKeyCode={null}
>
<Background color="#E5E6EB" gap={20} />
<Controls />
<Panel position="top-right">
<div className="map-toolbar glass">
<button className="tool-btn" onClick={handleAutoLayout}></button>
<button className="tool-btn" onClick={handleExportImage}></button>
</div>
</Panel>
<Panel position="bottom-right">
<div className="shortcuts-hint">
{!executionMode ? (
<>
<div className="shortcut-row"><kbd>Tab</kbd> </div>
<div className="shortcut-row"><kbd>Enter</kbd> </div>
<div className="shortcut-row"><kbd>Delete</kbd> </div>
<div className="shortcut-row"><kbd>/F2</kbd> </div>
</>
) : (
<div className="shortcut-row" style={{ color: '#165DFF', fontWeight: 600 }}></div>
)}
</div>
</Panel>
</ReactFlow>
<style>{`
.map-toolbar {
padding: 6px;
border-radius: 12px;
display: flex;
gap: 6px;
border: 1px solid var(--border-color);
box-shadow: var(--shadow-md);
}
.tool-btn {
background: white;
border: 1px solid #E5E6EB;
padding: 6px 14px;
font-size: 13px;
font-weight: 500;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
}
.tool-btn:hover {
background: #F2F3F5;
border-color: var(--primary);
color: var(--primary);
}
.tool-btn:active {
transform: scale(0.95);
}
.shortcuts-hint {
background: rgba(255,255,255,0.92);
backdrop-filter: blur(8px);
border: 1px solid #E5E6EB;
border-radius: 8px;
padding: 8px 12px;
display: flex;
flex-direction: column;
gap: 4px;
}
.shortcut-row {
font-size: 11px;
color: #86909C;
display: flex;
align-items: center;
gap: 8px;
}
.shortcut-row kbd {
background: #F2F3F5;
border: 1px solid #E5E6EB;
border-radius: 4px;
padding: 1px 6px;
font-size: 10px;
font-family: monospace;
color: #4E5969;
min-width: 40px;
text-align: center;
}
.react-flow__edge-path {
stroke: #86909C !important;
stroke-width: 2 !important;
}
`}</style>
</div>
);
};
const MindMapView: React.FC<MindMapProps> = (props) => (
<ReactFlowProvider>
<MindMapInner {...props} />
</ReactFlowProvider>
);
export default MindMapView;