Просмотр исходного кода

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

guantao 19 часов назад
Родитель
Сommit
c50a832e77

+ 0 - 10
knowhub/frontend/src/App.tsx

@@ -3,7 +3,6 @@ import { useNavigate, useLocation } from 'react-router-dom';
 import { MainLayout } from './layouts/MainLayout';
 import { MainLayout } from './layouts/MainLayout';
 import type { TabId } from './components/layout/Navbar';
 import type { TabId } from './components/layout/Navbar';
 import { Dashboard } from './pages/Dashboard';
 import { Dashboard } from './pages/Dashboard';
-import { Relations } from './pages/Relations';
 import { Requirements } from './pages/Requirements';
 import { Requirements } from './pages/Requirements';
 import { Capabilities } from './pages/Capabilities';
 import { Capabilities } from './pages/Capabilities';
 import { Tools } from './pages/Tools';
 import { Tools } from './pages/Tools';
@@ -12,7 +11,6 @@ import { Knowledge } from './pages/Knowledge';
 const PATH_TO_TAB: Record<string, TabId> = {
 const PATH_TO_TAB: Record<string, TabId> = {
   '/': 'dashboard',
   '/': 'dashboard',
   '/dashboard': 'dashboard',
   '/dashboard': 'dashboard',
-  '/relations': 'relations',
   '/requirements': 'requirements',
   '/requirements': 'requirements',
   '/capabilities': 'capabilities',
   '/capabilities': 'capabilities',
   '/tools': 'tools',
   '/tools': 'tools',
@@ -21,7 +19,6 @@ const PATH_TO_TAB: Record<string, TabId> = {
 
 
 const TAB_TO_PATH: Record<TabId, string> = {
 const TAB_TO_PATH: Record<TabId, string> = {
   dashboard: '/',
   dashboard: '/',
-  relations: '/relations',
   requirements: '/requirements',
   requirements: '/requirements',
   capabilities: '/capabilities',
   capabilities: '/capabilities',
   tools: '/tools',
   tools: '/tools',
@@ -39,19 +36,12 @@ function App() {
     navigate(TAB_TO_PATH[tab]);
     navigate(TAB_TO_PATH[tab]);
   };
   };
 
 
-  const navigateToDashboard = (nodeName: string) => {
-    setPendingDashboardNode(nodeName);
-    navigate('/');
-  };
-
   return (
   return (
     <MainLayout activeTab={activeTab} onTabChange={handleTabChange}>
     <MainLayout activeTab={activeTab} onTabChange={handleTabChange}>
       {(tab) => {
       {(tab) => {
         switch (tab) {
         switch (tab) {
           case 'dashboard':
           case 'dashboard':
             return <Dashboard pendingNode={pendingDashboardNode} onPendingConsumed={() => setPendingDashboardNode(null)} />;
             return <Dashboard pendingNode={pendingDashboardNode} onPendingConsumed={() => setPendingDashboardNode(null)} />;
-          case 'relations':
-            return <Relations onNavigateToDashboard={navigateToDashboard} />;
           case 'requirements':
           case 'requirements':
             return <Requirements />;
             return <Requirements />;
           case 'capabilities':
           case 'capabilities':

+ 175 - 138
knowhub/frontend/src/components/dashboard/CategoryTree.tsx

@@ -1,20 +1,30 @@
 import { useState } from 'react';
 import { useState } from 'react';
 import { cn } from '../../lib/utils';
 import { cn } from '../../lib/utils';
-import { ChevronRight, ChevronDown, ZoomIn, ZoomOut, Maximize } from 'lucide-react';
+import { ChevronRight, ChevronDown, ZoomIn, ZoomOut, Maximize, FolderTree } from 'lucide-react';
 
 
 interface NodeProps {
 interface NodeProps {
   node: any;
   node: any;
   onSelect: (node: any) => void;
   onSelect: (node: any) => void;
+  onDoubleClick: (node: any) => void;
   selectedId: string | number | null;
   selectedId: string | number | null;
   level: number;
   level: number;
+  highlightLeafNames: Set<string> | null; // null = no filter active
 }
 }
 
 
-function HorizontalTreeNode({ node, onSelect, selectedId, level }: NodeProps) {
-  const [expanded, setExpanded] = useState(true); // Default to fully expanded
+// Returns true if this node or any descendant is in the highlight set
+function nodeHasHighlightedLeaf(node: any, highlightLeafNames: Set<string> | null): boolean {
+  if (!highlightLeafNames) return true;
+  const hasChildren = node.children && node.children.length > 0;
+  if (!hasChildren) return highlightLeafNames.has(node.name);
+  return node.children.some((child: any) => nodeHasHighlightedLeaf(child, highlightLeafNames));
+}
+
+function HorizontalTreeNode({ node, onSelect, onDoubleClick, selectedId, level, highlightLeafNames }: NodeProps) {
+  const [expanded, setExpanded] = useState(true);
   const hasChildren = node.children && node.children.length > 0;
   const hasChildren = node.children && node.children.length > 0;
 
 
   const count = node.total_posts_count || 0;
   const count = node.total_posts_count || 0;
-  const status = node.node_status ?? 0; // 0=grey for both non-leaf and unassociated leaf nodes
+  const status = node.node_status ?? 0;
 
 
   let intensity = 0;
   let intensity = 0;
   if (count < 10) intensity = 0;
   if (count < 10) intensity = 0;
@@ -33,56 +43,60 @@ function HorizontalTreeNode({ node, onSelect, selectedId, level }: NodeProps) {
       { bg: "bg-slate-500", border: "border-slate-600", text: "text-white" },
       { bg: "bg-slate-500", border: "border-slate-600", text: "text-white" },
       { bg: "bg-slate-600", border: "border-slate-700", text: "text-white" }
       { bg: "bg-slate-600", border: "border-slate-700", text: "text-white" }
     ],
     ],
-    // High contrast ranges for other categories to make count distinguishable
     1: [
     1: [
-      { bg: "bg-blue-100", border: "border-blue-200", text: "text-blue-900" },
-      { bg: "bg-blue-300", border: "border-blue-400", text: "text-blue-900" },
-      { bg: "bg-blue-500", border: "border-blue-600", text: "text-white" },
-      { bg: "bg-blue-600", border: "border-blue-700", text: "text-white" },
-      { bg: "bg-blue-700", border: "border-blue-800", text: "text-white" },
-      { bg: "bg-blue-900", border: "border-blue-950", text: "text-white" }
+      { 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: [
     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-100", border: "border-green-200", text: "text-green-900" },
       { bg: "bg-green-300", border: "border-green-400", 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-500", border: "border-green-600", text: "text-white" },
       { bg: "bg-green-600", border: "border-green-700", 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-700", border: "border-green-800", text: "text-white" },
       { bg: "bg-green-900", border: "border-green-950", text: "text-white" }
       { bg: "bg-green-900", border: "border-green-950", text: "text-white" }
-    ],
-    3: [
-      { bg: "bg-cyan-100", border: "border-cyan-200", text: "text-cyan-900" },
-      { bg: "bg-cyan-300", border: "border-cyan-400", text: "text-cyan-900" },
-      { bg: "bg-cyan-500", border: "border-cyan-600", text: "text-white" },
-      { bg: "bg-cyan-600", border: "border-cyan-700", text: "text-white" },
-      { bg: "bg-cyan-700", border: "border-cyan-800", text: "text-white" },
-      { bg: "bg-cyan-900", border: "border-cyan-950", text: "text-white" }
     ]
     ]
   };
   };
-  
+
   const theme = palettes[status as keyof typeof palettes][intensity];
   const theme = palettes[status as keyof typeof palettes][intensity];
-  let bgColor = theme.bg;
-  let borderColor = theme.border;
   let textColor = theme.text;
   let textColor = theme.text;
+  if (hasChildren) textColor = cn(textColor, "font-extrabold");
 
 
-  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);
 
 
   return (
   return (
-    <div className="flex flex-row items-start">
+    <div className={cn("flex flex-row items-start transition-opacity duration-200", highlightLeafNames && !inHighlight && "opacity-20")}>
       {/* Node Card */}
       {/* Node Card */}
-      <div 
+      <div
         className={cn(
         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-colors z-10 h-[34px]",
-          bgColor, borderColor,
-          selectedId === node.id ? "ring-2 ring-indigo-500 ring-offset-1 border-indigo-400" : "hover:brightness-95"
+          "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"
+            : isLeafHighlighted
+            ? "ring-2 ring-orange-400 ring-offset-1"
+            : "hover:brightness-95"
         )}
         )}
         onClick={() => onSelect(node)}
         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' }); }}
       >
       >
         <span className={cn("text-xs font-bold mr-3", textColor)}>{node.name || "Root"}</span>
         <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>}
         {node.id && <span className="text-[10px] opacity-60 mr-2 font-mono">{node.id}</span>}
-        
+
         {/* Count Pill */}
         {/* Count Pill */}
         <div className="flex text-[9px] bg-white/70 rounded px-1 group shadow-sm items-center">
         <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 text-slate-500 font-medium">{node.total_element_count || 0}</span>
@@ -90,134 +104,157 @@ function HorizontalTreeNode({ node, onSelect, selectedId, level }: NodeProps) {
         </div>
         </div>
 
 
         {hasChildren && (
         {hasChildren && (
-          <button 
-            className="ml-2 px-1 text-slate-400 hover:text-slate-700 focus:outline-none transition-transform" 
+          <button
+            className="ml-2 px-1 text-slate-400 hover:text-slate-700 focus:outline-none transition-transform"
             onClick={(e) => { e.stopPropagation(); setExpanded(!expanded); }}
             onClick={(e) => { e.stopPropagation(); setExpanded(!expanded); }}
           >
           >
-            {expanded ? <ChevronDown size={14} className="opacity-70"/> : <ChevronRight size={14} className="opacity-70"/>}
+            {expanded ? <ChevronDown size={14} className="opacity-70" /> : <ChevronRight size={14} className="opacity-70" />}
           </button>
           </button>
         )}
         )}
       </div>
       </div>
 
 
-      {/* Children Container (Horizontal Layout Connections) */}
+      {/* Children */}
       {expanded && hasChildren && (
       {expanded && hasChildren && (
         <div className="flex flex-col relative ml-8">
         <div className="flex flex-col relative ml-8">
-           {/* Horizontal line from parent node to intersection (17px is half of 34px height) */}
-           <div className="absolute -left-8 top-[17px] w-8 h-px bg-slate-300"></div>
-           
-           {node.children.map((child: any, i: number) => (
-             <div 
-               key={child.id || child.path || i}
-               className={cn(
-                 "relative pl-8 pb-3 flex items-start",
-                 // Horizontal line to this child box from vertical intersection
-                 "before:absolute before:left-0 before:top-[17px] before:w-8 before:h-px before:bg-slate-300",
-                 // Vertical connection line
-                 "after:absolute after:left-0 after:w-px after:bg-slate-300",
-                 // Adjust vertical line start/end based on position to avoid overhanging lines
-                 "first:after:top-[17px] first:after:bottom-0",
-                 "last:after:top-0 last:after:bottom-[calc(100%-17px)]",
-                 "[&:not(:first-child):not(:last-child)]:after:top-0 [&:not(:first-child):not(:last-child)]:after:bottom-0",
-                 // If it's the ONLY child, hide vertical line completely
-                 "first:last:after:hidden"
-               )}
-             >
-                <HorizontalTreeNode node={child} onSelect={onSelect} selectedId={selectedId} level={level + 1} />
-             </div>
-           ))}
+          <div className="absolute -left-8 top-[17px] w-8 h-px bg-slate-300"></div>
+          {node.children.map((child: any, i: number) => (
+            <div
+              key={child.id || child.path || i}
+              className={cn(
+                "relative pl-8 pb-3 flex items-start",
+                "before:absolute before:left-0 before:top-[17px] before:w-8 before:h-px before:bg-slate-300",
+                "after:absolute after:left-0 after:w-px after:bg-slate-300",
+                "first:after:top-[17px] first:after:bottom-0",
+                "last:after:top-0 last:after:bottom-[calc(100%-17px)]",
+                "[&:not(:first-child):not(:last-child)]:after:top-0 [&:not(:first-child):not(:last-child)]:after:bottom-0",
+                "first:last:after:hidden"
+              )}
+            >
+              <HorizontalTreeNode
+                node={child}
+                onSelect={onSelect}
+                onDoubleClick={onDoubleClick}
+                selectedId={selectedId}
+                level={level + 1}
+                highlightLeafNames={highlightLeafNames}
+              />
+            </div>
+          ))}
         </div>
         </div>
       )}
       )}
     </div>
     </div>
   );
   );
 }
 }
 
 
-export function CategoryTree({ data, onSelect, selectedId }: { data: any, onSelect: (node: any) => void, selectedId: any }) {
+export function CategoryTree({
+  data,
+  onSelect,
+  onDoubleClick,
+  selectedId,
+  highlightLeafNames = null,
+  totalNodeCount,
+  wideMode = false,
+  onToggleWideMode,
+}: {
+  data: any;
+  onSelect: (node: any) => void;
+  onDoubleClick?: (node: any) => void;
+  selectedId: any;
+  highlightLeafNames?: Set<string> | null;
+  totalNodeCount?: number;
+  wideMode?: boolean;
+  onToggleWideMode?: () => void;
+}) {
   const [scale, setScale] = useState(1);
   const [scale, setScale] = useState(1);
+
   if (!data || !data.children) return (
   if (!data || !data.children) return (
-    <div className="bg-white rounded-3xl border border-slate-100 shadow-sm p-6 flex flex-col items-center justify-center min-h-[400px] text-slate-400">
+    <div className="bg-slate-100/50 rounded-2xl border border-slate-200 flex flex-col items-center justify-center min-h-[400px] text-slate-400">
       <div className="w-8 h-8 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin mb-4"></div>
       <div className="w-8 h-8 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin mb-4"></div>
-      加载树形结构中... (请确保已重跑 python server.py)
+      加载树形结构中...
     </div>
     </div>
   );
   );
 
 
   return (
   return (
-    <div className="bg-white rounded-3xl border border-slate-100 shadow-sm p-0 overflow-hidden flex flex-col h-[calc(100vh-160px)] min-h-[500px] relative">
-       <div className="absolute top-4 right-4 z-50 flex gap-2 bg-white/90 backdrop-blur p-1.5 rounded-lg shadow-sm border border-slate-200">
-         <button onClick={() => setScale(s => Math.min(s + 0.15, 3))} className="p-1.5 hover:bg-slate-100 rounded text-slate-600 transition-colors" title="放大">
-           <ZoomIn size={18} />
-         </button>
-         <button onClick={() => setScale(s => Math.max(s - 0.15, 0.3))} className="p-1.5 hover:bg-slate-100 rounded text-slate-600 transition-colors" title="缩小">
-           <ZoomOut size={18} />
-         </button>
-         <button onClick={() => setScale(1)} className="p-1.5 hover:bg-slate-100 rounded text-slate-600 transition-colors" title="重置比例">
-           <Maximize size={18} />
-         </button>
-       </div>
-       
-       <div className="flex-1 w-full h-full overflow-x-auto overflow-y-auto bg-slate-50/30 p-8 custom-scrollbar">
-         <div 
-            className="flex flex-col gap-8 select-none min-w-max pb-8 origin-top-left transition-all duration-200"
-            style={{ zoom: scale } as any}
-         >
-            {(() => {
-               // The exact order the user requested:
-               const orderKeyWords = ["形式", "实质", "意图"];
-               
-               // Group the children nodes by their source_type
-               const groups: Record<string, any[]> = {
-                 "形式": [],
-                 "实质": [],
-                 "意图": []
-               };
-
-               data.children.forEach((node: any) => {
-                 const type = node.source_type;
-                 if (type && groups[type]) {
-                   groups[type].push(node);
-                 } else if (type === "形式" || type === "实质" || type === "意图") {
-                   groups[type] = [node];
-                 }
-               });
-
-               return orderKeyWords.map((dimensionName: string) => {
-                 const nodesInDimension = groups[dimensionName] || [];
-                 if (nodesInDimension.length === 0) return null;
-
-                 // Assign fixed colors complementing their semantic dimension
-                 let color = { bg: 'bg-slate-50', border: 'border-slate-500', text: 'text-slate-800' };
-                 if (dimensionName === "形式") {
-                   color = { bg: 'bg-[#E3F2FD]', border: 'border-[#2196F3]', text: 'text-slate-800' };
-                 } else if (dimensionName === "实质") {
-                   color = { bg: 'bg-[#FFF3E0]', border: 'border-[#FF9800]', text: 'text-slate-800' };
-                 } else if (dimensionName === "意图") {
-                   color = { bg: 'bg-[#F1F8E9]', border: 'border-[#8BC34A]', text: 'text-slate-800' };
-                 }
-
-                 return (
-                   <div key={dimensionName} className="flex flex-col">
-                     {/* Category Header */}
-                     <div className={cn("px-4 py-3 border-l-[6px] text-sm font-bold w-full mb-4", color.bg, color.border, color.text)}>
-                       {dimensionName} 维度
-                     </div>
-                     
-                     {/* Children Nodes Stacked Vertically, without parent link */}
-                     <div className="flex flex-col gap-6 pl-4">
-                       {nodesInDimension.map((subNode: any, subIdx: number) => (
-                         <HorizontalTreeNode 
-                           key={subNode.id || subIdx} 
-                           node={subNode} 
-                           onSelect={onSelect} 
-                           selectedId={selectedId} 
-                           level={1} 
-                         />
-                       ))}
-                     </div>
-                   </div>
-                 )
-               });
-            })()}
-         </div>
-       </div>
+    <div className="bg-slate-100/50 rounded-2xl border border-slate-200 overflow-hidden flex flex-col h-full relative">
+      {/* 标题栏 */}
+      <div className="px-4 py-3 font-bold text-slate-700 border-b bg-slate-200/50 flex justify-between shrink-0 items-center">
+        <div className="flex items-center gap-2">
+          <FolderTree size={14} className="text-slate-500" />
+          内容树
+        </div>
+        <div className="flex items-center gap-2">
+          <button
+            onClick={onToggleWideMode}
+            className={cn(
+              "text-[10px] font-bold px-2 py-0.5 rounded transition-colors",
+              wideMode ? "bg-indigo-100 text-indigo-600" : "bg-slate-200 text-slate-500 hover:bg-slate-300"
+            )}
+          >
+            {wideMode ? '窄模式' : '宽模式'}
+          </button>
+          <span className="text-slate-400">{totalNodeCount ?? 0}</span>
+        </div>
+      </div>
+
+      {/* 缩放控件 */}
+      <div className="absolute top-[52px] right-3 z-50 flex gap-1 bg-white/90 backdrop-blur p-1 rounded-lg shadow-sm border border-slate-200">
+        <button onClick={() => setScale(s => Math.min(s + 0.15, 3))} className="p-1 hover:bg-slate-100 rounded text-slate-600 transition-colors" title="放大">
+          <ZoomIn size={14} />
+        </button>
+        <button onClick={() => setScale(s => Math.max(s - 0.15, 0.3))} className="p-1 hover:bg-slate-100 rounded text-slate-600 transition-colors" title="缩小">
+          <ZoomOut size={14} />
+        </button>
+        <button onClick={() => setScale(1)} className="p-1 hover:bg-slate-100 rounded text-slate-600 transition-colors" title="重置">
+          <Maximize size={14} />
+        </button>
+      </div>
+
+      <div className="flex-1 w-full h-full overflow-x-auto overflow-y-auto bg-slate-50/30 p-4 custom-scrollbar">
+        <div
+          className="flex flex-col gap-8 select-none min-w-max pb-8 origin-top-left transition-all duration-200"
+          style={{ zoom: scale } as any}
+        >
+          {(() => {
+            const orderKeyWords = ["形式", "实质", "意图"];
+            const groups: Record<string, any[]> = { "形式": [], "实质": [], "意图": [] };
+            data.children.forEach((node: any) => {
+              const type = node.source_type;
+              if (type && groups[type]) groups[type].push(node);
+            });
+
+            return orderKeyWords.map((dimensionName: string) => {
+              const nodesInDimension = groups[dimensionName] || [];
+              if (nodesInDimension.length === 0) return null;
+
+              let color = { bg: 'bg-slate-50', border: 'border-slate-500', text: 'text-slate-800' };
+              if (dimensionName === "形式") color = { bg: 'bg-[#E3F2FD]', border: 'border-[#2196F3]', text: 'text-slate-800' };
+              else if (dimensionName === "实质") color = { bg: 'bg-[#FFF3E0]', border: 'border-[#FF9800]', text: 'text-slate-800' };
+              else if (dimensionName === "意图") color = { bg: 'bg-[#F1F8E9]', border: 'border-[#8BC34A]', text: 'text-slate-800' };
+
+              return (
+                <div key={dimensionName} className="flex flex-col">
+                  <div className={cn("px-4 py-3 border-l-[6px] text-sm font-bold w-full mb-4", color.bg, color.border, color.text)}>
+                    {dimensionName} 维度
+                  </div>
+                  <div className="flex flex-col gap-6 pl-4">
+                    {nodesInDimension.map((subNode: any, subIdx: number) => (
+                      <HorizontalTreeNode
+                        key={subNode.id || subIdx}
+                        node={subNode}
+                        onSelect={onSelect}
+                        onDoubleClick={onDoubleClick ?? (() => {})}
+                        selectedId={selectedId}
+                        level={1}
+                        highlightLeafNames={highlightLeafNames}
+                      />
+                    ))}
+                  </div>
+                </div>
+              );
+            });
+          })()}
+        </div>
+      </div>
     </div>
     </div>
   );
   );
 }
 }

+ 2 - 3
knowhub/frontend/src/components/layout/Navbar.tsx

@@ -1,7 +1,7 @@
-import { Search, Settings, Home, Target, Cpu, Wrench, FileText, Waypoints } from 'lucide-react';
+import { Search, Settings, Home, Target, Cpu, Wrench, FileText } from 'lucide-react';
 import { cn } from '../../lib/utils';
 import { cn } from '../../lib/utils';
 
 
-export type TabId = 'dashboard' | 'relations' | 'requirements' | 'capabilities' | 'tools' | 'knowledge';
+export type TabId = 'dashboard' | 'requirements' | 'capabilities' | 'tools' | 'knowledge';
 
 
 interface NavbarProps {
 interface NavbarProps {
   activeTab: TabId;
   activeTab: TabId;
@@ -10,7 +10,6 @@ interface NavbarProps {
 
 
 const TABS = [
 const TABS = [
   { id: 'dashboard', label: 'Dashboard', icon: Home },
   { id: 'dashboard', label: 'Dashboard', icon: Home },
-  { id: 'relations', label: '关系表', icon: Waypoints },
   { id: 'requirements', label: '需求库', icon: Target },
   { id: 'requirements', label: '需求库', icon: Target },
   { id: 'capabilities', label: '能力库', icon: Cpu },
   { id: 'capabilities', label: '能力库', icon: Cpu },
   { id: 'tools', label: '工具库', icon: Wrench },
   { id: 'tools', label: '工具库', icon: Wrench },

+ 3 - 3
knowhub/frontend/src/layouts/MainLayout.tsx

@@ -8,8 +8,8 @@ interface MainLayoutProps {
   children: (activeTab: TabId) => React.ReactNode;
   children: (activeTab: TabId) => React.ReactNode;
 }
 }
 
 
-// 这里的顺序决定了滑动的顺序,必须是 6 个!
-const TAB_ORDER: TabId[] = ['dashboard', 'relations', 'requirements', 'capabilities', 'tools', 'knowledge'];
+// 这里的顺序决定了滑动的顺序,必须是 5 个!
+const TAB_ORDER: TabId[] = ['dashboard', 'requirements', 'capabilities', 'tools', 'knowledge'];
 const MIN_SWITCH_INTERVAL = 1000;
 const MIN_SWITCH_INTERVAL = 1000;
 
 
 export function MainLayout({ activeTab, onTabChange, children }: MainLayoutProps) {
 export function MainLayout({ activeTab, onTabChange, children }: MainLayoutProps) {
@@ -98,7 +98,7 @@ export function MainLayout({ activeTab, onTabChange, children }: MainLayoutProps
           {TAB_ORDER.map((tab) => (
           {TAB_ORDER.map((tab) => (
             <div key={tab} className="shrink-0 h-full overflow-y-auto" style={{ width: `${100 / totalTabs}%` }}>
             <div key={tab} className="shrink-0 h-full overflow-y-auto" style={{ width: `${100 / totalTabs}%` }}>
               <div className="flex justify-center pb-12">
               <div className="flex justify-center pb-12">
-                <div className="w-full max-w-[1600px] px-6 py-6">
+                <div className="w-full px-6 py-6">
                   {children(tab)}
                   {children(tab)}
                 </div>
                 </div>
               </div>
               </div>

+ 1101 - 479
knowhub/frontend/src/pages/Dashboard.tsx

@@ -1,83 +1,656 @@
-import { useState, useEffect } from 'react';
-import { FolderTree, Target, Wrench, ChevronRight, Brain, FileText } from 'lucide-react';
+import { useState, useEffect, useMemo, useRef, type ReactNode, type WheelEvent } from 'react';
+import { createPortal } from 'react-dom';
+import { Target, Wrench, FileText, ListTree, Cpu, X, ChevronLeft, ChevronRight } from 'lucide-react';
 import { CategoryTree } from '../components/dashboard/CategoryTree';
 import { CategoryTree } from '../components/dashboard/CategoryTree';
+import { SideDrawer } from '../components/common/SideDrawer';
 import { cn } from '../lib/utils';
 import { cn } from '../lib/utils';
-import { getRequirements, getCapabilities, getTools, getKnowledge } from '../services/api';
+import { getRequirements, getCapabilities, getTools, getKnowledge, getResource, batchGetPosts } from '../services/api';
+
+// ─── 覆盖率统计 ────────────────────────────────────────────────────────────────
+
+function CoverageStats({ stats, weightTab, setWeightTab }: any) {
+  const data = weightTab === 'unweighted' ? [
+    { label: '全局节点', value: stats.totalLeaves, percent: '100%', color: 'bg-slate-400' },
+    { label: '需求覆盖节点', value: stats.reqCoveredNodes, percent: stats.reqCoveragePerc + '%', color: 'bg-indigo-400' },
+    { label: '工序覆盖节点', value: 0, percent: '0%', color: 'bg-purple-400' },
+    { label: '原子能力覆盖节点', value: 0, percent: '0%', color: 'bg-teal-400' },
+    { label: '工具覆盖节点', value: stats.toolCoveredNodes, percent: (stats.totalLeaves ? (stats.toolCoveredNodes / stats.totalLeaves * 100).toFixed(1) : 0) + '%', color: 'bg-green-500' },
+  ] : [
+    { label: '全局节点', value: stats.totalPostsCnt, percent: '100%', color: 'bg-slate-400' },
+    { label: '需求覆盖节点', value: stats.coveredPostsCnt, percent: stats.weightedCoveragePerc + '%', color: 'bg-indigo-400' },
+    { label: '工序覆盖节点', value: 0, percent: '0%', color: 'bg-purple-400' },
+    { label: '原子能力覆盖节点', value: 0, percent: '0%', color: 'bg-teal-400' },
+    { label: '工具覆盖节点', value: stats.toolCoveredPostsCnt, percent: (stats.totalPostsCnt ? (stats.toolCoveredPostsCnt / stats.totalPostsCnt * 100).toFixed(1) : 0) + '%', color: 'bg-green-500' },
+  ];
 
 
-function RelationGroup({ title, count, colorClass, borderClass, children, defaultOpen = true }: any) {
-  const [open, setOpen] = useState(defaultOpen);
   return (
   return (
-    <div className="mt-8 mb-4">
-      <div 
-        className={cn("flex items-center gap-3 font-black text-[13px] tracking-wide mb-4 cursor-pointer select-none", colorClass)}
-        onClick={() => setOpen(!open)}
-      >
-        <div className={cn("w-6 h-[2px]", borderClass)}></div>
-        {title} ({count})
+    <div className="bg-white rounded-3xl border border-slate-100 shadow-sm p-6 overflow-hidden shrink-0">
+      <div className="flex justify-between items-center mb-6">
+        <div className="flex bg-slate-100 p-1 rounded-lg">
+          <button
+            className={cn("px-4 py-1.5 text-sm font-bold rounded-md transition-all", weightTab === 'unweighted' ? "bg-white text-indigo-700 shadow-sm" : "text-slate-500 hover:text-slate-700")}
+            onClick={() => setWeightTab('unweighted')}
+          >无权重 (节点数)</button>
+          <button
+            className={cn("px-4 py-1.5 text-sm font-bold rounded-md transition-all", weightTab === 'weighted' ? "bg-white text-indigo-700 shadow-sm" : "text-slate-500 hover:text-slate-700")}
+            onClick={() => setWeightTab('weighted')}
+          >带权重 (帖子数)</button>
+        </div>
+      </div>
+      <div className="flex justify-center items-center h-36">
+        <div className="flex w-full max-w-4xl h-full relative" style={{ filter: "drop-shadow(0 4px 6px rgba(0,0,0,0.05))" }}>
+          {data.map((item, idx) => {
+            const prevWidth = idx === 0 ? 100 : (data[idx - 1].value / Math.max(data[0].value, 1) * 100);
+            const currWidth = (item.value / Math.max(data[0].value, 1) * 100);
+            const leftHeight = prevWidth;
+            const rightHeight = currWidth;
+            const clipPath = `polygon(0 ${50 - leftHeight / 2}%, 100% ${50 - rightHeight / 2}%, 100% ${50 + rightHeight / 2}%, 0 ${50 + leftHeight / 2}%)`;
+            return (
+              <div key={idx} className="flex-1 flex flex-col items-center justify-center relative border-r-2 border-white last:border-r-0">
+                <div className={cn("absolute inset-0 transition-all duration-700 opacity-90", item.color)} style={{ clipPath }}></div>
+                <div className="z-10 flex flex-col items-center text-slate-900">
+                  <span className="text-2xl font-black tracking-tight">{item.value}</span>
+                  <span className="text-xs font-bold mt-0.5 opacity-90">{item.label}</span>
+                </div>
+                {idx > 0 && (
+                  <div className="absolute top-0 left-0 -translate-x-1/2 -mt-4 text-[11px] font-bold text-slate-500 bg-white px-2 py-0.5 rounded shadow-sm border border-slate-100 z-20">
+                    转化率 {item.percent}
+                  </div>
+                )}
+              </div>
+            );
+          })}
+        </div>
       </div>
       </div>
-      {open && <div className="pl-1 mb-8">{children}</div>}
     </div>
     </div>
   );
   );
 }
 }
 
 
-function CompactListCard({ data, type, onDrillDown }: { data: any, type: 'req' | 'cap' | 'tool' | 'know', onDrillDown: (t: any, d: any) => void }) {
-  let Icon: any = Target;
-  let iconColor = "text-indigo-500";
-  let title = "";
-  let status = "";
-
-  if (type === 'req') {
-    Icon = Target; iconColor = "text-indigo-500";
-    title = data.description || data.id;
-    status = data.status || '未满足';
-  } else if (type === 'cap') {
-    Icon = Brain; iconColor = "text-amber-500";
-    title = data.name || data.id;
-  } else if (type === 'tool') {
-    Icon = Wrench; iconColor = "text-emerald-500";
-    title = data.name || data.id;
-    status = data.status;
-  } else if (type === 'know') {
-    Icon = FileText; iconColor = "text-violet-500";
-    title = data.task || data.content?.substring(0, 40) || data.id;
-  }
+// ─── 关系列卡片 ────────────────────────────────────────────────────────────────
+
+function RelationCard({ type, item, activeId, isLeafActive, relatedIds, selectedLeafNames, onSingleClick, onDoubleClick }: {
+  type: string;
+  item: any;
+  activeId: string | null;
+  isLeafActive?: boolean;
+  relatedIds: Set<string>;
+  selectedLeafNames?: Set<string>;
+  onSingleClick: (nodeId: string) => void;
+  onDoubleClick: (type: string, data: any) => void;
+}) {
+  const nodeId = `${type}:${item.id}`;
+  const isSelected = activeId === nodeId;
+  const hasActive = !!activeId || !!isLeafActive;
+  const isRelated = !hasActive || relatedIds.has(nodeId);
+  const dimmed = hasActive && !isRelated;
+
+  const lastClickRef = useRef<{ time: number } | null>(null);
+
+  const handleClick = () => {
+    const now = Date.now();
+    if (lastClickRef.current && now - lastClickRef.current.time < 300) {
+      lastClickRef.current = null;
+      onDoubleClick(type, item);
+    } else {
+      lastClickRef.current = { time: now };
+      onSingleClick(nodeId);
+    }
+  };
+
+  const sourceNodeTags: string[] = type === 'req'
+    ? (item.source_nodes || []).slice(0, 3).map((sn: any) =>
+        typeof sn === 'object' ? (sn.node_name || sn.name || '') : sn
+      ).filter((name: string) => Boolean(name) && name !== '__abstract__')
+    : [];
+  const totalSourceNodes = type === 'req'
+    ? (item.source_nodes || []).filter((sn: any) => {
+        const name = typeof sn === 'object' ? (sn.node_name || sn.name || '') : sn;
+        return Boolean(name) && name !== '__abstract__';
+      }).length
+    : 0;
+  const extraCount = type === 'req' ? Math.max(0, totalSourceNodes - 3) : 0;
+
+  const label = item.name || item.description || item.task || item.id;
+
+  // 语义颜色:每种类型对应一套颜色
+  const typeColors: Record<string, { accent: string; tagBg: string; tagText: string; leftBar: string }> = {
+    req:  { accent: 'border-l-indigo-400', tagBg: 'bg-indigo-50', tagText: 'text-indigo-700', leftBar: 'bg-indigo-400' },
+    cap:  { accent: 'border-l-teal-400',   tagBg: 'bg-teal-50',   tagText: 'text-teal-700',   leftBar: 'bg-teal-400' },
+    tool: { accent: 'border-l-green-400',  tagBg: 'bg-green-50',  tagText: 'text-green-700',  leftBar: 'bg-green-400' },
+    know: { accent: 'border-l-purple-400', tagBg: 'bg-purple-50', tagText: 'text-purple-700', leftBar: 'bg-purple-400' },
+  };
+  const tc = typeColors[type] ?? typeColors.req;
 
 
   return (
   return (
-    <div className="bg-white border border-slate-200 rounded-xl overflow-hidden shadow-sm hover:border-indigo-300 hover:shadow-md transition-all mb-2 w-full text-left">
-      <div 
-        className="flex justify-between items-center p-3 cursor-pointer hover:bg-slate-50 transition-colors group"
-        onClick={() => onDrillDown(type, data)}
-      >
-        <div className="flex items-center gap-2 font-bold text-sm text-slate-800 flex-1 pr-2">
-          <Icon size={14} className={iconColor} />
-          <span className="truncate">{title}</span>
+    <div
+      onClick={handleClick}
+      className={cn(
+        "p-3 rounded-xl cursor-pointer mb-2 select-none bg-white transition-all border-l-4",
+        tc.accent,
+        isSelected
+          ? "border border-orange-400 border-l-4 shadow-[0_0_0_1px_rgba(251,146,60,0.7)]"
+          : isRelated && hasActive
+          ? "border border-orange-300 border-l-4"
+          : dimmed
+          ? "border border-transparent border-l-4 opacity-20 grayscale scale-95"
+          : "border border-transparent border-l-4"
+      )}
+    >
+      <div className="flex items-start gap-2">
+        <div className="min-w-0 flex-1">
+          <div className="flex items-center gap-2">
+            <div className={cn("text-xs font-bold leading-snug", isSelected ? "text-orange-800" : "text-slate-700")}>
+              {label}
+            </div>
+            </div>
+          {sourceNodeTags.length > 0 && (
+            <div className="flex flex-wrap gap-1 mt-1.5">
+              {sourceNodeTags.map((name: string) => {
+                const isHighlighted = selectedLeafNames && selectedLeafNames.has(name);
+                return (
+                  <span key={name} className={cn(
+                    "text-[9px] px-1.5 py-0.5 rounded-md font-medium truncate max-w-[100px]",
+                    isHighlighted ? "bg-orange-100 text-orange-700 ring-1 ring-orange-400" : cn(tc.tagBg, tc.tagText)
+                  )}>
+                    {name}
+                  </span>
+                );
+              })}
+              {extraCount > 0 && (
+                <span className="text-[9px] px-1.5 py-0.5 rounded-md bg-slate-100 text-slate-400">+{extraCount}</span>
+              )}
+            </div>
+          )}
+        </div>
+      </div>
+    </div>
+  );
+}
+
+// ─── 详情抽屉内容 ──────────────────────────────────────────────────────────────
+
+function DrawerContent({ type, data, dbData }: { type: string; data: any; dbData: any }) {
+  const relReqs = type === 'cap' ? dbData.reqs.filter((r: any) => (data.requirement_ids || []).includes(r.id))
+    : type === 'tool' ? [] : [];
+  const relCaps = type === 'req' ? dbData.caps.filter((c: any) => (c.requirement_ids || []).includes(data.id))
+    : type === 'tool' ? dbData.caps.filter((c: any) => (data.capability_ids || []).includes(c.id))
+    : type === 'know' ? dbData.caps.filter((c: any) => (data.capability_ids || []).includes(c.id)) : [];
+  const relTools = type === 'cap' ? dbData.tools.filter((t: any) => (t.capability_ids || []).includes(data.id))
+    : type === 'know' ? dbData.tools.filter((t: any) => (data.tool_ids || []).includes(t.id)) : [];
+  const relKnow = type === 'cap' ? dbData.know.filter((k: any) => (k.capability_ids || []).includes(data.id))
+    : type === 'tool' ? dbData.know.filter((k: any) => (k.tool_ids || []).includes(data.id)) : [];
+
+  const Section = ({ title, color, items, renderItem }: any) =>
+    items.length > 0 ? (
+      <div className="mt-6">
+        <div className={cn("text-xs font-black tracking-wide mb-3", color)}>{title} ({items.length})</div>
+        <div className="space-y-2">{items.map(renderItem)}</div>
+      </div>
+    ) : null;
+
+  const MiniCard = ({ icon: Icon, label, iconColor }: any) => (
+    <div className="flex items-center gap-2 bg-white rounded-lg p-2.5 border border-slate-100 text-xs font-bold text-slate-700">
+      <Icon size={12} className={iconColor} />
+      <span className="truncate">{label}</span>
+    </div>
+  );
+
+  return (
+    <div className="space-y-4">
+      {/* 主内容 */}
+      {type === 'req' && (
+        <>
+          <div className="bg-indigo-50 p-4 rounded-xl border border-indigo-100">
+            <div className="text-xs font-bold text-indigo-500 mb-2">需求描述</div>
+            <p className="text-indigo-800 text-sm leading-relaxed whitespace-pre-wrap">{data.description}</p>
+          </div>
+          <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>
+        </>
+      )}
+      {type === 'cap' && (
+        <>
+          <div className="bg-amber-50 p-4 rounded-xl border border-amber-100">
+            <div className="text-xs font-bold text-amber-600 mb-2">能力定义</div>
+            <p className="text-amber-800 text-sm leading-relaxed">{data.description || '暂无描述'}</p>
+          </div>
+          <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>
+        </>
+      )}
+      {type === 'tool' && (
+        <>
+          <div className="bg-emerald-50 p-4 rounded-xl border border-emerald-100">
+            <div className="text-xs font-bold text-emerald-600 mb-2">工具介绍</div>
+            <p className="text-emerald-800 text-sm leading-relaxed">{data.introduction || '暂无介绍'}</p>
+          </div>
+          {data.status && (
+            <div className="bg-slate-50 p-3 rounded-xl border border-slate-100 flex items-center justify-between">
+              <span className="text-[10px] text-slate-400">接入状态</span>
+              <span className={cn("text-xs font-bold px-2 py-1 rounded-full",
+                (data.status === '已接入' || data.status === '正常') ? 'bg-emerald-100 text-emerald-700' : 'bg-slate-200 text-slate-500'
+              )}>{data.status}</span>
+            </div>
+          )}
+          <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>
+        </>
+      )}
+      {type === 'know' && (
+        <>
+          <div className="bg-violet-50 p-4 rounded-xl border border-violet-100">
+            <div className="text-xs font-bold text-violet-600 mb-2">知识正文</div>
+            <p className="text-violet-800 text-sm leading-relaxed whitespace-pre-wrap">{data.content}</p>
+          </div>
+          <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>
+        </>
+      )}
+
+      {/* 关联数据 */}
+      <Section title="关联需求" color="text-indigo-600" items={relReqs}
+        renderItem={(r: any) => <MiniCard key={r.id} icon={Target} label={r.description || r.id} iconColor="text-indigo-400" />} />
+      <Section title="关联能力" color="text-amber-600" items={relCaps}
+        renderItem={(c: any) => <MiniCard key={c.id} icon={Cpu} label={c.name || c.id} iconColor="text-amber-400" />} />
+      <Section title="关联工具" color="text-emerald-600" items={relTools}
+        renderItem={(t: any) => <MiniCard key={t.id} icon={Wrench} label={t.name || t.id} iconColor="text-emerald-400" />} />
+      <Section title="关联知识" color="text-violet-600" items={relKnow}
+        renderItem={(k: any) => <MiniCard key={k.id} icon={FileText} label={k.task || k.id} iconColor="text-violet-400" />} />
+    </div>
+  );
+}
+
+function PostDetailModal({ post, postId, onClose }: { post: any; postId: string; onClose: () => void }) {
+  useEffect(() => {
+    const onKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape') onClose();
+    };
+    window.addEventListener('keydown', onKeyDown);
+    return () => window.removeEventListener('keydown', onKeyDown);
+  }, [onClose]);
+
+  const images: string[] = post?.images || [];
+
+  return createPortal(
+    <div className="fixed inset-0 z-[260] flex items-center justify-center p-6">
+      <button className="absolute inset-0 bg-slate-900/45 backdrop-blur-[1px]" onClick={onClose} aria-label="关闭帖子详情" />
+      <div className="relative z-[261] w-full max-w-4xl max-h-[85vh] overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-2xl flex flex-col">
+        <div className="flex items-start justify-between gap-4 px-6 py-4 border-b border-slate-100 shrink-0">
+          <div className="min-w-0">
+            <div className="text-xs font-bold text-slate-400 mb-1">帖子详情</div>
+            <div className="text-base font-bold text-slate-800 leading-snug">{post?.title || '无标题'}</div>
+            <div className="text-[11px] text-slate-400 mt-1 break-all">{postId}</div>
+          </div>
+          <button onClick={onClose} className="p-2 rounded-lg text-slate-400 hover:bg-slate-100 hover:text-slate-600 transition-colors">
+            <X size={16} />
+          </button>
         </div>
         </div>
-        <div className="flex items-center gap-2 shrink-0">
-           {status && (
-             <span className={cn("text-[10px] px-2 py-0.5 rounded-full font-bold", 
-               (status==='已满足'||status==='已接入'||status==='正常') ? 'bg-emerald-50 text-emerald-700' : 'bg-slate-100 text-slate-500')}>
-               {status}
-             </span>
-           )}
-           <ChevronRight size={14} className="text-slate-300 group-hover:text-indigo-500 transition-colors"/>
+        <div className="overflow-y-auto px-6 py-5 space-y-5">
+          {(post?.platform || post?.platform_account_name || post?.publish_date) && (
+            <div className="flex flex-wrap gap-2">
+              {post?.platform && <span className="text-xs px-2 py-1 rounded-full bg-slate-100 text-slate-600 font-medium">{post.platform}</span>}
+              {post?.platform_account_name && <span className="text-xs px-2 py-1 rounded-full bg-indigo-50 text-indigo-700 font-medium">{post.platform_account_name}</span>}
+              {post?.publish_date && <span className="text-xs px-2 py-1 rounded-full bg-emerald-50 text-emerald-700 font-medium">{post.publish_date}</span>}
+            </div>
+          )}
+          {images.length > 0 && (
+            <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
+              {images.map((url: string, i: number) => (
+                <div key={i} className="rounded-2xl overflow-hidden bg-slate-100 border border-slate-100">
+                  <img src={url} alt="" className="w-full h-full object-cover" loading="lazy" />
+                </div>
+              ))}
+            </div>
+          )}
+          <div className="space-y-4">
+            {post?.body_text && (
+              <div className="bg-slate-50 rounded-2xl border border-slate-100 p-4">
+                <div className="text-xs font-bold text-slate-500 mb-2">正文</div>
+                <p className="text-sm text-slate-700 whitespace-pre-wrap leading-relaxed">{post.body_text}</p>
+              </div>
+            )}
+            {post?.decode_result && (
+              <div className="bg-indigo-50 rounded-2xl border border-indigo-100 p-4 space-y-3">
+                <div className="text-xs font-bold text-indigo-600">解析信息</div>
+                {Object.entries(post.decode_result).map(([key, value]) => (
+                  value ? (
+                    <div key={key}>
+                      <div className="text-[11px] font-bold text-indigo-400 uppercase tracking-wide">{key}</div>
+                      <div className="text-sm text-indigo-900 whitespace-pre-wrap leading-relaxed">{String(value)}</div>
+                    </div>
+                  ) : null
+                ))}
+              </div>
+            )}
+          </div>
         </div>
         </div>
       </div>
       </div>
+    </div>,
+    document.body
+  );
+}
+
+function PostCard({ postId, post, loading, onClick }: { postId: string; post?: any; loading?: boolean; onClick?: () => void }) {
+  const images: string[] = post?.images || [];
+
+  return (
+    <button
+      type="button"
+      onClick={post ? onClick : undefined}
+      className={cn(
+        "w-[220px] shrink-0 bg-white rounded-xl border border-slate-100 overflow-hidden shadow-sm flex flex-col text-left",
+        post ? "cursor-pointer hover:border-indigo-200 hover:shadow-md transition-all" : "cursor-default"
+      )}
+    >
+      {post ? (
+        <>
+          {images.length > 0 && (
+            <div className="grid grid-cols-3 gap-0.5 shrink-0">
+              {images.slice(0, 3).map((url: string, i: number) => (
+                <div key={i} className="relative aspect-square overflow-hidden bg-slate-100">
+                  <img
+                    src={url}
+                    alt=""
+                    className="w-full h-full object-cover"
+                    loading="lazy"
+                    onError={(e) => { (e.target as HTMLImageElement).parentElement!.style.display = 'none'; }}
+                  />
+                </div>
+              ))}
+            </div>
+          )}
+          <div className="px-2.5 pt-2 pb-1 shrink-0">
+            <div className="text-[11px] font-bold text-slate-800 leading-snug line-clamp-2">{post.title || '无标题'}</div>
+          </div>
+          {post.body_text && (
+            <div className="px-2.5 pb-2 flex-1 overflow-hidden">
+              <p className="text-[10px] text-slate-400 leading-relaxed line-clamp-4 whitespace-pre-wrap">{post.body_text}</p>
+            </div>
+          )}
+        </>
+      ) : !loading ? (
+        <div className="p-3 font-mono text-[10px] text-slate-300 break-all">{postId}</div>
+      ) : (
+        <div className="h-full min-h-[160px] bg-slate-50 animate-pulse"></div>
+      )}
+    </button>
+  );
+}
+
+function HorizontalPostScroller({ children, className = '' }: { children: ReactNode; className?: string }) {
+  const scrollRef = useRef<HTMLDivElement | null>(null);
+  const [canScrollLeft, setCanScrollLeft] = useState(false);
+  const [canScrollRight, setCanScrollRight] = useState(false);
+
+  const updateScrollState = () => {
+    const el = scrollRef.current;
+    if (!el) return;
+    const maxScrollLeft = el.scrollWidth - el.clientWidth;
+    setCanScrollLeft(el.scrollLeft > 4);
+    setCanScrollRight(el.scrollLeft < maxScrollLeft - 4);
+  };
+
+  useEffect(() => {
+    updateScrollState();
+    const el = scrollRef.current;
+    if (!el) return;
+    const onResize = () => updateScrollState();
+    window.addEventListener('resize', onResize);
+    return () => window.removeEventListener('resize', onResize);
+  }, [children]);
+
+  const handleWheel = (e: WheelEvent<HTMLDivElement>) => {
+    const el = scrollRef.current;
+    if (!el) return;
+
+    const delta = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY;
+    if (delta === 0) return;
+
+    const canScroll = el.scrollWidth > el.clientWidth;
+    if (!canScroll) return;
+
+    const maxScrollLeft = el.scrollWidth - el.clientWidth;
+    const nextLeft = el.scrollLeft + delta;
+    const willScrollWithinBounds = nextLeft > 0 && nextLeft < maxScrollLeft;
+
+    if (willScrollWithinBounds || (delta < 0 && el.scrollLeft > 0) || (delta > 0 && el.scrollLeft < maxScrollLeft)) {
+      e.preventDefault();
+      el.scrollLeft += delta;
+      window.requestAnimationFrame(updateScrollState);
+    }
+  };
+
+  const scrollByPage = (direction: -1 | 1) => {
+    const el = scrollRef.current;
+    if (!el) return;
+    el.scrollBy({ left: direction * Math.max(el.clientWidth * 0.8, 240), behavior: 'smooth' });
+    window.setTimeout(updateScrollState, 250);
+  };
+
+  return (
+    <div className={cn("relative min-w-0 max-w-full overflow-hidden", className)}>
+      {canScrollLeft && (
+        <button
+          type="button"
+          onClick={() => scrollByPage(-1)}
+          className="absolute left-2 top-1/2 z-20 -translate-y-1/2 rounded-full bg-white/95 border border-slate-200 shadow-md p-1.5 text-slate-500 hover:text-slate-700 hover:border-slate-300"
+          aria-label="向左滚动帖子"
+        >
+          <ChevronLeft size={16} />
+        </button>
+      )}
+      {canScrollRight && (
+        <button
+          type="button"
+          onClick={() => scrollByPage(1)}
+          className="absolute right-2 top-1/2 z-20 -translate-y-1/2 rounded-full bg-white/95 border border-slate-200 shadow-md p-1.5 text-slate-500 hover:text-slate-700 hover:border-slate-300"
+          aria-label="向右滚动帖子"
+        >
+          <ChevronRight size={16} />
+        </button>
+      )}
+      <div
+        ref={scrollRef}
+        onWheel={handleWheel}
+        onScroll={updateScrollState}
+        className="w-full overflow-x-auto overflow-y-hidden scrollbar-thin"
+      >
+        {children}
+      </div>
     </div>
     </div>
   );
   );
 }
 }
 
 
+// ─── 业务需求帖子聚合抽屉 ──────────────────────────────────────────────────────
+
+function RequirementPostsDrawer({
+  requirement,
+  nodePostsMap,
+  onOpenPost,
+}: {
+  requirement: any;
+  nodePostsMap: Record<string, string[]>;
+  onOpenPost: (postId: string, post: any) => void;
+}) {
+  const [selectedNodeName, setSelectedNodeName] = useState<string | null>(null);
+  const [posts, setPosts] = useState<Record<string, any>>({});
+  const [loading, setLoading] = useState(false);
+
+  const nodeNames = Object.keys(nodePostsMap);
+  const allPostIds = useMemo(() => {
+    const ids: string[] = [];
+    Object.values(nodePostsMap).forEach(pids => pids.forEach(pid => { if (!ids.includes(pid)) ids.push(pid); }));
+    return ids;
+  }, [nodePostsMap]);
+
+  useEffect(() => {
+    if (allPostIds.length === 0) return;
+    setLoading(true);
+    setPosts({});
+    batchGetPosts(allPostIds)
+      .then(map => setPosts(map))
+      .catch(err => {
+        console.error('Failed to load requirement posts:', err);
+      })
+      .finally(() => setLoading(false));
+  }, [allPostIds, requirement.id]);
+
+  const displayPostIds = selectedNodeName ? (nodePostsMap[selectedNodeName] || []) : allPostIds;
+
+  return (
+    <div className="flex flex-row gap-3 h-full overflow-hidden min-w-0">
+      {/* 左侧:节点列表 */}
+      <div className="w-[200px] shrink-0 bg-slate-50 rounded-xl border border-slate-100 p-3 overflow-y-auto">
+        <div className="text-xs font-bold text-slate-600 mb-2">关联节点 ({nodeNames.length})</div>
+        <button
+          onClick={() => setSelectedNodeName(null)}
+          className={cn(
+            "w-full text-left px-2 py-1.5 rounded-lg text-xs mb-1 transition-colors",
+            selectedNodeName === null ? "bg-indigo-100 text-indigo-700 font-bold" : "hover:bg-slate-100 text-slate-600"
+          )}
+        >
+          全部 ({allPostIds.length})
+        </button>
+        {nodeNames.map(name => (
+          <button
+            key={name}
+            onClick={() => setSelectedNodeName(name)}
+            className={cn(
+              "w-full text-left px-2 py-1.5 rounded-lg text-xs mb-1 transition-colors truncate",
+              selectedNodeName === name ? "bg-indigo-100 text-indigo-700 font-bold" : "hover:bg-slate-100 text-slate-600"
+            )}
+          >
+            {name === '__unmatched__' ? '未定位帖子' : name} ({nodePostsMap[name].length})
+          </button>
+        ))}
+      </div>
+
+      {/* 右侧:帖子横向滚动 */}
+      <HorizontalPostScroller className="flex-1 min-w-0">
+        <div className="inline-flex gap-3 px-4 py-3 min-w-max">
+        {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 (
+            <PostCard key={pid} postId={pid} post={post} onClick={() => onOpenPost(pid, post)} />
+          );
+        })}
+        </div>
+      </HorizontalPostScroller>
+    </div>
+  );
+}
+
+// ─── 叶子节点详情抽屉 ─────────────────────────────────────────────────────────
+
+function LeafNodeDrawer({ node, onOpenPost }: { node: any; onOpenPost: (postId: string, post: any) => void }) {
+  const [posts, setPosts] = useState<Record<string, any>>({});
+  const [loading, setLoading] = useState(false);
+
+  const postIds: string[] = useMemo(() => {
+    const ids: string[] = [];
+    (node.elements || []).forEach((el: any) => {
+      (el.post_ids || []).forEach((pid: string) => {
+        if (!ids.includes(pid)) ids.push(pid);
+      });
+    });
+    return ids;
+  }, [node.name]);
+
+  useEffect(() => {
+    if (postIds.length === 0) return;
+    setLoading(true);
+    setPosts({});
+    batchGetPosts(postIds)
+      .then(map => setPosts(map))
+      .catch(err => {
+        console.error('Failed to load leaf node posts:', err);
+      })
+      .finally(() => setLoading(false));
+  }, [node.name, postIds]);
+
+  return (
+    <HorizontalPostScroller className="h-full w-full min-w-0">
+      <div className="inline-flex flex-row gap-3 h-full px-4 py-3 min-w-max">
+      {/* 节点统计卡 */}
+      <div className="w-[160px] shrink-0 bg-slate-50 rounded-xl border border-slate-100 p-3 flex flex-col gap-2 justify-center">
+        <div className="text-center">
+          <div className="text-2xl font-black text-slate-800">{node.total_posts_count || 0}</div>
+          <div className="text-[10px] text-slate-400">帖子总数</div>
+        </div>
+        <div className="text-center">
+          <div className="text-xl font-black text-slate-600">{postIds.length}</div>
+          <div className="text-[10px] text-slate-400">去重帖子</div>
+        </div>
+        {loading && (
+          <div className="flex items-center justify-center gap-1 text-[10px] text-slate-400 mt-1">
+            <div className="w-3 h-3 border-2 border-indigo-200 border-t-indigo-500 rounded-full animate-spin"></div>
+            加载中
+          </div>
+        )}
+      </div>
+
+      {/* 帖子卡片横向列表 */}
+      {postIds.length === 0 ? (
+        <div className="flex items-center justify-center flex-1 text-xs text-slate-300 font-bold">该节点暂无帖子</div>
+      ) : (
+        postIds.map(pid => {
+          const post = posts[pid];
+          return (
+            <PostCard key={pid} postId={pid} post={post} loading={loading} onClick={() => onOpenPost(pid, post)} />
+          );
+        })
+      )}
+      </div>
+    </HorizontalPostScroller>
+  );
+}
+
+// ─── Dashboard 主体 ────────────────────────────────────────────────────────────
 
 
 export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: string | null, onPendingConsumed?: () => void }) {
 export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: string | null, onPendingConsumed?: () => void }) {
-  type NavItem = { type: 'node' | 'req' | 'cap' | 'tool' | 'know', data: any };
   const [treeData, setTreeData] = useState<any>(null);
   const [treeData, setTreeData] = useState<any>(null);
   const [selectedNode, setSelectedNode] = useState<any>(null);
   const [selectedNode, setSelectedNode] = useState<any>(null);
-  const [navStack, setNavStack] = useState<NavItem[]>([]);
   const [nameToNodeMap, setNameToNodeMap] = useState<Record<string, any>>({});
   const [nameToNodeMap, setNameToNodeMap] = useState<Record<string, any>>({});
   const [dbData, setDbData] = useState<{ reqs: any[], caps: any[], tools: any[], know: any[] }>({
   const [dbData, setDbData] = useState<{ reqs: any[], caps: any[], tools: any[], know: any[] }>({
     reqs: [], caps: [], tools: [], know: []
     reqs: [], caps: [], tools: [], know: []
   });
   });
+  const [weightTab, setWeightTab] = useState<'unweighted' | 'weighted'>('unweighted');
+  const [coverageStats, setCoverageStats] = useState({
+    totalLeaves: 0, reqCoveredNodes: 0, reqCoveragePerc: 0,
+    toolCoveredNodes: 0, toolCoveragePerc: 0, verifiedNodes: 0,
+    verifiedCoveragePerc: 0, weightedCoveragePerc: 0, coveredPostsCnt: 0,
+    totalPostsCnt: 0, toolCoveredPostsCnt: 0, toolWeightedCoveragePerc: 0,
+    verifiedPostsCnt: 0, verifiedWeightedCoveragePerc: 0
+  });
 
 
-  // 处理来自其他页面的跳转请求
+  // 关系列状态
+  const [activeId, setActiveId] = useState<string | null>(null);
+  const [activeLeafNode, setActiveLeafNode] = useState<any>(null); // 点击叶子节点时存储
+  const [drawerItem, setDrawerItem] = useState<{ type: string; data: any } | null>(null);
+  const [leafDetailNode, setLeafDetailNode] = useState<any>(null);
+  const [treeWideMode, setTreeWideMode] = useState(true);
+  const [requirementPostsData, setRequirementPostsData] = useState<{ requirement: any; nodePostsMap: Record<string, string[]> } | null>(null);
+  const [selectedPostDetail, setSelectedPostDetail] = useState<{ postId: string; post: any } | null>(null);
+  const [onlyCoveredFilter, setOnlyCoveredFilter] = useState(false); // 只看覆盖需求的数据
+
+  // 来自其他页面的跳转
   useEffect(() => {
   useEffect(() => {
     if (pendingNode && nameToNodeMap[pendingNode]) {
     if (pendingNode && nameToNodeMap[pendingNode]) {
       setSelectedNode(nameToNodeMap[pendingNode]);
       setSelectedNode(nameToNodeMap[pendingNode]);
@@ -85,207 +658,120 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
     }
     }
   }, [pendingNode, nameToNodeMap]);
   }, [pendingNode, nameToNodeMap]);
 
 
-  useEffect(() => {
-    if (selectedNode) setNavStack([{ type: 'node', data: selectedNode }]);
-    else setNavStack([]);
-  }, [selectedNode]);
-
-  const handleDrillDown = (type: NavItem['type'], data: any) => {
-    setNavStack(prev => [...prev, { type, data }]);
-  };
-
-  const handleBreadcrumbClick = (idx: number) => {
-    setNavStack(prev => prev.slice(0, idx + 1));
-  };
-
-  const [coverageStats, setCoverageStats] = useState({
-    totalLeaves: 0,
-    reqCoveredNodes: 0,
-    reqCoveragePerc: 0,
-    toolCoveredNodes: 0,
-    toolCoveragePerc: 0,
-    verifiedNodes: 0,
-    verifiedCoveragePerc: 0,
-    weightedCoveragePerc: 0,
-    coveredPostsCnt: 0,
-    totalPostsCnt: 0,
-    toolCoveredPostsCnt: 0,
-    toolWeightedCoveragePerc: 0,
-    verifiedPostsCnt: 0,
-    verifiedWeightedCoveragePerc: 0
-  });
-
-  const [weightTab, setWeightTab] = useState<'unweighted' | 'weighted'>('unweighted');
-
   const getLeafNodes = (nodes: any[]): any[] => {
   const getLeafNodes = (nodes: any[]): any[] => {
     let leaves: any[] = [];
     let leaves: any[] = [];
     nodes.forEach(n => {
     nodes.forEach(n => {
-      if (!n.children || n.children.length === 0) {
-        leaves.push(n);
-      } else {
-        leaves = leaves.concat(getLeafNodes(n.children));
-      }
+      if (!n.children || n.children.length === 0) leaves.push(n);
+      else leaves = leaves.concat(getLeafNodes(n.children));
     });
     });
     return leaves;
     return leaves;
   };
   };
 
 
+  const collectNodePostIds = (node: any): string[] => {
+    const ids: string[] = [];
+    const walk = (current: any) => {
+      (current?.elements || []).forEach((el: any) => {
+        (el.post_ids || []).forEach((pid: string) => {
+          if (pid && !ids.includes(pid)) ids.push(pid);
+        });
+      });
+      (current?.children || []).forEach((child: any) => walk(child));
+    };
+    walk(node);
+    return ids;
+  };
+
   useEffect(() => {
   useEffect(() => {
     async function loadStats() {
     async function loadStats() {
       try {
       try {
-        // 1. Fetch Tree
         const treeRes = await fetch('/category_tree.json');
         const treeRes = await fetch('/category_tree.json');
         const data = await treeRes.json();
         const data = await treeRes.json();
         setTreeData(data);
         setTreeData(data);
         const leaves = getLeafNodes([data]);
         const leaves = getLeafNodes([data]);
 
 
-        // 2. Fetch associations
         const [reqRes, capRes, toolRes] = await Promise.all([
         const [reqRes, capRes, toolRes] = await Promise.all([
-          getRequirements(1000, 0),
-          getCapabilities(1000, 0),
-          getTools(1000, 0)
+          getRequirements(1000, 0), getCapabilities(1000, 0), getTools(1000, 0)
         ]);
         ]);
-
         let knowRes: any = { results: [] };
         let knowRes: any = { results: [] };
-        try {
-          knowRes = await getKnowledge(1, 1000);
-        } catch (e) {
-          console.warn('knowledge API not available or failed', e);
-        }
+        try { knowRes = await getKnowledge(1, 1000); } catch (e) { /* optional */ }
 
 
         const reqs = reqRes.results || [];
         const reqs = reqRes.results || [];
         const caps = capRes.results || [];
         const caps = capRes.results || [];
         const tools = toolRes.results || [];
         const tools = toolRes.results || [];
         const know = knowRes.results || [];
         const know = knowRes.results || [];
-
         setDbData({ reqs, caps, tools, know });
         setDbData({ reqs, caps, tools, know });
 
 
-        // 3. Map reqs to nodes by name
-        const nodeToReqs: Record<string, any[]> = {};
-        leaves.forEach(l => { nodeToReqs[l.name] = []; });
-        
         const nameToNode: Record<string, any> = {};
         const nameToNode: Record<string, any> = {};
         const buildNameMap = (nodes: any[]) => {
         const buildNameMap = (nodes: any[]) => {
-          nodes.forEach(n => {
-            nameToNode[n.name] = n;
-            if (n.children) buildNameMap(n.children);
-          });
+          nodes.forEach(n => { nameToNode[n.name] = n; if (n.children) buildNameMap(n.children); });
         };
         };
         buildNameMap([data]);
         buildNameMap([data]);
         setNameToNodeMap(nameToNode);
         setNameToNodeMap(nameToNode);
 
 
+        const nodeToReqs: Record<string, any[]> = {};
+        leaves.forEach(l => { nodeToReqs[l.name] = []; });
+
         reqs.forEach((r: any) => {
         reqs.forEach((r: any) => {
           (r.source_nodes || []).forEach((sn: any) => {
           (r.source_nodes || []).forEach((sn: any) => {
             const nodeName = typeof sn === 'object' ? (sn.node_name || sn.name) : sn;
             const nodeName = typeof sn === 'object' ? (sn.node_name || sn.name) : sn;
             if (nodeName && nameToNode[nodeName]) {
             if (nodeName && nameToNode[nodeName]) {
-              const matchedLeaves = getLeafNodes([nameToNode[nodeName]]);
-              matchedLeaves.forEach(ml => {
-                if (nodeToReqs[ml.name]) {
-                  nodeToReqs[ml.name].push(r);
-                }
+              getLeafNodes([nameToNode[nodeName]]).forEach(ml => {
+                if (nodeToReqs[ml.name]) nodeToReqs[ml.name].push(r);
               });
               });
             }
             }
           });
           });
         });
         });
 
 
-        // Mark has_requirement flag and complex node_status on all leaves
         leaves.forEach(l => {
         leaves.forEach(l => {
           const attachedReqs = nodeToReqs[l.name];
           const attachedReqs = nodeToReqs[l.name];
           l.has_requirement = !!(attachedReqs && attachedReqs.length > 0);
           l.has_requirement = !!(attachedReqs && attachedReqs.length > 0);
-          
-          if (!l.has_requirement) {
-             l.node_status = 0; // 灰色 (没有需求)
-          } else {
-             const rIds = new Set(attachedReqs.map(r => r.id));
-             const relCaps = caps.filter((c: any) => (c.requirement_ids || []).some((rid: string) => rIds.has(rid)));
-             
-             // 检查是否所有挂载的需求都有对应的原子能力
-             const reqsWithCaps = attachedReqs.filter((r: any) => caps.some((c: any) => (c.requirement_ids || []).includes(r.id)));
-             if (reqsWithCaps.length < attachedReqs.length) {
-                l.node_status = 1; // 蓝色 (有需求,但没满足,缺能力覆盖)
-             } else {
-                const cIds = new Set(relCaps.map((c: any) => c.id));
-                const relTools = tools.filter((t: any) => (t.capability_ids || []).some((cid: string) => cIds.has(cid)));
-                
-                if (relTools.length === 0) {
-                   l.node_status = 2; // 黄色 (有需求且全部被能力满足,但工具未接入/没有工具)
-                } else {
-                   const hasDisconnected = relTools.some((t: any) => t.status !== '已接入' && t.status !== '正常' && t.status !== '已上线' && t.status !== 'active');
-                   if (hasDisconnected) {
-                      l.node_status = 2; // 黄色 (工具状态有未接入)
-                   } else {
-                      l.node_status = 3; // 绿色 (全部满足且工具全接入)
-                   }
-                }
-             }
+          if (!l.has_requirement) { l.node_status = 0; }
+          else {
+            const rIds = new Set(attachedReqs.map(r => r.id));
+            const relCaps = caps.filter((c: any) => (c.requirement_ids || []).some((rid: string) => rIds.has(rid)));
+            const reqsWithCaps = attachedReqs.filter((r: any) => caps.some((c: any) => (c.requirement_ids || []).includes(r.id)));
+            if (reqsWithCaps.length < attachedReqs.length) { l.node_status = 1; }
+            else {
+              const cIds = new Set(relCaps.map((c: any) => c.id));
+              const relTools = tools.filter((t: any) => (t.capability_ids || []).some((cid: string) => cIds.has(cid)));
+              if (relTools.length === 0) { l.node_status = 2; }
+              else {
+                const hasDisconnected = relTools.some((t: any) => t.status !== '已接入' && t.status !== '正常' && t.status !== '已上线' && t.status !== 'active');
+                l.node_status = hasDisconnected ? 2 : 3;
+              }
+            }
           }
           }
         });
         });
-        setTreeData({...data});
+        setTreeData({ ...data });
 
 
-        // 4. Calculate metrics based strictly on nodes with Xiaohongshu posts (total_posts_count > 0)
         const activeLeaves = leaves.filter(l => l.total_posts_count && l.total_posts_count > 0);
         const activeLeaves = leaves.filter(l => l.total_posts_count && l.total_posts_count > 0);
         const totalLeavesCount = activeLeaves.length;
         const totalLeavesCount = activeLeaves.length;
-        
-        let reqCoveredNodes = 0;
-        let toolCoveredNodes = 0;
-        let verifiedNodes = 0;
-        let totalWeight = 0;
-        let coveredWeight = 0;
-        let toolCoveredWeight = 0;
-        let verifiedWeight = 0;
-
+        let reqCoveredNodes = 0, toolCoveredNodes = 0, verifiedNodes = 0;
+        let totalWeight = 0, coveredWeight = 0, toolCoveredWeight = 0, verifiedWeight = 0;
         activeLeaves.forEach(l => {
         activeLeaves.forEach(l => {
           const count = l.total_posts_count || 0;
           const count = l.total_posts_count || 0;
           totalWeight += count;
           totalWeight += count;
           const attachedReqs = nodeToReqs[l.name];
           const attachedReqs = nodeToReqs[l.name];
-          let isToolCovered = false;
-          let isVerified = false;
-
           if (attachedReqs && attachedReqs.length > 0) {
           if (attachedReqs && attachedReqs.length > 0) {
-            reqCoveredNodes++;
-            coveredWeight += count;
-            
-            if (l.node_status >= 2) {
-               isToolCovered = true;
-            }
-            if (l.node_status === 3) {
-               isVerified = true;
-            }
-          }
-
-          if (isToolCovered) {
-             toolCoveredNodes++;
-             toolCoveredWeight += count;
-          }
-          if (isVerified) {
-             verifiedNodes++;
-             verifiedWeight += count;
+            reqCoveredNodes++; coveredWeight += count;
+            if (l.node_status >= 2) { toolCoveredNodes++; toolCoveredWeight += count; }
+            if (l.node_status === 3) { verifiedNodes++; verifiedWeight += count; }
           }
           }
         });
         });
-
-        const reqCoveragePerc = totalLeavesCount > 0 ? (reqCoveredNodes / totalLeavesCount) * 100 : 0;
-        const toolCoveragePerc = reqCoveredNodes > 0 ? (toolCoveredNodes / reqCoveredNodes) * 100 : 0;
-        const verifiedCoveragePerc = totalLeavesCount > 0 ? (verifiedNodes / totalLeavesCount) * 100 : 0;
-        const weightedCoveragePerc = totalWeight > 0 ? (coveredWeight / totalWeight) * 100 : 0;
-        const toolWeightedCoveragePerc = coveredWeight > 0 ? (toolCoveredWeight / coveredWeight) * 100 : 0;
-        const verifiedWeightedCoveragePerc = totalWeight > 0 ? (verifiedWeight / totalWeight) * 100 : 0;
-
         setCoverageStats({
         setCoverageStats({
-          totalLeaves: totalLeavesCount,
-          reqCoveredNodes,
-          reqCoveragePerc: Number(reqCoveragePerc.toFixed(1)),
+          totalLeaves: totalLeavesCount, reqCoveredNodes,
+          reqCoveragePerc: Number((totalLeavesCount > 0 ? reqCoveredNodes / totalLeavesCount * 100 : 0).toFixed(1)),
           toolCoveredNodes,
           toolCoveredNodes,
-          toolCoveragePerc: Number(toolCoveragePerc.toFixed(1)),
+          toolCoveragePerc: Number((reqCoveredNodes > 0 ? toolCoveredNodes / reqCoveredNodes * 100 : 0).toFixed(1)),
           verifiedNodes,
           verifiedNodes,
-          verifiedCoveragePerc: Number(verifiedCoveragePerc.toFixed(1)),
-          weightedCoveragePerc: Number(weightedCoveragePerc.toFixed(1)),
-          coveredPostsCnt: coveredWeight,
-          totalPostsCnt: totalWeight,
+          verifiedCoveragePerc: Number((totalLeavesCount > 0 ? verifiedNodes / totalLeavesCount * 100 : 0).toFixed(1)),
+          weightedCoveragePerc: Number((totalWeight > 0 ? coveredWeight / totalWeight * 100 : 0).toFixed(1)),
+          coveredPostsCnt: coveredWeight, totalPostsCnt: totalWeight,
           toolCoveredPostsCnt: toolCoveredWeight,
           toolCoveredPostsCnt: toolCoveredWeight,
-          toolWeightedCoveragePerc: Number(toolWeightedCoveragePerc.toFixed(1)),
+          toolWeightedCoveragePerc: Number((coveredWeight > 0 ? toolCoveredWeight / coveredWeight * 100 : 0).toFixed(1)),
           verifiedPostsCnt: verifiedWeight,
           verifiedPostsCnt: verifiedWeight,
-          verifiedWeightedCoveragePerc: Number(verifiedWeightedCoveragePerc.toFixed(1))
+          verifiedWeightedCoveragePerc: Number((totalWeight > 0 ? verifiedWeight / totalWeight * 100 : 0).toFixed(1))
         });
         });
-
       } catch (err) {
       } catch (err) {
         console.error("Failed to load dashboard stats", err);
         console.error("Failed to load dashboard stats", err);
       }
       }
@@ -293,301 +779,437 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
     loadStats();
     loadStats();
   }, []);
   }, []);
 
 
+  // ── 点击叶子节点时,找到它关联的所有需求 ID ────────────────────────────────
+  const activeLeafReqIds = useMemo((): Set<string> => {
+    if (!activeLeafNode) return new Set();
+    const leafName = activeLeafNode.name;
+    const relatedReqs = dbData.reqs.filter((r: any) =>
+      (r.source_nodes || []).some((sn: any) => {
+        const nodeName = typeof sn === 'object' ? (sn.node_name || sn.name) : sn;
+        return nodeName === leafName;
+      })
+    );
+    return new Set(relatedReqs.map((r: any) => r.id));
+  }, [activeLeafNode, dbData.reqs]);
+
+  // ── 点击叶子节点时,收集所有共享需求的叶子节点名称(用于树高亮)────────────────────────────────
+  const highlightLeafNamesFromActiveLeaf = useMemo((): Set<string> => {
+    if (activeLeafReqIds.size === 0) return new Set();
+    const relatedReqs = dbData.reqs.filter((r: any) => activeLeafReqIds.has(r.id));
+    const leafNames = new Set<string>();
+    relatedReqs.forEach(req => {
+      (req.source_nodes || []).forEach((sn: any) => {
+        const nodeName = typeof sn === 'object' ? (sn.node_name || sn.name) : sn;
+        if (nodeName && nodeName !== '__abstract__' && nameToNodeMap[nodeName]) {
+          getLeafNodes([nameToNodeMap[nodeName]]).forEach(leaf => {
+            leafNames.add(leaf.name);
+          });
+        }
+      });
+    });
+    return leafNames;
+  }, [activeLeafReqIds, dbData.reqs, nameToNodeMap]);
+
+  // ── 选中节点时,计算所有共享需求的节点名称(展开到叶子节点)────────────────────────────────
+  const selectedLeafNames = useMemo((): Set<string> => {
+    if (!selectedNode) return new Set();
+    const clickedLeafNames = new Set(getLeafNodes([selectedNode]).map(l => l.name));
+    const relatedReqs = dbData.reqs.filter((r: any) =>
+      (r.source_nodes || []).some((sn: any) =>
+        clickedLeafNames.has(typeof sn === 'object' ? (sn.node_name || sn.name) : sn)
+      )
+    );
+    const allRelatedLeafNames = new Set<string>();
+    relatedReqs.forEach(req => {
+      (req.source_nodes || []).forEach((sn: any) => {
+        const nodeName = typeof sn === 'object' ? (sn.node_name || sn.name) : sn;
+        if (nodeName && nodeName !== '__abstract__' && nameToNodeMap[nodeName]) {
+          // 展开到叶子节点
+          getLeafNodes([nameToNodeMap[nodeName]]).forEach(leaf => {
+            allRelatedLeafNames.add(leaf.name);
+          });
+        }
+      });
+    });
+    return allRelatedLeafNames;
+  }, [selectedNode, dbData.reqs, nameToNodeMap]);
+
+  // ── 树节点过滤:选中节点后只显示关联数据(基于所有共享需求的节点)───────────────────────────────────
+  const filteredData = useMemo(() => {
+    let baseReqs = dbData.reqs;
+
+    // 如果开启"只看覆盖需求的数据",先过滤出有 source_nodes 的需求
+    if (onlyCoveredFilter) {
+      baseReqs = dbData.reqs.filter((r: any) => {
+        const nodes = r.source_nodes || [];
+        return nodes.some((sn: any) => {
+          const nodeName = typeof sn === 'object' ? (sn.node_name || sn.name) : sn;
+          return nodeName && nodeName !== '__abstract__';
+        });
+      });
+    }
+
+    // 如果选中了节点,进一步过滤
+    if (selectedNode) {
+      const leafNames = selectedLeafNames.size > 0 ? selectedLeafNames : new Set(getLeafNodes([selectedNode]).map(l => l.name));
+      baseReqs = baseReqs.filter((r: any) =>
+        (r.source_nodes || []).some((sn: any) => leafNames.has(typeof sn === 'object' ? (sn.node_name || sn.name) : sn))
+      );
+    }
+
+    const reqIds = new Set(baseReqs.map((r: any) => r.id));
+    const filteredCaps = dbData.caps.filter((c: any) => (c.requirement_ids || []).some((rid: string) => reqIds.has(rid)));
+    const capIds = new Set(filteredCaps.map((c: any) => c.id));
+    const filteredTools = dbData.tools.filter((t: any) => (t.capability_ids || []).some((cid: string) => capIds.has(cid)));
+    const toolIds = new Set(filteredTools.map((t: any) => t.id));
+    const filteredKnow = dbData.know.filter((k: any) =>
+      (k.capability_ids || []).some((cid: string) => capIds.has(cid)) ||
+      (k.tool_ids || []).some((tid: string) => toolIds.has(tid))
+    );
+    return { reqs: baseReqs, caps: filteredCaps, tools: filteredTools, know: filteredKnow };
+  }, [selectedNode, selectedLeafNames, dbData, onlyCoveredFilter]);
+
+  // ── 反向联动:activeId 对应的叶子节点名称集合,用于高亮内容树 ──────────────
+  const highlightLeafNames = useMemo((): Set<string> | null => {
+    if (!activeId) return null;
+    const [type, id] = activeId.split(':');
+    let sourceNodes: any[] = [];
+    if (type === 'req') {
+      const req = dbData.reqs.find((r: any) => r.id === id);
+      sourceNodes = req?.source_nodes || [];
+    } else if (type === 'cap') {
+      const cap = dbData.caps.find((c: any) => c.id === id);
+      const reqIds: string[] = cap?.requirement_ids || [];
+      reqIds.forEach(rid => {
+        const req = dbData.reqs.find((r: any) => r.id === rid);
+        if (req) sourceNodes = sourceNodes.concat(req.source_nodes || []);
+      });
+    } else if (type === 'tool') {
+      const tool = dbData.tools.find((t: any) => t.id === id);
+      const capIds: string[] = tool?.capability_ids || [];
+      capIds.forEach(cid => {
+        const cap = dbData.caps.find((c: any) => c.id === cid);
+        const reqIds: string[] = cap?.requirement_ids || [];
+        reqIds.forEach(rid => {
+          const req = dbData.reqs.find((r: any) => r.id === rid);
+          if (req) sourceNodes = sourceNodes.concat(req.source_nodes || []);
+        });
+      });
+    }
+    if (sourceNodes.length === 0) return null;
+    const leafNames = new Set<string>();
+    sourceNodes.forEach((sn: any) => {
+      const nodeName = typeof sn === 'object' ? (sn.node_name || sn.name) : sn;
+      if (nodeName && nameToNodeMap[nodeName]) {
+        getLeafNodes([nameToNodeMap[nodeName]]).forEach(l => leafNames.add(l.name));
+      }
+    });
+    return leafNames.size > 0 ? leafNames : null;
+  }, [activeId, dbData, nameToNodeMap]);
+
+  // ── 全局节点总数(叶子节点数)──────────────────────────────────────────────
+
+  // ── 有需求覆盖的叶子节点名称集合(用于 onlyCoveredFilter)──────────────────
+  const coveredLeafNames = useMemo((): Set<string> => {
+    const names = new Set<string>();
+    dbData.reqs.forEach((r: any) => {
+      (r.source_nodes || []).forEach((sn: any) => {
+        const nodeName = typeof sn === 'object' ? (sn.node_name || sn.name) : sn;
+        if (nodeName && nodeName !== '__abstract__' && nameToNodeMap[nodeName]) {
+          getLeafNodes([nameToNodeMap[nodeName]]).forEach(l => names.add(l.name));
+        }
+      });
+    });
+    return names;
+  }, [dbData.reqs, nameToNodeMap]);
+  const totalNodeCount = useMemo(() => {
+    if (!treeData) return 0;
+    return getLeafNodes([treeData]).filter((l: any) => l.total_posts_count && l.total_posts_count > 0).length;
+  }, [treeData]);
+
+  // ── BFS 关联高亮(与 Relations.tsx 逻辑一致)──────────────────────────────
+  const adjacencyMap = useMemo(() => {
+    const map = new Map<string, Set<string>>();
+    const add = (a: string, b: string) => {
+      if (!map.has(a)) map.set(a, new Set());
+      if (!map.has(b)) map.set(b, new Set());
+      map.get(a)!.add(b);
+      map.get(b)!.add(a);
+    };
+    filteredData.reqs.forEach(r => {
+      (r.capability_ids || []).forEach((cid: string) => add(`req:${r.id}`, `cap:${cid}`));
+    });
+    filteredData.caps.forEach(c => {
+      (c.tool_ids || c.capability_ids || []).forEach((tid: string) => add(`cap:${c.id}`, `tool:${tid}`));
+      (c.requirement_ids || []).forEach((rid: string) => add(`cap:${c.id}`, `req:${rid}`));
+    });
+    filteredData.tools.forEach(t => {
+      (t.capability_ids || []).forEach((cid: string) => add(`tool:${t.id}`, `cap:${cid}`));
+    });
+    return map;
+  }, [filteredData]);
+
+  const colOrder: Record<string, number> = { req: 0, proc: 1, cap: 2, tool: 3 };
+  const getColType = (nodeId: string) => nodeId.split(':')[0];
+
+  const relatedIds = useMemo(() => {
+    // 如果点击了叶子节点,把它关联的所有需求作为起点
+    const startNodes: string[] = [];
+    if (activeLeafNode && activeLeafReqIds.size > 0) {
+      activeLeafReqIds.forEach(reqId => startNodes.push(`req:${reqId}`));
+    } else if (activeId) {
+      startNodes.push(activeId);
+    }
+
+    if (startNodes.length === 0) return new Set<string>();
+
+    const visited = new Set<string>(startNodes);
+    const queue: [string, number][] = startNodes.map(n => [n, 0]);
+
+    while (queue.length > 0) {
+      const [u, dir] = queue.shift()!;
+      const uOrder = colOrder[getColType(u)] ?? -1;
+      const neighbors = adjacencyMap.get(u) || new Set();
+      neighbors.forEach(v => {
+        if (visited.has(v)) return;
+        const vOrder = colOrder[getColType(v)] ?? -1;
+        const goRight = vOrder > uOrder;
+        const goLeft = vOrder < uOrder;
+        const allowed = dir === 0 || (dir === 1 && goRight) || (dir === -1 && goLeft);
+        if (allowed) { visited.add(v); queue.push([v, goRight ? 1 : -1]); }
+      });
+    }
+    return visited;
+  }, [activeId, activeLeafNode, activeLeafReqIds, adjacencyMap]);
+
+  const sortedItems = (items: any[], type: string) => {
+    if (!activeId && !activeLeafNode) return items;
+    const activeType = activeId ? activeId.split(':')[0] : 'req';
+    if (type === activeType) return items;
+    return [...items].sort((a, b) => {
+      const aRel = relatedIds.has(`${type}:${a.id}`) ? 0 : 1;
+      const bRel = relatedIds.has(`${type}:${b.id}`) ? 0 : 1;
+      return aRel - bRel;
+    });
+  };
+
+  const handleSingleClick = (nodeId: string, _item: any) => {
+    setActiveId(prev => prev === nodeId ? null : nodeId);
+    setActiveLeafNode(null);
+  };
+
+  const handleDoubleClick = (type: string, data: any) => {
+    if (type === 'req') {
+      // 业务需求双击 → 打开帖子聚合抽屉
+      openRequirementPostsDrawer(data);
+    } else {
+      setDrawerItem({ type, data });
+    }
+  };
+
+  const openRequirementPostsDrawer = (req: any) => {
+    // 对每个来源节点,只展示“需求输入帖子”与“该节点真实帖子”的交集。
+    const nodePostsMap: Record<string, string[]> = {};
+    const requirementInputPosts = [
+      ...(req.source_posts || []),
+      ...(req.post_ids || []),
+    ].map((item: any) => typeof item === 'string' ? item : item?.post_id).filter(Boolean);
+    const fallbackInputPosts: string[] = [];
+
+    (req.source_nodes || []).forEach((sn: any) => {
+      const nodeName = typeof sn === 'object' ? (sn.node_name || sn.name || '') : sn;
+      if (!nodeName || nodeName === '__abstract__') return;
+      const node = nameToNodeMap[nodeName];
+      if (!node) return;
+
+      const nodePostIds = collectNodePostIds(node);
+      const nodePostIdSet = new Set(nodePostIds);
+
+      const explicitPosts = typeof sn === 'object'
+        ? [
+            ...(sn.posts || []),
+            ...(sn.post_ids || []),
+            ...(sn.source_posts || []),
+          ]
+        : [];
+
+      const candidatePosts = (explicitPosts.length > 0 ? explicitPosts : requirementInputPosts)
+        .map((item: any) => typeof item === 'string' ? item : item?.post_id)
+        .filter(Boolean);
+      candidatePosts.forEach((pid: string) => {
+        if (pid && !fallbackInputPosts.includes(pid)) fallbackInputPosts.push(pid);
+      });
+
+      const postIds: string[] = [];
+      candidatePosts.forEach((pid: string) => {
+        if (nodePostIdSet.has(pid) && !postIds.includes(pid)) postIds.push(pid);
+      });
+
+      if (postIds.length > 0) nodePostsMap[nodeName] = postIds;
+    });
+
+    const allInputPosts = requirementInputPosts.length > 0 ? requirementInputPosts : fallbackInputPosts;
+    const matchedPosts = new Set(Object.values(nodePostsMap).flat());
+    const unmatchedPosts = allInputPosts.filter((pid: string) => pid && !matchedPosts.has(pid));
+
+    if (unmatchedPosts.length > 0) {
+      nodePostsMap.__unmatched__ = unmatchedPosts;
+    }
+
+    if (Object.keys(nodePostsMap).length === 0 && allInputPosts.length > 0) {
+      nodePostsMap.__unmatched__ = allInputPosts;
+    }
+
+    setRequirementPostsData({ requirement: req, nodePostsMap });
+  };
+
+  const columns = [
+    { t: 'req',  l: '业务需求', i: Target,   d: filteredData.reqs,  headerColor: 'border-t-2 border-t-indigo-400' },
+    { t: 'proc', l: '生产工序', i: ListTree,  d: [],                 headerColor: 'border-t-2 border-t-purple-400' },
+    { t: 'cap',  l: '原子能力', i: Cpu,       d: filteredData.caps,  headerColor: 'border-t-2 border-t-teal-400' },
+    { t: 'tool', l: '执行工具', i: Wrench,    d: filteredData.tools, headerColor: 'border-t-2 border-t-green-400' },
+  ];
+
   return (
   return (
-    <div className="space-y-8 animate-in fade-in duration-500">
-      <div className="bg-white rounded-3xl border border-slate-100 shadow-sm p-6 overflow-hidden">
-        <div className="flex justify-between items-center mb-8">
-          <div className="flex bg-slate-100 p-1 rounded-lg">
-            <button 
-              className={cn("px-4 py-1.5 text-sm font-bold rounded-md transition-all", weightTab === 'unweighted' ? "bg-white text-indigo-700 shadow-sm" : "text-slate-500 hover:text-slate-700")}
-              onClick={() => setWeightTab('unweighted')}
-            >
-              无权重 (节点数)
-            </button>
-            <button 
-              className={cn("px-4 py-1.5 text-sm font-bold rounded-md transition-all", weightTab === 'weighted' ? "bg-white text-indigo-700 shadow-sm" : "text-slate-500 hover:text-slate-700")}
-              onClick={() => setWeightTab('weighted')}
-            >
-              带权重 (帖子数)
-            </button>
-          </div>
+    <div className="flex flex-col h-[calc(100vh-64px)] gap-4 animate-in fade-in duration-500 overflow-hidden relative">
+      {/* 顶部抽屉:叶子节点帖子详情 或 业务需求帖子聚合(全局 fixed) */}
+      <div className={cn(
+        "fixed left-0 right-0 top-0 z-[200] bg-white border-b border-slate-200 shadow-xl transition-transform duration-300 ease-in-out flex flex-col",
+        (leafDetailNode || requirementPostsData) ? "translate-y-0" : "-translate-y-full"
+      )} style={{ maxHeight: '60vh' }}>
+        <div className="flex items-center justify-between px-6 py-3 border-b border-slate-100 shrink-0">
+          <span className="font-bold text-slate-800">
+            {leafDetailNode ? leafDetailNode.name : requirementPostsData ? `${requirementPostsData.requirement.name || requirementPostsData.requirement.description || '业务需求'} - 输入帖子` : ''}
+          </span>
+          <button onClick={() => { setLeafDetailNode(null); setRequirementPostsData(null); }} className="p-1.5 hover:bg-slate-100 rounded-lg text-slate-400 hover:text-slate-600 transition-colors">
+            <X size={16} />
+          </button>
         </div>
         </div>
-
-        <div className="flex justify-center items-center h-48 py-4">
-          {(() => {
-             const data = weightTab === 'unweighted' ? [
-               { label: '全局节点', value: coverageStats.totalLeaves, percent: '100%', color: 'bg-blue-400' },
-               { label: '需求覆盖节点', value: coverageStats.reqCoveredNodes, percent: coverageStats.reqCoveragePerc + '%', color: 'bg-indigo-400' },
-               { label: '工具覆盖节点', value: coverageStats.toolCoveredNodes, percent: (coverageStats.totalLeaves ? (coverageStats.toolCoveredNodes/coverageStats.totalLeaves*100).toFixed(1) : 0) + '%', color: 'bg-emerald-400' },
-               { label: '已验证节点', value: coverageStats.verifiedNodes, percent: coverageStats.verifiedCoveragePerc + '%', color: 'bg-cyan-400' },
-             ] : [
-               { label: '全局节点 (帖子)', value: coverageStats.totalPostsCnt, percent: '100%', color: 'bg-blue-400' },
-               { label: '需求覆盖 (帖子)', value: coverageStats.coveredPostsCnt, percent: coverageStats.weightedCoveragePerc + '%', color: 'bg-indigo-400' },
-               { label: '工具覆盖 (帖子)', value: coverageStats.toolCoveredPostsCnt, percent: (coverageStats.totalPostsCnt ? (coverageStats.toolCoveredPostsCnt/coverageStats.totalPostsCnt*100).toFixed(1) : 0) + '%', color: 'bg-emerald-400' },
-               { label: '已验证 (帖子)', value: coverageStats.verifiedPostsCnt, percent: coverageStats.verifiedWeightedCoveragePerc + '%', color: 'bg-cyan-400' },
-             ];
-
-             return (
-               <div className="flex w-full max-w-4xl h-full relative" style={{ filter: "drop-shadow(0 4px 6px rgba(0,0,0,0.05))" }}>
-                 {data.map((item, idx) => {
-                    const prevWidth = idx === 0 ? 100 : (data[idx-1].value / Math.max(data[0].value, 1) * 100);
-                    const currWidth = (item.value / Math.max(data[0].value, 1) * 100);
-                    // 取消最小高度限制,严格按比例渲染梯形
-                    const leftHeight = prevWidth;
-                    const rightHeight = currWidth;
-                    const clipPath = `polygon(0 ${50 - leftHeight/2}%, 100% ${50 - rightHeight/2}%, 100% ${50 + rightHeight/2}%, 0 ${50 + leftHeight/2}%)`;
-
-                    return (
-                      <div key={idx} className="flex-1 flex flex-col items-center justify-center relative border-r-2 border-white last:border-r-0">
-                         <div className={cn("absolute inset-0 transition-all duration-700 opacity-90", item.color)} style={{ clipPath }}></div>
-                         <div className="z-10 flex flex-col items-center text-slate-900">
-                           <span className="text-2xl font-black tracking-tight">{item.value}</span>
-                           <span className="text-xs font-bold mt-0.5 opacity-90">{item.label}</span>
-                         </div>
-                         {idx > 0 && (
-                            <div className="absolute top-0 left-0 -translate-x-1/2 -mt-4 text-[11px] font-bold text-slate-500 bg-white px-2 py-0.5 rounded shadow-sm border border-slate-100 z-20">
-                              转化率 {item.percent}
-                            </div>
-                         )}
-                      </div>
-                    );
-                 })}
-               </div>
-             );
-          })()}
+        <div className="overflow-hidden flex-1 min-w-0">
+          {leafDetailNode && <LeafNodeDrawer node={leafDetailNode} onOpenPost={(postId, post) => setSelectedPostDetail({ postId, post })} />}
+          {requirementPostsData && (
+            <RequirementPostsDrawer
+              requirement={requirementPostsData.requirement}
+              nodePostsMap={requirementPostsData.nodePostsMap}
+              onOpenPost={(postId, post) => setSelectedPostDetail({ postId, post })}
+            />
+          )}
         </div>
         </div>
       </div>
       </div>
-      
-      <div className="flex flex-col lg:flex-row gap-6">
-        {/* Left Side: Tree Viewer */}
-        <div className={cn("transition-all duration-300 ease-in-out", selectedNode ? "w-full xl:w-2/3" : "w-full")}>
-           <CategoryTree data={treeData} onSelect={setSelectedNode} selectedId={selectedNode?.id} />
+      {selectedPostDetail && (
+        <PostDetailModal
+          postId={selectedPostDetail.postId}
+          post={selectedPostDetail.post}
+          onClose={() => setSelectedPostDetail(null)}
+        />
+      )}
+
+      {/* 覆盖率统计 */}
+      <CoverageStats stats={coverageStats} weightTab={weightTab} setWeightTab={setWeightTab} />
+
+      {/* 工具栏 */}
+      <div className="flex items-center gap-2 shrink-0">
+        <button
+          onClick={() => setOnlyCoveredFilter(v => !v)}
+          className={cn(
+            "text-xs flex items-center gap-1.5 px-3 py-1.5 rounded-lg transition-colors font-bold",
+            onlyCoveredFilter
+              ? "bg-indigo-100 text-indigo-700 hover:bg-indigo-200"
+              : "bg-slate-200 text-slate-500 hover:bg-slate-300"
+          )}
+        >
+          <span className={cn("w-2 h-2 rounded-full", onlyCoveredFilter ? "bg-indigo-500" : "bg-slate-400")} />
+          只看覆盖需求的数据
+        </button>
+        {(selectedNode || activeLeafNode || activeId) && (
+          <button
+            onClick={() => { setSelectedNode(null); setActiveId(null); setActiveLeafNode(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"
+          >
+            <X size={12} /> 清除筛选
+          </button>
+        )}
+      </div>
+
+      {/* 主区域:整行横向滚动,每列固定宽度 */}
+      <div className="flex flex-row gap-4 flex-1 min-h-0 overflow-x-auto">
+        {/* 内容树 */}
+        <div className={cn("shrink-0 min-h-0", treeWideMode ? "w-[900px]" : "w-[420px]")}>
+          <CategoryTree
+            data={treeData}
+            onSelect={(node) => {
+              const isLeaf = !node.children || node.children.length === 0;
+              const isSame = selectedNode?.id === node.id;
+              setSelectedNode(isSame ? null : node);
+              setActiveId(null);
+              setActiveLeafNode(isLeaf && !isSame ? node : null);
+            }}
+            onDoubleClick={(node) => {
+              setLeafDetailNode(node);
+            }}
+            selectedId={activeLeafNode?.id ?? selectedNode?.id}
+            highlightLeafNames={
+              activeId
+                ? highlightLeafNames
+                : activeLeafNode
+                ? (highlightLeafNamesFromActiveLeaf.size > 0 ? highlightLeafNamesFromActiveLeaf : null)
+                : onlyCoveredFilter
+                ? coveredLeafNames
+                : null
+            }
+            totalNodeCount={totalNodeCount}
+            wideMode={treeWideMode}
+            onToggleWideMode={() => setTreeWideMode(m => !m)}
+          />
         </div>
         </div>
 
 
-        {/* Right Side: Drill-down Details Panel */}
-        {navStack.length > 0 && (
-          <div className="w-full xl:w-1/3 bg-white p-6 rounded-3xl border border-slate-100 shadow-sm sticky top-24 max-h-[calc(100vh-100px)] overflow-y-auto custom-scrollbar">
-            {/* Breadcrumbs */}
-            <div className="flex flex-wrap items-center gap-1.5 font-bold text-[13px] text-slate-500 border-b pb-4 mb-4">
-               {navStack.map((item, idx) => {
-                 let Icon: any = FolderTree;
-                 let crumbTitle = "Node";
-                 if (item.type === 'node') { Icon = FolderTree; crumbTitle = item.data.name || "Root"; }
-                 if (item.type === 'req') { Icon = Target; crumbTitle = item.data.id.substring(0, 8); }
-                 if (item.type === 'cap') { Icon = Brain; crumbTitle = item.data.name || item.data.id.substring(0,8); }
-                 if (item.type === 'tool') { Icon = Wrench; crumbTitle = item.data.name || item.data.id.substring(0,8); }
-                 if (item.type === 'know') { Icon = FileText; crumbTitle = item.data.task ? item.data.task.substring(0, 10)+"..." : item.data.id.substring(0,8); }
-                 
-                 const isLast = idx === navStack.length - 1;
-                 
-                 return (
-                   <div key={idx} className="flex items-center gap-1.5">
-                     <div 
-                       className={cn("flex items-center gap-1 transition-colors bg-slate-50 px-2 py-1 rounded-md border border-slate-100", 
-                         isLast ? "text-indigo-700 bg-indigo-50 border-indigo-100 shadow-sm" : "hover:text-indigo-600 cursor-pointer")}
-                       onClick={() => !isLast && handleBreadcrumbClick(idx)}
-                     >
-                       <Icon size={14} />
-                       <span className="max-w-[120px] truncate">{crumbTitle}</span>
-                     </div>
-                     {!isLast && <ChevronRight size={14} className="text-slate-300" />}
-                   </div>
-                 );
-               })}
+        {/* 关系列:每列固定宽度 */}
+        {columns.map(col => (
+          <div key={col.t} className={cn("w-[260px] shrink-0 flex flex-col h-full bg-slate-100/50 rounded-2xl border border-slate-200 overflow-hidden", col.headerColor)}>
+            <div className="px-4 py-3 font-bold text-slate-700 border-b bg-slate-200/50 flex justify-between shrink-0">
+              {col.l}
+              <span className="text-slate-400">{col.d.length}</span>
+            </div>
+            <div className="flex-1 overflow-y-auto p-3 custom-scrollbar">
+              {col.t === 'proc' ? (
+                <div className="flex items-center justify-center h-full text-xs text-slate-300 font-bold select-none">暂无数据</div>
+              ) : (
+                sortedItems(col.d, col.t).map(item => (
+                  <RelationCard
+                    key={item.id}
+                    type={col.t}
+                    item={item}
+                    activeId={activeLeafNode ? null : activeId}
+                    isLeafActive={!!activeLeafNode}
+                    relatedIds={relatedIds}
+                    selectedLeafNames={activeLeafNode ? highlightLeafNamesFromActiveLeaf : new Set()}
+                    onSingleClick={(nodeId) => handleSingleClick(nodeId, item)}
+                    onDoubleClick={handleDoubleClick}
+                  />
+                ))
+              )}
+              {col.t !== 'proc' && col.d.length === 0 && (
+                <div className="flex items-center justify-center h-full text-xs text-slate-300 font-bold select-none">
+                  {selectedNode ? '该节点无关联数据' : '无数据'}
+                </div>
+              )}
             </div>
             </div>
-
-            {/* Content Area */}
-            {(() => {
-               const currentItem = navStack[navStack.length - 1];
-               const d = currentItem.data;
-               
-               let relReqs: any[] = [];
-               let relCaps: any[] = [];
-               let relTools: any[] = [];
-               let relKnow: any[] = [];
-
-               if (currentItem.type === 'node') {
-                  const getLeafNames = (nodes: any[]): any[] => {
-                    let leaves: any[] = [];
-                    nodes.forEach(n => {
-                      if (!n.children || n.children.length === 0) leaves.push(n);
-                      else leaves = leaves.concat(getLeafNames(n.children));
-                    });
-                    return leaves;
-                  };
-                  const leafNames = getLeafNames([d]).map(l => l.name);
-
-                  relReqs = dbData.reqs.filter((r: any) => 
-                    (r.source_nodes || []).some((sn: any) => leafNames.includes(typeof sn === 'object' ? (sn.node_name || sn.name) : sn))
-                  );
-                  const relReqIds = new Set(relReqs.map(r => r.id));
-                  relCaps = dbData.caps.filter((c: any) => (c.requirement_ids || []).some((rid: string) => relReqIds.has(rid)));
-                  const relCapIds = new Set(relCaps.map(c => c.id));
-                  relTools = dbData.tools.filter((t: any) => (t.capability_ids || []).some((cid: string) => relCapIds.has(cid)));
-                  const relToolIds = new Set(relTools.map(t => t.id));
-                  relKnow = dbData.know.filter((k: any) => {
-                    const hasCap = (k.capability_ids || []).some((cid: string) => relCapIds.has(cid));
-                    const hasTool = (k.tool_ids || []).some((tid: string) => relToolIds.has(tid));
-                    return hasCap || hasTool;
-                  });
-               }
-               else if (currentItem.type === 'req') {
-                  relCaps = dbData.caps.filter((c: any) => (c.requirement_ids || []).includes(d.id));
-               }
-               else if (currentItem.type === 'cap') {
-                  relReqs = dbData.reqs.filter((r: any) => (d.requirement_ids || []).includes(r.id));
-                  relTools = dbData.tools.filter((t: any) => (t.capability_ids || []).includes(d.id));
-                  relKnow = dbData.know.filter((k: any) => (k.capability_ids || []).includes(d.id));
-               }
-               else if (currentItem.type === 'tool') {
-                  relCaps = dbData.caps.filter((c: any) => (d.capability_ids || []).includes(c.id));
-                  relKnow = dbData.know.filter((k: any) => (k.tool_ids || []).includes(d.id));
-               }
-               else if (currentItem.type === 'know') {
-                  relCaps = dbData.caps.filter((c: any) => (d.capability_ids || []).includes(c.id));
-                  relTools = dbData.tools.filter((t: any) => (d.tool_ids || []).includes(t.id));
-               }
-
-               return (
-                 <div className="space-y-6 pb-8 animate-in fade-in slide-in-from-right-4 duration-300">
-                   {/* Dedicated Detail Views */}
-                   <div>
-                     {currentItem.type === 'node' && (
-                       <>
-                         <h2 className="text-2xl font-black text-slate-800">{d.name || "Root"}</h2>
-                         <div className="text-xs text-slate-400 font-mono mt-1 break-all bg-slate-50 p-2 rounded">{d.path || "/"}</div>
-                         {d.description && (
-                           <div className="bg-indigo-50/50 p-4 rounded-xl border border-indigo-100/50 mt-4">
-                             <h3 className="font-bold text-indigo-900 mb-2 text-sm">定义与描述</h3>
-                             <p className="text-indigo-700 text-sm leading-relaxed">{d.description}</p>
-                           </div>
-                         )}
-                       </>
-                     )}
-                     {currentItem.type === 'req' && (
-                       <>
-                         <h2 className="text-xl font-black text-slate-800 leading-snug">需求定义</h2>
-                         <p className="mt-4 text-indigo-800 text-sm leading-relaxed whitespace-pre-wrap bg-indigo-50/50 p-4 rounded-xl border border-indigo-100/50">{d.description}</p>
-                         <div className="mt-4 bg-slate-50 p-3 rounded-xl border border-slate-100">
-                           <div className="text-[10px] text-slate-500 mb-1">追踪 ID</div>
-                           <div className="font-mono text-slate-700 text-[11px] break-all">{d.id}</div>
-                         </div>
-                       </>
-                     )}
-                     {currentItem.type === 'cap' && (
-                       <>
-                         <h2 className="text-xl font-black text-amber-600">{d.name}</h2>
-                         <div className="bg-amber-50/50 p-4 rounded-xl mt-4 border border-amber-100/50">
-                           <h3 className="font-bold text-amber-900 mb-2 text-sm">能力标准定义</h3>
-                           <p className="text-amber-800 text-sm leading-relaxed">{d.description || "暂无描述"}</p>
-                         </div>
-                         <div className="mt-4 bg-slate-50 p-3 rounded-xl border border-slate-100">
-                           <div className="text-[10px] text-slate-500 mb-1">能力标识 ID</div>
-                           <div className="font-mono text-slate-700 text-[11px] break-all">{d.id}</div>
-                         </div>
-                       </>
-                     )}
-                     {currentItem.type === 'tool' && (
-                       <>
-                         <h2 className="text-xl font-black text-emerald-600">{d.name}</h2>
-                         <div className="bg-emerald-50/50 p-4 rounded-xl mt-4 border border-emerald-100/50">
-                           <h3 className="font-bold text-emerald-900 mb-2 text-sm">工具介绍</h3>
-                           <p className="text-emerald-800 text-sm leading-relaxed">{d.introduction || "暂无介绍"}</p>
-                         </div>
-                         <div className="mt-4 bg-slate-50 p-3 rounded-xl border border-slate-100">
-                           <div className="text-[10px] text-slate-500 mb-1">执行端 ID</div>
-                           <div className="font-mono text-slate-700 text-[11px] break-all">{d.id}</div>
-                         </div>
-                       </>
-                     )}
-                     {currentItem.type === 'know' && (
-                       <>
-                         <h2 className="text-xl font-black text-violet-700 leading-snug">{d.task}</h2>
-                         <div className="bg-violet-50/50 p-4 rounded-xl mt-4 border border-violet-100/50">
-                           <h3 className="font-bold text-violet-900 mb-2 text-sm">知识正文</h3>
-                           <p className="text-violet-800 text-sm whitespace-pre-wrap leading-relaxed">{d.content}</p>
-                         </div>
-                         <div className="mt-4 bg-slate-50 p-3 rounded-xl border border-slate-100">
-                           <div className="text-[10px] text-slate-500 mb-1">知识库 ID</div>
-                           <div className="font-mono text-slate-700 text-[11px] break-all">{d.id}</div>
-                         </div>
-                       </>
-                     )}
-                   </div>
-                   
-                   {currentItem.type === 'node' && (
-                     <div className="grid grid-cols-2 gap-3 mt-6">
-                        <div className="bg-slate-50 p-3 rounded-xl border border-slate-100">
-                          <div className="text-[10px] text-slate-500 mb-1">层级分支</div>
-                          <div className="font-bold text-slate-800 text-sm">{d.children?.length || 0} 个</div>
-                        </div>
-                        <div className="bg-slate-50 p-3 rounded-xl border border-slate-100">
-                          <div className="text-[10px] text-slate-500 mb-1">小红书热度</div>
-                          <div className="font-bold text-indigo-600 text-sm">{d.total_element_count || 0} 篇</div>
-                      </div>
-                      </div>
-                   )}
-
-                   {/* Relations Map */}
-                   <div className="pt-2 mt-6 border-t border-slate-100">
-                      {relReqs.length > 0 && currentItem.type !== 'req' && (
-                        <RelationGroup title="基于此的需求" count={relReqs.length} colorClass="text-indigo-600" borderClass="bg-indigo-600">
-                          <div className="space-y-1">
-                            {relReqs.map((r: any) => <CompactListCard key={r.id} data={r} type="req" onDrillDown={handleDrillDown}/>)}
-                          </div>
-                        </RelationGroup>
-                      )}
-                      {relReqs.length === 0 && currentItem.type === 'node' && (
-                        <RelationGroup title="关联需求" count={0} colorClass="text-indigo-600" borderClass="bg-indigo-600">
-                          <div className="text-xs text-slate-400 pl-4 border-l-2 border-slate-100">未检索到任何需求</div>
-                        </RelationGroup>
-                      )}
-                      
-                      {relCaps.length > 0 && currentItem.type !== 'cap' && (
-                        <RelationGroup title="下属原子能力" count={relCaps.length} colorClass="text-amber-700" borderClass="bg-amber-700">
-                          <div className="space-y-1">
-                            {relCaps.map((c: any) => <CompactListCard key={c.id} data={c} type="cap" onDrillDown={handleDrillDown}/>)}
-                          </div>
-                        </RelationGroup>
-                      )}
-                      {relCaps.length === 0 && currentItem.type === 'node' && (
-                        <RelationGroup title="下属原子能力" count={0} colorClass="text-amber-700" borderClass="bg-amber-700">
-                          <div className="text-xs text-slate-400 pl-4 border-l-2 border-slate-100">能力库为空</div>
-                        </RelationGroup>
-                      )}
-
-                      {relTools.length > 0 && currentItem.type !== 'tool' && (
-                        <RelationGroup title="相关实现工具" count={relTools.length} colorClass="text-emerald-700" borderClass="bg-emerald-700">
-                          <div className="space-y-1">
-                            {relTools.map((t: any) => <CompactListCard key={t.id} data={t} type="tool" onDrillDown={handleDrillDown}/>)}
-                          </div>
-                        </RelationGroup>
-                      )}
-                      {relTools.length === 0 && currentItem.type === 'node' && (
-                        <RelationGroup title="相关实现工具" count={0} colorClass="text-emerald-700" borderClass="bg-emerald-700">
-                          <div className="text-xs text-slate-400 pl-4 border-l-2 border-slate-100">未发现关联的执行工具</div>
-                        </RelationGroup>
-                      )}
-
-                      {relKnow.length > 0 && currentItem.type !== 'know' && (
-                        <RelationGroup title="相关支撑知识" count={relKnow.length} colorClass="text-violet-700" borderClass="bg-violet-700">
-                          <div className="space-y-1">
-                            {relKnow.map((k: any) => <CompactListCard key={k.id} data={k} type="know" onDrillDown={handleDrillDown}/>)}
-                          </div>
-                        </RelationGroup>
-                      )}
-                      {relKnow.length === 0 && currentItem.type === 'node' && (
-                        <RelationGroup title="相关支撑知识" count={0} colorClass="text-violet-700" borderClass="bg-violet-700">
-                          <div className="text-xs text-slate-400 pl-4 border-l-2 border-slate-100">无相关文档资料</div>
-                        </RelationGroup>
-                      )}
-                   </div>
-                 </div>
-               );
-            })()}
           </div>
           </div>
-        )}
+        ))}
       </div>
       </div>
+
+      {/* 详情抽屉 */}
+      <SideDrawer
+        isOpen={!!drawerItem}
+        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)) : ''}
+        width="w-[560px]"
+      >
+        {drawerItem && (
+          <DrawerContent type={drawerItem.type} data={drawerItem.data} dbData={dbData} />
+        )}
+      </SideDrawer>
+
     </div>
     </div>
   );
   );
 }
 }

+ 23 - 0
knowhub/frontend/src/services/api.ts

@@ -66,4 +66,27 @@ export const getTags = async () => {
   return fetchWithCache(`/knowledge/meta/tags`);
   return fetchWithCache(`/knowledge/meta/tags`);
 };
 };
 
 
+export const getResource = async (resourceId: string) => {
+  return fetchWithCache(`/resource/${resourceId}`);
+};
+
+export const batchGetPosts = async (postIds: string[]): Promise<Record<string, any>> => {
+  if (postIds.length === 0) return {};
+  const resp = await fetch('/api/pattern/posts/batch', {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({ post_ids: postIds }),
+  });
+  if (!resp.ok) {
+    throw new Error(`batchGetPosts failed with status ${resp.status}`);
+  }
+  const data = await resp.json();
+  const map: Record<string, any> = {};
+  (data.posts || []).forEach((p: any) => {
+    const key = p.post_id || p.id;
+    if (key) map[key] = p;
+  });
+  return map;
+};
+
 export default api;
 export default api;

+ 52 - 0
knowhub/server.py

@@ -16,6 +16,7 @@ from contextlib import asynccontextmanager
 from datetime import datetime, timezone
 from datetime import datetime, timezone
 from typing import Optional, List, Dict
 from typing import Optional, List, Dict
 from pathlib import Path
 from pathlib import Path
+import httpx
 from cryptography.hazmat.primitives.ciphers.aead import AESGCM
 from cryptography.hazmat.primitives.ciphers.aead import AESGCM
 
 
 from fastapi import FastAPI, HTTPException, Query, Header, Body, BackgroundTasks, Request
 from fastapi import FastAPI, HTTPException, Query, Header, Body, BackgroundTasks, Request
@@ -211,6 +212,10 @@ class ResourcePatchIn(BaseModel):
     metadata: Optional[dict] = None
     metadata: Optional[dict] = None
 
 
 
 
+class PostBatchRequest(BaseModel):
+    post_ids: List[str] = Field(default_factory=list)
+
+
 # Knowledge Models
 # Knowledge Models
 class KnowledgeIn(BaseModel):
 class KnowledgeIn(BaseModel):
     task: str
     task: str
@@ -2432,6 +2437,38 @@ def delete_requirement(req_id: str):
         raise
         raise
     except Exception as e:
     except Exception as e:
         raise HTTPException(status_code=500, detail=str(e))
         raise HTTPException(status_code=500, detail=str(e))
+<<<<<<< HEAD
+
+
+@app.post("/api/pattern/posts/batch")
+async def proxy_pattern_posts_batch(payload: PostBatchRequest):
+    """代理帖子批量查询,避免前端直接请求外部域名失败后静默回退为纯 ID。"""
+    post_ids = [pid for pid in payload.post_ids if pid]
+    if not post_ids:
+        return {"success": True, "posts": []}
+
+    try:
+        async with httpx.AsyncClient(timeout=30.0) as client:
+            resp = await client.post(
+                "https://pattern.aiddit.com/api/pattern/posts/batch",
+                json={"post_ids": post_ids},
+            )
+        resp.raise_for_status()
+        return resp.json()
+    except httpx.HTTPStatusError as e:
+        raise HTTPException(status_code=e.response.status_code, detail="Pattern posts API returned an error")
+    except Exception as e:
+        raise HTTPException(status_code=502, detail=f"Failed to fetch pattern posts: {e}")
+
+
+@app.get("/")
+def frontend():
+    """KnowHub 管理前端"""
+    index_file = STATIC_DIR / "index.html"
+    if not index_file.exists():
+        return HTMLResponse("<h1>KnowHub Frontend Not Found</h1><p>Please ensure knowhub/frontend/dist/index.html exists. Run 'yarn build' in frontend directory.</p>", status_code=404)
+    return FileResponse(str(index_file))
+=======
 # ===== Relation API =====
 # ===== Relation API =====
 
 
 @app.get("/api/relation/{table_name}")
 @app.get("/api/relation/{table_name}")
@@ -2482,6 +2519,7 @@ async def get_relations(table_name: str, request: Request):
             cursor.close()
             cursor.close()
     except Exception as e:
     except Exception as e:
         raise HTTPException(status_code=500, detail=str(e))
         raise HTTPException(status_code=500, detail=str(e))
+>>>>>>> origin/main
 
 
 @app.get("/category_tree.json")
 @app.get("/category_tree.json")
 def serve_category_tree():
 def serve_category_tree():
@@ -2491,9 +2529,23 @@ def serve_category_tree():
         return {"error": "Not Found"}
         return {"error": "Not Found"}
     return FileResponse(str(tree_file))
     return FileResponse(str(tree_file))
 
 
+<<<<<<< HEAD
+
+@app.get("/{frontend_path:path}")
+def frontend_spa_fallback(frontend_path: str):
+    """SPA 路由兜底:将非 API 的前端子路径回退到 index.html。"""
+    if frontend_path.startswith("api/") or frontend_path.startswith("assets/"):
+        raise HTTPException(status_code=404, detail="Not Found")
+
+    # 带扩展名的路径按静态文件处理,不走 SPA fallback。
+    if "." in Path(frontend_path).name:
+        raise HTTPException(status_code=404, detail="Not Found")
+
+=======
 @app.get("/{full_path:path}")
 @app.get("/{full_path:path}")
 def frontend(full_path: str):
 def frontend(full_path: str):
     """KnowHub 管理前端 — 所有非 API 路径都返回 index.html,由 React Router 处理"""
     """KnowHub 管理前端 — 所有非 API 路径都返回 index.html,由 React Router 处理"""
+>>>>>>> origin/main
     index_file = STATIC_DIR / "index.html"
     index_file = STATIC_DIR / "index.html"
     if not index_file.exists():
     if not index_file.exists():
         return HTMLResponse("<h1>KnowHub Frontend Not Found</h1><p>Please ensure knowhub/frontend/dist/index.html exists. Run 'yarn build' in frontend directory.</p>", status_code=404)
         return HTMLResponse("<h1>KnowHub Frontend Not Found</h1><p>Please ensure knowhub/frontend/dist/index.html exists. Run 'yarn build' in frontend directory.</p>", status_code=404)