فهرست منبع

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

guantao 18 ساعت پیش
والد
کامیت
c50a832e77

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

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

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

@@ -1,20 +1,30 @@
 import { useState } from 'react';
 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 {
   node: any;
   onSelect: (node: any) => void;
+  onDoubleClick: (node: any) => void;
   selectedId: string | number | null;
   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 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;
   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-600", border: "border-slate-700", text: "text-white" }
     ],
-    // High contrast ranges for other categories to make count distinguishable
     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: [
+      { bg: "bg-teal-100", border: "border-teal-200", text: "text-teal-900" },
+      { bg: "bg-teal-300", border: "border-teal-400", text: "text-teal-900" },
+      { bg: "bg-teal-500", border: "border-teal-600", text: "text-white" },
+      { bg: "bg-teal-600", border: "border-teal-700", text: "text-white" },
+      { bg: "bg-teal-700", border: "border-teal-800", text: "text-white" },
+      { bg: "bg-teal-900", border: "border-teal-950", text: "text-white" }
+    ],
+    3: [
       { bg: "bg-green-100", border: "border-green-200", text: "text-green-900" },
       { bg: "bg-green-300", border: "border-green-400", text: "text-green-900" },
       { bg: "bg-green-500", border: "border-green-600", text: "text-white" },
       { bg: "bg-green-600", border: "border-green-700", text: "text-white" },
       { bg: "bg-green-700", border: "border-green-800", text: "text-white" },
       { bg: "bg-green-900", border: "border-green-950", text: "text-white" }
-    ],
-    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];
-  let bgColor = theme.bg;
-  let borderColor = theme.border;
   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 (
-    <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 */}
-      <div 
+      <div
         className={cn(
-          "flex items-center px-3 py-1.5 rounded-md border shadow-[0_1px_2px_rgba(0,0,0,0.05)] cursor-pointer whitespace-nowrap transition-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)}
+        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>
         {node.id && <span className="text-[10px] opacity-60 mr-2 font-mono">{node.id}</span>}
-        
+
         {/* Count Pill */}
         <div className="flex text-[9px] bg-white/70 rounded px-1 group shadow-sm items-center">
           <span className="px-1 text-slate-500 font-medium">{node.total_element_count || 0}</span>
@@ -90,134 +104,157 @@ function HorizontalTreeNode({ node, onSelect, selectedId, level }: NodeProps) {
         </div>
 
         {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); }}
           >
-            {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>
         )}
       </div>
 
-      {/* Children Container (Horizontal Layout Connections) */}
+      {/* Children */}
       {expanded && hasChildren && (
         <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>
   );
 }
 
-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);
+
   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>
-      加载树形结构中... (请确保已重跑 python server.py)
+      加载树形结构中...
     </div>
   );
 
   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>
   );
 }

+ 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';
 
-export type TabId = 'dashboard' | 'relations' | 'requirements' | 'capabilities' | 'tools' | 'knowledge';
+export type TabId = 'dashboard' | 'requirements' | 'capabilities' | 'tools' | 'knowledge';
 
 interface NavbarProps {
   activeTab: TabId;
@@ -10,7 +10,6 @@ interface NavbarProps {
 
 const TABS = [
   { id: 'dashboard', label: 'Dashboard', icon: Home },
-  { id: 'relations', label: '关系表', icon: Waypoints },
   { id: 'requirements', label: '需求库', icon: Target },
   { id: 'capabilities', label: '能力库', icon: Cpu },
   { id: 'tools', label: '工具库', icon: Wrench },

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

@@ -8,8 +8,8 @@ interface MainLayoutProps {
   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;
 
 export function MainLayout({ activeTab, onTabChange, children }: MainLayoutProps) {
@@ -98,7 +98,7 @@ export function MainLayout({ activeTab, onTabChange, children }: MainLayoutProps
           {TAB_ORDER.map((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 max-w-[1600px] px-6 py-6">
+                <div className="w-full px-6 py-6">
                   {children(tab)}
                 </div>
               </div>

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

@@ -1,83 +1,656 @@
-import { useState, useEffect } from 'react';
-import { FolderTree, Target, Wrench, ChevronRight, Brain, FileText } from 'lucide-react';
+import { useState, useEffect, useMemo, useRef, type ReactNode, type WheelEvent } from 'react';
+import { createPortal } from 'react-dom';
+import { Target, Wrench, FileText, ListTree, Cpu, X, ChevronLeft, ChevronRight } from 'lucide-react';
 import { CategoryTree } from '../components/dashboard/CategoryTree';
+import { SideDrawer } from '../components/common/SideDrawer';
 import { cn } from '../lib/utils';
-import { getRequirements, getCapabilities, getTools, getKnowledge } from '../services/api';
+import { getRequirements, getCapabilities, getTools, getKnowledge, getResource, batchGetPosts } from '../services/api';
+
+// ─── 覆盖率统计 ────────────────────────────────────────────────────────────────
+
+function CoverageStats({ stats, weightTab, setWeightTab }: any) {
+  const data = weightTab === 'unweighted' ? [
+    { label: '全局节点', value: stats.totalLeaves, percent: '100%', color: 'bg-slate-400' },
+    { label: '需求覆盖节点', value: stats.reqCoveredNodes, percent: stats.reqCoveragePerc + '%', color: 'bg-indigo-400' },
+    { label: '工序覆盖节点', value: 0, percent: '0%', color: 'bg-purple-400' },
+    { label: '原子能力覆盖节点', value: 0, percent: '0%', color: 'bg-teal-400' },
+    { label: '工具覆盖节点', value: stats.toolCoveredNodes, percent: (stats.totalLeaves ? (stats.toolCoveredNodes / stats.totalLeaves * 100).toFixed(1) : 0) + '%', color: 'bg-green-500' },
+  ] : [
+    { label: '全局节点', value: stats.totalPostsCnt, percent: '100%', color: 'bg-slate-400' },
+    { label: '需求覆盖节点', value: stats.coveredPostsCnt, percent: stats.weightedCoveragePerc + '%', color: 'bg-indigo-400' },
+    { label: '工序覆盖节点', value: 0, percent: '0%', color: 'bg-purple-400' },
+    { label: '原子能力覆盖节点', value: 0, percent: '0%', color: 'bg-teal-400' },
+    { label: '工具覆盖节点', value: stats.toolCoveredPostsCnt, percent: (stats.totalPostsCnt ? (stats.toolCoveredPostsCnt / stats.totalPostsCnt * 100).toFixed(1) : 0) + '%', color: 'bg-green-500' },
+  ];
 
-function RelationGroup({ title, count, colorClass, borderClass, children, defaultOpen = true }: any) {
-  const [open, setOpen] = useState(defaultOpen);
   return (
-    <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>
-      {open && <div className="pl-1 mb-8">{children}</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 (
-    <div className="bg-white border border-slate-200 rounded-xl overflow-hidden shadow-sm hover:border-indigo-300 hover:shadow-md transition-all mb-2 w-full text-left">
-      <div 
-        className="flex justify-between items-center p-3 cursor-pointer hover:bg-slate-50 transition-colors group"
-        onClick={() => onDrillDown(type, data)}
-      >
-        <div className="flex items-center gap-2 font-bold text-sm text-slate-800 flex-1 pr-2">
-          <Icon size={14} className={iconColor} />
-          <span className="truncate">{title}</span>
+    <div
+      onClick={handleClick}
+      className={cn(
+        "p-3 rounded-xl cursor-pointer mb-2 select-none bg-white transition-all border-l-4",
+        tc.accent,
+        isSelected
+          ? "border border-orange-400 border-l-4 shadow-[0_0_0_1px_rgba(251,146,60,0.7)]"
+          : isRelated && hasActive
+          ? "border border-orange-300 border-l-4"
+          : dimmed
+          ? "border border-transparent border-l-4 opacity-20 grayscale scale-95"
+          : "border border-transparent border-l-4"
+      )}
+    >
+      <div className="flex items-start gap-2">
+        <div className="min-w-0 flex-1">
+          <div className="flex items-center gap-2">
+            <div className={cn("text-xs font-bold leading-snug", isSelected ? "text-orange-800" : "text-slate-700")}>
+              {label}
+            </div>
+            </div>
+          {sourceNodeTags.length > 0 && (
+            <div className="flex flex-wrap gap-1 mt-1.5">
+              {sourceNodeTags.map((name: string) => {
+                const isHighlighted = selectedLeafNames && selectedLeafNames.has(name);
+                return (
+                  <span key={name} className={cn(
+                    "text-[9px] px-1.5 py-0.5 rounded-md font-medium truncate max-w-[100px]",
+                    isHighlighted ? "bg-orange-100 text-orange-700 ring-1 ring-orange-400" : cn(tc.tagBg, tc.tagText)
+                  )}>
+                    {name}
+                  </span>
+                );
+              })}
+              {extraCount > 0 && (
+                <span className="text-[9px] px-1.5 py-0.5 rounded-md bg-slate-100 text-slate-400">+{extraCount}</span>
+              )}
+            </div>
+          )}
+        </div>
+      </div>
+    </div>
+  );
+}
+
+// ─── 详情抽屉内容 ──────────────────────────────────────────────────────────────
+
+function DrawerContent({ type, data, dbData }: { type: string; data: any; dbData: any }) {
+  const relReqs = type === 'cap' ? dbData.reqs.filter((r: any) => (data.requirement_ids || []).includes(r.id))
+    : type === 'tool' ? [] : [];
+  const relCaps = type === 'req' ? dbData.caps.filter((c: any) => (c.requirement_ids || []).includes(data.id))
+    : type === 'tool' ? dbData.caps.filter((c: any) => (data.capability_ids || []).includes(c.id))
+    : type === 'know' ? dbData.caps.filter((c: any) => (data.capability_ids || []).includes(c.id)) : [];
+  const relTools = type === 'cap' ? dbData.tools.filter((t: any) => (t.capability_ids || []).includes(data.id))
+    : type === 'know' ? dbData.tools.filter((t: any) => (data.tool_ids || []).includes(t.id)) : [];
+  const relKnow = type === 'cap' ? dbData.know.filter((k: any) => (k.capability_ids || []).includes(data.id))
+    : type === 'tool' ? dbData.know.filter((k: any) => (k.tool_ids || []).includes(data.id)) : [];
+
+  const Section = ({ title, color, items, renderItem }: any) =>
+    items.length > 0 ? (
+      <div className="mt-6">
+        <div className={cn("text-xs font-black tracking-wide mb-3", color)}>{title} ({items.length})</div>
+        <div className="space-y-2">{items.map(renderItem)}</div>
+      </div>
+    ) : null;
+
+  const MiniCard = ({ icon: Icon, label, iconColor }: any) => (
+    <div className="flex items-center gap-2 bg-white rounded-lg p-2.5 border border-slate-100 text-xs font-bold text-slate-700">
+      <Icon size={12} className={iconColor} />
+      <span className="truncate">{label}</span>
+    </div>
+  );
+
+  return (
+    <div className="space-y-4">
+      {/* 主内容 */}
+      {type === 'req' && (
+        <>
+          <div className="bg-indigo-50 p-4 rounded-xl border border-indigo-100">
+            <div className="text-xs font-bold text-indigo-500 mb-2">需求描述</div>
+            <p className="text-indigo-800 text-sm leading-relaxed whitespace-pre-wrap">{data.description}</p>
+          </div>
+          <div className="bg-slate-50 p-3 rounded-xl border border-slate-100">
+            <div className="text-[10px] text-slate-400 mb-1">追踪 ID</div>
+            <div className="font-mono text-slate-600 text-[11px] break-all">{data.id}</div>
+          </div>
+        </>
+      )}
+      {type === 'cap' && (
+        <>
+          <div className="bg-amber-50 p-4 rounded-xl border border-amber-100">
+            <div className="text-xs font-bold text-amber-600 mb-2">能力定义</div>
+            <p className="text-amber-800 text-sm leading-relaxed">{data.description || '暂无描述'}</p>
+          </div>
+          <div className="bg-slate-50 p-3 rounded-xl border border-slate-100">
+            <div className="text-[10px] text-slate-400 mb-1">能力 ID</div>
+            <div className="font-mono text-slate-600 text-[11px] break-all">{data.id}</div>
+          </div>
+        </>
+      )}
+      {type === 'tool' && (
+        <>
+          <div className="bg-emerald-50 p-4 rounded-xl border border-emerald-100">
+            <div className="text-xs font-bold text-emerald-600 mb-2">工具介绍</div>
+            <p className="text-emerald-800 text-sm leading-relaxed">{data.introduction || '暂无介绍'}</p>
+          </div>
+          {data.status && (
+            <div className="bg-slate-50 p-3 rounded-xl border border-slate-100 flex items-center justify-between">
+              <span className="text-[10px] text-slate-400">接入状态</span>
+              <span className={cn("text-xs font-bold px-2 py-1 rounded-full",
+                (data.status === '已接入' || data.status === '正常') ? 'bg-emerald-100 text-emerald-700' : 'bg-slate-200 text-slate-500'
+              )}>{data.status}</span>
+            </div>
+          )}
+          <div className="bg-slate-50 p-3 rounded-xl border border-slate-100">
+            <div className="text-[10px] text-slate-400 mb-1">执行端 ID</div>
+            <div className="font-mono text-slate-600 text-[11px] break-all">{data.id}</div>
+          </div>
+        </>
+      )}
+      {type === 'know' && (
+        <>
+          <div className="bg-violet-50 p-4 rounded-xl border border-violet-100">
+            <div className="text-xs font-bold text-violet-600 mb-2">知识正文</div>
+            <p className="text-violet-800 text-sm leading-relaxed whitespace-pre-wrap">{data.content}</p>
+          </div>
+          <div className="bg-slate-50 p-3 rounded-xl border border-slate-100">
+            <div className="text-[10px] text-slate-400 mb-1">知识库 ID</div>
+            <div className="font-mono text-slate-600 text-[11px] break-all">{data.id}</div>
+          </div>
+        </>
+      )}
+
+      {/* 关联数据 */}
+      <Section title="关联需求" color="text-indigo-600" items={relReqs}
+        renderItem={(r: any) => <MiniCard key={r.id} icon={Target} label={r.description || r.id} iconColor="text-indigo-400" />} />
+      <Section title="关联能力" color="text-amber-600" items={relCaps}
+        renderItem={(c: any) => <MiniCard key={c.id} icon={Cpu} label={c.name || c.id} iconColor="text-amber-400" />} />
+      <Section title="关联工具" color="text-emerald-600" items={relTools}
+        renderItem={(t: any) => <MiniCard key={t.id} icon={Wrench} label={t.name || t.id} iconColor="text-emerald-400" />} />
+      <Section title="关联知识" color="text-violet-600" items={relKnow}
+        renderItem={(k: any) => <MiniCard key={k.id} icon={FileText} label={k.task || k.id} iconColor="text-violet-400" />} />
+    </div>
+  );
+}
+
+function PostDetailModal({ post, postId, onClose }: { post: any; postId: string; onClose: () => void }) {
+  useEffect(() => {
+    const onKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape') onClose();
+    };
+    window.addEventListener('keydown', onKeyDown);
+    return () => window.removeEventListener('keydown', onKeyDown);
+  }, [onClose]);
+
+  const images: string[] = post?.images || [];
+
+  return createPortal(
+    <div className="fixed inset-0 z-[260] flex items-center justify-center p-6">
+      <button className="absolute inset-0 bg-slate-900/45 backdrop-blur-[1px]" onClick={onClose} aria-label="关闭帖子详情" />
+      <div className="relative z-[261] w-full max-w-4xl max-h-[85vh] overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-2xl flex flex-col">
+        <div className="flex items-start justify-between gap-4 px-6 py-4 border-b border-slate-100 shrink-0">
+          <div className="min-w-0">
+            <div className="text-xs font-bold text-slate-400 mb-1">帖子详情</div>
+            <div className="text-base font-bold text-slate-800 leading-snug">{post?.title || '无标题'}</div>
+            <div className="text-[11px] text-slate-400 mt-1 break-all">{postId}</div>
+          </div>
+          <button onClick={onClose} className="p-2 rounded-lg text-slate-400 hover:bg-slate-100 hover:text-slate-600 transition-colors">
+            <X size={16} />
+          </button>
         </div>
-        <div className="flex items-center gap-2 shrink-0">
-           {status && (
-             <span className={cn("text-[10px] px-2 py-0.5 rounded-full font-bold", 
-               (status==='已满足'||status==='已接入'||status==='正常') ? 'bg-emerald-50 text-emerald-700' : 'bg-slate-100 text-slate-500')}>
-               {status}
-             </span>
-           )}
-           <ChevronRight size={14} className="text-slate-300 group-hover:text-indigo-500 transition-colors"/>
+        <div className="overflow-y-auto px-6 py-5 space-y-5">
+          {(post?.platform || post?.platform_account_name || post?.publish_date) && (
+            <div className="flex flex-wrap gap-2">
+              {post?.platform && <span className="text-xs px-2 py-1 rounded-full bg-slate-100 text-slate-600 font-medium">{post.platform}</span>}
+              {post?.platform_account_name && <span className="text-xs px-2 py-1 rounded-full bg-indigo-50 text-indigo-700 font-medium">{post.platform_account_name}</span>}
+              {post?.publish_date && <span className="text-xs px-2 py-1 rounded-full bg-emerald-50 text-emerald-700 font-medium">{post.publish_date}</span>}
+            </div>
+          )}
+          {images.length > 0 && (
+            <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
+              {images.map((url: string, i: number) => (
+                <div key={i} className="rounded-2xl overflow-hidden bg-slate-100 border border-slate-100">
+                  <img src={url} alt="" className="w-full h-full object-cover" loading="lazy" />
+                </div>
+              ))}
+            </div>
+          )}
+          <div className="space-y-4">
+            {post?.body_text && (
+              <div className="bg-slate-50 rounded-2xl border border-slate-100 p-4">
+                <div className="text-xs font-bold text-slate-500 mb-2">正文</div>
+                <p className="text-sm text-slate-700 whitespace-pre-wrap leading-relaxed">{post.body_text}</p>
+              </div>
+            )}
+            {post?.decode_result && (
+              <div className="bg-indigo-50 rounded-2xl border border-indigo-100 p-4 space-y-3">
+                <div className="text-xs font-bold text-indigo-600">解析信息</div>
+                {Object.entries(post.decode_result).map(([key, value]) => (
+                  value ? (
+                    <div key={key}>
+                      <div className="text-[11px] font-bold text-indigo-400 uppercase tracking-wide">{key}</div>
+                      <div className="text-sm text-indigo-900 whitespace-pre-wrap leading-relaxed">{String(value)}</div>
+                    </div>
+                  ) : null
+                ))}
+              </div>
+            )}
+          </div>
         </div>
       </div>
+    </div>,
+    document.body
+  );
+}
+
+function PostCard({ postId, post, loading, onClick }: { postId: string; post?: any; loading?: boolean; onClick?: () => void }) {
+  const images: string[] = post?.images || [];
+
+  return (
+    <button
+      type="button"
+      onClick={post ? onClick : undefined}
+      className={cn(
+        "w-[220px] shrink-0 bg-white rounded-xl border border-slate-100 overflow-hidden shadow-sm flex flex-col text-left",
+        post ? "cursor-pointer hover:border-indigo-200 hover:shadow-md transition-all" : "cursor-default"
+      )}
+    >
+      {post ? (
+        <>
+          {images.length > 0 && (
+            <div className="grid grid-cols-3 gap-0.5 shrink-0">
+              {images.slice(0, 3).map((url: string, i: number) => (
+                <div key={i} className="relative aspect-square overflow-hidden bg-slate-100">
+                  <img
+                    src={url}
+                    alt=""
+                    className="w-full h-full object-cover"
+                    loading="lazy"
+                    onError={(e) => { (e.target as HTMLImageElement).parentElement!.style.display = 'none'; }}
+                  />
+                </div>
+              ))}
+            </div>
+          )}
+          <div className="px-2.5 pt-2 pb-1 shrink-0">
+            <div className="text-[11px] font-bold text-slate-800 leading-snug line-clamp-2">{post.title || '无标题'}</div>
+          </div>
+          {post.body_text && (
+            <div className="px-2.5 pb-2 flex-1 overflow-hidden">
+              <p className="text-[10px] text-slate-400 leading-relaxed line-clamp-4 whitespace-pre-wrap">{post.body_text}</p>
+            </div>
+          )}
+        </>
+      ) : !loading ? (
+        <div className="p-3 font-mono text-[10px] text-slate-300 break-all">{postId}</div>
+      ) : (
+        <div className="h-full min-h-[160px] bg-slate-50 animate-pulse"></div>
+      )}
+    </button>
+  );
+}
+
+function HorizontalPostScroller({ children, className = '' }: { children: ReactNode; className?: string }) {
+  const scrollRef = useRef<HTMLDivElement | null>(null);
+  const [canScrollLeft, setCanScrollLeft] = useState(false);
+  const [canScrollRight, setCanScrollRight] = useState(false);
+
+  const updateScrollState = () => {
+    const el = scrollRef.current;
+    if (!el) return;
+    const maxScrollLeft = el.scrollWidth - el.clientWidth;
+    setCanScrollLeft(el.scrollLeft > 4);
+    setCanScrollRight(el.scrollLeft < maxScrollLeft - 4);
+  };
+
+  useEffect(() => {
+    updateScrollState();
+    const el = scrollRef.current;
+    if (!el) return;
+    const onResize = () => updateScrollState();
+    window.addEventListener('resize', onResize);
+    return () => window.removeEventListener('resize', onResize);
+  }, [children]);
+
+  const handleWheel = (e: WheelEvent<HTMLDivElement>) => {
+    const el = scrollRef.current;
+    if (!el) return;
+
+    const delta = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY;
+    if (delta === 0) return;
+
+    const canScroll = el.scrollWidth > el.clientWidth;
+    if (!canScroll) return;
+
+    const maxScrollLeft = el.scrollWidth - el.clientWidth;
+    const nextLeft = el.scrollLeft + delta;
+    const willScrollWithinBounds = nextLeft > 0 && nextLeft < maxScrollLeft;
+
+    if (willScrollWithinBounds || (delta < 0 && el.scrollLeft > 0) || (delta > 0 && el.scrollLeft < maxScrollLeft)) {
+      e.preventDefault();
+      el.scrollLeft += delta;
+      window.requestAnimationFrame(updateScrollState);
+    }
+  };
+
+  const scrollByPage = (direction: -1 | 1) => {
+    const el = scrollRef.current;
+    if (!el) return;
+    el.scrollBy({ left: direction * Math.max(el.clientWidth * 0.8, 240), behavior: 'smooth' });
+    window.setTimeout(updateScrollState, 250);
+  };
+
+  return (
+    <div className={cn("relative min-w-0 max-w-full overflow-hidden", className)}>
+      {canScrollLeft && (
+        <button
+          type="button"
+          onClick={() => scrollByPage(-1)}
+          className="absolute left-2 top-1/2 z-20 -translate-y-1/2 rounded-full bg-white/95 border border-slate-200 shadow-md p-1.5 text-slate-500 hover:text-slate-700 hover:border-slate-300"
+          aria-label="向左滚动帖子"
+        >
+          <ChevronLeft size={16} />
+        </button>
+      )}
+      {canScrollRight && (
+        <button
+          type="button"
+          onClick={() => scrollByPage(1)}
+          className="absolute right-2 top-1/2 z-20 -translate-y-1/2 rounded-full bg-white/95 border border-slate-200 shadow-md p-1.5 text-slate-500 hover:text-slate-700 hover:border-slate-300"
+          aria-label="向右滚动帖子"
+        >
+          <ChevronRight size={16} />
+        </button>
+      )}
+      <div
+        ref={scrollRef}
+        onWheel={handleWheel}
+        onScroll={updateScrollState}
+        className="w-full overflow-x-auto overflow-y-hidden scrollbar-thin"
+      >
+        {children}
+      </div>
     </div>
   );
 }
 
+// ─── 业务需求帖子聚合抽屉 ──────────────────────────────────────────────────────
+
+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 }) {
-  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 [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({
+    totalLeaves: 0, reqCoveredNodes: 0, reqCoveragePerc: 0,
+    toolCoveredNodes: 0, toolCoveragePerc: 0, verifiedNodes: 0,
+    verifiedCoveragePerc: 0, weightedCoveragePerc: 0, coveredPostsCnt: 0,
+    totalPostsCnt: 0, toolCoveredPostsCnt: 0, toolWeightedCoveragePerc: 0,
+    verifiedPostsCnt: 0, verifiedWeightedCoveragePerc: 0
+  });
 
-  // 处理来自其他页面的跳转请求
+  // 关系列状态
+  const [activeId, setActiveId] = useState<string | null>(null);
+  const [activeLeafNode, setActiveLeafNode] = useState<any>(null); // 点击叶子节点时存储
+  const [drawerItem, setDrawerItem] = useState<{ type: string; data: any } | null>(null);
+  const [leafDetailNode, setLeafDetailNode] = useState<any>(null);
+  const [treeWideMode, setTreeWideMode] = useState(true);
+  const [requirementPostsData, setRequirementPostsData] = useState<{ requirement: any; nodePostsMap: Record<string, string[]> } | null>(null);
+  const [selectedPostDetail, setSelectedPostDetail] = useState<{ postId: string; post: any } | null>(null);
+  const [onlyCoveredFilter, setOnlyCoveredFilter] = useState(false); // 只看覆盖需求的数据
+
+  // 来自其他页面的跳转
   useEffect(() => {
     if (pendingNode && nameToNodeMap[pendingNode]) {
       setSelectedNode(nameToNodeMap[pendingNode]);
@@ -85,207 +658,120 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
     }
   }, [pendingNode, nameToNodeMap]);
 
-  useEffect(() => {
-    if (selectedNode) setNavStack([{ type: 'node', data: selectedNode }]);
-    else setNavStack([]);
-  }, [selectedNode]);
-
-  const handleDrillDown = (type: NavItem['type'], data: any) => {
-    setNavStack(prev => [...prev, { type, data }]);
-  };
-
-  const handleBreadcrumbClick = (idx: number) => {
-    setNavStack(prev => prev.slice(0, idx + 1));
-  };
-
-  const [coverageStats, setCoverageStats] = useState({
-    totalLeaves: 0,
-    reqCoveredNodes: 0,
-    reqCoveragePerc: 0,
-    toolCoveredNodes: 0,
-    toolCoveragePerc: 0,
-    verifiedNodes: 0,
-    verifiedCoveragePerc: 0,
-    weightedCoveragePerc: 0,
-    coveredPostsCnt: 0,
-    totalPostsCnt: 0,
-    toolCoveredPostsCnt: 0,
-    toolWeightedCoveragePerc: 0,
-    verifiedPostsCnt: 0,
-    verifiedWeightedCoveragePerc: 0
-  });
-
-  const [weightTab, setWeightTab] = useState<'unweighted' | 'weighted'>('unweighted');
-
   const getLeafNodes = (nodes: any[]): any[] => {
     let leaves: any[] = [];
     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;
   };
 
+  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(() => {
     async function loadStats() {
       try {
-        // 1. Fetch Tree
         const treeRes = await fetch('/category_tree.json');
         const data = await treeRes.json();
         setTreeData(data);
         const leaves = getLeafNodes([data]);
 
-        // 2. Fetch associations
         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: [] };
-        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 caps = capRes.results || [];
         const tools = toolRes.results || [];
         const know = knowRes.results || [];
-
         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 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]);
         setNameToNodeMap(nameToNode);
 
+        const nodeToReqs: Record<string, any[]> = {};
+        leaves.forEach(l => { nodeToReqs[l.name] = []; });
+
         reqs.forEach((r: any) => {
           (r.source_nodes || []).forEach((sn: any) => {
             const nodeName = typeof sn === 'object' ? (sn.node_name || sn.name) : sn;
             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 => {
           const attachedReqs = nodeToReqs[l.name];
           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 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 => {
           const count = l.total_posts_count || 0;
           totalWeight += count;
           const attachedReqs = nodeToReqs[l.name];
-          let isToolCovered = false;
-          let isVerified = false;
-
           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({
-          totalLeaves: totalLeavesCount,
-          reqCoveredNodes,
-          reqCoveragePerc: Number(reqCoveragePerc.toFixed(1)),
+          totalLeaves: totalLeavesCount, reqCoveredNodes,
+          reqCoveragePerc: Number((totalLeavesCount > 0 ? reqCoveredNodes / totalLeavesCount * 100 : 0).toFixed(1)),
           toolCoveredNodes,
-          toolCoveragePerc: Number(toolCoveragePerc.toFixed(1)),
+          toolCoveragePerc: Number((reqCoveredNodes > 0 ? toolCoveredNodes / reqCoveredNodes * 100 : 0).toFixed(1)),
           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,
-          toolWeightedCoveragePerc: Number(toolWeightedCoveragePerc.toFixed(1)),
+          toolWeightedCoveragePerc: Number((coveredWeight > 0 ? toolCoveredWeight / coveredWeight * 100 : 0).toFixed(1)),
           verifiedPostsCnt: verifiedWeight,
-          verifiedWeightedCoveragePerc: Number(verifiedWeightedCoveragePerc.toFixed(1))
+          verifiedWeightedCoveragePerc: Number((totalWeight > 0 ? verifiedWeight / totalWeight * 100 : 0).toFixed(1))
         });
-
       } catch (err) {
         console.error("Failed to load dashboard stats", err);
       }
@@ -293,301 +779,437 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
     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 (
-    <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 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 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>
 
-        {/* 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>
-
-            {/* 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>
+
+      {/* 详情抽屉 */}
+      <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>
   );
 }

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

@@ -66,4 +66,27 @@ export const getTags = async () => {
   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;

+ 52 - 0
knowhub/server.py

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