619 lines
19 KiB
TypeScript
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;
|