|
|
@@ -84,17 +84,18 @@ function CoverageFlowBoard({ data }: { data: any }) {
|
|
|
return { list: res, totalWidth: curX - GAP_X + 40 };
|
|
|
}, [data.nodes, selectedDetailNode]);
|
|
|
|
|
|
- const DETAILED_FLOWS = [
|
|
|
- { source: 'req', target: 'node', label: '得到需求后,关联到实质等节点', triggerNodes: ['node', 'req'] },
|
|
|
- { source: 'tool', target: 'cap', label: '新工具关联', triggerNodes: ['tool', 'cap'] },
|
|
|
- { source: 'tool', target: 'proc', label: '新工具关联', triggerNodes: ['tool', 'proc'] },
|
|
|
- { source: 'cap', target: 'req', label: '参考已有能力', triggerNodes: ['cap', 'req'] },
|
|
|
- { source: 'node', target: 'req', label: '制作需求归纳', triggerNodes: ['node', 'req'] },
|
|
|
- { source: 'req', target: 'proc', label: '搜索工序', triggerNodes: ['req', 'proc'] },
|
|
|
- { source: 'req', target: 'cap', label: '总结工序时,复用、更新、创建能力', triggerNodes: ['req', 'cap'] },
|
|
|
- { source: 'cap', target: 'proc', label: '参考已有能力', triggerNodes: ['cap', 'proc'] },
|
|
|
- { source: 'proc', target: 'tool', label: '记录新工具需求', triggerNodes: ['proc', 'tool'] },
|
|
|
- { source: 'cap', target: 'tool', label: '记录新工具需求', triggerNodes: ['cap', 'tool'] },
|
|
|
+ const DETAILED_FLOWS: Array<{ source: string, target: string, label: string, type: 'source' | 'association' }> = [
|
|
|
+ { source: 'req', target: 'node', label: '得到需求后,关联到实质等节点 [已实现待执行]', type: 'association' },
|
|
|
+ { source: 'tool', target: 'cap', label: '新工具关联 [已实现待执行]', type: 'association' },
|
|
|
+ { source: 'tool', target: 'proc', label: '新工具关联 [已实现待执行]', type: 'association' },
|
|
|
+ { source: 'tool', target: 'cap', label: '以工具为线索、调研归纳 [已完成]', type: 'source' },
|
|
|
+ { source: 'cap', target: 'req', label: '参考已有能力', type: 'source' },
|
|
|
+ { source: 'node', target: 'req', label: '制作需求归纳 [已完成]', type: 'source' },
|
|
|
+ { source: 'req', target: 'proc', label: '逐个需求调研、总结工序 [5%测试]', type: 'source' },
|
|
|
+ { source: 'req', target: 'cap', label: '总结工序时,复用、更新、创建能力 [5%测试]', type: 'source' },
|
|
|
+ { source: 'cap', target: 'proc', label: '参考已有能力', type: 'source' },
|
|
|
+ { source: 'proc', target: 'tool', label: '记录新工具需求', type: 'source' },
|
|
|
+ { source: 'cap', target: 'tool', label: '记录新工具需求', type: 'source' },
|
|
|
];
|
|
|
// --- 补充辅助流最终汇合后的状态文本配置 ---
|
|
|
const TARGET_STATUS_MAP: Record<string, string> = {
|
|
|
@@ -104,21 +105,7 @@ function CoverageFlowBoard({ data }: { data: any }) {
|
|
|
tool: '完成',
|
|
|
};
|
|
|
|
|
|
- const topOnlyFlows = DETAILED_FLOWS.filter((flow) => {
|
|
|
- const sIdx = data.nodes.findIndex((n: any) => n.key === flow.source);
|
|
|
- const tIdx = data.nodes.findIndex((n: any) => n.key === flow.target);
|
|
|
- const isForwardAdjacent = tIdx === sIdx + 1;
|
|
|
- return sIdx >= 0 && tIdx >= 0 && !isForwardAdjacent;
|
|
|
- });
|
|
|
- const getAdjacentDetailFlow = (leftKey?: string, rightKey?: string) => {
|
|
|
- if (!leftKey || !rightKey || !selectedDetailNode) return null;
|
|
|
- const pairFlows = DETAILED_FLOWS.filter((flow) => {
|
|
|
- const isForwardAdjacent = flow.source === leftKey && flow.target === rightKey;
|
|
|
- return isForwardAdjacent && flow.target === selectedDetailNode;
|
|
|
- });
|
|
|
- if (pairFlows.length === 0) return null;
|
|
|
- return pairFlows[0];
|
|
|
- };
|
|
|
+ const topOnlyFlows = DETAILED_FLOWS;
|
|
|
|
|
|
// --- 1. 整理常驻的单步覆盖率边数据 ---
|
|
|
const baseEdges = data.edges || [];
|
|
|
@@ -288,9 +275,9 @@ function CoverageFlowBoard({ data }: { data: any }) {
|
|
|
</defs>
|
|
|
|
|
|
{(() => {
|
|
|
- // Compute active top flows and sort them globally to assign non-overlapping lanes
|
|
|
+ // 仅对当前选中卡片的“入边”(即 target 为该节点)进行计算和渲染
|
|
|
const activeTopFlows = selectedDetailNode ? topOnlyFlows.filter((flow) =>
|
|
|
- flow.target === selectedDetailNode || flow.source === selectedDetailNode
|
|
|
+ flow.target === selectedDetailNode
|
|
|
) : [];
|
|
|
|
|
|
const sortedActiveFlows = [...activeTopFlows].sort((a, b) => {
|
|
|
@@ -301,109 +288,80 @@ function CoverageFlowBoard({ data }: { data: any }) {
|
|
|
return Math.abs(tIdxA - sIdxA) - Math.abs(tIdxB - sIdxB);
|
|
|
});
|
|
|
const flowLaneMap = new Map();
|
|
|
- sortedActiveFlows.forEach((flow, idx) => flowLaneMap.set(`${flow.source}-${flow.target}`, idx));
|
|
|
+ sortedActiveFlows.forEach((flow, idx) => flowLaneMap.set(`${flow.source}-${flow.target}-${flow.label}`, idx));
|
|
|
|
|
|
- return data.nodes.map((node: any, tIdx: number) => {
|
|
|
- const isSelectedTarget = node.key === selectedDetailNode;
|
|
|
- const targetFlows = activeTopFlows.filter((flow) => flow.target === node.key);
|
|
|
- if (targetFlows.length === 0) return null;
|
|
|
+ return sortedActiveFlows.map((flow, idx) => {
|
|
|
+ const sIdx = data.nodes.findIndex((n: any) => n.key === flow.source);
|
|
|
+ const tIdx = data.nodes.findIndex((n: any) => n.key === flow.target);
|
|
|
+ if (sIdx < 0 || tIdx < 0) return null;
|
|
|
|
|
|
- const tX = dynamicLayouts.list[tIdx]?.x || 0;
|
|
|
- const prevXWithWidth = tIdx > 0 ? (dynamicLayouts.list[tIdx - 1]?.x || 0) + NODE_W : tX - 100;
|
|
|
- const dropX = (tX + prevXWithWidth) / 2;
|
|
|
- const entryY = FLOW_Y;
|
|
|
+ const laneIdx = flowLaneMap.get(`${flow.source}-${flow.target}-${flow.label}`) ?? 0;
|
|
|
+ const isAdjacentForward = tIdx === sIdx + 1;
|
|
|
+
|
|
|
+ const sX = dynamicLayouts.list[sIdx]?.cx || 0;
|
|
|
+ const tX = dynamicLayouts.list[tIdx]?.cx || 0;
|
|
|
+ const dir = Math.sign(tX - sX);
|
|
|
const R = 12;
|
|
|
|
|
|
+ let pathD = '';
|
|
|
+ let labelY = 0;
|
|
|
+ let labelX = (sX + tX) / 2;
|
|
|
+
|
|
|
+ if (isAdjacentForward) {
|
|
|
+ // 紧挨着向右的直接用直线
|
|
|
+ const sRight = sX + NODE_W / 2;
|
|
|
+ const tLeft = tX - NODE_W / 2;
|
|
|
+ const y = CARD_TOP + TOP_H / 2;
|
|
|
+ pathD = `M ${sRight} ${y} L ${tLeft} ${y}`;
|
|
|
+ labelX = (sRight + tLeft) / 2;
|
|
|
+ labelY = y - 8;
|
|
|
+ } else {
|
|
|
+ // 其他任何跨步或后退的边,统统在从上方空域走!
|
|
|
+ const startY = CARD_TOP - 2;
|
|
|
+ const flowTrunkY = CARD_TOP - 26 - laneIdx * 18;
|
|
|
+ pathD = [
|
|
|
+ `M ${sX} ${startY}`,
|
|
|
+ `L ${sX} ${flowTrunkY + R}`,
|
|
|
+ `Q ${sX} ${flowTrunkY}, ${sX + dir * R} ${flowTrunkY}`,
|
|
|
+ `L ${tX - dir * R} ${flowTrunkY}`,
|
|
|
+ `Q ${tX} ${flowTrunkY}, ${tX} ${flowTrunkY + R}`,
|
|
|
+ `L ${tX} ${startY}`
|
|
|
+ ].join(' ');
|
|
|
+ labelY = flowTrunkY - 8;
|
|
|
+ }
|
|
|
+
|
|
|
+ const pathColor = getNodeColor(flow.source);
|
|
|
+
|
|
|
return (
|
|
|
- <g key={`top-group-${node.key}`}>
|
|
|
- {targetFlows.map((flow, idx) => {
|
|
|
- const sIdx = data.nodes.findIndex((n: any) => n.key === flow.source);
|
|
|
- if (sIdx < 0) return null;
|
|
|
-
|
|
|
- const laneIdx = flowLaneMap.get(`${flow.source}-${flow.target}`) ?? 0;
|
|
|
- const flowTrunkY = CARD_TOP - 26 - laneIdx * 18;
|
|
|
-
|
|
|
- const sX = dynamicLayouts.list[sIdx]?.cx || 0;
|
|
|
- const startY = CARD_TOP - 2;
|
|
|
- const dir = Math.sign(dropX - sX); // -1 if sX > dropX
|
|
|
-
|
|
|
- // Rounded polyline path computation
|
|
|
- const pathD = [
|
|
|
- `M ${sX} ${startY}`,
|
|
|
- `L ${sX} ${flowTrunkY + R}`,
|
|
|
- `Q ${sX} ${flowTrunkY}, ${sX + dir * R} ${flowTrunkY}`,
|
|
|
- `L ${dropX - dir * R} ${flowTrunkY}`,
|
|
|
- `Q ${dropX} ${flowTrunkY}, ${dropX} ${flowTrunkY + R}`,
|
|
|
- `L ${dropX} ${entryY - R}`,
|
|
|
- `Q ${dropX} ${entryY}, ${dropX + R} ${entryY}`,
|
|
|
- `L ${tX - 4} ${entryY}`
|
|
|
- ].join(' ');
|
|
|
-
|
|
|
- const pathColor = getNodeColor(flow.source);
|
|
|
- const labelX = (sX + dropX) / 2;
|
|
|
- const statusTextForEdge = TARGET_STATUS_MAP[flow.target] || '';
|
|
|
- const isSolidLine = statusTextForEdge.includes('完成');
|
|
|
-
|
|
|
- return (
|
|
|
- <g key={`top-edge-${idx}`} className="transition-opacity duration-500 opacity-100">
|
|
|
- <path
|
|
|
- d={pathD}
|
|
|
- fill="none"
|
|
|
- stroke={pathColor}
|
|
|
- strokeWidth="1.5"
|
|
|
- strokeLinecap="round"
|
|
|
- strokeLinejoin="round"
|
|
|
- markerEnd={`url(#arrow-top-${idx})`}
|
|
|
- />
|
|
|
-
|
|
|
- {/* 汇合前写入当前线条内容(放在横线处,纯文字无背景盒) */}
|
|
|
- <g className={cn("transition-opacity duration-300", !isSelectedTarget ? "opacity-0" : "opacity-100")} transform={`translate(${labelX}, ${flowTrunkY})`}>
|
|
|
- <text
|
|
|
- x={0}
|
|
|
- y={-6}
|
|
|
- fill={pathColor}
|
|
|
- fontSize="9"
|
|
|
- fontWeight="700"
|
|
|
- opacity="0.85"
|
|
|
- textAnchor="middle"
|
|
|
- letterSpacing="0.02em"
|
|
|
- >
|
|
|
- {flow.label}
|
|
|
- </text>
|
|
|
- </g>
|
|
|
- </g>
|
|
|
- );
|
|
|
- })}
|
|
|
-
|
|
|
- {/* 汇合后写状态 (完成,直接压在横线上) - 仅当有流向该卡片时渲染,如果是多条则共用。由于总览常驻时也要显示,直接绘制 */}
|
|
|
- <g className={cn("transition-opacity duration-500 delay-150", !isSelectedTarget ? "opacity-0" : "opacity-100")} transform={`translate(${dropX + 46}, ${entryY})`}>
|
|
|
- <rect
|
|
|
- x={-20}
|
|
|
- y={-9}
|
|
|
- rx={6}
|
|
|
- ry={6}
|
|
|
- width={40}
|
|
|
- height={18}
|
|
|
- fill="white"
|
|
|
- opacity="1"
|
|
|
- stroke={getNodeColor(node.key)}
|
|
|
- strokeOpacity="0.22"
|
|
|
+ <g key={`flow-${idx}`} className="transition-opacity duration-500 opacity-100">
|
|
|
+ <path
|
|
|
+ d={pathD}
|
|
|
+ fill="none"
|
|
|
+ stroke={pathColor}
|
|
|
+ strokeWidth="1.5"
|
|
|
+ strokeLinecap="round"
|
|
|
+ strokeLinejoin="round"
|
|
|
+ strokeDasharray={flow.type === 'association' ? "4 4" : undefined}
|
|
|
+ markerEnd={`url(#arrow-top-${idx})`}
|
|
|
/>
|
|
|
- <text
|
|
|
- x={0}
|
|
|
- y={3.5}
|
|
|
- fill={getNodeColor(node.key)}
|
|
|
- fontSize="9"
|
|
|
- fontWeight="800"
|
|
|
- textAnchor="middle"
|
|
|
- letterSpacing="0.02em"
|
|
|
- >
|
|
|
- {TARGET_STATUS_MAP[node.key] || '完成'}
|
|
|
- </text>
|
|
|
+ <g className="transition-opacity duration-300 opacity-100" transform={`translate(${labelX}, ${labelY})`}>
|
|
|
+ <text
|
|
|
+ x={0}
|
|
|
+ y={0}
|
|
|
+ fill={pathColor}
|
|
|
+ fontSize="9"
|
|
|
+ fontWeight="700"
|
|
|
+ opacity="0.85"
|
|
|
+ textAnchor="middle"
|
|
|
+ letterSpacing="0.02em"
|
|
|
+ >
|
|
|
+ {flow.label}
|
|
|
+ </text>
|
|
|
+ </g>
|
|
|
</g>
|
|
|
- </g>
|
|
|
- );
|
|
|
- })})()}
|
|
|
+ );
|
|
|
+ });
|
|
|
+ })()}
|
|
|
</svg>
|
|
|
|
|
|
{/* ================= 2. 卡片与主流程层 ================= */}
|
|
|
@@ -411,13 +369,6 @@ function CoverageFlowBoard({ data }: { data: any }) {
|
|
|
const style = nodeStyle[node.key] || nodeStyle.node;
|
|
|
const edge = baseEdges[i];
|
|
|
const x = dynamicLayouts.list[i]?.x || 0;
|
|
|
- const nextX = dynamicLayouts.list[i + 1]?.x || (x + NODE_W + GAP_X);
|
|
|
- const gapWidth = Math.max(0, nextX - (x + NODE_W));
|
|
|
- const adjacentDetailFlow = getAdjacentDetailFlow(node.key, data.nodes[i + 1]?.key);
|
|
|
- const mainFlowColor = adjacentDetailFlow ? getNodeColor(adjacentDetailFlow.source) : getNodeColor(node.key);
|
|
|
-
|
|
|
- const targetKeyForEdge = data.nodes[i + 1]?.key;
|
|
|
- const statusTextForEdge = targetKeyForEdge ? (TARGET_STATUS_MAP[targetKeyForEdge] || '') : '';
|
|
|
const isCardCompleted = (TARGET_STATUS_MAP[node.key] || '').includes('完成');
|
|
|
|
|
|
return (
|
|
|
@@ -447,46 +398,16 @@ function CoverageFlowBoard({ data }: { data: any }) {
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
-
|
|
|
- {/* === B. 上方主流程箭头 === */}
|
|
|
- {edge && i < data.nodes.length - 1 && edge.flowLabel !== '提取能力' && (
|
|
|
- <div
|
|
|
- className={cn(
|
|
|
- "absolute flex flex-col items-center justify-center z-10 transition-all duration-500",
|
|
|
- selectedDetailNode ? "opacity-85" : "opacity-100"
|
|
|
- )}
|
|
|
- style={{ left: x + NODE_W, top: FLOW_Y - 10, width: gapWidth, height: 20 }}
|
|
|
- >
|
|
|
- <div
|
|
|
- className={cn(
|
|
|
- "text-[10px] font-black tracking-widest absolute -top-5 whitespace-nowrap uppercase transition-opacity duration-300",
|
|
|
- (data.nodes[i + 1]?.key === selectedDetailNode || data.nodes[i]?.key === selectedDetailNode) ? "opacity-100" : "opacity-0"
|
|
|
- )}
|
|
|
- style={{ color: mainFlowColor }}
|
|
|
- >
|
|
|
- {edge.flowLabel}
|
|
|
- </div>
|
|
|
- <div className="w-full flex items-center px-1">
|
|
|
- <div
|
|
|
- className="flex-1 transition-all duration-300 h-[2px] rounded-full"
|
|
|
- style={{
|
|
|
- backgroundColor: mainFlowColor,
|
|
|
- borderColor: 'transparent',
|
|
|
- opacity: !selectedDetailNode ? 0 : ((data.nodes[i + 1]?.key === selectedDetailNode || data.nodes[i]?.key === selectedDetailNode) ? 0.95 : 0.05),
|
|
|
- }}
|
|
|
- />
|
|
|
- <svg className="w-2.5 h-3.5 -ml-[1px] transition-opacity duration-300"
|
|
|
- style={{ color: mainFlowColor, opacity: !selectedDetailNode ? 0 : ((data.nodes[i + 1]?.key === selectedDetailNode || data.nodes[i]?.key === selectedDetailNode) ? 0.95 : 0.05) }}
|
|
|
- viewBox="0 0 8 12" fill="currentColor">
|
|
|
- <path d="M0 0L8 6L0 12V0Z" />
|
|
|
- </svg>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- )}
|
|
|
</div>
|
|
|
);
|
|
|
})}
|
|
|
|
|
|
+ {/* ================= 图例 ================= */}
|
|
|
+ <div className="absolute left-6 bottom-4 flex gap-4 text-[11px] text-slate-500/80 font-bold z-50">
|
|
|
+ <div className="flex items-center gap-1.5"><div className="w-5 h-0 border-t-2 border-slate-400"></div>数据来源</div>
|
|
|
+ <div className="flex items-center gap-1.5"><div className="w-5 h-0 border-t-2 border-dashed border-slate-400"></div>数据关联</div>
|
|
|
+ </div>
|
|
|
+
|
|
|
{/* ================= 4. 底部覆盖数字与百分比徽章 ================= */}
|
|
|
{activeEdges.map((edge: any) => {
|
|
|
const isConnectedToSelectedNode =
|
|
|
@@ -1693,7 +1614,7 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
});
|
|
|
const finalTreeData = { ...data };
|
|
|
setTreeData(finalTreeData);
|
|
|
-
|
|
|
+
|
|
|
// 存入全局缓存
|
|
|
globalCacheTreeData = finalTreeData;
|
|
|
globalCacheDbData = { reqs, caps, tools, know, procs };
|