소스 검색

Merge branch 'main' of https://git.yishihui.com/howard/Agent

guantao 1 개월 전
부모
커밋
8d8c234e19
3개의 변경된 파일553개의 추가작업 그리고 236개의 파일을 삭제
  1. 8 15
      knowhub/frontend/src/components/dashboard/CategoryTree.tsx
  2. 337 158
      knowhub/frontend/src/pages/Dashboard.tsx
  3. 208 63
      knowhub/frontend/src/pages/Workflows.tsx

+ 8 - 15
knowhub/frontend/src/components/dashboard/CategoryTree.tsx

@@ -12,7 +12,7 @@ interface NodeProps {
   subtreeHighlightNodeIds: Set<string> | null;
   sourceNodeIds: Set<string> | null;
   patternNodeIds: Set<string> | null;
-  nodeMetricsMap: Record<string, { reqCount: number; procCount: number; capCount: number; toolCount: number; nodeCount: number }>;
+  nodeMetricsMap: Record<string, { reqCount: number; procCount: number; capCount: number; toolCount: number; nodeCount: number; patternCount: number }>;
   dimensionColor: string; // hex color for the node's dimension
   focusedTreeNodeId?: string | number | null;
   focusTrigger?: number;
@@ -31,7 +31,7 @@ function HorizontalTreeNode({ node, onSelect, selectedId, level, highlightLeafNa
   const [hoveredMetric, setHoveredMetric] = useState<null | { key: string; count: number; colorClass: string; x: number; y: number }>(null);
   const nodeRef = useRef<HTMLDivElement>(null);
   const hasChildren = node.children && node.children.length > 0;
-  const metrics = nodeMetricsMap[String(node.id)] || { reqCount: 0, procCount: 0, capCount: 0, toolCount: 0, nodeCount: 0 };
+  const metrics = nodeMetricsMap[String(node.id)] || { reqCount: 0, procCount: 0, capCount: 0, toolCount: 0, nodeCount: 0, patternCount: 0 };
   let textColor = "text-slate-800";
   if (hasChildren) textColor = cn(textColor, "font-extrabold");
 
@@ -79,21 +79,14 @@ function HorizontalTreeNode({ node, onSelect, selectedId, level, highlightLeafNa
         onClick={() => onSelect(node)}
       >
         <span className={cn("text-xs font-bold mr-3", textColor)}>{node.name || "Root"}</span>
-        {isPatternNode && (
-          <span className="text-[9px] mr-2 px-1.5 py-0.5 rounded bg-sky-100 text-sky-700 border border-sky-300 font-bold whitespace-nowrap shadow-sm">
-            Pattern
-          </span>
-        )}
-
         <div className="flex text-[9px] bg-slate-100 rounded px-1.5 shadow-sm items-center font-bold text-slate-700">
           {node.total_posts_count || 0} 帖
         </div>
         <div className="relative flex items-center gap-2 ml-2">
           <span
-            onMouseEnter={(e) => metrics.nodeCount > 0 && setHoveredMetric({ key: 'node', count: metrics.nodeCount, colorClass: 'border text-white', x: e.currentTarget.getBoundingClientRect().left + e.currentTarget.getBoundingClientRect().width / 2, y: e.currentTarget.getBoundingClientRect().top - 8 })}
-            onMouseLeave={() => setHoveredMetric((prev) => (prev?.key === 'node' ? null : prev))}
-            className={cn("w-2.5 h-2.5 rounded-full", metrics.nodeCount > 0 ? "opacity-100" : "opacity-0")}
-            style={metrics.nodeCount > 0 ? { backgroundColor: dimensionColor } : undefined}
+            onMouseEnter={(e) => metrics.patternCount > 0 && setHoveredMetric({ key: 'pattern', count: metrics.patternCount, colorClass: 'bg-amber-500 border-amber-500 text-white', x: e.currentTarget.getBoundingClientRect().left + e.currentTarget.getBoundingClientRect().width / 2, y: e.currentTarget.getBoundingClientRect().top - 8 })}
+            onMouseLeave={() => setHoveredMetric((prev) => (prev?.key === 'pattern' ? null : prev))}
+            className={cn("w-2.5 h-2.5 rounded-full", metrics.patternCount > 0 ? "bg-amber-500 opacity-100" : "opacity-0")}
           />
           <span
             onMouseEnter={(e) => metrics.reqCount > 0 && setHoveredMetric({ key: 'req', count: metrics.reqCount, colorClass: 'bg-indigo-500 border-indigo-500 text-white', x: e.currentTarget.getBoundingClientRect().left + e.currentTarget.getBoundingClientRect().width / 2, y: e.currentTarget.getBoundingClientRect().top - 8 })}
@@ -163,8 +156,8 @@ function HorizontalTreeNode({ node, onSelect, selectedId, level, highlightLeafNa
       )}
       {hoveredMetric && createPortal(
         <div
-          className={cn("fixed z-[99999] rounded-lg border shadow-lg min-w-[40px] px-3 py-2 flex items-center justify-center -translate-x-1/2 -translate-y-full", hoveredMetric.key === 'node' ? 'text-white' : hoveredMetric.colorClass)}
-          style={hoveredMetric.key === 'node' ? { left: hoveredMetric.x, top: hoveredMetric.y, backgroundColor: dimensionColor, borderColor: dimensionColor } : { left: hoveredMetric.x, top: hoveredMetric.y }}
+          className={cn("fixed z-[99999] rounded-lg border shadow-lg min-w-[40px] px-3 py-2 flex items-center justify-center -translate-x-1/2 -translate-y-full", hoveredMetric.colorClass)}
+          style={{ left: hoveredMetric.x, top: hoveredMetric.y }}
         >
           <div className="text-sm font-black leading-none text-center">{hoveredMetric.count}</div>
         </div>,
@@ -201,7 +194,7 @@ export function CategoryTree({
   subtreeHighlightNodeIds?: Set<string> | null;
   sourceNodeIds?: Set<string> | null;
   patternNodeIds?: Set<string> | null;
-  nodeMetricsMap?: Record<string, { reqCount: number; procCount: number; capCount: number; toolCount: number; nodeCount: number }>;
+  nodeMetricsMap?: Record<string, { reqCount: number; procCount: number; capCount: number; toolCount: number; nodeCount: number; patternCount: number }>;
   filterLabel?: string | null;
   onClearFilter?: () => void;
   totalNodeCount?: number;

+ 337 - 158
knowhub/frontend/src/pages/Dashboard.tsx

@@ -472,7 +472,7 @@ function CoverageFlowBoard({ data }: { data: any }) {
 
 // ─── 关系列卡片 ────────────────────────────────────────────────────────────────
 
-function RelationCard({ type, item, activeId, shouldScrollIntoView = false, selectedLeafNames, directMatch = false, dimmed = false, showAllSourceTags = false, metrics, reqPlanBData, onSingleClick, onSourceNodeClick }: {
+function RelationCard({ type, item, activeId, shouldScrollIntoView = false, selectedLeafNames, directMatch = false, dimmed = false, showAllSourceTags = false, metrics, relationTags = [], reqPlanBData, onSingleClick, onSourceNodeClick }: {
   type: string;
   item: any;
   activeId: string | null;
@@ -482,6 +482,7 @@ function RelationCard({ type, item, activeId, shouldScrollIntoView = false, sele
   dimmed?: boolean;
   showAllSourceTags?: boolean;
   metrics?: { nodeCount: number; reqCount: number; procCount: number; capCount: number; toolCount: number };
+  relationTags?: Array<{ label: string; tone?: 'pattern' | 'direct' }>;
   reqPlanBData?: any;
   onSingleClick: (nodeId: string) => void;
   onSourceNodeClick?: (nodeName: string) => void;
@@ -548,7 +549,10 @@ function RelationCard({ type, item, activeId, shouldScrollIntoView = false, sele
   return (
     <div
       ref={cardRef}
-      onClick={() => onSingleClick(nodeId)}
+      onClick={() => {
+        if (dimmed) return;
+        onSingleClick(nodeId);
+      }}
       className={cn(
         "group p-3 rounded-xl cursor-pointer mb-2 select-none bg-white transition-all border-l-4",
         tc.accent,
@@ -559,6 +563,8 @@ function RelationCard({ type, item, activeId, shouldScrollIntoView = false, sele
           : 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"
+      ,
+        dimmed && "cursor-not-allowed"
       )}
     >
       <div className="flex items-start gap-2">
@@ -580,11 +586,25 @@ function RelationCard({ type, item, activeId, shouldScrollIntoView = false, sele
                   来源节点
                 </span>
               )}
+              {relationTags.map((tag) => (
+                <span
+                  key={`${nodeId}-${tag.label}`}
+                  className={cn(
+                    "text-[9px] px-1.5 py-0.5 rounded-md font-bold border",
+                    tag.tone === 'pattern'
+                      ? "bg-amber-50 text-amber-700 border-amber-200"
+                      : "bg-sky-50 text-sky-700 border-sky-200"
+                  )}
+                >
+                  {tag.label}
+                </span>
+              ))}
               {sourceNodeTags.map((name: string) => {
                 const isHighlighted = selectedLeafNames && selectedLeafNames.has(name);
                 return (
                   <span key={name} 
                     onClick={(e) => {
+                      if (dimmed) return;
                       if (onSourceNodeClick) {
                         e.stopPropagation();
                         onSourceNodeClick(name);
@@ -593,7 +613,7 @@ function RelationCard({ type, item, activeId, shouldScrollIntoView = false, sele
                     className={cn(
                     "text-[9px] px-1.5 py-0.5 rounded-md font-medium truncate max-w-[100px]",
                     isHighlighted ? "bg-sky-100 text-sky-700 ring-1 ring-sky-400 font-bold" : cn(tc.tagBg, tc.tagText),
-                    onSourceNodeClick && "cursor-pointer hover:ring-1 hover:ring-indigo-300"
+                    onSourceNodeClick && !dimmed && "cursor-pointer hover:ring-1 hover:ring-indigo-300"
                   )}>
                     {name}
                   </span>
@@ -1613,26 +1633,32 @@ function PatternColumn({
   selectedItemsetId,
   itemsets,
   eligibleItemsetIds,
+  frozenOrder,
   contextNodeNames,
   patternMatchedNodesMap,
   nodeRoleByName,
   hasAnyFilter,
   focusIndex,
+  focusTrigger,
   onSelectItemset,
   onOpenDrawer,
+  onNodeClick,
   onFocusPrev,
   onFocusNext,
 }: {
   selectedItemsetId: number | null;
   itemsets: any[];
   eligibleItemsetIds: Set<string>;
+  frozenOrder?: string[];
   contextNodeNames: Set<string>;
   patternMatchedNodesMap?: Record<string, Set<string>>;
   nodeRoleByName: Record<string, 'substance' | 'form' | 'both'>;
   hasAnyFilter: boolean;
   focusIndex: number;
-  onSelectItemset: (itemsetId: number | null) => void;
+  focusTrigger: number;
+  onSelectItemset: (itemsetId: number | null, currentOrderIds: string[]) => void;
   onOpenDrawer: (itemset: any) => void;
+  onNodeClick?: (nodeName: string) => void;
   onFocusPrev: () => void;
   onFocusNext: () => void;
 }) {
@@ -1649,18 +1675,24 @@ function PatternColumn({
       }
       return { ...itemset, matched_nodes: matchedNodes };
     });
+    if (selectedItemsetId !== null && frozenOrder && frozenOrder.length > 0) {
+      const rank = new Map<string, number>(frozenOrder.map((id, index) => [id, index]));
+      return [...withMatches].sort((a, b) => {
+        const aRank = rank.get(String(a.id));
+        const bRank = rank.get(String(b.id));
+        if (aRank === undefined && bRank === undefined) return 0;
+        if (aRank === undefined) return 1;
+        if (bRank === undefined) return -1;
+        return aRank - bRank;
+      });
+    }
     return withMatches.sort((a, b) => {
       const aEligible = eligibleItemsetIds.has(String(a.id)) ? 0 : 1;
       const bEligible = eligibleItemsetIds.has(String(b.id)) ? 0 : 1;
       if (aEligible !== bEligible) return aEligible - bEligible;
-
-      const aMatchCount = (a.matched_nodes || []).length;
-      const bMatchCount = (b.matched_nodes || []).length;
-      if (aMatchCount !== bMatchCount) return bMatchCount - aMatchCount;
-
-      return b.absolute_support - a.absolute_support;
+      return 0;
     });
-  }, [contextNodeNames, patternMatchedNodesMap, itemsets, eligibleItemsetIds]);
+  }, [contextNodeNames, patternMatchedNodesMap, itemsets, eligibleItemsetIds, selectedItemsetId, frozenOrder]);
 
   const matchedIndices = useMemo(() => {
     const indices: number[] = [];
@@ -1676,10 +1708,10 @@ function PatternColumn({
   const focusedItemIndex = matchedIndices[clampedFocusIdx] ?? -1;
 
   useEffect(() => {
-    if (focusedItemIndex >= 0) {
+    if (focusTrigger > 0 && focusedItemIndex >= 0) {
       itemRefs.current[focusedItemIndex]?.scrollIntoView({ behavior: 'smooth', block: 'start' });
     }
-  }, [focusedItemIndex]);
+  }, [focusedItemIndex, focusTrigger]);
 
   return (
     <div className="w-[260px] shrink-0 flex flex-col h-full bg-slate-100/50 rounded-2xl border border-slate-200 overflow-hidden border-t-2 border-t-amber-400">
@@ -1725,8 +1757,9 @@ function PatternColumn({
                 itemRefs.current[idx] = el;
               }}
               onClick={() => {
+                if (hasAnyFilter && !isEligible && !isSelected) return;
                 const next = isSelected ? null : itemset.id;
-                onSelectItemset(next);
+                onSelectItemset(next, displayItemsets.map((entry) => String(entry.id)));
                 if (next !== null) onOpenDrawer(itemset);
               }}
               className={cn(
@@ -1736,7 +1769,7 @@ function PatternColumn({
                   : isEligible
                   ? "border border-sky-300 bg-sky-50/70 shadow-[0_0_0_1px_rgba(125,211,252,0.5)]"
                   : "border border-slate-100 hover:border-amber-200",
-                hasAnyFilter && !isEligible && !isSelected && "opacity-40"
+                hasAnyFilter && !isEligible && !isSelected && "opacity-40 cursor-not-allowed"
               )}
             >
               <div className="flex items-center justify-between">
@@ -1746,12 +1779,21 @@ function PatternColumn({
               <div className="flex flex-wrap gap-1">
                 {(itemset.leaf_names || []).map((name: string) => {
                   const isMatched = itemset.matched_nodes.includes(name);
-                  const role = nodeRoleByName[name];
                   return (
-                    <span key={name} className={cn(
+                    <span
+                      key={name}
+                      onClick={(e) => {
+                        if (hasAnyFilter && !isEligible && !isSelected) return;
+                        if (!onNodeClick) return;
+                        e.stopPropagation();
+                        onNodeClick(name);
+                      }}
+                      className={cn(
                       "px-2 py-0.5 rounded-full text-xs border",
-                      isMatched ? "bg-orange-50 text-orange-700 border-orange-200 font-bold" :
-                      "bg-slate-50 text-slate-500 border-slate-200"
+                      (isSelected || isEligible) && isMatched
+                        ? "bg-sky-50 text-sky-700 border-sky-200 font-bold"
+                        : "bg-slate-50 text-slate-500 border-slate-200",
+                      onNodeClick && !(hasAnyFilter && !isEligible && !isSelected) && "cursor-pointer hover:ring-1 hover:ring-sky-300"
                     )}>{name}</span>
                   );
                 })}
@@ -1792,8 +1834,11 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
   const [selectedCapId, setSelectedCapId] = useState<string | null>(null);
   const [selectedToolId, setSelectedToolId] = useState<string | null>(null);
   const [columnFocusIndex, setColumnFocusIndex] = useState<Record<string, number>>({});
+  const [columnFocusTrigger, setColumnFocusTrigger] = useState<Record<string, number>>({});
+  const [columnFrozenOrders, setColumnFrozenOrders] = useState<Record<string, string[]>>({});
   const [treeFocusIndex, setTreeFocusIndex] = useState(0);
   const [treeFocusTrigger, setTreeFocusTrigger] = useState(0);
+  const [manualFocusedTreeNodeId, setManualFocusedTreeNodeId] = useState<string | number | null>(null);
   const [dashboardLoadingText, setDashboardLoadingText] = useState<string | null>('开始初始化底座...');
   const [flowBoardExpanded, setFlowBoardExpanded] = useState(false);
   const [allItemsets, setAllItemsets] = useState<any[]>([]);
@@ -2136,6 +2181,13 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
     return mapping;
   }, [treeData]);
 
+  const jumpToTreeNodeByName = useCallback((nodeName: string) => {
+    const targetNode = (nameToNodesMap[nodeName] && nameToNodesMap[nodeName][0]) || nameToNodeMap[nodeName];
+    if (!targetNode) return;
+    setManualFocusedTreeNodeId(targetNode.id);
+    setTreeFocusTrigger(prev => prev + 1);
+  }, [nameToNodeMap, nameToNodesMap]);
+
   const nodeIdToExtractionSubtree = useMemo((): Record<string, { parentId: string; memberIds: Set<string>; leafIds: Set<string> }> => {
     const mapping: Record<string, { parentId: string; memberIds: Set<string>; leafIds: Set<string> }> = {};
     extractionSubtreePartitions.forEach((partition) => {
@@ -2213,8 +2265,8 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
     return collectDirectNodeIdsFromReqs(reqs);
   }, [reqById, collectDirectNodeIdsFromReqs]);
 
-  const nodeMetricsMap = useMemo((): Record<string, { reqCount: number; procCount: number; capCount: number; toolCount: number; nodeCount: number }> => {
-    const metricsMap: Record<string, { reqCount: number; procCount: number; capCount: number; toolCount: number; nodeCount: number }> = {};
+  const nodeMetricsMap = useMemo((): Record<string, { reqCount: number; procCount: number; capCount: number; toolCount: number; nodeCount: number; patternCount: number }> => {
+    const metricsMap: Record<string, { reqCount: number; procCount: number; capCount: number; toolCount: number; nodeCount: number; patternCount: number }> = {};
     if (!treeData) return metricsMap;
 
     const countDescendantNodes = (node: any): number => {
@@ -2231,6 +2283,7 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
       const capIds = new Set<string>();
       const toolIds = new Set<string>();
       const procIds = new Set<string>();
+      const patternIds = new Set<string>();
 
       dbData.reqs.forEach((req: any) => {
         const isDirectNode = (req.source_nodes || []).some((sn: any) => {
@@ -2261,6 +2314,12 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
         }
       });
 
+      allItemsets.forEach((itemset: any) => {
+        if ((itemset.leaf_names || []).includes(node.name)) {
+          patternIds.add(String(itemset.id));
+        }
+      });
+
       if (node.id !== undefined && node.id !== null) {
         metricsMap[String(node.id)] = {
           reqCount: reqIds.size,
@@ -2268,6 +2327,7 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
           capCount: capIds.size,
           toolCount: toolIds.size,
           nodeCount: countDescendantNodes(node),
+          patternCount: patternIds.size,
         };
       }
 
@@ -2276,7 +2336,7 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
 
     walk(treeData);
     return metricsMap;
-  }, [treeData, dbData.reqs, dbData.tools, nameToNodeMap, virtualCaps]);
+  }, [treeData, dbData.reqs, dbData.tools, virtualCaps, dbData.procs, allItemsets]);
 
   // ── 树节点过滤:选中节点后只显示关联数据(基于所有共享需求的节点)───────────────────────────────────
   const selectedReqCapabilityIds = useMemo(() => {
@@ -2390,7 +2450,8 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
         ));
         (req.patterns || []).forEach((pattern: any) => {
           const hasValidRepresents = (pattern.judgments || []).some((j: any) => j.represents && sourceNodes.has(j.node));
-          if (!hasValidRepresents) return;
+          const hasDirectLeafMatch = (pattern.leaf_names || []).some((name: string) => sourceNodes.has(name));
+          if (!hasValidRepresents && !hasDirectLeafMatch) return;
 
           const patternId = String(pattern.pattern_id);
           if (!map.has(patternId)) map.set(patternId, new Set<string>());
@@ -2436,6 +2497,17 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
     return itemsetReqIdsMap.get(String(selectedItemset.id)) || new Set<string>();
   }, [itemsetReqIdsMap, selectedItemset]);
 
+  const isPlanBPatternAssociatedWithReq = useCallback((reqInfo: any, pattern: any) => {
+    const sourceNodes = new Set<string>(
+      (reqInfo?.source_nodes || []).map((sn: any) =>
+        typeof sn === 'object' ? (sn.node_name || sn.name || '') : sn
+      ).filter((name: string) => !!name && name !== '__meta__' && name !== '__abstract__')
+    );
+    const hasValidRepresents = (pattern?.judgments || []).some((j: any) => j.represents && sourceNodes.has(j.node));
+    const hasDirectLeafMatch = (pattern?.leaf_names || []).some((name: string) => sourceNodes.has(name));
+    return hasValidRepresents || hasDirectLeafMatch;
+  }, []);
+
   const selectedItemsetDirectNodeIds = useMemo((): Set<string> | null => {
     if (!selectedItemset) return null;
     const ids = collectDirectNodeIdsFromNames((selectedItemset.leaf_names || []).filter(Boolean), true);
@@ -2449,13 +2521,10 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
     (selectedNodeDirectNames || new Set<string>()).forEach((name) => names.add(name));
     if (selectedReqId) {
       const req = dbData.reqs.find((r: any) => r.id === selectedReqId);
-      const meta = (req.source_nodes || []).find((sn: any) => {
+      (req?.source_nodes || []).forEach((sn: any) => {
         const nodeName = typeof sn === 'object' ? (sn.node_name || sn.name) : sn;
-        return nodeName === '__meta__';
+        if (nodeName && nodeName !== '__meta__' && nodeName !== '__abstract__') names.add(nodeName);
       });
-      const ctx = meta?.extraction_context || {};
-      (ctx.substance?.nodes || []).forEach((name: string) => name && names.add(name));
-      (ctx.form?.nodes || []).forEach((name: string) => name && names.add(name));
     }
     return names;
   }, [selectedNodeDirectNames, selectedReqId, dbData.reqs]);
@@ -2465,15 +2534,12 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
     const reqInfo = reqPlanBData.requirements.find((r: any) => r.requirement_id === selectedReqId);
     if (!reqInfo) return undefined;
 
-    const sourceNodes = new Set<string>((reqInfo.source_nodes || []).map((sn: any) => 
-      typeof sn === 'object' ? (sn.node_name || sn.name || '') : sn
-    ));
-
     const map: Record<string, Set<string>> = {};
     (reqInfo.patterns || []).forEach((pattern: any) => {
+      if (!isPlanBPatternAssociatedWithReq(reqInfo, pattern)) return;
       const set = new Set<string>();
       (pattern.judgments || []).forEach((j: any) => {
-        if (j.represents && sourceNodes.has(j.node)) {
+        if (j.represents) {
           set.add(j.node);
         }
       });
@@ -2482,7 +2548,7 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
       }
     });
     return map;
-  }, [reqPlanBData, selectedReqId]);
+  }, [reqPlanBData, selectedReqId, isPlanBPatternAssociatedWithReq]);
 
   const requirementVisible = (req: any) => {
     if (onlyCoveredFilter) {
@@ -2508,12 +2574,7 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
   const requirementMatches = (req: any) => {
     if (!requirementVisible(req)) return false;
     if (selectedNode) {
-      const selectedNodeName = selectedNode.name;
-      const hasDirectSourceNode = (req.source_nodes || []).some((sn: any) => {
-        const nodeName = typeof sn === 'object' ? (sn.node_name || sn.name) : sn;
-        return nodeName === selectedNodeName;
-      });
-      if (!hasDirectSourceNode) return false;
+      if (!selectedNodeReqIds.has(req.id)) return false;
     }
     if (selectedProcId) {
       const proc = dbData.procs.find((item) => item.id === selectedProcId);
@@ -2576,18 +2637,36 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
     return ids;
   }, [selectedNode, visibleData.reqs]);
 
+  const patternNodeReqIds = useMemo((): Set<string> => {
+    if (!selectedNode || !selectedNodeDirectNames || selectedNodeDirectNames.size === 0) return new Set<string>();
+    const ids = new Set<string>();
+    allItemsets.forEach((itemset: any) => {
+      const hasNodeMatch = (itemset.leaf_names || []).some((name: string) => selectedNodeDirectNames.has(name));
+      if (!hasNodeMatch) return;
+      (itemsetReqIdsMap.get(String(itemset.id)) || new Set<string>()).forEach((reqId) => ids.add(reqId));
+    });
+    return ids;
+  }, [selectedNode, selectedNodeDirectNames, allItemsets, itemsetReqIdsMap]);
+
+  const selectedNodeReqIds = useMemo((): Set<string> => {
+    const ids = new Set<string>();
+    directNodeReqIds.forEach((id) => ids.add(id));
+    patternNodeReqIds.forEach((id) => ids.add(id));
+    return ids;
+  }, [directNodeReqIds, patternNodeReqIds]);
+
   const directNodeCapIds = useMemo((): Set<string> => {
     const ids = new Set<string>();
-    if (directNodeReqIds.size === 0) return ids;
+    if (selectedNodeReqIds.size === 0) return ids;
     visibleData.reqs.forEach((req: any) => {
-      if (!directNodeReqIds.has(req.id)) return;
+      if (!selectedNodeReqIds.has(req.id)) return;
       (req.capability_ids || []).forEach((cid: string) => ids.add(cid));
       virtualCaps.forEach((cap: any) => {
         if ((cap.requirement_ids || []).includes(req.id)) ids.add(cap.id);
       });
     });
     return ids;
-  }, [directNodeReqIds, visibleData.reqs, virtualCaps]);
+  }, [selectedNodeReqIds, visibleData.reqs, virtualCaps]);
 
   const directNodeToolIds = useMemo((): Set<string> => {
     const ids = new Set<string>();
@@ -2600,14 +2679,14 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
 
   const directNodeProcIds = useMemo((): Set<string> => {
     const ids = new Set<string>();
-    if (directNodeReqIds.size === 0) return ids;
+    if (selectedNodeReqIds.size === 0) return ids;
     dbData.procs.forEach((workflow) => {
-      if ((workflow.requirement_ids || []).some((reqId: string) => directNodeReqIds.has(reqId))) {
+      if ((workflow.requirement_ids || []).some((reqId: string) => selectedNodeReqIds.has(reqId))) {
         ids.add(workflow.id);
       }
     });
     return ids;
-  }, [directNodeReqIds]);
+  }, [selectedNodeReqIds]);
 
   const unionActiveSets = (...sets: Array<Set<string> | null | undefined>) => {
     const activeSets = sets.filter((set): set is Set<string> => !!set);
@@ -2639,7 +2718,7 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
   };
 
   const isGlobalConflict = useMemo(() => {
-    if (selectedNode && selectedReqId && !directNodeReqIds.has(selectedReqId)) return true;
+    if (selectedNode && selectedReqId && !selectedNodeReqIds.has(selectedReqId)) return true;
     if (selectedNode && selectedProcId && !directNodeProcIds.has(selectedProcId)) return true;
     if (selectedNode && selectedCapId && !directNodeCapIds.has(selectedCapId)) return true;
     if (selectedNode && selectedToolId && !directNodeToolIds.has(selectedToolId)) return true;
@@ -2681,7 +2760,7 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
     return false;
   }, [
     selectedNode, selectedReqId, selectedProcId, selectedCapId, selectedToolId, selectedItemsetId,
-    directNodeReqIds, directNodeProcIds, directNodeCapIds, directNodeToolIds,
+    selectedNodeReqIds, directNodeProcIds, directNodeCapIds, directNodeToolIds,
     selectedReqProcIds, selectedReqCapabilityIds, selectedReqToolIds, itemsetReqIdsMap,
     selectedProcCapabilityIds, visibleData.tools, selectedItemsetReqIds, dbData.procs, allCaps
   ]);
@@ -2747,7 +2826,7 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
 
   const relatedReqIds = useMemo((): Set<string> => {
     if (isGlobalConflict) return new Set<string>();
-    const fromNode = selectedNode ? directNodeReqIds : null;
+    const fromNode = selectedNode ? selectedNodeReqIds : null;
     const fromProc = selectedProcId ? selectedProcRequirementIds : null;
     const fromReq = selectedReqId ? new Set<string>([selectedReqId]) : null;
     const fromCap = selectedCapId
@@ -2756,7 +2835,7 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
     const fromTool = selectedToolId ? selectedToolRequirementIds : null;
     const fromItemset = selectedItemsetId && selectedItemsetReqIds ? selectedItemsetReqIds : null;
     return intersectWithAllowed(intersectActiveSets(fromNode, fromProc, fromReq, fromCap, fromTool, fromItemset), visibleReqIds);
-  }, [directNodeReqIds, visibleReqIds, selectedNode, selectedProcId, selectedProcRequirementIds, selectedReqId, selectedCapId, selectedCapRequirementIds, selectedToolId, selectedToolRequirementIds, selectedItemsetId, selectedItemsetReqIds]);
+  }, [selectedNodeReqIds, visibleReqIds, selectedNode, selectedProcId, selectedProcRequirementIds, selectedReqId, selectedCapId, selectedCapRequirementIds, selectedToolId, selectedToolRequirementIds, selectedItemsetId, selectedItemsetReqIds]);
 
   const relatedProcIds = useMemo((): Set<string> => {
     if (isGlobalConflict) return new Set<string>();
@@ -2824,6 +2903,7 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
       if (selectedReqId && !selectedReqCapabilityIds.has(cap.id)) return false;
       if (selectedCapId && cap.id !== selectedCapId) return false;
       if (selectedToolId && !selectedToolCapabilityIds.has(cap.id)) return false;
+      if (selectedItemsetId && !relatedCapIds.has(cap.id)) return false;
       // 如果有节点选择,能力必须在 relatedCapIds 中
       if (selectedNode && !relatedCapIds.has(cap.id)) return false;
       return true;
@@ -2838,6 +2918,7 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
         if (!hasProcCap) return false;
       }
       if (selectedToolId && tool.id !== selectedToolId) return false;
+      if (selectedItemsetId && !relatedToolIds.has(tool.id)) return false;
       // 如果有节点选择,工具必须在 relatedToolIds 中
       if (selectedNode && !relatedToolIds.has(tool.id)) return false;
       return true;
@@ -2856,6 +2937,7 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
     selectedReqId,
     selectedCapId,
     selectedToolId,
+    selectedItemsetId,
     selectedProcCapabilityIds,
     selectedReqCapabilityIds,
     selectedReqToolIds,
@@ -2885,13 +2967,22 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
     const form = new Set<string>();
 
     relatedReqs.forEach((req: any) => {
+      const directSourceNames = new Set<string>();
+      (req.source_nodes || []).forEach((sn: any) => {
+        const nodeName = typeof sn === 'object' ? (sn.node_name || sn.name) : sn;
+        if (nodeName && nodeName !== '__meta__' && nodeName !== '__abstract__') directSourceNames.add(nodeName);
+      });
       const meta = (req.source_nodes || []).find((sn: any) => {
         const nodeName = typeof sn === 'object' ? (sn.node_name || sn.name) : sn;
         return nodeName === '__meta__';
       });
       const ctx = meta?.extraction_context || {};
-      (ctx.substance?.nodes || []).forEach((name: string) => name && substance.add(name));
-      (ctx.form?.nodes || []).forEach((name: string) => name && form.add(name));
+      (ctx.substance?.nodes || []).forEach((name: string) => {
+        if (name && directSourceNames.has(name)) substance.add(name);
+      });
+      (ctx.form?.nodes || []).forEach((name: string) => {
+        if (name && directSourceNames.has(name)) form.add(name);
+      });
     });
 
     const result: Record<string, 'substance' | 'form' | 'both'> = {};
@@ -2904,23 +2995,35 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
     return result;
   }, [filteredData.reqs, selectedProcId, selectedProcRequirementIds, selectedReqId, selectedCapId, selectedCapRequirementIds, selectedToolId, selectedToolRequirementIds, selectedNode, directNodeReqIds]);
 
-  const selectedReqPatternNodeIds = useMemo((): Set<string> | null => {
-    if (!selectedReqId) return null;
+  const collectPatternNodeIdsFromReqIds = useCallback((reqIds: Set<string>): Set<string> | null => {
+    if (reqIds.size === 0) return null;
+
     const relatedLeafNames = new Set<string>();
 
     if (reqPlanBData && reqPlanBData.requirements) {
-      const reqInfo = reqPlanBData.requirements.find((r: any) => r.requirement_id === selectedReqId);
-      if (reqInfo) {
+      reqIds.forEach((reqId) => {
+        const reqInfo = reqPlanBData.requirements.find((r: any) => r.requirement_id === reqId);
+        if (!reqInfo) return;
         (reqInfo.patterns || []).forEach((pattern: any) => {
-          (pattern.leaf_names || []).forEach((name: string) => {
-            if (name) relatedLeafNames.add(name);
+          if (!isPlanBPatternAssociatedWithReq(reqInfo, pattern)) return;
+          let hasTrueJudgment = false;
+          (pattern.judgments || []).forEach((j: any) => {
+            if (j.represents) {
+              hasTrueJudgment = true;
+              relatedLeafNames.add(j.node);
+            }
           });
+          if (!hasTrueJudgment) {
+            (pattern.leaf_names || []).forEach((name: string) => {
+              if (name) relatedLeafNames.add(name);
+            });
+          }
         });
-      }
+      });
     } else {
       allItemsets.forEach((itemset: any) => {
-        const reqIds = itemsetReqIdsMap.get(String(itemset.id)) || new Set<string>();
-        if (!reqIds.has(selectedReqId)) return;
+        const itemsetReqIds = itemsetReqIdsMap.get(String(itemset.id)) || new Set<string>();
+        if (!Array.from(itemsetReqIds).some((reqId) => reqIds.has(reqId))) return;
         (itemset.leaf_names || []).forEach((name: string) => {
           if (name) relatedLeafNames.add(name);
         });
@@ -2930,7 +3033,12 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
     if (relatedLeafNames.size === 0) return null;
     const ids = collectDirectNodeIdsFromNames(relatedLeafNames);
     return ids.size > 0 ? ids : null;
-  }, [reqPlanBData, allItemsets, itemsetReqIdsMap, selectedReqId, collectDirectNodeIdsFromNames]);
+  }, [reqPlanBData, allItemsets, itemsetReqIdsMap, collectDirectNodeIdsFromNames, isPlanBPatternAssociatedWithReq]);
+
+  const selectedReqPatternNodeIds = useMemo((): Set<string> | null => {
+    if (!selectedReqId) return null;
+    return collectPatternNodeIdsFromReqIds(new Set<string>([selectedReqId]));
+  }, [selectedReqId, collectPatternNodeIdsFromReqIds]);
 
   const filteredProcItems = useMemo(() => {
     if (isGlobalConflict) return [];
@@ -2939,11 +3047,12 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
       if (selectedProcId && workflow.id !== selectedProcId) return false;
       if (selectedCapId && !(workflow.capability_ids || []).includes(selectedCapId)) return false;
       if (selectedToolId && !(workflow.capability_ids || []).some((capId: string) => selectedToolCapabilityIds.has(capId))) return false;
+      if (selectedItemsetId && !relatedProcIds.has(workflow.id)) return false;
       // 如果有节点选择,工序必须在 relatedProcIds 中
       if (selectedNode && !relatedProcIds.has(workflow.id)) return false;
       return true;
     });
-  }, [visibleProcItems, selectedReqId, selectedProcId, selectedCapId, selectedToolId, selectedToolCapabilityIds, selectedNode, relatedProcIds]);
+  }, [visibleProcItems, selectedReqId, selectedProcId, selectedCapId, selectedToolId, selectedToolCapabilityIds, selectedItemsetId, selectedNode, relatedProcIds]);
 
   const relationFilterHighlightLeafNames = useMemo((): Set<string> | null => null, []);
 
@@ -2952,43 +3061,57 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
   const filteredToolIds = useMemo(() => new Set(filteredData.tools.map((tool: any) => tool.id)), [filteredData.tools]);
   const filteredProcIds = useMemo(() => new Set(filteredProcItems.map((workflow) => workflow.id)), [filteredProcItems]);
 
+  const reqRelationTagsMap = useMemo((): Record<string, Array<{ label: string; tone?: 'pattern' | 'direct' }>> => {
+    const map: Record<string, Array<{ label: string; tone?: 'pattern' | 'direct' }>> = {};
+    if (!selectedNode) return map;
+
+    filteredData.reqs.forEach((req: any) => {
+      const tags: Array<{ label: string; tone?: 'pattern' | 'direct' }> = [];
+      if (directNodeReqIds.has(req.id)) tags.push({ label: '来源', tone: 'direct' });
+      if (patternNodeReqIds.has(req.id)) tags.push({ label: 'Pattern', tone: 'pattern' });
+      if (tags.length > 0) map[req.id] = tags;
+    });
+
+    return map;
+  }, [selectedNode, filteredData.reqs, directNodeReqIds, patternNodeReqIds]);
+
   const selectedReq = dbData.reqs.find((r: any) => r.id === selectedReqId) ?? null;
   const selectedProc = dbData.procs.find((p) => p.id === selectedProcId) ?? null;
   const selectedCap = allCaps.find((c: any) => c.id === selectedCapId) ?? null;
   const selectedTool = dbData.tools.find((t: any) => t.id === selectedToolId) ?? null;
 
   const selectedReqSourceNodeIds = useMemo((): Set<string> | null => {
-    const relatedReqs =
-      selectedProcId
-        ? filteredData.reqs.filter((req: any) => selectedProcRequirementIds.has(req.id))
-        : selectedReqId
-        ? filteredData.reqs.filter((req: any) => req.id === selectedReqId)
-        : selectedCapId
-        ? filteredData.reqs.filter((req: any) => selectedCapRequirementIds.has(req.id))
-        : selectedToolId
-        ? filteredData.reqs.filter((req: any) => selectedToolRequirementIds.has(req.id))
-        : [];
+    const hasReqSideFilter = !!(selectedReqId || selectedProcId || selectedCapId || selectedToolId);
+    if (!hasReqSideFilter || filteredData.reqs.length === 0) return null;
 
-    if (relatedReqs.length === 0) return null;
-    const ids = collectDirectNodeIdsFromReqs(relatedReqs);
+    const patternIds = collectPatternNodeIdsFromReqIds(new Set<string>(filteredData.reqs.map((req: any) => req.id)));
+    if (patternIds && patternIds.size > 0) return patternIds;
+    const ids = collectDirectNodeIdsFromReqs(filteredData.reqs);
     return ids.size > 0 ? ids : null;
   }, [
     filteredData.reqs,
     selectedProcId,
-    selectedProcRequirementIds,
     selectedReqId,
     selectedCapId,
-    selectedCapRequirementIds,
     selectedToolId,
-    selectedToolRequirementIds,
+    collectPatternNodeIdsFromReqIds,
     collectDirectNodeIdsFromReqs,
   ]);
 
+  const activeTreeNodeIds = useMemo((): Set<string> | null => {
+    const fromNode = selectedNode ? new Set<string>([String(selectedNode.id)]) : null;
+    const fromReqChain = selectedReqSourceNodeIds;
+    const fromItemset = selectedItemsetId ? selectedItemsetDirectNodeIds : null;
+
+    const ids = intersectActiveSets(fromNode, fromReqChain, fromItemset);
+    return ids.size > 0 ? ids : null;
+  }, [selectedNode, selectedReqSourceNodeIds, selectedItemsetId, selectedItemsetDirectNodeIds]);
+
   const selectedPatternNodeIds = useMemo((): Set<string> | null => {
-    if (selectedItemsetDirectNodeIds) return selectedItemsetDirectNodeIds;
-    if (selectedReqPatternNodeIds) return selectedReqPatternNodeIds;
+    if (activeTreeNodeIds) return activeTreeNodeIds;
+    if (selectedReqId) return selectedReqPatternNodeIds;
     return null;
-  }, [selectedItemsetDirectNodeIds, selectedReqPatternNodeIds]);
+  }, [activeTreeNodeIds, selectedReqId, selectedReqPatternNodeIds]);
 
   const selectedSubtreeLeafNamesKey = useMemo(
     () => (selectedSubtreeLeafNames ? Array.from(selectedSubtreeLeafNames).sort().join('|') : ''),
@@ -3325,26 +3448,73 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
     false
   );
 
-  const sortedItems = (items: any[], eligibleIds?: Set<string>) => {
+  const sortedItems = (items: any[], eligibleIds?: Set<string>, frozenOrder?: string[]) => {
+    if (frozenOrder && frozenOrder.length > 0) {
+      const rank = new Map<string, number>(frozenOrder.map((id, index) => [id, index]));
+      return [...items].sort((a, b) => {
+        const aRank = rank.get(String(a.id));
+        const bRank = rank.get(String(b.id));
+        if (aRank === undefined && bRank === undefined) return 0;
+        if (aRank === undefined) return 1;
+        if (bRank === undefined) return -1;
+        return aRank - bRank;
+      });
+    }
     if (!eligibleIds || eligibleIds.size === 0) return [...items];
     return [...items].sort((a, b) => {
       const aEligible = eligibleIds.has(a.id) ? 0 : 1;
       const bEligible = eligibleIds.has(b.id) ? 0 : 1;
-      return aEligible - bEligible;
+      if (aEligible !== bEligible) return aEligible - bEligible;
+      return 0;
     });
   };
 
-  const handleSingleClick = (nodeId: string, item: any) => {
+  const handleSingleClick = (nodeId: string, item: any, currentOrderIds: string[]) => {
     const [type, id] = nodeId.split(':');
     const isSameProc = type === 'proc' && selectedProcId === id;
     const isSameReq = type === 'req' && selectedReqId === id;
     const isSameCap = type === 'cap' && selectedCapId === id;
     const isSameTool = type === 'tool' && selectedToolId === id;
 
-    if (type === 'proc') { setSelectedProcId(isSameProc ? null : id); setColumnFocusIndex(prev => ({ ...prev, proc: 0 })); }
-    if (type === 'req') { setSelectedReqId(isSameReq ? null : id); setColumnFocusIndex(prev => ({ ...prev, req: 0 })); }
-    if (type === 'cap') { setSelectedCapId(isSameCap ? null : id); setColumnFocusIndex(prev => ({ ...prev, cap: 0 })); }
-    if (type === 'tool') { setSelectedToolId(isSameTool ? null : id); setColumnFocusIndex(prev => ({ ...prev, tool: 0 })); }
+    setManualFocusedTreeNodeId(null);
+    setTreeFocusTrigger(prev => prev + 1);
+
+    if (type === 'proc') {
+      setSelectedProcId(isSameProc ? null : id);
+      setColumnFrozenOrders(prev => {
+        const next = { ...prev };
+        if (isSameProc) delete next.proc;
+        else next.proc = currentOrderIds;
+        return next;
+      });
+    }
+    if (type === 'req') {
+      setSelectedReqId(isSameReq ? null : id);
+      setColumnFrozenOrders(prev => {
+        const next = { ...prev };
+        if (isSameReq) delete next.req;
+        else next.req = currentOrderIds;
+        return next;
+      });
+    }
+    if (type === 'cap') {
+      setSelectedCapId(isSameCap ? null : id);
+      setColumnFrozenOrders(prev => {
+        const next = { ...prev };
+        if (isSameCap) delete next.cap;
+        else next.cap = currentOrderIds;
+        return next;
+      });
+    }
+    if (type === 'tool') {
+      setSelectedToolId(isSameTool ? null : id);
+      setColumnFrozenOrders(prev => {
+        const next = { ...prev };
+        if (isSameTool) delete next.tool;
+        else next.tool = currentOrderIds;
+        return next;
+      });
+    }
 
     if (type === 'req') {
       if (isSameReq) {
@@ -3424,25 +3594,6 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
   const eligibleCapIds = useMemo(() => new Set(filteredData.caps.map((c: any) => c.id)), [filteredData.caps]);
   const eligibleToolIds = useMemo(() => new Set(filteredData.tools.map((t: any) => t.id)), [filteredData.tools]);
 
-  // 同步 columnFocusIndex.req 到 selectedReqId 的实际位置
-  useEffect(() => {
-    if (!selectedReqId) return;
-    const displayedReqs = visibleData.reqs;
-    const matchedIndices: number[] = [];
-    displayedReqs.forEach((req: any, idx: number) => {
-      const isSelected = req.id === selectedReqId;
-      const isEligible = eligibleReqIds.has(req.id);
-      if (isSelected || isEligible) {
-        matchedIndices.push(idx);
-      }
-    });
-    const selectedReqIdx = displayedReqs.findIndex((r: any) => r.id === selectedReqId);
-    const positionInMatched = matchedIndices.indexOf(selectedReqIdx);
-    if (positionInMatched >= 0) {
-      setColumnFocusIndex(prev => ({ ...prev, req: positionInMatched }));
-    }
-  }, [selectedReqId, visibleData.reqs, eligibleReqIds]);
-
   const displayedReqs = visibleData.reqs;
   const displayedProcs = visibleProcItems;
   const displayedCaps = visibleData.caps;
@@ -3611,6 +3762,51 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
           <span className={cn("w-2 h-2 rounded-full", onlyCoveredFilter ? "bg-indigo-500" : "bg-slate-400")} />
           只看覆盖需求的数据
         </button>
+        {selectedReq && (
+          <button
+            type="button"
+            onClick={() => setSelectedReqId(null)}
+            className="text-xs font-bold px-3 py-1.5 rounded-lg bg-indigo-100 text-indigo-700 hover:bg-indigo-200 transition-colors"
+          >
+            {selectedReq.description?.slice(0, 18) || selectedReq.id} ×
+          </button>
+        )}
+        {selectedProc && (
+          <button
+            type="button"
+            onClick={() => setSelectedProcId(null)}
+            className="text-xs font-bold px-3 py-1.5 rounded-lg bg-purple-100 text-purple-700 hover:bg-purple-200 transition-colors"
+          >
+            {selectedProc.id} ×
+          </button>
+        )}
+        {selectedCap && (
+          <button
+            type="button"
+            onClick={() => setSelectedCapId(null)}
+            className="text-xs font-bold px-3 py-1.5 rounded-lg bg-rose-100 text-rose-700 hover:bg-rose-200 transition-colors"
+          >
+            {selectedCap.name || selectedCap.id} ×
+          </button>
+        )}
+        {selectedTool && (
+          <button
+            type="button"
+            onClick={() => setSelectedToolId(null)}
+            className="text-xs font-bold px-3 py-1.5 rounded-lg bg-green-100 text-green-700 hover:bg-green-200 transition-colors"
+          >
+            {selectedTool.name || selectedTool.id} ×
+          </button>
+        )}
+        {selectedItemset && (
+          <button
+            type="button"
+            onClick={() => setSelectedItemsetId(null)}
+            className="text-xs font-bold px-3 py-1.5 rounded-lg bg-amber-100 text-amber-700 hover:bg-amber-200 transition-colors"
+          >
+            Pattern #{selectedItemset.id} ×
+          </button>
+        )}
         {hasAnyFilter && (
           <button
             onClick={() => {
@@ -3620,6 +3816,8 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
               setSelectedCapId(null);
               setSelectedToolId(null);
               setSelectedItemsetId(null);
+              setManualFocusedTreeNodeId(null);
+              setColumnFrozenOrders({});
             }}
             className="text-xs text-slate-400 hover:text-slate-600 flex items-center gap-1 bg-slate-200 hover:bg-slate-300 px-3 py-1.5 rounded-lg transition-colors font-bold"
           >
@@ -3644,6 +3842,7 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
           <CategoryTree
             data={treeData}
             onSelect={(node) => {
+              setManualFocusedTreeNodeId(null);
               const isSameNode = selectedNode && String(selectedNode.id) === String(node.id);
               if (isSameNode) {
                 setSelectedNode(null);
@@ -3669,7 +3868,7 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
               if (selectedNode) setSelectedNode(null);
               if (!selectedNode && selectedReqId) setSelectedReqId(null);
             } : undefined}
-            sourceNodeIds={selectedReqSourceNodeIds}
+            sourceNodeIds={selectedPatternNodeIds || selectedReqSourceNodeIds}
             patternNodeIds={selectedPatternNodeIds}
             nodeMetricsMap={nodeMetricsMap}
             totalNodeCount={totalNodeCount}
@@ -3677,7 +3876,7 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
             onToggleWideMode={() => setTreeWideMode(m => !m)}
             treeFocusIndex={treeFocusIndex}
             treeMatchedCount={treeMatchedCount}
-            focusedTreeNodeId={focusedTreeNode?.id}
+            focusedTreeNodeId={manualFocusedTreeNodeId ?? focusedTreeNode?.id}
             focusTrigger={treeFocusTrigger}
             onTreeFocusPrev={() => setTreeFocusIndex(prev => Math.max(0, prev - 1))}
             onTreeFocusNext={() => setTreeFocusIndex(prev => Math.min(treeMatchedCount - 1, prev + 1))}
@@ -3689,31 +3888,44 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
           selectedItemsetId={selectedItemsetId}
           itemsets={allItemsets}
           eligibleItemsetIds={relatedItemsetIds}
+          frozenOrder={columnFrozenOrders.pattern}
           contextNodeNames={patternContextNodeNames}
           patternMatchedNodesMap={patternMatchedNodesMap}
           nodeRoleByName={patternRoleByName}
           hasAnyFilter={hasAnyFilter}
           focusIndex={columnFocusIndex.pattern ?? 0}
-          onSelectItemset={(itemsetId) => {
+          focusTrigger={columnFocusTrigger.pattern ?? 0}
+          onSelectItemset={(itemsetId, currentOrderIds) => {
+            setManualFocusedTreeNodeId(null);
             setSelectedItemsetId(itemsetId);
             if (itemsetId !== null) {
               setTreeFocusTrigger(prev => prev + 1);
             }
-            setColumnFocusIndex(prev => ({ ...prev, pattern: 0 }));
+            setColumnFrozenOrders(prev => {
+              const next = { ...prev };
+              if (itemsetId === null) delete next.pattern;
+              else next.pattern = currentOrderIds;
+              return next;
+            });
             if (itemsetId === null) setDrawerItem(null);
           }}
           onOpenDrawer={(itemset) => setDrawerItem({ type: 'itemset', data: itemset })}
-          onFocusPrev={() => setColumnFocusIndex(prev => ({ ...prev, pattern: Math.max(0, (prev.pattern ?? 0) - 1) }))}
+          onNodeClick={jumpToTreeNodeByName}
+          onFocusPrev={() => {
+            setColumnFocusIndex(prev => ({ ...prev, pattern: Math.max(0, (prev.pattern ?? 0) - 1) }));
+            setColumnFocusTrigger(prev => ({ ...prev, pattern: (prev.pattern ?? 0) + 1 }));
+          }}
           onFocusNext={() => {
             const matchedCount = Array.from(relatedItemsetIds).length + (selectedItemsetId ? (relatedItemsetIds.has(String(selectedItemsetId)) ? 0 : 1) : 0);
             setColumnFocusIndex(prev => ({ ...prev, pattern: Math.min(Math.max(0, matchedCount - 1), (prev.pattern ?? 0) + 1) }));
+            setColumnFocusTrigger(prev => ({ ...prev, pattern: (prev.pattern ?? 0) + 1 }));
           }}
         />
 
         {/* 关系列:每列固定宽度 */}
         {columns.map(col => {
           const activeId = getColumnActiveId(col.t);
-          const orderedItems = sortedItems(col.d, col.eligibleIds);
+          const orderedItems = sortedItems(col.d, col.eligibleIds, activeId !== null ? columnFrozenOrders[col.t] : undefined);
 
           // 计算所有可用匹配项的索引(按原始顺序)
           const matchedIndices: number[] = [];
@@ -3740,7 +3952,10 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
                     <div className="flex items-center gap-1">
                       <button
                         type="button"
-                        onClick={() => setColumnFocusIndex(prev => ({ ...prev, [col.t]: Math.max(0, (prev[col.t] ?? 0) - 1) }))}
+                        onClick={() => {
+                          setColumnFocusIndex(prev => ({ ...prev, [col.t]: Math.max(0, (prev[col.t] ?? 0) - 1) }));
+                          setColumnFocusTrigger(prev => ({ ...prev, [col.t]: (prev[col.t] ?? 0) + 1 }));
+                        }}
                         disabled={clampedFocusIdx === 0}
                         className="p-0.5 rounded text-slate-400 hover:text-slate-600 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
                       >
@@ -3751,7 +3966,10 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
                       </span>
                       <button
                         type="button"
-                        onClick={() => setColumnFocusIndex(prev => ({ ...prev, [col.t]: Math.min(matchedIndices.length - 1, (prev[col.t] ?? 0) + 1) }))}
+                        onClick={() => {
+                          setColumnFocusIndex(prev => ({ ...prev, [col.t]: Math.min(matchedIndices.length - 1, (prev[col.t] ?? 0) + 1) }));
+                          setColumnFocusTrigger(prev => ({ ...prev, [col.t]: (prev[col.t] ?? 0) + 1 }));
+                        }}
                         disabled={clampedFocusIdx === matchedIndices.length - 1}
                         className="p-0.5 rounded text-slate-400 hover:text-slate-600 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
                       >
@@ -3764,42 +3982,6 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
                   </span>
                 </div>
               </div>
-              {col.t === 'req' && selectedReq && (
-                <button
-                  type="button"
-                  onClick={() => setSelectedReqId(null)}
-                  className="mt-2 text-[10px] font-bold px-2 py-1 rounded-md bg-indigo-100 text-indigo-700 hover:bg-indigo-200 transition-colors"
-                >
-                  {selectedReq.description?.slice(0, 18) || selectedReq.id} ×
-                </button>
-              )}
-              {col.t === 'proc' && selectedProc && (
-                <button
-                  type="button"
-                  onClick={() => setSelectedProcId(null)}
-                  className="mt-2 text-[10px] font-bold px-2 py-1 rounded-md bg-purple-100 text-purple-700 hover:bg-purple-200 transition-colors"
-                >
-                  {selectedProc.id} ×
-                </button>
-              )}
-              {col.t === 'cap' && selectedCap && (
-                <button
-                  type="button"
-                  onClick={() => setSelectedCapId(null)}
-                  className="mt-2 text-[10px] font-bold px-2 py-1 rounded-md bg-rose-100 text-rose-700 hover:bg-rose-200 transition-colors"
-                >
-                  {selectedCap.name || selectedCap.id} ×
-                </button>
-              )}
-              {col.t === 'tool' && selectedTool && (
-                <button
-                  type="button"
-                  onClick={() => setSelectedToolId(null)}
-                  className="mt-2 text-[10px] font-bold px-2 py-1 rounded-md bg-green-100 text-green-700 hover:bg-green-200 transition-colors"
-                >
-                  {selectedTool.name || selectedTool.id} ×
-                </button>
-              )}
             </div>
             <div className="flex-1 overflow-y-auto p-3 custom-scrollbar">
               {orderedItems.map((item: any, idx: number) => (
@@ -3817,7 +3999,7 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
                   }
                   dimmed={!col.eligibleIds?.has(item.id)}
                   activeId={activeId}
-                  shouldScrollIntoView={idx === focusedItemIndex}
+                  shouldScrollIntoView={idx === focusedItemIndex && (columnFocusTrigger[col.t] ?? 0) > 0}
                   selectedLeafNames={selectedSubtreeLeafNames || undefined}
                   directMatch={
                     col.t === 'req' ? relatedReqIds.has(item.id) :
@@ -3826,14 +4008,11 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
                     col.t === 'tool' ? relatedToolIds.has(item.id) :
                     false
                   }
+                  relationTags={col.t === 'req' ? (reqRelationTagsMap[item.id] || []) : []}
                   showAllSourceTags={col.t === 'req' && !!selectedTreePartition}
-                  onSingleClick={(nodeId) => handleSingleClick(nodeId, item)}
+                  onSingleClick={(nodeId) => handleSingleClick(nodeId, item, orderedItems.map((entry: any) => String(entry.id)))}
                   onSourceNodeClick={(name) => {
-                    const node = nameToNodeMap[name];
-                    if (node) {
-                      setSelectedNode(node);
-                      setDrawerItem({ type: 'node', data: node });
-                    }
+                    jumpToTreeNodeByName(name);
                   }}
                 />
               ))}

+ 208 - 63
knowhub/frontend/src/pages/Workflows.tsx

@@ -1,13 +1,15 @@
 import { useState, useEffect } from 'react';
 import type { FormEvent } from 'react';
-import { Layers, Cpu, CheckCircle2, Target, Search, Activity } from 'lucide-react';
-import { getStrategies, getCapabilities } from '../services/api';
+import { Layers, Cpu, CheckCircle2, Target, Search, Activity, ChevronDown, ChevronRight } from 'lucide-react';
+import { getStrategies, getCapabilities, getTools, getRequirements } from '../services/api';
 import { StatCard } from '../components/common/StatCard';
 import { StatusBadge } from '../components/common/EntityTag';
 
 interface WorkflowStep {
   title?: string;
   description: string;
+  input?: string;
+  tools?: string[];
   capabilities: any[];
 }
 
@@ -44,14 +46,27 @@ function parseSteps(body: string | any[] | object): WorkflowStep[] {
       const parsedCaps = capsArray.map((c: any) => typeof c === 'string' ? { id: c } : c).filter((c: any) => c && (c.capability_id || c.id || c.name || c.capability_name));
       
       let title = phaseObj.phase || phaseObj.name || phaseObj.title || phaseObj.step || phaseObj.module_label || '';
-      let description = phaseObj.description || phaseObj.desc || phaseObj.details || (typeof phase === 'string' ? phase : JSON.stringify(phase));
+      let description = phaseObj.description || phaseObj.desc || phaseObj.details || '';
+      let inputVal = phaseObj.input || phaseObj.inputs || phaseObj.output || '';
+      
+      // If phase is string and title is not set, meaning fallback
+      if (typeof phase === 'string' && !title && !description) {
+         description = phase;
+      }
+      // If we don't have a separated description but we do have a title (phase), that's fine.
+      
+      let toolsRaw = phaseObj.tools || phaseObj.tool || [];
+      const parsedTools = Array.isArray(toolsRaw) ? toolsRaw : (typeof toolsRaw === 'string' ? [toolsRaw] : []);
 
       if (typeof title === 'object') title = JSON.stringify(title);
       if (typeof description === 'object') description = JSON.stringify(description);
+      if (typeof inputVal === 'object') inputVal = JSON.stringify(inputVal);
 
       return {
         title: String(title),
         description: String(description),
+        input: String(inputVal),
+        tools: parsedTools,
         capabilities: parsedCaps
       };
     });
@@ -81,6 +96,8 @@ export function Workflows() {
   
   const [strategies, setStrategies] = useState<any[]>(() => getCache()?.strategies || []);
   const [capabilities, setCapabilities] = useState<Record<string, any>>(() => getCache()?.capabilities || {});
+  const [allToolsMap, setAllToolsMap] = useState<Record<string, any>>(() => getCache()?.allToolsMap || {});
+  const [allReqsMap, setAllReqsMap] = useState<Record<string, any>>(() => getCache()?.allReqsMap || {});
   const [isLoading, setIsLoading] = useState(() => !(getCache()?.strategies?.length > 0));
   const [isLoadingMore, setIsLoadingMore] = useState(false);
   const [offset, setOffset] = useState(0);
@@ -88,6 +105,8 @@ export function Workflows() {
   const [totalCount, setTotalCount] = useState<number>(() => getCache()?.totalCount || 0);
   const [activeCount, setActiveCount] = useState<number>(() => getCache()?.activeCount || 0);
   const [searchQuery, setSearchQuery] = useState("");
+  const [expandedReqs, setExpandedReqs] = useState<Record<string, boolean>>({});
+  const [expandedSteps, setExpandedSteps] = useState<Record<string, boolean>>({});
   const LIMIT = 50;
   
   const loadStrategies = (currentOffset: number, isInit = false) => {
@@ -121,13 +140,26 @@ export function Workflows() {
 
   useEffect(() => {
     setIsLoading(true);
-    getCapabilities(1000).then(capRes => {
+    Promise.all([getCapabilities(1000), getTools(1000), getRequirements(1000)]).then(([capRes, toolRes, reqRes]) => {
       const capMap: Record<string, any> = {};
       (capRes.capabilities || capRes.results || (Array.isArray(capRes) ? capRes : [])).forEach((c: any) => {
         capMap[c.capability_id || c.id || c.capability_name || c.name] = c;
       });
       setCapabilities(capMap);
-      saveCache({ capabilities: capMap });
+
+      const tMap: Record<string, any> = {};
+      (toolRes.tools || toolRes.results || (Array.isArray(toolRes) ? toolRes : [])).forEach((t: any) => {
+        tMap[t.id] = t;
+      });
+      setAllToolsMap(tMap);
+
+      const rMap: Record<string, any> = {};
+      (reqRes.requirements || reqRes.results || (Array.isArray(reqRes) ? reqRes : [])).forEach((r: any) => {
+        rMap[r.req_id || r.id] = r;
+      });
+      setAllReqsMap(rMap);
+
+      saveCache({ capabilities: capMap, allToolsMap: tMap, allReqsMap: rMap });
       loadStrategies(0, true);
       
       // Calculate active count without downloading all bodies
@@ -212,72 +244,185 @@ export function Workflows() {
             const stratCaps = strategy.capability_ids || [];
 
           return (
-            <div key={strategy.id} className="bg-white rounded-3xl border border-slate-200 shadow-sm overflow-hidden hover:border-indigo-300 transition-colors duration-300">
-              <div className="p-6 border-b border-slate-100 flex flex-col md:flex-row justify-between items-start gap-4 bg-slate-50/50">
-                <div>
-                   <div className="flex items-center gap-3 mb-2">
-                     <h2 className="text-lg font-black text-slate-900">{strategy.name || '未命名工序'}</h2>
-                     <span className="text-[11px] font-mono text-slate-400 bg-white px-2 py-0.5 rounded border border-slate-100">ID: {strategy.id}</span>
+            <div key={strategy.id} className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden mb-6">
+              <div className="px-5 py-4 border-b border-slate-100 flex flex-col gap-3 bg-slate-50">
+                 <div className="flex items-center justify-between">
+                   <div className="flex items-center gap-3">
+                     <span className="text-sm font-bold text-slate-500 font-mono select-all">{strategy.id}</span>
+                     <h2 className="text-[16px] font-bold text-slate-900">{strategy.name || '未命名工序'}</h2>
                    </div>
-                   <p className="text-sm text-slate-600 leading-relaxed max-w-3xl">
-                     {strategy.description || '无详细描述'}
-                   </p>
-                </div>
-              </div>
+                 </div>
+                 
+                 {strategy.description && (
+                   <p className="text-sm text-slate-600 leading-relaxed max-w-4xl">{strategy.description}</p>
+                 )}
+                     
+                     {(() => {
+                       let bodyObj: any = {};
+                       try {
+                         bodyObj = typeof strategy.body === 'string' ? JSON.parse(strategy.body || '{}') : (strategy.body || {});
+                       } catch { /* ignore */ }
+                       
+                       const evals = bodyObj.coverage_evaluations;
+                       const hasEvals = evals && Object.keys(evals).length > 0;
+                       
+                       return (
+                         <div className="mt-4 border border-slate-100 rounded-xl overflow-hidden bg-slate-50/50">
+                           <div className="px-4 py-2 bg-slate-100/50 border-b border-slate-100 text-[11px] font-bold text-slate-500 uppercase tracking-wider flex items-center gap-2">
+                              <Target size={14} className="text-indigo-400" />
+                              覆盖需求判定
+                           </div>
+                           <div className="divide-y divide-slate-100">
+                           {!hasEvals ? (
+                             <div className="p-4 text-sm text-slate-400 text-center italic">该工序暂无需求判定记录</div>
+                           ) : (
+                             Object.entries(evals).map(([reqId, val]: [string, any]) => {
+                             const score = val?.score ?? 0;
+                             let scoreColor = "text-rose-600";
+                             if (score >= 0.8) scoreColor = "text-emerald-600";
+                             else if (score >= 0.5) scoreColor = "text-amber-600";
+                             
+                             const explainText = val?.explanation || val?.explaination || val?.reason || '';
+                             const reqObj = allReqsMap[reqId];
+                             const reqDescription = reqObj?.description || reqObj?.subject || reqObj?.name || '暂无描述';
+                             const isExpanded = expandedReqs[`${strategy.id}-${reqId}`];
+                             
+                             return (
+                               <div key={reqId} className="flex flex-col bg-white hover:bg-slate-50/50 transition-colors">
+                                 <div 
+                                    className="flex flex-col md:flex-row gap-4 p-4 items-start cursor-pointer group"
+                                    onClick={(e) => {
+                                      e.stopPropagation();
+                                      const key = `${strategy.id}-${reqId}`;
+                                      setExpandedReqs(prev => ({ ...prev, [key]: !prev[key] }));
+                                    }}
+                                 >
+                                   <div className="w-6 shrink-0 flex items-center justify-center text-slate-300 group-hover:text-indigo-400 transition-colors pt-1">
+                                     {isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
+                                   </div>
+                                   <div className="w-[120px] shrink-0 pt-0.5">
+                                     <span className="inline-block px-2 py-1 bg-slate-100 text-slate-600 border border-slate-200 rounded text-[11px] font-bold font-mono truncate max-w-full" title={reqId}>
+                                       {reqId}
+                                     </span>
+                                   </div>
+                                   <div className="flex-1 min-w-[200px] pt-1">
+                                     <div className="text-[13px] font-medium text-slate-800 leading-relaxed whitespace-pre-wrap">
+                                       {reqDescription}
+                                     </div>
+                                   </div>
+                                   <div className={`w-[80px] shrink-0 text-right font-mono font-black text-[16px] pt-0.5 ${scoreColor}`}>
+                                     {Number(score).toFixed(2)}
+                                   </div>
+                                 </div>
+                                 {isExpanded && (
+                                   <div className="px-4 pb-4 md:pl-[11.5rem] animate-in fade-in slide-in-from-top-1">
+                                     <div className="p-3 bg-slate-50 rounded-lg border border-slate-100 text-[12px] text-slate-500 leading-relaxed whitespace-pre-wrap">
+                                       {explainText || <span className="italic text-slate-400">暂无判定说明</span>}
+                                     </div>
+                                   </div>
+                                 )}
+                               </div>
+                             );
+                           })
+                           )}
+                           </div>
+                         </div>
+                       );
+                     })()}
+                  </div>
 
-              <div className="p-6 bg-white">
-                <h3 className="font-black text-[13px] text-indigo-900 tracking-wide mb-4 flex items-center gap-2">
-                   <Activity size={16} className="text-indigo-500" />
-                   执行流展开 (Workflow)
-                </h3>
-                
+                  <div className="overflow-x-auto p-5">
                 {steps.length === 0 ? (
                   <div className="text-sm text-slate-400 p-4 bg-slate-50 rounded-xl border border-slate-100 flex items-center gap-2">
                      该工序暂未定义内部执行流
                   </div>
                 ) : (
-                  <div className="space-y-3">
-                    {steps.map((step, idx) => (
-                      <div key={idx} className="group flex gap-4 p-4 bg-slate-50/50 border border-slate-100 rounded-2xl items-stretch hover:bg-indigo-50/30 hover:border-indigo-100 transition-colors">
-                        
-                        <div className="w-8 shrink-0 flex flex-col items-center">
-                           <span className="w-7 h-7 rounded-xl bg-white border border-slate-200 text-indigo-600 font-black text-sm flex items-center justify-center shadow-sm group-hover:border-indigo-300 group-hover:bg-indigo-600 group-hover:text-white transition-all">
-                             {idx + 1}
-                           </span>
-                           {idx !== steps.length - 1 && (
-                             <div className="w-[2px] h-full bg-slate-200 mt-2 mb-[-16px] group-hover:bg-indigo-200 transition-colors"></div>
-                           )}
-                        </div>
-                        
-                        <div className="flex-1 py-1">
-                           {step.title && <div className="font-bold text-slate-800 text-[15px] mb-1.5">{step.title}</div>}
-                           <div className="text-slate-600 text-[13px] leading-relaxed break-words whitespace-pre-wrap">{step.description}</div>
-                        </div>
-                        
-                        <div className="w-1/3 shrink-0 py-1 pl-4 border-l border-slate-200 border-dashed flex flex-col justify-start">
-                           <span className="text-[10px] font-black text-slate-400 mb-2 uppercase tracking-wide">调用的原子能力</span>
-                           {(!step.capabilities || step.capabilities.length === 0) ? (
-                             <span className="text-[11px] text-slate-400">无特定关联能力</span>
-                           ) : (
-                             <div className="flex flex-wrap gap-2">
-                               {step.capabilities.map((capObj: any, i: number) => {
-                                 const capIdStr = typeof capObj === 'string' ? capObj : (capObj?.capability_id || capObj?.id);
-                                 const cap = capabilities[capIdStr] || (typeof capObj === 'object' ? capObj : null);
-                                 const fallbackStr = typeof capObj === 'string' ? capObj : (capObj?.capability_name || capObj?.name || capIdStr);
-                                 return (
-                                   <div key={i} className="flex items-center gap-1.5 bg-white border border-emerald-100 px-2 py-1.5 rounded-lg text-xs hover:border-emerald-400 hover:bg-emerald-50 transition-colors shadow-sm w-full">
-                                     <Cpu size={12} className="text-emerald-500 shrink-0" />
-                                     <span className="text-emerald-900 font-bold truncate">{cap?.capability_name || cap?.name || fallbackStr}</span>
-                                   </div>
-                                 );
-                               })}
+                  <table className="w-full text-left text-sm whitespace-nowrap min-w-[700px]">
+                    <thead className="bg-slate-50 border-b border-slate-100 text-[13px] text-slate-500 font-bold">
+                      <tr>
+                        <th className="px-5 py-3 w-16 text-center">步骤</th>
+                        <th className="px-5 py-3 w-[30%] whitespace-normal">描述</th>
+                        <th className="px-5 py-3 w-[20%] whitespace-normal">工具</th>
+                        <th className="px-5 py-3 w-[30%] whitespace-normal">原子能力分类</th>
+                      </tr>
+                    </thead>
+                    <tbody className="divide-y divide-slate-100/50 text-[13px]">
+                      {steps.map((step, idx) => {
+                         const isStepExpanded = expandedSteps[`${strategy.id}-${idx}`];
+                         return (
+                         <tr 
+                           key={idx} 
+                           className="hover:bg-indigo-50/20 transition-colors cursor-pointer group"
+                           onClick={() => {
+                             const key = `${strategy.id}-${idx}`;
+                             setExpandedSteps(prev => ({...prev, [key]: !prev[key]}));
+                           }}
+                         >
+                           <td className="px-5 py-4 text-center font-black text-slate-400">
+                             <div className="flex items-center gap-2 justify-center">
+                               <div className="text-slate-300 group-hover:text-indigo-400 transition-colors">
+                                 {isStepExpanded ? <ChevronDown size={14}/> : <ChevronRight size={14}/>}
+                               </div>
+                               <span>{idx + 1}</span>
                              </div>
-                           )}
-                        </div>
-                        
-                      </div>
-                    ))}
-                  </div>
+                           </td>
+                           <td className="px-5 py-4 font-medium text-slate-800 whitespace-normal leading-relaxed">
+                              {step.title ? (
+                                <div className="font-bold">{step.title}</div>
+                              ) : (
+                                <div className="font-bold text-slate-500">{(step.description || '').slice(0, 30)}...</div>
+                              )}
+                              
+                              {isStepExpanded && step.description && (
+                                <div className="text-slate-600 font-normal mt-2 whitespace-pre-wrap animate-in fade-in slide-in-from-top-1">
+                                  {step.description}
+                                </div>
+                              )}
+                           </td>
+                           <td className="px-5 py-4 text-slate-600 whitespace-normal">
+                              {(() => {
+                                 const derivedToolIds = new Set<string>();
+                                 if (step.capabilities && step.capabilities.length > 0) {
+                                     step.capabilities.forEach((capObj: any) => {
+                                        const capIdStr = typeof capObj === 'string' ? capObj : (capObj?.capability_id || capObj?.id);
+                                        const cap = capabilities[capIdStr];
+                                        if (cap && cap.tool_ids) {
+                                            cap.tool_ids.forEach((tid: string) => derivedToolIds.add(tid));
+                                        }
+                                     });
+                                 }
+                                 let derivedTools = Array.from(derivedToolIds).map(tid => allToolsMap[tid]?.name || tid);
+                                 if (derivedTools.length === 0 && step.tools && step.tools.length > 0) {
+                                     derivedTools = step.tools;
+                                 }
+                                 return derivedTools.length > 0 ? derivedTools.map((t, i) => (
+                                    <div key={i} className="mb-1">{t}</div>
+                                 )) : <span className="text-slate-400 italic">暂无</span>;
+                              })()}
+                           </td>
+                           <td className="px-5 py-4 whitespace-normal">
+                              {(!step.capabilities || step.capabilities.length === 0) ? (
+                                <span className="text-slate-400 italic">暂无</span>
+                              ) : (
+                                <div className="flex flex-wrap gap-1.5">
+                                  {step.capabilities.map((capObj: any, i: number) => {
+                                      const capIdStr = typeof capObj === 'string' ? capObj : (capObj?.capability_id || capObj?.id);
+                                      const cap = capabilities[capIdStr] || (typeof capObj === 'object' ? capObj : null);
+                                      const capName = cap?.capability_name || cap?.name || capObj?.capability_name || capObj?.name || capIdStr;
+                                      return (
+                                        <span key={i} className="px-2 py-1 bg-indigo-50 text-indigo-600 rounded text-xs font-bold whitespace-nowrap">
+                                          {capName}
+                                        </span>
+                                      );
+                                  })}
+                                </div>
+                              )}
+                           </td>
+                         </tr>
+                       );
+                      })}
+                    </tbody>
+                  </table>
                 )}
               </div>
             </div>