Browse Source

feat: update knowhub

elksmmx 1 day ago
parent
commit
f8381521f5

+ 36 - 9
knowhub/frontend/src/App.tsx

@@ -1,20 +1,47 @@
+import { useState } from 'react';
+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 { 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';
 import { Knowledge } from './pages/Knowledge';
 import { Knowledge } from './pages/Knowledge';
 
 
+const PATH_TO_TAB: Record<string, TabId> = {
+  '/': 'dashboard',
+  '/dashboard': 'dashboard',
+  '/requirements': 'requirements',
+  '/capabilities': 'capabilities',
+  '/tools': 'tools',
+  '/knowledge': 'knowledge',
+};
+
+const TAB_TO_PATH: Record<TabId, string> = {
+  dashboard: '/',
+  requirements: '/requirements',
+  capabilities: '/capabilities',
+  tools: '/tools',
+  knowledge: '/knowledge',
+};
+
 function App() {
 function App() {
+  const navigate = useNavigate();
+  const location = useLocation();
+  const [pendingDashboardNode, setPendingDashboardNode] = useState<string | null>(null);
+
+  const activeTab: TabId = PATH_TO_TAB[location.pathname] ?? 'dashboard';
+
+  const handleTabChange = (tab: TabId) => {
+    navigate(TAB_TO_PATH[tab]);
+  };
+
   return (
   return (
-    <MainLayout>
-      {(activeTab) => {
-        switch (activeTab) {
+    <MainLayout activeTab={activeTab} onTabChange={handleTabChange}>
+      {(tab) => {
+        switch (tab) {
           case 'dashboard':
           case 'dashboard':
-            return <Dashboard />;
-          case 'relations':
-            return <Relations />;
+            return <Dashboard pendingNode={pendingDashboardNode} onPendingConsumed={() => setPendingDashboardNode(null)} />;
           case 'requirements':
           case 'requirements':
             return <Requirements />;
             return <Requirements />;
           case 'capabilities':
           case 'capabilities':
@@ -24,11 +51,11 @@ function App() {
           case 'knowledge':
           case 'knowledge':
             return <Knowledge />;
             return <Knowledge />;
           default:
           default:
-            return <Dashboard />;
+            return <Dashboard pendingNode={null} onPendingConsumed={() => {}} />;
         }
         }
       }}
       }}
     </MainLayout>
     </MainLayout>
   );
   );
 }
 }
 
 
-export default App;
+export default App;

+ 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 },

+ 25 - 53
knowhub/frontend/src/layouts/MainLayout.tsx

@@ -1,18 +1,18 @@
-import { useState, useEffect, useRef } from 'react';
+import { useEffect, useRef } from 'react';
 import { Navbar } from '../components/layout/Navbar';
 import { Navbar } from '../components/layout/Navbar';
 import type { TabId } from '../components/layout/Navbar';
 import type { TabId } from '../components/layout/Navbar';
 
 
 interface MainLayoutProps {
 interface MainLayoutProps {
+  activeTab: TabId;
+  onTabChange: (tab: TabId) => void;
   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({ children }: MainLayoutProps) {
-  const [activeTab, setActiveTab] = useState<TabId>('dashboard');
-
+export function MainLayout({ activeTab, onTabChange, children }: MainLayoutProps) {
   const lastSwitchTime = useRef(0);
   const lastSwitchTime = useRef(0);
   const accumX = useRef(0);
   const accumX = useRef(0);
   const touchStartX = useRef(0);
   const touchStartX = useRef(0);
@@ -22,17 +22,12 @@ export function MainLayout({ children }: MainLayoutProps) {
     const handleTabSwitch = (direction: 1 | -1) => {
     const handleTabSwitch = (direction: 1 | -1) => {
       const now = Date.now();
       const now = Date.now();
       if (now - lastSwitchTime.current < MIN_SWITCH_INTERVAL) return;
       if (now - lastSwitchTime.current < MIN_SWITCH_INTERVAL) return;
-
-      setActiveTab(prev => {
-        const currentIndex = TAB_ORDER.indexOf(prev);
-        const nextIndex = currentIndex + direction;
-
-        if (nextIndex >= 0 && nextIndex < TAB_ORDER.length) {
-          lastSwitchTime.current = now;
-          return TAB_ORDER[nextIndex];
-        }
-        return prev;
-      });
+      const currentIndex = TAB_ORDER.indexOf(activeTab);
+      const nextIndex = currentIndex + direction;
+      if (nextIndex >= 0 && nextIndex < TAB_ORDER.length) {
+        lastSwitchTime.current = now;
+        onTabChange(TAB_ORDER[nextIndex]);
+      }
     };
     };
 
 
     const isInsideHorizontallyScrollable = (targetNode: EventTarget | null) => {
     const isInsideHorizontallyScrollable = (targetNode: EventTarget | null) => {
@@ -40,9 +35,7 @@ export function MainLayout({ children }: MainLayoutProps) {
       while (target && target !== document.body) {
       while (target && target !== document.body) {
         if (target.scrollWidth > target.clientWidth) {
         if (target.scrollWidth > target.clientWidth) {
           const style = window.getComputedStyle(target);
           const style = window.getComputedStyle(target);
-          if (style.overflowX === 'auto' || style.overflowX === 'scroll' || style.overflowX === 'overlay') {
-            return true;
-          }
+          if (style.overflowX === 'auto' || style.overflowX === 'scroll' || style.overflowX === 'overlay') return true;
         }
         }
         target = target.parentElement;
         target = target.parentElement;
       }
       }
@@ -53,25 +46,13 @@ export function MainLayout({ children }: MainLayoutProps) {
       if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) return;
       if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) return;
       if (isInsideHorizontallyScrollable(e.target)) return;
       if (isInsideHorizontallyScrollable(e.target)) return;
       if (e.cancelable) e.preventDefault();
       if (e.cancelable) e.preventDefault();
-
       const now = Date.now();
       const now = Date.now();
-      if (now - lastSwitchTime.current < MIN_SWITCH_INTERVAL) {
-        accumX.current = 0;
-        return;
-      }
-
+      if (now - lastSwitchTime.current < MIN_SWITCH_INTERVAL) { accumX.current = 0; return; }
       accumX.current += e.deltaX;
       accumX.current += e.deltaX;
-
       clearTimeout(wheelTimeout.current);
       clearTimeout(wheelTimeout.current);
       wheelTimeout.current = setTimeout(() => { accumX.current = 0; }, 150);
       wheelTimeout.current = setTimeout(() => { accumX.current = 0; }, 150);
-
-      if (accumX.current > 120) {
-        handleTabSwitch(1);
-        accumX.current = 0;
-      } else if (accumX.current < -120) {
-        handleTabSwitch(-1);
-        accumX.current = 0;
-      }
+      if (accumX.current > 120) { handleTabSwitch(1); accumX.current = 0; }
+      else if (accumX.current < -120) { handleTabSwitch(-1); accumX.current = 0; }
     };
     };
 
 
     const handleTouchStart = (e: TouchEvent) => {
     const handleTouchStart = (e: TouchEvent) => {
@@ -81,10 +62,8 @@ export function MainLayout({ children }: MainLayoutProps) {
 
 
     const handleTouchEnd = (e: TouchEvent) => {
     const handleTouchEnd = (e: TouchEvent) => {
       if (isInsideHorizontallyScrollable(e.target)) return;
       if (isInsideHorizontallyScrollable(e.target)) return;
-
       const now = Date.now();
       const now = Date.now();
       if (now - lastSwitchTime.current < MIN_SWITCH_INTERVAL) return;
       if (now - lastSwitchTime.current < MIN_SWITCH_INTERVAL) return;
-
       const diffX = touchStartX.current - e.changedTouches[0].clientX;
       const diffX = touchStartX.current - e.changedTouches[0].clientX;
       if (diffX > 60) handleTabSwitch(1);
       if (diffX > 60) handleTabSwitch(1);
       else if (diffX < -60) handleTabSwitch(-1);
       else if (diffX < -60) handleTabSwitch(-1);
@@ -93,42 +72,35 @@ export function MainLayout({ children }: MainLayoutProps) {
     window.addEventListener('wheel', handleWheel, { passive: false });
     window.addEventListener('wheel', handleWheel, { passive: false });
     window.addEventListener('touchstart', handleTouchStart, { passive: true });
     window.addEventListener('touchstart', handleTouchStart, { passive: true });
     window.addEventListener('touchend', handleTouchEnd, { passive: true });
     window.addEventListener('touchend', handleTouchEnd, { passive: true });
-
     return () => {
     return () => {
       window.removeEventListener('wheel', handleWheel);
       window.removeEventListener('wheel', handleWheel);
       window.removeEventListener('touchstart', handleTouchStart);
       window.removeEventListener('touchstart', handleTouchStart);
       window.removeEventListener('touchend', handleTouchEnd);
       window.removeEventListener('touchend', handleTouchEnd);
       clearTimeout(wheelTimeout.current);
       clearTimeout(wheelTimeout.current);
     };
     };
-  }, []);
+  }, [activeTab, onTabChange]);
 
 
   const currentIndex = TAB_ORDER.indexOf(activeTab);
   const currentIndex = TAB_ORDER.indexOf(activeTab);
   const totalTabs = TAB_ORDER.length;
   const totalTabs = TAB_ORDER.length;
 
 
   return (
   return (
     <div className="min-h-screen bg-slate-50 flex flex-col overflow-x-hidden" style={{ overscrollBehaviorX: 'none' }}>
     <div className="min-h-screen bg-slate-50 flex flex-col overflow-x-hidden" style={{ overscrollBehaviorX: 'none' }}>
-      <Navbar activeTab={activeTab} onTabChange={setActiveTab} />
-
-      <main className="flex-1 w-full overflow-x-hidden relative">
+      <Navbar activeTab={activeTab} onTabChange={onTabChange} />
+      <main className="w-full overflow-hidden relative" style={{ height: 'calc(100vh - 60px)' }}>
         <div
         <div
           className="flex h-full will-change-transform"
           className="flex h-full will-change-transform"
           style={{
           style={{
-            // 轨道总宽度由标签页数量动态决定,6个页面就是 600%
             width: `${totalTabs * 100}%`,
             width: `${totalTabs * 100}%`,
-            // 偏移量
             transform: `translateX(-${(currentIndex / totalTabs) * 100}%)`,
             transform: `translateX(-${(currentIndex / totalTabs) * 100}%)`,
             transition: 'transform 0.7s cubic-bezier(0.34, 1.3, 0.64, 1)'
             transition: 'transform 0.7s cubic-bezier(0.34, 1.3, 0.64, 1)'
           }}
           }}
         >
         >
           {TAB_ORDER.map((tab) => (
           {TAB_ORDER.map((tab) => (
-            <div
-              key={tab}
-              className="shrink-0 flex justify-center pb-12"
-              // 每个页面的宽度是总宽度的 1/6
-              style={{ width: `${100 / totalTabs}%` }}
-            >
-              <div className="w-full max-w-[1600px] px-6 py-6">
-                {children(tab)}
+            <div key={tab} className="shrink-0 h-full overflow-y-auto" style={{ width: `${100 / totalTabs}%` }}>
+              <div className="flex justify-center pb-12">
+                <div className="w-full px-6 py-6">
+                  {children(tab)}
+                </div>
               </div>
               </div>
             </div>
             </div>
           ))}
           ))}
@@ -136,4 +108,4 @@ export function MainLayout({ children }: MainLayoutProps) {
       </main>
       </main>
     </div>
     </div>
   );
   );
-}
+}

+ 4 - 1
knowhub/frontend/src/main.tsx

@@ -1,10 +1,13 @@
 import { StrictMode } from 'react'
 import { StrictMode } from 'react'
 import { createRoot } from 'react-dom/client'
 import { createRoot } from 'react-dom/client'
+import { BrowserRouter } from 'react-router-dom'
 import './index.css'
 import './index.css'
 import App from './App.tsx'
 import App from './App.tsx'
 
 
 createRoot(document.getElementById('root')!).render(
 createRoot(document.getElementById('root')!).render(
   <StrictMode>
   <StrictMode>
-    <App />
+    <BrowserRouter>
+      <App />
+    </BrowserRouter>
   </StrictMode>,
   </StrictMode>,
 )
 )

+ 1107 - 475
knowhub/frontend/src/pages/Dashboard.tsx

@@ -1,281 +1,777 @@
-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-400' },
+  ] : [
+    { 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-400' },
+  ];
 
 
-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>
-        <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
+      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>
       </div>
     </div>
     </div>
   );
   );
 }
 }
 
 
+// ─── 详情抽屉内容 ──────────────────────────────────────────────────────────────
 
 
-export function Dashboard() {
-  type NavItem = { type: 'node' | 'req' | 'cap' | 'tool' | 'know', data: any };
-  const [treeData, setTreeData] = useState<any>(null);
-  const [selectedNode, setSelectedNode] = useState<any>(null);
-  const [navStack, setNavStack] = useState<NavItem[]>([]);
-  const [dbData, setDbData] = useState<{ reqs: any[], caps: any[], tools: any[], know: any[] }>({
-    reqs: [], caps: [], tools: [], know: []
-  });
+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(() => {
   useEffect(() => {
-    if (selectedNode) setNavStack([{ type: 'node', data: selectedNode }]);
-    else setNavStack([]);
-  }, [selectedNode]);
+    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 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>,
+    document.body
+  );
+}
+
+function PostCard({ postId, post, loading, onClick }: { postId: string; post?: any; loading?: boolean; onClick?: () => void }) {
+  const images: string[] = post?.images || [];
 
 
-  const handleDrillDown = (type: NavItem['type'], data: any) => {
-    setNavStack(prev => [...prev, { type, data }]);
+  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 handleBreadcrumbClick = (idx: number) => {
-    setNavStack(prev => prev.slice(0, idx + 1));
+  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>
+  );
+}
+
+// ─── 业务需求帖子聚合抽屉 ──────────────────────────────────────────────────────
+
+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 }) {
+  const [treeData, setTreeData] = useState<any>(null);
+  const [selectedNode, setSelectedNode] = useState<any>(null);
+  const [nameToNodeMap, setNameToNodeMap] = useState<Record<string, any>>({});
+  const [dbData, setDbData] = useState<{ reqs: any[], caps: any[], tools: any[], know: any[] }>({
+    reqs: [], caps: [], tools: [], know: []
+  });
+  const [weightTab, setWeightTab] = useState<'unweighted' | 'weighted'>('unweighted');
   const [coverageStats, setCoverageStats] = useState({
   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
+    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 [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(() => {
+    if (pendingNode && nameToNodeMap[pendingNode]) {
+      setSelectedNode(nameToNodeMap[pendingNode]);
+      onPendingConsumed?.();
+    }
+  }, [pendingNode, nameToNodeMap]);
 
 
   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);
+
+        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);
       }
       }
@@ -283,301 +779,437 @@ export function Dashboard() {
     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>
   );
   );
 }
 }

+ 133 - 163
knowhub/frontend/src/pages/Relations.tsx

@@ -3,194 +3,164 @@ import { Target, Cpu, Wrench, ListTree, Waypoints } from 'lucide-react';
 import { getRequirements, getCapabilities, getTools } from '../services/api';
 import { getRequirements, getCapabilities, getTools } from '../services/api';
 import { cn } from '../lib/utils';
 import { cn } from '../lib/utils';
 
 
-export function Relations() {
+export function Relations({ onNavigateToDashboard }: { onNavigateToDashboard?: (nodeName: string) => void }) {
   const [reqs, setReqs] = useState<any[]>([]);
   const [reqs, setReqs] = useState<any[]>([]);
   const [caps, setCaps] = useState<any[]>([]);
   const [caps, setCaps] = useState<any[]>([]);
   const [tools, setTools] = useState<any[]>([]);
   const [tools, setTools] = useState<any[]>([]);
-  const [isLoading, setIsLoading] = useState(true);
-
-  // 记录当前点击高亮的节点 ID (格式如 "req:123" 或 "cap:456")
-  const [activeNode, setActiveNode] = useState<string | null>(null);
+  const [activeId, setActiveId] = useState<string | null>(null);
 
 
   useEffect(() => {
   useEffect(() => {
-    setIsLoading(true);
-    Promise.all([
-      getRequirements(1000),
-      getCapabilities(1000),
-      getTools(1000)
-    ]).then(([r, c, t]) => {
-      // --- 关键调试代码 ---
-      console.log("接口返回原始数据:", { r, c, t });
-
-      // 兼容两种返回格式:
-      // 1. { results: [...] }
-      // 2. 直接就是一个数组 [...]
-      const extract = (data: any) => (Array.isArray(data) ? data : (data?.results || []));
-
-      setReqs(extract(r));
-      setCaps(extract(c));
-      setTools(extract(t));
-      setIsLoading(false);
-    }).catch(err => {
-      console.error("接口请求彻底失败:", err);
-      setIsLoading(false); // 必须停掉转圈,否则用户没法用
-    });
+    Promise.all([getRequirements(1000), getCapabilities(1000), getTools(1000)])
+      .then(([r, c, t]) => {
+        setReqs(r.results || []);
+        setCaps(c.results || []);
+        setTools(t.results || []);
+      });
   }, []);
   }, []);
 
 
-  // 【核心算法 1:构建双向连通图】
-  // 把分散的表结构组合成一个内存图 (Adjacency List),便于递归寻路
-  const graph = useMemo(() => {
-     const g = new Map<string, string[]>();
-     const addEdge = (n1: string, n2: string) => {
-       if (!g.has(n1)) g.set(n1, []);
-       if (!g.has(n2)) g.set(n2, []);
-       // 建立无向图的双向连接
-       if (!g.get(n1)!.includes(n2)) g.get(n1)!.push(n2);
-       if (!g.get(n2)!.includes(n1)) g.get(n2)!.push(n1);
-     };
-
-     // 从 能力 寻找其关联的 需求
-     caps.forEach(c => {
-       (c.requirement_ids || []).forEach((rid: string) => addEdge(`cap:${c.id}`, `req:${rid}`));
-     });
-     // 从 工具 寻找其关联的 能力
-     tools.forEach(t => {
-       (t.capability_ids || []).forEach((cid: string) => addEdge(`tool:${t.id}`, `cap:${cid}`));
-     });
-     return g;
-  }, [caps, tools]);
-
-  // 【核心算法 2:BFS 广度优先搜索】
-  // 当选中一个节点时,找出网络中所有与其直接或间接连通的节点集合
-  const connectedNodes = useMemo(() => {
-     if (!activeNode) return new Set<string>();
-     const visited = new Set<string>();
-     const queue = [activeNode];
-     visited.add(activeNode);
-
-     while (queue.length > 0) {
-       const curr = queue.shift()!;
-       const neighbors = graph.get(curr) || [];
-       for (const n of neighbors) {
-         if (!visited.has(n)) {
-           visited.add(n);
-           queue.push(n);
-         }
-       }
-     }
-     return visited;
-  }, [activeNode, graph]);
+  // 核心:构建全网映射表
+  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);
+    };
+
+    reqs.forEach(r => {
+      (r.capability_ids || []).forEach((cid: string) => add(`req:${r.id}`, `cap:${cid}`));
+    });
+    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}`));
+    });
+    tools.forEach(t => {
+      (t.capability_ids || []).forEach((cid: string) => add(`tool:${t.id}`, `cap:${cid}`));
+    });
+    return map;
+  }, [reqs, caps, tools]);
+
+  // 列顺序:只允许向右(序号更大)方向遍历,防止回路
+  const colOrder: Record<string, number> = { req: 0, proc: 1, cap: 2, tool: 3 };
+  const getColType = (nodeId: string) => nodeId.split(':')[0];
+
+  // 核心:有向 BFS,携带方向状态,禁止 U 形回头
+  // dir: 0=起点(双向), 1=向右, -1=向左
+  const relatedIds = useMemo(() => {
+    if (!activeId) return new Set<string>();
+    const visited = new Set<string>([activeId]);
+    const queue: [string, number][] = [[activeId, 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, adjacencyMap]);
+
+  // 高亮元素上浮:有选中时把相关项排到前面
+  const sortedItems = (items: any[], type: string) => {
+    if (!activeId) return items;
+    const activeType = activeId.split(':')[0];
+    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 renderCard = (type: string, item: any, Icon: any, colorClass: string, bgClass: string, borderClass: string) => {
+  const renderCard = (type: string, item: any, Icon: any) => {
     const nodeId = `${type}:${item.id}`;
     const nodeId = `${type}:${item.id}`;
-    const isSelected = activeNode === nodeId; // 自己被点击
-    const isConnected = connectedNodes.has(nodeId); // 自己属于关联网络中的一员
-    const isDimmed = activeNode !== null && !isConnected; // 自己是个局外人(需要变暗)
+    const isSelected = activeId === nodeId;
+    const isRelated = !activeId || relatedIds.has(nodeId);
+    const dimmed = !!activeId && !isRelated;
+
+    // 需求列:展示 source_nodes 树节点标签(和 Dashboard 颜色一致)
+    const sourceNodeTags: string[] = type === 'req'
+      ? (item.source_nodes || []).slice(0, 4).map((sn: any) =>
+          typeof sn === 'object' ? (sn.node_name || sn.name || '') : sn
+        ).filter(Boolean)
+      : [];
+    const extraCount = type === 'req' ? Math.max(0, (item.source_nodes || []).length - 4) : 0;
 
 
     return (
     return (
       <div
       <div
-        key={item.id}
-        onClick={() => setActiveNode(isSelected ? null : nodeId)}
+        key={nodeId}
+        onClick={() => setActiveId(isSelected ? null : nodeId)}
         className={cn(
         className={cn(
-          "p-3 rounded-xl border transition-all duration-300 cursor-pointer text-left",
-          // 状态 1:当前亲自点击选中的主角
-          isSelected ? `ring-2 ring-offset-1 ${borderClass} bg-white shadow-md scale-[1.02]` :
-          // 状态 2:主角的兄弟姐妹(被点亮)
-          isConnected ? `${bgClass} ${borderClass} bg-white opacity-100` :
-          // 状态 3:无关人员(变暗,黑白)
-          isDimmed ? "opacity-30 grayscale border-slate-100 bg-slate-50 hover:opacity-60" :
-          // 状态 4:没有任何选中时的默认状态
-          `bg-white border-slate-200 hover:${borderClass} hover:shadow-md`
+          "p-3 rounded-xl cursor-pointer mb-2 select-none bg-white transition-all",
+          isSelected
+            ? "border border-orange-400 shadow-[0_0_0_1px_rgba(251,146,60,0.7)]"
+            : isRelated && activeId
+            ? "border border-orange-300"
+            : dimmed
+            ? "border border-transparent opacity-20 grayscale scale-95"
+            : "border border-transparent"
         )}
         )}
       >
       >
-        <div className="flex items-start gap-2 mb-1.5">
-          <Icon size={14} className={cn("mt-0.5 shrink-0", isSelected || isConnected ? colorClass : "text-slate-400")} />
-          <span className={cn("text-xs font-bold leading-snug line-clamp-2", isSelected || isConnected ? "text-slate-800" : "text-slate-600")}>
-            {item.name || item.description || item.id}
-          </span>
-        </div>
-        {item.introduction && type === 'tool' && (
-           <div className="text-[10px] text-slate-500 line-clamp-2 leading-relaxed pl-5">
-            {item.introduction}
+        <div className="flex items-start gap-2">
+          <Icon size={14} className={cn("mt-0.5 shrink-0", isSelected ? "text-orange-500" : "text-slate-400")} />
+          <div className="min-w-0">
+            <div className={cn("text-xs font-bold leading-snug", isSelected ? "text-orange-800" : "text-slate-700")}>
+              {item.name || item.description || item.id}
+            </div>
+            {sourceNodeTags.length > 0 && (
+              <div className="flex flex-wrap gap-1 mt-1.5" onClick={e => e.stopPropagation()}>
+                {sourceNodeTags.map((name: string) => (
+                  <span
+                    key={name}
+                    onClick={() => onNavigateToDashboard?.(name)}
+                    className="text-[9px] px-1.5 py-0.5 rounded-md bg-blue-100 text-blue-700 font-medium truncate max-w-[120px] cursor-pointer hover:bg-blue-200 transition-colors"
+                  >
+                    {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 className="text-[9px] text-slate-400 font-mono mt-2 pl-5">ID: {item.id.substring(0,6)}...</div>
+        </div>
       </div>
       </div>
     );
     );
   };
   };
 
 
-  if (isLoading) {
-     return <div className="flex justify-center items-center h-64"><div className="w-8 h-8 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin"></div></div>;
-  }
-
   return (
   return (
-    <div className="flex flex-col h-[calc(100vh-120px)] animate-in fade-in duration-500">
-      {/* 顶部标题区 */}
-      <div className="flex justify-between items-end shrink-0 mb-6">
-        <div>
-          <h1 className="text-2xl font-black text-slate-900 mb-1 flex items-center gap-2">
-            <Waypoints size={24} className="text-indigo-600"/> 全局关系追溯
-          </h1>
-          <p className="text-slate-500 text-sm">选择任意一列的节点,即可递归高亮展示整条产业链(直接与间接关联要素)。</p>
-        </div>
-        {activeNode && (
-          <button
-            onClick={() => setActiveNode(null)}
-            className="px-4 py-2 bg-slate-100 hover:bg-slate-200 text-slate-600 text-sm font-bold rounded-xl transition-colors shadow-sm"
-          >
-            清除选中关系
-          </button>
-        )}
+    <div className="flex flex-col h-[calc(100vh-156px)] animate-in fade-in duration-500">
+      <div className="flex justify-between items-center mb-6 shrink-0">
+        <h1 className="text-2xl font-black text-slate-900 flex items-center gap-2">
+          <Waypoints className="text-indigo-600" /> 全局关系追溯
+        </h1>
+        {activeId && <button onClick={() => setActiveId(null)} className="text-xs bg-slate-200 px-3 py-1.5 rounded-lg font-bold hover:bg-slate-300">清除选中</button>}
       </div>
       </div>
 
 
-      {/* 4 列主体表格 */}
       <div className="grid grid-cols-4 gap-4 flex-1 min-h-0">
       <div className="grid grid-cols-4 gap-4 flex-1 min-h-0">
-
-        {/* 第 1 列:需求 */}
-        <div className="bg-slate-50/50 border border-slate-200 rounded-2xl flex flex-col overflow-hidden">
-          <div className="bg-indigo-50 px-4 py-3 border-b border-indigo-100 font-bold text-indigo-800 flex items-center justify-between shrink-0">
-            <div className="flex items-center gap-2"><Target size={16}/> 业务需求</div>
-            <span className="text-xs bg-white px-2 py-0.5 rounded-full text-indigo-600 shadow-sm">{reqs.length}</span>
-          </div>
-          <div className="p-3 overflow-y-auto custom-scrollbar flex-1 space-y-2">
-            {reqs.map(r => renderCard('req', r, Target, 'text-indigo-600', 'bg-indigo-50/40', 'border-indigo-400'))}
+        {[ {t:'req', l:'业务需求', i:Target, d:reqs}, {t:'proc', l:'生产工序', i:ListTree, d:[]}, {t:'cap', l:'原子能力', i:Cpu, d:caps}, {t:'tool', l:'执行工具', i:Wrench, d:tools} ].map(col => (
+          <div key={col.t} className="flex flex-col h-full bg-slate-100/50 rounded-2xl border border-slate-200 overflow-hidden">
+            <div className="px-4 py-3 font-bold text-slate-700 border-b bg-slate-200/50 flex justify-between">
+              {col.l} <span className="text-slate-400">{col.d.length}</span>
+            </div>
+            <div className="flex-1 overflow-y-auto p-3 custom-scrollbar">
+              {sortedItems(col.d, col.t).map(i => renderCard(col.t, i, col.i))}
+            </div>
           </div>
           </div>
-        </div>
-
-        {/* 第 2 列:工序(暂空) */}
-        <div className="bg-slate-50/50 border border-slate-200 rounded-2xl flex flex-col overflow-hidden opacity-90">
-          <div className="bg-slate-200 px-4 py-3 border-b border-slate-300 font-bold text-slate-700 flex items-center justify-between shrink-0">
-            <div className="flex items-center gap-2"><ListTree size={16}/> 生产工序</div>
-            <span className="text-xs bg-white px-2 py-0.5 rounded-full text-slate-500 shadow-sm">0</span>
-          </div>
-          <div className="p-4 overflow-y-auto custom-scrollbar flex-1 flex flex-col items-center justify-center text-slate-400 text-sm font-medium border-2 border-dashed border-slate-200 m-3 rounded-xl bg-slate-50/50">
-            预留扩展位,暂无数据
-          </div>
-        </div>
-
-        {/* 第 3 列:能力 */}
-        <div className="bg-slate-50/50 border border-slate-200 rounded-2xl flex flex-col overflow-hidden">
-          <div className="bg-emerald-50 px-4 py-3 border-b border-emerald-100 font-bold text-emerald-800 flex items-center justify-between shrink-0">
-            <div className="flex items-center gap-2"><Cpu size={16}/> 原子能力</div>
-            <span className="text-xs bg-white px-2 py-0.5 rounded-full text-emerald-600 shadow-sm">{caps.length}</span>
-          </div>
-          <div className="p-3 overflow-y-auto custom-scrollbar flex-1 space-y-2">
-            {caps.map(c => renderCard('cap', c, Cpu, 'text-emerald-600', 'bg-emerald-50/40', 'border-emerald-400'))}
-          </div>
-        </div>
-
-        {/* 第 4 列:工具 */}
-        <div className="bg-slate-50/50 border border-slate-200 rounded-2xl flex flex-col overflow-hidden">
-          <div className="bg-amber-50 px-4 py-3 border-b border-amber-100 font-bold text-amber-800 flex items-center justify-between shrink-0">
-            <div className="flex items-center gap-2"><Wrench size={16}/> 执行工具</div>
-            <span className="text-xs bg-white px-2 py-0.5 rounded-full text-amber-600 shadow-sm">{tools.length}</span>
-          </div>
-          <div className="p-3 overflow-y-auto custom-scrollbar flex-1 space-y-2">
-             {tools.map(t => renderCard('tool', t, Wrench, 'text-amber-600', 'bg-amber-50/40', 'border-amber-400'))}
-          </div>
-        </div>
-
+        ))}
       </div>
       </div>
     </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;

+ 44 - 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
 from fastapi import FastAPI, HTTPException, Query, Header, Body, BackgroundTasks
@@ -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
@@ -2382,6 +2387,29 @@ 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))
+
+
+@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("/")
 @app.get("/")
 def frontend():
 def frontend():
     """KnowHub 管理前端"""
     """KnowHub 管理前端"""
@@ -2398,6 +2426,22 @@ def serve_category_tree():
         return {"error": "Not Found"}
         return {"error": "Not Found"}
     return FileResponse(str(tree_file))
     return FileResponse(str(tree_file))
 
 
+
+@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")
+
+    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))
+
 if __name__ == "__main__":
 if __name__ == "__main__":
     import uvicorn
     import uvicorn
     uvicorn.run(app, host="0.0.0.0", port=9999)
     uvicorn.run(app, host="0.0.0.0", port=9999)