|
|
@@ -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>
|
|
|
+ );
|
|
|
+}
|