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