Przeglądaj źródła

Merge remote-tracking branch 'refs/remotes/origin/main'

Talegorithm 22 godzin temu
rodzic
commit
787cdbe770

+ 22 - 40
knowhub/frontend/src/components/common/SideDrawer.tsx

@@ -1,4 +1,5 @@
 import { useState, useEffect } from 'react';
+import { createPortal } from 'react-dom';
 import { X } from 'lucide-react';
 import { cn } from '../../lib/utils';
 
@@ -17,48 +18,29 @@ export function SideDrawer({ isOpen, onClose, title, children, width = 'w-[650px
     setMounted(true);
   }, []);
 
-  useEffect(() => {
-    if (isOpen) {
-      document.body.style.overflow = 'hidden';
-    } else {
-      document.body.style.overflow = 'auto';
-    }
-  }, [isOpen]);
-
   if (!mounted) return null;
 
-  return (
-    <>
-      {/* Backdrop */}
-      <div 
-        className={cn(
-          "fixed inset-0 bg-slate-900/20 backdrop-blur-sm z-[100] transition-opacity duration-300",
-          isOpen ? "opacity-100 visible" : "opacity-0 invisible"
-        )}
-        onClick={onClose}
-      />
-      
-      {/* Drawer */}
-      <div 
-        className={cn(
-          "fixed top-0 right-0 h-full bg-white shadow-2xl z-[101] transform transition-transform duration-300 ease-in-out overflow-hidden flex flex-col border-l border-slate-200",
-          width,
-          isOpen ? "translate-x-0" : "translate-x-full"
-        )}
-      >
-        <div className="flex justify-between items-center px-6 py-4 border-b border-slate-100 bg-white sticky top-0 z-10">
-          <div className="text-xl font-bold text-slate-900">{title}</div>
-          <button 
-            onClick={onClose}
-            className="p-2 hover:bg-slate-100 rounded-lg text-slate-400 hover:text-slate-600 transition-colors"
-          >
-            <X size={20} />
-          </button>
-        </div>
-        <div className="flex-1 overflow-y-auto p-6 bg-slate-50">
-          {children}
-        </div>
+  return createPortal(
+    <div 
+      className={cn(
+        "fixed top-0 right-0 h-full bg-white shadow-2xl z-[220] transform transition-transform duration-300 ease-in-out overflow-hidden flex flex-col border-l border-slate-200",
+        width,
+        isOpen ? "translate-x-0" : "translate-x-full"
+      )}
+    >
+      <div className="flex justify-between items-center px-6 py-4 border-b border-slate-100 bg-white sticky top-0 z-10">
+        <div className="text-xl font-bold text-slate-900">{title}</div>
+        <button 
+          onClick={onClose}
+          className="p-2 hover:bg-slate-100 rounded-lg text-slate-400 hover:text-slate-600 transition-colors"
+        >
+          <X size={20} />
+        </button>
+      </div>
+      <div className="flex-1 overflow-y-auto p-6 bg-slate-50">
+        {children}
       </div>
-    </>
+    </div>,
+    document.body
   );
 }

+ 88 - 68
knowhub/frontend/src/components/dashboard/CategoryTree.tsx

@@ -1,14 +1,17 @@
 import { useState } from 'react';
+import { createPortal } from 'react-dom';
 import { cn } from '../../lib/utils';
 import { ChevronRight, ChevronDown, ZoomIn, ZoomOut, Maximize, FolderTree } from 'lucide-react';
 
 interface NodeProps {
   node: any;
   onSelect: (node: any) => void;
-  onDoubleClick: (node: any) => void;
   selectedId: string | number | null;
   level: number;
   highlightLeafNames: Set<string> | null; // null = no filter active
+  subtreeHighlightNodeIds: Set<string> | null;
+  sourceNodeNames: Set<string> | null;
+  nodeMetricsMap: Record<string, { reqCount: number; procCount: number; capCount: number; toolCount: number }>;
 }
 
 // Returns true if this node or any descendant is in the highlight set
@@ -19,88 +22,75 @@ function nodeHasHighlightedLeaf(node: any, highlightLeafNames: Set<string> | nul
   return node.children.some((child: any) => nodeHasHighlightedLeaf(child, highlightLeafNames));
 }
 
-function HorizontalTreeNode({ node, onSelect, onDoubleClick, selectedId, level, highlightLeafNames }: NodeProps) {
+function HorizontalTreeNode({ node, onSelect, selectedId, level, highlightLeafNames, subtreeHighlightNodeIds, sourceNodeNames, nodeMetricsMap }: NodeProps) {
   const [expanded, setExpanded] = useState(true);
+  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 count = node.total_posts_count || 0;
-  const status = node.node_status ?? 0;
-
-  let intensity = 0;
-  if (count < 10) intensity = 0;
-  else if (count < 50) intensity = 1;
-  else if (count < 100) intensity = 2;
-  else if (count < 300) intensity = 3;
-  else if (count < 800) intensity = 4;
-  else intensity = 5;
-
-  const palettes = {
-    0: [
-      { bg: "bg-slate-100", border: "border-slate-200", text: "text-slate-900" },
-      { bg: "bg-slate-200", border: "border-slate-300", text: "text-slate-900" },
-      { bg: "bg-slate-300", border: "border-slate-400", text: "text-slate-900" },
-      { bg: "bg-slate-400", border: "border-slate-500", text: "text-slate-900" },
-      { bg: "bg-slate-500", border: "border-slate-600", text: "text-white" },
-      { bg: "bg-slate-600", border: "border-slate-700", text: "text-white" }
-    ],
-    1: [
-      { bg: "bg-indigo-100", border: "border-indigo-200", text: "text-indigo-900" },
-      { bg: "bg-indigo-300", border: "border-indigo-400", text: "text-indigo-900" },
-      { bg: "bg-indigo-500", border: "border-indigo-600", text: "text-white" },
-      { bg: "bg-indigo-600", border: "border-indigo-700", text: "text-white" },
-      { bg: "bg-indigo-700", border: "border-indigo-800", text: "text-white" },
-      { bg: "bg-indigo-900", border: "border-indigo-950", text: "text-white" }
-    ],
-    2: [
-      { bg: "bg-teal-100", border: "border-teal-200", text: "text-teal-900" },
-      { bg: "bg-teal-300", border: "border-teal-400", text: "text-teal-900" },
-      { bg: "bg-teal-500", border: "border-teal-600", text: "text-white" },
-      { bg: "bg-teal-600", border: "border-teal-700", text: "text-white" },
-      { bg: "bg-teal-700", border: "border-teal-800", text: "text-white" },
-      { bg: "bg-teal-900", border: "border-teal-950", text: "text-white" }
-    ],
-    3: [
-      { bg: "bg-green-100", border: "border-green-200", text: "text-green-900" },
-      { bg: "bg-green-300", border: "border-green-400", text: "text-green-900" },
-      { bg: "bg-green-500", border: "border-green-600", text: "text-white" },
-      { bg: "bg-green-600", border: "border-green-700", text: "text-white" },
-      { bg: "bg-green-700", border: "border-green-800", text: "text-white" },
-      { bg: "bg-green-900", border: "border-green-950", text: "text-white" }
-    ]
-  };
-
-  const theme = palettes[status as keyof typeof palettes][intensity];
-  let textColor = theme.text;
+  const metrics = nodeMetricsMap[String(node.id)] || { reqCount: 0, procCount: 0, capCount: 0, toolCount: 0 };
+  let textColor = "text-slate-800";
   if (hasChildren) textColor = cn(textColor, "font-extrabold");
 
   // Highlight/dim logic for reverse filtering
   const inHighlight = nodeHasHighlightedLeaf(node, highlightLeafNames);
   const isLeafHighlighted = highlightLeafNames && highlightLeafNames.has(node.name);
-
+  const isInSubtreeHighlight = subtreeHighlightNodeIds?.has(String(node.id)) ?? false;
+  const isSourceNode = sourceNodeNames?.has(node.name) ?? false;
+  const isSelected =
+    selectedId !== null &&
+    selectedId !== undefined &&
+    node.id !== null &&
+    node.id !== undefined &&
+    String(selectedId) === String(node.id);
   return (
     <div className={cn("flex flex-row items-start transition-opacity duration-200", highlightLeafNames && !inHighlight && "opacity-20")}>
       {/* Node Card */}
       <div
         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]",
-          theme.bg, theme.border,
-          selectedId === node.id
-            ? "ring-2 ring-indigo-500 ring-offset-1 border-indigo-400"
+          "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)]"
+            : isSourceNode
+            ? "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
-            ? "ring-2 ring-orange-400 ring-offset-1"
+            ? "ring-2 ring-sky-300 ring-offset-1 border-sky-300"
             : "hover:brightness-95"
         )}
         onClick={() => onSelect(node)}
-        onDoubleClick={(e) => { e.stopPropagation(); if (!hasChildren) onDoubleClick(node); }}
-        ref={(el) => { if (el && selectedId === node.id) el.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' }); }}
+        ref={(el) => { if (el && isSelected) el.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' }); }}
       >
         <span className={cn("text-xs font-bold mr-3", textColor)}>{node.name || "Root"}</span>
-        {node.id && <span className="text-[10px] opacity-60 mr-2 font-mono">{node.id}</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>
+        )}
 
-        {/* Count Pill */}
-        <div className="flex text-[9px] bg-white/70 rounded px-1 group shadow-sm items-center">
-          <span className="px-1 text-slate-500 font-medium">{node.total_element_count || 0}</span>
-          <span className="px-1 font-bold text-slate-800 border-l border-white/50 pl-1">{node.total_posts_count || 0} ▶</span>
+        <div className="flex text-[9px] bg-slate-100 rounded px-1.5 shadow-sm items-center font-bold text-slate-700">
+          {node.total_posts_count || 0} 帖
+        </div>
+        <div className="relative flex items-center gap-2 ml-2">
+          <span
+            onMouseEnter={(e) => metrics.reqCount > 0 && setHoveredMetric({ key: 'req', count: metrics.reqCount, colorClass: 'bg-indigo-500 border-indigo-500 text-white', x: e.currentTarget.getBoundingClientRect().left + e.currentTarget.getBoundingClientRect().width / 2, y: e.currentTarget.getBoundingClientRect().top - 8 })}
+            onMouseLeave={() => setHoveredMetric((prev) => (prev?.key === 'req' ? null : prev))}
+            className={cn("w-2.5 h-2.5 rounded-full", metrics.reqCount > 0 ? "bg-indigo-500" : "opacity-0")}
+          />
+          <span
+            onMouseEnter={(e) => metrics.procCount > 0 && setHoveredMetric({ key: 'proc', count: metrics.procCount, colorClass: 'bg-purple-500 border-purple-500 text-white', x: e.currentTarget.getBoundingClientRect().left + e.currentTarget.getBoundingClientRect().width / 2, y: e.currentTarget.getBoundingClientRect().top - 8 })}
+            onMouseLeave={() => setHoveredMetric((prev) => (prev?.key === 'proc' ? null : prev))}
+            className={cn("w-2.5 h-2.5 rounded-full", metrics.procCount > 0 ? "bg-purple-500" : "opacity-0")}
+          />
+          <span
+            onMouseEnter={(e) => metrics.capCount > 0 && setHoveredMetric({ key: 'cap', count: metrics.capCount, colorClass: 'bg-rose-500 border-rose-500 text-white', x: e.currentTarget.getBoundingClientRect().left + e.currentTarget.getBoundingClientRect().width / 2, y: e.currentTarget.getBoundingClientRect().top - 8 })}
+            onMouseLeave={() => setHoveredMetric((prev) => (prev?.key === 'cap' ? null : prev))}
+            className={cn("w-2.5 h-2.5 rounded-full", metrics.capCount > 0 ? "bg-rose-500" : "opacity-0")}
+          />
+          <span
+            onMouseEnter={(e) => metrics.toolCount > 0 && setHoveredMetric({ key: 'tool', count: metrics.toolCount, colorClass: 'bg-green-500 border-green-500 text-white', x: e.currentTarget.getBoundingClientRect().left + e.currentTarget.getBoundingClientRect().width / 2, y: e.currentTarget.getBoundingClientRect().top - 8 })}
+            onMouseLeave={() => setHoveredMetric((prev) => (prev?.key === 'tool' ? null : prev))}
+            className={cn("w-2.5 h-2.5 rounded-full", metrics.toolCount > 0 ? "bg-green-500" : "opacity-0")}
+          />
         </div>
 
         {hasChildren && (
@@ -133,15 +123,26 @@ function HorizontalTreeNode({ node, onSelect, onDoubleClick, selectedId, level,
               <HorizontalTreeNode
                 node={child}
                 onSelect={onSelect}
-                onDoubleClick={onDoubleClick}
                 selectedId={selectedId}
                 level={level + 1}
                 highlightLeafNames={highlightLeafNames}
+                subtreeHighlightNodeIds={subtreeHighlightNodeIds}
+                sourceNodeNames={sourceNodeNames}
+                nodeMetricsMap={nodeMetricsMap}
               />
             </div>
           ))}
         </div>
       )}
+      {hoveredMetric && createPortal(
+        <div
+          className={cn("fixed z-[99999] rounded-lg border shadow-lg min-w-[40px] px-3 py-2 flex items-center justify-center -translate-x-1/2 -translate-y-full", hoveredMetric.colorClass)}
+          style={{ left: hoveredMetric.x, top: hoveredMetric.y }}
+        >
+          <div className="text-sm font-black leading-none text-center">{hoveredMetric.count}</div>
+        </div>,
+        document.body
+      )}
     </div>
   );
 }
@@ -149,18 +150,26 @@ function HorizontalTreeNode({ node, onSelect, onDoubleClick, selectedId, level,
 export function CategoryTree({
   data,
   onSelect,
-  onDoubleClick,
   selectedId,
   highlightLeafNames = null,
+  subtreeHighlightNodeIds = null,
+  sourceNodeNames = null,
+  nodeMetricsMap = {},
+  filterLabel,
+  onClearFilter,
   totalNodeCount,
   wideMode = false,
   onToggleWideMode,
 }: {
   data: any;
   onSelect: (node: any) => void;
-  onDoubleClick?: (node: any) => void;
   selectedId: any;
   highlightLeafNames?: Set<string> | null;
+  subtreeHighlightNodeIds?: Set<string> | null;
+  sourceNodeNames?: Set<string> | null;
+  nodeMetricsMap?: Record<string, { reqCount: number; procCount: number; capCount: number; toolCount: number }>;
+  filterLabel?: string | null;
+  onClearFilter?: () => void;
   totalNodeCount?: number;
   wideMode?: boolean;
   onToggleWideMode?: () => void;
@@ -183,6 +192,15 @@ export function CategoryTree({
           内容树
         </div>
         <div className="flex items-center gap-2">
+          {filterLabel && (
+            <button
+              type="button"
+              onClick={onClearFilter}
+              className="text-[10px] font-bold px-2 py-1 rounded-md bg-sky-100 text-sky-700 hover:bg-sky-200 transition-colors"
+            >
+              {filterLabel} ×
+            </button>
+          )}
           <button
             onClick={onToggleWideMode}
             className={cn(
@@ -242,10 +260,12 @@ export function CategoryTree({
                         key={subNode.id || subIdx}
                         node={subNode}
                         onSelect={onSelect}
-                        onDoubleClick={onDoubleClick ?? (() => {})}
                         selectedId={selectedId}
                         level={1}
                         highlightLeafNames={highlightLeafNames}
+                        subtreeHighlightNodeIds={subtreeHighlightNodeIds}
+                        sourceNodeNames={sourceNodeNames}
+                        nodeMetricsMap={nodeMetricsMap}
                       />
                     ))}
                   </div>

Plik diff jest za duży
+ 696 - 189
knowhub/frontend/src/pages/Dashboard.tsx


Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików