|
@@ -484,6 +484,7 @@ function RelationCard({ type, item, activeId, shouldScrollIntoView = false, sele
|
|
|
metrics?: { nodeCount: number; reqCount: number; procCount: number; capCount: number; toolCount: number };
|
|
metrics?: { nodeCount: number; reqCount: number; procCount: number; capCount: number; toolCount: number };
|
|
|
onSingleClick: (nodeId: string) => void;
|
|
onSingleClick: (nodeId: string) => void;
|
|
|
}) {
|
|
}) {
|
|
|
|
|
+ const cardRef = useRef<HTMLDivElement | null>(null);
|
|
|
const nodeId = `${type}:${item.id}`;
|
|
const nodeId = `${type}:${item.id}`;
|
|
|
const isSelected = activeId === nodeId;
|
|
const isSelected = activeId === nodeId;
|
|
|
|
|
|
|
@@ -536,13 +537,15 @@ function RelationCard({ type, item, activeId, shouldScrollIntoView = false, sele
|
|
|
</>
|
|
</>
|
|
|
);
|
|
);
|
|
|
|
|
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ if (shouldScrollIntoView && cardRef.current) {
|
|
|
|
|
+ cardRef.current.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
|
|
|
+ }
|
|
|
|
|
+ }, [shouldScrollIntoView]);
|
|
|
|
|
+
|
|
|
return (
|
|
return (
|
|
|
<div
|
|
<div
|
|
|
- ref={(el) => {
|
|
|
|
|
- if (el && shouldScrollIntoView) {
|
|
|
|
|
- el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
|
|
|
- }
|
|
|
|
|
- }}
|
|
|
|
|
|
|
+ ref={cardRef}
|
|
|
onClick={() => onSingleClick(nodeId)}
|
|
onClick={() => onSingleClick(nodeId)}
|
|
|
className={cn(
|
|
className={cn(
|
|
|
"group p-3 rounded-xl cursor-pointer mb-2 select-none bg-white transition-all border-l-4",
|
|
"group p-3 rounded-xl cursor-pointer mb-2 select-none bg-white transition-all border-l-4",
|
|
@@ -559,9 +562,14 @@ function RelationCard({ type, item, activeId, shouldScrollIntoView = false, sele
|
|
|
<div className="flex items-start gap-2">
|
|
<div className="flex items-start gap-2">
|
|
|
<div className="min-w-0 flex-1">
|
|
<div className="min-w-0 flex-1">
|
|
|
<div className="flex items-start gap-3">
|
|
<div className="flex items-start gap-3">
|
|
|
- <div className={cn("text-xs font-bold leading-snug min-w-0", isSelected ? "text-orange-800" : "text-slate-700")}>
|
|
|
|
|
|
|
+ <div className={cn("text-xs font-bold leading-snug min-w-0 flex-1", isSelected ? "text-orange-800" : "text-slate-700")}>
|
|
|
{label}
|
|
{label}
|
|
|
</div>
|
|
</div>
|
|
|
|
|
+ {type === 'tool' && (item.status === '已接入' || item.status === 'active' || item.status === 'available' || item.status === '已使用') && (
|
|
|
|
|
+ <span className="shrink-0 text-[9px] px-1.5 py-0.5 rounded-full bg-green-100 text-green-700 font-bold border border-green-200">
|
|
|
|
|
+ 可接入
|
|
|
|
|
+ </span>
|
|
|
|
|
+ )}
|
|
|
</div>
|
|
</div>
|
|
|
{sourceNodeTags.length > 0 && (
|
|
{sourceNodeTags.length > 0 && (
|
|
|
<div className="flex flex-wrap gap-1 mt-1.5">
|
|
<div className="flex flex-wrap gap-1 mt-1.5">
|
|
@@ -611,11 +619,11 @@ function RelationCard({ type, item, activeId, shouldScrollIntoView = false, sele
|
|
|
// ─── 详情抽屉内容 ──────────────────────────────────────────────────────────────
|
|
// ─── 详情抽屉内容 ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
function DrawerContent({ type, data, dbData, onOpenPost, nodePostsMap }: { type: string; data: any; dbData: any; onOpenPost: (postId: string, post: any) => void; nodePostsMap?: Record<string, string[]> }) {
|
|
function DrawerContent({ type, data, dbData, onOpenPost, nodePostsMap }: { type: string; data: any; dbData: any; onOpenPost: (postId: string, post: any) => void; nodePostsMap?: Record<string, string[]> }) {
|
|
|
- if (type === 'req' && nodePostsMap) {
|
|
|
|
|
|
|
+ if (type === 'req') {
|
|
|
return (
|
|
return (
|
|
|
<RequirementPostsDrawer
|
|
<RequirementPostsDrawer
|
|
|
requirement={data}
|
|
requirement={data}
|
|
|
- nodePostsMap={nodePostsMap}
|
|
|
|
|
|
|
+ nodePostsMap={nodePostsMap || {}}
|
|
|
onOpenPost={onOpenPost}
|
|
onOpenPost={onOpenPost}
|
|
|
/>
|
|
/>
|
|
|
);
|
|
);
|
|
@@ -847,6 +855,61 @@ function DrawerContent({ type, data, dbData, onOpenPost, nodePostsMap }: { type:
|
|
|
</div>
|
|
</div>
|
|
|
</>
|
|
</>
|
|
|
)}
|
|
)}
|
|
|
|
|
+ {type === 'itemset' && (
|
|
|
|
|
+ <>
|
|
|
|
|
+ <div className="bg-amber-50 p-4 rounded-xl border border-amber-100">
|
|
|
|
|
+ <div className="text-xs font-bold text-amber-600 mb-2">Pattern 节点组合</div>
|
|
|
|
|
+ <div className="flex flex-wrap gap-1.5">
|
|
|
|
|
+ {(data.leaf_names || []).map((name: string) => (
|
|
|
|
|
+ <span key={name} className="px-2 py-0.5 rounded-full text-xs bg-amber-100 text-amber-800 border border-amber-200 font-medium">{name}</span>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="mt-3 flex items-center gap-3 text-[11px] text-amber-700">
|
|
|
|
|
+ <span>支持度 <span className="font-bold">{data.absolute_support}</span></span>
|
|
|
|
|
+ <span className="text-amber-400">·</span>
|
|
|
|
|
+ <span className="font-mono text-amber-500">#{data.id}</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {(data.req_associations || []).length > 0 && (
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <div className="text-xs font-bold text-slate-500 mb-2">关联需求 ({data.req_associations.length})</div>
|
|
|
|
|
+ <div className="space-y-3">
|
|
|
|
|
+ {(data.req_associations as any[]).map((assoc: any) => {
|
|
|
|
|
+ const trueNodes = (assoc.judgments || []).filter((j: any) => j.represents).map((j: any) => j.node);
|
|
|
|
|
+ const falseNodes = (assoc.judgments || []).filter((j: any) => !j.represents).map((j: any) => j.node);
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div key={assoc.req_id} className="bg-white border border-slate-100 rounded-xl p-3 space-y-2">
|
|
|
|
|
+ <div className="flex items-center gap-2">
|
|
|
|
|
+ <span className="font-mono text-[10px] text-indigo-500 shrink-0">{assoc.req_id}</span>
|
|
|
|
|
+ <span className="text-xs text-slate-700 leading-snug line-clamp-2">{assoc.req_text}</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ {assoc.judgments?.length > 0 && (
|
|
|
|
|
+ <div className="flex flex-wrap gap-1">
|
|
|
|
|
+ {(assoc.judgments as any[]).map((j: any) => (
|
|
|
|
|
+ <span key={j.node} title={j.reason} className={cn(
|
|
|
|
|
+ "px-1.5 py-0.5 rounded-full text-[10px] border font-medium cursor-help",
|
|
|
|
|
+ j.represents
|
|
|
|
|
+ ? "bg-emerald-50 text-emerald-700 border-emerald-200"
|
|
|
|
|
+ : "bg-slate-50 text-slate-400 border-slate-200 line-through"
|
|
|
|
|
+ )}>{j.node}</span>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ {trueNodes.length === 0 && falseNodes.length > 0 && (
|
|
|
|
|
+ <div className="text-[10px] text-slate-400">无节点被判断为代表该需求</div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ );
|
|
|
|
|
+ })}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ {(data.req_associations || []).length === 0 && (
|
|
|
|
|
+ <div className="text-xs text-slate-400 text-center py-4">暂无关联需求数据</div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </>
|
|
|
|
|
+ )}
|
|
|
</div>
|
|
</div>
|
|
|
);
|
|
);
|
|
|
}
|
|
}
|
|
@@ -1155,9 +1218,7 @@ function RequirementPostsDrawer({
|
|
|
setPosts({});
|
|
setPosts({});
|
|
|
batchGetPosts(allPostIds)
|
|
batchGetPosts(allPostIds)
|
|
|
.then(map => setPosts(map))
|
|
.then(map => setPosts(map))
|
|
|
- .catch(err => {
|
|
|
|
|
- console.error('Failed to load requirement posts:', err);
|
|
|
|
|
- })
|
|
|
|
|
|
|
+ .catch(err => { console.error('Failed to load requirement posts:', err); })
|
|
|
.finally(() => setLoading(false));
|
|
.finally(() => setLoading(false));
|
|
|
}, [allPostIds, requirement.id]);
|
|
}, [allPostIds, requirement.id]);
|
|
|
|
|
|
|
@@ -1165,7 +1226,6 @@ function RequirementPostsDrawer({
|
|
|
|
|
|
|
|
return (
|
|
return (
|
|
|
<div className="flex flex-col gap-3 h-full overflow-hidden min-w-0">
|
|
<div className="flex flex-col gap-3 h-full overflow-hidden min-w-0">
|
|
|
- {/* 顶部:节点列表 */}
|
|
|
|
|
<div className="shrink-0 bg-slate-50 rounded-xl border border-slate-100 p-3">
|
|
<div className="shrink-0 bg-slate-50 rounded-xl border border-slate-100 p-3">
|
|
|
<div className="text-xs font-bold text-slate-600 mb-2">关联节点 ({nodeNames.length})</div>
|
|
<div className="text-xs font-bold text-slate-600 mb-2">关联节点 ({nodeNames.length})</div>
|
|
|
<div className="flex flex-wrap gap-2 max-h-[140px] overflow-y-auto">
|
|
<div className="flex flex-wrap gap-2 max-h-[140px] overflow-y-auto">
|
|
@@ -1192,27 +1252,25 @@ function RequirementPostsDrawer({
|
|
|
))}
|
|
))}
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
-
|
|
|
|
|
- {/* 下方:帖子竖向列表 */}
|
|
|
|
|
<div className="flex-1 min-h-0 overflow-y-auto pr-1">
|
|
<div className="flex-1 min-h-0 overflow-y-auto pr-1">
|
|
|
<div className="grid grid-cols-1 gap-3">
|
|
<div className="grid grid-cols-1 gap-3">
|
|
|
- {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 (
|
|
|
|
|
- <div key={pid} className="w-full">
|
|
|
|
|
- <PostCard postId={pid} post={post} compact onClick={() => onOpenPost(pid, post)} />
|
|
|
|
|
|
|
+ {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>
|
|
</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 (
|
|
|
|
|
+ <div key={pid} className="w-full">
|
|
|
|
|
+ <PostCard postId={pid} post={post} compact onClick={() => onOpenPost(pid, post)} />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ );
|
|
|
|
|
+ })}
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
@@ -1294,6 +1352,153 @@ function LeafNodeDrawer({ node, onOpenPost }: { node: any; onOpenPost: (postId:
|
|
|
);
|
|
);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+// ─── 频繁模式列 ────────────────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+function PatternColumn({
|
|
|
|
|
+ selectedItemsetId,
|
|
|
|
|
+ itemsets,
|
|
|
|
|
+ eligibleItemsetIds,
|
|
|
|
|
+ contextNodeNames,
|
|
|
|
|
+ nodeRoleByName,
|
|
|
|
|
+ hasAnyFilter,
|
|
|
|
|
+ focusIndex,
|
|
|
|
|
+ onSelectItemset,
|
|
|
|
|
+ onOpenDrawer,
|
|
|
|
|
+ onFocusPrev,
|
|
|
|
|
+ onFocusNext,
|
|
|
|
|
+}: {
|
|
|
|
|
+ selectedItemsetId: number | null;
|
|
|
|
|
+ itemsets: any[];
|
|
|
|
|
+ eligibleItemsetIds: Set<string>;
|
|
|
|
|
+ contextNodeNames: Set<string>;
|
|
|
|
|
+ nodeRoleByName: Record<string, 'substance' | 'form' | 'both'>;
|
|
|
|
|
+ hasAnyFilter: boolean;
|
|
|
|
|
+ focusIndex: number;
|
|
|
|
|
+ onSelectItemset: (itemsetId: number | null) => void;
|
|
|
|
|
+ onOpenDrawer: (itemset: any) => void;
|
|
|
|
|
+ onFocusPrev: () => void;
|
|
|
|
|
+ onFocusNext: () => void;
|
|
|
|
|
+}) {
|
|
|
|
|
+ const itemRefs = useRef<Record<number, HTMLDivElement | null>>({});
|
|
|
|
|
+ const displayItemsets = useMemo(() => {
|
|
|
|
|
+ const withMatches = [...itemsets].map(itemset => {
|
|
|
|
|
+ const leafNames: string[] = itemset.leaf_names || [];
|
|
|
|
|
+ const matchedNodes = contextNodeNames.size > 0 ? leafNames.filter(n => contextNodeNames.has(n)) : [];
|
|
|
|
|
+ return { ...itemset, matched_nodes: matchedNodes };
|
|
|
|
|
+ });
|
|
|
|
|
+ return withMatches.sort((a, b) => {
|
|
|
|
|
+ const aEligible = eligibleItemsetIds.has(String(a.id)) ? 0 : 1;
|
|
|
|
|
+ const bEligible = eligibleItemsetIds.has(String(b.id)) ? 0 : 1;
|
|
|
|
|
+ if (aEligible !== bEligible) return aEligible - bEligible;
|
|
|
|
|
+ return b.absolute_support - a.absolute_support;
|
|
|
|
|
+ });
|
|
|
|
|
+ }, [contextNodeNames, itemsets, eligibleItemsetIds]);
|
|
|
|
|
+
|
|
|
|
|
+ const matchedIndices = useMemo(() => {
|
|
|
|
|
+ const indices: number[] = [];
|
|
|
|
|
+ displayItemsets.forEach((itemset, idx) => {
|
|
|
|
|
+ const isSelected = selectedItemsetId === itemset.id;
|
|
|
|
|
+ const isEligible = eligibleItemsetIds.has(String(itemset.id));
|
|
|
|
|
+ if (isSelected || isEligible) indices.push(idx);
|
|
|
|
|
+ });
|
|
|
|
|
+ return indices;
|
|
|
|
|
+ }, [displayItemsets, selectedItemsetId, eligibleItemsetIds]);
|
|
|
|
|
+
|
|
|
|
|
+ const clampedFocusIdx = matchedIndices.length > 0 ? Math.min(focusIndex, matchedIndices.length - 1) : 0;
|
|
|
|
|
+ const focusedItemIndex = matchedIndices[clampedFocusIdx] ?? -1;
|
|
|
|
|
+
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ if (focusedItemIndex >= 0) {
|
|
|
|
|
+ itemRefs.current[focusedItemIndex]?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
|
|
|
+ }
|
|
|
|
|
+ }, [focusedItemIndex]);
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div className="w-[260px] shrink-0 flex flex-col h-full bg-slate-100/50 rounded-2xl border border-slate-200 overflow-hidden border-t-2 border-t-amber-400">
|
|
|
|
|
+ <div className="px-4 py-3 font-bold text-slate-700 border-b bg-slate-200/50 shrink-0">
|
|
|
|
|
+ <div className="flex justify-between items-center">
|
|
|
|
|
+ <span>Pattern</span>
|
|
|
|
|
+ <div className="flex items-center gap-2">
|
|
|
|
|
+ {matchedIndices.length > 1 && (
|
|
|
|
|
+ <div className="flex items-center gap-1">
|
|
|
|
|
+ <button
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ onClick={onFocusPrev}
|
|
|
|
|
+ disabled={clampedFocusIdx === 0}
|
|
|
|
|
+ className="p-0.5 rounded text-slate-400 hover:text-slate-600 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
|
|
|
|
+ >
|
|
|
|
|
+ <ChevronLeft size={13} />
|
|
|
|
|
+ </button>
|
|
|
|
|
+ <span className="text-[10px] font-bold text-slate-500 min-w-[28px] text-center">
|
|
|
|
|
+ {clampedFocusIdx + 1}/{matchedIndices.length}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ <button
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ onClick={onFocusNext}
|
|
|
|
|
+ disabled={clampedFocusIdx === matchedIndices.length - 1}
|
|
|
|
|
+ className="p-0.5 rounded text-slate-400 hover:text-slate-600 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
|
|
|
|
+ >
|
|
|
|
|
+ <ChevronRight size={13} />
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ <span className="text-slate-400">{displayItemsets.length}</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="flex-1 overflow-y-auto p-3 custom-scrollbar">
|
|
|
|
|
+ {displayItemsets.map((itemset, idx) => {
|
|
|
|
|
+ const isSelected = selectedItemsetId === itemset.id;
|
|
|
|
|
+ const isEligible = eligibleItemsetIds.has(String(itemset.id));
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div
|
|
|
|
|
+ key={itemset.id}
|
|
|
|
|
+ ref={(el) => {
|
|
|
|
|
+ itemRefs.current[idx] = el;
|
|
|
|
|
+ }}
|
|
|
|
|
+ onClick={() => {
|
|
|
|
|
+ const next = isSelected ? null : itemset.id;
|
|
|
|
|
+ onSelectItemset(next);
|
|
|
|
|
+ if (next !== null) onOpenDrawer(itemset);
|
|
|
|
|
+ }}
|
|
|
|
|
+ className={cn(
|
|
|
|
|
+ "bg-white rounded-xl p-3 mb-2 space-y-2 cursor-pointer transition-all border-l-4 border-l-amber-300",
|
|
|
|
|
+ isSelected
|
|
|
|
|
+ ? "border border-orange-400 shadow-md ring-2 ring-orange-200"
|
|
|
|
|
+ : isEligible
|
|
|
|
|
+ ? "border border-sky-300 bg-sky-50/70 shadow-[0_0_0_1px_rgba(125,211,252,0.5)]"
|
|
|
|
|
+ : "border border-slate-100 hover:border-amber-200",
|
|
|
|
|
+ hasAnyFilter && !isEligible && !isSelected && "opacity-40"
|
|
|
|
|
+ )}
|
|
|
|
|
+ >
|
|
|
|
|
+ <div className="flex items-center justify-between">
|
|
|
|
|
+ <span className="text-[10px] font-mono text-slate-400">#{itemset.id}</span>
|
|
|
|
|
+ <span className="text-[10px] text-slate-500">支持度 <span className="font-bold text-slate-700">{itemset.absolute_support}</span></span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="flex flex-wrap gap-1">
|
|
|
|
|
+ {(itemset.leaf_names || []).map((name: string) => {
|
|
|
|
|
+ const isMatched = itemset.matched_nodes.includes(name);
|
|
|
|
|
+ const role = nodeRoleByName[name];
|
|
|
|
|
+ return (
|
|
|
|
|
+ <span key={name} className={cn(
|
|
|
|
|
+ "px-2 py-0.5 rounded-full text-xs border",
|
|
|
|
|
+ isMatched && role === 'substance' ? "bg-orange-50 text-orange-700 border-orange-200 font-bold" :
|
|
|
|
|
+ isMatched && role === 'form' ? "bg-sky-50 text-sky-700 border-sky-200 font-bold" :
|
|
|
|
|
+ isMatched && role === 'both' ? "bg-emerald-50 text-emerald-700 border-emerald-200 font-bold" :
|
|
|
|
|
+ isMatched ? "bg-slate-100 text-slate-700 border-slate-300 font-bold" :
|
|
|
|
|
+ "bg-slate-50 text-slate-500 border-slate-200"
|
|
|
|
|
+ )}>{name}</span>
|
|
|
|
|
+ );
|
|
|
|
|
+ })}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ );
|
|
|
|
|
+ })}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ );
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
// ─── Dashboard 主体 ────────────────────────────────────────────────────────────
|
|
// ─── Dashboard 主体 ────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: string | null, onPendingConsumed?: () => void }) {
|
|
export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: string | null, onPendingConsumed?: () => void }) {
|
|
@@ -1306,7 +1511,7 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
});
|
|
});
|
|
|
// 关系列状态
|
|
// 关系列状态
|
|
|
const [drawerItem, setDrawerItem] = useState<{ type: string; data: any; nodePostsMap?: Record<string, string[]> } | null>(null);
|
|
const [drawerItem, setDrawerItem] = useState<{ type: string; data: any; nodePostsMap?: Record<string, string[]> } | null>(null);
|
|
|
- const [treeWideMode, setTreeWideMode] = useState(true);
|
|
|
|
|
|
|
+ const [treeWideMode, setTreeWideMode] = useState(false);
|
|
|
const [selectedPostDetail, setSelectedPostDetail] = useState<{ postId: string; post: any } | null>(null);
|
|
const [selectedPostDetail, setSelectedPostDetail] = useState<{ postId: string; post: any } | null>(null);
|
|
|
const [onlyCoveredFilter, setOnlyCoveredFilter] = useState(false); // 只看覆盖需求的数据
|
|
const [onlyCoveredFilter, setOnlyCoveredFilter] = useState(false); // 只看覆盖需求的数据
|
|
|
const [selectedProcId, setSelectedProcId] = useState<string | null>(null);
|
|
const [selectedProcId, setSelectedProcId] = useState<string | null>(null);
|
|
@@ -1317,6 +1522,8 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
const [treeFocusIndex, setTreeFocusIndex] = useState(0);
|
|
const [treeFocusIndex, setTreeFocusIndex] = useState(0);
|
|
|
const [dashboardLoadingText, setDashboardLoadingText] = useState<string | null>('开始初始化底座...');
|
|
const [dashboardLoadingText, setDashboardLoadingText] = useState<string | null>('开始初始化底座...');
|
|
|
const [flowBoardExpanded, setFlowBoardExpanded] = useState(false);
|
|
const [flowBoardExpanded, setFlowBoardExpanded] = useState(false);
|
|
|
|
|
+ const [allItemsets, setAllItemsets] = useState<any[]>([]);
|
|
|
|
|
+ const [selectedItemsetId, setSelectedItemsetId] = useState<number | null>(null);
|
|
|
|
|
|
|
|
// 来自其他页面的跳转
|
|
// 来自其他页面的跳转
|
|
|
useEffect(() => {
|
|
useEffect(() => {
|
|
@@ -1563,6 +1770,9 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
const leaves = getLeafNodes([data]);
|
|
const leaves = getLeafNodes([data]);
|
|
|
setDbData({ reqs, caps, tools, know, procs });
|
|
setDbData({ reqs, caps, tools, know, procs });
|
|
|
|
|
|
|
|
|
|
+ // 加载频繁项集数据
|
|
|
|
|
+ fetch('/itemsets_all.json').then(r => r.json()).then(d => setAllItemsets(d.itemsets || [])).catch(() => {});
|
|
|
|
|
+
|
|
|
const nameToNode: Record<string, any> = {};
|
|
const nameToNode: Record<string, any> = {};
|
|
|
const idToNode: Record<string, any> = {};
|
|
const idToNode: Record<string, any> = {};
|
|
|
const buildNameMap = (nodes: any[]) => {
|
|
const buildNameMap = (nodes: any[]) => {
|
|
@@ -1701,6 +1911,23 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
return new Set(collectDirectNodesFromReqs(reqs).map((node: any) => node.name).filter(Boolean));
|
|
return new Set(collectDirectNodesFromReqs(reqs).map((node: any) => node.name).filter(Boolean));
|
|
|
}, [collectDirectNodesFromReqs]);
|
|
}, [collectDirectNodesFromReqs]);
|
|
|
|
|
|
|
|
|
|
+ const collectDirectNodeIdsFromNames = useCallback((nodeNames: Iterable<string>, skipEmptyFilter = false): Set<string> => {
|
|
|
|
|
+ const ids = new Set<string>();
|
|
|
|
|
+ for (const nodeName of nodeNames) {
|
|
|
|
|
+ if (!nodeName) continue;
|
|
|
|
|
+ (nameToNodesMap[nodeName] || []).forEach((directNode: any) => {
|
|
|
|
|
+ if (!skipEmptyFilter) {
|
|
|
|
|
+ const hasChildren = directNode.children && directNode.children.length > 0;
|
|
|
|
|
+ if (!(hasChildren || (directNode.total_posts_count || 0) > 0)) return;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (directNode.id !== undefined && directNode.id !== null) {
|
|
|
|
|
+ ids.add(String(directNode.id));
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+ return ids;
|
|
|
|
|
+ }, [nameToNodesMap]);
|
|
|
|
|
+
|
|
|
const reqById = useMemo(() => new Map(dbData.reqs.map((req: any) => [req.id, req])), [dbData.reqs]);
|
|
const reqById = useMemo(() => new Map(dbData.reqs.map((req: any) => [req.id, req])), [dbData.reqs]);
|
|
|
|
|
|
|
|
const collectDirectNodeIdsFromReqIds = useCallback((reqIds: Iterable<string>): Set<string> => {
|
|
const collectDirectNodeIdsFromReqIds = useCallback((reqIds: Iterable<string>): Set<string> => {
|
|
@@ -1870,6 +2097,127 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
);
|
|
);
|
|
|
}, [dbData.procs, selectedToolCapabilityIds, selectedToolId]);
|
|
}, [dbData.procs, selectedToolCapabilityIds, selectedToolId]);
|
|
|
|
|
|
|
|
|
|
+ const selectedItemset = useMemo(
|
|
|
|
|
+ () => allItemsets.find((itemset: any) => itemset.id === selectedItemsetId) || null,
|
|
|
|
|
+ [allItemsets, selectedItemsetId]
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ const itemsetReqIdsMap = useMemo(() => {
|
|
|
|
|
+ const map = new Map<string, Set<string>>();
|
|
|
|
|
+ allItemsets.forEach((itemset: any) => {
|
|
|
|
|
+ const leafNames = new Set<string>((itemset.leaf_names || []).filter(Boolean));
|
|
|
|
|
+ const ids = new Set<string>();
|
|
|
|
|
+ dbData.reqs.forEach((req: any) => {
|
|
|
|
|
+ const meta = (req.source_nodes || []).find((sn: any) => {
|
|
|
|
|
+ const nodeName = typeof sn === 'object' ? (sn.node_name || sn.name) : sn;
|
|
|
|
|
+ return nodeName === '__meta__';
|
|
|
|
|
+ });
|
|
|
|
|
+ const ctx = meta?.extraction_context || {};
|
|
|
|
|
+ const reqNodes = new Set<string>([
|
|
|
|
|
+ ...(ctx.substance?.nodes || []),
|
|
|
|
|
+ ...(ctx.form?.nodes || []),
|
|
|
|
|
+ ]);
|
|
|
|
|
+ const hasMatch = Array.from(leafNames).some((name) => reqNodes.has(name));
|
|
|
|
|
+ if (hasMatch) ids.add(req.id);
|
|
|
|
|
+ });
|
|
|
|
|
+ map.set(String(itemset.id), ids);
|
|
|
|
|
+ });
|
|
|
|
|
+ return map;
|
|
|
|
|
+ }, [allItemsets, dbData.reqs]);
|
|
|
|
|
+
|
|
|
|
|
+ const selectedItemsetLeafNames = useMemo(() => {
|
|
|
|
|
+ if (!selectedItemset) return null;
|
|
|
|
|
+ return new Set<string>((selectedItemset.leaf_names || []).filter(Boolean));
|
|
|
|
|
+ }, [selectedItemset]);
|
|
|
|
|
+
|
|
|
|
|
+ const selectedNodeDirectNames = useMemo(() => {
|
|
|
|
|
+ if (!selectedNode) return null;
|
|
|
|
|
+ return new Set<string>(selectedNode.name ? [selectedNode.name] : []);
|
|
|
|
|
+ }, [selectedNode]);
|
|
|
|
|
+
|
|
|
|
|
+ const selectedItemsetReqIds = useMemo(() => {
|
|
|
|
|
+ if (!selectedItemset) return null;
|
|
|
|
|
+ return itemsetReqIdsMap.get(String(selectedItemset.id)) || new Set<string>();
|
|
|
|
|
+ }, [itemsetReqIdsMap, selectedItemset]);
|
|
|
|
|
+
|
|
|
|
|
+ const selectedItemsetDirectNodeIds = useMemo((): Set<string> | null => {
|
|
|
|
|
+ if (!selectedItemset) return null;
|
|
|
|
|
+ const ids = collectDirectNodeIdsFromNames((selectedItemset.leaf_names || []).filter(Boolean), true);
|
|
|
|
|
+ return ids.size > 0 ? ids : null;
|
|
|
|
|
+ }, [selectedItemset, collectDirectNodeIdsFromNames]);
|
|
|
|
|
+
|
|
|
|
|
+ const relatedItemsetIds = useMemo((): Set<string> => {
|
|
|
|
|
+ const byNode = selectedNode
|
|
|
|
|
+ ? new Set<string>(
|
|
|
|
|
+ allItemsets
|
|
|
|
|
+ .filter((itemset: any) => (itemset.leaf_names || []).some((name: string) => selectedNodeDirectNames?.has(name)))
|
|
|
|
|
+ .map((itemset: any) => String(itemset.id))
|
|
|
|
|
+ )
|
|
|
|
|
+ : null;
|
|
|
|
|
+ const byReq = selectedReqId
|
|
|
|
|
+ ? new Set<string>(
|
|
|
|
|
+ allItemsets
|
|
|
|
|
+ .filter((itemset: any) => (itemsetReqIdsMap.get(String(itemset.id)) || new Set<string>()).has(selectedReqId))
|
|
|
|
|
+ .map((itemset: any) => String(itemset.id))
|
|
|
|
|
+ )
|
|
|
|
|
+ : null;
|
|
|
|
|
+ const byProc = selectedProcId
|
|
|
|
|
+ ? new Set<string>(
|
|
|
|
|
+ allItemsets
|
|
|
|
|
+ .filter((itemset: any) => {
|
|
|
|
|
+ const reqIds = itemsetReqIdsMap.get(String(itemset.id)) || new Set<string>();
|
|
|
|
|
+ return Array.from(reqIds).some((reqId) => selectedProcRequirementIds.has(reqId));
|
|
|
|
|
+ })
|
|
|
|
|
+ .map((itemset: any) => String(itemset.id))
|
|
|
|
|
+ )
|
|
|
|
|
+ : null;
|
|
|
|
|
+ const byCap = selectedCapId
|
|
|
|
|
+ ? new Set<string>(
|
|
|
|
|
+ allItemsets
|
|
|
|
|
+ .filter((itemset: any) => {
|
|
|
|
|
+ const reqIds = itemsetReqIdsMap.get(String(itemset.id)) || new Set<string>();
|
|
|
|
|
+ return Array.from(reqIds).some((reqId) => selectedCapRequirementIds.has(reqId));
|
|
|
|
|
+ })
|
|
|
|
|
+ .map((itemset: any) => String(itemset.id))
|
|
|
|
|
+ )
|
|
|
|
|
+ : null;
|
|
|
|
|
+ const byTool = selectedToolId
|
|
|
|
|
+ ? new Set<string>(
|
|
|
|
|
+ allItemsets
|
|
|
|
|
+ .filter((itemset: any) => {
|
|
|
|
|
+ const reqIds = itemsetReqIdsMap.get(String(itemset.id)) || new Set<string>();
|
|
|
|
|
+ return Array.from(reqIds).some((reqId) => selectedToolRequirementIds.has(reqId));
|
|
|
|
|
+ })
|
|
|
|
|
+ .map((itemset: any) => String(itemset.id))
|
|
|
|
|
+ )
|
|
|
|
|
+ : null;
|
|
|
|
|
+
|
|
|
|
|
+ const activeSets = [byNode, byReq, byProc, byCap, byTool].filter((set): set is Set<string> => !!set);
|
|
|
|
|
+ if (activeSets.length === 0) return new Set<string>();
|
|
|
|
|
+ const [first, ...rest] = activeSets;
|
|
|
|
|
+ const result = new Set<string>();
|
|
|
|
|
+ first.forEach((value) => {
|
|
|
|
|
+ if (rest.every((set) => set.has(value))) result.add(value);
|
|
|
|
|
+ });
|
|
|
|
|
+ return result;
|
|
|
|
|
+ }, [allItemsets, itemsetReqIdsMap, selectedNode, selectedNodeDirectNames, selectedReqId, selectedProcId, selectedCapId, selectedToolId, selectedProcRequirementIds, selectedCapRequirementIds, selectedToolRequirementIds]);
|
|
|
|
|
+
|
|
|
|
|
+ const patternContextNodeNames = useMemo(() => {
|
|
|
|
|
+ const names = new Set<string>();
|
|
|
|
|
+ (selectedNodeDirectNames || new Set<string>()).forEach((name) => names.add(name));
|
|
|
|
|
+ if (selectedReqId) {
|
|
|
|
|
+ const req = dbData.reqs.find((r: any) => r.id === selectedReqId);
|
|
|
|
|
+ const meta = (req.source_nodes || []).find((sn: any) => {
|
|
|
|
|
+ const nodeName = typeof sn === 'object' ? (sn.node_name || sn.name) : sn;
|
|
|
|
|
+ return nodeName === '__meta__';
|
|
|
|
|
+ });
|
|
|
|
|
+ const ctx = meta?.extraction_context || {};
|
|
|
|
|
+ (ctx.substance?.nodes || []).forEach((name: string) => name && names.add(name));
|
|
|
|
|
+ (ctx.form?.nodes || []).forEach((name: string) => name && names.add(name));
|
|
|
|
|
+ }
|
|
|
|
|
+ return names;
|
|
|
|
|
+ }, [selectedNodeDirectNames, selectedReqId, dbData.reqs]);
|
|
|
|
|
+
|
|
|
const requirementVisible = (req: any) => {
|
|
const requirementVisible = (req: any) => {
|
|
|
if (onlyCoveredFilter) {
|
|
if (onlyCoveredFilter) {
|
|
|
const hasCoveredSourceNode = (req.source_nodes || []).some((sn: any) => {
|
|
const hasCoveredSourceNode = (req.source_nodes || []).some((sn: any) => {
|
|
@@ -1912,6 +2260,7 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
if (!matchesRealCap && !matchesDerivedCap) return false;
|
|
if (!matchesRealCap && !matchesDerivedCap) return false;
|
|
|
}
|
|
}
|
|
|
if (selectedToolId && !(req.capability_ids || []).some((cid: string) => selectedToolCapabilityIds.has(cid))) return false;
|
|
if (selectedToolId && !(req.capability_ids || []).some((cid: string) => selectedToolCapabilityIds.has(cid))) return false;
|
|
|
|
|
+ if (selectedItemsetReqIds && !selectedItemsetReqIds.has(req.id)) return false;
|
|
|
|
|
|
|
|
return true;
|
|
return true;
|
|
|
};
|
|
};
|
|
@@ -1924,11 +2273,7 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
? allCaps.filter((cap: any) => (cap.requirement_ids || []).some((rid: string) => reqIds.has(rid)))
|
|
? allCaps.filter((cap: any) => (cap.requirement_ids || []).some((rid: string) => reqIds.has(rid)))
|
|
|
: allCaps;
|
|
: allCaps;
|
|
|
const capIds = new Set(caps.map((cap: any) => cap.id));
|
|
const capIds = new Set(caps.map((cap: any) => cap.id));
|
|
|
- const tools = dbData.tools.filter((tool: any) => {
|
|
|
|
|
- const hasVisibleCap = (tool.capability_ids || []).some((cid: string) => capIds.has(cid));
|
|
|
|
|
- if (!hasVisibleCap) return false;
|
|
|
|
|
- return true;
|
|
|
|
|
- });
|
|
|
|
|
|
|
+ const tools = dbData.tools;
|
|
|
const toolIds = new Set(tools.map((tool: any) => tool.id));
|
|
const toolIds = new Set(tools.map((tool: any) => tool.id));
|
|
|
const know = dbData.know.filter((k: any) =>
|
|
const know = dbData.know.filter((k: any) =>
|
|
|
(k.capability_ids || []).some((cid: string) => capIds.has(cid)) ||
|
|
(k.capability_ids || []).some((cid: string) => capIds.has(cid)) ||
|
|
@@ -2034,24 +2379,31 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
? new Set<string>(selectedCapRequirementIds)
|
|
? new Set<string>(selectedCapRequirementIds)
|
|
|
: null;
|
|
: null;
|
|
|
const fromTool = selectedToolId ? selectedToolRequirementIds : null;
|
|
const fromTool = selectedToolId ? selectedToolRequirementIds : null;
|
|
|
- return intersectWithAllowed(intersectActiveSets(fromNode, fromProc, fromCap, fromTool), visibleReqIds);
|
|
|
|
|
- }, [directNodeReqIds, visibleReqIds, selectedNode, selectedProcId, selectedProcRequirementIds, selectedCapId, selectedCapRequirementIds, selectedToolId, selectedToolRequirementIds]);
|
|
|
|
|
|
|
+ const fromItemset = selectedItemsetId && selectedItemsetReqIds ? selectedItemsetReqIds : null;
|
|
|
|
|
+ return intersectWithAllowed(intersectActiveSets(fromNode, fromProc, fromCap, fromTool, fromItemset), visibleReqIds);
|
|
|
|
|
+ }, [directNodeReqIds, visibleReqIds, selectedNode, selectedProcId, selectedProcRequirementIds, selectedCapId, selectedCapRequirementIds, selectedToolId, selectedToolRequirementIds, selectedItemsetId, selectedItemsetReqIds]);
|
|
|
|
|
|
|
|
const relatedProcIds = useMemo((): Set<string> => {
|
|
const relatedProcIds = useMemo((): Set<string> => {
|
|
|
const fromNode = selectedNode ? directNodeProcIds : null;
|
|
const fromNode = selectedNode ? directNodeProcIds : null;
|
|
|
const fromReq = selectedReqId ? selectedReqProcIds : null;
|
|
const fromReq = selectedReqId ? selectedReqProcIds : null;
|
|
|
const fromCap = selectedCapId ? selectedCapProcIds : null;
|
|
const fromCap = selectedCapId ? selectedCapProcIds : null;
|
|
|
const fromTool = selectedToolId ? selectedToolProcIds : null;
|
|
const fromTool = selectedToolId ? selectedToolProcIds : null;
|
|
|
- return intersectWithAllowed(intersectActiveSets(fromNode, fromReq, fromCap, fromTool), visibleProcIds);
|
|
|
|
|
- }, [directNodeProcIds, visibleProcIds, selectedNode, selectedReqId, selectedReqProcIds, selectedCapId, selectedCapProcIds, selectedToolId, selectedToolProcIds]);
|
|
|
|
|
|
|
+ const fromItemset = selectedItemsetId && selectedItemsetReqIds
|
|
|
|
|
+ ? new Set<string>(dbData.procs.filter((workflow) => (workflow.requirement_ids || []).some((reqId: string) => selectedItemsetReqIds.has(reqId))).map((workflow) => workflow.id))
|
|
|
|
|
+ : null;
|
|
|
|
|
+ return intersectWithAllowed(intersectActiveSets(fromNode, fromReq, fromCap, fromTool, fromItemset), visibleProcIds);
|
|
|
|
|
+ }, [directNodeProcIds, visibleProcIds, selectedNode, selectedReqId, selectedReqProcIds, selectedCapId, selectedCapProcIds, selectedToolId, selectedToolProcIds, selectedItemsetId, selectedItemsetReqIds, dbData.procs]);
|
|
|
|
|
|
|
|
const relatedCapIds = useMemo((): Set<string> => {
|
|
const relatedCapIds = useMemo((): Set<string> => {
|
|
|
const fromNode = selectedNode ? directNodeCapIds : null;
|
|
const fromNode = selectedNode ? directNodeCapIds : null;
|
|
|
const fromProc = selectedProcId ? selectedProcCapabilityIds : null;
|
|
const fromProc = selectedProcId ? selectedProcCapabilityIds : null;
|
|
|
const fromReq = selectedReqId ? new Set<string>(selectedReqCapabilityIds) : null;
|
|
const fromReq = selectedReqId ? new Set<string>(selectedReqCapabilityIds) : null;
|
|
|
const fromTool = selectedToolId ? new Set<string>(selectedToolCapabilityIds) : null;
|
|
const fromTool = selectedToolId ? new Set<string>(selectedToolCapabilityIds) : null;
|
|
|
- return intersectWithAllowed(intersectActiveSets(fromNode, fromProc, fromReq, fromTool), visibleCapIds);
|
|
|
|
|
- }, [directNodeCapIds, visibleCapIds, selectedNode, selectedProcId, selectedProcCapabilityIds, selectedReqId, selectedReqCapabilityIds, selectedToolId, selectedToolCapabilityIds]);
|
|
|
|
|
|
|
+ const fromItemset = selectedItemsetId && selectedItemsetReqIds
|
|
|
|
|
+ ? new Set<string>(allCaps.filter((cap: any) => (cap.requirement_ids || []).some((reqId: string) => selectedItemsetReqIds.has(reqId))).map((cap: any) => cap.id))
|
|
|
|
|
+ : null;
|
|
|
|
|
+ return intersectWithAllowed(intersectActiveSets(fromNode, fromProc, fromReq, fromTool, fromItemset), visibleCapIds);
|
|
|
|
|
+ }, [directNodeCapIds, visibleCapIds, selectedNode, selectedProcId, selectedProcCapabilityIds, selectedReqId, selectedReqCapabilityIds, selectedToolId, selectedToolCapabilityIds, selectedItemsetId, selectedItemsetReqIds, allCaps]);
|
|
|
|
|
|
|
|
const relatedToolIds = useMemo((): Set<string> => {
|
|
const relatedToolIds = useMemo((): Set<string> => {
|
|
|
const fromNode = selectedNode ? directNodeToolIds : null;
|
|
const fromNode = selectedNode ? directNodeToolIds : null;
|
|
@@ -2060,8 +2412,23 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
: null;
|
|
: null;
|
|
|
const fromReq = selectedReqId ? selectedReqToolIds : null;
|
|
const fromReq = selectedReqId ? selectedReqToolIds : null;
|
|
|
const fromCap = selectedCapId ? selectedCapToolIds : null;
|
|
const fromCap = selectedCapId ? selectedCapToolIds : null;
|
|
|
- return intersectWithAllowed(intersectActiveSets(fromNode, fromProc, fromReq, fromCap), visibleToolIds);
|
|
|
|
|
- }, [directNodeToolIds, visibleData.tools, visibleToolIds, selectedReqId, selectedReqToolIds, selectedCapId, selectedCapToolIds, selectedProcId, selectedProcCapabilityIds, selectedNode]);
|
|
|
|
|
|
|
+ const fromItemset = selectedItemsetId && selectedItemsetReqIds
|
|
|
|
|
+ ? new Set<string>(
|
|
|
|
|
+ visibleData.tools
|
|
|
|
|
+ .filter((tool: any) => {
|
|
|
|
|
+ const toolReqIds = new Set<string>();
|
|
|
|
|
+ (tool.capability_ids || []).forEach((capId: string) => {
|
|
|
|
|
+ allCaps.forEach((cap: any) => {
|
|
|
|
|
+ if (cap.id === capId) (cap.requirement_ids || []).forEach((reqId: string) => toolReqIds.add(reqId));
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+ return Array.from(toolReqIds).some((reqId) => selectedItemsetReqIds.has(reqId));
|
|
|
|
|
+ })
|
|
|
|
|
+ .map((tool: any) => tool.id)
|
|
|
|
|
+ )
|
|
|
|
|
+ : null;
|
|
|
|
|
+ return intersectWithAllowed(intersectActiveSets(fromNode, fromProc, fromReq, fromCap, fromItemset), visibleToolIds);
|
|
|
|
|
+ }, [directNodeToolIds, visibleData.tools, visibleToolIds, selectedReqId, selectedReqToolIds, selectedCapId, selectedCapToolIds, selectedProcId, selectedProcCapabilityIds, selectedNode, selectedItemsetId, selectedItemsetReqIds, allCaps]);
|
|
|
|
|
|
|
|
const filteredData = useMemo(() => {
|
|
const filteredData = useMemo(() => {
|
|
|
const reqs = visibleData.reqs.filter((req: any) => {
|
|
const reqs = visibleData.reqs.filter((req: any) => {
|
|
@@ -2118,6 +2485,58 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
relatedToolIds,
|
|
relatedToolIds,
|
|
|
]);
|
|
]);
|
|
|
|
|
|
|
|
|
|
+ const patternRoleByName = useMemo((): Record<string, 'substance' | 'form' | 'both'> => {
|
|
|
|
|
+ const relatedReqs =
|
|
|
|
|
+ selectedProcId
|
|
|
|
|
+ ? filteredData.reqs.filter((req: any) => selectedProcRequirementIds.has(req.id))
|
|
|
|
|
+ : selectedReqId
|
|
|
|
|
+ ? filteredData.reqs.filter((req: any) => req.id === selectedReqId)
|
|
|
|
|
+ : selectedCapId
|
|
|
|
|
+ ? filteredData.reqs.filter((req: any) => selectedCapRequirementIds.has(req.id))
|
|
|
|
|
+ : selectedToolId
|
|
|
|
|
+ ? filteredData.reqs.filter((req: any) => selectedToolRequirementIds.has(req.id))
|
|
|
|
|
+ : selectedNode
|
|
|
|
|
+ ? filteredData.reqs.filter((req: any) => directNodeReqIds.has(req.id))
|
|
|
|
|
+ : [];
|
|
|
|
|
+
|
|
|
|
|
+ const substance = new Set<string>();
|
|
|
|
|
+ const form = new Set<string>();
|
|
|
|
|
+
|
|
|
|
|
+ relatedReqs.forEach((req: any) => {
|
|
|
|
|
+ const meta = (req.source_nodes || []).find((sn: any) => {
|
|
|
|
|
+ const nodeName = typeof sn === 'object' ? (sn.node_name || sn.name) : sn;
|
|
|
|
|
+ return nodeName === '__meta__';
|
|
|
|
|
+ });
|
|
|
|
|
+ const ctx = meta?.extraction_context || {};
|
|
|
|
|
+ (ctx.substance?.nodes || []).forEach((name: string) => name && substance.add(name));
|
|
|
|
|
+ (ctx.form?.nodes || []).forEach((name: string) => name && form.add(name));
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ const result: Record<string, 'substance' | 'form' | 'both'> = {};
|
|
|
|
|
+ substance.forEach((name) => {
|
|
|
|
|
+ result[name] = form.has(name) ? 'both' : 'substance';
|
|
|
|
|
+ });
|
|
|
|
|
+ form.forEach((name) => {
|
|
|
|
|
+ if (!result[name]) result[name] = 'form';
|
|
|
|
|
+ });
|
|
|
|
|
+ return result;
|
|
|
|
|
+ }, [filteredData.reqs, selectedProcId, selectedProcRequirementIds, selectedReqId, selectedCapId, selectedCapRequirementIds, selectedToolId, selectedToolRequirementIds, selectedNode, directNodeReqIds]);
|
|
|
|
|
+
|
|
|
|
|
+ const selectedReqPatternNodeIds = useMemo((): Set<string> | null => {
|
|
|
|
|
+ if (!selectedReqId) return null;
|
|
|
|
|
+ const relatedLeafNames = new Set<string>();
|
|
|
|
|
+ allItemsets.forEach((itemset: any) => {
|
|
|
|
|
+ const reqIds = itemsetReqIdsMap.get(String(itemset.id)) || new Set<string>();
|
|
|
|
|
+ if (!reqIds.has(selectedReqId)) return;
|
|
|
|
|
+ (itemset.leaf_names || []).forEach((name: string) => {
|
|
|
|
|
+ if (name) relatedLeafNames.add(name);
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+ if (relatedLeafNames.size === 0) return null;
|
|
|
|
|
+ const ids = collectDirectNodeIdsFromNames(relatedLeafNames);
|
|
|
|
|
+ return ids.size > 0 ? ids : null;
|
|
|
|
|
+ }, [allItemsets, itemsetReqIdsMap, selectedReqId, collectDirectNodeIdsFromNames]);
|
|
|
|
|
+
|
|
|
const filteredProcItems = useMemo(() => {
|
|
const filteredProcItems = useMemo(() => {
|
|
|
return visibleProcItems.filter((workflow) => {
|
|
return visibleProcItems.filter((workflow) => {
|
|
|
if (selectedReqId && !(workflow.requirement_ids || []).includes(selectedReqId)) return false;
|
|
if (selectedReqId && !(workflow.requirement_ids || []).includes(selectedReqId)) return false;
|
|
@@ -2137,12 +2556,10 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
const filteredToolIds = useMemo(() => new Set(filteredData.tools.map((tool: any) => tool.id)), [filteredData.tools]);
|
|
const filteredToolIds = useMemo(() => new Set(filteredData.tools.map((tool: any) => tool.id)), [filteredData.tools]);
|
|
|
const filteredProcIds = useMemo(() => new Set(filteredProcItems.map((workflow) => workflow.id)), [filteredProcItems]);
|
|
const filteredProcIds = useMemo(() => new Set(filteredProcItems.map((workflow) => workflow.id)), [filteredProcItems]);
|
|
|
|
|
|
|
|
- const selectedProc = dbData.procs.find((p) => p.id === selectedProcId) || null;
|
|
|
|
|
- const selectedReq = dbData.reqs.find((r: any) => r.id === selectedReqId) || null;
|
|
|
|
|
- const selectedCap = allCaps.find((c: any) => c.id === selectedCapId) || null;
|
|
|
|
|
- const selectedTool = dbData.tools.find((t: any) => t.id === selectedToolId) || null;
|
|
|
|
|
- const hasExplicitColumnFilter = !!selectedProcId || !!selectedReqId || !!selectedCapId || !!selectedToolId;
|
|
|
|
|
- const hasAnyFilter = !!selectedNode || !!selectedProcId || !!selectedReqId || !!selectedCapId || !!selectedToolId;
|
|
|
|
|
|
|
+ const selectedReq = dbData.reqs.find((r: any) => r.id === selectedReqId) ?? null;
|
|
|
|
|
+ const selectedProc = dbData.procs.find((p) => p.id === selectedProcId) ?? null;
|
|
|
|
|
+ const selectedCap = allCaps.find((c: any) => c.id === selectedCapId) ?? null;
|
|
|
|
|
+ const selectedTool = dbData.tools.find((t: any) => t.id === selectedToolId) ?? null;
|
|
|
|
|
|
|
|
const selectedReqSourceNodeIds = useMemo((): Set<string> | null => {
|
|
const selectedReqSourceNodeIds = useMemo((): Set<string> | null => {
|
|
|
const relatedReqs =
|
|
const relatedReqs =
|
|
@@ -2171,6 +2588,12 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
collectDirectNodeIdsFromReqs,
|
|
collectDirectNodeIdsFromReqs,
|
|
|
]);
|
|
]);
|
|
|
|
|
|
|
|
|
|
+ const selectedPatternNodeIds = useMemo((): Set<string> | null => {
|
|
|
|
|
+ if (selectedItemsetDirectNodeIds) return selectedItemsetDirectNodeIds;
|
|
|
|
|
+ if (selectedReqPatternNodeIds) return selectedReqPatternNodeIds;
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }, [selectedItemsetDirectNodeIds, selectedReqPatternNodeIds]);
|
|
|
|
|
+
|
|
|
const selectedSubtreeLeafNamesKey = useMemo(
|
|
const selectedSubtreeLeafNamesKey = useMemo(
|
|
|
() => (selectedSubtreeLeafNames ? Array.from(selectedSubtreeLeafNames).sort().join('|') : ''),
|
|
() => (selectedSubtreeLeafNames ? Array.from(selectedSubtreeLeafNames).sort().join('|') : ''),
|
|
|
[selectedSubtreeLeafNames]
|
|
[selectedSubtreeLeafNames]
|
|
@@ -2183,17 +2606,21 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
() => (selectedReqSourceNodeIds ? Array.from(selectedReqSourceNodeIds).sort().join('|') : ''),
|
|
() => (selectedReqSourceNodeIds ? Array.from(selectedReqSourceNodeIds).sort().join('|') : ''),
|
|
|
[selectedReqSourceNodeIds]
|
|
[selectedReqSourceNodeIds]
|
|
|
);
|
|
);
|
|
|
|
|
+ const selectedPatternNodeIdsKey = useMemo(
|
|
|
|
|
+ () => (selectedPatternNodeIds ? Array.from(selectedPatternNodeIds).sort().join('|') : ''),
|
|
|
|
|
+ [selectedPatternNodeIds]
|
|
|
|
|
+ );
|
|
|
|
|
|
|
|
// 节点变化时重置所有列的定位索引
|
|
// 节点变化时重置所有列的定位索引
|
|
|
useEffect(() => {
|
|
useEffect(() => {
|
|
|
- setColumnFocusIndex({ req: 0, proc: 0, cap: 0, tool: 0 });
|
|
|
|
|
|
|
+ setColumnFocusIndex({ req: 0, proc: 0, cap: 0, tool: 0, pattern: 0 });
|
|
|
setTreeFocusIndex(0);
|
|
setTreeFocusIndex(0);
|
|
|
}, [selectedNode]);
|
|
}, [selectedNode]);
|
|
|
|
|
|
|
|
// 筛选条件变化时重置树导航索引
|
|
// 筛选条件变化时重置树导航索引
|
|
|
useEffect(() => {
|
|
useEffect(() => {
|
|
|
setTreeFocusIndex(0);
|
|
setTreeFocusIndex(0);
|
|
|
- }, [selectedSubtreeLeafNamesKey, relationFilterHighlightLeafNamesKey, onlyCoveredFilter, selectedReqSourceNodeIdsKey]);
|
|
|
|
|
|
|
+ }, [selectedSubtreeLeafNamesKey, relationFilterHighlightLeafNamesKey, onlyCoveredFilter, selectedReqSourceNodeIdsKey, selectedPatternNodeIdsKey]);
|
|
|
|
|
|
|
|
const treeMatchedNodes = useMemo((): any[] => {
|
|
const treeMatchedNodes = useMemo((): any[] => {
|
|
|
if (!treeData) return [];
|
|
if (!treeData) return [];
|
|
@@ -2201,9 +2628,10 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
const collectMatchedNodes = (node: any): any[] => {
|
|
const collectMatchedNodes = (node: any): any[] => {
|
|
|
const hasChildren = node.children && node.children.length > 0;
|
|
const hasChildren = node.children && node.children.length > 0;
|
|
|
const isSourceNode = selectedReqSourceNodeIds?.has(String(node.id)) ?? false;
|
|
const isSourceNode = selectedReqSourceNodeIds?.has(String(node.id)) ?? false;
|
|
|
|
|
+ const isPatternNode = selectedPatternNodeIds?.has(String(node.id)) ?? false;
|
|
|
const matched: any[] = [];
|
|
const matched: any[] = [];
|
|
|
|
|
|
|
|
- if (isSourceNode) {
|
|
|
|
|
|
|
+ if (isSourceNode || isPatternNode) {
|
|
|
matched.push(node);
|
|
matched.push(node);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -2233,7 +2661,7 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
return allMatched;
|
|
return allMatched;
|
|
|
- }, [treeData, selectedSubtreeLeafNames, selectedSubtreeNodeIds, selectedReqSourceNodeIds]);
|
|
|
|
|
|
|
+ }, [treeData, selectedSubtreeLeafNames, selectedSubtreeNodeIds, selectedReqSourceNodeIds, selectedPatternNodeIds]);
|
|
|
|
|
|
|
|
const treeMatchedCount = treeMatchedNodes.length;
|
|
const treeMatchedCount = treeMatchedNodes.length;
|
|
|
const clampedTreeFocusIdx = treeMatchedCount > 0 ? Math.min(treeFocusIndex, treeMatchedCount - 1) : 0;
|
|
const clampedTreeFocusIdx = treeMatchedCount > 0 ? Math.min(treeFocusIndex, treeMatchedCount - 1) : 0;
|
|
@@ -2502,8 +2930,13 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
false
|
|
false
|
|
|
);
|
|
);
|
|
|
|
|
|
|
|
- const sortedItems = (items: any[]) => {
|
|
|
|
|
- return [...items];
|
|
|
|
|
|
|
+ const sortedItems = (items: any[], eligibleIds?: Set<string>) => {
|
|
|
|
|
+ if (!eligibleIds || eligibleIds.size === 0) return [...items];
|
|
|
|
|
+ return [...items].sort((a, b) => {
|
|
|
|
|
+ const aEligible = eligibleIds.has(a.id) ? 0 : 1;
|
|
|
|
|
+ const bEligible = eligibleIds.has(b.id) ? 0 : 1;
|
|
|
|
|
+ return aEligible - bEligible;
|
|
|
|
|
+ });
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
const handleSingleClick = (nodeId: string, item: any) => {
|
|
const handleSingleClick = (nodeId: string, item: any) => {
|
|
@@ -2591,6 +3024,25 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
const eligibleCapIds = useMemo(() => new Set(filteredData.caps.map((c: any) => c.id)), [filteredData.caps]);
|
|
const eligibleCapIds = useMemo(() => new Set(filteredData.caps.map((c: any) => c.id)), [filteredData.caps]);
|
|
|
const eligibleToolIds = useMemo(() => new Set(filteredData.tools.map((t: any) => t.id)), [filteredData.tools]);
|
|
const eligibleToolIds = useMemo(() => new Set(filteredData.tools.map((t: any) => t.id)), [filteredData.tools]);
|
|
|
|
|
|
|
|
|
|
+ // 同步 columnFocusIndex.req 到 selectedReqId 的实际位置
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ if (!selectedReqId) return;
|
|
|
|
|
+ const displayedReqs = visibleData.reqs;
|
|
|
|
|
+ const matchedIndices: number[] = [];
|
|
|
|
|
+ displayedReqs.forEach((req: any, idx: number) => {
|
|
|
|
|
+ const isSelected = req.id === selectedReqId;
|
|
|
|
|
+ const isEligible = eligibleReqIds.has(req.id);
|
|
|
|
|
+ if (isSelected || isEligible) {
|
|
|
|
|
+ matchedIndices.push(idx);
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+ const selectedReqIdx = displayedReqs.findIndex((r: any) => r.id === selectedReqId);
|
|
|
|
|
+ const positionInMatched = matchedIndices.indexOf(selectedReqIdx);
|
|
|
|
|
+ if (positionInMatched >= 0) {
|
|
|
|
|
+ setColumnFocusIndex(prev => ({ ...prev, req: positionInMatched }));
|
|
|
|
|
+ }
|
|
|
|
|
+ }, [selectedReqId, visibleData.reqs, eligibleReqIds]);
|
|
|
|
|
+
|
|
|
const displayedReqs = visibleData.reqs;
|
|
const displayedReqs = visibleData.reqs;
|
|
|
const displayedProcs = visibleProcItems;
|
|
const displayedProcs = visibleProcItems;
|
|
|
const displayedCaps = visibleData.caps;
|
|
const displayedCaps = visibleData.caps;
|
|
@@ -2708,6 +3160,8 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
return map;
|
|
return map;
|
|
|
}, [dbData.tools, allCaps, collectDirectNodeIdsFromReqIds]);
|
|
}, [dbData.tools, allCaps, collectDirectNodeIdsFromReqIds]);
|
|
|
|
|
|
|
|
|
|
+ const hasAnyFilter = !!(selectedNode || selectedProcId || selectedReqId || selectedCapId || selectedToolId || selectedItemsetId);
|
|
|
|
|
+
|
|
|
if (dashboardLoadingText) {
|
|
if (dashboardLoadingText) {
|
|
|
return (
|
|
return (
|
|
|
<div className="flex flex-col h-[calc(100vh-64px)] w-full items-center justify-center text-slate-400">
|
|
<div className="flex flex-col h-[calc(100vh-64px)] w-full items-center justify-center text-slate-400">
|
|
@@ -2765,6 +3219,7 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
setSelectedReqId(null);
|
|
setSelectedReqId(null);
|
|
|
setSelectedCapId(null);
|
|
setSelectedCapId(null);
|
|
|
setSelectedToolId(null);
|
|
setSelectedToolId(null);
|
|
|
|
|
+ setSelectedItemsetId(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"
|
|
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"
|
|
|
>
|
|
>
|
|
@@ -2815,6 +3270,7 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
if (!selectedNode && selectedReqId) setSelectedReqId(null);
|
|
if (!selectedNode && selectedReqId) setSelectedReqId(null);
|
|
|
} : undefined}
|
|
} : undefined}
|
|
|
sourceNodeIds={selectedReqSourceNodeIds}
|
|
sourceNodeIds={selectedReqSourceNodeIds}
|
|
|
|
|
+ patternNodeIds={selectedPatternNodeIds}
|
|
|
nodeMetricsMap={nodeMetricsMap}
|
|
nodeMetricsMap={nodeMetricsMap}
|
|
|
totalNodeCount={totalNodeCount}
|
|
totalNodeCount={totalNodeCount}
|
|
|
wideMode={treeWideMode}
|
|
wideMode={treeWideMode}
|
|
@@ -2827,10 +3283,32 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
/>
|
|
/>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
|
|
+ {/* 频繁模式列 */}
|
|
|
|
|
+ <PatternColumn
|
|
|
|
|
+ selectedItemsetId={selectedItemsetId}
|
|
|
|
|
+ itemsets={allItemsets}
|
|
|
|
|
+ eligibleItemsetIds={relatedItemsetIds}
|
|
|
|
|
+ contextNodeNames={patternContextNodeNames}
|
|
|
|
|
+ nodeRoleByName={patternRoleByName}
|
|
|
|
|
+ hasAnyFilter={hasAnyFilter}
|
|
|
|
|
+ focusIndex={columnFocusIndex.pattern ?? 0}
|
|
|
|
|
+ onSelectItemset={(itemsetId) => {
|
|
|
|
|
+ setSelectedItemsetId(itemsetId);
|
|
|
|
|
+ setColumnFocusIndex(prev => ({ ...prev, pattern: 0 }));
|
|
|
|
|
+ if (itemsetId === null) setDrawerItem(null);
|
|
|
|
|
+ }}
|
|
|
|
|
+ onOpenDrawer={(itemset) => setDrawerItem({ type: 'itemset', data: itemset })}
|
|
|
|
|
+ onFocusPrev={() => setColumnFocusIndex(prev => ({ ...prev, pattern: Math.max(0, (prev.pattern ?? 0) - 1) }))}
|
|
|
|
|
+ onFocusNext={() => {
|
|
|
|
|
+ const matchedCount = Array.from(relatedItemsetIds).length + (selectedItemsetId ? (relatedItemsetIds.has(String(selectedItemsetId)) ? 0 : 1) : 0);
|
|
|
|
|
+ setColumnFocusIndex(prev => ({ ...prev, pattern: Math.min(Math.max(0, matchedCount - 1), (prev.pattern ?? 0) + 1) }));
|
|
|
|
|
+ }}
|
|
|
|
|
+ />
|
|
|
|
|
+
|
|
|
{/* 关系列:每列固定宽度 */}
|
|
{/* 关系列:每列固定宽度 */}
|
|
|
{columns.map(col => {
|
|
{columns.map(col => {
|
|
|
const activeId = getColumnActiveId(col.t);
|
|
const activeId = getColumnActiveId(col.t);
|
|
|
- const orderedItems = sortedItems(col.d);
|
|
|
|
|
|
|
+ const orderedItems = sortedItems(col.d, col.eligibleIds);
|
|
|
|
|
|
|
|
// 计算所有可用匹配项的索引(按原始顺序)
|
|
// 计算所有可用匹配项的索引(按原始顺序)
|
|
|
const matchedIndices: number[] = [];
|
|
const matchedIndices: number[] = [];
|
|
@@ -2961,7 +3439,11 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
<SideDrawer
|
|
<SideDrawer
|
|
|
isOpen={!!drawerItem}
|
|
isOpen={!!drawerItem}
|
|
|
onClose={() => setDrawerItem(null)}
|
|
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)) : ''}
|
|
|
|
|
|
|
+ title={drawerItem ? (
|
|
|
|
|
+ drawerItem.type === 'itemset'
|
|
|
|
|
+ ? `Pattern #${drawerItem.data.id}`
|
|
|
|
|
+ : (drawerItem.data.name || drawerItem.data.description?.slice(0, 20) || drawerItem.data.task?.slice(0, 20) || drawerItem.data.id?.slice(0, 12))
|
|
|
|
|
+ ) : ''}
|
|
|
width="w-[360px]"
|
|
width="w-[360px]"
|
|
|
>
|
|
>
|
|
|
{drawerItem && (
|
|
{drawerItem && (
|