elksmmx 1 месяц назад
Родитель
Сommit
1042a7a0cc

+ 14 - 16
knowhub/frontend/src/components/dashboard/CategoryTree.tsx

@@ -1,4 +1,4 @@
-import { useState } from 'react';
+import { useState, useRef, useEffect } from 'react';
 import { createPortal } from 'react-dom';
 import { cn } from '../../lib/utils';
 import { ChevronRight, ChevronDown, ChevronLeft, ZoomIn, ZoomOut, Maximize, FolderTree } from 'lucide-react';
@@ -15,6 +15,7 @@ interface NodeProps {
   nodeMetricsMap: Record<string, { reqCount: number; procCount: number; capCount: number; toolCount: number; nodeCount: number }>;
   dimensionColor: string; // hex color for the node's dimension
   focusedTreeNodeId?: string | number | null;
+  focusTrigger?: number;
 }
 
 // Returns true if this node or any descendant is in the highlight set
@@ -25,9 +26,10 @@ function nodeHasHighlightedLeaf(node: any, highlightLeafNames: Set<string> | nul
   return node.children.some((child: any) => nodeHasHighlightedLeaf(child, highlightLeafNames));
 }
 
-function HorizontalTreeNode({ node, onSelect, selectedId, level, highlightLeafNames, subtreeHighlightNodeIds, sourceNodeIds, patternNodeIds, nodeMetricsMap, dimensionColor, focusedTreeNodeId = null }: NodeProps) {
+function HorizontalTreeNode({ node, onSelect, selectedId, level, highlightLeafNames, subtreeHighlightNodeIds, sourceNodeIds, patternNodeIds, nodeMetricsMap, dimensionColor, focusedTreeNodeId = null, focusTrigger = 0 }: NodeProps) {
   const [expanded, setExpanded] = useState(true);
   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 };
   let textColor = "text-slate-800";
@@ -37,9 +39,7 @@ function HorizontalTreeNode({ node, onSelect, selectedId, level, highlightLeafNa
   const inHighlight = nodeHasHighlightedLeaf(node, highlightLeafNames);
   const isLeafHighlighted = highlightLeafNames && highlightLeafNames.has(node.name);
   const isInSubtreeHighlight = subtreeHighlightNodeIds?.has(String(node.id)) ?? false;
-  const isSourceNode = sourceNodeIds?.has(String(node.id)) ?? false;
   const isPatternNode = patternNodeIds?.has(String(node.id)) ?? false;
-  const isDualLinked = isSourceNode && isPatternNode;
   const isSelected =
     selectedId !== null &&
     selectedId !== undefined &&
@@ -52,20 +52,24 @@ function HorizontalTreeNode({ node, onSelect, selectedId, level, highlightLeafNa
     node.id !== null &&
     node.id !== undefined &&
     String(focusedTreeNodeId) === String(node.id);
+
+  useEffect(() => {
+    if (nodeRef.current && (isSelected || shouldScrollIntoView)) {
+      nodeRef.current.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' });
+    }
+  }, [isSelected, shouldScrollIntoView, focusTrigger]);
+
   return (
     <div className={cn("flex flex-row items-start transition-opacity duration-200", highlightLeafNames && !inHighlight && "opacity-20")}>
       {/* Node Card */}
       <div
+        ref={nodeRef}
         className={cn(
           "flex items-center px-3 py-1.5 rounded-md border shadow-[0_1px_2px_rgba(0,0,0,0.05)] cursor-pointer whitespace-nowrap transition-all z-10 h-[34px] bg-white border-slate-200",
           isSelected
             ? "ring-2 ring-orange-400 ring-offset-2 border-orange-400 shadow-[0_0_0_2px_rgba(251,146,60,0.16)]"
-            : isDualLinked
-            ? "ring-2 ring-emerald-400 ring-offset-1 border-emerald-300 shadow-[0_0_0_2px_rgba(16,185,129,0.16)]"
-            : isSourceNode
-            ? "ring-2 ring-sky-400 ring-offset-1 border-sky-300 shadow-[0_0_0_2px_rgba(56,189,248,0.16)]"
             : isPatternNode
-            ? "ring-2 ring-violet-400 ring-offset-1 border-violet-300 shadow-[0_0_0_2px_rgba(139,92,246,0.16)]"
+            ? "ring-2 ring-sky-400 ring-offset-1 border-sky-300 shadow-[0_0_0_2px_rgba(56,189,248,0.16)]"
             : isInSubtreeHighlight
             ? "border-sky-300 border-dashed bg-white shadow-none"
             : isLeafHighlighted
@@ -73,16 +77,10 @@ function HorizontalTreeNode({ node, onSelect, selectedId, level, highlightLeafNa
             : "hover:brightness-95"
         )}
         onClick={() => onSelect(node)}
-        ref={(el) => { if (el && (isSelected || shouldScrollIntoView)) el.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' }); }}
       >
         <span className={cn("text-xs font-bold mr-3", textColor)}>{node.name || "Root"}</span>
-        {isSourceNode && (
-          <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">
-            来源
-          </span>
-        )}
         {isPatternNode && (
-          <span className="text-[9px] mr-2 px-1.5 py-0.5 rounded bg-violet-100 text-violet-700 border border-violet-300 font-bold whitespace-nowrap shadow-sm">
+          <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>
         )}

+ 377 - 62
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, onSingleClick }: {
+function RelationCard({ type, item, activeId, shouldScrollIntoView = false, selectedLeafNames, directMatch = false, dimmed = false, showAllSourceTags = false, metrics, onSingleClick, onSourceNodeClick }: {
   type: string;
   item: any;
   activeId: string | null;
@@ -483,6 +483,7 @@ function RelationCard({ type, item, activeId, shouldScrollIntoView = false, sele
   showAllSourceTags?: boolean;
   metrics?: { nodeCount: number; reqCount: number; procCount: number; capCount: number; toolCount: number };
   onSingleClick: (nodeId: string) => void;
+  onSourceNodeClick?: (nodeName: string) => void;
 }) {
   const cardRef = useRef<HTMLDivElement | null>(null);
   const nodeId = `${type}:${item.id}`;
@@ -581,9 +582,17 @@ function RelationCard({ type, item, activeId, shouldScrollIntoView = false, sele
               {sourceNodeTags.map((name: string) => {
                 const isHighlighted = selectedLeafNames && selectedLeafNames.has(name);
                 return (
-                  <span key={name} className={cn(
+                  <span key={name} 
+                    onClick={(e) => {
+                      if (onSourceNodeClick) {
+                        e.stopPropagation();
+                        onSourceNodeClick(name);
+                      }
+                    }}
+                    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)
+                    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"
                   )}>
                     {name}
                   </span>
@@ -631,6 +640,148 @@ function DrawerContent({ type, data, dbData, onOpenPost, nodePostsMap }: { type:
 
   const capNameById = new Map((dbData.caps || []).map((cap: any) => [cap.id, cap.name || cap.description || cap.id]));
 
+  const renderStructuredValue = (value: any, path: string): ReactNode => {
+    if (value === null || value === undefined || value === '') {
+      return <span className="text-slate-400">空</span>;
+    }
+
+    if (Array.isArray(value)) {
+      if (value.length === 0) return <span className="text-slate-400">[]</span>;
+      return (
+        <div className="space-y-2">
+          {value.map((item, index) => (
+            <div key={`${path}-${index}`} className="rounded-lg border border-slate-200 bg-white p-2">
+              <div className="text-[10px] font-mono text-slate-400 mb-1">[{index}]</div>
+              {renderStructuredValue(item, `${path}-${index}`)}
+            </div>
+          ))}
+        </div>
+      );
+    }
+
+    if (typeof value === 'object') {
+      const entries = Object.entries(value);
+      if (entries.length === 0) return <span className="text-slate-400">{'{}'}</span>;
+      return (
+        <div className="space-y-2">
+          {entries.map(([key, nestedValue]) => (
+            <div key={`${path}-${key}`} className="rounded-lg border border-slate-200 bg-white p-3">
+              <div className="text-[10px] font-bold text-slate-400 mb-1 break-all">{key}</div>
+              {renderStructuredValue(nestedValue, `${path}-${key}`)}
+            </div>
+          ))}
+        </div>
+      );
+    }
+
+    if (typeof value === 'boolean') {
+      return <span className="text-sm text-slate-700 font-medium">{value ? 'true' : 'false'}</span>;
+    }
+
+    return <div className="text-sm text-slate-700 whitespace-pre-wrap break-words leading-relaxed">{String(value)}</div>;
+  };
+
+  const normalizeList = (value: any): any[] => {
+    if (!value) return [];
+    if (Array.isArray(value)) return value;
+    if (typeof value === 'object') return Object.values(value);
+    return [];
+  };
+
+  const parseWorkflowBody = (rawBody: any, dataItem?: any) => {
+    let parsed: any = {};
+    let parseError = false;
+    try {
+      parsed = typeof rawBody === 'string' ? JSON.parse(rawBody || '{}') : (rawBody || {});
+    } catch (e) {
+      parseError = true;
+    }
+
+    let workflowOutlineRaw: any[] = [];
+    if (dataItem?.fine_steps && Array.isArray(dataItem.fine_steps) && dataItem.fine_steps.length > 0) {
+      workflowOutlineRaw = dataItem.fine_steps;
+    } else if (Array.isArray(parsed)) {
+      workflowOutlineRaw = parsed;
+    } else if (parsed && typeof parsed === 'object') {
+      workflowOutlineRaw =
+        parsed.workflow
+        || parsed.phases
+        || parsed.workflow_outline
+        || parsed.selected_strategy?.workflow_outline
+        || parsed.steps
+        || parsed.process_flow
+        || parsed.flow
+        || [];
+    }
+
+    const workflowOutline = normalizeList(workflowOutlineRaw).map((phase: any, index: number) => {
+      const phaseObj = typeof phase === 'string' ? { name: phase } : (phase || {});
+      const title = phaseObj.phase || phaseObj.name || phaseObj.title || phaseObj.step || phaseObj.module_label || `步骤 ${index + 1}`;
+      const description = phaseObj.description || phaseObj.desc || phaseObj.details || '';
+      const capsRaw = phaseObj.capabilities || phaseObj.capability || phaseObj.capability_ids || [];
+      
+      const capabilities = normalizeList(capsRaw).map((c: any) => {
+        if (typeof c === 'string') return { capability_name: c, capability_id: c, is_new: false };
+        return {
+          ...c,
+          capability_name: c.capability_name || c.name,
+          capability_id: c.capability_id || c.id,
+        };
+      });
+
+      return {
+        ...phaseObj,
+        __title: title,
+        __description: description,
+        capabilities,
+        __index: index,
+      };
+    });
+
+    return { parsed, parseError, workflowOutline };
+  };
+
+  const formatFieldLabel = (key: string) =>
+    key
+      .replace(/_/g, ' ')
+      .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
+      .replace(/\s+/g, ' ')
+      .trim();
+
+  const renderCompactFieldValue = (value: any): ReactNode => {
+    if (value === null || value === undefined || value === '') {
+      return <span className="text-slate-400">空</span>;
+    }
+    if (Array.isArray(value)) {
+      if (value.length === 0) return <span className="text-slate-400">[]</span>;
+      return (
+        <div className="flex flex-wrap gap-1.5">
+          {value.map((item, index) => (
+            <span
+              key={`field-${index}-${typeof item === 'object' ? 'obj' : String(item)}`}
+              className="px-2 py-0.5 rounded-full text-[11px] bg-slate-100 text-slate-700 border border-slate-200"
+            >
+              {typeof item === 'object' ? JSON.stringify(item) : String(item)}
+            </span>
+          ))}
+        </div>
+      );
+    }
+    if (typeof value === 'object') {
+      return (
+        <div className="flex flex-wrap gap-1.5">
+          {Object.entries(value).map(([nestedKey, nestedValue]) => (
+            <span key={nestedKey} className="px-2 py-0.5 rounded-full text-[11px] bg-slate-100 text-slate-700 border border-slate-200">
+              <span className="font-semibold text-slate-500">{formatFieldLabel(nestedKey)}:</span>{' '}
+              {typeof nestedValue === 'object' ? JSON.stringify(nestedValue) : String(nestedValue)}
+            </span>
+          ))}
+        </div>
+      );
+    }
+    return <span className="text-sm text-slate-700 whitespace-pre-wrap break-words leading-relaxed">{String(value)}</span>;
+  };
+
   return (
     <div className="space-y-4">
       {/* 主内容 */}
@@ -682,27 +833,29 @@ function DrawerContent({ type, data, dbData, onOpenPost, nodePostsMap }: { type:
         <>
           <div className="bg-purple-50 p-4 rounded-xl border border-purple-100">
             <div className="text-xs font-bold text-purple-600 mb-2">工序说明</div>
-            <p className="text-purple-900 text-sm leading-relaxed">{data.description || '暂无说明'}</p>
+            <p className="text-purple-900 text-sm leading-relaxed whitespace-pre-wrap">{data.description || '暂无说明'}</p>
           </div>
           {data.resource_ids?.length > 0 && (
             <StrategyResourcesGrid resourceIds={data.resource_ids} onOpenPost={onOpenPost} />
           )}
-          <div className="bg-slate-50 p-3 rounded-xl border border-slate-100">
-            <div className="text-[10px] text-slate-400 mb-1">工序 ID</div>
-            <div className="font-mono text-slate-600 text-[11px] break-all">{data.id}</div>
-          </div>
-          {data.category && (
+          <div className="grid grid-cols-1 gap-3 md:grid-cols-2">
             <div className="bg-slate-50 p-3 rounded-xl border border-slate-100">
-              <div className="text-[10px] text-slate-400 mb-1">所属品类</div>
-              <div className="text-sm text-slate-700 font-medium">{data.category}</div>
+              <div className="text-[10px] text-slate-400 mb-1">工序 ID</div>
+              <div className="font-mono text-slate-700 text-[11px] break-all">{data.id}</div>
             </div>
-          )}
+            {data.category && (
+              <div className="bg-slate-50 p-3 rounded-xl border border-slate-100">
+                <div className="text-[10px] text-slate-400 mb-1">所属品类</div>
+                <div className="text-sm text-slate-800 font-semibold">{data.category}</div>
+              </div>
+            )}
+          </div>
           {data.path_labels?.length > 0 && (
             <div className="bg-slate-50 p-3 rounded-xl border border-slate-100">
               <div className="text-[10px] text-slate-400 mb-2">工序链路</div>
               <div className="flex flex-wrap gap-2">
                 {data.path_labels.map((label: string, idx: number) => (
-                  <span key={`${data.id}-step-${idx}`} className="text-[11px] px-2 py-1 rounded-md bg-purple-100 text-purple-700 font-medium">
+                  <span key={`${data.id}-step-${idx}`} className="text-[11px] px-2.5 py-1 rounded-lg bg-white text-purple-700 font-semibold border border-purple-200 shadow-sm">
                     {data.path[idx]} · {label}
                   </span>
                 ))}
@@ -710,43 +863,124 @@ function DrawerContent({ type, data, dbData, onOpenPost, nodePostsMap }: { type:
             </div>
           )}
           {(() => {
-            let fineSteps = [];
-            try {
-              const parsed = JSON.parse(data.body || '{}');
-              fineSteps = (parsed.phases || parsed.selected_strategy?.workflow_outline || []).map((phase: any, index: number) => ({
-                module_id: `step-${index + 1}`,
-                module_label: phase.phase || phase.description || '',
-                capability_ids: (phase.capabilities || []).map((c: any) => c.capability_id).filter(Boolean)
-              }));
-            } catch (e) {}
-            if (fineSteps.length === 0) return null;
+            const { parsed, parseError, workflowOutline } = parseWorkflowBody(data.body, data);
+
+            const source = parsed.selected_strategy?.source || parsed.source;
+            const excludedBodyKeys = new Set([
+              'workflow',
+              'process_flow',
+              'flow',
+              'phases',
+              'workflow_outline',
+              'steps',
+              'selected_strategy',
+              'source',
+              'reasoning',
+              'highlight_coverage',
+              'baseline_coverage',
+              'vs_alternatives',
+              'uncovered_requirements',
+            ]);
+            const globalExtraFields = Object.entries(parsed || {}).filter(([key]) => !excludedBodyKeys.has(key));
+
             return (
-              <div className="bg-slate-50 p-3 rounded-xl border border-slate-100">
-                <div className="text-[10px] text-slate-400 mb-2">细工序关联能力</div>
-                <div className="space-y-2">
-                  {fineSteps.map((step: any) => (
-                    <div key={`${data.id}-${step.module_id}`} className="rounded-lg border border-slate-200 bg-white p-3">
-                      <div className="text-sm font-medium text-slate-800">
-                        <span className="font-mono text-[11px] text-purple-600 mr-2">{step.module_id}</span>
-                        <span>{step.module_label}</span>
-                      </div>
-                      <div className="mt-2 flex flex-wrap gap-2">
-                        {step.capability_ids?.length > 0 ? (
-                          step.capability_ids.map((capId: string) => (
-                            <span key={`${step.module_id}-${capId}`} className="text-[11px] px-2 py-1 rounded-md bg-rose-50 text-rose-700 border border-rose-200 font-medium">
-                              {capId} · {capNameById.get(capId) || capId}
-                            </span>
-                          ))
-                        ) : (
-                          <span className="text-[11px] px-2 py-1 rounded-md bg-slate-100 text-slate-500 border border-slate-200">
-                            暂无映射能力
-                          </span>
-                        )}
-                      </div>
+              <>
+                {source && (
+                  <div className="bg-blue-50 p-3 rounded-xl border border-blue-100">
+                    <div className="text-[10px] text-blue-400 mb-1">灵感来源</div>
+                    <p className="text-sm text-blue-800 leading-relaxed">{source}</p>
+                  </div>
+                )}
+                {workflowOutline.length > 0 && (
+                  <div className="bg-slate-50 p-3 rounded-xl border border-slate-100">
+                    <div className="flex items-center justify-between mb-3">
+                      <div className="text-[10px] text-slate-400">工序流程</div>
+                      <div className="text-[10px] font-bold text-purple-600">{workflowOutline.length} 步</div>
                     </div>
-                  ))}
-                </div>
-              </div>
+                    <div className="space-y-3">
+                      {workflowOutline.map((phase: any, index: number) => (
+                        <div key={`${data.id}-phase-${index}`} className="rounded-lg border border-slate-200 bg-white p-3">
+                          <div className="flex items-start gap-3">
+                            <div className="shrink-0 w-6 h-6 rounded-full bg-purple-100 text-purple-700 text-[11px] font-black flex items-center justify-center">
+                              {index + 1}
+                            </div>
+                            <div className="min-w-0 flex-1">
+                              <div className="text-sm font-bold text-slate-800 leading-snug mb-1">
+                                {phase.__title}
+                              </div>
+                              {phase.__description && (
+                                <p className="text-xs text-slate-600 leading-relaxed whitespace-pre-wrap">{phase.__description}</p>
+                              )}
+                            </div>
+                          </div>
+                          {phase.capabilities && phase.capabilities.length > 0 && (
+                            <div className="mt-3 pl-9 space-y-2">
+                              {phase.capabilities.map((cap: any, capIdx: number) => (
+                                <div key={`${data.id}-phase-${index}-cap-${capIdx}`} className="bg-slate-50 p-2 rounded border border-slate-200">
+                                  <div className="flex items-start gap-2">
+                                    <span className={cn(
+                                      "text-[10px] px-1.5 py-0.5 rounded font-bold",
+                                      cap.is_new ? "bg-green-100 text-green-700" : "bg-indigo-100 text-indigo-700"
+                                    )}>
+                                      {cap.is_new ? "新能力" : "关联能力"}
+                                    </span>
+                                    <div className="flex-1">
+                                      <div className="text-xs font-medium text-slate-800">
+                                        {cap.capability_id && cap.capability_id !== cap.capability_name && (
+                                          <span className="font-mono text-indigo-600 mr-1">{cap.capability_id}</span>
+                                        )}
+                                        {cap.capability_name || cap.capability_id || '未命名能力'}
+                                      </div>
+                                      {cap.suggested_tools && cap.suggested_tools.length > 0 && (
+                                        <div className="mt-1 flex flex-wrap gap-1">
+                                          {cap.suggested_tools.map((tool: string, toolIdx: number) => (
+                                            <span key={`tool-${toolIdx}`} className="text-[10px] px-1.5 py-0.5 rounded bg-amber-50 text-amber-700 border border-amber-200">
+                                              {tool}
+                                            </span>
+                                          ))}
+                                        </div>
+                                      )}
+                                      {cap.case_references && cap.case_references.length > 0 && (
+                                        <div className="mt-1 text-[10px] text-slate-500">
+                                          <span className="font-medium">案例引用:</span>
+                                          {cap.case_references.join(' · ')}
+                                        </div>
+                                      )}
+                                    </div>
+                                  </div>
+                                </div>
+                              ))}
+                            </div>
+                          )}
+                        </div>
+                      ))}
+                    </div>
+                  </div>
+                )}
+                {!parseError && globalExtraFields.length > 0 && (
+                  <div className="bg-slate-50 p-3 rounded-xl border border-slate-100">
+                    <div className="text-[10px] text-slate-400 mb-2">流程补充信息</div>
+                    <div className="flex flex-wrap gap-2">
+                      {globalExtraFields.map(([fieldKey, fieldValue]) => (
+                        <div key={`${data.id}-body-field-${fieldKey}`} className="rounded-lg border border-slate-200 bg-white px-3 py-2 min-w-[180px] max-w-full">
+                          <div className="text-[10px] font-bold text-slate-400 mb-1">{formatFieldLabel(fieldKey)}</div>
+                          {renderCompactFieldValue(fieldValue)}
+                        </div>
+                      ))}
+                    </div>
+                  </div>
+                )}
+                {parseError && (
+                  <div className="bg-slate-50 p-3 rounded-xl border border-slate-100">
+                    <div className="text-[10px] text-slate-400 mb-2">原始 Body</div>
+                    <pre className="text-[12px] text-slate-700 whitespace-pre-wrap break-words leading-relaxed bg-white rounded-lg border border-slate-200 p-3">
+                      {typeof data.body === 'string'
+                        ? (data.body || '无 body')
+                        : JSON.stringify(data.body ?? {}, null, 2)}
+                    </pre>
+                  </div>
+                )}
+              </>
             );
           })()}
           {data.requirement_texts?.length > 0 && (
@@ -763,8 +997,7 @@ function DrawerContent({ type, data, dbData, onOpenPost, nodePostsMap }: { type:
             </div>
           )}
           {(() => {
-            let parsed: any = {};
-            try { parsed = JSON.parse(data.body || '{}'); } catch(e) {}
+            const { parsed } = parseWorkflowBody(data.body, data);
             
             const rationale = data.rationale || parsed.selected_strategy?.reasoning || parsed.reasoning;
             const highlightCoverage = parsed.selected_strategy?.highlight_coverage || parsed.highlight_coverage || [];
@@ -1359,6 +1592,7 @@ function PatternColumn({
   itemsets,
   eligibleItemsetIds,
   contextNodeNames,
+  patternMatchedNodesMap,
   nodeRoleByName,
   hasAnyFilter,
   focusIndex,
@@ -1371,6 +1605,7 @@ function PatternColumn({
   itemsets: any[];
   eligibleItemsetIds: Set<string>;
   contextNodeNames: Set<string>;
+  patternMatchedNodesMap?: Record<string, Set<string>>;
   nodeRoleByName: Record<string, 'substance' | 'form' | 'both'>;
   hasAnyFilter: boolean;
   focusIndex: number;
@@ -1383,16 +1618,27 @@ function PatternColumn({
   const displayItemsets = useMemo(() => {
     const withMatches = [...itemsets].map(itemset => {
       const leafNames: string[] = itemset.leaf_names || [];
-      const matchedNodes = contextNodeNames.size > 0 ? leafNames.filter(n => contextNodeNames.has(n)) : [];
+      let matchedNodes: string[] = [];
+      if (patternMatchedNodesMap && patternMatchedNodesMap[String(itemset.id)]) {
+        const specificMatches = patternMatchedNodesMap[String(itemset.id)];
+        matchedNodes = leafNames.filter(n => specificMatches.has(n));
+      } else {
+        matchedNodes = contextNodeNames.size > 0 ? leafNames.filter(n => contextNodeNames.has(n)) : [];
+      }
       return { ...itemset, matched_nodes: matchedNodes };
     });
     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;
     });
-  }, [contextNodeNames, itemsets, eligibleItemsetIds]);
+  }, [contextNodeNames, patternMatchedNodesMap, itemsets, eligibleItemsetIds]);
 
   const matchedIndices = useMemo(() => {
     const indices: number[] = [];
@@ -1503,6 +1749,14 @@ function PatternColumn({
 
 export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: string | null, onPendingConsumed?: () => void }) {
   const [treeData, setTreeData] = useState<any>(null);
+  const [reqPlanBData, setReqPlanBData] = useState<any>(null);
+
+  useEffect(() => {
+    fetch('/requirements_planb.json')
+      .then(res => res.json())
+      .then(data => setReqPlanBData(data))
+      .catch(console.error);
+  }, []);
   const [selectedNode, setSelectedNode] = useState<any>(null);
   const [nameToNodeMap, setNameToNodeMap] = useState<Record<string, any>>({});
   const [idToNodeMap, setIdToNodeMap] = useState<Record<string, any>>({});
@@ -1520,6 +1774,7 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
   const [selectedToolId, setSelectedToolId] = useState<string | null>(null);
   const [columnFocusIndex, setColumnFocusIndex] = useState<Record<string, number>>({});
   const [treeFocusIndex, setTreeFocusIndex] = useState(0);
+  const [treeFocusTrigger, setTreeFocusTrigger] = useState(0);
   const [dashboardLoadingText, setDashboardLoadingText] = useState<string | null>('开始初始化底座...');
   const [flowBoardExpanded, setFlowBoardExpanded] = useState(false);
   const [allItemsets, setAllItemsets] = useState<any[]>([]);
@@ -2104,6 +2359,22 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
 
   const itemsetReqIdsMap = useMemo(() => {
     const map = new Map<string, Set<string>>();
+
+    if (reqPlanBData && reqPlanBData.requirements) {
+      allItemsets.forEach((itemset: any) => {
+        map.set(String(itemset.id), new Set<string>());
+      });
+      reqPlanBData.requirements.forEach((req: any) => {
+        const reqId = req.requirement_id;
+        (req.patterns || []).forEach((pattern: any) => {
+          const patternId = String(pattern.pattern_id);
+          if (!map.has(patternId)) map.set(patternId, new Set<string>());
+          map.get(patternId)!.add(reqId);
+        });
+      });
+      return map;
+    }
+
     allItemsets.forEach((itemset: any) => {
       const leafNames = new Set<string>((itemset.leaf_names || []).filter(Boolean));
       const ids = new Set<string>();
@@ -2123,7 +2394,7 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
       map.set(String(itemset.id), ids);
     });
     return map;
-  }, [allItemsets, dbData.reqs]);
+  }, [allItemsets, dbData.reqs, reqPlanBData]);
 
   const selectedItemsetLeafNames = useMemo(() => {
     if (!selectedItemset) return null;
@@ -2218,6 +2489,22 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
     return names;
   }, [selectedNodeDirectNames, selectedReqId, dbData.reqs]);
 
+  const patternMatchedNodesMap = useMemo(() => {
+    if (!reqPlanBData || !reqPlanBData.requirements || !selectedReqId) return undefined;
+    const reqInfo = reqPlanBData.requirements.find((r: any) => r.requirement_id === selectedReqId);
+    if (!reqInfo) return undefined;
+
+    const map: Record<string, Set<string>> = {};
+    (reqInfo.patterns || []).forEach((pattern: any) => {
+      const set = new Set<string>();
+      (pattern.represents || []).forEach((name: string) => {
+        if (name) set.add(name);
+      });
+      map[String(pattern.pattern_id)] = set;
+    });
+    return map;
+  }, [reqPlanBData, selectedReqId]);
+
   const requirementVisible = (req: any) => {
     if (onlyCoveredFilter) {
       const hasCoveredSourceNode = (req.source_nodes || []).some((sn: any) => {
@@ -2525,17 +2812,30 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
   const selectedReqPatternNodeIds = useMemo((): Set<string> | null => {
     if (!selectedReqId) return null;
     const relatedLeafNames = new Set<string>();
-    allItemsets.forEach((itemset: any) => {
-      const reqIds = itemsetReqIdsMap.get(String(itemset.id)) || new Set<string>();
-      if (!reqIds.has(selectedReqId)) return;
-      (itemset.leaf_names || []).forEach((name: string) => {
-        if (name) relatedLeafNames.add(name);
+
+    if (reqPlanBData && reqPlanBData.requirements) {
+      const reqInfo = reqPlanBData.requirements.find((r: any) => r.requirement_id === selectedReqId);
+      if (reqInfo) {
+        (reqInfo.patterns || []).forEach((pattern: any) => {
+          (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;
+        (itemset.leaf_names || []).forEach((name: string) => {
+          if (name) relatedLeafNames.add(name);
+        });
       });
-    });
+    }
+
     if (relatedLeafNames.size === 0) return null;
     const ids = collectDirectNodeIdsFromNames(relatedLeafNames);
     return ids.size > 0 ? ids : null;
-  }, [allItemsets, itemsetReqIdsMap, selectedReqId, collectDirectNodeIdsFromNames]);
+  }, [reqPlanBData, allItemsets, itemsetReqIdsMap, selectedReqId, collectDirectNodeIdsFromNames]);
 
   const filteredProcItems = useMemo(() => {
     return visibleProcItems.filter((workflow) => {
@@ -2627,11 +2927,10 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
 
     const collectMatchedNodes = (node: any): any[] => {
       const hasChildren = node.children && node.children.length > 0;
-      const isSourceNode = selectedReqSourceNodeIds?.has(String(node.id)) ?? false;
       const isPatternNode = selectedPatternNodeIds?.has(String(node.id)) ?? false;
       const matched: any[] = [];
 
-      if (isSourceNode || isPatternNode) {
+      if (isPatternNode) {
         matched.push(node);
       }
 
@@ -3278,6 +3577,7 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
             treeFocusIndex={treeFocusIndex}
             treeMatchedCount={treeMatchedCount}
             focusedTreeNodeId={focusedTreeNode?.id}
+            focusTrigger={treeFocusTrigger}
             onTreeFocusPrev={() => setTreeFocusIndex(prev => Math.max(0, prev - 1))}
             onTreeFocusNext={() => setTreeFocusIndex(prev => Math.min(treeMatchedCount - 1, prev + 1))}
           />
@@ -3289,11 +3589,19 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
           itemsets={allItemsets}
           eligibleItemsetIds={relatedItemsetIds}
           contextNodeNames={patternContextNodeNames}
+          patternMatchedNodesMap={patternMatchedNodesMap}
           nodeRoleByName={patternRoleByName}
           hasAnyFilter={hasAnyFilter}
           focusIndex={columnFocusIndex.pattern ?? 0}
           onSelectItemset={(itemsetId) => {
             setSelectedItemsetId(itemsetId);
+            if (itemsetId !== null) {
+              setSelectedReqId(null);
+              setSelectedProcId(null);
+              setSelectedCapId(null);
+              setSelectedToolId(null);
+              setTreeFocusTrigger(prev => prev + 1);
+            }
             setColumnFocusIndex(prev => ({ ...prev, pattern: 0 }));
             if (itemsetId === null) setDrawerItem(null);
           }}
@@ -3422,6 +3730,13 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
                   }
                   showAllSourceTags={col.t === 'req' && !!selectedTreePartition}
                   onSingleClick={(nodeId) => handleSingleClick(nodeId, item)}
+                  onSourceNodeClick={(name) => {
+                    const node = nameToNodeMap[name];
+                    if (node) {
+                      setSelectedNode(node);
+                      setDrawerItem({ type: 'node', data: node });
+                    }
+                  }}
                 />
               ))}
               {col.d.length === 0 && (