Talegorithm 14 часов назад
Родитель
Сommit
fb72da285d
1 измененных файлов с 307 добавлено и 182 удалено
  1. 307 182
      knowhub/frontend/src/pages/Dashboard.tsx

+ 307 - 182
knowhub/frontend/src/pages/Dashboard.tsx

@@ -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)}
                   />