Преглед изворни кода

feat: update knowhub frontend

elksmmx пре 1 месец
родитељ
комит
c3fc51d88c

+ 17 - 1
knowhub/frontend/src/components/dashboard/CategoryTree.tsx

@@ -11,6 +11,7 @@ interface NodeProps {
   highlightLeafNames: Set<string> | null; // null = no filter active
   highlightLeafNames: Set<string> | null; // null = no filter active
   subtreeHighlightNodeIds: Set<string> | null;
   subtreeHighlightNodeIds: Set<string> | null;
   sourceNodeIds: 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 }>;
   dimensionColor: string; // hex color for the node's dimension
   dimensionColor: string; // hex color for the node's dimension
   focusedTreeNodeId?: string | number | null;
   focusedTreeNodeId?: string | number | null;
@@ -24,7 +25,7 @@ function nodeHasHighlightedLeaf(node: any, highlightLeafNames: Set<string> | nul
   return node.children.some((child: any) => nodeHasHighlightedLeaf(child, highlightLeafNames));
   return node.children.some((child: any) => nodeHasHighlightedLeaf(child, highlightLeafNames));
 }
 }
 
 
-function HorizontalTreeNode({ node, onSelect, selectedId, level, highlightLeafNames, subtreeHighlightNodeIds, sourceNodeIds, nodeMetricsMap, dimensionColor, focusedTreeNodeId = null }: NodeProps) {
+function HorizontalTreeNode({ node, onSelect, selectedId, level, highlightLeafNames, subtreeHighlightNodeIds, sourceNodeIds, patternNodeIds, nodeMetricsMap, dimensionColor, focusedTreeNodeId = null }: NodeProps) {
   const [expanded, setExpanded] = useState(true);
   const [expanded, setExpanded] = useState(true);
   const [hoveredMetric, setHoveredMetric] = useState<null | { key: string; count: number; colorClass: string; x: number; y: number }>(null);
   const [hoveredMetric, setHoveredMetric] = useState<null | { key: string; count: number; colorClass: string; x: number; y: number }>(null);
   const hasChildren = node.children && node.children.length > 0;
   const hasChildren = node.children && node.children.length > 0;
@@ -37,6 +38,8 @@ function HorizontalTreeNode({ node, onSelect, selectedId, level, highlightLeafNa
   const isLeafHighlighted = highlightLeafNames && highlightLeafNames.has(node.name);
   const isLeafHighlighted = highlightLeafNames && highlightLeafNames.has(node.name);
   const isInSubtreeHighlight = subtreeHighlightNodeIds?.has(String(node.id)) ?? false;
   const isInSubtreeHighlight = subtreeHighlightNodeIds?.has(String(node.id)) ?? false;
   const isSourceNode = sourceNodeIds?.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 =
   const isSelected =
     selectedId !== null &&
     selectedId !== null &&
     selectedId !== undefined &&
     selectedId !== undefined &&
@@ -57,8 +60,12 @@ function HorizontalTreeNode({ node, onSelect, selectedId, level, highlightLeafNa
           "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",
           "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
           isSelected
             ? "ring-2 ring-orange-400 ring-offset-2 border-orange-400 shadow-[0_0_0_2px_rgba(251,146,60,0.16)]"
             ? "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
             : isSourceNode
             ? "ring-2 ring-sky-400 ring-offset-1 border-sky-300 shadow-[0_0_0_2px_rgba(56,189,248,0.16)]"
             ? "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)]"
             : isInSubtreeHighlight
             : isInSubtreeHighlight
             ? "border-sky-300 border-dashed bg-white shadow-none"
             ? "border-sky-300 border-dashed bg-white shadow-none"
             : isLeafHighlighted
             : isLeafHighlighted
@@ -74,6 +81,11 @@ function HorizontalTreeNode({ node, onSelect, selectedId, level, highlightLeafNa
             来源
             来源
           </span>
           </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">
+            Pattern
+          </span>
+        )}
 
 
         <div className="flex text-[9px] bg-slate-100 rounded px-1.5 shadow-sm items-center font-bold text-slate-700">
         <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} 帖
           {node.total_posts_count || 0} 帖
@@ -142,6 +154,7 @@ function HorizontalTreeNode({ node, onSelect, selectedId, level, highlightLeafNa
                 highlightLeafNames={highlightLeafNames}
                 highlightLeafNames={highlightLeafNames}
                 subtreeHighlightNodeIds={subtreeHighlightNodeIds}
                 subtreeHighlightNodeIds={subtreeHighlightNodeIds}
                 sourceNodeIds={sourceNodeIds}
                 sourceNodeIds={sourceNodeIds}
+                patternNodeIds={patternNodeIds}
                 nodeMetricsMap={nodeMetricsMap}
                 nodeMetricsMap={nodeMetricsMap}
                 dimensionColor={dimensionColor}
                 dimensionColor={dimensionColor}
                 focusedTreeNodeId={focusedTreeNodeId}
                 focusedTreeNodeId={focusedTreeNodeId}
@@ -170,6 +183,7 @@ export function CategoryTree({
   highlightLeafNames = null,
   highlightLeafNames = null,
   subtreeHighlightNodeIds = null,
   subtreeHighlightNodeIds = null,
   sourceNodeIds = null,
   sourceNodeIds = null,
+  patternNodeIds = null,
   nodeMetricsMap = {},
   nodeMetricsMap = {},
   filterLabel,
   filterLabel,
   onClearFilter,
   onClearFilter,
@@ -188,6 +202,7 @@ export function CategoryTree({
   highlightLeafNames?: Set<string> | null;
   highlightLeafNames?: Set<string> | null;
   subtreeHighlightNodeIds?: Set<string> | null;
   subtreeHighlightNodeIds?: Set<string> | null;
   sourceNodeIds?: 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 }>;
   filterLabel?: string | null;
   filterLabel?: string | null;
   onClearFilter?: () => void;
   onClearFilter?: () => void;
@@ -314,6 +329,7 @@ export function CategoryTree({
                         highlightLeafNames={highlightLeafNames}
                         highlightLeafNames={highlightLeafNames}
                         subtreeHighlightNodeIds={subtreeHighlightNodeIds}
                         subtreeHighlightNodeIds={subtreeHighlightNodeIds}
                         sourceNodeIds={sourceNodeIds}
                         sourceNodeIds={sourceNodeIds}
+                        patternNodeIds={patternNodeIds}
                         nodeMetricsMap={nodeMetricsMap}
                         nodeMetricsMap={nodeMetricsMap}
                         dimensionColor={color.hex}
                         dimensionColor={color.hex}
                         focusedTreeNodeId={focusedTreeNodeId}
                         focusedTreeNodeId={focusedTreeNodeId}

+ 540 - 58
knowhub/frontend/src/pages/Dashboard.tsx

@@ -484,6 +484,7 @@ function RelationCard({ type, item, activeId, shouldScrollIntoView = false, sele
   metrics?: { nodeCount: number; reqCount: number; procCount: number; capCount: number; toolCount: number };
   metrics?: { nodeCount: number; reqCount: number; procCount: number; capCount: number; toolCount: number };
   onSingleClick: (nodeId: string) => void;
   onSingleClick: (nodeId: string) => void;
 }) {
 }) {
+  const cardRef = useRef<HTMLDivElement | null>(null);
   const nodeId = `${type}:${item.id}`;
   const nodeId = `${type}:${item.id}`;
   const isSelected = activeId === nodeId;
   const isSelected = activeId === nodeId;
 
 
@@ -536,13 +537,15 @@ function RelationCard({ type, item, activeId, shouldScrollIntoView = false, sele
     </>
     </>
   );
   );
 
 
+  useEffect(() => {
+    if (shouldScrollIntoView && cardRef.current) {
+      cardRef.current.scrollIntoView({ behavior: 'smooth', block: 'start' });
+    }
+  }, [shouldScrollIntoView]);
+
   return (
   return (
     <div
     <div
-      ref={(el) => {
-        if (el && shouldScrollIntoView) {
-          el.scrollIntoView({ behavior: 'smooth', block: 'start' });
-        }
-      }}
+      ref={cardRef}
       onClick={() => onSingleClick(nodeId)}
       onClick={() => onSingleClick(nodeId)}
       className={cn(
       className={cn(
         "group p-3 rounded-xl cursor-pointer mb-2 select-none bg-white transition-all border-l-4",
         "group p-3 rounded-xl cursor-pointer mb-2 select-none bg-white transition-all border-l-4",
@@ -559,9 +562,14 @@ function RelationCard({ type, item, activeId, shouldScrollIntoView = false, sele
       <div className="flex items-start gap-2">
       <div className="flex items-start gap-2">
         <div className="min-w-0 flex-1">
         <div className="min-w-0 flex-1">
           <div className="flex items-start gap-3">
           <div className="flex items-start gap-3">
-            <div className={cn("text-xs font-bold leading-snug min-w-0", isSelected ? "text-orange-800" : "text-slate-700")}>
+            <div className={cn("text-xs font-bold leading-snug min-w-0 flex-1", isSelected ? "text-orange-800" : "text-slate-700")}>
               {label}
               {label}
             </div>
             </div>
+            {type === 'tool' && (item.status === '已接入' || item.status === 'active' || item.status === 'available' || item.status === '已使用') && (
+              <span className="shrink-0 text-[9px] px-1.5 py-0.5 rounded-full bg-green-100 text-green-700 font-bold border border-green-200">
+                可接入
+              </span>
+            )}
           </div>
           </div>
           {sourceNodeTags.length > 0 && (
           {sourceNodeTags.length > 0 && (
             <div className="flex flex-wrap gap-1 mt-1.5">
             <div className="flex flex-wrap gap-1 mt-1.5">
@@ -611,11 +619,11 @@ function RelationCard({ type, item, activeId, shouldScrollIntoView = false, sele
 // ─── 详情抽屉内容 ──────────────────────────────────────────────────────────────
 // ─── 详情抽屉内容 ──────────────────────────────────────────────────────────────
 
 
 function DrawerContent({ type, data, dbData, onOpenPost, nodePostsMap }: { type: string; data: any; dbData: any; onOpenPost: (postId: string, post: any) => void; nodePostsMap?: Record<string, string[]> }) {
 function DrawerContent({ type, data, dbData, onOpenPost, nodePostsMap }: { type: string; data: any; dbData: any; onOpenPost: (postId: string, post: any) => void; nodePostsMap?: Record<string, string[]> }) {
-  if (type === 'req' && nodePostsMap) {
+  if (type === 'req') {
     return (
     return (
       <RequirementPostsDrawer
       <RequirementPostsDrawer
         requirement={data}
         requirement={data}
-        nodePostsMap={nodePostsMap}
+        nodePostsMap={nodePostsMap || {}}
         onOpenPost={onOpenPost}
         onOpenPost={onOpenPost}
       />
       />
     );
     );
@@ -847,6 +855,61 @@ function DrawerContent({ type, data, dbData, onOpenPost, nodePostsMap }: { type:
           </div>
           </div>
         </>
         </>
       )}
       )}
+      {type === 'itemset' && (
+        <>
+          <div className="bg-amber-50 p-4 rounded-xl border border-amber-100">
+            <div className="text-xs font-bold text-amber-600 mb-2">Pattern 节点组合</div>
+            <div className="flex flex-wrap gap-1.5">
+              {(data.leaf_names || []).map((name: string) => (
+                <span key={name} className="px-2 py-0.5 rounded-full text-xs bg-amber-100 text-amber-800 border border-amber-200 font-medium">{name}</span>
+              ))}
+            </div>
+            <div className="mt-3 flex items-center gap-3 text-[11px] text-amber-700">
+              <span>支持度 <span className="font-bold">{data.absolute_support}</span></span>
+              <span className="text-amber-400">·</span>
+              <span className="font-mono text-amber-500">#{data.id}</span>
+            </div>
+          </div>
+
+          {(data.req_associations || []).length > 0 && (
+            <div>
+              <div className="text-xs font-bold text-slate-500 mb-2">关联需求 ({data.req_associations.length})</div>
+              <div className="space-y-3">
+                {(data.req_associations as any[]).map((assoc: any) => {
+                  const trueNodes = (assoc.judgments || []).filter((j: any) => j.represents).map((j: any) => j.node);
+                  const falseNodes = (assoc.judgments || []).filter((j: any) => !j.represents).map((j: any) => j.node);
+                  return (
+                    <div key={assoc.req_id} className="bg-white border border-slate-100 rounded-xl p-3 space-y-2">
+                      <div className="flex items-center gap-2">
+                        <span className="font-mono text-[10px] text-indigo-500 shrink-0">{assoc.req_id}</span>
+                        <span className="text-xs text-slate-700 leading-snug line-clamp-2">{assoc.req_text}</span>
+                      </div>
+                      {assoc.judgments?.length > 0 && (
+                        <div className="flex flex-wrap gap-1">
+                          {(assoc.judgments as any[]).map((j: any) => (
+                            <span key={j.node} title={j.reason} className={cn(
+                              "px-1.5 py-0.5 rounded-full text-[10px] border font-medium cursor-help",
+                              j.represents
+                                ? "bg-emerald-50 text-emerald-700 border-emerald-200"
+                                : "bg-slate-50 text-slate-400 border-slate-200 line-through"
+                            )}>{j.node}</span>
+                          ))}
+                        </div>
+                      )}
+                      {trueNodes.length === 0 && falseNodes.length > 0 && (
+                        <div className="text-[10px] text-slate-400">无节点被判断为代表该需求</div>
+                      )}
+                    </div>
+                  );
+                })}
+              </div>
+            </div>
+          )}
+          {(data.req_associations || []).length === 0 && (
+            <div className="text-xs text-slate-400 text-center py-4">暂无关联需求数据</div>
+          )}
+        </>
+      )}
     </div>
     </div>
   );
   );
 }
 }
@@ -1155,9 +1218,7 @@ function RequirementPostsDrawer({
     setPosts({});
     setPosts({});
     batchGetPosts(allPostIds)
     batchGetPosts(allPostIds)
       .then(map => setPosts(map))
       .then(map => setPosts(map))
-      .catch(err => {
-        console.error('Failed to load requirement posts:', err);
-      })
+      .catch(err => { console.error('Failed to load requirement posts:', err); })
       .finally(() => setLoading(false));
       .finally(() => setLoading(false));
   }, [allPostIds, requirement.id]);
   }, [allPostIds, requirement.id]);
 
 
@@ -1165,7 +1226,6 @@ function RequirementPostsDrawer({
 
 
   return (
   return (
     <div className="flex flex-col gap-3 h-full overflow-hidden min-w-0">
     <div className="flex flex-col gap-3 h-full overflow-hidden min-w-0">
-      {/* 顶部:节点列表 */}
       <div className="shrink-0 bg-slate-50 rounded-xl border border-slate-100 p-3">
       <div className="shrink-0 bg-slate-50 rounded-xl border border-slate-100 p-3">
         <div className="text-xs font-bold text-slate-600 mb-2">关联节点 ({nodeNames.length})</div>
         <div className="text-xs font-bold text-slate-600 mb-2">关联节点 ({nodeNames.length})</div>
         <div className="flex flex-wrap gap-2 max-h-[140px] overflow-y-auto">
         <div className="flex flex-wrap gap-2 max-h-[140px] overflow-y-auto">
@@ -1192,27 +1252,25 @@ function RequirementPostsDrawer({
           ))}
           ))}
         </div>
         </div>
       </div>
       </div>
-
-      {/* 下方:帖子竖向列表 */}
       <div className="flex-1 min-h-0 overflow-y-auto pr-1">
       <div className="flex-1 min-h-0 overflow-y-auto pr-1">
         <div className="grid grid-cols-1 gap-3">
         <div className="grid grid-cols-1 gap-3">
-        {loading && (
-          <div className="flex items-center justify-center flex-1 gap-2 text-slate-400">
-            <div className="w-4 h-4 border-2 border-indigo-200 border-t-indigo-500 rounded-full animate-spin"></div>
-            加载中...
-          </div>
-        )}
-        {!loading && displayPostIds.length === 0 && (
-          <div className="flex items-center justify-center flex-1 text-xs text-slate-300 font-bold">暂无帖子</div>
-        )}
-        {!loading && displayPostIds.map(pid => {
-          const post = posts[pid];
-          return (
-            <div key={pid} className="w-full">
-              <PostCard postId={pid} post={post} compact onClick={() => onOpenPost(pid, post)} />
+          {loading && (
+            <div className="flex items-center justify-center flex-1 gap-2 text-slate-400">
+              <div className="w-4 h-4 border-2 border-indigo-200 border-t-indigo-500 rounded-full animate-spin"></div>
+              加载中...
             </div>
             </div>
-          );
-        })}
+          )}
+          {!loading && displayPostIds.length === 0 && (
+            <div className="flex items-center justify-center flex-1 text-xs text-slate-300 font-bold">暂无帖子</div>
+          )}
+          {!loading && displayPostIds.map(pid => {
+            const post = posts[pid];
+            return (
+              <div key={pid} className="w-full">
+                <PostCard postId={pid} post={post} compact onClick={() => onOpenPost(pid, post)} />
+              </div>
+            );
+          })}
         </div>
         </div>
       </div>
       </div>
     </div>
     </div>
@@ -1294,6 +1352,153 @@ function LeafNodeDrawer({ node, onOpenPost }: { node: any; onOpenPost: (postId:
   );
   );
 }
 }
 
 
+// ─── 频繁模式列 ────────────────────────────────────────────────────────────────
+
+function PatternColumn({
+  selectedItemsetId,
+  itemsets,
+  eligibleItemsetIds,
+  contextNodeNames,
+  nodeRoleByName,
+  hasAnyFilter,
+  focusIndex,
+  onSelectItemset,
+  onOpenDrawer,
+  onFocusPrev,
+  onFocusNext,
+}: {
+  selectedItemsetId: number | null;
+  itemsets: any[];
+  eligibleItemsetIds: Set<string>;
+  contextNodeNames: Set<string>;
+  nodeRoleByName: Record<string, 'substance' | 'form' | 'both'>;
+  hasAnyFilter: boolean;
+  focusIndex: number;
+  onSelectItemset: (itemsetId: number | null) => void;
+  onOpenDrawer: (itemset: any) => void;
+  onFocusPrev: () => void;
+  onFocusNext: () => void;
+}) {
+  const itemRefs = useRef<Record<number, HTMLDivElement | null>>({});
+  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)) : [];
+      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;
+      return b.absolute_support - a.absolute_support;
+    });
+  }, [contextNodeNames, itemsets, eligibleItemsetIds]);
+
+  const matchedIndices = useMemo(() => {
+    const indices: number[] = [];
+    displayItemsets.forEach((itemset, idx) => {
+      const isSelected = selectedItemsetId === itemset.id;
+      const isEligible = eligibleItemsetIds.has(String(itemset.id));
+      if (isSelected || isEligible) indices.push(idx);
+    });
+    return indices;
+  }, [displayItemsets, selectedItemsetId, eligibleItemsetIds]);
+
+  const clampedFocusIdx = matchedIndices.length > 0 ? Math.min(focusIndex, matchedIndices.length - 1) : 0;
+  const focusedItemIndex = matchedIndices[clampedFocusIdx] ?? -1;
+
+  useEffect(() => {
+    if (focusedItemIndex >= 0) {
+      itemRefs.current[focusedItemIndex]?.scrollIntoView({ behavior: 'smooth', block: 'start' });
+    }
+  }, [focusedItemIndex]);
+
+  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">
+      <div className="px-4 py-3 font-bold text-slate-700 border-b bg-slate-200/50 shrink-0">
+        <div className="flex justify-between items-center">
+          <span>Pattern</span>
+          <div className="flex items-center gap-2">
+            {matchedIndices.length > 1 && (
+              <div className="flex items-center gap-1">
+                <button
+                  type="button"
+                  onClick={onFocusPrev}
+                  disabled={clampedFocusIdx === 0}
+                  className="p-0.5 rounded text-slate-400 hover:text-slate-600 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
+                >
+                  <ChevronLeft size={13} />
+                </button>
+                <span className="text-[10px] font-bold text-slate-500 min-w-[28px] text-center">
+                  {clampedFocusIdx + 1}/{matchedIndices.length}
+                </span>
+                <button
+                  type="button"
+                  onClick={onFocusNext}
+                  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"
+                >
+                  <ChevronRight size={13} />
+                </button>
+              </div>
+            )}
+            <span className="text-slate-400">{displayItemsets.length}</span>
+          </div>
+        </div>
+      </div>
+      <div className="flex-1 overflow-y-auto p-3 custom-scrollbar">
+        {displayItemsets.map((itemset, idx) => {
+          const isSelected = selectedItemsetId === itemset.id;
+          const isEligible = eligibleItemsetIds.has(String(itemset.id));
+          return (
+            <div
+              key={itemset.id}
+              ref={(el) => {
+                itemRefs.current[idx] = el;
+              }}
+              onClick={() => {
+                const next = isSelected ? null : itemset.id;
+                onSelectItemset(next);
+                if (next !== null) onOpenDrawer(itemset);
+              }}
+              className={cn(
+                "bg-white rounded-xl p-3 mb-2 space-y-2 cursor-pointer transition-all border-l-4 border-l-amber-300",
+                isSelected
+                  ? "border border-orange-400 shadow-md ring-2 ring-orange-200"
+                  : 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"
+              )}
+            >
+              <div className="flex items-center justify-between">
+                <span className="text-[10px] font-mono text-slate-400">#{itemset.id}</span>
+                <span className="text-[10px] text-slate-500">支持度 <span className="font-bold text-slate-700">{itemset.absolute_support}</span></span>
+              </div>
+              <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(
+                      "px-2 py-0.5 rounded-full text-xs border",
+                      isMatched && role === 'substance' ? "bg-orange-50 text-orange-700 border-orange-200 font-bold" :
+                      isMatched && role === 'form' ? "bg-sky-50 text-sky-700 border-sky-200 font-bold" :
+                      isMatched && role === 'both' ? "bg-emerald-50 text-emerald-700 border-emerald-200 font-bold" :
+                      isMatched ? "bg-slate-100 text-slate-700 border-slate-300 font-bold" :
+                      "bg-slate-50 text-slate-500 border-slate-200"
+                    )}>{name}</span>
+                  );
+                })}
+              </div>
+            </div>
+          );
+        })}
+      </div>
+    </div>
+  );
+}
+
 // ─── Dashboard 主体 ────────────────────────────────────────────────────────────
 // ─── Dashboard 主体 ────────────────────────────────────────────────────────────
 
 
 export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: string | null, onPendingConsumed?: () => void }) {
 export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: string | null, onPendingConsumed?: () => void }) {
@@ -1306,7 +1511,7 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
   });
   });
   // 关系列状态
   // 关系列状态
   const [drawerItem, setDrawerItem] = useState<{ type: string; data: any; nodePostsMap?: Record<string, string[]> } | null>(null);
   const [drawerItem, setDrawerItem] = useState<{ type: string; data: any; nodePostsMap?: Record<string, string[]> } | null>(null);
-  const [treeWideMode, setTreeWideMode] = useState(true);
+  const [treeWideMode, setTreeWideMode] = useState(false);
   const [selectedPostDetail, setSelectedPostDetail] = useState<{ postId: string; post: any } | null>(null);
   const [selectedPostDetail, setSelectedPostDetail] = useState<{ postId: string; post: any } | null>(null);
   const [onlyCoveredFilter, setOnlyCoveredFilter] = useState(false); // 只看覆盖需求的数据
   const [onlyCoveredFilter, setOnlyCoveredFilter] = useState(false); // 只看覆盖需求的数据
   const [selectedProcId, setSelectedProcId] = useState<string | null>(null);
   const [selectedProcId, setSelectedProcId] = useState<string | null>(null);
@@ -1317,6 +1522,8 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
   const [treeFocusIndex, setTreeFocusIndex] = useState(0);
   const [treeFocusIndex, setTreeFocusIndex] = useState(0);
   const [dashboardLoadingText, setDashboardLoadingText] = useState<string | null>('开始初始化底座...');
   const [dashboardLoadingText, setDashboardLoadingText] = useState<string | null>('开始初始化底座...');
   const [flowBoardExpanded, setFlowBoardExpanded] = useState(false);
   const [flowBoardExpanded, setFlowBoardExpanded] = useState(false);
+  const [allItemsets, setAllItemsets] = useState<any[]>([]);
+  const [selectedItemsetId, setSelectedItemsetId] = useState<number | null>(null);
 
 
   // 来自其他页面的跳转
   // 来自其他页面的跳转
   useEffect(() => {
   useEffect(() => {
@@ -1563,6 +1770,9 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
         const leaves = getLeafNodes([data]);
         const leaves = getLeafNodes([data]);
         setDbData({ reqs, caps, tools, know, procs });
         setDbData({ reqs, caps, tools, know, procs });
 
 
+        // 加载频繁项集数据
+        fetch('/itemsets_all.json').then(r => r.json()).then(d => setAllItemsets(d.itemsets || [])).catch(() => {});
+
         const nameToNode: Record<string, any> = {};
         const nameToNode: Record<string, any> = {};
         const idToNode: Record<string, any> = {};
         const idToNode: Record<string, any> = {};
         const buildNameMap = (nodes: any[]) => {
         const buildNameMap = (nodes: any[]) => {
@@ -1701,6 +1911,23 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
     return new Set(collectDirectNodesFromReqs(reqs).map((node: any) => node.name).filter(Boolean));
     return new Set(collectDirectNodesFromReqs(reqs).map((node: any) => node.name).filter(Boolean));
   }, [collectDirectNodesFromReqs]);
   }, [collectDirectNodesFromReqs]);
 
 
+  const collectDirectNodeIdsFromNames = useCallback((nodeNames: Iterable<string>, skipEmptyFilter = false): Set<string> => {
+    const ids = new Set<string>();
+    for (const nodeName of nodeNames) {
+      if (!nodeName) continue;
+      (nameToNodesMap[nodeName] || []).forEach((directNode: any) => {
+        if (!skipEmptyFilter) {
+          const hasChildren = directNode.children && directNode.children.length > 0;
+          if (!(hasChildren || (directNode.total_posts_count || 0) > 0)) return;
+        }
+        if (directNode.id !== undefined && directNode.id !== null) {
+          ids.add(String(directNode.id));
+        }
+      });
+    }
+    return ids;
+  }, [nameToNodesMap]);
+
   const reqById = useMemo(() => new Map(dbData.reqs.map((req: any) => [req.id, req])), [dbData.reqs]);
   const reqById = useMemo(() => new Map(dbData.reqs.map((req: any) => [req.id, req])), [dbData.reqs]);
 
 
   const collectDirectNodeIdsFromReqIds = useCallback((reqIds: Iterable<string>): Set<string> => {
   const collectDirectNodeIdsFromReqIds = useCallback((reqIds: Iterable<string>): Set<string> => {
@@ -1870,6 +2097,127 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
     );
     );
   }, [dbData.procs, selectedToolCapabilityIds, selectedToolId]);
   }, [dbData.procs, selectedToolCapabilityIds, selectedToolId]);
 
 
+  const selectedItemset = useMemo(
+    () => allItemsets.find((itemset: any) => itemset.id === selectedItemsetId) || null,
+    [allItemsets, selectedItemsetId]
+  );
+
+  const itemsetReqIdsMap = useMemo(() => {
+    const map = new Map<string, Set<string>>();
+    allItemsets.forEach((itemset: any) => {
+      const leafNames = new Set<string>((itemset.leaf_names || []).filter(Boolean));
+      const ids = new Set<string>();
+      dbData.reqs.forEach((req: any) => {
+        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 || {};
+        const reqNodes = new Set<string>([
+          ...(ctx.substance?.nodes || []),
+          ...(ctx.form?.nodes || []),
+        ]);
+        const hasMatch = Array.from(leafNames).some((name) => reqNodes.has(name));
+        if (hasMatch) ids.add(req.id);
+      });
+      map.set(String(itemset.id), ids);
+    });
+    return map;
+  }, [allItemsets, dbData.reqs]);
+
+  const selectedItemsetLeafNames = useMemo(() => {
+    if (!selectedItemset) return null;
+    return new Set<string>((selectedItemset.leaf_names || []).filter(Boolean));
+  }, [selectedItemset]);
+
+  const selectedNodeDirectNames = useMemo(() => {
+    if (!selectedNode) return null;
+    return new Set<string>(selectedNode.name ? [selectedNode.name] : []);
+  }, [selectedNode]);
+
+  const selectedItemsetReqIds = useMemo(() => {
+    if (!selectedItemset) return null;
+    return itemsetReqIdsMap.get(String(selectedItemset.id)) || new Set<string>();
+  }, [itemsetReqIdsMap, selectedItemset]);
+
+  const selectedItemsetDirectNodeIds = useMemo((): Set<string> | null => {
+    if (!selectedItemset) return null;
+    const ids = collectDirectNodeIdsFromNames((selectedItemset.leaf_names || []).filter(Boolean), true);
+    return ids.size > 0 ? ids : null;
+  }, [selectedItemset, collectDirectNodeIdsFromNames]);
+
+  const relatedItemsetIds = useMemo((): Set<string> => {
+    const byNode = selectedNode
+      ? new Set<string>(
+          allItemsets
+            .filter((itemset: any) => (itemset.leaf_names || []).some((name: string) => selectedNodeDirectNames?.has(name)))
+            .map((itemset: any) => String(itemset.id))
+        )
+      : null;
+    const byReq = selectedReqId
+      ? new Set<string>(
+          allItemsets
+            .filter((itemset: any) => (itemsetReqIdsMap.get(String(itemset.id)) || new Set<string>()).has(selectedReqId))
+            .map((itemset: any) => String(itemset.id))
+        )
+      : null;
+    const byProc = selectedProcId
+      ? new Set<string>(
+          allItemsets
+            .filter((itemset: any) => {
+              const reqIds = itemsetReqIdsMap.get(String(itemset.id)) || new Set<string>();
+              return Array.from(reqIds).some((reqId) => selectedProcRequirementIds.has(reqId));
+            })
+            .map((itemset: any) => String(itemset.id))
+        )
+      : null;
+    const byCap = selectedCapId
+      ? new Set<string>(
+          allItemsets
+            .filter((itemset: any) => {
+              const reqIds = itemsetReqIdsMap.get(String(itemset.id)) || new Set<string>();
+              return Array.from(reqIds).some((reqId) => selectedCapRequirementIds.has(reqId));
+            })
+            .map((itemset: any) => String(itemset.id))
+        )
+      : null;
+    const byTool = selectedToolId
+      ? new Set<string>(
+          allItemsets
+            .filter((itemset: any) => {
+              const reqIds = itemsetReqIdsMap.get(String(itemset.id)) || new Set<string>();
+              return Array.from(reqIds).some((reqId) => selectedToolRequirementIds.has(reqId));
+            })
+            .map((itemset: any) => String(itemset.id))
+        )
+      : null;
+
+    const activeSets = [byNode, byReq, byProc, byCap, byTool].filter((set): set is Set<string> => !!set);
+    if (activeSets.length === 0) return new Set<string>();
+    const [first, ...rest] = activeSets;
+    const result = new Set<string>();
+    first.forEach((value) => {
+      if (rest.every((set) => set.has(value))) result.add(value);
+    });
+    return result;
+  }, [allItemsets, itemsetReqIdsMap, selectedNode, selectedNodeDirectNames, selectedReqId, selectedProcId, selectedCapId, selectedToolId, selectedProcRequirementIds, selectedCapRequirementIds, selectedToolRequirementIds]);
+
+  const patternContextNodeNames = useMemo(() => {
+    const names = new Set<string>();
+    (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) => {
+        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 && names.add(name));
+      (ctx.form?.nodes || []).forEach((name: string) => name && names.add(name));
+    }
+    return names;
+  }, [selectedNodeDirectNames, selectedReqId, dbData.reqs]);
+
   const requirementVisible = (req: any) => {
   const requirementVisible = (req: any) => {
     if (onlyCoveredFilter) {
     if (onlyCoveredFilter) {
       const hasCoveredSourceNode = (req.source_nodes || []).some((sn: any) => {
       const hasCoveredSourceNode = (req.source_nodes || []).some((sn: any) => {
@@ -1912,6 +2260,7 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
       if (!matchesRealCap && !matchesDerivedCap) return false;
       if (!matchesRealCap && !matchesDerivedCap) return false;
     }
     }
     if (selectedToolId && !(req.capability_ids || []).some((cid: string) => selectedToolCapabilityIds.has(cid))) return false;
     if (selectedToolId && !(req.capability_ids || []).some((cid: string) => selectedToolCapabilityIds.has(cid))) return false;
+    if (selectedItemsetReqIds && !selectedItemsetReqIds.has(req.id)) return false;
 
 
     return true;
     return true;
   };
   };
@@ -1924,11 +2273,7 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
       ? allCaps.filter((cap: any) => (cap.requirement_ids || []).some((rid: string) => reqIds.has(rid)))
       ? allCaps.filter((cap: any) => (cap.requirement_ids || []).some((rid: string) => reqIds.has(rid)))
       : allCaps;
       : allCaps;
     const capIds = new Set(caps.map((cap: any) => cap.id));
     const capIds = new Set(caps.map((cap: any) => cap.id));
-    const tools = dbData.tools.filter((tool: any) => {
-      const hasVisibleCap = (tool.capability_ids || []).some((cid: string) => capIds.has(cid));
-      if (!hasVisibleCap) return false;
-      return true;
-    });
+    const tools = dbData.tools;
     const toolIds = new Set(tools.map((tool: any) => tool.id));
     const toolIds = new Set(tools.map((tool: any) => tool.id));
     const know = dbData.know.filter((k: any) =>
     const know = dbData.know.filter((k: any) =>
       (k.capability_ids || []).some((cid: string) => capIds.has(cid)) ||
       (k.capability_ids || []).some((cid: string) => capIds.has(cid)) ||
@@ -2034,24 +2379,31 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
       ? new Set<string>(selectedCapRequirementIds)
       ? new Set<string>(selectedCapRequirementIds)
       : null;
       : null;
     const fromTool = selectedToolId ? selectedToolRequirementIds : null;
     const fromTool = selectedToolId ? selectedToolRequirementIds : null;
-    return intersectWithAllowed(intersectActiveSets(fromNode, fromProc, fromCap, fromTool), visibleReqIds);
-  }, [directNodeReqIds, visibleReqIds, selectedNode, selectedProcId, selectedProcRequirementIds, selectedCapId, selectedCapRequirementIds, selectedToolId, selectedToolRequirementIds]);
+    const fromItemset = selectedItemsetId && selectedItemsetReqIds ? selectedItemsetReqIds : null;
+    return intersectWithAllowed(intersectActiveSets(fromNode, fromProc, fromCap, fromTool, fromItemset), visibleReqIds);
+  }, [directNodeReqIds, visibleReqIds, selectedNode, selectedProcId, selectedProcRequirementIds, selectedCapId, selectedCapRequirementIds, selectedToolId, selectedToolRequirementIds, selectedItemsetId, selectedItemsetReqIds]);
 
 
   const relatedProcIds = useMemo((): Set<string> => {
   const relatedProcIds = useMemo((): Set<string> => {
     const fromNode = selectedNode ? directNodeProcIds : null;
     const fromNode = selectedNode ? directNodeProcIds : null;
     const fromReq = selectedReqId ? selectedReqProcIds : null;
     const fromReq = selectedReqId ? selectedReqProcIds : null;
     const fromCap = selectedCapId ? selectedCapProcIds : null;
     const fromCap = selectedCapId ? selectedCapProcIds : null;
     const fromTool = selectedToolId ? selectedToolProcIds : null;
     const fromTool = selectedToolId ? selectedToolProcIds : null;
-    return intersectWithAllowed(intersectActiveSets(fromNode, fromReq, fromCap, fromTool), visibleProcIds);
-  }, [directNodeProcIds, visibleProcIds, selectedNode, selectedReqId, selectedReqProcIds, selectedCapId, selectedCapProcIds, selectedToolId, selectedToolProcIds]);
+    const fromItemset = selectedItemsetId && selectedItemsetReqIds
+      ? new Set<string>(dbData.procs.filter((workflow) => (workflow.requirement_ids || []).some((reqId: string) => selectedItemsetReqIds.has(reqId))).map((workflow) => workflow.id))
+      : null;
+    return intersectWithAllowed(intersectActiveSets(fromNode, fromReq, fromCap, fromTool, fromItemset), visibleProcIds);
+  }, [directNodeProcIds, visibleProcIds, selectedNode, selectedReqId, selectedReqProcIds, selectedCapId, selectedCapProcIds, selectedToolId, selectedToolProcIds, selectedItemsetId, selectedItemsetReqIds, dbData.procs]);
 
 
   const relatedCapIds = useMemo((): Set<string> => {
   const relatedCapIds = useMemo((): Set<string> => {
     const fromNode = selectedNode ? directNodeCapIds : null;
     const fromNode = selectedNode ? directNodeCapIds : null;
     const fromProc = selectedProcId ? selectedProcCapabilityIds : null;
     const fromProc = selectedProcId ? selectedProcCapabilityIds : null;
     const fromReq = selectedReqId ? new Set<string>(selectedReqCapabilityIds) : null;
     const fromReq = selectedReqId ? new Set<string>(selectedReqCapabilityIds) : null;
     const fromTool = selectedToolId ? new Set<string>(selectedToolCapabilityIds) : null;
     const fromTool = selectedToolId ? new Set<string>(selectedToolCapabilityIds) : null;
-    return intersectWithAllowed(intersectActiveSets(fromNode, fromProc, fromReq, fromTool), visibleCapIds);
-  }, [directNodeCapIds, visibleCapIds, selectedNode, selectedProcId, selectedProcCapabilityIds, selectedReqId, selectedReqCapabilityIds, selectedToolId, selectedToolCapabilityIds]);
+    const fromItemset = selectedItemsetId && selectedItemsetReqIds
+      ? new Set<string>(allCaps.filter((cap: any) => (cap.requirement_ids || []).some((reqId: string) => selectedItemsetReqIds.has(reqId))).map((cap: any) => cap.id))
+      : null;
+    return intersectWithAllowed(intersectActiveSets(fromNode, fromProc, fromReq, fromTool, fromItemset), visibleCapIds);
+  }, [directNodeCapIds, visibleCapIds, selectedNode, selectedProcId, selectedProcCapabilityIds, selectedReqId, selectedReqCapabilityIds, selectedToolId, selectedToolCapabilityIds, selectedItemsetId, selectedItemsetReqIds, allCaps]);
 
 
   const relatedToolIds = useMemo((): Set<string> => {
   const relatedToolIds = useMemo((): Set<string> => {
     const fromNode = selectedNode ? directNodeToolIds : null;
     const fromNode = selectedNode ? directNodeToolIds : null;
@@ -2060,8 +2412,23 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
       : null;
       : null;
     const fromReq = selectedReqId ? selectedReqToolIds : null;
     const fromReq = selectedReqId ? selectedReqToolIds : null;
     const fromCap = selectedCapId ? selectedCapToolIds : null;
     const fromCap = selectedCapId ? selectedCapToolIds : null;
-    return intersectWithAllowed(intersectActiveSets(fromNode, fromProc, fromReq, fromCap), visibleToolIds);
-  }, [directNodeToolIds, visibleData.tools, visibleToolIds, selectedReqId, selectedReqToolIds, selectedCapId, selectedCapToolIds, selectedProcId, selectedProcCapabilityIds, selectedNode]);
+    const fromItemset = selectedItemsetId && selectedItemsetReqIds
+      ? new Set<string>(
+          visibleData.tools
+            .filter((tool: any) => {
+              const toolReqIds = new Set<string>();
+              (tool.capability_ids || []).forEach((capId: string) => {
+                allCaps.forEach((cap: any) => {
+                  if (cap.id === capId) (cap.requirement_ids || []).forEach((reqId: string) => toolReqIds.add(reqId));
+                });
+              });
+              return Array.from(toolReqIds).some((reqId) => selectedItemsetReqIds.has(reqId));
+            })
+            .map((tool: any) => tool.id)
+        )
+      : null;
+    return intersectWithAllowed(intersectActiveSets(fromNode, fromProc, fromReq, fromCap, fromItemset), visibleToolIds);
+  }, [directNodeToolIds, visibleData.tools, visibleToolIds, selectedReqId, selectedReqToolIds, selectedCapId, selectedCapToolIds, selectedProcId, selectedProcCapabilityIds, selectedNode, selectedItemsetId, selectedItemsetReqIds, allCaps]);
 
 
   const filteredData = useMemo(() => {
   const filteredData = useMemo(() => {
     const reqs = visibleData.reqs.filter((req: any) => {
     const reqs = visibleData.reqs.filter((req: any) => {
@@ -2118,6 +2485,58 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
     relatedToolIds,
     relatedToolIds,
   ]);
   ]);
 
 
+  const patternRoleByName = useMemo((): Record<string, 'substance' | 'form' | 'both'> => {
+    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))
+        : selectedNode
+        ? filteredData.reqs.filter((req: any) => directNodeReqIds.has(req.id))
+        : [];
+
+    const substance = new Set<string>();
+    const form = new Set<string>();
+
+    relatedReqs.forEach((req: any) => {
+      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));
+    });
+
+    const result: Record<string, 'substance' | 'form' | 'both'> = {};
+    substance.forEach((name) => {
+      result[name] = form.has(name) ? 'both' : 'substance';
+    });
+    form.forEach((name) => {
+      if (!result[name]) result[name] = 'form';
+    });
+    return result;
+  }, [filteredData.reqs, selectedProcId, selectedProcRequirementIds, selectedReqId, selectedCapId, selectedCapRequirementIds, selectedToolId, selectedToolRequirementIds, selectedNode, directNodeReqIds]);
+
+  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 (relatedLeafNames.size === 0) return null;
+    const ids = collectDirectNodeIdsFromNames(relatedLeafNames);
+    return ids.size > 0 ? ids : null;
+  }, [allItemsets, itemsetReqIdsMap, selectedReqId, collectDirectNodeIdsFromNames]);
+
   const filteredProcItems = useMemo(() => {
   const filteredProcItems = useMemo(() => {
     return visibleProcItems.filter((workflow) => {
     return visibleProcItems.filter((workflow) => {
       if (selectedReqId && !(workflow.requirement_ids || []).includes(selectedReqId)) return false;
       if (selectedReqId && !(workflow.requirement_ids || []).includes(selectedReqId)) return false;
@@ -2137,12 +2556,10 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
   const filteredToolIds = useMemo(() => new Set(filteredData.tools.map((tool: any) => tool.id)), [filteredData.tools]);
   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 filteredProcIds = useMemo(() => new Set(filteredProcItems.map((workflow) => workflow.id)), [filteredProcItems]);
 
 
-  const selectedProc = dbData.procs.find((p) => p.id === selectedProcId) || null;
-  const selectedReq = dbData.reqs.find((r: any) => r.id === selectedReqId) || null;
-  const selectedCap = allCaps.find((c: any) => c.id === selectedCapId) || null;
-  const selectedTool = dbData.tools.find((t: any) => t.id === selectedToolId) || null;
-  const hasExplicitColumnFilter = !!selectedProcId || !!selectedReqId || !!selectedCapId || !!selectedToolId;
-  const hasAnyFilter = !!selectedNode || !!selectedProcId || !!selectedReqId || !!selectedCapId || !!selectedToolId;
+  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 selectedReqSourceNodeIds = useMemo((): Set<string> | null => {
     const relatedReqs =
     const relatedReqs =
@@ -2171,6 +2588,12 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
     collectDirectNodeIdsFromReqs,
     collectDirectNodeIdsFromReqs,
   ]);
   ]);
 
 
+  const selectedPatternNodeIds = useMemo((): Set<string> | null => {
+    if (selectedItemsetDirectNodeIds) return selectedItemsetDirectNodeIds;
+    if (selectedReqPatternNodeIds) return selectedReqPatternNodeIds;
+    return null;
+  }, [selectedItemsetDirectNodeIds, selectedReqPatternNodeIds]);
+
   const selectedSubtreeLeafNamesKey = useMemo(
   const selectedSubtreeLeafNamesKey = useMemo(
     () => (selectedSubtreeLeafNames ? Array.from(selectedSubtreeLeafNames).sort().join('|') : ''),
     () => (selectedSubtreeLeafNames ? Array.from(selectedSubtreeLeafNames).sort().join('|') : ''),
     [selectedSubtreeLeafNames]
     [selectedSubtreeLeafNames]
@@ -2183,17 +2606,21 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
     () => (selectedReqSourceNodeIds ? Array.from(selectedReqSourceNodeIds).sort().join('|') : ''),
     () => (selectedReqSourceNodeIds ? Array.from(selectedReqSourceNodeIds).sort().join('|') : ''),
     [selectedReqSourceNodeIds]
     [selectedReqSourceNodeIds]
   );
   );
+  const selectedPatternNodeIdsKey = useMemo(
+    () => (selectedPatternNodeIds ? Array.from(selectedPatternNodeIds).sort().join('|') : ''),
+    [selectedPatternNodeIds]
+  );
 
 
   // 节点变化时重置所有列的定位索引
   // 节点变化时重置所有列的定位索引
   useEffect(() => {
   useEffect(() => {
-    setColumnFocusIndex({ req: 0, proc: 0, cap: 0, tool: 0 });
+    setColumnFocusIndex({ req: 0, proc: 0, cap: 0, tool: 0, pattern: 0 });
     setTreeFocusIndex(0);
     setTreeFocusIndex(0);
   }, [selectedNode]);
   }, [selectedNode]);
 
 
   // 筛选条件变化时重置树导航索引
   // 筛选条件变化时重置树导航索引
   useEffect(() => {
   useEffect(() => {
     setTreeFocusIndex(0);
     setTreeFocusIndex(0);
-  }, [selectedSubtreeLeafNamesKey, relationFilterHighlightLeafNamesKey, onlyCoveredFilter, selectedReqSourceNodeIdsKey]);
+  }, [selectedSubtreeLeafNamesKey, relationFilterHighlightLeafNamesKey, onlyCoveredFilter, selectedReqSourceNodeIdsKey, selectedPatternNodeIdsKey]);
 
 
   const treeMatchedNodes = useMemo((): any[] => {
   const treeMatchedNodes = useMemo((): any[] => {
     if (!treeData) return [];
     if (!treeData) return [];
@@ -2201,9 +2628,10 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
     const collectMatchedNodes = (node: any): any[] => {
     const collectMatchedNodes = (node: any): any[] => {
       const hasChildren = node.children && node.children.length > 0;
       const hasChildren = node.children && node.children.length > 0;
       const isSourceNode = selectedReqSourceNodeIds?.has(String(node.id)) ?? false;
       const isSourceNode = selectedReqSourceNodeIds?.has(String(node.id)) ?? false;
+      const isPatternNode = selectedPatternNodeIds?.has(String(node.id)) ?? false;
       const matched: any[] = [];
       const matched: any[] = [];
 
 
-      if (isSourceNode) {
+      if (isSourceNode || isPatternNode) {
         matched.push(node);
         matched.push(node);
       }
       }
 
 
@@ -2233,7 +2661,7 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
     }
     }
 
 
     return allMatched;
     return allMatched;
-  }, [treeData, selectedSubtreeLeafNames, selectedSubtreeNodeIds, selectedReqSourceNodeIds]);
+  }, [treeData, selectedSubtreeLeafNames, selectedSubtreeNodeIds, selectedReqSourceNodeIds, selectedPatternNodeIds]);
 
 
   const treeMatchedCount = treeMatchedNodes.length;
   const treeMatchedCount = treeMatchedNodes.length;
   const clampedTreeFocusIdx = treeMatchedCount > 0 ? Math.min(treeFocusIndex, treeMatchedCount - 1) : 0;
   const clampedTreeFocusIdx = treeMatchedCount > 0 ? Math.min(treeFocusIndex, treeMatchedCount - 1) : 0;
@@ -2502,8 +2930,13 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
     false
     false
   );
   );
 
 
-  const sortedItems = (items: any[]) => {
-    return [...items];
+  const sortedItems = (items: any[], eligibleIds?: Set<string>) => {
+    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;
+    });
   };
   };
 
 
   const handleSingleClick = (nodeId: string, item: any) => {
   const handleSingleClick = (nodeId: string, item: any) => {
@@ -2591,6 +3024,25 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
   const eligibleCapIds = useMemo(() => new Set(filteredData.caps.map((c: any) => c.id)), [filteredData.caps]);
   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]);
   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 displayedReqs = visibleData.reqs;
   const displayedProcs = visibleProcItems;
   const displayedProcs = visibleProcItems;
   const displayedCaps = visibleData.caps;
   const displayedCaps = visibleData.caps;
@@ -2708,6 +3160,8 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
     return map;
     return map;
   }, [dbData.tools, allCaps, collectDirectNodeIdsFromReqIds]);
   }, [dbData.tools, allCaps, collectDirectNodeIdsFromReqIds]);
 
 
+  const hasAnyFilter = !!(selectedNode || selectedProcId || selectedReqId || selectedCapId || selectedToolId || selectedItemsetId);
+
   if (dashboardLoadingText) {
   if (dashboardLoadingText) {
     return (
     return (
       <div className="flex flex-col h-[calc(100vh-64px)] w-full items-center justify-center text-slate-400">
       <div className="flex flex-col h-[calc(100vh-64px)] w-full items-center justify-center text-slate-400">
@@ -2765,6 +3219,7 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
               setSelectedReqId(null);
               setSelectedReqId(null);
               setSelectedCapId(null);
               setSelectedCapId(null);
               setSelectedToolId(null);
               setSelectedToolId(null);
+              setSelectedItemsetId(null);
             }}
             }}
             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"
             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"
           >
           >
@@ -2815,6 +3270,7 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
               if (!selectedNode && selectedReqId) setSelectedReqId(null);
               if (!selectedNode && selectedReqId) setSelectedReqId(null);
             } : undefined}
             } : undefined}
             sourceNodeIds={selectedReqSourceNodeIds}
             sourceNodeIds={selectedReqSourceNodeIds}
+            patternNodeIds={selectedPatternNodeIds}
             nodeMetricsMap={nodeMetricsMap}
             nodeMetricsMap={nodeMetricsMap}
             totalNodeCount={totalNodeCount}
             totalNodeCount={totalNodeCount}
             wideMode={treeWideMode}
             wideMode={treeWideMode}
@@ -2827,10 +3283,32 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
           />
           />
         </div>
         </div>
 
 
+        {/* 频繁模式列 */}
+        <PatternColumn
+          selectedItemsetId={selectedItemsetId}
+          itemsets={allItemsets}
+          eligibleItemsetIds={relatedItemsetIds}
+          contextNodeNames={patternContextNodeNames}
+          nodeRoleByName={patternRoleByName}
+          hasAnyFilter={hasAnyFilter}
+          focusIndex={columnFocusIndex.pattern ?? 0}
+          onSelectItemset={(itemsetId) => {
+            setSelectedItemsetId(itemsetId);
+            setColumnFocusIndex(prev => ({ ...prev, pattern: 0 }));
+            if (itemsetId === null) setDrawerItem(null);
+          }}
+          onOpenDrawer={(itemset) => setDrawerItem({ type: 'itemset', data: itemset })}
+          onFocusPrev={() => setColumnFocusIndex(prev => ({ ...prev, pattern: Math.max(0, (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) }));
+          }}
+        />
+
         {/* 关系列:每列固定宽度 */}
         {/* 关系列:每列固定宽度 */}
         {columns.map(col => {
         {columns.map(col => {
           const activeId = getColumnActiveId(col.t);
           const activeId = getColumnActiveId(col.t);
-          const orderedItems = sortedItems(col.d);
+          const orderedItems = sortedItems(col.d, col.eligibleIds);
 
 
           // 计算所有可用匹配项的索引(按原始顺序)
           // 计算所有可用匹配项的索引(按原始顺序)
           const matchedIndices: number[] = [];
           const matchedIndices: number[] = [];
@@ -2961,7 +3439,11 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
       <SideDrawer
       <SideDrawer
         isOpen={!!drawerItem}
         isOpen={!!drawerItem}
         onClose={() => setDrawerItem(null)}
         onClose={() => setDrawerItem(null)}
-        title={drawerItem ? (drawerItem.data.name || drawerItem.data.description?.slice(0, 20) || drawerItem.data.task?.slice(0, 20) || drawerItem.data.id?.slice(0, 12)) : ''}
+        title={drawerItem ? (
+          drawerItem.type === 'itemset'
+            ? `Pattern #${drawerItem.data.id}`
+            : (drawerItem.data.name || drawerItem.data.description?.slice(0, 20) || drawerItem.data.task?.slice(0, 20) || drawerItem.data.id?.slice(0, 12))
+        ) : ''}
         width="w-[360px]"
         width="w-[360px]"
       >
       >
         {drawerItem && (
         {drawerItem && (

+ 8 - 0
knowhub/server.py

@@ -813,6 +813,14 @@ STATIC_DIR = Path(__file__).parent / "frontend" / "dist"
 if STATIC_DIR.exists():
 if STATIC_DIR.exists():
     app.mount("/assets", StaticFiles(directory=str(STATIC_DIR / "assets")), name="assets")
     app.mount("/assets", StaticFiles(directory=str(STATIC_DIR / "assets")), name="assets")
 
 
+# 提供 itemsets_all.json
+@app.get("/itemsets_all.json")
+async def serve_itemsets():
+    itemsets_file = STATIC_DIR / "itemsets_all.json"
+    if itemsets_file.exists():
+        return FileResponse(itemsets_file)
+    return {"error": "itemsets_all.json not found"}
+
 
 
 # --- 缓存自动失效中间件 ---
 # --- 缓存自动失效中间件 ---
 # 任何对核心实体的写操作(POST/PATCH/DELETE)自动清除对应缓存
 # 任何对核心实体的写操作(POST/PATCH/DELETE)自动清除对应缓存