elksmmx 8 часов назад
Родитель
Сommit
bd4990230a
1 измененных файлов с 197 добавлено и 0 удалено
  1. 197 0
      knowhub/frontend/src/pages/Relations.tsx

+ 197 - 0
knowhub/frontend/src/pages/Relations.tsx

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