|
|
@@ -6,6 +6,13 @@ import { SideDrawer } from '../components/common/SideDrawer';
|
|
|
import { cn } from '../lib/utils';
|
|
|
import { getRequirements, getCapabilities, getTools, getKnowledge, getResource, batchGetPosts, getStrategies } from '../services/api';
|
|
|
|
|
|
+// --- Dashboard 内存级全局缓存 (避免路由切换时重复发起耗时请求) ---
|
|
|
+let globalCacheLoaded = false;
|
|
|
+let globalCacheTreeData: any = null;
|
|
|
+let globalCacheDbData: any = null;
|
|
|
+let globalCacheNameToNodeMap: Record<string, any> = {};
|
|
|
+let globalCacheIdToNodeMap: Record<string, any> = {};
|
|
|
+
|
|
|
// ─── 数据仪表盘 ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
function CoverageFlowBoard({ data }: { data: any }) {
|
|
|
@@ -15,9 +22,9 @@ function CoverageFlowBoard({ data }: { data: any }) {
|
|
|
// --- 视觉主题配置 ---
|
|
|
const nodeStyle: Record<string, { themeText: string; bg: string; border: string; darkBg: string; ring: string; glow: string }> = {
|
|
|
node: { themeText: 'text-slate-700', bg: 'bg-slate-50', border: 'border-slate-200', darkBg: 'bg-slate-500', ring: 'ring-slate-400', glow: 'shadow-slate-200/80' },
|
|
|
- req: { themeText: 'text-indigo-700', bg: 'bg-indigo-50', border: 'border-indigo-200', darkBg: 'bg-indigo-500', ring: 'ring-indigo-400', glow: 'shadow-indigo-200/80' },
|
|
|
+ req: { themeText: 'text-indigo-700', bg: 'bg-indigo-50', border: 'border-indigo-200', darkBg: 'bg-indigo-500', ring: 'ring-indigo-400', glow: 'shadow-indigo-200/80' },
|
|
|
proc: { themeText: 'text-purple-700', bg: 'bg-purple-50', border: 'border-purple-200', darkBg: 'bg-purple-500', ring: 'ring-purple-400', glow: 'shadow-purple-200/80' },
|
|
|
- cap: { themeText: 'text-rose-700', bg: 'bg-rose-50', border: 'border-rose-200', darkBg: 'bg-rose-500', ring: 'ring-rose-400', glow: 'shadow-rose-200/80' },
|
|
|
+ cap: { themeText: 'text-rose-700', bg: 'bg-rose-50', border: 'border-rose-200', darkBg: 'bg-rose-500', ring: 'ring-rose-400', glow: 'shadow-rose-200/80' },
|
|
|
tool: { themeText: 'text-green-700', bg: 'bg-green-50', border: 'border-green-200', darkBg: 'bg-green-500', ring: 'ring-green-400', glow: 'shadow-green-200/80' },
|
|
|
};
|
|
|
|
|
|
@@ -27,7 +34,7 @@ function CoverageFlowBoard({ data }: { data: any }) {
|
|
|
if (textClass.includes('purple')) return '#a855f7';
|
|
|
if (textClass.includes('rose')) return '#f43f5e';
|
|
|
if (textClass.includes('green')) return '#22c55e';
|
|
|
- return '#cbd5e1';
|
|
|
+ return '#cbd5e1';
|
|
|
};
|
|
|
|
|
|
const getNodeColor = (key?: string) => {
|
|
|
@@ -53,16 +60,50 @@ function CoverageFlowBoard({ data }: { data: any }) {
|
|
|
// 常驻单步轨道的垂直深度
|
|
|
const TRACK_Y = CURVE_START_ABS_Y + 76;
|
|
|
|
|
|
+ // --- 动态布局坐标引擎 ---
|
|
|
+ const dynamicLayouts = useMemo(() => {
|
|
|
+ const selectedIndex = selectedDetailNode ? data.nodes.findIndex((n: any) => n.key === selectedDetailNode) : -1;
|
|
|
+ const res: { x: number, cx: number, scale: number }[] = [];
|
|
|
+ let curX = 20; // safe padding
|
|
|
+ for (let i = 0; i < data.nodes.length; i++) {
|
|
|
+ let scale = 1;
|
|
|
+ let extraBefore = 0;
|
|
|
+ if (selectedIndex !== -1) {
|
|
|
+ if (i === selectedIndex) {
|
|
|
+ scale = 1.05;
|
|
|
+ if (i > 0) extraBefore = 120; // larger space before selected for horizontal line
|
|
|
+ } else {
|
|
|
+ scale = 0.85;
|
|
|
+ if (i === selectedIndex + 1) extraBefore = 80; // extra space after selected
|
|
|
+ }
|
|
|
+ }
|
|
|
+ curX += extraBefore;
|
|
|
+ res.push({ x: curX, cx: curX + NODE_W / 2, scale });
|
|
|
+ curX += NODE_W + GAP_X;
|
|
|
+ }
|
|
|
+ return { list: res, totalWidth: curX - GAP_X + 40 };
|
|
|
+ }, [data.nodes, selectedDetailNode]);
|
|
|
+
|
|
|
const DETAILED_FLOWS = [
|
|
|
- { source: 'tool', target: 'cap', label: '制作能力归纳', triggerNodes: ['tool', 'cap'] },
|
|
|
+ { 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: 'cap', label: '归纳能力', triggerNodes: ['proc', 'cap'] },
|
|
|
- { source: 'proc', target: 'tool', label: '存储工具', triggerNodes: ['proc', 'tool'] },
|
|
|
- { source: 'cap', target: 'tool', label: '寻找工具', triggerNodes: ['cap', 'tool'] },
|
|
|
+ { source: 'proc', target: 'tool', label: '记录新工具需求', triggerNodes: ['proc', 'tool'] },
|
|
|
+ { source: 'cap', target: 'tool', label: '记录新工具需求', triggerNodes: ['cap', 'tool'] },
|
|
|
];
|
|
|
+ // --- 补充辅助流最终汇合后的状态文本配置 ---
|
|
|
+ const TARGET_STATUS_MAP: Record<string, string> = {
|
|
|
+ req: '图文已完成',
|
|
|
+ proc: '抽样测试',
|
|
|
+ cap: '抽样测试',
|
|
|
+ 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);
|
|
|
@@ -84,8 +125,8 @@ function CoverageFlowBoard({ data }: { data: any }) {
|
|
|
const activeEdges = baseEdges.map((edge: any, index: number) => {
|
|
|
if (!edge || edge.coveredCount === 0 || index >= data.nodes.length - 1) return null;
|
|
|
|
|
|
- const startX = index * STEP_X + NODE_W / 2;
|
|
|
- const endX = (index + 1) * STEP_X + NODE_W / 2;
|
|
|
+ const startX = dynamicLayouts.list[index]?.cx || 0;
|
|
|
+ const endX = dynamicLayouts.list[index + 1]?.cx || 0;
|
|
|
const startKey = data.nodes[index]?.key;
|
|
|
const endKey = data.nodes[index + 1]?.key;
|
|
|
const color = getStrokeColor(edge.textClass);
|
|
|
@@ -107,8 +148,8 @@ function CoverageFlowBoard({ data }: { data: any }) {
|
|
|
const targetIndex = data.nodes.findIndex((node: any) => node.key === edge.target);
|
|
|
if (sourceIndex < 0 || targetIndex < 0 || !edge.coveredCount) return null;
|
|
|
|
|
|
- const sourceCenterX = sourceIndex * STEP_X + NODE_W / 2;
|
|
|
- const targetCenterX = targetIndex * STEP_X + NODE_W / 2;
|
|
|
+ const sourceCenterX = dynamicLayouts.list[sourceIndex]?.cx || 0;
|
|
|
+ const targetCenterX = dynamicLayouts.list[targetIndex]?.cx || 0;
|
|
|
const span = Math.abs(targetIndex - sourceIndex);
|
|
|
const laneY = TRACK_Y + 56 + Math.max(0, span - 2) * 48;
|
|
|
const sourceBadgeX = sourceCenterX;
|
|
|
@@ -135,24 +176,24 @@ function CoverageFlowBoard({ data }: { data: any }) {
|
|
|
}).filter(Boolean);
|
|
|
const visibleCrossEdges = selectedDetailNode
|
|
|
? activeCrossEdges
|
|
|
- .filter((edge: any) => edge.source === selectedDetailNode || edge.target === selectedDetailNode)
|
|
|
- .map((edge: any) => {
|
|
|
- const selectedIsSource = edge.source === selectedDetailNode;
|
|
|
- const selectedIndex = selectedIsSource ? edge.sourceIndex : edge.targetIndex;
|
|
|
- const otherIndex = selectedIsSource ? edge.targetIndex : edge.sourceIndex;
|
|
|
- const selectedCenterX = selectedIndex * STEP_X + NODE_W / 2;
|
|
|
- const otherCenterX = otherIndex * STEP_X + NODE_W / 2;
|
|
|
- const selectedAnchorX = selectedIsSource ? selectedCenterX + 40 : selectedCenterX - 40;
|
|
|
- const otherAnchorX = otherIndex < selectedIndex ? otherCenterX + 40 : otherCenterX - 40;
|
|
|
- return {
|
|
|
- ...edge,
|
|
|
- sourceBadgeX: selectedIsSource ? selectedAnchorX : otherAnchorX,
|
|
|
- targetBadgeX: selectedIsSource ? otherAnchorX : selectedAnchorX,
|
|
|
- };
|
|
|
- })
|
|
|
+ .filter((edge: any) => edge.source === selectedDetailNode || edge.target === selectedDetailNode)
|
|
|
+ .map((edge: any) => {
|
|
|
+ const selectedIsSource = edge.source === selectedDetailNode;
|
|
|
+ const selectedIndex = selectedIsSource ? edge.sourceIndex : edge.targetIndex;
|
|
|
+ const otherIndex = selectedIsSource ? edge.targetIndex : edge.sourceIndex;
|
|
|
+ const selectedCenterX = dynamicLayouts.list[selectedIndex]?.cx || 0;
|
|
|
+ const otherCenterX = dynamicLayouts.list[otherIndex]?.cx || 0;
|
|
|
+ const selectedAnchorX = selectedIsSource ? selectedCenterX + 40 : selectedCenterX - 40;
|
|
|
+ const otherAnchorX = otherIndex < selectedIndex ? otherCenterX + 40 : otherCenterX - 40;
|
|
|
+ return {
|
|
|
+ ...edge,
|
|
|
+ sourceBadgeX: selectedIsSource ? selectedAnchorX : otherAnchorX,
|
|
|
+ targetBadgeX: selectedIsSource ? otherAnchorX : selectedAnchorX,
|
|
|
+ };
|
|
|
+ })
|
|
|
: [];
|
|
|
|
|
|
- const canvasWidth = data.nodes.length * NODE_W + (data.nodes.length - 1) * GAP_X;
|
|
|
+ const canvasWidth = dynamicLayouts.totalWidth;
|
|
|
|
|
|
const maxCrossLaneY = visibleCrossEdges.length > 0
|
|
|
? Math.max(...visibleCrossEdges.map((edge: any) => edge.laneY))
|
|
|
@@ -162,7 +203,7 @@ function CoverageFlowBoard({ data }: { data: any }) {
|
|
|
return (
|
|
|
<div className="rounded-3xl border border-slate-200 bg-white shadow-sm p-8 overflow-x-auto overflow-y-hidden custom-scrollbar">
|
|
|
<div className="relative mx-auto" style={{ width: canvasWidth, height: canvasHeight }}>
|
|
|
-
|
|
|
+
|
|
|
{/* ================= 1. 常驻直角实线布线层 (SVG) ================= */}
|
|
|
<svg className="absolute inset-0 w-full h-full pointer-events-none z-40" style={{ overflow: 'visible' }}>
|
|
|
<defs>
|
|
|
@@ -245,80 +286,139 @@ function CoverageFlowBoard({ data }: { data: any }) {
|
|
|
</marker>
|
|
|
))}
|
|
|
</defs>
|
|
|
- {selectedDetailNode && topOnlyFlows.filter((flow) => flow.target === selectedDetailNode).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 startX = sIdx * STEP_X + NODE_W / 2;
|
|
|
- const endX = tIdx * STEP_X + NODE_W / 2;
|
|
|
- const startY = CARD_TOP - 6;
|
|
|
- const span = Math.abs(sIdx - tIdx);
|
|
|
- const baseLift = span === 2 ? 54 : span === 3 ? 74 : 92;
|
|
|
- const laneOffset = (idx % 2) * 12;
|
|
|
- const topY = CARD_TOP - (baseLift + laneOffset);
|
|
|
- const cp1X = startX + (endX - startX) * 0.22;
|
|
|
- const cp2X = startX + (endX - startX) * 0.78;
|
|
|
- const pathD = `M ${startX} ${startY} C ${cp1X} ${topY}, ${cp2X} ${topY}, ${endX} ${startY}`;
|
|
|
- const labelY = topY - 8;
|
|
|
- const sourceColor = getNodeColor(flow.source);
|
|
|
- const isFromSelectedNode = flow.source === selectedDetailNode;
|
|
|
|
|
|
- return (
|
|
|
- <g key={`top-edge-${idx}`} className="animate-in fade-in zoom-in-95 duration-300">
|
|
|
- <path
|
|
|
- d={pathD}
|
|
|
- fill="none"
|
|
|
- stroke={sourceColor}
|
|
|
- strokeWidth="8"
|
|
|
- opacity="0.08"
|
|
|
- strokeLinecap="round"
|
|
|
- />
|
|
|
- <path
|
|
|
- d={pathD}
|
|
|
- fill="none"
|
|
|
- stroke={sourceColor}
|
|
|
- strokeWidth="2.75"
|
|
|
- strokeLinecap="round"
|
|
|
- markerEnd={`url(#arrow-top-${idx})`}
|
|
|
- />
|
|
|
- <g transform={`translate(${(startX + endX) / 2}, ${labelY - 12})`}>
|
|
|
+ {(() => {
|
|
|
+ // Compute active top flows and sort them globally to assign non-overlapping lanes
|
|
|
+ const activeTopFlows = selectedDetailNode ? topOnlyFlows.filter((flow) =>
|
|
|
+ flow.target === selectedDetailNode || flow.source === selectedDetailNode
|
|
|
+ ) : [];
|
|
|
+
|
|
|
+ const sortedActiveFlows = [...activeTopFlows].sort((a, b) => {
|
|
|
+ const sIdxA = data.nodes.findIndex((n: any) => n.key === a.source);
|
|
|
+ const tIdxA = data.nodes.findIndex((n: any) => n.key === a.target);
|
|
|
+ const sIdxB = data.nodes.findIndex((n: any) => n.key === b.source);
|
|
|
+ const tIdxB = data.nodes.findIndex((n: any) => n.key === b.target);
|
|
|
+ return Math.abs(tIdxA - sIdxA) - Math.abs(tIdxB - sIdxB);
|
|
|
+ });
|
|
|
+ const flowLaneMap = new Map();
|
|
|
+ sortedActiveFlows.forEach((flow, idx) => flowLaneMap.set(`${flow.source}-${flow.target}`, 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;
|
|
|
+
|
|
|
+ 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 R = 12;
|
|
|
+
|
|
|
+ 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={-46}
|
|
|
- y={-10}
|
|
|
- rx={10}
|
|
|
- ry={10}
|
|
|
- width={92}
|
|
|
- height={20}
|
|
|
+ x={-20}
|
|
|
+ y={-9}
|
|
|
+ rx={6}
|
|
|
+ ry={6}
|
|
|
+ width={40}
|
|
|
+ height={18}
|
|
|
fill="white"
|
|
|
- opacity="0.94"
|
|
|
- stroke={sourceColor}
|
|
|
+ opacity="1"
|
|
|
+ stroke={getNodeColor(node.key)}
|
|
|
strokeOpacity="0.22"
|
|
|
/>
|
|
|
<text
|
|
|
x={0}
|
|
|
- y={4}
|
|
|
- fill={sourceColor}
|
|
|
- fontSize="10.5"
|
|
|
+ y={3.5}
|
|
|
+ fill={getNodeColor(node.key)}
|
|
|
+ fontSize="9"
|
|
|
fontWeight="800"
|
|
|
textAnchor="middle"
|
|
|
- letterSpacing="0.01em"
|
|
|
+ letterSpacing="0.02em"
|
|
|
>
|
|
|
- {flow.label}
|
|
|
+ {TARGET_STATUS_MAP[node.key] || '完成'}
|
|
|
</text>
|
|
|
</g>
|
|
|
</g>
|
|
|
);
|
|
|
- })}
|
|
|
+ })})()}
|
|
|
</svg>
|
|
|
|
|
|
{/* ================= 2. 卡片与主流程层 ================= */}
|
|
|
{data.nodes.map((node: any, i: number) => {
|
|
|
const style = nodeStyle[node.key] || nodeStyle.node;
|
|
|
const edge = baseEdges[i];
|
|
|
- const x = i * STEP_X;
|
|
|
+ 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) : getStrokeColor(edge?.textClass);
|
|
|
+ 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 (
|
|
|
<div key={`node-${node.key}`}>
|
|
|
@@ -328,9 +428,11 @@ function CoverageFlowBoard({ data }: { data: any }) {
|
|
|
className={cn(
|
|
|
"absolute flex flex-col justify-between rounded-xl border bg-white p-3.5 z-20 cursor-pointer transition-all duration-300 group hover:-translate-y-1",
|
|
|
style.border,
|
|
|
+ !isCardCompleted ? "border-dashed" : "border-solid",
|
|
|
selectedDetailNode === node.key
|
|
|
- ? cn("shadow-lg ring-2 ring-offset-2 scale-[1.03]", style.ring, style.glow)
|
|
|
- : "shadow-sm hover:shadow-md"
|
|
|
+ ? cn("shadow-lg ring-2 ring-offset-2", style.ring, style.glow)
|
|
|
+ : "shadow-sm hover:shadow-md",
|
|
|
+ dynamicLayouts.list[i]?.scale === 1.05 ? "scale-[1.05]" : dynamicLayouts.list[i]?.scale === 0.85 ? "scale-[0.85]" : "scale-100"
|
|
|
)}
|
|
|
style={{ left: x, top: CARD_TOP, width: NODE_W, height: TOP_H }}
|
|
|
>
|
|
|
@@ -347,29 +449,35 @@ function CoverageFlowBoard({ data }: { data: any }) {
|
|
|
</div>
|
|
|
|
|
|
{/* === B. 上方主流程箭头 === */}
|
|
|
- {edge && i < data.nodes.length - 1 && (
|
|
|
+ {edge && i < data.nodes.length - 1 && edge.flowLabel !== '提取能力' && (
|
|
|
<div
|
|
|
className={cn(
|
|
|
- "absolute flex flex-col items-center justify-center z-10 transition-opacity duration-500",
|
|
|
+ "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: GAP_X, height: 20 }}
|
|
|
+ style={{ left: x + NODE_W, top: FLOW_Y - 10, width: gapWidth, height: 20 }}
|
|
|
>
|
|
|
<div
|
|
|
- className="text-[10px] font-black tracking-widest absolute -top-5 whitespace-nowrap uppercase"
|
|
|
- style={{ color: mainFlowColor, opacity: selectedDetailNode ? 0.95 : 1 }}
|
|
|
+ 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="h-[2px] flex-1 rounded-full"
|
|
|
+ className="flex-1 transition-all duration-300 h-[2px] rounded-full"
|
|
|
style={{
|
|
|
backgroundColor: mainFlowColor,
|
|
|
- opacity: selectedDetailNode ? 0.95 : 0.38,
|
|
|
+ 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]" style={{ color: mainFlowColor, opacity: selectedDetailNode ? 0.95 : 0.7 }} viewBox="0 0 8 12" fill="currentColor">
|
|
|
+ <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>
|
|
|
@@ -395,7 +503,7 @@ function CoverageFlowBoard({ data }: { data: any }) {
|
|
|
"border-slate-200",
|
|
|
selectedDetailNode && !isConnectedToSelectedNode ? "opacity-0" : "opacity-100"
|
|
|
)}
|
|
|
- style={{ color: edge.startColor }}>
|
|
|
+ style={{ color: edge.startColor }}>
|
|
|
{edge.leftCoverage}
|
|
|
</div>
|
|
|
</div>
|
|
|
@@ -409,7 +517,7 @@ function CoverageFlowBoard({ data }: { data: any }) {
|
|
|
"border-slate-200",
|
|
|
selectedDetailNode && !isConnectedToSelectedNode ? "opacity-0" : "opacity-100"
|
|
|
)}
|
|
|
- style={{ color: edge.endColor }}>
|
|
|
+ style={{ color: edge.endColor }}>
|
|
|
{edge.rightCoverage}
|
|
|
</div>
|
|
|
</div>
|
|
|
@@ -480,15 +588,15 @@ function RelationCard({ type, item, activeId, shouldScrollIntoView = false, sele
|
|
|
|
|
|
const allSourceNodeTags: string[] = type === 'req'
|
|
|
? (item.source_nodes || []).map((sn: any) =>
|
|
|
- typeof sn === 'object' ? (sn.node_name || sn.name || '') : sn
|
|
|
- ).filter((name: string) => Boolean(name) && name !== '__abstract__' && name !== '__meta__')
|
|
|
+ typeof sn === 'object' ? (sn.node_name || sn.name || '') : sn
|
|
|
+ ).filter((name: string) => Boolean(name) && name !== '__abstract__' && name !== '__meta__')
|
|
|
: [];
|
|
|
const sourceNodeTags: string[] = showAllSourceTags ? allSourceNodeTags : allSourceNodeTags.slice(0, 3);
|
|
|
const totalSourceNodes = type === 'req'
|
|
|
? (item.source_nodes || []).filter((sn: any) => {
|
|
|
- const name = typeof sn === 'object' ? (sn.node_name || sn.name || '') : sn;
|
|
|
- return Boolean(name) && name !== '__abstract__' && name !== '__meta__';
|
|
|
- }).length
|
|
|
+ const name = typeof sn === 'object' ? (sn.node_name || sn.name || '') : sn;
|
|
|
+ return Boolean(name) && name !== '__abstract__' && name !== '__meta__';
|
|
|
+ }).length
|
|
|
: 0;
|
|
|
const extraCount = type === 'req' ? Math.max(0, totalSourceNodes - 3) : 0;
|
|
|
|
|
|
@@ -496,10 +604,10 @@ function RelationCard({ type, item, activeId, shouldScrollIntoView = false, sele
|
|
|
|
|
|
// 语义颜色:每种类型对应一套颜色
|
|
|
const typeColors: Record<string, { accent: string; tagBg: string; tagText: string; leftBar: string }> = {
|
|
|
- req: { accent: 'border-l-indigo-400', tagBg: 'bg-indigo-50', tagText: 'text-indigo-700', leftBar: 'bg-indigo-400' },
|
|
|
+ req: { accent: 'border-l-indigo-400', tagBg: 'bg-indigo-50', tagText: 'text-indigo-700', leftBar: 'bg-indigo-400' },
|
|
|
proc: { accent: 'border-l-purple-400', tagBg: 'bg-purple-50', tagText: 'text-purple-700', leftBar: 'bg-purple-400' },
|
|
|
- cap: { accent: 'border-l-rose-400', tagBg: 'bg-rose-50', tagText: 'text-rose-700', leftBar: 'bg-rose-400' },
|
|
|
- tool: { accent: 'border-l-green-400', tagBg: 'bg-green-50', tagText: 'text-green-700', leftBar: 'bg-green-400' },
|
|
|
+ cap: { accent: 'border-l-rose-400', tagBg: 'bg-rose-50', tagText: 'text-rose-700', leftBar: 'bg-rose-400' },
|
|
|
+ tool: { accent: 'border-l-green-400', tagBg: 'bg-green-50', tagText: 'text-green-700', leftBar: 'bg-green-400' },
|
|
|
know: { accent: 'border-l-purple-400', tagBg: 'bg-purple-50', tagText: 'text-purple-700', leftBar: 'bg-purple-400' },
|
|
|
};
|
|
|
const tc = typeColors[type] ?? typeColors.req;
|
|
|
@@ -540,10 +648,10 @@ function RelationCard({ type, item, activeId, shouldScrollIntoView = false, sele
|
|
|
isSelected
|
|
|
? "border border-orange-400 border-l-4 shadow-[0_0_0_1px_rgba(251,146,60,0.7)]"
|
|
|
: directMatch
|
|
|
- ? "border border-sky-300 border-l-4 bg-sky-50/70 shadow-[0_0_0_1px_rgba(125,211,252,0.5)]"
|
|
|
- : dimmed
|
|
|
- ? "border border-slate-200 border-l-4 bg-slate-50 opacity-45 saturate-50"
|
|
|
- : "border border-transparent border-l-4 hover:border-slate-200"
|
|
|
+ ? "border border-sky-300 border-l-4 bg-sky-50/70 shadow-[0_0_0_1px_rgba(125,211,252,0.5)]"
|
|
|
+ : dimmed
|
|
|
+ ? "border border-slate-200 border-l-4 bg-slate-50 opacity-45 saturate-50"
|
|
|
+ : "border border-transparent border-l-4 hover:border-slate-200"
|
|
|
)}
|
|
|
>
|
|
|
<div className="flex items-start gap-2">
|
|
|
@@ -694,7 +802,7 @@ function DrawerContent({ type, data, dbData, onOpenPost, nodePostsMap }: { type:
|
|
|
module_label: phase.phase || phase.description || '',
|
|
|
capability_ids: (phase.capabilities || []).map((c: any) => c.capability_id).filter(Boolean)
|
|
|
}));
|
|
|
- } catch (e) {}
|
|
|
+ } catch (e) { }
|
|
|
if (fineSteps.length === 0) return null;
|
|
|
return (
|
|
|
<div className="bg-slate-50 p-3 rounded-xl border border-slate-100">
|
|
|
@@ -740,8 +848,8 @@ function DrawerContent({ type, data, dbData, onOpenPost, nodePostsMap }: { type:
|
|
|
)}
|
|
|
{(() => {
|
|
|
let parsed: any = {};
|
|
|
- try { parsed = JSON.parse(data.body || '{}'); } catch(e) {}
|
|
|
-
|
|
|
+ try { parsed = JSON.parse(data.body || '{}'); } catch (e) { }
|
|
|
+
|
|
|
const rationale = data.rationale || parsed.selected_strategy?.reasoning || parsed.reasoning;
|
|
|
const highlightCoverage = parsed.selected_strategy?.highlight_coverage || parsed.highlight_coverage || [];
|
|
|
const baselineCoverage = parsed.selected_strategy?.baseline_coverage || parsed.baseline_coverage || [];
|
|
|
@@ -1045,13 +1153,13 @@ function StrategyResourcesGrid({ resourceIds, onOpenPost }: { resourceIds: strin
|
|
|
|
|
|
useEffect(() => {
|
|
|
if (!resourceIds || resourceIds.length === 0) return;
|
|
|
-
|
|
|
+
|
|
|
let isMounted = true;
|
|
|
-
|
|
|
+
|
|
|
async function loadResources() {
|
|
|
setLoading(true);
|
|
|
const map: Record<string, any> = {};
|
|
|
-
|
|
|
+
|
|
|
for (const rid of resourceIds) {
|
|
|
if (!isMounted) break;
|
|
|
try {
|
|
|
@@ -1075,14 +1183,14 @@ function StrategyResourcesGrid({ resourceIds, onOpenPost }: { resourceIds: strin
|
|
|
console.error(`Failed to fetch resource ${rid}`, e);
|
|
|
}
|
|
|
}
|
|
|
-
|
|
|
+
|
|
|
if (isMounted) {
|
|
|
setLoading(false);
|
|
|
}
|
|
|
}
|
|
|
-
|
|
|
+
|
|
|
loadResources();
|
|
|
-
|
|
|
+
|
|
|
return () => { isMounted = false; };
|
|
|
}, [resourceIds]);
|
|
|
|
|
|
@@ -1180,23 +1288,23 @@ function RequirementPostsDrawer({
|
|
|
{/* 下方:帖子竖向列表 */}
|
|
|
<div className="flex-1 min-h-0 overflow-y-auto pr-1">
|
|
|
<div className="grid grid-cols-1 gap-3">
|
|
|
- {loading && (
|
|
|
- <div className="flex items-center justify-center flex-1 gap-2 text-slate-400">
|
|
|
- <div className="w-4 h-4 border-2 border-indigo-200 border-t-indigo-500 rounded-full animate-spin"></div>
|
|
|
- 加载中...
|
|
|
- </div>
|
|
|
- )}
|
|
|
- {!loading && displayPostIds.length === 0 && (
|
|
|
- <div className="flex items-center justify-center flex-1 text-xs text-slate-300 font-bold">暂无帖子</div>
|
|
|
- )}
|
|
|
- {!loading && displayPostIds.map(pid => {
|
|
|
- const post = posts[pid];
|
|
|
- return (
|
|
|
- <div key={pid} className="w-full">
|
|
|
- <PostCard postId={pid} post={post} compact onClick={() => onOpenPost(pid, post)} />
|
|
|
+ {loading && (
|
|
|
+ <div className="flex items-center justify-center flex-1 gap-2 text-slate-400">
|
|
|
+ <div className="w-4 h-4 border-2 border-indigo-200 border-t-indigo-500 rounded-full animate-spin"></div>
|
|
|
+ 加载中...
|
|
|
</div>
|
|
|
- );
|
|
|
- })}
|
|
|
+ )}
|
|
|
+ {!loading && displayPostIds.length === 0 && (
|
|
|
+ <div className="flex items-center justify-center flex-1 text-xs text-slate-300 font-bold">暂无帖子</div>
|
|
|
+ )}
|
|
|
+ {!loading && displayPostIds.map(pid => {
|
|
|
+ const post = posts[pid];
|
|
|
+ return (
|
|
|
+ <div key={pid} className="w-full">
|
|
|
+ <PostCard postId={pid} post={post} compact onClick={() => onOpenPost(pid, post)} />
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+ })}
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
@@ -1461,7 +1569,7 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
module_label: phase.phase || phase.description || '',
|
|
|
capability_ids: (phase.capabilities || []).map((c: any) => c.capability_id).filter(Boolean)
|
|
|
}));
|
|
|
- } catch (e) {}
|
|
|
+ } catch (e) { }
|
|
|
fineSteps.forEach((step: any) => {
|
|
|
if ((step.capability_ids || []).length > 0 && !(step.capability_ids || []).some((capId: string) => capId.startsWith('VCAP_'))) return;
|
|
|
const generatedIds = (step.capability_ids || []).filter((capId: string) => capId.startsWith('VCAP_'));
|
|
|
@@ -1496,6 +1604,15 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
useEffect(() => {
|
|
|
async function loadStats() {
|
|
|
try {
|
|
|
+ if (globalCacheLoaded && globalCacheTreeData && globalCacheDbData) {
|
|
|
+ setTreeData(globalCacheTreeData);
|
|
|
+ setDbData(globalCacheDbData);
|
|
|
+ setNameToNodeMap(globalCacheNameToNodeMap);
|
|
|
+ setIdToNodeMap(globalCacheIdToNodeMap);
|
|
|
+ setDashboardLoadingText(null);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
setDashboardLoadingText('连接服务端:获取预备目录...');
|
|
|
const treeRes = await fetch('/category_tree.json');
|
|
|
const data = await treeRes.json();
|
|
|
@@ -1515,9 +1632,9 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
const procRes = await getStrategies(1000, 0);
|
|
|
|
|
|
let knowRes: any = { results: [] };
|
|
|
- try {
|
|
|
+ try {
|
|
|
// setDashboardLoadingText('底层依赖获取:知识碎片集...');
|
|
|
- knowRes = await getKnowledge(1, 1000);
|
|
|
+ knowRes = await getKnowledge(1, 1000);
|
|
|
} catch (e) { /* optional */ }
|
|
|
|
|
|
const reqs = reqRes.results || [];
|
|
|
@@ -1574,7 +1691,15 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
}
|
|
|
}
|
|
|
});
|
|
|
- setTreeData({ ...data });
|
|
|
+ const finalTreeData = { ...data };
|
|
|
+ setTreeData(finalTreeData);
|
|
|
+
|
|
|
+ // 存入全局缓存
|
|
|
+ globalCacheTreeData = finalTreeData;
|
|
|
+ globalCacheDbData = { reqs, caps, tools, know, procs };
|
|
|
+ globalCacheNameToNodeMap = nameToNode;
|
|
|
+ globalCacheIdToNodeMap = idToNode;
|
|
|
+ globalCacheLoaded = true;
|
|
|
|
|
|
} catch (err) {
|
|
|
console.error("Failed to load dashboard stats", err);
|
|
|
@@ -1587,7 +1712,7 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
}
|
|
|
}
|
|
|
loadStats();
|
|
|
- }, [virtualCaps]);
|
|
|
+ }, []);
|
|
|
|
|
|
const extractionSubtreePartitions = useMemo(() => {
|
|
|
const traversalRoot = nameToNodeMap['呈现'] || treeData;
|
|
|
@@ -1970,17 +2095,17 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
const relatedReqs =
|
|
|
selectedProcId
|
|
|
? filteredData.reqs.filter((req: any) => {
|
|
|
- const proc = dbData.procs.find((item) => item.id === selectedProcId);
|
|
|
- return (proc?.requirement_ids || []).includes(req.id);
|
|
|
- })
|
|
|
+ const proc = dbData.procs.find((item) => item.id === selectedProcId);
|
|
|
+ return (proc?.requirement_ids || []).includes(req.id);
|
|
|
+ })
|
|
|
:
|
|
|
- selectedReqId
|
|
|
- ? filteredData.reqs.filter((req: any) => req.id === selectedReqId)
|
|
|
- : selectedCapId
|
|
|
- ? filteredData.reqs.filter((req: any) => (req.capability_ids || []).includes(selectedCapId))
|
|
|
- : selectedToolId
|
|
|
- ? filteredData.reqs.filter((req: any) => (req.capability_ids || []).some((cid: string) => selectedToolCapabilityIds.has(cid)))
|
|
|
- : [];
|
|
|
+ selectedReqId
|
|
|
+ ? filteredData.reqs.filter((req: any) => req.id === selectedReqId)
|
|
|
+ : selectedCapId
|
|
|
+ ? filteredData.reqs.filter((req: any) => (req.capability_ids || []).includes(selectedCapId))
|
|
|
+ : selectedToolId
|
|
|
+ ? filteredData.reqs.filter((req: any) => (req.capability_ids || []).some((cid: string) => selectedToolCapabilityIds.has(cid)))
|
|
|
+ : [];
|
|
|
|
|
|
if (relatedReqs.length === 0) return null;
|
|
|
const names = new Set<string>();
|
|
|
@@ -2175,7 +2300,7 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
],
|
|
|
edges: [
|
|
|
{
|
|
|
- flowLabel: '归纳需求',
|
|
|
+ flowLabel: '遍历呈现树、采样帖子、归纳需求',
|
|
|
coverageLabel: `${nodesCoveredByReqCount}/${totalNodeCount} · ${reqsWithNodeCoverageCount}/${dbData.reqs.length}`,
|
|
|
coveredCount: Math.min(nodesCoveredByReqCount, reqsWithNodeCoverageCount),
|
|
|
rate: makeRate(reqsWithNodeCoverageCount, dbData.reqs.length),
|
|
|
@@ -2183,7 +2308,7 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
textClass: 'text-indigo-600',
|
|
|
},
|
|
|
{
|
|
|
- flowLabel: '提取工序',
|
|
|
+ flowLabel: '逐个需求调研、总结工序',
|
|
|
coverageLabel: `${reqIdsWithProcCoverage.size}/${dbData.reqs.length} · ${procIdsCoveredByReq.size}/${dbData.procs.length}`,
|
|
|
coveredCount: Math.min(reqIdsWithProcCoverage.size, procIdsCoveredByReq.size),
|
|
|
rate: makeRate(reqIdsWithProcCoverage.size, dbData.reqs.length),
|
|
|
@@ -2199,7 +2324,7 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
textClass: 'text-rose-600',
|
|
|
},
|
|
|
{
|
|
|
- flowLabel: '寻找工具',
|
|
|
+ flowLabel: '记录新工具需求',
|
|
|
coverageLabel: `${capIdsWithToolCoverage.size}/${allCaps.length} · ${toolIdsCoveredByCap.size}/${dbData.tools.length}`,
|
|
|
coveredCount: Math.min(capIdsWithToolCoverage.size, toolIdsCoveredByCap.size),
|
|
|
rate: makeRate(capIdsWithToolCoverage.size, allCaps.length),
|
|
|
@@ -2256,18 +2381,18 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
|
|
|
const getColumnActiveId = (type: string): string | null => (
|
|
|
type === 'req' ? (selectedReqId ? `req:${selectedReqId}` : null) :
|
|
|
- type === 'proc' ? (selectedProcId ? `proc:${selectedProcId}` : null) :
|
|
|
- type === 'cap' ? (selectedCapId ? `cap:${selectedCapId}` : null) :
|
|
|
- type === 'tool' ? (selectedToolId ? `tool:${selectedToolId}` : null) :
|
|
|
- null
|
|
|
+ type === 'proc' ? (selectedProcId ? `proc:${selectedProcId}` : null) :
|
|
|
+ type === 'cap' ? (selectedCapId ? `cap:${selectedCapId}` : null) :
|
|
|
+ type === 'tool' ? (selectedToolId ? `tool:${selectedToolId}` : null) :
|
|
|
+ null
|
|
|
);
|
|
|
|
|
|
const isDirectMatchForType = (type: string, itemId: string) => (
|
|
|
type === 'req' ? relatedReqIds.has(itemId) :
|
|
|
- type === 'proc' ? relatedProcIds.has(itemId) :
|
|
|
- type === 'cap' ? relatedCapIds.has(itemId) :
|
|
|
- type === 'tool' ? relatedToolIds.has(itemId) :
|
|
|
- false
|
|
|
+ type === 'proc' ? relatedProcIds.has(itemId) :
|
|
|
+ type === 'cap' ? relatedCapIds.has(itemId) :
|
|
|
+ type === 'tool' ? relatedToolIds.has(itemId) :
|
|
|
+ false
|
|
|
);
|
|
|
|
|
|
const sortedItems = (items: any[], type: string, eligibleIds?: Set<string>) => {
|
|
|
@@ -2323,10 +2448,10 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
const nodePostIdSet = new Set(nodePostIds);
|
|
|
const explicitPosts = typeof sn === 'object'
|
|
|
? [
|
|
|
- ...(sn.posts || []),
|
|
|
- ...(sn.post_ids || []),
|
|
|
- ...(sn.source_posts || []),
|
|
|
- ]
|
|
|
+ ...(sn.posts || []),
|
|
|
+ ...(sn.post_ids || []),
|
|
|
+ ...(sn.source_posts || []),
|
|
|
+ ]
|
|
|
: [];
|
|
|
|
|
|
const candidatePosts = (explicitPosts.length > 0 ? explicitPosts : requirementInputPosts)
|
|
|
@@ -2384,10 +2509,10 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
const eligibleToolIds = useMemo(() => new Set(filteredData.tools.map((t: any) => t.id)), [filteredData.tools]);
|
|
|
|
|
|
const columns = [
|
|
|
- { t: 'req', l: '业务需求', i: Target, d: visibleData.reqs, eligibleIds: eligibleReqIds, headerColor: 'border-t-2 border-t-indigo-400' },
|
|
|
+ { t: 'req', l: '业务需求', i: Target, d: visibleData.reqs, eligibleIds: eligibleReqIds, headerColor: 'border-t-2 border-t-indigo-400' },
|
|
|
{ t: 'proc', l: '生产工序', i: ListTree, d: visibleProcItems as any[], eligibleIds: eligibleProcIds, headerColor: 'border-t-2 border-t-purple-400' },
|
|
|
- { t: 'cap', l: '原子能力', i: Cpu, d: visibleData.caps, eligibleIds: eligibleCapIds, headerColor: 'border-t-2 border-t-rose-400' },
|
|
|
- { t: 'tool', l: '执行工具', i: Wrench, d: visibleData.tools, eligibleIds: eligibleToolIds, headerColor: 'border-t-2 border-t-green-400' },
|
|
|
+ { t: 'cap', l: '原子能力', i: Cpu, d: visibleData.caps, eligibleIds: eligibleCapIds, headerColor: 'border-t-2 border-t-rose-400' },
|
|
|
+ { t: 'tool', l: '执行工具', i: Wrench, d: visibleData.tools, eligibleIds: eligibleToolIds, headerColor: 'border-t-2 border-t-green-400' },
|
|
|
];
|
|
|
|
|
|
const toolIdsByCapId = useMemo(() => {
|
|
|
@@ -2491,12 +2616,12 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
<div className="flex flex-col h-[calc(100vh-64px)] w-full items-center justify-center text-slate-400">
|
|
|
<div className="flex flex-col items-center gap-4">
|
|
|
<div className="w-8 h-8 flex items-center justify-center">
|
|
|
- {!dashboardLoadingText.includes('失败') && (
|
|
|
- <div className="w-5 h-5 border-[3px] border-indigo-200 border-t-indigo-500 rounded-full animate-spin"></div>
|
|
|
- )}
|
|
|
- {dashboardLoadingText.includes('失败') && (
|
|
|
- <span className="text-xl">❌</span>
|
|
|
- )}
|
|
|
+ {!dashboardLoadingText.includes('失败') && (
|
|
|
+ <div className="w-5 h-5 border-[3px] border-indigo-200 border-t-indigo-500 rounded-full animate-spin"></div>
|
|
|
+ )}
|
|
|
+ {dashboardLoadingText.includes('失败') && (
|
|
|
+ <span className="text-xl">❌</span>
|
|
|
+ )}
|
|
|
</div>
|
|
|
<span className={cn("text-sm tracking-widest", dashboardLoadingText.includes('失败') ? "text-rose-500 max-w-xl text-center leading-relaxed" : "text-slate-400 font-bold")}>{dashboardLoadingText}</span>
|
|
|
</div>
|
|
|
@@ -2571,10 +2696,10 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
selectedSubtreeLeafNames
|
|
|
? selectedSubtreeLeafNames
|
|
|
: relationFilterHighlightLeafNames
|
|
|
- ? relationFilterHighlightLeafNames
|
|
|
- : onlyCoveredFilter
|
|
|
- ? coveredLeafNames
|
|
|
- : null
|
|
|
+ ? relationFilterHighlightLeafNames
|
|
|
+ : onlyCoveredFilter
|
|
|
+ ? coveredLeafNames
|
|
|
+ : null
|
|
|
}
|
|
|
filterLabel={effectiveSelectedTreeNode ? (effectiveTreePartition ? `${effectiveSelectedTreeNode.name} / 子树` : effectiveSelectedTreeNode.name) : null}
|
|
|
onClearFilter={effectiveSelectedTreeNode ? () => {
|
|
|
@@ -2653,10 +2778,10 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
item={item}
|
|
|
metrics={
|
|
|
col.t === 'req' ? reqMetricsMap[item.id] :
|
|
|
- col.t === 'proc' ? procMetricsMap[item.id] :
|
|
|
- col.t === 'cap' ? capMetricsMap[item.id] :
|
|
|
- col.t === 'tool' ? toolMetricsMap[item.id] :
|
|
|
- undefined
|
|
|
+ col.t === 'proc' ? procMetricsMap[item.id] :
|
|
|
+ col.t === 'cap' ? capMetricsMap[item.id] :
|
|
|
+ col.t === 'tool' ? toolMetricsMap[item.id] :
|
|
|
+ undefined
|
|
|
}
|
|
|
dimmed={!col.eligibleIds?.has(item.id)}
|
|
|
activeId={activeId}
|
|
|
@@ -2664,11 +2789,11 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
selectedLeafNames={selectedSubtreeLeafNames || undefined}
|
|
|
directMatch={
|
|
|
col.t === 'req' ? relatedReqIds.has(item.id) :
|
|
|
- col.t === 'proc' ? relatedProcIds.has(item.id) :
|
|
|
- col.t === 'cap' ? relatedCapIds.has(item.id) :
|
|
|
- col.t === 'tool' ? relatedToolIds.has(item.id) :
|
|
|
- false
|
|
|
- }
|
|
|
+ col.t === 'proc' ? relatedProcIds.has(item.id) :
|
|
|
+ col.t === 'cap' ? relatedCapIds.has(item.id) :
|
|
|
+ col.t === 'tool' ? relatedToolIds.has(item.id) :
|
|
|
+ false
|
|
|
+ }
|
|
|
showAllSourceTags={col.t === 'req' && !!selectedTreePartition}
|
|
|
onSingleClick={(nodeId) => handleSingleClick(nodeId, item)}
|
|
|
/>
|