Kaynağa Gözat

fix: cap filter & tree css

Talegorithm 1 ay önce
ebeveyn
işleme
84cff97e37

+ 31 - 10
knowhub/frontend/src/components/dashboard/CategoryTree.tsx

@@ -19,6 +19,10 @@ interface NodeProps {
   focusTrigger?: number;
   isOpen: (nodeId: string) => boolean;
   onToggleOpen: (nodeId: string) => void;
+  // 与 RelationCard 4 态视觉对齐所需的输入
+  directHighlightNodeIds: Set<string> | null;
+  indirectHighlightNodeIds: Set<string> | null;
+  hasActiveFilter: boolean;
 }
 
 // Returns true if this node or any descendant is in the highlight set
@@ -29,7 +33,7 @@ function nodeHasHighlightedLeaf(node: any, highlightLeafNames: Set<string> | nul
   return node.children.some((child: any) => nodeHasHighlightedLeaf(child, highlightLeafNames));
 }
 
-function HorizontalTreeNode({ node, onSelect, onOpenDetail, selectedIds, level, highlightLeafNames, subtreeHighlightNodeIds, sourceNodeIds, patternNodeIds, nodeMetricsMap, dimensionColor, focusedTreeNodeId = null, focusTrigger = 0, isOpen, onToggleOpen }: NodeProps) {
+function HorizontalTreeNode({ node, onSelect, onOpenDetail, selectedIds, level, highlightLeafNames, subtreeHighlightNodeIds, sourceNodeIds, patternNodeIds, nodeMetricsMap, dimensionColor, focusedTreeNodeId = null, focusTrigger = 0, isOpen, onToggleOpen, directHighlightNodeIds, indirectHighlightNodeIds, hasActiveFilter }: NodeProps) {
   const expanded = isOpen(String(node.id));
   const [hoveredMetric, setHoveredMetric] = useState<null | { key: string; count: number; colorClass: string; x: number; y: number }>(null);
   const nodeRef = useRef<HTMLDivElement>(null);
@@ -38,17 +42,18 @@ function HorizontalTreeNode({ node, onSelect, onOpenDetail, selectedIds, level,
   let textColor = "text-slate-800";
   if (hasChildren) textColor = cn(textColor, "font-extrabold");
 
-  // Highlight/dim logic for reverse filtering
+  // Highlight/dim logic for reverse filtering (legacy:仅 inHighlight 用于 subtree fade-out)
   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 isAssociatedNode = isSourceNode || isPatternNode;
   const isSelected =
     node.id !== null &&
     node.id !== undefined &&
     selectedIds.has(String(node.id));
+
+  // 4 态分层(叶子 & 非叶一视同仁):非叶节点按 "子树聚合" 判断
+  // 聚合集由 CategoryTree 根预计算,passed-in sets 已包含"自己或任一后代命中"的 id
+  const isDirect = directHighlightNodeIds?.has(String(node.id)) ?? false;
+  const isIndirect = !isDirect && (indirectHighlightNodeIds?.has(String(node.id)) ?? false);
+  const isNoMatch = hasActiveFilter && !isSelected && !isDirect && !isIndirect;
   const shouldScrollIntoView =
     focusedTreeNodeId !== null &&
     focusedTreeNodeId !== undefined &&
@@ -71,10 +76,14 @@ function HorizontalTreeNode({ node, onSelect, onOpenDetail, selectedIds, level,
           "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)]"
-            : isAssociatedNode || isInSubtreeHighlight || isLeafHighlighted
-              // 与 RelationCard 的 direct-match 样式统一:实线 sky 边 + sky-50 浅底 + 外发光
+            : isDirect
+              // direct:与 RelationCard direct-match 视觉一致
               ? "border-sky-300 bg-sky-50/70 shadow-[0_0_0_1px_rgba(125,211,252,0.5)]"
-              : "hover:brightness-95"
+              : isNoMatch
+                // 无关联:置灰但仍可点击
+                ? "border-slate-200 bg-slate-50 opacity-45 saturate-50"
+                // indirect 或无筛选:默认态
+                : "hover:brightness-95"
         )}
         onClick={() => onSelect(node)}
       >
@@ -162,6 +171,9 @@ function HorizontalTreeNode({ node, onSelect, onOpenDetail, selectedIds, level,
                 focusedTreeNodeId={focusedTreeNodeId}
                 isOpen={isOpen}
                 onToggleOpen={onToggleOpen}
+                directHighlightNodeIds={directHighlightNodeIds}
+                indirectHighlightNodeIds={indirectHighlightNodeIds}
+                hasActiveFilter={hasActiveFilter}
               />
             </div>
           ))}
@@ -200,6 +212,9 @@ export function CategoryTree({
   focusedTreeNodeId = null,
   onTreeFocusPrev,
   onTreeFocusNext,
+  directHighlightNodeIds = null,
+  indirectHighlightNodeIds = null,
+  hasActiveFilter = false,
 }: {
   data: any;
   onSelect: (node: any) => void;
@@ -220,6 +235,9 @@ export function CategoryTree({
   focusedTreeNodeId?: string | number | null;
   onTreeFocusPrev?: () => void;
   onTreeFocusNext?: () => void;
+  directHighlightNodeIds?: Set<string> | null;
+  indirectHighlightNodeIds?: Set<string> | null;
+  hasActiveFilter?: boolean;
 }) {
   const [scale, setScale] = useState(1);
 
@@ -452,6 +470,9 @@ export function CategoryTree({
                         focusedTreeNodeId={focusedTreeNodeId}
                         isOpen={isOpen}
                         onToggleOpen={onToggleOpen}
+                        directHighlightNodeIds={directHighlightNodeIds}
+                        indirectHighlightNodeIds={indirectHighlightNodeIds}
+                        hasActiveFilter={hasActiveFilter}
                       />
                     ))}
                   </div>

+ 4 - 4
knowhub/frontend/src/components/dashboard/details/DrawerContentComponents.tsx

@@ -785,8 +785,8 @@ export function ItemsetPostsDrawer({
       <div className="shrink-0 bg-blue-50 p-4 rounded-xl border border-blue-100">
         <div className="text-xs font-bold text-blue-600 mb-2">Pattern 节点组合</div>
         <div className="flex flex-wrap gap-1.5">
-          {(itemset.leaf_names || []).map((name: string) => (
-            <span key={name} className="px-2 py-0.5 rounded-full text-xs bg-blue-100 text-blue-800 border border-blue-200 font-medium">{name}</span>
+          {(itemset.leaf_names || []).map((name: string, idx: number) => (
+            <span key={`${name}-${idx}`} className="px-2 py-0.5 rounded-full text-xs bg-blue-100 text-blue-800 border border-blue-200 font-medium">{name}</span>
           ))}
         </div>
         <div className="mt-3 grid grid-cols-2 gap-2">
@@ -1163,11 +1163,11 @@ function PatternColumn({
               <div className="flex items-start gap-2">
                 <div className="min-w-0 flex-1">
                   <div className="flex flex-wrap gap-1 mt-1.5">
-                    {(itemset.leaf_names || []).map((name: string) => {
+                    {(itemset.leaf_names || []).map((name: string, nameIdx: number) => {
                       const isMatched = itemset.matched_nodes.includes(name);
                       return (
                         <span
-                          key={name}
+                          key={`${name}-${nameIdx}`}
                           onClick={(e) => {
                             if (hasAnyFilter && !isEligible && !isSelected) return;
                             if (!onNodeClick) return;

+ 31 - 3
knowhub/frontend/src/pages/Dashboard.tsx

@@ -646,11 +646,11 @@ function PatternColumn({
               <div className="flex flex-col">
                 <div className="flex items-start justify-between">
                   <div className="flex flex-wrap gap-1 flex-1 mt-1.5">
-                    {(itemset.leaf_names || []).map((name: string) => {
+                    {(itemset.leaf_names || []).map((name: string, nameIdx: number) => {
                       const isMatched = itemset.matched_nodes.includes(name);
                       return (
                         <span
-                          key={name}
+                          key={`${name}-${nameIdx}`}
                           onClick={(e) => {
                             if (!onNodeClick) return;
                             e.stopPropagation();
@@ -974,6 +974,27 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
     return ids;
   }, [dashFilter.filterResults]);
 
+  // 为内容树拆出 direct / indirect 两个 id 集,与其他列的样式分层保持一致
+  const treeDirectHighlightIds = useMemo(() => {
+    const ids = new Set<string>();
+    Array.from(dashFilter.filterResults?.directMatched || []).forEach(ref => {
+      if (typeof ref === 'string' && ref.startsWith('tree:::')) {
+        ids.add(ref.split(':::')[1]);
+      }
+    });
+    return ids;
+  }, [dashFilter.filterResults]);
+
+  const treeIndirectHighlightIds = useMemo(() => {
+    const ids = new Set<string>();
+    Array.from(dashFilter.filterResults?.indirectMatched || []).forEach(ref => {
+      if (typeof ref === 'string' && ref.startsWith('tree:::')) {
+        ids.add(ref.split(':::')[1]);
+      }
+    });
+    return ids;
+  }, [dashFilter.filterResults]);
+
   // The filtered arrays:
   const filteredData = useMemo(() => ({
     reqs: dbData.reqs.filter((r: any) => dashFilter.getItemState('req', r.id) !== 'dimmed'),
@@ -1371,7 +1392,11 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
   }, [nameToNodeMap]);
 
   const handleSingleClick = (nodeId: string, item: any, currentOrderIds: string[]) => {
-    const [type, id] = nodeId.split(':');
+    // nodeId 形如 "type:<id>",但 id 本身可能含冒号(如 cap id "REQ_001::CAP-001")
+    // 只能按首个 ':' 切分,不能用 split(':')
+    const colonIdx = nodeId.indexOf(':');
+    const type = colonIdx >= 0 ? nodeId.slice(0, colonIdx) : nodeId;
+    const id = colonIdx >= 0 ? nodeId.slice(colonIdx + 1) : '';
 
     setManualFocusedTreeNodeId(null);
     setTreeFocusTrigger(prev => prev + 1);
@@ -1606,6 +1631,9 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
                 }}
                 selectedIds={dashFilter.selections.tree}
                 subtreeHighlightNodeIds={treeNavigableNodeIds}
+                directHighlightNodeIds={treeDirectHighlightIds}
+                indirectHighlightNodeIds={treeIndirectHighlightIds}
+                hasActiveFilter={hasAnyFilter}
                 highlightLeafNames={
                   selectedSubtreeLeafNames
                     ? selectedSubtreeLeafNames